Android子线程到底能否更新UI——可能是最全面的解析

Android子线程到底能否更新UI——可能是最全面的解析

前言:Android 开发中有这么一条“潜规则”:「一个 App 的主线程就是 UI 线程,子线程不能更新 UI」。绝大部分情况下,我们在需要处理 UI 逻辑时,都会自觉地放在主线程操作,但是为什么会有这么一条“铁律”,其原因是什么,以及这条“铁律”就一定正确吗?带着这些疑问我在度娘和 StackOverFlow 上搜了一遍,绝大部分分析都止于「子线程更新 UI 会抛出异常的逻辑在哪」,所以我决定自己探索一遍,并写下这篇截止到目前,【可能】是最全面的一篇分析。

当然,这篇文章不会深入到屏幕渲染、线程调度等等这样的层面,其重点在于从源码的角度论证:「子线程到底能不能更新 UI」,本文默认读者已有初级 Android 基础。


1. 子线程更新UI异常

下面这段代码,是很典型的子线程更新 UI 的操作:

1
2
3
4
5
6
7
8
9
HandlerThread newThread = new HandlerThread("NewThread");
newThread.start();
Handler newThreadHandler = new Handler(newThread.getLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
tvNewThreadText.setText("子线程内更新 UI");
}
};
newThreadHandler.sendEmptyMessage(0);

这段代码运行会抛出异常:

1
CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

为什么子线程不能更新 UI 呢?这就需要从 3 个方面解释。

1.1 设计思想

首先不得不提到,子线程更新 UI 会抛出异常肯定是 Android 有意为之,但 Android 为什么要这么设计呢?

这是因为,人眼感到【流畅】需要满足帧率大于等于 60 Fps,对应的也就是约等于 16 毫秒一帧,Android 为了让交互和显示足够流畅,就需要尽可能保证这个帧率,尤其在现在高刷屏普及的时代,就需要尽可能缩短每一帧的渲染时间。因为频繁的加锁和锁释放会带来很大的内存开销,很可能会延长每一帧的渲染时间,因此对于 UI 更新的操作,是没有加锁的。但如果不加锁,在出现并发问题时,系统如何确保下一帧画面到底应该渲染成什么样呢?

所以,Android 系统为了避免这个问题,就从源码层限制了其他线程更新 UI,以兼顾 UI 更新的效率和并发安全性。

1.2 异常原因

解释完设计思想,就要老生常谈分析一下抛出异常的直接原因了。

首先有一个基础:View 在更新时,是将自己测量并绘制,但这个绘制并不是一旦 View 完成初始化、或者调用更新时就马上绘制,而是发起一个屏幕同步 Sync 请求,等待下一次屏幕刷新时,再绘制到屏幕上。

而这个 View 发起绘制请求的命令,就是 UI 更新都离不开的:requestLayout()

就以 TextView.setText(...) 为例,顺着 setText(...) 的源码一路点进去,直到下面这个方法(省略其他代码):

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
public class TextView {
......
private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
......
if (mLayout != null) {
checkForRelayout();
}
}

private void checkForRelayout() {
......
// 关键就是这个 requestLayout():
requestLayout();
}
}

// 再点进 requestLayout() 源码:
public class View {
......
public void requestLayout() {
......
// 每个 View 都逐级调用上层父 View 的 requestLayout,最上层的父 View 就是 ViewRootImpl
if (mParent != null && !mParent.isLayoutRequested()) {
mParent.requestLayout();
}
}
}

可以看到,每个 View 都会一层层请求自己的父布局调用 requestLayout(),而最最上层的父布局,就是一个 ViewRootImpl,它也实现了 ViewParent 接口。而在 ViewRootImpl 内,requestLayout() 的实现是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final class ViewRootImpl implements ViewParent {
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
......
// 注意这个方法是关键
checkThread();
}
}

