Skip to content

IdleHandler 使用边界

从 MessageQueue.isIdle 与 IdleHandler 注册表追踪空闲查询、回调保留、并发修改、系统消费者和资源清理边界。

基于android-17.0.0_r1
AndroidIdleHandlerMessageQueueStrictMode源码阅读

IdleHandler 使用边界 ​

MessageQueue.IdleHandler 经常被描述成“主线程空闲时执行一次的回调”,但这个说法少了几个 关键条件:队列可能仍然有未来消息;isIdle() 只查询原始队头;回调在队列锁外运行;返回 true 只保留注册,不提供固定周期;而且已经从队列摘下的消息不能再靠 IdleHandler 撤销。

本文面向已经读过 IdleHandler空闲回调、 MessageQueue取消息 和 消息延迟边界 的读者。HL023 负责解释 next() 的首轮快照、异常 和重查算法,本文把这些机制放到三个可操作问题中:如何判断“现在是否空闲”,谁拥有 IdleHandler 的注册状态,以及系统代码如何避免重复注册和长期持有不需要的回调。本文限定 Android 17 的 LegacyMessageQueue;CombinedMessageQueue 与 CombinedDeliMessageQueue 的 空闲实现另有分支,不从本文的字段和控制流外推结论。

读完后,读者应能区分点查询与事件回调,解释一个返回 true 的 handler 为什么可能很久不再 运行,定位 removeIdleHandler() 的所有者,并判断某个系统消费者的空闲回调是在做清理、状态 通知还是队列完成广播。

1. 语义分界 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

接口注释把 IdleHandler 定义为:队列已经没有当前可处理的消息,即将等待更多消息时调用的 回调。队列里仍可存在未来消息,所以“空闲”不是“消息链表为空”,更不是“进程没有任何工作”。

java
public static interface IdleHandler {
    /**
     * Called when the message queue has run out of messages and will now wait
     * for more.  Return true to keep your idle handler active, false to have
     * it removed.
     */
    boolean queueIdle();
}

同一个类还公开了 isIdle()。它是同步点查询:调用方立即得到一个布尔值,不会注册回调,也不 会等到 Looper 下一轮轮询。

java
public boolean isIdle() {
    synchronized (this) {
        final long now = SystemClock.uptimeMillis();
        return mMessages == null || now < mMessages.when;
    }
}

两者关系可以写成:

能力读取/执行者触发方式能否告诉你未来会何时再次触发
isIdle()任意查询线程调用时读取不能
queueIdle()Looper 线程next() 首轮发现空闲不能
postDelayed()Looper 线程到达 Message.when只能按消息时间排序

因此,isIdle() 适合诊断或条件判断;IdleHandler 适合把工作挂到队列下一次进入等待前, 但不能替代定时消息。

2. 队头判定 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

isIdle() 读取的是 mMessages 原始头节点,不会像同步屏障路径那样跳过屏障去寻找异步消息。 判断只有两个分支:没有头节点,或头节点的 when 仍在未来。

java
final long now = SystemClock.uptimeMillis();
return mMessages == null || now < mMessages.when;
原始队头isIdle()含义
nulltrue队列没有待处理消息
when > nowtrue队列有消息,但下一条尚未到期
when <= nowfalse队列头已有可处理消息
同步屏障取决于屏障 when不等价于“后面没有可执行的异步消息”

最后一行是实际使用中的陷阱:isIdle() 的 API 语义与 next() 的“寻找可分发异步消息”分支 不是同一套扫描。用 isIdle() 推断“下一次一定会执行某个异步消息”是不成立的。

3. 注册所有权 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

队列把长期注册保存到 mIdleHandlers,把一次空闲批次的遍历对象保存到 mPendingIdleHandlers。注册和移除都在队列锁内完成;回调本身不会在这把锁里执行。

java
private final ArrayList<IdleHandler> mIdleHandlers = new ArrayList<IdleHandler>();
private IdleHandler[] mPendingIdleHandlers;

public void addIdleHandler(@NonNull IdleHandler handler) {
    if (handler == null) {
        throw new NullPointerException("Can't add a null IdleHandler");
    }
    synchronized (this) {
        mIdleHandlers.add(handler);
    }
}

