EN

Android 应用启动优化 - 一种 DelayLoad 的实现和原理(上篇)

字数统计: 3.5k阅读时长: 14 min
2015/11/18
loading

在 Android 开发中,应用启动速度是一个非常重要的点,应用启动优化也是一个非常重要的过程.对于应用启动优化,核心思想就是在启动过程中少做事情,具体实践的时候无非就是下面几种:

  1. 异步加载
  2. 延时加载
  3. 懒加载

不用一一去解释,做过启动优化的估计都使用过,本篇文章将详细讲解一下一种延时加载的实现以及其原理.
这种加载的实现很简单,但是其中的原理可能比较复杂,还涉及到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 方案,后面再说明它的边界:

  1. 首先, 创建 Handler 和 Runnable 对象, 其中 Runnable 对象的 run方法里面去更新 UI 线程.
1
2
3
4
5
6
7
8
private final Handler myHandler = new Handler(Looper.getMainLooper());
private Runnable mLoadingRunnable = new Runnable() {

@Override
public void run() {
updateText(); //更新UI线程
}
};
  1. 在主 Activity 的 onCreate 中加入下面的代码
1
2
3
4
5
6
7
getWindow().getDecorView().post(new Runnable() {

@Override
public void run() {
myHandler.post(mLoadingRunnable);
}
});

这里显式使用主 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package com.gracker.delaytest;

import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Trace;
import android.util.Log;
import android.util.Printer;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {
// Experiment settings: 1 = post, 2 = postDelayed, 3 = nested post.
private static final int MODE = 1;
private static final long DELAY_TIME_MS = 300;
private static final boolean DUMP_QUEUE = false;

private final Handler mainHandler = new Handler(Looper.getMainLooper());
private View decorView;
private ImageView imageView1, imageView2, imageView3;
private TextView textView1, textView2, textView3;
private boolean destroyed;

private final Runnable loadingTask = new Runnable() {
@Override public void run() {
if (!destroyed) updateText();
}
};
private final Runnable enqueueTask = new Runnable() {
@Override public void run() {
if (!destroyed) mainHandler.post(loadingTask);
}
};

@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
imageView1 = findViewById(R.id.image1);
imageView2 = findViewById(R.id.image2);
imageView3 = findViewById(R.id.image3);
textView1 = findViewById(R.id.text1);
textView2 = findViewById(R.id.text2);
textView3 = findViewById(R.id.text3);
decorView = getWindow().getDecorView();

switch (MODE) {
case 1:
mainHandler.post(loadingTask);
break;
case 2:
mainHandler.postDelayed(loadingTask, DELAY_TIME_MS);
break;
case 3:
decorView.post(enqueueTask);
break;
default:
throw new IllegalArgumentException("Unknown experiment mode");
}
if (DUMP_QUEUE) {
getMainLooper().dump(new Printer() {
@Override public void println(String line) {
Log.i("DelayLoad", line);
}
}, "onCreate");
}
}

private void updateText() {
Trace.beginSection("updateText");
try {
textView1.setText("image1: w=" + imageView1.getWidth()
+ " h=" + imageView1.getHeight());
textView2.setText("image2: w=" + imageView2.getWidth()
+ " h=" + imageView2.getHeight());
textView3.setText("image3: w=" + imageView3.getWidth()
+ " h=" + imageView3.getHeight());
} finally {
Trace.endSection();
}
}

@Override protected void onDestroy() {
destroyed = true;
decorView.removeCallbacks(enqueueTask);
mainHandler.removeCallbacks(loadingTask);
super.onDestroy();
}
}

配套的 res/layout/activity_main.xml 如下。把原仓库的样例图片 zhihu.png 放入 res/drawable/。尺寸仅用于形成三组不同布局,不复刻历史截图的像素值。调试时可开启 DUMP_QUEUE,性能对比时保持关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView android:id="@+id/image1"
android:layout_width="match_parent" android:layout_height="200dp"
android:src="@drawable/zhihu" android:contentDescription="@null" />
<TextView android:id="@+id/text1"
android:layout_width="wrap_content" android:layout_height="wrap_content" />
<ImageView android:id="@+id/image2"
android:layout_width="160dp" android:layout_height="160dp"
android:src="@drawable/zhihu" android:contentDescription="@null" />
<TextView android:id="@+id/text2"
android:layout_width="wrap_content" android:layout_height="wrap_content" />
<ImageView android:id="@+id/image3"
android:layout_width="96dp" android:layout_height="96dp"
android:src="@drawable/zhihu" android:contentDescription="@null" />
<TextView android:id="@+id/text3"
android:layout_width="wrap_content" android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>