// 看看源码
void checkThread() {
if (mThread != Thread.currentThread()) {
// 就是在这里判断了线程
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
}

到这一步就清晰明了了,因为 Thread.currentThread() 是子线程,而 mThread 是主线程,所以在这里抛出了异常。

【但是!】这个判断有个很大的问题,因为它的判断是 mThread != Thread.currentThread(),而 mThread 是在这个 ViewRootImpl 的构造方法里面存入的,因此这个判断本质上比较的是:当前线程与「ViewRootImpl 初始化时的线程」是否相同,而不是当前线程与「主线程」是否相同,并且这里抛出的异常说明也是「the original thread」而不是「the main thread」,所以我们常说的「子线程不能更新 UI」,实际上是:

一个线程初始化了 ViewRootImpl 后,其 UI 不能被其他线程更新!而这两个线程,和是不是主线程并没有关系!


2. Activity视图加载流程

现在知道了线程能否更新 UI 主要看这个 UI 所处的最上层 ViewRootImpl 是否由同一个线程初始化,那么 ViewRootImpl 是怎么初始化的呢?又是在什么时候初始化的呢?

既然 ViewRootImpl 是最上层布局,那不妨从 Activity 启动加载开始:Android-Activity深入理解


3. ViewRootImpl线程的定义

通过 Android-Activity深入理解,可以知道:

  • ViewRootImpl 判断线程时依据的 mThread 就是创建并初始化 ViewRootImpl 时的所在线程。
  • setContentView(...) 之后 Activity 视图的加载流程,主要包括对 DecorView、subDecor、以及 mContentParent 的加载和持有逻辑。

但是!结合上面这两条来看,就会发现两个结论:

  • setContentView(...) 中,虽然也有判断 Window、DecorView、subDecor 等是否创建以及立即创建的逻辑,但并没有对 ViewRootImpl 的操作逻辑!!!也就是说,调用 setContentView(...) 时所处的线程并不能决定 ViewRootImpl 的初始线程,也就无法决定哪个线程可以更新 UI!
  • 从 Activity 的加载流程 2.2.1 部分来看,决定 ViewRootImpl 初始线程的,似乎只有 handleResumeActivity(...),而不论 startActivity 是否在子线程中调用,一个 Activity 都是通过 AMS 管理的,handleResumeActivity(...) 的调用都会发生在 ActivityThread 中,ActivityThread 又处在主线程中。

这两个结论说明:不论 startActivity 是否在子线程中调用,也不论一个 Activity 的 setContentView(...) 是否在子线程中调用,都无法影响到 Activity 是在 ActivityThread 这个主线程中加载的,所以尽管 ViewRootImpl 比较的线程是【初始线程】与当前线程,但在 Activity 常规加载流程中,ViewRootImpl 总是在主线程初始化的,所以在大部分情况下,子线程的确无法更新 UI。


4. 子线程绝对不能更新UI吗?

在上面第 3 部分,我做了一个结论,表明「大部分情况下,子线程的确无法更新 UI」,但请注意原画中的「常规加载流程」,以及「大部分情况下」这两个关键词。

先写结论:实际上,子线程可以更新 UI。

在这里我又再一次推倒了前面的结论,因为我们已经知道,只要能让 ViewRootImpl 在子线程中初始化,就能在该子线程中更新 UI。虽然通常初始化 ViewRootImpl 的动作会被 ActivityThread 自动完成,但实际上仍有方法手动创建。

4.1 手动触发ViewRootImpl的初始化

从前文可以知道,ViewRootImpl 的初始化发生在 ActivityThread.handleResumeActivity(...) 中,并且发生在初始化 Window 和 DecorView 之后调用 mWindow.addView(DecorView, LayoutParams) 时。那就可以想个办法,在 onCreate 阶段就手动初始化 PhoneWindow,手动触发 mWindow.addView(...)

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
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

HandlerThread newThread = new HandlerThread("NewThread");
newThread.start();
Handler newThreadHandler = new Handler(newThread.getLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
tvNewThreadText = new TextView(DemoActivity.this);
WindowManager.LayoutParams windowLP = new WindowManager.LayoutParams(
200, // width
50, // height
100, // x position
100, // y position
WindowManager.LayoutParams.FIRST_SUB_WINDOW, // type
WindowManager.LayoutParams.TYPE_TOAST, // flag
PixelFormat.OPAQUE // format
);
WindowManager windowManager = MainActivity.this.getWindowManager();
// 实际上就是把这个 TextView 作为 DecorView 传递给 WindowManager 加载
windowManager.addView(tvNewThreadText, windowLP);
tvNewThreadText.setText("子线程内更新 UI");
}
};
newThreadHandler.sendEmptyMessage(0);
}

