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 启动加载开始。但在一切分析之前,首先解释一下 ActivityClientRecord 这个东西:

1
2
3
4
5
6
7
8
9
10
public final class ActivityThread {
/** Activity client record, used for bookkeeping for the real {@link Activity} instance. */
public static final class ActivityClientRecord {
......
Activity activity;
Window window;
Activity parent;
......
}
}

源码注释也解释的比较清楚了,ActivityClientRecord 其实是一个容器,它持有了一个 Activity,以及管理这个 Activity 相关的其他资源。可以形象的理解为:「Activity 就像一个主机,Window 就像显示器,同时还有鼠标键盘、电源数据线、维修工具、备用零件、等等,所有这些东西加起来,就能作为完整的计算机提供使用。然后用一个箱子把所有这些都装起来,这个箱子就是 ActivityClientRecord」。ActivityClientRecord 和其持有的 Activity 是一一对应的,系统在调度和管理 Activity 时,大多都是通过 ActivityClientRecord 操作 Activity。

2.1 生命周期onCreate

2.1.1 onCreate流程分析

首先启动一个 Activity 的入口,是通过 ActivityThread.startActivityNow() 开始的,并且创建了一个 ActivityClientRecord,用来持有目标 Activity 以及一些相关的对象,再通过 ActivityThread.performLaunchActivity(...) 创建并启动目标 Activity。这个过程中调用了一个重要方法 Activity.attach()

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
public final class ActivityThread {
public final Activity startActivityNow(Activity parent, String id,
Intent intent, ActivityInfo activityInfo, IBinder token, Bundle state,
Activity.NonConfigurationInstances lastNonConfigurationInstances, IBinder assistToken) {
ActivityClientRecord r = new ActivityClientRecord();
......
return performLaunchActivity(r, null /* customIntent */);
}

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
......
Window window = null;
if (r.mPendingRemoveWindow != null && r.mPreserveWindow) {
// 从 ActivityClientRecord 中取出了一个 Window
window = r.mPendingRemoveWindow;
r.mPendingRemoveWindow = null;
r.mPendingRemoveWindowManager = null;
}
// 这个方法是重点,并且注意参数中传入了上面从 ActivityClientRecord 中取出的 Window
activity.attach(appContext, this, getInstrumentation(), r.token,
r.ident, app, r.intent, r.activityInfo, title, r.parent,
r.embeddedID, r.lastNonConfigurationInstances, config,
r.referrer, r.voiceInteractor, window, r.configCallback,
r.assistToken);
}
}

先绕个圈子,看到最入口处,出现了一个从 ActivityClientRecord 中取 Window 的操作,那 ActivityClientRecord 中的 Window 又是哪来的呢?查找一下 ActivityClientWindow 中 mPendingRemoveWindow 写入的地方,发现在 handleDestroyActivity(...) 中有这么一段:

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
public final class ActivityThread {
@Override
public void handleDestroyActivity(IBinder token, boolean finishing, int configChanges,
boolean getNonConfigInstance, String reason) {
ActivityClientRecord r = performDestroyActivity(token, finishing,
configChanges, getNonConfigInstance, reason);
if (r != null) {
......
View v = r.activity.mDecor;
if (v != null) {
......
if (r.activity.mWindowAdded) {
if (r.mPreserveWindow) {
// Hold off on removing this until the new activity's
// window is being added.
// 核心就是这里:
r.mPendingRemoveWindow = r.window;
r.mPendingRemoveWindowManager = wm;
// We can only keep the part of the view hierarchy that we control,
// everything else must be removed, because it might not be able to
// behave properly when activity is relaunching.
r.window.clearContentView();
} else {
wm.removeViewImmediate(v);
}
}
}
}
}
}

看源码注释就能明白,当一个 Activity 销毁时,会在其 ActivityVlientRecord 中把自己的 Window 保存到 mPendingRemoveWindow 中,然后移除 Window 中所有 View,直到新的 Activity 的 Window 被添加为止,但是在通过 startActivityNow(...) 启动 Activity 时,ActivityClientRecord 都是 new 出来的,调用到 performLaunchActivity(...) 时,r.mPendingRemoveWindow 肯定是 null,那什么场景会用到这个 mPendingRemoveWindow 呢?查找 performLaunchActivity(...) 的调用发现:

  • handleRelaunchActivityInner(...) 中,先调用了 handleDestroyActivity(...),这一步就把当前 Activity 的 Window 存进了 mPendingRemoveWindow
  • 接下来调用了 handleLaunchActivity(...)
  • 然后在其内部调用了 performLaunchActivity(...)

