React Native for Android 异常处理概览

本文的RN代码基于0.43版本

准备接入React Native(RN)时,看看前辈们分享的经验,都说刚接入时崩溃率是一个问题。最近在做RN的Native部分优化,今天就来聊聊在RN中的异常都是什么,该怎么处理。

前言

首先,研究RN框架异常的动机在于,我们需要建立起一套针对性的容错机制,毕竟它还是一个不够成熟的框架。期望能够做到的效果就是,对于每一个RN页面的启动,我们能够在进入页面至退出页面期间侦测所有发生的RN相关的崩溃,然后根据崩溃来考虑该页面是否该有降级策略、判断框架是否真的能够支持稳定迭代。

并且,集中地处理崩溃,也有利于我们后续对框架稳定性进行针对性的统计与优化。即使最后还是最简单粗暴的try-catch,这个catch的位置也是一门艺术。

在异常处理这块,我觉得至少需要搞清楚下面两个问题:

  • 有哪几种异常? - 异常产生的位置,有Java代码的,有在C++执行的,有在js触发的,要知道它们分别是什么,代码在哪。
  • 能不能捕获它们? - 是否能够针对性地捕获RN的异常

在大方向的分类上,可以分启动期运行期发生的异常,分别聊一下:

启动期的异常

首先需要找到RN页面启动的起点与终点(即启动实例页面展示),再来缕其中的问题。

根据需求不同,团队可能会对RN做一些定制,可能框架加载、生命周期的流程会进行部分改动。这些属于比较容易改的地方,RN的核心启动流程是不太会大改的。我眼中这个核心启动流程的起点是ReactActivityDelegate.loadApp函数:

rn-loadapp

在RN的框架里面,这个函数在ReactActivity.onCreate时被调用的,启动好的ReactRootView会被直接setContent给Activity,这样界面就展示出来了。这个loadApp里面做了两件事情:

  • 预初始化:上面图里两个框起来的函数。他们会初始化ReactRootViewReactInstanceManager实例,之所以说是预初始化,因为它实际上也仅仅只做了初始化实例,做的事情很少。
  • 创建React上下文:由ReactRootView.startReactApplication一路走下来,会用懒加载方式创建React上下文,通过进入ReactInstanceManager.ReactContextInitAsyncTask这个AsyncTask中执行上下文创建的工作,里面执行的东西可以说是所有的Java部分初始化内容(收集与注册提供的Native/JS/View模块、初始化CatalystInstance(负责核心Bridge相关)、运行JSBundle、将界面展示到ReactRootView上等)。

具体的启动流程这里就不细讲了,我们关注的是它分成哪几块内容,以及除了异常是否有地方捕获?在上下文启动期,RN4A做了比较稳妥的Crash Handle工作。我画了一张简单图,把上述流程及RN的异常处理工作描述一下:

rn-crash-loadapp-proc

在框架启动的核心阶段,我们看到RN都是有捕获异常的,但是框架中仅仅只用DevSupportManager去处理它,当不使用develop support时,它会直接抛出异常。

这里我们可以做第一个修改:在不使用dev support时,使用自己传入的一个Handler去专门处理启动期的崩溃。

至于怎么传,大家可以按喜好做,也可以直接使用ReactInstanceManagerBuilder.setNativeModuleCallExceptionHandler中传入的ExceptionHandler去处理这块的崩溃,这样能够比较统一地处理。

上面保护的都是在AsyncTask执行期内的问题,这块做的事情也最多,最复杂,所以RN也只在这里做了崩溃处理。至于预初始化期间,由于它也涉及了so加载的操作,可以在ReactRootView.startReactApplication外部再加一层try-catch,来保证捕获住所有启动期间的崩溃。

运行期的异常

运行期的异常就比启动期复杂多了,但是依然有一些逻辑可循。我缕了以下8个点,如果确定能将他们cover住,这就能够暂时达到目标了:

  1. JS调用的Native模块不存在
  2. JS调用的Native模块函数原型不一致
  3. JS调用Native模块时,Native模块执行的异常
  4. Native调用的JS模块不存在
  5. Native调用的JS模块函数原型不一致
  6. Native调用的JS模块时,JS模块执行的异常
  7. JS本身代码执行的异常
  8. UI操作的异常

看起来很多,但是没事,RN代码还是比较顺的,他们也比较容易找到痕迹。

运行线程

首先说说运行线程,RN代码维护了三个线程的MessageQueue,所有的操作都会被push到上面运行。

  • UIQueue 专门做UI操作,使用Android里面的主线程;
  • NativeQueue 运行Native模块函数的线程(通常由JS发起),后台线程;
  • JSQueue 运行JS的线程,后台线程。

具体怎么将JS代码、Native代码运行到他们上的,需要深入一点RN的Bridge原理。由于去年RN基本将Bridge部分移入c++,很多市面上的Bridge分析文章,包括我之前的文章也已经过时了(参考commit 1a690d5)。以后有时间我会将这段补上吧,这里我们先不去探究这些线程与bridge之间的联系与调用。

在CatalystInstance初始化的时候会初始化三个Queue,启动后台线程,并将他们的句柄传给C++层的Bridge。这个Queue其实没什么,但是我们需要注意到的是,它里面调度使用的Handler,重写了dispatchMessage,并在外面包装了层try-catch,统统都会交到CatalystInstance.onNativeException中。而这其中使用的就是我们传入给ReactInstanceManager的ExceptionHandler。