实践证明:通过手动初始化 Window 并添加 View,的确可以在子线程中更新 UI,且该方法适用于所有 View。

4.2 避开ViewRootImpl的检查

从文章最开始对 TextView#setText(...) 的源码分析可知,子线程中更新 UI 会抛出异常在于更新 UI 时,View 会逐级向上层父 View 调用 requestLayout(),直到最上层的 ViewRootImpl#requestLayout() 判断了线程。但在后面 Activity 加载流程的分析中又发现,ViewRootImpl 是在 handleResumeActivity() 时初始化的,也就是说,在 Activity 处于 onCreate 生命周期时,ViewRootImpl 根本都还没有初始化,此时如果 TextView 更新 UI,则在逐级向上层调用父 View 的 requestLayout() 时,到了 ViewRootImpl 就会因为 mParent == null 而跳过了。

Show me the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_demo);

tvNewThreadTitle = findViewById(R.id.tvNewThreadTitle);
new Thread(new Runnable() {
@Override
public void run() {
tvNewThreadTitle.setText("子线程修改后的 Text");
}
}).start();
}

实践证明:通过避开 ViewRootImpl 的检查,的确也可以在子线程中更新 UI,且该方法适用于所有 View。

4.3 针对TextView避开重绘

4.1 和 4.2 中的两个方法,是对所有 View 更新 UI 都适用的,但对于 TextView,还有一种方式,就是避开重绘。

首先看下这样一个布局(省略部分):

1
2
3
4
5
6
7
8
9
10
11
12
<TextView
android:id="@+id/tvNewThreadTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="初始 TextView"
/>
<Button
android:id="@+id/btUpdate"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:text="在子线程更新 UI"
/>

通过 Activity 加载(省略部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void onResume() {
super.onResume();

btUpdate.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new Thread(new Runnable() {
@Override
public void run() {
tvNewThreadTitle.setText("修改后的 Text");
}
}).start();
}
});
}

点击 Button 时,会开启一个子线程并在子线程中更新 TextView。

毫无疑问这段代码 Crash 了,原因和文首说明的一样,因为 ViewRootImpl 在主线程中初始化,因此子线程无法更新 UI。

但!如果把布局中 TextView 的宽度改为精确值或 match_parent,Activity 中的代码不变:

1
2
3
4
5
6
7
8
9
10
<!--布局中把 TextView 的宽度改为精确值或 match_parent-->
<TextView
android:id="@+id/tvNewThreadTitle"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="初始 TextView"
/>
<Button
......
/>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Activity 中的代码逻辑不变,仍然是在点击时开启子线程并在子线程中更新 TextView
@Override
protected void onResume() {
super.onResume();

btUpdate.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new Thread(new Runnable() {
@Override
public void run() {
tvNewThreadTitle.setText("修改后的 Text");
}
}).start();
}
});
}

再次运行发现居然没有 Crash,子线程成功更新了 UI!这难道又要再次推翻之前的结论吗?

再重新翻一下 TextView#setText(...) 的源码,这一次仔细看看:

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
public class TextView {
......
private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
......
// 这一步可以理解成,把传进来的 text 存入到 TextView 的成员变量中
setTextInternal(text);
......
if (mLayout != null) {
checkForRelayout();
}
}

