DeviceStorageMonitorService 流程分析
引言
DeviceStorageMonitorService 是 Android 系统服务,负责监控各存储卷的可用空间,并在逼近阈值时回收缓存、发送广播、推送通知,以避免磁盘进入低态。其类注释原文:
Service that monitors and maintains free space on storage volumes. As the free space on a volume nears the threshold defined by
StorageManager#getStorageLowBytes(File), this service will clear out cached data to keep the disk from entering this low state.
它继承 SystemService,在 SystemServer 启动阶段被实例化并发布,服务名 devicestoragemonitor(line 155),可通过 dumpsys devicestoragemonitor 或 cmd shell 进行调试。
本文将从类结构、关键阈值、状态机、checkLow/checkHigh 两条主流程、通知与广播分发、Shell 调试接口等方面,逐段对照源码解析。
一、服务定位与类结构
DeviceStorageMonitorService 继承自 SystemService(line 71):
scala
public class DeviceStorageMonitorService extends SystemService {
private static final String TAG = "DeviceStorageMonitorService";
static final String SERVICE = "devicestoragemonitor";
...
}
1.1 核心成员
| 成员 | 类型 | 行号 | 作用 |
|---|---|---|---|
mHandlerThread / mHandler |
HandlerThread / Handler |
159-160 | 后台线程执行检查任务 |
mStates |
ArrayMap<UUID, State> |
100 | 按 volume UUID 缓存每个卷的内部状态 |
mSeq |
AtomicInteger |
95 | 广播序号,测试与去重用 |
mForceLevel |
volatile int |
97 | 测试用强制级别 |
mNotifManager |
NotificationManager |
92 | 推送低存储通知 |
mCacheFileDeletedObserver |
CacheFileDeletedObserver |
150 | 监听缓存目录删除事件 |
1.2 发布服务
onStart()(line 308-333)中:
scss
publishBinderService(SERVICE, mRemoteService); // 对外 Binder
publishLocalService(DeviceStorageMonitorInternal.class, mLocalService); // 进程内 LocalService
mHandler.obtainMessage(MSG_CHECK_LOW).sendToTarget(); // 触发首次检查
同时初始化 TV 通知通道(若设备为 Leanback/TV,line 319-325),并启动 CacheFileDeletedObserver。
1.3 类结构图
scss
SystemServiceDeviceStorageMonitorService-ArrayMap mStates-Handler mHandler-AtomicInteger mSeq-int mForceLevel+onStart()-checkLow()-checkHigh()-updateNotifications()-updateBroadcasts()State+int level+long lastUsableBytes+isEntering()+isLeaving()+levelToString()CacheFileDeletedObserverDeviceStorageMonitorInternalmStates 持有publishLocalService

