今天讲一个老生常谈的问题,"Android中子线程真的不能更新UI吗?”
Android中UI访问是没有加锁的,这样在多个线程访问UI是不安全的。
所以Android中规定只能在UI线程中访问UI。子线程更新是不被允许的。
那么子线程访问UI会报错吗?
首先,我们在布局文件随意定义一个textview:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/tv_test"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:textColor="@color/yellow"
android:background="#000000"
android:text="test"
android:textSize="16sp"
android:gravity="center"/>
</LinearLayout>
接着我们在activity中开了个子线程修改UI:
class TestActivity :AppCompatActivity(R.layout.activity_test) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Thread(object :Runnable{
override fun run() {
tv_test.text = "这是修改后的text"
}
}).start()
}
}
展示,并没有报错:
紧接着,我们试试延时试试:
class TestActivity :AppCompatActivity(R.layout.activity_test) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Thread(object :Runnable{
override fun run() {
Thread.sleep(3000)
tv_test.text = "这是修改后的text"
}
}).start()
}
}
运行试了下,3s后居然奔溃了,这是为啥呢?
查看错误日志:
经典问题出现了,查看源码可以看到,是在ViewRootImpl.checkThread中抛出的这个异常.
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
当访问 UI 时,ViewRootImpl 会调用 checkThread方法去检查当前访问 UI 的线程是否为创建 UI 的那个线程,如果不是。则会抛出异常。
那为啥要一定需要checkThread呢?根据handler的相关知识:
因为UI控件不是线程安全的。那为啥不加锁呢?
一是加锁会让UI访问变得复杂;
二是加锁会降低UI访问效率,会阻塞一些线程访问UI。
所以干脆使用单线程模型处理UI操作,使用时用Handler切换即可。
疑问点:为什么一开始在 MainActivity 的 onCreate 方法中创建一个子线程访问 UI,程序还是正常能跑起来呢???
我们可以看看tv_text.setText触发的调用流程:
TextView#setText
-->View#requestLayout()
满足条件:
--> ViewParent # requestLayout
--> ViewRootImpl # requestLayout
-->View#invalidate
--> View#invalidate(boolean)
--> View#invalidateInternal //如果 if mAttachInfo 以及 mParent 都不为空
-->ViewParent#invalidateChild
-->ViewRootImpl#invalidateChild
-->ViewRootImpl#invalidateChildInParent
---------------------
View#invalidateInternal
// Propagate the damage rectangle to the parent view.
final AttachInfo ai = mAttachInfo;
final ViewParent p = mParent;
if (p != null && ai != null && l < r && t < b) {
final Rect damage = ai.mTmpInvalRect;
damage.set(l, t, r, b);
p.invalidateChild(this, damage);
}
ViewRootImpl#invalidateChildInParent
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
checkThread();
if (DEBUG_DRAW) Log.v(mTag, "Invalidate child: " + dirty);
if (dirty == null) {
invalidate();
return null;
} else if (dirty.isEmpty() && !mIsAnimating) {
return null;
}
我们仔细看一下mThread,他这个错误信息并不是:
Only the UI Thread ... 而是 Only the original thread。
这个mThread是什么?
是ViewRootImpl的成员变量,我们重点应该关注它什么时候赋值的:
public ViewRootImpl(Context context, Display display) {
mContext = context;
mThread = Thread.currentThread();
}
在ViewRootImpl构造的时候赋值的,赋值的就是当前的Thread对象。
也就是说,你ViewRootImpl在哪个线程创建的,你后续的UI更新就需要在哪个线程执行,跟是不是UI线程毫无关系。
那么,VIewRootImpl是什么时候创建的呢?
我们启动Activity绘制UI的方法在onResume方法里,所以我们找到Activity的线程ActivityThread类。在ActivityThread中,我们找到handleResumeActivity方法,内部调用了performResumeActivity方法,逐层跟进会发现调用了activity.onResume()方法,所以performResumeActivity确实是resume的入口。
ActivityThread.java#handleResumeActivity
final void handleResumeActivity(IBinder token,
boolean clearHide, boolean isForward, boolean reallyResume) {
...
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
if (a.mVisibleFromClient) {
a.mWindowAdded = true;
wm.addView(decor, l);
}
// If the window has already been added, but during resume
// we started another activity, then don't yet make the
// window visible.
} else if (!willBeVisible) {
if (localLOGV) Slog.v(
TAG, "Launch " + r + " mStartedActivity set");
r.hideForNow = true;
}
...
}
wm.addView(decor, l);是他进行的View的加载,我们去看看他的实现方法,在WindowManager的实现类WindowManagerImpl里:
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
mGlobal.addView(view, params, mDisplay, mParentWindow);
}
发现他是调用WindowManagerGlobal的方法实现的,最后我们找到了最终实现addView的方法:
WindowManagerGlobal.java#addView
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
...
ViewRootImpl root;
View panelParentView = null;
synchronized (mLock) {
// Start watching for system property changes.
...
root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
}
// do this last because it fires off messages to start doing things
try {
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
synchronized (mLock) {
final int index = findViewLocked(view, false);
if (index >= 0) {
removeViewLocked(index, true);
}
}
throw e;
}
}
果然在这里,View的加载最后就是在这里实现的,而ViewRootImpl的实例化也在这里。
所以如果我们在子线程中调用WindowManager的addView方法,是不是就可以成功更新UI呢?
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Thread(object :Runnable{
override fun run() {
Thread.sleep(3000)
// tv_test.text = "这是修改后的text"
val tx = TextView(this@TestActivity)
tx.text = "这是修改后的text"
tx.setBackgroundColor(Color.WHITE)
val layoutParams = WindowManager.LayoutParams(
200, 200, 200, 200, WindowManager.LayoutParams.FIRST_SUB_WINDOW,
WindowManager.LayoutParams.TYPE_TOAST, PixelFormat.OPAQUE
)
windowManager.addView(tx, layoutParams)
}
}).start()
}
错误原因是没有启动Looper。原来是因为在ViewRootImpl类里新建了ViewRootHandler的实例mHandler,在子线程中加上Looper.prepare()和Looper.loop(),然后成功了~
Thread(object :Runnable{
override fun run() {
// tv_test.text = "这是修改后的text"
Thread.sleep(3000)
Looper.prepare()
val tx = TextView(this@TestActivity)
tx.text = "这是修改后的text"
tx.setBackgroundColor(Color.BLACK)
tx.setTextColor(Color.YELLOW)
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, 100, 0, 0, WindowManager.LayoutParams.TYPE_DRAWN_APPLICATION,
WindowManager.LayoutParams.TYPE_STATUS_BAR, PixelFormat.OPAQUE
)
windowManager.addView(tx, layoutParams)
Looper.loop()
}
}).start()
运行成功!
扩展点:为什么子线程需要加Looper.loop(),主线程不用?
总结:
ViewRootImpl 的创建在 onResume 方法回调之后,而我们一开篇是在 onCreate 方法中创建了子线程并访问 UI,在那个时刻,ViewRootImpl 还没有创建,我们在因此 ViewRootImpl#checkThread 没有被调用到,也就是说,检测当前线程是否是创建的 UI 那个线程 的逻辑没有执行到,所以程序没有崩溃一样能跑起来。而之后修改了程序,让线程休眠了 3000 毫秒后,程序就崩了。很明显 3000 毫秒后 ViewRootImpl 已经创建了,可以执行 checkThread 方法检查当前线程。
等等,还没完?
在测试的时候,偶然发现这么写不会报错??怀疑人生!!?
Thread(object :Runnable{
override fun run() {
Thread.sleep(3000)
tv_test.text = tv_test.text.toString()+"abc"
}
}).start()
仔细想想,想明白了,这块就涉及到View的绘制流程了,设置值时,执行到View.requestLayout->ViewRootImpl#requestLayout 方法,
ViewRoot的performTraversals()方法会在measure结束后继续执行,并调用View的layout()方法来执行此过程,如下所示:
host.layout(0, 0, host.mMeasuredWidth, host.mMeasuredHeight);
在layout()方法中,首先会判断视图的宽高是否发生过变化,以确定有没有必要对当前的视图进行重绘,这块显然没有变化,自然也就不会继续往下执行了。
下次如果有人问你 Android 中子线程真的不能更新 UI 吗? 你可以这么回答:
任何线程都可以更新自己创建的 UI。只要保证满足下面几个条件就好了
- 在 ViewRootImpl 还没创建出来之前。
- UI 修改的操作没有线程限制。因为 checkThread 方法不会被执行到。
- 在 ViewRootImpl 创建完成之后
- 保证「创建 ViewRootImpl 的操作」和「执行修改 UI 的操作」在同一个线程即可。也就是说,要在同一个线程调用 ViewManager#addView 和 ViewManager#updateViewLayout 的方法。
- 注:ViewManager 是一个接口,WindowManger 接口继承了这个接口,我们通常都是通过 WindowManger(具体实现为 WindowMangerImpl) 进行 view 的 add remove update 操作的。
- 对应的线程需要创建 Looper 并且调用 Looper#loop 方法,开启消息循环。
- 保证「创建 ViewRootImpl 的操作」和「执行修改 UI 的操作」在同一个线程即可。也就是说,要在同一个线程调用 ViewManager#addView 和 ViewManager#updateViewLayout 的方法。
有同学可能会问,保证上述条件 1 成立,不就可以避免 checkThread 时候抛出异常了吗?为什么还需要开启消息循坏?
条件 1 可以避免检查异常,但是无法保证 UI 可以被绘制出来。
条件 2 可以让更新的 UI 效果呈现出来
- WindowManger#addView 最终会调用 WindowManageGlobal#addView 方法,进而触发ViewRootImpl#setView 方法,该方法内部会调用ViewRootImpl#requestLayout 方法。
- 根据 UI 绘制原理,下一步就是 scheduleTraversals 了,该方法会往消息队列中插入一条消息屏障,然后调用 Choreographer#postCallback 方法,往 looper 中插入一条异步的 MSG_DO_SCHEDULE_CALLBACK 消息。等待垂直同步信号回来之后执行。
注:ViewRootImpl 有一个 Choreographer 成员变量,ViewRootImpl 的构造函数中会调用 Choreographer#getInstance(); 方法,获取一个当前线程的 Choreographer 局部实例。
使用子线程更新 UI 有实际应用场景吗?
Android 中的 SurfaceView 通常会通过一个子线程来进行页面的刷新。如果我们的自定义 View 需要频繁刷新,或者刷新时数据处理量比较大,那么可以考虑使用 SurfaceView 来取代 View。
扩展知识-使用Looper实现日志捕获:
讲这个之前,先说下Looper的流程吧~
前面说到,需要在子线程中调用Looper.loop开启循环。那么我们的主线程为什么没有调用呢?
这里又涉及到handler的事件分发机制了,查看源码得知,在ActivityThread中,系统已经帮我们调用了Looper.prepareMainLooper()
public static void main(String[] args) { Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "ActivityThreadMain"); // Install selective syscall interception AndroidOs.install(); // CloseGuard defaults to true and can be quite spammy. We // disable it here, but selectively enable it later (via // StrictMode) on debug builds, but using DropBox, not logs. CloseGuard.setEnabled(false); Environment.initForCurrentUser(); // Make sure TrustedCertificateStore looks in the right place for CA certificates final File configDir = Environment.getUserConfigDirectory(UserHandle.myUserId()); TrustedCertificateStore.setDefaultUserDirectory(configDir); Process.setArgV0("<pre-initialized>"); Looper.prepareMainLooper(); // Find the value for {@link #PROC_START_SEQ_IDENT} if provided on the command line. // It will be in the format "seq=114" long startSeq = 0; if (args != null) { for (int i = args.length - 1; i >= 0; --i) { if (args[i] != null && args[i].startsWith(PROC_START_SEQ_IDENT)) { startSeq = Long.parseLong( args[i].substring(PROC_START_SEQ_IDENT.length())); } } } ActivityThread thread = new ActivityThread(); thread.attach(false, startSeq); if (sMainThreadHandler == null) { sMainThreadHandler = thread.getHandler(); } if (false) { Looper.myLooper().setMessageLogging(new LogPrinter(Log.DEBUG, "ActivityThread")); } // End of event ActivityThreadMain. Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); Looper.loop(); throw new RuntimeException("Main thread loop unexpectedly exited"); }
然后执行Looper.loop(),不断从队列中抽取message。
public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
//1、获取到消息队列
final MessageQueue queue = me.mQueue;
//..................
//开启死循环
for (;;) {
//2、拿到队列中的消息
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
//省略部分不相关的代码
//..................
try {
//3、执行队列中的消息
msg.target.dispatchMessage(msg);
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} finally {
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
//..................
msg.recycleUnchecked();
}
loop()方法中,代码非常简单,分三步走
1、获取到looper中的 MessageQueue
2、开启一个死循环,从MessageQueue 中不断的取出消息
3、执行取出来的消息 msg.target.dispatchMessage(msg);(顺便说一下,Handler的handleMessage()方法就是在这一步执行的)
在第二步里面,会发生阻塞,如果消息队列里面没有消息了,会无限制的阻塞下去,主线程休眠,释放CPU资源,直到有消息进入消息队列,唤醒线程。从这里就可以看出来,loop死循环本身大部分时间都处于休眠状态,并不会占用太多的资源,真正会造成线程阻塞的反而是在第三步里的 msg.target.dispatchMessage(msg)方法,因此如果在生命周期或者handler的Handler的handleMessage执行耗时操作的话,才会真正的阻塞UI线程。
由此我们可以自定义一个handler+Looper截全局崩溃(主线程),避免 APP 退出。
相关代码如下:
class MyApp : Application() { override fun onCreate() { super.onCreate() var handler = Handler(Looper.getMainLooper()) handler.post { while (true){ try { Looper.loop() }catch (e:Throwable){ e.printStackTrace() if (e.message != null && e.message!!.startsWith("Unable to start activity")) { System.gc(); _restart(); android.os.Process.killProcess(android.os.Process.myPid()); break } } } } Thread.setDefaultUncaughtExceptionHandler { t, e -> e.printStackTrace() } } private fun _restart() { val intent = getPackageManager().getLaunchIntentForPackage(getPackageName()) intent?.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } }
testDemo:
var data:ArrayList<String> ?= null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
tv_test.setOnClickListener {
data!!.add("1")
}
}
不添加上述代码时,点击文本:
页面闪退,报错:
添加后:
异常捕获,页面不受影响
通过上面的代码就可以就可以实现拦截UI线程的崩溃。但是也并不能够拦截所有的奔溃,如果在Activity的onCreate出现崩溃,导致Activity创建失败,那么就会杀死该app并重启。
参考链接:
- Android UI 线程更新UI也会崩溃???
- 面试官:子线程 真的不能更新UI ?
- Android 中子线程真的不能更新 UI 吗?
- Looper.loop 为什么不会阻塞掉 UI 线程?
- Android源码剖析:基于 Handler、Looper 实现拦截全局崩溃、监控ANR等