private void checkForRelayout() {
// If we have a fixed width, we can just swap in a new text layout
// if the text height stays the same or if the view height is fixed.
if ((mLayoutParams.width != LayoutParams.WRAP_CONTENT
|| (mMaxWidthMode == mMinWidthMode && mMaxWidth == mMinWidth))
&& (mHint == null || mHintLayout != null)
&& (mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight() > 0)) {
// Static width, so try making a new text layout.
......
if (mEllipsize != TextUtils.TruncateAt.MARQUEE) {
// In a fixed-height view, so use our new text layout.
if (mLayoutParams.height != LayoutParams.WRAP_CONTENT
&& mLayoutParams.height != LayoutParams.MATCH_PARENT) {
autoSizeText();
invalidate();
// ----- 重点:return 了 -----
return;
}

// Dynamic height, but height has stayed the same,
// so use our new text layout.
if (mLayout.getHeight() == oldht
&& (mHintLayout == null || mHintLayout.getHeight() == oldht)) {
autoSizeText();
invalidate();
// ----- 重点:return 了 -----
return;
}
}
// ----- 如果走到这里,就会触发 requestLayout,导致判断 ViewRootImpl 线程 -----

// We lose: the height has changed and we have a dynamic height.
// Request a new view layout using our new text layout.
requestLayout();
invalidate();
} else {
// ----- 如果走到这里,就会触发 requestLayout,导致判断 ViewRootImpl 线程 -----

// Dynamic width, so we have no choice but to request a new
// view layout with a new text layout.
nullLayouts();
requestLayout();
invalidate();
}
}
}

看源码可以发现,在 checkForRelayout() 之前,先通过 setTextInternal(text); 把 text 存入了成员变量,然后才会调用 checkForRelayout() 检查线程。

通过上文已经知道,如果调用了 requestLayout(),就会导致 ViewRootImpl 判断线程。而 TextView#checkForRelayout() 中,requestLayout() 之前有两个 return 的机会(已在代码注释中标出),接下来就是看看如何才能触发这两个 return

(1)首先是最外层的 if 判断必须要满足的条件,否则 else 中一定会走到 requestLayout。这个最外层的 if 条件是(为了更加直观调整了缩进):

1
2
3
4
5
6
7
8
if (
(
mLayoutParams.width != LayoutParams.WRAP_CONTENT
|| (mMaxWidthMode == mMinWidthMode && mMaxWidth == mMinWidth)
)
&& (mHint == null || mHintLayout != null)
&& (mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight() > 0)
)

简单来说,这三个条件均满足就表示 TextView 的宽度是固定值且大于 0,也就是宽度是不需要重新测绘的。这也是为什么当 TextView 的宽度设置为 wrap_content 时,子线程更新 TextView 会抛出异常的原因,因为这个最外层的 if 不满足而走到了 else 中。

(2)接着是第二层的 if 判断也必须要满足条件:

1
if (mEllipsize != TextUtils.TruncateAt.MARQUEE)

这个判断满足时表示 TextView 不是跑马灯效果的状态。这个很好理解,因为跑马灯效果是需要一直刷新 UI 的。

(3)然后是第一个可能 return 的条件(为了更加直观调整了缩进):

1
2
3
4
if (
mLayoutParams.height != LayoutParams.WRAP_CONTENT
&& mLayoutParams.height != LayoutParams.MATCH_PARENT
)

代码很好懂,如果高度既不是 wrap_content 又不是 match_parent,那就只能是精确高度了,这也就表示高度也不需要重新测绘。

(4)最后是第二个可能 return 的条件(为了更加直观调整了缩进):

1
2
3
4
5
6
7
if (
mLayout.getHeight() == oldht
&& (
mHintLayout == null
|| mHintLayout.getHeight() == oldht
)
)

代码依然很好懂,如果新的高度和久的高度一致,也表示高度不需要重新测绘。

综合上述的(1)、(2)、(3)、(4)可以得出结论:如果一个 TextView 的内容被改变了,但是新 TextView 的高度和宽度都不会发生变化,并且也不是跑马灯效果模式,也即 TextView 不需要重新测绘,则不需要调用 requestLayout,也就不会走到 ViewRootImpl 判断线程的地方!