二、关键常量与阈值(本文件硬编码)
DeviceStorageMonitorService 自身只硬编码了几个与检查频率、日志、预留相关的常量,真正的高低水位阈值并不在本文件定义,而是在运行时通过 StorageManager 动态获取。
| 常量 | 值 | 行号 | 作用 |
|---|---|---|---|
DEFAULT_LOG_DELTA_BYTES |
64 MiB(MEBIBYTES.toBytes(64)) |
83 | 可用空间变化超过此值时写 EventLog |
LOW_CHECK_INTERVAL |
1 分钟(MINUTE_IN_MILLIS) |
84 | 低水位检查轮询间隔 |
HIGH_CHECK_INTERVAL |
10 小时(10 * HOUR_IN_MILLIS) |
85 | 高水位检查轮询间隔 |
BOOT_IMAGE_STORAGE_REQUIREMENT |
250 MiB | 90 | 默认卷上为 boot image 预留的空间;低于此值也判 LEVEL_LOW |
运行时阈值(来自 StorageManager)
checkLow() 中(line 184-185):
ini
final long fullBytes = storage.getStorageFullBytes(file);
final long lowBytes = storage.getStorageLowBytes(file);
这两个值由 StorageManager 基于 storage_threshold_percentage / storage_threshold_max_bytes 等系统资源与 DeviceConfig 配置计算得出,本类不直接定义具体数值。
三、实际设备阈值(历史会话 dumpsys 实测)
在 2026-07-20 的历史分析会话中,用户从设备抓取了 dumpsys devicestoragemonitor 输出:
ini
console:/ # dumpsys devicestoragemonitor
Known volumes:
Default:
level=NORMAL lastUsableBytes=7817580544
lowBytes=524288000 fullBytes=50000000
path=/data
mSeq=1 mForceState=UNKNOWN
换算可得:
- lowBytes = 524288000 B = 500 MiB
- fullBytes = 50000000 B ≈ 47.68 MiB
lastUsableBytes = 7817580544 B ≈ 7.28 GiB(当前可用,远大于阈值,故level=NORMAL)
结合源码逻辑,本设备的实际触发点为:
| 项 | 计算式 | 实测值 | 触发行为 |
|---|---|---|---|
| 缓存回收触发点 | lowBytes * 3 / 2(line 190) |
750 MiB | 当 usableSpace < 750 MiB,调用 freeStorage(uuid, lowBytes*2, 0) |
| 缓存回收目标 | lowBytes * 2(line 194) |
1000 MiB | 将空间清回到 lowBytes 的 200% 水平 |
LEVEL_LOW 触发 |
usableBytes <= lowBytes(line 216) |
≤ 500 MiB | 发送低存储广播/通知 |
LEVEL_FULL 触发 |
usableBytes <= fullBytes(line 214) |
≤ ~47.68 MiB | 发送满存储广播 |
| 附加 LOW 规则 | usableBytes < 250 MiB 且为默认卷(line 219) |
< 250 MiB | 即使未到 lowBytes 也判 LOW |
注:这 500 MiB 是该设备运行时由
StorageManager.getStorageLowBytes()计算的结果(通常来自系统资源storage_threshold_percentage_default与storage_threshold_max_bytes_default,约总空间 5% 与某绝对下限取最大),并非本文件硬编码的值。
四、状态机:四级 State
State(line 106-148)定义了四个级别:
arduino
private static final int LEVEL_UNKNOWN = -1;
private static final int LEVEL_NORMAL = 0;
private static final int LEVEL_LOW = 1;
private static final int LEVEL_FULL = 2;
4.1 进入/离开语义
isEntering / isLeaving(line 124-137)定义了"跨越"语义:
arduino
private static boolean isEntering(int level, int oldLevel, int newLevel) {
return newLevel >= level && (oldLevel < level || oldLevel == LEVEL_UNKNOWN);
}
private static boolean isLeaving(int level, int oldLevel, int newLevel) {
return newLevel < level && (oldLevel >= level || oldLevel == LEVEL_UNKNOWN);
}
关键理解:
LEVEL_NORMAL(0) → LEVEL_FULL(2),对LEVEL_LOW(1)而言同时 isEntering(LOW) 和 isEntering(FULL) ------一次跨越两级会触发两个级别的"进入"逻辑。LEVEL_FULL(2) → LEVEL_NORMAL(0),对 LOW 和 FULL 同时判定为isLeaving。oldLevel == LEVEL_UNKNOWN时无论新级别高低,只要newLevel >= level就视为 entering、newLevel < level就视为 leaving,用于测试强制流转。
4.2 状态机图
sql
usableBytes > lowBytes
usableBytes <= lowBytes
usableBytes <= fullBytes
usableBytes <= lowBytes
(或默认卷<250MiB)
usableBytes <= fullBytes
usableBytes > fullBytes
usableBytes > lowBytes
跨越式下跌
跨越式回升
UNKNOWN
NORMAL
LOW
FULL
mForceLevel 测试模式
oldLevel 固定取 UNKNOWN

4.3 级别字符串
levelToString(line 139-147)将级别转为可读字符串,供 dumpsys 输出使用。
五、checkLow 全流程(核心)
checkLow()(line 177-249)是本服务的核心逻辑,自启动起每 LOW_CHECK_INTERVAL(1 分钟)循环一次。
5.1 流程图
ini
是
否
是
否
是
否
是
否
是
否
是
否
onStart / checkMemory
发送 MSG_CHECK_LOW
Handler.checkLow
遍历 storage.getWritablePrivateVolumes
每个 vol
取 fullBytes/lowBytes
usableSpace < lowBytes*3/2?
pm.freeStorage uuid, lowBytes*2, 0
前置缓存回收到200%水位
跳过清理
重读 usableBytes/totalBytes
mForceLevel != UNKNOWN?
newLevel = mForceLevel
oldLevel = UNKNOWN
usableBytes <= fullBytes?
newLevel = FULL
usableBytes <= lowBytes?
newLevel = LOW
默认卷 且 <250MiB?
newLevel = NORMAL
记录 EventLog
若变化>64MiB 或级别变
updateNotifications
updateBroadcasts
state.level = newLevel
还有下一卷?
自调度
1分钟后 MSG_CHECK_LOW
10小时后 MSG_CHECK_HIGH



