在 Android 开发中,应用启动速度是一个非常重要的点,应用启动优化也是一个非常重要的过程.对于应用启动优化,核心思想就是在启动过程中少做事情,具体实践的时候无非就是下面几种:
- 异步加载
- 延时加载
- 懒加载
不用一一去解释,做过启动优化的估计都使用过,本篇文章将详细讲解一下一种延时加载的实现以及其原理.
这种加载的实现很简单,但是其中的原理可能比较复杂,还涉及到Looper/Handler/MessageQueue/VSYNC等.以及其中碰到的一些问题,还会有一些我自己额外的思考.
版本说明(2026-10-13 核查):以下是 2015 年的历史实验,保留原截图和三种写法。“两次 post 保证首帧上屏后执行”以及“启动固定经过两次 performTraversals”的结论已不成立,不能作为当前 API 保证。文中按 AOSP Android 6.0 / 16 源码解释队列行为,末尾给出布局回调、Android 10 / API 29 帧提交回调和当前测量方式。截图只能支持当时样本的执行顺序,不能证明普遍的上屏时刻。
1. 优化后的DelayLoad的实现
一提到 DelayLoad,大家可能先想到在 onCreate 中调用 Handler.postDelayed。它设置的是任务最早可执行的时间,不是“界面加载完成”的时间。排队、主线程忙碌和深度休眠都可能让实际执行更晚,所以第一个问题是:延迟多久?
这里先保留原文的嵌套 post 方案,后面再说明它的边界:
- 首先, 创建 Handler 和 Runnable 对象, 其中 Runnable 对象的 run方法里面去更新 UI 线程.
1 | private final Handler myHandler = new Handler(Looper.getMainLooper()); |
- 在主 Activity 的 onCreate 中加入下面的代码
1 | getWindow().getDecorView().post(new Runnable() { |
这里显式使用主 Looper(需要导入 android.os.Looper)。两层 Runnable 最终仍在 UI 线程执行,并没有创建后台线程;耗时工作即使晚一点执行,也仍会阻塞输入和后续绘制。下面比较三种写法。
2. 三种写法的差异对比
为了比较这三种写法,我写了一个简单的 App:放三张不同大小的图片,每张下面用一个 TextView 显示宽高。注意 getWidth() / getHeight() 返回 View 布局尺寸,单位为像素,不是 Drawable 或 Bitmap 的原始尺寸。下面是整理后的完整示例,显式绑定主线程、配对 Trace,并取消销毁后未执行的回调;它不是原仓库的逐字摘录。
示例用于 API 21+、已配置 AndroidX AppCompat 及 AppCompat 主题的项目;包名按项目调整,并在 Manifest 中声明此 Activity。每次只选择一个 MODE,重新启动实验;300ms 和下文的 50ms 都是对照参数,不是推荐的产品延迟。
1 | package com.gracker.delaytest; |
配套的 res/layout/activity_main.xml 如下。把原仓库的样例图片 zhihu.png 放入 res/drawable/。尺寸仅用于形成三组不同布局,不复刻历史截图的像素值。调试时可开启 DUMP_QUEUE,性能对比时保持关闭。
1 |
|
我们需要区分三个问题:
- updateText 这个函数是什么时候被执行的?
- 三个 ImageView 是否已完成布局,读到的尺寸是否对应那次布局?
- 延后的工作是否退出首屏关键路径,以及是否拖慢后续可用性?
2.1 第一种写法
updateText执行的时机?
下面是第一种写法的Trace图:
这次记录中,updateText出现在 Activity 启动相关回调之后、首次 traversal 之前。这是该样本的顺序,不能把Handler.post()理解成等待onResume或布局完成的专用接口。图片的宽高是否正确显示?

从图中能看到宽高为 0:这次读取时 View 尚未布局。onCreate/onStart/onResume 的完成不保证已经测量、布局;View 的首次尺寸通常在后续 traversal 才建立。另一个场景里,合法的零尺寸也可能来自实际布局,不能只凭 0 就判断发生了相同问题。
- 是否有 Delay Load 的效果?
这次没有把任务移出首次 traversal 之前的主线程工作,因此它可能延长初始显示路径。原稿把“两次 performTraversals 完成”直接等同于首帧显示,这个判断过于绝对:traversal 的次数取决于窗口状态等条件,CPU 绘制结束也不等于 GPU、SurfaceFlinger 和显示端已经完成。
2.2 第二种写法
第二种写法我们Delay了300ms .我们来看一下表现.
- updateText执行的时机?

这个历史样本中,updateText 位于图中前两段 traversal 之后,随后 setText 又触发界面更新。仅凭这些 CPU 区间,不能精确断定首帧已在屏幕上显示。
- 图片的宽高是否正确显示?

本次截图读到了非零的布局尺寸,因为相关 ImageView 已经完成布局。这些值属于 View 的当前布局,不是对图片原始宽高的测量。
- 是否有 Delay Load 的效果?
取决于延迟时长和当次负载。我当时记下了约 170ms 的额外等待,但这张裁剪图没有完整时间轴和呈现轨道,无法重新核准这个数值,只能把它留作那次实验的记录。
这里只有一个简单的 TextView 更新,如果换成首屏的大块内容,用户可能先看到空白,再等内容填上,甚至附送一个“闪一下”的特效……耗时工作还可能拖慢后面的操作。这显然不是我们想要的。
可能有人会说:把 Delay 时间减小一点不就好了?问题是不同设备、启动状态和调度负载下,同一个 300ms 也没有统一意义。我把参数改成 50ms,又抓了一次 Trace:

这次 updateText 位于两段 traversal 之间,已经能读取布局尺寸,但并未因此获得“首帧已上屏”的保证。尺寸可用、主线程任务执行完和首帧可见,是不同的条件。
2.3 第三种写法
试过前面两种写法之后,我想的是:能不能不用猜延迟多少毫秒,又能让任务靠后执行?第三种写法就是这个尝试。它在这次实验中确实改变了执行顺序,但“嵌套两次 post”并没有首帧上屏完成的语义。
- updateText执行的时机?

在这张历史图中,updateText 紧跟在后一个 traversal 之后,而 RenderThread 的工作仍在继续。后续 TextView 更新还要经过帧调度与绘制,不能承诺恰好在下一个 VSYNC 就显示。
- 图片的宽高是否正确显示?
本次实验得到了布局后的尺寸,如图:
- 是否有 Delay Load 的效果?
这次实现了队列层面的延后,也避免了选择固定毫秒数。但不能从该样本推广为所有 Android 版本和窗口状态下的“首帧后加载”方案。
3. 一些思考
这个实现从代码层面看很简单,但为什么会出现图中的顺序,还需要看 Handler/MessageQueue/Looper、View 的 attach 状态和 ViewRootImpl 的 traversal 调度。下一篇继续分析这些细节;这里先记住,观测到一次有效的延后,不等于获得了一个稳定的首帧回调。
4. 代码
历史项目在 DelayLoadSample。本次核对的固定提交为 62e03ae(2020-02-25):它已改为一张图,并默认演示 IdleHandler,与本文三组控件不完全相同。其高度文本误用了 getWidth(),应改用 getHeight();本文补全示例已修正。仓库旧 Gradle、SDK 和依赖配置只作历史参考,不保证在当前工具链直接构建。
5. 2026 年补充:按需要等待的条件选择接口
只需要布局尺寸:使用布局回调。下面的 Kotlin 函数依赖 AndroidX Core 的 doOnLayout(1.1.0 起提供),在 UI 线程对已绑定的 ImageView/TextView 调用即可;三组控件分别调用一次。已完成布局且没有待处理布局请求时,它会立即执行,否则等下一次 layout,执行一次后移除监听。它不等待像素上屏,也不应装入耗时初始化。
1 | import android.widget.ImageView |
需要绘制前状态或帧提交状态:OnPreDrawListener 在 draw 之前回调,不能叫“首帧后”。Android 10 / API 29 的 registerFrameCommitCallback 可观察内容渲染并提交到 swap chain,仅适用于硬件渲染;回调时画面仍可能尚未可见。应在目标帧之前注册,并按生命周期移除未触发的回调,它不等同于精确的显示完成通知。
需要延后非必要工作:先确认依赖关系,主线程只保留必须在 UI 线程执行的短任务。Handler.post、postDelayed 和 IdleHandler 都不自动转到后台;IdleHandler 的空闲是消息队列条件,不等于首帧可见。CPU/IO 工作使用适当的执行线程并管理取消,主内容就绪前仍要计入用户等待时间。AndroidX App Startup 管理初始化依赖与入口,也不是帧后调度器;手动懒初始化要先移除对应自动初始化配置,并确认没有其他 initializer 把它作为依赖提前初始化。
这个限制也能从版本差异中看出来。AOSP android-6.0.0_r1 的未 attach View.post 把任务放入 ViewRootImpl.getRunQueue(),在 traversal 中转交 Handler;android-16.0.0_r1 则使用 View 自己的队列,在 attach 时转交。已 attach 时两者都直接投递到关联 Handler。再次 post 只是新增队列任务,不会等待 GPU 或显示端。
测量时把初始显示和完整可用性分开:Macrobenchmark 的 StartupTimingMetric(1.1.0 起)提供 TTID 对应的 timeToInitialDisplayMs,以及由 fully-drawn 信号决定的 timeToFullDisplayMs。后者结束于包含该信号或紧随其后的那一帧完成渲染;API 29 以前可能不可用,缺失值不能当成 0。在主要内容完成渲染、达到产品定义的可用状态后报告 fully drawn,不在 onCreate 中无条件报告。比较相同设备、构建、数据状态、编译模式下的多次冷/温/热启动,并检查后续帧;不要把任务挪到首帧后就宣布优化成功。
Baseline Profiles 主要帮助 ART 预编译覆盖路径;Startup Profiles 用于构建期 DEX 布局,两者不负责调度任务。DEX 布局优化从 AGP 8.1 提供,AGP 8.3 起默认启用,仍需对应配置、R8 与实际生成的 profile;不承诺一个固定提速百分比。Android 12 / API 31 起还可结合 Perfetto FrameTimeline 检查帧提交、GPU 和显示端的关联。
核对依据
- AOSP 6.0 View.java, ViewRootImpl.java
- AOSP 16 View.java, Handler.java
- ViewTreeObserver, doOnLayout
- App Startup, StartupTimingMetric, App startup time
- Baseline Profiles, Startup Profiles, FrameTimeline
关于我 && 博客
下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!
- 博主个人介绍 :里面有个人的微信和微信群链接。
- 本博客内容导航 :个人博客内容的一个导航。
- 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
- Android性能优化知识星球:付费社群已于 2026 年停止运营,不再招收新成员;此处保留历史介绍。
一个人可以走的更快 , 一群人可以走的更远