public void removeIdleHandler(@NonNull IdleHandler handler) {
    synchronized (this) {
        mIdleHandlers.remove(handler);
    }
}

这里的所有权关系很具体:MessageQueue 持有 handler 引用,调用方持有能移除它的对象引用。 如果调用方只创建匿名对象而不保存引用,后续就只能等待它返回 false,不能主动取消。

ArrayList 不做去重。相同对象被添加两次,会在注册表中占据两个位置,并可能在一次快照中被 调用两次;使用者应把“是否已注册”作为自己的状态,而不是假设队列会替自己去重。

4. 首轮触发 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

next() 用 pendingIdleHandlerCount = -1 标记一次调用的首轮。只有在没有到期消息且尚未 建立快照时,才读取 mIdleHandlers.size();如果队列随后再次循环,pendingIdleHandlerCount 会被置为 0,同一轮不会重复调用相同快照。

java
int pendingIdleHandlerCount = -1;
int nextPollTimeoutMillis = 0;
for (;;) {
    nativePollOnce(ptr, nextPollTimeoutMillis);

    synchronized (this) {
        final long now = SystemClock.uptimeMillis();
        Message msg = mMessages;
        if (msg != null && msg.target == null) {
            do {
                prevMsg = msg;
                msg = msg.next;
            } while (msg != null && !msg.isAsynchronous());
        }
        if (msg != null && now >= msg.when) {
            // 摘除并返回一条到期消息
            return msg;
        }

        if (pendingIdleHandlerCount < 0
                && (mMessages == null || now < mMessages.when)) {
            pendingIdleHandlerCount = mIdleHandlers.size();
        }
    }
}

首轮判定发生在“尝试取消息”之后。若原始队头已经到期,IdleHandler 不会抢在这条消息前面; 若只有未来消息,IdleHandler 可以在等待该消息期间先运行一次。

图中“记录注册数量”和“复制快照”是队列控制动作;queueIdle() 是用户代码,故意在锁外 执行。这个边界允许回调中调用 addIdleHandler() 或发送消息,而不会把队列锁带入业务代码。

5. 快照边界 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

队列先把注册表复制到可复用数组,再逐项调用。调用前清空数组槽位,调用后才根据返回值从长期 注册表移除对象。

java
if (mPendingIdleHandlers == null) {
    mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);

for (int i = 0; i < pendingIdleHandlerCount; i++) {
    final IdleHandler idler = mPendingIdleHandlers[i];
    mPendingIdleHandlers[i] = null;

    boolean keep = false;
    try {
        keep = idler.queueIdle();
    } catch (Throwable t) {
        Log.wtf(TAG, "IdleHandler threw exception", t);
    }
    if (!keep) {
        synchronized (this) {
            mIdleHandlers.remove(idler);
        }
    }
}

快照造成两个可观察结果:

  • 回调 A 在本批次新增的 handler 不会插入正在遍历的数组,通常要等下一次空闲;
  • 回调 A 在本批次移除的 handler 仍可能已经存在于数组中,因此不能用“移除”阻止本批次的 后续调用。

这不是并发 bug,而是“注册表状态”和“本批次遍历输入”被有意分离。若任务需要取消当前批次 的业务动作,应由任务自身设置取消状态,不能只依赖 removeIdleHandler()。

6. 返回状态 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

queueIdle() 的返回值只决定当前对象是否留在 mIdleHandlers。它不改变 Looper 是否退出, 也不控制同一快照中的其他对象。

返回值本次回调以后空闲
false当前回调返回后结束从注册表移除
true当前回调返回后结束保留,等待下一次空闲批次

持续返回 true 也不等于固定周期。只要队列一直有到期消息,next() 就会优先交付这些消息; 只有再次进入空闲判定,handler 才有机会运行。

7. 异常处理 ​

源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

IdleHandler 的异常被 next() 捕获并记录,keep 保持初始值 false,所以抛异常的对象不会 继续留在注册表。

java
boolean keep = false;
try {
    keep = idler.queueIdle();
} catch (Throwable t) {
    Log.wtf(TAG, "IdleHandler threw exception", t);
}
if (!keep) {
    synchronized (this) {
        mIdleHandlers.remove(idler);
    }
}