这下明白了,当一个 Activity 被系统回收等等场景销毁时,有可能接下来又会回到这个 Activity,这种情况下就可以先把 Window 缓存下来,当自己这个 Activity 再次被激活时,就可以直接复用 Activity 上一次的 Window 而不需要从新分配。当然,由于 r.window.clearContentView();,Window 中的 View 被清空了,所以还是需要重新走一遍视图加载。这样做的好处也许是在效率和内存消耗中的一种折中,或许是因为 View 会占用很大内存,所以需要回收 View,但 Window 本身占用较少,每次都回收再让系统重新分配可能比缓存占用的内存更多、或者可能带来更大的内存抖动风险,所以选择临时缓存。

从上面的分析知道,而当一个 Activity 被新启动时,performLaunchActivity(...) 传入的 ActivityClientRecord.mPendingRemoveWindow 肯定是 null,跳转到 Activity.attach(...) 中查看:

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
public class Activity {
/**
* 当新启动一个 Activity 时,调用这个 attach 方法的入参中,window 是 null,
* 因为是 startActivityNow(...) 中 new 出来的 ActivityClientRecord。
* 如果是 performRelaunchActivity 则会把这个 Activity 上一次销毁前的 Window 缓存下来并在这个方法中传入。
*/
final void attach(...) {
......
// 创建一个 Window 实例,这里 mWindow 是 Window 类型的接口对象,该接口只有 PhoneWindow 唯一一个实例对象
// 并且当新启动一个 Activity 时,这里传入的 window 也是 null
mWindow = new PhoneWindow(this, window, activityConfigCallback);
......
mUiThread = Thread.currentThread();
......
// 调用系统服务创建一个 WindowManager,并存进 mWindow 中
mWindow.setWindowManager(
(WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
mToken, mComponent.flattenToString(),
(info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
if (mParent != null) {
mWindow.setContainer(mParent.getWindow());
}
// 同时也把上面创建的 WindowManager 存在 Activity 内
mWindowManager = mWindow.getWindowManager();
}
}

attach(..)new 了一个 PhoneWindow 对象,再看看 PhoneWindow 的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PhoneWindow extends Window implements MenuBuilder.Callback {
/**
* Constructor for main window of an activity.
*/
public PhoneWindow(Context context, Window preservedWindow,
ActivityConfigCallback activityConfigCallback) {
......
// 注意这里的判断条件,是需要 preservedWindow != null 才去初始化 DecorView。
// 如果是新启动一个 Activity,传进来的 preservedWindow == null,是不会走这个逻辑的。
if (preservedWindow != null) {
mDecor = (DecorView) preservedWindow.getDecorView();
mElevation = preservedWindow.getElevation();
mLoadElevation = false;
mForceDecorInstall = true;
// If we're preserving window, carry over the app token from the preserved
// window, as we'll be skipping the addView in handleResumeActivity(), and
// the token will not be updated as for a new window.
getAttributes().token = preservedWindow.getAttributes().token;
}
......
}

看来如果是新启动一个 Activity,attach(...) 传入的 window 对象其实没有什么用。Activity.attach() 完成后会由 Instrumentation 回调 Activity.onCreate(),表示 Activity 走到了 onCreate() 的生命周期。

2.1.2 onCreate流程总结

Activity 在 onCreate 时的流程可以这么总结:

  • 通过 startActivityNow(...) 启动一个 Activity
  • startActivityNow(...) 内部调用 performLaunchActivity(...)
  • performLaunchActivity(...) 判断传入的 ActivityClientRecord.mPendingRemoveWindow 是否为空
  • 如果是通过 startActivityNow(...) 触发的 performLaunchActivity(...),则 ActivityClientRecord.mPendingRemoveWindow == null;否则如果一个 Activity 是被 handleRelaunchActivityInner(...) 的,就会先调用 handleDestroyActivity(...),这一步将当前 Activity 的 Window 混存在 mPendingRemoveWindow 后再销毁,然后调用 performLaunchActivity(...),这样重新启动的同一个 Activity 就能拿到上一次销毁时保存的 Window 了。
  • performLaunchActivity(...) 中从 ActivityClientRecord 取出对应的 Activity,并调用 Activity.attach(...),传入了从 ActivityClientRecord 中取出的 mPendingRemoveWindow(可能为空)
  • Activity.attach(...) 中直接 new 了一个新的 PhoneWindow,构造方法中传入了 ActivityClientRecord.mPendingRemoveWindow,PhoneWindow 判断 mPendingRemoveWindow 是否为空,为空说明是新启动的 Activity,否则说明是 relaunch 的 Activity
  • 如果传入的 mPendingRemoveWindow 不为空,则从 mPendingRemoveWindow 中取出 DecorView,并作为当前 Activity 自己的 DecorView,否则不处理。
  • 创建完新的 PhoneWindow 后,再通过系统服务创建一个 WindowManager
  • 将这个新建的 WindowManager 绑定到 Activity 新建的 PhoneWindow 中,以及在 Activity 本身中保存。
  • 此时 Activity 和对应的 ActivityClientRecord 共用同一个 WindowManager,但注意,Activity 在 attach(...)new 出来的 Window,暂时还没有存入到对应的 ActivityClientRecord 中。

也就是说,走完 onCreate() 的流程后,一个 Activity 就创建好了自己的 Window 并绑定了 WindowManager。并且如果这个 Activity 是 relaunch 的,还会直接复用上一次销毁时缓存的 Window 和 DecorView;但如果是新启动的 Activity,则到当前为止,Activity 已经创建好了 Window,但还不具有 DecorView。

2.2 生命周期onResume

2.2.1 onResume流程分析

接下来是 onResume() 的生命周期,这是通过 ActivityThread.handleResumeActivity() 调用的:

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
91
92
93
94
public final class ActivityThread {
public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward, String reason) {
......
// 通过 token 从所有 ActivityClientRecord 中取出目标
// 如果是新启动的 Activity,可以理解为这里取出来的 ActivityClientRecord 就是上面 startActivityNow(...) 中 new 出来的 ActivityClientRecord
final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);
......
final Activity a = r.activity;
if (r.window == null && !a.mFinished && willBeVisible) {
// 因为 onCreate 的时候 Activity 在 attach(...) 中 new 了一个 PhoneWindow,但还没有存入 ActivityClientRecord
// 这一步就是存入了 ActivityClientRecord,此时 Activity 和对应的 ActivityClientRecord 都持有了同一个 Window 和 WindowManager 对象
r.window = r.activity.getWindow();
// 获取目标 Activity 的 Window 的 DecorView
// Window#getDecorView() 内部包含两个逻辑,如果持有的 DecorView 已存在(例如 relaunch 的场景)则直接返回,否则(例如 startActivityNow 的场景)就 new 一个再返回
// 详见下面的源码
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
// 取出之前 Activity.attach() 中绑定的 WindowManager
// WindowManager 接口的实例对象是 WindowManagerImpl 对象
ViewManager wm = a.getWindowManager();
// 创建的 DecorView 的窗口属性
WindowManager.LayoutParams l = r.window.getAttributes();
// 将创建的的 DecorView 作为目标 Activity 的 DecorView,此时还未创建 ViewRootImpl
a.mDecor = decor;
......
// 判断 Activity 的 Window 是不是从上一次销毁时缓存下来的,如果是则通知 DecorView 的所有子 View 即将重建
if (r.mPreserveWindow) {
......
// 获取 DecorView 的 ViewRootImpl,如果没有则返回 null

// Normally the ViewRoot sets up callbacks with the Activity
// in addView->ViewRootImpl#setView. If we are instead reusing
// the decor view we have to notify the view root that the
// callbacks may have changed.
ViewRootImpl impl = decor.getViewRootImpl();
// 当 Activity 是 relaunch 的时候,Window 和其持有的 DecorView 是从上一次缓存下来的,则 DecorView#getViewRootImpl 才不为空
if (impl != null) {
// 通知所有的子 View:「准备重新创建视图」
impl.notifyChildRebuilt();
}
......
}
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
a.mWindowAdded = true;
// 注意这个动作
wm.addView(decor, l);
} else {
......
}
}
} else if (!willBeVisible) {
......
}
// 清除掉 Activity 缓存的 Window 等,并重置了 r.mPreserveWindow 标志位
// Get rid of anything left hanging around.
cleanUpPendingRemoveWindows(r, false /* force */);
......
}
}

