阅读视图

发现新文章,点击刷新页面。

2024 抖音欢笑中国年(五):Wasm、WebGL 在互动技术中的创新应用

前言

随着 Web 前端技术的不断发展,越来越多的新兴技术方案被引入到 Web 开发中,其中 Wasm 和 WebGL 作为前端领域的两大利器,为开发者带来了更多的可能性。

本文将结合2024 年抖音欢笑中国年的部分项目,重点介绍如何利用 Wasm 和 WebGL 对目前流行的一些前端互动技术(比如 Lottie、渲染引擎、动画图片等)进行创新和实践,利用 Wasm 和 WebGL 等新技术方案的特性和优势提升业务性能和流畅度,给用户带来更好的体验。

Simple 渲染引擎

WebAssembly(Wasm)是一种可以在 Web 浏览器中运行,提供比 JavaScript 更高的性能,并且支持多种编程语言的全新的字节码格式;基于其高性能的优势,我们团队尝试将其应用到渲染场景中,推出了基于 Wasm + WebGL 的高性能、轻量化的 Simple 渲染引擎。

以期借助于 Wasm 的高性能计算,以 Simple 引擎为基础,保持轻量化的同时,解决目前前端动效和轻互动场景主流技术方案如 Lottie 动画、动画图片(序列帧、 Apng 、WebP)、JS 渲染引擎等存在的能力受限、资源体积大、性能较低等问题。

引擎架构

考虑到前端用户学习和使用成本,Simple 引擎使用 TypeScript 语言开发上层接口,主要是利用 TS 封装简单对象,同时做类型提示方便前端用户使用,另外还提供尽可能高性能的方式和 Wasm 进行交互;底层则使用 C/C++,主要处理计算工作,比如:矩阵计算、图形计算、动画计算、动态合批等;

Simple 引擎目前的渲染管线主要以 2D 为主,也分为两部分:JS 部分负责处理数据量少但是 GL 调用频繁的操作,Wasm 部分则相反负责处理数据量多但是 GL 调用少的操作,尽可能达到性能最优解。

整体架构如下:

0b7490e96201ec48415abe448e6f41a3.png

性能收益

受益于 Wasm 的计算性能优势,Simple 引擎相比主流的 JS 渲染引擎,比如:PixiJS 6.3、Cocos 2.4 在 Spine 动画、精灵旋转、精灵跳动、图形水平移动等基准性能测试场景中取得了不俗的表现:

41134740c43c0f8270a2ceab7ee62943.jpeg

以上测试数据来自 Android OPPO Find X1 抖音 跨端框架 V8 环境,可以看到基于 Wasm 的 Simple 引擎相比基于 JS 的引擎性能提升明显,计算任务越复杂,性能收益越大

计算任务复杂度:骨骼动画 > 图形计算 > 旋转变换 > 位移变换

测试代码

100 个 Spine 动画 Simple 和 Cocos 2.4 测试代码如下:

  • Cocos 2.4

875be47a74bc90af5cb69348e23a163c.png
  • Simple

948015216feb3cc8f58f77b0b5edb2eb.png

兼容性

从 2015 年 Wasm 项目正式启动到现在,经过多年的发展 Wasm 规范不断完善和扩展,目前主流的浏览器已经全面支持 Wasm 技术;同时 Simple 引擎最早在 2022年7月启动,之后在直播、财经、头条、音乐、小说等多个业务场景中落地,并在 2024 年初获得了在春节项目中应用的机会。

从数据来看抖音跨端框架中使用 Wasm 的用户占比高达 96.97% ,对于不支持 Wasm 的情况也可以使用 Simple 引擎编译的 asm.js 版本来进行降级。

经过一年多的反复实践与验证,总得来说 Simple 引擎兼容性表现稳定可靠。

Lottie WebGL 渲染

Lottie WebGL 渲染是利用上文提到的 Wasm + WebGL 渲染引擎去渲染 Lottie 动画的方案,在几乎完美还原 Lottie 动画的基础上,利用引擎封装好的相关机制(事件、渲染对象)扩展 Lottie 动画的交互控制能力,丰富其特性支持,以及基于 Wasm + WebGL 渲染提升动画性能。

性能收益

Lottie 是动画 Airbnb 公司开源的跨平台动画框架,支持将 AE 中设计的动画导出为 Json 协议,是前端最流行的动画协议;但是在 Web 上官方只提供了三种渲染方式(SVG、Canvas 2D、HTML),并没有支持 WebGL:

2bdb4f1052a0931cbec0a992a4c85600.jpeg

而前端最流行的那些 JS WebGL 渲染引擎也没有直接支持 Lottie 动画,这就会带来三个问题:

官方渲染方案性能低

SVG、HTML 不适合处理大量元素的动画,而 Canvas 2D 的使用方式决定了它很难充分利用缓存机制,因此官方提供的这三种渲染方案其实性能是偏低的,会在较多、较复杂动画场景遭遇性能瓶颈。

0f212bf8c21d1df5ac9abe326db87ee2.jpegcb5db40736d2d31bf205c7617dad93a6.png

如上图所示某 Lottie 动画在 Chrome 6 倍降速模拟移动端设备的性能表现,Lottie SVG 每帧耗时 5.77 毫秒,而 Simple Lottie 每帧耗时仅 1.10 毫秒,性能提升近 6 倍。

离屏 Canvas 渲染性能低

目前主流的 JS WebGL 渲染引擎只能使用离屏 Canvas 的方式去渲染 Lottie 动画,这种方式需要先创建一个离屏的 Canvas 然后用 Lottie 官方提供的 Canvas 2D 方式把动画的当前状态渲染到离屏 Canvas 中,接着再把这个离屏的 Canvas 用纹理的方式上传到 GPU,如果动画更新还需要重复这个流程。

相对复杂的渲染流程会导致其性能较低:

bfc872288ec0fecc293985c55391846d.png

1b88f7c5e69fb147c2f2931f9e7681dd.png

如上图所示,12 * 18 个 Lottie 动画在 i7 Mac Chrome 上测试,右侧 WebGL 直接渲染 Lottie 动画比左侧离屏 Canvas 渲染帧数高了 35 fps,性能提升近 3 倍。

JS 图形计算性能低

提升 Lottie 动画性能,除了要考虑渲染性能,还需要考虑计算性能,比如上面两个例子中的 Lottie 动画更多是图片元素,但是矢量图形也是 Lottie 动画中非常重要的功能。

如果基于目前主流的 JS WebGL 渲染引擎去渲染 Lottie 动画,在以矢量图形为主的 Lottie 动画中对比 Lottie 官方提供的 SVG 或者 Canvas 甚至会出现性能劣化的情况:

d934ff9f49eab98c6e3a59b8d419940b.jpegc6801d39ecbae9a9050184ca80628455.jpeg

可以看到左侧 SVG 在动画峰值每帧仅需 6.26 毫秒,而右侧 PixiJS 在动画峰值每帧需要 11.68 毫秒。

基于 JS WebGL 渲染引擎渲染 Lottie 动画在矢量图形场景出现性能劣化最主要原因在于 SVG 的计算在浏览器封装的 Native 模块中进行,而 JS WebGL 渲染引擎的计算在 JS 中进行,哪怕是几乎优化到极致的 PixiJS 受限于 JS 也只能在图片元素等部分 Lottie 动画中取得性能优化收益;因此只有采用 Simple 引擎这种 Wasm + WebGL 的方案才能彻底、全面优化 Lottie 动画的性能

905f5c96220564096eae1925f85e8ce6.png

相同场景 Simple Lottie 在动画峰值每帧仅需 5.35 毫秒(这其中还包含了 JS 动画参数插值计算部分,后续这部分 JS 计算也下沉 Wasm ,整体估计还能优化 1 毫秒 )。

交互控制

使用 Simple 引擎直接去渲染 Lottie 动画,除了性能上的收益之外,还利用引擎提供的能力增加了很多交互控制上的便利,比如:素材替换、事件监听、动画混合、文字变更、物理碰撞等等。

在 2024 年春节群红包雨项目中几乎全部的素材都是 Lottie 动画素材:

4af8e382b0d1c28e3969993e3d7f697f.png

0736769d7b4100ed9acc77a2abe4b0b8.png

静态的 Lottie 素材结合 Simple Lottie 提供的动态交互能力就可以很方便的实现诸如红包点击、红包动态纹理、点中动画动态文字、大红包点击、连击动画动态数字等等。

举个例子,用户点中红包雨之后需要播放一个动画,整体是一个烟花的效果,需要随机展示不同的文字:

7e42529a36aea53847702d1aa412fe34.jpeg

对于这个需求,设计同学只需要提供一个固定的 Lottie 动画,然后再提供一些其它的文字素材;开发则需要在代码中首先从动画中查找到文字精灵,然后随机选取一张文字纹理,最后更新文字精灵的纹理即可。

b88a62f521e5a8fb9a137b56ca561889.png

特性支持

使用 Simple 引擎去渲染 Lottie 动画,还能在原有 Lottie 特性支持的基础上增加更多的能力。比如从动画素材上来说我们可以把原始 Lottie 动画产物中零散的图片合成一张雪碧图,减少资源请求数量,减少纹理个数,提升渲染性能:

e0251a69adabb2f0b55642088645813d.png

33136a79d563d4896cc8b666746a30e0.png

左侧是散图,右侧是雪碧图。除此之外甚至还能把图片转成压缩纹理,减少内存占用和加快渲染速度。

除了动画素材上的优化,还能给 Lottie 动画增加更多的渲染效果,比如:

粒子
6d1955696c83244ab161c9acdf71c793.jpeg
滤镜

Simple 引擎通过 Shader 实现了很多常用的滤镜效果,比如:

53c37f51345b32b0de9a5a3b8d50960b.png

d73d0a4e76634cf802a40700847c1089.png

左侧是模糊效果、右侧是颜色卷积效果。

自定义效果

基于 Simple 引擎提供的 WebGL 能力封装,还可以实现更多更丰富的自定义效果,比如:透明视频以及下面将要介绍 WebGL 帧差序列帧。

WebGL 帧差序列帧

帧差序列帧是一种基于 WebGL 1.0 标准的动画图片规范,由首帧图片、帧差图片(不透明帧差、半透明帧差)、帧差配置信息组成,一般包含 4 个文件:

2abdb79a4955255221847c183283e2a9.jpeg

背景

由于原始 Lottie 动画只支持少部分 AE 特性,对于不支持的特性设计师往往会把这些效果转成序列帧实现,这就会导致动画素材产物体积增加。而另一方面目前常用的视频、Apng、WebP 这些主流素材又各有各的问题,比如:

  • 视频存在兼容性问题、不能交互、对低端机来说存在一定的性能压力;

  • Apng、WebP 这些传统动画图片格式基本没怎么考虑帧间压缩:

4d659f28793f06ffe423fa7cb9a3393f.png

上图是一张 Apng 解码后的信息,IDAT 表示首帧、fdAT 表示后续帧,可以看到后续帧和首帧几乎一样大,也就是说对于这个素材生成的 Apng 效果和简单拼接的原始序列帧差不多。

资源体积优化

基于上述现状,如果能基于兼容性最好的 WebGL 1.0 标准去实现一些类视频的简单自定义帧间压缩算法, 就能在动画场景以更小的资源体积完美替代序列帧、 Apng WebP

2930afdb287ee8bb16d8288ad34ffa99.jpeg

对于一般素材帧差序列帧在体积上相比序列帧、 Apng WebP 会减少 50% 左右。

交互能力

帧差序列帧还能以更高的性能和提供任意时刻切任意帧的能力,实现一定的交互能力,从而满足一些轻互动场景的需求,对视频和 3D 模型形成差异化优势。

4f7127c6c4a27395de8120b1075cecf1.png

6668d3da94f67114b3a591786fc5c95e.jpeg

caa532be53baea4b0ac369d18729ddff.jpeg

我们以小火人、打年兽和徽章项目为例:

  • 小火人、年兽的动画状态是会随着用户的操作发生变化的,而视频是很难实现无缝的帧切换;

  • 徽章需要 360 度旋转展示,同时还需要响应用户的滑动操作,一般情况下这里只能使用 3D 模型,那么整个项目就得以传统 3D 项目的流程去做,一方面成本会高出很多,另一方面考虑实时渲染性能压力,效果上可能还做不到百分百还原。而采用帧差序列帧的方案去做,项目成本就会低很多,同时效果上也不会有任何折扣。

未来展望

以上即是我们团队在 Wasm 和 WebGL 上的一些尝试与思考,虽然取得了一定的突破,但是方案本身还有很多需要优化和完善的地方:

  • Wasm 标准依然在继续更新,SIMD、线程、垃圾回收、WASI 等新特性会进一步提升性能,同时可能会对现有引擎架构的升级产生很大影响;

  • 帧差序列帧目前的帧间压缩效率只是优于序列帧、Apng、WebP,和视频相比还有很大差距;在保持现有优势的前提下怎么更接近视频是充满挑战的问题;

  • Lottie 动画性能得到了优化,增加了很多能力;这些能力怎么高效率的开放给设计和开发去使用,怎么改进现有工作流效率,动效、轻互动的完美工作流需要满足哪些标准等等。

欢迎大家和我们团队一起探索和交流,共同推进技术的发展。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

下期主题是前端互动场景中的性能优化,将介绍前端互动页面在与宿主端共享有限进程资源的场景下遇到的性能问题及相应的解决方案,敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

2024 抖音欢笑中国年(三):编辑器技巧与实践

2024 抖音欢笑中国年(四):渲染技术实践与探索

2024 抖音欢笑中国年(四):渲染技术实践与探索

作者:陈瑞、欧阳浩铸、王武俊、倪梵云

前言

抖音在2024年春节期间推出了欢笑中国年系列活动,为用户带来了全新的体验和乐趣。而SAR Creator则为该项目研发工作提供了重要的技术支持。SAR Creator是一款基于 Typescript 的高性能、轻量化的互动解决方案,目前支持了浏览器和跨端框架平台,服务于字节内部的各种互动业务。

这些绚烂多彩的互动场景当然也离不开实时渲染技术的支持,因此本文将专门介绍春节活动招财神龙神龙探宝中SAR Creator渲染相关的业务实践经验以及技术探索和尝试。

春节—招财神龙

7345d622438ef00c6dc710ba6ca73298.gif

春节—神龙探宝

0773877fecef5af189e5529b1e164646.gif

比如抖音欢笑中国年系列文章《招财神龙互动技术揭秘》中就有提到,项目中“家”场景就是由2D元素以及不同材质支持的3D元素共同组成的。出于性能和美术效果的考虑,各3D模型使用的材质会有所不同,比如无光照Unlit材质、基于物理的PBR材质。对于阴影这种移动端性能消耗比较大的特性,不同物体的接收也会做特殊处理。这些材质的选择以及光照阴影的支持都是依托于SAR Creator材质库能力的支持。如下图所示即为SAR Creator Unlit材质(左图)和PBR材质(右图)的示例。

736c2f25060ad4c19b787a7ca48b685f.png
无光照Unlit材质示例

e98c392344d5de3ed7b7ad78ed12b931.png

基于物理的PBR材质示例

此外,SAR Creator支持使用ShaderGraph插件制作自定义材质,帮助用户制作更多可定制化的特效。神龙探宝项目中实现了多种基于ShaderGraph的特殊效果,包括:入场溶解特效、分区解冻特效、拖尾特效等。下图展示了利用ShaderGraph定制卡通风格水体的效果。

e54fa82a89251689459dbeb48f5d5f7e.gif

ShaderGraph卡通水体

除了上述春节活动中顺利落地的渲染效果外,我们还尝试做了很多效果提升的技术探索,比如后处理辉光效果、凹凸贴图等。希望可以更好的提升美术设计师的设计体验和最终的渲染效果。

55a5f3b786490a099e7cc180c1553988.gif

bloom辉光

3ae0d3723647a5cdb90b42ce4f0dbd88.gif

招财神龙渲染实践

“招财神龙”活动是2024年春节游戏化玩法之一,活动整体采用3D场景(龙在家场景)+ 2D场景(龙寻宝场景)结合的方式。在招财神龙的活动中,设计同学基于SAR Creator编辑器,进行场景搭建和效果还原工作;研发同学基于SAR Creator渲染能力,快速进行技术方案选型和实施。

2D&3D混合渲染

对于活动中的“龙”和“小女孩”元素,我们采用3D模型,提供更为逼真的体验感。而针对场景中的房子、炮仗等,我们使用2D贴片来呈现。通过调整相机的远近平面、fov等参数,展示出小女孩在炮仗前、龙在房子前、龙在炮仗后的视觉假象。

336d0293c792719c9641cb25a6a0f0da.png

34d8f2b847a414da1de5a98bc5183187.png

a21bb64ca35245440ec48320b81758ab.gif

材质库

SAR Creator提供了Unlit、PBR、Uber和NPR等多种材质的选择。

8a85431372592a5662db84565458ec2a.png

例如,这次“招财神龙”中的白天/黑夜场景场景,小女孩和龙的皮肤颜色等需要有不同的表现,就是基于材质的“颜色贴图”能力来实现的。

07f0149d3361217c6e15319eee486fb2.png

针对PBR材质来说,设计同学还基于SAR Creator提供的金属度、粗糙度来进行小女孩身体细节的调整。

ed71f2a0d9db0de3a1ceaf78ecd2604c.png

为了追求更佳的视觉体验,在小女孩的模型上,设计同学为不同的部位(身体、头发和衣服)赋予了不同的PBR材质的实例,再通过调整不同PBR材质的金属度、粗糙度属性,微调受光条件下,不同部位的表现细节。

这次活动,我们不仅使用了PBR材质,综合性能和实用性的角度,还使用了Sar Creator提供的Unlit材质。比如“龙”模型中的身体、胡须、眼睛等模块。Unlit材质是一种简单的、不受光源影响的材质,在技术选型时,为了平衡性能和效果,通常是活动开发的首选。

d7b7383b8145219e1421bdb58c85f4e4.png

光照阴影

除了上述所说的这些材质,为了实现场景中元素的真实性,设计同学借助SAR Creator提供的渲染能力,利用灯光、阴影来优化渲染场景。

SAR Creator提供了平行光的方向、颜色、强度等属性,使得设计同学可以调整出不同效果。

39976b076785d8074127a86f6af5905f.png

为了更好的光照效果,我们这次使用了两个平行光,利用PBR受光的特性,可以实现更贴近真实世界的效果。

d9b082ee9df047348c1f5b388821fc7a.png

只使用了环境光

44f2296144524ae50720137dc8e93e3a.png

使用了一个平行光(小女孩鞋子、脸、手部等部分都收到了光照影响)

9db27f1336e809dcff31fd9f287529d7.png

使用了两个平行光微调(小女孩背部收到了光照,更贴近真实效果)

只有光照,没有阴影的话,同样也不符合物理世界的客观规律。SAR Creator通过在光源上设置“投射阴影”,在需要显示阴影的物体上,设置“接受阴影”,即可快速的实现阴影的效果。

81d9ef000bbe49b1da1ec6aaca9ee2ed.pngf7817ec7b92b2c2ecce0037551b6b3f8.png

787cf895fa8694d31a1003447133d4c3.png

无阴影

d8e376ac6b1159d19890b34badf89739.png

有阴影

利用SAR Creator提供的ShadowMaterial这种自实现的材质,我们还能通过调整颜色、透明度等常用属性,快速调整出设计师想要的阴影效果。

8ebfacd7274e6f0220e782fcfd6e8e65.png

神龙探宝渲染实践

神龙探宝是2024年春节系列活动中的一个以2D场景为主的互动玩法,其尝试并成功落地了多种特效渲染技术。本章节主要有三个特效渲染技术点可分享给大家。分别是:入场溶解特效、分区解冻特效、拖尾特效。

入场溶解

实现入场溶解特效核心是采样一张溶解图(可低分辨率128x128),通过动画step.edge即可。该方案主要通过ShaderGraph可视化界面开发Shader帮助实现美术预期效果,具体节点实现实现如下图所示:

5ad9bb97d13e346c63d30a2bce591f0b.gif

5154513474f9f4c3e21492d7ba98a5aa.png

如想了解更多技术细节,各位同学可用WebGPU版ShaderGraph在线体验(https://deepkolos.github.io/shader-graph-wgsl/?graph=demoSummberDissolve)(PC Chrome113+),也欢迎内部同学直接体验SAR Creator。

地图分区解冻

地图分区解冻需要实现的效果是支持分区块单独控制其处于解冻/未解冻状态。表现效果会随着解冻状态的变化而变化。

如果按照传统前端实现估计需要7个区域小图+1张底图=8张图片去实现, 即需要消耗8个绘制指令(DrawCall)。虽然传统方式可以通过动态合批的方式优化绘制指令(DrawCall)为1个,但合批操作本身也有耗时,且每次资源替换+小图位置调整,会带来额外工作量。而利用ShaderGraph插件定义支持图片存储特定贴图IDMap的Shader可解决这些问题,只需一张JPG 一张PNG 即可。首先我们需要将区域信息存储在A通道,比如区域A = 0.9 区域B = 0.8 以此类推。

未点亮

caf77b7f7548674f540b340cb21cb5e5.png

已点亮

393e03faf9a1aea4f4b442a22828155f.png

解冻过程

4dd5cbffd0801726a243732e2c70589e.gif

然后在Shader中根据点亮前后纹理采样颜色值,混合计算出最终像素颜色值,以实现每个区域的解冻/未解冻状态变化。具体计算逻辑如下所示:

1641a5d6cb74f5dc92f07aeaa7aac780.png5e04238b882a497af44647829418a96b.png

如想了解更多技术细节,该例子也可用WebGPU版ShaderGraph在线体验(https://deepkolos.github.io/shader-graph-wgsl/?graph=demoCustomMap)(PC Chrome113+)。

然而在上述效果的实现基础上,设计同学提出了更高的渲染需求,要求冰冻区域沿边缘浸染已解冻区域,来避免硬边缘。由于项目时间节奏比较紧张,综合考虑时间成本和收益后,边缘浸染需求最终没有推进支持。通过简单的调研,该效果可能的一个解法是:增加7个2D光照,通过光照计算范围来实现冰冻浸染效果,但问题在于没法实现沿边缘浸染。各位如果有什么好的思路也可以分享下,也许可以通过ShaderGraph插件快速支持这种定制需求。

粒子拖尾+几何拖尾

设计效果

f0d6cb18492987003dace0e57cdd1ec2.gif

落地实现

30c220951971cc3ba028452bcff90173.gif

我们可以分析表格第三列中效果参考的构成,得出技术要点为: 几何拖尾+粒子拖尾+头部星光。头部星光较为简单,只需要一个Sprite/Plane+Tween增加下旋转动画即可。下面将主要介绍粒子拖尾和几何拖尾的技术实现。

de9545f53ca53b482c3a70ec345f3ec1.png

粒子拖尾

目前SAR Creator现有非常强大的粒子系统,可快速实现粒子效果,提效非常明显。

8c4ebad09160abc28f675aa887b2c05e.png

但完整的粒子系统在功能强大的同时包体积也相对较大,为了兼顾粒子效果的同时也避免包体积问题,需实现简易版拖尾粒子。通过ShaderGraph结合EmitOverDisatance,抽离出的粒子拖尾特效资源打包后只有7.66KB。下图左为: 简易版粒子拖尾+EmitOverDistance+ShaderGraph联动,下图中/右为: 粒子系统示例和ShaderGraph定制材质的参数设置。

a220a3ec0bed89eb28f8e48b7f468357.png

655e1e5c0ecb42cf5b20e5a612c8c758.gif

ed0746fcd21b5ee8ac38163ded5fbdcb.png

目前粒子拖尾已集成进SAR Creator以及ShaderGraph插件,方便用户更加直观的调试材质特效。

28c3ea21774df80cfeaa74646e0a7c9c.png

GPU Instancing是一个DrawCall绘制大量相同几何,姿态不同的技术。

所以简易版拖尾粒子本质上是一个GPU Instancing几何更新器

7e100ba8879beff159dc3adf9fd2f39a.png

3e24629dc3f7f3dee6ea31c0d0ca14e3.gif

48375ea3ba6c515e78458255a434fc92.png

0a974ff379ea89c01ac1b1ff0b0ee50d.png

大量粒子的位移动画通过Shader使用GPU并行算力完成,节约宝贵的CPU算力。

e9ca8bf996bcdb72c5e1c16385a2d79a.png

感兴趣代码实现,可查看👉开源实现(https://github.com/deepkolos/three-js-trail) or 线上Demo(https://deepkolos.github.io/three-js-trail/),或者SAR Creator中直接体验。

几何拖尾

几何拖尾本质上也是一个几何更新器,不过并非更新Instanced数据,而是几何本身数据Position+Index,使用下图可直观了解几何拖尾的关键逻辑。

939703f5cfebf697e35ffb5a843778ed.png

fae1e8371003b186fcf2e6aad74f7ebf.png

1144e0de44db55950dd7d1458dfe235a.png

如上图左所示为几何拖尾的几何部分实现,参考效果的游动效果则需要在Shader中增加UV动画+拖尾沿Brush重心缩小,所以几何拖尾同样支持ShaderGraph扩展材质。

7f525d7adc0ae085647588279ebb846d.png

72cbd4f7ccc3b66e561901fadbdde60b.gif

如感兴趣代码实现,可查看👉开源实现(https://github.com/deepkolos/three-js-trail) or 线上Demo(https://deepkolos.github.io/three-js-trail/),或者SAR Creator中直接体验。

ShaderGraph探索

ShaderGraph自定义Shader相较于研发编写定制材质而言主要优势在于更高的自由度。SAR Creator通过ShaderGraph插件可以边看中间结果,边理解特效的实现方式。同时帮助用户更好的调试渲染结果高度不可控的特效。此外,ShaderGraph插件实现思路和节点能力实现方式与Unity ShaderGraph基本一致,用户可以以极低成本的方式参考Unity已有特效并搬运到SAR Creator上。

比如抖音故障效果:

61c5350c9e01c526e9845c88f5570c2a.png

再或者卡通水体WebGPU版ShaderGraph在线预览(https://deepkolos.github.io/shader-graph-wgsl/?graph=demoCartoonWater):

4ff473dfb317bf788819070f44f39173.png

渲染技术探索

除了在项目实际落地的渲染技术外,我们也在春节项目中尝试探索渲染技术可能应用场景。下面我们将通过后处理篇和材质篇来进一步介绍其中的技术点。

后处理篇

比如在“招财神龙”的龙须上,我们希望能增加辉光bloom的效果。bloom是屏幕后处理效果中较为常用的一种,表现为高光物体带有泛光效果,通常会搭配HDR来得到更好的效果。

d175823fe1054da9bb5eee75c6c8dd8b.gif

在技术方案的实现上:针对此类特定区域的辉光,我们引入了亮度阈值。第一步,对原场景图进行筛选时,所有小于这个阈值的像素都会被筛掉,仅保留大于等于该亮度阈值的区域,即我们的龙须区域。第二步,对上一步操作的结果龙须区域进行模糊操作,达到光溢出的效果。最后,我们将处理过的图像和原图像进行叠加,就得到了最终的效果。

de591ea8cbfb447edbd0848c30176af5.png

bloom渲染流程示意图

bloom渲染流程中的第一步:过滤高亮区域,我们在shader的属性中加一个lumaThreshold,然后提取图片像素亮度进行step过滤,在片段着色器中代码示例如下:

uniform float lumaThreshold;

float luminance(vec3 color) {
    return dot(color,vec3( 0.299,0.587,0.114 ));
}

void main{
    vec4 texel = texture2D( tDiffuse,vUv );
    float luminosity = luminance(texel.xyz);
    float contribute = step( lumaThreshold,luminosity );
    gl_FragColor = texel * contribute;
}

bloom渲染流程中的第二步,图像模糊算法在后处理渲染领域中占据着重要的地位,后处理中所采用模糊算法的优劣,直接决定了后处理管线最终的渲染品质和消耗性能的多少。高品质后处理:十种图像模糊算法的总结与实现(https://zhuanlan.zhihu.com/p/125744132) 对十种模糊算法进行总结、对比和盘点,其中双重模糊(dual blur) 获得了高性价比评价,故SAR Creator中bloom的模糊算法采用双重模糊进行了实现。双重模糊的核心思路在于模糊的过程中进行了降采样和升采样,即对RT进行了降采样以及升采样。

83ed52badf1e76c55fd983ba8621509f.png

3bd657aa8317fef625f62189bf8e7860.png

部分代码示例如下:

// 新建renderTexture数组
for (let i = 0; i < downSampleNum; i++) {
    this.fboArr[i] = new RenderTexture(0,0,fboOptions);
    if (i !== downSampleNum - 1) {
    this.fboArr[(downSampleNum - 1) * 2 - i] = new RenderTexture(0,0,fboOptions);
    }
}
// 下采样
for (let i = 0; i < downSampleNum - 1; i++) {
      uniforms.tDiffuse.value = fboArr[i].texture;
      uniforms.halfPixel.value.set(1 / fboArr[i].width,1 / fboArr[i].height);
      fsQuad.render(renderer,fboArr[i + 1]);
}
// 上采样
const n = downSampleNum;
for (let i = downSampleNum - 1; i < (downSampleNum - 1) * 2; i++) {
    uniforms.tDiffuse.value = fboArr[i].texture;
    uniforms.downTexture.value = fboArr[2 * n - i - 3].texture;
    uniforms.halfPixel.value.set(1 / fboArr[i].width,1 / fboArr[i].height);
    fsQuad.render(renderer,fboArr[i + 1]);
}

此外,SAR Creator提供了多种blur kernel,设计师可以切换对比,调整出自己想要的光晕效果。

07a2665fb613140b4d3ad090d051d7dc.png

所有的模糊算法都是利用周遭像素值加权叠加计算得到结果的,权重则取决于距离。部分模糊算法的shader实现如下:

#if BLUR_KERNEL == 0 // --------------- Kawase ---------------
void blurKernel() {
    #if SAMPLE_PHASE == 0 // down
    vec4 sum = texture2D(tDiffuse,vUv) * 4.0
            + texture2D(tDiffuse,uv01.xy) + texture2D(tDiffuse,uv01.zw)
            + texture2D(tDiffuse,uv23.xy) + texture2D(tDiffuse,uv23.zw);
    gl_FragColor = sum * 0.125;
    #elif SAMPLE_PHASE == 1 // up
    vec4 sum = texture2D(tDiffuse,uv01.xy) + texture2D(tDiffuse,uv23.xy)
            + texture2D(tDiffuse,uv45.xy) + texture2D(tDiffuse,uv67.xy)
            + (texture2D(tDiffuse,uv01.zw) + texture2D(tDiffuse,uv23.zw)
            +  texture2D(tDiffuse,uv45.zw) + texture2D(tDiffuse,uv67.zw)) * 2.0;
    gl_FragColor = sum * 0.0833;
    #endif
}
#elif BLUR_KERNEL == 1 // --------------- Box4Tap ---------------
    void blurKernel() {
        vec4 sum = texture2D(tDiffuse,vUv + uvOffset.xy) + texture2D(tDiffuse,vUv + uvOffset.zy)
                + texture2D(tDiffuse,vUv + uvOffset.xw) + texture2D(tDiffuse,vUv + uvOffset.zw);
        gl_FragColor = sum * 0.25;
    }
#elif BLUR_KERNEL == 2 // --------------- Tent9Tap ---------------
    void blurKernel() {
        vec4 sum = texture2D(tDiffuse,vUv + uvOffset.xy)
                + texture2D(tDiffuse,vUv - uvOffset.xy) 
                + texture2D(tDiffuse,vUv + uvOffset.zy)
                + texture2D(tDiffuse,+ vUv - uvOffset.zy)
                + (texture2D(tDiffuse,vUv - uvOffset.wy) 
                +  texture2D(tDiffuse,vUv + uvOffset.zw) 
                +  texture2D(tDiffuse,vUv + uvOffset.xw) 
                +  texture2D(tDiffuse,vUv + uvOffset.wy)) * 2.0 
                + texture2D(tDiffuse,vUv) * 4.0; 
        gl_FragColor = sum * 0.0625;
    }

材质篇

互动活动场景中经常会出现3D地形,如果通过建模软件来生成三角形面片几何体的方式支持该需求的话,会存在三角面片数过高从而消耗性能过大的问题。此外,设计师很难得到所需要的凹凸起伏的建模,设计调整成本过高。一般而言,设计师会希望通过一张灰度图来表示相应区域的高低,从而通过控制平面中各个着色点在垂直方向上的偏移来表达起伏。

而支持这种实现的技术就是位移贴图(DisplacementMap)。如下图1、2所示展示了PBR材质修改位移贴图缩放指数(DisplacementScale)为0和2的区别。如下图3所示展示了控制高低的位移贴图。

50e0c138a5ed36f4ba5087844cb32c2e.png

472487c452d39e8d420c6a212ce176b7.png

ffb95da908bd480bfb1b185bd97ba795.png

位移贴图的实现方式是在材质顶点着色器VertexShader中基于原始顶点信息、结合位移贴图对顶点偏移的计算,得到实际展示的顶点位置,具体shader中定义和实现如下所示:

// 前置信息定义
#ifdef USE_DISPLACEMENTMAP
    vec2 displacementUV; // 位移贴图UV
    uniform mat3 displacementUVMatrix; // 位移贴图UV变换锯齿
    uniform sampler2D displacementMap;  // 位移贴图
    uniform float displacementScale; // 位移缩放比例
    uniform float displacementBias; // 位移偏移
#endif
// 顶点计算
#ifdef USE_DISPLACEMENTMAP
    positionV4.xyz += normalize( positionV4.xyz ) * ( texture2D( displacementMap,        displacementUV ).x * displacementScale + displacementBias );
#endif

此外,与位移贴图调整当前绘制点顶点不同,还可以通过调整当前绘制点法线计算的方式使得渲染结果展示出一种比较细致的凹凸感,即凹凸贴图(BumpMap)技术。如下图1、2所示展示了PBR材质修改凹凸贴图缩放指数(BumpMapScale)为0和100的区别。如下图3所示展示了控制表面粗糙感的凹凸贴图。

be424966f632df9903d20fdc260210f0.png

407c6235afb58159ceaa67d0cb35f0a6.png

dcb50fb7504ebbfae7a616798fd8c54f.png

凹凸贴图的实现方式是在材质片元着色器FragmentShader中基于原始顶点/法线信息、结合凹凸贴图,计算出调整后的法线结果,具体shader中定义和实现如下所示:

// 前置信息定义
#ifdef USE_BUMPMAP
    varying vec2 bumpUV; // 凹凸贴图UV
    uniform sampler2D bumpMap; // 凹凸贴图
    uniform float bumpScale; // 凹凸缩放比例
#endif
#ifdef  USE_BUMPMAP 
    vec2 dHdxy_fwd() {
        vec2 dSTdx = dFdx( bumpUV );
        vec2 dSTdy = dFdy( bumpUV );
        float Hll = bumpScale * texture2D( bumpMap,bumpUV ).x;
        float dBx = bumpScale * texture2D( bumpMap,bumpUV + dSTdx ).x - Hll;
        float dBy = bumpScale * texture2D( bumpMap,bumpUV + dSTdy ).x - Hll;
        return vec2( dBx,dBy );
    }
    vec3 perturbNormalArb( vec3 surf_pos,vec3 surf_norm,vec2 dHdxy,float faceDirection ) {
        vec3 vSigmaX = normalize( dFdx( surf_pos.xyz ) );
        vec3 vSigmaY = normalize( dFdy( surf_pos.xyz ) );
        vec3 vN = surf_norm;
        vec3 R1 = cross( vSigmaY,vN );
        vec3 R2 = cross( vN,vSigmaX );
        float fDet = dot( vSigmaX,R1 ) * faceDirection;
        vec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );
        return normalize( abs( fDet ) * surf_norm - vGrad );
    }
#endif
// 法线信息计算
#ifdef  USE_BUMPMAP 
    normal = perturbNormalArb( posWorld,normal,dHdxy_fwd(),faceDirection );
#endif

所以我们可以看到,渲染引擎除了提供通用的基础材质外,往往需要在原有材质基础上灵活迭代,根据用户使用场景和需求不断支持新特性。

未来展望

SAR Creator 材质库、ShaderGraph特效、后处理等渲染能力在24年春节活动中得到进一步完善和项目验证,为互动场景提供了不错的视觉效果。当然在业务落地过程中,我们也发现了一些不足之处。比如后处理可选效果还不够多、功能还不够完善,目前只初步支持bloom和fxaa后处理。比如美术工作流还不够高效,美术资产的制作和落地过程中可能存在卡点,需要多方沟通协调。后续我们会和美术设计师保持更紧密的沟通,更深入的了解用户需求,提供更多开箱即用的材质特性、后处理效果和ShaderGraph特效能力。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

下期主题是Wasm、WebGL 在互动技术中的创新应用,重点介绍如何利用 Wasm 和 WebGL 对目前流行的一些前端互动技术(比如:Lottie、渲染引擎、动画图片等)进行创新和实践,利用 Wasm 和 WebGL 等新技术方案的特性和优势提升业务性能和流畅度,给用户带来更好的体验,敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

2024 抖音欢笑中国年(三):编辑器技巧与实践

2024 抖音欢笑中国年(三):编辑器技巧与实践

前言

本次春节活动中,我们大部分场景使用内部的 SAR Creator互动方案来实现。

SAR Creator 是一款基于 TypeScript 的高性能、轻量化的互动解决方案,目前支持了Web和字节内部跨端框架平台,服务于字节内部的各种互动业务,包括但不限于抖音春节、抖音直播礼物、抖音UG活动等。

c77ecd1f40b0c06260f89cf4784504e4.png

SAR Creator 编辑器支持了图形化界面,提供了各类完善的系统(光照、动画、脚本等)供用户快速便捷的搭建一个高质量的互动场景,集成了场景搭建、资源配置、预览调试等功能,并提供了打包构建等工程化插件,可配合业务定制高效2D、3D内容生产工作流。

本文将就本次春节活动中编辑器的工作流程、开发常用功能、以及配套的工程配置方案等进行介绍。

工作流程

目前编辑器的工作流程能极大的提升开发效率,在于研发和设计能够极好的解耦工作。对于设计同学来说,不仅能在研发同学在线编辑器上协作,也能独立制作通过Asset Package包进行资产的交付;而对于开发来说,编辑器能够可视化的进行大部分场景搭建以及资产配置,能直观的检查资产信息。

906f200aa59bd0fea91368bc9377b237.png

资产制作

由于设计同学本地没有开发环境,为了方便设计同学脱离研发单独使用SAR Creator制作美术资产,我们提供了SAR Creator Launcher工具供美术使用,使用 Sar Creator Launcher 可以管理 Sar Creator 编辑器的多个版本安装创建新项目快速启动项目查看编辑器版本的更新信息等功能。

5bdcf9795be11c1e902ef83dca3350c4.png

设计同学使用Launcher搭建一个项目之后,可以将他从PS、Blender、Spine制作的资源导入到SAR Creator当中进行组合到场景当中,并导出成预制体资源,例如春节活动以下场景就是由设计同学制作然后交付给研发使用。

42c8bedec92635dee467484046d9d986.png

动效预览

设计同学产出的资源可以直接导入到SAR Creator中进行播放预览以及调整,美术可以确保最终交付给研发的动画资源是符合预期的。

beb956a77fcc67b760305144d5b31a23.png

目前支持的动画有 lottie、spine、帧动画、模型动画、组合动画以及帧差动画。

资产导出

设计同学最终的导出产物一般为prefabs资源,因为设计和研发的项目不互通,需要一种更快捷的方式同步资源,设计同学可以在创建的预制体右键导出资源包,此功能会将预制体依赖的所有资源导出为一个asset压缩包,设计同学可以直接将此压缩包发送给研发同学导入

16caf2707223d84e81e608d2ff08b768.png

d146610c67261366b5d1083f75eaf9a8.png

研发同学直接在game目录上右键选择导入资源包,即可选择设计同学交付的文件将资源导入

810e15a6cbcd6cb3f83205499629451f.png

68ed939d7073af76af9e84fba21c0ea5.png

功能介绍

画布适配

画布适配是指调整互动区域的视觉输出以适应不同的屏幕尺寸和分辨率,这样可以确保互动区域在不同分辨率的设备上看起来运行都正常。

57a549f6b30d397d6513061effb77ecc.png

SAR Creator 提供了简单的解决方案,首先需要指定设计的宽度和高度,它们定义了游戏将以何种分辨率进行开发,通过设定这些值可以确保互动区域在所在目标设备上正确缩放并保持一致的视觉呈现。

除此之外除了为了进一步增强画布的适配功能,我们提供了根据特定需求的各种适配策略。这些策略包括:

  • FixedWidth(固定宽度):在此策略中,互动区域保持其设计宽度,高度根据纵横比自动调整。这确保互动区域始终占据屏幕的整个宽度,没有重要信息被裁剪掉。

  • FixedHeight(固定高度):与固定宽度策略类似,互动区域保持其设计高度,而宽度则根据纵横比调整。这可以确保互动区域始终垂直填充屏幕,并在各种设备上提供一致的体验。

  • Auto(自动适应):此策略将互动区域按比例缩放以适应可用屏幕空间的宽度和高度。取决于设备的纵横比,两侧或上下可能会出现一些空白区域,但整个互动区域内容都是可见的且无失真。

2D&3D混合开发

互动场景的编辑中,经常需要2D和3D元素相互结合才能创造出更好的交互体验,3D模型的移动经常能带来更逼真的效果,2D元素经常应用于背景图或者UI元素。

a1f0813a0a41728626e639d7cedc62e7.png

通过使用SAR Creator编辑器,可以很轻松的在同一场景中同时添加2D和3D对象。而且SAR Creator编辑器还允许自由调整这些对象之间的层级关系、渲染顺序以及视觉表现。此外,还可以为场景设置多种灯光和相机效果,进一步丰富场景表现力。

使用2D用来编辑器场景UI,对比使用前端技术编写UI界面逻辑,可视化拖拽编辑能大大提升开发速度,拿之前的一个项目举例:

  • 左下界面UI是使用前端技术方案代码编写界面,耗时3天。

  • 右下UI是使用SAR Creator 2D场景制作,耗时0.5天。

33d910c66f505a8e98a9dba26a4bd1d1.png

302a32eb4a2790c0432b075de532089a.png

Bundle分包

在项目开发过程中,资源管理和加载优化是至关重要的。随着互动内容变得越来越丰富,会出现资源体积庞大、加载速度缓慢等问题。为了解决这些问题,SAR Creator编辑器提供了Bundle分包能力,帮助开发者轻松地对游戏资源进行分组、管理和优化。

1692c2955f530f2565c1d5a24064b968.png

a0a90849f89e1c2da3a37bdf393fba84.png

21e1b28e24e87e265afe96ea0ec6ec41.png

Bundle分包使用起来非常简便,只需要选中文件夹,然后如左上图所示,勾选上配置为Bundle,就可以在构建的时候将这个文件夹内的资源拆分为独立的Bundle包,分组选项中有三种资源合并类型,功能介绍如下

  • 无:不做任何优化;

  • 合并依赖:将Bundle中相互依赖的资源二进制文件合并在一起,从而减少运行时加载请求次数;

  • 全部合并:将Bundle中所有资源二进制文件合并为一个,最大化减少请求数量,但可能会增加单个资源加载时间。

用户可以根据自己bundle包的资源情况合理选择分组方式。使用Bundle分包可以让开发者按需加载分包资源,无需项目初始化时就加载所有资源,这样不仅能减少项目首包的大小,还能提升游戏的启动速度和运行效率。

图片分级

图片资源的大小不仅影响着项目最终的包体大小,分辨率大的图片同样会占用设备更多的内存以及性能,而不同的设备的性能是不一样的,为了达到最好的显示效果又要满足用户设备的性能要求,SAR Creator编辑器对图片提供了分级功能,可以使不同性能的设备加载不同分辨率级别的图片,这在春节活动中得到了广泛使用。

0a7fccb3b4bba98a024d55e0f976b925.png

当勾选去掉禁用分级后,构建的时候会生成normal、medium、low三个级别的文件资源,其中medium、low的图片分辨率会低于原始图片,会根据用户手机的性能评分下发不同等级的图片资源,而无需研发同学关心下发的内容。

f3be30d8d1b6b1c0092012e2b9b08805.png

e3c8be38cd10a08b463efe68bc2ca33e.png

需要注意的是,在使用图片分级时,实际上是会修改图片的分辨率大小,而当我们使用spine或者序列帧这类动画文件时,图片资源为图集,如果降低了图片的分辨率,它们就无法在图集上获取正确的像素数据,导致动画播放异常,因此针对图集文件或者动画文件的图片资源,我们需要禁用分级。

压缩纹理

压缩纹理,是一种 GPU 能直接读取并显示的格式,使得图像无需在 CPU 侧解压成 bitmap 即可直接传入 GPU 进行渲染,节约大量的显存开销,而在移动端即内存开销。

在图片的检查器中,勾选上使用压缩纹理即可开启使用,压缩纹理在运行时会根据运行环境自动判断是否使用压缩纹理,使用时用户无需关注。

f8e8832c32ef505b8eeff49261484f60.png

4c69f4ee3e6b4fc1a9808b0910fa4ff3.png

启动图片上的压缩纹理配置后,可以配置选择etc、astc类型的压缩纹理,并通过排序决定运行时加载的优先级。同时可以配置压缩纹理的压缩质量、是否翻转等属性。

另外需要注意的是

  • 压缩纹理不能翻转纹理,即 flipY 表现始终为 false

  • 压缩纹理不能生成 mipmap,即 generateMipmaps 始终为 false

  • 为了适配不兼容压缩纹理的机型,图片资源仍然会被打进包体当中,所以包体会存在图片和图片的压缩纹理资源,包体体积会变得更大

透明像素扩边

在Canvas中绘制图片纹理时,图片边缘的像素会比较难处理,当我们选择边缘像素插值方式为linear时,虽然边缘会更光滑,但是由于插值方式的原因,图片的像素边缘会有一圈黑边,如下左图所示:

b4605170bc145ebfd8817242ad3a9706.jpeg

7865be46c5ae46a3d3aaedd97218aa2c.jpeg

因此,为了解决这个问题,SAR Creator编辑器提供了透明像素扩边的能力,我们可以通过图片配置勾选上透明像素扩边,将图片的边缘增加一圈纯白像素,使插值的像素可以不为黑色,如果扩边单个像素为白色不明显时,我们也可以选择扩全部像素,将透明像素图片的所有透明像素点填充为通过插值计算的带有颜色透明度的像素点,避免边缘渲染时产生黑边。

b87b8c2c6ebc881fee0937e0ac273048.png

841eeff679e2ede36f59935e836ef493.png

不过透明像素扩边同样存在着一些问题,经过扩边操作的图片因为会修改原先的图片文件,所以会导致图片体积增大,具体的增大情况要看最终的图片产物。

工程配置

配置文件

SAR Creator的配置文件主要分为两块:

  • SAR Creator项目的配置文件,其指定了项目的路径、入口文件等基础信息以及SAR Creator中预览的一些基础配置,方便运行项目的时候找到项目的位置。

  • SAR Creator项目构建插件的配置,因为SAR Creator可以适配各种平台,因此SAR Creator的构建流程通过插件的形式嵌入到不同平台的构建流程当中,并可以在插件中深度集成平台的相关能力。

SAR Creator项目配置文件通常叫做sar.config.js,这边以春节的摇签项目配置举例,项目的配置文件如下所示:

const path = require('path');
module.exports = {
  // 指定入口文件目录
  projectRoot: path.resolve(__dirname, 'game'),
  previewConfig: {
    previewDevtool: true,
    resolve: {
      alias: {
        // 别名,脚本中使用@lottery代替绝对路径,用来避免sar creator报错
        '@lottery': path.resolve(__dirname, './')
      }
    }
  }
};

插件通常在平台的配置文件中引入,因此插件的配置通常也在平台的配置文件中,此次春节活动是基于字节内的跨端框架平台开发,所以接下来会结合跨端框架平台做插件配置的功能介绍。

分包配置

跨端框架平台的项目会主动下发资源到用户手机,但是为了提高资源包下发的成功率,单包的限制通常在5M,因为如果我们的项目资源大于5M时,就需要进行拆包,在上面我们已经介绍过了Bundle分包,其能很好的将互动资源进行拆分并动态加载,但是一个Bundle包不一定有5M,因此为了让资源包收益最大化,一个资源包可以由几个Bundle包组成,这个拆分手动的话会很麻烦,因此我们通过工程配置在,构建完成后进行分包,配置信息如下:

const lotterySarConfig = {
  // ......
  /** 分包策略,构建才会生效 **/
  subPackages: {
    // 可以过滤掉一些包,比如测试包需要被剔除
    excludeBundles: ['test'],
    // 组合bundle包为gecko包的规则
    combineBundleRules: [
      {
        // 拆分到的gecko包名
        channelName: 'pitaya-lottery-game-2',
        // bundle包匹配符合条件的正则规则
        test: [/mascot/]
      },
      {
        channelName: 'pitaya-lottery-game',
        test: [/main-scene/, /resources/],
        preload: true,
        preloadPriority: 96
      }
    ]
  }
};

内联配置

在Bundle包中,会存在各种config或者json配置文件,文件数量较多时,每次访问都需要请求一个链接地址,为了减少请求次数,我们可以通过配置插件构建,将每个Bundle包中的json文件合并成一个二进制文件,减少请求的次数和资源的大小,并且为了防止单个二进制文件太大,我们仍可以限制合并的二进制的文件大小,以确保不影响请求资源下载的速度,配置如下:

const lotterySarConfig = {
  // ....
  ejectOptions: {
    // bundle的config文件不生成
    disableGenConfigWhenInline: true,
    // group分组配置信息
    groupConfig: {
      // 针对 bundle 下 group 的体积限制,优先级更高
      groupPackingSizeLimitBundleConfig: {
        // 名为mascot的 bundle 下单个group限制400k大小 
        mascot: 400,
        // 同上
        resources: 400
      }
    },
    // 内置bundle文件的config配置到entry.ts里
    inlineConfig: {
      'main-scene': true,
      resources: true,
      mascot: true
    }
  },
}

预加载配置

跨端框架平台支持资源的预加载,为了配合平台特性,我们还可以在构建生成游戏的时候就配置Bundle里的资源支持预加载,然后在构建完成后就可以生成预加载的配置文件,可以使用户在打开容器时就可以让容器提前加载好配置文件里的资源,使项目内加载到资源的时候,能更快的加载资源,其配置如下:

const lotterySarPreloadUrlList = [];
const lotterySarConfig = {
  // ......
  // 
  preload: {
    // 预加载资源前缀
    publicPath: `https://${CDN_DOMAIN}${GECKO_PREFIX}/pitaya-lottery-main/`,
    // 限制预加载资源个数
    limit: 300,
    // 限制预加载文件总体积, 默认就是3M
    sizeLimit: 3 * 1024, 
    // 配置文件输出目录
    outputDir: 'merge-js/lottery',
    // 过滤资源的规则
    asset: {
      rules: [
        {
          // 匹配规则
          test: /.*/,
          // 可以匹配重写该资源是否加入 preload.json,返回 { enableMemory, url, priority } 或 null
          rewrite: (item) => {
            if (
              item.url.endsWith('.png') ||
              item.url.endsWith('.jpg') ||
              item.url.endsWith('.jpeg') ||
              // 已经加入的不再次加入preload
              lotterySarPreloadUrlList.includes(item.url)
            ) {
              // 不写入预加载配置
              return;
            }
            lotterySarPreloadUrlList.push(item.url);
            item.enableMemory = true;
            // 写入预加载配置
            return item;
          }
        }
      ]
    }
  }
}

最后预加载的配置文件如下所示:

{
    // 跨端框架页面路径
    "***/index/template.js": {
        "image": [
            {
                // 是否开启内存缓存
                "enableMemory": true,
                // 资源加载路径
                "url": "***/resource/image/back.bcd0a82e.png",
                // 资源大小
                "size": 280
            },
            // ....
        ],
        "other": [
            {
                "enableMemory": true,
                "url": "***/main-scene/g_0.cc991.group",
                "priority": 96,
                "size": 143296,
                "assetBundleType": "group"
            },
            // ......
        ]
    }
}

依据跨端框架的页面路径,来预加载配置的URL链接以及优先级,能够在页面打开前就提前加载好资源内容。

开发实践

脚本编写

在资产平台右键我们可以创建默认脚本,脚本格式如下所示

a39feb4cb45bf606b323286cd4b93f21.png
// newscript.ts 脚本文件

import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';

@ScriptUtil.Register('NewScript')
export default class NewScript extends Script {
  onStart() {
    const scene = this.world.getEntitiesByName('Scene')[0];
    if (scene) {
      // TODO:
    }
  }
}

SAR Creator 脚本的生命周期分为以下几个阶段:

  • onAwake:节点挂载时执行一次

  • onDestroy:节点销毁时执行

  • onPause:暂停时执行

  • onResume:恢复时执行

  • onStart:第一次帧动画开始前执行一次

  • onUpdate:每帧开始时执行

  • onLateUpdate:每帧结束时执行

我们常用的生命周期有onStartonUpdateonResume这个三个生命周期。

onStart是在节点首帧时激活一次,表明游戏已经可以开始运行,我们一般在此阶段动态加载资源,将获取到的资源添加到场景中,或者获取场景的某些节点实例。因为我们的项目是SAR Creator + 跨端框架组合,所以在跨端框架阶段初始化引擎的时候,我们会同步去获取服务端数据,当满足onStart触发以及数据获取成功两个条件时,我们一般会在脚本内依据数据初始化场景内容。

onUpdate会在节点的每帧渲染前触发一次,由requestAnimationFrame驱动,当我们需要变换场景中的元素的时候,就可以依靠每帧触发修改微小的变量,达到最终看起来连贯变换的样子,例如在摇签玩法中我们会依据陀螺仪的数据来确定签筒的状态,因此我们在此阶段每帧获取陀螺仪的数据,然后对其操作同步转换签筒的状态,让签筒有一种跟手的感觉。

onResume会在游戏暂停后重启时触发,一般来说当我们有些定时操作会在暂停阶段销毁,在恢复阶段触发,除此之外,当节点隐藏恢复时也会触发此阶段,当一个节点启动时播放动画,播放完成后隐藏,当恢复时需要重置动画状态并播放就可以在这个阶段进行。

预制体制作

当我们将层级面板中的节点拖拽到资产面板中就会自动生成预制体资产,预制体资产储存了这个节点的所有的数据以及依赖的资源信息。

aa49991b785f383f18cb353c4079af64.png

双击预制体资源,我们仍可以对预制体进行二次编辑等操作。

b1062bd3002fc95f22ce7c86b71cdfca.png

使用预制体资源可以帮我们可视化的预设大量的节点数据,我们可以通过直接加载制作的预制体,节省大量的实体修改参数配置等操作,从而提高我们的开发速度。

另外将场景中的非必要资源拆成预制体加载,可以有效提升主场景的加载速度,并渐进式加载其他装饰节点预制体到场景当中,并且可以依据用户手机性能,选择性加载不同复杂程度的预制体到场景当中。

例如在摇签玩法中,我们预设了不同的背景烟花以及签筒的预制体,使用时我们依据时机判断加载不同的烟花到场景当中,以及依据用户的手机性能,选择性加载要求更高性能的签筒还是性能要求更低的签筒。

c8683dfd43742295aef5fc4f47e83295.png

动态加载

在项目搭建中我们已经介绍了Bundle的配置方式,用户可以将文件夹配置成动态资源包,那么加载动态资源包我们提供了资源管理器assetManager挂载在world上,例如加载一个预制体资源如下所示:

const { 
  bundle, 
  error: bundleError
} = await this.world.assetManager.loadBundle('resources');

if(!bundle || errror) throw bundleError

const {
  asset,
  error: prefabError
} = await bundle.load<Entity>(`prefabs/fireworks/${prefabName}.prefab`);

 if (!asset?.get?.() || prefabError) throw prefabError;
 
 const entity = asset.get() as Entity

我们需要先使用loadBundle获取到这个Bundle包的实例,loadBundle支持传入Bundle的名称或者链接,拿到Bundle实例后,我们按照资源在bundle包中的路径加载,就可以获得到一个asset资产对象,asset调用get方法可以获取当前加载资产的实例,例如加载预制体获取到Entity实例。

另外当我们加载图片资源时,我们从资产面板可以看到图片的texture虚拟资产Texture对象,如果我们想要直接加载图片的Texture对象时,就可以在加载图片的路径中拼接上index.texture,例如下所示:

bf02cc73a1a56cebb3522554db65a6b8.png

const { 
  bundle, 
  error: bundleError
} = await this.world.assetManager.loadBundle('resources');

if(!bundle || errror) throw bundleError

const {
  asset,
  error: textureError
} = await bundle.load<Texture>(`texture/lamp/rect.png/index.texture`);

 if (!asset?.get?.() || textureError) throw textureError;
 
 const texture = asset.get() as Texture


fda486398ea9723832b83f1a2de896ea.png
const { 
  bundle, 
  error: bundleError
} = await this.world.assetManager.loadBundle('resources');

if(!bundle || errror) throw bundleError

const {
  asset,
  error: textureError
} = await bundle.load<Texture>(`texture/lamp/rect.png/index.texture`);

 if (!asset?.get?.() || textureError) throw textureError;
 
 const texture = asset.get() as Texture

未来展望

在过去的一年中,我们的SAR Creator产品经历了三个重要的大版本更新,分别是1.0、1.1、以及1.2,三个版本的迭代都是我们基于用户需求并不断优化用户体验推出来的阶段性版本。

在三个版本的迭代中,我们满足了研发同学的一些重要需求,例如脚本配置、bundle分包、性能优化等;增添了一些新功能,例如动画编辑器、VFX Particle、SAR Creator Launcher等;优化了SAR Creator的使用体验,例如多语言版本、材质属性分组、资产预览面板等。

基于过去一年的迭代,SAR Creator为春节的活动的开发带来了稳定的功能支持,在2024年中,SAR Creator将会持续优化引擎性能以及编辑器的易用性。

除此之外动画将是编辑器能力的核心,在2024年中我们将会不断扩展动画编辑器的能力,使用SAR Creator能够实现更加丰富的动画编辑功能,简单的动画元素将直接可以使用编辑器完成开发和预览。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

下期主题为渲染技术与实践,为了满足视觉和性能的两个要求,我们在渲染能力上提供了多种方法在适合的场景使用,最终达到了极为优秀的效果,敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

本文基于24年抖音春节活动业务背景,介绍了字节跨端容器AnnieX在游戏互动套件上的探索,致力于提升容器在游戏互动场景的优化能力。

业务背景

AnnieX作为字节一方游戏统一容器,服务字节内部电商、直播、UG等跨端场景业务。在字节一方游戏互动场景,有大量的一方游戏业务对容器有特定的流量、端能力和游戏优化的诉求。因此我们不断深入互动游戏业务特点,为字节游戏提供完善游戏端能力和流量运营能力,同时提供游戏互动场景极致优化能力,提升游戏业务的整体性能。

养羊赚钱

种树赚钱

6699998c85f35a3ff42ba6bd7d047359.gif

f27d8dc4f5f072ea8b2eab508dd42597.gif

萌宠旅行家

新周中/周末

outside_default.png

dc614a6f7bc65ab28dd6a16914224874.gif

今年是抖音春节活动的第7年,我们跨端容器已经具备丰富的端能力的同时,也提供了一些对于跨端容器常规的一些优化手段,例如:资源预加载、网络预请求等手段,并且在往年春节活动中取得了不错的优化效果。而今年,我们希望能够基于游戏场景,逐渐深挖游戏互动场景的特点,为春节等游戏场景去提供更加丰富和专业的游戏优化能力

春节-神龙寻宝

春节-守卫现金

db39de3191245c4c5a1b9dadd9a01fbb.gif

dc0939ac00ee03bba5eef54b2956f348.gif

春节-摇福签

春节-神龙探宝

6bc9d3e98087576784263418604812d0.gif

6dd28bb2b7f84752dcf654dda68d0e82.gif

互动套件介绍

因此我们基于24年抖音春节活动,在容器侧提出了互动套件的建设,希望为游戏互动场景提供优化解决方案。24年春节我们完成游戏引擎预热和游戏资产公共离线库的相关优化,在24年春节得到了不错的优化效果;同时我们完成了游戏资源(解)压缩的探索和更高效的物理引擎探索,希望能够在25年春节完成压缩和物理引擎的落地,从而丰富游戏互动玩法的同时,提升游戏的整体性能。

4ee1d79e00b4ecea43a48855e84c1732.png

资产离线公共库是一个重要的资源管理工具,管理了游戏互动业务所需要的游戏引擎资源。通过对游戏主包拆包和引擎资源的统一管理,资产离线公共库能够有效地帮助降低游戏包的大小,从而提高游戏的运行效率。此外,资产离线公共库还能够通过复用本地离线公共库的引擎版本,进一步降低游戏包下载的整体耗时和带宽成本。

引擎预热是一种提供了与游戏引擎优化相关的能力的技术。其原理是在游戏启动过程中,对游戏引擎进行预热,以便游戏逻辑执行过程中更快地加载游戏引擎包,从而提高游戏加载速度。通过提前准备游戏引擎,在游戏启动时,就可以更快地进入游戏世界,减少等待时间。这种技术可以为玩家带来更好的游戏体验。

资源的压缩与解压缩可以极大地提高资源利用率,有效降低资源包体积和网络加载时长。资源的压缩处理是在引擎编辑器的打包构建阶段做的资源预处理,而资源的解压缩则被放在了互动容器中,在游戏载入过程中实时进行处理。

物理引擎,目前前端游戏开发者主要依赖js或者wasm版本的物理引擎,在游戏性能和体验上存在较大的优化空间,因此我们探索在容器侧提供更高效的物理引擎方案,从而为游戏开发者和设计师提供更好的物理引擎解决方案,丰富游戏互动玩法体验。

本文主要介绍24年在春节场景落地的引擎预热和资产公共离线库相关的优化手段。同时介绍、探讨我们游戏资源(解)压缩方案的探索和在探索中遇到的问题

名词解释

67caebe43a6690c12be91312891e028b.png

引擎预热&资产离线公共库

业务痛点

8dbeae53f161b4d73400eb3bf2bb191f.png

基于游戏业务已有的数据进行分析,我们发现游戏互动场景有以下2个特点

  • 依赖游戏引擎:在游戏中,游戏互动场景的创建和运行需要依赖于游戏引擎的加载和初始化逻辑。这个过程会消耗大约200ms~300ms的时间,这可能会对游戏的性能产生一定的影响。因此,为了提高游戏的响应速度和流畅度,游戏开发者需要尽可能地优化引擎加载和初始化逻辑的执行效率。

  • 游戏加载耗时和用户异常流失率呈正比:启动性能对于游戏的用户体验至关重要,因为如果游戏启动没有优化好,用户需要等待很长时间才能看到游戏画面。这样很可能会导致许多用户在游戏加载完成之前就退出游戏。根据已有的游戏数据分析,游戏加载时间在1.5秒后,每0.5秒左右就会有4%左右的用户异常流失。因此,游戏开发者需要高度重视游戏启动性能的优化,以确保游戏能够快速启动,让用户尽快进入游戏世界。

因此我们针对游戏互动场景完成了引擎预热的方案,整体在24年春节场景落地,双端取得了不错的加载收益。

整体思路

051152242c6770dd7b0deff4adb7e147.png

在优化之前,在容器加载游戏主包完成之后,才会开始渲染游戏首屏页面和执行游戏业务逻辑。当游戏的业务逻辑在执行的过程中,必须等待容器完成游戏引擎资源的加载和初始化,这部分在线上存在一定的耗时。因此,为了解决这个问题,我们在容器侧和前端工具链完成了一系列改造,以提高游戏页面的加载速度。

具体地来说我们在容器打开游戏页面路由阶段,就完成了游戏引擎的预加载,并在跨端框架完成初始化之后,充分利用JS线程空闲的时间,完成游戏引擎的预热,并将预热好的实例挂载在全局对象中,这样当游戏页面真正执行的时候,就能直接通过全局对象中的获取到游戏引擎实例,从而完成后续的游戏业务逻辑。

技术细节

开发工具链改造

df77f7586ff0307e823a5d0e991a7d7c.png

前端工具链方面,在构建工具中接入speedy-split-chunks插件,在游戏打包的过程中,将游戏引擎包从游戏主包中拆分出去,并将引擎库发布到静态资源分发平台公共离线库下;封装split-engine-adapter组件,完成引擎预热的前端代码插桩,并在引擎预热时,提供兜底逻辑,保障业务的稳定性。在剔除游戏引擎包同时,并将剔除出来的引擎包和对应的meta文件部署到静态资源分发平台公共离线库,通过meta文件来维护离线公共库的游戏引擎资源版本,供多个游戏互动业务使用,从而降低带宽成本。

另一方面,我们的split-engine-adapter插件可以在请求加载引擎资源文件时,完成对requireModule函数的hook。在这个过程中,如果发现全局对象上不存在的引擎实例,那么就会通过requireModule请求拆包引擎包作为兜底措施。通过这种方式,可以避免因引擎实例不存在而导致的错误,从而提高系统的稳定性和可靠性。

预热游戏引擎

98acf2b35479dc75991c453a82acbddb.png

客户端通过前端拆包的meta文件,进行公共库离线库资源版本管理。当客户端页面schema中打开引擎预热的能力时,客户端会在页面路由时,根据meta查找对应的引擎版本,提前去预加载游戏引擎。由于离线公共库存在客户端本地,为了防止替换客户端本地引擎JS文件,整体加载过程中会对游戏引擎资源进行验签,从而达到避免JS注入攻击的风险。同时,在跨端容器创建的过程中,将游戏引擎文件在JS线程进行预热,最终将游戏引擎实例挂载到全局对象上,以确保游戏引擎的正常运行。

当前端业务逻辑执行时,可以通过全局对象访问游戏引擎实例。如果引擎预热失败,前端业务会通过兜底的逻辑,请求游戏引擎资源进行使用,从而提升整体方案的稳定性和可靠性。

业务收益

1280f78a84a3fbc7c61dbf4502089bd1.png

在游戏互动场景中,AnnieX互动容器通过提供引擎预热和依赖资产公共离线库托管游戏引擎的方案,实现了游戏引擎的快速加载和初始化,从而提升了游戏业务的整体性能。这一方案成功地帮助完成了24年抖音春节活动的落地,游戏主包大小降低了28.05%。通过复用公共离线包引擎资源,节约了数万元网络带宽的成本

959899d12382391bee70f2812471950c.png

在游戏加载耗时方面,双端在不同游戏的表现中存在200ms~500ms左右的优化幅度。由于iOS整体性能更佳,因此优化幅度整体上相较于Android用户优化幅度更大。在Android端中,PCT90有32.12%的优化幅度,PCT50有20.75%的优化幅度;在iOS端中,PCT90有47.24%,PCT50有33.89%的优化幅度

资源压缩

业务痛点

3D资源往往文件体积较大,在需要网络传输的场景,对这些资源的压缩通常能够有效提升资源的加载速度,节约网络流量传输成本。

目前,常用的3D资源文件格式主要有glTF、fbx、obj等,这些资源通常包含了3D资源的几何体、骨骼、动画、材质等等所有数据,因此围绕着24年抖音春节活动,我们开始了基于游戏3D资源的压缩和解压缩的探索和调研。

整体思路

这里主要介绍AnnieX互动容器对于几何体和动画数据的压缩算法及其具体实现方式,分别是:量化(Quantization)、稀疏访问器(Sparse Accessor)和网格优化(meshopt)。

e938f27fa19b005524e9de0effa43d24.png

技术细节

几何体的压缩与解压缩

在SAR Creator中,几何体(Geometry)除了包含了顶点的坐标(Position)、纹理坐标(UV)、法向量(Normal)、切向量(Tangent)、颜色(Color)等数据之外,还包含了组成三角形的顶点索引(Index)、骨骼(Skeleton)和混合变形(Blend Shape)等数据。

由于这些不同的数据有着不同的格式和特性,所以往往需要分别应用不同的压缩格式,用以在尽可能保证数据精度的同时尽量提高压缩效率。

这里介绍几种SAR Creator中使用到的压缩算法:量化、稀疏访问器和meshopt。

量化(Quantization)

量化是一种常用的简单且高效的资源压缩方式,通常用于顶点数据的压缩,是一种有损压缩

量化是将32位浮点值转换为16位或8位的整数进行存储的过程,可以达到50%甚至25%的压缩率

转换公式

a89ff5020f7f516ea55b5851e356d71c.png

详情

由上述转换说明易知,量化是一个有损的压缩,其压缩和解压缩均只需要对所有原始数据做当且仅当一次乘法/除法运算,算法复杂度为O(n),开销较低。

量化对原始浮点数的范围有一个前提要求,其范围必须在0到1之间(可转换到无符号整数)或-1到1之间(可转换到有符号整数)。当原始浮点数的值不在这个范围内的话,需要预先做一次归一化处理,使其落在需要的范围内,才能继续进行量化处理。

本质上,量化是建立了一个浮点数和整数的对应关系,整数的存储位数越大,对应的浮点数精度越高。理论上可以实现任意位数的整数跟浮点数的转换。

在SAR Creator中,因为不同的数据对于精度的要求不同,故在实际应用时对于不同类型的数据使用了不同位数的整数进行存储,具体如下:

  • position:14

  • uv:12

  • normal:10

  • tangent:12

  • color:8

  • blend shape:8

以上不同类型数据的存储位数的配置选择参考自业界经验,可以在尽量保证数据精度的同时提高压缩率。在对精度要求较高的情况下,可以对该配置进行调整优化,一般最大值不会超过16位。

稀疏访问器(Sparse Accessor)

这里的稀疏访问器来自于glTF规范中的稀疏访问器(Sparse Accessor),特别适合用来存储混合变形(BlendShape,以下简称BS)数据,因为BS数据中往往存在较多的“0”。

在glTF规范中,Sparse Accessor会把一个较大的vector2/3/4的数组(Buffer),拆分成3个部分:非0值数组(Values Array)、索引下标数组(Indices Array)和原始长度值(Count)。通过这样仅存储非0值及其下标的方式,结合一个原始长度,就可以很方便地还原出原始数据。

在SAR Creator中会基于glTF的Sparse Accessor的格式再进一步做优化调整,仅会存储非0的vector2/3/4的非0通道的数据,而不是glTF规范中必须是Vector2/3/4中所有通道的数据均为0才不去存储,这样可以进一步减少序列化后存储数据的文件体积大小。同时,由于SAR的AssetBundle格式对于数组存储采用了特殊的方式,所以这里还可以少存一个Int32的Count值。

转换格式

转换格式示意图如下:

42338287c41339a8cd6dd3c0e5bf1aa1.jpeg

由上图可知,稀疏访问器是无损压缩,且其压缩和解压缩的流程简单,算法复杂度为O(n),但实际的解压缩过程并不需要对所有的数据进行处理,只需要创建跟原先相同大小的buffer,按下标将所有的非零值填进去即可,故而开销很低

详情

另外,由于稀疏访问器仅在当BS数据的0值较多时,才会有较高的压缩效率,否则反而会降低压缩效率。SAR Creator默认会自动判断应用了稀疏访问器后是否会减少资源文件体积,来决定是否采用稀疏访问器,不需要手动开启。

附:真实业务(虚拟形象)中的glTF文件为例,仅采用Sparse Accessor能减少53%左右的文件体积,如果采用Sar的Asset Bundle中优化后的数据格式,可以减少78%左右的文件体积。

值得一提的是,稀疏访问器可以跟量化一起叠加同时应用在几何体数据上,两者互不影响,互不冲突,最终的精度损失仅为量化的损失。

网格优化(meshopt)

meshopt是glTF规范中的一个扩展:EXT_meshopt_compression,该扩展可以对glTF文件中的几何体和动画数据进行优化与压缩。在SAR Creator中,几乎全量移植了这个扩展的具体实现,使得SAR Creator的Asset Bundle中的几何体和动画数据同样支持了meshopt压缩。

由于meshopt压缩和解压缩的算法较为复杂,SAR Creator在实现时应用了wasm的方式(同时用asm.js做兜底),这里使用了开源项目meshoptimizer,它提供了对Mesh进行优化压缩相关算法的C/C++接口,也可以编译为wasm使用。

压缩流程

meshopt在压缩前需要先优化顶点和索引数据,用于提高压缩效率,同时也可以提高解压缩后的渲染效率(GPU缓存)。meshopt提出了三种不同的模式(mode:attribute、triangles、indices)的和三种不同的过滤器(filter:octahedral、quaternion、exponential),针对不同的数据类型应用不同的模式和过滤器,可以最大化压缩率。

对于几何体的meshopt压缩,SAR Creator实现时的具体流程如下:

  1. 优化索引顺序

    1. 优化索引顺序,可以最大限度地利用附近的顶点数据,从而尽量复用在GPU硬件中的缓存。

  2. 优化顶点顺序

    1. 顶点顺序应按照顶点在索引数据中出现的顺序进行顺序排列,以而可以最大化索引数据的压缩率。

需要注意的是:有时候更高的压缩率反而会降低渲染效率,需要取好平衡点。

  1. 压缩顶点数据

    1. 顶点数据需要先进行量化(quantized),才能进行后续的压缩处理;

    2. 量化后的顶点数据需要用attributes模式来进行二进制比特流存储;

    3. 对于normal/tangent的数据,使用octahedral的filter可以进一步提高压缩率,对于position或其他数据,可以使用exponential的filter进行处理;

  2. 压缩索引数据

    1. 索引数据用indices模式进行存储,也可以用triangles模式进行存储;

    2. 其中,triangles模式仅支持三角形列表的存储,indices模式可以支持其他拓扑类型的索引数据存储;

  3. 压缩BS数据

    1. BS数据可以完全当作顶点数据来进行处理,也需要先进行量化(quantized),之后再以attributes模式进行二进制比特流存储;

    2. BS数据因为是增量(relative/delta)的值,故也可以选用比顶点数据在量化后更小的比特位来进行存储;

解压缩流程

对于几何体的meshopt解压缩,则需要反过来进行,互动容器中实现的具体流程如下

  1. 解压缩顶点数据

    1. 按attributes模式进行解压缩;

    2. 对于采用了对应filter的数据,还需要再进行filter的解压缩;

    3. (可选)如果有必要,在进行反量化转换(de-quantized)处理;

  2. 解压缩索引数据

    1. 直接按压缩时采用的indices或triangles模式进行解压缩即可;

  3. 解压缩BS数据

    1. 同顶点数据的解压,不再赘述;

详情

对于几何体数据应用meshopt压缩,往往可以带来10%-20%的压缩率,可以极大减少资源体积。但由于其复杂度较高,需要使用wasm加速压缩和解压缩的过程

对于不支持wasm的环境,SAR Creator使用了asm.js作为兜底方案。实际测试结果显示,asm.js会比wasm慢2-4倍。在后续版本的SAR中,可能会直接使用C++的方案加速运行时对meshopt压缩后的资源进行解压缩,加速资源加载和解析的整个流程。

另:SAR Creator对于几何体数据的meshopt压缩实现,目前仍在feature分支,未经过全面测试,仅上线了对于动画数据的meshopt压缩。

动画的压缩与解压缩

动画(Animation)数据是由一系列的关键帧(keyframe)组成的。所以动画数据是由两部分组成的:关键帧的时间数组(times)和关键帧的值数组(values)。

网格优化(meshopt)

SAR Creator中对于动画(Animation)数据的压缩,也是移植自上述meshopt扩展。

压缩流程

对于动画数据的meshopt压缩,SAR Creator实现时的具体流程如下:

  1. 对动画数据进行重采样

    1. 对动画数据的压缩,最有收益的是进行重采样(resample),这样可以减少存储的关键帧数量,进而减少动画数据的文件存储体积;

    2. 具体实现使用开源库:keyframe-resample-wasm,它提供了WebAssembly版本的重采样方式,会比纯js的版本快一些,而且这个库的重采样过程是无损的。

  2. 压缩关键帧的times数据

    1. 如果times数据是均匀的,仅使用attribute模式而不使用任何过滤器已经能取得比较高的压缩率了;

    2. 为了保证最终的动画不变形,一般不建议对关键帧的times数据进行filter处理,也可以继续使用具有最大尾数位数(23)的exponential过滤器进行处理,从而保证最小的精度损失;

    3. 最后使用attributes模式进行存储;

  3. 压缩关键帧的values数据

    1. 旋转的数据可以使用16-bit进行量化处理,并应用quaternion过滤器进行处理;

    2. 位移和缩放数据可以应用exponential过滤器应用相同的指数进行处理;

    3. 最后使用attributes模式进行存储;

解压缩流程

对于动画数据的meshopt解压缩,互动容器中的实现具体流程如下:

  1. 解压缩关键帧的times数据

    1. 按attributes模式进行解压缩;

    2. 如果压缩时采用了exponential的过滤器(filter),还需要再进行exponential的过滤器解压缩;

  2. 解压缩关键帧的values数据

    1. 按attributes模式进行解压缩;

    2. 旋转的数据还需要用quaternion过滤器进行解压缩;

    3. 位移和缩放数据还需要用exponential过滤器进行压缩时相同指数的解压缩;

详情

对于动画数据的meshopt压缩,同样也是有损的,往往有40%左右的压缩率,同样采用了wasm的方式加速压缩和解压缩的过程。

附:真实业务(招财神龙)中的动画资源文件为例,有38.50%的压缩率,可以减少1.9MB的资源包体积。

另:招财神龙业务中,考虑中低端机在跨端框架下wasm的兼容性问题,开发时直接默认启用了asm.js的降级方案,但在后来的性能测试环节中发现meshopt解压缩的asm.js在iOS的JSC运行时下效率较低,会严重增加资源的解析耗时,最终上线时并没有启用动画数据的meshopt压缩。

未来规划

24年春节我们完成了引擎预热、资产公共离线库的落地,取得了不错的游戏优化效果。但在业务落地的过程中,我们也发现一些存在的问题,例如引擎预热逻辑命中率双端平均只达到81.33%,整体上还存在优化的空间;另一方面随着资产公共离线库的托管的引擎类型和版本越来越多,体积越来越大,我们需要加强对离线公共库的版本管理,识别和控制ROI整体较低的引擎和版本,从而更好的控制离线公共库的带宽成本。

而在资源压缩方面,我们完成了相关方案js和wasm版本的流程的调研和探索,以24年春节玩法招财神龙为例,我们取得了不错的资源体积优化效果,降低整体游戏包的大小。但在资源压缩方面,由于低端机和部分iOS手机上对于网格优化的解压缩效率低下,甚至部分机型适用wasm会有崩溃的情况存在,所以虽然完成了整体的技术方案落地,但考虑到稳定性并没有在线上启用。后续会进一步优化这一方面的兼容性问题,解压的性能问题上也会直接采用C++而非wasm的方案,将资源压缩方案完整在正式的游戏业务落地,同时覆盖更多的游戏业务场景。

另外,在3D游戏场景下,一般意义上的资源还包括图片、视频、字体、脚本以及特定编辑器导出的资源(如lottie、spine等),对于这些资源文件的压缩也有着明显的意义,我们将在后续进一步探索相关资产的压缩,从而优化整体游戏的资产大小。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

系列下期主题是编辑器技巧与实践。作为字节互动方案的主要工具,其提供各类完善的系统方便用户使用快速搭建互动场景,并提供丰富的配置能力,解决了预览调试到构建打包的一系列流程问题,可配合业务定制高效的内容生产工作流。敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

字节跳动旗下的抖音等 App 在 2024 年春节期间推出了欢笑中国年系列活动,在实现增长业务目标的同时,为用户带来了全新的体验和乐趣。「招财神龙」是其中的一个重要玩法。

前言

本次春节活动,使用到了字节内的主要前端、跨端、互动技术产品。主要涉及:

  • 跨端框架 提供了首屏直出的方案使其具有较短的首屏时间,能够大大提升业务加载成功率。跨端框架也提供了 Canvas 作为 SAR Creator 等渲染引擎的运行环境。

  • SAR Creator 是抖音前端架构自研的一款基于 TypeScript 的高性能互动解决方案。SAR Creator 提供面向设计和研发同学的工作流,内置常见 2D / 3D 渲染能力、动效、粒子、物理等效果支持。

活动中,主要支持了 5 个互动玩法:“招财神龙”、“神龙探宝”、“摇福签”、“保卫现金”和“红包雨”,如下所示。

eba000f3f707d798b41908560941d891.png

我们会通过系列文章,介绍春节玩法用到的互动技术。文章所说的互动技术指以图形 API(如:WebGL)为基础,结合前端工程化、图形渲染、引擎技术、交互能力和跨平台能力,面向前端技术栈的动效和游戏化技术,如下图所示。

5fd7aa0eb3a70ec24e5e2fc18d82409e.png

在活动开发中,前端 UI 如:滑动列表、页面布局,可以用成熟的前端框架(React)。需要图形绘制的地方,如:渲染 3D 模型,就要用到互动解决方案(SAR Creator)。互动所用到的图形绘制部分往往是页面中的一个区域,我们会把互动部分封装成一个 SDK,通过使用 SDK API 和前端进行通信。

本文作为系列开篇,主要从“招财神龙”玩法视角,分享团队前端互动玩法的相关开发经验。

活动玩法介绍

下面是招财神龙玩法示意,用户可以点击“去寻宝”按钮(称此时的场景为「家场景」),让神龙去寻宝(称此时的场景为「寻宝场景」),寻宝过程中神龙会遇到福袋和龙蛋。福袋自动掉落到宝箱中,而龙蛋需要用户点击。寻宝过程中,红色的主按钮上有倒计时,倒计时结束后寻宝结束,用户可以打开宝箱领取奖励。寻宝过程中,场景中会有一些可点击的发光建筑,用户点击它们,发光效果消失,可能触发任务。

在「家场景」,用户可以点击小女孩,与之产生轻互动,如下图所示。

3e696e16c4dbdca89cedcc7da11a49e0.gif

包含四个主题的「寻宝场景」,每次寻宝会随机一个主题,如下,从左到右分别是山川、雪乡、丹霞和江南。

9e23083dca6700f8ccd0361489524f75.png

招财神龙互动玩法实现

实现招财神龙的互动玩法,需要多个工种配合。首先产品提出玩法需求,描述整个场景的构成要素和玩法逻辑。然后设计同学根据产品的描述,产出设计草图,逐步细化,最终通过 DCC 软件(如:C4D)生成 3D 模型、视频、2D 贴图或动画等美术资源程序需要根据产品需求和设计产出实现互动玩法的代码逻辑。整个开发过程需要三方通力合作,尤其需要程序和设计同学的有效沟通,以确保设计方案可以用程序顺利实施。为了保障产品质量,还需要测试人员验收产品。整个开发过程大致如下图所示。开发过程是持续迭代的,比如:产品可能在开发中期提出新需求,就需要设计、程序和测试做出响应。

9eb13b36c4b6afc74f4edfb54bb61de1.png

这里以程序的视角描述招财神龙互动玩法的实现。如上文所述,招财神龙互动部分由「家场景」和「寻宝场景」构成,两个场景通过一个转场动画过渡。每个场景使用了不同的美术资源和互动技术。程序不直接消费 DCC 软件生成的美术资源,而是消费 SAR Creator 产出的资源包(即 bundle)。

  • bundle: 设计在 SAR Creator 编辑器中导入 DCC 软件的产物(如:3D 模型),通过二进制序列化生成的运行时消费用的资源包。

  • prefab: 一个 bundle 可以包含多个 prefab(预制体),一个 prefab 可以包含 3D 模型、2D 贴图、动画甚至脚本代码等元素。

SAR Creator 为 bundle 及 prefab 提供了序列化、反序列化和管理等功能

接下来让我们先了解一下招财神龙页面元素的构成。

招财神龙页面元素构成

招财神龙活动在抖音App及多端(抖极、头条、西瓜、番茄等)的任务页上线,为了让大家对整个招财神龙前端页面有个清晰的认识,这里我们以任务页为例,为大家讲解一下页面构成。

e91ed3ea7c945d18d3a13a86267bae54.png

如上图所示:

  • 任务页(图左):字节系 App(例如西瓜视频),大多会有一个长期在线的激励页面,如上面左图所示,用户可以通过完成任务获得现金、或者积分等虚拟货币奖励。

  • 互动区域(图右):如上面右图所示,互动区域即为场景区域,是页面主 KV (Key Visual) 中的核心区域,用 Canvas 承载,使用 SAR Creator 来渲染互动内容。

任务页在非活动期间,以日常的形态展示(各 App 独立迭代),而在活动阶段,则以统一的活动内容展示。这是怎么做到的呢?

03553d7b527dfaaef9d404a65cf4649a.png

如上图所示,我们把任务页抽象为收益区 + 主 KV + 任务专区。在有活动的时候,我们只需要替换主 KV 对应的内容就可以了。在实际开发中,活动的主 KV 则抽象为活动 SDK。在满足活动条件时,服务端下发活动内容字段,任务页动态渲染活动组件,完成活动内容的展示;在活动结束后,服务端移除活动内容字段,页面切换回日常形态。

任务页上开发互动内容,存在较大的性能挑战。任务页前端 UI 繁多,业务逻辑复杂,而互动的资源加载往往又是 CPU 密集型任务,所以往往在首次渲染页面时,造成页面和互动区域的 JS 线程繁忙,进而形成卡顿和渲染时间过长。同时由于任务页已经存在大量的前端 UI 和动画,留给互动部分可用的内存安全余量往往仅有 200-300 MB,稍有不慎就有可能导致 OOM。在任务页上既要完成视觉表现精美,又要保证性能良好,是一件非常有挑战的事情。

招财神龙前端与互动的交互

我们将前端的同学分为两部分,一部分负责处理活动的主逻辑,例如和服务端交互、处理业务元素(例如进度条、明信片等挂件、任务列表等),这一部分的工作角色,我们通常称之为“前端同学”,另一部分同学主要用来处理游戏相关的逻辑,聚焦在互动上,我们称之为“互动同学”。 他们相互协作,共同实现了招财神龙的活动玩法。二者的协作方式如下图所示。

9765d680ef1686bbf7e42e31a549b8d0.png

游戏初始化阶段,游戏加载完 SAR Creator 运行框架后,向前端同学“索要”本次初始化的服务端数据,用来判断该用户进入游戏后,应该展示的是「家场景」还是「寻宝场景」。用户完成相关任务后,主接口刷新。前端同学以事件通信的形式通知互动同学渲染当前场景并播放相关动效。互动同学也会监听主接口数据,更新互动模块专有的逻辑或效果。

「家场景」的实现

「家场景」是引导用户“唤醒神龙”、“去寻宝”以及“领取福袋”的核心场景,如下图所示。本章节会将介绍「家场景」的搭建过程,并分享「家场景」开发过程中有趣的实现。

cb19ae8dff1794e03296fa82410513e8.gif

整个「家场景」是由 3D 和 2D 元素混合构建的。3D 部分包括小女孩、龙、地面和雪堆。2D 元素主要有炮仗、房子以及神龙回家后小女孩头上的提示气泡,是用图片实现的。还有一些 2D 动画元素,比如房子后面一直循环播放的红包动画、龙沉睡时嘴角的“zzz”呼吸效果。

场景搭建

设计同学使用 SAR Creator 编辑器搭建「家场景」,包括 3D 模型/2D 精灵的摆放、灯光和相机参数的设置等。SAR Creator 编辑器提供了图形化界面,可以方便地调整场景元素的层级关系、位置、朝向、缩放比例以及材质参数等。「家场景」的 3D 模型使用透视相机渲染,而 2D 精灵等使用正交相机渲染。最终,SAR Creator 渲染出的场景画面还原了设计稿的效果。

SAR Creator 场景中所有元素,包括相机、灯光等,都以 entity(实体)的形式存在,entity 之间存在父子关系,形成一棵节点树,如下图左上角“层级”标签页下的内容。父节点 entity 的 Transform3D 组件的位置、旋转和缩放属性,会影响子节点的相同属性。Enity 上可以挂载自定义脚本,影响 enity 的行为逻辑。SAR Creator 提供了大量操纵 entity 的引擎能力

46b4d9e3e64f61a4b9a83b1d46f06c90.png

动画播放

为了呈现出精彩的效果、给用户带来尽可能好的视觉体验,我们设计了14个模型动画,并通过出色的逻辑串联,保证了动画播放流程的简洁高效。

export enum HomeAnimName {
  HomeSleep = 'home_sleep', // 沉睡
  HomeAwake = 'home_awake', // 苏醒
  HomeIdle = 'home_idle', // 待机
  HomeClick = 'home_click', // 点击效果1
  HomeClickA = 'home_click_a', // 点击效果2
  HomeClickB = 'home_click_b', // 点击效果3
  HomeHappy = 'home_happy', // 完成任务,开心状态
  HomeGoHome = 'home_gohome', // 龙回家
  HomeHoldBox = 'home_hold_box', // 宝箱状态
  HomeOpenBox = 'home_open_box', // 龙推宝箱
  HomeCloseBox = 'home_close_box', // 关闭宝箱
  HomeCloseBoxIdle = 'home_close_box_idle', // 关闭宝箱后的待机态
  HomeOpenBoxIdle = 'home_open_box_idle', // 开完宝箱后的待机态
  HomeGoOut = 'home_goout' // 龙去寻宝
}

我们使用了 SAR Creator 提供的动画播放能力:Animator 组件。获取到 3D 模型的 animator 组件,并调用它的crossFade函数,在第二个参数duration指定的时间内,从当前动画状态过渡到另一个动画状态,即下面代码中的第一个参数anim。调用animator.on('finished',cbFunc)可以自定义动画结束后的回调函数。

this._dragonAnimator.crossFade(anim as string, duration);
this._charAnimator.crossFade(anim as string, duration);
this._dragonAnimator.on('finished', () => this.onAnimEnd(anim, params));

设置动画的loopCount属性,可以指定该动画播放的次数。设置clampWhenFinished可以指定播放完该动画后,是否停留在最后一帧。

const setClip = () => {
    const loopCount = loop ? -1 : 1;
    
    const _dragonClip = this._animator.clips.find((i) => i.clip?.name === anim);
    if (_dragonClip) {
      _dragonClip.loopCount = loopCount;
      const action = this._dragonAnimator.getAction(anim);
      if (action) {
        action.clampWhenFinished = !loop;
      }
    }
}

基于上述的这些底层的Api,我们实现了一套AnimationGraph来帮助研发和设计同学更好地开发提效。

a451a8192d6e9eecc396644c7349dadf.png

对于设计同学使用来说,例如想实现一个龙睡觉状态到龙待机状态,我们可以将HomeAwakeHomeIdle动画拖入到graph中,并创建动画链路。

16da673cb46c1012d69596dc84260029.png

HomeAwake动画播完以后,会在HomeIdle动画进行loop播放。选中链路,可以对链路进行配置和预览

b761e6fa375425befc15a55bde8a5c4e.gif

对于研发同学,可以基于graph进行逻辑条件的配置。

dcb00f5e949c7233d4f73c4a5a34141d.gif

如上图所示,例如进入游戏后,用户可能是在“龙沉睡”或者“龙待机”的状态,我们通过在Graph的变量区建立代码运行的逻辑条件(支持Number和Boolean两种类型),可以自定义一个case变量,当case = 1,播放“龙沉睡”、当case = 2,播放“龙待机”。

在代码中,我们可以通过使用AnimationController.setValue(variableName,value)来触发动画执行。

const animationController = this.entity.getScript(AnimationController);
if(showAwake) {
    // 需要播沉睡
    animationController.setValue("case", 1)
}else if(showIdle){
    //需要播放idle
    animationController.setValue("case", 2)
}

再比如,在某一个时间,用户点击了“去寻宝”按钮,这时候通过设置animationController.setValue("showGoOut",true)即可触发龙去寻宝的动画。

我们还为动画播放提供了钩子函数,在动画播放的特定时间,触发自定义的逻辑回调。

onStateEnter 在进入状态时触发
onStateExit 在完全退出状态时触发
onStateUpdate 在状态更新时触发
// 获取动画控制器组件
const animationController = this.entity.getScript(AnimationController);

animationController.on('onStateEnter',(controller:AnimationController,state:AnimationState)=>{
    //在此处实现业务逻辑
});

坐标同步

在实现一些特殊效果时,为了保障效果的高度还原,我们使用了坐标同步。例如小女孩头上的提示气泡和龙嘴角的“zzz”呼吸特效,接下来以气泡为例介绍一下这一部分的实现。

b11d5ed888cbb38b53e8574ee1634f8f.png

若用常规的模式在 3D 场景中摆放一个 2D 的片,会导致小女孩动的时候,渲染出来的气泡会穿帮或者 z-fighting

f449ea0a22a8ea638bd1f54724fb821d.png

3D-2D 坐标同步的做法是将 Bubble 节点放在 UICanvas(SAR Creator 处理 2D 元素的节点)中,每一帧将小女孩模型里的骨骼变换节点在 3D 空间中的位置转化成 UICanvas 坐标系的坐标,再实时设置 Bubble 的位置属性。

164aac3e851999b618e0039bf48f2c7f.png

e9b8086b1eb528c7f26ded8c0abcea28.png

坐标同步代码如下👇

const TEMP_VEC3 = new Vector3();
export const threeD2UICanvas = (entity: Entity, camera: PerspectiveCamera) => {
  entity?.object?.getWorldPosition(TEMP_VEC3);
  const vec3 = camera?.project(TEMP_VEC3) || new Vector3();
  // 375 * 500 为画布大小
  const x = vec3.x * 375;
  const y = vec3.y * 500;
  return { x, y };
};

每一帧设置 UICanvas 画布中气泡节点的位置,最终实现小女孩在 3D 场景中动来动去,头上的气泡也会跟着一起移动。

class BubbleScript extends Script {
    // ECS 脚本每一帧的回调
    onUpdate() {
        if(NEED_SYNC_POS){
            const bubbleRootIn3D = CharGlb.getChildByName('girl_Root_for_bubble')
            const bubbleEntityIn2D = UICanvas.getChildByName('Bubble')
            // 3D场景下的相机
            const cameraIn3D = MainScene.getChildByName('MainCamera')
            // sync pos
            const pos = threeD2UICanvas(bubbleRootIn3D, cameraIn3D)
            bubbleEntityIn2D.position?.set(pos.x, pos.y)
        }
    }
}

「寻宝场景」的实现

「寻宝场景」是一个纯 2D 互动场景,是招财神龙玩法的重要环节。为了实现有趣、自然的互动效果,「寻宝场景」要处理许多复杂逻辑。为了让互动和前端在动效上衔接流畅,互动和前端会在必要时通信。

简化版的“寻宝”逻辑如下图所示,包括地形等美术资源的加载相机处理探测点检测福袋和龙蛋触发以及地形回收等逻辑。每次寻宝开始前,服务端提前下发“寻宝数据”,包括本次寻宝开始和结束的时间戳以及 timeline 信息,timeline 是一个“道具”触发列表,列表中每个元素包含一个道具 id、触发时间戳、道具类型和道具状态等信息。

0d22ed0f0b1728689fc58935cdae8650.png

「寻宝场景」的 timeline 数据结构伪代码如下面所示。其中"prop_type"是道具类型,可能是福袋或龙蛋。福袋不需要用户点击交互,寻宝结束后总是发放给用户。在视觉效果上神龙会撞上福袋。但龙蛋需要用户手动点击,若不点击就会错失对应奖励。"timestamp"是道具触发的时间戳。

/** 一次寻宝的信息 */
export interface TreasureHuntData {
  /** 当前状态 */
  treasure_hunt_status: TreasureHuntStatus;
  /** 时间轴开始时间 */
  start: Int64;
  /** 时间轴结束时间 */
  end: Int64;
  /** 时间轴信息 */
  timeline: Array<PropTriggerInfo>;
  current_time: Int64;
  // ...
}

export interface PropTriggerInfo {
  /** 道具的id */
  prop_id: string;
  /** 在时间轴上的时间戳 */
  timestamp: Int64;
  /** 类型 */
  prop_type: PropType;
  /** 道具领取状态,寻宝结束时才有 */
  propStatus?: PropStatus;
}

相机逻辑

「寻宝场景」使用一个正交相机渲染。「寻宝场景」的地形大部分时间保持不动,相机不停地往前移动。相机的逻辑比较简单,只是 x 轴不停地增加,其伪代码如下所示。

// deltaTime是上一帧到当前帧的时间间隔,_moveSpeed是相机移动速度
this._camEntity.position.x += deltaTime * this._moveSpeed;

相机移动速度可在 SAR Creator 中配置,如下图中红框中的 Speed 所示。

da9dd4d761ef85860c8c143f70024669.png

SAR Creator 提供了装饰器工具@ScriptUtil,用于把一个脚本及其字段暴露给编辑器。相机配置脚本 TravelCameraConfig.ts 挂在上图中 TravelCamera 节点下,其伪代码如下:

import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';

// TravelCameraConfig是一个脚本,继承SAR Creator的Script类。
// 脚本类名前使用装饰器@ScriptUtil.Register(),可以在编辑器中挂到节点上
@ScriptUtil.Register('TravelCameraConfig')
export default class TravelCameraConfig extends Script {
  // 在脚本字段名前使用装饰器@ScriptUtil.Field(),可以在编辑器中编辑该字段
  @ScriptUtil.Field('float', { default: 0, params: { precision: 4 } })
  speed = 0;
  // ...
}

构建无限地形

每次寻宝的时间长度由服务端动态下发,最长为 20 分钟。每个主题的「寻宝场景」都有一个地形块队列每个地形块以 prefab 的形式提供。线上,每个主题的地形队列由两个地形块构成,这里我们记作 map_x_a.prefab 和 map_x_b.prefab,其中 x 是主题的索引。每个主题的地形块 prefab 由设计同学在 SAR Creator 中制作完成并以资源包的形式提供给研发同学,极大地减少了二者的工作耦合度,提升了开发效率。

「寻宝场景」一屏的设计分辨为 750x1000。每个地形块的宽度为 3750。这样两个地形块的宽度就是 10 屏,能提供足够多的细节差异、降低场景元素的重复感。下图是一个地形块 prefab 在 SAR Creator 中的样子,可以看出它在 3750x1000 的矩形外,还会多出一些视觉元素(如左右边界上的云),这些多出的视觉元素能够和另一个地形块上的视觉元素有机地融合。

104d29098430ed8628d38749b4c20703.png

为了让大家更容易理解,我们把山川主题的两个地形 prefab 都拖到 SAR Creator 中,如下图所示,它们总是可以无缝拼接的。

732f85f61db707845ea49da59134200d.png

在实际项目中,因为主题是随机指定的,所以这两个地形 prefab 是用代码动态加载的,而非直接拖到场景中。为了让用户更早地看到「寻宝场景」的视觉内容,我们同步加载第一个地形块 prefab , 异步加载第二个地形块 prefab。其伪代码如下所示。

async _loadTerrains(travelScene2D: Object2D): Promise<void> {
    const terrainNames = TerrainNamesByTheme[this._theme];
    // 加载当前主题第一块地形prefab
    const firstTerrainName = terrainNames[0];
    // 注_loadTerrain是异步的,返回promise。我们会在本函数返回前await此promise。
    this._firstTerrainPromise = this._loadTerrain(firstTerrainName!);

    // 异步加载其它地形prefab,其实对于线上的情况就只有第二块地形了。
    const terrainPromises = 
        terrainNames.filter((_, idx) => idx !== 0).map((i) => this._loadTerrain(i));

    void Promise.all(terrainPromises).then(async () => {
      // 注意要保证第一块地形已经加载好了,_tryCreateFirstTerrainBlock函数内部做判断,
      // 保证第一块地形块不被创建两次。
      await this._tryCreateFirstTerrainBlock(travelScene2D);
      
      let lastTerrainBlock = this._firstPrefabBlock;
      const terrainPos = this._terrainOffset.clone();
      for (const terrainPromise of terrainPromises) {
          if (lastTerrainBlock !== undefined) {
            const terrainEntity = await terrainPromise;
            terrainPos.x += lastTerrainBlock.getBlockSize();
            lastTerrainBlock = this._createTerrainBlock(travelScene2D, terrainEntity, false, terrainPos);
          }
      }
    });

    // 同步加载第一个地形block
    await this._tryCreateFirstTerrainBlock(travelScene2D);
  }

队首的地形块完全离开屏幕后把它移到队尾成为“新”的地形块。为了处理地形块边缘多出的部分视觉元素,延迟一屏让当前队首地形块消失,提前一屏让队列中第二个地形块可见,让用户看不到任何缝隙,伪代码如下所示。

_recycleTerrain(cameraPos: Vector3): void {
    const headRightX = this._terrainBlocks[0].getBlockRightPositionWorld();
    const terrainScreenWidth = this._terrainBlocks[0].getTerrainScreenWidth();
    const screenLeftEdge = cameraPos.x - this._halfScreenWidth!;
    const screenRightEdge = cameraPos.x + this._halfScreenWidth!;

    // 首队地图延迟一屏消失
    if (headRightX + terrainScreenWidth < screenLeftEdge) {
      // 队首地形右边界,离开屏幕左边缘
      this._terrainBlocks[0]?.setVisible(false);
      const headBlock = this._terrainBlocks.shift();
      if (headBlock) {
        const tailPos = this._terrainBlocks[this._terrainBlocks.length - 1].getPosition();
        headBlock.setPositionX(tailPos.x + this._terrainPrefabLength);
        this._terrainBlocks.push(headBlock);
        // 重置队首地形上的探测点
        headBlock.resetDetectors();
        // 通知prefab离开屏幕等
      }
    } else if (
      headRightX - terrainScreenWidth <= screenRightEdge &&
      !this._terrainBlocks[1].getVisible()
    ) {
      // 地图队列中第二个地图,提前一屏显示。队首地形右边界,离开屏幕右边缘
      this._terrainBlocks[1].setVisible(true);
      // 通知prefab进入屏幕等
    }
  }

地形上还有一些挂载点,程序根据当前机型的评分等,挂载不同类型的发光建筑。例如,高端机会挂载用 Spine 制作的发光建筑,而低端机挂载普通的精灵图。高端机能实现好的视觉 效 果,低端机减轻了 CPU 负担,保障程序运行流畅。下图红框中的节点就是发光建筑的挂载点。

15128630e05907a1a9c5e2b6111cfb39.png

探测点、神龙和福袋

「寻宝场景」中,神龙是最显眼的视觉元素,是用 Spine 制作的,但它并非一直在播放。神龙的行为和场景中一些被称为探测点的特殊节点有关。下图左边红框内有一个名为"commonDetector_common_2"的探测点,该探测点同层级还有一个福袋槽位"redpacket_2"节点,下图右边的福袋就是挂在"redpacket_2"节点上。下图右边的紫色方块是左边的探测点的可视化“符号”,只在开发阶段标识探测点位置,方便调试。一个地形块可能有 5 到 6 个探测点,大约一屏的宽度一个探测点

996889fa41ea193b0b4e3c3bb6c0bf63.png

探测点命名规则是程序和设计约定好的,第一个下划线“_”前面的部分是探测点类型,而后面部分神龙动画名称,如下图左边红、蓝框圈住的地方。探测点有多种类型,后面详述。

每个探测点上还挂有一个可配置脚本,如下图右下角红框圈住的区域,可以配置当前探测点触发时的神龙在 z 轴上的层级("Z value"),以及该探测点触发后对应的福袋槽位上挂的福袋多少秒后播放 “ 出现 ” 动画,即下图左下角的"Red Packet Appear Time"),多少秒后播放 “ 消失 ” 动画,即下图左下角的"Red Packet Hide Time"。

2e6d56a01f452835215cd5e1659b1550.png

在水平方向上,当一个探测点位于屏幕中心时,该探测点被触发。不能以 x 轴上探测点到屏幕中心的距离接近 0 来判断一个探测点到达了屏幕中心,这样有很大误差,因为相机移动速度很快。甚至有可能错过探测点的触发时机,比如下一帧本来要触发的,结果当前帧由于某种原因卡顿了一下,deltaTime 突然变大很多,导致下一帧探测点直接越过屏幕中心,且距离远大于零。

在实际项目中,每个探测点都有一个标志位,这里记作 isTriggered,当探测点在屏幕中心右边时,isTriggered 记为 false,当某一帧探测点突然在屏幕中心左边时,说明探测点刚刚越过了屏幕中心,探测点触发,设置其值为 true。在回收队首地形块时,其上所有探测点的 isTriggered 标志位都要重置为 false,因为此时该地形块会被移到场景的最右边,也在屏幕中心右边。

当一个探测点被触发时,程序播放对应的神龙动画,神龙在场景中游动。同时,程序根据探测点上配置的时间设置定时器,经过"Red Packet Appear Time"秒后,播 放 福袋的“出现”动画,出现动画结束后自动播福袋的待机动画,经过"Red Packet Hide Time"秒后,播放福袋的“消失”动画。此神龙头部恰好撞到福袋。这些时间的值是设计同学根据神龙和福袋的动画时长在 SAR Creator 中配置的。程序只需读取配置,实现代码逻辑。时间线上,探测点触发与神龙和福袋动画的关系如下图所示。

4d7113fd8ac8f9f977e503d8f6650bdf.png

设计同学在制作神龙的每个动画时,都是以对应主题的第一个地形块的左下角为参考点。程序运行时,需要在第一个地形块中,记录每个探测点和地形块左下角的偏移向量。在后面所有地形块中播放神龙动画前,都要重置神龙的位置。神龙的新位置是以探测点为基准,减去对应的偏移向量得到的。设计同学保证所有探测点都在第一个地形块中出现。

ee4befc91b9412d73c82cb96356aab61.png

每个主题的「寻宝场景」有多个类型的探测点,如上图所示。普通探测点达到屏幕中心时,播放对应的神龙动画,并且若福袋槽位上挂有福袋,会在配置的时间后,播放对应的福袋动画。两个特殊探测点是在普通探测点基础上添加了限制或功能。

  • 好友龙探测点:用户获得其他用户助力后去寻宝,好友龙探测点首先被触发,程序除了播放主龙动画外还播放好友龙动画,下图左边半透明的就是好友龙。

  • 接近好友龙探测点的探测点:以“nearFriendDetector_”开头的探测点是在位置上十分接近“好友龙探测点”的探测点,如下图右边红框中第二个探测点(紫色方块)。当有好友助力时,在第一个地形块首次出现时,程序不触发这类探测点,因为好友龙相关的动画只出现一次且不能被打断。其它情况,其行为和普通探测点一致。

6e5c02110a3a24ba740b1f7513c19456.png

寻宝过程中福袋其实有两种美术表达形式:

  • Spine 动画:神龙“撞”到的福袋,即上面所述的,是互动侧实现的,每个福袋是一个 Spine 动画,如下图左边红框里圈住的部分。

  • Lottie 动画 当神龙撞到 Spine 福袋后,Spine 福袋消失,同时屏幕中心出现一个大福袋,它是一个 Lottie 动画 ,并掉落到底部的宝箱中,如右图蓝框中圈中的福袋,这是前端同学实现的

06b17c2a05453be2c155d046a56ea684.png

「寻宝场景」的龙蛋视觉效果,是前端同学实现的。互动侧代码根据 timeline 数据检测到一个龙蛋触发时,就向前端发送消息,前端代码弹出一个龙蛋的 Lottie 动画,如本文开头的视频所示。

2D 场景实现“3D”效果

寻宝场景」是 2D 的,如何实现自然的“3D”效果呢?下图左边是丹霞主题,龙可以穿过石拱门,龙身一部分在拱门前,另一部分在拱门后;右边是山川主题,龙可以绕着山体一圈,龙尾在山后,而龙头在山前。这是设计同学通过在 SAR Creator 中设置 2D 元素的层级(z-轴)实现的

5c01a9eb83c514e9361e0a602156aa5f.png

以上图右边的“龙绕山”为例山顶其实有一小片是单独的精灵图,有单独的层级 ,与山体的层级不一样。当龙走到这里时,程序把龙的层级设置为一个恰好处于山顶和山体层级之间的值,这样就达到视觉效果了。下图右边是把山顶往左偏移后的效果,方便观察。

5bbfadfb843908b35ee59e38e2bc05f0.png

「寻宝场景」有四个主题的地形,但神龙只有一个,独立于地形之外。在不同主题和探测点处,神龙的层级值是不同的,这一点是通过在探测点上添加 Z Value 配置实现的,在上一小节的截图中展示过。每当一个探测点触发时,程序先读取配置的 Z Value,并把它赋值给神龙的 Entity,然后再播放神龙动画,就实现“龙绕山”的“3D”效果了。相关流程如下图所示。

48a30a02786170295cbbd839f165e653.png

相关伪代码:

// 播放每一个动画时,主龙的transform3D.position.z的值都有一个对应的配置,以实现渲染层级。
  newWorldPos.z = curDetector.ipZValue; // ipZValue是探测器上配置的Z Value。
  const ipAnimEventInfo = {
    isFriendDragon: curDetector.isFriendDector,
    animName: curDetector.ipAnimName,
    newIPPos: newWorldPos
  };
  // 播放神龙动画,playIPAnimation()内部重置神龙位置
  playIPAnimation(GameEvent.TRAVEL_IP_ANIMATE, ipAnimEventInfo);
}

正是为了实现这种“3D 效果” ,我们才引入探测点的概念,互动代码需要感知「寻宝场景」的环境信息,以播放对应的神龙动画。引入福袋槽位的概念是为了方便实现神龙头部撞上福袋的视觉效果。福袋槽位的位置是由设计同学精心设计好的,可以保证龙头恰好在对应的时间经过那里。福袋槽位的位置是固定的所以互动代码并不能完全遵循服务器下发的 timeline 中福袋的触发时间戳,而是尽量和它对齐,同时有一些自己的规则,比如:不能早于 timeline 中的时间触发福袋、优先把福袋放置在最近的可用福袋槽位上等。

场景管理策略

上文介绍了「家场景」和「寻宝场景」的实现。如何将这两个场景串起来,做好场景管理呢?

首先,我们需要确认游戏初始化完后应该加载哪个场景。引擎能力准备完毕后,互动向前端获取本次用户进入游戏的活动数据,判断进入游戏后是“寻宝中”还是“在家”的状态,根据状态,加载对应场景的资源,然后展示给用户。

如何实现两个场景的丝滑切换?例如,用户此刻在「家场景」,点击“去寻宝”,如下图所示。

777853085859a84bfc183f266bd045ce.png

龙转场

我们会播一个龙的转场动画,转场完,给用户展示出另一个场景。

首先,设计同学导出一份 spine 动画资源, 有 start、loop、end 三个动画,分别为龙从屏幕左下角起飞、龙身占满整个屏幕循环播放、龙身离开直到龙尾离开屏幕。

d10b2ca3f117cde430d8fa32212f59c6.png

在龙身 loop 动画的这一段时间内,进行另一个场景的加载和逻辑处理。

c5d3a9a4d98ed4eb6e76a787980641e1.png

相关代码如下:

interface TransferLifeCycle {
    onStart?: () => Promise<void>; // loop动画开始时机,此时用户完全看不到转场后面的内容
    onEnd?: () => void; // loop动画结束时机,此时用户能看到转场后面的一些内容
    onRemove?: () => void; // end动画结束时机。此时龙尾巴完全离开屏幕
    onError?: (e: Error) => void; // 转场出错
}

// 转场逻辑
class Transfer {
    _spine: Spine; // 转场的动画资源
    _transfer!: TransferLifeCycle; // 存转场的钩子函数
    _canEnd = false // 标记用户的start逻辑是否处理完毕
    
    startTransfer = async (params: TransferLifeCycle) => {
        this._transfer = { ...this._transfer, ...params }
        try {
            // 开始触发spine的start动画播放,交由spine的complete监听来处理每一个阶段的逻辑
            this._canEnd = false
            // 若未加载Spine,则加载spine资源,并播放其的'start'动画,略。
        } catch(e){
            this._transfer?.onError?.(e);
        }
    }
    // Spine资源加载完毕后,此回调函数被自动调用
    async onSpineAnimComplete(entry: any) {
        const animateName = entry.animation.name;
        // start动画播完 => 需要开始播loop动画,并处理onStart的逻辑
        try {
            if (animateName === 'start'){
                // 播放Spine的'loop'动画, 略。
                await this._transferParams.onStart?.()
                this._canEnd = true // 标记用户处理完了onStart逻辑
            } else if(animationName === 'loop'){
                if(this._canEnd) {
                    // 处理完了onStart逻辑。播放end动画,略。
                    this._transfer?.onEnd?.()
                }
            } else if (animationName === 'end'){
                this._transfer?.onRemove?.()
            }
        } catch (e){
            this._transfer?.onError?.(e)
        }
    }
}

“家”和“寻宝”两个场景的管理怎么做呢?主要使用了“预加载”、“缓存”和“销毁”三种手段。

预加载

为了做到场景加载的更快,对场景进行预加载,提升用户的体验。

游戏初始化后,若加载的是“家”场景,则充分利用加载完“家”到用户“点击寻宝”之间的这段时间,对“寻宝”场景进行预加载。

const isHome = mainData.isHome // 是否是家场景

const preloadTravel = () => {
    const { bundle } = assetManager.loadBundle('travel')
    bundle.load('Travel.prefab')
}

const preloadHome = () => {
    xxx
}

// 预加载另一个场景
const preload = () => {
    if(isHome){
        preloadTravel()
    }else{
        preloadHome()
    }
}

利用 bundle.preload( prefab )可以将 prefab 依赖到的资源提前 fetch 到本地 。

缓存和销毁

除了预加载资源,我们还适当地使用了缓存,用空间换时间,提升切换场景的速度。 

SAR Creator 提供了将子节点从父节点移除,但是不销毁其依赖的资源的能力。这是实现缓存逻辑的基础。

class SceneManager {
    homeRoot?: Entity; // 家场景
    travelRoot?: Entity; // 寻宝场景
    
    // 加载
    async loadHomeRoot() {
        // 若有缓存,这步就不会走,直接addChild即可
        if(!this.homeRoot){
            this.homeRoot = await bundle.load('HomeRoot.prefab')
        }
        
        // 加载缓存的或者第一次初始化出来的家场景
        if(this.homeRoot){
            scene.addChild(this.homeRoot)
        }
    }
    
    async loadTravelRoot() {
        if(!this.travelRoot){
            this.travelRoot = await bundle.load('TravelRoot.prefab')
        }
    }
    
    // 缓存
    dispose() {
        // 缓存
        if(USE_STORAGE){
            // 将节点从场景中移除,但保留其依赖的资源
            this.homeRoot?.parent?._deleteEntityFromChildren(this.homeRoot)
        }else{
            // 销毁
            entity.dispose()
        }
    }
}

所有机型无差别地缓存,风险很大。为此,我们对低端机采取资源销毁的逻辑。

使用entity.dispose方法实现销毁逻辑,它会递归该 entity 及所有子 entity 依赖的资源,释放其纹理、material、geometry 等。

对于使用缓存还是销毁,程序定义了如下数据结构:

export interface DowngradeIParams {
    // 静态获取
    enable: boolean,
    blackList: [],
    i32Forbidden: boolean, // 是否在32位包上禁用缓存能力
    deviceScoreHigh: 10, // 超过此评分算高端
    deviceScoreMid: 8, // 超过此评分算中端
    deviceLevel: ['high', 'mid', 'low'], // 缓存能力启用的机型
    
    // 动态获取
    memoryLimit: Infinity // 剩余内存超过这个数才启用
}

上面数据结构提供了全局开启/关闭(enable)、机型黑名单、32 包禁用、机型打分、动态内存等多个度量标准来帮助我们做缓存/销毁的判断,配置的数据走 settings(字节内部客户端配置动态下发平台)下发。

基于这些技术,每次场景切换时,我们根据当前的机型信息和实时内存数据来判断采用哪种策略,例如,剩余内存不够多时,加载“寻宝”场景,并销毁“家”场景的所有资源,以此来保障游戏稳定性

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队、跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

系列下期主题是 AnnieX 互动容器。作为字节互动解决方案的一环,结合客户端能力,为互动场景提供极致的性能体验,满足互动游戏多样化场景需求。敬请期待。

Monorepo 解决方案 — 基于 Bazel 的 Xcode 性能优化实践

背景介绍

书接上回《Monorepo 解决方案 — Bazel 在头条 iOS 的实践》,在头条工程切换至 Bazel 构建系统后,为了支持用户使用 Xcode 开发的习惯,我们使用了开源项目 Tulsi 作为生成工具,用于将 Bazel 工程转换为 Xcode 工程。但是在使用的过程中,我们发现了一些问题,其中影响较大的是,

  • Xcode 工程卡顿:对于头条这种大型项目来说,Xcode 卡顿一直是本地研发的痛点问题,在切换 Bazel 构建系统之后卡顿现象明显加剧。

  • Xcode 功能支持受限:Tulsi 支持的功能有限,很多功能都年久失修,并未持续适配 Bazel 与 rules 的更新。

在做了一些前期调研后,我们发现 rules_xcodeproj 提供了更好的解决方案。rules_xcodeproj 是一个由一系列 Bazel rules 组成的开源项目,使用它可以从一个 Bazel 工程生成对应的 Xcode 工程,实现在 Xcode 中写代码同时使用 Bazel 进行真正的构建任务,相比于 Tulsi,rules_xcodeproj 在以下几个方面有着更为明显的优势。

  • Xcode 工程更加流畅: 头条工程迁移到 rules_xcodeproj 后,工程首次冷启、二次启动和文件增删操作的时间有了明显缩短,工程卡顿情况也明显好转。

  • Xcode 功能支持度更高: rules_xcodeproj 对 Xcode 的支持更全面(包括单元测试、SwiftUI Previews),能够更好地满足我们的需求。

  • 社区更加活跃:随着 rules_xcodeproj 在 2023 年 2 月份发布正式版,Tulsi 项目也正式宣布停止维护,这意味着对于新版本的 Bazel,Tulsi 将不再提供适配和支持;同时 rules_xcodeproj 几乎每月都会更新一个中版本,对于后续适配 Bazel 更新的成本会更低。

  • 更符合 BitSky 的演进路线:由 Bazel 驱动的工程生成方式更符合 BitSky 的 Bazel Native 演进路线,可以完全在 Bazel 环境下生成工程。

因此,我们将 Xcode 生成工具从 Tulsi 迁移到了 rules_xcodeproj,并对 BitSky 工具链进行了适配。适配过程中,我们在修复 rules_xcodeproj 索引问题的同时,对工程结构进行了一些优化,进一步提升了工程流畅度。头条 iOS 工程的测试数据如下:

测试设备 MacBook Pro,芯片 M1 Pro,内存 32GB

ps. 本文介绍均基于 rules_xcodeproj 1.4.0 版本,build with bazel 模式

pps. 由于 Xcode 主线程的卡顿较难监测,此处用主动操作的执行时间衡量流畅度


Tulsi rules_xcodeproj
工程首次冷启 47s 16s
二次启动 33s 12s
文件操作 新增 20s
删除 23s
新增 8s
删除 6s

下面来看下我们具体做的适配工作。

适配过程

从 Tulsi 迁移到 rules_xcodeproj 后,我们发现 Xcode 卡顿有明显改善,仔细分析发现 Tulsi 工程的卡顿主要有两方面原因:

  1. 头条工程中组件数量多、依赖关系复杂:Tulsi 的索引方案需要 Xcode Target 之间保留这些依赖关系,但这些依赖关系并不会被 Xcode 构建消费,却增加了 Xcode 对工程进行解析和计算的成本。

图中 Pod X_A 与 Pod X_B 为 Pod X 分别在 App A 与 App B 下被引用的 Target

4ec246b5859f3a8f8259299f78a27790.png
  1. 全源码构建:我们在切换 Bazel 时还推进了工程的全源码构建,源码数量大幅增加,也给索引任务带来更大的压力。

接下来详细说明整个分析和适配过程,带大家更全面地了解我们结合 rules_xcodeproj 在 Xcode 背后做了什么。

在 Xcode 中开发时主要会用到三部分功能:构建、索引和调试。而 Xcode 并不能直接理解 Bazel 项目的工程文件(BUILD 和 WORKSPACE),所以我们需要通过工具(rules_xcodeproj 或 Tulsi)将 Bazel 的工程文件转换为 Xcode 可以理解的 .xcodeproj来支持这些功能正常工作。

各功能的适配点如下表所示。

ae4f2b66f0acc6128c5367818f006120.png

rules_xcodeproj 原生方案的调试模块基本能正常工作,而索引和构建两个模块中我们对原生方案的改造较大,因此本文主要对这两个模块进行展开介绍。

索引

前面提到支持 Xcode 索引功能需要提供各个源文件的编译参数,rules_xcodeproj 实现这一点的工作流程是:

  1. rules_xcodeproj 在 Bazel 的分析阶段获取到源码文件和编译参数;

  2. 用这些信息创建 Xcode Target 的 Compile Sources 和 Build Setting 产出工程文件 .xcodeproj

  3. Xcode 加载工程文件,获取源码文件对应的索引参数,调用 clang 或 swiftc 执行索引命令。

迁移过程中我们注意到 rules_xcodeproj 工程的索引在多 Target 共用源文件的场景存在语法高亮异常的问题,对比 Tulsi 工程的处理方式后我们得出两个结论:

  • 索引异常是由于 rules_xcodeproj 移除了所有 Library Target 间的依赖导致的;

  • Tulsi 工程中 Library Target 间的依赖关系正是导致 Tulsi 工程更为卡顿的关键原因。

后文会先分析依赖关系在索引中发挥的作用,以及 rules_xcodeproj 如何处理依赖关系移除带来的问题,然后介绍我们如何通过源码合并方案解决 rules_xcodeproj 索引方面的缺陷。

依赖关系在索引中的作用

这里提到的“依赖关系”主要是指 Xcode 工程文件(.xcodeproj,准确来说是其中的project.pbxproj文件)中记录的 Xcode Target 之间的依赖关系。

需要明确的是这部分依赖关系只会在 Xcode 索引过程被用到,被移除后也只会影响 Xcode 的索引功能。构建时用到依赖关系 Bazel 会从 BUILD 文件中获取,不会关心 .xcodeproj 中的信息。

这些依赖关系在 Xcode 中的表现形式如下图所示,既有直接在 Build Phases 的 Target Dependencies 中声明的依赖,也会对声明在 Link Binary With Libraries 中 Target 产生依赖。

9a70beae7d3eabf6c246b869a8ec32a8.png

概括来说依赖关系在 Xcode 的索引过程中发挥着以下两方面作用:

  • 正确的中间产物生成顺序:clang/Swift Module 中间产物的生成需要依赖关系来确定构建顺序;

  • 正确的多 Target 编译参数:多 Target 共用源文件时应用正确的编译参数,以便于正确地高亮代码。

下面分别对其进行展开介绍。

中间产物生成顺序

以一个 Swift 组件为例,当它被 OC 组件引用时,需要生成一个 XX-Swift.h 文件,把方法和声明暴露给 OC 组件,当它被其他 Swift 组件引用时,也需要生成一个 swiftmodule 文件供其他 Swift 组件引用。XX-Swift.h 和 swiftmodule 并不是原始的源码文件,也不是最终的二进制产物,是构建时的中间产物。在 Xcode 对一个组件的源代码索引时,需要这个组件的依赖组件已经准备好中间产物供索引时消费。这里,我们先分析下 Xcode 是怎样解决这个问题的。

首先,Xcode 通过 Target 来描述产物是如何构建出来的,每个 Target 拥有自己的 Build PhasesBuild Settings(通常一个组件对应一个 Target)。Build Phases 中的 Compile Sources 记录了构建这个 Target 需要编译哪些源码文件。Build Settings 里记录了构建这个 Target 时的各种配置,这其中就包括了编译参数。Xcode 可以从 Build Settings 里去获取编译参数,然后对 Compile Sources 中记录的源码文件进行索引编译。

2414d15fa65de663ef9fc654842c642b.png

一个 Target 引用另外一个 Target 时,需要将依赖关系添加到 Build PhasesTarget Dependencies 中。Xcode 在构建时会根据这些依赖关系来决定构建 Target 的顺序,保证一个 Target 构建时,它依赖的 Target 已经完成构建。

索引时也是类似的处理,Xcode 有一个 Index Build 的阶段,在这个阶段也会根据依赖关系按照顺序去触发 Target 的 Build Phase,完成之后才会开始索引这个 Target 的源码文件。

e133de8ddd922d6967a7b560c6ae0ede.png

这里有一个例子,SwiftDemo 依赖了 HelloLib,两个 Target 均为 Swift 组件。

f14f30f22fdb571db7e449ef614a591a.png

在对 SwiftDemo 的源码文件进行索引编译之前,会先触发 Index BuildIndex Build 时根据依赖关系,先 Build HelloLib,这个时候会进行 Compile Swift source files,参数中包含 -emit-module -emit-module-path /path,最终会生成 swiftmodule 。

f87c913f5b942d6786a1cf97f33728f2.png

如果将 HelloLib 从 SwiftDemo 的 Target Dependencies 中移除,在 SwiftDemo 的 Index Build 时,不会先 Build HelloLib,并且在 HelloLib 的 Index Build 中,也不会生成 swiftmodule。

可以看出,Xcode 正是通过依赖关系来保证构建时的顺序,索引也依赖构建顺序来保证 Module 中间产物的生成时机。

在 Tulsi 生成的 Xcode 工程中,依然保留了 Target 之间的依赖关系,用于解决索引的中间产物生成问题。这里就不做过多介绍。

而在 rules_xcodeproj 生成的工程中,Target 之间的依赖关系是完全去掉的,每个 Target 都有且仅有一个额外添加的依赖 BazelDependencies。对于 Module 中间产物的处理,在生成的 Xcode Target 中,我们可以看到这样一条 Build Phase

6b02f18b9c49dd4fb39cbf5b187be395.png

原理是在 Index Build 阶段,执行到这 Target 时去跑一个 shell 脚本。

以 NewsInHouse 这个 Target 为例,这个脚本里经过一系列的处理,最终会去调用这样一条 Bazel Build 命令,

403385e739f097aa6325286ad7cc20e1.png

在这条命令中有一些关键信息:

@rules_xcodeproj_generated//generator/xcodeproj:xcodeproj

Bazel Build 的 Target,可以认为是我们使用 rules_xcodeproj 定义的生成工程的 Bazel Target。

bc //Article:NewsInHouse applebin_ios-*

在上述 Target 里 rules_xcodeproj 添加了一些 OutputGroupInfo。Bazel Build 时可以通过 --output_groups 参数指定输出产物。

这一条 output group 对应的是 //Article:NewsInHouse 及其依赖 Target 的产物中, swiftmodule 之类的编译依赖部分。

bg //Article:NewsInHouse applebin_ios-*

这一条 output group 对应的是 //Article:NewsInHouse 及其依赖 Target 的输入文件中的非源文件的部分,比如编译时依赖的 hmap 。

这样的 Bazel Build 命令可以在 Index Build 时将 hmap 和 swiftmodule 之类的索引中间产物生成出来,然后再索引具体文件时就不会因为缺少这些中间产物而失败。

并且这里的依赖关系是由 Bazel 去处理的,不是必须像 Xcode 原生机制那样,按照依赖关系来决定 Index Build 中 Target 的顺序。

所以仅从 "Module 中间产物" 这方面来说,Xcode 中的依赖关系并不是必需的。

多 Target 编译参数

除了中间产物生成之外,依赖关系在多个 Target 共用源文件时的编译参数计算也发挥着作用。这里的“编译参数计算”是指当一个源文件被不同 Target 引用时,应用的编译参数可能不同的情况。这么介绍比较抽象,来看下具体的例子:

下面 Demo 工程中有两个 App Target:AppA 和 AppB

  • 在两个 App Target 的 Build Settings 中分别注入了宏IS_APP_AIS_APP_B

b61e982229b656e45a22aea1fac454cb.png688f083887273e47d8649857f9d63926.png
  • 有一份公共的代码文件 public.m 分别被添加到两个 App Target 的 Compile Sources 中。

eb35f875f2cc087c3805c5cba1c451be.pnga9ff4466ff877923f1cf9c1843774e14.png
  • public.m 内用预编译宏隔离了存在差异的逻辑

  • 随着我们切换构建的 App Scheme,由于编译参数的差异,宏作用域中高亮的代码区域也会随之变化(如下图)。

29024af5ffe1e9c7ce3faadbfb7697ca.png2e0e51210f4c66bd2ca580a2a9b20999.png

此时的工程结构如下图所示,Xcode 可以通过选中的 AppA Scheme 获取到 AppA Target 的 Build Settings(图中红线路径),正确地传递编译参数-DIS_APP_A=1

31e5adafeea72779cfbf53715ff7f6d2.png

实际的情况会复杂一些,因为工程的组件化建设将代码下沉到了一个个组件内,而非直接与 App Target 关联。此时同一份代码文件在不同 App Target 内的索引参数计算,则是通过 Target 之间的依赖关系实现的。

对应到 Xcode 中,Xcode 可以通过 App Target -> Library Target 的依赖关系,应用对应的 Build Settings 生成索引。

1ed1eae4b77ab07080641ebf9ebb2ecd.png

此时的工程结构则变成了下图的模式,Xcode 依然可以通过 AppA Target 与 PublicLibraryA Target 的依赖关系应用正确的编译参数(图中红线路径)。

471dc3126ce61f6981f7abc06acab5a9.png

Xcode 原生工程和 Tulsi 生成的 Xcode 工程都是通过这种依赖关系来保证编译参数的正确计算的。

而 rules_xcodeproj 生成的工程完全移除了 Target 之间的依赖关系,转而给所有 Target 都添加了对 BazelDependencies 的依赖(如下图所示)。

b5dd264280a1bc407672bf1d1b81a8ab.png

从图中可以看到,在缺少 AppA Target 对 PublicLibraryA Target 依赖的情况下,对于同时被 PublicLibraryA 和 PublicLibraryB 引用的 public.m ,Xcode 无法感知应该应用哪个 Target 中的编译参数(图中红线路径无法关联 AppA Scheme 与 public.m)。此时 Xcode 触发索引时应用的 Build Settings 是固定的,不会随着构建 App Scheme 切换而更改。

具体的表现当构建目标从 AppB 切换到 AppA 时,IS_APP_B宏中的代码仍然会展示为高亮,而不会随之切换,从而给开发者带来困惑。

c4e6a814d21eee832f88595647f2eda0.png

对于这个问题,rules_xcodeproj 可以通过构建索引解决,因为依赖信息在 Bazel 侧(BUILD 文件中)是完整的,所以触发构建后可以让代码高亮正确展示。

但由于编辑索引使用的参数是 Xcode 从文件所属的 Library Target 的 Build Settings 中获取的,因此在代码编辑过程中仍然会出现高亮错误的问题。

构建索引:指在构建过程中输出索引产物,需要通过 index-import 导入 Xcode 缓存目录(Derived Data)下供 Xcode 消费。

编辑索引:在代码编辑过程中实时生成的索引,在内存中消费索引结果,不会将产物写入磁盘。

在这个场景下,rules_xcodeproj 移除依赖的做法是有缺陷的

那么我们要在 rules_xcodeproj 恢复 Target 间的依赖关系么?

答案是不需要。首先,前文有提及大量复杂的依赖关系会导致 Xcode 卡顿,不应恢复;其次,要解决这类代码高亮错误的问题,需要的其实并不是所有 Target 之间的依赖关系,而是源码文件当前构建 App Target 的关系。

回顾一下 Demo 工程最简单的结构,当源码文件直接被对应 App Target 引用时,是不需要 Library Target 间的依赖关系来建立联系的。

29ae12a58eb0efcf6f9ddf8f1252a741.png

基于这一思路,我们将所有 Library Target 的源码合并到了对应 App Target。当然,直接合并源码以后索引并不能正常工作,需要对受影响的功能点进行适配,这些适配将在下一节源码合并方案中展开介绍。

源码合并方案
索引参数接管

将所有源码合并至 App Target 虽然能解决文件与 App 的关联问题,但各个 Library Target 编译参数是不同的,聚合之后不同 Target 下源文件的参数就无法通过 Build Settings 区分了。

对于这个问题,我们是通过 XCBBuildServiceProxy 接管索引参数计算解决的。

索引构建时,Xcode 会先将文件所属 Target 的 Build Settings 发送给 XCBBuildService 处理成编译器理解的参数,再交给 SKAgent 触发编译器进行实际编译行为。而我们在 XCBBuildServiceProxy 的基础上实现了 BitSkyXCBBuildService,可以拦截 Xcode 发给 XCBBuildService 的请求,通过 Bazel aquery 查询到具体文件的编译参数,直接返回给 Xcode 完成后续的索引构建行为。

2284995caa498d3ca7a0e8e8d855e8d6.png

完成源码合并索引参数接管这两步改造以后,工程结构如下图所示。可以看到 AppA Scheme 能够直接通过 AppA Target 关联到  public.m(图中红线),从而正确地应用编译参数,高亮对应代码块。rules_xcodeproj 移除依赖关系的副作用也完全被修复。

e51d0d84d960fe3075732b71d8affd40.png
Library Target 移除

完成源码合并以及索引参数的接管之后,Library Target 中的主要信息( Build Settings 和源码)都不再有意义了,是否能将这些 Target 信息直接移除呢?

经过梳理,Library Target 主要有以下三个作用,在完成源码合并以及索引参数接管后,仅需对“Module 中间产物生成”进行一些改造即可将几百个 Library Target 的信息进行移除,大幅精简工程文件的内容。

Library Target 作用 说明 适配方案
触发 Xcode 索引 Xcode 只会对添加到 Build Phase - Compile Sources 中的源码文件生成索引 将源码添加到 App Target 的 Compile Sources 中有相同的效果;
按 Target 维度隔离 Build Settings Xcode 原生的索引功能会通过 Build Settings 生成文件的编译参数 通过 XCBBuildServiceProxy 接管索引参数请求,交由 Bazel aquery 查询具体文件的编译参数
Module 中间产物生成 在 Library Target 的 Build Phase 触发各个 Target 维度的中间产物生成 将所需产物聚合到各个 App Target 的 Build Phase 触发生成

最终的工程结构如下图所示。

a9ea7b9908b2eeafafad6fad41d0833b.png

整体方案上线后,头条工程文件(project.pbxproj)行数从 45w 减少至 35w,工程启动与文件操作耗时也比原来的 Tulsi 工程减少了 60% 以上


Tulsi rules_xcodeproj(原生) rules_xcodeproj(源码合并)
工程首次冷启 47s 22s 16s
二次启动 33s 16s 12s
文件操作 新增 20s
删除 23s
新增 13s
删除 11s
新增 8s
删除 6s

p.s. 源码合并后存在一个副作用是 Xcode Build Phase 页面加载时间会增加很多,但考虑到使用 bazel 构建后我们并不需要在 Build Phase 修改配置,这个副作用是可以接受的。

构建

rules_xcodeproj 目前提供了两种 Build 模式,分别是 "Build with Xcode" 和 "Build with Bazel"。

  • "Build with Xcode" 模式下,构建行为是由 Xcode 接管的。

  • "Build with Bazel" 模式下,构建行为是由 Bazel 接管的。

据 rules_xcodeproj 官方介绍,"Build with Xcode" 模式在 Bazel 7 下将很难支持,并且即将到来的新的增量生成模式也会放弃 "Build with Xcode" 。

所以这里主要看一下 "Build with Bazel" ,这个模式生成的工程中,宿主 Target 对应一个 XCScheme,这个 Scheme 的 Build Pre-actions 里生成一个 SCHEME_Target_IDS_FILE 用于记录 Target 的 Bazel Label。然后宿主 Target 依赖了 Target BazelDependencies,Xcode 在构建宿主 Target 之前会先构建 BazelDependencies。BazelDependencies 通过 Build Phase 去调用 Bazel Build,这个时候会解析 SCHEME_Target_IDS_FILE 获取需要构建的 Bazel Target。BazelDependencies 构建完成后,宿主 Target 的 Build Phase 里会去把相应的 Bazel 的产物拷贝到 Xcode Derivedata 目录下。

c7851019139afd7c4452be34401c92d4.png

另外,在 rules_xcodeproj 的规划中,未来还会提供一种新的模式,叫做 "Build with Proxy",在这个模式下,会通过 XCBBuildServiceProxy 完全绕过 Xcode build system,由 Bazel 控制整个 build 过程。相比 "Build with Bazel" ,这个模式可以带来一些更贴近原生的 Xcode 使用体验,比如:

  • 无需添加 BazelDependencies Target

  • 可以去掉重复的 warnings/errors

  • 可以有更稳定的索引效果

  • 可以在进度条展示更多信息

  • 可以有更详细的 Build 报告

当然这种模式也存在比较大的问题

  • 在不同 Xcode 版本之间,Xcode 和 XCBBuildService 交互的 API 可能会有一些破坏性的变更,需要逐一适配

  • 需要在 Xcode 启动时注入环境变量,将 XCBBuildService 指向自定义的 XCBBuildServiceProxy

BitSky 目前采用的方案和 "Build with Proxy" 是类似的,通过 BitSkyXCBBuildService 接管 Xcode 的 build 行为。在用户点击 Build 时,BitSkyXCBBuildService 里可以从宿主 Target 的 Build Settings 里解析获取对应的 Bazel Target,然后再由 BitSky 生成调用 Bazel Build 的命令,这样可以保证 Bazel Build 的参数完全由 BitSky 控制,同时可以通过 Bazel 的 Build Event Protocol 来更好的提供 Xcode 的进度 和 Build 日志展示。

649de1ed5e5b365e161175e8965aeb1a.png

同时为了保证在打开生成的 Xcode 工程时,都能够使用 BitSkyXCBBuildService,BitSky 在生成工程同时,会生成一个 Xcode 的影子分身 BitSkyXcode。使用这个 BitSkyXcode 打开工程,无需手动注入环境变量,体验上和使用原生 Xcode 打开工程基本一致。

总结

本文主要介绍了我们将 Xcode 工程生成工具切换到 rules_xcodeproj 过程中做的一些适配和优化工作:

  • 索引方面:

    • 在分析 Tulsi 与 rules_xcodeproj 工程文件的过程中我们注意到最大的差异在于 rules_xcodeproj 移除了 Library Target 间的依赖关系,这也是 Tulsi 工程更加卡顿的罪魁祸首。

    • rules_xcodeproj 移除依赖关系后会导致多 Target 共用的源文件语法高亮异常,我们通过源码合并方案解决了这个问题,并且精简了工程文件信息,提升了 Xcode 流畅度。

  • 构建方面:

    • 我们通过 BitSkyXCBBuildService 接管了 Xcode 的 build 行为,能够更好地管理构建参数并在 Xcode 提供构建进度和日志的展示。

在完成切换之后,虽然 Xcode 代码编辑过程中的卡顿得到了明显的缓解,但本地研发的调试过程,仍然存在 Xcode 卡顿/卡死等现象,对研发同学的开发工作存在较大困扰。后续,我们将针对调试体验,从生成工程的角度做一些优化工作。

目前考虑基于 Focus Mode 的理念,从底层能力上支持研发同学仅关注与当前需求开发相关联的部分代码,比如:

  • 裁剪 Xcode 工程中需要索引的源代码;

  • 裁剪构建过程中需要执行编译源代码;

  • 裁剪调试时调试器需要加载调试信息;

另外在用户侧,通过策略智能帮助研发同学,选择和添加需要 "Focus" 的源码。

参考文档

Tulsi (https://github.com/bazelbuild/tulsi)

rules_xcodeproj (https://github.com/MobileNativeFoundation/rules_xcodeproj)

Migrating from Xcode to Bazel (https://bazel.build/migrate/xcode)

Monorepo 解决方案 — Bazel 在头条 iOS 的实践

哔哩哔哩 iOS Bazel 进化之路

用VSCode基于Bazel打造Apple生态开发环境

抖音 ANR 自动归因平台建设实践

背景介绍

本文在 2024 年初最新一期『抖音客户端基础技术大揭秘』技术沙龙活动中已做过专题分享,本次将内容重新整理文章进行分享。公众号后台回复技术沙龙可查看沙龙回放及 PPT~

抖音作为一个超大型的应用,我们在 ANR 问题治理上面临着很大的挑战。首先对于存量问题的优化,由于缺少有效的归因手段,一些长期的疑难问题一直难以突破解决,例如长期位于 Top 1 的 nativePollOnce 问题。同时我们在防劣化上也面临很大的压力,版本快速迭代引入的新增劣化,以及线上变更导致的激增劣化,都需要投入大量的人力去排查定位,无法在第一时间快速修复止损。

ANR 原理简介

既然我们要建设的是 ANR 归因平台,首先需要了解下什么是 ANR ?它是 Android 系统定义的一种“应用程序无响应”的异常问题,目的是为了监控发现应用程序是否存在交互响应慢或卡死的问题。从用户的视角来看,发生 ANR 时设备上会出现提示应用无响应的弹窗,甚至在一些机型上可能就直接闪退了。所以 ANR 与其他崩溃问题一样,是一种会对用户体验造成严重打断的异常问题。

接下来我们从系统的设计原理来看下为什么会发生 ANR ?以一种常见的广播超时引起的 ANR 为例,首先系统 AMS 服务会通过 IPC 方式将一个有序广播发送给应用进程,并在同时启动一个超时监控。应用进程在 Binder 线程接收到广播之后,会将其封装成一个消息 Message 加入到主线程的消息队列里等待执行。正常情况下,广播消息在应用内都会得到及时响应,然后通知系统 AMS 服务取消超时监控。但是在一些异常的情况下,如果在系统设置的超时到来之前,目标消息还没有调度执行完成的话,系统就会判定响应超时并触发 ANR。

de9c98e41446f6d2bb1962ea348f715f.png

归因方案现状

当前业界针对 ANR 问题的归因手段有哪些?第一种是传统归因方案:基于系统生成的 ANR Trace 和 ANR Info 来定位问题原因。这里的 ANR Trace 是在问题发生时系统通知应用自身 dump 采集的各个线程的堆栈以及状态信息,而 ANR Info 中则包括了 ANR 原因、系统以及应用进程的 CPU 使用率 和 IO 负载等信息。对于像下图中这类由于当前消息严重耗时或卡死引起的 ANR,这种系统原生的方案可以帮助我们快速的定位到问题堆栈。

但是它也存在一个明显的问题,从前面的原理分析可以知道,广播等系统组件消息在加入到主线程之后,是按照它在消息队列中的先后顺序来执行的,所以有可能是之前的历史消息存在严重耗时从而引起的问题。这种情况下在 ANR 实际发生时系统抓取的堆栈很有可能就已经错过了问题的现场,基于这样的数据进行归因得到的结果也是不准确的。

7cca25514d47ef655bed43ed0a6d6710.png

第二种是慢消息归因方案:通过监控主线程消息的执行情况,并结合耗时消息的采样抓栈来定位问题原因。这个方案解决了前面传统归因方案中存在的问题,提供了一种更细粒度的监控和归因能力,可以同时发现当前和历史的耗时消息,以及其中可能存在的耗时问题堆栈。

但这个方案也同样存在的一些不足之处, 因为对于像抖音这样的大型应用来说,ANR 通常是由于各种复杂的综合性因素导致的,包括子线程 / 子进程 CPU 抢占、应用 / 系统内存不足等也都会对主线程的执行效率造成影响,间接导致主线程整体都变慢了。在这种情况下,主线程的慢消息或堆栈可能并不是问题的根本原因,同样以此得到的归因结果也是不全面的。

91df5902d92809ebc46c35875fb6b0f7.png

所以总结一下目前的现状,现有的 ANR 归因方案存在以下几个痛点问题:首先是归因不准确,归因结果难以消费,不能真正解决问题。其次是归因能力少,对于复杂问题难以定位根本原因。最后是归因效率低,人工排查周期长。

建设思路

接下来重点介绍下 ANR 归因平台的建设思路,平台归因体系主要围绕以下三个方向进行建设:

  • 单点问题归因:首先需要对单个 ANR 问题实现精准的归因,这也是我们整个归因体系建设的重点和基础。

  • 聚合问题归因:其次是线上大数据的聚合问题归因,帮助我们聚焦 Top 重点问题。

  • 劣化问题归因:最后线上灰度以及全量版本劣化问题的自动归因,提升新增 / 激增问题的解决效率,目前正在建设中,本次分享就不展开介绍了。

单点归因思路

单点 ANR 问题的归因可以分为三个步骤,首先需要从原理出发明确 ANR 问题区间;接下来对问题进行粗归因,也就是一种定性的分析,比如说是主线程阻塞卡死、还是 CPU 抢占或是内存异常导致的问题;最后就是进一步进行细归因,也就是需要定位到具体的问题代码,能实际指导我们消费并解决问题。

问题区间

首先从 ANR 问题的原理出发,来分析一下如何大致确定 ANR 问题区间。我们同样还是以上面的广播消息超时引起的 ANR 为例,问题产生的时间顺序为:从系统 AMS 服务发送有序广播并启动超时监控开始,到应用进程将该广播消息加入到主线程消息队列,并按照队列中的先后顺序等待调度执行。当系统进程的超时时间结束前,对应的广播消息还没有执行完成并通知系统,系统就会判定响应超时并触发 ANR。这里我们可以以 ANR 实际发生时间为结束点,往前回溯对应的超时时间(这里不同的 ANR 原因会对应不同的超时时间设置),也就是后续需要诊断分析 ANR 问题区间范围。

9c6cde83c1538f7c30d5e0973a27bf28.png
粗归因

在明确 ANR 问题的时间范围后,我们需要从技术角度来拆解下,如何进行粗归因的定性分析。从上面的原理分析可以推导出,引起 ANR 的根本原因就是系统组件消息(包括 input 事件)在应用侧没有得到及时的执行。而我们知道这些关键目标消息都是在主线程中进行消费处理的,所以这里的关键点就是 ANR 区间内之前的这些消息为什么执行耗时 ?从系统的角度来看,所有代码逻辑的执行可以分为 On-CPUOff-CPU 两种情况:

  • On-CPU:对应 Running 状态,即当前任务正在占用 CPU 资源进行计算处理。已知 计算耗时 = 计算量 / 计算速度,这里计算速度会受到 CPU 硬件本身的限制,比如 CPU 核心频率以及当前运行在大核或小核上。另一个跟应用自身关系比较紧密的就是计算量,例如主线程在执行 CPU 密集型的操作,比如 JSON 序列化 / 反序列化,或是在处理大量的高频业务消息。

  • Off-CPU:包括 Runnable 和 Sleep 两种状态。Runnable 代表任务所需的资源已就绪,正在 CPU 运行队列上等待调度执行,这里除了受到系统本身的调度策略的影响之外,也跟当前同样已就绪并等待调度的任务数量有关。如果子线程或子进程有很多 CPU 耗时任务在等待执行的话,因为总的计算资源是有限的,互相之间频繁的抢占也会影响主线程的执行效率。Sleep 代表任务在阻塞等待资源,比如等待 Lock、IO、同步 Binder 以及内存 Block GC 等。通常情况下我们应该避免在主线程发生这类 Block 阻塞问题,同时这也是比较常见的一类解决卡顿或 ANR 的优化手段。

32e7779f51a9592be17dddbcc021c758.png

下面我们来看下第一种主线程异常消息直接引起的 ANR,它可能是由于当前消息严重耗时或卡死导致的,也可能是由于之前的一个或多个历史耗时消息引起的 ANR。

92ba695a1daabe0b8ee0703059d83d70.pngb0bfab73400a0b1781a6111d9cf9cca1.png

第二种就是后台任务 CPU 资源抢占引起的ANR,它会间接影响主线程的执行效率。从下图中可以看到在子线程 CPU 负载变高之后,主线程的整体性能开始下降变慢,这种情况下就会更容易发生 ANR。最典型的就是在冷启动的场景下,通过降级或打散 CPU 耗时的后台任务,我们已经验证可以有效的降低 ANR 率以及缩短启动首刷耗时。

618c885a6c57751e8d372281291f0ab6.png

最后一种内存等资源异常问题,例如虚拟机 Java 内存不足时,GC 线程就会开始变得活跃并进行频繁 GC,这同样也会抢占主线程 CPU 资源或 Block GC 等待,从而导致 ANR 问题的发生。对于抖音这样的视频类大型应用,线上内存问题导致的卡顿或 ANR 问题的占比较高,目前也在专项治理优化中。

59bfa2da9d6547b9a7eb487375d30b46.png

基于以上的演绎推理,总结一下我们的归因思路主要包括以下几类:第一,主线程本身的异常问题,例如存在严重的阻塞等待或者 CPU 繁忙等问题。第二,后台任务抢占 CPU 资源导致的异常问题。最后,内存 / IO 等系统资源不足导致的异常问题,目前正在探索中,本次分享就不展开介绍了。

细归因
主线程消息异常

首先来看主线程消息异常的归因思路:我们需要先对主线程消息进行监控,这里包括三种情况,已经执行完成正在执行中的消息以及消息队列中待执行的消息。对于已经或正在执行的消息,我们主要关注它们的耗时情况,通过分析系统源码我们可以知道,主线程消息队列里处理的消息一共包括三种:Java 消息、Native 消息和 Idle Handler。而对于待执行的消息,我们主要关注其中的数量,从而判断是否存在大量消息堆积的异常问题。

59244ecdfdc57366ddbc9042f2f7f571.jpeg

对于主线程异常消息的问题类型,第一类就是耗时消息,即在问题区间内存在一个或多个耗时大于阈值的慢消息。另一种是高频消息,也就是存在出现次数以及累计耗时超过一定阈值的高密度消息。这里通过对业务消息的 target、callback 和 what 等信息进行聚合分析,有时也可以协助定位到导致问题的业务方。

e22e2b5facfa7918a79c6955737a7413.pngd9de1058829dd9f8e18dfc35bdd5e3ff.png

但是仅仅找到异常消息并不能直接帮助我们解决问题,还需要对消息的耗时原因做进一步的归因分析,找到其中引起消息耗时的问题函数。接下来介绍一下线上 Trace 数据采集方案,这里我们采取类似 Matrix 方案,通过 ASM 字节码工具,在编译时对应用内的业务代码进行插桩,也就是在函数的入口和出口插入一行统计代码,来记录当前方法的运行耗时。为了降低采集时的性能损耗,将方法 begin / end 状态标识、当前方法 ID 以及时间戳的 Diff 相对值,合并使用一个 64 位的变量来记录。在 ANR 等异常问题发生时,再将 Ring Buffer 中记录的数据进行上报,在后端数据链路处理生成对应的 Trace 堆栈,并提供给后续的诊断算法进行自动化分析,以及 Perfetto 人工可视化分析的需求。

d65be46b15e83cbb049c64e3661b6dd2.png

但对于抖音这样的大型应用来说,插桩方案也会有一些弊端,当插桩的函数过多时,会对包体积以及性能产生负面的影响。为了尽可能降低监控工具对线上用户体验的影响,我们提出了精准插桩的方案!因为结合对于线上问题的分析诉求来看,我们重点关注的是上层执行的业务函数,比如页面生命周期、业务消息等入口方法,以及底层那些可能耗时的业务函数。所以我们基于静态代码分析的基础能力,分析提取出带有耗时特征的函数来进行插桩,例如下面表格中带有锁关键字的函数、存在 Native / IO 等调用的函数以及特别复杂的大方法等。在这个精准插桩的优化策略之下,大幅减少了约 90% 的插桩数量!

db8232416f8b87c1433f9b9b8c2526e5.png

在大幅精简插桩数量之后,我们在消费线上数据时又面临到一个问题,就是仅有插桩的堆栈信息太少,有时难以帮助我们实际定位到发生问题的代码。为了解决这一痛点问题,我们又设计了插桩和抓栈数据拟合的优化方案,其原理就是通过对耗时超过一定阈值的慢函数进行抓栈上报,然后在服务端再将插桩与抓栈数据根据时间点进行拟合,补齐其中缺失的业务和系统堆栈信息。如下图中所示,当插桩堆栈中连续两个节点能在抓栈数据中找到最小间距的数据并对齐时,抓栈数据中对应层之间的数据将会被补全到到插桩数据中,生成新的拟合堆栈数据,图中的 D 就是被补全的数据。

ec0c7c1fe5d4b38465354f8a9e595ab3.png6593bc56eae44ba9783b801b50b4f53b.png

在获取到线上问题发生时的详细 Trace 数据后,我们需要进一步找到其中引起耗时的问题函数,常见的问题函数类型包括以下两种:

  • 慢函数:是指函数的执行耗时超过一定的阈值;并且从实际可消费性的角度来看,我们预期是要能找到更靠近叶子节点的业务慢函数。所以需要根据实际调用堆栈的情况,进一步剔除底层的基础库或工具类方法,以及存在相同调用链路的亲缘父子节点,来找到最合适的慢函数问题。

  • 高频函数:对应就是单个虽然并不耗时,但是由于次数很多,累计执行耗时超过阈值的函数;根据我们以往的经验来看,对于高频函数的优化通常也能带来不错的收益。

e50b11152e8eb106392f5cb49cc497e0.png865e8e8a16ae41d94af230ea8b515999.png

当然仅仅知道函数慢也是不够的!我们结合一个线上实际的示例来看,通过 Trace 可以发现标记红框的这里有一个业务函数执行比较耗时,但是这里为什么会耗时呢?我们要如何进行优化呢?目前仅有的堆栈信息并不能满足我们的归因需求。之后通过分析业务代码并补齐了缺失的“关键”信息之后,我们就可以明确知道这里耗时的原因是由于锁竞争导致的,并且我们还进一步补充了当前持有锁的线程以及堆栈等重要信息。

9e25ffd17147f41f7dd0a9f08fa715ba.pngbc0980e68251d2d169e38715639624e7.png

为了更好的对慢函数的耗时进行归因,除了前面采集的 Trace 堆栈数据之外,我们还需要补充一些关键的上下文信息,这里就统称为精细化数据。比如上面提到的锁,还有函数的 CPU-Time、Binder 调用的名称、IO 读写的文件路径和大小、绘制渲染相关的 RenderNode,以及内存 Block GC 等相关信息。

dc5eb90197654ec706302d50334dc29c.png

所以回顾总结一下主线程消息异常的归因流程,首先需要明确当前 ANR 的问题区间,然后找到其中的异常消息(耗时消息或高频消息),进一步下钻找到其中引起耗时的问题函数(慢函数或高频函数),最后再结合精细化数据对其耗时原因进行归因。

a23da95b0ece6b60c9f28e8eba48e3d9.jpeg
后台任务异常

接下来再看下后台任务 CPU 异常的归因思路:首先我们需要明确是否存在后台任务对主线程 CPU 资源产生抢占的问题,这里可以结合主线程的非自愿上下文切换以及调度状态的信息,来观测主线程是否有较多的时间都花费在等待系统调度上。如果存在明显的异常情况,再结合系统和应用的 CPU 使用率信息,可以进一步先定位到是应用的子线程 / 子进程,或是关键系统进程(如 dex2oat 进程等),还是其他应用进程造成的 CPU 资源抢占。

对于应用自身造成的 CPU 抢占问题,我们需要进一步定位到具体的问题代码。所以我们在之前的 Trace 采集方案的基础上,进行了重大的升级改造,扩展支持了全线程的 Trace 数据采集。

66c033666c553602392257c6e38e24c8.png

单线程 Trace 多线程 Trace 说明
Flag 状态位 2 bit 2 bit 代表函数开始/结束:3(二进制0b11)= catch, // 预留 2(二进制0b10)= throw,// 预留 1(二进制0b01)= begin, 0(二进制0b00)= end
Method ID 20 bit 20 bit 代表插桩的方法 ID,最大支持 1048575 个函数
Thread ID - 15 bit 代表当前线程的 TID
Timestamp 42 bit 27 bit 代表当前函数执行时与基准时间的相对时间,多线程模式下最大支持 134,217,727 ms = 约 1.5 天

由于后台任务我们重点关注的是 CPU-Time 耗时,所以在采集函数的 Wall-Time 执行耗时之外,同时也支持函数粒度的 CPU-Time 耗时采集,并在后端进行处理关联。这里出于性能损耗上的考虑,我们会进一步精简控制同时需要采集 CPU 时间的插桩函数数量,例如仅对系统的生命周期方法,以及子线程的 Runnable、Callable 以及二方 / 三方的任务框架入口方法,以及少量的关键特征方法才开启,并且会设置最小的采样间隔时间。

对于 CPU-Time 的获取一般有两种方式:一种是通过定期读取 proc 文件系统下的文件来解析获取,这种方式如果想要精确到方法级别需要相当高的读取频率,这种高频率读取文件并解析的性能损耗很高,不适合在线上方法级别的 CPU-Time 采集;另一种则是通过 Android 提供的 SystemClock.currentThreadTimeMillis() 方法或者 Native 层的 clock_gettime(CLOCK_THREAD_CPUTIME_ID) 方法获取当前线程的 CPU-Time,这种方式比较适合采集方法级别的 CPU 耗时,在方法开始和结束时分别调用前述方法再计算差值即可,因此我们线上采集选择的也是这个方案。

同样回顾总结一下后台任务异常的归因流程,在 ANR 的问题区间内,首先需要明确是否存在对主线程执行效率产生明显影响的 CPU 资源抢占,如果是应用自身的问题,先找到应用内 CPU 负载较高的线程或进程,进一步定位到对应异常阶段里的后台任务代码,最后再结合精细化数据对其 CPU 耗时原因进行归因。

aea19f7cc3f2a838951bcdc195c25059.jpeg

聚合归因思路

基于以上对 ANR 单点问题进行诊断分析后产出的归因结论,我们可以进一步结合线上大数据进行聚合归因,从而帮助我们更好的聚焦到 Top 重点问题的优化上。

归因标签

首先是对归因标签的聚合分析,主要包括以下几类:

  • 粗归因标签:针对 ANR 问题定性的归因分类标签,包括主线程阻塞、高频消息、CPU 抢占、堆内存不足等。

  • 细归因标签:针对细归因定位到的问题代码的精细化归因标签,包括主线程锁、IO、Binder 或者 Block GC 阻塞耗时等。

  • 业务归因特征:发生 ANR 时用户所在场景页面等业务维度的特征标签,有时也可以辅助快速定位到问题相关的业务方。

如下图所示,通过对以上不同归因标签的多维聚合分析,可以帮助我们对线上 ANR 问题的特征分布有一个全局的了解和认知,同时也能指导我们在归因能力上下一步需要重点攻坚的方向。

650439e6f3958040b6690769e97164f7.png
异常问题

其次是对细归因产出的异常问题进行聚合分析, 目前主要包括主线程异常函数后台任务以及内存这三个维度。聚合后的问题列表支持渗透率、耗时均值、PCT 50 / 90 耗时以及场景等维度的统计数据,可以帮助我们识别出线上整体占比较高或耗时特别严重的这类问题。

bc747844606adf36f6825523782c46ca.png

在进入异常函数的归因详情页之后,可以查看当前问题函数在线上大数据聚合后的火焰图,其中 Caller 堆栈代表上层不同业务方的调用次数分布情况,而 Callee 堆栈则是所有子函数的耗时分布情况。

c56442d7da2cf31ff28712b26bd0845b.pnga1378ff79bd2ebc045f974911fe25507.png

最后,基于以上的归因标签、异常问题以及业务归因信息,平台会产出一个对 ANR 问题最终的归因结论以及对应的综合置信度评分。

a8c56a8251db26524238181d17809b47.png

落地效果

接下来再介绍一下平台目前的落地效果:首先这个案例是一个启动阶段的 ANR 问题,我们从主线程 Trace 中可以分析定位到主线程的耗时函数,并且通过细归因标签的结果,可以明确知道是一个锁耗时的问题。进一步结合锁的详情信息进行下钻分析,通过当前子线程持有锁的聚合堆栈,发现是由于某个后台任务的执行时机变更提前了,从而与主线程某个任务产生了锁竞争冲突,导致主线程长时间的阻塞等待引起了 ANR。

37c0ab15350f719210c2007b3fd6a0a8.png

36071a4666d0b71bbd0bc2310c66469c.png9fb4e85bf70535ff680505564e3068d2.png

第二个案例是一个主线程高频消息问题,从定位到的问题函数可以发现其调用的非常高频,平均在一次 ANR 里会出现了上千次!通过进一步分析发现是由于某个业务的逻辑 Bug,导致在特定场景下会发送大量的重复消息,导致主线程消息队列堵塞引起的 ANR 劣化。

4fc30a206949862a558741c90cfa3a33.png0a962f493e5812a263fceeec36f49fff.png

第三个案例是一个子线程高频任务问题,通过定位到的后台任务可以发现其在多个子线程的出现次数都非常高频,并且累计的 CPU 耗时也比较高。进一步分析发现也是某个业务的 Bug 问题,在特定场景下会向子线程发送大量的重复任务,并且由于这些异步任务内部还会给主线程 Handler 发送或删除消息,所以除了会抢占 CPU 资源之外,还会间接导致主线程消息队列在遍历取消息时会发生高频的锁竞争耗时,两个因素叠加之下引起的 ANR。

2ef17bba30395c10dc4ca662601903b8.pngdbab7ff4447ddbf529fbb876d45d1745.png

最后总结下平台过去一年的阶段性成果,总共累计发现了有效问题 88 个,修复并优化其中 56 个,同时协助抖音 / 抖极的大盘 ANR 率分别下降了 -13.06%-8.70% ,并取得了不错的业务收益。

总结展望

抖音 ANR 自动归因平台未来的规划主要包括以下三个方面:

  • 归因体系:持续打磨监控能力和归因算法,包括探索完善 Java / Native 内存、绘制渲染以及 Native Trace 等方向上的精细化归因能力。

  • 防劣化体系:持续优化线上劣化归因和消费流程,提升线上自动归因准确率,以及劣化问题的消费解决效率。

  • 专家系统:沉淀专家经验,并尝试结合大模型等新技术,通过对技术特征和业务特征进行精细化聚合分析,进一步提升问题发现和解决效率。

加入我们

抖音基础技术客户端团队是一个深度追求极致的团队,我们专注于 Android / iOS 的体验、稳定性、架构、编译构建、工程效率等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、深圳等地都有人才需要,欢迎有志之士与我们共同建设亿级用户全球化 APP!你可以进入字节跳动招聘官网查询「抖音基础技术客户端」相关职位,也可邮件联系 chenjiawei.kisson@bytedance.com 咨询相关信息或者直接发送简历内推!

CVPR 2024 | Modular Blind Video Quality Assessment:模块化无参视频质量评估

无参视频质量评估 (Blind Video Quality Assessment,BVQA) 在评估和改善各种视频平台并服务用户的观看体验方面发挥着关键作用。当前基于深度学习的模型主要以下采样/局部块采样的形式分析视频内容,而忽视了实际空域分辨率和时域帧率对视频质量的影响,随着高分辨率和高帧率视频投稿逐渐普及,特别是跨分辨率/帧率视频转码档位画质评估场景中,这种影响变得更加不可忽视。在本文中,我们提出了一种模块化 BVQA 模型,以及一种训练该模型以提高其模块化性的方法。我们的模型包括基础质量预测模块、空域矫正模块和时域矫正模块,分别显式地响应视频质量的视觉内容和失真、空域分辨率和时域帧率变化情况。我们用提出的模块化BVQA模型在专业生成的内容和用户生成的内容视频数据库上进行了大量实验。实验表明,我们的质量模型实现了优于当前方法或相近的性能。此外,模块化的模型为分析现有视频质量数据库的空间和时间复杂性提供了机会。最后,我们的 BVQA 模型可以轻量高效地添加其他与质量相关的视频属性,例如动态范围和色域作为额外的矫正模块。

背景

多年来,研究人员从心理物理学和感知研究中收集了大量证据,证明更高的空域分辨率和更高的帧速率对视频主观画质有积极的影响。具体而言,感知质量取决于视频内容,特别是空域和时域复杂性。针对这些主观发现,早期的知识驱动的BVQA模型直接将空域分辨率和帧速率参数作为压缩视频质量预测的输入的一部分。尽管这种方法非常简单,但这些视频属性参数与内容和失真无关,因此它们与感知的视频质量不太相关。

基于卷积神经网络(CNN)的数据驱动的 BVQA 方法面临的计算问题十分明显。它们几乎没有尝试评估全尺寸视频,主要原因是计算复杂度很高,尤其是在处理高分辨率和帧速率的视频时,面临的挑战更大。此外,由于视频质量数据集规模较小,许多基于 CNN 的 BVQA 方法依赖于对象识别任务的预训练模型,这些模型通常需要小且固定大小的输入。因此,视频需要在空域上调整大小,并在时域上进行二次采样。在空域中处理视频的传统方法如图1所示,在时域中处理视频的传统方法如图2所示。

1300e53debd82b57d3d3cc39ba981ba9.png

图 1. 在空域视图中处理视频的传统方法。(a) 代表来自 Waterloo IVC 4K 的具有相同内容但不同空域分辨率的两个视频。(b) 在不保持宽高比的情况下调整视频大小,与视频质量相关的局部纹理可能会受到影响。(c) 调整视频大小,同时保留纵横比并将其裁剪为固定大小,无论实际空域分辨率如何,都会产生几乎相同的输入。(d) 裁剪视频会缩小视野并导致不同空域分辨率的内容覆盖范围不同。

937eae85e36a5071f3caad6d2ab30bd4.png

图.2 来自 LIVE-YT-HFR 的两个视频序列,具有相同的内容,但是时域帧率不同。当根据帧速对帧进行二次采样时,生成的帧是相同的。此外,高达 120 fps 的极高帧速率对端到端 VQA 模型提出了重大挑战。

方法

为了可靠地评估具有丰富内容和失真多样性以及多种空域分辨率和帧速率的数字视频质量,我们提出了一种模块化 BVQA 模型。我们的模型由三个模块组成:基础质量预测模块、空域矫正模块和时域矫正模块,分别响应视频质量中的视觉内容和失真、空域分辨率和帧速率变化。基础质量预测模块将一组稀疏的空域下采样关键帧作为输入,并生成一个标量作为质量分数。空域矫正模块依靠浅层 CNN 来处理实际空域分辨率下关键帧的拉普拉斯金字塔,并计算缩放和移位参数来校正基础质量得分。类似地,时域矫正模块依靠轻量级 CNN 以实际帧速率处理以关键帧为中心的空域下采样视频块,并计算另一个缩放和移位参数以进行质量得分校正。为了增强模型的模块化,我们在训练期间引入了 dropout 策略。在每次迭代中,我们以预先指定的概率随机丢弃空域和/或时域整流器。这种训练策略鼓励基础质量预测模块作为 BVQA 模型独立运行,并且在配备矫正模块时会表现更好。

a8467753d128cb93cb0e82d4784ed166.png

图3. 所提出模型总体结构。基础质量预测模块采用一组稀疏的空域下采样关键帧作为输入,生成表示为 的基础质量值。空域矫正模块采用从实际空域分辨率的关键帧导出的拉普拉斯金字塔,计算缩放参数 和移位参数 来校正基础质量。时域校正模块利用以实际帧速率的关键帧为中心的视频块的特征来计算另一个缩放参数 和移位参数 以进行质量校正。空域和时域矫正模块可以使用模块化方法协同组合,其中利用尺度参数的几何平均值和移位参数的算术平均值。

实验结果

为了评估空域整流器的性能,我们采用了 BVI-SR和 Waterloo IVC 4K,重点研究不同空域分辨率对视频质量的影响。为了评估时域整流器的有效性,我们利用 BVI-HFR和 LIVE-YT-HFR,它们专门用于分析不同帧速率对视频质量的影响。这四个数据集都是PGC(Professionally-Generated Content,专业生成的内容)数据集。我们还使用八个 UGC (User-Generated Content,用户生成的内容)数据库进一步验证了我们提出的模型的普遍性。这些数据库包含各种内容类型、视觉扭曲、空域分辨率和时域帧率。表1 中提供了这些数据库的全面介绍。

b073e4024d93910aabf754eca5acef2a.png

PGC数据集结果

表2和表3展示了4个PGC数据集的结果。可以看出空域矫正模块和时域矫正模块可以分别有效地感知空域分辨率和时域帧率对视频质量带来的影响,并很好地对基础质量分数进行矫正。

e357dff2de6d52e46b225820fb4a2642.pngcb673d87efe55b41b021e3bb44f57258.png

UGC数据集结果

表4和表5展示了8个UGC数据集的结果。可以看出两个矫正模块的集成显着增强了八个 UGC 数据库的性能,与当前最优模型相比也展示了具有竞争力的结果。此外,包含这两个矫正模块可以实现有效的泛化,证明它们对提高预测视频质量有突出贡献。此外,我们的模型的模块化设计提供了对常见 UGC 数据库中主要失真类型的全面理解。

b6e8fdb721e00fde872858d1db4fc747.png8603f5efd82c206a5206ddc39000d1dc.png

多媒体实验室简介

火山引擎多媒体实验室是字节跳动旗下的研究团队,致力于探索多媒体领域的前沿技术,参与国际标准化工作,其众多创新算法及软硬件解决方案已经广泛应用在抖音、西瓜视频等产品的多媒体业务,并向火山引擎的企业级客户提供技术服务。实验室成立以来,多篇论文入选国际顶会和旗舰期刊,并获得数项国际级技术赛事冠军、行业创新奖及最佳论文奖。

火山引擎是字节跳动旗下的云服务平台,将字节跳动快速发展过程中积累的增长方法、技术能力和工具开放给外部企业,提供云基础、视频与内容分发、大数据、人工智能、开发与运维等服务,帮助企业在数字化升级中实现持续增长。

CVPR 2024 | CAMixerSR 动态注意力分配的超分辨率加速框架

背景

随着相关技术和应用的发展,比如超高清屏幕、虚拟现实(VR)等沉浸式体验的增加,用户对超高分辨率图像和视频的需求变得越来越强烈。在这些场景中,图像的质量和清晰度对于提供最佳的用户体验至关重要。超高分辨率不仅能提供更清晰、更真实的视觉效果,还能在一定程度上增强人们的互动和沉浸感,在一些VR场景中我们需要8K甚至16K的才可以满足需求。然而要生成或者处理这些超高分辨率的内容,对算力的要求也是与日增长,对相关算法提出了挑战。

超分辨率是一个经典的计算机底层视觉问题,该问题要解决的是通过低分辨率的图像输入,获得高分辨率的图像输出。目前该领域的算法模型主要是有CNN以及Transformer两大类别,考虑到实际的应用场景,超分的一个细分领域方向是算法的轻量化。在上述提到的超高分辨率的场景,超分算法的算力消耗问题变得尤为关键。基于此,本文提出了一种名为CAMixerSR的超分框架,可以做到内容感知,通过对Conv和Self-Attention的分配做到计算量的大幅优化。

cc20e1c95f3a5a1640678039694da9e4.jpeg

论文地址:http://arxiv.org/abs/2402.19289

方法

b3af023b408f355f7748df17d9b23544.jpeg
Table 1 不同难易程度内容的超分算力与效果对比

我们在对内容进行分块并且根据处理的难易程度分成了简单、中等、困难三个类型,并且使用不同FLOPS的计算单元,Conv以及SA +Conv两种类型进行比较,发现对于简单的模块我们可以利用较少的FLOPS进行计算,并且可以得到较为不错的PSNR结果,只有在中等以及困难的分块内容中,SA+Conv的效果优势才较为明显。通过这个实验我们发现,如果对内容进行分块并且动态调整优化处理策略,有可能在保持性能的同时,大幅降低FLOPS。

3cb085bbcf0e2aad08250cb980d5b8a3.jpeg
图 1 CAMixer的算法框架

上图是我们方案的整体流程图,可以看到,我们的方案分成了三个部分,包括Predictor模块,Self-Attention模块以及Convolution模块。其中的Predictor模块是基于局部条件以及全局条件以及对应的线性位置编码函数,通过该模块,我们可以输出Offsets Maps、Mixer Mask、Spatial Attention、Channel Attention, 这些信息在Self-Attention模块以及Convolution模块的后续计算中进行使用。CAMixerSR中网络的主体模块是基于SwinIR-light进行优化。对于复杂区域,我们使用offsets map来进行更高效的attention计算,并且将输入和V分成了简单和困难两种分块,从而得到对应的Q和K,并且将他们分别进行计算,得到attention部分的V。Convolution模块我们使用depth-wise进行计算,将Self-Attention的结果合并后即可得到我们最后的输出结果。

实验

b3183c313ca8dda17c140941189ac998.jpeg
图2 Predictor模块输出的Mask可视化结果

图2显示了我们的Predictor模块的输出结果,可以看到在很多的场景里,不同的区域内容有较为大的差异,并且我们的算法可以精准预测出分块的类型。

9ef3d94186d75956697fdd758e1d8be4.jpeg
表2 CAMixerSR在超高分辨率数据集上的实验对比

表2和表3是CAMixerSR与之前高性能超分在超高分辨率数据集上做的实验对比,我们可以看到,在多个数据集(F2K、Tesk2K、Tesk4K、Tesk8K)上,相比经典的Transformer based超分方案SwinIR-light,CAMixerSR都有比较大的优势,在经过我们的方案优化后,可以做到PSNR接近的情况下节约将近一半的FLOPS以及参数量Params。

0e06b0fe117af58b354e0a99dfa795f0.jpeg
表3 CAMixerSR通用超分辨率数据集上的实验对比

除了超大分辨率的场景,我们的方案在一些通用场景下同样有不错的性能优势,表3中我们在一些常见的超分测试集上和一些常见的高性能超分方案进行了测试。

d7b8f664b0c0942aa14131a8c5298aaf.jpeg
表4 CAMixerSR在球面超分辨率数据集上的实验对比

球面内容是一个重要的超高分辨率场景,我们在两个全景超分数据集上进行了测试,甚至不需要通过球面数据集进行训练,仅进行测试的情况下同样发现我们的方案在PSNR效果以及性能上都超过了过去的方案。在这项实验中可以表明CAMixserSR在沉浸式场景有比较大的收益潜力。

火山引擎多媒体实验室简介

火山引擎多媒体实验室是字节跳动旗下的研究团队,致力于探索多媒体领域的前沿技术,参与国际标准化工作,其众多创新算法及软硬件解决方案已经广泛应用在抖音、西瓜视频等产品的多媒体业务,并向火山引擎的企业级客户提供技术服务。实验室成立以来,多篇论文入选国际顶会和旗舰期刊,并获得数项国际级技术赛事冠军、行业创新奖及最佳论文奖。

火山引擎是字节跳动旗下的云服务平台,将字节跳动快速发展过程中积累的增长方法、技术能力和工具开放给外部企业,提供云基础、视频与内容分发、大数据、人工智能、开发与运维等服务,帮助企业在数字化升级中实现持续增长。

2024 AI & 前端:回首展望,光芒未至,破晓之前!

前言

回望 2023 年,ChatGPT 的突然爆火,让 AI 无疑成为最为值得注目的新兴领域之一,我们也一起见证了生成式 AI 的寒武纪大爆发。这一年来,国内外的生成式 AI 、大模型和相关产品以令人眼花缭乱的速度更新迭代,新的创业浪潮风起云涌。在这 AI 浪潮下,也让我们有了新的开发思考,探索着在各个环节中“前端 & AI”的应用场景。勇于探索的前端开发者们已经开始挥舞着 AI 的“魔法棒”,譬如代码生成、辅助 CR、低代码、测试、业务提效等各类开发环节都被赋予了新的活力和可能性。

在经历长时间与复杂项目“搏斗” 的你,是否对不断重复的工作感到厌倦 ?

当你面对上万行的代码的 Code Review 时,是否也曾让你感到力不从心 ?

业务遇到发展瓶颈,是否你也会把希望寄托于 AI 相关应用为其注入活力?

当面对种种类似问题时,你是否也曾幻想过有这样一个神器帮你胜任琐碎的语法检验、代码梳理、模块测试甚至是 整体搭建呢 ?

AI 技术的不断成熟,让这些梦想开始变得触手可及。AI 工具如同瑞士军刀般,其功能与用途变得越来越多样,它们正在帮助我们更有效地解决问题、提高效率,将想象中的可能变为现实。在本次大盘点中,我们会聚焦近一年 AI 相关技术在前端领域的趋势与未来展望,将一一回答上述疑问,帮助读者挖掘从业者的新机会,迎接 AI 的挑战与机遇。

如果你对 AI & 前端感兴趣,请一定要阅读下去,相信我们的精心准备一定不会让你失望~

“勇敢实践 AI 的人,将最先享受世界”

AI & 代码生成

一切故事开始来自于 ChatGPT 的横空出世,让世界看到了 AI 写代码的能力。大语言模型能写算法题、能写游戏、能写网站,不仅写的又快又好,还能直接运行,AI 的表现让人惊讶,人们开始感叹:“程序员的职业恐将不复存在!”

不过只是在 ChatGPT 的对话框里和 AI 交流代码技术还是不够方便,于是,已经默默存在了一段时间的 AI 代码生成辅助工具们乘势而起,成为了爆火产品。代表有 Github Copilot,Tabnine,CodeWhisperer,Cursor,Codeium,Safurai 等,其中有自研模型工具,也有套用模型服务主打产品交互和 prompt 的工具。

a659755f4b212973d2256100e7d452a1.png12338a5c831558db0110543358bbf9b0.png947a4e1f4f434e6f6f601c390cb89b2b.png01ce16998e79d4a6d2ddbfbdb6cc2ec3.png

AI 代码生成可以细分为很多场景,比如 自然语言生成代码单元测试代码重构代码续写代码解释代码评审问答助手。AI 代码生成工具的出现颠覆了程序员的工作方式,影视作品中对着机器人说两句话就完成编程的时代仿佛就在眼前了。

生态现状及使用体验

但我们抛开博人眼球的新闻,AI代码生成效果如何?程序员真的要失业了吗?

以下是 AI 生成的代码在日常开发过程中的一些亲身体会:

  • 对于单元测试:AI 考虑了非常全面的分支条件,但是运行就报错,因为一些基础依赖、mock 方法、API 的使用不够准确;

  • 对于代码生成:第一次生成效果最好,在后续的讨论修改中逐渐变得上文不接下文,直到所有代码变得不可用 ;

  • 对于代码补全: Copilot 虽然很惊艳 但是在企业的业务开发过程中由于存在内部代码泄露风险,并不能随心所欲的使用;

  • 对于代码助手: 向它寻求帮助时,它编造不存在工具和 API 推荐给我;

总的来说,AI 代码生成这件事经过了一年时间的考验,仿佛变成了食之无味弃之可惜的鸡肋骨,程序员战战兢兢使用 AI 生成着代码,却惊喜地发现这东西一时半会还替代不了自己。

退而求其次,让 AI 打打下手吧,AI 生成的代码,修改一下,可以使用吗?

在日常开发中,AI 已被业界许多工具证明可以提高开发效率。但如果代码量快速膨胀,人类还改的过来吗?如果随着 AI 生产力进一步提升,当代码检查成为效率的瓶颈时,大量未经人类把关的 AI 代码直接用在生产环境,人类真的敢用吗?

AI & 代码重构的探索

为什么做重构?

在 AI 代码生成的众多细分场景中,代码重构是相对来说是一次生成代码量最大的场景之一,同时又因为有原代码逻辑可对照,它生成的代码的评判标准又相对明确。于是代码重构场景成为了很好的观察对象。

在没有AI 工具的时代,程序员们也在持续做代码重构,工程在迭代过程中逐渐腐坏,需要不同程度的重构去保证迭代效率。同时重构的项目总不会是刚写没多久就开始重构的,所以重构总是伴随着大量的、难以追溯的业务逻辑。在这个过程中,陈年逻辑是不是理解到位?再次实现是否能保证没有 bug?这些问题都折磨着每个做重构项目的程序员。

AI 随着时代的洪流登场了。大家都知道代码重构是麻烦又危险的操作,如果 AI 能代劳,既保持了代码的可维护性,又没有程序员因为实施重构而失去头发。在实践中,我们的业务刚好有代码重构的需求:陈年的 Vue 代码希望重构成React版本,目标通过重构使团队技术栈保持统一,同时完成一些针对老项目的性能优化动作。

58ea5f4a4887c57dd6c866e02158d3e6.png

重构前

1bfb2da70c979f433f029deaa7b5d99e.png

AI 重构后

AI 的表现如何?

考虑到 AI 已经被鼓吹可以替代人类,那么人类程序员能做到的代码重构效果,AI 也一定能做到,所以一开始在提示词中希望它在重构的过程中优化文件结构,移除冗余代码,同时保持原始逻辑不变。这是一个非常“普通且自信”的想法:既要、又要、还要。

于是沟通遇到的第一个问题出现了,即使将复杂的任务拆分,人类也很难用自然语言明确告知模型,怎么样算优化文件结构,什么样的逻辑是冗余逻辑,毕竟人类程序员还要经过反复的需求讨论才能逐渐确认需求的全部细节。于是一步到位的提示词方案被否定。

接下来对 AI 的要求是:文件对文件,逻辑对逻辑进行代码转换,不必做过程中的优化和调整。这样任务就简单了许多,降低了沟通成本,指令的下达变得明确而高效。

随着项目的推进,又遇到了类似的问题:对于混杂在多任务中的简单小任务,AI 无法做到,无论是明确定义、增加示例、反复强调、给予鼓励。 比如特定第三方组件库的使用参数,JSX 条件渲染的实现等。这时问题可能出现在这些小任务合集触碰到了 AI 能力的上限,除非针对特定任务精调,去提升它在任务中的表现。

去评判 AI 的表现,总的来说还是沟通和能力边界的问题。对于业务中使用的某个具体模型,它的能力边界不完全确定,由于模型内部逻辑原理的不可解释性,提示词工程师(Prompt Engineer,PE)们只能不断猜测、试验,而在这其中投入多少精力的度,是不确定的。另外考虑到 AI 的幻觉问题,如何评判“胜任”,如何确保 AI 每一次任务都“胜任”也是一个挑战。所以这充满不确定的沟通调试过程,影响了 AI 代码生成的效果,较低的代码产物质量让它们暂时无法被信任地直接用在生产环境。

总结

和人之间的信任一样,AI 的信任是逐渐建立的,从简单的任务到复杂的任务如果都能逐一胜任,那么人类对 AI 的信任就会增加。对于 AI 出现的失误,并不是不被允许,只是这些失误需要是可解释的,并且有规律可规避的。 对 AI 更进一步的期待是,其内部的运行逻辑是可解释的,其行为是可控的。

我们期待这一天的到来,期待AI 成为人类更加可信任的工作伙伴。

AI & 测试

你可能会觉得“啊,既然 AI代码生成问题那么多,那么退一步让 AI 来测试吧”,但是,实际上 AI 测试当前也有着数不尽的问题。本小节将会详细拆解这些问题来逐一分析。

AI 与测试相关的问题

41a5c8c63148e075d3b30076b22ae448.png

这里面涉及到很多很虚无缥缈的东西,你可能还会说“废话少说,直接告诉我最佳实践”。然而事实是,别说最佳实践,以至于截止到 2023 年,前端 + 大语言模型 + 测试的实践几乎为 0。如果你在 Github 搜索,仓库不超过 10 个,合计的 Star 数加起来 5 个都不到。

1148d1608f262cedcb7ad8152b631a70.png0a4dff8bdfb5ceaff155cb131d60d1c3.png

为什么?为什么感觉这么“前途无量”的场景连 Github 仓库都没几个?

这个问题很难用一句来解释清楚,但是我向你保证,看完本节,你一定会得到答案。

单元测试

先说实施起来最简单的单元测试。单元测试非常的直白,就是使用一段纯 JS 代码来测试另一段纯 JS 代码,比较结果,一样就成功不一样的就失败。这么简单的任务,强如 GPT-4,不会做不到吧?

能做到,但是不能完全做到。

其实在 AI + 单元测试,在 LLM 出现之前,业界就已经有很多很多的实践了,而在 LLM 后,又崛起了一批 LLM + 单元测试的试验品。但是我打赌读者一个也没听说过对不对?没听说过就对了,因为哪怕有了 LLM 的助力,效果依旧一言难尽,根本拿不出手。

接受率

在这里引入一个接受率的概念。简单来说就是 AI 生成了 100 个用例,哪些是可以直接使用的。假如有 80 个可以不经修改的直接使用,那么接受率就是 80 / 100 = 80%

但是这里有个非常尴尬事情——并不是说接受率 > 0% 那么就可以用,因为你依旧需要花精力在那些“不可用的用例”。

假如接受率是 40%,AI 生成了 100 个用例,你其实并不知道这 100 个用例中哪 40 个是正确的,而为了使用这 40 个正确的用例,你需要:

  1. 想办法将可用的用例挑出来,并逐一验证

  2. 将剩下不可用的用例一个一个改好

  3. 重新整体检查,手动填补用例遗漏

假如接受率真的是 40%,这个过程将会是地狱,将远远超越你自己写 100 个用例的时间。

好了,那么读者不妨在此猜测一下,那么在 LLM 之前和之后,AI 单元测试的接受率是多少?

单元测试的进一步分析

通过接受率小节,我们了解的接受率必须高到一定阈值才有意义,否则就是单纯的把“写用例”变成了“调用例+改用例”。据我们的估计,接受率至少要达到 80% 以上,否则就完全是在帮倒忙。

而目前LLM+ 单元测试 普遍的接受率仅为 50% 不到,而如果是用 NLP 来代替 LLM,接受率将低到 30% 以下:

7bb73eb41739a63f225dfefd31e11845.png

Pass@10[%] 代表 AI 拿到错误信息再次迭代,尝试了 10 次

这就好比有个实习生,写了 10 个页面,其中:

  1. 2 个页面看上去正常,实际上交互有各种问题和巧妙的 Bug

  2. 4 个页面运行正常,但是和上面这种页面混在一起了,你需要测试后才能找到他们

  3. 2 个页面打开就白屏,你简单修改几次它还是白屏,你也不知道具体为啥,需要慢慢调

  4. 2 个页面忘做了

这种实习生你要吗?因此,AI & 单元测试依旧要求程序员拥有极强的单测完善能力,它不能代替你来直接完成单元测试。

其他测试

单元测试包含了太多的细节和边界情况,那么我们把视野拉远,比如 E2E 测试,是不是会好一点呢?正好前端单测用的也不多。

别说,你还真别说,当我们聚焦于 E2E 而不是单测后,各种问题确实是迎刃......增加了~!

当你想使用 AI + E2E 后,第一件事就是——怎么让 AI 和浏览器交互。目前在这方面,业界没有任何的实践,所有的胶水代码都必须由你来开发。

目前 GPT 确实有检索和理解网页的能力,但是仅限于内容的理解。无法做到对于前端页面交互的理解,比如在选择框来选择某项

在单元测试中,我们可以很容易的“报错后直接发给 AI,让 AI 多试几次”,但是这个交互流程在 E2E 情景下是没有相关实践的,其中伴随很多的开发工作。

同样的,AI 对于一个网页的理解是非常有限的,他并不知道一个用户交互是错误的还是正确的,你可能还需要传递给 AI 一些背景资料,比如产品文档、测试用例、交互图、代码。这部分的序列化工作也是一个全新的领域,所有的胶水代码都必须由你来开发。

如果你不是在一个大型 IT 公司,开发胶水代码成本是难以接受的。它可能要花费一年的时间,并且过程中没有任何阶段性产出,且部分功能随时可能被新一代的 LLM 所代替。

总结

通过单元测试 和 E2E 测试的分析,相信读者已经明白了的当前 AI & 测试面临的主要挑战:

  1. 如何序列化让 AI 理解现状上下文

  2. 过低接受率导致的额外人工介入的成本

但是我们相信这些这些问题都是有解的。可能是更加聪明的 LLM 提高了接受率,可能是规范的合作方合作流程,比如测试用例写的足够可读,并能直接导出为表格发送给 LLM,也可能是某个公司大笔一挥,决定花一年来开发一个 AI E2E 测试框架。

现在,软件测试的世界在等待一位英雄,他手持 LLM 的利剑,彻底斩除软件开发的质量痛点。而那位英雄,会不会是正在阅读本文的你呢?

AI & 辅助CR

除了开发与测试,换个角度看问题,如果让 AI 来当我们的CR助手呢?

代码审查(CodeReview)是软件开发过程中的重要环节,Code Review 是指开发人员对彼此的代码进行审查和评估的过程。通过 Code Review,团队成员可以相互检查代码的正确性、可读性、可维护性以及是否符合编码规范等方面的问题。可以帮助开发者发现并修复代码中的错误和缺陷,提高代码质量 ,减少技术债务。

1470d1ec68fcfb1ebcdc81de17dcf877.png

传统的代码审查由人工完成,团队在 Code Review 上花费了大量时间,且容易出现疏漏。随着大型语言模型(LLM)的发展,LLM 在代码审查领域得到了越来越多的关注。LLM 可以通过对代码的语义理解,快速识别代码中的错误和缺陷,从而提高代码审查的效率和准确性,以下是 AI Code Review 的几个好处:

  • 自动化和效率:与传统的人工 Code Review 相比,AI Code Review 具有更高的自动化程度,可以大大提高审查的效率。

  • 捕捉更多问题:AI Code Review 工具可以检测出更多的代码问题,包括潜在的性能问题、代码重复、安全漏洞等,帮助开发人员更全面地改进代码。

  • 减少人为偏差:由于人为因素,传统的 Code Review 可能存在一些主观偏差。而 AI Code Review 可以基于大量的事实和规则进行评估,减少主观因素的影响,提供更客观的评价结果

  • 持续集成与部署:AI Code Review 工具可以与CI/CD相结合,实现自动的代码审查和反馈,帮助开发团队更好地管理代码质量。

AI 在 Code Review 的场景

LLM 在代码审查领域的应用主要有以下几个方面:

  • 补充CR信息:根据 CR 的 Diff 内容,生成 CR 的表述、标题、CR 总结 ,方便人工短时间了解代码变更关键信息。

  • 基本代码问题:变量名、函数名的语义化、可读性、可维护性、可扩展性等。通过分析代码的结构、语法和语义特征,工具可以自动判断代码的质量等级,为开发团队提供改进建议。

  • 代码性能问题:AI 代码评审工具可以分析代码的执行效率,发现潜在的性能瓶颈和优化点。例如,工具可以识别循环嵌套、冗余计算等问题,并提供优化建议,提高代码的执行效率。

  • 逻辑问题:比如边界问题、条件判断 、循环控制的合理性。

  • 代码安全问题:AI 代码评审工具可以用于识别潜在的安全漏洞和风险。例如,工具可以分析代码中的敏感数据处理、输入验证、权限控制、SQL 注入,XSS 等方面,发现可能存在的安全问题,并提供修复建议。

当前应用

当前业界有不少 AI Code Review 的能力应用,除了大家熟知的 Github Copilot ,开发者对其进行提问可以帮助完成 Code Review 的动作,我们再看一下还有哪些成熟的应用。

ChatGPT-CodeReview

通过调用 Chagpt 的 Open API 以 GIthub Action 的形式集成到 Github 里面,机器人会自动进行代码审查,审查信息将显示在 pr timeline / file changes 部分。在git push更新 PR 之后,CR bot 将重新审查更改的文件:

cde2224e20fc174ee883576027ef4fad.png
CodiumAI PR-Agent

CodiumAI PR-Agent:一款基于 Chatgpt 的开源工具,可帮助高效审查和处理拉取请求。它自动分析 pull request,并提供多种类型的命令:自动生成 PR 描述、可调整的反馈、问题解答、代码建议、更新 CHANGELOG.md 文件、查找相似问题、自动添加文档、生成自定义标签和自动分析 PR 变更。

49125538b4463dbc16ba00d6000b7738.png

4fdb689f9b807e7787eaf8faeed85c9f.gif

总结

如果你决定开始使用 AI 能力作为团队 CR 助手,请注意:

  • 由于 LLM 本身的 Token 上下文限制,当MR超出最大的 Context 上下文限制时,会造成信息丢失导致 Review 结果不全面。

  • 由于 LLM 回答的随机性,同一份代码多次 Review 可能有不同的结果,以及 Review 的结论会偏啰嗦 ,不容易抓出重点问题,所以在提示词里面可以多强调关注重点问题,比如 Prompt 加入 “作为一名代码 review 专家、对 Gitlab 的 MR 进行代码 review. 只关注代码性能、代码安全问题、严重的逻辑错误”

  • 如果要针对团队业务场景或基于团队研发规范进行 AI Review,可以将团队的业务背景信息规范等作为知识库对模型进行微调 ,对于担心代码泄漏的场景,还可以对开源的LLM进行私有化部署作为企业内的方案。

一言以蔽之,当前只是辅助人工 Review 帮助快速发现一些问题,减轻人工的简单重复的劳动,但无法完全取代人,最终还得由人作判断,但我们可以用节省下来的人力关注到更高优的事情上面。

AI & 低代码

前两年火爆的低代码概念,似乎和 AI 有不错的“缘分”,两者结合往往能达到一加一大于二的效果。

什么是低代码

低代码开发平台(英语:Low-Code Development Platform,简称 LCDP),是一种方便产生应用程序的平台软件,软件会让用户以图形化接口以及配置编写程序,而不是用传统的程序设计作法。此平台是针对某些种类的应用而设计开发的,例如数据库、业务过程、以及用户界面(例如网页应用程序)。这类平台一般可以产生完整且可运作的应用代码,但在一些特殊的情形下仍需要编写程序。

低代码概念最早出现在 1982 年《无程序员的应用程序开发》一书中,美国低代码产品的研究和实践过程较长,积累了较丰富的经验。中国最早出现低代码平台大概是在 2014 年,产品的形态从最初的数据库交付,到数据集结构搭建逐渐演变抽象出各种流程引擎、可视化界面等产品能力,应用能力也从 BPM 延伸到 ERP、CRM 等大型复杂应用,整个行业经历了 2017-2020 年的快速发展阶段,市场增速开始放缓,但相对于其他企业服务赛道仍处于高增长阶段,预计 2025 年中国低代码行业市场规模将达到 118.4 亿。

2bc480dd2ab86b76e95447481f28492a.png

存在问题

但低代码自出现以来,一直还是饱受质疑,核心的点在于通过低代码的可视化搭建大大降低了建设成本,且使用群体从开发人员逐步向业务人员渗透,但低代码本身是存在一定的的上手门槛,包括各类物料的用法、如何实现与后端接口通信、物料之间如何配置交互等等,这使得业务人员无法快速上手完成页面搭建,而开发人员需要学习特定搭建规则和限制,可能不如实际开发来的快。

在不同类型的低代码产品的相关的限制又会有所差异,从应用场景上可以划分通用型和垂直型

782921e72714c4a11965f3ffae63554d.png
  • 对于垂直型低代码产品,深耕特定行业或特定的场景,提供当前垂类更具有专业性的解决方案和搭建能力,在具体的搭建场景中专业性贴合更深,会存在复杂搭建场景,如公式、模型、数据等,门槛更高,搭建依赖的前置输入也要更多。

  • 而对于通用型,由于覆盖各行业各场景,沉淀了大量的行业模版和解决方案,虽然搭建门槛相对垂直型要更低,但内容覆盖面更广,大量的物料和模版不仅造成了用户选择压力,也提升了搭建的学习负担。

低代码产品本身的易用性有待进一步提升,包括底层技术框架和可视化界面的呈现,另外低代码相关的培训机制有待完善,培训不足导致用户对于低代码产品的接纳度低,用户本身对于繁重的产品使用学习的意愿不足,也会大大阻碍低代码产品的推广和应用。

如何赋能

随着 GPT 为代表大模型爆火,AIGC 也开始在各个行业渗透,借助大语言模型的能力精准识别用户需求,进而彻底改变当前低代码搭建工作流。通过自然语言交互替代原有选择、编辑、拖拽等操作,从而屏蔽了低代码平台本身的使用规则,这大大降低用户的学习上手成本,进而提升低代码的应用场景和搭建效率。未来 AI 能力将会成为低代码平台的标配,成为未来重要的发展方向。

当前阶段,各个低代码平台都在摩拳擦掌,跃跃欲试,很多平台已经逐步上线自己的 AI 融合能力,一些典型场景包括:

  • 智能创建页面

通过自然语言完成页面创建,主要应用在通用型低代码平台中,来避免大量物料和模版的学习选择成本,钉钉宜搭、织信、易鲸云都上线了这部分能力,比如在宜搭平台上线了通过简要描述门户的基本信息,系统将智能化地按需生成门户的能力。

03cca81fe28981d04554c1c387f7ec18.png

核心逻辑在于通过自然语言精准识别用户需求,匹配合适的物料、模版,完成内容生成,进一步组装形成 DSL 进行渲染。

  • 局部场景智能生成

聚焦低代码平台搭建中的局部场景,如公式编辑器、流程编排、智能表格、辅助编程等,通过自然语言来替换原有搭建方式,进而降低整体搭建复杂度,这么做的优势在于将自然语言聚焦特定局部场景的优势,也可以使得需求识别更加精准,下图是易鲸云通过自然语言自动化完成流程编辑的效果。

易鲸云AI生成流程演示

  • 智能问答

通过将低代码平台中沉淀的大量的文本、视频材料喂给大语言模型,来提升用户问题解决效率,节省运营成本。当然不仅仅是低代码,智能文档这一场景适用各类平台中。

总结

当然 AIGC 与低代码的融合还处于早期阶段,当前应用范围也主要还是在一些容错率较高的场景,通过大语言模型可能不能完全理解用户的真实需求,这需要庞大的上下文做支撑,虽然融合方向仍然有诸多问题亟需解决,但未来低代码平台必将会全面拥抱 AIGC,这也意味着我们需要重新去思考低代码平台的交互界面的设计,充分利用自然语言交互特点,提升用户体验。

另外数据安全风险也是在商业化产品中必须要考量的一点,因此多数企业都在尝试走自研大模型的路子,然而这并不是一件容易的事。它需要大量高质量数据的收集、清洗和标注,以及昂贵的计算资源来支持模型训练。这也意味着未来 AI 加持下的低代码平台的竞争成本会更高,会更加聚焦在有雄厚实力的头部企业。

除了 AIGC +低代码融合之外,还有一方观点认为既然 AIGC 能力越来越强,是否会完全替代低代码,直接生成原始应用?我们认为至少在可以预见很长范围内低代码不会被完全替换掉,就像上面几部分提到的,大模型通过语义直接生成代码并不能保证其完全的准确性,且代码可用性需要大量的人工校正。而低代码融合 AIGC 可以通过语义生成模型,加速需求分析工作进程,可以让 AI 更快的进入业务。

AI & 业务

说到业务,下面就用各大互联网企业中最为常见的审核业务作为例子来进行探讨:

思考

对于审核业务与 AI ,可能大家的第一反应就是,AI 是否能完全替代审核员对内容进行审核呢?

我们认为未来是可以的。 内容对于人类来说,无非也是来自声光电的刺激。而当这些声光电的刺激能被模型接受、理解、推理后,也就做到了和人类一样的事。而且,相比较人,它更容易管理,标准更统一,成本也会更低。

但现阶段,就像推测会被替代的所有工作类型一样,还有太多的问题要解决了。AI 会作为不同的工具形态,慢慢融入人们的工作;但距离完全替代,还有不小的距离。

所以业务上,我们也在思考,它能不能也辅助审核员,取得更好的审核质量和效率。也尝试了一些 demo 项目,想知道 AI 能做到什么地步,这样有助于我们探索业务场景上能做到的极限。

但即使我们只是想让他作为一个额外的信息来源,来辅助审核员来的判断,也存在非常多现阶段需要解决的问题:

  • 成本问题:无论是使用外部的 LLM ,支付接口调用的费用,还是自己训练并使用内部的模型,基于现在每日发布内容的巨大量级,预估这都是一笔巨大的开支。在目前 AI 辅助审核的前进方向不明确的情况下,成本能否打平收益,还是一个需要进一步深究的问题。

  • 合规问题:只能使用用户已经公开在平台发表可见的内容,公司内的数据,例如 Policy 、代码、培训材料等,都是不允许输入给外部大模型的。

  • 人为限制:外部给用户使用的大模型需要满足世俗的道德和法律体系,因此模型训练和输出过程中做了对齐,但审核业务就是为了能让大模型正确分析以及反馈有害内容中的风险细节, LLM 往往因为对齐,而拒绝回答,给审核场景的探索带来了不少阻碍。

  • 标准问题:各家大模型都有自己的 Policy ,特定业务中的 Policy 因为合规问题,无法输入给 LLM ,那 LLM 就无法给出符合我们审核标准的答案。

  • 多模态问题:面对一个以音视频为主的内容社区,很多的风险是在画面、音频中的,甚至有的还要靠连续的画面或者多个模态的信息结合起来判断,这些都大大限制了目前以文字输入为主的大模型的在内容社区审核中的使用场景。

其中,合规和标准、人为限制可以通过使用私有的开源大模型解决,但也需要持续进行效果的调优。

但依然也可以看到,在过去的2023年,大模型业界,很多问题被研究,很多的方案被提出,也有很多的场景逐步落地,曙光变成了朝霞,荒野也被勇敢探索的人踏出了小路。

当然,除了审核本身,对于其他平台业务来说,什么是未来平台产品的范式:AI 在其中解决什么问题,扮演着什么样的角色。随着业界方案成熟和持续的思考,也可以有计划地集成到我们的平台里。

可能性

23年,AI 技术上也在不断的发展,这给了业务更多思考空间,如何合理的在业务中使用这些技术。

在模型和基建上,能看到下面这些现象:

8d3e6c6c1e751aa71c317eb09ef84fc8.png

而在应用层,我们可以参考一些现在实用程度较高的方案,思考它们如何在业务中结合:

097b27e9895d0eabf567bd8e3135a8f6.png

d406c1de755144fdd37de938ee0c15fc.png

当前其实可用的开源方案有不少,例如:

  • https://github.com/logspace-ai/langflow

  • https://github.com/langchain-ai/langchain

  • https://github.com/lobehub/lobe-chat

  • https://github.com/e2b-dev/E2B

最后的最后

随着 2023 年的日历缓缓翻至尾页,我们站在新的起点上,回首这一年 AI 技术尤其是生成式 AI 的巨大飞跃,ChatGPT 的崛起不只标志着技术的飞速进步,更是我们共同见证的历史性时刻。在过去的一年里,AI 的变革触及了从代码生成到业务优化的各个方面,前端开发者们大胆拥抱这场变革,将 AI 的巨大潜力转化为实际的生产力。

然而,正如我们所经历的,AI 并非全能。它在代码生成、测试和重构等领域虽有所进步,却也显露出一些局限。不尽完美的接受率、结果的随机性,以及对复杂任务处理的不足,都提醒着我们:AI 当前只是辅助工具,而非全能替代者。它能帮助我们,但最终的创新和决策仍需人类来掌握。

面对 AI 在代码审查和低代码开发等领域的实践应用,我们看到了潜力与挑战的共存。

AI 技术的发展,不仅仅是技术层面的进步,更是对我们工作方式的深刻挑战和重塑。我们热切期待在新的一年中 AI 技术带来的更多创新与变革,同时也清楚地认识到,在与 AI 携手共进的道路上,还有许多未知和挑战等待我们去克服和探索。

展望 2024 年,我们满怀期待地看向在 AI 的助力下,不仅在技术上实现更多突破,还能在业务层面实现新的飞跃。

带着这一年的收获和经验,让我们共同迈向全新的一年。我们坚信,凭借对技术的持续探索和对业务的深入理解,我们能够在 AI 的支持下,开拓更宽广的业务领域,创造更加卓越的未来。

迎着 2024 年,我们满载信心地向前行!

一则小广告

欢迎加入我们!「Data-TnS-FE」部门为国际化产品的内容安全业务提供各类能力,其目标是从用户/业务视角出发,结合技术赋能于业务,打造更多普适便捷的业务中台、基础架构、解决方案等,为团队所有业务的运作提供稳定、高效、易用的能力支撑。

abe3d1b01a9e9ce7dfe4dfb56cdc84d6.png

a065a34ffa5ee7ee25581b7189e441c1.png

fbfdc6970aba5dca33d65bb6574355d8.png

参考链接

  • https://mp.weixin.qq.com/s/A0t4I6EqZZAVXibxhXZdAA

  • https://github.com/search?q=LLM+test+frontend&type=repositories&s=&o=desc

  • https://github.com/search?q=GPT+test+frontend&type=repositories&s=&o=desc

  • https://github.com/Codium-ai/pr-agent

  • https://www.fiverr.com/resources/guides/programming-tech/code-review-using-ai

  • https://github.com/anc95/ChatGPT-CodeReview

  • https://bytedance.larkoffice.com/wiki/RgFFwmbrtirYqCkTqVXcECf2nNg

  • https://bytedance.larkoffice.com/docx/CALQdFxsAoTpOSxWdpmcrWXgnqe

Kotlin 云端差分缓存技术

本文由字节跳动 Buildinfra 团队出品。

在我们的工程上线 Monorepo 全源码后,Kotlin 编译成了整个编译中最耗时的步骤,全源码过程中大量的 BuildCache Miss 导致我们的编译数据落后原来多仓二进制时代很多,且业界没有相关的解决方案。本篇文章我们来具体阐述下 BuildInfra 团队自研的解决方案 - Kotlin 云端差分方案的原理和技术实现。

一、Monorepo 中的噩梦

在 2022-2023 年,我们的头部业务开始慢慢地从原来的多仓二进制模式,迁移到全新 Monorepo 方案。

在多仓二进制时代,由于 Maven 的加持,大部分时候我们的都不需要直接编译代码,而是复用 Maven 的『缓存』。

在工程进入 Monorepo 时代之后,我们花了大量的精力建设 BuildCache,来试图抹平 Monorepo 与二进制的构建速度差异,但遗憾的是,尽管我们已经做了很多的努力,但由于 Gradle 脆弱的 BuildCache 设计,导致很多时候改动一行代码就会全局 miss,不得不重新编译一遍所有的源码。这很不环保(

  • 本地编译:首次编译/更新代码/切分支等场景,非常容易触发整个工程的重新编译,动辄 20min+。

  • CI 编译:从原来多仓二进制时代的 25min 劣化到了 40min+

并且,90 分位的构建劣化更是飙升到不可控的地步,它直接决定了本地开发、CI 合码的体验。

在可预见的将来,随着代码的快速增长、更多子仓的合入,仅靠 BuildCache 已经完全无法支撑大型 Monorepo 工程的开发。

二、分析

如果想优化 Kotlin 的全量编译,如果从计算机通用优化角度来看,不外乎以下几种方案:

  • 更高效的实现

  • 并发

  • 缓存

更高效的实现:语言编译是一个庞大且复杂的系统,涉及前后端编译,目前针对前后端编译流程进行优化实施起来难度较大,是一个长期且艰巨的任务。但它并不和其他优化方案有冲突。

并发:如果是相互之间没有复杂依赖关系的多 Module,那么 Gradle 已经保证了模块之间的并行度;只要你的项目依赖足够 flat,那么就可以充分利用计算机并发资源,在 CI 场景会获得非常大的提升,所以从工程角度可以进行依赖的优化来提升并行度。但这也不是一个通用的方案,非常依赖于业务方的改造。

缓存:Kotlin 目前使用的是 Gradle 的缓存,众所周知,Gradle 为了保证正确性,有着一套极其严格的 Cache key 生成策略,很多「可能」不影响 kotlin 编译正确性的变化(比如编译插件的 classpath)却会让整个 BuildCache 崩盘,全部 Task 全部 Cache Miss。

在 kotlin 中,不仅是 classpath 这些变动会导致 cache miss,kotlin 的 sourceset(即源码)也会作为 cache key 的计算输入。这意味着,只要代码里有一个字符不一样,就会导致 cache miss,整个模块就需要重新编译,那么这块是否有优化空间呢?

综合来看,从缓存角度入手,可以以比较低成本来实现较大的收益。

为此,我们提出了一套创新性的方案:

通过云端模糊匹配缓存来将全量编译转化为增量编译,来减少全量编译的耗时。

三、核心原理

我们通过改造 Kotlin Gradle Plugin 入手,当 Kotlin Task 由于各种原因不能命中 Build Cache 时,就会 fallback 到我们的『Kotlin 差分编译系统』。通过云端模糊匹配缓存来将全量编译转化为增量编译,来将本来动辄 10min+ 的全量编译转化成 10s 内的增量编译。

云端缓存模糊匹配

常规编译构建缓存方案如 Gradle Build Cache 采用的是 kv 一对一匹配。

对于 Kotlin 来说,即通过对源码文件、依赖 Jar 包,编译参数等信息算出一个缓存 key 之后,唯一匹配出一个缓存。当匹配到缓存后,缓存包的内容就是 Kotlin 的最终产物,然后就可以跳过 Kotlin Task,直接进入下一步。

由于精确匹配需要达成的条件较多,比如只要修改了一个 .kt文件,就无法匹配到缓存

因此可以考虑实现一套模糊匹配的机制:

  • 先根据一些必要参数匹配一组可能符合要求的缓存

  • 然后从这一组缓存中寻找与当前场景最接近的缓存包来进行使用

全量转增量

最接近的缓存包,也只是最接近的,是不能直接拿来用的。

那我们是不是可以将这个缓存包对应的源码与当前源码做 diff,然后将全量转增量呢?

比如,某个模块有 A.kt,B.kt,我们基于某个 Commit 打出来了一个云端缓存包。

有一天,我们新增了一个 C.kt,改动了 B.kt,这时候,我们找到之前打出来的缓存包,对他们对应的源码做一次 diff,那么我们可以得到这组变动:

[
   changed: ["B.kt"],
   added: ["C.kt"]
]

我们只需要将这个 Diff 输入给 kotlin 编译器,kotlin 就会自动帮我们完成耗时较低的增量编译

基于 #1#2 的两个核心原理,我们设计了一套『Kotlin 差分编译系统』,整体架构图如下,有四个重要的角色:

  • 客户端:Hook Kotlin Compiler,负责将全量编译转为增量编译。

  • 服务端:基于客户端的请求匹配最佳缓存。

  • 种子节点:基于 develop 分支,定时打包,上传缓存。

  • 分布式缓存:我们内部使用的缓存系统,主要通过 K-V 的形式来存储我们的缓存包。

e67917671e26352924f8409cb27ae3cb.png

四、方案详解

客户端方案

客户端的核心逻辑:对 kotlin 编译流程进行 hook,构造/加载缓存包,将全量编译转换为增量编译,转换的关键在于增量编译与全量编译的差异部分:

  1. 增量编译环境中有上次编译好的编译产物、增量追踪文件(符号引用关系,abi 信息等)。

  2. 增量编译相比于上次编译有哪些输入文件产生了修改的文件 diff 信息。

226f61615e8f2dd4045bcf25b6136e04.jpeg

缓存包内容

为了能够在全量编译时顺利还原出一个正确的增量编译现场,要求加载的缓存包中应该包含的内容有:

  1. 编译产物 (减少不必要的源文件重新编译耗时)

  2. metadata 包括 kt 、java、layout 文件等 源文件信息,包括这些文件在项目中的相对路径以及 hash 值,在还原编译现场时,根据这些内容还原为源文件的 diff 信息 ( add, remove ,modify)

  3. 编译器信息:编译器参数,编译器 JAR 包的 hash 信息等。

  4. 增量中间产物:涉及到一系列追踪文件,这些文件包含了kotlin 编译器在增量编译时 用来计算 dirty file (需要重新编译的源文件)的关键信息,包括符号引用关系、abi 信息、源文件与编译产物的对应关系等。

4ff465b416641c0ea157435a75ed6dd4.png
缓存匹配

如前文中的『核心原理』章节所描述,我们采取以下方案来进行缓存的匹配。

  • 先根据一些精准参数匹配一组可能符合要求的缓存

  • 然后从这一组缓存中寻找与当前场景最接近的缓存包来进行使用

其中以下参数会参与精确参数(unique_hash)的计算

  • Kotlin 版本

  • Kotlin Compiler Plugin (KCP)

  • Kotlin 编译器参数

我们通过将这些参数组合到一起组成一个 hash 值:unique_hash。

在之后的流程中,会使用 unique_hash 来筛选出一组缓存。我们再通过服务端的策略来筛选出最佳的缓存。

596f6942b42c425bc88a24a7b45e5589.png
编译流程 hook

拿到了缓存包后,我们就可以进行缓存包的复用了。但是我们在上一个流程中匹配到的仅仅是最优缓存,但是这个缓存和我们当前的包还存在 diff,比如代码、模块依赖信息 等的差异。

我们需要计算出拉到的缓存包和当前编译的 diff,然后将 diff 传给 kotlin 编译器,并将全量编译转成增量编译。

为了能够实现增量编译的转换,需要在编译流程的三个阶段进行 hook

  1. 编译前执行前解析模块依赖信息。

  2. 在提交编译参数到编译器执行前,匹配、下载、校验、加载缓存包,将全量编译参数转换为增量编译参数。

  3. 编译完成后构造并上传缓存包。

5d23093f28c134ec8bc59ce226e7bad8.png

其中,最核心的 Hook 点在于:将全量编译转化成增量编译。

internal fun CallCompilerAsyncOpt.callCompilerAsyncOpt(
    ....
) {
    val icEnv = IncrementalCompilationEnvironment(
            changedFiles,
            classpathChanges,
            workingDir ,
            ...
        )
        
    val hookedIcEnv = hook(icEnv)

    val environment = GradleCompilerEnvironment(
        incrementalCompilationEnvironment = hookedIcEnv,
        outputFiles,...
    )

    optRunJvmCompilerAsync(..., source, args, environment , ... )
}

我们会 Hook KotlinCompile 中的 callCompilerAsync 方法,将原来的全量 Env 转换成增量 Env。增量的 InputChanges 来源于我们的缓存包和编译的 diff (源码、依赖的变更等)。

而这个关键的全量转增量 Hook 步骤为:

  1. 匹配下载缓存:为了能够匹配到最接近的缓存包,实现最佳增量编译效率,需要对这些参数进行比较:

    1. 根据 metadata 中 java/kotlin/layout 文件的 hash 比较源码文件的 diff (其中包括 layout xml 是因为 KAE 会根据 layout 进行 kotlin 代码插桩)

    2. 利用 kotlin 1.7+ 的 classpath-snapshot.bin 计算依赖模块依赖信息的 abi diff

  2. 校验加载缓存: 基于缓存包中的 metadata ,结合编译产物、编译中间产物等,将对应信息加载到对应位置。

  3. 构造增量编译参数: 将加载的缓存包中的相对路径转换为绝对路径,用绝对路径的 diff 信息等构造出可用的增量 Env , 并提交给 Kotlin Compiler

预期之外的问题

理论上来讲,如果 kotlin 编译器是能保证增量编译是完全不出错的,那么我们的方案肯定不会引入稳定性的问题。

但是这样的期待或许过高。我们的方案是基于 Kotlin 1.7.21 进行开发的,kotlin 在 1.7+ 引入了新的 kotlin 增量编译方案,还在一个相对不稳定的阶段。实际上,我们也在上线前后遇到了各种奇怪的问题。经过长时间的 debug 和翻阅源码后得到了解决,目前在抖音项目上已经趋于稳定。

跨端缓存复用问题

问题表现为:OSX 复用 ci 种子节点打包的缓存时,对于部分删除的源代码文件,并没有将其对应的编译产物删除

通过一系列的排查发现,该问题是 Kotlin Compiler 自身的 bug ,且在高版本进行了修复,bug 来源于 kotlin 的增量编译中间产物中,用于追踪源码文件与编译产物关系的信息异常,在涉及到大小写敏感不一致的系统时,无法正常删除编译产物。

解决方案: 对高版本的修复 commit 进行了 cherry-pick

kotlin 修复 https://github.com/JetBrains/kotlin/commit/be71d8841ebc22c79bb6b4bc6f3ad93c147ba9c0

大小写敏感问题

由于 OSX 不是一个大小写敏感的操作系统,这意味着,在一个文件夹中,不能同时存在去掉大小写后字符一样的两个文件。比如:

074d10652492df8780339b20d6bf2eff.png

但是这个行为在 Linux 系统上是允许的。

我们的遇到的问题是,项目中不同文件夹中有两个包名叫 com.xxx.legoImp ,一个叫com.xxx.legoimp 。这两个包名会在 Linux 种子节点上进行 Jar 包压缩,在 mac 上进行解压 ,由于 Linux 不是 Case Sensitive 的系统,所以 jar 包中,他俩会同时存在,但是在 mac 上解压时,mac 会自动合并了这两个包,并且转换成小写。这样就会在运行时崩溃,找不到这个包名。

解决方案:我们提供了一个类似的包名大小写检测,帮助业务提前暴露问题,并进行代码的整改。

方案性能极致优化
加速缓存解压

在最初的缓存包压缩中,采用的是 Java ZipFile 中默认的压缩算法,上线后发现有较多模块的缓存解压时间较长,后续参考了Gradle build-cache 方案,将压缩算法修改为 lz4,大大降低了解压缩的时间。

降低缓存淘汰率

在本方案中,缓存远端存储在我们的分布式缓存服务中,由于分布式缓存空间有限(2TB),超过缓存空间时会根据 LRU 淘汰最近未使用的缓存,这样就会导致缓存命中率下降,而由于在抖音项目中,生产缓存的种子节点会在每个 MR 合入后进行缓存打包,每次全量打包缓存时,缓存包约1GB+ , 为了减少不必要的缓存上传,采取了两个方案:

  1. 与 build-cache 进行关联,如果种子节点打包时命中 build-cache ,将 kotlin 缓存与 build-cache 进行关联,而不是重复上 kotlin 缓存

  2. 计算缓存包内容 hash ,如果存在相同的缓存包,将他们关联到一起,而不是重复上传缓存。

服务端策略

区别于传统的 Build Cache 服务端的简单 K-V 存储,我们的服务端需要帮助客户端去存储产物、匹配产物、选择最佳产物。客户端和服务端的通信时序图如下:

6208cdee321509d8399e60e7669a7108.png

其中,服务端最核心的逻辑在于匹配最佳产物

每一个 unique hash 都会对应一组产物,我们要从这一组产物里找到一个最优解。

最优解的定义是:这个产物离我们当前的编译较为接近。我们为这个『接近』,定义了一个记分制度。

data class Diff(val added: List<String>, 
                val removed: List<String>,
                val modified: List<String>)

fun diffScore(): Int {
    return this.diff.removed.size * 3 +
            this.diff.modified.size * 2 +
            this.diff.added.size
}

每个产物和当前编译都会产生一个 diffdiff 里有 addedremovedmodified

我们定义一个函数 diffScoreremove 记三分,modified 记两分,added 记一分。

之所以按照这样的系数,是因为 removed 大概率会引起很多底层依赖的重新编译,所以我们需要尽可能地减少 removed 的代码,added 的代码一般不会引起其它代码重新编译,所以我们可以记为一分。modified 介于两者之间,记两分。

那么,我们的核心的逻辑就变成了:从一组缓存里,找到 diffScore 最少的产物

但是因为我们的 unique hash 对应的 hash 生成并不复杂,一般来说,除非是升级 kotlin 版本,其它情况这个 unique hash 基本不会变,所以这个缓存的量可能会很大,我们不可能实现找到所有的缓存,并且进行 diffScore 的比较。我们需要筛选出一定范围内的缓存。

首先,我们的所有的产物生成都是在主干分支上。

那么意味着,对于 feature 分支,与其代码最接近的 commit 应该是图中的 base commit

95a9b2df1c67070483ad5c03cee08fa1.png

因为成本的原因,种子任务可能并不会在主干分支的每个节点都打缓存包,可能是定时打包。那么意味着不是所有的 merge commit 都有缓存包。

所以,我们找到这个 commit 的前后一天时间内的 commit。并和表中的所有产物进行取交集

a5ea0a36d03e651033ac3b65541e29d5.png

最后得到所有黄色的点。(黄色的点是打了缓存包的 merge 节点)

我们将所有黄色的点对应的 MetaData 与当前 commit 的 MetaData 进行 diff。

取一个 diffScore 最少的节点,作为最终的产物。

val bestArtifact = artifacts.minOf {
    it.diffScore()
}

这样我们就可以认为它是和当前 commit 最接近的 commit。对应的产物对于客户端来说,转增量编译的风险最小、速度最快。

五、上线收益

目前项目上线了大部分的字节 Monorepo 项目,稳定运行了半年有余。期间也修复了 kotlin 1.7 上的若干官方增量编译相关的 bug,最后取得了较为理想的收益。 

抖音从二进制演进到 Mopnorepo 后,Kotlin 部分的编译时间劣化超过 10m+,我们通过本文的方案,将劣化的部分基本抹平,90 分位下降 60%,大幅提升了开发的体验和 CI 合码效率。

六、总结

本文主要阐述了 Kotlin 云端差分方案的技术原理。

实现超大型工程 Monorepo 全源码的研发模式,仅靠 BuildCache 是无法实现的,Kotlin 云端差分方案对于全源码起着不可或缺的作用。

通过模糊匹配缓存的方式将全量编译模拟成增量编译的思想,可能在其他端上比较容易实现或者就是标准方案,但目前 Android CI 构建领域均采用 clean 编译,无法复用构建中间产物。该方案的出现也有望实现思想的复用,运用到所有『类似』的任务中,比如 Java Compile、Transform、Dex 等,只要它本身是支持增量、编译幂等特性的,都可以复用这套方案,从而进一步提升 Android 的构建效率。

在研究云端差分方案期间,我们积累了大量的 Kotlin 编译器相关的知识,为我们平时排查 kotlin 疑难问题提供了非常多的技术储备,也发现了很多 kotlin 官方的 bug 和设计上的缺陷,我们也会在将来修复后回馈 kotlin 社区。

欢迎对编译构建、Kotlin相关技术感兴趣的小伙伴找我们进行技术交流与讨论!

加入我们

Build Infra Android 团队专注于工程架构、全流程研发体验的优化与提升。如果你对工程架构、编译优化、IDE 体验优化、CI/CD、静态代码分析等技术感兴趣,欢迎与我们一起在 Android 研发体验方向进行技术突破。简历投递至:

lanjunjian@bytedance.com

最 Nice 的工作氛围和成长机会,福利与机遇多多,北京、上海、杭州均有职位,欢迎加入字节 Android 中台基建团队 !

字节跳动基础架构SRE-Copilot获得2023 CCF国际AIOps挑战赛冠军

近日,2023 CCF国际AIOps挑战赛决赛暨“大模型时代的AIOps”研讨会在北京成功举办,活动吸引了来自互联网、运营商、科研院所、高校、软硬件厂商等领域多名专家学者参与,为智能运维的前沿学术研究、落地生产实践打开了新思路。决赛中,从初赛两百多支队伍中脱颖而出的十支入围队伍分别展示了各自的方案,并进行了现场答辩,评审专家从选题方向、创新性、实用性、完整度和实验复现结果等多角度进行了综合评定,最终,来自字节跳动基础架构-SRE 团队的 SRE-Copilot战队,以SRE-Copilot:基于 LLM 的多场景智能运维”,获得本届大赛冠军

83ed35f441d587eb747a2e7fb2280c57.jpeg

CCF国际AIOps挑战赛由中国计算机学会(CCF)、清华大学和南开大学联合发起,旨在借助社区力量,运用人工智能算法解决各类运维难题。自2017年底首次举办,迄今为止已经成功举办六届,吸引了大量AIOps从业者和关注者,赛事规模和影响力不断扩大,是智能运维领域极具影响力的专业赛事。本届CCF国际AIOps挑战赛共有来自265支队伍的677名选手报名参赛,决赛现场有超300人线下参会,同时有近5万人次观看线上直播。

CCF国际AIOps挑战赛自创办以来,赛题覆盖了不同的运维场景、运维数据、故障来源、应用类型。本届大赛赛题全新升级,首次采用开放式赛题,基于建行云龙舟运维平台的稳定性工具和多维监控系统,由参赛选手自主确定需要解决的运维问题,并对主办方提供的交易、日志、调用链、监控指标等一种或多种模态数据进行故障检测、定位、根因分析、影响分析等。本次赛题不再局限于单个运维场景,而是模拟了企业运维团队面临的系统架构复杂、数据规模庞大、数据种类繁多等一系列需要解决的运维挑战,使AIOps生态里的所有产、学、研、用各方,都可以基于同样的数据,展开竞赛,并鼓励参赛选手探索大语言模型(LLM)在智能运维领域的应用。

e94c7e22f953c956281a239aba09f6cd.png

为拥抱这一变化,SRE-Copilot战队提出了一套基于大语言模型的多场景智能运维框架——SRE-Copilot,该框架参考了GPT的思想,即通过集成学习的方式,用多个专业的子Agent组合成强大的混合专家(MoE,Mixture of Experts)系统,支持多个智能体Agent的协作与动态编排调度,有计划、记忆、反思与推理等能力,为SRE提供智能化服务,切实提升SRE工作效率。其技术性和创新性主要体现在以下几个方面:

1、基于 ReAct 框架和CoT思维链的 Multi-Agent 编排调度,实现了多模态数据按需异常检测

ReAct的思想参考自论文ReAct: Synergizing Reasoning and Acting in Language Models,包括推理(Reasoning)和行动(Action),推理帮助模型生成、追踪和更新计划并处理异常,行动允许模型与外部环境交互以获取更多信息Observation,提升准确率与适应性。

fea8e19d40835c5439c738d54c716127.png

在异常检测场景中,首先定义多数据源Agent,分别负责选择合适的算法对不同模态数据进行异常检测与检索,主持人Copilot负责解析用户意图,RCAAgent负责收集其他Agent检测到的异常结果与链路、配置信息,进行根因定位。如上图所示,用户提问中提到“交易大量失败”,此时模型会将问题交给负责交易数据的TradeAgent进行检测,TradeAgent检测得出“交易性能下降”,则问题会进一步交给负责性能数据的MonitorAgent。通过这种模式,将排障流程进行下去,每个Agent的检测顺序及内容均根据检测到的异常动态编排。RCAAgent负责收敛协作轮次,并根据反馈决定下一步分析与下钻的方向,当没有额外信息时,就会停止检测,进行根因定位。

SRE-Copilot模拟了真实的大规模云平台跨组件协同定位,利用多个Agent替代多个组件运维团队,发挥各自所长,并动态编排决定排查方向;同时,SRE-Copilot更关注多个组件(多个数据)的表现形态,而非根据单一组件(单一数据)判断是否异常,降低噪声,具有更高的鲁棒性。

2、基于 RAG 检索增强的框架进行根因推理

检索增强生成 (RAG) 是使用来自私有或专有数据源的信息来辅助文本生成的技术。它将检索模型(用于搜索大型数据集或知识库)和生成模型(使用检索到的信息生成可供阅读的文本回复)结合在一起,通过从更多数据源添加背景信息,比如训练 LLM 时并未用到的互联网上的新信息、专有商业背景信息或者属于企业的内部文档等,来补充LLM原始知识库,改善大型语言模型的输出,使生成的答案更可靠,还有助于缓解“幻觉”问题,且不需要重新训练。

根因定位过程主要包含以下过程:

  • 知识库构建:需要提前定义一些专家诊断经验和历史故障库,并将信息转化为高维度空间中的向量,存储在向量数据库中。专家经验可以由运维工程师或者业务专家来定义,比如:流量突增,内存打满,服务不可用,对应的可能是大量访问带来的问题,此时应该扩容或重启等。

  • RAG检索增强:使用异常检测生成的故障摘要作为输入,对历史故障、专家经验、知识库文档等进行检索,检索的TopN结果作为上下文和原始提示词组合,再提交给LLM进行根因定位。LLM的参数化知识是静态的,RAG让LLM不用重新训练就能获取最新相关信息,提升了模型的准确性和实时性。

  • 推理与反思:由于本次比赛使用的是6b的小模型(兼容本地化部署环境),推理稳定性较差,因此引入“反思”机制,让模型对自己诊断的根因进行再次判断,进一步提高了根因定位的准确度。

  • 学习新的策略:每次诊断结果既会生成诊断报告,也会加入模型记忆,再次诊断时对最相近的专家经验与诊断结果进行推理,让模型获得持续学习与迭代的能力。

基于RAG,即使是小模型,在没有专家经验和历史故障的输入时,仍然能对一些简单问题进行根因推断,例如:磁盘写满故障、java虚拟机GC问题等等。通过让模型进行自我评估和自我反省,能够将模型推理根因的准确率进一步提升30%以上 。模型在诊断过程中能够不断迭代、持续学习,随着学习和推理的逐渐完善,SRE-Copilot故障诊断的能力也将不断提升。

3、沿着稳定性全生命周期管理,提供多种运维能力

3992de2781e3053391111f99918d5d7d.png

基于大语言模型使用tools的能力,把散落的各个运维场景进行统一集成,理解、拆分用户意图,编排调用不同工具,提供稳定性建设全流程的智能运维能力。用户可通过自然语言提问方式使用SRE-Copilot框架的以下运维能力:

  • 运维计划:解析用户运维需求,生成自然语言的工作流,并从系统可调用的组件中选择合适组件,动态生成可执行的工作流;

  • 运维可视化:通过自然语言交互,自动执行简易的数据查询/分析,对故障数据进行可视化;

  • 异常检测:支持多模态数据类型,灵活拓展,通过多Agent协同编排,整合不同平台数据,极大缩短MTTR;

  • 根因定位:无监督,支持专家经验、历史故障输入,对已知故障准确率高,对于未知故障可推理;

  • 故障分类:根据专家经验和历史故障所属类别,以及本次故障表现,对故障进行分类,有助于后续按组织或改进措施推进复盘与优化;

  • 故障自愈:在推理得到故障根因和故障分类后,可以推荐合适的自愈措施,流程自动化,让运维人员集中精力,无需频繁切换上下文,确保响应和处理的及时性和准确性 ;

  • 代码生成:基于用户的提示生成代码,将复杂脚本的调试开发时间从几小时缩短到几分钟;

  • 故障报告:利用LLM自动生成故障诊断报告,以自然语言方式表述5W问题:When-Where-Who-What-Why,显著提升故障诊断报告的效率与质量,方便团队积累经验和知识库 ;

  • 知识库问答:基于本地知识库进行私域知识问答,提升应答准确率,减少Oncall系统人力投入。

综上所述,SRE-Copilot框架将大语言模型引入AIOps领域,解决了一些传统AIOps的痛点问题,具有以下优势

首先,当前各公司系统架构愈发复杂,各种组件依赖越来越多,很难有一个运维团队精通全部架构及组件的技术细节。而LLM可以学习近乎无限的知识,也可以通过设计多个专家Agent的方式进行编排调度无限拓展,读取、检测不同系统不同数据源的异常信息,并将多模态异常都转化为LLM可理解的半结构化或结构化语言形式,交由LLM分析诊断,提升了故障处理效率。

其次,传统AIOps算法大多是单场景、单AI、解决单个问题,且异常检测和根因诊断大部分算法都依赖于数据的标注。而LLM基于检索增强的方式,不需要或者很少用人工标注的数据进行训练,很大程度上解决了传统AIOps领域人工标注的成本高、周期长、精确度受限等问题,减少了训练所需的数据量。

同时,在接入维护方面,传统AIOps当遇到新客户、私域知识、业务经验、数据变动等情况时,通常只能重新训练,而LLM的泛化能力、自监督学习能力与交互形式,让开发者与客户可以一定程度上松耦合:开发者降低了对客户数据的依赖程度,用统一的大模型或预训练的行业大模型,就能解决客户大部分问题;而客户仅需要了解自己的系统逻辑,通过简单微调就能获得模型的通用能力,通过多Agent的方式,甚至可以将自己的逻辑经验轻松接入,降低了接入成本。

接着,LLM已经在其他领域出现了涌现和推理能力,通过对通用知识的学习,可以对未知故障进行推断,人工确认后加入知识库或记忆来实现模型演进,这似乎是解决新故障诊断的最佳选择。

最后,LLM都是自然语言的形式交互,无需严格传参,降低了使用成本,其精调和上下文学习的语料也都是语言形式,业务SRE可以一起参与共建。

团队介绍:

基础架构-SRE,负责字节跳动基础架构部门所有组件的SRE工作,沿着成本、稳定性、效率、服务四条主线,致力于打造高扩展、高可用的生产系统。基础架构-SRE-数据化团队,负责SRE的数据化运营及智能化探索,数据化产品包括基础架构离线数仓与数据门户、资源交付数据化运营系统;智能化方向涵盖异常检测、智能变更、故障诊断、智能限流、运筹优化与大语言模型应用。协同和赋能SRE从DataOps向AIOps和ChatOps转变,是我们一直努力的方向。欢迎加入,共同探索大模型在智能运维领域中的落地应用:https://jobs.bytedance.com/experienced/position/7262287728477751589/detail

字节跳动百万级Metrics Agent性能优化的探索与实践

背景

ff1542bf4b3f0c7f905f082cb6d19f2d.png

metricserver2 (以下简称Agent)是与字节内场时序数据库 ByteTSD 配套使用的用户指标打点 Agent,用于在物理机粒度收集用户的指标打点数据,在字节内几乎所有的服务节点上均有部署集成,装机量达到百万以上。此外Agent需要负责打点数据的解析、聚合、压缩、协议转换和发送,属于CPU和Mem密集的服务。两者结合,使得Agent在监控全链路服务成本中占比达到70%以上,对Agent进行性能优化,降本增效是刻不容缓的命题。本文将介绍我们在Agent性能优化上的探索和实践。

基本架构

d5379c1599552ec947812d9789aceed2.jpeg
  • Receiver 监听socket、UDP端口,接收SDK发出的metrics数据

  • Msg-Parser对数据包进行反序列化,丢掉不符合规范的打点,然后将数据点暂存在Storage中

  • Storage支持7种类型的metircs指标存储

  • Flusher在每个发送周期的整时刻,触发任务获取Storage的快照,并对其存储的metrics数据进行聚合,将聚合后的数据按照发送要求进行编码

  • Compress对编码的数据包进行压缩

  • Sender支持HTTP和TCP方式,将数据发给后端服务

我们将按照数据接收、数据处理、数据发送三个部分来分析Agent优化的性能热点。

数据接收

Case 1

Agent与用户SDK通信的时候,使用 msgpack 对数据进行序列化。它的数据格式与json类似,但在存储时对数字、多字节字符、数组等都做了优化,减少了无用的字符,下图是其与json的简单对比:

fbc81aef87e9e1f16a4729ce20b6320a.png

Agent在获得数据后,需要通过msgpack.unpack进行反序列化,然后把数据重新组织成 std::vector。这个过程中,有两步复制的操作,分别是:从上游数据反序列为 msgpack::object 和 msgpack::object 转换 std::vector。

{ // Process Function
    msgpack::unpacked msg;
    msgpack::unpack(&msg, buffer.data(), buffer.size());
    msgpack::object obj = msg.get();
    
    std::vector<std::vector<std::string>> vecs;
    if (obj.via.array.ptr[0].type == 5) {
        std::vector<std::string> vec;
        obj.convert(&vec);
        vecs.push_back(vec);
    } else if (obj.via.array.ptr[0].type == 6) {
        obj.convert(&vecs);
    } else {
        ++fail_count;
        return result;
    }
    
    // Some more process steps
}

但实际上,整个数据的处理都在处理函数中。这意味着传过来的数据在整个处理周期都是存在的,因此这两步复制可以视为额外的开销。

msgpack协议在对数据进行反序列化解析的时候,其内存管理的基本逻辑如下:

71d01434aeabf8c40f55d006a43e001a.png

为了避免复制 string,bin 这些类型的数据,msgpack 支持在解析的时候传入一个函数,用来决定这些类型的数据是否需要进行复制:

2a5407eba73c2db394fb29cff59591af.png

因此在第二步,对 msgpack::object 进行转换的时候,我们不再转换为 string,而是使用 string_view,可以优化掉 string 的复制和内存分配等:

// Define string_view convert struct.
template <>
struct msgpack::adaptor::convert<std::string_view> {
    msgpack::object const& operator()(msgpack::object const& o, std::string_view& v) const {
        switch (o.type) {
        case msgpack::type::BIN:
            v = std::string_view(o.via.bin.ptr, o.via.bin.size);
            break;
        case msgpack::type::STR:
            v = std::string_view(o.via.str.ptr, o.via.str.size);
            break;
        default:
            throw msgpack::type_error();
            break;
        }
        return o;
    }
};

static bool string_reference(msgpack::type::object_type type, std::size_t, void*) {
    return type == msgpack::type::STR;
}

{ 
    msgpack::unpacked msg;
    msgpack::unpack(msg, buffer.data(), buffer.size(), string_reference);
    msgpack::object obj = msg.get();

    std::vector<std::vector<std::string_view>> vecs;
    if (obj.via.array.ptr[0].type == msgpack::type::STR) {
        std::vector<std::string_view> vec;
        obj.convert(&vec);
        vecs.push_back(vec);
    } else if (obj.via.array.ptr[0].type == msgpack::type::ARRAY) {
        obj.convert(&vecs);
    } else {
        ++fail_count;
        return result;
    }
}

经过验证可以看到:零拷贝的时候,转换完的所有数据的内存地址都在原来的的 buffer 的内存地址范围内。而使用 string 进行复制的时候,内存地址和 buffer 的内存地址明显不同。

827d4cd5c210318ba6594862cd0e5411.png

Case 2

8893a1d4ffe4d5fc37d49945f1a40405.png

Agent在接收端通过系统调用完成数据接收后,会立刻将数据投递到异步的线程池内,进行数据的解析工作,以达到不阻塞接收端的效果。但我们在对线上数据进行分析时发现,用户产生的数据包大小是不固定的,并且存在大量的小包(比如一条打点数据)。这会导致异步线程池内的任务数量较多,平均每个任务的体积较小,线程池需要频繁的从队列获取新的任务,带来了处理性能的下降。

因此我们充分理解了msgpack的协议格式(https://github.com/msgpack/msgpack/blob/master/spec.md)后,在接收端将多个数据小包(一条打点数据)聚合成一个数据大包(多条打点数据),进行一次任务提交,提高了接收端的处理性能,降低了线程切换的开销。

static inline bool tryMerge(std::string& merge_buf, std::string& recv_buf, int msg_size, int merge_buf_cap) {
    uint16_t big_endian_len, host_endian_len, cur_msg_len;

    memcpy(&big_endian_len, (void*)&merge_buf[1], sizeof(big_endian_len));
    host_endian_len = ntohs(big_endian_len);
    cur_msg_len = recv_buf[0] & 0x0f;

    if((recv_buf[0] & 0xf0) != 0x90 || merge_buf.size() + msg_size > merge_buf_cap || host_endian_len + cur_msg_len > 0xffff) {
        // upper 4 digits are not 1001
        // or merge_buf cannot hold anymore data
        // or array 16 in the merge_buf cannot hold more objs (although not possible right now, but have to check)
        return false;
    }

    // start merging
    host_endian_len += cur_msg_len;
    merge_buf.append(++recv_buf.begin(), recv_buf.begin() + msg_size);

    // update elem cnt in array 16
    big_endian_len = htons(host_endian_len);

    memcpy((void*)&merge_buf[1], &big_endian_len, sizeof(big_endian_len));
    return true;
}

{ // receiver function 
    // array 16 with 0 member
    std::string merge_buf({(char)0xdc, (char)0x00, (char)0x00});
    
    for(int i = 0 ; i < 1024; ++i) {
        int r = recv(fd, const_cast<char *>(tmp_buffer_.data()), tmp_buffer_size_, 0);
        if (r > 0) {
            if(!tryMerge(merge_buf, tmp_buffer_, r, tmp_buffer_size_)) {
                // Submit Task
            }
        // Some other logics
    }
}

从关键的系统指标的角度看,在merge逻辑有收益时(接收QPS = 48k,75k,120k,150k),小包合并逻辑大大减少了上下文切换,执行指令数,icache/dcache miss,并且增加了IPC(instructions per cycle)见下表:

d0f82bbe22658ea9f37d61fc58a32234.jpeg

同时通过对前后火焰图的对比分析看,在合并数据包之后,原本用于调度线程池的cpu资源更多的消耗在了收包上,也解释了小包合并之后context switch减少的情况。

Case 3

用户在打点指标中的Tags,是拼接成字符串进行纯文本传递的,这样设计的主要目的是简化SDK和Agent之间的数据格式。但这种方式就要求Agent必须对字符串进行解析,将文本化的Tags反序列化出来,又由于在接收端收到的用户打点QPS很高,这也成为了Agent的性能热点。

早期Agent在实现这个解析操作时,采用了遍历字符串的方式,将字符串按|=分割成 key-value 对。在其成为性能瓶颈后,我们发现它很适合使用SIMD进行加速处理。

原版

inline bool is_tag_split(const char &c) {
    return c == '|' || c == ' ';
}

inline bool is_kv_split(const char &c) {
    return c == '=';
}

bool find_str_with_delimiters(const char *str, const std::size_t &cur_idx, const std::size_t &end_idx,
    const Process_State &state, std::size_t *str_end) {
    if (cur_idx >= end_idx) {
        return false;
    }
    std::size_t index = cur_idx;
    while (index < end_idx) {
        if (state == TAG_KEY) {
            if (is_kv_split(str[index])) {
                *str_end = index;
                return true;
            } else if (is_tag_split(str[index])) {
                return false;
            }
        } else {
            if (is_tag_split(str[index])) {
                *str_end = index;
                return true;
            }
        }
        index++;
    }
    if (state == TAG_VALUE) {
        *str_end = index;
        return true;
    }
    return false;
}

SIMD

#if defined(__SSE__)
static std::size_t find_key_simd(const char *str, std::size_t end, std::size_t idx) {
    if (idx >= end) { return 0; }
    
    for (; idx + 16 <= end; idx += 16) {
        __m128i v = _mm_loadu_si128((const __m128i*)(str + idx));
        __m128i is_tag = _mm_or_si128(_mm_cmpeq_epi8(v, _mm_set1_epi8('|')),
                                     _mm_cmpeq_epi8(v, _mm_set1_epi8(' ')));
        __m128i is_kv = _mm_cmpeq_epi8(v, _mm_set1_epi8('='));

        int tag_bits = _mm_movemask_epi8(is_tag);
        int kv_bits = _mm_movemask_epi8(is_kv);
        // has '|' or ' ' first
        bool has_tag_first = ((kv_bits - 1) & tag_bits) != 0;
        if (has_tag_first) { return 0; }
        if (kv_bits) { // found '='
            return idx + __builtin_ctz(kv_bits);
        }
    }

    for (; idx < end; ++idx) {
        if (is_kv_split(str[idx])) { return idx; } 
        else if (is_tag_split(str[idx])) { return 0; }
    }

    return 0;
}

static std::size_t find_value_simd(const char *str, std::size_t end, std::size_t idx) {
    if (idx >= end) { return 0; }
    
    for (; idx + 16 <= end; idx += 16) {
        __m128i v = _mm_loadu_si128((const __m128i*)(str + idx));
        __m128i is_tag = _mm_or_si128(_mm_cmpeq_epi8(v, _mm_set1_epi8('|')),
                                     _mm_cmpeq_epi8(v, _mm_set1_epi8(' ')));
        int tag_bits = _mm_movemask_epi8(is_tag);
        if (tag_bits) {
            return idx + __builtin_ctz(tag_bits);
        }
    }

    for (; idx < end; ++idx) {
        if (is_tag_split(str[idx])) { return idx; }
    }

    return idx;
}

构建的测试用例格式为 。text 则是测试例子里的 str_size,用来测试不同 str_size 下使用 simd 的收益。可以看到,在 str_size 较大时,simd 性能明显高于标量的实现。

str_size simd scalar
1 109 140
2 145 158
4 147 198
8 143 283
16 155 459
32 168 809
64 220 1589
128 289 3216
256 477 6297
512 883 12494
1024 1687 24410

数据处理

Case 1

Agent在数据聚合过程中,需要一个map来存储一个指标的所有序列,用于对一段时间内的打点值进行聚合计算,得到一个固定间隔的观测值。这个map的key是指标的tags,map的value是指标的值。我们通过采集火焰图发现,这个map的查找操作存在一定程度的热点。

6b562ca32e9d804422ef4d625b6671a7.png

下面是 _M_find_before_node 的实现:

1fe999c303fdcca5008006c167a985a4.png

这个函数作用是:算完 hash 后,在 hash 桶里找到匹配 key 的元素。这也意味着,即使命中了,hash 查找的时候也要进行一次 key 的比较操作。而在 Agent 里,这个 key 的比较操作定义为:

bool operator==(const TagSet &other) const {
        if (tags.size() != other.tags.size()) {
            return false;
        }
        for (size_t i = 0; i < tags.size(); ++i) {
            auto &left = tags[i];
            auto &right = other.tags[i];
            if (left.key_ != right.key_ || left.value_ != right.value_) {
                return false;
            }
        }
        return true;
    }

这里需要遍历整个 Tagset 的元素并比较他们是否相等。在查找较多的情况下,每次 hash 命中后都要进行这样一次操作是非常耗时的。可能导致时间开销增大的原因有:

  1. 每个 tag 的 key_ 和 value_ 是单独的内存(如果数据较短,stl 不会额外分配内存,这样的情况下就没有单独分配的内存了),存在着 cache miss 的开销,硬件预取效果也会变差;

  2. 需要频繁地调用 memcmp 函数;

  3. 按个比较每个 tag,分支较多。

2eb76915e976fc49e18356b5fbe609b9.png

因此,我们将 TagSet 的数据使用 string_view 表示,并将所有的 data 全部存放在同一块内存中。在 dictionary encode 的时候,再把 TagSet 转换成 string 的格式返回出去。

// TagView 
#include <functional>
#include <string>
#include <vector>

struct TagView {
    TagView() = default;
    TagView(std::string_view k, std::string_view v) : key_(k), value_(v) {}
    std::string_view key_;
    std::string_view value_;
};

struct TagViewSet {
    TagViewSet() = default;
    TagViewSet(const std::vector<TagView> &tgs, std::string&& buffer) : tags(tgs), 
        tags_buffer(std::move(buffer)) {}
    TagViewSet(std::vector<TagView> &&tgs, std::string&& buffer) { tags = std::move(tgs); }
    TagViewSet(const std::vector<TagView> &tgs, size_t buffer_assume_size) {
        tags.reserve(tgs.size());
        tags_buffer.reserve(buffer_assume_size);
        for (auto& tg : tgs) {
            tags_buffer += tg.key_;
            tags_buffer += tg.value_;
        }
        const char* start = tags_buffer.c_str();
        for (auto& tg : tgs) {
            std::string_view key(start, tg.key_.size());
            start += key.size();
            std::string_view value(start, tg.value_.size());
            start += value.size();
            tags.emplace_back(key, value);
        }
    }

    bool operator==(const TagViewSet &other) const {
        if (tags.size() != other.tags.size()) {
            return false;
        }
        // not compare every tag
        return tags_buffer == other.tags_buffer;
    }

    std::vector<TagView> tags;
    std::string tags_buffer;
};

struct TagViewSetPtrHash {
    inline std::size_t operator()(const TagViewSet *tgs) const {
        return std::hash<std::string>{}(tgs->tags_buffer);
    }
};

验证结果表明,当 Tagset 中 kv 的个数大于 2 的时候,新方法性能较好。

42fe08f4f794603b4bf88e223b2f012e.png

数据发送

Case 1

早期Agent使用zlib进行数据发送前的压缩,随着用户打点规模的增长,压缩逐步成为了Agent的性能热点。

因此我们通过构造满足线上用户数据特征的数据集,对常用的压缩库进行了测试:

zlib使用cloudflare

3878a7844f1e1cd3cec72a4fd9fbe622.jpeg

zlib使用1.2.11

ee51519f1dd494c825972e27ccd263e3.jpeg

通过测试结果我们可以看到,除bzip2外,其他压缩算法均在不同程度上优于zlib:

  • zlib的高性能分支,基于cloudflare优化 比 1.2.11的官方分支性能好,压缩CPU开销约为后者的37.5%

    • 采用SIMD指令加速计算

  • zstd能够在压缩率低于zlib的情况下,获得更低的cpu开销,因此如果希望获得比当前更好的压缩率,可以考虑zstd算法

  • 若不考虑压缩率的影响,追求极致低的cpu开销,那么snappy是更好的选择

结合业务场景考虑,我们最终执行短期使用 zlib-cloudflare 替换,长期使用 zstd 替换的优化方案。

结论

上述优化取得了非常好的效果,经过上线验证得出:

  • CPU峰值使用量降低了10.26%,平均使用量降低了6.27%

  • Mem峰值使用量降低了19.67%,平均使用量降低了19.81%

综合分析以上性能热点和优化方案,可以看到我们对Agent优化的主要考量点是:

  • 减少不必要的内存拷贝

  • 减少程序上下文的切换开销,提高缓存命中率

  • 使用SIMD指令来加速处理关键性的热点逻辑

除此之外,我们还在开展 PGO 和 clang thinLTO 的验证工作,借助编译器的能力来进一步优化Agent性能。

加入我们

本文作者赵杰裔,来自字节跳动 基础架构-云原生-可观测团队,我们提供日均数十PB级可观测性数据采集、存储和查询分析的引擎底座,致力于为业务、业务中台、基础架构建设完整统一的可观测性技术支撑能力。同时,我们也将逐步开展在火山引擎上构建可观测性的云产品,较大程度地输出多年技术沉淀。 如果你也想一起攻克技术难题,迎接更大的技术挑战,欢迎投递简历到 zhaojieyi@bytedance.com

最 Nice 的工作氛围和成长机会,福利与机遇多多,在上海、杭州和北京均有职位,欢迎加入字节跳动可观测团队 !

参考引用

  1. v2_0_cpp_unpacker:https://github.com/msgpack/msgpack-c/wiki/v2_0_cpp_unpacker#memory-management

  2. messagepack-specification:https://github.com/msgpack/msgpack/blob/master/spec.md

  3. Cloudflare fork of zlib with massive performance improvements:https://github.com/RJVB/zlib-cloudflare

  4. Intel® Intrinsics Guide:https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html

  5. Profile-guided optimization:https://en.wikipedia.org/wiki/Profile-guided_optimization

  6. ThinLTO:https://clang.llvm.org/docs/ThinLTO.html

字节电商双11 大促容量保障是如何做的?

前言

Rhino 简介

Rhino是字节自研全链路容量评估产品,致力于构建完整的全链路容量评估解决方案(覆盖:容量预估->资源准备->数据准备->容量验证->监控->分析->决策->处理反馈);围绕容量在稳定性、成本、效率 三方面提供业务全方位基础支撑。Rhino 目前已经成为字节各业务容量评估主流解决方案,并且历年来在业务大型活动稳定性保障中(抖音春节项目、电商618/双11大促等)均扮演了关键角色。

bba87d6c54e88d8673a102b6976651f9.png

Rhino 所解决的核心问题包括:

  • 如何降低字节各业务容量风险,内部变更、外部流量、活动、容灾、应急等场景容量风险?

  • 如何控制业务资源成本管控同时不拖累业务发展,尤其是例行抖音春节项目和电商业务大促活动?

  • 如何在资源有限情况下,保障业务持续运行,兼顾稳定性与成本?

容量评估产品体系

Rhino 发展历程

Rhino 是由之前全链路压测升级而来,自上线以来经历了3次大的产品变革,当前围绕容量评估进行容量保障生态体系建设。

4cfdf9527effe9dc25cf6de094ae57f3.png
Rhino 产品架构
ae12e30d295a893665ad1a8b99d37d9a.png

Rhino 整个产品体系是围绕容量验证和容量预估两个基础能力构建,协同为业务提供完整容量评估解决方案:

  • 以分布式压测为核心的容量验证,能够为业务提供高置信度评估支持;但随着各业务压测进入深水区,仿真度和安全性保障趋于完善,业务成本方面关注度更高;

  • 基于算法模型的容量预估,对于业务而言能够大幅降低容量评估成本,但缺点是置信度不如压测,这个也是我们要持续提升算法模型准确性的原因,没有置信度即使成本 0也没有价值。

其中,基于算法模型的容量预估自22年初开始开始在各头部业务和活动中实现规模化应用,容量模型基础能力建设是【Rhino】 、【电商服务架构】以及【基础架构各组件团队】深度合作完成的。

容量模型建设里程碑:

6cfe9046ded08329a827191e94200da6.png
Rhino 核心能力
  • 全链路容量评估自动化及多场景支持

    • 构建面向业务提供容量预估、资源准备、数据准备、容量验证、监控、分析、决策、处理、反馈完整容量评估流程;

    • 容量评估流程各环节交互高度自动化;

    • 提供多种场景容量评估支持,如大型活动、新服务上线、线上性能探测等 。

  • 超大流量、多场景施压引擎能力

    • 提供不同协议、多地区、数据类型、过亿 QPS 级别施压引擎支持 ;

    • 针对业务复杂场景,提供开放性支持,如灵活任务编排、流量调度、Plugin、云 IDE 支持等 。

  • 安全、可靠、准确容量评估支持

    • 提供多种协议的流量染色标记和不同泳道流量数据监控、追踪能力 ;

    • 构建完整的安全合规配套管控方案;

    • 多评估模型支持能力。

  • 定义容量评估标准,实现容量评估低成本、常态化

    • 容量评估配套基础能力横向打通,提供丰富数据构造、基础组件改造、容灾等基础能力支持 ;

    • 研发流程相结合,提供容量评估管控能力支持,实现容量评估同时覆盖左移、右移;

    • 容量评估标准化、流程化,如基础组件改造统一、数据构造标准化、业务评估模板定制化。

业务背景

活动背景及目标

基础信息:双11 历来都是电商业务年度重要的节点,技术侧整体目标对系统架构也一向有很高的要求。

活动策略

fde4b949b190dc8b800d9f593e19bba4.png

核心挑战

业务容量保障流程长

本次电商大促活动,Rhino 全链路容量保障方案从活动前、活动中、活动后三个活动时段,拆分 7 个阶段围绕:流量预估、压测、限流、资源、风险巡检等Topic 展开。

6f45c1bd4bf6bbf357524b4fba63a6c3.png
链路复杂、业务规模大

2023年双11 大促涉及链路覆盖上下游:上千个服务, 几十个子域场景 & 几百个核心接口全链路压测 & 入口千万级QPS发压。

  • 复杂场景下资源预估多组件覆盖技术挑战

    • 单一模型无法适用所有组件类型和业务场景,针对不用业务场景和组件需要构建不同模型;

    • 预估效果影响因素众多:除受算法模型影响之外,还跟不同类型组件设计架构、业务工程策略等因素有关。

  • 容量治理能力初次在电商活动中实践落地

    • 变化:由于双十一活动业务目标的提升,业务在容量治理方面提出了更高的要求;过去常态容量保障基础上增加了:核心服务极限容量探测、强弱依赖集群拆分验证、线上性能防劣化等容量治理诉求。

    • 挑战

      • 容量治理基建能力 —— 线上切流巡检,在稳定性、安全性上需要深度契合电商业务场景,不能引起线上切流资损或事故;

      • 容量治理在问题召回及问题分析方面,数据标准化缺失,需平衡对齐多方标准提升业务认可度。

容量保障方案介绍

活动前流量预估

流量预估架构
73b6dc567e681460a315cd9e46ec1695.png
详细说明

对业务所有接口进行逐个分析,将流量预估方案细化到单接口的维度上;在此过程中,我们主要考虑接口流量来源、接口流量的影响因素以及接口之间的关联性。接口流量预估主要可以分为两部分:

  • 大促基础流量:分为以下几种类型采用不同方式进行流量预估

    • 背景流量:流量不随大促活动变化,仅作为背景流量补充,因此可以直接取近期线上最高流量水位;

    • 模型预估接口:流量随电商直播间PCU、直播间进房量等业务指标变化而变化,而大促期间这2组指标数据量波动较大,这类接口受大促影响较大。我们对这类接口进行流量数据建模并沉淀出日常流量模型、大促流量模型作为后续流量预估基准;

    • 重保接口:业务方往往有强诉求,其流量目标比通用模型估出来的结果要高,对标历史大促的最高峰值流量,比如交易提下单、购物车、福袋抽奖等模块。

  • 高热直播间核心接口增量流量:这类接口容易受主播达人的口播内容、玩法策略影响产生尖刺流量,因此需要使用更精细的秒级数据进行建模。

流量预估模型
c81880fee160ec7119e70dd3d3faa8ba.png

活动前资源预估

目标

电商上下游依赖上千服务,人工估算难以准确推算整体资源部署情况;Rhino 资源预估能力借助拓扑流量估算、排队论等算法,自动化估算全链路资源缺口。

资源准备流程
0a924c742b4b513c3f3df773951b8aef.png
  • 业务目标明确: 在开始流量预估前,借助关注的往年及今年核心整体数据(例如 DAU、GMV、PCU),提供统一的参考基准;不同阶段会综合考虑多层面影响因素,例如:

    • 大促活动目标交易额预估:往届、本届大促成交量目标 GMV 及头部主播实时在线人数 PCU 变化等;

    • 子域链路目标流量预估:拆分至营销、直播间等子域链路目标流量;

    • 具体到接口流量的预估:业务逻辑、调用关系、流量增幅等。

  • 入口服务流量估算:业务目标及转化关系明确后,完成入口服务流量预估。

  • 资源预估入口导入: 流量预估完成后通过填写或导入的方式,开始活动资源预估

    • 参考历史时段:选择参考线上的 QPS、CPU 等指标日期,以及链路拓扑时间区间;

    • 预期 CPU 利用率:选择大促活动各组件目标资源水位;

    • 2e293e96000b95cf3bbc2444cfed5476.png

资源预估方案
d803c2edd34b7f697f31d7ba1ecedbd0.png
  • 业务冗余度注入:基础组件资源预估往往需要考虑更高维度指标(磁盘 IO、链接池、主从同步等);从业务评估灵活度考虑,流量冗余度参考计算的方式,能够弥补业务其他角度补充预估考虑。

  • 模型细分构建:综合各组件架构、部署特性、工程策略等因素进行容量模型建模,为各类基础组件提供资源预估数据,例如下面典型组件核心影响因素差异。


MySQL( RDS) Redis Abase ByteKV ByteGraph ...
系统架构 Proxy - Server Proxy - Server Proxy - Server Proxy - Server Proxy - Cache - Server ...
数据存储方式 单机 + 分片存储 分片存储 分片存储 分片存储 分片存储 ...
部署方式 物理机 + 同规格容器 + 不同规格容器 同规格容器 同规格容器 同规格容器 同规格容器 + 不同规格容器 ...
容灾方式 主从容灾部署 主从 + 分机房容灾部署 主从 + 分机房容灾部署 分布式部署 分布式部署 ...
存储资源依赖 磁盘 磁盘 磁盘 磁盘 依赖 ByteKV 存储 ...
资源申报方式 CPU + 存储分离申报 合并存储资源申报 按分片量申报 按分片量申报 按分片量申报 ...
  • 读写流量分离:基础组件主从实例流量与读写流量存在特殊分布关系,例如 Redis 主从实例流量架构:

b2304a5aae51827f344177c5dd8b49b9.png

Rhino 预估方案聚焦此架构特点,在流量预估阶段按照部署架构分离读写流量至对应实例。

  • 存储资源预估:Rhino 预估方案从资源瓶颈和申报口径两个视角切入评估存储资源

    • 存储资源增长趋势预测:预测存储资源剩余使用天数,辅助业务评估存储瓶颈;

    • 资源申报汇总:结合计算资源、存储资源,汇总资源申报建议数据。

注:业务完成资源预估后,即可根据预估数据进行资源填报和进行资源交付,从而完成资源准备工作。

活动前容量治理

容灾链路性能摸底
  • 目标:为了活动保障电商核心链路稳定性,业务会通过‘重保集群’拆分对链路进行隔离,达到容灾目的;拆分集群的性能有一致性要求,在这个过程中我们需要辅助业务对容灾链路进行性能摸底。

  • 实现方案

72b9fbfbe09741af5c1e65705490502b.png
  • 任务&环境创建:自动化的构建泳道环境创建,支持集群间业务真实流量切流,低成本实现单pod性能压测。

  • 服务接入完成后,业务需要确立服务性能摸底目标并调试任务运行,以保障泳道切流能够成功运行并达成业务目标,主要工作包含:

    • 切流负载目标:评估服务性能摸底 CPU 负载目标;

    • 持续时间:泳道切流运行持续时间,及步长;

    • 熔断配置:保障线上流量安全性,进行错误率、时延等任务熔断配置。

  • 确立性能摸底目标后,即可开始泳道切流任务,通过服务端监控观测性能差异,从而判断性能基本状态。

限流治理
  • 背景

    • 电商大促活动中,流量增长往往超出限流配置预期,易出现限流误命中造成流量损失;

    • 电商大促容量验证期间,限流误命中会导致容量验证目标无法达成,影响验证效率。

    • 过往电商链路由于流量异常增长等情况未配置有效限流造成事故占比较高,所以电商业务针对全链路服务进行通用限流建设,限流覆盖率达到 80%+;

    • 限流覆盖率增高,误限流导致的问题同样增多,影响场景如下:

  • 限流前置治理目的

    • 通过前置限流治理,提前暴露风险,并提高整体容量评估效率;

    • 为保护系统热点状态稳定性,针对业务核心接口进行限流全覆盖及L0&L1 核心链路覆盖;

    • 为保护基础组件稳定性,针对热点直播间、热点商品进行独立限流,并通过子域压测检验可用性。

  • 限流治理类型和方案

0b97f07ab296f5a7584666c68b876024.jpeg8b18f7bf95b10d6cba2a592761d3bbe8.jpeg

  • 限流风险排查

    • 结合Rhino 限流瓶颈预估组件,定期全链路扫描潜在限流风险;

    • 组织业务侧限流风险review,检验限流设置合理性,并在活动保障中取得正向价值;

    • 同时,针对异常报警数据建立了自动化追踪机制,持续协助业务侧高效排查风险案例。

活动前全链路压测

业务全链路压测实施要点

整体来讲,电商业务全链路压测包括2个阶段:子域压测、C端全链路压测。

  • 子域压测意义

8e2e25d098e7d2b15e8a37560a4b7818.png

全链路压测之前需要各个业务模块提前进行子域压测,重要性如下:

  • 流程效率:

    • 大促机器资源到位时间较晚,等全部资源到位之后再开始压测时间会来不及,因此可以在部分机器资源到位之后就开始做子域压测,先把子域容量压到预期;

    • 全链路压测的定位是整体容量验收,原则上需要各个子域确保自己没有容量问题了才能参加。避免全链路压测的时候因为某个子模块容量问题而反复回归与验证。

  • 压测仿真度:全链路压测无法保证100%仿真,流入到某些子域的流量可能不及预期,需要在子域压测中覆盖。

  • 覆盖场景:

    • B端场景覆盖:B端目前没有全链路压测,只能依靠子域压测来覆盖;

    • 极端场景覆盖:某些极端场景(比如秒杀场景、热点直播间场景等)在子域压测中覆盖,全链路压测更多的起到端到端容量评估和验收的作用;

    • 横向玩法场景覆盖:某些横向玩法和C端核心接口的链路耦合性不强,适合通过子域压测单独覆盖。

  • C 端全链路压测

3cd720d985df5441255e23e6a3888c5c.png

C端全链路压测定位是端到端容量评估和验收,因此在子域压测完成之后进行。

全链路压测技术支撑
  • Rhino 经过多年来字节各大活动容量保障支持,沉淀打磨下完整稳定容量验证平台化能力,自2022年以来验证环节中具体步骤不再需要 Rhino 过多介入;故为了保证全链路容量验证预期目标,Rhino 与业务方分工:

    • 业务侧:主导容量验证各环节,包含:链路梳理、数据构造、任务创建、调试、执行等;

    • Rhino:架构师角色参与关键方案评审,主要评估、协调验证环节风险点。

  • 容量验证方案

87ecc02f968835b1c277e56c339ec40b.png
  • 前置准备支持: 辅助服务改造、场景梳理、数据准备以及相关知识培训,为正式开展容量验证做准备

    • 服务改造:包括存储改造、登录鉴权改造、时间穿梭改造;

    • 场景梳理:梳理接口逻辑、调用下游情况、不同参数不同链路情况;

    • 数据模型构造:包括那些参数需要替换、替换方式确定、铺底数据如何构造。

  • 资源、上下游风险评估:保障容量验证流程、目标顺利完成,及上下游依赖方系统安全性:

    • 业务验证规模报备:峰值 QPS、使用场景、预期发压引擎资源、Mock 量级、改写数据量级等;

    • 发压引擎资源预估、部署(优化中):通过发压引擎模型,预估预期使用资源量;

    • 上下游风险评估:评估录制、改写、Mock、压测服务账号等上下游风险点,并同步依赖方。

  • 平台基建保障: 提供平台稳定性保障,保证活动顺利进行。

  • 全链路压测执行: 目前业界对全链路压测的实施成熟方案已经很多,并且在我们过去的技术文章中有过介绍(https://mp.weixin.qq.com/s/vofrpFGvnptj3MNAv1hQ-w),本文不再赘述。

活动中容量巡检

业务自建链路巡检
  • 电商系统庞大复杂,这么多指标,哪些才是重点?业务会构建自己的系统监控大盘。

    • 呈现核心业务指标、核心链路及关键系统指标、机房网络、基础组件监控;

    • 其中核心业务指标以L0链路为主体,汇总SRE及各域稳定性同学共同决策出电商及各域结果指标,作为大促时最关注的指标之一;

    • 同时监控与告警联动,当有指标异常时,会出现红色提示标识,并出现告警信息弹窗,以快速发现问题。

269de043e83bbfac627246d998a659ee.png
  • 只看重点还不够,如何看全、看细?子域监控大盘。

    • 提供子域监控能力,用于构建子域业务及系统链路,双十一前完成各个子域大屏接入;

    • 类似,子域大屏与告警打通,如有异常会进行染色及标注提示,辅助业务快速发现子域内的问题;

    • 同时与业务之前自建的Grafana等大盘结合互补,从电商整体的角度,看全看细。

基于模型多组件容量巡检
  • 电商大促活动依赖的下游上千个,根据核心链路的强弱梳理,电商强依赖组件有十几种类型、几百个部署集群,且容量评估维度较多,依赖人工评估+巡检耗时巨大,容易遗漏。那么如何进行容量保障?引入自动化巡检提升效率和精准度。

  • 由以下几个步骤来分阶段实现自动化:

d4a3cc4bfb11f819e8b6acd349159ac8.png

当前Rhino 提供的容量预估与巡检覆盖了主流在线计算相关组件,覆盖电商96%+核心链路服务,风险拦截准确率达到 90%以上。

活动后缩容

整体来讲,活动结束后会进行:容量调整和缩容验证。

6b02df09e2030320a522ccaf4661303e.png
容量调整
  • 容量建议:容量调整的输入,是基于活动前预估数据进行操作,通常无需重新计算;

  • 容量调整:需要进行计算资源和相关配置等项的恢复。

缩容验证
  • 目的

完成活动资源回收目标后,从电商链路稳定性角度考虑,需要进行一轮电商全链路压测进行验证,确保缩容后服务稳定性。

  • 基于调度的自动化验证方案

c32c65e6ab5d925d986e947c5375b361.png

本次电商双11 活动中,基于调度的全链路自动化压测方案投入使用,主要优势如下:

  • 通过生产流量的调度,使得流量仿真度及下游服务触达率可以得到更有效保障,最终取得效果:压测仿真度100%;

  • 切流操作步骤和压测执行过程中监控、分析等核心环节实现了高度自动化,异常检测分析和止损效率有可靠保障,最大限度的降低了整个压测活动的实施成本。

总结与展望

自2022年初以来,Rhino 成功完成了从压测 到容量产品转型,围绕业务全流程容量保障构建了基础能力支撑体系;这个期间,非常感谢公司内所有合作团队,尤其是基础架构各兄弟团队、中台团队对Rhino 的支撑,没有他们的支撑,整个容量评估体系难以完成的。

未来计划

  • 多模态容量模型持续探索

    • 继续围绕业务场景,构建基于:成本 - 流量 - 容量 模型分析能力,以及结合容量验证客观数据反馈,不断提升数据质量;

    • 同时推进模型精细化训练,例如:集群、接口、时间、单指标等维度模型训练粒度。

    • 持续探索基于模型容量评估解决方案(排队论、深度学习、LLM等),不断推进组件类型和业务场景低成本覆盖;

    • 容量模型预测、分析准确率持续提升:

  • 面向业务完整容量解决方案SOP 构建

    • 继续提高全链路容量评估流程完整度,实现:容量评估、决策、处理、反馈闭环能力建设,面向业务构建全流程容量保障SOP;

    • 细分业务场景SOP建设,例如容量基础能力在业务容量成本优化过程中提供系统解决方案。

  • 常态化容量评估能力建设

    • 常态运维能力建设:极致弹性运维、应急/自愈等核心能力强化;

    • 全链路容量评估常态化运行,实现服务形态全新升级。

招聘广告

Rhino 属于字节跳动架构体系下QE 团队,致力于建设字节跳动完整全链路容量评估生态体系和标准,提供覆盖容量预估、容量分析、容量验证、容量治理及容量运维等完整容量保障解决方案,以及低成本、安全、准确的容量评估支持,覆盖场景包括大型活动、技术升级验证、常态化容量评估等。

  1. 打造字节跳动全链路容量评估体系,负责平台架构设计和研发工作,持续保证系统高可用,为亿级DAU产品提供技术支撑;

  2. 持续优化容量模型,优化全链路容量评估活动实施成本,为业务方降本增效赋能;

  3. 研究软件工程学术前沿,并结合业务场景进行探索落地,引领公司和业界技术潮流。

职位要求:

  1. 本科及以上学历,计算机、软件工程及相关专业;

  2. 热爱计算机科学和互联网技术,精通 Go/Python/Java/C++ 等至少一门编程语言,有良好的编码能力和风格;

  3. 熟悉Linux,对容器化、云原生架构、网络协议、文件系统等相关领域有一定了解;

  4. 良好的后端架构设计能力,具备分布式、高并发开发经验;

  5. 对问题有清晰分析和全局思维,能提出具有创造性的解决思路和方案;责任心强,有良好的对外沟通和团队协作能力;

  6. 具有全链路压测、机器学习、存储组件研发经验者优先。

目前团队【后端开发工程师】和【算法工程师】紧缺,非常期待感兴趣的同学加入。

  • 微信联系:duguxiaolong11;

  • 简历投递邮箱:gaoyujun@bytedance.com(邮件标题:姓名 - 工作年限 - Rhino )。

  • 帮转【前端工程师】招聘:https://zjsms.com/i8jXQcAM/

西瓜视频RenderThread引起的闪退问题攻坚历程

背景

影响

西瓜之前存在过一类RenderThread闪退,从堆栈上看,全部都是系统so调用,给人的第一印象像是一个系统bug,无从下手。闪退集中在Android 5~6上,表现为打开直播间立即闪退。该问题在2022年占据Native Crash Top5,2023年更是上升到到Top1。因此有必要投入时间和精力再重新审视一下这个问题。在历经多周的源码分析和排查后,逐步明确了问题根因并修复,最终取得了显著的稳定性收益和业务收益。

接下来,我们将抽丝剥茧,一步步深入分析这个历史遗留问题,揭开它背后真正的原因。

基本信息

具体堆栈如下:

12a06dd4db3d8419cc772fea9c608541.png

堆栈都是系统的so调用,不能明确具体闪退业务场景,只能看出是RenderThread线程主动abort了。

根据abort message找到对应的abort代码,在CanvasContext::requireSurface时闪退了,代码如下:

ac6433511b6b3180aa910155f9dd0007.png

问题特征:

问题集中在Android 5.0~6.0,线程集中在RenderThread,无明显机型、厂商特征。

RenderThread简介

为了便于理解下面的分析过程,先对RenderThread简单的介绍。顺便看一下是怎么调用到CanvasContext::requireSurface的。

相关类图如下:

87b7ece3aec66a4aa298273ddb9df7df.png

相关源码:frameworks/base/libs/hwui/renderthread

RenderThread::threadLoop

RenderThread继承自Thread和Singleton,是一个单例模式的线程,通过RenderThread.getInstance()获取。和主线程很像,内部是一个通过for实现的无限循环,不断从TaskQueue里通过nextTask函数获取RenderTask并执行,RenderTask执行完后会按需调用requestVsync。核心代码在threadLoop函数中:

6a42fc716192a7714ce7f0a670287ee6.png

ThreadedRender

Java层通过ThreadedRender与RenderThread进行通信。当Window启用硬件加速时,ViewRootImpl会通过HardwareRenderer.create()创建一个ThreadedRender实例。ThreadedRender在创建时,会调用nCreateProxy在native层创建一个RenderProxy。ThreadedRender通过RenderProxy向RenderThread提交任务。

dee555bbfd540fee585c11c7f8fb7646.png

RenderProxy

RenderProxy在创建时,会同步创建一个CanvasContext,再通过RenderThread.getInstance()拿到RenderThread实例。RenderProxy通过CREATE_BRIDGE定义了许多Bridge函数,再通过SETUP_TASK把这些Bridge函数包装成RenderTask,再通过postAndWait提交给RenderThread调用。postAndWait之后,当前线程进入等待状态,当对应的task执行完毕之后唤醒当前线程。以RenderProxy::createTextureLayer为例:

b4256c051d590169b1115fcd06caee56.pngd5e9a71a89e2f1635a75fe6502140bf5.png

CanvasContext

RenderProxy把任务提交给RenderThread之后,执行的实际上是CanvasContext::createTextureLayer,就是在这里调用了requireSurface。

9f90f322cef5eb304ad5822656eb7a29.png

初步猜想

其他 App 相似问题修复

其他端App也曾有过'requireSurface() called but no surface set! '相关闪退。原因是:在Activity进行侧滑退出时,侧滑框架需要强制对下层Activity进行绘制生成Bitmap,再用这个Bitmap来实现Activity的切换效果。但由于下层Activity此前已处于不可见状态,可能有业务层主动释放了下层Activity中的TextureView,导致了no surface set的闪退。经过对西瓜的侧滑框架的源码分析,发现不会产生此类问题,因此西瓜的问题应该另有其因。

正面分析西瓜问题

问题的条件是mEglSurface == EGL_NO_SURFACE,看下mEglSurface赋值为EGL_NO_SURFACE的时机。

总共有两处:

第一处:CanvasContext::setSurface

这里总共两处mEglSurface赋值操作。一处直接赋值为EGL_NO_SURFACE,另一处为mEglManager.createSurface的返回值。而mEglManager.createSurface在返回前判断如果是EGL_NO_SURFACE会主动abort,显然createSurface的返回值一定不是EGL_NO_SURFACE。

void CanvasContext::setSurface(ANativeWindow* window) {
    if (mEglSurface != EGL_NO_SURFACE) {
        mEglSurface = EGL_NO_SURFACE;
    }
    if (window) {//不可能返回EGL_NO_SURFACE
        mEglSurface = mEglManager.createSurface(window);
    }
}

EGLSurface EglManager::createSurface(EGLNativeWindowType window) {
    EGLSurface surface = eglCreateWindowSurface(mEglDisplay, mEglConfig, window, nullptr);
    LOG_ALWAYS_FATAL_IF(surface == EGL_NO_SURFACE,"Failed to create EGLSurface for window %p, eglErr = %s",(void*) window, egl_error_str());
    return surface;
}

那么这里根据window是否为nullptr又可以分为两种情况:

  1. setSurface(nullptr)之后,mEglSurface 最终赋值为 EGL_NO_SURFACE,之后调用requireSurface发生abort。

  2. setSurface(window),第5行会先设置为EGL_NO_SURFACE,在第10行createSurface返回之前,此时在另外一个线程调用requireSurface也会发生abort。

第二处:初始值

初始值为EGL_NO_SURFACE。只有调用CanvasContext::setSurface时,mEglSurface 才会被赋值,在此之前,调用了requireSurface也会引发闪退。

class CanvasContext : public IFrameCallback {
    private:
    EGLSurface mEglSurface = EGL_NO_SURFACE;
}

总结下来,有三个时机调用requireSurface会导致闪退:

  1. 多线程并发,mEglSurface短暂为EGL_NO_SURFACE

  2. CanvasContext::setSurface(nullptr)之后,即mEglSurface被销毁

  3. CanvasContext::setSurface之前,即mEglSurface未初始化

深入分析

7.0+系统是如何避免这个问题的?

从多维信息看出,问题在6.0及以下版本发生。那么7.0上系统做了哪些优化,是如何规避我们上面三种可能的情况的?这些优化思路对我们解决问题能否提供帮助?

对比6.0和7.0代码之后,发现谷歌直接把requireSurface这个方法移除了!

逐个翻看6.0~7.0上RenderThread相关的commit,最终找到了这个commit(8afcc769)。这里确实是把requireSurface删除了,并在createTextureLayer中调用了一下 mEglManager.initialize()。而EglManager::initialize里的实现,是执行下EglManager的初始化,这里跟6.0基本一致。

f7771d130dcd9b1a4cbca0a84425585d.pngc1b49f99461b799cbbe37aba4ed5d4aa.png5d646cbe5cf5c010c2a932eb862ac47b.png

那6.0上的abort原来是可以直接删掉的吗?如果是这样,我们是不是可以有样学样,尝试hook requireSurface,把abort去掉,再主动调用一下mEglManager.initialize,从而达到这个commit相似的修复效果?

在云真机上找了个6.0的设备,把libhwui.so pull下来,通过 readelf -sW libhwui.so查看requireSurface的符号。发现,没有requireSurface的符号。解开so后发现,requireSurface被inline进了createTextureLayer:

7aa30c30708a687feb96b12e7756e5dd.png

再尝试一下hook requireSurface的上一层调用CanvasContext::createTextureLayer,发现也没有对应的符号,只有RenderProxy::createTextureLayer 的符号。如果采用7.0的修复方案,需要修改的指令非常多。而且,这个MR中还有其他改动,要不要一起hook?这些问题暂时还没搞清楚,花大力气去做的话风险太高。

c81a73615a8dd8321ec30e7e1bbd5b41.png

看起来,参考7.0修复方案操作难度大,并且不确定是否有效。我们先把它作为备选方案,另谋出路!

正面分析三种可能性

多线程问题?

CanvasContext的createTextureLayer和setSurface相关代码,都被RenderProxy转移到了RenderThread上执行,而RenderThread又是单例的,所以这里不存在多线程问题,因此可以直接排除线程并发问题

setSurface(null)导致?

前面分析过,setSurface(nullptr)也会导致EGL_NO_SURFACE。下面是ThreadedRender一个大概的调用时序图,把几个可能产生EGL_NO_SURFACE的setSurface调用做了标记,序号24就是的闪退函数requireSurface。时序图:

1f07fdaba6e940ceb83bb564f0dfcf1c.png

可以看出在CanvasContext中,setSurface的调用有:initialize、swapBuffers、makeCurrent、updateSurface、destory、~ConvasContext。对应的java层调用为ThreadedRender中的的initialize、draw、createTextureLayer、updateSurface、destory、finalize方法。排除一些不会出现异常的方法:

排除ThreadedRender.initialize

initialize时java层传过来的surface做了判断保护,可以确保surface不为nullptr,因此可以排除initialize,代码如下:

2ec574b41a9c2076bc05f2e5c68a9b65.png

排除ThreadedRender.draw

draw对应的问题是swapBuffers失败。发生在swapBuffers失败时,也就是eglSwapBuffers错误了。

170a268af064e522c308c656868ef713.png

系统有以下两种方式处置错误:

  1. EGL_BAD_SURFACE:打印失败日志。但翻看多个跟踪日志,均没有发现类似日志,暂时不关注。

  2. 其他EGL错误:直接abort掉,此时变成另外一个闪退,因此可以排除。

综上,对于swapBuffers失败这种情况可能存在,但未发现相关报错日志,暂时不作过多关注。相关代码如下:

759740d50a56aefa615bb592339f214a.png

排除ThreadedRender.finalize

ThreadedRender.finalize之后,会在native层通过delete 释放RenderProxy和ConvasContext,在~ConvasContext析构时调用setSurface(nullptr)。因此,如果之后再调用requireSurface,应该会发生SIGSEGV相关错误,不可能出现surface not set异常。因此,也可以排除掉。代码如下:

50e9ee00f0117ce8b283ef65307eb4f5.png4ae0c9af04b8af5eb159f7c18896fe1c.pngd9ce31a6e277e4ba9afdf5e8ffcd24f5.png

剩余情况

排除了intialize、swapBuffers、finalize之后,还剩下makeCurrent失败、updateSurface(nullptr)、destory都有可能产生问题,上游调用比较多追踪起来依然比较困难,暂时无法排除。

初始化前触发了requireSurface?

一般来说,setSurface的首次调用是在initialize中。那么,如果在initialize之前就调用了requireSurface,是不是就会出问题呢?从前面的分析可以看出,requireSurface的上游是java层的createTextureLayer,而createTextureLayer的调用处只有一个,在TextureView的getHardwareLayer中。

619380dac2a7ec4d346f15d086ba2261.png

getHardwareLayer是View的一个方法,默认返回null。从注释上也能看出,在6.0上只有TextureView用到了这个方法,调用处也只有移除在getBitmap中。在7.0上也是直接把getHardwareLayer从View中移除了,变成TextureView的一个方法。而getBitmap是个public方法,这里是可以被app调用到的。

8f97c4defe0942d5f2bd51a5ee2091c5.png8556bb2c64c7c54f25c609fde02f7956.png

闪退的前提条件:

  1. ThreadedRender.initialize还未调用

  2. ThreadedRender.destroy或者ThreadedRender.updateSurface(null)之后

在Java层触发requireSurface步骤如下:

  • TextureView.getBitmap => ThreadedRender.createTextureLayer => ConvasContext::requireSurface

最终定位

验证分析结论

通过前面的分析,找到了问题的前提条件,并发现了一条触发requireSurface的方式。那么,就可以结束纸上谈兵,通过实操来在本地复现这个闪退,来实锤前面的结论。

ThreadedRender.initialize还未调用

由于ThreadedRenderer不是公开api,需要通过反射来创建实例。拿到实例后不调用其intialize方法,直接反射调用createTextureLayer。代码如下:

5de947166067017105569e9957156adb.png

果然,复现了'requireSurface() called but no surface set!' 这个问题:

8a2380a1eccd3d8fceef0e90542ce02c.png

destroy或updateSurface(null)

通过反射创建ThreadedRender实例,先执行ThreadedRender.intialize,之后调用destroy或updateSurface清空surface,最后调用createTextureLayer,也成功复现了这个闪退。

业务场景定位

前面的复现,只是从技术层面确认了问题发生的几种可能,但还没有与业务场景关联起来。真实的问题是否在前面提到的这几种可能中间?如果在的话,那具体的调用点在哪,又该如何修复?

尝试在真实场景中复现

通过shaddow hook RenderProxy的Initialize、destroy、updateSurface、createTextureLayer等函数,在hook函数中打印一些日志。由于RenderProxy可能存在多个实例,需要在日志里加上RenderProxy实例的地址来方便追踪单个RenderProxy调用时序。

hook函数如下:

4b8260b8023abd9ecc49073c7f583aa4.png

尽管这个问题在android 6上闪退率比较高,但我用6.0测试机跑自动化测试,还是没有复现这个问题。

线上定位

问题不是必现的,排查进程在线下难以为继。而只有在真实的业务场景中复现,才能明确问题根因,找到最佳修复方案。因此,需要加把这些hook点上线进一步排查。上线无小事,线下可以小步快走,逐渐定位问题。但上线步子一定要稳,不能迈太大。但也不能太小,否则周期会拉的很长。所以,既要保证有足够的信息排查,也要尽可能的降低稳定性和性能影响。

明确业务堆栈

前面的hook方案只能确认真实业务场景是否也有调用时序问题,并不清楚具体的业务调用堆栈。

从业务上层代码到异常点,必然经过了ThreadedRender的Initialize、destroy、update、createTexture等方法,那么通过java hook把这些方法hook住,并打印堆栈应该就定位到业务代码。需要注意的是:

  1. 稳定性问题

Java hook 目前主流的方案中还没有能达到线上大规模使用的水平,只能小流量观察。

保障方案:系统版本限制在6.0,放量计划1%->5%->10%,小流量观察。

  1. 性能问题

由于这些api调用频率可能很高,也都在主线程,直接打印堆栈会影响性能。真正需要关注的就是异常前的几次调用,且有些case可以通过以下条件预判,其余的堆栈都不必要甚至是干扰信息。需要关注的堆栈如下:

  1. initialize:不需要堆栈。只要知道有没有调用过,打印一行日志即可。

  2. destroy:必须全部打印堆栈。发生在异常前,无法预判过滤。

  3. updateSurface:surface==null时打印堆栈。surface不为null不会导致异常。

  4. createTextureLayer:未初始化或者surface==null时打印堆栈。只有这两种可能会有异常。

总结:可以通过surface是否为null、initialize是否调用过这两个条件减少stackTrace。

在ThreadedRender中,surface都被透传给了native层,没有对应的Java引用,需要手动维护一个java 层的实例。初始化状态可以通过反射ThreadedRenderer.mInitialized拿到,不过既然已经hook intialize和destroy了,这里也选择手动维护一个初始化状态,毕竟可以减少一次反射调用。

public class ThreadedRenderer extends HardwareRenderer {
    private boolean mInitialized = false;
        
    @Override
    void updateSurface(Surface surface) throws OutOfResourcesException {
        updateEnabledState(surface);//透传给了Native层,Java层没有引用
        nUpdateSurface(mNativeProxy, surface);
    }
}

Java hook伪代码如下:

public static class ExtraInfo {
    private boolean isSurfaceNull = true;
    private boolean mInitialized = false;
}

static Map<Object, ExtraInfo> infoMap = new ConcurrentHashMap<>();

private static ExtraInfo extraInfo(Object instance) {
    ExtraInfo threadedRendererInfo = infoMap.get(instance);
    if (threadedRendererInfo == null) {
        threadedRendererInfo = new ExtraInfo();
        infoMap.put(instance, threadedRendererInfo);
    }
    return threadedRendererInfo;
}
public static boolean initializedHook(Object instance,Surface surface) {
    extraInfo(instance).mInitialized = true;
    return (Boolean) Hubble.callOrigin(initializeHookEntry, instance, surface);
}

public static void destroyHook(Object instance) {
    infoMap.remove(instance);
    Log.d("REPAIR", "destroy", new Throwable());
    Hubble.callOrigin(destroyHookEntry, instance);
}

public static void updateSurfaceHook(Object instance, Surface surface) {
    extraInfo(instance).isSurfaceNull = surface == null;
    if (surface == null) {
        Log.d("REPAIR", "updateSurface null ", new Throwable());
    }
    Hubble.callOrigin(destroyHookEntry, instance);
}

public static void createTextureLayerHook(Object instance) {
    ExtraInfo extraInfo = extraInfo(instance);
    if (extraInfo.mInitialized || extraInfo.isSurfaceNull) {
        Log.d("REPAIR", "createTextureLayer null ", new Throwable());
    }
    return Hubble.callOrigin(createTextureHookEntry, instance);
}

线上日志

上线后成功采集到了关键的Java调用堆栈!基本都集中在直播业务场景下,初始化前调用requireSurface。也有一些零星的destroy之后requireSurface的case,由于量级太小本文不做重点讨论。

ThreadedRender.Initialize之前

日志截图如下:

68f02ab211eadf3fd5d65c0f6582fd29.png

调用时序问题确认:

对于地址为0x814a0b0的RenderProxy实例,没有它intialize相关调用日志,只有一条createTextureLayer调用日志。可以明确,这个RenderProxy实例是在initialize之前调用createTextureLayer导致闪退!

Java 堆栈分析:

Log.d会对过长的堆栈进行截取,FrameLayout.onMeasure之前的都被截取了,不过对于排查问题,影响不大。

堆栈关键信息整理如下:

  1. 闪退时正在执行onMeasure

  2. 在com.bytedance.android.livesdk.chatroom.ui.LivePlayerWidget.loadSharedPlayer中调用了TextureView的getBitmap方法,再到ThreadedRender.creatTextureLayer,之后就发生了Native crash。

为什么没有intialize?

onMeasure会早于ThreadedRender.initialize执行吗?

ThreadedRender.initialize和performMeasure相关的代码都在performTraversals中,再次回到源码中去分析。从代码结构来看,initialize前后都有measure相关操作。initialize之前通过measureHierarchy调用了performMeasure,initialize之后是直接调用performMeasure。由于measureHierarchy外部包了许多判断条件,所以不能直接从代码行的上下关系,得出measure早于initialize的结论,但我们可以保持这个怀疑进一步验证。

这个方法过于巨大,移除无关代码后如下:

private void performTraversals() {
    if (mFirst) {
        mLayoutRequested = true;
    }
    
    boolean layoutRequested = mLayoutRequested && (!mStopped || mReportNextDraw);
    if (layoutRequested) {
        windowSizeMayChange |= measureHierarchy(host, lp, res, desiredWindowWidth, desiredWindowHeight);
    }

    if (mApplyInsetsRequested) {
        if (mLayoutRequested) {
            windowSizeMayChange |= measureHierarchy(host, lp, mView.getContext().getResources(), desiredWindowWidth, desiredWindowHeight);
        }
    }
    if (mFirst || windowShouldResize || insetsChanged || viewVisibilityChanged || params != null) {
        if (!hadSurface) {
            if (mSurface.isValid()) {
                if (mAttachInfo.mHardwareRenderer != null) {
                    hwInitialized = mAttachInfo.mHardwareRenderer.initialize(mSurface);
                }
            }
        }

        if (!mStopped || mReportNextDraw) {
            if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight() || contentInsetsChanged) {
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }
    }
}

private boolean measureHierarchy(final View host, final WindowManager.LayoutParams lp,
                                 final Resources res, final int desiredWindowWidth, final int desiredWindowHeight) {
    boolean goodMeasure = false;
    if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
        if (baseSize != 0 && desiredWindowWidth > baseSize) {
            performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            if ((host.getMeasuredWidthAndState()&View.MEASURED_STATE_TOO_SMALL) == 0) {
                goodMeasure = true;
            } else {
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
                if ((host.getMeasuredWidthAndState()&View.MEASURED_STATE_TOO_SMALL) == 0) {
                    goodMeasure = true;
                }
            }
        }
    }
    if (!goodMeasure) {
        performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
    return windowSizeMayChange;
}

由于堆栈被裁剪了,无法确认异常是从哪个分支过来的。不过没关系,注意到当mFirst=true时,满足layoutRequested = true,会先调用执行measureHierarchy,可以在本地模拟mFirst=true这种情况,即可验证。

本地通过onMeasure复现

本地写个demo,在FrameLayout.onMeasure中立即调用TextureView.getBitmap,并通过反射查看mFirst的值,找个6.0的云真机验证一下。onMeasure会连续执行多次,只有第一次的mFirst为true,但没能复现问题,代码如下:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    boolean first=reflectFirst();//反射获取mFirst
    activity.log("mFirst=" + first);
    Bitmap bitmap = mTextureView.getBitmap(mBitmap);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

再来看下mTextureView.getBitmap的实现,

public Bitmap getBitmap(Bitmap bitmap) {
        if (bitmap != null && isAvailable()) {
            if (mLayer == null && mUpdateSurface) {
                getHardwareLayer();
            }
        }
        return bitmap;
    }

    public boolean isAvailable() {
        return mSurface != null;
    }
    
    public void setSurfaceTexture(@NonNull SurfaceTexture surfaceTexture) {
        mSurface = surfaceTexture;
    }

可以看到,要想执行到ThreadedRender.createTextureLayer还需要满足以:isAvailable()为true,手动调用一下TextureView.setSurfaceTexture就可以满足。

根据猜想,再次编写代码终于复现成功! demo如下:

mTextureView.setSurfaceTexture(new SurfaceTexture(0));

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    boolean first=reflectFirst();
    activity.log("mFirst=" + first);;
    mTextureView.getBitmap(mBitmap);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

尝试只在mFisrt=false时执行getBitmap,再次运行,不崩了。可见异常的关键条件就是mFirst!

if (!first) { //没问题
    mTextureView.getBitmap(mBitmap);
 }

问题根因

梳理下整体流程。ViewRootImpl首次performTraversals时(mFirst=true),onMeasure会早于ThreadedRenderer.initialize。而业务方在onMeasure中又调用了TextureView.getBitmap,最终在native层会调用CanvasContext::requreSurface。由于还没有执行过CanvasContext::initialize,当前mEglSurface为EGL_NO_SURFACE,于是在Android5~6上触发了abort,发生surface not set的异常。

总结起来:在android6.0上,ViewRootImpl首次performTraversals时,如过在onMeasure中调用了TextureView.getBitmap,就可能会发生这个异常。

线上还存在一些零星的destroy之后requireSurface、swapBuffers失败后requireSurface的异常,由于排查思路大同小异,这里就不展开说了。

修复方案

通过字节码插桩全局替换TextureView.getBitmap方法,当ViewRootImpl.mFirst=true时,就返回默认值而不执行getBitmap原有逻辑,这样就不会调用到ThreadedRender.createTextureLayer。

但由于mFirst只能通过反射获取,这可能会影响performTraversals性能,有没有性能更好的方案?

通过代码分析,发现performTraversals会经历layout阶段,而layout之后View会增加一个PFLAG3_IS_LAID_OUT:

/**
* Flag indicating that the view has been through at least one layout since it
* was last attached to a window.
*/
static final int PFLAG3_IS_LAID_OUT = 0x4;

public void layout(int l, int t, int r, int b) {  
    mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
}


public boolean isLaidOut() {
    return (mPrivateFlags3 & PFLAG3_IS_LAID_OUT) == PFLAG3_IS_LAID_OUT;
}

因此,可以通过isLaidOut ()获取到这一个属性,达到和mFirst基本一致的效果。

最终方案:

插装替换全局TextureView.getBitmap调用,增加textureView.isLaidOut()判断。

public static boolean isGetBitmapSafe(TextureView textureView) {
    return Build.VERSION.SDK_INT > 23 || textureView.isLaidOut() || !AppSettings.inst().mFerretSettings.autoFixRequireSurface.enable();
}

@ReplaceMethodInvoke(targetClass = TextureView.class, methodName = "getBitmap", includeOverride = true)
public static Bitmap getBitmapHook(TextureView textureView) {
    return isGetBitmapSafe(textureView) ? textureView.getBitmap() : null;
}

@ReplaceMethodInvoke(targetClass = TextureView.class, methodName = "getBitmap", includeOverride = true)
public static Bitmap getBitmapHook(TextureView textureView, int width, int height) {
    return isGetBitmapSafe(textureView) ? textureView.getBitmap(width, height) : null;
}

@ReplaceMethodInvoke(targetClass = TextureView.class, methodName = "getBitmap", includeOverride = true)
public static Bitmap getBitmapHook(TextureView textureView, Bitmap bitmap) {
    return isGetBitmapSafe(textureView) ? textureView.getBitmap(bitmap) : bitmap;
}

修复效果

实验全量后requireSurface 相关crash明显下降,观察两周业务指标没有明显劣化,直播场景有正向收益,符合预期。全量后量级大幅下降,还剩下一小部分主要是老版本、以及一些少量的destroy、swapBuffer失败相关的问题。

业务收益:看播渗透显著提升;人均看播天数显著提升;

稳定性收益:Native Crash大幅下降

84e65b80902c4a424af26c17a6aef58b.pngff68340af3f8188ca69e6090cb1d42b6.png

后续思考

这个requireSurface问题发生在RenderThread,但造成问题的原因在主线程,因此如果能在RenderThread线程发生native crash时抓到主线程java堆栈,就可以定位到业务根因,也就不需要一系列自下而上地代码分析来寻找hook点了。

因此,后续有RenderThread线程异常时,应该把主线程堆栈上报上来,提高RenderThread问题的排查效率。

加入我们

我们是字节跳动西瓜视频客户端团队,专注于西瓜视频 App 的开发和基础技术建设,在客户端架构、性能、稳定性、编译构建、研发工具等方向都有投入。如果你也想一起攻克技术难题,迎接更大的技术挑战,欢迎点击阅读原文,或者投递简历到xiaolin.gan@bytedance.com。

最 Nice 的工作氛围和成长机会,福利与机遇多多,在上海和杭州均有职位,欢迎加入西瓜视频客户端团队 !

文章推荐

客户端架构设计的过程

Android D8编译器“bug”导致Crash的问题排查

Baseline Profile 安装时优化在西瓜视频的实践

使用火山引擎 APMPlus 解决抖音Top 1 Java 崩溃的通用优化方案

背景

近3个月,抖音 Android 版面临一个多次触发线上报警的崩溃问题,全量版本和灰度版本的异常数据激增,该问题不仅容易触发报警,更成为了 Java Top 1 崩溃问题,带来巨大困扰,急需攻坚解决。

本文展现了具体的分析过程、优化思路和解决方案,同时提供了已集成该方案的实用工具。

初步分析

多维特征

我们以某发版期间数据为例进行分析:

  • 机型方面:比较分散,有聚集部分samsung sm-s9180 占比 top1

  • 渠道方面:比较分散,华为、小米、三星占比 top3。

  • ROM方面:比较分散。

  • OS 版本方面:排除古老的 5.1.1 系统,只有 10 以上版本,12/13/10 占比 top3

  • App小版本方面,排除古老的版本,基本跟日活排序一致。

通过对多维特征分析,我们得出结论是:存在 OS 高版本和三星机型聚集特征。

aae682e22056224cc65d240e23e93f44.png

崩溃堆栈

从下图我们看到,崩溃类型 TransactionTooLargeException,崩溃现场在 BinderProxy.transactNative 方法附近,崩溃提示消息 data parcel size xxx bytes,崩溃时 Activity 在向 AMS 传递 stop 信息。初步分析这些信息,我们得出结论:

  • 这是由 Binder 传输数据量超过阈值触发的崩溃。

  • 排除其中无关的动态代理 ProxyInvocationHandler 的业务堆栈,其它都在系统堆栈中,无法直接定位到具体业务方解决。

60f73e51013dd202ff81985408d2d540.png

崩溃日志

除了崩溃堆栈,我们也要分析崩溃日志,这样往往能够从中获得一些有价值的线索。以下,我们分别从聚合 logcat 日志和单次 logcat 日志进行分析。通过 应用性能监控全链路版(APMPlus)App端监控 的 logcat 词云,看到聚合日志 top1 是 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **。这个是系统 Binder 传输数据失败打印的日志。

e6766c8aa8c288022243aee5af3edaf6.png

我们从源码里面找到打印日志的地方,发现是在 Native 层 Binder.cpp 里面,接收到 Binder 驱动的错误码 FAILED_TRANSACTION 之后,先打印错误日志,然后通过 jniThrowException 函数从 JNI 层进入 Java 层抛出异常。

7b7ba11551843fdb6da3eb577320a2c7.png

通过对聚合 logcat 日志分析,我们得到结论:只有系统 Binder 传输失败日志,无业务相关日志,不确定是哪个业务不合理导致的崩溃。根据前面聚合 locat 日志关键字,我们翻看了几十个上报日志,发现在 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **的后面紧接着出现 Tag 为 ActivityStopInfoBundle stats: 日志,其中一个如图所示。因此这时我们知道 Binder transaction 传输的 parcel 内容很大可能跟 Bundle 有关。

从打印的 Bundle stats:树形结构看,只打印了系统的 view 和 fragment 占用 size,没有 App 业务相关占用 size,无法细分是哪里不合理。另外在日志 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = ** 的前面我们终于发现了跟业务相关的 Activity 是 com.ss.android.ugc.aweme.detail.ui.DetailActivity。翻看了几十个上报日志,并不是每个日志都是这个 com.ss.android.ugc.aweme.detail.ui.DetailActivity ,还有 com.ss.android.ugc.aweme.shortvideo.ui.VideoPublishActivity等。

经过对单次 logcat 日志分析,能够从单次 logcat 日志中知道是哪个 Activity stop 时出错,但是目前分析不出这些处于 stop 状态的 Activity 占比情况。

47915aaeb06442fbadb0d19fee0ca3bf.png

综合聚合和单次 logcat 日志看,无明显 App 业务逻辑聚合,只有系统日志,无法简单定位到某个业务解决。

初步结论

经过前面的分析,我们得出初步结论:

  • 问题是 Activity stop 时给 AMS 传输 Bundle 超过 Binder 驱动限制的大小,触发了崩溃。

  • 调用栈和日志无明显业务聚合,无法简单确定是哪个业务引入的问题。

因此,需要进行深入分析,一方面要从监控归因着手排查其中不合理的因素,另一方面也要从底层通用解决方案尝试突破限制彻底解决问题。

深入分析

下面将主要从崩溃堆栈进行深入分析,翻了几十个崩溃堆栈,我们发现并不是只有 Activity stop 调用栈,还有 Activity start 调用栈,主要包含两类,一个是 activityStopped 关键字的崩溃,占比约 97.25%;另一个是 handleStartActivity 关键字的崩溃,占比约 2.75%,这些崩溃主要与三星兼容性问题有关。

因此,我们判断在有限的时间和精力下,先解决 top 的 activityStopped 关键字的崩溃,以下主要是对 activityStopped 分析。如图所示,我们接下来从底层向上层对核心逻辑崩溃堆栈进行逐步分析。

bf1c399f8542c9d836a609994d5a4d8b.png

Caused by: android.os.TransactionTooLargeException: data parcel size 541868 bytes

根据关键字 data parcel size 和相关源码分析,我们得出 data parcel size 附近的数据处理流程:

21ba548e930cf6cbbc512472bc5a0066.png
  1. JNI 层 BinderProxy_transact 函数先调用 IBinder->transact 函数进行 Binder 传输数据。

  2. IBinder->transact 函数的返回值 err 用于识别 Binder 传输数据错误码。

  3. 当 err != NO_ERROR && err != UNKNOWN_TRANSACTION 情况下,说明 Binder 传输数据出现异常。

  4. signalExceptionForError 函数对异常情况多种错误码进行分发处理。

  5. 当错误码 err = FAILED_TRANSACTION 时,说明是在传输的过程中失败,接下来主要根据 parcelSize 和 200*1024 的大小关系处理。

  6. 当 parcelSize > 200*1024 时,后续通过 jniThrowException 函数在 Java 层抛出 TransactionTooLargeException,并提示消息 "data parcel size %d bytes"

  7. 当 parcelSize <= 200*1024 时,后续通过 jniThrowException 函数在 Java 层抛出 DeadObjectException 或者 RuntimeException,并提示消息 "Transaction failed on small parcel; remote process probably died, but this could also be caused by running out of binder buffer space",意思是 Binder 传输小的数据量情况下也可能失败,原因有两个:第一是远程进程可能已经死亡,Binder 驱动找不到远程进程对应的 mmap 虚拟内存,也就无法完成 Binder 数据传输;第二是远程进程还存活,但是它的 Binder mmap 接收缓冲区已经被用完,无法分配空闲的 mmap 虚拟内存,也就无法完成 Binder 数据传输。

通过对错误消息分析,我们得出结论:

  • IBinder transact 函数返回 FAILED_TRANSACTION 错误码且 parcelSize > 200*1024 情况下会抛出 TransactionTooLargeException。

BinderProxy.transactNative 流程

前面我们在 signalExceptionForError 函数中读取这个 FAILED_TRANSACTION 错误码,接下来我们要知道其写入的地方在哪里,以便理顺数据处理流程。通过关键字查找,我们发现这个 FAILED_TRANSACTION 错误码只在 IPCThreadState::waitForResponse 函数中写入。对应有两种情况会写入:BR_FAILED_REPLY 回复失败,BR_FROZEN_REPLY 冻结回复。

这两个 cmd 对应的值是从 mIn.readInt32读取出来,也就是 Parcel 中读取出来的命令值。

e26291e9cffefd5fb7dc8c8c4b4fb7f9.png

我们依然循着错误码传递路径来到 Binder 驱动层。

BR_FAILED_REPLY 错误码在 binder 驱动中有 40 个引用,过于宽泛,没有唯一性,导致继续使用这个排查方向的 ROI 不高,因此暂停这个排查方向。

http://aospxref.com/kernel-android13-5.10-lts/more/drivers/android/binder.c?full=BR_FAILED_REPLY

fe67f356c99ffd3ec9b91b26e15e97ac.png

BR_FROZEN_REPLY 错误码在 binder 驱动中有 1 个引用,指进程/线程被冻结情况,主要用于区分进程死亡时 Binder 无法回复 BR_DEAD_REPLY。我们这个 Activity stop 场景用户大概率还在前台,App 和 System Server 不太可能出现进程被冻结,因此暂停这个排查方向。

http://aospxref.com/kernel-android13-5.10-lts/xref/drivers/android/binder.c#6298

3504266f1ef98758e246bed2b5421487.png

除了错误码之外,我们结合源码得出 BinderProxy.transactNative 执行流程如下:

1652a86d1757d35f62066e21e50a1983.png
  • Java 层 BinderProxy.transactNative 调用 Native 层 android_os_BinderProxy_transact。

  • 数据经过 IBinder::transact、BpBinder::transact 函数调用到达 IPCThreadState::transact。

  • IPCThreadState::writeTransactionData 先把待传输数据写入 Parcel 。

  • IPCThreadState::waitForResponse 负责和 Binder 驱动通讯进行数据发送和接收,并处理接收的命令。

  • IPCThreadState::talkWithDriver 负责和和 Binder 驱动通讯,关键是通过 ioctl 系统调用进入内核处理。

PendingTransactionActions$StopInfo.run 流程

前面我们分析了崩溃类型 TransactionTooLargeException、崩溃消息 data parcel size xx bytes、和堆栈最底层函数 BinderProxy.transactNative 数据处理流程之后,得出的结论是:该崩溃是Binder 传输数据超过 200 * 1024 时出现的崩溃。

由于 BinderProxy.transactNative 是底层通用函数,它的调用方有很多,我们需要快速确定这个崩溃场景最大的 Context 是在哪个里面,所以我们直接跳到分析主线程消息处理里面业务根消息PendingTransactionActions$StopInfo.run 的分析。

04d6b099302dbee15b685d21bad7f19a.png

通过下图源码,我们发现 App 进程通过 Binder IPC 通知 System Server 进程的 AMS Activity Stop 信息时,有个 catch RemoteException 异常捕获逻辑。

通过先打印 Bundle 统计数据,后进行异化逻辑处理,我们看到 targetSdk < 24 情况下,程序打印了一行日志 App sent too much data in instance state, so it was ignored,然后 return 返回,并没有执行 throw 异常。所以推测系统在这种情况下其实不崩溃。

88899e9716a37a0c3f4ac4b5cdc3947c.png

使用 App 监控统计分析到,6/7/10系统没有崩溃,这个和源码逻辑可以匹配上。在 targetSdk >= 24 之后,把捕获的异常重新通过 throw 关键字抛出,这种情况下会出现崩溃。经过回溯 StopInfo 中的 Bundle 来自Activity.onSaveInstanceState :

b3ab2acfaa164086e5a8c88df760f342.png

因此,我们得出崩溃场景是:

6ec0dd18a86028b35ee3519550ba462c.png
  • ActivityThread.handleStopActivity 过程中保存 callActivityOnSaveInstanceState 产生的 Bundle 数据。

  • 在主线程异步消息执行 StopInfo.run 告诉 AMS activityStopped 信息过程中,传递 Bundle 数据用于未来恢复 Activity 状态。

Activity.onRestoreInstanceState 流程

前面我们分析崩溃场景 Binder 传输数据和 Activity.onSaveInstance 的 Bundle 数据有关,接下来我们分析这个 Bundle 数据恢复处理流程。

7df709238a27333b9c88f8479e7cf03b.png
  • Activity 被销毁重建场景,在 ActivityThread.performLaunchActivity 函数中,Bundle 会跟随新 Activity 实例创建从 AMS 传递到 App 进程内。

  • 由于 ActivityThread.performLaunchActivity 中 Bundle 是从 AMS 进程序列化传递到 App 进程,所以其中的类需要在 App 进程进行反序列化,重新进程类加载和类初始化,所以 Bundle.setClassLoader 用于 App 进程内 Bundle 数据反序列化。

  • 接下来通过 Instrumentation 传递 Bundle 到 Activity.onCreate 函数,在这里是 Activity 接收到 Bundle 最早的地方,Activity 可以从这里开始访问被恢复的 Bundle 数据。

  • 在 ActivityThread.handleStartActivity 处理 start 时,也会通过 Activity.onRestoreInstanceState 函数用于恢复 Activity 状态。

TransactionTooLargeException 直接原因

经过前面分析,我们知道 Binder 传输数据量超限是 Activity.onSaveInstance 保存的 Bundle 过大导致。但是我们并不知道 Bundle 超过多大会崩溃,比如 Bundle 超过 200K、500K、1M 会崩溃吗?

为了分析崩溃执行过程,在 demo App 中 Activity.onSaveInstanceState 保存大数据,并使用 root 机器分析崩溃场景日志:

de4d2cf07b729585c1267d765d87004c.png
// 4692 是发送方 app 进程 pid,1838 是接收方 system server 进程 pid。接收方接收数据时,遇到分配 4198824 虚拟内存失败,没有足够的虚拟地址空间
4692  4692 E binder_alloc: 1838: binder_alloc_buf size 4198824 failed, no address space
4692  4692 E binder_alloc: allocated: 2240 (num: 8 largest: 2080), free: 1038144 (num: 4 largest: 1036672)
4692  4692 I binder  : 4692:4692 transaction failed 29201/-28, size 4198812-8 line 3317
4692  4692 E JavaBinder: !!! FAILED BINDER TRANSACTION !!!  (parcel size = 4198812)

结合源码,我们得出 BinderProxy.transactNative 执行流程是这样:

d05d50ef05c5638fcf51b97ad155804c.png
  • Java 层 BinderProxy.transactNative 调用 Native 层 android_os_BinderProxy_transact 。

  • 数据经过 IBinder::transact、BpBinder::transact 函数调用到达 IPCThreadState::transact 。

  • IPCThreadState::writeTransactionData 先把待传输数据写入 Parcel 。

  • IPCThreadState::waitForResponse 负责和 Binder 驱动通讯进行数据发送和接收,并处理接收的命令。

  • IPCThreadState::talkWithDriver 负责和和 Binder 驱动通讯,关键是通过 ioctl 系统调用进入内核 binder_ioctl 处理。

  • binder_thread_write 负责向目标进程写入 Binder 传输数据,binder_thread_read 负责读取其它进程发送给当前进程的 Binder 传输数据。

  • 进程间传输数据走到公用函数 binder_transaction,通过函数参数 int reply识别区分是否是发送数据还是接收数据。

  • 在往一个进程写入传输数据之前,需要通过 binder_alloc_new_buf_locked 函数先申请合适大小的 Binder 接收缓冲区,如果分配失败,则无法完成数据传输。

b480a20d4ab76db5087960bdb63f8dee.png

因此,TransactionTooLargeException 直接原因是接收方进程 Binder mmap 接收缓冲区无法分配足够大的空闲内存。

Binder 传输数据最大值

前面我们只传输了约 500K 大小的数据就遇到了 TransactionTooLargeException 问题。那么

  • 是否 Binder mmap 接收缓冲区大小就是小于 500K 呢?

  • 是否最大缓冲区其实超过 100M,只是被其它 Binder 调用分配用完了呢?

通过 binder 驱动底层源码分析,单进程 Binder mmap 分析最大值 4MB,但是还受到参数 vma 控制。

ecb04de448c0bb821511ae5283c5bdd0.png

而从整个 Binder 架构初始化流程分析,这个参数来自 ProcessState 里面,最大值是 1M - 8K。

这两个值里面取最小值作为 Binder 传输数据最大值。

ea4ee9bfc6807de49e2e8d46a361a418.png

因此,一个进程 Binder 接收缓冲区最大是 1M-8K。

问题总结

经过前面的深入分析,我们对这个问题得出更加清晰的结论:

  • 问题是在 App 进程向 system_server 进程发送 Activity.onSaveInstanceState 保存的 Bundle 数据时,驱动层在 system_server 的 Binder 接收缓冲区(大小上限 1M-8K)中分配不出用于接收 Bundle 大小的内存,导致 Binder 发送失败

  • Bundle 数据用于 Activity 页面销毁重建时恢复页面状态,其来源包含 framework 和 App 层保存的数据。当页面过于复杂或者很重场景下可能会出现超限问题

  • 从日志和调用栈无法定位/拆解出 Bundle 中什么数据过大导致的崩溃,需要有归因和解决工具

解决方案

优化思路

前面我们分析得知崩溃场景是 Activity stop 和 start 时和 AMS 传递的 Bundle 过大流程:

758e85ca2d7ec6882c9af7e9a70976bb.png
  • App 进程内通过 Activity.onSaveInstance 方法把数据放到 Bundle 中

  • 通过 Binder Driver 提供的接口 App 把 Bundle 写入 System Server 进程的 mmap 内存

  • 接下来 Activity 重建/启动时,System Server 再把保存的 Bundle 数据转发给 App 进程内 Activity

按照这个数据处理流程,一旦 Bundle 超过进程 Binder 接收缓冲区大小,就会触发 Binder 传输失败。

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    // 需要 hook 拦截处理 outState
}

@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
    // 需要 hook 拦截处理 savedInstanceState
    super.onRestoreInstanceState(savedInstanceState);
}

因此,结合问题场景和现状,我们的优化思路是:

  • 由于目前没有太好的办法在运行时应用态直接修改 Binder 驱动层内核态的执行指令,因此主要从上层 App 应用态入手解决问题

  • 在 onSaveInstanceState 之后把 outState 缩小到小于底层 Binder 传输的阈值

  • 在 onRestoreInstanceState 之前把 savedInstanceState 扩大到原始大小给业务层使用

Hook 点

通过阅读源码和断点调试,我们发现一个合适的 hook 类 ActivityLifecycleCallbacks,其能满足我们的诉求:

  • onActivityPostSaveInstanceState 正好是在 onSaveInstanceState 之后执行,而且能拿到 outState 参数

5440bded9327a005ed747221e80e3080.png
  • onActivityPreCreated 正好是在 onRestoreInstanceState 之前执行,而且能拿到 savedInstanceState 参数

所以我们通过 Application.registerActivityLifecycleCallbacks 能够拿到合适的执行时机。

缩小 Binder 传输 Bundle

为了让 Binder 读取到的 Bundle 数据量小从而避免崩溃,我们需要思考小到什么程度?比如:

  • 完全不执行 onSaveInstanceState 函数,这样也就不会超过 Binder 阈值

  • onSaveInstanceState 中主动调用 clear 函数清理 outState 数据,这样也不会超过 Binder 阈值

  • 把 outState 数据替换为一个小的 KEY 数据,而且建立 KEY-outState 一一对应的 Map 结构,方便查找还原 Bundle

为了尽量不影响系统本身的 save 和 restore 逻辑,我们选择传输 KEY 数据缩小 Bundle 数据。其中依赖的 KEY 我们使用 UUID 生成方式。

public void onActivityPostSaveInstanceState(Activity activity, Bundle outState) 
    outState.clear();
    String uuid = UUID.randomUUID().toString();
    sKey2ContentMap.put(uuid, outState);
    outState.putString("TransactionTooLargeOptKey", uuid);
}

还原 Binder 传输 Bundle

前面我们有了 KEY 和 Map 结构,现在我们只需要根据 KEY 查找对应的 Bundle 数据进行还原即可:

public void onActivityPreCreated(Activity activity, Bundle savedInstanceState) {
    if (savedInstanceState != null) {
        Object uuid = savedInstanceState.get("TransactionTooLargeOptKey");
        Bundle bundle = sKey2ContentMap.get(uuid);
        if (bundle != null) {
            savedInstanceState.clear();
            savedInstanceState.putAll(bundle);
        }
    }
}

功能、性能和稳定性折中

有了整体稳定性优化数据处理流程之后,我们发现维护的 Map 结构需要一定的空间开销,可能包括内存或者外存。

一方面,假如我们需要跟系统 save 和 restore 行为保持一致,即冷启动之后还可以恢复 Activity 状态,那么需要把 Bundle 信息保存在外存。而外存一般只能保存文本/二进制数据,这就需要把 Bundle 进行转换成二进制,并且涉及到一些 io 操作,很可能影响性能。

另一方面,我们对抖音主要 Activity 进行销毁重建,发现 Activity 并不能很完美的支持恢复能力。

所以,基于以上两点,我们决定 Map 结构不涉及的 io,只做内存级别。

前面我们的流程中针对所有 Bundle 都进行了优化,可能它就没有超过 Binder 的阈值。因此,为了精准识别问题场景,我们希望对超过一定大小的 Bundle 才进行优化。所以我们使用 Parcel 对 Bundle 进行了序列化量化:

private static byte[] bundle2Bytes(Bundle outState) {
    Parcel parcel = Parcel.obtain();
    try {
        parcel.writeBundle(outState);
        return parcel.marshall();
    } catch (Throwable t) {
        t.printStackTrace();
    } finally {
        parcel.recycle();
    }
    return null;
}

把 Bundle 序列化为 byte[] 之后,我们限制大小超过 1024 Bytes 才进行优化。

假如用户不断触发 save 操作,那么很可能会挤爆 Map 内存。所以我们需要有个 Map 容量上限和清理逻辑。这里初步设置容量 64,超限之后清理掉最久不用的记录:

if (sKey2ContentMap.size() > 64) {
    Set<Map.Entry<String, byte[]>> entries = sKey2ContentMap.entrySet();
    List<String> removeKeys = new ArrayList<>();
    // 清理掉前 20%,留下后面 80%
    final int removeCount = (int) (64 * 0.2);
    for (Map.Entry<String, byte[]> entry : entries) {
        removeKeys.add(entry.getKey());
        if (removeKeys.size() >= removeCount) {
            break;
        }
    }
    for (String firstKey : removeKeys) {
        sKey2ContentMap.remove(firstKey);
    }
}

Demo 验证

在 demo App 中,我们尝试往 Bundle 中写入超过 Binder 传输阈值的 16 MB 数据,预期在 restore 中能读取到相同数据:

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    byte[] bytes = new byte[16 * 1024 * 1024]; // 16MB
    outState.putByteArray("key", bytes);
    System.out.println("onSaveInstanceState bytes.length = " + bytes.length);
}


@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
    super.onRestoreInstanceState(savedInstanceState);
    byte[] bytes = savedInstanceState.getByteArray("key");
    System.out.println("onRestoreInstanceState bytes.length = " + bytes.length);
}

在机器中开启开发者选项中不保留活动选项,测试结果中两行日志 bytes 数组长度一致,说明方案可行。

05020c63fc64b7d1653aaeac6bbf3819.png

最终方案

经过前面层层分析和逐步优化,我们的最终方案是:

  • 启动之后通过 Application#registerActivityLifecycleCallbacks 注册 Hook 点函数,从而拿到 save 和 restore 执行时机和参数。

  • 在 ActivityLifecycleCallbacks#onActivityPostSaveInstanceState 时把 Bundle 序列化为二进制,当大小超过 1024 Bytes 时创建 UUID 并在内存记录 UUID 和 Bundle 映射关系,替换 Bundle 数据为 ID,从而减小 Binder 看到的数据量。

  • 在 ActivityLifecycleCallbacks#onActivityPreCreated 时从 Bundle 中读取出 UUID,并从内存 Map 中查找出映射的 Bundle,还原 ID 为 Bundle 数据。

3c999f78926ddc987270e3a7353cc9de.png

上线效果

单个 issue 修复效果:优化方案已经解决 97% 的问题。

剩余三星 startActivity 兼容性问题,已经不是 Top 问题。由于涉及启动 Activity 多进程数据共享问题,故此次未做优化

接入方式

目前该方案已经在抖音全量上线,并集成到火山引擎 APMPlus ,供各产品快速接入(由于不同产品的复杂度不同,建议接入时做好观察验证)。接入方式如下:

MonitorCrash.Config config = MonitorCrash.Config.app(APM_AID)
        .enableOptimizer(true);//打开稳定性优化开关
        .build();

只需要初始化时打开优化开关即可,欢迎各产品接入优化异常问题,该能力开箱即用,具体接入方式详见文档(https://www.volcengine.com/docs/6431/68852)。

总结和思考

  • 系统层:对于系统服务端 system_server 进程,其会接收众多 App 进程发送的 Binder 数据,Binder 传输数据量上限限制和 App 进程一样,可能是不太合理的。随着手机内存发展,这个数值在内存充足的机器上或存在优化空间,例如扩容到 2M 之后,传输失败率下降,App 使用时长增加。

  • 业务层:跨进程场景 Bundle 和 Intent 中不适合传递大数据,可以经过外存中转,通过维护外存 Map 和传递 Key 解决;另外 Bundle 中不建议放受到后台 Server 活动影响较大的序列化数据结构,这种情况平时没有问题,一到线上活动就容易崩溃,在活动上线前需要多加演练。

关于APMPlus

APMPlus 是火山引擎下的应用性能监控产品,通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。基于海量数据的聚合分析,平台可帮助客户发现多类异常问题,并及时报警,做分配处理。目前,APMPlus 已服务了抖音、今日头条等多个大规模移动 App,以及作业帮、Keep、德邦等众多外部业务方。

45e5e6644baaf208c131a8729ef9195d.png

APMPlus APP端监控方案提供了 Java 崩溃、Native 崩溃、ANR 等不同的异常类别监控,以及启动、页面、卡顿等流畅性监控,还包括内存、CPU、电量、流量、磁盘等资源消耗问题的监控。此外,APMPlus 提供网络耗时和异常监控,拥有强大的单点分析和日志回捞能力。在数据采集方面,提供采样和开关配置,以满足客户对数据量和成本控制的需求,并支持实时报警能力。针对跨平台方案,其提供了 WebView 页面的监控。丰富的能力满足客户对 App 全面性能监控的诉求。

方案亮点

  • Java OOM 监控提供全流程自动分析能力,准确定位Java内存问题,泄漏链、泄漏大小、大对象一目了然。

  • ANR 使用基于信号的捕获方案,更节省系统资源,准确度高,提供现场消息调度图,高度还原现场主线程阻塞情况。

  • 真正解决 Native(C/C++)崩溃的现场还原能力,提供了最有价值的Tombstone,精细还原现场。完整展示崩溃线程的进程信息、信号信息、寄存器信息,还原崩溃现场汇编指令,详细的maps、fd和内存信息。

  • 高性能日志库,做到数据稳定性强、性能好,保障了现场业务信息的高度还原。

804db56a42a804657c26a60e5f2b154a.png46c44e25b6c29c6fd58c56e29810b924.png

扫码申请免费试用

👇 点击阅读原文,了解更多

用 Addon 增强 Node.js 和 Electron 应用的原生能力

前言

Node.js Addon 是 Node.js 中为 JavaScript 环境提供 C/C++ 交互能力的机制。其形态十分类似 Java 的 JNI,都是通过提供一套 C/C++ SDK,用于在 C/C++ 中创建函数方法、进行数据转换,以便 JavaScript / Java 等语言进行调用。这样编写的代码通常叫做 Bindings。

此外还有基于 C ABI Calling Convention (例如 stdcall / System-V 等标准) 直接进行跨语言调用的方案,例如 Rust FFI、Python 的 ctypes、Node.js 的 ffi 包等。这两者的差别在于 Rust 等原生语言是直接针对平台来将函数调用编译为机器码,而 ctypes 和 ffi 包则是基于 libffi 动态生成机器码来完成函数调用的。和 Node.js Addon 的差别则在于调用和类型转换的开销上。

本文将围绕 Node.js Addon 进行介绍,即创建一个 Bindings 来增强 Node.js 或 Electron 应用的原生能力,使其可以和系统进行交互,或者使用一些基于 C/C++ 编写的第三方库。

Node.js Electron 的关系

Electron 在主进程和渲染进程中都包含了完整的 Node.js 环境,因此本文既适用于 Node.js 程序,也适用于 Electron 程序。

Node.js Addon 的类型

在 Node.js 的 Addon,有三种类型:

dc401e6cb8bf2d8dfe1eb643b8420c0b.png

本文主要介绍 Node-API 的原理,以及以 node-addon-api 作为例子。

Node-API 基本原理

4467c6b1fe7336efa658380ee1797d0b.png

Node.js 本质上是一个动态链接库(即 Windows 下的 .dll 文件、MacOS 下的 .dylib 文件、Linux 下的 .so 文件),只不过在分发时会将文件的扩展名改为 .node

加载

Node.js Addon 通常通过 CommonJS 的 require 函数进行导入和初始化。require 在被 .node 扩展名路径作为参数进行调用的情况下,最终会利用 dlopen(Windows 下是 LoadLibrary)方法来动态加载这个以 .node 扩展名的动态链接库:

a2f5c61c11d1269b6e9c8f34504b56da.png

初始化

以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作为参考:

static napi_value Init(napi_env env, napi_value exports) {
  napi_status status;
  napi_property_descriptor desc = DECLARE_NAPI_METHOD("hello", Method);
  status = napi_define_properties(env, exports, 1, &desc);
  assert(status == napi_ok);
  return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

NAPI_MODULE 宏用来绑定一个 C 函数作为初始化函数。这个函数中可以用来给模块的 exports 对象添加所需要的功能。

例如上述的代码中,给 exports 添加了一个叫做 hello 的函数。这样一来,我们在 Node.js 中 require 这个模块之后,就能获得到一个包含 hello 函数的 exports 对象:

b562b57293da43c8b8238981b5410b97.png

调用

以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作为参考:

static napi_value Method(napi_env env, napi_callback_info info) {
  napi_status status;
  napi_value world;
  status = napi_create_string_utf8(env, "world", 5, &world);
  assert(status == napi_ok);
  return world;
}

Method 本身是一个 C 函数,接受 napi_env 作为 JavaScript 的上下文信息。napi_callback_info 作为当前函数调用的信息,例如函数参数等。返回一个 napi_value 作为函数的返回结果。

从这个函数的例子中可以看到,在 C 中是可以获取到函数的调用参数,并且产生一个值作为函数的返回结果。稍后我们会以 node-addon-api 作为例子来具体介绍其编写方式。

cb989e00d6a2daa04ed0cd54335f397d.png

模块编写指南

本节介绍使用 C++ 配合 node-addon-api 开发模块时常见的一些模式和样板代码,仅供参考。

更多用法详见官方文档:https://github.com/nodejs/node-addon-api/blob/main/doc/hierarchy.md

模块初始化

使用 NODE_API_MODULE 宏绑定一个 C++ 函数进行模块初始化:

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set(Napi::String::New(env, "hello"),
              Napi::Function::New(env, Method));
  return exports;
}

NODE_API_MODULE(hello, Init)
  • 其中 Napi::Env 是对 napi_env 的封装,代表一个 JavaScript 上下文,大部分和 JavaScript 交互的场景都需要这个上下文,可以保存起来以供下次使用(但是不要跨线程使用)。

  • Napi::Object exports 则是这个模块的 exports 对象,可以把想要给 JavaScript 暴露的值和函数都设置到这个上面。

创建 JavaScript 函数

首先需要创建一个如下函数签名的 C++ 函数:

Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  double arg0 = info[0].As<Napi::Number>().DoubleValue();
  double arg1 = info[1].As<Napi::Number>().DoubleValue();
  Napi::Number num = Napi::Number::New(env, arg0 + arg1);
  return num;
}

其中函数的返回值可以是任何派生自 Napi::Value 的类型,也可以是 Napi::Value 本身。

获取函数参数

通过 Napi::CallbackInfo& 来获取函数参数,例如 info[0] 代表第一个参数。

info[n] 会获取一个 Napi::Value 值,我们需要调用它的 As<T> 方法来转换为具体的值,我们才能将它继续转换为 C/C++ 可用的数据类型。例如,我们希望将函数的第一个参数转换为字符串,我们需要经过两个步骤:

  1. 将 Napi::Value 转换为 Napi::String:

Napi::String js_str = info[0].As<Napi::String>();
  1. 将 Napi::String 转换为 std::string

std::string cpp_str = js_str.Utf8Value();

其他数据类型例如 Napi::NumberNapi::Buffer<T> 均有类似的方法。

返回函数结果

我们可以直接创建一个 JavaScript 值并在 C++ 函数中返回。具体创建值的方法详见下一小节。

创建 JavaScript 值

我们可以利用各种实例化方法,来从 C/C++ 的数据类型中创建 JavaScript 的值,下面举几个常见的例子。

创建字符串
Napi::String::New(env, "字符串内容")
创建数字
Napi::Number::New(env, 123)
创建 Buffer

创建 Buffer 是一个有风险的操作。Node-API 提供了两种创建方式:

  • 提供一个指针和数据长度,创建一个数据的拷贝

    • ✅ 安全,首选这种方法

    • ✅ v8 会负责这个 Buffer 的垃圾回收

Napi::Buffer::Copy(napi_env env, const T* data, size_t length)
  • 直接基于指针和数据长度创建一个 External Buffer

    • ⚠️ 同一个指针(相同的内存地址)只能创建一个 Buffer,重复创建会引起错误

    • ⚠️ v8 / Node.js 不负责这个 Buffer 的内存管理

Napi::Buffer::New(napi_env env, const T* data, size_t length)

异步代码

异步函数

异步函数通常用于实现一些异步 IO 任务、事件,例如实现一个异步网络请求库的绑定。

异步函数通常有两种实现方式:回调 和 Promise。

同线程回调

同线程回调的使用场景比较少:

  • 使用了 libuv 来运行了一些异步任务,并且这个异步任务会在 libuv 主线程唤醒事件循环来返回结果,这时候可以比较安全地直接进行同线程回调。但是要求事先把 Napi::Env 保存在一个地方。

  • 实现一个函数的时候,在实现中直接同步调用一个 Napi::Function。

获取函数

通常我们会从函数调用的参数中获取到 Napi::Function,一般来说我们需要在当次调用就把这个函数给使用掉,避免后续被 v8 GC 回收。

持久化函数

如果我们确实需要在之后的其他时机去使用函数,我们需要将它通过 Napi::Persistent 持久化:

Napi::FunctionReference func_persist = Napi::Persistent(func);

使用时,可以作为一个正常的函数去使用。

调用函数

无论是 Napi::Function 还是 Napi::FunctionReference,我们都可以通过 Call 方法来调用:

Napi::Value ret_val = func_persist.Call({
  Napi::String::New(env, "Arg0")
});
跨线程回调

跨线程回调是比较常见使用场景,因为我们通常会想在另外一个线程调用 JavaScript 函数。

使用线程安全函数 (ThreadSafeFunction)

为了在其他线程中调用 JavaScript 函数,我们需要基于 Napi::Function 去创建一个 Napi::ThreadSafeFunction

Napi::ThreadSafeFunction tsfn = Napi::ThreadSafeFunction::New(
  env,                     // Napi::Env
  info[0].As<Function>(),  // JavaScript 函数
  "handler",               // 异步函数的名称,用于调试的识别
  0,                       // 队列最大大小,通常指定为 0 代表没有限制。如果队列已满则可能会导致调用时阻塞。
  1                        // 初始线程数量,通常指定为 1。实际上是作为内存管理使用。可参考这篇文档。
);

接着就可以把 tsfn 保存在任何位置,并且并不需要同时保存一份 Napi::Env

调用线程安全函数

调用线程函数有两种形式,一种是同步调用,另一种是异步调用。

同步调用

同步调用指的是如果我们限制了 ThreadSafeFunction 的队列大小,并对其进行了多次调用,从而创建了许多调用任务,则会导致队列已满,调用就会被阻塞,直到成功插入队列后返回结果。

这是进行一次同步调用的例子:

const char* value = "hello world";
napi_status status = tsfn.BlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  callback.Call({ arg0 });
});

这样一来就能顺利地在任意线程去调用 JavaScript 函数。

但是我们发现,实际上我们并不能同步地获取函数调用的返回结果。并且 Node-API 或者 node-addon-api 都没有提供这么一种机制。但是我们可以借助 libuv 的信号量来达到这个目的。

uv_sem_t sem;
uv_sem_init(&sem, 0);
const char* value = "hello world";
Napi::Value ret_val;
napi_status status = tsfn.BlockingCall(value, [&ret_val](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  *ret_val = callback.Call({ arg0 });
  uv_sem_post(&sem);
});
uv_sem_wait(&sem);

// 直至 JavaScript 运行结束并返回结果,才会走到这里
// 这里就可以直接使用 ret_val 了

异步调用

异步调用则会在队列已满时直接返回错误状态而不进行函数调用。除此之外的使用方法同 “同步调用” 完全一致:

const char* value = "hello world";
napi_status status = tsfn.NonBlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  callback.Call({ arg0 });
});
Promise
C++ 中创建 Promise 给 JavaScript 使用

我们通常会需要在 C++ 中实现异步函数。除了直接用上面已经介绍的基于回调的方法之外,我们还可以直接在 C++ 中创建一个 Promise。

Promise 只支持同 线程 调用

由于 Promise 并未提供跨线程 Resolve 的方式,因此如果希望在其他线程对 Promise 进行 Resolve 操作,则需要结合 libuv 来实现。此方法比较繁琐,建议转而使用跨线程回调函数。如果读者感兴趣,后续本文可以补充相关内容。

我们可以直接创建一个 Promise,并在函数中返回:

Napi::Value YourFunction(const Napi::CallbackInfo& info) {
  Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(info.Env());

  // 我们可以把 env 和 Napi::Promise::Deferred 保存在任何地方。
  // deferred_ 会在 Resolve 或者 Reject 之后释放。
  env_ = info.Env();
  deferred_ = deferred;

  return deferred.Promise();
}

接着我们可以在其他地方调用 Napi::Promise::Deferred 来完成 Promise。注意,这里一定需要在主线程中调用:

// 返回成功结果
deferred_.Resolve(Napi::String::New(info.Env(), "OK"));
// 返回错误
deferred_.Reject(Napi::String::New(info.Env(), "Error"));
C++ 中使用来自 JavaScript 的 Promise

由于 Node-API 或者 node-addon-api 均没有提供使用 Promise 的封装,因此我们需要像在 JavaScript 中通过 .then 手动使用 Promise 的方式,在 C++ 中使用 Promise。

// 首先需要定义两个函数,用来接受 Promise 成功和失败
Napi::Value ThenCallback(const Napi::CallbackInfo &info) { 
  Napi::Value result = info[0];
  // result 是 Promise 的返回结果
  return info.Env().Undefined();
}
Napi::Value CatchCallback(const Napi::CallbackInfo &info) { 
  Napi::Value error = info[0];
  // error 是 Promise 的错误信息
  return info.Env().Undefined();
}

Napi::Promise promise = async_function.Call({}).As<Napi::Promise>()
Napi::Value then_func = promise.Get("then").As<Napi::Function>();
then_func.Call(promise, { Napi::Function::New(env, ThenCallback, "then_callback") });
Napi::Value catch_func = promise.Get("catch").As<Napi::Function>();
catch_func.Call(promise, { Napi::Function::New(env, CatchCallback, "catch_callback") });

显然这种使用方式是比较繁琐的,我们也可以通过一些办法使其可以将 C++ Lambda 作为回调函数来使用,但是本文暂时不涉及这部分内容。

异步任务

异步任务通常是利用 libuv 提供的线程池来运行一些 CPU 密集型的工作。而对于一些跨线程异步回调的 Bindings 实现则直接使用 ThreadSafeFunction 即可。

具体使用可以参考:https://github.com/nodejs/node-addon-api/blob/main/doc/async_worker.md

Node-API 的构建

基本构建配置

Node.js Addon 通常使用 node-gyp 构建,这是一个基于 Google 的 gyp 构建系统实现的构建工具。至于为何是 gyp,因为 Node.js 是基于 gyp 构建的。

我们来看一个 node-addon-api 项目的构建配置,以 bindings.gyp 命名:

{
  "targets": [
    {
      "target_name": "hello",
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [ "hello.cc" ],
      "include_dirs": [
        "<!@(node -p "require('node-addon-api').include")"
      ],
      'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ],
    }
  ]
}

具体配置可以参考官方使用文档:https://gyp.gsrc.io/docs/UserDocumentation.md

一些常识:

  • "sources" 中需要包含所有 C/C++ 代码文件,不需要包含头文件

  • "<!@(node -p "require('node-addon-api').include")" 在使用 Node-API 还是 node-addon-api 的情况下是不同的。

  • "target_name" 通常需要修改为你希望使用的扩展名称,它会影响编译产物的名称。

常用构建命令

  • node-gyp rebuild 重新构建,会清理掉已有的构建缓存,推荐每次都使用这个命令来构建产物,避免出现奇怪的问题

    • 可以添加 --arch <ARCH> 参数来指定构建的目标架构,例如希望构建一个 32 位的产物,则可以使用 --arch ia32 来构建。

  • node-gyp clean 清理构建缓存。如果希望使用 node-gyp build 来进行构建的话,需要善用 clean 功能。

实用构建配置

添加头文件目录
'include_dirs': [
  'win32/x64/include'
]
在 Windows 下进行动态链接 / 静态链接
'libraries': [
  'some_library.lib'
]
  • 对于动态链接,需要指定 .dll 对应的 .lib 文件,并在分发的时候将 .dll 放在 .node 相同的目录下。

  • 对于静态链接,则直接指定 .lib 文件即可。但是在 Node.js Addon 中进行静态链接是一个比较费劲的事情,因为通常涉及到对其他静态依赖的管理,需要谨慎选择此方案。

在 Windows 下设置 C++ 版本
'msvs_settings': {
  'VCCLCompilerTool': {
    'AdditionalOptions': [
      '/std:c++20'
    ]
  }
}
在 Windows MSVC 下构建支持代码文件中的 UTF-8 字符(中文注释等)

本质上是给 MSVC 的编译器添加一个 /utf-8 参数

'msvs_settings': {
  'VCCLCompilerTool': {
    "AdditionalOptions": [
      '/utf-8'
    ]
  }
}
在 MacOS 下进行动态链接 / 静态链接
'link_settings': {
  'libraries': [
    '-L<动态库或静态库所在的文件夹>',
    '-l<动态库名称>'
  ]
}
在 MacOS 下引入系统 Framework 依赖
'libraries': [
  '-framework MediaPlayer',
  '-framework Cocoa',
]
在 MacOS 下设置 C++ 版本
"cflags_cc": [
  "-std=c++20"
]
在 MacOS Xcode 的 Release 构建下生成 .dSYM 调试文件
'xcode_settings': {
  'DEBUG_INFORMATION_FORMAT': 'dwarf-with-dsym'
}
使 MacOS 下的 addon 能够使用同目录下的动态库 / Framework
'link_settings': {
  'libraries': [
    '-Wl,-rpath,@loader_path',
    ## 此外,还可以设置到任何相对于 .node 文件的其他目录下
    '-Wl,-rpath,@loader_path/../../darwin/arm64',
  ]
},

但是这也要求 .dylib 文件支持该功能,可以通过 otool -D <你的动态链接库位置>.dylib 的返回结果来检查:

<你的动态链接库>.dylib:
@rpath/<链接库名称>.dylib

如果文件名往前的开头是 @rpath,则意味着支持该功能。如果不是,则可以使用 install_name_tool 来修改动态链接库使其支持:

install_name_tool -id "@rpath/<链接库名称>.dylib" <你的动态链接库位置>.dylib
在 MacOS 下支持 Objective-C 和 C++ 混编
'xcode_settings': {
  'OTHER_CFLAGS': [
    '-ObjC++'
  ]
}

开发&分发&使用

项目文件组织

通常来说,我们可以用下面的文件夹结构来扁平地组织我们的 addon 文件:

.
├── node_modules                   ## npm 依赖
├── build                          ## 构建路径
│   ├── Release                   ## Release 产物路径
│       ├── myaddon.node          ## addon 产物
│       ├── myaddon.node.dSYM     ## addon 的符号文件
├── binding.gyp                    ## 构建配置
├── addon.cc                       ## Addon 的 C++ 源码
├── index.js                       ## Addon 的 JavaScript 源码
├── index.d.ts                     ## Addon 的 TypeScript 类型(下方会介绍)
└── package.json                   ## Addon 的 package.json 文件

当然我们也可以把 JavaScript 源码和 C++ 源码分别放入不同的文件夹,只需要修改对应的构建配置和 package.json 即可。

编写 index.js - 使用 bindings 包

一般来说我们会直接在 C++ 中实现大部分逻辑,JavaScript 文件只用来引入 .node 文件。由于 Node.js Addon 存在各种不同的方案、构建配置,因此 .node 文件产物的位置可能也会因此不同,所以我们需要借助一个第三方 npm 包来自动为我们寻找 .node 文件的位置:

https://github.com/TooTallNate/node-bindings

通过 bindings,我们的 index.js 仅需一行代码就能自动获取并导出 .node 模块:

module.exports = require('bindings')('binding.node')

同时保证 package.json 的 main 配置为我们的 index.js:

{
  // ...
  "main": "index.js"
  // ...
}

为 Addon 添加 TypeScript 类型

添加 TypeScript 类型,最简单的方式只需要创建一个 index.d.ts 文件,并在其中声明在 C++ 代码中创建的函数们即可:

export interface FooOptions {
  bar: string
}

export function foo(options: FooOptions)

并在 package.json 添加一行参数用于指向类型文件:

{
  // ...
  "types": "index.d.ts"
  // ...
}

大部分情况下,这个方法就可以给你的 Node.js Addon 声明类型。

分发形式

安装时编译

一种方式是在使用者进行 npm install 时,使用用户设备进行 Addon 的编译。这时候我们可以使用 install 钩子来实现,我们仅需在 package.json 文件中添加如下内容:

{
  // ...
  "scripts": {
    // ...
    "install": "prebuild-install || node-gyp rebuild --release"
    // ...
  }
  // ...
}

保险起见,确保 node-gyp 在你的 devDependencies 之中,这样就能在用户通过 npm 安装你的 Addon 时,自动编译当前系统架构所对应的产物。

预编译

如果希望更近一步,节约用户安装 Addon 的时间,或者是为了让用户无需具备编译环境即可安装 Addon,可以使用预编译方案。即在集成环境中提前编译常见的操作系统、架构对应的 .node 文件,并随着 npm 包进行分发,再通过 bindings 或者其他一些库来自动匹配寻找系统所需要的对应 .node 文件。

由于预编译方案涉及到更多的细节,本文不再做介绍,大家可以参考该项目:

https://github.com/mapbox/node-pre-gyp

火山引擎 ByteHouse 的增强型数据导入技术实践

作为企业数字化建设的必备要素,易用的数据引擎能帮助企业提升数据使用效率,更好提升数据应用价值,夯实数字化建设基础。

数据导入是衡量OLAP引擎性能及易用性的重要标准之一,高效的数据导入能力能够加速数据实时处理和分析的效率。作为一款OLAP引擎,火山引擎云原生数据仓库ByteHouse源于开源ClickHouse,在字节跳动多年打磨下,提供更丰富的能力和更强性能,能为用户带来极速分析体验,支撑实时数据分析和海量离线数据分析,具备便捷的弹性扩缩容能力,极致的分析性能和丰富的企业级特性。

随着ByteHouse内外部用户规模不断扩大, 越来越多用户对数据导入提出更高的要求,这也为ByteHouse的数据导入能力带来了更大的挑战。

本篇文章来源于ByteHouse产品专家在火山引擎数智平台(VeDI)主办的“数智化转型背景下的火山引擎大数据技术揭秘”线下Meeup的演讲,将从ByteHouse数据库架构演进、增强HaKafka引擎实现方案、增强Materialzed MySQL实现方案、案例实践和未来展望四个部分展开分享。

ByteHouse数据库的架构演进

作为一款分析型数据库,ByteHouse已经应用在互联网、金融、汽车领域,帮助企业实现人群洞察、行为分析、 IOT 风控等场景的实时分析。

ByteHouse的演进

  • 从2017年开始,字节内部的整体数据量不断上涨,为了支撑实时分析的业务,字节内部开始了对各种数据库的选型。经过多次实验,在实时分析版块,字节内部决定开始试水ClickHouse。

  • 2018年到2019年,字节内部的ClickHouse业务从单一业务,逐步发展到了多个不同业务,适用到更多的场景,包括BI 分析、A/B测试、模型预估等。

  • 在上述这些业务场景的不断实践之下,研发团队基于原生ClickHouse做了大量的优化,同时又开发了非常多的特性。

  • 2020年, ByteHouse正式在字节跳动内部立项,2021年通过火山引擎对外服务。

  • 截止2022年3月,ByteHouse在字节内部总节点数达到18000个,而单一集群的最大规模是2400个节点。

ByteHouse的架构

ByteHouse架构分为分布式架构和云原生架构两种。分布式架构的主要特点就是单集群可以支持 2000 多个节点的“大兵团”;通过分布式的并行计算体现的高性能,能够充分利用每个节点的计算和存储资源;云原生实现了存算分离,计算资源通过容器化进行弹性和秒级的扩容,这对业务是无感知的。

从分布式架构来看,ByteHouse具备MPP 1.0特点:

  • 存算一体:通过本地存储能够保证它极致的这种查询性能。

  • 自研的表引擎:包含 HaMergeTree和 HaUniqueMergeTree。

  • 在社区 RBO 优化器的基础上增强 RBO 加 CBO 的结合的查询优化,并基于 CBO 的分布式计划能够在集群模式下计算全局最优的查询计划。

  • 支持数据的冷热分存,同时兼顾性能和成本。

  • 增强关键的数据类型,从而优化查询性能。

  • 通过统一的管控面提供可视化的管理查询和运维,从内到外给用户提供优质的使用体验。

7fdc2254405d63ab983322e9386236db.png

但MPP 1.0存在资源隔离、扩容等痛点,由此演进到云原生架构,即MPP 2.0:其中存算分离通过结合 shared-everything 存储和 shared-nothing 计算层,避免了传统 MPP 架构中数据重新分配 (re-sharding) 的问题。

好处在于:

  • 更好地实现资源隔离。每个用户不同的计算都提交到不同的计算组,并进行计算资源和存储资源的扩容,再结合按量计费的计费策略可以降低用户使用成本。

  • 底层存储既支持HDFS,也支持 S3 对象存储,能够让 ByteHouse实现真正的云原生。

7798a70bdf5421f634e7c5c67e8e7bf0.png

ByteHouse技术优势

在增强型数据导入场景中,ByteHouse核心优势体现在自研表引擎:

  • 在社区版的基础上,ByteHouse对表引擎做了进一步增强,使其能够实现开源的ClickHouse所做不到的场景。

  • 高可用引擎,相比社区高可用引擎,可以支持表的数量更多,集群的规模更大,稳定性会更高。

  • 实时数据引擎,相比社区实时数据引擎,消费能力更强,支持 at least once 的语义,排除单点写入的性能故障。

  • Unique引擎,相比社区Unique引擎,ByteHouse没有更新延迟问题,能够实现真正实时的 upsert。

  • Bitmap 引擎,在特定的场景比如用户圈选圈群的场景中支持大量的交并补操作,能够使整体的性能提升 10 - 50 倍以上。

4cc9d467414bbf9c650ad5a2082700ca.png

这里具体再介绍一下ByteHouse自研引擎的优势——与导入密切相关的表引擎。

首先,ByteHouse 提供的HaMergeTree方案能够降低 ZK 负载,提升可承载的数据量级。

  • ClickHouse 社区版本: 社区提供的ReplicatedMergeTree表引擎让 ClickHouse 实现了从单机到集群的演进,通过ZK节点来同步并维护两个MergeTree之间的元数据和数据。痛点在于,在 TB 级的数据量级之下, ZK 重复地进行分发日志和数据交换等操作,极大地增加了ZK的压力,使ZK 成为整个集群的故障点。

  • ByteHouse 自研HaMergeTree: 将元数据的同步和数据的同步解耦,ZK只负责元数据的同步,而数据的同步是通过 LogExchange 来实现,在两个MergeTree之间进行对等拷贝。优势在于,降低了 ZK 的负载,即使是承载 PB 级的数据量,集群也能够平稳地运行。

其次, ByteHouse 提供的HaMergeTree方案能平衡读写性能。

  • ClickHouse 社区版本: 提供ReplacingMerge Tree实现了对唯一键的支持;使用Merge-on-read的实现逻辑,在不同批次的数据中包含着相同的 key ,需要在读时做合并,让相同的 key 返回最新的版本。痛点在于,数据存在延迟、滞后,降低读的性能。

  • ByteHouse 自研的HaUniqueMergeTree: 引入了 delete bitmap 的组件在数据插入时即标记删除,然后在数据查询时过滤掉标记删除的数据。优势在于,整体上平衡了读和写的性能,保障了读取时性能一致性。

增强HaKafka引擎实现方案

HaKafka 引擎架构介绍

社区版Kafka 优势 : 由于社区版ClickHouse是一个分布式结构,其数据分布在多个Shard上,Kafka引擎可以在多个Shard上去做并发的写入,而在同一个Shard内可以启动多线程做并发写入,并具备本地盘的极致的性能读写。

社区版 Kafka 不足 :

  • 在内外部业务的场景中,会经常遇到唯一键场景,由于社区版本的 Kafka的 high level 的消费模式(这种模式就决定无法预知数据被写入到哪一个Shard上),所以很难满足这一类场景。

  • 社区版的 Kafka 引擎没有高可用,即使ClickHouse是多副本的,在当一个节点发生宕机时,无法保证另一个节点继续消费。

HaKafka引擎架构(分布式架构)

保持社区版本两级并发两大的优化点:

  • 引入高可用,让备节点处于 stand-by 的状态,一旦主节点发生宕机,备节点立刻继续进行消费。

  • 升级为low-level的消费模式,当数据写入的时候,相同的 key 会写到相同的 partition 里面,保证在同一个Shard下支持的唯一键场景。

ByteHouse增强HaKafka引擎核心功能实现

  • 高可用(主备切换)

在备节点上起一个 stand by的consumer ,通过 ZK 来进行选组,选到的主节点进行消费,当主节点发生宕机或者无法进行服务时,在秒级之内切换到备节点上,让备节点继续消费。

假设现在 replica 1因为故障宕机了,无法继续进行消费,那么Z K能在秒级内把 replica 2 选为leader。replica 2 随即会立即启动相同数量的消费者,启动之后会继续从 replica 1 的消费位置开始继续进行消费。

b245807531972c6468dd87a11e0cddf0.png
  • 替换节点

随着集群规模的增大,节点数越来越多的情况下,不可避免地遇到节点故障,这个时候就需要替换节点。

对于分布式架构,替换节点一个重要的操作就是拷贝数据,在拷贝数据的时候意味着新的节点的数据是不全的,如图示,示意图 replica 1为新替换的节点,还处于数据拷贝的状态,即数据是不全,如果此时实施消费的 leader 起在了 replica 1,就意味着 最新的消费数据会写进 replica 1,但是它缺失一部分旧的数据。

而replica 2有旧的数据,它的最新数据还需要从replica 1进行拷贝,那这个时候下载之内没有一个副本上面的数据是完整的,所有的节点就不可能对外提供服务。

这时HaKafka会做强制限制,如果 replica 1是一个新节点,且还在拷贝数据的状态,那么就会强制把 leader 切换成 replica 2,由 replica 2 继续去消费最新的数据,replica 1保持继续拷贝数据,这样可以保证在节点替换的过程中至少有一个副本是能够正常提供服务。

3af3e3554840676b5da9fbd94d7b0e13.png
  • Memory table

不同于社区的Memory Table和底层存储绑定,ByteHouse的Memory Table是和Hakafka绑定的,主要使用在有百列或者千列的大宽表的场景。

对于ClickHouse来说,每一次导入的写的文件的数量和列数是成正比的。如果列很多,但是每批次写入的数据量不大,这时每一次写入就会造成很多的碎片,这对于 IO的消耗会比较高,写入的性能其实也会比较差。

针对这种情况,考虑使用Memory Table,让写不直接落盘,每一次写先写到Memory Table中,攒到一定的批次或者到达一定的时间之后再一次性刷盘。

当数据写入Memory Table之后,就可以对外提供查询服务了,因为 memory table 是跟 Kafka 绑定的,在同一个下的内是唯一的。当查询来了之后,只需要路由到对应的消费节点下 the Memory Table,就能保证了数据查询的一致性。

29468f9d8f44112388eefa97a847f2d1.png
  • 云原生架构增强

分布式架构的痛点在于:

1.节点故障: 字节的集群规模较大,每周/每天会遇到节点故障的问题,需要进行节点替换,是一个比较大的负担。

2.读写冲突问题: 随着集群的接入的业务越来越多,数据量越来越大的情况下,每一个节点同时承担着查询和写入的操作,之间会有冲突。

3.扩容成本: 唯一键的场景对数据分布要求非常严格,扩容在这种场景下很难支持,因为扩容之后partition的映射关系发生了变化。

云原生架构优点在于,存算分离、自动扩容、自动容错轻量级的扩缩容等,因为云原生支持事物,让我们可以将消费语义增强到exactly once。

67a419d263a389390064edd5d5a426b1.png
  • 在云原生架构下的 Kafka 引擎是如何通过事务来实现 exactly once:

    • 事务保证: 因为云原生架构有事务的支持,所以每一轮的消费都需要有事物来保证。因为 Catalog 的元信息和 Catalog 元信息的交互是在 Server 端进行的,所以第一步会通过 RPC 的请求向 Server 端请求创建消费事务,然后客户端创建正常,创建消费事务之后会把 transaction ID 给consumer, consumer 拿到这种全声音 ID 之后就可以开始正常地消费了。之后它就会从分配到的 partition 里面不停地消费数据,当消费到足够的数据量或者消费满足一定的时间时,它就会把消费的这数据转换为对应的 part 文件并dump到存储层。在 dump 之后,数据是不可见的,因为这个时候的 transaction 还没有提交,所以在第五步的时候,还是会通过一个 RPC 的 call 把刚才 dump 的元信息消费的 offseed 提交到 catalog 中。这一步是一个原子性的提交,也是我们的消费语义升级从 at least once 到 exactly once 的一个核心关键点

    • 容错保证: 因为manager 和它具体之间的任务是在不同的节点上的,所以需要有一定的这种容错机制。当前是让 manager 和 task 之间保持一种一个双向的心跳机制来保证,比如说manager每隔 10 秒钟会去探活一次,看看当前任务是否正常运行,如果没有在正常运行,它就会重新拉起一个新的task。而对于 task 来说,它每一次的消费都会有两次的 RPC call 和 Server 端做交互,这两次的 RPC 交互都会向 manager 去校验自身的有效性,如果校验到自己当前是一个失效的状态,它就会把自己 kill 掉,从而保证整个全局的唯一任务的运行。

    • b6c99b8a6d78a58d78f10fa4d10b4170.png

    • Memory Buffer : 与社区相似,Memory Buffer和底层的存储表绑定。因为都是写入底表的,不仅Kafka的导入可以用,Flink的导入也可以用。memory buffer 的使用场景是高频的小批量的导入场景,因为每一次导入都会写一个part,不停地写 part 会对集群产生压力。而 ClickHouse 的话,对 ClickHouse 来说 part 越多性能越差,所以使用 memory buffer 来缓存小批量的数据,到达一定批次之后再进行导入。首先需要有一个事务的保证,才能保证导入的完整性和一致性。另外它需要有WAL,因为首先把数据要先写到 WAL 中,数据写入到 WAL 中之后,就认为导入成功了,因为 WAL 本身也是一个持久化的存储,数据写入 WAL 之后,再将数据写入到 memory buffer。当数据写入了 memory buffer 之后就可以对外提供查询服务。

14373f20d52ff57d85e52bf2dfe9ec24.png

增强 Materialzed MySQL 实现方案

社区版 Materialzed MySQL 介绍

物化 MySQL 将MySQL的表映射到 ClickHouse 中, ClickHouse 服务会读取binlog,并执行 DDL 和 DML 的请求,实现了这种基于实现了基于 MySQL binlog 的实时 CDC 同步。它的架构很简单,不依赖于数据同步工具,使用自身的资源就能将整个 MySQL 的数据库同步到 ClickHouse中,并且时效性很好,因为实时同步的延时一般在秒级、毫秒级到秒级之间。

社区版本的这种物化MySQL 在很大程度上去解决了 MySQL 数据库到 ClickHouse 之间的这种实时同步。在实际业务、实际场景中,遇到不少问题

1.社区版本的物化MySQL,它是不支持同步到分布式表,也不支持跳过DDL,缺乏这些功能就很难将数据库的引擎应用到实际生产中。

2.社区版本的物化 MySQL 不支持在数据同步发生异常时进行辅助,发生异常的时候发起重新同步的命令,它没有同步的日志信息和没有同步的状态信息,缺少了这些信息会导致同步发生异常的时候,很难在短期内把这些任务重新启动。

基于这些问题和痛点, ByteHouse在社区版本的物化 MySQL 的基础之上做了一些功能增强易用性,降低了运维成本,让数据的同步更加稳定。

f33a4915316704180584a1dcc7cf5aec.png

ByteHouse的物化 MySQL 结合了HaUniqueMergeTree表引擎:结合这样的表引擎之后,它就能够实现数据的实时去重能力,同时它能够支持分布式的能力,我们通过底层的中间的参数优化,比如 include tables、 exclude tables、 SKIP DDL 等等能够允许用户自定义同步的表的同步范围。

通过下 model 这样的一个参数,能够支持分布式表的同步,然后通过 Rethink 参数的设置支持将额外增加的表启动独立的数据同步任务去进行 CDC 同步,在出现异常的时候,我们也支持跳过这种不支持的 DDL 语句。另外,可以通过系统日志的抓取和展示进行可视化的运维。

b55fa18ca669b3383c9667b84253d45d.png

ByteHouse增强Materialzed MySQL核心功能实现

  • 实时去重 / 分布式

社区版的物化 MySQL 使用的是ReplacingMergeTree,每一个同步任务都会将源端的 MySQL 数据库同步到 ClickHouse 的某一个节点上面,它不支持按照分片逻辑将数据分布到所有的节点,也无法利用 ClickHouse 整个集群的分布式计算和存储能力,所ByteHouse的物化MySQL 支持分布式地同步利用。我们利用HaUniqueMergeTree表引擎,将每张表同步到对应的分布式节点上,充分利用集群的这种分布式计算能力,同时通过表引擎的实时 upsert 能力来实现快速地去重。

2a53fab2322eb3f290bb8d7cbcc870b8.png
  • 异步 Resync

这里有三个对象, SYNC manager是用来管理主 SYNC 线程和 Resync 线程,然后 SYNC task 和 resync task 各自管理各自的任务。比如说一个 MySQL 的库有 100 张表,我们选了 50 张表进行同步,所以在同步进行过程中,当 think task 同步到 binlog 的 position 位置,比如到 1000 的时候,用户修改了配置之后,它增加了 30 张表。增加 30 张表的时候, SYNC manager 就会启动 Resync task 去同步另外 30 张表,那这个时候 SYNC task 是继续执行的;RESYNC task 会从 position 0 开始,它先做全量的同步,然后再做增量的同步。所以当到达某一个阶段,比如说 sync task 跑到了 position 1500 的时候, resync task 跑到了 position 1490 的时候,这时 SYNC manager 就会去判断两者的误差,这个 position 的误差在一定的阈值之内,在一定阈值之内之后,它会将 SYNC task 停止1秒钟,将 RESYNC task 合并到 SYNC task 中。合并成功之后,这 80 张表就都会通过SYNC task 继续同步,而 RESYNC task 这个任务就会被停止掉。这就是现在 RESYNC task 做了一个能力实现。

65376143e24d75201374df9a54d91781.png
  • 可视化运维

通过可视化的任务监控和任务启停异常的重启任务告警这些方式实现了物化 MySQL 的可视化易用性的极大提升。

a8c895e43093b20d3282ff01fb00aa91.png

案例实践与未来展望

案例一:短视频直播

该场景下的数据是批流一体写入,为了维护和管理抖音创作者的数据,并且面向这种业务运营和达人经营提供数据查询服务,需要将短视频和直播的实时数据和离线数据做融合,来构建 B端的数据分析。

  • 问题: 首先,创作者是唯一的,需要我们进行数据去重。第二,数据源是比较多样化的,所以它整个字段超过 4000 +,是典型的大宽表场景。第三,T+1的数据,T+1数据离线同步后,T+0数据要对它进行更新。第四,是对任何指标的实时查询需要秒级出结果,这是业务面临的问题。

  • 解决方案: 第一,我们采用了自研的 Unique表引擎来做实时的去重,并且能够让数据在写入时就可以实时去重、实时查询。第二,通过 Kafka 引擎的 memory table 来实现大宽表数据先缓存,到达了一定的批次之后再集中刷盘。通过对 Byte house 的优化方案有效地解决了碎片化、IO负载高的问题,能够支持 10 亿+创作数据实时写入和实时查询。

5c2d8acabca7418bdbac43760951f4f3.png

案例二:营销实时数据的监控

营销实时监控是对业务营销活动效果的实时查询和实时回收,希望通过这种实时回收来动态调整奖品的实时发放策略来做到最终的 IOR、ROI 的提升。这就要求数据实时写入、落盘延时要非常低,对数据处理的性能也有很高的要求。在数据传送上面需要保证数据传输的唯一性,以保证奖品不会重复发放,也不会丢失。

  • 解决方案: 我们在方案上首先采用自研的Kafka 引擎来支持流式数据的实时写入,实时写入便实时入库。通过 low-level 的这种消费来保证数据的有序分片,再通过增强的消费语义 exactly once 保证数据的精准一次传输。最后我们通过自研的Unique引擎来实现实时的这种 upsert 的语义,让数据实时写入、实时去重。通过 ByteHouse 方案的优化,营销业务的每一个节点的实时性能达到了 30 MB/s/node,分析性能也是在秒级的延时,让运营人员能根据不同用户群,实时发放奖励,并秒级地监控奖品发放的进展,从而调整奖品的发放策略。

69fca8fcca854a78dd6da68aba65dd28.png

案例三:游戏广告的数据分析

游戏广告数据分析是在广告业务中会做一些人群圈选、广告投放、效果反馈等投放策略,用户行为预测这些全流程的统计和监控来实现广告营销过程的数字化,提升整个广告游戏投放的 ROI 。

  • 问题: 业务数据和日志数据要求实时写入、实时去重,由于体量比较大,所以写入压力和查询压力都比较大。

  • 解决方案: 首先使用 Kafka 引擎来支持流式数据写入,通过 low level 消费模式保障数据的有序分片,再通过 Unique引擎来实现数据的唯一性,并且实时地去重。在业务数据方面,我们使用物化MySQL来保障业务数据从 MySQL 到 ByteHouse 之间能够实时同步。最后使用自研的查询优化器来优化查询性能,通过ByteHouse 的优化之后,广告效果分析从原来的小时级提升到了现在的秒级延时数据查询的性能,单线程同步 20+MB/s ,并且整个查询性能提升了 3 倍,用户的收益和体验得到了明显的改善。

5b3d38baa0c51dfeb775da6579205b7c.png

未来战略:全链路和一体化

  • 端到端。从语法转换、数据迁移,到数据校验,形成完整的全链路方案。

  • 一体化。通过DES的逻辑复制能力实现 TP / AP 的一体化,同时实现数据仓库和数据集市的一体化。

  • 资源隔离。支持用户使用共享资源池或者数据库引擎来进行数据的同步,也支持用户通过独享的资源池来进行高效数据同步。

  • 多引擎方案。除了基于 ClickHouse 引擎的基础能力,我们也会去探索更多的底层引擎能力来增强ByteHouse的数据同步。

打造企业级智能问答系统的秘密:如何使用云数据库 PostgreSQL 版实现向量检索

本文就如何利用火山引擎云数据库 PostgreSQL 版和大语言模型技术(Large Language Model,简称 LLM),实现企业级智能交互式问答系统进行介绍。通过本文,你将会了解交互式问答系统的原理,学习 PostgreSQL 的向量化存储和检索技术,以及大语言模型交互技术等。

背景

在大数据的浪潮下,众多企业建立了自己的知识库,以便于信息检索和知识查询。然而,随着知识库内容的膨胀,传统的信息检索方式变得低效,经常出现费时费力且结果不尽人意的情况。随着生成式人工智能(AI Generated Content,简称 AIGC)的出现,人们看到了一种更智能的实现方式,通过问答的方式,知识获取的效率、准确性和用户体验在多方面得到提升。

即便如此,对于特定垂直领域的企业,生成式人工智能的局限性也开始显现,例如大模型训练周期长、对某一领域专业知识掌握不足等,这常常会导致 AI“幻觉”问题的出现(即 AI 的“一本正经地胡说八道”)。为了解决这一难题,我们通常会采用以下两种方式:

  • Fine Tune 方法,“驯服”大语言模型。

    • 利用领域知识,对大语言模型进行监督微调(Supervised Fine Tune)和蒸馏(Distillation)。这种方式可塑性强,但需要大量的算力和人才资源,综合成本高。此外,企业还需要持续监控和更新模型,以确保与不断变化的领域知识保持同步。

  • Prompt Engineering 方法,改变“自己”。

    • 该方法基于向量数据库,补充足够的对话上下文和参考资料,完善与大语言模型进行交互的问答问题(Prompt),其本质是将大语言模型的推理归纳能力与向量化信息检索能力相结合,从而快速建立能够理解特定语境和逻辑的问答系统。该方法的实现成本相对较低。

接下来,本文针对 Prompt Engineering 方法,来演示将云数据库 PostgreSQL 版作为向量数据库的使用方法。

核心概念及原理

嵌入向量(Embedding Vectors)

向量 Embedding 是在自然语言处理和机器学习中广泛使用的概念。各种文本、图片或其他信号,均可通过一些算法转换为向量化的 Embedding。在向量空间中,相似的词语或信号距离更近,可以用这种性质来表示词语或信号之间的关系和相似性。例如,通过一定的向量化模型算法,将如下三句话,转换成二维向量(x,y),我们可通过坐标系来画出这些向量的位置,它们在二维坐标中的远近,就显示了其相似性,坐标位置越接近,其内容就越相似。如下图所示:

“今天天气真好,我们出去放风筝吧”
“今天天气真好,我们出去散散步吧”
“这么大的雨,我们还是在家呆着吧”
f0c827048f9329a4477c54e337cd9dd9.png

Prompt Engineering 过程原理

如上所说,使用者需要不断调整输入提示,从而获得相关领域的专业回答。输入模型的相关提示内容越接近问题本身,模型的输出越趋近于专业水平。通俗理解就是,模型能够利用所输入的提示信息,从中抽取出问题的答案,并总结出一份专业水准的回答。整个 Prompt Engineering 工作流程如下图所示:

c79b683824ac1b92bf324b935cd54b2e.jpeg

其大致可以分为两个阶段:向量构建阶段和问答阶段。在向量构建阶段,将企业知识库的所有文档,分割成内容大小适当的片段,然后通过 Embeddings 转换算法,例如 OpenAI 的模型 API(https://platform.openai.com/docs/guides/embeddings/what-are-embeddings),将其转换成 Embeddings 数据,存储于云数据库 PostgreSQL 版向量数据库中,详细流程如下图所示:

d2860632bbc52a861981ecec844d3f8c.png

在问答阶段,问答系统首先接收用户的提问,并将其转化为 Embedding 数据,然后通过与向量化的问题进行相似性检索,获取最相关的 TOP N 的知识单元。接着,通过 Prompt 模板将问题、最相关的 TOP N 知识单元、历史聊天记录整合成新的问题。大语言模型将理解并优化这个问题,然后返回相关结果。最后,系统将结果返回给提问者。流程如下图所示:

c5245476fdd03d97e4a9e93253ca074c.png

实现过程

接下来将介绍如何利用云数据库 PostgreSQL 版提供的 pg_vector 插件构建用于向量高效存储、检索的向量数据库。

前置条件

  • 已创建 ECS 实例,或者使用本地具备 Linux 环境的主机,作为访问数据库的客户端机器。

  • 请确保您具备 OpenAI Secret API Key,并且您的网络环境可以使用 OpenAI。

训练步骤

本文将以构建企业专属“数据库顾问”问答系统为例,演示整个构建过程。使用的知识库样例为https://www.postgresql.org/docs/15/index.html,脚本获取方式详见文末。

搭建的环境基于 Debian 9.13,以下方案仅供参考,环境不同依赖包安装有所差异。

以下过程包括两个主要脚本文件,构建知识库的 generate-embeddings.ts,问答脚本 queryGPT.py,建议组织项目目录如下所示:

.
├── package.json                              // ts依赖包
├── docs
│   ├── PostgreSQL15.mdx                      // 知识库文档
├── script
│   ├── generate-embeddings.ts                // 构建知识库
│   ├── queryGPT.py                           // 问答脚本

1. 学习阶段

1. 创建 PostgreSQL 实例

登录云数据库 PostgreSQL 版控制台(https://console.volcengine.com/db/rds-pg)创建实例,并创建数据库和账号。关于创建 PostgreSQL 实例、数据库、账号的详细信息,请参见云数据库 PostgreSQL 版快速入门(https://www.volcengine.com/docs/6438/79234)。

2. 创建插件

进入测试数据库,并创建 pg_vector 插件。

create extension if not exists vector;

创建对应的数据库表,其中表 doc_chunks 中的字段 embedding 即为表示知识片段的向量。

-- 记录文档信息
create table docs (
  id bigserial primary key,
  -- 父文档ID
  parent_doc bigint references docs,
  -- 文档路径
  path text not null unique,
  -- 文档校验值
  checksum text
);
-- 记录chunk信息
create table doc_chunks (
  id bigserial primary key,
  doc_id bigint not null references docs on delete cascade, -- 文档ID
  content text, -- chunk内容
  token_count int, -- chunk中的token数量
  embedding vector(1536), -- chunk转化成的embedding向量
  slug text, -- 为标题生成唯一标志
  heading text -- 标题
);
3. 构建向量知识库

在客户端机器上,将知识库文档内容,分割成内容大小适当的片段,通过 OpenAI 的 embedding 转化接口,转化成embedding 向量,并存储到数据库,参考脚本获取方式详见文末。

注意 该脚本只能处理 markdown 格式的文件。

安装 pnpm:

curl -fsSL https://get.pnpm.io/install.sh | sh -

安装 nodejs(参考https://github.com/nodesource/distributions):

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=16
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update
sudo apt-get install nodejs -y

安装 typescript 依赖,配置文件 package.json 获取方式详见文末:

pnpm run setup
pnpm install tsx

修改 generate-embeddings.ts,设置 OpenAI 的 key、PG 的连接串以及 markdown 文档目录:

#这里需要将user、passwd、127.0.0.1、5432 替换为实际数据库用户、密码、数据库地址、端口
const postgresql_url = 'pg://user:passwd@127.0.0.1:5432/database';
const openai_key = '-------------';
const SOURCE_DIR = path.join(__dirname, 'document path');

运行脚本,生成文档 embedding 向量并插入数据库:

pnpm tsx script/generate-embeddings.ts

运行过程:

af2a3f118e1775fe373b8ee77cfb7122.png

脚本运行后,我们查看下所构建的知识库。查询 docs 表:

3fef4ee682bee002a3e41ae5e8ddb41a.png

查询 docs_chunk 表,批量导入向量成功:

e7b12d787ed81eb6ca93f6dcc7ad0b76.png

2. 问答阶段

1. 创建相似度计算函数

为了方便应用使用,使用 PostgreSQL 的自定义函数功能,创建内置于数据库内的函数。应用只需调用 PostgreSQL,该函数便可在应用程序中获取向量匹配结果。示例中使用“内积”来计算向量的相似性。

create or replace function match_chunks(chunck_embedding vector(1536), threshold float, count int, min_length int)
returns table (id bigint, content text, similarity float)
language plpgsql
as $$
begin
  return query
  select
    doc_chunks.id,
    doc_chunks.content,
    (doc_chunks.embedding <#> chunck_embedding) * -1 as similarity
  from doc_chunks

  -- chunk内容大于设定的长度
  where length(doc_chunks.content) >= min_length

  -- The dot product is negative because of a Postgres limitation, so we negate it
  and (doc_chunks.embedding <#> chunck_embedding) * -1 > threshold
  order by doc_chunks.embedding <#> chunck_embedding
  
  limit count;
end;
$$;
2. 提问及回答

以下 Python 程序,可以接收提问者问题,并实现上述 Prompt Engineering 的“问答阶段”的功能,最终将具备“逻辑思考”+“深度领域知识”的解答,发送给提问者。

import os, psycopg2, openai

def query_handler(query = None):
    if query is None or query == "":
        print('请输入有效问题')
        return

    query = query.strip().replace('\n', ' ')
    embedding = None
    try:
        # 使用 GPT 将提问转化为 embedding 向量
        response = openai.Embedding.create(
            engine="text-embedding-ada-002",  # 固定为text-embedding-ada-002
            input=[query],
        )
        embedding = response.data[0].embedding
    except Exception as ex:
        print(ex)
        return

    content = ""
    con = None
    try:
        # 处理 postgres 配置,连接数据库
        # host:127.0.0.1,port:5432,user:test,password:test,database:test
        params = postgresql_url.split(',')
        database, user, password, host, port = "test", "test", "test", "127.0.0.1", "5432"
        for param in params:
            pair = param.split(':')
            if len(pair) != 2:
                print('POSTGRESQL_URL error: ' + postgresql_url)
                return
            k, v = pair[0].strip(), pair[1].strip()
            if k == 'database':
                database = v
            elif k == 'user':
                user = v
            elif k == 'password':
                password = v
            elif k == 'host':
                host = v
            elif k == 'port':
                port = v
        # connect postgres
        con = psycopg2.connect(database=database, user=user, password=password, host=host, port=port)
        cur = con.cursor()
        # 从数据库查询若干条最接近提问的 chunk
        sql = "select match_chunks('[" + ','.join([str(x) for x in embedding]) + "]', 0.78, 5, 50)"
        cur.execute(sql)
        rows = cur.fetchall()
        for row in rows:
            row = row[0][1:-2].split(',')[-2][1:-2].strip()
            content = content + row + "\n---\n"

    except Exception as ex:
        print(ex)
        return

    finally:
        if con is not None:
            con.close()

    try:
        # 组织提问和 chunk 内容,发送给 GPT
        prompt = '''Pretend you are GPT-4 model , Act an database expert.
        I will introduce a database scenario for which you will provide advice and related sql commands.
        Please only provide advice related to this scenario. Based on the specific scenario from the documentation,
        answer the question only using that information. Please note that if there are any updates to the database
        syntax or usage rules, the latest content shall prevail. If you are uncertain or the answer is not explicitly
        written in the documentation, please respond with "I'm sorry, I cannot assist with this.\n\n''' + "Context sections:\n" + \
        content.strip().replace('\n', ' ') + "\n\nQuestion:"""" + query.replace('\n', ' ') + """"\n\nAnswer:"

        print('\n正在处理,请稍后。。。\n')
        response = openai.ChatCompletion.create(
            engine="gpt_openapi",  # 固定为gpt_openapi
            messages=[
                {"role": "user", "content": prompt}
            ],
            model="gpt-35-turbo",
            temperature=0,
        )
        print('回答:')
        print(response['choices'][0]['message']['content'])

    except Exception as ex:
        print(ex)
        return

os.environ['OPENAI_KEY'] = '-----------------------'
os.environ['POSTGRESQL_URL'] = 'host:127.0.0.1,port:5432,user:test,password:test,database:test'
openai_key = os.getenv('OPENAI_KEY')
postgresql_url = os.getenv('POSTGRESQL_URL')
# openai config
openai.api_type = "azure"
openai.api_base = "https://example-endpoint.openai.azure.com"
openai.api_version = "2023-XX"
openai.api_key = openai_key

def main():
    if openai_key is None or postgresql_url is None:
        print('Missing environment variable OPENAI_KEY, POSTGRESQL_URL(host:127.XX.XX.XX,port:5432,user:XX,password:XX,database:XX)')
        return
    print('我是您的PostgreSQL AI助手,请输入您想查询的问题,例如:\n1、如何创建table?\n2、给我解释一下select语句?\n3、如何创建一个存储过程?')
    while True:
        query = input("\n输入您的问题:")
        query_handler(query)
        
if __name__ == "__main__":
    main()

先修改 90、91 行的 OpenAI 的 key 和 PG 的连接串,为实际 key 和连接地址:

os.environ['OPENAI_KEY'] = '-----------------------'
os.environ['POSTGRESQL_URL'] = 'host:127.0.0.1,port:5432,user:test,password:test,database:test'

然后修改 GPT 的参数:

openai.api_type = "azure"
openai.api_base = "https://example-endpoint.openai.azure.com"
openai.api_version = "2023-XX"

其次通过修改机器人自我介绍,以让提问者快速了解问答机器人的专业特长,这里的自我介绍,说明机器人是一个数据库专家的角色。

prompt = '''Pretend you are GPT-4 model , Act an database expert.
        I will introduce a database scenario for which you will provide advice and related sql commands.
        Please only provide advice related to this scenario. Based on the specific scenario from the documentation,
        answer the question only using that information. Please note that if there are any updates to the database
        syntax or usage rules, the latest content shall prevail. If you are uncertain or the answer is not explicitly
        written in the documentation, please respond with "I'm sorry, I cannot assist with this.\n\n''' + "Context sections:\n" + \
        content.strip().replace('\n', ' ') + "\n\nQuestion:"""" + query.replace('\n', ' ') + """"\n\nAnswer:"

最后安装脚本依赖:

pip install psycopg2-binary
pip install openai
pip install 'openai[datalib]'

测试过程:

197d1b6314fb879eeb4b439440a8f0df.jpeg

到此为止,您就获得了一个企业级专属智能问答系统。

方案优势

相较于其他向量数据库,借助火山引擎云数据库 PostgreSQL 版提供的 pg_vector 插件构建的向量数据库具有如下优势:

  • 使用便捷易上手: 无需专业 AI 专家介入,无需构建其他大规模复杂分布式集群,只需要一个数据库实例,便可构建专用向量数据库。使用接口兼容现有 SQL 语法,不需要定制化调度框架、终端。

  • 性价比高: 可使用已有数据库实例,不需要额外购买其他庞大的集群资源。

  • 数据实时更新可用: 向量数据可以在毫秒级实现新增、更新,并且依然具备事务属性,无需担心数据的错乱。

  • 支持高并发,扩展容易: 在向量化场景可支持数千 TPS;在性能出现瓶颈时,可以通过一键扩展只读节点,轻松实现整体吞吐的瞬间提升。

  • 支持向量维度高: pg_vector 还具备支持向量维度高的特点。最多可支持 16000 维向量,能够满足绝大部分向量化存储、使用场景。

关注「字节跳动数据库」公众号回复【脚本】即可获取参考代码。

点击下方阅读原文,了解云数据库PostgreSQL版。

❌