这里需要注意的是:宽度和高度必须同时都是固定值(精确值或 match_parent)才不会发生重绘。上面测试代码中,TextView 的高度为 wrap_content 却没问题的原因,是更新内容时能在一行内显示完全,因此高度没有发生变化,走进了条件(4)中的 return。如果把 TextView 改成宽度为很小的值、高度为自适应,然后子线程中 set 一个很长的文本,使得 TextView 会因为换行导致高度发生变化,则也是会抛出异常的:

1
2
3
4
5
6
7
8
9
10
<!--布局中把 TextView 的宽度设为很小的值,高度为自适应,然后子线程中 set 一个很长的文本使其换行导致高度变化,会抛出异常-->
<TextView
android:id="@+id/tvNewThreadTitle"
android:layout_width="10dp"
android:layout_height="wrap_content"
android:text="初始 TextView"
/>
<Button
......
/>

因此 TextView 的 UI 更新方式可以总结为两种:

  • 如果更新后宽度或高度会发生变化,或者是跑马灯效果模式,则立即逐级向父 View 请求重绘一次,并在绘制时绘制出新的文本。
  • 否则就把把需要更新的文本存在 TextView 内,等下一次屏幕刷新的时候顺便就绘制成新的文本。

实践证明:针对 TextView,通过避免重绘,的确可以实现子线程更新 UI,但仅针对 TextView 或类似有跳过重绘逻辑的 View。

4.4 使用SurfaceView/TextureView

SurfaceView 算是正儿八经使用子线程更新 UI 的例子了,也是其最大的优点。SurfaceView 的画面渲染主要是通过其持有的一个 Surface 类型的 mSurface 对象实现的,这个 Surface 并不是一个 View 的子类,因此其更新并不收到 View 更新中 checkThread() 的限制。简单来说,SurfaceView 可以在子线程中更新 UI 的原理是因为其渲染的目标并不是一个 View。

当然,实际上 SurfaceView / TextureView 的原理远不止这么简单,本文主要聚焦于子线程更新 UI 的可行性,所以不对 SurfaceView / TextureView 的原理深入解析,相关解析也在计划中,感兴趣的读者可以关注后续更新。

4.5 特例Toast

Toast 作为 Android 系统级别的 UI 组件,甚至与 Activity 生命周期都无关,常见的例子就是如果一个 App 正在弹 Toast 的时候出现 Crash 或者手动杀掉了,Toast 还是能正常显示的。

4.5.1 Toast可以跨线程显示

实际上 Toast 的显示除了和 Activity 无关之外,也和线程无关,下面这段代码执行不会抛出异常:

1
2
3
4
5
6
7
8
9
10
@Override
HandlerThread newThread = new HandlerThread("NewThread");
newThread.start();
Handler newThreadHandler = new Handler(newThread.getLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
Toast.makeText(NewThreadActivity.this, "子线程中的Toast", Toast.LENGTH_LONG).show();
}
};
newThreadHandler.sendEmptyMessage(0);

同时抛出一个注意事项:如果需要在子线程中 Toast,则该子线程必须初始化 Looper,因此需要使用 HandlerThread 或者在子线程中手动调用 Looper 的初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 直接在未初始化 Looper 的子线程中 Toast 会抛出异常
// RuntimeException: Can't toast on a thread that has not called Looper.prepare()
new Thread(new Runnable() {
@Override
public void run() {
Toast.makeText(context, "未初始化Looper的子线程Toast会报错", Toast.LENGTH_LONG).show();
}
}).start();

// 可以使用 HandlerThread,或者手动初始化 Looper
new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
Toast.makeText(context, "已初始化Looper的子线程可以正确Toast", Toast.LENGTH_LONG).show();
Looper.loop();
}
}).start();