// 对应上面 View decor = r.window.getDecorView(); 的源码
public class PhoneWindow extends Window {
......
ViewGroup mContentParent;
......

/**
* Retrieve the top-level window decor view (containing the standard
* window frame/decorations and the client's content inside of that), which
* can be added as a window to the window manager.
*
* <p><em>Note that calling this function for the first time "locks in"
* various window characteristics as described in
* {@link #setContentView(View, android.view.ViewGroup.LayoutParams)}.</em></p>
*
* @return Returns the top-level window decor view.
*/
public final @NonNull View getDecorView() {
if (mDecor == null || mForceDecorInstall) {
// 内部逻辑其实就是 new 出来一个 DecorView,并与当前 Window 绑定
installDecor();
}
return mDecor;
}

private void installDecor() {
......
if (mContentParent == null) {
// mContentParent 其实就是 DecorView 加载出来之后的布局容器 ViewGroup
mContentParent = generateLayout(mDecor);
}
......
}

注意两个重点:

  • 如果handleResumeActivity(...) 是因为 startActivityNow 触发的,则ActivityClientRecord 中的 Window 是在 Activity.attach(...)new 出来的,所以并没有缓存下来的 DecorView,因此 View decor = r.window.getDecorView(); 这一步会走到 new 一个 DecorView 的逻辑,所以此时这个 DecorView 也还没有 ViewRootImpl,或者说 Activity 还没有 ViewRootImpl。
  • 而如果 handleResumeActivity(...) 是由于 relaunch 触发的,则会在 relaunch 时将 r.mPreserveWindow 置为 true,表示 Window 和 DecorView 会缓存下来复用,则 Activity 其实已经拥有了 Window、DecorView、以及 ViewRootImpl 了。

接下来再通过 wm.addView(decor, l) 把新建的 DecorView 按照其窗口属性 WindowManager.LayoutParams 添加到目标 Activity 的 WindowManager 中,这个 WindwoManager 的实现类是 WindowManagerImpl,找到 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
28
29
30
31
32
33
34
35
36
37
38
39
public final class WindowManagerImpl implements WindowManager {
......
private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();
......

// handleResumeActivity(...) 中,调用该方法传入的 View 实际上就是 DecorView
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
// 这个 mGlobal 是 WindowManagerGlobal 实例对象
mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}
}

