Hippy首屏渲染优化小结
01
背景
背景主要是减少页面打开耗时,提升业务秒开率。
回顾 Hippy 业务从入口点击到页面呈现整个过程,大致包含引擎初始化,jsbundle 加载和 view 创建三块,其中还包括 js 与native 之间的通讯耗时以及数据的编解码耗时。减少页面 loading 或白屏时间,同样可从这三方面入手,而 Hippy SDK 或团队内部本身已经做过一些优化,比如下面可以看看引擎加载 jsbundle 的时候做了哪些事情:
Engine 相关
Hippy 在 android使用 v8 作为 JS 引擎,而 iOS 则为 JSCore,以 v8 为例,其加载js的流程大体上分成:
Step1: context 初始化
Step2: 源代码字符串加载
Step3: 代码编译
Step4: run 执行
相关伪代码如下所示:
v8::Local<v8::Context> context = v8::Local<v8::Context>::New(runtime->isolate, runtime->context);
v8::Handle<v8::String> v8Source = v8::String::NewFromUtf8(runtime->isolate, script);
v8::MaybeLocal<v8::Script> v8Script = v8::Script::Compile(v8Source, &origin);
v8Script.ToLocalChecked()->Run(context);
其中 Script 的编译是整个过程中最耗时的,对此 v8 有提供 code cache 的能力,允许通过将编译好的结果实现缓存起来,并在下次加载脚本时使用缓存的编译结果来得到序列化好的对象,以此缩短加载耗时:
if(code_cache_data)
{
v8::ScriptCompiler::CachedData* cached_data = new v8::ScriptCompiler::CachedData(xxx));
v8::ScriptCompiler::Source script_source(v8Source,origin,cached_data);
v8Script =v8::ScriptCompiler::Compile(xxx);
}
同时Hippy SDK本身有预留code cache的开关供业务方自由开闭,业务方可以通过这个开关优化jsbundle的加载耗时。
val aseetLoader = HippyFileBundleLoader(chunkJSPath.absolutePath, true, projectName + chunkName)
优化点
回归到正题,一个hippy业务的加载耗时除了有很大一部分时间花在于引擎初始化和源码的编译上,另外view的创建耗时也是一笔不小的开销,此前前端侧虽然可以通过一些常规手段去减少首屏的节点创建,以此减少js与native的通信耗时达到降低首帧耗时的目的,但实际上在用户侧呈现出来最终画面在时间点上并没有提前,这里我们在想是否还有更优的方式。
最终与同事沟通后的结论是,提前将业务侧的节点数据跑出来,并在业务bundle加载前将节点数据渲染到终端上。这里主要包含节点数据生成和预渲染两部分,按照分工本人负责native侧的预渲染部分,前端侧的节点数据生成由@zehui完成。
02
实现
节点数据生成部分,这里要做的事情是生成一份hippy的UI创建描述,如下图所示:
{
"id": 600,
"pId": 1100,
"index": 0,
"name": "View",
"props": {
"width": 80,
"height": 50,
"flexDirection": "row",
"alignItems": "center",
"justifyContent": "space-between",
"style": {
"width": 80,
"height": 50,
"flexDirection": "row",
"alignItems": "center",
"justifyContent": "space-between"
}
}
}
方法主要是通过拦截hippy的callNative方法将节点数据收集起来,再通过算法将createNode,deleteNode和updateNode等操作合并成一份只执行createNode的nodeMap文件,具体的合并算法以及双端抹平的工作由@zehui完成。
下面主要介绍一下本人负责的部分,主要的工作包括 native预渲染 和 数据合法性校验。
2.1. 预渲染
对于 预渲染 部分,想象起来应该是很简单的,但真正实施起来还是遇到了一些阻碍,归结起来需要考虑三个点:何时渲染, 怎么渲染和何时销毁。
2.1.1. 怎么渲染
先看看怎么渲染,自己根据节点描述去创建 native 的节点工作量会很大,需要考虑样式,层级和布局嵌套等等问题,不过幸好hippy sdk 有提供 UIManager 的模块进行节点的操作, 伪代码如下,这没什么好说了:
val context = hippyView?.getEngineContext()
val rootId: Int = hippyView?.getId()
var uiManager = UIManagerModule(context)
val arr: HippyArray = ArgumentUtils.parseToArray(jsonStr);
uiManager.createNode(rootId, arr)
2.1.2. 何时渲染
怎么渲染其实决定了何时渲染,因为已经很明确了要在业务加载前把native节点渲染出来,最开始我们是定在hippy 引擎初始化前读取 json 节点信息,并根据描述提前创建 native 节点,理论上我们只要在 hippy engine 初始化前调用 UIManager 模块的节点创建方法即可,UIManager 的节点管理实际上是由 hippy sdk 中的 DomManager 完成,但在阅读过 DomManager 的相关源码后,会发现 DomManager 里会绑定 HippyContext 的对象,作用是维护 instance 对象整个生命周期,这意味着必须得在引擎初始化完成后才可以使用 DomManager,所以这条链路走不通。
关于引擎的初始化耗时如何,我们可看看任务页的具体加载指标,如下图所示。如果暂时无法在引擎初始化前预渲染,那退而求其次,在jsbundle加载时执行该动作,在下面这个场景中也至少可以节省400+ms。
Hippy模块名 |
js版本号 |
js版本加载总次数 |
首帧平均耗时ms |
1000ms打开率(%) |
加载js耗时ms |
创建view耗时ms |
引擎初始化耗时ms |
---|---|---|---|---|---|---|---|
vMission |
1.20883.1 |
33,930 |
1006.92278219 |
76.89 |
412.3619216 |
240.51164161 |
122.19982316 |
通过对 hippy sdk 源码的分析,Hippy RootView(根节点)的创建有同步与异步两个过程,异步回调发生在jsbundle加载成功之后,那么就可以利用RootView同步创建后通过在根节点渲染首屏节点来减少js的加载耗时,相应的代码如下:
// 同步创建
public abstract HippyRootView loadModule(ModuleLoadParams loadParams);
// 异步创建
public abstract HippyRootView loadModule(ModuleLoadParams loadParams, ModuleListener listener);
确认好预渲染的出场顺序,在调试过程中也并不是一帆风顺,这里主要遇到了样式错乱的问题,具体体现的报错为 createNode addChild error index > parent.size 或 Create Node DomManager Parent IS Null等错误,这类问题还是比较容易想象得到是由于首屏节点元素中的id和pid与实际业务bundle运行起来后生成的值冲突了,导致互相找不到对应的节点。
解决方法是将首屏生成的节点id全都x100,或者使用个小trick将他们符号取反即可,另外为了防止首屏与实际生成节点的样式出现覆盖,我们还在节点数组最外层加了层包裹,如下所示:
{
"id": -1115,
"pId": "$instanceId",
"index": 0,
"name": "View",
"props": {
"style": {
"zIndex": 9999,
"backgroundColor": 0,
"position": "absolute",
"top": 0,
"right": 0,
"left": 0,
"bottom": 0
},
"zIndex": 9999,
"backgroundColor": 0,
"position": "absolute",
"top": 0,
"right": 0,
"left": 0,
"bottom": 0
}
}
2.1.3. 何时销毁
首屏节点销毁的时机执行太早容易出现白屏闪屏,执行太晚又容易阻塞用户可操作的时间,事实上在调试过程中两者都有遇到过,比较自动化的解决方式是客户端检测到页面不再发生变化后自动将首屏的 View 给移除掉,通过查阅资料得知 android 的 view 提供视图树的变化检测方法 addOnGlobalLayoutListener, 可以监听 View 的全局变化事件。比如,layout 变化,draw 事件等,这样子我们就可以通过这个方法来判断承载业务运行的view是否发生变化,以此决定是否要把首屏节点所处的容器给销毁掉,具体代码如下:
hippyView.getViewTreeObserver().addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
hippyView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
val context = hippyView?.getEngineContext()
val domManager: DomManager = context.getDomManager()
hippyView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
domManager.deleteNode(firstScreenId);
}
})
不过这个事件也存在一些问题,因为是检测视图树的变化,所以只要是视图树发生变化就会触发回调,并把首屏节点移除,这里可能会出现首屏节点创建完了但页面实际并未完全渲染完毕,这就会导致闪屏的现象,这里的优化方式(TODO)可以是通过检测回调的触发次数来判断页面节点发生变化的程度,以此作为移除首屏节点的时机标注。
hippyView.getViewTreeObserver().addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
changeCount++;
if(changeCount > MIN_TIMES) {
hippyView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
val context = hippyView?.getEngineContext()
val domManager: DomManager = context.getDomManager()
hippyView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
domManager.deleteNode(firstScreenId);
}
}
})
另一种方式是可以复用业务的 onHippyDataReady 的事件来通知native移除首屏节点,这种方式更加简洁有效:
override fun onHippyDataReady( hippyView: HippyRootView?) {
hippyViewCreateListener.onHippyDataReady()
val context = hippyView?.getEngineContext()
if(context != null) {
val domManager: DomManager = context.getDomManager()
domManager.deleteNode(instanceId)
}
}
这种方法可以更为精确地移除首屏节点,缺点是实际销毁的时机会存在一定延迟,因为这里还得算上 js2native 的通信延迟,在实际应用中可以考虑结合两种方式运行,并且需要确保有保底的延迟移除方案。
2.2. 合法性校验
该部分实际上是节点联调的过程,主要验证前端生成的节点数组是否可以正确上屏,其中还涉及到Hippy SDK的调试。这里分享其中两个案例以及发现问题的过程:
错误1:createNode addChild error index > parent Size
从错误信息得出这里的意思应该是添加子节点的时候下标值有问题,通过代码搜索可定位到SDK中错误信息的出处:
int realIndex = index;
if (realIndex > parentNode.getChildCount())
{
realIndex = parentNode.getChildCount();
Log.e("DomManager", "createNode addChild error index > parent.size");
}
parentNode.addChildAt(node, realIndex);
从源码的意思可知子节点的index下标越界了,所以只要找出越界的子节点描述,再反推源数组就可以找到问题根结。如果是通过节点Json去寻找越界子节点,那难度还是挺大的,因为生成的数据量有1.5w行,而这时候只需要在源码中打上断点就可以找到对应的上下文信息,从而找到具体的节点id与pid的值。
通过对sdk的断点调试,找到出问题的节点id值,经过反推得知源节点数组在执行deleteNode操作没有处理index的值(此处应该index–),导致问题的产生,掌握一点点调试知识,问题随即迎刃而解。
错误2:java.lang.IllegalStateException: ScrollView can host only one direct child
在使用包含ScrollView的节点数据进行native渲染时,出现了如上错误,从错误信息里可以很容易获取到的信息是 ScrollView 组件里不能添加1个以上的直接子节点,起初很直接会想到会不会也跟上面的问题一样,合并节点的时候顺序或者层级出现了问题,但经过反复确认发现并没有这样的问题。
同时对于这类错误在源码中也没有办法直接定位到具体的问题行,因为这其实并不是sdk抛出的错误,那有什么办法可以通过调试工具快速定位到上下文的信息呢?这里Android Studio debug模式也有提供类似chrome condition breakpoint的能力,如下图:
利用条件断点我们的确可以定位到具体出问题的节点,然而还是不能从中获取到什么有用的信息,这时候就只能死啃代码了,根据父子节点添加的相关代码中找寻到了这一段:
if (!isLayoutOnly && !node.isVirtual())
{
final DomNode nativeParentNode = findNativeViewParent(node);
final ViewIndex childIndex = findNativeViewIndex(nativeParentNode, node, 0);
final HippyMap newProps = map;
// ....
mRenderManager.createPreView(hippyRootView, id, nativeParentNode.getId(), childIndex.mIndex, xxx);
mRenderManager.createNode(hippyRootView, id, nativeParentNode.getId(), childIndex.mIndex, className)
}
这里我觉得不用太多去深究里面的一些技术细节,只需要注意到只有在非isLayoutOnly和非isVirtual条件下才会去创建节点与视图,有了这个重要的信息我们可以继续利用调试工具输出更多的日志,但每次断点都会阻塞联调进度,有时候如果增加日志输出还免不了重新构建项目,这里有什么办法可以在不挂起程序运行过程中输出日志呢,这里Android Studio也有提供相应的能力,只需要把 Suspend 开关关闭,并且在 Evaluate and log 输入框中补充自己的日志逻辑即可:
通过对两个调试小技巧的应用,发现了 isLayoutOnly 本应为 true 的节点实际上输出结果为 false,而 isLayoutOnly 的判断标准是通过提取节点属性中的 collapsable 来判断:
if (props.get(NodeProps.COLLAPSABLE) != null && (Boolean) props.get(NodeProps.COLLAPSABLE) == false)
{
return false;
}
而源节点数组却只有 collapse 而没有 collapsable,这很明显是双端的节点数据不统一(iOS与android生成的节点数据不一致),不明白为什么 hippy 并不把节点数据标准化,类似的问题还有 image 类型,双端差异如下图所示:
找到问题后,只需要在生成数据的时候抹平双端差异即可解决。
03
效果展示
Tencent Video
00:04
当前为非Wi-Fi环境,请注意流量消耗
正在使用流量播放,该视频预计消耗
03:30 / 03:30
为了方便看到首屏效果,视频区分了首屏节点和业务实际产生的节点,这里实际上节省的是jsbundle的加载耗时,从本地炸鸡数据表现来看等待时长减少了约100ms,目前还处于Demo阶段,待节点数据运行打包与解析整个流程打通后,会补充更详细的数据。
04
更多优化
节点数据优化
以VIP个人主页为例,提前跑出的节点数据占了1.5W,这里的数据量着实大,如果是在前端传给客户端生成的话,这里通讯消耗就不小,由于预渲染是一次性插入所有提前跑出的节点,这里其实可以从下面两方面做一些优化:
模板化
观察每个节点的数据格式,它们其实都有一些公共的属性,例如id,pid,props,index,name和style等,如果每个节点都声明这些属性,其实也是一种资源的占用和浪费,那么根据模板化的思想我们可以把这个节点数组改写成模板+数据,由native侧做数据的还原与组装,减少打包文件的大小数组裁剪
首屏节点渲染的时长通常比较短,而且也并非每个节点都是有用的(可渲染到屏幕上)数据,这里其实也可以通过构建或者开关的方式介入首屏节点数量的限制,减少资源开销。
数据生成时机
原期望是在js编译期可以将节点提前跑出来,并与hippy业务bundle打包在一起发布,但实施难度较大,所以改为运行时生成节点数据。不过这里也可以做成更自动化一些,可结合UI自动化测试,在业务构建结束后触发模拟器生成节点数据并保存,合并压缩。
v8 snapshot
在第一章节里我们有提到v8拥有code cache的能力,此外它还支持快照的功能,开启了该功能后可在运行期对isolate与context拍快照并保存。等下次引擎启动时就可以通过该快照生成原场景,提高引擎启动速度。值得好好研究一番。
让我知道你在看