这里的行为不能套用到普通 Handler 消息:普通 callback 的未捕获异常由 Handler/Looper 的分发 路径处理;IdleHandler 则由 MessageQueue.next() 把异常转成日志并继续轮询。IdleHandler 内部 仍应自行保证资源清理,因为队列只负责移除注册对象,不知道业务资源处于什么状态。

8. 系统消费者 ​

8.1 空闲清理 ​

源码文件:frameworks/base/core/java/android/app/ActivityThread.java

ActivityThread 的 GcIdler 和 PurgeIdler 都返回 false。它们的调度状态由 mGcIdlerScheduled 与 mPurgeIdlerScheduled 管理,调度和取消路径显式成对出现。

java
final GcIdler mGcIdler = new GcIdler();
final PurgeIdler mPurgeIdler = new PurgeIdler();
boolean mGcIdlerScheduled = false;
boolean mPurgeIdlerScheduled = false;

final class GcIdler implements MessageQueue.IdleHandler {
    public final boolean queueIdle() {
        doGcIfNeeded();
        purgePendingResources();
        return false;
    }
}

void scheduleGcIdler() {
    if (!mGcIdlerScheduled) {
        mGcIdlerScheduled = true;
        Looper.myQueue().addIdleHandler(mGcIdler);
    }
    mH.removeMessages(H.GC_WHEN_IDLE);
}

void unscheduleGcIdler() {
    if (mGcIdlerScheduled) {
        mGcIdlerScheduled = false;
        Looper.myQueue().removeIdleHandler(mGcIdler);
    }
    mH.removeMessages(H.GC_WHEN_IDLE);
}

这里的“最佳实践”不是人为规定,而是源码中的所有权设计:ActivityThread 保存稳定的 idler 对象,用布尔字段防止重复加入,用显式移除取消尚未触发的清理。queueIdle() 返回 false 表示一次清理机会结束;doGcIfNeeded() 自己还会基于最近 GC 时间判断是否真的触发 GC, 所以 IdleHandler 触发不等于 GC 一定发生。

8.2 空闲检测 ​

源码文件:frameworks/base/core/java/android/os/StrictMode.java

StrictMode 使用一个静态共享 handler 做实例计数检查,并在 queueIdle() 内再次按 uptimeMillis() 限制检查间隔。它返回 true,所以真正的生命周期由 VM policy 开关控制。

java
private static boolean sIsIdlerRegistered = false;
private static final MessageQueue.IdleHandler sProcessIdleHandler =
        new MessageQueue.IdleHandler() {
    public boolean queueIdle() {
        long now = SystemClock.uptimeMillis();
        if (now - sLastInstanceCountCheckMillis > 30 * 1000) {
            sLastInstanceCountCheckMillis = now;
            conditionallyCheckInstanceCounts();
        }
        return true;
    }
};

if (!shouldCheckInstanceCountViolations(policy)) {
    mq.removeIdleHandler(sProcessIdleHandler);
    sIsIdlerRegistered = false;
} else if (!sIsIdlerRegistered) {
    mq.addIdleHandler(sProcessIdleHandler);
    sIsIdlerRegistered = true;
}

这个消费者说明 return true 只能表达“保留注册”,不能表达“每次空闲都做昂贵工作”。 StrictMode 还在回调内部做 30 秒节流;关闭策略时则主动移除。若应用只写 return true 而没有 类似节流或退出条件,就会让对象长期挂在队列注册表上。

8.3 队列完成通知 ​

源码文件:frameworks/base/core/java/android/speech/tts/TextToSpeechService.java

TTS 的 SynthThread 在自己的 HandlerThread.onLooperPrepared() 中注册 IdleHandler。第一次 空闲只翻转 mFirstIdle;后续空闲才广播队列处理完成,并且始终返回 true。

java
private class SynthThread extends HandlerThread implements MessageQueue.IdleHandler {
    private boolean mFirstIdle = true;

    @Override
    protected void onLooperPrepared() {
        getLooper().getQueue().addIdleHandler(this);
    }

    @Override
    public boolean queueIdle() {
        if (mFirstIdle) {
            mFirstIdle = false;
        } else {
            broadcastTtsQueueProcessingCompleted();
        }
        return true;
    }
}