// 对应 mGlobal.addView(...) 的源码
public final class WindowManagerGlobal {
// 这里传入的 View 实际上是 DecorView
public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) {
......
ViewRootImpl root;
......
synchronized (mLock) {
......
// 目标 Activity 的 ViewRootImpl 就是在这里初始化的!
root = new ViewRootImpl(view.getContext(), display);
......
// mRoots 是一个 ArrayList<ViewRootImpl> 类型的集合
mRoots.add(root);
mParams.add(wparams);

// do this last because it fires off messages to start doing things
try {
// 加载 DecorView
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
。。。。。。
}
}
}
}

终于找到了 ViewRootImpl 初始化的地方,其构造方法内绑定了初始化时的线程:

1
2
3
4
5
6
7
public final class ViewRootImpl implements ViewParent {
public ViewRootImpl(Context context, Display display) {
......
// 绑定了初始化时的线程
mThread = Thread.currentThread();
......
}

然后 Instrumentation 会回调 Activity.onResume(),表示 Activity 进入到了 onResume() 的生命周期。到了这一步,Activity 初始化了 PhoneWindow 以及 DecorView,并且在 ViewRootImpl 中加载了 DecorView,这也表示 Activity 已经从视觉上可见。

2.2.2 onResume流程总结

Activity 在 onResume 时的流程可以这么总结:

