动手点关注 干货不迷路
2016 年 9 月 26 日,抖音 1.0.0 版本上线,截至目前,抖音日活跃用户突破 6 亿,短短 6 年间,抖音实现了从零开始的爆发性增长。在业务快速发展、数据海量增长、视频/直播形式对画质提出更高要求的背景下,抖音基础技术团队如何以技术革新应对时代变局、以匠人之心优化用户体验?在不被外界“看好”的 iOS 开发领域,抖音团队又交出了一份怎样的答卷?
1 月 22 日下午,第三期字节跳动技术沙龙以线上直播的方式与广大观众见面。本次沙龙以《抖音 iOS 基础技术大揭秘》为主题,邀请了陈显财、陈文欢、舒彪、韩建磊、朱峰 5 位抖音 iOS 客户端工程师,从不同角度阐释了亿级 App 抖音在 iOS 客户端开发方面的实践经验,为近 4 万名在线观众带来了一场扎根实践、面向前沿的技术盛宴。
架构的优劣决定了工程的规模和效率。抖音基础技术 iOS 客户端架构师陈显财老师介绍了抖音团队如何在不影响业务迭代和业务规模扩张的前提下,持续推进抖音从模块化、组件化、到插件化的架构演进历程。
面对开发初期代码体量膨胀、业务规模扩大、开发人员增加与业务正常迭代的矛盾,技术团队首先考虑从提升效率的角度出发,从主工程中剥离出依赖工程、环境配置和 App 资源,并设计了底层代码的基础依赖能力,形成一个壳工程。团队还从源码环境资源工具的角度设计了相对统一的模板,使得模块能够基于统一的标准进行创建和开发。
在研发流程和工具量上,团队支持了多仓 MR的开发,为本地研发和 CI/CD 搭建了基本的研发环境。进而,团队依据模块标准对整个工程进行模块化拆分,并确保大部分模块可以编译为二进制目标。模块化目标的实现在提升效率的同时,也为后续架构的持续演进奠定了基础。
在业务持续发展的背景下,单个业务模块内的代码也在加速膨胀;模块化拆分后,不合理的接口依赖有待进一步分析治理;iOS 新增的扩展插件和基础能力推动 Swift 混编落地成为必然……在此背景下,技术人员开启了组件化进程,降低团队整体的研发能效。
为解决代码复用问题、降低依赖复杂度,团队重新定义了抖音的 5 级架构分层:
壳工程;
业务层;
接口层;
服务层;
基础层;
这一架构分层将模块化带来的网状依赖结构改造成树状依赖结构,降低了依赖的复杂度,保障各层之间的依赖不劣化。
针对成百上千个组件的依赖管理,技术团队打破了常规做法“依赖打平”的局限,用容器化方案予以改进。一个版本的容器包括壳工程、依赖列表、依赖变更记录、整体构建历史、产品发布聚合信息。针对依赖链缺失或断开的情况,通过求差集的方式,在 mr 子仓中校验依赖缺失问题,防止工程依赖关系劣化。
同时,基于新的分层架构,技术人员定义了每一层组件的依赖规范,以防止不合理的循环依赖,保证整体依赖不劣化。在分层依赖规范中,高层可以依赖低层、实现可以依赖接口,接口层没有依赖,且优先以前向声明为主。终于,抖音在经过多个版本的迭代优化后,各组件的依赖度明显下降。
二进制化带来的另一个问题是接口层的变更。为应对接口冲突带来的二进制污染问题,技术人员结合主干的语法树信息,通过 mr 直接检查真实的调用量,每天拦截二进制污染的问题大概在 10%左右,有效保障了团队整体开发的稳定性。
为应对配置问题、环境问题、异步 mr 接口调用和冲突等问题对主干稳定性的冲击,技术人员引入了RC(Release Candidate)分支,合并多个 mr 代码,代码经过检查之后进入稳定性主干,从而规避了本地编译失败、CI 出包失败等问题。
解决了稳定化问题后,新增业务仓不断拆分仓库也成为影响开发效率的问题。技术人员引入了单仓多组件——一个仓库内基于分层架构可以增加多个组件而不用拆仓。同时,还在接口层上隔离了 Swift 和 OC 代码,以规避组件间的编译依赖传递。
为提升整体研发能效,团队还提供了一套基于二进制的代码隔离方案,通过绑定适配器协议和获取适配器协议的方法,把业务差异化的代码通过适配器隔离到二进制中。同时,建设了相关的基础配套设施来监控代码变更,使多个 App 的影响可以被感知量化。
在组件化演进进程中,抖音的业务规模持续扩大,组件数量从最初的 100+增至 800+,二进制化已经无法满足效率层面的提升需求。与此同时,团队在效率、质量、成本方面均迎来新的挑战。
在此背景下,为了提高线上性能和本地效率,技术人员开启了静态二进制向动态二进制改造的进程。在业务懒加载场景,技术人员将非首页业务代码及其独占的基础库依赖直接打成动态库进行懒加载;此外,专项代码通过动态库进行隔离,在 iPad 定制业务、大业务块重构等具体场景中发挥作用。
为降低底层依赖复杂度、提升代码质量,团队还设计了服务框架,支持把抽象的接口绑定到具体的实现上,并支持实现的热切。这项框架极大满足了解耦服用、动态部署、服务组合、编译期抹平底层语言差异、运行期支持服务热切等方面的能力需求。
此外,技术人员还在本地多模式研发等方面做出了积极探索。
自动化测试与持续集成对于保障软件工程质量具有重要价值,也是大型项目增量式开发的保障手段之一。抖音基础技术 iOS 客户端工程师陈文欢老师介绍了抖音 iOS 自动化如何做到容器化和规模化服务,以及其中涉及到的一些技术挑战和解决方案。
容器化测试一方面是为了测试稳定性的提升,另一方面也能隔离不同测试任务间的环境影响。在抖音 iOS 容器化建设的服务分层架构中,最底层的机架平台提供了抽象的机器管理和控制能力。基于此,技术人员搭建了包括单元测试、UI 测试等在内的专项测试服务,平台侧还提供了数据报表消费和一些业务管理的能力。同时,技术人员还基于公司的组件化现状和不同的 CI 系统,接入了研发环境和 CI 工具链。整套架构的运行,使公司众多的项目组件得以使用一些通用的测试服务,目前已应用于抖音、直播中台、今日头条等大型项目中。
在机架平台的服务隔离方案中,技术人员采用了 Linux 集群下的 Docker 方案(下图左侧),Docker 镜像中包含了一些测试用例和工具链。通过该方案,机架环境得以相互独立运行,并且支持了快速部署和管控的能力。下图右侧是技术人员在 iOS 设备上使用的核心服务,顶层是字节工程师自研的 iOS 后台 runner 进程,用于接受设备控制指令并与 iOS 底层服务进行通信。此外,还包括 installation proxy、debugserver 等进程。Docker 镜像与 iOS 设备间的交互则通过 USB 协议与 lockdownd 服务进行通信。
设备控制离不开 UI 交互。常见的点击操作、滑动手势、弹窗控件、键盘输入、前台唤起等都是自动化测试中需要使用到的基础能力。基于 XCTest 系统库,测试代码被集成到一个特殊的 App 中(称之为 UI runner),从而安装到测试设备上开始执行。陈文欢老师以XCTest 模拟点击 home 键的 API为切入点,详细分析了 iOS 设备的控制机制。
在 XCTest 模拟点击 home 键的实例中,NSXPCConnection是一个值得关注的名词。NSXPCConnection 是苹果提供的进程间双向通信方式,一个进程可以创建 listener 监听其他进程的请求。技术人员在运行时通过打印 NSXPCConnection 的实例信息,发现其指向了com.apple.testmanagerd服务,其对应的二进制为testmanagerd。从 testmanagerd 启动之后的 main 函数过程可以看到,它注册的服务名称也为 com.apple.testmanagerd,与运行分析的结果保持一致。接下来,如果我们能通过观察 Xcode 工具链驱动测试时 testmanagerd 的 NSXPCConnection 调用过程,便能了解到整个Xcode 工具链驱动测试的机制。
通过分析 xpc message 的完整协议交互过程,大体上可以发现其使用了两组协议,一组是以 XCT 开头的 XCTest 协议,其作用是直接调用 testmanagerd 的 UI 交互能力;另一组是IDE 开头的,为 Xcode 工具链进行白名单授权的过程。
在字节自研的 iOS 设备控制链方案中,启动一个 App 并通过它的_IDE_authorize 协议授权 App 进程,其 PID 添加到 testmanagerd 接口使用白名单列表中,从而使 App 能通过跨进程调用的方式直接使用 testmanagerd 中所有关于设备控制的接口。
2020 年 11 月,苹果自研 M1 芯片发布,并能在 M1 芯片上运行 iOS 程序。在此背景下,抖音团队开始探索在 M1 设备上进行测试,以期降低构建成本、为提升测试稳定性提供新可能。
如果在 M1 直接运行真机包测试,会面临 App 运行签名校验、同一个 bundleld 只能对应运行一个 App、没有 home 键且屏幕大小固定、机型和版本固定等局限,这些问题都会制约机架的规模化测试。因此,在 M1 模拟器上运行真机包测试,成为技术团队着力探索的方向。
面对模拟器启动时带来的Binary with wrong platform错误,技术人员经过验证后采用了类似 IPAPatch 的处理方式,在编译产物生成后添加了后置处理过程,增加 macho 修改,注入/修改 LC_BUILD_VERSION 字段进行兼容,最终使抖音真机包能够在 M1 模拟器上顺利运行。
此外,陈文欢老师还以Metal framework 适配为例,介绍了系统库适配问题的处理思路和应对方案。
JoJo 是字节自研的以 bazel 为核心的 iOS 构建系统,提供了从 CI/CD 到本地构建开发所需要的一整套解决方案。抖音基础技术 iOS 开发工程师舒彪老师从 JoJo 和 bazel 的关系出发,介绍了 JoJo高性能、高可扩展性、多工程架构支持、多 IDE 支持的四大特性,揭示了 JoJo 助力抖音、今日头条等亿级 App 构建效能提升 40%的奥秘所在。
构建的核心是由许多个不同的任务及其相互依赖组成的。在构建系统中通常会有这样一个要求:即,针对某一个任务,在资源、参数、工具相同的情况下,应该产出固定的产物。基于此,构建系统便能够建立一个单任务级别的缓存复用,从而大大加快构建性能。
实现编译缓存机制的核心问题是构建任务的依赖计算。与一般的构建系统不同,JoJo 将远端缓存、远端执行和依赖计算结合了起来。JoJo 在本地构建时,实现了类似 Xcode 的增量构建方案——通过上次构建的 C 系或 Swift 系源码后编译器生成的.d 文件来获取构建需要用到的所有文件,从而进行依赖计算。这里的.d 文件是一种依赖描述文件,在编译器完成一次构建后,就会生成.d 文件,用以描述本次构建过程中所涉及到的所有文件。
在 JoJo 中,技术人员基于 clone 和 Swift 的编译器实现对 C 系和 Swift 系代码进行快速依赖计算的工具,2000+C 系文件扫描在数秒内即可完成、Swift 系代码也可实现类似性能。由此,JoJo 在保证正确性的前提下,又几乎不会带来 overhead,实现了正确而又快速的缓存复用体验。
此外,在 JoJo 构建系统中,通过分布式缓存和构建集群来提速构建。对于每一个构建的子任务,JoJo 会根据其依赖计算出一个 key,然后再通过这个 key 去远端缓存服务器查询已有产物,如果匹配成功,则下载产物、文件输出,子任务完成;如果未命中,JoJo 就会真正调用相关工具进行一次构建,在本地或远端执行。为避免从本地上传相关的资源文件到远端集群,JoJo 会通过内部高速网络从缓存服务器下载所需文件,本地只需传输一份清单给集群。远端集群本身可以扩展,可以是 Mac 机器,也可以是 Linux 机器,使集群可扩充性大大提升。最终便形成了一个完整的分布式构建体系。
具体到工程师个人的构建场景,由于网速、本机性能的不同,整体任务调度的需求也充满了变数。为此,JoJo 实现了一个智能的调度系统,不同于 Xcode 有固定的任务并发数的限制,JoJo 可通过网络、CPU、集群资源的差异来动态地调整调度策略。此外,在网络传输数据时,JoJo 还会实时测速,根据本机 CPU 性能情况来决定是否熔断远端机制。这些都进一步确保了分布式构建体系的稳定性和性能。
JoJo 以 bazel 作为核心引擎的同时,又重写和新建了大量 rules,使其摆脱对 bazel 的完全依赖。在实践中,JoJo 将单元测试、静态分析、动态库懒加载、索引构建等流程作为旁路,使相关任务也可以被构建系统自动管理、自动缓存。
bazel 自带的 query 命令和 aspect 机制为 JoJo 赋予了灵活的数据查询能力,使工程师可以自由获取包括构建参数、依赖信息等在内的任何编译信息,而这些信息也可以被构建流程中的另一个 rules 所消费,从而实现了动态的构建能力。
目前常见的仓库管理机制有 Monolith、Multirepo、Monorepo。JoJo 被设计为可以扩展地支持任何架构。目前,JoJo 支持标准 cocoapods 工程直接构建,而无需进行任何业务改造,抖音即以这样的模式来运行。今日头条则采用 Monorepo 进行业务管理、第三方库和基础库继续使用 cocoapods 管理的混合构建模式。同时,JoJo 也在尝试制定公司内部的Monorepo 开发标准范式,以一站式解决学习成本和迁移成本。
对于不同架构,JoJo 通过扩展一个新的规则来支持不同的架构描述,对于某个具体的架构,相关规则才会负责具体的处理工作,这些都会统一转化为一层中间层进行表示。这层中间表示会抽象地描述静态库动态库的构建、依赖关系等。最终,JoJo 通过中间层来生成最后的产物完成构建。由此,便实现了对多架构与混合模式的支持。
JoJo 本身支持多种 IDE,舒彪老师以 Xcode 为例,介绍了Xcode 下使用 JoJo 构建的方式。为使业务同学切换到 JoJo 后尽量无感,技术团队经过研究后自研了部分逻辑,通过一定手段完全接管了 Xcode 的索引、调试、日志、进度条等功能。由此,在 JoJo 的体系下,Xcode 工程彻底转变为“前端”的角色,只需浏览工程文件及目录结构即可,所有底层任务均由 JoJo 完成,业务体验也基本接近原生体验。
经过改造后,Xcode 直接与 JoJo 的 Build Service 通信,JoJo Build Service 又会调用 JoJo 来进行构建,同时提供构建进度、日志、编译、参数等数据给 Xcode 进行消费。其他不相关请求则继续转发给 XCB Build Service 来处理。更进一步,JoJo 还hook 了 SK Agent 的索引构建进程,这样技术人员就可以使用 JoJo 进行索引任务的构建,从而通过 JoJo 实现了全流程接管,并保证各功能间相互独立。
此外,技术团队还从索引缓存、二进制调试源码索引、引入智能分析系统进行错误提示优化与指引等方面,对 JoJo 进行了进一步优化,以更好地助力各项业务的发展。
目前负责抖音 iOS 客户端基础体验工作的韩建磊老师从具体可感的案例出发,为大家理清了流畅性相关的常见问题和优化策略,并结合实践经验为指标劣化问题提供了一定的排查思路和解决方案。
什么是流畅性?如果从场景来区分,包括页面刷新、动画、转场、弹窗、拖拽、滑动等在内的一系列操作,都属于流畅性的范畴。如果从用户体验的视角出发,对流畅性的理解可以包含视觉体验、触觉体验和听觉体验三大指标。总体来说,流畅性可以用来衡量用户在各场景下的交互体验。根据抖音技术团队的实践经验,流畅性优化至少可带来3%的观看时长收益和 6.6%的视频播放数量收益,流畅性优化与人均播放时长、页面渗透率、用户留存、广告营收等业务指标息息相关。
当前,抖音主要以丢帧和FPS作为衡量流畅性问题的核心指标。
韩建磊老师引导大家试想这样一个场景:某一天,线上核心指标 FPS 突然大幅劣化,问题应如何排查?
第一步:分析。从版本、渠道、场景等多维度进行分析,还可通过其他指标进行横向对比,找出问题点;
第二步:Debug 复现。使用 Time Profiler 等工具做排查,此时如果排查出了很多的耗时操作,那应该如何判断哪些是新增的、哪些是历史债呢?只能判定劣化,却无法确定劣化幅度怎么办?甚至在 Debug 下不能复现怎么办?这些问题能否提前拦截,不带到线上?
基于上述问题,技术团队研发了一套函数耗时监控系统,通过线上的大盘耗时对比,很容易定位到哪个函数发生了劣化及劣化的幅度是多少,帮助技术人员快速定位到新增的劣化函数,同时也无需考虑 Debug 能否复现的问题。
此外,技术人员还梳理了滑动、首刷等场景下各关键函数的调用,再通过汇编 Hook 的形式进行函数拦截,在主函数调用周期内,记录子函数的执行耗时,实现了既能采集各子函数耗时,又能采集内部的调用栈。同时,为使函数耗时监控系统能应用到各个场景,上层支持动态配置下发,还支持导出完整的调用链路,在达成监控目标的同时,将整体的性能损耗控制到最小。
在回顾了常见的优化策略后,韩建磊老师从帧率和卡顿这两类细节优化入手,针对具体 case 阐述了应对问题的方法论。
这里列举了 3 个帧率优化的 case。
第一个在滑动中调用了 self.xxViewControler.view, 而且这个 xxVC 是懒加载的,如果之前没有初始化过,在这里触发 vc 创建、调用 viewdidload 明显是不合理的;
第二个在拖拽时进行了 userDefaults 的读取,第一次读取时,会把 plist 里所有数据全部加载到内容,从而导致卡顿。这个问题虽然很简单,但实际开发中却经常出现。
第三个在 willDisplayCell 时做了遍历,根据字符串做匹配,如果数组很大(比如几百几千个),也可能会造成丢帧。
此外,韩建磊老师还以抖音的 3 个真实卡顿 case为例,介绍了技术团队在实战中应对卡顿问题的解决思路和优化路径。
在卡顿问题得到有效应对的基础上,类似丢帧问题的小劣化应如何拦截?反复出现的问题怎样防止再劣化?带着这些问题,团队通过问卷调研发现,流畅性问题劣化并不被高度重视,RD 对于存量问题的修复热情也远不及新增问题。在此背景下,技术人员又研发了一套精细化监控系统,在用户相对敏感的时机或场景,对一些常见的卡顿、耗时操作等 bad case 进行 hook 拦截、记录,并应用于防劣化平台。随着卡顿、帧率问题的优化数量增加,bad case 的录入数随之增加,精细化监控的质量随之提升、粒度也越来越细,由此形成了一套良性循环。
整体流程如上图,客户端通过动态库注入,把监控代码植入到宿主 App,然后执行自动化测试任务,当命中各个场景时,会进行数据和堆栈记录;任务结束后,统一进行符号化;进而上报防劣化后台,最后生成数据报表,触发报警或进行智能诊断。
此外,抖音技术团队还在慢函数、动画、耗时任务打散、低端机降级等方面有较多投入,以更好地满足用户的流畅性体验。未来,团队还将在UI/动画、架构、线程管控等方向继续探索,不断交出一份份关于流畅性、关于用户体验的满意答卷。
抖音基础技术 iOS 客户端工程师朱峰老师一直参与抖音 iOS 应用的稳定性优化与保障体系建设,他从稳定性的基础概念出发,详细解读了稳定性框架和核心指标,并畅想了稳定性优化的未来。
狭义上的Crash是指代码层面遇到的语言机制错误、CPU 访问异常、主动退出等问题;广义上的Crash则包括了内存过多被系统 kill(OOM)、主线程 block 被系统 kill(WatchDog)、CPU 过高被系统 kill 等问题,而这些都属于稳定性所关注的范畴。
谈及稳定性框架,不得不提的一个问题是启动任务。在 APM 的 SDK 初始化时机问题中,我们需要让大多数代码在监控 SDK 后再执行,这里就包含了 Crash 监控、WatchDog 监控、OOM 监控,与稳定性框架产生了直接关联。
在premain code的治理中,朱峰老师介绍了自定义 section 方式延后的方法。这种方法可以取代传统的+load 方法,但同时提醒大家,不能让 section 数量无限制扩张,否则可能超过系统 dyld 限制导致启动崩溃。
日志对于排查稳定性问题必不可少 。疑难问题往往出于堆栈不明确,此时通过日志查找分析 Crash 上下文的信息就显得十分重要。其中,日志记录上务必要基于 mmap,确保日志不会丢失,此外,抖音技术团队开发的日志分析工具也可以帮助开发人员显著提升分析效率。
针对 Objective C 异常、多线程 Crash、杀进程时 Crash、全系统调用栈、编译优化级别导致的 Crash 等常见问题,朱峰老师阐释了问题的形成机制及应对策略。
以应对全系统调用栈 Crash为例,一般的应对流程是,在查看日志分析上下文环境的基础上,进行逆向系统库代码,通过 swizzle/fishhook 绕过有问题代码,结合使用 CoreDump 分析,如果本地能复现问题,可以使用 Xcode Malloc Logging 查找地址分配调用栈。
在应对 Crash 问题时,除了疑难问题的排查,还有线上线下的长效应对机制。线下有 asan 自动化测试、灰度阶段的 monkey 自动化测试、集成阶段启动崩溃自动化测试等;线上则通过安全气垫、安全模式、coredump 等进行应对。
WatchDog常见的成因有文件 IO、网络 IO、CPU 密集、主线程和子线程共用锁等。对应的解决方案通常包括放入子线程, 业务逻辑适配回调形式,优化锁的粒度等。
OOM 是抖音技术团队在稳定性优化中面临的最严峻挑战之一。对抖音来说,出现 OOM 问题的数量是 Crash 的 2 倍多。用户使用时长增加导致内存占用、优化/劣化难以归因,加之低端机数量众多,这些因素都导致了应对 OOM 困难重重。
对于线上 OOM,技术团队主要运用MemoryGraph 机制(自研)、Matrix Memory Stat、大图片监控予以应对;线下部分则通过 MLeaksFinder、Xcode Leaks 自动化测试、AutoreleasePool 缺失自动化测试等进行规避。
朱峰老师对每一种解决思路和工具进行了针对性的讲解。
展望未来,抖音 iOS 应用的稳定性优化还将从框架、流程、静态动态分析等层面做出更多的探索和努力,为超大型 App 的建设保驾护航。朱峰老师最后鼓励大家,要永远保持对底层技术的兴趣和不断探索的热情,也不要对自己的成长设限。
每位讲师的分享结束后,在线观众纷纷通过评论区、弹幕与讲师互动交流。5 位老师都结合自己的专业方向与实践经验,耐心、细致地做出了针对性解答。
至此,第三期字节跳动技术沙龙圆满结束。
关注公众号“字节跳动技术团队”,在后台回复关键词“沙龙回顾”,获取 5 位老师 PPT 的下载链接及回放视频。
字节跳动技术沙龙,是由字节跳动技术社区 ByteTech 发起的,面向全行业开发者的技术交流活动。通过搭建一个包容、开放、自由的交流平台,促进前沿技术的普及与落地,帮助技术团队和开发者快速成长。字节跳动技术沙龙的技术分享来源于字节跳动及互联网一线大厂任职的技术专家,针对热点技术方向和实践总结,为技术团队和开发者呈现一场场可供参考的技术盛宴。
你希望在今后的沙龙中听到哪些主题的分享?你期待看到哪位技术专家分享自己的实践经验?欢迎在文章下方留言,说出你的心声~ 第四期字节跳动技术沙龙预计于3 月份举行,让我们共同相约春暖花开时!