5.2 逐段源码对照
1) 触发入口(line 296-298, 331-340)
scss
// onStart()
mHandler.removeMessages(MSG_CHECK_LOW);
mHandler.obtainMessage(MSG_CHECK_LOW).sendToTarget();
// LocalService.checkMemory()
mHandler.removeMessages(MSG_CHECK_LOW);
mHandler.obtainMessage(MSG_CHECK_LOW).sendToTarget();
mHandler(line 292-304)在 handleMessage 中将 MSG_CHECK_LOW 路由到 checkLow()。
2) 遍历私有卷取阈值(line 178-185)
ini
final StorageManager storage = getContext().getSystemService(StorageManager.class);
final int seq = mSeq.get();
for (VolumeInfo vol : storage.getWritablePrivateVolumes()) {
final File file = vol.getPath();
final long fullBytes = storage.getStorageFullBytes(file);
final long lowBytes = storage.getStorageLowBytes(file);
...
}
只检查可写私有卷(getWritablePrivateVolumes),SD 卡等非私有卷不在本轮处理。
3) 自动清理缓存(line 190-198)
scss
// when it's within 150% of the threshold, we try trimming usage
// back to 200% of the threshold.
if (file.getUsableSpace() < (lowBytes * 3) / 2) {
final PackageManagerInternal pm =
LocalServices.getService(PackageManagerInternal.class);
try {
pm.freeStorage(vol.getFsUuid(), lowBytes * 2, 0);
} catch (IOException e) {
Slog.w(TAG, e);
}
}
- 触发点:
usableSpace < lowBytes * 3 / 2(150% ,即实测 750 MiB) - 清理目标:调用
freeStorage释放缓存,回收到lowBytes * 2(200% ,即实测 1000 MiB)水平 - 早于正式进入
LEVEL_LOW就开始回收,避免真正触底------这正是类注释"keep the disk from entering this low state"的实现。
4) 重新读取可用空间并定级(line 202-223)
ini
final long usableBytes = file.getUsableSpace();
int oldLevel = state.level;
int newLevel;
if (mForceLevel != State.LEVEL_UNKNOWN) {
oldLevel = State.LEVEL_UNKNOWN; // 强制每次都视为"进入/离开"
newLevel = mForceLevel;
} else if (usableBytes <= fullBytes) {
newLevel = State.LEVEL_FULL;
} else if (usableBytes <= lowBytes) {
newLevel = State.LEVEL_LOW;
} else if (StorageManager.UUID_DEFAULT.equals(uuid)
&& usableBytes < BOOT_IMAGE_STORAGE_REQUIREMENT) {
newLevel = State.LEVEL_LOW; // 默认卷为 boot image 预留 250MiB
} else {
newLevel = State.LEVEL_NORMAL;
}
注意:清理缓存后再读一次 usableBytes,定级依据是清理后的最新值;mForceLevel 用于 shell 测试,会将 oldLevel 强制设为 LEVEL_UNKNOWN,使任何目标级别都满足 isEntering,以触发广播。
5) EventLog 记录(line 226-231)
ini
if ((Math.abs(state.lastUsableBytes - usableBytes) > DEFAULT_LOG_DELTA_BYTES)
|| oldLevel != newLevel) {
EventLogTags.writeStorageState(uuid.toString(), oldLevel, newLevel,
usableBytes, totalBytes);
state.lastUsableBytes = usableBytes;
}
当可用空间变化超过 64 MiB 或级别发生迁移时写一条 StorageState EventLog,便于事后回溯。
6) 通知与广播分发(line 233-234)
ini
updateNotifications(vol, oldLevel, newLevel);
updateBroadcasts(vol, oldLevel, newLevel, seq);
state.level = newLevel;
详见后文第七节。
7) 自调度循环(line 241-248)
less
if (!mHandler.hasMessages(MSG_CHECK_LOW)) {
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CHECK_LOW),
LOW_CHECK_INTERVAL); // 1 分钟后再检查
}
if (!mHandler.hasMessages(MSG_CHECK_HIGH)) {
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CHECK_HIGH),
HIGH_CHECK_INTERVAL); // 10 小时后再检查
}
每轮结束后自续定时,不主动移除已有消息,保证立即挂起的 MSG_CHECK_* 不会被重复堆积。
六、checkHigh 流程
checkHigh()(line 257-284)处理"高水位"检查,频率为 HIGH_CHECK_INTERVAL(10 小时)。
6.1 阈值来源
ini
final int storageThresholdPercentHigh = DeviceConfig.getInt(
DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT,
StorageManager.STORAGE_THRESHOLD_PERCENT_HIGH_KEY,
StorageManager.DEFAULT_STORAGE_THRESHOLD_PERCENT_HIGH);
通过 DeviceConfig 从 NAMESPACE_STORAGE_NATIVE_BOOT 读取 STORAGE_THRESHOLD_PERCENT_HIGH_KEY,默认值取 StorageManager.DEFAULT_STORAGE_THRESHOLD_PERCENT_HIGH(通常为 20,即总空间的 20%)。
6.2 清理逻辑
ini
for (VolumeInfo vol : storage.getWritablePrivateVolumes()) {
final File file = vol.getPath();
if (file.getUsableSpace() < file.getTotalSpace() * storageThresholdPercentHigh / 100) {
final PackageManagerInternal pm =
LocalServices.getService(PackageManagerInternal.class);
try {
pm.freeAllAppCacheAboveQuota(vol.getFsUuid());
} catch (IOException e) {
Slog.w(TAG, e);
}
}
}
触发条件 usableSpace < totalSpace * 20% 时,调用 freeAllAppCacheAboveQuota() 清理超额的应用缓存。注意此处直接用 totalSpace * pct / 100 做整数百分比比较,与 checkLow 的 lowBytes/fullBytes 不同源。
6.3 流程图
是
否
MSG_CHECK_HIGH
读 storageThresholdPercentHigh
默认 20
遍历 getWritablePrivateVolumes
usableSpace < totalSpace*pct/100?
pm.freeAllAppCacheAboveQuota
跳过
10小时后 自调度