这个例子不能被简化成“空闲就代表所有业务完成”:它有意跳过首次空闲,并把后续空闲当作 合成队列处理完成的信号。它还在 onDestroy() 通过 getLooper().quit() 结束线程,所以注册 handler 的存活范围受 SynthThread 生命周期约束,而不是永久属于进程。

9. 测试输入 ​

源码文件:frameworks/base/core/tests/coretests/src/android/os/IdleHandlerTest.java

测试用一个 HandlerThread 驱动输入,把 queueIdle() 次数写入计数器,再用延迟消息作为 “让队列再次忙起来”的边界。关键测试不是等待某个固定墙钟时刻,而是通过后续消息检查空闲 回调次数。

java
public void go() {
    super.go();
    mCount = 0;
    mHandler.sendMessageDelayed(mHandler.obtainMessage(0), 100);
    addIdleHandler();
}

public boolean queueIdle() {
    mCount++;
    return false;
}

testOneShotFirst 的动作是先排入 100 ms 后的消息,再注册一次性 handler;收到第一条消息后 再排第二条消息,最终断言计数为 1。testRepeatedFirst 将返回值改为 true,两条延迟消息 之间最终断言计数为 2。它们证明的是“未来队头允许首次空闲”和 true/false 的注册差异, 不证明 IdleHandler 具有固定频率。

源码文件:frameworks/base/core/tests/coretests/src/android/os/MessageQueueTest.java

testResetClearsIdleHandlers 设计为:先让 handler 运行一次,调用 resetForTest(),再发送 普通消息;如果注册表已清空,第二次空闲不会再次减少 latch。但该测试当前被 @Ignore 标记, 因为使用的 TestLooperManager 不会可靠进入 idle。它可以说明 reset 的预期清理范围,不能作为 已执行通过的运行时断言。

10. 复查命令 ​

源码文件:

  • frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
  • frameworks/base/core/java/android/app/ActivityThread.java
  • frameworks/base/core/java/android/os/StrictMode.java
  • frameworks/base/core/java/android/speech/tts/TextToSpeechService.java
  • frameworks/base/core/tests/coretests/src/android/os/IdleHandlerTest.java
  • frameworks/base/core/tests/coretests/src/android/os/MessageQueueTest.java
bash
rg -n "isIdle|addIdleHandler|removeIdleHandler|pendingIdleHandlerCount|queueIdle" \
  frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java

rg -n "scheduleGcIdler|unscheduleGcIdler|GcIdler|PurgeIdler" \
  frameworks/base/core/java/android/app/ActivityThread.java

rg -n "sProcessIdleHandler|sIsIdlerRegistered|queueIdle" \
  frameworks/base/core/java/android/os/StrictMode.java

rg -n "SynthThread|onLooperPrepared|broadcastTtsQueueProcessingCompleted" \
  frameworks/base/core/java/android/speech/tts/TextToSpeechService.java

rg -n "testOneShot|testRepeated|testResetClearsIdleHandlers" \
  frameworks/base/core/tests/coretests/src/android/os/IdleHandlerTest.java \
  frameworks/base/core/tests/coretests/src/android/os/MessageQueueTest.java

阅读任一消费者时,先找三个符号:注册状态的 owner、queueIdle() 的返回值、取消或线程退出 路径。只看回调体而不看注册/移除,会把一次性清理、节流监控和持续完成通知误读成同一种用法。

11. 使用边界 ​

IdleHandler 适合把短小、可被队列忙碌推迟的工作接到一个已有 Looper 的空闲窗口;它不适合 表达固定延迟、强实时截止时间、跨进程持久化或必须在当前批次立即取消的任务。回调运行在 Looper 线程,任何长时间 I/O 或复杂计算都会占用这条线程,并把下一条消息的处理继续向后推。

一个可审计的使用方式应满足:注册者保存 handler 引用;一次性任务返回 false;持续任务有 明确的节流和显式移除条件;回调内部的业务取消状态独立于队列移除;线程退出时不再把 idle 回调当作完成确认。这样使用时,IdleHandler 才是“空闲窗口通知”,而不是一个被误用的定时器 或后台执行器。