Toast 本质上也是一种 View,因此是可以通过 toast.setView(View) 来自定义 Toast 样式的,那既然 Toast 是 View,为什么可以在子线程显示呢?老办法,看源码:

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
public class Toast {

public static Toast makeText(Context context, CharSequence text, @Duration int duration) {
return makeText(context, null, text, duration);
}
/**
* Make a standard toast to display using the specified looper.
* If looper is null, Looper.myLooper() is used.
* @hide
*/
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
Toast result = new Toast(context, looper);
......
}

/**
* Constructs an empty Toast object. If looper is null, Looper.myLooper() is used.
* @hide
*/
public Toast(@NonNull Context context, @Nullable Looper looper) {
mContext = context;
mTN = new TN(context.getPackageName(), looper);
......
}

private static class TN extends ITransientNotification.Stub {
......
TN(String packageName, @Nullable Looper looper) {
......
if (looper == null) {
// Use Looper.myLooper() if looper is not specified.
looper = Looper.myLooper();
if (looper == null) {
throw new RuntimeException(
"Can't toast on a thread that has not called Looper.prepare()");
}
}
}
}
}

可以看到:

  • 默认情况下三参数的 Toast.makeText(...) 会调用四参数的重载方法,并且传入的 looper 参数是 null
  • 四参数的方法中,new 了一个 Toast 实例
  • 查看对应的 Toast 构造方法发现,又用传入的 looper 作为构造函数参数 new 了一个 TN 类的实例
  • 再查看 TN 的构造方法发现,如果传入的 looper 为 null,就直接用当前调用线程的 Looper

简言之,Toast.makeText(...) 是直接使用调用的线程作为显示线程的,这就可以直接验证上文说的 Toast 的两个特性:

  • Toast 可以在子线程显示,因为 Toast.makeText(...) 内部在调用时每次都使用当前线程作为显示线程,因此实际上不存在跨线程的问题。
  • Toast 要求线程初始化 Looper 否则在 new TN(...) 的时候就会因为拿不到 looper 抛出异常。

4.5.2 Toast不能跨线程更新

看到这个小标题别慌,Toast 可以在子线程中显示是毫无疑问的,但是有一种情况下,Toast 也会抛出 CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. 异常,就是更新内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private Toast generalToast;

// 在子线程中弹一个 Toast,并把这个 Toast 持久化到成员变量
HandlerThread newThread = new HandlerThread("NewThread");
newThread.start();
Handler newThreadHandler = new Handler(newThread.getLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
generalToast = Toast.makeText(NewThreadActivity.this, "子线程中创建并显示的Toast", Toast.LENGTH_LONG);
generalToast.show();
}
};
newThreadHandler.sendEmptyMessage(0);

// 确保子线程已经弹了 Toast 之后,也就是 generalToast 已经初始化,再在主线程更新 generalToast 的内容
btUpdateInMainThread.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (generalToast != null) {
generalToast.setText("在主线程更新子线程创建的Toast的内容");
generalToast.show();
}
}
});

运行发现报错了:

1
CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

为什么 Toast.makeText(...) 不限制线程,但 toast.setText(...) 又限制线程呢?再仔细看看:

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
public class Toast {
......
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
Toast result = new Toast(context, looper);

LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);

result.mNextView = v;
result.mDuration = duration;

return result;
}
......
public void setText(CharSequence s) {
if (mNextView == null) {
throw new RuntimeException("This Toast was not created with Toast.makeText()");
}
TextView tv = mNextView.findViewById(com.android.internal.R.id.message);
if (tv == null) {
throw new RuntimeException("This Toast was not created with Toast.makeText()");
}
tv.setText(s);
}
}

原来在 Toast.makeText(...) 时,Toast 会使用当前线程作为该 Toast 的消息处理 Looper,然后使用系统的 Inflater 服务去加载一个 com.android.internal.R.layout.transient_notification 的布局作为 Toast 的根布局,其中具有一个 TextView 元素,使用该 TextView 元素承载需要显示的文字。