  • 通过 ActivityThread 创建一个 Activity,以及对应的 ActivityClientRecord
  • 获取 DecorView,分为两种逻辑:由于 startActivityNow 触发的 handleResumeActivity、以及由于 relaunch 触发的 handleResumeActivity
  • 如果是由于 startActivityNow 触发的,则 DecorView 尚未创建,立即创建一个 DecorView,此时 DecorView 或者说 Activity 尚未初始化 ViewRootImpl
  • 如果是由于 relaunch 触发的,则 Window、DecorView 均是从上一次销毁时缓存中复用的,此时 DecorView 已经初始化过了 ViewRootImpl,也即 Activity 已经拥有 ViewRootImpl 了,只需要通知所有子 View 即将重建。
  • 如果 Activity 还未初始化 ViewRootImpl(对应 startActivityNow 的场景),则调用 WindowManager#addView(...),其内部初始化了 ViewRootImpl,将初始化 ViewRootImpl 时的所在线程,作为 ViewRootImpl 的初始线程
  • ViewRootImpl 持有 DecorView

也就是说,到了 onResume 这一步,Activity 已经创建了 DecorView 和 ViewRootImpl,并且对 ViewRootImpl 的原始线程做了初始化。此时 Activity 已经完整具有了 Window、DecorView、ViewRootImpl。

2.3 加载布局setContentView

2.3.1 setContentView流程分析

接下来就是 Activity 的另一项重要功能,加载布局文件。通常加载布局文件都是通过 setContentView(...) 实现的,这里其实分为两个版本:

(1)早期的 Activity 直接继承自 Activity 类:

1
2
3
4
5
6
public class Activity {
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
}

也就是通过之前 Activity.attach(...) 时创建的 PhoneWindow 去直接加载布局。

(2)新版本的 Activity 继承自 AppCompatActivity 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AppCompatActivity {
public void setContentView(@LayoutRes int layoutResID) {
// getDelegate() 返回一个 AppCompatDelegate 接口类型的实例对象
getDelegate().setContentView(layoutResID);
}

public AppCompatDelegate getDelegate() {
if (mDelegate == null) {
// 实际上内部就是 new 了一个 AppCompatDelegateImpl
mDelegate = AppCompatDelegate.create(this, this);
}
return mDelegate;
}
}

再看看 AppCompatDelegateImpl.setContentView()

1
2
3
4
5
6
7
8
9
10
class AppCompatDelegateImpl extends AppCompatDelegate {
public void setContentView(int resId) {
// 注意这个方法,创建了一个叫 subDecor 的东西
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}
}

注意这个 ensureSubDecor() 方法,从命名就能看出,这个方法确保了 subDecor 一定能被创建:

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
91
92
93
94
95
96
97
98
99
100
101
102
103
class AppCompatDelegateImpl extends AppCompatDelegate {
private void ensureSubDecor() {
if (!mSubDecorInstalled) {
// 真正的创建是这个方法
mSubDecor = createSubDecor();

// 可以看到在 setContentView(...) 内部加载了标题和 ActionBar
// 这也是为什么在 Activity 中,对 ActionBar 或 Title 的修改必须放在 setContentView(...) 调用之前
// If a title was set before we installed the decor, propagate it now
CharSequence title = getTitle();
if (!TextUtils.isEmpty(title)) {
if (mDecorContentParent != null) {
mDecorContentParent.setWindowTitle(title);
} else if (peekSupportActionBar() != null) {
peekSupportActionBar().setWindowTitle(title);
} else if (mTitleView != null) {
mTitleView.setText(title);
}
}
......
mSubDecorInstalled = true;
}
}

......

// 接下来看看 createSubDecor() 的源码:
private ViewGroup createSubDecor() {
TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);

// 这下面的一堆 if - else 都只是为了确定这个 Activity 对应 Window 的样式
if (!a.hasValue(R.styleable.AppCompatTheme_windowActionBar)) {
a.recycle();
throw new IllegalStateException(
"You need to use a Theme.AppCompat theme (or descendant) with this activity.");
}

if (a.getBoolean(R.styleable.AppCompatTheme_windowNoTitle, false)) {
requestWindowFeature(Window.FEATURE_NO_TITLE);
} else if (a.getBoolean(R.styleable.AppCompatTheme_windowActionBar, false)) {
// Don't allow an action bar if there is no title.
requestWindowFeature(FEATURE_SUPPORT_ACTION_BAR);
}
if (a.getBoolean(R.styleable.AppCompatTheme_windowActionBarOverlay, false)) {
requestWindowFeature(FEATURE_SUPPORT_ACTION_BAR_OVERLAY);
}
if (a.getBoolean(R.styleable.AppCompatTheme_windowActionModeOverlay, false)) {
requestWindowFeature(FEATURE_ACTION_MODE_OVERLAY);
}
mIsFloating = a.getBoolean(R.styleable.AppCompatTheme_android_windowIsFloating, false);
a.recycle();

// 先确保了 Window 已经初始化
// Now let's make sure that the Window has installed its decor by retrieving it
ensureWindow();
// 从 Activity 存储的 PhoneWindow 中获取 DecorView,但并不是需要拿到 DecorView 实例,
// 而是为了像上面 handleResumeActivity(...) 中的 r.activity.getWindow() 一样,确保 DecorView 已被创建
mWindow.getDecorView();
......
// 这个就是目标 subDecor
ViewGroup subDecor = null;

if (!mWindowNoTitle) {
// 如果 Activity 含有 WindowTitle:
if (mIsFloating) {
// 加载悬浮窗样式的 subDecor
subDecor = (ViewGroup) inflater.inflate(...);
} else if (mHasActionBar) {
......
// 加载有 ActionBar 样式的 subDecor
subDecor = (ViewGroup) LayoutInflater.from(themedContext).inflate(...);

// 这个 mDecorContentParent 是 ActionBarOverlayLayout 类型的,用于管理 ActionBar
mDecorContentParent = (DecorContentParent) subDecor.findViewById(R.id.decor_content_parent);
mDecorContentParent.setWindowCallback(getWindowCallback());

/**
* Propagate features to DecorContentParent
* 给 DecorContentParent 应用特性
*/
......
}
} else {
// 这里同样是加载某个样式的 subDecor,省略
......
}

if (subDecor == null) {
// 如果仍然无法加载出 subDecor,就抛出异常
throw new IllegalArgumentException("AppCompat does not support the current theme features: { " + ...... + " }");
}

if (Build.VERSION.SDK_INT >= 21) {
// 针对 API >= 21 做适配
} else if (subDecor instanceof FitWindowsViewGroup) {
// 针对 FitWindowsViewGroup 做适配
}
// 将 subDecor 加载到 PhoneWindow 中
mWindow.setContentView(subDecor);
......
return subDecor;
}
}