七、通知与广播分发
7.1 updateNotifications(line 506-552)
- 进入 LOW (
State.isEntering(LEVEL_LOW, ...)):构造ACTION_MANAGE_STORAGE的PendingIntent,使用SystemNotificationChannels.ALERTS通道弹出"低内部存储"通知,带FLAG_NO_CLEAR不可清除;TV 设备扩展TvExtender使用独立通道devicestoragemonitor.tv(line 537);同时写FrameworkStatsLog.LOW_STORAGE_STATE_CHANGED ... ON。 - 离开 LOW (
State.isLeaving(LEVEL_LOW, ...)):cancelAsUser取消通知,写...__STATE__OFF。 LEVEL_FULL不发额外通知,只在广播侧处理。
less
if (State.isEntering(State.LEVEL_LOW, oldLevel, newLevel)) {
// 构造通知并 notifyAsUser
} else if (State.isLeaving(State.LEVEL_LOW, oldLevel, newLevel)) {
mNotifManager.cancelAsUser(uuid.toString(), SystemMessage.NOTE_LOW_STORAGE, UserHandle.ALL);
}
7.2 updateBroadcasts(line 554-591)
仅对内置私有卷广播
csharp
if (!Objects.equals(StorageManager.UUID_PRIVATE_INTERNAL, vol.getFsUuid())) {
// We don't currently send broadcasts for secondary volumes
return;
}
外置卡等二级卷不发系统广播。
LOW / OK 广播
scss
final Intent lowIntent = new Intent(Intent.ACTION_DEVICE_STORAGE_LOW)...putExtra(EXTRA_SEQUENCE, seq);
final Intent notLowIntent = new Intent(Intent.ACTION_DEVICE_STORAGE_OK)...putExtra(EXTRA_SEQUENCE, seq);
if (State.isEntering(State.LEVEL_LOW, oldLevel, newLevel)) {
getContext().sendStickyBroadcastAsUser(lowIntent, UserHandle.ALL);
} else if (State.isLeaving(State.LEVEL_LOW, oldLevel, newLevel)) {
getContext().removeStickyBroadcastAsUser(lowIntent, UserHandle.ALL);
getContext().sendBroadcastAsUser(notLowIntent, UserHandle.ALL);
}
ACTION_DEVICE_STORAGE_LOW 使用 sticky 广播,后注册的接收者仍可收到;离开时移除 sticky 并发 ACTION_DEVICE_STORAGE_OK。
FULL / NOT_FULL 广播
scss
if (State.isEntering(State.LEVEL_FULL, oldLevel, newLevel)) {
getContext().sendStickyBroadcastAsUser(fullIntent, UserHandle.ALL);
} else if (State.isLeaving(State.LEVEL_FULL, oldLevel, newLevel)) {
getContext().removeStickyBroadcastAsUser(fullIntent, UserHandle.ALL);
getContext().sendBroadcastAsUser(notFullIntent, UserHandle.ALL);
}
广播均带 EXTRA_SEQUENCE(seq),供接收者判断新旧、去重。
7.3 广播与状态迁移对照表
| oldLevel → newLevel | isEntering(LOW) | isLeaving(LOW) | isEntering(FULL) | isLeaving(FULL) | 实际广播 |
|---|---|---|---|---|---|
| NORMAL → LOW | ✓ | STORAGE_LOW(sticky) |
|||
| LOW → FULL | ✓(对 FULL) | ✓ | STORAGE_FULL(sticky)。注:对 LOW 已 entering 不重发 |
||
| NORMAL → FULL | ✓(对 LOW 也 entering) | ✓ | 实际广播 LOW + FULL 双 sticky | ||
| FULL → LOW | ✓(对 FULL leaving) | ✓ | STORAGE_NOT_FULL;LOW 不进入(已经在 LOW) |
||
| LOW → NORMAL | ✓ | ✓(对 LOW 离开) | STORAGE_OK |
||
| FULL → NORMAL | ✓(对 LOW+FULL 都 leaving) | ✓ | STORAGE_OK + STORAGE_NOT_FULL |
说明:由于
isEntering/isLeaving的"跨越"语义,FULL↔NORMAL 的直接迁移会同时触发对中间级别 LOW 的进入/离开判定,从而一次迁移可能发出多组广播。
八、Shell 调试接口
DeviceStorageMonitorService 通过 Binder.mRemoteService 提供 shell 命令(line 355-368, 370-457)。dumpsys devicestoragemonitor force-low
8.1 支持的命令
| 命令 | 作用 | 行号 |
|---|---|---|
force-low [-f] |
强制 mForceLevel = LEVEL_LOW,冻结状态为低 |
402-413 |
force-not-low [-f] |
强制 mForceLevel = LEVEL_NORMAL,冻结为正常 |
414-425 |
reset [-f] |
mForceLevel = LEVEL_UNKNOWN,恢复真实值 |
426-437 |
-f |
附加选项:立即触发一次 MSG_CHECK_LOW 并打印新 seq |
383-394 |
ini
case "force-low": {
mForceLevel = State.LEVEL_LOW;
int seq = mSeq.incrementAndGet();
if ((opts & OPTION_FORCE_UPDATE) != 0) {
mHandler.removeMessages(MSG_CHECK_LOW);
mHandler.obtainMessage(MSG_CHECK_LOW).sendToTarget();
pw.println(seq);
}
} break;
均需 android.Manifest.permission.DEVICE_POWER 权限。
8.2 dumpsys 输出格式
dumpImpl(line 459-504)在无参数或 -a 时输出各卷状态:
ini
Known volumes:
Default:
level=NORMAL lastUsableBytes=7817580544
lowBytes=524288000 fullBytes=50000000
path=/data
mSeq=1 mForceState=UNKNOWN
对照历史会话实测数据,可见 levelToString 输出、各卷的阈值与路径、以及共享的 mSeq 与 mForceState。
九、CacheFileDeletedObserver
CacheFileDeletedObserver(line 593-602)是一个 FileObserver,监听下载缓存目录(Environment.getDownloadCacheDirectory())的 DELETE 事件:
scala
private static class CacheFileDeletedObserver extends FileObserver {
public CacheFileDeletedObserver() {
super(Environment.getDownloadCacheDirectory().getAbsolutePath(), FileObserver.DELETE);
}
@Override
public void onEvent(int event, String path) {
EventLogTags.writeCacheFileDeleted(path);
}
}
每当缓存文件被删除,写一条 CacheFileDeleted EventLog,与 checkLow 的缓存回收动作配合,可事后审计被清理的文件路径。它在 onStart() 中通过 startWatching() 启动(line 313)。
十、整体时序总结
scss
SystemServerDeviceStorageMonitorServiceHandler(后台线程)StorageManagerPackageManagerInternalNotificationManager应用/接收者构造 + onStart()obtainMessage(MSG_CHECK_LOW).sendToTarget()触发 checkLow()getWritablePrivateVolumesVolumeInfo[]getStorageLowBytes / getStorageFullByteslowBytes / fullBytesfile.getUsableSpace()freeStorage(uuid, lowBytes*2, 0)清理完成alt[usableSpace < lowBytes*3/2]重读 usableBytes 定级写 EventLog(变化>64MiB 或迁移)updateNotifications(进入/离开 LOW)updateBroadcasts(STORAGE_LOW/OK/FULL/NOT_FULL)loop[每个私有卷]sendMessageDelayed(MSG_CHECK_LOW, 1min)sendMessageDelayed(MSG_CHECK_HIGH, 10h)loop[每 1 分钟]SystemServerDeviceStorageMonitorServiceHandler(后台线程)StorageManagerPackageManagerInternalNotificationManager应用/接收者