当调用 toast.setText(...) 时,TextView 就会像文首提到的方式,一层层向上通知更新,因此如果线程与 toast 在初始化时的线程不一致,自然会抛出异常。

4.6 捕获异常

上述的在子线程更新 UI 的方式,都是通过避开已知会抛出异常的情况(SurfaceView 相当于直接不检查)实现的。还有一种更新 UI 的方式最为简单粗暴,就是捕获异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
HandlerThread newThread = new HandlerThread("NewThread");
newThread.start();
Handler newThreadHandler = new Handler(newThread.getLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
try {
tvNewThreadTitle.setText("子线程中更新UI并捕获异常");
ivImage.setImageResource(R.drawable.ic_launcher_foreground);
} catch (Exception ignore){
}
}
};
newThreadHandler.sendEmptyMessage(0);

这段代码当然不会抛出异常,并且 TextView 也确实能更新文本内容,但是 ImageView 却没有任何反应。对比一下 TextView#setText(...)ImageView#setImageResource(...) 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class TextView {
......
private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
......
// 这一步可以理解成,把传进来的 text 存入到 TextView 的成员变量中
setTextInternal(text);
......
if (mLayout != null) {
checkForRelayout();
}
}
}

public class ImageView extends View {
......
public void setImageResource(@DrawableRes int resId) {
......
if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
requestLayout();
}
invalidate();
}
}

通过看源码发现:

  • TextView#setText(...) 是先通过 setTextInternal(...) 把 text 存入到成员变量,然后再调用 checkForRelayout() 检查是否需要重绘,如果需要的话才调用 requestLayout(),检查线程也就发生在 ViewRootImpl#requestLayout() 中。所以即使 ViewRootImpl#requestLayout() 抛出了异常,也不会影响到 setTextInternal(...) 已经把 text 存下来了,那只需要等待下一次屏幕刷新即可把文本刷新上去。
  • ImageView#setImageResource(...) 是先通过 requestLayout() 请求更新,并在 ViewRootImpl#requestLayout() 中检查了线程,只有未抛出异常时,才会走到 invalidate() 并重绘,否则抛出异常则会中断跳出方法。

所以,通过捕获异常的方式,只能针对类似于 TextView 这种,可以在检查线程前先做更新 / 缓存的 View,其他 View 则尽管不会抛出异常,也无法更新 UI,所以捕获异常属于一种“骚操作”,是极为不建议使用的。


5. 总结

概括一下本文内容:

  • Android 中视图的顶点都是 Window,显示视图的根基就是需要有一个可用的 Window
  • Window 持有 DecorView
  • Window 在创建并持有 DecorView 时会初始化 ViewRootImpl 时的当前线程会作为 ViewRootImpl 持有的初始化线程
  • DecorView 加载 subDecor
  • 用 subDecor 承载 Activity 的 layout
  • 更新 View 时,如果需要重绘,会逐级调用父 View 的 requestLayout(),最上层的父 View 就是 ViewRootImpl,在 ViewRootImpl#requestLayout() 中判断了当前线程与初始化线程是否相同,如果不相同则抛出异常
  • 有几种方式是可以在子线程更新 UI 的:
  • 手动触发 ViewRootImpl 初始化:也就是手动创建 Window 并添加 DecorView。
  • 避开 ViewRootImpl 的检查:针对 TextView 这类先缓存再判断的 View 可以通过避开重绘等待下一次屏幕刷新时显示已缓存的内容来刷新 UI。
  • 使用 SurfaceView / TextureView。
  • 显示 Toast:Toast 的创建每次都会使用当前线程初始化,因此显示 Toast 不受跨线程的影响。但不能对其他线程的 Toast 实例对象调用 Toast#setText(...),否则就相当于子线程更新 UI。
  • 捕获异常:针对 TextView 这类先缓存再判断的 View,可以更新 UI。但其他 View 通常会先检查线程再重绘,就会导致检查的那一步抛出异常,虽然捕获了不会 Crash,但也会中断重绘逻辑导致无法刷新。