再回头看:

1
2
3
4
5
6
7
8
9
10
11
class AppCompatDelegateImpl extends AppCompatDelegate {
public void setContentView(int resId) {
// 注意这个方法,创建了一个叫 subDecor 的东西
ensureSubDecor();
// 这个临时变量 contentParent 是 subDecor 的一个子元素
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}
}

这个 contentParent,它是从 subDecor 加载后的布局中,通过 findViewById(...) 的方式获取到的一个 ViewGroup,也就是说,实际上在 Activity 中调用的 setContentView(int resId) 传入的布局文件,是加载在 subDecor 的一个子 View 中。

createSubDecor() 中,先确保 Window 和 DecorView 已创建,然后根据 Title 和 ActionBar 的样式不同,通过 inflate 的方式加载对应的模板 subDecor。

@注:如果 Activity 含有 WindowTitle,且具有 ActionBar,还会从 subDecor 的模板布局中通过 findViewById(int) 的方式获取一个叫 mDecorContentParent 的 ActionBarOverlayLayout 类型的 View,从类名也能看出这个 View 就是专门负责 ActionBar 的。

当 subDecor 被成功创建后,通过 mWindow.setContentView(subDecor); 把 subDecor 加载到 Activity 的 Window 中:

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
public class PhoneWindow extends Window {
// 传入的参数 View 就是 subDecor
public void setContentView(View view) {
// 这里也可以看出,默认情况下是全屏的
setContentView(view, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
}
// 传入的参数 View 就是 subDecor
public void setContentView(View view, ViewGroup.LayoutParams params) {
// Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
// decor, when theme attributes and the like are crystalized. Do not check the feature
// before this happens.
if (mContentParent == null) {
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}

if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
view.setLayoutParams(params);
final Scene newScene = new Scene(mContentParent, view);
transitionTo(newScene);
} else {
// 传入的参数 View 就是 subDecor
mContentParent.addView(view, params);
}
mContentParent.requestApplyInsets();
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
mContentParentExplicitlySet = true;
}

private void installDecor() {
......
if (mContentParent == null) {
// mContentParent 其实就是 DecorView 加载出来之后的布局容器 ViewGroup
mContentParent = generateLayout(mDecor);
}
......
}
}