附:源码行号速查表
| 主题 | 关键方法/常量 | 行号 |
|---|---|---|
| 类声明 | class DeviceStorageMonitorService |
71 |
| 服务名 | SERVICE = "devicestoragemonitor" |
155 |
| 检查消息 | MSG_CHECK_LOW / MSG_CHECK_HIGH |
80-81 |
| 日志阈值 | DEFAULT_LOG_DELTA_BYTES(64 MiB) |
83 |
| 检查间隔 | LOW_CHECK_INTERVAL / HIGH_CHECK_INTERVAL |
84-85 |
| Boot 预留 | BOOT_IMAGE_STORAGE_REQUIREMENT(250 MiB) |
90 |
| 状态类 | class State / 级别常量 |
106-110 |
| 进入/离开 | isEntering / isLeaving |
124-137 |
| 取得状态 | findOrCreateState |
162-169 |
| checkLow | 自动回收缓存 + 定级 + 通知广播 + 自调度 | 177-249 |
| 缓存触发 | usableSpace < lowBytes*3/2 |
190 |
| 缓存目标 | freeStorage(uuid, lowBytes*2, 0) |
194 |
| 定级链 | mForceLevel / full / low / 250MiB / normal | 209-223 |
| EventLog | writeStorageState |
228 |
| checkHigh | DeviceConfig 读 percentHigh(默认 20) | 257-284 |
| 构造 | HandlerThread + Handler |
286-305 |
| onStart | publishBinder/Local + 启动观察器 + 首次检查 | 308-333 |
| LocalService | checkMemory / isMemoryLow / getMemoryLowThreshold |
335-353 |
| Shell 命令 | force-low / force-not-low / reset |
402-441 |
| dump 帮助 | dumpHelp |
444-457 |
| dumpImpl | dumpsys 输出 | 459-504 |
| 通知 | updateNotifications |
506-552 |
| 广播 | updateBroadcasts |
554-591 |
| 二级卷过滤 | UUID_PRIVATE_INTERNAL 判断 |
555-558 |
| Sticky 广播 | sendStickyBroadcastAsUser |
572, 586 |
| 缓存删除观察器 | CacheFileDeletedObserver |
593-602 |
结语
DeviceStorageMonitorService 的设计精髓在于三点:
- 前置回收 :在真正进入低态之前(
lowBytes*3/2)就启动缓存回收到lowBytes*2,把"补刀"放在"止血"之前,避免磁盘真的触底。 - 分层职责 :本类只固化检查节奏、日志粒度、通知/广播协议,真正的阈值数值交给
StorageManager与DeviceConfig,便于厂商与设备配置差异化。 - 状态机语义 :
isEntering/isLeaving的跨越式定义,让NORMAL↔FULL直跳也能正确触发 LOW/FULL 双广播,避免了"中间级别被跳过"的隐蔽 bug。