我们需要区分三个问题:

  • updateText 这个函数是什么时候被执行的?
  • 三个 ImageView 是否已完成布局,读到的尺寸是否对应那次布局?
  • 延后的工作是否退出首屏关键路径,以及是否拖慢后续可用性?

2.1 第一种写法

  1. updateText执行的时机?
    下面是第一种写法的Trace图:
    第一种写法
    这次记录中,updateText 出现在 Activity 启动相关回调之后、首次 traversal 之前。这是该样本的顺序,不能把 Handler.post() 理解成等待 onResume 或布局完成的专用接口。

  2. 图片的宽高是否正确显示?
    第一种写法

从图中能看到宽高为 0:这次读取时 View 尚未布局。onCreate/onStart/onResume 的完成不保证已经测量、布局;View 的首次尺寸通常在后续 traversal 才建立。另一个场景里,合法的零尺寸也可能来自实际布局,不能只凭 0 就判断发生了相同问题。

  1. 是否有 Delay Load 的效果?
    这次没有把任务移出首次 traversal 之前的主线程工作,因此它可能延长初始显示路径。原稿把“两次 performTraversals 完成”直接等同于首帧显示,这个判断过于绝对:traversal 的次数取决于窗口状态等条件,CPU 绘制结束也不等于 GPU、SurfaceFlinger 和显示端已经完成。

2.2 第二种写法

第二种写法我们Delay了300ms .我们来看一下表现.

  1. updateText执行的时机?
    第二种写法

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

  1. 图片的宽高是否正确显示?
    第二种写法

本次截图读到了非零的布局尺寸,因为相关 ImageView 已经完成布局。这些值属于 View 的当前布局,不是对图片原始宽高的测量。

  1. 是否有 Delay Load 的效果?
    取决于延迟时长和当次负载。我当时记下了约 170ms 的额外等待,但这张裁剪图没有完整时间轴和呈现轨道,无法重新核准这个数值,只能把它留作那次实验的记录。

这里只有一个简单的 TextView 更新,如果换成首屏的大块内容,用户可能先看到空白,再等内容填上,甚至附送一个“闪一下”的特效……耗时工作还可能拖慢后面的操作。这显然不是我们想要的。

可能有人会说:把 Delay 时间减小一点不就好了?问题是不同设备、启动状态和调度负载下,同一个 300ms 也没有统一意义。我把参数改成 50ms,又抓了一次 Trace:

第二种写法:Delay 50ms

这次 updateText 位于两段 traversal 之间,已经能读取布局尺寸,但并未因此获得“首帧已上屏”的保证。尺寸可用、主线程任务执行完和首帧可见,是不同的条件。

2.3 第三种写法

试过前面两种写法之后,我想的是:能不能不用猜延迟多少毫秒,又能让任务靠后执行?第三种写法就是这个尝试。它在这次实验中确实改变了执行顺序,但“嵌套两次 post”并没有首帧上屏完成的语义。

  1. updateText执行的时机?

第三种写法

在这张历史图中,updateText 紧跟在后一个 traversal 之后,而 RenderThread 的工作仍在继续。后续 TextView 更新还要经过帧调度与绘制,不能承诺恰好在下一个 VSYNC 就显示。

  1. 图片的宽高是否正确显示?
    本次实验得到了布局后的尺寸,如图:
    第三种写法
  2. 是否有 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
2
3
4
5
6
7
8
9
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.doOnLayout

fun showSizeAfterLayout(image: ImageView, label: TextView) {
image.doOnLayout { view ->
label.text = "w=${view.width} h=${view.height}"
}
}

需要绘制前状态或帧提交状态: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 和显示端的关联。

核对依据

关于我 && 博客

下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!

  1. 博主个人介绍 :里面有个人的微信和微信群链接。
  2. 本博客内容导航 :个人博客内容的一个导航。
  3. 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
  4. Android性能优化知识星球:付费社群已于 2026 年停止运营,不再招收新成员;此处保留历史介绍。

一个人可以走的更快 , 一群人可以走的更远

微信扫一扫

CATALOG
  1. 1. 1. 优化后的DelayLoad的实现
  2. 2. 2. 三种写法的差异对比
    1. 2.1. 2.1 第一种写法
    2. 2.2. 2.2 第二种写法
    3. 2.3. 2.3 第三种写法
  3. 3. 3. 一些思考
  4. 4. 4. 代码
  5. 5. 5. 2026 年补充:按需要等待的条件选择接口
    1. 5.1. 核对依据
  6. 6. 关于我 && 博客