线程与更新UI,消除偏见,细谈原理
前言
相信不少读者都阅读过相类似的文章了,但是我还是想完整的把这之间的关系梳理清楚,细节聊好,希望你也能从中学到一些。
进入正题,大家应该都听过这样一句话——“UI更新要在主线程,子线程更新UI会崩溃”。久而久之就感觉这是个真理,甚至被认为是“官方结论”。
但是如果问你,官方什么时候在哪里说过这句话,你会不会有点懵。而且就算是官方说的,也就不一定对的是吧,众所周知,Google
官方文档一直都有点说的不清不楚,需要我们进行大量实践得出实际的结论。
就好比之前的Android11
更新文档,我也是看了好久,通过一个个实践才写出了适配指南,然后就发现其中一个比较明显的BUG
,Google
官方有说过这样一句:
下面是首先需要关注的行为变更 (无论您应用的 targetSdkVersion 是多少):
外部存储访问权限 - 应用无法再访问外部存储空间中其他应用的文件。
其实经过实践会发现,外部存储访问权限还是会和targetSdkVersion
有关,具体可以看这篇Android11适配指南。
废话有点多了,今天还是通过实践案例,看看这个关于线程和UI更新的 “官方结论” 正确吗?
案例一,子线程更新button文字
1)onCreate
方法中更新了按钮显示文字,修改Button
的宽度为固定或者wrap_content
,都不崩溃。
<Button
android:id="@+id/btn_ui"
android:layout_width="100dp"
android:layout_height="70dp"
android:layout_centerInParent="true"
android:text="我是一个按钮"
/>
//或者
<Button
android:id="@+id/btn_ui"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:layout_centerInParent="true"
android:text="我是一个按钮"
/>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ui)
thread {
btn_ui.text="我去年买了个表"
}
}
2)onCreate
方法中更新了按钮显示文字,加了延时。
Button
的宽度为固定不崩溃。
Button
的宽度为wrap_content,崩溃报错——Only the original thread that created a view hierarchy can touch its views
。
<Button
android:id="@+id/btn_ui"
android:layout_width="100dp"
android:layout_height="70dp"
android:layout_centerInParent="true"
android:text="我是一个按钮"
/>
//或者
<Button
android:id="@+id/btn_ui"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:layout_centerInParent="true"
android:text="我是一个按钮"
/>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ui)
thread {
Thread.sleep(3000)
btn_ui.text="我去年买了个表"
}
}
案例一分析
有点懵的感觉,不慌,来看看崩溃信息。
崩溃是在按钮宽度为wrap_content
,也就是根据内容设定宽度,然后3秒之后去更新按钮文字,发生了崩溃。相比之下,有两个崩溃影响点
需要注意下:
宽度wrap_content
。如果设置为固定值,是不会崩溃的,见案例2,所以是不是跟布局改变的逻辑有关呢?延时3秒
。如果不延时的话,即使是wrap_content也不会崩溃,见案例1,所以是不是跟某些类的加载进度有关呢?
带着这些疑问去源码中找找答案。先看看崩溃日志:
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:9219)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1600)
at android.view.View.requestLayout(View.java:24884)
可以看到是ViewRootImpl
的requestLayout
中检查线程的时候报错了,那我们就看看这个方法:
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
在解开谜底之前,我们先了解下ViewRootImpl
。
ViewRootImpl
Activity从创建到我们看到界面,其实是经历了两个过程:加载布局和绘制
。
- 加载布局
加载布局其实就是我们常用的setContentView(int layoutResID)
方法,这个方法主要做的就是新建了一个DecorView
,然后根据activity
设置的主题(theme)
或者特征(Feature)
加载不同的根布局文件,最后再加载layoutResID
资源文件。为了方便大家理解,画了一张图:
这里的最后一步是调用了LayoutInflater
的inflate()
方法,这个方法只做了一件事,就是解析xml
文件,然后根据节点生成了view
对象。最后形成了一个完整的DOM
结构,返回最顶层的根布局View。(DOM
是一种文档对象模型,他的层次结构是除了顶级元素,所有元素都被包括到另外的元素节点中,有点像家谱树结构,很典型的就是html
代码解析)
到这里,一个有完整view结构的DecorView
就创建出来了,但是它还没有被绘制,也没有被显示到手机界面上。
- 绘制
绘制的流程发生在handleResumeActivity
中,熟悉app启动流程的朋友应该知道,handleResumeActivity
方法是用来触发onResume
方法的,这里也完成了DecorView的绘制。再来一张图:
- 总结
由此我们可以得出一些结论:
1)setContentView
用来新建DecorView
并加载布局的资源文件。
2)onResume
方法之后,会新建一个ViewRootImpl
,作为DecorView
的parent
对DecorView
进行测量,布局和绘制等操作。
3)PhoneWindow
作为Window
的唯一子类,存储了DecorView
变量,并对其进行管理,属于Activity
和View
交互的中间层。
分析崩溃
好了。再回来看看崩溃的原因:
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
可以看到是因为当前线程currentThread
不是mThread
的时候,就会崩溃,报的错误是 “只有创建视图层次结构的原始线程才能触摸它的视图” ,看到这里是不是猜到一些了,这个mThread
难道就是“创建视图的原始线程”?
通过查找,其实这个mThread
是在ViewRootImpl
被创建的时候赋值的:
public ViewRootImpl(Context context, Display display) {
mThread = Thread.currentThread();
}
而通过上方分析Activity
加载布局过程得知,ViewRootImpl
实例化发生在onResume
之后,用来绘制DecorView
到window
上。
所以我们就可以得知崩溃的真正原因,就是当前线程不是ViewRootImpl
创建时候的线程就会崩溃。翻译的还是比较准确的,只有创建视图的原始线程才能修改这个视图,听起来也蛮有道理的,我创造了你才有权利改变你,有那味了。
然后再看看前面的案例:
案例一,在
onCreate
中修改Button,这时候只是在修改DecorView,都没创建ViewRootImpl
,也就没走到所以checkThread
方法,当然不会崩溃了。ViewRootImpl
的创建是在onResume之后。案例二,延时3秒之后,界面也绘制完成了,创建
ViewRootImpl
显然是在主线程完成的,所以mThread
为主线程,而改变Button
的线程为子线程,所以setText方法会触发requestLayout
方法重新绘制,最终导致崩溃。
但是,Button
的宽度设置为固定值咋又不崩溃了?难道就不会执行checkThread
方法了?奇怪。
找找setText
的源码可以发现,有一个方法是负责检查是否需要新的布局——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)) {
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;
}
//...
}
// 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 {
// Dynamic width, so we have no choice but to request a new
// view layout with a new text layout.
nullLayouts();
requestLayout();
invalidate();
}
}
可以看到,如果布局大小没有改变的话,我们是不会去执行requestLayout
方法重新进行布局绘制的,只会调用autoSizeText
方法计算文字大小,invalidate
绘制文字本身,所以当我们宽高设置为固定值,setText()
方法就不会执行到requestLayout()
方法了,自然也就执行不到checkThread()
方法了。
反思
解决了问题,还需要反思下,为什么需要checkThread
检查线程呢?
- 检查线程,其实就是检查更新UI操作的当前线程是不是当初创建UI的那个线程,这样就保证了
线程安全
,因为UI控件本身不是线程安全的,但是加锁又显得太重,会降低View加载效率,毕竟是跟交互相关的。所以就直接通过判断线程这一逻辑来形成一个单线程模型
,保证View操作的线程安全。
案例二,子线程和主线程分别showToast
1)onCreate方法中弹出toast,崩溃——Can't toast on a thread that has not called Looper.prepare()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ui)
thread {
showToast("我去年买了个表")
}
}
2)onCreate
方法中弹出toast,增加Looper.prepare(),Looper.loop()
方法。不崩溃。
加上延时3秒,不崩溃。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ui)
thread {
//Thread.sleep(3000)
Looper.prepare()
showToast("我去年买了个表")
Looper.loop()
}
}
3)使用同一个Toast
实例,在子线程中的Toast
没消失之前点击按钮,在主线程中修改Toast
文字并显示,则程序崩溃——Only the original thread that created a view hierarchy can touch its views.。
(主线程更新UI也会崩溃!你没有看错!)
重新运行,在子线程中显示并消失后,点击按钮,不崩溃。
换个手机——三星s9
,重新运行,在子线程中的Toast
没消失之前点击按钮,不崩溃。
lateinit var mToast: Toast
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ui)
thread {
Looper.prepare()
mToast=Toast.makeText(this@UIMainActivity,"我去年买了个表",Toast.LENGTH_LONG)
mToast.show()
Looper.loop()
}
btn_ui.setOnClickListener {
mToast.setText("我今年买了个表")
mToast.show()
}
}
案例二分析
在解开谜底之前,我们先了解下Toast
。
Toast原理
Toast.makeText(this,msg,Toast.LENGTH_SHORT).show()
简单又常用的一句代码,还是通过流程图的方式看看它是怎么创建并展示的。
和DecorView
加载绘制流程如出一辙,首先加载了布局文件,创建了View
。然后通过addView
方法,再次新建一个ViewRootImpl
实例,作为parent
,进行测量布局和绘制。
崩溃分析
1)首先,说下第一次崩溃——Can't toast on a thread that has not called Looper.prepare()
,也就是在创建Toast
的线程必须要有Looper
在运行。
根据源码我们也得知Toast
的显示和隐藏都是通过Handler
传递消息的,所以必须要有Handler
使用环境,也就是绑定Looper
对象,并且通过loop
方法开始循环处理消息。
2)第二次崩溃——Only the original thread that created a view hierarchy can touch its views
。
这里的崩溃和之前更新Button
一样的报错,所以我们有理由怀疑也是一样的原因,在不同的线程调用了ViewRootImpl
的requestLayout
方法。
我们看到点击按钮的时候,调用了mToast.setText()
方法,咦,这不就跟案例一一模一样
了吗。
setText
方法中调用了TextView
的setText()
方法,然后由于Toast中的TextView宽高都是wrap_content
的,所以会触发requestLayout
方法,最后会调用到最上层View也就是ViewRootImpl
的requestLayout
方法。
所以崩溃的原因就是因为Toast
在第一次在子线程中show的时候,新建了一个ViewRootImpl
实例,绑定了当前线程也就是子线程到mThread
变量。
然后同一个Toast
,在主线程调用setText方法,最终会调用到ViewRootImpl的requestLayout
方法,引起线程检查,当前线程也就是主线程并不是当初那个创建ViewRootImpl
实例的线程,所以导致崩溃。
3)那为什么等Toast消失之后,点击按钮又不崩溃了呢?
原因就在Toast的hide
方法中,最终会调用到View的assignParent
方法,将Toast的mParent
设置为null,也就是ViewRootImpl
设置为null了。所以调用setText方法的时候也就执行不到requestLayout
方法了,也就不会到checkThread
方法检查线程了。贴下代码:
public void handleHide() {
if (mView != null) {
if (mView.getParent() != null) {
mWM.removeViewImmediate(mView);
}
mView = null;
}
}
removeViewImmediate--->removeViewLocked
private void removeViewLocked(int index, boolean immediate) {
ViewRootImpl root = mRoots.get(index);
View view = root.getView();
//...
if (view != null) {
view.assignParent(null);
if (deferred) {
mDyingViews.add(view);
}
}
}
void assignParent(ViewParent parent) {
if (mParent == null) {
mParent = parent;
} else if (parent == null) {
mParent = null;
} else {
throw new RuntimeException("view " + this + " being added, but"+ " it already has a parent");
}
}
4)但是但是,为啥换个手机又不崩溃了呢?
这是我偶然发现的,在我的三星S9
手机上,运行时不会崩溃的,而且界面给我的反馈并不是修改当前页面上Toast
上的文字,而是像新建了一个Toast
展示,即时代码中写的是setText
方法。
所以我猜测在部分手机上,应该是改变了Toast
的设置,当调用setText
方法的时候,就会马上结束当前的Toast
展示,调用hide
方法。然后再进行Toast
文字修改并展示,也就是刚才第三点的做法。
当然这只是我的猜测,有研究过手机源码的大神也可以补充下。
总结
任何线程都可以更新UI,也都有更新UI导致崩溃的可能。
其中的关键就是view被绘制到界面时候的线程(也就是最顶层ViewRootImpl
被创建时候的线程)和进行UI更新时候的线程是不是同一个线程,如果不是就会报错。
参考
https://www.jianshu.com/p/1cdd5d1b9f3d
https://www.cnblogs.com/fangg/p/12917235.html
拜拜
有一起学习的小伙伴可以关注下️我的公众号——码上积木,每天剖析一个知识点,我们一起积累知识。
线程与更新UI,消除偏见,细谈原理的更多相关文章
- Android开之在非UI线程中更新UI
当在非UI线程中更新UI(程序界面)时会出现例如以下图所看到的的异常: 那怎样才干在非UI线程中更细UI呢? 方法有非常多种.在这里主要介绍三种: 第一种:调用主线程mHandler的post(Run ...
- C# WINFORM 线程中更新UI
幸好今天是周末,有时间把这个问题记录一下.在多种语言之间切换,发现开发效率降的很低了,开发成本都集中到调式上了,C/C++这些放弃很久了,突然感觉线程这个问题搞的有点烦躁 我这里提到的线程中更新UI, ...
- Android在子线程中更新UI(二)
MainActivity如下: package cc.testui2; import android.os.Bundle; import android.view.View; import andro ...
- Android在子线程中更新UI(一)
MainActivity如下: package cc.testui1; import android.os.Bundle; import android.os.Handler; import andr ...
- 关于Handler的理解,子线程不能更新UI的纠正和回调的思考
开发Android这么久了,总会听到有人说:主线程不能访问网络,子线程不能更新UI.Android的主线程的确不能长时间阻塞,但是子线程为什么不能更新UI呢?今天把这些东西整理,顺便在子线程更新UI. ...
- android 不能在子线程中更新ui的讨论和分析
问题描写叙述 做过android开发基本都遇见过 ViewRootImpl$CalledFromWrongThreadException,上网一查,得到结果基本都是仅仅能在主线程中更改 ui.子线程要 ...
- C#子线程中更新ui
本文实例总结了C#子线程更新UI控件的方法,对于桌面应用程序设计的UI界面控制来说非常有实用价值.分享给大家供大家参考之用.具体分析如下: 一般在winform C/S程序中经常会在子线程中更新控件的 ...
- 使用Handler在子线程中更新UI
Android规定仅仅能在主线程中更新UI.假设在子线程中更新UI 的话会提演示样例如以下错误:Only the original thread that created a view hierach ...
- Android多线程之(一)View.post()源码分析——在子线程中更新UI
提起View.post(),相信不少童鞋一点都不陌生,它用得最多的有两个功能,使用简便而且实用: 1)在子线程中更新UI.从子线程中切换到主线程更新UI,不需要额外new一个Handler实例来实现. ...
- 如何在子线程中更新UI
一:报错情况 android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that creat ...
随机推荐
- 多测师讲解_ 高级自动化测试selenium_001基本学习
高级自动化测试python+selenium教程手册 --高级讲师肖sir 第 1 章webdriver 环境搭建好了,我们正式学习 selenium 的 webdriver 框架,它不像 QTP 之 ...
- 正式班D8
2020.10.15星期四 正式班D8 一.上节课复习 OSI七层协议 socket socket是对传输层以下的封装 IP+port标识唯一一个基于网络通讯的软件 TCP与UDP TCP:因为在通信 ...
- springMvc配置拦截器无效
说明 springMvc配置国际化拦截器失败,点击页面按钮切换中英文无效,排查发现没有进入 LocaleChangeInterceptor 类中,判断拦截器没有起作用,那么是什么原因导致拦截器无效,通 ...
- day39 Pyhton 并发编程02
一.内容回顾 并发和并行的区别 并发 宏观上是在同时运行的 微观上是一个一个顺序执行 同一时刻只有一个cpu在工作 并行 微观上就是同时执行的 同一时刻不止有一个cpu在工作 什么是进程 一个运行中的 ...
- 【组合计数】visit
题目大意 从 \((0,0)\) 开始,每次只可走上下左右一个单位长度,可走重复路,求第 \(T\) 步正好走到 \((n,m)\) 的方案数. 答案要求对 \(MOD\) 取模,\(MOD\) 保证 ...
- Go语言基础知识01-用Go打个招呼
每一种编程语言,从读一本好书开始 每一种编程语言,也从Helloworld开始 1. 环境准备 1.1 安装golang 在Ubuntu下,直接输入命令可以安装最新版本: $ sudo apt-get ...
- spring boot:用zxing生成二维码,支持logo(spring boot 2.3.2)
一,zxing是什么? 1,zxing的用途 如果我们做二维码的生成和扫描,通常会用到zxing这个库, ZXing是一个开源的,用Java实现的多种格式的1D/2D条码图像处理库. zxing还可以 ...
- centos8平台使用rz/sz上传下载文件
一,rz/sz的用途 1,rz,sz是Linux/Unix服务器同客户端通过ZModem进行文件传输的命令行工具 说明:客户端需要使用支持ZModem的telnet/ssh客户端(比如windows平 ...
- win10开机启动文件夹
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp
- PHP-fpm启动后没有查询到9000端口的问题
Nginx与php-fpm通信的两种方式:tcp socket 和 Unix socket. Unix socket 是一种终端,可以使同一台操作系统上的两个或多个进程进行数据通信.这种方式需要再ng ...