又看到上文中在初始化 DecorView 时的 PhoneWindow#installDecor() 了,通过 mContentParent.addView(view, params); 这行可以看出实际上是把 subDecor 加载到了 mContentParent 中,而这个 mContentParent 就是 DecorView 加载出来的布局容器实例 ViewGroup,所以本质就是把 subDecor 加载到了 DecorView 中!

注意两个细节:

  • PhoneWindow#getDecorView() - installDecor() 中,DecorView 被加载到了 PhoneWindow#mContentParent 这个 ViewGroup 中
  • 然后在 AppCompatDelegateImpl#setContentView(int resId) 中,当 subDecor 被按照模板创建后,使用其中一个叫 contentParent 的子元素 inflate 了传入的布局文件

2.3.2 setContentView流程总结

至此,可以对 Activty 中调用 setContentView(int resId) 后的流程做一个总结:

  • 如果是老版本直接继承自 Activity,则调用 setContentView(int resId) 后会直接把布局 inflate 到 Activity 对应的 Window 中。
  • 如果是新版本继承自 AppCompatActivity,则先确保 subDecor 已创建。
  • 在创建 subDecor 前,先确保 Window 和 DecorView 已创建。
  • 首先判断 PhoneWindow 是否创建,如果未创建则先创建 Window。
  • 然后通过 PhoneWindow#getDecorView() - installDecor() 确保了 DecorView 已被创建、以及根据 DecorView 生成了 PhoneWindow#mContentParent 这个实例 ViewGroup,也即:PhoneWindow 加载了 DecorView 并持有了 DecorView 加载后的 ViewGroup。
  • 创建 subDecor 时,根据 Activity 及其 Window 的属性加载预置的 subDecor 模板,如果 Activity 含有 WindowTitle,且具有 ActionBar,还会从 subDecor 的模板布局中通过 findViewById(int) 的方式获取一个叫 mDecorContentParent 的 ActionBarOverlayLayout 类型的 View,专门用于管理 ActionBar 相关的视图。
  • 根据模板创建了 subDecor 后,通过 Window#setContentView(View) 将 subDecor 传入,并把传入的 subDecor 作为子 View 添加到 PhoneWindow#mContentParent 中,本质上就是把 subDecor 添加到 DecorView 中。
  • subDecor 创建好后,从 subDecor 中通过 findViewById(int) 获取一个 ID 为 android.R.id.content 的布局 contentParent,接着 contentParent 先 remove 掉所有子 View,再 inflate 传入的布局文件。这一步才真正加载 Activity 布局。

也就是说,在 setContentView 的时候,Activity 实际上是在确保 Window 和 DecorView 均已创建的基础上,把 subDecor 根据模板创建出来并 add 进 mContentParent 这个 ViewGroup 中(mContentParent 是从 DecorView 加载出来的),然后从 subDecor 的模板布局中获取一个子元素 contentParent,最后把实际的 Activity 布局 inflatecontentParent

虽然顺序上是先把 subDecor 添加到 DecorView 中,再将 Activity 布局 inflate 进 subDecor 的子元素 contentParent 中,但因为 JVM 对象引用的缘故,所以实际上的关系链依然是:

Window 持有 DecorView - DecorView 持有 subDecor - subDecor 加载 Activity 的布局。


3. ViewRootImpl线程的定义

  • 通过上文 2.2.2 的总结,可以知道 ViewRootImpl 判断线程时依据的 mThread 就是创建并初始化 ViewRootImpl 时的所在线程。
  • 通过上文 2.3.2 的总结,可以知道 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,但也会中断重绘逻辑导致无法刷新。

以上就是本篇关于「子线程到底能不能更新 UI」的全部内容了,相信看完后应当可以对 Android 到底能不能在子线程中更新 UI 有了全面了解。如果觉得写得不错的,欢迎留个言鼓励一下~