全民K歌跨端技术体系建设
date
Sep 1, 2021
slug
tme-cross-platform
status
Published
tags
技术沉淀
summary
跨端技术的本质是实现代码复用,减少开发者在多平台上的适配工作量,移动互联网发展至今,跨端技术…
type
Post
1. 背景
1.1 移动端技术演进
跨端技术的本质是实现代码复用,减少开发者在多平台上的适配工作量,移动互联网发展至今,跨端技术经历了许多阶段,大体上可以分成如下四类:
- 最早是通过 H5 来实现跨浏览器的页面渲染;
- 移动互联网发展,催生了越来越多调用到原生能力的场景,于是步入了 Hybrid 技术阶段;
- 紧接着为了解决渲染性能问题,又催生了像 RN/Weex 这类的 Native 容器方案;
- 再往下一步走,就是近年来聊得比较火热的 Flutter,以及相关的一些自绘制能力的渲染技术;
从整个变迁过程中我们会发现,跨端技术的演进实际上是以 H5 代表的效率、动态性逐步迁往 Native 代表的性能体验的过程,并在整个过程中不断寻找两者间的平衡点。
1.2 主流方案对比
接着我们再来看看目前主流的 Native 容器与自绘引擎方案的对比。
Native 容器方案
市面上 Native 容器方案最具代表的框架当属 Weex 和 RN 两类,对于这类框架而言,原理都大同小异,
他们其实由三部分组成,分别是 JS,Native 以及中间的 Bridge 层,JS 层通常是我们熟知的 Vue / React 框架,他其实会把需要生成的节点信息通过 Bridge 层序列化后转发到 Native 层进行解析,并最终渲染为 Native 的组件,这是为什么 Native 容器方案性能比 H5 要好的一个重要的原因。
自绘制引擎
而对于自绘引擎方案,目前最具话题与代表性的是 Google 的 Flutter 框架,它重写了一套跨平台的 UI 框架,包括 UI 组件,
渲染引擎以及开发语言,渲染引擎基于 Skia 图形库,而依赖系统部分就仅有图形绘制相关的接口,可以最大程度上保证跨平台的一致性,同时也意味着不需要再像 RN 那样经过一层 Bridge 进行 JS 和 Native 的通信数据转发,渲染效率更高一筹,另外其上次基于 Dart 语言开发,在执行效率上也比 JS 要高效得多。
小结
两种类型的框架就目前而言没有谁优谁劣,对于 Native 容器方案来说,他更多的是代表了研发效率与动态性;对于 Flutter 这类框架而言,它虽然实现了高性能,但是却缺少一套较为完备的动态化方案。
PS: 这里需要提一嘴,RN 目前正在把 JsBridge 的架构重构为 JSI,通过 JSI 架构可以省去 JsBridge 的异步序列化操作,节省了成本,提高了效率,目前还在研发当中。
1.3 技术选型
对于该使用哪种跨端技术作为产品的接入方案,在选型上我们应该考量到以下几点:
- 从产品角度,需要考虑迭代情况以及使用场景,比方说像直播这种类型就更适合用 Native 而不是任何一种跨端技术实现;
- 从接入成本,需要看看接入这项技术所带来的成本,比如团队的技术储备,是否有相关人力,比如客户端来支持,遇到问题依靠现有人力和生态是否能 hold 住;
- 从研发效率,怎么实现最大化的代码复用,以及建设相关的开发调试工具链,提高开发效率,是应该考虑的问题之一;
- 从性能体验,跨端技术通常是通过牺牲部分体验换来效率提升,所以体验比起 Native 还是要差一些,这也是情理之中,但如何通过建设配套工具和针对性专项优化也需要重点考虑进来。
1.4 K 歌现状
对 K 歌来说,通过一段时间的调研,实践和风险考虑,最终主要采用了腾讯内部自研的类 RN 框架 - Hippy,目前在站内已开发超过 200+的业务,覆盖了像点歌台,任务页这类一级入口,同时包含 Poplayer 和游戏化的场景,日均 PV 直达 2.9 亿,覆盖了 41%+ 的主业务场景。作为 Hippy 最早的接入团队之一,在广泛应用的同时,我们还深挖了框架技术痛点,并深度参与到开源共建中。
同时我们也有投入使用 Flutter 技术栈,目前主要是在应用在创新项目当中。
2. K 歌跨端体系建设
2.1 体系建设
跨端技术的实践往往需要一系列的配套建设,以及针对性的性能优化,下面是 K 歌在跨端上的体系建设,主要分为四个部分:
- 开发支持,这里主要有开发调试使用到的工具链,组件文档,以及与客户端约定好的标准规范等;
- 发布部署,包括 UI 自动化测试,Bundle 的包管理以及加载策略;
- 质量监控,主要是一些质量指标维度的制定以及配套的监控系统;
- 性能优化,包括在具体实践过程中遇到的性能问题,以及解决方案,包括 Bundle 拆分,包内置,秒开率及稳定性的优化等;
2.2 开发调试
为了提升开发联调效率,除了框架本身提供的开发调试手段外,我们还沉淀了自己的一些工具和文档,在新增一个接口协议后并提交后,触发钩子构建,自动生成接口的文档及调试工具。
同时为了方便开发过程中日志信息的查看,我们也开发了一个类 RN 版的 vConsole 工具,可对输出及网络流水进行拦截。
2.3 接口规范化
在与客户端同学接口联调时,经常出现三端没有对齐的情况,比如输入输出不一致的问题。由此提出了接口标准化的方案,通过在一个接口模板中约定好接口的规范,包括接口名,具体的输入和输出,再自动生成三端的接口模板,并在各端完成逻辑补齐、代码提交后自动打包推送到各自仓库,大大提高了联调的效率,目前该方案已应用到 k 歌国内版,极速版以及 Flutter 的项目中。
3. K 歌跨端实践优化
3.1 能力扩展
首先是能力扩展部分,原框架提供的能力往往不能满足和覆盖我们复杂的业务场景,为此我们根据业务需要和优先级梳理了一套符合 W3C 规范的 WebApi,依靠框架提供的插件能力扩展了所需要的功能,如触摸事件,Performance 等原生接口能力,又如 Canvas, Audio 等原生组件和还有渐变色,魔法色等样式能力支持。
其次 App 内将常驻一个 Master 的无界面实例,这实际上是一个后台的业务,会在 App 启动后自动加载,主要是服务于两类任务,一种是在 App 启动时做一些初始化的操作,同时会启动一个轮询任务,定时去拉取业务包的配置信息,实现业务包的下载管理,这个在文章后面也会提及;另外是实例间的通讯,在歌房和直播间的场景下,可能同时挂载多个 Hippy 业务,而业务之间往往有通信的需求,针对这种场景,我们通过在 Native 实现一个中转站对业务间的数据进行实时转发,以此完成业务实例间通讯的需求。
另外团队与内部设计团队约定了组件的设计规范,并拉通客户端同学,最终沉淀出三端通用的 UI 组件库,包含多种通用组件与业务组件,目前已在业务中广泛接入与使用,通过规范化,极大提升了组件复用性,同时也降低业务的开发成本。
3.2 性能优化
下面将从以下几个维度对页面性能进行分析与优化。
3.2.1 加载速度优化
首先我们来看看加载速度方面的优化,在这之前可以先了解一个 Hippy 业务的加载链路:一个业务在用户点击入口时,先会经过 JS 引擎的初始化,接着是 JsBundle 文件的加载,在文件加载后客户端会发送一个 loadInstance 的事件通知前端完成业务注册,与此同时会创建一个 view 节点就绪,随后前端会通过一系列的异步请求完成整个页面的渲染。
在这个链路里面我们把引擎初始化到第一个 view 节点创建结束的这段时间成为首帧耗时,而此后直到页面渲染完成的时间段称之为异步加载耗时。由此可见,加载耗时也可以拆分成三部分,分别是引擎初始化耗时,JSBundle 耗时和请求的耗时,针对这三个细化的指标,也有相应的优化方案,分别是引擎的复用,包拆分、裁剪和预请求等优化。
引擎复用
先看看引擎复用部分,在未做优化之前,一个业务 bundle 的加载需要开启一个 JS 的引擎,而一个 JS 引擎初始化时间大概是在 500ms 左右;而在实际应用中,可能存在多业务并行的场景,随着业务加载数量的增加,我们需要开启越来越多的 JS 引擎,由于每次初始化引擎都需要耗费耗时,这样会直接导致页面耗时增加,此外也占用了不少的客户端的 CPU 和内存等资源。
对于这个问题,我们首先想到的是用引擎池的方案,客户端会预先创建两个引擎作为备用,业务开始加载时,会优先使用缓存池中的已经创建好的引擎,当 2 个引擎不满足场景时才开始创建下一个引擎。这种方案可以一定程度上解决引擎初始化耗时问题,但还不够极致。我们在想每个业务新起一个引擎的目的究竟是什么?其实只是为了做业务的隔离,因为我们使用了 V8 作为 JS 的引擎,在 V8 中其实是可以通过单引擎多 context 方式进行业务隔离,所以我们在最新的业务实践中也采用了这样的方式来实现引擎的复用,从而减少初始化的耗时。
Bundle 包优化
接着看看 bundle 包优化,这里其实可以从两方面着手,分别是体积优化与下载耗时优化,具体使用到的措施有如下几点:
- JS 拆包,采用了业界比较通用的做法,将前端的 JS 包拆分成 Base bundle 和业务 bundle 两部分,并把 base bunble 内置于客户端中,以此减少包体积与下载时长;
- 业务优化,不少业务中使用了很多 Base64 的图片,会对业务包的体积造成一定影响,这里可以只保留必要的小图 Base64 图片,其他均采用外链方式,同时可以通过业务代码重构,Tree-Shaking 等方式减少一些冗余代码;
- Chunk 包加载,原框架不支持 Chunk 包加载,通过在 Native 层实现 Chunk 包的缓存与加载,并将非首屏/核心资源抽离成 Chunk 包异步加载,从而实现首屏 Bundle 体积的减少;
- 增量更新,在构建阶段通过与上次编译结果的对比,实现了差分包的拆解,使得在业务包下载任务中只需要下载差分包,并在后台合成,以此减少 Bundle 包下载耗时。
预请求优化
再往下是预请求部分,上面我们提到一个业务中异步请求的耗时也是影响首屏渲染速度的一个重要因素,对此我们采用了预请求的优化手段。
回顾及细化之前的加载链路,在前端业务响应之前其实会经过引擎初始化和业务 bundle 的下载及加载的过程,在完成这两个过程之后才能发起接口请求进行页面的渲染,如果我们把请求提前至这几个过程之前,或者说跟他们的加载时机并行跑,则会节省掉很多的耗时。事实上我们的预请求优化就是这么做的,在加载业务之前,引擎通过业务 url 上开关参数判断出需要发起预请求,于是会读取配置并发起一个请求,当前端业务被唤起后,通过客户端提供的接口我们可以获取缓存好的请求结果,从而减少请求的耗时。
关于预请求的配置我们通过一个 json 配置保存起来,并与业务 bundle 一起打包到 Zip 包里,里面主要约定了具体请求的命令字,请求参数,以及请求 Id 等信息,同时为了在请求参数中传送一些动态的信息,比如登录态中的 uid,url 上的参数信息等,我们也与客户端同学约定了一些全局的变量定义以及获取动态值的语法等。
3.2.2 卡顿优化
Native 分析工具
对于 K 歌所使用到的 Hippy 框架,或者是 RN/Weex 这类的跨端框架,其实最终还是会渲染成 Native 的原生组件,所以我们也可以借助客户端的相关工具排查卡顿性能,比如以 Android 为例,可以通过【设置】【开发者调试选项】中的【GPU 条形模式分析】得到页面的 FPS 绘制效率,iOS 则可以使用 xcode 的 instruments 工具排查卡顿性能。
左上图为安卓的 GPU 分析案例,可以看到截图最下方是一条绿线,代表的是 16ms 的阈值,超过了这个界限则表示当前帧绘制的时间出现了延迟,卡顿的现象,而图中具体的颜色值所代表的含义在右边这张图中我们也可以找到,如果以该案例来看,所处第二个颜色,也就是 Layout Measure 的处理耗时太多,表示当前触发 onLayout 和 onMeasure 的回调次数太多,由此可以推断出页面层级可能比较复杂。
有了这个结论后,再往下就可以通过过度绘制检查工具来排查页面的层级,如图中所示,绿色部分表示层级简单,红色则表示嵌套层级比较深,那么在后续就可以做针对性的优化了。
前端通信分析
对于 Hippy 这类跨端框架而言,前端最终传递给客户端的是抽象的节点以及响应的操作行为,当客户端通过 UIManager 模块接收这些信息,对其进行整合,并转化为一系列的操作指令,最终渲染到 Native 中。
在前端部分,可以通过拦截 js2native 的通信数据来排查卡顿问题,比方说在列表场景下,会存在同时传递多个节点信息,这样会导致频繁的 js2native 的通信,对帧率也会造成影响。
问题及解决方案
通过结合 Native 与 JS 的排查手段,可以推断卡顿的问题可能有如下几类问题产生,分别是:
- 层级复杂,处理任务过多;
- 频繁的 js2native 的通信;
- js2native 通信数据更大导致通信延迟;
针对这类问题常用的解决方案分别是:
- 减少页面层级,前端同学由于开发背景的原因,往往会忽略层级嵌套的问题,导致性能问题,这个需要在开发侧注意,尽量减少组件层级的嵌套;
- 对于频繁 js2native 的通信,我们尝试在前端 SDK 层面做处理,在一个微任务里对节点的操作进行合并,再统一传到 Native,这样可减少 js2native 的操作数量;
- 对于节点数据问题,通过模版化的思想将节点里面一些公共的属性和数据进行模版化的操作,并沉淀了高性能的列表组件。
- 另外在必要的时候还需要推进客户端的同学进行具体问题分析,比如之前我们就遇到过列表里重复对 Base64 图片进行解码的问题,导致 FPS 数据下降,后面的解决方案是推进客户端进行 Base64 图片缓存,以此提高绘制效率。
3.2.3 成功率优化
接着是成功率优化,这里主要指的是业务的加载成功率,由于业务最终还是通过网络分发,在业务加载时,为了减少业务包的网络下载耗时或者由于网络问题而导致下载失败的问题,在这里团队结合了内置包和缓存包两种方式来保证业务的加载成功率。
内置包
内置包作为极端网络情况下的保底方案,适用于 App 内的一级入口页面,如点歌台,歌房 Tab 等业务,保证在失去网络时仍能打开这类业务,它的具体更新流程如下:通过在一个特定的仓库下维护了一份业务的版本配置文件,这里提供了手工录入和定时拉取外网最新稳定版本两种方式来修改这份配置文件,而当该配置文件完成修改,并通过 Review 后,将触发 WebHook 钩子通知构建系统,此时构建会取拉取配置文件中的业务版本信息,并下载下来放到客户端指定的本地目录中,再向客户端仓库发起新的 MR,通过客户端人员的 CR 后内置包即可更新,并在下次发布 AppStore 或应用市场时将最新的内置包带到外网中。
缓存包
接着再来看看缓存包部分,由于内置包集成会增加客户端的安装包体积,并不适合所有的 Hippy 业务,这时候缓存包就可以发挥作用了。同时由于业务频繁迭代发布的特性,如何保证外网可以较实时地拉取,也需要一套更新机制,前面我们有提及 APP 启动后会加载一个无界面的实例,该实例会在加载完毕后启动一个轮询任务,时间间隔为 2min,在这个过程中将会实时地拉取外网的业务版本配置,并与本地的配置进行比对,如果发现版本有更新,将会下载最新的业务包,并更新到本地缓存中,提高缓存命中率。
小结
针对成功率优化部分,我们通过结合内置包和缓存包机制来完成,这里的优先级是优先使用缓存包,因为得先保证命中最新的版本,而当缓存失效或者不存在缓存包时才会命中内置包,只有当内置包不存在时,才会实时取外网拉取并下载最新包,以此提升成功率,再结合外网缓存命中率和 Crash 告警,实时监听外网成功率变化。
3.2.4 优化效果
另外我们还做了其他的一些优化,比如在用户体验部分,我们也采用了骨架屏的方式减少用户肉眼等待时间;内存部分,通过前端业务侧和客户端的图片缓存,按容器尺寸渲染等方式减少了内存消耗;针对 CPU/GPU 优化部分,我们与官方的团队保持密切的联系,并通过推动优化 SDK 升级等方式解决。
通过结合加载速度,卡顿和成功率三个纬度的优化,整体下来目前取得了不错的效果,其中成功率已达 3 个 9,主业务 FPS 达 54+,这两个指标均与 Native 对齐,另外整体秒开率方面对比优化前有了 5% 以上的提升,达到 85%+。
3.3 质量监控
3.3.1 自动化测试
下一部分是质量监控部分,这里我们与测试团队一起打通了自动化的测试流程,利用测试同学提供的测试机与埋点服务,保障业务发布前的稳定性,具体的流程如下:
- 首先是前端同学触发一个业务的构建,当业务构建成功后会同步至我们的虚拟或体验环境;
- 当业务包在环境同步结束之后,构建系统会向测试同学所提供的服务触发一个钩子,并附带环境,业务名与具体的版本号信息;
- 测试服务收到这些信息之后,通过脚本启动真机群,并在上面运行指定环境与版本的业务,通过埋点信息记录业务的性能数据,包括首帧,秒开率等等指标;
- 自动化测试流程结束之后会回调给我们的构建机,构建机发起微信/邮件的结果推送,并记录保存该任务的测试结果;
- 到业务的发布阶段,通过上面保存的测试结果,可以对业务的发布进行二次确认或阻断。
3.3.2 监控系统
监控指标梳理
在做监控之前,我们首先对监控上报的指标进行了梳理和统一,大致可分为 5 大类,首先是性能纬度,这里包含了业务运行时所占用的内存,CPU,GPU,FPS,引擎耗时和首帧耗时等等;然后是业务 bundle 纬度,包括了业务的下载时间,加载时间和包体积大小等;紧接着是接口部分,包括了客户端和后台接口的调用,具体指标有调用/请求耗时,返回码,通信数据大小等;再往下是错误纬度,包括 JS 异常,业务运行时的 Native Crash 等;最后还有一些大图检测和进程通信等等上报。
监控系统
最后是监控系统部分,其实在之前很长一段时间内我们内部有使用过多个不同的监控系统,这样做的历史背景是每个监控系统的侧重点有所不同,这就导致我们在比较长一段时间内需要到各个平台上筛选不同的上报数据,这对问题的定位和排查造成了很大困扰和阻碍,在这之后我们下定决心把所有的上报口径和渠道统一起来,最终收归落到同一个平台上。
一个靠谱好用的监控系统应该支持实时性,多维度/指标看板和自定义告警等能力,在经过一段时间的试用和产品 PK,我们最终采用腾讯内部自研的监控系统,同时也参与到内部的开源共建中。
4. 最后
本篇文章首先是对跨端技术的演进过程进行了介绍,从整个变迁过程中我们会发现,跨端技术的演进实际上是以 H5 代表的效率、动态性逐步迁往 Native 代表的性能体验的过程,并在整个过程中不断寻找两者间的平衡点。在跨端建设的实践过程中,往往需要建设一系列配套建设和针对性性能优化,这里也介绍了我们在这方面的一些实践。
最后引用阿里圣司之前在分享中的话,“性能体验、研发效率与稳定性是前端三驾马车,围绕这些大方向很多技术规划都是类似的,但落到执行结果上千差万别;顶层设计之下,对每个细节的执行与积累存在差异,而这些差异聚合在一起决定了成败”。