这样就能在一定程度上保证了在Bridge层出现的问题能跑到Java层上被捕获。

我们可以简单了解一下RN实例是怎么跑起来的,有助于理解运行期会都有哪些代码。首先它是通过Native调用AppRegistry这个JS模块的runApplication方法,让JS跑起来的(JSQueue执行的操作)。后续的一系列操作驱动,要么是Native的View收到事件传递给JS(JSQueue执行的操作),要不就是JS Component生命周期中的代码运行(JSQueue执行的操作),后者可能带来的UIManager的UI线程操作、调用的Native模块(NativeQueue执行的操作)。

ps: 更多细节可以参考一下我上一次的文章:React Native 核心渲染流程分析(1) - 初识组件系统

那我们只要初始化ReactInstanceManager的时候传自己的Handler来统一处理,看起来已经皆大欢喜了!

其实并不是,我们记住,要把之前列出的8个点都cover住,那在ReactInstanceManager这里的exception handler可以捕获上面描述的哪几个点?

  • NativeQueue中的handler,能够捕获2、3
  • JSQueue中的handler,能够捕获1、4、5、6、7

NativeQueue捕获几个异常的原因

  • 捕获2(JS调用的Native模块函数原型不一致)的原因:

由于调用Native模块函数时,会通过MessageQueue.js走到C++层Bridge中,在这个调用链的C++层最后一个关卡NativeToJsBridge.cpp中可以看到它的真身:

这个m_nativeQueue的实例就是对应的之前说的NativeQueue在C++层的对应类(见JMessageQueueThread.cpp),它会将这个闭包包通过wrapRunnable装成一个NativeRunnable交给Java层执行,由它里面去解析、运行JS传入的模块、函数、参数,如果解析与原型匹配错误,这个异常理所当然会被NativeQueue捕获。

  • 捕获3(JS调用Native模块时,Native模块执行的异常)的原因:

捕获2的原因中已经提到了,Native模块函数会由NativeQueue执行,异常当然被它捕获。

JSQueue捕获的几个异常的原因

C++层的Bridge对JSQueue的封装基本都可以从NativeToJsBridge.cpp中看出一点端倪,首先它初始化的时候会通过传入的JSQueue初始化一个JSCExecutor:

每次这个Executor执行代码的时候,其实都是执行到JSQueue的线程上,错误就会被它捕获。

  • 捕获1(JS调用的Native模块不存在)的原因:

JS找Native模块都是通过RN的NativeModules(一个JS模块)去找的,那获取一个不存在的属性肯定是JS执行错误,被执行它的JSQueue捕获;

  • 捕获4(Native调用的JS模块不存在)、5(Native调用的JS模块函数原型不一致 )的原因:

Native在找JS模块时,其实是走的Java逻辑,调用JS模块函数会通过动态代理走到CatalystInstanceImpl.callFunction里面,最后会走到NativeToJsBridge.cpp里面的callFunction函数,它同样交给JSQueue去解析模块、运行函数,如果发生错误(模块不存在、原型不一致),那么会被执行它的JSQueue捕获。

  • 捕获6(Native调用的JS模块时,JS模块执行的异常)、7(JS本身代码执行的异常)的原因:

看完上面的描述,这个应该比较好懂,因为他们就是执行JS时发生了问题,自然被JSQueue捕获。

UI操作的异常

上面将1~7都捕获完了,那么我们需要知道最后一个8:UI操作的异常要怎么捕获。虽然UI操作,其实是调用的UIManagerModule这个Java模块进行的操作,但是实际上它不是马上被同步执行的,而是仅仅只有一个入队列的操作。所有的UI操作都通过Choreographer来驱动执行,那这个时候虽然是在主线程运行,但是不在上面任何一个MessageQueue里面了,于是上面的方式捕获不到。

那要怎么捕获这里产生的异常呢?我们拿创建视图(UIManager.createView)这个操作来追踪一下它的调用流程:

每一个操作都是进入操作队列,由Choreographer进行调度,这个DispatchUIFrameCallback其实就是一个Choreographer.FrameCallback,在每次doFrame时候将队列内的操作flush并执行,但是它在RN中被包装了一层:GuardedFrameCallback.java:

我们看到它也是catch了Exception!由ReactContext.handleException来处理它,追踪下去我们发现RN会React上下文创建时设给它一个Handler:

但是它只在develop support时才起作用!所以如果我们希望要捕获这里的崩溃,就必须做一点改动。

在这里我们可以做第二个修改:初始化ReactContext时,在使用develop support时设给它DevSupportManagerImpl,否则使用传入的NativeModuleExceptionHandler

总结

要在企业级的应用中使用RN需要很小心,不能因为应用了新技术而导致包体积增大过多(后面我会写一篇RN for Android的包体积优化)、崩溃率上升等问题,这就必须要多研究它的源码。要吃透它的RN的代码不是一个很容易的工作(有js、c++都需要看),并且它本身也处于一个快速的迭代周期中,可能会存在一些缺陷。但是它的代码结构、程序逻辑是比较清晰的,多梳理几次,其实它也不那么神秘。