SegmentFault 网易云音乐技术团队最新的文章
2024-03-20T10:38:56+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
云音乐会员支付链路优化实践
https://segmentfault.com/a/1190000044729902
2024-03-20T10:38:56+08:00
2024-03-20T10:38:56+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:周伟 夏银竹 李昂 武鹏</blockquote><p>支付链路整体承载了云音乐业务的主要交易流量。随着营收业务的快速增长,链路整体的复杂性持续提升的同时,也带来稳定性与支付效率的压力。2023年,我们以专项的方式对支付链路的各个环节尝试了不同方式的优化方案,并取得了一些核心指标增长的优化结果。本文主要介绍云音乐会员团队在支付链路优化上所做的一些解决方案和思路。</p><h3>业务背景</h3><p>支付链路,从用户进入支付触点开始到订单支付完成、履约结束,整体承载了云音乐业务的主要交易流量,涵盖会员、数专、商城等多种业务场景以及支付宝、微信、抖音等各类支付渠道与支付方式。随着营收业务的快速增长,链路整体的复杂性持续提升的同时,也带来稳定性与支付效率的压力。</p><p>2023年,我们以专项的方式对支付链路的各个环节尝试了不同方式的优化方案,并取得了一些核心指标增长的优化结果。本文主要介绍云音乐会员团队在支付链路优化上所做的一些解决方案和思路。</p><h3>优化总览</h3><p><img src="/img/remote/1460000044729904" alt="image (5)" title="image (5)"></p><p>完整的支付链路包含引导用户购买的支付触点、不同形态和意图的收银台、承载营销和商品信息的订单服务、三方支付的渠道以及支付结束后的履约和挽留。支付链路是个巨大的流量漏斗,每个环节都有一定的用户流失,如收银台的到达率影响用户流入,下单链路复杂的流量流失以及各类原因的支付失败等。</p><p>我们建立了全链路的支付漏斗监控,并针对重要环节的流失问题进行了精细化的分析。期间也选取了一些关键指标和扩展因子,如支付成功率、错误占比、支付应用安装情况、手机厂商信息、阶段性支付数据等共同组成了支付大盘。</p><p><img src="/img/remote/1460000044729905" alt="" title=""></p><p>最终,我们选取了支付链路的一些环节,并针对流失问题采取了不同的策略和工具集,如下图所示:</p><p><img src="/img/remote/1460000044729906" alt="" title=""></p><h3>收银台性能优化</h3><p>收银台页面是用户购买的核心场景,页面的曝光量与订单的成交量呈显著正相关。因此有必要优化页面加载体验,减少页面加载耗时,增加页面曝光量,从而提升最终转化的订单量。</p><h4>页面性能分析</h4><p>目前云音乐的核心收银台均为 RN 页面,完整的 RN 应用加载流程中如下图所示,可以划分成三个阶段:白屏阶段、页面首帧(FCP)阶段、页面内容可见(LCP)阶段。<br>性能优化的目的是尽可能地减少 LCP 的加载耗时,让用户可以尽早地看到完整的页面。<br>因此,可以根据 RN 应用的加载特性,逐个阶段进行优化,从而缩小整体的加载耗时。下图展示了一些通用的优化手段。<br><img src="/img/remote/1460000044729908" alt="image-20240225154703789" title="image-20240225154703789"></p><h4>优化结果</h4><ul><li><p>技术指标</p><ul><li>FCP到达率:<strong>+1.37pt</strong></li><li>LCP到达率:<strong>+3.66pt</strong></li><li>FMP加载耗时:<strong>-800ms</strong></li></ul></li><li><p>业务指标</p><ul><li>购买UV:<strong>+8.54%</strong></li><li>SKU曝光率:<strong>+8.26%</strong></li><li>转化率:<strong>+0.08%</strong></li></ul></li></ul><p>数据来源于多种手段优化后的收银台A</p><h4>通用优化手段</h4><h5>主进程加载 - Android</h5><p>默认的RN页面运行于按需加载的 broswer 进程,进程的 fork、初始化以及加载会额外带来约数百毫秒的开销,其中低端机耗时更明显。由于收银台页面的崩溃率以及内存控制较好,跨进程的优势不明显,因此将收银台容器切换至主进程更合适业务场景。</p><h5>RN 离线包</h5><p>RN 离线包是指将 Bundle 提前存储在客户端本地,从而免去运行时下载 Bundle 文件的时间。目前常见的有两种方式:</p><ul><li>预下载 RN Bundle:APP 启动时预下载 Bundle 并离线存储在本地</li><li>APP 内置兜底包:直接将应用的 Bundle 打包进 APP 内,保证用户端肯定有一份 Bundle 在本地</li></ul><p>虽然离线包的优化效果很好,但是同时也会带来一定的资源浪费和 APP 体积增加。目前会控制仅对 P0 的业务应用开启配置。</p><h5>RN拆包</h5><p>RN 拆包是将应用拆分成基础包和业务包两部分,这样做有两方面的好处:</p><ul><li>运行时只需要下载 / 加载业务包部分的 Bundle,大大减少了获取 Bundle 文件和 Bundle 解析的耗时</li><li>客户端会提前预热好基础包的容器,RN 加载时可以使用预热的容器加载,减少 Bundle 解析部分的耗时。</li></ul><h5>Hermes + Bytecode</h5><p>RN 升级 0.70 后使用了 Hermes 引擎,Hermes 引擎的一大优势是预编译与字节码执行能力,下面是使用新架构 + Hermes 引擎 + 预编译后的对比数据:</p><pre><code>Android 小米8 SE 首帧提升 71.5%,LCP 提升 40.1%;
红米 Note 9 pro 首帧提升 77.3%,LCP 提升 41.9%;
iPhone 6 首帧耗时提升 63%,iPhone 12 提升 42%;
LCP iPhone 6 提升 48.5%,iPhone 12 提升 18.3%。
相关链接可看前文 《网易云音乐 RN 新架构升级实践》</code></pre><h5>动态导入</h5><p>随着 RN 应用越来越复杂, RN Bundle 的体积也会越来越大。为了避免加载巨大的代码文件,可以将代码拆分成多个小文件。首屏的代码可以打包成一个文件立即加载执行,而非首屏的代码可以在与页面交互后懒加载,从而提升页面加载性能。</p><h5>接口预加载</h5><p>预先声明需要预加载的接口以及参数,在RN容器初始化的同时,前置且并行进行接口的请求,从而减少接口加载阶段时间。</p><pre><code>优化收益 = Math.min( 容器初始化耗时 , 接口加载耗时 )</code></pre><p><img src="/img/remote/1460000044729909" alt="" title=""></p><h4>深度优化定制</h4><p>由于安卓中低端机性能太差,即使 RN 应用做了上述的通用优化措施,也无法彻底实现在中低端机型上的秒开体验。<br>因此,为了持续地提升核心页面性能,我们也针对业务场景定制化一些非普适性的优化措施;</p><h5>RN 预渲染</h5><p>RN 预渲染是指在客户端启动 / 空闲时,提前预渲染好 RN 页面。等到用户真正打开 RN 页面时,无需加载直接可见,实现真正意义上的秒开。<br>但是这种优化手段略显激进,会增加 APP 的内存压力。所以需要制定相应的优化策略,在合适的时机、合适的人群以及合适的应用开启 RN 预渲染,尽可能提高预渲染的利用率;</p><p>合适的时机</p><ul><li>应用首页加载完毕</li><li>应用主线程空闲时: Looper.getMainLooper().queue.addIdleHandler</li></ul><p>合适的人群</p><ul><li>接口请求校验是否满足人群包:高付费意愿度</li></ul><p>合适的应用</p><ul><li>P0级应用 + 主动型业务场景:页面收银台,用户主动触发会员充值收银台,流量流失与整体加载时间正相关。</li></ul><p><img src="/img/remote/1460000044729910" alt="" title=""></p><h5>RN 静默加载</h5><p>在某些场景下,用户满足一定的触发条件、策略后(如播放付费片段、会员临期等),会弹出浮层 / 弹窗收银台引导用户付费。这种类型的收银台称为被动型收银台,具备用户非主动点击、出现时机无感知、加载过程可取消、用户取消率高等特点。<br>针对被动性收银台,可以后台静默加载页面,加载完成后再展示给用户,从而减少用户体感加载时间,进而降低用户在页面加载过程中的取消率,最终提高页面访问量。</p><p><strong>用户体感加载时间:用户等待页面加载流程时间</strong></p><p>主动型收银台:用户体感加载时间 = 页面LCP时间 - 用户点击开始时间</p><p>被动型收银台:用户体感加载时间 = 页面LCP时间 - 白屏 / 加载感知时间</p><p><img src="/img/remote/1460000044729911" alt="" title=""></p><h6>视图静默加载</h6><p>静默加载期间视图不显示,用户可正常交互原有界面视图。待收银台视图完整加载结束,RN通知Native直接显示完整视图:用户所见即所得,从而减少加载流失,提高页面曝光量。</p><p><img src="/img/remote/1460000044729912" alt="" title=""></p><h5>接口前置请求</h5><p>区别于接口预加载,预加载依赖于用户网络,如果用户网络加载时间超过容器加载时间,那么整体加载速度仍然受到网络加载影响。</p><p>接口前置请求则在上一业务场景/策略触发时请求网络,接口请求结束后加载对应页面。比如收听某一首会员歌曲时,需要弹出浮层收银台前,先请求相关SKU数据,待数据完全返回后直接带入RN容器。</p><p><img src="/img/remote/1460000044729913" alt="" title=""></p><h5>数专收银台H5->RN迁移</h5><p>受限于历史原因,数专收银台具备原生容器和H5容器,但两类方案均具备一定的劣势:</p><ul><li>Native方案受限于发版更新问题,无法满足业务增长的需求,且迭代开发成本高;</li><li>H5方案受限于技术栈的问题,即使启用离线包等H5优化手段,仍然无法持续深入优化性能和达到率,AB实验数据显示具备一定的关键指标数据下降;</li></ul><p>RN迁移统一是更为兼顾业务和性能的方案,一方面开发迁移成本低,另一方面可复用上文中的各类RN优化工具集,性能与到达率接近于原生;同时,H5迁移RN成本较低,可通过替代View层(CSS -> JSX)与平台API适配层完成迁移,整体逻辑具备通用性和一致性;</p><h4>IAP体系及优化手段 - iOS</h4><h5>IAP数据预取</h5><p>在常规的IAP支付流程中,整体流程是从请求苹果服务端获取当前交易商品的对象开始的。因为苹果服务端架设在海外,仅有香港等地有转接点,导致国内用户负责的网络环境请求苹果服务端时错误率较高,当商品信息获取失败时,用户本次支付流程也将会失败。该部分错误在云音乐App内占比不小,因此我们针对该流程进行了预取优化。</p><h6>优化结果</h6><p>缓存命中率率:<strong>0%->96.52%</strong> </p><p>-4错误量:<strong>降至0</strong></p><p>消耗型商品支付成功率:<strong>+1.35pt</strong></p><p>订阅型商品支付成功率:<strong>+0.46pt</strong></p><p><strong>IAP商品预取流程</strong></p><ol><li>APP启动进入到首页云音乐服务端下发热门商品IAPID列表;</li><li>使用热门商品IAPID请求苹果服务端,获取对应的商品对象,并做内存级别的缓存;</li><li>在合适的时机以及频率对该部分缓存进行更新+维护;</li></ol><p><strong>IAP商品预取后的支付流程</strong></p><ol><li>用户在端内发起IAPID为X的商品支付;</li><li>查找端上缓存是否存在X商品的IAP商品对象缓存;若存在,则直接使用缓存对象进行后续支付流程;若不存在走原本的支付流程,从请求IAP商品对象开始;</li></ol><p><img src="/img/remote/1460000044729914" alt="iamge-iappreget" title="iamge-iappreget"></p><p>该方案使IAP支付成功率有不显著正向提升,其中商品预取流程对消耗型商品的整体支付成功率提升大于对订阅型商品;对IAP支付整体的商品预取错误(errorCode = -4)几乎是完全解决。</p><h5>Storekit2</h5><p>StoreKit 2 是苹果公司在 iOS 15 和 macOS Monterey 中引入的一组更新和改进的框架,用于处理应用内购买和订阅相关的功能。StoreKit 2 提供了一些新的功能和改进,使开发者能够更方便地实现应用内购买和订阅的流程。<br>其中云音乐需要使用的新特性如下:</p><ul><li>小票使用UUID编码附带OrderId</li><li>小票监听功能:跨设备订单同步</li><li>可以准确判断用户是否具有促销优惠资格isEligibleForIntroOffer等</li></ul><p>StoreKit2的接入,应当把现有能力都包含在内,和StoreKit1的流程对齐;核心注意点如下:</p><ol><li>为了避免一次性全量对营收造成较大不可预估的影响,StoreKit2需要逐步放量,通过AB实验控制放量节奏与随时降级回StoreKit</li></ol><p><img src="/img/remote/1460000044729915" alt="iamge-storeKit2AB" title="iamge-storeKit2AB"></p><ol start="2"><li>StoreKit2苹果仅推出了Swift版本,因此部分StoreKit1的逻辑需要重新开发,例如商品预取逻辑StoreKit2与StoreKit1的商品对象是完全不同的两个类,StoreKit2的预取逻辑需要重新使用Swift开发</li></ol><p><img src="/img/remote/1460000044729916" alt="iamge-storeKit2AB" title="iamge-storeKit2AB"></p><ol start="3"><li>StoreKit2针对未完成小票的处理和StoreKit1有较大的不同:云音乐端内在StoreKit1的验票流程中有做缓存小票+轮询重试的优化措施,在StoreKit2中的类似逻辑的处理方式需要用配套的API重新开发,并且StoreKit1和2存在同时监听到同一小票的情况,因此需要隔离两套未完成小票的监听逻辑</li></ol><p><img src="/img/remote/1460000044729917" alt="iamge-storeKit2AB" title="iamge-storeKit2AB"></p><ol start="4"><li>SwiftOC混编问题,云音乐主站核心是使用OC进行开发的,因此为了避免影响工程整体且方便使用,StoreKit2的必要流程都使用Swift开发,对外暴露的接口使用OC再进行封装一层;业务层使用时仅需要使用上层OC接口无需使用Swift接口</li></ol><p><img src="/img/remote/1460000044729918" alt="iamge-storeKit2AB" title="iamge-storeKit2AB"></p><p><strong>具体预期提升</strong></p><ul><li>小票关联订单ID提升问题排查效率;</li><li>新的小票监听能力比StoreKit1能够获取更加全面的历史小票信息,能有效减少因验票问题导致的退单问题;</li><li>StoreKit2部分接口性能按照苹果官方文档介绍有所提升,预期能够提升支付成功率;</li><li>StoreKit2升级方案在端内拥有随时降级的能力,不会破坏原有的StoreKit1支付能力;</li></ul><h4>端侧能力同步更新 - Android</h4><p>Android支付唤起依赖于三方渠道,目前云音乐App集成支付宝、微信支付、银联支付、抖音支付、网易支付等多种三方支付渠道。</p><p>云音乐App对于三方支付SDK更新频率较低,但部分三方支付SDK新版本引入了新功能以及稳定性提升,同步更新升级这类SDK可带来一些支付成功率的提升。如支付宝新SDK引入淘宝登录等能力,可以减少未安装支付宝App用户更便捷使用H5页面支付等。</p><h5>优化结果</h5><p>支付成功率(升级渠道单次支付):<strong>+3.29%</strong></p><h5>更新与验证</h5><p>SDK升级较简单,只需按照三方官网升级版本号以及更改兼容方法即可,但升级SDK也具备一定的难点:</p><ul><li>价值测算:一个App中仅可依赖一份SDK,难以通过AB实验验证价值;</li><li>问题回滚:若升级SDK对支付成功率有负向影响,实时回滚成本高;</li></ul><p>我们基于上述问题采用了下述支付SDK升级发布流程,在分流 / 灰度 / 渠道分阶段去验证不同问题。</p><p><strong>三方支付SDK升级验证流程</strong></p><ul><li>分流:同时打出两个版本包C与T(仅支付SDK版本差异)替代AB实验进行少量且等量分发,在此阶段初步验证崩溃问题以及支付成功率数据;</li><li>灰度:分流验证无问题后,合入灰度分支,并跟随灰度扩量分发继续观测相关数据;</li><li>渠道:版本扩量,同时对比多个线上版本,通过实时监控验证与分析支付成功率等数据;</li></ul><p><img src="/img/remote/1460000044729919" alt="image-20240225152023392" title="image-20240225152023392"></p><h4>支付挽留措施</h4><h5>IAP支付挽留短信 - iOS</h5><p>因iOS系统中IAP的支付成功率较低,且这部分有意愿购买用户的错误支付很容易造成订单流失,给云音乐整体营收带来不小的影响,因此我们在IAP支付失败后,通过短信挽留的方式,提醒用户支付失败,引导用户重新支付。</p><h6>优化结果</h6><p>主动购买UV:<strong>+0.28%</strong></p><h6>具体方案</h6><p>云音乐并未采用前端在用户支付失败后,主动调用短信接口的方案,因为部分前端页面可能会在支付接口返回前就已经被关闭,覆盖量有限。</p><p>期望覆盖的所有的IAP未支付成功的订单,就需要在IAP支付未成功时一定延迟后发送延迟消息,并由服务端内部判定订单状态。根据业务定制的延迟时间复核订单是否支付,如未支付则通知到业务侧,触发后续挽留动作。</p><p><img src="/img/remote/1460000044729920" alt="image-iapmessage" title="image-iapmessage"></p><p>相较于无挽留动作时,针对流失订单的挽回还是有很好的效果的,该方案投入产出比极佳。</p><h2>总结</h2><p>在过去的一年,我们建立了交易链路领域相关的漏斗,并基于分析落地了链路不同切面上的优化,如核心收银台的性能优化、三方渠道支付稳定性保障与改善以及支付挽留等。事实证明,作为营收业务的核心模块,针对支付链路的优化不仅能提升支付效率与用户体验,还能有效赋能业务,带动业务关键指标的提升。</p><p>未来我们会持续聚焦于交易链路领域进行技术漏斗及策略的优化,探索端侧智能与营收策略的结合;</p><h2>最后</h2><p><img src="/img/remote/1460000044729921" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=qzdLtJn7HEUMRAz6DNE3oA%3D%3D.A9TLl6RqNgMCM3T%2FNJxmskdhTDziLxci7Z2vs9mxuB8%3D" rel="nofollow">https://hr.163.com/</a></p>
Tango 低代码引擎沙箱实现解析
https://segmentfault.com/a/1190000044716088
2024-03-15T15:43:43+08:00
2024-03-15T15:43:43+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:0xcc</blockquote><h2>Tango 基本介绍</h2><p>Tango 是一个用于快速构建低代码平台的低代码设计器框架,并以源代码为中心,执行和渲染前端视图,并为用户提供低代码可视化搭建能力,用户的搭建操作会转为对源代码的修改。借助于 Tango 构建的低代码工具或平台,可以实现 源码进,源码出的效果,无缝与企业内部现有的研发体系进行集成。</p><h3>开源进展</h3><p>目前 Tango 设计器引擎部分已经开源,正在积极推进中,可以通过如下的信息了解到我们的最新进展:</p><ul><li>开源代码库:<a href="https://link.segmentfault.com/?enc=ttIxSjwdDkup9qkCxuMecg%3D%3D.D9EWWjmiFemyOv11bkwfTRnuTNA%2Fjrx%2B%2BPLvtUmnHWPVqdohhajwV7Q2wuCCarnl" rel="nofollow">https://github.com/NetEase/tango</a></li><li>文档地址:<a href="https://link.segmentfault.com/?enc=VOMahtPAW48geUIWZarZxw%3D%3D.uE8nJpIJwU9CGUXrYxqs7S5udJ632NDHWcs%2FTvQm5IKSPupHjf2YcBxf7VIOORJO" rel="nofollow">https://netease.github.io/tango-site/</a></li><li>社区讨论组:<a href="https://link.segmentfault.com/?enc=WUcHUZcZZPsz4R1enrXp2w%3D%3D.xIxkyKPfjR40Ex8p%2F20V%2Fc6C5CxSpQazM5NaARTMeSJONIbil6nztk4FtZ6vTIjt" rel="nofollow">https://github.com/NetEase/tango/discussions</a></li></ul><p>此外,Tango 的文档现已全面更新,欢迎浏览。</p><p><a href="https://link.segmentfault.com/?enc=oRxEE9uQqdYg01l%2BKb%2BDbw%3D%3D.K9eerHhU2EWA3s4JeY68Qunqi2NE%2F8WtTvHQQouSzhVsOrylhuyd%2FJ%2BbJchl0GJq" rel="nofollow"><img src="/img/remote/1460000044716090" alt="" title=""></a></p><p>欢迎大家加入到我们的社区中来,一起参与到 Tango 低代码引擎的开源建设中。有任何问题都可以通过 <a href="https://link.segmentfault.com/?enc=8wdis31lIYuL0WFnvX%2FtNw%3D%3D.D6oT5tCsfxGOIyo6EC8te71A84Aas1kr%2FFnuBwZvOVirZ%2BUPIEbjqswr2WqrepRk" rel="nofollow">Github Issues</a> 反馈给我们,我们会及时跟进处理。</p><h3>往期系列文章</h3><ul><li><a href="https://link.segmentfault.com/?enc=wgCC4CSAk2cjhjTp6Ky0aA%3D%3D.0tw0KUslYHJKiUi00sv8Q%2F0ohSNhPKCOkbBN4SKdOWnbTkIq71Pb4mWqBTF%2FIg2o" rel="nofollow">网易云音乐 RN 低代码体系建设思考与实践</a></li><li><a href="https://link.segmentfault.com/?enc=tb0HFwiX8zOfiCMLcmzMLw%3D%3D.N3%2Fg6YqPqxQdShSFIl39zBOZ5aNmXgQU0lXD0vFH1ebyoKq8qlWxeEZQXojKyJT1" rel="nofollow">手把手带你走进Babel的编译世界</a></li><li><a href="https://link.segmentfault.com/?enc=Zpr2Z3Hgdu5AzKHJIjUqFA%3D%3D.vEI40q%2BEAA7c1XJaPFd2IK9IXaBQMmndGlN%2BwYOoSg%2FZ2OZYr3Kwg9TbHZFva1vs" rel="nofollow">网易云音乐低代码体系建设思考与实践</a></li><li><a href="https://link.segmentfault.com/?enc=spQh9vAhk6R%2FqUaIVcotCw%3D%3D.CIOmBGOWghjFssrxFCkO9%2BNppb8N1iHpbmCEIn%2BSJSSrfIC2Rk56F5gaqWhPHLi4" rel="nofollow">云音乐低代码:基于 CodeSandbox 的沙箱性能优化</a></li><li><a href="https://link.segmentfault.com/?enc=QSo%2Ba8IlthkRlaMLznceRA%3D%3D.aaQJ2r%2BIPxM5km8%2BzgBXK9vAHVo2xPbkRJZHK54MVctHbGgAV4bFQqf7Tw2eK%2BQz" rel="nofollow">云音乐低代码 + ChatGPT 实践方案与思考</a></li><li><a href="https://link.segmentfault.com/?enc=PT4wR6OJkxrCeOzmgH9SOQ%3D%3D.gW%2B9GM6tyx9Gs6cyblysAPfyc0i%2B5TKS827sXswigAVkHHD%2FSMqiKUErBQJlkGRh" rel="nofollow">网易云音乐 Tango 低代码引擎实现揭秘</a></li><li><a href="https://link.segmentfault.com/?enc=NzcPq23B2SiTtLHHBvz3NA%3D%3D.RgWXhVd%2BUA9HvarUYHvQKRUM%2FXE8fNog44O%2B8Tp4PWi9nGG7Tt69G1AucMh0wSWl" rel="nofollow">网易云音乐 Tango 低代码引擎正式开源</a></li><li><a href="https://link.segmentfault.com/?enc=W8T2b2Lx4Ko9hibnevFg%2Fw%3D%3D.tdtlDkuHXy8hyMuMh861SNuDE4G2BSjUazq971NL8RDSl3OYZn3wNH%2Ff7IZ2%2BhCA" rel="nofollow">低代码在云音乐数据业务中的落地实践与思考</a></li></ul><hr><h2>为什么 Tango 需要沙箱</h2><p>传统的基于 DSL 的低代码方案通常需要实现一套对应的 DSL 语法与渲染器,在渲染器内渲染给定的组件、绑定事件等。与此不同,Tango 是基于 AST 驱动的面向源码的低代码方案。相较于 DSL 方案,Tango 的写法更加灵活,但也带来了支持源代码实时运行的挑战。此外,为了与团队内已有的物料集成,Tango 支持添加业务组件,因此设计器还需要考虑三方依赖的加载与运行。因此,Tango 需要一个独立的沙箱来运行源码,提供可以媲美本地开发的代码运行时。</p><p>在初期,Tango 曾调研了几种方案,如基于 Sea.js 这类 AMD 加载方案。然而,这类方案的问题在于依赖比较固定,需要将依赖预先构建出符合规范的产物(如 UMD 资源),因此不能灵活地添加依赖。至于 SystemJS 和 ViteSandbox 这类 ESM 方案,由于 Tango 期望支持直接使用已有的组件物料,而它们的产物主要以 CommonJS 为主,缺少 ESM 产物。此外,我们后续对沙箱的改造优化大幅减少了沙箱初始化的时间,因此没有采用该方案。</p><p>Tango 目前采用的沙箱方案是基于 <a href="https://link.segmentfault.com/?enc=TuZ4A%2FfkUCHq8jI%2FPO2qbQ%3D%3D.jE3Y0KFia3g3Xhjqy2dTbwnAvnlIdbGq1cp8xojcgnIQYOqz23afAgoMr2dqcma2KMJbIL28SP5FG%2FSlCJWyzg%3D%3D" rel="nofollow">CodeSandbox</a> 提供的沙箱能力实现的。它的优势在于提供了更完整、接近本地开发的运行时环境,支持直接拉取 npm 包并运行。它借助 Babel 将 ESM 和浏览器不支持的新语法转译为 CommonJS,模拟了 CommonJS 的运行环境,实现了源码在浏览器上直接运行。这样即便依赖没有提供可供浏览器使用的预构建产物,也能在沙箱内实时转译并运行。此外,CodeSandbox 的沙箱运行在一个 iframe 内,可以隔离代码的运行时环境,避免污染设计器的全局变量。</p><h2>Tango 沙箱的基本结构</h2><p>CodeSandbox 是一个在线运行 JavaScript 代码的平台,它的沙箱借助 Babel 与 Web Worker 等能力,在浏览器上实时转译与运行代码。你可以把它的沙箱能力想象成一个在浏览器上运行的 webpack,比如它的转译器 Transpiler 就和 webpack 的 loader 比较接近。。</p><p>由于 CodeSandbox 自己实现了各个模板的转译规则,整个转译流程均由自己把控,因此它整体上会比 webpack 轻量些。例如 CodeSandbox 在初始化依赖时能忽略掉绝大多数的 <code>devDependencies</code>,从而大幅减少项目的依赖初始化时间与转译时间。</p><p>结合 Tango 后的沙箱可以简化为三个部分:</p><p><img src="/img/remote/1460000044283909" alt="" title=""></p><ul><li>沙箱前端组件:一个开箱即用的沙箱组件,只需要传入代码和配置就可以完成应用的渲染</li><li>在线打包器:提供搭建产物的浏览器端构建能力,类似于一个浏览器版本的 webpack,最终形态是一个独立的 iframe</li><li>沙箱后端服务:对依赖的资源进行预构建,以及提供资源合并等服务,用来加速沙箱内部的构建打包过程</li></ul><p>它的工作流程可以简述如下:</p><ul><li>代码准备:平台引用沙箱组件,通过 <code>postMessage</code> 将代码传递给沙箱</li><li>依赖初始化:沙箱处理传入的文件,根据 <code>package.json</code> 的 <code>dependencies</code> 调用 Packager 打包服务获取依赖</li><li>转译代码:解析代码的依赖关系,将依赖的代码通过对应的 Transpiler 转译</li><li>执行代码:在沙箱中初始化 html 等,然后从代码的入口文件开始执行转译后的代码</li><li>上述执行周期内和执行完成后,沙箱会抛出事件让平台感知</li></ul><h2>Tango 沙箱的工作流程</h2><blockquote>本部分主要参考了 <a href="https://link.segmentfault.com/?enc=iWX1ZhY53SP%2BSUaaR3DBlQ%3D%3D.kJ5Yc2CU78GfqQaJFdpA7HK9%2B6RTeXP%2F%2B08xvMyfXxvlMKZjxwFkqaRPWu3t8652" rel="nofollow">CodeSandbox 如何工作? 上篇</a> 的部分内容,并在此基础上进行了修改。如果你对 CodeSandbox 底层的更多细节感兴趣,不妨阅读下这篇文章。</blockquote><h3>依赖的初始化</h3><p>如前所述,CodeSandbox 在内部实现了核心的转译逻辑(例如 Babel 与 less 转译),整个转译流程都由自己控制,因此在初始化依赖时可以相对轻量一些,只需获取 <code>dependencies</code> 里必要的依赖,忽略掉 <code>devDependencies</code> 以及 <code>@types</code> 开头的只在本地开发时才会用上的依赖。</p><p>CodeSandbox 是如何获取依赖的呢?CodeSandbox 实现了两套方案,一套是默认的远程在线打包方案,另一套是从 unpkg/jsdelivr 等 npm 包资源的 CDN 获取依赖的兜底方案。</p><p>CodeSandbox 设计了一个 Serverless 服务 <a href="https://link.segmentfault.com/?enc=4tfHTVFiarX1yHR7I40v3g%3D%3D.VXu5F9VGVLWrrzFhOuTTpt2KeBa8U7vNt2JZi4DClBuD%2F72MyJhLTlMEZJCcwYwwSE6TVKLT9XkLXKMkcUoLEQ%3D%3D" rel="nofollow">dependency-packager</a>,这个服务负责在线拉取依赖,然后一次性返回包括子依赖在内的所有需要的文件。当服务接收到接口请求后,会解析 URL 中的包名与版本号,并在服务端执行 <code>yarn install</code> 安装 npm 包,然后从入口文件开始逐一解析依赖的文件以及各个包之间的依赖关系,最后将被依赖的文件一次性返回。由于该服务仅返回被依赖的文件,在减少网络请求的资源大小的同时,沙箱可以避免转译 <code>.d.ts</code> 或测试用例这样运行时不需要的文件。</p><p>不过由于 packager 返回的文件是从包的入口文件开始计算的被引入的文件,因此在实际使用中,一些未被引入的文件可能也会被项目使用。当项目引入了被排除的资源时,沙箱会在前端请求 unpkg/jsdelivr 作为兜底方案,从而顺利完成转译。当然,缺点就是如果缺失的文件比较多,实时获取的方案会多出很多的网络请求开销。因此 CodeSandbox 还使用了 Service Worker 作资源缓存,减少二次复访的网络请求。</p><h3>转译与构建</h3><p>当 CodeSandbox 开始转译时,会调用 <code>compile()</code> 方法开始转译,整个转译流程大致如下:</p><p><img src="/img/remote/1460000044716091" alt="" title=""></p><p>传入沙箱的参数除了代码外,还需要传入 <code>template</code> 参数,该参数用于指定沙箱转译时需要使用的 Preset。Preset 就像 webpack 的配置文件一样,内部定义了如何预处理依赖、不同的文件该使用哪些 Transpiler、在代码执行前做一些其他的操作等。</p><p>Preset 初始化好后,沙箱将初始化一个 Manager 实例,这个 Manager 实例会被 <code>compile()</code> 使用,用于控制整个转译流程的生命周期。然后,Manager 会按照上一节提到的方式初始化项目的依赖。如果传入的依赖发生了变更,沙箱会重新初始化一个新的 Manager 实例,避免运行时被旧的 Manager 依赖影响。</p><p>依赖准备好后,传入沙箱的代码会被传入 Manager,Manager 会将代码实例化为 TranspiledModule,解析各模块的依赖关系,计算是否被更新或删除等。然后沙箱将从代码的入口模块开始,根据 Preset 里定义的规则,对每一个模块递归调用指定的 Transpiler 转译。这里 Transpiler 就像 webpack 的 loader 一样,负责将文件转译为需要的产物。对于复杂的 Transpiler——例如负责转译 JavaScript 的 BabelTranspiler——还会使用 Web Worker 队列来提升转译效率。</p><p>当相关的模块都被转译好后,Manager 会进入代码执行阶段。</p><h3>代码执行</h3><p>沙箱的运行时模拟了 CommonJS 所需的环境,如 <code>require</code>、<code>module</code>、<code>exports</code>、<code>global</code> 等方法与变量。当所有需要的模块都被转译好后,Manager 会进入代码执行阶段。代码执行的核心代码如下:</p><pre><code class="ts">const allGlobals: { [key: string]: any } = {
require, module, exports, process, global, ...globals,
};
const allGlobalKeys = Object.keys(allGlobals);
const globalsCode = allGlobalKeys.length
? allGlobalKeys.join(', ') : '';
const globalsValues = allGlobalKeys.map(k => allGlobals[k]);
const newCode =
`(function $csb$eval(` + globalsCode + `){` + code + `\n})`;
// @ts-ignore
(0, eval)(newCode).apply(allGlobals.global, globalsValues);
return module.exports;</code></pre><p>沙箱会从入口模块开始执行,执行时会将代码封装为上述的立即执行函数,然后调用 <code>eval()</code> 执行并传入上述 CommonJS 的方法与变量。若代码引用了其他文件,执行时调用的 <code>require()</code> 方法会按照相同的逻辑递归执行并返回执行后的产物。</p><p>经过上述流程后,项目中的代码就会被转译并执行,最终渲染在沙箱里,你就能看到代码的实际效果了。</p><h2>沙箱的优化改造</h2><p>在 Tango 上开发的应用是一个完整的项目,并非像 CodeSandbox 网站上那样主要用于承载简单的示例或代码片段。因此用户对沙箱自身的构建性能与加载速度有较高的要求,以满足日常的开发体验。</p><p>关于我们对 CodeSandbox 优化的具体细节,可以参考我们之前的这篇 <a href="https://link.segmentfault.com/?enc=OmvD2rhTrERxjwfOn3xgRg%3D%3D.UURRGgQdqwreTP687fV3vjFhwhAdCQnFpIVP%2F2S%2BsXiyYK82t18Kq5%2F%2BuVFOxnHp" rel="nofollow">云音乐低代码:基于 CodeSandbox 的沙箱性能优化</a> ,修改后的 CodeSandbox 代码也可以在 <a href="https://link.segmentfault.com/?enc=RBp04y7mi088pxPLkv9joA%3D%3D.UydBOFOwZyYqJH%2BVvtLOSUj5l4LRXdHqzJDFq3VgVt%2BDbHxGnjfuOqmHmonvHDOX" rel="nofollow">GitHub</a> 上找到。</p><h2>接入 Tango 沙箱</h2><p>Tango 低代码设计器除了需要让沙箱运行源码、渲染页面以外,还需要实现可视化搭建的拖拽能力,因此设计器需要感知到用户在沙箱内的操作。但是,由于沙箱运行在一个独立的 iframe 内,并且部署在独立的域名下,两者之间是跨域的,因此需要做跨域兼容。通过将设计器平台与沙箱的 <code>document.domain</code> 均设为相同的父域名,并针对 <a href="https://link.segmentfault.com/?enc=faMilWE7xyeU1gVKIIP4jg%3D%3D.ePlB7MOBWLpcTIDCqfNK4vePQd352puagyoZiSk98GGLmIMj2%2B8G4j680res0W%2FlftxJTys9DmGmAS4%2B6mvBW0%2FZDnGNfbONETomGHnAaNo%3D" rel="nofollow">Chrome 的安全策略</a> 在平台与沙箱添加 <code>Origin-Agent-Cluster: ?0</code> 的 HTTP 响应头,就能实现平台与沙箱的跨域通信。</p><p>为了简化沙箱的使用成本,我们封装了一个 React 组件 <code>@music163/tango-sandbox</code> 供设计器使用,相关代码可以在 Tango 的 <a href="https://link.segmentfault.com/?enc=gMHFE6jTwNVb4pnyT10kOw%3D%3D.ffX03nWUpmW7FSETET0twIrHdKKLOMGFaBR4HO%2FQqHxy17WGBf1tPmFQvbvgnRJySzhAr9iDrrgjy8fx46T6LQ%3D%3D" rel="nofollow">GitHub</a> 仓库里找到。它主要分为如下三个部分:</p><ul><li><code>IFrameProtocol</code>:负责与沙箱通信。通过监听 <code>message</code> 事件接收从沙箱传出的消息,以获取沙箱主动传出的生命周期。通过在 iframe 内部调用 <code>postMessage()</code> 方法向沙箱传递事件,从而控制沙箱。</li><li><code>PreviewManager</code>:负责管理沙箱的基本渲染。其借助上面的 <code>IFrameProtocol</code> 与沙箱通信,当代码发生变化时,会向沙箱发送 <code>compile</code> 消息,从而触发沙箱的构建与渲染。</li><li><code>Sandbox</code>:用于渲染沙箱的 React 组件。除了挂载沙箱的 iframe 外,还包括了沙箱配置、注册事件监听函数、消息传递、路由管理等功能。当组件传入的 props 发生变化时,会相应地更新沙箱代码、更新 iframe 路由等。</li></ul><p>Tango 低代码引擎通过向 Sandbox 组件传入 <code>files</code> 来实现代码的渲染,并传入 <code>eventHandler</code> 来监听用户在沙箱内的拖拽操作,最终实现了设计器的组件拖拽搭建能力。</p><p>不过,沙箱获取依赖的基本能力主要是 CodeSandbox 提供的 packager 与 JSDelivr、unpkg 提供的,如果需要使用团队内部的私有 registry 就需要将相关服务私有化部署了。限于篇幅就不在此做过多赘述,关于 Tango 沙箱的具体接入文档,以及上述第三方服务私有化部署需要做的修改,可以参考我们提供的 <a href="https://link.segmentfault.com/?enc=SyJ5ANyaox%2FupLMCIlJt4g%3D%3D.twRpDCo%2B1sPbfhnV4lzMsXdDwneNlQOAztuLl7fJijxPMR0K1bJx5Y%2FI3z%2F0EYtql78e%2FxlCrM9L4CEoKupxP8wr5snHnf7iJ0P2AArcjGc%3D" rel="nofollow">沙箱接入文档</a>。</p><h2>总结</h2><p>本文简单介绍了 Tango 低代码引擎的沙箱能力,并分析了 CodeSandbox 的基本结构和工作流程。通过 CodeSandbox 强大的沙箱能力与优化,Tango 低代码引擎实现了可视化预览与搭建能力,为开发者提供了便捷高效的开发体验。</p><h2>Tango 开源计划</h2><p>目前我们已经完成了 Tango 核心实现的基本代码库的开源,包括核心引擎内核、沙箱、设置器、应用框架、物料协议等等,并发布了 RC 版本。在今年,我们将持续推进云音乐低代码核心能力的开源,包括基本的服务端能力,前端组件库等,并持续优化和完善开源文档。并且,随着其他能力的稳定和时间的成熟,我们还将会持续向社区开源更多的内部实践。</p><h2>参考资料</h2><ul><li><a href="https://link.segmentfault.com/?enc=7wmgZxK3Qj3xT%2BD4tiXxnw%3D%3D.fLrVJ%2BlH%2BLAm3sepGHBsqZeq1SFLKRtGQwg5Zci40UeMB51TF1JZU1tgr%2BSeuOs7" rel="nofollow">CodeSandbox 如何工作? 上篇</a></li><li><a href="https://link.segmentfault.com/?enc=DC6M1%2BMtMbfFgCBEev%2B9PQ%3D%3D.%2BObXos0CIJcg79JrwDWXjrzVQfnJQUjeGoF9sY4jV8ZygjXY%2BROauzDQv35iaRvm" rel="nofollow">云音乐低代码:基于 CodeSandbox 的沙箱性能优化</a></li><li><a href="https://link.segmentfault.com/?enc=S5%2BQjMCS44JpAK72Z8Dymw%3D%3D.%2B9m3KgSKaCVqDTzybupzJwbm3E30DMF9R%2BNi6Oi3qUYSsP3d5v1sK0dmSTeBFMJd" rel="nofollow">搭建一个属于自己的在线 IDE</a></li><li><a href="https://link.segmentfault.com/?enc=rVFS2JLGwfTS69pa8YwKtQ%3D%3D.ZE0QWsnRHp2Jplm0c%2FlfNgNZyHTQYuH%2BS0ot7lJHRLv6frs3Gg3qTxc9sNuhnqTd" rel="nofollow">网易云音乐 Tango 低代码引擎实现揭秘</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044716092" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=7tg1MMymJz%2BqDmTZ5rrwhw%3D%3D.boaKx7ITgVh6ZGwxchRfwUtpYyw2Pd5PBbGEZ2Q%2FBZ8%3D" rel="nofollow">https://hr.163.com/</a></p>
Web 端 RTL 适配实践
https://segmentfault.com/a/1190000044694397
2024-03-08T14:58:36+08:00
2024-03-08T14:58:36+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
2
<blockquote>本文作者:杨彩芳</blockquote><p>本文在介绍云音乐出海业务中,Web 项目针对阿拉伯语、希伯来语等 RTL 语言的布局适配实践。</p><h2>前言</h2><p>在业务全球化的进程中,我们会面对产品本地化的需求。在中东地区,许多国家使用阿拉伯语、希伯来语等语言,其书写和阅读习惯是从右向左(简称 RTL),与我们日常使用的中、英文环境中的从左向右(简称 LTR)阅读习惯相反。为了确保我们的产品在 RTL 语言用户中依然能够提供良好的体验,需要进行 RTL 适配。</p><h2>RTL 布局概述</h2><p><img src="/img/remote/1460000044694399" alt="MATERIAL DESIGN lrt-vs-rtl.png" title="MATERIAL DESIGN lrt-vs-rtl.png"><br>如上图所示,左右两边分别展示了 RTL 和 LTR 的效果图。从图中我们可以直观地看出两者布局的区别:文本的对齐方向、主按钮和辅助按钮的排列方向、进度条的填充方向以及返回图标的方向是相反的,而其他图标则是相同的。具体总结如下:</p><table><thead><tr><th> </th><th>LTR</th><th>RTL</th></tr></thead><tbody><tr><td>文本</td><td>句子从左向右阅读</td><td>句子从右向左阅读</td></tr><tr><td>时间线</td><td>事件序列从左向右进行</td><td>事件序列从右向左进行</td></tr><tr><td>图像</td><td>从左向右的箭头表示向前运动:→</td><td>从右向左的箭头表示向前运动:←</td></tr></tbody></table><p>了解了 RTL 布局的特点之后,我们可以开始考虑如何低成本地将线上已有场景的 UI 从 LTR 调整为 RTL。在将 UI 从 LTR 调整为 RTL(或反之)时,我们通常称之为镜像。</p><h2>实现 RTL 的两种方案</h2><h3>transfrom</h3><p>基于 transform 的方案,是利用 CSS 的 transform 属性,通过设置 <code>transform: scaleX(-1);</code> 实现页面的水平翻转。</p><p><img src="/img/remote/1460000044694400" alt="transform-scalex.png" title="transform-scalex.png"></p><p>如上图所示,通过翻转解决了布局问题,但文字和图像也被翻转。为了解决这个问题,对于不需要翻转的内容(如文字、非指向性图像),需要进行二次翻转。然而,该方案的缺点在于,首次翻转只需要处理根节点,而二次翻转则需要处理所有不需要翻转的元素,工作量较大。该方案的优点在于开发者无需修改 JS 逻辑。例如,通常情况下,左滑/左向箭头图标的点击事件在 RTL 时会将前进改为后退,右向将后退改为前进。</p><h3>direction</h3><p>基于 direction 的方案,是利用 CSS 的 direction 属性,该属性用于设置文本、表格列和水平溢出的方向。通过将 direction 设置为 rtl 可以改变页面布局,在 html 标签上添加 <code>dir="rtl"</code> 与设置 direction 效果相同。我们通过一个简单的例子来具体了解设置为 <code>rtl</code> 的效果。</p><p><img src="/img/remote/1460000044694401" alt="ltr-rtl.png" title="ltr-rtl.png"></p><p>如上图所示,设置为 <code>rtl</code> 之后,我们发现 UI 并没有完全兼容 RTL 场景。我们可以观察到,direction 在设置 <code>rtl</code> 之后只对部分属性进行了镜像处理:</p><ul><li>如果元素没有预先定义过 <code>text-align</code>,那么该元素的文本会从向左对齐变成向右对齐,如果设置了 <code>left/center</code> 则 direction 的设置不会对其产生影响</li><li><code>inline-block</code>、<code>flex</code>、<code>table</code>、<code>grid</code> 的布局方向被影响,<code>absolute</code> / <code>fixed</code>、<code>float</code>、<code>margin</code>、<code>padding</code> 无任何变化。</li></ul><p>为了页面能够在 RTL 布局时正常呈现,我们需要对未被影响的属性调整。目前有以下两种方式可以解决这个问题。</p><p><strong>CSS 逻辑属性与逻辑值</strong></p><p>逻辑属性和逻辑值用抽象术语块向和行向描述其流向。块向尺度(<code>block</code>)是指与行内文本流向垂直的方向上的尺度。行向尺度(<code>inline</code>)是指与行内文本流向平行的方向上的尺度。LTR 布局时,<code>block-start</code> 对应 <code>top</code>,<code>block-end</code> 对应 <code>bottom</code>, <code>inline-start</code> 对应 <code>left</code>,<code>inline-end</code> 对应 <code>right</code>,<code>inline-size</code> 对应 <code>width</code>,<code>block-size</code> 对应 <code>height</code>。</p><p>通过改写为逻辑属性,可以同时适配 LTR 和 RTL 布局,无需专门为 RTL 布局进行适配。例如,将 <code>margin-left</code> 改写成 <code>margin-inline-start</code>,将 <code>left: 0;</code> 改写成 <code>inline-start: 0;</code>。我们只需要全局替换需要调整的行向尺度的 CSS 属性即可。然而,使用逻辑属性存在两个问题:一方面是浏览器的兼容性问题(B 端项目可以考虑使用,浏览器兼容性较好),另一方面开发者只能处理本地代码,无法处理 npm 包中的代码。</p><p><strong>CSS 翻转工具</strong></p><p>另一个方案是使用 CSS 转换工具(<a href="https://link.segmentfault.com/?enc=VfT%2FgaXypKs7WszUX5xIaw%3D%3D.LZis7VoJEJMxSoMKnvrs7LRj5xrpKeKCTWq0ZYtnTT3vp16syUsUdJLKXXedCRgW" rel="nofollow">rtlcss</a>、<a href="https://link.segmentfault.com/?enc=Wg01hjNuNiSvHGclWMtFiw%3D%3D.hun211W5ssAPoe4bN1uiG7k%2Bq7DKjP9d%2BNZidQ%2B58mAPUQ3PEQoflEzYj%2Fj1OLJi" rel="nofollow">css-flip</a>),按照 RTL 布局对 CSS 代码进行转换,例如将 <code>margin-left</code> 改写成 <code>margin-right</code>,将 <code>left: 0;</code> 改写成 <code>right: 0;</code>。我们可以在代码构建过程中使用这类工具,自动将 CSS 代码转换为对应的 RTL 布局代码,这样开发者仍然可以按照 LTR 的布局书写代码。</p><p>与 CSS 逻辑属性相比,使用 CSS 转换工具是更好的选择。通过这种方案,可以完美解决布局镜像的问题。然而,direction 还存在另一个缺点,即它仅适用于 CSS,涉及 JS 就无能为力。</p><h3>方案选择</h3><p>我们希望以较低的成本改造线上已有的 UI 场景,以支持 RTL 布局。大部分业务内容中的文本和图片无需翻转,因此使用 transform 方案逐一适配这部分内容会带来大量工作量,需要编写大量影响业务逻辑的代码。在业务迭代过程中,开发人员需要不断处理二次翻转的问题。相比之下,使用 direction 方案能减少开发者对哪些模块需要翻转的关注,只需对个别组件的 JS 逻辑进行适配。在权衡利弊后,我们选择了基于 direction 的方案。接下来,我们对该方案进行细化和完善。</p><h2>基于 direction 通用适配方案</h2><h3>direction 设置</h3><p>首先,我们要基于用户语言,在 html 标签设置属性 dir。语言的获取可以从 <code>URL</code> 的 <code>search</code> 属性或 <code>cookie</code>。我们提供一个工具库进行初始化设置,同时提供了更新方法 <code>setDirecion</code>、根据语言判断是否需要 RTL 布局的工具函数 <code>isRTL</code>。</p><pre><code class="ts">import { Cookie } from '@music/helper';
import { parse } from '@music/mobile-url';
const rtlLngs = ['ar-EG', 'he_IL'];
export default class RTL {
private lng: string;
constructor(lng?: string) {
this.lng = lng || '';
if (typeof window !== 'undefined') {
const { location } = (window as Window);
if (!this.lng) {
this.lng = (parse(location.search) as any).language || Cookie.get('language') || 'en-US';
}
document.documentElement.setAttribute('dir', rtlLngs.includes(this.lng) ? 'rtl' : 'ltr');
}
}
setDirecion(lng?: string) {
this.lng = lng || '';
document.documentElement.setAttribute('dir', rtlLngs.includes(this.lng) ? 'rtl' : 'ltr');
}
static isRTL(lng?: string) {
if (lng) return rtlLngs.includes(lng);
if (typeof window !== 'undefined') {
const { location } = (window as Window);
const l = (parse(location.search) as any).language || Cookie.get('language') || 'en-US';
return rtlLngs.includes(l);
}
return false;
}
}</code></pre><p>使用时非常简单,在页面入口文件引入该模块即可。</p><pre><code class="js">import RTL from '@music/tl-rtl';
new RTL();</code></pre><p>SSR 无法从 <code>document</code> / <code>window</code> 获取 <code>cookie</code> / <code>URL</code> 的 <code>search</code> 属性,所以需要通过 <code>getInitialData</code> 获取存储在 store 中,然后通过 Helmet 设置 html 的 dir 属性。</p><pre><code class="js">import { createUrl, parse } from '@music/mobile-url';
// 获取 isRTL 并存储 store
static getInitialData({ req }) {
const { url, header } = req;
const cookieLng = headers?.cookie
?.split(';')
.map((c) => c?.split('='))
?.find((c) => c[0]?.trim() === 'language')?.[1];
const lng = parse(createUrl(url).search).language || cookieLng || 'en-US';
const isRTL = RTL.isRTL(lng);
... // 选择合适的 store 方案存储 isRTL 值
}</code></pre><pre><code class="jsx">import { Helmet, HelmetProvider } from 'react-helmet-async';
// 从 store 获取 isRTL 并设置 html dir
function App ({ isRTL }) {
return (
<HelmetProvider>
<div>
<Helmet>
<html dir={isRTL ? 'rtl' : 'ltr'} />
</Helmet>
...
</div>
</HelmetProvider>
);
}</code></pre><h3>PostCSS Plugin 配置</h3><p>接下来就需要转换 CSS 代码适配 RTL。前面我们说到了选用 CSS 转换工具处理 CSS 代码这一步最好在构建过程中完成,<a href="https://link.segmentfault.com/?enc=vUr%2BaicTVk19g6ziaSuWnw%3D%3D.RnzWjqY%2B2M4XnKoWpF6b0DeC1JcgrLh8k%2BkCSJ8cV4Mgv2hfLegpx70ktB5Y9epfSO4pAG9cqpdrF7L%2Fuz5VH80uQCET2kPq09PXoW3lwvgUMThZyDTO9hKOeyiLRXjL" rel="nofollow">postcss-rtlcss</a>(基于 rtlcss)很好的满足了这一特点,它作为 PostCSS 插件可以在 webpack 构建过程中可以将所有本地代码和 npm 包中的 CSS 文件统一处理。</p><p>下面是 postcss-rtlcss 的使用方式,及一些关键参数的解析。</p><pre><code class="js">import { postcssRTLCSS } from 'postcss-rtlcss';
import { Mode } from 'postcss-rtlcss/options';
const defaultOptions = {
mode: Mode.combined,
ignorePrefixedRules: true,
ltrPrefix: '[dir="ltr"]',
rtlPrefix: '[dir="rtl"]',
bothPrefix: '[dir]',
};
const options = {
...defaultOptions,
safeBothPrefix: true,
processUrls: true,
processKeyFrames: true,
useCalc: true,
};
export default {
module: {
rules: [
{
test: /\.css$/,
use: [
...
{ loader: 'css-loader' },
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
postcssRTLCSS(options)
]
}
}
}
...
]
},
]
}
}</code></pre><p><strong>mode</strong></p><p>该参数控制了 CSS 的生成方式,三种模式分别输出的 CSS 代码如下所示。</p><pre><code class="css">/* input */
.test1 {
width: 10px;
padding: 10px;
}
.test2 {
padding-right: 20px;
}
/* output Mode.diff */
.test1 {
width: 10px;
padding: 10px;
}
.test2 {
padding-left: 20px;
padding-right: 0;
}
/* output Mode.override */
.test1 {
width: 10px;
padding: 10px;
}
.test2 {
padding-right: 20px;
}
[dir="rtl"] .test2 {
padding-left: 20px;
padding-right: 0;
}
/* output Mode.combined */
.test1 {
width: 10px;
padding: 10px;
}
[dir="ltr"] .test2 {
padding-right: 20px;
}
[dir="rtl"] .test2 {
padding-left: 20px;
}</code></pre><p>我们的需求是用一份代码根据语言同时适配 LTR 和 RTL 布局。<code>Mode.diff</code> 模式会将 CSS 代码转换为 RTL 布局的代码,无法同时适配两种布局,因此首先排除。另外两种模式 <code>Mode.override</code>、<code>Mode.combined</code> 则可以生成两种布局的代码。然而,<code>Mode.override</code> 模式在样式覆盖的情况下转换处理会出现一些问题。如上所示,在 RTL 布局时 <code>padding-right</code>最终生效值是 <code>0</code>,与期望的 <code>10px</code> 不符。为了符合预期,我们需要给 <code>.test2</code> 增加一行代码 <code>padding-left: 10px;</code>。而 <code>Mode.combined</code> 模式无需额外处理现有代码即可生成符合预期的代码。</p><p>因此,我们最终选择 <code>Mode.combined</code> 模式,该模式会将需要处理的 CSS 代码生成两份,以便在渲染时对应生效。接下来的 demo 输出的 CSS 都是基于此模式。</p><p><strong>safeBothPrefix</strong></p><p>该参数设置为 <code>true</code> 时 CSS 输出结果如下所示,即会给不需要翻转的方向性 CSS 属性类名增加 <code>bothPrefix</code>(<code>[dir]</code>)。在 <code>class="test1 test2"</code> 时,可以按照 CSS 书写顺序使得 <code>.test2</code> 的 <code>padding</code> 样式能正确覆盖 <code>.test1</code> 的。设置为 <code>false</code> 时输出的 <code>.test2</code> 的规则名保持不变,不会变成 <code>[dir] .test2</code> ,按照 CSS 选择器权重会导致最终生效的是 <code>[dir="ltr"].test1</code> / <code>[dir="rtl"].test1</code> 对应的 <code>padding</code> 样式,与期望不符。</p><pre><code class="css">/* input */
.test1 {
padding: 0 10px 0 20px;
}
.test2 {
padding: 0 20px;
}
/* output */
[dir="ltr"] .test1 {
padding: 0 10px 0 20px;
}
[dir="rtl"] .test1 {
padding: 0 20px 0 10px;
}
[dir] .test2 {
padding: 0 20px;
}</code></pre><p><strong>processUrls</strong></p><p>该参数控制是否按照字符串映射来翻转更改 URL 中的字符串,例如 <code>ltr</code> <code>left</code>。当设置为 <code>false</code> 不会处理 URL 地址,当设置为 <code>true</code> 会翻转处理如下所示。</p><pre><code class="css">/* input */
.test {
background-image: url("./img/ltr/arrow-left.png");
}
/* output */
[dir="ltr"] .test {
background-image: url("./img/ltr/arrow-left.png");
}
[dir="rtl"] .test {
background-image: url("./img/rtl/arrow-right.png");
}</code></pre><p><strong>ignorePrefixedRules</strong></p><p>该参数值为 <code>true</code> 会忽略 CSS 选择器中包含 <code>rtlPrefix</code>、<code>ltrPrefix</code>、<code>bothPrefix</code> 的 CSS 规则,不进行转换。当设置为 <code>false</code> 会被转换为如下所示,导致 CSS 选择器无法匹配,从而使样式失效。</p><pre><code class="css">/* input */
[dir="rtl"] .test {
left: 10px;
}
/* output */
[dir="ltr"] [dir="rtl"] .test {
left: 10px;
}
[dir="rtl"] [dir="rtl"] .test {
right: 10px;
}</code></pre><p>前文我们说到,指向性图像需要在 RTL 布局时翻转,而 <strong>ignorePrefixedRules</strong> 和 <strong>processUrls</strong> 恰好可以用来处理这种情况。<strong>processUrls</strong> 适用于本地资源,本地存放 2 份资源图片即可;<strong>ignorePrefixedRules</strong> 可同时作用于远程资源,增加下面的全局样式(该样式不会被转换,且仅在 RTL 布局生效),并给需要翻转的图片增加 <code>flip-img</code> 类名即可。</p><pre><code class="css">[dir="rtl"] .filp-img {
transform: scaleX(-1);
}</code></pre><p><strong>useCalc</strong></p><p>该参数控制是否翻转 <code>background-position-x</code> 和 <code>transform-origin</code> ,当设置为 false 时不处理,当设置 <code>true</code> 会被转换为如下所示。</p><pre><code class="css">/* input */
.test {
background-position-x: 5px;
transform-origin: 10px 20px;
}
/* output */
[dir="ltr"] .test {
background-position-x: 5px;
transform-origin: 10px 20px;
}
[dir="rtl"] .test {
background-position-x: calc(100% - 5px);
transform-origin: calc(100% - 10px) 20px;
}</code></pre><p><strong>processKeyFrames</strong></p><p>该参数控制是否翻转关键帧动画中的样式规则,考虑到动画中也会存在左右移动的情况,设置为 <code>true</code>。</p><p>更多参数设置可以查看 <a href="https://link.segmentfault.com/?enc=jttwx7bkAe1iy6EG35lTGg%3D%3D.Iy3TUrBFXargD9pi%2BImfYoiwGt5mYd07C0SPj%2Fsi1AlWQwp%2FS9NbTW3lVTWY3jzzwsNwwxOlcYkEXTOFoY22lJeYnYtWiFsJ%2F6%2B%2BnVqgpzznQkig00PFka6EZ%2BNKsLQ%2B" rel="nofollow">options</a>了解。</p><h3>避免内连样式</h3><p>由于 postcss-rtlcss 插件只处理样式文件,所以 CSS 都要书写在样式文件中,如非必要,不要使用如下内联样式,</p><pre><code class="jsx"><div style={{ marginLeft: 10 }}>
...
</div></code></pre><p>如果必须使用内联样式,比如说需要在 JS 中计算 CSS 属性值,需要业务自行适配 RTL 布局。</p><h3>第三方库的适配</h3><p>在业务开发时我们通常会用到一些三方组件,例如 <code>antd</code>、<code>Swiper</code>,我们需要考虑这些组件如何适配 RTL。</p><p><strong>antd</strong></p><p><code>antd</code> 已经支持了 RTL 布局,需要进行如下配置即可(本文讨论的 <code>antd</code> 基于 4.x 版本)。</p><pre><code class="jsx">import { ConfigProvider } from 'antd';
export default ({ isRTL }) => (
<ConfigProvider direction={isRTL ? 'rtl' : 'ltr'}>
<App />
</ConfigProvider>
);</code></pre><p>配置之后我们发现展示结果与期望不符,排查发现是因为 <code>antd</code> 已经根据 direction 对组件的类名和 CSS 样式做了镜像处理。</p><pre><code class="js">// ltr
<Component className="ant-xxx" />
// rtl
<Component className="ant-xxx ant-xxx-rtl" /></code></pre><pre><code class="css">.ant-xxx {
margin: 0 8px 0 0;
}
.ant-xxx.ant-xxx-rtl {
margin-left: 8px;
margin-right: 0;
}</code></pre><p>在配置 <code>postcss-rtlcss</code> 插件之后,CSS 代码会被处理成下面的代码,导致在 RTL 布局时,根据书写顺序和 CSS 选择器优先级最终按照 <code>[dir="rtl"].ant-xxx.ant-xxx-rtl</code> 渲染,导致结果错误。</p><pre><code class="css">/* output */
[dir="ltr"] .ant-xxx {
margin: 0 8px 0 0;
}
[dir="rtl"] .ant-xxx {
margin: 0 0 0 8px;
}
[dir="ltr"] .ant-xxx.ant-xxx-rtl {
margin-left: 8px;
margin-right: 0;
}
[dir="rtl"] .ant-xxx.ant-xxx-rtl {
margin-right: 8px;
margin-left: 0;
}</code></pre><p>所以,在配置 <code>postcss-rtlcss</code> 插件时需要将 <code>antd</code> 的样式资源 <code>exclude</code>,保证其 CSS 资源不被镜像处理。</p><p><strong>Swiper</strong></p><p><code>Swiper</code> 组件也适配了 RTL 布局,只需要在其祖先节点设置 <code>dir="rtl"</code> 即可,而我们的方案就是在 html 标签设置 dir,无需要额外处理。</p><p>其他涉及 JS 层面需要适配 RTL 的私有组件需要开发者获取 dir 的值,并对组件进行适配改造。</p><h3>快捷工具</h3><p>在开发调试过程中,我们提供了一个语种快速切换工具,便于预览对应的 LTR 和 RTL 的布局效果。</p><p><img src="/img/remote/1460000044694402" alt="rtl-helper.jpg" title="rtl-helper.jpg"></p><p>该工具的具体实现如下:</p><pre><code class="jsx">import React, { useCallback } from 'react';
import reactDOM from 'react-dom';
import Select from 'antd/lib/select';
import { parse, stringify } from '@music/mobile-url';
import { Cookie } from '@music/helper';
const rtlLngs = ['ar-EG', 'he_IL'];
const i18nMap = {
'zh-CN': '简体中文',
'en-US': '英文',
'ar-EG': '阿拉伯语',
};
// 创建语种切换组件
const SwitchLng = ({ lngs }) => {
const lng = parse(window.location.search).language || Cookie.get('language') || 'en-US';
const handleSwitch = useCallback((l) => {
// cookie 更新语种
Cookie.set('language', l);
// 替换 url 语种参数并 reload 页面
const searchStrs = parse(window.location.search) || {};
searchStrs.language = l;
const { origin, pathname } = window.location;
window.location.href = `${origin}${pathname}?${stringify(searchStrs)}`;
}, []);
return (
<Select
style={{ position: 'fixed', bottom: 10, left: 10, width: 140 }}
defaultValue={lng}
onChange={handleSwitch}>
{lngs.map((l) => (
<Select.Option value={l}>
{i18nMap[l]}
{rtlLngs.includes(l) && (
<span style={{ color: 'red', marginLeft: 5 }}>RTL</span>
)}
</Select.Option>
))}
</Select>
);
};
class RTLHelper {
constructor(lngs) {
const l = (lngs || []).map((e) => e.replace('_', '-'));
const allLngs = ['en-US', 'ar-EG', 'zh-CN'].concat(l);
this.supportLngs = [...new Set(allLngs)];
this.renderDOM();
}
// 渲染组件到页面中
renderDOM() {
const btn = document.createElement('div');
document.body.appendChild(btn);
reactDOM.render(<SwitchLng lngs={this.supportLngs} />, btn);
}
}
export default RTLHelper;</code></pre><p>使用时在 dev 文件中引用即可。</p><pre><code class="js">import RTLHelper from '@music/tl-rtl/helper';
new RTLHelper();</code></pre><h2>总结</h2><p>本文介绍了云音乐出海业务中 Web 项目对 RTL 语言的适配实践,并总结为一套通用高效的方案。该方案使开发者在处理业务需求时无需过多关注样式适配问题,为开发者提供了便捷高效的开发体验。</p><h2>参考资料</h2><ul><li><a href="https://link.segmentfault.com/?enc=jYy2YRRYE5M8ljX9Y5BU5g%3D%3D.NLASU%2FpKjknUj7gyNXDiEQODqrfgnrL8Lm3rDK7r%2FCNHCWMNPynAWoau6xWmSTO1qP44o3x25IifdX%2BFNDaIyGjz2n2%2BvQTcnjcmHgAi54s%3D" rel="nofollow">MATERIAL DESIGN - Bidirectionality</a></li><li><a href="https://link.segmentfault.com/?enc=Yu5joyTdr6jwMRWSm4c8hA%3D%3D.BnxmsXDebwcV5G0CqBe1D5D1S6053NXvqh9EzKxp0eQz5Dna6EbUnP5cU8vWU3ar6%2BCnemEfVkevmSyBGlovMA%3D%3D" rel="nofollow">CSS direction</a></li><li><a href="https://link.segmentfault.com/?enc=XGMDYUpNltWO%2BVBu8LRLpQ%3D%3D.ewxQendcS2dXtydAhZCzc9fz4nA7ZXCa6WeyimwTKK404XM8C6dH2q%2BfvCvjPxGHqI1s%2Fhah7jcxZljL5L1zj0fEUmWtPaM5nF2MBY4T6%2F1K%2FJij017zSu6bSrUGkcPPjBmsPxyA7%2Fsy%2BpUpGdMS2m7lXubzbJ7GROVO7J6bd%2F4L2Bcz9LNfXfzh8H9UE8tg" rel="nofollow">CSS 逻辑属性</a></li><li><a href="https://link.segmentfault.com/?enc=CxGVtP%2ByEDf580Xfbi%2BVRg%3D%3D.K%2FgECFGK0foiRhjQaypIiM1czMocIV8PyZryoFXr2Y0NveY7Kpj%2FLqDcFbQc6SOf" rel="nofollow">postcss-rtlcss</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044694403" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=pmoPVZl%2BoZ2gBsfgpzv01A%3D%3D.2LH6vo6p3wRiRmnAFXp4MhyvAUoezT57nGlNLevcips%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐前端国际化多语言探索实践
https://segmentfault.com/a/1190000044667632
2024-02-29T10:19:55+08:00
2024-02-29T10:19:55+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
4
<blockquote>本文作者:atie,时浅</blockquote><p>本文深入探讨了云音乐海外项目在实现多语言支持过程中的探索和实践,从最初的手动文案管理到发展出一套全自动化的多语言管理系统——千语平台的演变过程。文章介绍了云音乐海外团队如何通过技术创新和流程优化,有效提升了多语言项目的开发效率,解决了多语言应用开发中遇到的常见问题,包括但不限于代码中的语义清晰性、文案维护的高效率,以及性能优化等挑战。通过这一系列的改进,云音乐海外项目能够为全球用户提供更加流畅和响应迅速的使用体验,同时也为多语言应用开发提供了宝贵的实践经验和启示。</p><h2>背景</h2><p>一个国际化的产品,要在不同的国家和地区使用,就必须在设计软件时仔细考虑如何使产品的文本贴合当地的语种。为每个地区单独开发一个版本当然也是一个选择,但是这样做势必浪费人力,资源。云音乐海外项目一直在探索如何更好更优地渲染不同语种的前端文本,目前得出的一个较优的做法是将软件与特定的语种及地区分离,使得软件被移植到不同的语种及地区时,其本身不用做内部工程上的改变或修正就可以将文案,图片等从源码中提取出来,渲染并显示给相应的用户。</p><blockquote>本文侧重于分享我们在开发多语言文案消费端(用户端)时的经验,包括开发效率、项目优化的思考与实践。</blockquote><h2>一些流行的语言多语言库</h2><p>在介绍云音乐海外的多语言方案之前,我们先了解下当前一些流行的多语言库以及一些常规的做法</p><h3>i18next及react-i18next</h3><p><strong>i18next</strong> 是一个用于前端国际化的 <strong>JavaScript</strong> 库。它提供了一个简单易用的 <strong>API</strong>,可以帮助开发人员将应用程序本地化到多种语言。它提供了一种简洁的方式来加载翻译资源,并且支持多种资源格式(如 <strong>JSON、PO</strong> 等)。同时,它还支持动态加载和缓存翻译资源,以提高性能和用户体验。</p><p><strong>react-i18next</strong> 则是基于 <strong>i18next</strong> 的一个 <strong>React</strong> 绑定库,提供了一套用于在 <strong>React</strong> 应用程序中实现国际化的组件和高阶组件。它能够无缝集成到 <strong>React</strong> 应用程序中,并且提供了方便的 <strong>API</strong> 来处理语言切换、翻译文本和处理复数等国际化相关任务</p><p><strong>用法</strong></p><p>初始化 <strong>i18next</strong>,并在入口文件引入</p><pre><code class="js">// i18n.js
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(LanguageDetector)
// 注入 react-i18next 实例
.use(initReactI18next)
// 初始化 i18next
.init({
debug: true,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
resources: {
en: {
translation: {
// 这里是我们的翻译文本
welcome: "Welcome to my website",
},
},
zh: {
translation: {
// 这里是我们的翻译文本
welcome: "欢迎来到我的网站",
},
},
},
});
export default i18n;</code></pre><pre><code class="js">// app.js
import { useTranslation, Trans } from "react-i18next";
function App() {
const { t } = useTranslation();
return (
<div>
<main>
<p>{t("welcome")}</p>
</main>
</div>
);
}
export default App;</code></pre><h3>vue-i18n</h3><p><strong>vue-i18n</strong> 是一个用于在 <strong>Vue.js</strong> 应用程序中实现国际化的库。它同样提供了一种简单易用的方式来处理对多语言的支持,使开发人员能够轻松地将应用程序本地化到不同的语言。<strong>vue-i18n</strong> 支持多种语言切换策略,包括 URL 参数、浏览器语言设置和自定义逻辑。同时它还支持动态加载和异步加载翻译资源,以提高性能和用户体验。</p><p><strong>用法</strong></p><pre><code class="js">// 准备翻译的语言环境信息
const messages = {
en: {
message: {
hello: 'hello world'
}
},
ja: {
message: {
hello: 'こんにちは、世界'
}
}
}
// 通过选项创建 VueI18n 实例
const i18n = new VueI18n({
locale: 'ja', // 设置地区
messages, // 设置地区信息
})
<div id="app">
<p>{{ $t("message.hello") }}</p>
</div>
</code></pre><p>通过上面的代码,可以看出两个流行库的用法实际上有比较多的相似点。大体上都是在代码中内置多语种文案,在业务代码中通过调用 <strong>i18n</strong> 方法,并传入对应文案的 <strong>key</strong>。编译的时候,会根据当前语种,读取 <strong>key</strong> 对应的文案并渲染。</p><p>一开始,云音乐海外采用的也是与上述流行库类类似的用法来解决多语言的方案,但用得越久,我们发现的问题越多,诸如:</p><ul><li>1、<strong>写法复杂,效率低</strong>,<strong>t('key')</strong> 的写法需要思考映射内容</li><li>2、<strong>不符合语意化</strong>,代码中一堆的 <strong>key</strong>,会产生较强的割裂感</li><li>3、<strong>回溯困难</strong>,定位问题文案需要先找 <strong>key</strong>,再通过映射关系找到内容</li><li>4、<strong>维护困难</strong>,内置的文案,如果需要修改,会需要改代码增加开发人员的心智负担</li><li>5、<strong>代码冗余、影响性能</strong>,一个模块内的内容被重复引用,引入了不必要的文案</li><li>6、<strong>项目迁移难度大</strong>,一个原先国内的项目要接入多语言需要做大量的文本兼容</li></ul><p>诸如此类,上述的问题一度困扰了我们很长一段时间,而经过一年多时间的沉淀,目前海外的多语言方案已经能够较好地解决上述我们所面临的各种问题。下面,我们将会介绍我们是如何从文案管理到文案录入再到回归国内业务开发习惯(抛弃 <strong>t('key')</strong> 写法)以及性能优化等一步步形成云音乐海外国际化的方案。</p><h2>方案的演变</h2><h3>1. 千语管理平台</h3><p>云音乐海外项目启动后,iOS、android、前端和服务端都需要为多语言的切换做准备。在最开始的阶段海外团队尝试过用 <strong>Excel</strong> 来统一填写维护文案。但是通过 <strong>Excel</strong> 会存在如下问题:</p><ul><li><strong>复用率低下</strong>:传统的开发模式,各端本地存放国际化语言文本,难以重复利用;</li><li><strong>维护成本高</strong>:不同开发修改容易导致出错、命名冲突等问题且没有修改记录,无法追溯,维护成本大;</li><li><strong>沟通困难</strong>:产品运营和技术通过邮件、企业通讯工具等沟通配合难度大;</li></ul><p>所以我们萌生了以下几个想法,以优化多语言支持的流程和维护:</p><ol><li><strong>建立统一的国际化管理平台</strong>:开发一个中央化的国际化(i18n)管理系统,用于存储、更新和检索所有语言的文本。这个平台可以为所有端(iOS、Android、前端、服务端,flutter等)提供统一的文案资源。</li><li><strong>通知翻译</strong>: 开发者录入完文案之后,可以通过推送,将对应待翻译文案通过企业通讯工具推送给翻译同学。</li><li><strong>多语种文案长度对比功能</strong>:这一功能支持实时预览同一文案在不同语言下的文案长度,以便翻译人员调整文案,确保各语种版本在长度上尽可能一致,避免不同语种下产生的样式表现问题。</li><li><strong>Excel批量处理功能</strong>:平台支持通过 <strong>Excel</strong> 进行文案的批量导入和导出,以便于高效地管理和更新大量的文本内容。</li><li><strong>集成翻译服务</strong>:考虑集成专业的翻译服务或机器翻译API,以提高翻译效率和质量。</li><li><strong>版本控制</strong>:使用版本控制来管理国际化文本,确保更改的可追溯性。</li><li><strong>角色和权限管理</strong>:在国际化管理平台中实现角色和权限管理,确保产品运营、翻译人员和开发人员能够在适当的权限下进行工作。</li></ol><p>上述的这些方案与想法最终集合成了云音乐海外多语言文案管理平台——<strong>千语</strong>,<strong>千语</strong>的落地,极大地提高了多语言项目的效率和质量,同时降低维护成本和沟通难度。</p><p><strong>使用流程</strong></p><ol><li>创建应用(每个工程,或某个 App 都可创建一个应用)</li><li>创建模块(每个应用下,可以创建多个模块,一般我们把每个独立页面,或者某一个玩法活动归笼到某一个模块下)</li><li>创建文案</li><li>发布(发布到 CDN)</li></ol><blockquote>对于多语言文案生产端的设计与实现,本文不做详细讨论。市面上已经有一些对外提供服务的多语言管理平台产品,大家可以参考他们的设计与实现。</blockquote><h3>2. 千语自动化</h3><p><strong>背景</strong></p><p>一开始云音乐海外C端多语言方案是使用的 <code>i18next</code>,<code>react-i18next</code> 这两个库实现的。</p><p>该技术方案与上面介绍的 <code>i18next</code>,<code>react-i18next</code> 库的用法一致,区别在于一个是我们文案不是写死在代码中,而是通过 CDN 来获取文案内容,二是为了项目管理方便,我们的“key”是由项目模块module(module 可以理解为一个命名空间,不同的页面可以单独定一个 module,不同的应用也可以定一个 module)以及唯一键 key(key 可以理解为一个文案的唯一标识) 组成,具体方案大致如下:</p><ol><li>千语平台发布前端文案到 CDN 上</li><li>前端请求 CDN 获取多语言文案(由 key 跟文本组成的 JSON),并用 <code>i18next</code> 初始化<br><img src="/img/remote/1460000044667634" alt="image.png" title="image.png"></li><li>业务代码中使用 <code>react-i18next</code> 的 <code>useTranslation</code>,文案通过编写 <code>t('module:key')</code>,也即 <code>react-i18next</code> 的 <code>t('key')</code> 来获取对应模块下的文本映射</li><li>最终渲染页面</li></ol><p>我们开发流程大致如下:</p><ol><li>千语平台上录入文案</li><li>通知翻译同学翻译文案</li><li>发布文案到 CDN,更新 CDN 版本</li><li>修改代码中的CDN版本号,这样我们的文案才能请求到指定版本的文案</li><li>前端代码中文案通过书写 <code>t('module:key')</code></li></ol><h3>2.1 千语自动化1.0</h3><p>在经历多次需求迭代后,我们发现当前的多语言方案效率不佳。工作流程中需要频繁切换平台和 IDE,并且涉及修改 CDN 资源的版本号来确保获取最新的 CDN 资源。另外,代码中使用的 <code>t('module:key')</code> 缺少清晰的语义表达,这降低了其易理解性和维护性。因此,我们开始考虑实施多语言文案的自动化策略,以提升效率和代码质量。</p><h3>梳理可自动化流程</h3><p>为了提高云音乐海外项目的工作流程效率,经过深入讨论,我们决定对现有流程进行以下优化:</p><ol><li><strong>简化代码书写</strong>:不再使用传统的指定 <code>module</code> 和 <code>key</code> 的方法编写国际化代码,改为直接使用 <code>$i18n('中文')</code> 进行书写,简化开发过程并提高代码的可读性。</li><li><strong>自动化文案管理</strong>:开发人员无需手动在千语平台的文案管理页面创建录入文案。千语自动化插件将自动提取代码中的待翻译中文文案并自行创建唯一键 <code>key</code> 并上传,减少人工操作和潜在的错误。</li><li><strong>自动发布文案</strong>:一旦文案上传完成,系统将自动触发发布流程,将文案推送至 CDN,无需开发人员手动介入,提高发布效率。</li><li><strong>自动化版本管理</strong>:取消手动修改 CDN 版本号的步骤,通过读取缓存中的版本号,确保流程的连贯性和准确性。</li></ol><p>经过这些流程的优化,开发人员在编码时只需简单地使用 <code>$i18n()</code> 包裹中文文案,剩余的翻译上传、发布到 CDN 以及版本管理等流程均由自动化工具完成。这样不仅极大地提升了开发效率,也保证了流程的一致性和准确性,让团队能够更专注于核心开发工作。</p><h3>实现方案</h3><p><strong>架构图</strong></p><p><img src="/img/remote/1460000044667635" alt="" title=""></p><p>为了提升工作效率并实现国际化文案的自动化管理,我们设计了一个两阶段的自动化方案:</p><p><strong>第一阶段:文案自动替换</strong></p><ul><li><strong>技术实现</strong>:利用自开发的 <code>babel</code> 插件,这个插件通过分析抽象语法树(AST),识别出代码中的 <code>$i18n('你好')</code> 表达式。同时插件会以当前项目设定的模块 <code>module</code> 自动查询多语言平台,找到对应的 <code>module</code> 下“你好”这个文本的 <code>key</code>,然后将原始的 AST 节点 <code>$i18n('你好')</code> 替换成 <code>t('module:key')</code> 格式。</li><li><strong>迭代更新</strong>:在后续的版本迭代中,我们增加了对直接使用中文文案的支持(也即摒弃了<code>$i18n()</code>方法包裹的形式,通过 <code>babel</code> 插件直接识别代码中的中文文案,如“你好”),进一步简化了开发过程。</li></ul><p><strong>第二阶段:文案自动提取与上传</strong></p><ul><li><strong>过程描述</strong>:在代码提交前,通过 <code>commit</code> 钩子扫描修改过的代码。该过程与之前在文案自动替换阶段创建的缓存文件进行对比,以确定新的或修改过的文案。然后,将这些文案自动上传到多语言管理平台。</li><li><strong>自动触发发布</strong>:文案上传后,自动触发平台的发布流程,主要更新文案版本号。这确保了在代码的热更新过程中,如果文案发生变化,文案自动替换阶段能够识别并拉取最新的文案资源。</li></ul><p>通过这个方案,我们极大地简化了国际化文案的管理流程,从手动操作转向自动化处理,显著提升了开发效率并减少了人为错误,使得团队能够更加专注于产品的核心功能开发。</p><p><strong>重点部分</strong></p><p><strong>资源缓存</strong></p><p>工具包会缓存版本号跟文案资源到包中。初始化的时候,会先对比版本号是否一致,如果不一致,拉取平台最新文案,并缓存到本地,供后面 <code>babel-plugin</code> 文案替换使用。</p><p>技术方案中比较复杂的部分涉及到 <code>AST</code>,一个是 <code>babel-plugin</code>,一个是 <code>commit</code> 的时候的执行的 <code>node</code> 脚本。下面我将提供阉割过的代码,带大家了解下 <code>AST</code> 部分的实现。</p><p><strong><code>babel-plugin</code></strong></p><pre><code class="js">{
return {
visitor: {
Program: {
enter(programPath, { filename }) {
programPath.traverse({
// 拦截纯中文的节点
StringLiteral(path) {
visitorCallback(path, filename);
},
// 拦截纯中文的节点
JSXText(path) {
visitorCallback(path, filename);
},
// 拦截 $i18n() 的节点
CallExpression(path) {
ExpressionCallback(path, filename);
},
});
},
},
},
};
}</code></pre><p>上面三个节点,分别对应我们代码中的五种写法。</p><ul><li>纯中文写法</li><li><p><code>$I18n()</code> 写法(万能写法,支持很多功能)</p><ul><li><code>$i18n('纯中文')</code></li><li>文案中带有变量<code>$I18n('你好!%1', { 1: name })</code>,%1会被替换 name 对应的值</li><li><code>$i18n({ module: 'shop', key: 'dress' })</code>,支持 module key 的写法</li><li><code>$i18n({ text: '你好!<1>%1</1>', components: { 1: <span>}, values: { 1: name }})</code>多语言组件写法,例子最终会被替换为<code>你好<span>{name}</span></code>。比如 name 需要通过标签来修改他的样式。</li></ul></li></ul><p><strong><code>visitorCallback</code></strong></p><p>纯中文节点处理逻辑</p><pre><code class="js">function visitorCallback(path, filename) {
const CNValue = path.node.value.trim();
// 先判断是否中文 [yes] 已验证匹配到了所有中文
if (!(isChinese(CNValue) && !isIgnoreNode(path))) return;
// 第一种情况是打包时携带对应的语种进来
const languageModules = DefaultLangObj;
// 找到匹配到对应模块的module:key
const currentModuleName = getModuleNameByRelativePath(
Path.relative(i18nConfig.rootPath, filename),
);
const currentCNObj = LOCAL_DOC?.["zh-CN"]?.[currentModuleName] || {};
const textKey = Object.keys(currentCNObj).find(
(key) => currentCNObj[key] === CNValue,
);
// 替换原来的中文文案节点为当前语种对应的文案节点
const languageText =
languageModules?.[currentModuleName]?.[textKey] || CNValue;
path.replaceWith(t.stringLiteral(languageText));
}</code></pre><ol><li>通过拦截的中文,找到对应中文在千语平台上的 module 和 key</li><li>在对应语种文案集合中通过 module 和 key 找到对应的文案</li><li>文案替换</li></ol><p><strong><code>ExpressionCallback</code></strong></p><p><code>$i18n()</code> 写法处理逻辑</p><pre><code class="js">function ExpressionCallback(path, filename) {
// 如果里面是对象 对应 $i18n({})
if (t.isObjectExpression(node?.arguments[0])) {
// 没有components属性,代表是$i18n({ module, key }) 写法
if (!hasComponentAttr && keyFind && moduleFind) {
const languageModules = DefaultLangObj;
const key = keyFind.value.value;
const module = moduleFind.value.value;
// 找到匹配到对应模块的module:key
const languageText = languageModules?.[module]?.[key];
const valuesProps = findProperty(properties, VALUES);
// 有本地文件的处理方式
const newLiteral = t.stringLiteral(languageText);
// ... 一堆代码逻辑
// 通过上面的module key 从缓存文件中找到对应语种的文案,并替换
path.replaceWith(newLiteral);
path.skip();
}
// 如果里面有components属性,代表是多语言组件写法
if (hasComponentAttr) {
const CNAttr = findProperty(properties, TEXT);
const valuesProp = findProperty(properties, VALUES);
// ... 一堆代码
// 封装成一个react组件返回
}
}
// 如果里面是文本
if (t.isLiteral(node?.arguments[0])) {
// 主逻辑大致同上面纯文本visitorCallback的逻辑,只是多了一些逻辑的判断,兜底语种等功能
}
}</code></pre><ol><li>通过拦截的中文,找到对应中文在千语平台上的 module 和 key</li><li>在对应语种文案集合中通过 module 和 key 找到对应的文案</li><li>判断不同的写法类型,转化成相应的内容</li></ol><h3>接入指南</h3><pre><code class="js">const { I18nPlugin } = require("@music/i18n");
webpackChain: (chain) => {
chain.plugin("i18n").use(I18nPlugin, [{ id: 190 }]); // id 对应千语多语言平台的应用id
};</code></pre><h3>使用指南</h3><p>对于那些好奇如何在文案中嵌入变量或从接口动态获取数据的同学,这里提供了几种主要的使用方式来适应不同的场景:</p><ol><li><strong>直接使用中文</strong>:当文案中不包含变量时,书写纯中文即可。</li></ol><pre><code class="js"><p>你好</p></code></pre><ol start="2"><li><strong>嵌入变量的文案</strong>:使用 <code>$i18n('我有一个%1', { 1: apple })</code> 的格式来插入变量。例如,<code>$i18n('%1 world', { 1: 'hello' })</code> 允许你将 <code>hello</code> 作为变量动态插入到文案中。</li><li><strong>使用已有文案的引用</strong>:通过 <code>$i18n({ key, module, fallbackText })</code> 格式引用千语系统中已存在的文案。其中,<code>fallbackText</code> 作为未成功匹配文案时的备选内容。</li><li><p><strong>组件中的复杂文案</strong>:</p><pre><code class="js">$i18n({
text: "价格<1>%1</1>商品名<2>%2</2>",
components: {
1: <p style={{ margin: "0 5px", color: "#FDE020" }} />,
2: <p style={{ color: "#FDE020" }} />,
},
values: {
1: price || "",
2: name || "",
},
});</code></pre><p>这种方法允许在文案中嵌入React组件,并通过 <code>values</code> 传递变量。</p></li></ol><p>我们也在不断探索更优的用法来进一步提升开发体验。近期,我们计划引入基于字符串模板的变量嵌入方式,如通过 <code>${hello} world</code> 的形式来实现。这将使得带变量的文案书写更加直观和便捷,为开发者带来更佳的开发体验。</p><h3>2.2 千语自动化2.0:性能优化方案</h3><p>项目性能同样是海外项目的一个重要的考量因素。虽然基于 <code>i18next</code> 和 <code>react-i18next</code> 实现的自动化方案有效提升了开发效率,解决了一系列的效率问题,但它并未充分解决由多语言支持引入的各种性能挑战:</p><ol><li><strong>多语言资源加载</strong>:项目需要从CDN预加载多语言资源,或将所有语种文案打包进项目中,这增加了首屏加载时间。</li><li><strong>库依赖</strong>:引入 <code>i18next</code> 和 <code>react-i18next</code> 两个库,导致项目体积增加。</li><li><strong>渲染延迟</strong>:项目必须等待多语言库初始化完成后,才能进行最终渲染,影响用户体验。</li><li><strong>静态站点生成(SSG)不友好</strong>:当前方案不支持 SSG 预构建,无法为不同语种国家提供同一份预构建的产品(因为不同国家的语言不同)。</li></ol><p><strong>2.2.1 解决方案探索🤔️</strong></p><p>为了克服这些性能问题,我们决定跳出现有自动化方案的限制,采用一种新的思路:为每个语种创建独立的构建包。这个构建包将仅包含所需的语种文案,无需携带多余的语种信息或依赖 <code>i18next</code> 、 <code>react-i18next</code> 库。这样,我们可以针对不同的语种提供精简且高效的构建产物,避免不必要的资源加载和库依赖,同时解决SSG预构建的问题。</p><p>通过这种多构建产物方案,我们旨在显著提高项目的加载速度和运行效率,同时维持开发过程的自动化和高效性,为用户提供更加流畅和响应快速的体验。</p><p><strong>2.2.2 技术方案</strong></p><p><img src="/img/remote/1460000044667636" alt="" title=""></p><p>为了提升项目性能并解决多语言支持带来的挑战,我们对原有的自动化方案进行了多次优化和调整:</p><p><strong>2.2.3 生产产物的优化</strong></p><p><strong>编译阶段的改进</strong></p><ul><li>引入了 <code>I18N_LANGUAGE</code> 环境变量,在构建过程中指定当前构建目标的语种。</li><li><p>利用自定义的 <code>babel</code> 插件,在AST分析阶段将代码中的纯中文或通过 <code>i18n()</code> 方法包裹的文案,直接替换为当前构建语种对应的文案。这一步骤实现了在源代码层面的语言特定优化。</p><ul><li>前一阶段可以简单理解为 <strong>中文/$i18n('中文')** 通过babel转成 **$i18n('module:key')</strong> ===> 对应语种文案</li><li>现阶段直接越过了中间阶段,直接将中文文案编译成对应语种文案</li></ul></li></ul><p><strong>例子</strong></p><p>平台文案</p><pre><code class="js">{
'zh-CN': {
hello: '你好'
},
'en-US': {
hello: 'hello'
}
}</code></pre><p>源代码</p><pre><code class="jsx">import React from "react";
const Main = () => {
return <div>你好</div>;
};</code></pre><p>如果构建的时候,指定了英语语种,源代码会被转换成</p><pre><code class="jsx">import React from "react";
const Main = () => {
return <div>hello</div>;
};</code></pre><blockquote>构建产物实际是编译过的代码,上面的代码只是为了说明文案原地替换</blockquote><p><strong>产物输出阶段的调整</strong></p><ul><li>调整了构建产物的 <code>publicPath</code> 设置为 <code>dist/${I18N_LANGUAGE}</code>,确保每个语种的构建产物被放置在独立的目录中。这样,<code>dist</code> 目录下将组织有针对不同语种的构建包,使得资源管理更为清晰和高效。</li></ul><p>构建出来的 <code>dist</code> 目录如下</p><pre><code class="js">.
├── en-US
├── id-ID
├── tr-TR
└── zh-CN
...</code></pre><p>这样不同语种的路径如 <code>/heatup/en-US/pageA</code>,就会指向到<code>en-US</code>构建产物中的<code>pageA</code>页面。</p><p><strong>2.2.4 消费产物的变更</strong></p><p><strong>访问路径的调整</strong></p><ul><li>我们从原先直接访问如 <code>/pageA</code> 的方式,转变为访问指定语种的路径,例如 <code>/${language}/pageA</code>。这意味着,客户端在加载某个<strong>WebView</strong>页面时,会根据APP当前选择的语种,自动将链接调整为对应的语种版本,如访问 <code>/en-US/pageA</code>。</li><li>通过这种方式,资源请求直接指向 <code>dist/en-US</code> 下的构建包,从而实现了语种特定的资源加载,减少了不必要的资源请求和加载时间,提升了页面响应速度和用户体验。</li></ul><p>通过上述改动,我们不仅提升了项目的运行效率,减少了不必要的资源负担,也实现了更加灵活和高效的多语言支持方案。这些优化确保了项目在全球多语种环境下的性能表现同时保证了海外的用户体验。</p><h2>总结</h2><p>尽管本文未能覆盖所有细节,但已概述了云音乐海外项目在多语言上的探索实践以及目前云音乐海外多语言自动化最终方案的核心理念。与早期手动处理相比,目前该方案显著提高了开发效率,解决了多个长期存在的问题比如频繁手动输入文案的繁琐、代码中文案缺乏清晰语义以及文案重复输入等问题。此外,它还克服了传统方法导致的项目体积膨胀,以及随之而来的性能挑战。</p><p>通过自动化处理流程的引入和优化,云音乐海外项目不仅提升了工作流的效率,还确保了项目的轻量化和高性能运行,从而为海外用户提供了更加流畅和响应迅速的体验。云音乐海外多语言方案使得团队能够更专注于创新和提升产品质量,同时为用户带来更优质的服务。而于此同时我们也面临着更多的挑战,对多语言项目的优化、提升,仍是云音乐海外项目组需要不断思考与探索的课题。</p><h2>最后</h2><p><img src="/img/remote/1460000044667637" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=KRX%2Fbv8sza2nR5oNAkaw0A%3D%3D.xilPffIREL2tezohpBJ01h1%2Fm9bAdiHQHFiML37Mf3c%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐舆情平台建设
https://segmentfault.com/a/1190000044644390
2024-02-21T10:29:37+08:00
2024-02-21T10:29:37+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:王桂泽</blockquote><p>本文介绍了云音乐舆情平台建设过程中遇到的一些问题和解决方案。</p><h2>背景介绍</h2><h3>通用舆情分析概念和局限</h3><p>通用的舆情分析是指通过收集、整理和分析公众对某一特定话题或事件的言论、观点和情感,从而了解公众对该话题或事件的态度和情绪的方法。舆情分析可以通过监测社交媒体、新闻媒体、论坛、博客等渠道上的信息来获取公众的声音和反馈。</p><p><strong>通用舆情分析的局限</strong></p><p>通用的舆情分析由于数据来源广泛,内容格式宽泛,仅能基于特定主题进行情感分析或趋势分析,无法深入挖掘信息,这意味着企业可能无法获得关于产品的详细反馈和建议,无法了解消费者对产品的具体需求和改进方向。因此,为了满足企业内部对产品提升的需求,可能需要采用更专业、更定制化的舆情分析工具和方法,以便更全面、深入地了解消费者对产品的态度和期望。</p><h2>云音乐舆情平台建设</h2><h4>1. 数据特征:数据来源丰富</h4><p>云音乐舆情分析的数据来源不仅包括外部公众渠道上的信息(比如社交媒体、新闻、博客等),还有许多内部的数据来源,例如通过APP提交的反馈数据,在歌曲下方的评论数据,或者是通过七鱼客服人工反馈的数据等等。这些数据为精细化的舆情分析提供了基础。</p><p>这些数据具有如下特点:</p><ol><li>相关性更高:反馈内容都与产品密切相关。</li><li>馈更加及时:反馈消息实时推送,具有高时效性。</li><li>更加结构化:除了反馈内容,还包括用户信息、设备信息、系统信息等。</li></ol><h4>2. 分析诉求:精细化分析诉求</h4><p>云音乐的舆情分析平台与通用的舆情分析不同,它需要支持更多维度和更细致的分析能力,以满足不同业务和场景的监控需求。</p><p><strong>聚类分析</strong></p><p>云音乐拥有多个产品,每个产品都有各自的功能模块,而每个功能模块还可以进一步细分为子功能。可以将这种结构理解为每个产品都有一个功能树(聚类树)。聚类分析是指将舆情数据归类到聚类树上的某个具体的聚类节点,以便更好地了解用户对不同功能模块的态度和需求,从而针对性地进行改进和优化产品。</p><p><strong>反馈类型分析</strong></p><p>在确定舆情所属的功能模块之后,还需要进一步分析用户的反馈类型,不同的反馈类型需要不同的角色关注。包括:</p><ul><li>问题反馈:反馈产品或功能问题,开发人员需要关注</li><li>产品建议:反馈产品或功能改进建议,产品经理需要关注</li><li>使用咨询:用户咨询产品的使用方法或者相关问题,客服需要关注</li><li>投诉举报:反馈产品或功能的不良问题或违规行为,合规人员需要关注</li></ul><p><strong>摘要提取</strong></p><p>摘要提取是指提取舆情消息中的要点和关键信息。通过对原始消息进行提炼,摘要识别可以帮助用户快速了解舆情消息。另外,可以对大量舆情消息进行摘要分析,以便发现整体问题和趋势,并发现新的热点问题。</p><p><strong>情感分析</strong></p><p>情感分析类似于传统的舆情分析,主要是识别用户情感,包括正向、负向和中性。可以帮助我们了解用户对特定功能的态度和情绪,从而指导产品的改进和优化方向。</p><h4>3. 智能监控:监控和报警</h4><p>舆情监控和通用的监控系统存在一些区别:</p><ol><li>有些渠道的舆情消息是定时爬取的,实时性要求不高</li><li>舆情消息量一般都比较大,一般是对整体趋势、热点问题的监控</li><li>舆情变化趋势是随机的,和内部产品和外部环境都有关系,没有特定的规律</li></ol><p>这就要求平台制定更加智能的监控策略,当舆情消息超出预期时,可以通过短信、邮件等方式向指定人员发送报警通知,以便相关人员及时处理。</p><h3>舆情流转链路</h3><p>云音乐舆情平台更加专注于舆情数据的分析、洞察和监控,通过定义标准化的数据结构快速接入不同来源的数据,下面是核心的舆情流转链路:</p><p><img src="/img/remote/1460000044644392" alt="舆情流转链路图" title="舆情流转链路图"></p><p>舆情数据来自第三方平台,包括:反馈平台,七鱼私信平台、大数据平台;上报支持包括MQ协议和http协议;输出原始舆情。</p><p><strong>适配器</strong>:原始舆情先经过适配器处理,标准化各数据源模型结构,补充设备、产品等元数据信息。输出标准舆情。</p><p><strong>分析器</strong>:对标准舆情进行内容分析,根据舆情所属空间,获取该空间的聚类树,并进行聚类分析、情感分析、意图分析、摘要分析、关键词分析。输出标准舆情+分析标。</p><p><strong>存储器</strong>:将标准舆情和分析标存储到Elasticsearch,供后续在线查询和分析。</p><p><strong>报警计算器</strong>:根据平台内的报警规则(系统报警+用户报警),判断当前舆情是否满足报警规则并触发报警。</p><p><strong>在线查询&分析</strong>:查询、趋势分析、聚合分析等。</p><p><strong>舆情大盘</strong>:发现热点事件、各分析维度的排行榜等。</p><h3>舆情消息模型</h3><p>平台数据来源渠道广泛,而且每个数据源都有独立的属性,既要支持针对每种渠道的精细化分析,也要支持在全局视角对多种渠道数据进行整体分析。<br>为了解决这个问题,平台设计了通用的舆情消息模型,在数据接入层和产品展示层,都是面向这个数据模型进行设计,这样设计的好处有:</p><ol><li>在数据接入层,可以快速接入新的数据源</li><li>在产品层,可以复用舆情查询、分析、报警等功能</li></ol><p><img src="/img/remote/1460000044644393" alt="舆情消息模型图" title="舆情消息模型图"></p><p>一条标准化的舆情消息有下面一些属性:</p><h4>数据源</h4><p>数据源是指舆情的数据来源,比如来自App的用户反馈,来自七鱼私信的客服对话等。<br>平台会根据不同的数据源,在产品层做动态的功能展示。比如在舆情查询页,会根据数据源展示相应的属性,在报警配置页,会根据数据源展示相应的筛选条件。</p><h4>基础属性</h4><p>每种数据源都有一些基础属性。这些属性是在舆情上报时能够识别并携带上来的,例如用户信息、设备信息、App信息、操作系统信息等。<br>平台支持按照所有基础属性做筛选、聚合分析,在报警的时候也可以按照所有基础属性做筛选,提供了灵活的查询和监控能力。</p><h4>分析属性</h4><p>除了基础属性,分析器(包括平台内置的分析器和用户自定义的分析器)还会为舆情添加额外的分析属性。<br>不同的分析器会生成不同的分析属性,例如情感分析器会生成情感属性,聚类分析器会生成聚类属性等。<br>和基础属性类似,所有分析属性都支持筛选、聚合分析。</p><h4>扩展属性</h4><p>支持业务方自定义一些扩展属性,以满足不同业务方差异化的查询和分析需求。</p><h2>技术架构</h2><p><img src="/img/remote/1460000044644394" alt="技术架构图" title="技术架构图"></p><p><strong>数据接入</strong>:原始舆情数据,有来自反馈平台、七鱼平台、数据平台等;协议支持MQ和http协议。</p><p><strong>处理层</strong>:</p><ul><li><strong>适配器</strong>:将各种来源的数据源整合成标准文档结构,并补充元数据:如产品、设备信息、用户信息等。</li><li><strong>分析器</strong>:对舆情内容进行多维度分析,包括:聚类、情感、意图、关键词、摘要提取,分析之后会打上分析标</li></ul><p><strong>数据管理</strong>:数据管理主要是配置处理层的处理规则以及报警规则</p><p><strong>分析&可视化层</strong>:提供对分析之后的舆情数据的查询和分析能力;</p><p><strong>监控&报警</strong>:对接通用监控和统一报警实现舆情监控;同时提供定时分析和舆情洞察能力,提供舆情大盘和日报功能。</p><h2>分析引擎</h2><p>分析引擎负责对采集上来的数据做分析,生成对应的分析属性。 平台会内置一些分析器,比如情感分析、聚类分析、反馈类型分析等。<br>分析器的选择是灵活的,可以根据舆情的数据特征(数据源和基础属性)和分析需求,选择相应的一个或多个分析器进行分析处理。<br>同时,平台也可以方便地添加自定义的分析器,以满足不同场景的分析需求。可以通过GPT提示词开发、SDK插件、服务接入等多个方式接入自定义的分析器。</p><p><img src="/img/remote/1460000044644395" alt="分析引擎" title="分析引擎"></p><p><strong>内置分析器</strong></p><p>平台内置的分析器都是基于GPT开发的,相比传统的机器学习、NLP等分析方法,使用GPT分析具有以下优势。</p><ol><li>首先,GPT模型能够更好地理解和处理自然语言,在语义理解和文本生成方面表现出色,更好地理解语言的上下文和含义,从而析过程中能够更准确地捕捉到细微的语义差异。</li><li>其次,GPT不需要人工标注训练数据,根据需求调整提示词后即可立即生效。传统的机器学习和NLP方法通常需要大量标注数据来训练模型,需要耗费大量人力、机器和时间成本,无法满足快速变化的业务需求。</li><li>另外,GPT模型还能具有总结归纳、发现新问题的能力,而传统的机器学习和NLP方法则则无法完成这一任务。</li></ol><p><strong>GPT成本优化</strong></p><p>与传统的机器学习、NLP等分析方法相比,GPT分析会产生费用,并且随着分析文本数量的增加,成本也会增长。在某些情况下,成本可能会很高,例如在进行聚类分析时,需要将聚类树和文本一起输入给GPT。然而,聚类树本身(包括节点和节点的描述)可能非常庞大,这将消耗大量的Token。平台也针对性的做了一些成本优化措施:</p><p><strong>优化1 缓存</strong></p><ul><li>基于常见文本的分析结果缓存</li><li>基于文本+聚类树版本的分析结果缓存</li></ul><p><strong>优化2 精简聚类树</strong></p><p>聚类分析场景中,聚类树本身消耗了大量的Token,可以在分析之前通过文本相似度算法先筛选出"可能归属"<br>的聚类,在分析的时候只需要分析这些聚类即可,这可以大大减少聚类树的大小, 有效地降低分析成本。</p><h2>在线查询&聚合分析</h2><p>舆情消息经过分析引擎分析后会保存在 ElasticSearch 数据库中,以便支持实时地在线查询和分析。<br>舆情查询页设计如下:</p><p><img src="/img/remote/1460000044644396" alt="查询页" title="查询页"></p><p><strong>舆情查询</strong></p><p>舆情查询的主要场景:在限定上下文中,查询和某个关键词相关的舆情。限定上下文支持全属性(包括基础属性和分析属性);关键词也需要支持逻辑运算,通配符匹配等能力。</p><p>例如:查询用户反馈数据源、iphone端、负面情感的和『黑椒播放器』相关的舆情消息。</p><p><strong>趋势分析</strong></p><p>平台支持灵活的趋势分析能力。在给定查询条件后,您可以查看数据的变化趋势,并指定不同的聚合粒度。此外,平台还提供一些趋势指标,如平均值、最小值、最大值、P80和P95等数据,以满足不同的分析场景。</p><p>例如:在新建监控和报警时,希望根据历史的舆情数据趋势和指标,制定合理的报警阈值。</p><p><strong>聚合分析</strong></p><p>平台支持全属性的聚合分析能力。在给定查询条件后,平台会计算所有『可聚合维度』的分布情况,给出每个维度的不同取值的消息总数和占比。『可聚合维度』是根据当前搜索的数据源动态识别的,不同的数据源可以配置不同的聚合分析维度。</p><p>例如:查询某个时间范围内的Top聚类问题,或者分析和某个主题相关的所有舆情消息的情感分布、App版本分布等。</p><h2>监控和报警</h2><p>平台支持灵活的监控和报警策略。一条监控或报警规则包括3个部分:</p><p><strong>1. 数据筛选</strong></p><p>数据筛选指定了希望监控的舆情消息范围,支持全属性(基础属性和分析属性)的筛选,每个属性支持指定多个值。</p><p>例如:指定监控范围为:用户反馈数据源中,iphone端、改版相关、负面舆情。</p><p><img src="/img/remote/1460000044644397" alt="数据筛选" title="数据筛选"></p><p><strong>2. 报警条件</strong></p><p>平台支持常见的报警条件,例如检测周期、每次检测的时间范围,以及按照阈值、环比增长触发等。同时,平台对阈值的设定经过优化,可以根据历史数据的趋势指标来指定动态阈值。当趋势发生变化时,报警阈值也会相应地动态改变,以确保阈值始终与当前趋势匹配,从而更准确地反映问题。</p><p><img src="/img/remote/1460000044644398" alt="报警条件" title="报警条件"></p><p><strong>3. 报警接收</strong></p><p>当满足报警条件后,会通知相关的接收方。支持指定接收人、IM群组,发送方式也支持IM、短信、电话、邮件等。</p><p><img src="/img/remote/1460000044644399" alt="" title=""></p><p><strong>智能报警</strong></p><p>舆情报警具有一定的特殊性,首先舆情消息本身数据量较大,数据有一定的滞后性,通常会关注整体的变化趋势,而且趋势会随着产品功能迭代和外部环境发生较大的变动。<br>在这种场景下,报警的监控策略和阈值设置就难以确定,如果设置固定的报警阈值,很容易出现误报或者漏报的情况。如果都是靠人工定期维护报警,成本又会很高,而且及时性和有效性也难以保障。</p><p>针对这个问题,平台提供了一种智能报警的解决方案。平台会根据不同的监控场景自动创建报警规则,报警阈值是根据历史数据动态计算并定时刷新的。</p><p>例如,在聚类问题反馈类监控中,希望监控每个聚类的问题反馈情况,平台会为每个聚类创建一个智能报警规则,监控与该聚类相关且反馈类型是问题反馈的舆情数据。<br>同时根据在该数据筛选条件下的历史的舆情趋势,动态计算阈值和环比增长值,以确保阈值和环比值与当前舆情趋势相匹配。为了保证阈值的时效性,平台还会定时刷新这个阈值。<br>这样可以有效保证报警的有效性和时效性,同时不需要人工参与,大大降低了人工成本。</p><p><img src="/img/remote/1460000044644401" alt="智能报警流程" title="智能报警流程"></p><h2>总结</h2><p>云音乐舆情平台具有以下特点:多数据源、多维度的数据特征;丰富、可扩展的分析器;灵活的在线查询和聚合分析能力;以及智能的监控和报警能力。能够满足复杂场景的舆情分析、查询、监控和报警需求。</p><p>后续的发展方向是结合GPT,进一步挖掘数据背后的价值,例如提供智能日报或周报功能,对周期内的舆情数据进行提炼、总结,并给出分析报告,以减少人工分析的成本。</p><h2>最后</h2><p><img src="/img/remote/1460000044644402" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=tggJHdm3Qx2R3aizWQwvsA%3D%3D.7T2ldO3om2dgX8AtyzAkTIrOVPmSM%2BqHlc8MUPoFlXk%3D" rel="nofollow">https://hr.163.com/</a></p>
心遇APP站内玩法H5体验优化实践
https://segmentfault.com/a/1190000044625198
2024-02-07T10:36:52+08:00
2024-02-07T10:36:52+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:史志鹏</blockquote><p>本文主要介绍心遇APP站内玩法H5的体验优化实践,主要包括离线包功能简介、接口图片预加载、榜单优化等具体场景内容。</p><h2>1. 离线资源</h2><p>在H5的开发过程中,尽管我们实践了很多手段对H5进行性能提升,比如代码层面的 React 渲染优化,Web Vitals 体验优化;打包构建层面的 Code Split & Bundle Analyze 加载优化;应用发布层面的SSR、SSG、网络缓存访问优化等,我们不可否认这些优化手段的有效性和可行性,但是这些优化手段都无法以框架的形式沉淀下来,需要开发者根据已有的经验和分析在代码编写、构建打包、应用发布等各个阶段倾注额外的心力来进行性能优化提升工作,甚至有时候可能会弄巧成拙进行着“反向优化”。</p><p><img src="/img/remote/1460000044625200" alt="常见的优化措施" title="常见的优化措施"></p><p>对于心遇APP的社交活动玩法,一般来说和APP中的基础性功能相比有明显的不同:</p><ol><li>它的玩法逻辑具有一定的系统性,是多个子功能系统的数据生产和消费过程,比如在我们《抢车位》的玩法中,涉及到的生产子功能有10多个:<br><img src="/img/remote/1460000044625201" alt="功能模块" title="功能模块"></li><li>玩法场景具有一定的复杂性,存在若干个游戏场景,游戏场景间存在关联,在《抢车位》玩法中,有宝箱抽奖、停车、商城购车、碎片&皮肤合成等多个玩法场景,所有的玩法场景具有一定的数据关联。<br><img src="/img/remote/1460000044625202" alt="车玩法" title="车玩法"></li><li>玩法的体验也具有多样性,比如混合常见的互动营销交互、小游戏场景等,例如在《抢车位》玩法中也有九宫格抽奖、停车位停车收车等游戏化场景,在《打怪兽》玩法中,有怪兽击打效果、对战PK场景。视频不可见请点击 <a href="https://link.segmentfault.com/?enc=D4YZkheVf%2BfKjHS7oSoOTA%3D%3D.6NlWV%2Fmpib4ugosR%2F10Vwr3f5mlIKutP2LtsJkFQpC43vSsLtxGiSV4BmfkyQBbwIX3NqnywUntmlR%2FIVfY4JER9AWHkcjWOz9cmiYcYuMzRG%2BjhCZ9M5jjXiyWG25uN5wrPm6tLg4hgIX%2BnH8wlyQ%3D%3D" rel="nofollow">开宝箱效果</a>、<a href="https://link.segmentfault.com/?enc=5eJJHavSOasiP%2BJ3h6jHZQ%3D%3D.XE7eedL8pm4m1HgAwAEzg23RGiXDa2tGKEhudvHk3zpoT6mSGY1ToT0PqusbrDMeOEzTGVB1rc7vLVqoeG9WM9fU3tS166Z57CT%2B33sXmFJQVW7y45TObenvRhYtbZ6b8gyezNKp4G10%2FOf6rUNR3g%3D%3D" rel="nofollow">打怪PK效果</a></li></ol><p><video controls autoplay="autoplay" src="https://d1.music.126.net/dmusic/575c/8717/741a/d1434dc6f1c069a7389ccf8608479810.mp4?infoId=1396584" width="375" height="600" ></video><video controls autoplay="autoplay" src="https://d1.music.126.net/dmusic/45a2/8fd0/6dcf/b050dcd011ca19920f5c5b4aa355d09b.mp4?infoId=1394586" width="375" height="600"></video></p><p>因此这类业务的H5开发,不可避免的具有着静态资源大、交互方式多的特点。此外,心遇APP的用户量级与终端性能网络属性,对H5的加载和交互体验也有着一定的要求,这也决定了开发这类玩法H5需要提供很好的性能和交互体验。</p><p>综上,为了一劳永逸地解决前端资源加载的速度问题,我们和客户端、部署平台同事合作,共同推动了离线包功能的升级。</p><h3>1.1 离线包拆包</h3><p>上面说到,对于玩法类H5,静态资源往往比较多,比如在我们的两个玩法里,图片经过压缩后,打包的总体积仍然会达到 10M 以上,由于离线包 <code>diff</code> 的版本可能有限,碰到客户端缓存的版本已经超过离线包 <code>diff</code> 版本限制时,则需要下载全量的离线包,这个全量包的流量不应该是用户应该承担的,所以我们选择对离线包进行“拆包”,这个从功能上和小程序的分包一样,在技术实现上“接近” Webpack 的 code split,即按照功能模块划分,对重要的功能打包到主包进行优先加载,将不需要优先加载的子包(subpacks)按照一定的规则逻辑延后加载。</p><p>拆包实现的技术主要是:</p><ol><li>首先需要对功能模块进行划分,主要分为首屏、次屏。</li><li>按照首屏、次屏将文件组织好,比如子包都在 subpacks 文件目录下。</li><li>使用 Webpack optimization 自定义分包能力,将 subpacks 下的文件资源额外分包,形成独立的 chunkFile,构建产物也放到 publicPath 的 subpacks 目录下。</li><li>离线包发布平台提供主、子包打包、下发能力,同时提供后续发布时的diff能力、下发能力。</li><li>客户端根据一定策略,进行主、子包下载,同时提供 JSBridge 能力,交由前端进行子包下载。</li></ol><h3>1.2 Native开屏界面</h3><p>有了离线包功能之后,尽管我们可以忽略网络加载的延迟,但是前端资源仍然需要具有客户端拦截逻辑和磁盘加载带来的延迟, Webview 容器首次加载仍然会有白屏或者 Loading UI 的可能。为了带给用户好的加载体验,对于接入了离线包的 Web 应用都在客户端 Webview 容器上添加了统一的开屏界面,开屏界面支持简单的应用 UI 配置和显示,开屏界面可以由前端在合适的时机控制其销毁,比如在主界面的 DOM 渲染完成时,调用客户端隐藏开屏界面能力,用户即可以看到渲染好的 H5 界面,相比前端直接白屏和Loading,客户端原生的开屏体验更佳。</p><p>开屏界面和离线包功能绑定,具有应用层面的配置能力。</p><pre><code class="json">{
"moduleName": "xx", // 应用名,用于离线包关联
"url": ".+/xx", // publicPath 用于客户端资源匹配
"resID": "xx", // 离线包文件资源ID
"resVersion": "1700720234678", // 构建版本,timestamp
"loadingInfo": { // 开屏界面配置
"loadingBgUrl": "https://xxx.png", // 应用 icon
"loadingTextInterval": 1500, // 多个文案切换间隔
"loadingText": ["xxxx"] // 文案
},
"packages": [{ // // 子包信息
"moduleName": "subpacks-xxx",
"resID": "xx",
"strategy": "open_block|preload",
"resVersion": "1700720234678"
}],
"versionControl": [ // 版本控制配置,主要是过滤条件
{
"belowVersion": "xx", // 指定版本以下
"specificVersionList": [], // 特地版本
"minVersionName": "1.0.0", // 最小版本
"userNos": "xx" // 过滤userId
}
]
}</code></pre><h3>1.3 离线包拆包加载流程</h3><p>下面是客户端同学设计的离线包拆包加载流程,可以看到主要是基于子包拆包后添加了子包加载的逻辑,以及在原来离线包的功能上调整了主包的加载逻辑,同时增加 Native 开屏逻辑:<br><img src="/img/remote/1460000044625203" alt="" title=""></p><h2>2. 数据状态管理与预请求</h2><p>玩法类 H5,业务场景一般比较多。这里的业务场景,在技术层面可以理解为一个个的页面,也可以实现为一个个的全页面组件。业务场景之间存在比较多的数据状态同步,比如当前用户资产、全局性的逻辑数据等;除了比较多的数据状态同步之外,还存在多个业务模块数据的串并行读写,相同业务模块数据的不同表现形式等。基于这些业务情况,我们在数据管理上采用了以下两个措施:</p><h3>2.1 必要的数据状态管理</h3><p>通过全局数据状态管理,不仅可以提高开发效率,还可以“持久化”数据,做高效的数据传递和共享。在玩法类的 Web 应用,功能模块可以高达20多个,对于同一份业务数据,可能会被多个功能模块进行读写,为了高效地处理模块间数据的传递与同步,我们使用 zustand 来进行数据状态管理,在数据层封装好每个业务功能模块的数据读写,然后在业务逻辑层进行数据读写逻辑的引用和调用,UI 层直接取数据进行 UI 渲染,使业务逻辑的表达具有明显的层次性,带来业务模块编写的高效。以下为脱敏代码:</p><pre><code class="javascript">// store.js
export default create<StoreType>((set, get) => ({
data: {
// xxx
},
getData: async () => {
try {
const res = awwait servivce.getData();
set({ data: res });
} catch (e) {
//
}
},
// 暴露给其他业务逻辑
setData: payload => set({ data: payload });
// ...
}));
// view.js
const data = useStore((state: StoreType) => state.data);
const getData = useStore((state: StoreType) => state.getData);
// getData();
// <View data={data} /></code></pre><h3>2.2 数据预加载</h3><p>当然,为了减轻异步数据加载对视图展示的影响,使 H5 更具有小游戏的体验,我们还对各次级模块的数据进行预加载,具体的实现方式是在各次级模块的前一级模块的非阻塞逻辑里完成对次级模块核心数据的预加载请求,在次级模块加载时,再重新发起数据请求更新数据来兜底,这样在次级模块显示时则可以减去 Loading UI,加快次级模块的展示和数据的准确同步。<br>非阻塞逻辑是指前一级模块组件 useEffect 模拟的组件 ComponentDidMount,比如上一级页面或次级模块的入口组件 componentDidMount时机,尽管这些逻辑需要开发者关注更多的逻辑,但是当模块被处理成组件和页面时,则可以结合 React-Router V6 的 loader 字段和 React Suspense + use 的方案进行数据的规范预请求。以下为脱敏代码:</p><pre><code class="javascript">// A1, A2, A3...为不同的业务模块
// A1
useEffect(() => {
fetchData(A1);
prefetchData(A2);
prefetchData(A3);
}, []);
return (
<>
<A1 />
<Link to={A2} />
<Link to={A3} />
<>
);
// A2, A3
const data = useStore((state: StoreType) => state.dataA2);
return (
<A2 data={data} />
);</code></pre><h2>3. 图片加载优化</h2><p>图片资源的加载优化也是应用体验优化重要的一环,对应用的 LCP、FCP 数据有着明显提升。在 Web 应用中,图片分为应用本地的静态图片和接口返回的动态图片,在图片的加载和展示优化上我们也有一些实践。</p><h3>3.1 静态图片</h3><p>类似于接口预加载的思路,我们使用 web worker 技术,将核心次级模块中的大图进行提前加载,由于 web worker 的非阻塞性和浏览器本身的资源缓存能力,这些次级模块的背景图会被提前加载并缓存在浏览器的内存中,而由于图片模块引用路径的一致性,且这类静态图片都被离线缓存到客户端本地,所以提前和实时的渲染请求也不会造成消耗流量的问题,同时即使提前请求失败,也会有实时渲染请求来保底。</p><pre><code class="javascript">// preloadAssets.js
import { RESOURCE_TYPE } from '@music/tl-resource';
import BoxBg from '@/subpacks/assets/TreasureBox/tbg.png';
import PackageBg from '@/subpacks/assets/PackStore/bg.png';
// 需要预加载的图片
export default [{
src: BoxBg,
type: RESOURCE_TYPE.IMAGE,
},
{
src: PackageBg,
type: RESOURCE_TYPE.IMAGE,
},
{
src: StoreBg,
type: RESOURCE_TYPE.IMAGE,
}];
// view.js
// 预加载图片
await Resource.loadResource(loadAssets, (progress: number) => {
setLoadProgress(progress);
});</code></pre><h3>3.2 动态图片</h3><p>在 Web 应用中,接口返回动态图片,一般分为用户上传的 UGC 图片和平台在后台上传的 PGC 图片。我们对于这两类图片,从图片的生产、转换、消费流程上都进行了合理的优化:对于接口下发的 PGC 图片,在后台配置的时候就根据 UI 稿显示的大小限制好图片的宽高、大小、格式,比如 UI 稿上图片展示的是 100x100 像素,则取三倍图标准 300x300 进行限制,这样可以合理控制资源的大小,避免不必要的渲染。</p><p>同时对于在业务迭代过程中一些改动较少的 PGC 图片,我们会在工程内进行图片的本地化,然后基于图片上传得到的存储 key 创建和接口返回图片地址映射,当远程图片加载时,替换成了本地图片地址进行加载,这样可以做到远程图片的加载速度显著提升。<br>对于 UGC 图片,则使用 CDN 裁剪,减少不必要的像素渲染,同时对裁剪参数进行收敛,避免 CDN 由于参数差异性导致不必要的回源。</p><p>代码层面对比较大的图片减少使用 CSS <code>background-image</code>,增多使用 <code>img</code> 标签来提高浏览器对图片的加载优先级。</p><pre><code class="javascript">// 本地图片Map,key是存储 key,value 是对应图片的本地地址,数据的来源是基于接口解析获得
const LocalImgMap = {
obj_w57DlMOIw6PCnj7DjMOi_31820368447_d791_9c66_d7e1_a0b39b42967e725d72c1a701d6bbe3ec: require('./locals/obj_w57DlMOIw6PCnj7DjMOi_31820368447_d791_9c66_d7e1_a0b39b42967e725d72c1a701d6bbe3ec.png'),
obj_w57DlMOIw6PCnj7DjMOi_31820383635_fe96_8304_f720_474678d79820f05a5af723f710ecb54a: require('./locals/obj_w57DlMOIw6PCnj7DjMOi_31820383635_fe96_8304_f720_474678d79820f05a5af723f710ecb54a.png'),
obj_w57DlMOIw6PCnj7DjMOi_31820418766_05dd_fe2d_1313_5b80b1108b2bfbbbe084585a3cb57f1f: require('./locals/obj_w57DlMOIw6PCnj7DjMOi_31820418766_05dd_fe2d_1313_5b80b1108b2bfbbbe084585a3cb57f1f.png')
// ...
};
// 本地图片映射组件
const LocalImg = ({ src, ...rest }) => {
const localNosKeyStr = Object.keys(LocalImgMap).find(nosKeyStr => src.indexOf(nosKeyStr.replaceAll('_', '/')) > -1)
const nSrc = LocalImgMap?.[localNosKeyStr] || src;
return (
<Image src={nSrc} {...rest} />;
);
}</code></pre><h2>4. 过渡动画效果</h2><p>玩法 H5 开发和普通展示型的H5开发还有很大的不同,就是在交互体验上需要更接近一些小游戏,比如需要在一些场景转换和状态变更时,做一些合理的视觉效果,在按钮点击时需要有明显的交互反馈。总的来说就是要从交互优化的角度做的一系列的业务开发工作。这里我们举几个简单的例子:</p><ol><li>一般在 React 应用开发中,数据状态的变更,不可避免的会出现视图闪烁的情况,比如数据变更引起的局部UI结构变化,元素的清除、元素的更新等,对于这类小元素状态变更的处理,就是要在数据发生变化时进行过渡,但是视图时受数据响应的,这里需要结合数据发生变化时对元素做一些动画效果。比如列表项数据发生变化时,需要使用缓动消失,这里可以结合一些动画库进行处理。再比如为了数据项不生硬展示时,可以书写一些 CSS 动画让数据缓动入场等,再比如文字发生变化时,可以添加一个切换状态toogle,将数据变化和切换状态结合,切换状态又和动画绑定,则可以表达数据变化的过渡效果。</li><li>对于 UI 变动较大的情况,则可以参考行业内的做法,添加比较大的过场动画,来缓解用户的视觉冲击。比如玩法中场景的变化,可以在每一个场景组件中内置一个提前展示的全场动画,通过下一个场景的数据、UI的到达等合理去控制过场动画展示。</li><li>普通的交互最好都设计好一套标准的交互,比如按钮点击效果、弹窗展示和消失动画、模态弹窗的使用等,总之玩法H5的开发要逐步向游戏开发的标准靠近。</li></ol><h2>5. 榜单优化</h2><ol><li>直播社交类应用往往不乏排名榜单的功能,而且随着业务功能的扩大,榜单展示的逻辑也会变得复杂,比如从单层Tab榜单发展为多层 Tab 嵌套榜单,在我们的玩法中,榜单嵌套可以达到 2x3x2 = 12 个数据榜单,如何在满足较高体验目标的情况下设计这12个榜单的组织结构和数据加载,是一个值得考虑和实践的问题。</li></ol><p><img src="/img/remote/1460000044625204" alt="榜单" title="榜单"></p><ol start="2"><li>在最初的版本中,实现方式是多层 Tab 组合和一个数据列表 List,用户点击任一 Tab,触发新的数据请求,重新渲染 List,List 是一个最大长度为300的列表。这种实现方式相对比较简单,实际的效果就是频繁切换Tab的时候,同时一次性重新渲染300条数据的结构,造成明显的 UI 闪烁。</li></ol><pre><code class="javascript"><Fragment>
<Tabs tabs={[A1, A2]} />
<Tabs tabs={[B1, B2, B3]} />
<Tabs tabs={[C1, C2]} />
<List data={calc(A1, B1, C1)} />
</Fragment></code></pre><ol start="3"><li>为了解决重新渲染引起的闪烁问题,我们将榜单的 List 改成了 KeepAliveList,即维护了3个 List 节点,只有1个 List 处于可见区域,其他 List 则被 KeepAlive 组件缓存在内存当中,当用户在切换 Tab 时,就会将缓存住的 List 移入可见视图,这个过程不会再有大量的节点重建,只有已渲染缓存的节点移动,所以变消除了闪烁的情况。</li></ol><pre><code class="javascript"><KeepAlive cacheKey={`${biz}_pre`} saveScrollPosition={false}>
<div className="item hide" key={pre}>
{childs[pre]}
</div>
</KeepAlive>
<div className="item show cur" key={index}>
{childs[index]}
</div>
<KeepAlive cacheKey={`${biz}_next`} saveScrollPosition={false}>
<div className="item hide" key={next}>
{childs[next]}
</div>
</KeepAlive></code></pre><ol start="4"><li>同时,为了保证首次加载创建的闪烁问题,我们在游戏进入场景时即提前请求了全量榜单的前10条数据,这样可以既保证榜单首次创建时可以不会出现Loading的样式,也缓解了首次创建的数据加载消耗。当然,对于后续的数据加载,我们也采用了常见的上拉加载的方式,尽量避免单次大量数据的渲染。</li></ol><p><img src="/img/remote/1460000044625205" alt="" title=""></p><ol start="5"><li>在多榜单处理的中,还有一个比较常见的问题,就是滚动问题。使用了多个 List 来表单榜单后,由于不同榜单的高度可能不一致,如果使用全局滚动,则在 Tab 切换的时候,就会出现滚动重置的情况,所以在这种情况下有必要使用局部滚<br><img src="/img/remote/1460000044625206" alt="" title=""></li></ol><h2>总结规划</h2><p>以上,我们通过离线缓存、接口预加载、图片加载优化、过渡动画、KeepAliveList 榜单优化等实践方式优化了玩法H5的用户体验,虽然最后达成的效果从感官上相比普通的H5有明显的不一样,但是大部分优化都是需要耗费一定的开发成本。未来会将其中一些可以框架化的方案沉淀下来,减少一定的开发成本,比如数据预加载、图片预加载、KeepAliveList、动画组件等,为后续的小游戏H5开发提供较好的开发经验。</p><h2>参考</h2><ol><li><a href="https://link.segmentfault.com/?enc=qKORqCqEL6%2BixKqSyJb1gg%3D%3D.HpbIYnRH%2Bo0mId7FnhcNYRnfzrvfFfqT83sy%2FnPA%2Ba%2FIG2bG8hyj1unfLy0Aooav" rel="nofollow">react-activation</a></li><li><a href="https://link.segmentfault.com/?enc=pALYgBxvpem3u6w2e66X8g%3D%3D.k8nzL3uD0urvlZXkZ2Wdis2lCg%2BtGznitBHDvOkPPCrysYygbU0AXaWx7KszejXS" rel="nofollow">Web Worker在项目中的妙用</a></li></ol><h2>最后</h2><p><img src="/img/remote/1460000044625207" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=OHUTzkh7w5KP0G29MGwAXg%3D%3D.kOm%2Bv936jzoyRepNQAwwyo4BCrLZBABWcUnJY%2FJBwAQ%3D" rel="nofollow">https://hr.163.com/</a></p>
Closure in V8
https://segmentfault.com/a/1190000044620384
2024-02-05T10:32:45+08:00
2024-02-05T10:32:45+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:Vice</blockquote><h2>前言</h2><p>对于我们前端开发来说,无时无刻不在接触着闭包。比如在 <code>React Hooks</code> 中利用了闭包来捕获组件的状态,并在组件的生命周期中保持状态的一致性。在 <code>Vue</code> 中利用闭包来定义计算属性和监听器,以及在组件之间共享数据。在 <code>Angular</code> 中利用闭包可以用于创建服务和依赖注入。</p><p>所以理解闭包产生的原因和原理对我们的日常开发非常重要。</p><h2>热个身</h2><p>其实 JavaScript 本身的特性决定了一定要实现闭包:</p><ol><li>JavaScript 允许在函数内部定义新的函数。</li><li>因为 <code>词法作用域</code>,可以在内部函数中访问父函数中定义的变量。</li><li>函数作为一等公民,函数可以作为返回值。</li></ol><p>利用上面三点列举一个贯穿全文的 JavaScript 经典闭包代码:</p><pre><code class="JavaScript">function multi() {
var a = 10;
return function inner() {
return a * 10;
}
}
const p = multi();</code></pre><p>此段代码声明了 multi 函数,在函数内部定义了变量 a,并且返回了 inner 函数,inner 函数中访问 multi 函数中声明的 a,最后执行了 multi 函数并且将返回值返回给 p。这个时候闭包就创建完成啦,闭包让开发者可以从内部函数访问外部函数的作用域,p 函数始终能访问到 multi 函数中的 a。</p><p>但是大家都知道,multi 函数执行完之后,理应内部声明的变量都会被销毁,但是因为闭包的原因,这个 a 变量突破了这种限制。</p><p>为了实现闭包,我们来看看 V8 都是怎么做的吧。</p><h2>V8 是如何执行一段 JavaScript 代码的</h2><p>我们都知道,我们写的 JavaScript 代码,是需要经过编译的步骤,让 CPU 获取到一串二进制的指令去执行的。完成这一步的通常有两种方法:</p><ol><li>解释执行,将源代码通过解析器生成中间代码,然后用解释器解释执行,它的优势在于快速启动执行,但执行速度相对较慢。</li><li>编译执行,也是先生成中间代码,然后通过编译器将中间代码直接转换成二进制代码,执行的时候直接执行二进制文件即可,它的优势在于执行时直接操作二进制文件,执行速度更快,并且编译过程只进行了一次,所以在多次执行相同代码时,编译执行的性能更高,但是相对的启动速度就会比较慢。</li></ol><p>V8 采取的策略是混合编译执行和解释执行,也就是我们经常听到的 JIT,是一种对上述两种策略的一种权衡。流程如下:</p><p><img src="/img/remote/1460000044620386" alt="V8 执行代码" title="V8 执行代码"></p><ol><li>初始化执行环境,比如堆栈空间、事件循环系统等。</li><li>解析器解析代码生成 AST 和作用域。</li><li>根据 AST 和作用域生成中间代码,也就是字节码。</li><li>解释器解释执行中间代码输出结果。</li><li>监控解释器执行,发现频繁执行的热点代码会生成二进制代码以提高执行速度。</li><li>热点代码改变或者执行频率下降,编译器会执行反优化重新让这段代码生成字节码。</li></ol><h2>V8 遇到函数是如何编译的?</h2><p>上面说到执行 JavaScript 代码需要经过编译到中间代码的步骤,但是实际上 V8 并不会把所有代码全部进行解析,是因为如果一次性编译所有 JavaScript 代码,编译时间会很长,需要全部编译完才能执行代码,对用户来说会感到严重的延迟特别是大型项目。并且编译产生的大量中间代码会非常占用内存资源,特别是移动设备,内存的消耗是需要谨慎考虑的。</p><p>所以包括 V8,所有主流浏览器都实现了<code>延迟解析(lazy parsing)</code>。顾名思义,V8 会推迟对代码的解析,直到代码被实际执行时才进行解析。具体就是在解析器遇到函数声明时,只会解析函数的声明部分,而不会解析函数内部的代码。在执行函数的时候 V8 会对函数进行各种优化,例如内联优化、类型推断等。延迟解析也可以使 V8 有更多的执行上下文和运行时信息,从而更好地进行优化,提高代码的执行效率。</p><p>我们来使用 D8 工具具体看个例子:</p><pre><code class="JavaScript">var top = 1;
function multi(a) {
return a * 10;
}</code></pre><p>通过 <code>d8 --print-ast</code> 命令打印出 AST 信息:</p><blockquote>V8 首先会接收到我们书写的源代码,为了理解这段源代码,它需要结构化这段字符串来生成源代码中的语法结构和关系,便于后续 V8 的理解。比如语言转换器 Babel、语法检查工具 ESLint 等,底层都使用了 AST 去实现。</blockquote><pre><code class="AST">--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (0x7fa6a5810050) (mode = VAR, assigned = true) "top"
. . FUNCTION "multi" = function multi
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 10
. . . INIT at 10
. . . . VAR PROXY unallocated (0x7fa6a5810050) (mode = VAR, assigned = true) "top"
. . . . LITERAL 1</code></pre><p>简单解释下这段被解析器解析生成的 AST,着重看 <code>DECLS</code> 和 <code>EXPRESSION STATEMENT</code>。</p><p><code>DECLS</code> 代表一组声明,此处声明了一个名为 top 的变量,并且该变量被赋值(assigned = true)。还声明了一个名为 multi 的函数。</p><p><code>EXPRESSION STATEMENT</code> 表示一个表达式语句节点,这里就是 <code>var top = 1;</code>,下面的内容代表这段表达式的结构化表述,将变量 top 的 proxy(指向了实际 top 的值,可以看到 <code>0x7fbc75010c50</code> 地址相同)并且初始化为字面量 1。</p><p>所以自始至终解析器并没有解析函数体内部的代码,仅仅只解析了函数的声明部分。</p><p>我们也可以通过 <code>d8 --print-scopes</code> 打印此时 multi 函数的作用域:</p><pre><code class="scopes">Global scope:
global { // (0x7ff32601e030) (0, 53)
// will be compiled
// NormalFunction
// 1 stack slots
// temporary vars:
TEMPORARY .result; // (0x7ff32601e530) local[0]
// local vars:
VAR top; // (0x7ff32601e250)
VAR multi; // (0x7ff32601e4a0)
function multi () { // (0x7ff32601e2e0) (27, 53)
// lazily parsed
// NormalFunction
// 2 heap slots
}
}</code></pre><p>我们可以看到它没有为 multi 函数生成作用域,而是进行 <code>lazily parsed</code>。</p><p>那我们执行一下这个 multi 函数,看看 AST 会是什么样子:</p><pre><code class="JavaScript">var top = 1;
function multi(a) {
return a * 10;
}
multi(3);</code></pre><pre><code class="AST">[generating bytecode for function: multi]
--- AST ---
FUNC at 27
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "multi"
. PARAMS
. . VAR (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. DECLS
. . VARIABLE (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. RETURN at 37
. . MUL at 46
. . . VAR PROXY parameter[0] (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. . . LITERAL 10</code></pre><p>执行 multi 函数时,从 multi 函数对象中取出函数代码,和顶层代码一样编译为 AST 和字节码,然后再解释执行,这里我们简单看看生成的 AST 吧:</p><p><code>PARAMS</code> 代表函数参数部分,表示函数有一个参数 a,且该参数未被赋值(在执行阶段才会指向堆和栈中相应的数据)。<code>DECLS</code> 中声明了 a 变量,地址与参数 a 相同。<code>RETURN at</code> 代表函数返回语句位于源代码的位置。<code>MUL at</code> 代表返回值是一个乘法表达式。下面一行代表乘法表达式的第一个操作数是参数 a。<code>LITERAL 10</code> 代表乘法表达式的第二个操作数是字面量 10。</p><h2>延迟解析 & 闭包</h2><p>当延迟解析遇到了闭包,那么情况就又复杂了,我们来稍微改造一下上面的 multi 函数。</p><pre><code class="JavaScript">function multi() {
var a = 10;
return function inner() {
return a * 10;
}
}
const p = multi();</code></pre><p>这是一段闭包代码,我们简单分析下上述代码的执行流程:</p><ul><li>执行 multi 函数时,multi 函数会将它的内部函数 inner 返回给全局变量 p。</li><li>然后 multi 函数执行结束,执行上下文被 V8 销毁。</li></ul><blockquote>V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等,比如这里的 a 变量。</blockquote><ul><li>虽然 multi 函数的执行上下文被销毁了,但是被全局 p 引用的 inner 函数引用了 multi 函数作用域中的变量 a。</li></ul><blockquote>为什么 inner 函数中的 a 引用的是 multi 中的 a,这是因为 JavaScript 是基于词法作用域,是静态的作用域,和函数如何调用如何执行没有关系,是代码编译阶段就决定好的,查找顺序都是照当前函数作用域向上冒泡,最后到全局作用域。所以这里的变量查找规则为 inner 函数作用域 -> multi 函数作用域 -> 全局作用域。</blockquote><p>所以这里就会带来两个问题?</p><ol><li>当 multi 函数执行完成时,因为闭包的存在,此时 multi 的执行上下文被销毁,但是 a 变量又被引用了,肯定不能被销毁,那么 V8 会采取什么策略。</li><li>因为 V8 采用的延迟解析,在 inner 函数未执行的时候,是不会解析 inner 内部的代码的,所以 V8 并不知道是否引用了外部作用域中的变量。</li></ol><h2>预解析器(preparser)</h2><p>V8 为了解决这两个问题的,引入了 <code>预解析器(preparser)</code> 模块来解决,主要是做了两件事:</p><ol><li>当解析到顶层函数时,预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,是为了判断当前函数是不是存在一些语法上的错误。</li></ol><p><img src="/img/remote/1460000044620387" alt="报错" title="报错"></p><blockquote>在过去的版本中,预解析器在解析脚本时会忽略变量声明,例如在同一作用域中两次声明同名的变量应该被视为语法错误,但预解析器会允许这样的代码通过预解析阶段。当时是为了追求性能的提升,预解析器忽略了变量声明的处理。现在修复后的预解析器能够正确处理变量声明和引用,符合ECMAScript规范,并且也没有明显的性能损失。</blockquote><ol start="2"><li>当执行函数时,只会将当前函数生成 AST 以及字节码,对内部声明的其他函数进行预解析,是为了检查函数内部是否引用了外部变量。如果函数内部引用了外部变量,预解析器会将这些变量从栈中复制(值类型复制值,引用类型复制地址)到堆中。这样,在下次执行该函数时,函数可以直接使用堆中的引用,从而解决了闭包所带来的问题。</li></ol><p>我们来具体通过执行 multi 函数的字节码来理解下,通过 <code>d8 --print-bytecode</code> 来打印:</p><blockquote>其实早期的 V8 为了提升代码的执行速度,是直接将 JavaScript 源代码编译成了没有优化的二进制的机器代码,但是随着移动设备的普及,V8 团队逐渐发现将 JavaScript 源码直接编译成二进制代码存在两个致命的问题。第一是编译时间过久,影响代码启动速度;第二是缓存编译后的二进制代码占用更多的内存。所以便引入字节码来解决上述启动问题和空间问题。</blockquote><pre><code class="bytecode">[generated bytecode for function: multi (0x06d300259e19 <SharedFunctionInfo multi>)]
Bytecode length: 14
Parameter count 1
Register count 1
Frame size 8
Bytecode age: 0
0x6d30025a092 @ 0 : 83 00 01 CreateFunctionContext [0], [1]
0x6d30025a095 @ 3 : 1a fa PushContext r0
0x6d30025a097 @ 5 : 0d 0a LdaSmi [10]
0x6d30025a099 @ 7 : 25 02 StaCurrentContextSlot [2]
0x6d30025a09b @ 9 : 80 01 00 02 CreateClosure [1], [0], #2
0x6d30025a09f @ 13 : a9 Return
Constant pool (size = 2)
0x6d30025a061: [FixedArray] in OldSpace
- map: 0x06d300002231 <Map(FIXED_ARRAY_TYPE)>
- length: 2
0: 0x06d300259ff9 <ScopeInfo FUNCTION_SCOPE>
1: 0x06d30025a029 <SharedFunctionInfo inner>
Handler Table (size = 0)
Source Position Table (size = 0)</code></pre><p>我们看到 <code>Bytecode age: 0</code> (代表字节码的执行状态,数字增加代表函数的热度,也就是上面说的热点代码,V8 就会对这串代码进行针对性优化)下的一条条指令就是字节码啦,这六条指令解释器执行完就代表 multi 函数执行完成了,上面打印出来的字节码只是全部的冰山一角,若有同学有兴趣的话,可以到<a href="https://link.segmentfault.com/?enc=0b3dG1vSYA7ho9gc8z%2FEag%3D%3D.m1ACz0Tk2UgfpZ4WY5zonUj%2FQQy2KrE%2BAXORFNV8%2FBaGrYNOU6GZ13Y1kQRvEinLT6CsGuuNYNFC9Hkn9dcgIzc69vWcHofyAViCfwKBqQA%3D" rel="nofollow">V8源码</a>查看更多。</p><p>这里的字节码最终通过解释器解释执行,在执行的过程中,需要通过某些手段去保存参数、中间计算结果等,V8 的解释器(Ignition)采用的是基于寄存器的架构,他通过寄存器来保存所需要的数据。有兴趣的同学可以详细查看<a href="https://link.segmentfault.com/?enc=I%2BKkMOxNPLxHMtZsufT%2FDQ%3D%3D.mTKr%2BLBA3XfVfvoIX7C8CB%2FX%2FfLTzzaENkvbMlaHc5McGIVvB9LiC7tArBDM0%2FB7leXgzo0ivEexfjjiEWQAxuh2CrFnxmz3t0JyZB0wR9LtmyS9pMq2MTWZIkhpBv1Brrz4mq02IZuUZusl4JRxDA%3D%3D" rel="nofollow">Ignition 设计文档</a>中的 register 相关内容。</p><p>下面我来简单逐行解释下打印出来的代码。</p><p><code>Bytecode length</code> 表示函数 multi 的字节码长度。<code>Parameter count 1</code> 表示函数 multi 接收一个参数,这里是隐式地传入了 <code>this</code>。<code>Register count</code> 表示使用的寄存器数量。<code>Frame size</code> 代表栈帧大小(因为 V8 是通过栈结构来管理函数调用,栈帧是一个用于存储参数、被调用者的返回值、局部变量和寄存器的空间)。</p><p><code>CreateFunctionContext</code> 是用来创建函数上下文的,会把 multi 函数上下文和作用域信息存到寄存器中,当然 inner 函数也会存进去。<code>PushContext</code> 用于将寄存器中的上下文推入执行上下文栈。<code>LdaSmi</code> 和 <code>StaCurrentContextSlot</code> 代表将值 10 加载到寄存器中并且存储到当前上下文中。<code>CreateClosure</code> 就是通过传入上下文的一些信息,若发现内部有引用外层作用域链上的变量,则输出带有闭包信息的新的 inner 函数存进寄存器中最后返回。</p><p>我们重点看下下面的字节码,<code>Constant pool</code> 代表常量池,当代码中使用了多个相同的常量值时,V8 引擎会将这些常量值存储在 <code>Constant pool</code> 中,并在需要使用时直接引用它们,而不是重复创建多个相同的常量值。继续往下看 <code>[FixedArray] in OldSpace</code> 代表下面的常量存到了老生代中,老生代中的对象更稳定,不容易被回收,通常用于用于存储生命周期较长的对象,例如函数、闭包、大型对象。下面的 <code>ScopeInfo FUNCTION_SCOPE</code> 表示函数作用域信息的数据结构,它记录了函数内部的变量和作用域链等信息。<code>SharedFunctionInfo inner</code> 表示用于存储 inner 函数的字节码等。这两个常量同时存在表示内部函数 inner 与外部函数的作用域存在关联,通过 <code>ScopeInfo</code> 中的作用域链查找到内部函数访问了外部函数的变量。最后在 <code>SharedFunctionInfo</code> 中会存储内部函数引用的外部函数的变量作用域范围的信息,这里就是存储了闭包变量 a 的作用域范围,存储到了堆中供后续 inner 函数执行访问。</p><p>所以 V8 通过预解析器使得 JavaScript 的闭包特性得以实现。</p><h2>总结</h2><p>本文我们介绍了在 V8 中是如何实现闭包这一特性的,V8 在处理函数的时候采用的延迟解析来提高启动速度,但是延迟解析和闭包存在天然的矛盾,所以当一个函数中存在闭包并且执行时,V8 会通过引入预解析器去扫描内部函数使用到的外部变量,并且复制到堆中,下次执行内部函数的时候就是直接访问堆中的引用。</p><p>最后我们要注意闭包可能导致的内存泄露问题,我们书写闭包代码时如果引用了一些后续用不到的变量,比如说引用了一个大对象,但是我们只用这个对象中的一个属性值,那么就会导致这个大对象不会被销毁,导致内存泄漏,解决方式们就是要将需要的属性值提取出来成为一个新变量,在函数中引用此新变量就可以。还有一些引用 dom 节点产生的泄露等问题。</p><h2>参考</h2><p><a href="https://link.segmentfault.com/?enc=izR6fi5xtrpDYeXZ4VNjJg%3D%3D.rAtjkJFNTQXUWPM2rDW40onq%2BIggNBsLYPQa7y%2FTXzIZouRCmWvR%2BXO5iMIJZ2ywZSd7if1Gndv9WaxJCYYSPQ%3D%3D" rel="nofollow">图解 Google V8</a></p><p><a href="https://link.segmentfault.com/?enc=fe9MiUkTBfrOkoX2MLyE5w%3D%3D.Ipi9jdDtcQ1KL%2FXaJ5J%2FfpZckTHG%2FUr7leZr7pmbej8%3D" rel="nofollow">Blazingly fast parsing, part 2: lazy parsing</a></p><h2>最后</h2><p><img src="/img/remote/1460000044620388" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=JIYXPTbFlRQYa93nPQuiOw%3D%3D.Nhte%2BqfUFqtG9t3ZfBTbroGnm2BNAjX6HvUlelKQKaw%3D" rel="nofollow">https://hr.163.com/</a></p>
开启空间计算时代 - 初识苹果 Vision Pro
https://segmentfault.com/a/1190000044611647
2024-02-02T10:47:40+08:00
2024-02-02T10:47:40+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:徐凯斌、王维恒</blockquote><h2>本文预览</h2><p>1、苹果首款头显设备 Vision Pro 的背景和基础概念介绍,走入空间计算时代;</p><p>2、详细解读设备的硬件组成和空间设计的四个原则,揭示其独特之处;</p><p>3、展示「云音乐」App 在模拟器和真机上的运行情况;</p><p>4、苹果上海 Vision Pro 开发者实验室体验真机,行业内早期真机体验分享;</p><p>5、「云音乐」App 的落地畅想;</p><h2>背景</h2><p><img src="/img/remote/1460000044611650" alt="" title=""></p><p>苹果于 WWDC23 发布了首款头显 Vision Pro,一台搭载了全球首创的空间操作系统 VisionOS 的革命性的空间计算设备,具备多个摄像头,用户用手势、眼睛或者语音就可操作控制,可以用来工作、娱乐、沟通的新一代电子产品。2023 年 7 月,苹果正式开放 Vision Pro 头显开发套件的申请通道,以借出设备的形式为开发者提供服务,并在 2024 年 2 月 2 日在美国正式上市。笔者收到上海 Apple Vision Pro 开发者实验室的邀请,线下体验了 Vision Pro 设备,并适配运行了「网易云音乐」应用。</p><h2>功能解读</h2><h3>全方位的沉浸式体验</h3><p>Apple Vision Pro 提供了一幅无边的空间画布,供开发者探索、试验和畅玩,让大家可以自由地尽情重新构想 3D 体验。用户可以在与周围环境保持联系的同时与不同的 App 进行交互,也可以完全沉浸在 App 创造的世界中。用户体验将十分的流畅:首先创建一个窗口,引入 3D 内容,转换为能够完全令人沉浸其中的场景,然后回到其他开发工作之中。</p><p>选择权在你手上,一切要从 VisionOS 中的空间计算构建块开始。 </p><p><img src="/img/remote/1460000044611651" alt="" title=""></p><p>Apple Vision Pro 官方介绍影片中文版请见 <a href="https://www.bilibili.com/video/BV1Rs4y1i7MD/">链接</a>。</p><h4>窗口(Windows)</h4><p>你可以在 VisionOS App 中创建一个或多个窗口。它们使用 SwiftUI 构建,并包含传统的视图和控件(平面化的展示),开发者可以通过添加 3D 内容来添加深度,以丰富用户的体验。</p><p><img src="/img/remote/1460000044611652" alt="" title=""></p><h4>空间容器(Volumes)</h4><p>使用 3D 空间容器为 App 添加深度。空间容器是 SwiftUI 场景,可以使用 RealityKit 或 Unity 展示 3D 内容,从而打造可在共享空间或 App 的全空间中从任意角度查看的体验。</p><p><img src="/img/remote/1460000044611653" alt="" title=""></p><h4>空间(Spaces)</h4><p>默认情况下,App 启动时会进入共享空间(Shared Space),在其中这些 App 并排展示,就像 Mac 桌面上的多个 App 一样。App 可以使用窗口和空间容器来显示内容,用户可以根据需要调整这些元素的位置。为了打造更能令人沉浸其中的体验,App 可以打开一个专用的全空间(Full Space),在其中只显示这个 App 的内容。在全空间中,App 可以使用窗口和空间容器创建无边界的 3D 内容,打开通往一个不同世界的入口,甚至可以让用户完全沉浸在某个环境中。</p><p><img src="/img/remote/1460000044611654" alt="" title=""></p><h3>概念解读</h3><p><strong>AR(增强现实)</strong>:眼睛镜片是透明的,可以直接看到外部真实世界;</p><ul><li>通过数字元素叠加来呈现现实世界(物理世界)的视图。</li></ul><p><strong>MR(混合现实)</strong>:既可以看到外部真实世界,也可以看到纯虚拟世界,偏向硬件的描述;</p><ul><li>完全沉浸式的数字环境。</li></ul><p><strong>VR(虚拟现实</strong>):眼睛镜片不是透明的,不可以直接看到外部真实世界;</p><ul><li>现实世界(物理世界)的视图,具有数字元素的叠加,其中物理元素和数字元素可以交互。</li></ul><p><strong>XR(扩展现实)</strong>:AR + MR + VR 都属于 XR;和 MR 相比较,为偏向软件的描述,也可称 XR SDK;</p><ul><li>一个涵盖所有这些不同技术的总称,包括 AR、MR 和 VR。</li></ul><p><img src="/img/remote/1460000044611655" alt="" title=""></p><h4>共享模式(Shared Space)- AR</h4><p>也称为透视模式(Passthrough)。光照完全由系统托管,系统会自动探测环境光照信息和应用做融合。3D 内容都使用苹果自研的 RealityKit 引擎渲染。所以该模式下 Unity 的原始资产均需要被转换为 Realitykit 支持的资产。Unity 官方提供了配套工具可以方便的完成转换:PolySpatial。</p><h4>全沉浸模式(Full Space)- VR</h4><p>光照系统由场景决定,可按需定制。在此模式下,其它引擎不能使用系统的 2D UI,因为需要引擎支持系统窗口这种特殊材质。3D 场景直接使用 Unity 引擎渲染(无需资产翻译)。</p><h4>总结</h4><p>Vision Pro 实际支持 AR、VR、MR ,也可以简单的理解为是一台 支持 XR 的 MR 设备。</p><h3>硬件组成部分</h3><h4>正面</h4><p><img src="/img/remote/1460000044611656" alt="" title=""></p><p>一片独特的三维成型玻璃与铝合金框架,轻轻弯曲以包裹脸部。可在外置屏幕上模拟用户眼部画面。这块弧形屏幕,传感器收集到的用户眼部画面实时渲染出实景一般的图像呈现在屏幕上,让人有看穿屏幕的错觉。苹果将其称为 EyeSight。</p><p><img src="/img/remote/1460000044611657" alt="" title=""></p><h4>相机和传感器</h4><p><img src="/img/remote/1460000044611658" alt="" title=""></p><p>一系列先进的摄像头和传感器协同工作,清楚地看到世界、了解周边环境并检测手部输入。一对高分辨率摄像头每秒向显示器传输超过 <strong>10 亿像素</strong>,因此您可以清楚地看到周围的世界。该系统还有助于提供<strong>精确的头部和手部跟踪</strong>以及<strong>实时 3D 映射</strong>,同时从各种位置理解您的手势。</p><h4>音频带</h4><p><img src="/img/remote/1460000044611660" alt="" title=""></p><p>扬声器靠近耳朵,提供与真实世界的声音无缝融合的丰富<strong>空间音频</strong>。</p><h4>头带</h4><p><img src="/img/remote/1460000044611662" alt="" title=""></p><p>头带提供缓冲、透气性和弹性。通过旋钮根据自己的头部精确调整 Vision Pro;头带采用 3D 针织,形成独特的罗纹结构,提供缓冲、透气性和弹性。</p><h4>显示器</h4><p><img src="/img/remote/1460000044611663" alt="" title=""></p><p>一对定制的微型 OLED 显示器为<strong>每只眼睛提供比 4K 电视更多的像素</strong>,定制的微型 OLED 显示系统具有 2300 万像素,提供令人惊叹的分辨率和色彩。专门设计的三元素镜头营造出无处不在的显示屏感觉。</p><h4>遮光罩</h4><p><img src="/img/remote/1460000044611664" alt="" title=""></p><p>磁吸式遮光罩轻柔地贴合脸部,提供精确贴合,同时阻挡杂散光。</p><h4>表冠旋钮</h4><p><img src="/img/remote/1460000044611665" alt="" title=""></p><p>按下数码表冠调出主视图,然后转动它来控制使用环境时的沉浸感。就能从以假乱真的<strong>外部世界(AR)切换到沉浸的虚拟空间(VR)</strong>。</p><p><img src="/img/remote/1460000044611666" alt="" title=""></p><h4>顶部按钮</h4><p><img src="/img/remote/1460000044611667" alt="" title=""></p><p>按下顶部按钮即可即时拍摄空间视频和空间照片。</p><h4>针对近视的镜片</h4><p><img src="/img/remote/1460000044611668" alt="" title=""></p><p>蔡司光学插拔式镜片可根据视力进行定制,磁性附着在镜片上以实现精确观察和眼动追踪。</p><h4>外接电池</h4><p><img src="/img/remote/1460000044611669" alt="" title=""></p><p>外接电池支持长达 2 小时的使用,连接电源时,支持全天使用。另一侧则是类似的旋转接口的开发专用接口。</p><h4>整体结构</h4><p><img src="/img/remote/1460000044611670" alt="" title=""></p><p>铝壳电池可以放入口袋中作为便携式电源。它使用编织电缆进行连接,常规使用续航可达 2-3 小时。</p><h3>更多组成部分</h3><h4>眼动追踪</h4><p><img src="/img/remote/1460000044611671" alt="" title=""></p><p>由LED和红外摄像头组成的高性能眼动追踪系统将不可见光图案投射到每只眼睛上。这个先进的系统提供超精确的输入,无需您握住任何控制器,因此您只需看一下就可以准确地选择元素。Vision Pro可以在<strong>用户实际点击之前预测他们的点击操作</strong>。因为在用户准备点击之前,瞳孔的反应已经显示出大脑的“点击”动作了。一旦大脑的动作被发现,即可被设备识别。</p><p><img src="/img/remote/1460000044611672" alt="" title=""></p><h4>双芯片</h4><p><img src="/img/remote/1460000044611673" alt="" title=""></p><p>M2 芯片同时运行 VisionOS,执行先进的计算机视觉算法;R1 芯片专门用于处理来自摄像头、传感器和麦克风的输入,并在 <strong>12 毫秒</strong>内将图像流式传输到显示器(比眨眼速度还要快 <strong>8 倍</strong>)。</p><h4>总结</h4><p>Vision Pro 中放入了 <strong>2</strong> 块芯片、<strong>5</strong> 个传感器(包括 <strong>2</strong> 个景深相机)、 <strong>6</strong> 个麦克风、<strong>8</strong> 个高清摄像头、<strong>4</strong> 个红外摄像头、<strong>1</strong> 个激光雷达和 <strong>1</strong> 圈 LED,整个头显重量达到 <strong>450 克</strong>,成为限制用户使用时长的一个重要因素。</p><h2>设计原则</h2><p>Apple VisionOS 搭载全新的 3D 界面,让数字内容看起来、感觉上就像在用户的真实世界存在,透过自然光线和阴影的变化来帮助用户理解比例与距离。Apple Vision Pro 和 VisionOS 既强大又独特的功能,来设计全新的 App 并为空间计算重塑现有 App 的体验。</p><h3>基本空间设计原则(空间)</h3><p><a href="https://link.segmentfault.com/?enc=bkX7RuEdtULAEOvwRIEXyA%3D%3D.D51mCSUCqgxGGHeVbiyVDQXn6vZd6C9GTyHrAWRoyvJuV06g0gmYsxp3X6synLlPEuP1Rp3EMc0Zm2WSRnMA4g%3D%3D" rel="nofollow">https://developer.apple.com/videos/play/wwdc2023/10072/</a></p><p>Spatial design 是 VisionOS 的设计基础,它为用户创造了全新的、完整的基于空间的操作体验,同时保持了和 iPhone 相似的基本操作习惯,保持了苹果产品一贯的简单易用。</p><p><img src="/img/remote/1460000044611674" alt="" title=""></p><p><img src="/img/remote/1460000044611675" alt="" title=""></p><p>这是关于空间设计原则的讲座,由 Apple Design 团队的 Nathan Gitter 和 Amy DeDonato 主讲。以下是主要内容的总结:</p><ul><li><strong>设计空间操作系统</strong>:这种操作系统可以将周围的世界变成无限的画布,用于创建新的应用程序和游戏。通过深度、规模、自然输入和空间音频,可以创造出以前无法实现的体验。</li><li><strong>保持应用程序的熟悉性</strong>:尽管有许多新的可能性,但仍需要与用户熟悉的元素保持平衡。例如,侧边栏、标签和搜索字段等常见元素可以帮助用户找到他们正在寻找的音乐。</li><li><strong>人性化设计</strong>:设计应考虑用户的视野和可能的移动方式。例如,将最重要的内容放在中心,使用景观布局,以及考虑人的舒适姿势等。</li><li><strong>利用空间和尺度</strong>:设计应充分利用空间,并使用深度和规模来优化体验。例如,将窗口设计得足够大,以适应人们的视野,但又足够小,以避免阻挡过多的视线。</li><li><strong>创造沉浸式体验</strong>:沉浸式体验可以超越窗口,改变周围的世界。这种体验可以根据用户在体验中的位置,流畅地在不同的沉浸状态之间过渡。</li><li><strong>保持平台的真实性</strong>:最好的应用程序是丰富的、沉浸式的体验,利用了人们的空间。应用程序不应该是快速跳入一分钟的事情,而应该是值得、引人入胜、独特的体验。</li></ul><p><strong>总的来说,这个讲座强调了在设计空间应用程序时,需要考虑的一些关键原则,包括<strong><em><em>保持熟悉性、以人为中心的设计、利用空间和尺度、创造沉浸式体验,以及保持平台的真实性</em></strong></em>。</strong></p><h3>空间用户界面设计原则(空间 UI)</h3><p><a href="https://link.segmentfault.com/?enc=TXHzKLnyx1mYaxnZoANnGg%3D%3D.n0SuutPG8pDep9JJ0UHE%2BXLrSD7GvHosrSQiNi3aU6k4xmtI564ylSPMem7tWy3qVQGDaGzqNq%2Fbh7DgMyrssQ%3D%3D" rel="nofollow">https://developer.apple.com/videos/play/wwdc2023/10076/</a></p><p>了解如何为空间计算应用程序设计出色的界面。基于屏幕的知识如何轻松转化为为 VisionOS 创造出色的体验。探索 UI 组件、材料和排版指南,了解如何设计熟悉、清晰且易于使用的体验。</p><p><img src="/img/remote/1460000044611676" alt="" title=""></p><p><img src="/img/remote/1460000044611677" alt="" title=""></p><p>内容主要是介绍如何设计空间用户界面:</p><ul><li>Miquel Estany Rodriguez 和 Lorena Pazmino,来自 Apple Design 团队的两位成员,介绍了如何设计空间用户界面。他们构建了一种视觉语言,既保持了与现有平台的一致性和熟悉感,又发展了某些元素以适应沉浸式和空间体验。</li><li>首先讨论了<strong>创建应用图标和界面的 UI 基础和设计原则</strong>,这些图标和界面在环境中清晰可见且易于使用。然后,他们讨论了如何创建既符合人体工程学又易于定位的布局的关键概念和最佳实践。最后,他们展示了如何将应用从屏幕转换到空间,详细介绍了所有系统组件,其中一些你熟悉,一些则完全是新的。</li><li>详细解释了<strong>如何设计出色的图标,如何使用材料,以及如何优化 3D 内容的视觉质量和性能</strong>。它提供了一些关于如何创建 3D 效果,如何预览 3D 模型,以及如何使用新工具如 Reality Composer Pro 和 RealityKit Treace 来检查和优化内容的建议。</li><li>还详细讨论了如何使用空间输入设计,<strong>如何设置应用的核心结构,如何使用窗口、标签栏和侧边栏,以及如何使用新的内容呈现方式</strong>。最后,探讨了模态性,包括菜单、弹出窗口和表单。</li></ul><p>总的来说,这是一个非常详细的空间用户界面设计指南,为设计师和开发者提供了一系列的工具和技巧来创建和优化他们的空间体验。</p><h3>沉浸式声音设计原则(空间音频)</h3><p><a href="https://link.segmentfault.com/?enc=GUlls4Yn6NyLr3GZhOcHaQ%3D%3D.PXP%2FwB0w7vUTHgjmB%2BHjvoPe4e%2BP%2BWOOWhx5w0YLdyzmM6deQxZdDXSvcVlfXIr96%2BRTl%2BXtQ8g%2BC99OYlO5Rw%3D%3D" rel="nofollow">https://developer.apple.com/videos/play/wwdc2023/10271</a></p><p>了解如何使用声音来增强 VisionOS 应用程序和游戏的体验。了解 Apple 设计师如何选择声音并构建音景来打造质感十足的沉浸式体验。我们将分享当您在空间上放置音频提示、改变重复的声音以及在应用程序中构建声音愉悦的时刻时,如何通过声音丰富应用程序中的基本交互。</p><p><img src="/img/remote/1460000044611678" alt="" title=""></p><p><img src="/img/remote/1460000044611679" alt="" title=""></p><p>这是关于探索沉浸式声音设计的讲座,由设计团队的 Danielle Price 主讲。以下是主要内容的总结:</p><ul><li><strong>空间音频的应用</strong>:我们经常使用空间音频来导航世界,例如通过声音的方向和音量来定位 iPhone 的位置。</li><li><strong>空间音频的工作原理</strong>:设备可以适应不同的空间,并添加你的空间的混响,使事物听起来像是真的在房间里。空间音频源会根据它们的位置,听起来像是更近或更远。</li><li><strong>设计 UI 和沉浸式应用的声音</strong>:通过为每个交互添加微妙的声音,我们可以帮助用户产生熟悉感和信心。例如,虛拟键盘的每个按键都来自键盘前方的位置。</li><li><strong>设计 UI 声音</strong>:我们希望 UI 的声音与系统的其他声音相匹配,同时突出深度感。好的 UI 声音应该是微妙的,提供足够的反馈以提供帮助。</li><li><strong>使用声音设计更沉浸式的体验</strong>:例如,我们的环境,Mount Hood,是系统中的全面沉浸式体验。每个地方都有明暗两个版本,都有匹配的真实空问声音景观。</li><li><strong>设计、录制和混合这些体验的声音</strong>:我们可以自由地创造和策划最好的现实,使应用程序的声音以最好的方式补充其视觉效果。</li><li><strong>创建现实声音景观</strong>:我们使用了不同的麦克风来录制环境音,以捕捉一个地方周围的空气声音。然后,我们使用高灵敏度的定向麦克风来捕捉我们正在寻找的特定声音。</li><li><strong>在环境中放置音频对象</strong>:我们可以从真实生活经验中获取灵感。当我们走出去时,许多不同类型的动物会从不同的位置发出声音,它们都层叠在一起形成一个声音景观。我们的任务是以正确的距离和位置重新创建这个声音。</li></ul><p>总的来说,这个讲座强调了在设计沉浸式声音体验时,需要考虑的一些关键原则,包括空间音频的应用,设计 UI 和沉浸式应用的声音,设计 UI 声音,使用声音设计更沉浸式的体验,设计、录制和混合这些体验的声音,以及在环境中放置音频对象。</p><h3>空间输入设计原则(空间交互 - 全新的输入系统)</h3><p><a href="https://link.segmentfault.com/?enc=3pvOyoGfg1dCOyI1tiBfxg%3D%3D.sfMOHqL%2FvfEXN%2B5gSGsaIPCG7AUwflKEQdfGMFjCkcsrCI6rGakZHQng9bBRGPfSQdCyzvo0ImQjK9w%2FuhfgWA%3D%3D" rel="nofollow">https://developer.apple.com/videos/play/wwdc2023/10073/</a></p><p>了解如何为眼睛和手设计出色的交互。我们将分享空间输入的设计原则,探索输入法的最佳实践,并帮助您创造舒适、直观和令人满意的空间体验。</p><p><img src="/img/remote/1460000044611680" alt="" title=""></p><p><img src="/img/remote/1460000044611681" alt="" title=""></p><p>上述内容主要是关于在数字界面交互中手势和眼睛的作用。以下是主要的要点:</p><ul><li><strong>手势交互</strong>:手势是主要的交互方式,可以通过捏、拖动等操作进行交互。UI 反馈应继续手部的运动,以增强交互的连贯性。在设计交互时,应使用用户熟悉的模式,并确保手势的响应符合用户的预期。</li><li><strong>自定义手势</strong>:对于无法用标准手势表达的行为,可以定义自定义手势。自定义手势应易于理解和执行,与系统集合的标准手势明显不同,且用户能够在不感到疲芳的情況下连续重复。</li><li><strong>眼部定向</strong>:眼部定向与手势相结合,可以创建精确和满意的交互。这使得交互更精细和满足。</li><li><strong>直接触摸</strong>:我们支持使用指尖直接触摸和交互。在设计直接交互时,我们要考虑到长时间悬空的手会感到疲劳,因此需要提供充分的反馈以弥补缺失的感官信息。</li><li><strong>音频的作用</strong>:音频在连接输入与虛拟内容方面起到特殊的作用。</li><li><strong>设计的原则</strong>:使用与系统一致的手势语言,仅在无法使用标准集合实现期望行为时引入自定义手势,寻找使用眼睛作为意图信号的方式来改进交互,只有在直接交互是体验的核心时才使用它,并提供丰富的反馈以弥补缺失的感官信息。</li></ul><p>总的来说,这段内容强调了眼部和手部在空间交互设计中的重要性,突出了舒适性和人体工程学的重要性,并提倡设计者和开发者在设计交互体验时考虑舒适性和可访问性。</p><h2>MR 核心技术(透视技术)</h2><p>头带显示器自身具有显示虚拟世界的能力,如何同时在用户的视野中呈现现实世界与虚拟世界是实现 MR 体验的关键。下面是 MR 体验的两种不同方案,旨在解决如何将现实世界显示在用户视野中的问题。</p><h3>VST(视频透视 - Video See Through)</h3><p><img src="/img/remote/1460000044611682" alt="" title=""></p><p>以 <a href="https://link.segmentfault.com/?enc=EKy16gz7WNFcYldfV0AvGA%3D%3D.4N7ZylBuU45ku%2FT8suiwy7fzjkOY3QiBUAQUCxqRcXeng%2BRFiKemAt1RbkaqZAmc" rel="nofollow">Apple Vision Pro</a>、<a href="https://link.segmentfault.com/?enc=tXADnKFSJzD1%2B%2F4PESpPeQ%3D%3D.%2FpDXrTJUiVxKb6EBIU0%2FJ6itzwzDluq%2BH%2BCUkfgNfqU8ghnGjfxJP9EabvVCm9HE" rel="nofollow">Meta Quest-3</a> 等为代表。它利用摄像头等传感器,捕捉真实世界的影像,然后投射到屏幕上,看到的内容都是虚拟重建的。优点是可以构建一个更加虚拟的世界,效果更加梦幻。但是这也意味着对硬件、光线要求更高。如Vision Pro 采用多摄像头、双芯片方案,也进一步拉高了头显重量和成本价格。</p><p><em>实际体验效果请参考文档下方的 Vision Pro 真机体验章节。</em></p><p>VR 行业常用每 1° 视野中像素点(角分辨率,PPD)综合评判头戴设备的显示效果,达到人眼的效果需要到 60。现在的设备普遍只有 20 左右,而 Vision Pro 做到了 40。</p><p><img src="/img/remote/1460000044611683" alt="" title=""></p><h3>OST(光学透视 - Optic See Through)</h3><p><img src="/img/remote/1460000044611684" alt="" title=""></p><p>代表产品有 <a href="https://link.segmentfault.com/?enc=KHWNtCarfj8cAjVQvo7Q7A%3D%3D.CRDeKn1lusYR%2FKm1al1pphLR1ohufXnP9YU93NhvlN8VMWabpy8JwUSj2M0j3z%2BL" rel="nofollow">Microsoft Hololens-2</a>、<a href="https://link.segmentfault.com/?enc=K5s3Aqvn9mKJwKZCigcF4g%3D%3D.uJnW3q8jvDHI5AI%2By9wVvauLnwaSdY%2BQKsvduo8jbR8%3D" rel="nofollow">Rokid Max Pro</a> 等。它可以通过一层玻璃,让人看到的永远是真实世界,在此基础上构建虚拟物品,可以和现实世界产生交互。它的优点是能让人感受真实的世界,眼镜形式更加轻便。但在目前底层硬件技术的制约下,也势必需要牺牲性能、续航和散热。而且还需要不断在性能和重量之间做取舍。</p><p><img src="/img/remote/1460000044611685" alt="" title=""></p><h3>总结</h3><p>OST 被称为真正的 AR,OST 或是未来主要透视解决方案,但当前 VST 的诸多优点使其成为当前的主流方案。AR 眼镜的透视主要采用 OST 方案,AR 眼镜的轻便性或使其成为未来主流 XR 产品形态,相应 OST 也有望成为下一代主流透视技术方案,而 VST 则更适合于当前主流 VR 产品形态。OST 在亮度、真实世界分辨率、延迟、焦平面(影响晕眩感)有显著的优势,而 VST 则在遮挡效果、FOV、虚实匹配、配准、亮度匹配等方面更为成熟。从实机成像效果看,受制于目前光学技术瓶颈,OST 在色彩表现与虚实融合等性能指标上劣势较为明显,VST 虽然无法完全还原现实世界, 但虚实合成后的显示效果仍具有较大优势。</p><p>下面是 VST 和 OST 的各项指标的对比:</p><table><thead><tr><th> </th><th><strong>VST</strong></th><th><strong>OST</strong></th></tr></thead><tbody><tr><td>亮度</td><td><strong>100-600</strong> 尼特</td><td><strong>6600</strong> 尼特+</td></tr><tr><td>真实世界分辨率</td><td>单眼 <strong>2k-4k</strong></td><td>单眼 <strong>24K+</strong></td></tr><tr><td>延迟</td><td><strong>有延迟</strong></td><td>现实世界<strong>无延迟</strong>,虚拟世界有延迟</td></tr><tr><td>焦平面</td><td><strong>1</strong> 个焦平面</td><td><strong>无数</strong>个焦平面,可防止幅辏冲突和眩晕</td></tr><tr><td>遮挡效果</td><td><strong>合理</strong>遮挡</td><td>虚拟对现实<strong>不完全</strong>遮挡</td></tr><tr><td>FOV</td><td>主流在 <strong>90-120°</strong> 之间</td><td>主流在 <strong>30-70°</strong> 左右</td></tr><tr><td>虚实匹配</td><td>虚实匹配<strong>一致</strong></td><td>虚实匹配<strong>不佳</strong></td></tr><tr><td>配准信息</td><td><strong>更易</strong>配准</td><td><strong>仅靠</strong>头部追踪器匹配</td></tr><tr><td>亮度匹配控制</td><td>虚实亮度<strong>易</strong>匹配</td><td>虚实亮度<strong>难</strong>匹配</td></tr></tbody></table><h3>隐私和安全保护</h3><p>Optic ID 是一个全新的安全认证系统,通过分析在各种非可见 LED 光下的用户虹膜,并将其与存储在安全隔区的用户注册 Optic ID 比对以迅速解锁 Apple Vision Pro。用户的 Optic ID 信息完全加密存储在设备上,不会储存在 Apple 服务器上,也无法被任何 app 所访问。</p><p>用户在使用 Apple Vision Pro 时的浏览内容和眼睛追踪信息均不会与 Apple、第三方 app 或网站分享。除此之外,来自相机和其他传感器的信息均直接在设备端处理,所以 app 不需要看见用户的周围环境来提供空间体验。EyeSight 也包含一个视觉指示灯,让周围的人知道用户正在拍摄空间照片或空间视频。</p><h2>真机体验说明</h2><h3>模拟器体验</h3><p><img src="/img/remote/1460000044611686" alt="" title=""></p><h3>真机体验</h3><p>和下面的视频基本体验一致:</p><p><img src="/img/remote/1460000044611687" alt="" title=""></p><h3>使用流程和支持的手势操作</h3><p><img src="/img/remote/1460000044611688" alt="" title=""></p><h2>云音乐畅想</h2><p>借助 VisionPro 设备的无限画布的特性,不同类型的应用可以有不同的 VR 落地方向,如电商应用,可能会去探索沉浸式的 VR 购物体验,让用户在接近真实世界的环境下挑选合适尺码的衣服。下面是基于云音乐应用本身的特性,给出的一些想法和可供参考的探索方向(和实际是否落地无关)。</p><h3>黑胶唱片店</h3><p>首页/个人资产 — 黑胶唱片墙:可以不断切换风格以及动画内容进行展示。</p><p><img src="/img/remote/1460000044611689" alt="" title=""></p><p>Minibar — 黑胶唱片机:支持播控、切换歌曲、红心等,支持独立窗口 pin 在任意位置(同一应用多开)。</p><p><img src="/img/remote/1460000044611690" alt="" title=""></p><p>数码黑胶专辑拟物/装饰播放器样式等会员权益也可以在 VR 中展示出来。</p><h3>VR - 打碟台/多人歌房(派对房)</h3><p>直接触摸黑胶进行打碟、调音器、混合器、remix 的合成器。</p><p><img src="/img/remote/1460000044611691" alt="" title=""></p><h3>氛围空间(Environment Space)</h3><p>利用 Environment 将音乐与视频画面结合,如 VR 旅行、冥想等场景,参考<a href="https://www.bilibili.com/video/BV1oc411W74H">示例</a>。</p><p><img src="/img/remote/1460000044611692" alt="image" title="image"></p><h3>VR 一起听、演唱会</h3><p>支持虚拟人像进行内容透传,打造两人一起听的沉浸式体验。</p><p><img src="/img/remote/1460000044611693" alt="" title=""></p><p>举办个人演唱会(个人录音棚),各种现实世界中的乐器都能虚拟化出来。</p><p><img src="/img/remote/1460000044611694" alt="" title=""></p><h2>参考链接</h2><p><a href="https://link.segmentfault.com/?enc=WHULoV5NmCFDRjVnjyjm2A%3D%3D.SeW7MwMu5fcACehU4%2FaJo7br55Lvbk5J2w332mhzg3T9bvq4ODCqkgonFvp1X8T%2F2MWth%2B%2F8mYV9EXYSywR7%2FVPo8%2BGCZC1Ci7JqU36EkisnFCJOgvXpCVnwjpmiz4pE" rel="nofollow">https://developer.apple.com/documentation/visionos/bringing-y...</a></p><p><a href="https://link.segmentfault.com/?enc=4j6N1tX6queFBgeKLip2YQ%3D%3D.ZXke9FqtOu95NgeDE4Ns3ljtA2wBK9kGmvFpF8%2FcBRpuuQqyv%2FsLJGhpJIbSNLybSWFca1103H3Bqwt8cjOnUYDbZben7gcWp9uiJmcTav2Kr0ZTg2UjubRGBeMr%2B62x" rel="nofollow">https://developer.apple.com/documentation/visionos/making-you...</a></p><p><a href="https://link.segmentfault.com/?enc=rg8LiSZmIJKK0BZiVbYhcQ%3D%3D.TVwXGsTI2aiwgmYZKhczK1H3%2BmHjGqwm2KUooKY3VHrGkyozFiPu5EyolbNuhusbRPzprbqJ1TMAgKycxk156g%3D%3D" rel="nofollow">https://developer.apple.com/visionos/compatibility-evaluations/</a></p><p><a href="https://link.segmentfault.com/?enc=%2BBUCO01YMc%2FvKRHDG3PiNg%3D%3D.jGS2g8qy%2BX6ahAFmJFBvjlp%2FRlCC9mMy%2FD0Q34u1a0A%3D" rel="nofollow">https://vrtuoluo.cn/536959.html</a></p><p><a href="https://link.segmentfault.com/?enc=ZQtrOBQ7nTSfZdyZtj5zMA%3D%3D.1uIaxiBgDo7nCUO0piB8kTLK1MUNAbRWxemgy%2BGscedlMnenpJQX1e4W4NwT2MPu" rel="nofollow">https://developer.apple.com/cn/visionos/</a></p><p><a href="https://link.segmentfault.com/?enc=8M%2FnmGUl89LmOvCNsiIzpA%3D%3D.U5liBMj2nc12jDEKof8r4o0sf8c8%2F6%2Fg7YizrcQCEKJ7tFbRYW7Z1RZUZPY88aE9B87nCFoDiW4LhKZTWNSt2Q%3D%3D" rel="nofollow">https://developer.apple.com/cn/visionos/planning/</a></p><p><a href="https://link.segmentfault.com/?enc=Jxv6sb55Ah0feob61gDaBw%3D%3D.9p8Ge1OgkdMfqvXXy9%2FzwlyO5IElugjJwFIEsmcq9bnIJRa0E7%2Fmtq4nEIvYRfxONfmVq0cxsjaucmHIXN%2Bwt4HNhIz6VSHQAXWsL%2B13xYo%3D" rel="nofollow">https://www.apple.com.cn/newsroom/2023/06/introducing-apple-v...</a></p><p><a href="https://link.segmentfault.com/?enc=vPUnoCMTHI6RWZRDeERoQg%3D%3D.j%2FybI5PBYniIuEolWC9pHUbwcyFzX9MClaOSV4or0%2B9USD%2F5pPSjmxpYK1GzMDSg8LlDyPxKDeBbrq0mk6AhpZiRtXpzUV8Rm4uHHYHGj8w%3D" rel="nofollow">https://pdf.dfcfw.com/pdf/H3_AP202307141592272523_1.pdf?16893...</a></p><p><a href="https://link.segmentfault.com/?enc=jXfw1O64CGm%2B%2BGi%2FRDOi9w%3D%3D.dnRmCoHDsEVjtpEgXrWJmQ3FQrr7ysniAtLAAwLOSp3qiHsS0GR5EcHJRkXHA%2FLegCTxnJLUmb2D8WzMnsPI8OCjKDQoS5CGhXQ7Ubuc2%2B0MYzD42g%2FcigEcaxxAKfVX" rel="nofollow">https://mdpi-res.com/d_attachment/sensors/sensors-22-07709/ar...</a></p><p><a href="https://link.segmentfault.com/?enc=egDO1TTIwWVDprrc1ENRxg%3D%3D.MyM3uZ%2Bw3jx6XMR%2BVtgUWoZkKP38dm7Kdiv6prG%2FtNjUy03V7xBtHed6qnLP%2FS6VXFKxxFuS1c4z6f3HzriOV3N1jb3i512V0B0s1gvMqxoFawFhglei9ukJwVuop08g" rel="nofollow">https://niteeshyadav.com/blog/understanding-display-technique...</a></p><h2>最后</h2><p><img src="/img/remote/1460000044611695" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=Kpg%2BOEELvbfn1Ss1zUJMPw%3D%3D.TAyq1VA6%2Bg5FZhnEDGTnvK8Cjw5xS0IN2%2F6ezHXiK8M%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐RN新架构升级之iOS灰度方案
https://segmentfault.com/a/1190000044605166
2024-01-31T11:19:11+08:00
2024-01-31T11:19:11+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=9giglHqfAkYxbCxhTzNoAg%3D%3D.FaRt4u8ihy2u%2Bfn7vwXRV2ikmORAn5kejOKWLf4KDPQ%3D" rel="nofollow">张义</a>、<a href="https://link.segmentfault.com/?enc=r7kuW9op2m38uqaiec0D0A%3D%3D.1Xtxe3KSnGQIJbvTgkAlQFYgBjnhFMPt4V18VBfYtIy4Ed4bDcHRll6b%2FZAPULyB" rel="nofollow">谢富贵</a></blockquote><p>本文主要围绕云音乐iOS侧升级新版本RN时用到的灰度方案进行阐述。云音乐有 100+ 业务模块使用 RN 开发,占据了 30%+ 的业务模块,所以升级的新版本RN稳定性对我们来讲尤其重要。除此之外,iOS TestFlight 已经无法通过删除邮箱来实现无限分发。因此必须要有一个灰度方案来实现渐进式升级,直到稳定性以及各项指标数据打平后才能全量升级。</p><h2>背景</h2><p>文章《网易云音乐 RN 新架构升级实践》总体介绍了云音乐在升级 RN 过程中遇到的问题以及解决方案,本文主要围绕前文介绍到的 iOS 侧灰度方案进行阐述。由于云音乐已经有 <code>100+</code> 业务模块使用 RN 开发,占据了 <code>30%+</code> 的业务模块,所以升级后的 0.70 版本 RN的稳定性对我们来讲尤其重要。除此之外,iOS TestFlight 已经无法通过删除邮箱来实现无限分发。因此必须要有一个业务无感知的灰度方案来实现<strong>渐进式升级</strong>,直到稳定性以及各项指标数据打平后才能全量升级。</p><h2>思路和挑战</h2><p>实现渐进式的升级,势必就要引入两个版本的 RN 代码,然后通过AB实验进行放量控制,默认C组使用老版本代码,T组使用新版本代码。让不同版本的代码共存通常有两种方案:</p><p><strong>方案一:静态链接,修改符号名</strong></p><p>静态链接在编译时将所有的程序模块和库文件合并成一个单独的可执行文件,这个过程中不允许出现重复的符号,否则就无法完成符号的重定位导致链接失败。</p><p>解决符号冲突最简单的办法就是修改符号名,但是这不仅要修改定义符号的源文件,而且所有引用到相关符号的源文件同样要做修改,该方式极其繁琐。对于 RN 这种庞大的工程来讲,如果人工手动更改的话,显然是要耗费极大的人力和精力并且也无法保证准确性。即便写脚本用自动化的方式进行替换也难以覆盖所有的符号,因为有宏定义、动态调用等各种写法的存在,难免会导致疏漏,再者编写脚本的工作量也不小。</p><p><strong>方案二:动态链接</strong></p><p>动态链接则与静态链接相反是在运行时加载库文件进行链接,iOS 中 <code>NSBundle</code> 模块提供了 <code>loadAndReturnError:</code> 方法来支持动态的加载指定动态库的能力。因此将 RN 新老版本代码打成 2 个动态库后我们就可以解决了不同版本代码共存问题。</p><p>除此之外,由于业务层有很多地方引用了 RN 中的符号,延迟动态加载 RN 后会导致静态链接过程找不到符号而编译失败。所以我们必须还得解决<strong>静态链接过程中符号引用问题</strong>才能让双动态库方案完美落地。</p><h2>我们的方案</h2><p>在计算机领域有一句神圣的哲言「计算机科学领域的任何问题都可以通过增加一个间接的<strong>中间层</strong>来解决」, 从内存管理、网络模型、并发调度甚至是软硬件架构,都能看到这句哲言在闪烁着光芒,而我们的双动态库方案也是这一哲言的完美实践之一。整体方案设计如下图所示:</p><p><img src="/img/remote/1460000044605168" alt="整体架构图" title="整体架构图"></p><ol><li>将原先的React定义文件全部剥离,只剩下头文件给业务库依赖,确保编译过程中预处理阶段不会报错。</li><li><code>NEReactNative</code> 是我们引入的中间层,在这个库中定义了被业务层引用的 RN 符号(下文都以 RN 占位符号代指),确保静态链接阶段能找到相应的符号。除此之外该库是以插件的形式引入,业务层不感知。</li><li>真实 RN 的符号是运行时动态引入的,根据 AB 决定是加载新版本还是老版本。</li><li>完成动态库加载后还需要将占位符号与真实符号绑定起来。下文将针对符号绑定进行详细叙述</li></ol><h3>符号获取</h3><p>我们在打新老版本的 RN 动态库时加入一份统一的工具类去收集业务层用到的全局变量/函数地址以及下文的类符号地址。具体示例如下:</p><pre><code class="objc">@interface NEReactNativeDynamicFramework : NSObject
// 获取类符号地址
+ (Class _Nullable)getClass:(NSString *)name;
// 获取全局符号地址
+ (void * _Nullable)getSymbol:(NSString *)name;
@end
@implementation NEReactNativeDynamicFramework
static NSMutableDictionary<NSString *, NSValue *> *symbols;
static NSMutableDictionary<NSString *, NSValue *> *classes;
+ (void)prepare
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
symbols = [NSMutableDictionary dictionary];
classes = [NSMutableDictionary dictionary];
// TODO:获取符号地址,具体内容见下方
});
}
+ (Class _Nullable)getClass:(NSString *)name
{
[self prepare];
return (__bridge Class)[classes[name] pointerValue];
}
+ (void * _Nullable)getSymbol:(NSString *)name
{
[self prepare];
return [symbols[name] pointerValue];
}
@end</code></pre><p>对于全局变量/函数我们可以用 <code>extern</code> 符号声明的方式来获取地址,在链接阶段编译器会自动将同名符号绑定到统一的地址。</p><pre><code class="objc">// 宏定义胶水代码
#define INCLUDE_SYMBOL(NAME) \
do { \
__attribute__((visibility("hidden"))) extern void NAME; \
symbols[@(#NAME)] = [NSValue valueWithPointer:&NAME]; \
} while (0)
// 获取实际全局变量地址
INCLUDE_SYMBOL(RCTJavaScriptDidLoadNotification);
// 获取实际全局函数地址
INCLUDE_SYMBOL(RCTBridgeModuleNameForClass);</code></pre><p>细心的读者可能会发现,我们在用 <code>extern</code> 声明符号时统一用了 <code>void</code> 类型,但是 RN 并不是所有的全局符号都是 <code>void</code> 类型,比如示例中的 <code>RCTJavaScriptDidLoadNotification</code> 和 <code>RCTBridgeModuleNameForClass</code>。能够这么写得益于编译器的<strong>强弱符号</strong>选择策略:出现同名符号时会优先选择强符号。如示列中 <code>extern void RCTJavaScriptDidLoadNotification;</code> 声明的是弱符号,而实际定义<code>NSString *const RCTJavaScriptDidLoadNotification = @"RCTJavaScriptDidLoadNotification";</code> 为强符号。所以出现 <code>RCTJavaScriptDidLoadNotification</code> 符号的地方都会使用强符号所对应的地址进行重定位。</p><p>对于类符号地址的获取会稍微复杂点,我们使用了 <code>asm</code> 汇编指令进行符号重命名,示列如下:</p><pre><code class="objc">/**********定义胶水代码**********/
#define PASTE_HELPER(A, B) A ## B
#define PASTE(A, B) PASTE_HELPER(A, B)
#define INCLUDE_CLASS_HELPER(NAME, SYM, SYM_NAME) \
do { \
__attribute__((visibility("hidden"))) extern void PASTE(v, __LINE__) asm(SYM); NSValue *value = [NSValue valueWithPointer:&PASTE(v, __LINE__)]; \
classes[@(NAME)] = value; \
symbols[@(SYM_NAME)] = value; \
} while (0)
#define STRINGIFY_HELPER(X) #X
#define STRINGIFY(X) STRINGIFY_HELPER(X)
#define INCLUDE_CLASS(NAME) \
INCLUDE_CLASS_HELPER(STRINGIFY(NAME), STRINGIFY(PASTE(_OBJC_CLASS_$_, NAME)), STRINGIFY(PASTE(OBJC_CLASS_$_, NAME)))
/**********定义胶水代码**********/
// 获取实例类符号地址
INCLUDE_CLASS(RCTBridge);
</code></pre><p>关于 <code>asm</code> 指令详细介绍可以参考 gcc 里面的一篇<a href="https://link.segmentfault.com/?enc=ENHfk8VLdd8afotmquzrGw%3D%3D.IgB%2FzknIJ8C%2Fs3OMUmob9avDEki4Om08KO6dckKHUOYlIl%2FgGzsvYAUuzjMV5%2F4kJfIS%2FUTO9WV5XJbCHvR4y1XE4PYG7rzohrkqL62khMY%3D" rel="nofollow">文档</a>介绍。上述代码核心语句是 <code>extern void PASTE(v, __LINE__) asm(SYM);</code>, 先是动态声明了一个变量符号然后使用 <code>asm</code> 进行符号重写,所以我们通过获取该变量符号的地址就能拿到类符号地址。</p><h3>全局变量/符号内容替换</h3><p>在获取了全局函数/变量符号地址后,我们需要将占位符号的内容进行替换从而实现与真实符号的绑定。全局变量内容替换示列如下:</p><pre><code class="objc">// 定义胶水代码
#define NE_VAR_SYMBOL_DECLARE(NAME) \
extern void * NAME; \
void * NAME;
#define NE_VAR_SYMBOL_LOAD(NAME) \
NAME = *(void **)[NEReactNativeDynamicFramework getSymbol:@(#NAME)];
// 定义全局变量占位符号
NE_VAR_SYMBOL_DECLARE(RCTJavaScriptDidLoadNotification)
@implementation NEReactNativeGlobalSymbolLoader (variables)
+ (void)loadGlobalVariables
{
// 对占位符号进行内容替换
NE_VAR_SYMBOL_LOAD(RCTJavaScriptDidLoadNotification)
}
@end</code></pre><p>对于全局函数则可以使用汇编指令 <code>JMP</code> 进行跳转执行,在 ARM64 架构下对应的指令为 <code>BR</code>,具体示列如下:</p><pre><code class="objc">// 定义胶水代码
#if __x86_64__
#define _JMP_TO(PTR) __asm__ volatile("JMP *%0" : : "r"(PTR));
#elif __arm64__
#define _JMP_TO(PTR) __asm__ volatile("BR %0" : : "r"(PTR));
#endif
#define NE_FUN_SYMBOL_DECLARE(NAME) \
static void *SYM_ ## NAME = NULL; \
FOUNDATION_EXPORT void NAME(void); \
__attribute__((naked)) \
void NAME(void) { \
_JMP_TO(SYM_ ## NAME); \
}
#define NE_FUN_SYMBOL_LOAD(NAME) \
SYM_ ## NAME = [NEReactNativeDynamicFramework getSymbol:@(#NAME)];
// 定义全局函数占位符号
NE_FUN_SYMBOL_DECLARE(RCTBridgeModuleNameForClass)
@implementation NEReactNativeGlobalSymbolLoader (functions)
+ (void)loadGlobalFunctions
{
// 获取真实全局函数符号地址
NE_FUN_SYMBOL_LOAD(RCTBridgeModuleNameForClass)
}
@end</code></pre><h3>类符号绑定</h3><p>对 Objective-C 的类的处理采用了类似的思路,先是定义了一个占位符类,然后在运行时动态替换成真实的类。具体可以分为以下几种情况:</p><ol><li>对于类方法,直接使用方法转发,把占位符类的方法转发到真实类的方法上。</li><li>对于没有子类的类,覆盖 <code>+alloc</code>、<code>-init</code>、<code>+new</code> 等方法,在调用时直接创建真实类的对象返回。</li><li>由于 Category 方法会被加到占位符类上,而实际执行过程中由于步骤 2 的存在,拿到的可能是真实类的对象,这里需要把这些 Category 方法手动添加到真实类上。</li><li>有些地方可能会在运行时去检查类或者对象是否实现了某些 Protocol,这里就需要把真实类的 Protocol 列表添加到占位符类上。</li><li>对于有子类的类,会更复杂一些。我们的目标是非侵入式的,所以不会去修改子类的实现;上面的步骤可以覆盖非使用子类对象之外的场景,对于创建并使用子类对象的情况,需要额外的处理,下面详细分析一下。</li></ol><p>以一个组件为例:</p><pre><code class="objc">@interface MyViewManager : RCTViewManager <RCTUIManagerObserver>
@property (nonatomic, strong) NSString *myProperty;
@end
@implementation MyViewManager
- (void)setBridge:(RCTBridge *)bridge
{
[super setBridge:bridge];
[self.bridge.uiManager.observerCoordinator addObserver:self];
}
- (void)invalidate
{
[self.bridge.uiManager.observerCoordinator removeObserver:self];
}
RCT_EXPORT_MODULE()
RCT_EXPORT_VIEW_PROPERTY(myProperty, NSString)
- (UIView *)view
{
return [[MyView alloc] init];
}
// ...
#pragma mark - RCTUIManagerObserver
- (void)uiManagerDidPerformMounting:(__unused RCTUIManager *)manager
{
// ...
}
@end</code></pre><p>上面的代码覆盖了常见的使用情况:</p><ol><li>子类可以新增属性和方法,甚至可以覆盖基类的方法。</li><li>子类的方法中可以使用<code>super</code>关键字调用基类的方法。</li><li>调用方在拿到子类的对象调用方法时,如果子类没有实现该方法,会去基类中查找。</li></ol><p>在我们的方案中,子类继承的是占位符类,需要在运行时提供机制能满足上面的要求。</p><p>这里我们的方案同样是在<code>+alloc</code>、<code>-init</code>、<code>+new</code> 等方法中,添加逻辑,判断到正在创建子类对象时,动态为当前子类创建一个继承自真实类的代理子类,然后创建这个代理子类的对象,保存为属性,返回正常的子类(继承自占位符类)对象。</p><p>调用方在调用这个对象的方法时,对于子类实现或者覆盖的方法,直接调用到子类的实现;对于未实现的方法,使用方法转发,转发到代理子类的对象上,这样就能正确调用到基类的实现。</p><p>对于子类方法中使用<code>super</code>调用基类方法的情形,由于子类继承的是占位符类,所以<code>super</code>调用的是占位符类的方法,通过方法转发,同样可以正确调用到基类的实现。</p><p>需要注意的是,存在子类覆盖或者重写了基类的方法、但是在基类中被调用的情况,这时根据上面消息转发的机制,按照如下的继承结构:</p><p><img src="/img/remote/1460000044605169" alt="子类展示初版" title="子类展示初版"></p><p>外界拿到子类的对象调用<code>-methodB</code>时,会通过方法转发,通过<code>brokerObject</code> ⟹ <code>BrokerSubClass</code> ⇾ <code>RealClass</code> ⟹ <code>-methodB</code>的链路,调用到<code>RealClass</code>的<code>-methodB</code>方法,</p><p>我们期望<code>-methodB</code>里面调用<code>-methodA</code>时,能调用到我们子类自己写的<code>-methodA</code>方法,而不是<code>RealClass</code>的<code>-methodA</code>方法。 这就需要我们对上面的结构做一些修改,在<code>BrokerSubClass</code>中添加<code>-methodA</code>,实现为转发到<code>SubClass</code>的<code>-methodA</code>(为此还需要反向关联<code>SubClass</code>的对象到<code>brokerObject</code>),这样一来,<code>brokerObject</code>在调用<code>-methodB</code>(里面调用<code>-methodA</code>)时,会因为自身实现了<code>-methodA</code>而不再走到基类的同名方法中。从而达到我们的目的。</p><p><img src="/img/remote/1460000044605170" alt="子类展示终版" title="子类展示终版"></p><h2>实施过程中遇到的问题</h2><p>上面的方案覆盖了大部分的使用场景,但是在实施过程中还是发现了一些遗漏点,下面逐一介绍。</p><h3>使用方直接访问实例变量的情况</h3><p>系统在<code>UIView</code>的<code>-addSubview:</code>等方法中,会直接访问作为传入参数的<code>UIView</code>对象的某些实例变量,这种情况是我们上面的方法转发方法所不能覆盖的。<br>类似的,ReactNative中的<code>RCTShadowView</code>的<code>insertReactSubview:atIndex:</code>等方法也会直接访问传入参数的实例变量。</p><p>对于这种情况,我们 swizzle 了这些方法,把传入的对象替换成真实类的对象,这样就能正确访问到实例变量了。</p><h3>ReactNative 不同版本 API 的差异问题</h3><p>比如新版 RN 提供了 <code>RCTPLLabelForTag</code> 函数,而旧版本没有提供,我们的方案对于这种情况,会统一提供桥接的 <code>RCTPLLabelForTag</code> 函数,在切换到新版本 RN 时 <code>JMP</code> 到新版本的函数地址,而使用旧版本时函数未实现。<br>这就需要我们在使用这些函数的地方,提前对当前的 RN 版本做判断,确保只在新版本中使用新版本的 API。</p><p>在桥接函数的实现中也可以加上一些日志,方便我们在测试过程中发现这些问题。</p><h2>小结</h2><p>最终我们实现的中间层成功提供了业务方零感知的动态切换 RN 版本的能力,业务方的代码不需要做任何修改,通过配置就能实现 RN 版本的切换。</p><p>实际应用中,通过 AB 实验,我们在可控的范围内逐步放量,期间收集数据、反馈,发现并解决问题,最终实现了 0.70 版本 RN 的全量升级。</p><h2>最后</h2><p><img src="/img/remote/1460000044605171" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=DNHAf8YM77ypo52KVI5OTA%3D%3D.s37%2FGhWm0ISGDFaAduj3DlfB%2Ft834FbzePoWFCPpxVk%3D" rel="nofollow">https://hr.163.com/</a></p>
Android 居然还能这样抓捕和利用主线程碎片时间
https://segmentfault.com/a/1190000044598060
2024-01-29T14:41:54+08:00
2024-01-29T14:41:54+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者: zy</blockquote><p>在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿的情况,那么接下来我们就应该思考如何解决这个问题。</p><h2>背景与现状</h2><p>在 Android 应用开发的过程中,对于必须要在主线程执行的代码逻辑,可以使用由 Android 系统提供的主线程空闲任务调度器 IdleHandler 来处理。但如果空闲任务调度器执行任务过于耗时,依然会导致 APP 卡顿或者跳帧。另外如果开发者想要移除部分的空闲调度器任务,是无法实现不了的。只能选择全部移除。</p><h2>分析</h2><p>为了减少主线程的卡顿,提高主线程资源的利用率,我们通过系统源码了解到页面渲染的部分关键过程。</p><p><img src="/img/remote/1460000044598062" alt="" title=""></p><p>上图所示,当页面 View 有更新操作时,会通过 Choreographer 去注册一个 VSYNC 信号监听,等待 VSYNC 信号的到来,VSYNC 信号到来后,会执行我们熟知的 measure,layout,draw 方法,然后将视图数据通过 swipeBuffer 移交给屏幕的 DataBuffer 区域,等待进一步处理。在这个过程中,如果绘制操作比较耗时,掺杂了我们的业务逻辑,页面就会变得卡顿,如果每一帧的绘制都是在两个标准的 VSYNC 信号之间完成的,页面操作和展示就会变得非常流畅。分析发现,当一个 VSYNC 信号到来之后,如果页面的绘制能够提前完成,那么主线程会有一段时间的空窗期,如果我们能利用这段空窗期做点事情,那么就可以解决主线程任务过多造成主线程卡顿问题。主线程空窗期示意图如下图所示。</p><p><img src="/img/remote/1460000044598063" alt="" title=""></p><p>VSYNC 信号到达应用层后,经历 measure,layout,draw 几个阶段,这里统称为 render 阶段,render 阶段结束之后,如果 MessageQueue 没有其它的消息,这时候主线程就会处于空闲状态,等待视图刷新触发下一个 VSYNC 信号的到来。这里我们通过 Choreographer 来监听 VSYNC 信号的到来作为开始标记,以及 render 结束后的信号作为结束标记。结束标记和开始标记之间的时间差就是当前帧率下的主线程实际耗时也就是 render 时长,当前设备标准帧率时长( 图示以 60HZ 的刷新帧率,16.6ms 一帧的周期为基准 )与 render 时长的时间差就是我们可利用的主线程时长,有了这个时长以及 render 结束的触发点,就可以执行我们主线程的任务了。</p><h2>具体方案</h2><p>主线程碎片时间管理通过四个模块来实现,分别是帧率耗时监控模块,空闲时间切片模块,耗时任务拆分模块,子任务智能调度模块。 在帧率耗时监控模块,通过 HOOK 系统对象,注入自己的监听回调,来获取当前帧的渲染开始时间点和结束时间时间点。在空闲时间切片模块,生成当前帧可用的空闲时间。利用耗时任务拆分模块获取到可以被调度执行的子任务,最后由子任务智能调度系统负责子任务的调度执行以及记录每个任务在当前 CPU 的执行耗时情况,在初始化的时候通过读取上次 CPU 任务执行耗时的数据生成一个任务耗时记录表,用于给空闲时间切片模块提供时间更加精准的任务匹配,防止出现跳帧的情况。</p><h3>帧率耗时监控模块</h3><p>分析模块中,我们阐述了 render 阶段表示的是 View 视图树的计算阶段,包含了视图树的测量,布局,绘制。当完成这些任务之后,将剩下的工作交给系统渲染阶段来处理,系统渲染阶段会负责将视图渲染至屏幕上,这里我们需要关心的就是 render 阶段,这个阶段完成之后,即可认为当前帧的主线程工作完成了,等待接受下一个 VSYNC 信号的到来。在 View 视图树的计算阶段中,由于每一次需要计算页面视图树的复杂程度不一样,因此 VSYNC 中各个刷新周期的 render 阶段耗时也是不一样的,我们就需要监控每一个 VSYNC 信号到来之后 View 视图树计算阶段的耗时。 View 视图树监控( 帧率耗时监控模块 )全流程如下图所示</p><p><img src="/img/remote/1460000044598064" alt="" title=""></p><p>帧率耗时监控模块执行步骤:</p><ul><li>步骤一:在应用启动阶段,获取当前进程的系统的 Choreographer 对象</li><li>步骤二:创建视图帧开始渲染的监听回调,该回调除了首次由开发者手动注入至 Choreographer 对象中,后续的注入均由监听回调自己注入,当监听到渲染开始的回调后,再次将回调自己注入至 Choreographer 对象中,这样就能实现监听每一帧渲染开始的时间点,同时记录帧渲染开始时间</li><li>步骤三:创建视图帧结束渲染的监听回调,和开始渲染的监听回调注册流程类似,最终也是获取到每一帧渲染结束的时间点,将帧渲染结束时间记录下来</li><li>步骤四:在监听每一帧渲染结束之后,计算开始时间和结束时间的差值,这就是我们需要的每一帧可用的时间切片</li></ul><p>其中 Choreographer 是系统提供用于 View 视图树的计算以及与屏幕交互渲染的类,由 Choreographer 来监听 VSYNC 信号,信号到来之后,就会通知 View 视图树进行计算处理,当处理完成之后,将计算后的数据交给屏幕进行渲染。当前模块利用反射机制向 Choreographer 中注入渲染开始和渲染结束的监控回调,监控代码插入位置如下图所示</p><p><img src="/img/remote/1460000044598065" alt="" title=""></p><p>帧率的耗时监控就是在 render 阶段,通过插入帧率开始回调监听和帧率结束回调监听来计算得出的。</p><h3>空闲时间切片</h3><p>我们可以通过耗时监控模块获取到两个时间戳,分别是 View 视图树计算阶段渲染开始的时间戳和渲染结束的时间戳,我们需要的空闲时间就是两者的差值。View 视图树计算阶段的 render 部分完成之后,视图的绘制就会交给系统进行渲染,而这个渲染的过程是在其他线程和进程进行执行,这样,当前 APP 的主线程就会空闲下来,我们就可以利用这个空闲时间做点其他的时间,这个空闲时间就被称为空闲时间切片</p><h3>耗时任务拆分</h3><p>有了主线程可用的空闲时间切片,接下来我们就需要将我们的耗时任务进行一个拆分,如何找到耗时任务呢?这里我们使用 systrace 进行耗时方法采集</p><p><img src="/img/remote/1460000044598066" alt="" title=""></p><p>上图所示,当前业务有一个 300MS 的主线程耗时逻辑,后面的几个 VSYNC 信号周期都很空闲,我们可以将当前耗时的任务进行拆分切割,然后将拆分后的任务打散至后面空闲的时间切片中延后执行,如图</p><p><img src="/img/remote/1460000044598067" alt="" title=""></p><p>接下来定义一套数据结构,将拆分的任务当作一个子任务用自定义的数据结构保存起来(要注意内存泄漏的问题,页面销毁后,如果还存在任务未执行,需要把未执行的任务全部清空)</p><pre><code>class TraceTask(val bucketType: Int = BUCKET_TYPE_PRIORITY_30, val taskId: String = "", private val task: (() -> Unit)) {
fun invokeTask() {
task.invoke()
}
}</code></pre><p>到这里,可执行的子任务集就准备好了。</p><h3>子任务智能调度</h3><p>空闲时间切片和子任务集生成后,就可以通知任务调度系统进行子任务的执行调度,在空闲时间切片中插入适合当前时间切片执行的任务,如当前空闲时间切片只有 3ms,那么就应该从 3ms 及以下的任务桶中把需要执行的任务选出来,然后执行任务。整个模块的流程图如图所示</p><p><img src="/img/remote/1460000044598068" alt="" title=""></p><p>子任务智能调度执行步骤:</p><ul><li>步骤一:由 VSYNC 消息触发的结束监听模块开始执行,获取当前需要添加的子任务,如果没有要添加的子任务就走子任务的执行逻辑,如果存在,就走子任务的数据绑定和子任务添加逻辑</li><li>步骤二:子任务的数据绑定逻辑,将子任务和页面的生命周期进行绑定,这样做的好处是当页面销毁之后,绑定的子任务会自动删除,防止出现内存泄漏的情况。生命周期绑定之后,还需要绑定该子任务历史执行耗时,该模块是智能任务调度的核心,绑定历史执行耗时信息之后,在取子任务阶段,就可以快速获取到当前时间切片下可执行的任务了</li><li>步骤三:获取绑定后的子任务,添加到耗时任务表中,使用MAP+链表结构,方便任务的快速获取与增删</li><li>步骤四:判断当前是否存在子任务,如果存在可执行的子任务,则执行下一步操作,如果不存在可执行的子任务,跳出并结束当前流程。这里的任务查找是查看耗时任务表中是否还有任务元素存在</li><li>步骤五:判断当前是否为调度超时模式,如果当前非调度超时模式,则获取空闲时间切片剩余可用的时长,通过剩余时长去耗时任务队列中查找当前时长内可用的任务,如果找到可执行任务后,则执行任务,同时减掉当前任务执行时长,获取到更新后的时间切片可用时长,然后回到步骤四继续循环。如果没有找到任务,则结束当前流程</li><li>步骤六:如果当前为调度超时模式,则忽略剩余切片可用时长,找到耗时任务队列第一个任务元素,获取并执行。</li></ul><h4>智能任务调度核心</h4><p>智能任务调度核心主要负责计算出当前任务的实际耗时,这样做的目的是确保任务执行的时长不会超过空闲时间切片的剩余时长,例如:空闲时间切片剩余时长是 6ms ,那么智能任务调度核心就需要负责找出6ms以内能够完成的任务。当前任务第一次的时长是由开发者给出的默认时长( 开发者在自己手机系统上执行后得出的实际任务时长 ),当任务执行一次之后,会将任务在当前系统上的实际耗时保存下来,每条任务会保存最近 5 条数据。后续再取任务时长的时候,会将当前任务的历史执行时长的最大值取出,当作该任务的执行时长保留下来。所有任务执行时长数据会保存在 SD 卡上,在 APP 启动时,子线程进行任务执行时长的数据加载,将数据加载至手机运行内存中,加快后续读取任务时长的速度,在本次任务执行结束之后,需要将获取到新一轮的执行时长更新至内存中,等待页面关闭时,统一将数据写入至 SD 中。 智能任务调度核心时长获取以及保存示意图如图所示</p><p><img src="/img/remote/1460000044598069" alt="" title=""></p><h4>任务队列结构</h4><p>这里我们我们采用 MAP 表(KEY-VALUE)来存储数据,其中 KEY 为 INT 型,以任务执行耗时作为 KEY,VALUE 为链表结构,链表的增删效率非常高。使用链表的结构来保存当前耗时 KEY 下的所有任务。链表结构如图所示</p><p><img src="/img/remote/1460000044598070" alt="" title=""></p><h4>调度超时模式</h4><p>空闲时间切片的最大剩余时长不会超过 16.6ms ,在不同机型上,由于机器性能差异,导致各个任务的实际执行耗时可能会超过 16.6ms,在智能任务调度阶段,可能就会出现有个别超时任务一直无法和空闲时间切片的剩余时长匹配上,因此这里会提供一种兜底超时逻辑,当任务队列 1s 内都没有任何任务被调度执行( 60HZ 的情况下,1s 会有 60 次的帧率调用,也就是会有 60 次的任务调度执行),但是队列又不为空,可以说明当前存在异常超时的任务,为了保证所有任务的正常执行,这里会设置一个调度超时模式的标志状态,当进入调度超时模式中后,会上报当前异常任务,由开发者判断当前任务是因为手机性能问题超时,还是任务拆分不合理导致的。而程序也会再次进入判断逻辑中,逻辑判断发现当前处于调度超时模式时,不会检测当前剩余时长,而是直接取 MAP 表中的第一个元素,获取第一个任务并执行。从而保证所有添加的耗时任务,无论是否匹配上,都会得到执行.</p><h2>总结</h2><p>通过任务拆分+主线程空闲时间调度的方式,可以有效的利用主线程的空闲时间,让它来合理的帮助我们完成主线程逻辑的执行,而不会对主线程造成拥堵,给用户带来更好的操作体验。</p><p><img src="/img/remote/1460000044598071" alt="" title=""><br>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=ln2PjyA43i2zA73uI%2FHycQ%3D%3D.tNQ8FmY5vUCv6S6JOwiIsjI7Ak16hlsWKSjo6lpQj%2F0%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐RTA投放与承接系统建设实践
https://segmentfault.com/a/1190000044588566
2024-01-25T14:41:15+08:00
2024-01-25T14:41:15+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:huangleilei02</blockquote><h3>业务背景</h3><p>投放广告买量,不管是拉新还是促活场景,都是互联网用户增长的重要手段。RTA(Real Time API),是广告投放领域非常重要的一种投放方式,用于满足广告主实时个性化的投放需求。顾名思义,RTA指的是API接口实时调用,将直投的广告主的流量选择权交给广告主,媒体传入设备号调用RTA问询接口,进行用户投放的筛选,让广告主在广告曝光前进行投放策略的判断,满足拉新、促活等个性化需求,同时也能做到媒体和广告主数据隔离。</p><p>云音乐在对外买量时,作为广告主,也建设了一套从RTA人群圈选、到媒体响应、再到站内承接的完整系统。云音乐在过去一段时间里,不断从业务及技术等多个角度,对该系统进行建设和完善。</p><p>而在2023H2,RTA业务以RTA专项的方式进行实践,专项目标包含RTA接入多个媒体、投放量级增长、缩短人群圈选流程等。本文将以RTA专项建设过程中的解决方案为主,结合H2前的一些建设,来介绍云音乐RTA投放与承接系统建设中的一些思路。</p><p><img src="/img/remote/1460000044588568" alt="" title=""></p><h3>RTA投放侧架构</h3><p>RTA投放方式的显著特点是请求量大、实时性要求高。云音乐对接多个广告媒体,单媒体的请求QPS在数万-数十万不等,单次请求从媒体测请求发起到收到响应的超时时间一般仅约五六十毫秒,对于服务来说有着不小的挑战。</p><p>因此,云音乐RTA投放侧(即不包含站内承接的业务领域)的技术架构有以下特点:</p><ul><li>独立Nginx集群及专线,和主站隔离</li><li>分层解耦。请求适配层+投放策略层+设备映射层+设备写入层</li><li>异步模式,高性能、高吞吐量、低延迟。使用Netty处理请求、使用Lettuce作为redis客户端</li><li>针对海量请求,通过时间轮实现超时处理,大幅提升性能</li></ul><p><img src="/img/remote/1460000044588569" alt="" title=""></p><h3>RTA自动化圈选人群</h3><p>RTA投放的核心部分,就是圈选出一定规则的以设备号为主体的人群,针对不同媒体的不同投放账户,投放不同的人群。</p><p>由于用户增长领域广告投放复杂度高,以及当前互联网存量时代对精细化运营有更高诉求,业务对RTA投放人群包圈选的自动化和灵活性有一定诉求。云音乐RTA投放系统建设了自动化圈选后台,结合云音乐人群圈选平台“魔镜”,做到设备维度的RTA投放人群包的灵活圈选和快速设备同步。</p><p>其整体链路如下:</p><p><img src="/img/remote/1460000044588570" alt="" title=""></p><p>在圈包自动化能力的基础上,RTA投放平台做了一些扩展性工作,从而做到系统稳定性提升、数据问题快速响应、圈包流程缩短提效,上线后对业务的圈包投放动作提效60%以上,也为后续的自动化、精细化运营打下基础。</p><p>圈包后台可以通过以下几点概括:</p><ul><li>打通魔镜人群圈选平台、云音乐设备数据,使得能够通过简单的标签圈选或者自定义圈选条件SQL,完成投放设备的定向圈选,成本低、响应快速;</li><li>打通AB实验能力,能够灵活圈包并验证投放策略;</li><li>整个数据准备流程由运营操作,平台自动化进行校验;</li><li>能够应对单日数亿至十亿级别总数人群包数据写入;</li><li>数据同步结果通知,调度过程记录;</li><li>数据链路多类异常告警通知,做到快速定位问题并响应解决。</li></ul><h3>RTA存储痛点优化</h3><p>业内对于RTA设备数据的存储方式大同小异,因为设备数据和人群信息的关系以kv为主,所以redis是比较多的一种选择,云音乐也使用独立集群的redis cluster作为RTA数据存储的数据库。在业务初期,采用的是redis的String结构进行存储,例如将设备Oaid的MD5值作为key,对于人群包列表及其他相关信息的Json作为value,但该方式在存储成本上有着很大问题。</p><p>目前投放数据,会针对Oaid、Imei、Idfa为主的几种设备信息的MD5值进行存储,此外,在与字节抖音合作时,应对数据安全规范,还需要进行Prl加密存储,因此,一个手机会产生多条设备记录。rta投放人群设备数据非常庞大,用户数量数亿甚至十数亿左右量级,结合上述多种设备及多种加密方式,整体数十亿量级记录。在初期,投放平台使用redis的String类型存储数据,随着新加密方式的接入和设备数的增多,对存储造成了压力,如不做改造,将占用数百GB甚至1000GB的存储空间。</p><p>投放平台通过以下几个方式的组合,完成了超过80%的存储空间优化:key改造、value改造(分为人群包名改造、过期时间改造、序列化压缩)、存储结构改造。我们先从更直观的key、value本身对压缩开始介绍。</p><p><strong>key改造</strong>:此处的key指的是redis存储的key,其内容为设备号相关信息,由设备号类型、加密信息以及设备号加密结果(如MD5或者其他方式加密结果)组装而成的String,在39-86字节不等。使用MurmurHash2的64位hash算法对原本对长字符串进行哈希,此时冲突率非常低,对业务几乎无影响。并将其作为字节数组转为String,转为String时采用的编码字符集为ISO_8859_1,相比默认的UTF-8存储更少。当然,如果读写均选择合适的redis客户端,也可以直接用字节数组。</p><p><strong>value改造</strong>:原本的value信息相对冗余,经过以下三组改造的结合,可以将50-100+字节的Json压缩为8-16字节。</p><ul><li><strong>人群包名改造</strong>:原本的value为人群包信息列表,包含了冗长的人群包名、毫秒级别的业务过期时间以及其他相关信息。改造时将人群包名字序号化,将string转为int。</li><li><strong>业务过期时间改造</strong>:将毫秒级过期时间减去6未有效数字,粗化到十几分钟级别,在对业务影响很小的前提下有效减少了存储空间。</li><li><strong>序列化压缩</strong>:将原本JSON序列化结果转为Protostuff(Google ProtoBuf的改进版)序列化,虽然牺牲了可读性,但大幅压缩了存储空间。关于Protostuff序列化方式的压缩原理,各位可以自行检索研究,本文中就不再赘述了。<br><img src="/img/remote/1460000044588571" alt="" title=""></li></ul><p><strong>数据结构改造</strong>:除了业务数据的压缩,本身在redis中的存储结构也大有可为。为了更好介绍该项改造,我们不妨来看一个case,当我们使用redis的String类型存储单条数据{"wyyyykey":"wyyyyval"},该数据的key大小为8字节,value大小为8字节,那么这条键值对占用了多少内存呢?</p><pre><code>set wyyyykey wyyyyval
memory usage wyyyykey
--72</code></pre><p>72字节比键值对本身的16字节大了不少,那么多出来的这部分内存用在哪里了呢?这就得提到redis中字符串的实现方式了。redis首先会为每个键和每个值创建一个redisObject(以下简称robj),带有一些对象头信息;而我们都知道,redis的字符串对象的类型为简单动态字符串(Simple Dynamic String,SDS),其中有部分空间用于记录实际内容存储情况和存储时的预留空间;在维护全局哈希表的dictEntry时,需要维护指向key、value、和下一个节点的指针,这些都会造成额外的存储。</p><p>下图为一个字符串类型的键值对存储结构,关于该key和value的SDS实现结构为sdshdr5还是sdshdr8对于整体存储逻辑和量级影响很小,因此此处不做过多讨论,读者可自行阅读源码,此处参考引文2。需要注意的是,Redis的内存管理和优化策略是复杂的,并且在不同的版本和配置下可能会有所不同。因此,在具体情况下,实际的内存占用可能会有所变化。</p><p><img src="/img/remote/1460000044588572" alt="" title=""></p><p>而想要优化掉大量的robj元数据、dictEntry、sds信息,redis的Hash结构是一个很好的方案。在默认设置下,当哈希对象可以同时满足以下两个条件时,哈希对象使用压缩列表(ziplist)编码:哈希对象保存的所有键值对的键和值的字符串长度都小于64;哈希对象保存的键值对数量小于512。ziplist非常节省内存,是由一系列特殊编码的连续内存块组成的顺序性数据结构,一个ziplist可以包含多个节点,每个键值均为一个节点,每个节点紧挨着,能够大幅减少内存。</p><p>不妨假设原本有30亿个key需要存储,将这30亿个key通过hash打散成1500万个redis的hash对象,可以大幅减少robj元数据、dictEntry、sds信息等占用的内存。而虽然查询的时间复杂度由O(1)变为了O(n),但由于此处n为ziplist的entry数量大约为200,对整体的时间影响非常小。</p><p><img src="/img/remote/1460000044588573" alt="" title=""></p><h3>实时站内承接</h3><p>为了提高使买量用户的留存(是让钱花的更值),云音乐建设了投放用户实时站内内容承接,能够覆盖新客和召回用户等多种用户类型。当然,广告投放方式往往要多组合才能得到更好的效果,本节介绍的云音乐站内承接建设,并不只覆盖RTA链路投放到广告用户,也能够对其他方式的投放用户进行承接。</p><p>本节主要以云音乐首页模块及内容的投放用户承接作为例子进行介绍,除此之外,站内落地页直达、资源自动订阅、搜索底纹词等都是可以承接的手段,而用户的投放归因信息也可以辅助算法进行决策。</p><p><strong>用户来源实时归因</strong></p><p>假如云音乐在各媒体同时投了某首单曲和某个歌单,需要知晓用户成为新客或者回流APP来源于哪个广告资源,才能进行承接,这个步骤就是归因。广告的投放方式是多种多样的,例如A用户是点击广告后初次下载云音乐APP进站;B用户是云音乐老用户但已经卸载,也是点击广告后下载APP;而C用户则是老用户但手机上已经安装云音乐APP,直接通过广告所带的deeplink链接唤端;此外,还有渠道包等其他投放的方式。针对上述多种情况,需要聚合建立用户来源实时归因能力。</p><p>针对直接通过广告所带的deeplink链接唤端的用户,通过在deeplink上拼接业务字段,客户端在打开APP时进行解析并传给服务端,即可完成用户本次的归因,该方式非常直接且准确。针对下载APP的用户,则相对曲折一些。在用户初次激活APP时,将云音乐deviceId和历史数据进行比较,来判断该设备是新设备或者是回流设备,将新设备、回流设备数据和广告点击数据根据一定策略进行业务归因,从而定位到该次激活是否投放用户以及具体是哪个投放资源带来的用户。</p><p><img src="/img/remote/1460000044588574" alt="" title=""></p><p><strong>首页模块承接</strong></p><p>在首页场景下,上下文组装阶段进行投放归因信息和部分提权策略信息的组装,该信息将用于后续模块排序和各模块资源的组装过程中。在模块排序阶段,利用灵渠投放平台(感兴趣的读者可阅读参考第3篇)进行灵活可配的模块排序干预;在模块内容组装阶段,通过服务端强插干预或者算法策略干预的方式,将对应的投放资源按照一定业务策略进行提权。模块+资源的组装方式有效提高了投放用户的站内留存。</p><p>针对多模块多种资源的首页模块及资源承接策略,设计了一套通过json字段匹配方式灵活更改承接内容的解析规则,能够实现从广告媒体到广告计划到广告投放资源等多级可变的规则匹配。同时,针对业务透出规则及投放资源特性,采用过滤器链模式实现提权与否的校验。该承接能力涵盖多种用户类型(新用户、流失用户等)、涵盖多种首页类型(老首页、首页新框架等)、多种客户端(android投放、IOS投放)。</p><p><img src="/img/remote/1460000044588575" alt="" title=""></p><h3>总结与展望</h3><p>本文从整体架构、投放数据、站内承接等多个角度介绍了云音乐RTA投放与承接系统建设中的一些要点;从业务、技术两个角度结合,为广告投放业务提供了一些思路。</p><p>同时,从业务专项角度,较好地完成了专项目标,提高RTA渠道接入数量和业务量级,通过自动化圈选人群能力的建设缩短投放圈包配置周期60%,通过站内承接系统有效提高了投放用户的留存。</p><p>展望未来,处于成本和价值的考量,RTA投放及承接的精细化将是非常重要的一个方向。在有完善用户价值体系的基础上,RTA人群圈选及投放需要和个性化出价策略结合得更加紧密,而不同用户在站内的不同承接策略也需要更加精细和深入,形成相对完整的生态。</p><h2>参考链接</h2><ul><li><a href="https://link.segmentfault.com/?enc=8iKJkJp0xGLA8Kb5nVfD1A%3D%3D.DGJCrgOTbqI2ad2JtvkEk0nRfz2xI4pZD3BU9FLvc03BbvVSwzQEPFzkDLxHSagb" rel="nofollow">https://github.com/protostuff/protostuff</a></li><li><a href="https://link.segmentfault.com/?enc=QXll676fht8V6QaDFopt5A%3D%3D.c6UPpnF3sA%2BDJ%2FOCpMpj2Sv1CDecrfIJwDHlpHQjI3Wta1L2J9lQg1qX9F2bM0WuwzcUAHMGDhfxfkb%2BJQaUJQ%3D%3D" rel="nofollow">https://cloud.tencent.com/developer/article/1837860</a></li><li><a href="https://link.segmentfault.com/?enc=DihgaXTOCKw2G5LekeQYEA%3D%3D.dngXGpTHotZjk%2FjQhN6LN7wwRnsDB6onPX96x28hXjHXU%2BXQVftuul8DojvD4Kpc" rel="nofollow">https://juejin.cn/post/7290741484364562432</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044588576" alt="" title=""><br>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=9qO13SJ3QZgi4TweFQ59%2BA%3D%3D.crhGMNW2RmBtz2gizN9apTePgB5zFr0EquSuwAGQcvk%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐 RN 新架构升级之 Bytecode Bundle 缩包优化
https://segmentfault.com/a/1190000044581298
2024-01-23T15:02:30+08:00
2024-01-23T15:02:30+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:陈骏 陈东洋</blockquote><h3>背景</h3><p>RN 升级 0.70 后使用了 Hermes 引擎,Hermes 引擎的一大优势是预编译与字节码执行能力,但是将 JS 文本编译成字节码是有额外成本的,根据我们后续实际打包经验,JS Bundle 文件转换成 HBC Bundle(Hermes Bytecode Bundle)文件后的 ZIP 包体积增加了 40% ~100%,且增量包是原先的 2 ~ 3 倍。</p><table><thead><tr><th>是否压缩</th><th>JS Bundle 大小</th><th>Bytecode Bundle 大小</th></tr></thead><tbody><tr><td>ZIP 前</td><td>2.7MB</td><td>3.3MB</td></tr><tr><td>ZIP 后</td><td>623KB</td><td>1.4MB</td></tr></tbody></table><p>包大小的增加不仅影响到用户体验,也会使网络资费上涨,因此有必要对 HBC 包体积过大问题进行治理。我们主要从以下两个方面进行缩包:</p><ol><li>从产物压缩方式入手</li><li>从打包产物导出入手</li></ol><h3>从产物压缩方式入手</h3><p>在 RN 0.60 时期,我们一直选用的 zip 来对最终包产物进行压缩,zip 本身是一种压缩率比较低的压缩方式,为了能选择适合的压缩方式,对比了下市面上常用的压缩率比 zip 要高的三种压缩方式:gzip,bzip2,xz。</p><h4>压缩算法对比</h4><h5>gzip</h5><ul><li>采用 DEFLATE 算法进行数据压缩</li></ul><h5>性能</h5><p><img src="/img/remote/1460000044581300" alt="img" title="img"></p><h5>bzip2</h5><ul><li><p>采用 Burrows-Wheeler 变换和霍夫曼编码算法进行数据压缩</p><ul><li>Burrows-Wheeler 变换是一种数据重排技术</li><li>霍夫曼编码则用于进一步压缩重排后的数据</li></ul></li></ul><h5>性能</h5><p><img src="/img/remote/1460000044581301" alt="img2" title="img2"></p><h5>xz</h5><ul><li>采用 LZMA(Lempel-Ziv-Markov chain algorithm)算法进行数据压缩</li></ul><h5>性能</h5><p><img src="/img/remote/1460000044581302" alt="img3" title="img3"></p><h4>数据对比(使用默认压缩等级6对比)</h4><ul><li><p>压缩速度:</p><ul><li>对比:xz 耗时 1 分 27 秒 1,gzip 耗时 5 秒 1,bzip2 耗时 8 秒 8</li><li>结论:xz 压缩耗时比 gzip 与 bzip2 要长很多</li></ul></li><li><p>占用压缩内存:</p><ul><li>对比:xz 压缩最大内存为 97656KB,gzip 压缩最大内存为 2048KB,bzip2 压缩最大内存为 6164KB</li><li>结论:xz 压缩最大内存比 gzip 与 bzip2 要大很多</li></ul></li><li><p>压缩率:</p><ul><li>对比:xz 压缩率为 73.62%,gzip 压缩率 63.48%,bzip2 压缩率 70.32%</li><li>结论:xz 压缩率最高,bzip2 第二,gzip 最低</li></ul></li><li><p>解压耗时:</p><ul><li>对比:xz 解压耗时 1 秒 9,gzip 解压耗时 0.8 秒,bzip2 解压耗时 5 秒 5</li><li>结论:gzip 解压速度最快,xz 次之,bzip2 解压速度比其他慢好几倍</li></ul></li><li><p>解压内存占比:</p><ul><li>对比:xz 解压最大内存 10580KB,gzip 解压最大内存 1876KB,bzip2 解压最大内存 3812KB</li><li>结论:xz 解压最大内存比 gzip 与 bzip2 要大很多</li></ul></li></ul><h4>压缩方式选择</h4><ol><li>压缩速度: 由于我们在打包机压缩,不会影响到用户体验,可忽略</li><li>占用压缩内存:同 1,可忽略</li><li>压缩率(重点考虑):缩包主要减少包体积,优先选用 xz 压缩</li><li>解压耗时(重点考虑):gzip 耗时最短,但压缩率低,bzip2 解压速度太慢,优先选用 xz</li><li>解压内存占比(非重点考虑):测试了 23G 数据压缩,解压内存最高占用到 60MB,且是瞬时内存,马上下降。对于 RN 包来说体积不会像测试数据一样庞大,预估内存占用最多在 KB 级别,可忽略。</li></ol><p><strong>结论:</strong> 从压缩率,解压耗时两方面并结合解压内存进行考虑,最终我们选择了 xz 作为 HBC 包新的压缩方式</p><h4>HBC 压缩数据对比</h4><table><thead><tr><th> </th><th>xxx-home</th><th>xxx-vip</th><th>xxx-artist</th><th>xxx-timed</th><th>xxx-voice</th><th>xxx-detail</th><th>xxx-rn</th></tr></thead><tbody><tr><td>相比 ZIP 缩小百分比</td><td>-23%</td><td>-25%</td><td>-25%</td><td>-20%</td><td>-22%</td><td>-20%</td><td>-26%</td></tr></tbody></table><h3>从打包产物导出入手</h3><h4>HBC 包与 SourceMap</h4><h5>HBC 包优化导出</h5><p>在普通文本 Bundle 转换成 HBC Bundle 时,hermesc 提供一些优化选项,其中有 <code>-O</code> 最高级别优化,命令参数如下:</p><p><img src="/img/remote/1460000044581303" alt="Hermesc optimization level" title="Hermesc optimization level"></p><p>经过本地验证得知,相同的普通文本 Bundle 使用 <code>-O</code> 参数导出的 HBC Bundle 相较于未使用 <code>-O</code> 在文件大小上有 10% ~ 22% 的收益。主要收益来自于符号表(SourceMap)导出,试验数据列举如下:</p><table><thead><tr><th> </th><th>xxx-p</th><th>xxx-s</th><th>xxx-c</th><th>xxx-s</th></tr></thead><tbody><tr><td>减少百分比</td><td>-10.06%</td><td>-15.65%</td><td>-22.28%</td><td>-17.58%</td></tr></tbody></table><h5>HBC 优化导出后的 SourceMap 补全</h5><p><strong>JS 异常在 RN Bundle 里的符号解析</strong></p><p>在 RN 运行时,当发生 JS 异常时,引擎会生成异常堆栈。这个堆栈包含关键信息,其中之一是每个堆栈帧的行和列。通过这些行和列信息,我们能够在打包后的 Bundle 中定位到具体 JS 文件中出错的函数位置。这种定位背后使用的是一套标准的前端符号解析技术,即 SourceMap。在 NPM 上有标准的 SourceMap 解析库可供安装和使用 - <a href="https://link.segmentfault.com/?enc=N%2FX1wtifD4GPH1P1HL37cA%3D%3D.G%2FWAGZLlyJI7x3KgfaOEQSILcI089DM7BTSS9X5B2io4kY3TB7N7EbNDslJJBEleRWiZA%2Fs0n1jjmwtSiQOSCQQUSySrpJOH%2BzuLLcurOWQ%3D" rel="nofollow">SourceMap NPM安装</a>。为了获取 RN Bundle 的 SourceMap,我们需要在打包时进行导出。</p><p><strong>JS 到 普通文本 Bundle 打包,这里导出的 SourceMap 我们称为:普通文本 Bundle SourceMap</strong></p><pre><code>npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ./build/index.ios.bundle --sourcemap-output ./build/index.ios.bundle.packager.map
</code></pre><p>下面举个简单的例子,点击按钮访问未定义变量的 JS 异常例子:</p><p><img src="/img/remote/1460000044581304" alt="异常代码Demo" title="异常代码Demo"></p><p><img src="/img/remote/1460000044581305" alt="引擎报错" title="引擎报错"></p><p>此时我们有 SourceMap 文件和异常堆栈,就可以使用脚本进行符号解析,如本示例第 1 条堆栈的行号 384,列号 2419;第 2 条堆栈的行号 384,列号 2609,解析结果如下:</p><pre><code>//堆栈第1条
执行:node parse_error.js 384 2419
{
source: '/Users/xxx/Desktop/bear_baby/RNNew/App.js',
line: 120,
column: 18,
name: 'myVariable'
}
//堆栈第2条
执行:node parse_error.js 384 2609
{
source: '/Users/xxx/Desktop/bear_baby/RNNew/App.js',
line: 113,
column: 4,
name: 'undefinedVarTest'
}
</code></pre><p>这里我们很清晰的还原符号所在的文件,函数/变量名等信息。</p><p><strong>普通文本 Bundle 和 未使用 <code>-O</code> 优化导出的 HBC Bundle 异常解析</strong></p><p>正常情况下普通文本 Bundle 和 未使用 <code>-O</code> 优化导出的 HBC Bundle 都可以使用上述解析方案进行解析,都能还原现场信息,但是如何使用 <code>-O</code> 优化打包的 HBC Bundle 运行发生 JS 异常时就会出现如下问题。</p><p><strong>使用 <code>-O</code> 优化导出的 HBC Bundle 异常解析</strong></p><p>使用 <code>-O</code> 优化导出的 HBC Bundle 调试时发现 JS 错误时报错堆栈完全失去了关键信息可读性,如下:</p><p><img src="/img/remote/1460000044581306" alt="引擎报错图2" title="引擎报错图2"></p><p>此时 HBC Bundle 的 JS Fatal 错误堆栈中,定位到的行号都是 1,因为 HBC Bundle 真的只有 1 行。这是因为 <code>-O</code> 是最高优化级别,生成的最终产物中已经不包含符号表信息,导致引擎无法将异常还原到原始文本 Bundle 的行列。</p><p>显然这样不利于业务排查问题,因此需要着手解决优化后 HBC Bundle 加载异常符号缺失问题。经过本地试验分析,在普通文本 Bundle 转换成 HBC Bundle 时 Hermes 提供了再次导出 普通文本 Bundle 到 HBC Bundle 的 SourceMap 导出参数 <code>--sourcemap-output</code> ,如下:</p><p><strong>普通文本 Bundle 到 HBC Bundle 打包,这里导出的 SourceMap 我们称为: HBC Bundle SourceMap</strong></p><pre><code>./hermesc -O -emit-binary -output-source-map -out=./build/index.ios.bundle.hbc ./build/index.ios.bundle
</code></pre><p>至此我们有了这 2 个 SourceMap 文件,我们就可以对 <code>-O</code> 优化 HBC Bundle 发生的 JS 异常进行完整的解析,具体解析流程如下:</p><ol><li>对于使用 <code>-O</code> 优化导出 HBC Bundle 发生的 JS 符号异常,入参行列号我们使用 <strong>HBC Bundle SourceMap</strong> 去解析得到一个新的行列号,这个行列号就是 对应普通文本 Bundle 对象的行列号。</li><li>拿到上一步的普通文本的 Bundle 行列号,我们使用 <strong>普通文本 Bundle SourceMap</strong> 却解析得到此行列号对应的对应的 JS 文件名 和 所在 JS 文件具体的行列号。</li></ol><p>当然如果加载最终 Bundle 产物就是 <code>-O</code> 优化 HBC Bundle,那么我们也可以提前使用合并命令合并 2 个 SourceMap 文件得到最终的 SourceMap 文件,使用最终的 SourceMap 文件可以一步到位解析出符号所在文件位置等信息。合并命令如下:</p><pre><code>./node_modules/react-native/scripts/compose-source-maps.js ./build/index.ios.bundle.packager.map ./build/index.ios.bundle.hbc.map -o ./build/index.ios.bundle.map
</code></pre><p><strong>一图胜万言</strong></p><p><img src="/img/remote/1460000044581307" alt="SourceMap导出" title="SourceMap导出"></p><p><img src="/img/remote/1460000044581308" alt="SourceMap解析" title="SourceMap解析"></p><p><strong>结论:</strong></p><ol><li>hermes 导出 HBC Bundle 时可以使用 <code>-O</code> 参数优化导出产物,减少导出产物体积,有 10% ~ 22% 的收益。</li><li>使用 <code>-O</code> 参数优化导出的 HBC Bundle 在 JS 异常解析堆栈符号时,需要使用 <strong>HBC Bundle SourceMap</strong> 文件先解析出行列,再使用<strong>普通文本 Bundle SourceMap</strong> 解析出最终产物。</li><li>当然也可以提前合并 <strong>HBC Bundle SourceMap</strong> 和 <strong>普通文本 Bundle SourceMap</strong>,实现一步到位的解析。</li><li>实际实现时,还需要考虑 SourceMap 文件的打包存储及版本关系,这个就不做过多赘述。</li></ol><h4>增量包缩包</h4><p>这里的增量包是在原先 Bundle 包的基础上,进行修改代码,通过 bsdiff 生成的一种差量包,用于下发给客户端进行增量更新。增量包本身也有大小,且在使用 HBC Bundle 后,体积也增大明显,所以增量包缩包的意思是缩小增量包体积。</p><h5>了解 <code>-base-bytecode</code> 原理</h5><p>hermes 编译器有个参数 <code>-base-bytecode</code>,该参数的作用是指定一个基本的字节码文件,这个文件包含了可能会被多个包共享的代码。在生成新的字节码文件时,hermes 会使用这个基本字节码文件作为参考,这样可以减少重复编译相同代码的时间并减小最终字节码文件的大小。</p><p>执行步骤如下:</p><ol><li>引用基础字节码:编译器加载由 <code>-base-bytecode</code> 指定的基础字节码文件(如 test.hbc)。这个文件通常包含了一组 JS 代码编译后的字节码,它可能包括了库、框架或者其他常用功能的代码。</li><li>增量编译:当编译新的 JS 源文件时,编译器会检查这些源文件中的代码是否已经存在于基础字节码文件中。</li><li>避免重复:对于已经存在于基础字节码文件中的代码,编译器不会重新编译这部分代码。相反,它会在新生成的字节码文件中(如 test1.hbc)引用基础字节码文件中的对应部分。</li><li>编译新代码:对于新的源文件中独有的代码,编译器将其编译成字节码,并将这些新的字节码与基础字节码文件中的字节码合并,形成最终的字节码文件。</li></ol><p>从以上原理可以得出,编译器使用 <code>-base-bytecode</code> 后,不会重复编译已存在的代码,理论上对这部分代码进行 diff 操作不会出现任何差异。</p><h5><code>-base-bytecode</code> 与 bsdiff 结合</h5><p><strong>不添加-base-bytecode</strong></p><ul><li><p>增量包大小为:65kb</p><p>//生成新版本 hbc,test1 大小为 2.63mb 左右<br> hermes -emit-binary ./test1.bundle -out ./test1.hbc</p><p>//生成patch, 大小为 65kb<br> bsdiff test.hbc test1.hbc patchfile</p></li></ul><p><strong>添加-base-bytecode</strong></p><ul><li><p>'noneBaseBytecodeTest.hbc' 不是通过 <code>-base-bytecode</code> 方式生成的包</p><ul><li>增量包大小为:13kb</li></ul></li></ul><p><!----></p><pre><code>//生成新版本 hbc,大小为 2.65mb 左右,大小不变
hermes -emit-binary -base-bytecode='noneBaseBytecodeTest.hbc' ./test1.bundle -out ./test1.hbc
//生成patch, 大小为 13kb
bsdiff noneBaseBytecodeTest.hbc test1.hbc patchfile
</code></pre><ul><li><p>'baseBytecodeTest.hbc' 是通过 <code>-base-bytecode</code> 方式生成的包</p><ul><li>增量包大小为:9kb</li></ul></li></ul><p><!----></p><pre><code>//生成新版本 hbc,大小为 2.65mb 左右,大小不变
hermes -emit-binary -base-bytecode='baseBytecodeTest.hbc' ./test1.bundle -out ./test1.hbc
//生成patch, 大小为 9kb
bsdiff baseBytecodeTest.hbc test1.hbc patchfile
</code></pre><p><strong>结论:</strong></p><ol><li>使用 <code>-base-bytecode</code> 比不使用 <code>-base-bytecode</code>,使用 bsdiff 生成的增量包体积减少了 80% ~ 85%。</li><li>都使用 <code>-base-bytecode</code> 生成的包,使用 bsdiff 生成的增量包更小。</li></ol><h4>参考资料</h4><ul><li><a href="https://link.segmentfault.com/?enc=WKtmQsAuMzDEdPZ0q96JKQ%3D%3D.TAFwGd%2B0rUoySxDq32dmnxtJU4qyWPnKIYyEnqOtv%2FU8jMh9%2Fp6AxDCOv6UFxP7ZGY3BCiFcQRiwkHVFoI6FJFn7ukeZsVILa05YJopUsTUEmTG7PTIqUoLEfRRe%2FXfB" rel="nofollow">Comparison of gzip, bzip2 and xz compression tools.</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044581309" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=PXu6QuyXCxdXn%2Bc01aHJeQ%3D%3D.myy9CxaXxuBTZDDKZegmGCWzgPHuj1UtRYpIePBl8h0%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐服务端可视化编排平台 TangoFlow 设计与实现
https://segmentfault.com/a/1190000044569279
2024-01-19T10:48:14+08:00
2024-01-19T10:48:14+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:[帝青]</blockquote><p>在实际业务需求背景下,TangoFlow 寻求构建组装式架构,整合云音乐服务端技术栈,提供基础逻辑编排功能,以某种方式(网关API、统一SDK等)暴露编排结果;从长远来看,作为研发全链路低代码化中的一环,构建符合云音乐现状及长远愿景的服务端低代码平台;今天我们一起来聊下TangoFlow 的产生背景以及平台化建设实践;</p><h3>背景及诉求</h3><h4>1、BFF场景下灵活编排诉求</h4><p>目前云音乐的前后端协作基于Restful API进行交互,服务端在Controller层通过来自RPC的原子接口数据,组装出前端视图需要的数据并返回给前端,所以会存在客户端相关接口的契约定义对服务端有深度依赖,导致在协作上存在大量的沟通成本以及排期依赖,同时由于客户端UI的多变性,导致服务端面向客户端接口复用性较差,大前端同学苦恼于难以得到自己想要的数据,而服务端同学基于自己并不熟悉的视图拼接模型数据时也感到异常痛苦。</p><p><strong>BFF 研发模式</strong>一直是业界广泛青睐的前后端协作模式,它能有效解耦前后端在协作上的依赖关系,从而大幅度提升研发效率,目前云音乐内部已经完成基于GraphQL的BFF的研发模式提供,这种研发模式在云音乐内部有较为广泛的落地,<strong>在2022年完成了基于GraphQL的BFF应用建设解决上述问题,</strong> 目前已在较多团队中推广使用,但随着使用会存在以下问题:</p><ul><li><strong>受限于GraphQL引擎,编排能力不足</strong>:GraphQL是一种用于API的查询语言。可以将GraphQL看作一种新的API标准,它提供了一种高效、灵活的数据提供方式,但基于GraphQL引擎存在一些限制,在较为复杂的逻辑处理上无法进行灵活的编排,在组装RPC服务时,会存在很多个性化需求,目前是通过groovy脚本输入做输出做重新的组装,脚本内部存在大量业务逻辑,导致groovy脚本滥用;</li><li><strong>降低资源开销</strong>:当前BFF应用采用的是一个febase应用对应一个BFF后端引擎服务集群,目前测试、预发、线上都会创建对应的引擎服务集群,但基本无流量,且线上流量也都比较低,所以会存在大量的资源浪费;</li><li><strong>BFF引擎服务集群存在运维分工不清晰现象,缺乏机制保障</strong>,目前前端同学负责对BFF API接口搭建、自测、完成接口交付,但对于引擎服务集群稳定性、容量水位缺少评估经验,同时服务稳定性偏后置,在稳定性和容量水位服务端同学做的会更多一些,会更有经验;</li></ul><h4>2、业务上的编排诉求</h4><ul><li><strong>寻求业务架构可编排能力</strong>,基于现有业务沉淀服务资产,灵活快速高效编排出符合业务需求的流程,并以触发器(网关API服务、RPC服务、本地资源... ...)的形式暴露流程服务;</li><li><strong>活动场景编排提效:在活动场景下,</strong> 玩法中台之前产出了轻量级流程编排组件(在产品调研中有提及:《轻量级流程编排组件介绍》),由于接入比较较为复杂,平台使用体验一般,导致推广不是很广泛,<strong>对于玩法中台场景存在较多可沉淀的场景,能够通过复用已有资产组装完成新的业务场景</strong>;</li></ul><h4>3、标准化推进落地困难</h4><ul><li><strong>规范落地难:</strong> 目前云音乐存在诸多规范,但在真正落地效果上并不是很好;</li><li><strong>调试测试效率低:</strong> 目前在网易PostMan在公司是禁用的,所以现在大家都在GoTest上使用,使用起来会比较重,且会存在跨平台/工具的操作,使用体验、效率比较低,测试代码运行大多需要启动整个应用,而应用的启动通常都是分钟级的,这就导致我们研发效率进一步降低。</li><li><strong>服务治理能力弱:</strong> 代码本质上是非结构化的文本数据,我们很难基于代码进行统计,此时接口和服务、工具间的依赖关系就显得尤为重要,但基于编码的方式我们是很难做到精准统计,虽然有一些调用链追踪工具可以提供帮助,但还是不够直接,还是需要人肉的去做进一步的识别;服务治理能力(限流、降级、静态化、Redis治理... ...)仍然较为分散,应用维度治理能力仍然需要跨平台操作,易用性、开发者体验仍需提升;</li><li><strong>降低资源开销:</strong> 一方面是BFF自身的资源开销问题,另一方面,现在微服务比较多且占用资源不一,如何将微服务合并为一个微服务,进一步降低成本</li></ul><h4>4、全链路低代码建设</h4><p>对于一个完整的 web 应用来讲,会经过用户界面-接口服务-数据服务等多个模块,目前云音乐已完成了前端低代码的建设,已经大大提升了UI层的交付效率,接下来会在整个链路上进行尝试,对于服务端的逻辑编排、服务编排以及更长远的基于模型编排驱动方式,是我们重点关注的对象,<strong>期望能够从全链路上降低开发成本、提升交付效率和质量;</strong></p><p>所以基<strong>于上述背景,我们规划、设计流程编排平台,</strong>对于<strong>BFF应用,主要是解决编排的问题,</strong>同时支持更复杂的编排场景,服务编排出来的产物可以是RPC、API服务,编排出来的RPC、API服务直接集成在BFF中,灵活解决BFF应用的服务编排问题,同时<strong>从机制上解决引擎服务集群稳定性的问题,支持paas服务的管理方式,降低服务成本,同时在平台建设过程中,注重对资产的沉淀、平台研发易用性和使用效率</strong>;从<strong>长远来看,在整个研发全链路上进行低代码尝试</strong>,对于服务端的逻辑编排、服务编排以及更长远的基于模型编排驱动方式提供基础演进路线,能够更<strong>进一步实现技术中心全栈化</strong>,提升交付效率,降低交付成本;</p><h3>流程编排思路</h3><p>构建编排一个完整流程,主要是能够将可被编排资源以顺序、分支、循环、并行等的流程串联,可以被编排的资源主要可以分为两类,<strong>一类是业务域服务,一类是工具域服务</strong>,通过对对业务域服务、工具域服务的组装、编排,可以灵活构建一套符合业务需求的服务工作流;</p><p>下面针对业务域和工具域简单介绍下:</p><p><strong>1、业务域</strong>:主要是指<strong>当前云音乐的不同业务领域的服务能力聚合,通过对RPC、HTTP、FaaS等的服务沉淀</strong>,在业务领域模型比较稳定的情况下,沉淀出不同的业务域模型,比如评论域、活动域、用户域等,平时的业务需求主要来自上层聚合层,则是对领域模型中沉淀出来的服务能力的服务编排,此时便可以通过服务编排的方式提供更为灵活的聚合类服务;当前的业务领域沉淀比较弱,期望能以Tango Flow项目为契机沉淀业务领域资产;</p><p><strong>2、工具域:主要是针对工具的分类聚合</strong>,这里主要抽象为了一下三种类型:</p><ul><li><strong>中间件域</strong>:可以提供发送或消费消息队列,数据可以增删改查Redis服务,比如更多的分布式锁,分布式计数、指标监控等都会在中间件域中沉淀出来,便于搭建上层业务场景;</li><li><strong>AI工具域</strong>:当前云音乐正在沉淀相关能力,后续提供出来的一些能力,比如文本分类、知识问答等,则可以工具的形式在Tango Flow平台提供,从而可以灵活搭建更上层的业务场景;</li><li><strong>平台服务域</strong>:目前云音乐的一些能力都是相对比较单点的,比如当前有告警能力、数据监控、简单数据分析、以及流量治理能力,那按照发现问题、分析问题、解决问题的思路,是不是可以将这些能力串起来,比如将发出来的告警经过简单分析,去获取数据监控,在交给数据分析模块进行分析,分析发现是某种问题,可以通过限流来临时止血,进而整个流程可以自动化掉;</li></ul><p>当前的工具领域沉淀比较弱且很分散,期望能以Tango Flow项目为契机沉淀工具域资产;整个逻辑编排是对业务域和工具域资产的编排,具体表现如下图:</p><p><img src="/img/remote/1460000044569282" alt="" title=""></p><h3>产品架构</h3><p><strong>1、逻辑编排态:</strong> Tango Flow平台整合现有OX资产(业务侧的RPC接口、HTTP接口)及规范信息,用户可以在该平台完成流程的搭建、测试、发布动作;</p><p><strong>2、逻辑运行态:</strong> 流量从APP端达到网关、BFF、业务服务,那Flow Engine可以完全嵌入到这三层,比如现阶段直接取代BFF能力,通过逻辑编排出来网关API;</p><p><img src="/img/remote/1460000044569283" alt="" title=""></p><h3>产品介绍</h3><h4>产品优势</h4><p><img src="/img/remote/1460000044569284" alt="" title=""></p><p>下面针对个别特点进行简单介绍,部分会在产品使用及应用场景章节介绍:</p><h4>1、自研编排引擎</h4><p>TangoFlow自研逻辑编排引擎,编排引擎是静态流程的Runtime载体;同时也是一个逻辑概念,和使用姿势有关系,可以服务的形式承接网关流量,也可以以SDK的方式集成在业务应用中,自研编排引擎具有以下特点:</p><p><img src="/img/remote/1460000044569285" alt="" title=""></p><p><img src="/img/remote/1460000044569286" alt="" title=""></p><h4>2、自研DSL协议</h4><p>DSL分为元信息定义和逻辑流程定义,元信息定义会受流程内容影响而有很大不同,在下面这个例子中,元信息定义包括Trigger定义、RPC节点定义、Groovy脚本定义,更接近编程语言,后期借助AI Native prompt提升效率;</p><p><img src="/img/remote/1460000044569287" alt="" title=""></p><h4>3、集群托管机制</h4><p>托管集群是真正的运行TangoFlow 流程的服务,在运行TangoFlow的流程时,需要首先指定运行在哪个托管集群上,从开发 -> 回归 -> 预发 -> 线上,每个都需要制定运行的托管集群;<img src="/img/remote/1460000044569288" alt="" title=""></p><p><strong>1、引擎多租户,降低资源开销</strong></p><p>托管集群服务时支持多租户的,可以将不同应用指定到同一个托管集群上,一方面降低运维成本,另一方面也能够减低机器成本, 在服务达到容量瓶颈时可以对托管集群进行扩容,或重新新建托管集群承载新的服务;</p><p><strong>2、每个业务线或领域,都有一个“积木”系统</strong></p><p>托管集群的维护尽量按照业务线或业务领域规划,对于BFF场景,线下和预发可以用公共的环境,对于线上服务由于流量较大,需要单独申请托管集群承载运行时流程;</p><p><strong>3、建立统一运维协作机制</strong></p><p>由于角色的不同,可角色分为两大类:托管集群负责人,开发角色,同时为了集群的稳定性,会涉及到审批和通知,便于集群负责人对容量和稳定性进行把控,所以需要应用关联到托管集群和流程发布时通知到托管集群负责人、或需要托管集群做审核,简单示意图如下:</p><p><img src="/img/remote/1460000044569289" alt="" title=""></p><ul><li>托管集群负责人角色:一般为服务端开发</li><li>负责感知、审核应用关联集群、流程发布</li><li>特点:对稳定性、容量敏感,对引擎服务集群容量、稳定性负责</li><li>开发角色: 前端、客户端、服务端</li><li>负责流程搭建、测试、交付</li><li>特点:可能对服务容量、稳定性不敏感</li></ul><p><strong>4、环境隔离</strong></p><p>由于线下环境的特殊性(环境隔离),云音乐有一套自己的环境隔离策略,TangoFlow能够通过API网关负责API路由托管集群负责环境路的方案解决环境隔离问题;</p><p><img src="/img/remote/1460000044569290" alt="" title=""></p><p><strong>1、环境路由规则:</strong> 优先同标识环境服务路由;未找到同环境,则回归环境服务兜底</p><p><strong>2、传统环境隔离:</strong> 网关负责API路由、环境路由;需部署相应环境业务集群服务</p><p><strong>3、Tango Flow环境隔离:</strong> 网关负责API路由;Tango Flow引擎负责环境路由</p><h3>产品使用</h3><p>整体流程主要包括四个部分:编排搭建 -> 自测、调试 -> 发布流程 -> 运维,如下图所示</p><p><img src="/img/remote/1460000044569291" alt="" title=""></p><p>下面针对针流程编排中关键步骤进行简单介绍:</p><h4>1、流程中概念</h4><p>一个完整的流程包括Trigger +逻辑控制 + 数据来源 + 数据整合,下面对这几个概念进行简单介绍:</p><p><img src="/img/remote/1460000044569292" alt="" title=""></p><p>1、Trigger:流程编排后以何种方式暴露,流量以不同的方式调用到流程,比如可以以网关API、服务端SDK、事件消息、定时任务等方式,目前一期已支持网关API和业务服务接入SDK的方式完成流程的触发;</p><p>2、逻辑控制:用于控制流程逻辑流向,目前支持串行调用,并行调用、IF-Else、Switch、For迭代器(List、Map)的方式,能够支持绝大部分业务场景,对于一些逻辑比较复杂的场景可以通过Groovy脚本来完成;</p><p>3、数据来源:产生数据的源头,目前已支持云音乐RPC、网关HTTP接口、Groovy脚本、本地接口调用、固定值,这些都可以产生数据,被流程拿来进行编排,产生的数据可以在流程上进行流转;</p><p>4、数据整合:作为整个流程返回的结果,可以自由组装数据来源产生的数据,同时支持JsonPath的取数方式;</p><h4>2、逻辑编排可视化</h4><p>流程编排页面是TangoFlow最核心的页面,从区域上划分为组件区域、逻辑编排区域、属性面板,在调试情况下会弹出调试区域,同时支持分支切换、历史版本、视图切换(支持设计和DSL的切换)、流程快速发布等能力。</p><p><img src="/img/remote/1460000044569293" alt="" title=""></p><h4>3、参数选择</h4><p>在需要上游某个节点的返回结果作为当前流程节点的输入,在TangoFlow平台中所有的参数选择都是基于组件标识来识别的,可以选择将整个结果作为当前的输入,也可以通过 jsonPath来选择具体的某个值作为输入;</p><p><img src="/img/remote/1460000044569294" alt="" title=""></p><p>举个具体的例子:定义在触发器中gw\_http0中的一个参数userId,可以在下游RPC调用中被选择作为RPC的入参,另外也可以在结果集整合的时候作为结果被使用,在下面这个case中,是通过节点标识取到的该节点返回之中的data.rowkey信息作为返回结果的一部分;</p><p><img src="/img/remote/1460000044569295" alt="" title=""></p><h4>4、流程调试</h4><p>支持整个流程的调试、节点维度调试(RPC、HTTP、Groovy脚本)的调试能力,并且无需发布,可指定环境、直接测试、调试记录;</p><p><strong>1、流程调试:</strong> 针对整个流程的测试,Trigger开始触发</p><p><img src="/img/remote/1460000044569296" alt="" title=""></p><p><strong>2、节点调试:</strong> 单个RPC、HTTP-IN、Groovy脚本调试</p><p><img src="/img/remote/1460000044569297" alt="" title=""></p><p><strong>3、可视化调试记录:</strong> 入参、结果、调试记录栈、运行DSL</p><p><img src="/img/remote/1460000044569298" alt="" title=""></p><h4>5、Mock机制</h4><p>测试过程中,对于不容易构造或者不容易获取的对象,用一个虚拟的对象来代替以便测试的测试方法,只用于线下环境快速联调,<strong>在团队并行开发能极大提高效率</strong>;</p><p>Tango Flow支持Request Mock和Response Mock,Mock数据来自于统一资产平台(OX),数据可以在Tango Flow平台按需进行调整;同时在编排时若某个节点打开了Mock可在编排视图中会有特殊标记提示流程节点开启了Mock;</p><p><strong>1、Request Mock</strong></p><p>支持 RPC、HTTP、Groovy ,用于服务已就绪,Request Mock 数据作为请求数据,忽略上游传入数据的场景;</p><p><img src="/img/remote/1460000044569299" alt="" title=""></p><p><strong>2、Response Mock</strong></p><p>支持Trigger、 RPC、HTTP、Groovy ,用于下游服务接口未就绪、服务不存在等导致无法构建或获取对象场景</p><p><img src="/img/remote/1460000044569300" alt="" title=""></p><h4>6、流程持续发布</h4><p>发布流水线全生命周期管理,秒级完成发布,卡点环节使发布可靠、低风险、随时按需执行,流程发布具有以下特点:</p><p><strong>1、多分支并行开发:</strong> 可多分支并行开发,一个发布单对应一个分支,新建发布单从master分之拉取数据,发布完成后覆盖master;</p><p><strong>2、发布策略:</strong> 线下环境发布策略:开发、回归随意发布,回归环境只有一个,互相覆盖;线上环境发布策略:预发、线上通道独占,互斥发布;</p><p><strong>3、回滚策略</strong>:回滚发布以新发布单形式存在,与其他共用发布单共享发布通道,回滚但不能合并到Master;</p><p><strong>4、发布卡点</strong>:托管集群具有可配置的流程发布卡点机制,在开启发布审核时,在卡点环节会提示需要托管集群负责人审核才能进行预发和线上环境发布,保证发布的安全性,后续也会持续优化发布卡点逻辑。</p><p><img src="/img/remote/1460000044569301" alt="" title=""></p><p>以下为一个具体case,由于公共预发集群和线上集群都设置了开启审核,所以需要集群负责人审核通过后才能继续发布</p><p><img src="/img/remote/1460000044569302" alt="" title=""></p><h4>7、监控告警</h4><p>不同维度数据,不同负责人关注信息不一样,根据访问分布、RT访问分布,并在流程维度有RT和调用错误率的告警;</p><p><img src="/img/remote/1460000044569303" alt="" title=""></p><h3>应用场景</h3><p>一期流程编排暴露出来的能力是网关API和API-SDK,同时支持的数据节点主要是RPC接口、HTTP接口、本地接口调用、Groovy脚本,所以一期主要适用场景在:协议转换、数据聚合和简单逻辑编排的场景;</p><p><img src="/img/remote/1460000044569304" alt="" title=""></p><p>对于以上适用场景,可以具体应用到以下场景:</p><h4>1、BFF场景</h4><p>主要是解当前BFF决编排的问题,从机制上解决引擎服务集群稳定性的问题,支持paas服务的管理方式,降低服务机器成本;更多的从协议转换、数据聚合、逻辑编排维度提供能力;</p><p><img src="/img/remote/1460000044569305" alt="" title=""></p><h4>2、服务端业务场景(统一服务端SDK)</h4><p>灵活轻量的利用编排平台沉淀的服务端技术栈资产能力完成业务需求(只需接入引擎SDK,相当于接入了已沉淀的SDK的使用姿势),降低服务端资产使用成本,提高交付效率,在一定意义上实现技术架构转型;</p><p><img src="/img/remote/1460000044569306" alt="" title=""></p><h3>总结</h3><p>本文介绍了Tango Flow平台建设的问题、诉求及构建思路,<strong>首要是解决编排的问题,</strong>支持更复杂的编排场景,服务编排出来的产物可以是RPC、API服务,编排出来的RPC、API服务直接集成在BFF中,灵活解决BFF应用的服务编排问题,同时<strong>从机制上解决引擎服务集群稳定性的问题,支持paas服务的管理方式,降低服务成本,同时在平台建设过程中,注重对资产的沉淀、平台研发易用性和使用效率</strong>;从<strong>长远来看,在整个研发全链路上进行低代码尝试</strong>,对于服务端的逻辑编排、服务编排以及更长远的基于模型编排驱动方式提供基础演进路线,能够更<strong>进一步实现技术中心全栈化</strong>,提升交付效率,降低交付成本;</p><p><strong>Tango Flow是云音乐自研的流程编排平台</strong>,<strong>具有丰富的应用场景</strong>,可以用在BFF场景、服务端业务场景(统一服务端SDK),<strong>并具有场景易扩展能力</strong>;Tango Flow可通过<strong>可视化拖拉拽的方式快速搭建、测试和发布流程</strong>,平台提供简单易用流程搭建能力,通过组件标识+JsonPath可完成参数在流程节点之间传递;支持整<strong>个流程的调试、节点维度调试</strong>(RPC、HTTP、Groovy脚本)的调试能力,并且无需发布,可指定环境、直接测试、调试记录;<strong>建立统一的运维协作机制,发布流水线全生命周期管理,秒级完成发布</strong>,卡点环节使发布可靠、低风险、随时按需执行;具有<strong>监控告警</strong>能力,不同维度数据,不同负责人关注信息不一样,根据访问分布、RT访问分布,并在流程维度有RT和调用错误率的告警;</p><h2>未来规划</h2><p>1、服务端低代码演进 :提供更多触发场景,整合现有服务端技术栈组件;</p><p>2、前后端低代码一体化:完成模型驱动,实现前后端低代码一体化;</p><p>3、平台体验 & 效率 优化:基于业务痛点及反馈,完善和优化现有平台能力;</p><h2>最后</h2><p><img src="/img/remote/1460000044569307" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=YQMqQcldAeVD9xqP%2BGo8nQ%3D%3D.pEbMtDJh9B%2FGNmqRj3J0fjtY0faQCQOSvaKznDH7c1Y%3D" rel="nofollow">https://hr.163.com/</a></p>
客户端自动化测试在网易云音乐的实践与落地
https://segmentfault.com/a/1190000044548551
2024-01-12T13:45:23+08:00
2024-01-12T13:45:23+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:吕雨强</blockquote><h2>一、背景</h2><p>时间线回到 2021 年</p><p>云音乐目前版本双周迭代,在集成测试阶段会花费两天时间,在这两天时间里面有相当部分时间是人工进行用例回归,而且目前 UI 自动化现有方案会存在较多问题,导致目前这块的效率和质量均不太理想,而对业务侧又希望能降低业务上线的周期。</p><p>目前云音乐每个新版本质量稳定性不是很理想,在前一个版本上崩溃率做到了质量基线,下个版本很可能就快速反弹。</p><p>同时伴随着的以下一些痛点:</p><ul><li>测试回归人工效率低,目前使用的 appium、smartAuto 等框架均有部分缺陷,主要集中在用例维护成本高、UI 检索稳定性保障难、新版本用例录入不及时、双端一致性难保障、问题回溯定位效率低下等;</li><li>自动化测试对于用例和设备调度及管理基本靠人工进行;</li><li>冒烟测试等工作没有对应的高效验收机制和服务能力,主要依赖线下沟通;</li><li>自动化用例没有统一的调度管理平台,在 tc 用例平台执行用例时,无法精准筛选自动化用例执行,且每次执行结果保存也不直观。</li></ul><h2>二、Saturn 平台简介</h2><p>基于上述背景,2021 年下半年云音乐大前端 QA 与公技团队共同搭建 Saturn 平台。Saturn 的核心功能主要包括:</p><ul><li>用例录制:手机端操作录制,录制过程记录控件信息、操作信息、埋点信息、截图等扩展信息;</li><li>用例管理:录制的用例上传到 Saturn 后,进行维护、分类、放到各自模块以及执行集等;</li><li>执行集:定义各自需要执行的用例的集合,方便用例快速执行与制定执行计划;</li><li>设备调度:支持私有化部署、支持私有化调度、支持按测试类型调度、支持多 APP 之间调度、支持指定设备调度、支持随机分配设备等;</li><li>用例执行:根据执行设置分配执行机,把用例分发到指定 provider 端,provider 根据下发消息调度 Athena/NETestWD 执行自动化;</li><li>报告:报告汇总、执行步骤展示及执行截图、手动标记 check、失败原因归类、执行过程录屏与日志;</li><li>设备管理:自有设备的上下线、自有设备的用途、自有设备的部署类别;</li><li>各个专项,UI 自动化,RN 自动化,启动性能自动化,稳定性测试,覆盖安装等等</li></ul><p>接下来,本文讲重点介绍 Saturn 平台设计思路、技术演进,专项,落地成果。</p><h2>三、Saturn 平台架构</h2><p>Saturn 平台主要分为</p><ol><li>平台端:主要是用户交互、设备管理与调度;</li><li>Android provider 端:部署在 mac 或者 pc 上用于 Android 手机的监控、与平台端交互、下发执行自动化任务;</li><li>Athena APP:主要用于录制与回放自动化用例、上报用例、上报用例执行结果;</li><li>Android 端内置 SDK:辅助 Athena 录制与回放自动化用例、准备用例执行环境(登录、mock、后台环境等);</li><li>iOS provider 端:部署在 mac 上用于 iOS 设备监控、与平台端交互、下发执行自动化任务、维护 WDA、NETestWD 的稳定;</li><li>NETestWD:用于启动被测应用、接收用例、上报自动化用例执行结果;</li><li>WDA(WebDriverAgent):处理 iOS 端设备上的系统弹框;</li><li>iOS 端内置 SDK:主要用于录制自动化用例、准备用例执行环境(登录、mock、后台环境等)、执行自动化用例;</li></ol><p>如下面架构图所示:</p><p><img src="/img/remote/1460000044548553" alt="" title=""></p><h2>四、Saturn 设计原理</h2><h2>4.1 Saturn 平台设备调度工作流程</h2><p>设备调度工作流程用户执行用例,后台逻辑处理然后入库 history 表、发送 SystemHistory 消息入库 queue 表;SystemHistory 消息消费线程监听 queue 表中 SystemHistory 消息,获取消息后进行处理按照 Android/iOS 设备发送新的消息入库 queue 表;Android/iOS 消息消费线程监听 queue 表中 Android/iOS 消息,获取消息并锁定消息,之后检测可用设备,如果无空闲设备则释放消息,如果有空闲设备通过 websocket 下发消息并删除 queue 表中消息,provider 端接收到消息进行解析,执行自动化用例,执行完成后上报执行结果到平台,平台会更新用例执行结果并释放设备。</p><p>设备调度支持私有化部署、支持私有化调度、支持按测试类型调度、支持多 APP 之间调度、支持指定设备调度、支持随机分配设备等;</p><h3>4.1.1 设备管理页面</h3><p><img src="/img/remote/1460000044548554" alt="图" title="图"></p><h2>4.2 Saturn 平台 Athena 工作流程</h2><p>Athena 通过 adb 在录制时监听事件,回放时发送时间;通过 Accessibility 遍历节点、获取节点信息;</p><p><strong>录制时</strong></p><ul><li>Athena 记录操作节点信息、操作信息、埋点信息;</li></ul><p><strong>回放时</strong></p><ul><li>Athena 通过内置 SDK 快速准备环境,免去大量前置步骤执行;</li><li>Athena 优先通过埋点信息确定目标节点,无埋点信息时通过使用多种查找方式遍历多次计算综合得分确定目标节点;</li><li>Athena 会自动处理系统弹框,防止弹框对自动化执行的影响。</li></ul><p><img src="/img/remote/1460000044548555" alt="" title=""></p><h2>4.3 Saturn 平台 iOS 内置 SDK 工作流程</h2><p><strong>录制时</strong></p><ul><li>触摸、滚动事件的采集是通过 AOP 的方式进行采集信息,比如点击事件,通过拦截 UIControl 的 sendAction 方法和 UIView 的 addGestureRecognizer 方法,滚动时间通过 hook UISCrollViewDelegate 进行消息转发;</li><li>键盘输入、断言事件的采集目前没有很自然的方式,所以都是通过一个交互工具来手动进行录制;</li></ul><p><strong>回放时</strong></p><ul><li>回放的核心点是 UIAppliction 的 sendEvent 方法,sendEvent 方法是触摸事件分发的入口,回放时通过构造 UIEvent 再通过 sendEvent 方法进行事件分发;</li><li>快速环境准备,免去大量前置步骤执行;</li><li>执行时会优先使用埋点信息确定目标节点,无埋点信息时通过 xpath 定位的方式确定目标节点。</li></ul><h2>4.4 Saturn 自动化用例平台存储</h2><p>用例以 json 表达式,保存自动化的执行环境,需要 mock 的接口, 账号密码, 自动化步骤等信息。</p><p><img src="/img/remote/1460000044548556" alt="" title=""></p><h2>4.5 设备机房</h2><p><img src="/img/remote/1460000044548557" alt="" title=""></p><h2>五、在云音乐中的应用</h2><h3><strong>UI 自动化测试</strong></h3><p>可定时触发,可以手动触发, 与能效平台打通能 CI 触发。</p><p>执行情况</p><p><img src="/img/remote/1460000044548558" alt="" title=""></p><p>算上失败重跑 2 次, 一小时单台设备平均是能执行 30 条以上自动化用例</p><p>对用例执行情况,可查看截图和视频</p><p><img src="/img/remote/1460000044548559" alt="" title=""></p><p><img src="/img/remote/1460000044548560" alt="" title=""></p><h3><strong>兼容性测试</strong></h3><p>对需要测试兼容性的用例,可选择兼容性测试,自动从每一个系统中选出设备进行测试</p><p><img src="/img/remote/1460000044548561" alt="" title=""></p><h3><strong>稳定性测试</strong></h3><p>双端都可定时触发,可以手动触发 ,底层采用开源项目 Fastbot</p><p><img src="/img/remote/1460000044548562" alt="" title=""></p><h3><strong>启动性能自动化</strong></h3><p>通过定时打包,定时启动自动化,获取平台埋点的启动数据,可视化展示启动数据。 跟踪启动数据,防止启动劣化。</p><p><img src="/img/remote/1460000044548563" alt="" title=""></p><h3><strong>RN 自动化</strong></h3><p>图像对比 RN 页面</p><p><img src="/img/remote/1460000044548564" alt="" title=""></p><h2>六、云音乐客户端自动化效果</h2><p><img src="/img/remote/1460000044548565" alt="" title=""></p><ul><li>有效降低学习成本:人人均可录制测试用例;</li><li>Android 端 P0 级用例覆盖率 72.95%,执行成功率 91.87%;</li><li>iOS 端 P0 级用例覆盖率 71.86%,执行成功率 91.33%;</li><li>用例创建和维护效率,相比 Appium 和 Smartauto,分别提升了 9 倍和 3 倍;</li><li>用例执行效率提升 1.5 倍以上;</li><li>用例执行成功率提升 2 倍以上,稳定在 90%以上;</li><li>问题定位效率从天级别缩短小时;</li><li>迭代回归缩短 0.5 天;</li><li>双端同一埋点自动化用例可以复用;</li><li>用例易修改,位置变动可直接更新自动化用例的 json 数据;</li><li>平台整体录制自动化用例两千条以上;</li><li>执行自动化用例年均二十万次以上;</li><li>发现功能问题和稳定性问题几十个;</li></ul><h2>七、参考资料</h2><p>[1] <a href="https://link.segmentfault.com/?enc=abXtpP%2FudgD8PYdPaR0YjA%3D%3D.qqkZwd%2FTkzffKR81WzdtVbgxBlti%2FIHny1OKFcGZTVZ5TbV2JdvVsv9LuMOY21HX" rel="nofollow">https://github.com/appium/WebDriverAgent</a></p><p>[2] <a href="https://link.segmentfault.com/?enc=a55BTCpq2XyUcHYE1FCoUg%3D%3D.sKvHPMVHg3TGrZd9ar9yEUxuHmIJ75SPQPQNGorxOEmTkkXWp2fYPh8NP1wcT7Zw" rel="nofollow">https://github.com/alipay/SoloPi</a></p><p>[3] <a href="https://link.segmentfault.com/?enc=gR90QZKLLHwo8LEjXecKkw%3D%3D.0QZH507MhftXtssQ93juCSzBUkKoa3yf5PFNMW380R8a4H1lq61%2Fq%2BEqWrmBGSn0" rel="nofollow">https://github.com/openatx/atxserver2</a></p><p>[4] <a href="https://link.segmentfault.com/?enc=33CnMKjT0eL5VJFbX1iULg%3D%3D.ZaUqwLsuMTOyGTHD0oFTrV%2F54VeH3w2ua8sYqbW%2FdKSiw7Zikq1b%2FnwGyHjhDq2N" rel="nofollow">https://github.com/bytedance/Fastbot_iOS</a></p><p>[5] <a href="https://link.segmentfault.com/?enc=N51uijVkSSQtVJBsnDvBzA%3D%3D.Sz8%2BZ914V0TmEVbSLhEfkCYv5%2BMPc7NqP9wt4zYLFa0%2FdomhnYFXWz%2BL6YF7MI4J" rel="nofollow">https://github.com/bytedance/Fastbot_Android</a></p><p>[6] <a href="https://link.segmentfault.com/?enc=lfMcj15yOEIJ52UfjqJUfg%3D%3D.DMuSGJjS6KBXLkhnype2FRyUu4YG1xPVMStoCoEcF2%2Fu8HU1AI%2F9qaaVuV7%2BdBxp" rel="nofollow">https://github.com/alibaba/tidevice</a></p><h2>最后</h2><p><img src="/img/remote/1460000044548566" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=43yZiLZGwG7eFV3MzN%2F6aw%3D%3D.I2%2F8UHi3yH1N4F2XIj9pNuDIpgMn4QKWQsh%2FxFZbT6Y%3D" rel="nofollow">https://hr.163.com/</a></p>
基于VictoriaMetrics构建云音乐亿级APM Metric监控体系
https://segmentfault.com/a/1190000044543001
2024-01-10T17:20:04+08:00
2024-01-10T17:20:04+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:张琪</blockquote><p>Metrics是服务监控的重要部分,网易云音乐中间件团队基于VictoriaMetrics构建了服务端Metrics监控体系,旨在提供易用、高效的监控解决方案,本文介绍了建设中遇到的问题、方案与成果。</p><h2>一、背景介绍</h2><p>Trace、Metrics、Log是APM系统(Application Performance Management,应用性能管理)的三大支柱。过去云音乐使用的Metric监控体系与APM分属不同系统,使用时相互之间没有联动,导致Metric与Trace完全割裂,问题定位中将二者关联起来时需要一定成本;另外不同系统的数据视角不同,使用风格也有较大区别,导致总体问题分析能力较弱。 </p><p>为此,云音乐中间件团队规划建设了新版应用服务端监控体系(Pylon APM),重新实现了Metric体系,选型了作为云原生监控标准的Prometheus作为Metric监控基础。而云音乐庞大的服务规模,多样的监控需求也对Metric时序存储的可靠性、可用性及性能带来了很大挑战。我们最终形成了围绕VictoriaMetrics(以下简称VM)体系的Metric架构,旨在解决以下问题:</p><ul><li><strong>应用层Metric可观测性弱</strong>:过去音乐内部Metric监控以机器层面的Metric监控为主,虽也提供了常用框架的监控插件,但无论是性能还是可视化效果都有一定改进空间,问题排查效率低;</li><li><strong>Metric关联到Trace的问题</strong>:Metric是发现问题最直观的方式,比如“接口错误数10”,但还需要Trace协同工作才能定位到发生错误的根因;</li><li><strong>性能与成本问题</strong>:旧版Metric监控数据存储成本较高;而社区版Prometheus单体应用,无法支撑音乐如此大的数量级。需要一套高可用而低成本的数据采集、数据存储方案;</li><li><strong>数据维度大,聚合查询吃力</strong>:监控数据时常应对聚合查询,应用层数据的采集维度很大,若直接查询原始数据往往需要数秒甚至数十秒,严重影响问题排查;</li><li><strong>可视化能力弱,缺乏灵活的数据对比</strong>:监控数据时常需要同环比、多实例比较等手段来帮助定位问题,Prometheus UI和可视化工具Grafana都没有支持这项功能。</li></ul><p>为解决以上问题,我们对围绕VM时序采集、聚合、Grafana可视化做了深度扩展,最终达成以下目标:</p><ul><li><strong>Metric关联到Trace的问题排查</strong>:解决信息孤岛,从Metric入手可下钻到Trace、Log排查问题;</li><li><strong>高效的Metric监控可视化与图表分析能力</strong>:我们设计了丰富、直观、多维度的Dashboard,使用户能够在第一时间观测到Metric存在的问题,还改造Grafana提供了图表分析能力,大大提升问题分析效果;</li><li><strong>高性能、低成本的采集存储方案</strong>:我们采用VM作为Prometheus的替代存储方案,以较低的成本支撑了音乐Metric监控;</li><li><strong>毫秒级的聚合数据查询</strong>:为了解决数据聚合、查询效率低的问题,我们实现了时序数据预聚合Recording Rules服务和查询代理Proxy服务。受益于此,常用的大维度数据聚合查询得以在毫秒级完成。</li></ul><h2>二、项目思路和方案</h2><h3>2.1 选型与架构</h3><p>Prometheus定义了云原生监控体系,但由于社区版性能较差且对数据持久化、高可用的支持较弱,衍生了很多数据远程存储方案,用以支持高可用、超大量级的数据。目前主流方案有VictoriaMetrics、M3DB、InfluxDB等。 </p><p>其中VM以其极高的性能、对Prometheus生态的完整替代、其重新实现的PromQL进化版-MetricQL等优秀的特性,得到了业界的高度认可和广泛使用,故我们选型了基于VM来实现我们的Metric监控方案,关于VM与其他TSDB的性能对比可以参考<a href="https://link.segmentfault.com/?enc=uTFMqw6ygtIuv9AoSWu5iA%3D%3D.o%2BVxIjbbfHitqEjIxGc5GsVc3XueQL8cOiqScutn3kw%2FJnWGMWhTTkRMZS2e3VZ6pPSvf4YHq1IVWu47bPCnaaBkGQXWLEjzEYUYE%2FRd2Tn4bzwy6%2FK8QbKaAqIGgHDezXrHnYzyrGHVDdtlCgh0hjNAOSB%2BrKipnk4Z68Y%2FFAw%3D" rel="nofollow">VM作者的文章</a>。 </p><p>基于VictoriaMetrics的Metric方案整体架构如下: </p><p><img src="/img/remote/1460000044543003" alt="" title=""> </p><p>架构可分为采集链路、查询链路:</p><ul><li><p>采集链路负责将Metric数据分片收集、预聚合后存储到vmstorage(VM的存储引擎)中,由以下组件组成:</p><ul><li>Exporter:内嵌在业务服务中的Prometheus SDK,暴露数据采集端口;</li><li>vmagent:负责数据采集;</li><li>Nacos:注册中心,负责vmagent和Exporter之间的服务发现。监控数据采集的服务发现节点量级较大,对一致性的要求没有可用性和性能的要求高,故我们选型Nacos,并对其做了兼容Prometheus服务发现的补充;</li><li>Recording Rules:自研的Flink任务,负责Metric数据的流式预聚合;</li><li>vminsert:VM集群模式的组件之一,负责数据写入;</li></ul></li><li><p>查询链路负责优化数据查询语句,查询存储引擎,由以下组件组成:</p><ul><li>Grafana:数据可视化,我们将其二次开发支持了数据同环比、多实例比较;</li><li>proxy:自研的查询代理,负责解析并优化PromQL;</li><li>vmselect:VM集群模式的组件之一,负责数据查询。</li></ul></li></ul><h3>2.2 监控数据采集、预聚合和查询方案</h3><h4>问题背景</h4><p>一条完整的Metric数据结构如下: </p><p><img src="/img/remote/1460000044543004" alt="" title=""> </p><p>在此结构下应用层Metric监控数据label-value键值对取值情况多,其组合数量是乘积的关系。遇到大维度聚合查询,对存储层的查询压力很大,延迟较高,严重影响问题排查的效率。</p><blockquote><p>比如我们监控一个API网关服务,集群中有200台实例,注册有10000个API,平均每个API有10种返回code,则按集群查询总的code分布情况时,存储层需要聚合的时序量有:</p><p>200 <em> 10000 </em> 10 = 20000000 条。</p></blockquote><p><img src="/img/remote/1460000044543005" alt="" title=""></p><p>我们尝试了社区开源的后置聚合方案Recording Rules,发现后置聚合对存储层的压力并未缓解,整体性能并不高,并不能达到优化整体查询性能的目的。</p><h4>解决方案</h4><p>由于时序数据不断增长的特点,数据预处理提高查询时效率较好的解决方案。经过测试,开源方案后置聚合(数据存入存储引擎后,再查询出来聚合)的方式不能满足我们的性能要求,故我们基于Flink设计实现了预聚合的Recording Rules服务,另外为了让用户更方便地使用聚合数据,我们设计了查询代理Proxy。</p><ul><li><strong>预聚合的Recording Rules</strong></li></ul><p>预聚合服务负责将用户经常需要使用的大维度聚合查询提前的聚合,提高查询效率。 </p><p>Prometheus体系下的Metric数据是时间连续的,每隔一个interval都会有一组数据上报,非常符合流式数据窗口聚合处理的特点,故我们选型大数据领域广泛使用的Flink来实现数据预聚合Recording Rules。 </p><p>整体架构为:vmagent将采集上来的原始数据双写,一份直接写出到存储层,另一份写出Kafka中,由Recording Rules消费,经过滚动窗口聚合后,写出到vmstorage中。方案如下图: </p><p>经预聚合,大维度查询RT从数秒降低到毫秒级。</p><p><img src="/img/remote/1460000044543006" alt="" title=""></p><ul><li><strong>查询代理Proxy</strong></li></ul><p>经过数据预聚合的数据需要与原始数据隔离,metric名称、label都会发生变化。</p><blockquote><p>比如我们有聚合前原始数据gateway_call_code_total{application="app1",cluster="cluster1",host="host1",env="online"},按集群聚合。 </p><p>按集群聚合后host这个label即丢掉,且为了隔离,表名添加前缀后变化为cluster_gateway_call_code_total{application="app1",cluster="cluster1",env="online"}。</p></blockquote><p>用户若要在查询时使用聚合数据需感知聚合规则,比较不便。为解决这个问题,我们自研了查询代理Proxy,与聚合配置联动,为用户提供统一的数据查询接口,查询请求经过查询代理时直接优化修改用户的PromQL,将原始数据查询转为聚合数据查询、检测聚合数据正确性等。</p><p><img src="/img/remote/1460000044543007" alt="" title=""></p><h4>Flink聚合任务数据稳定性建设</h4><p>在设计我们的Flink任务Recording Rules过程中,也引入了一些新的问题,以下是一些重点问题的解决方案。</p><h5>任务发布、Failover的处理</h5><p>当Flink任务有需求变更、或底层资源导致的Failover,会发生任务重启,导致聚合停止。重新拉起服务时,从Kafka当前位点继续消费,无法完整拿到当前这分钟的完整数据,上一分钟的数据也可能未完全写出,故会造成数据丢失和错误。 </p><p>时序监控数据的丢失、错误会直接影响到告警、问题排查,需要尽量避免。考虑到时序数据量级大,Checkpoint存储成本高、效率低,我们采用记录Kafka位点,重启时将位点向前重置、重新计算的方式。在数据处理时,定期将当前处理到的kafka timestamp offset记录下来,重启时向前推至少2个聚合间隔。offset前推引入的数据重复问题,我们借助vmstorage自带数据去重处理。 </p><p><img src="/img/remote/1460000044543008" alt="" title=""></p><h5>Flink任务内部序列化优化</h5><p>我们的聚合数据量极大,超过了250万+QPS,且对实时性要求高,若通过简单扩容去支撑该量级,需要的IT资源过高,故需要提高任务效率。通过火焰图抓取可以发现,我们的任务花费了大量开销在Function之间的序列化上,我们的数据是JavaBean,其中包含泛型的HashMap,会劣化为性能最低的Kyro序列化。我们重新抽象了数据结构,将其设计为Flink原生的Tuple类型,其中只用基本数据类型。在同样的数据源和运行环境下对比,序列化开销从54%降低为15%(下火焰图中紫色部分为序列化),在物理资源不变的基础上,任务支撑处理的输入QPS扩大数十倍。</p><p><img src="/img/remote/1460000044543009" alt="" title=""></p><p>以下是Flink官方提供的各序列化的效率对比,可知Tuple序列化对比Kryo有巨大提升。 </p><p><img src="/img/remote/1460000044543010" alt="" title=""></p><h4>踩坑解决:Counter数据预聚合值下降导致Increase值突刺</h4><h5>问题背景</h5><p>采用预聚合的方案会遇到以下问题:目前我们的数据聚合主要是针对Counter做求和聚合,Counter的特点是在同一数据源上是累增的,若要获取一段时间内的值,需要用区间末尾减掉区间开始。 </p><p>我们若按照集群聚合,第n分钟该集群发布,则会有服务的Counter被重置为0,导致整个集群的聚合值下降。若此时我们用PromQL的rate或increase函数查询发布这一分钟的值,存储层会用n分钟的值减n-1分钟的值,但此时n分钟的值大于n-1分钟的值,即小值减大值。此时存储层会认为该Counter被重置,基数应当为0,则变成n分钟的值减0,得到n分钟的值。由于集群发布前大概率已经累计了很长时间的Counter,此时n分钟的值可能非常大,会导致这一分钟的increase结果非常大,展示在图表上为一个超大的突刺。 </p><p>若要在预聚合中像查询时聚合一样,在rate时对每条被聚合的原始数据一一检测counter重置,那么则需要存储每条原始数据的前值并一一检测,如此存储成本和计算成本都很高,所以我们需要其他方法来规避掉这个问题。 </p><p><img src="/img/remote/1460000044543011" alt="" title=""></p><h5>解决方案:通过查询代理Proxy实现聚合数据正确性检测</h5><p>前文的问题背景介绍中已经介绍过,Counter的聚合数据在遇到increase查询时会发生超大的突刺,我们想到在查询时检测和屏蔽这种情况。我们自研的Proxy查询代理,本身的功能是自动解析修改业务的PromQL,将普通查询转为原始查询,我们设计在这个转换过程中检测数据正确性。 </p><p><img src="/img/remote/1460000044543012" alt="" title=""> </p><p>通过此方案,我们解决了该问题。 </p><p><img src="/img/remote/1460000044543013" alt="" title=""></p><h3>2.3 Metric与Trace关联分析</h3><p>为关联Metric和Trace,我们设计了关联表,单独上报存储。我们从Metric关联到Trace时,先通过Metric的label、value、时间范围查出TraceId列表,随后查出对应的Trace详细信息。 </p><p><img src="/img/remote/1460000044543014" alt="" title=""> </p><p>在APM平台设计上,我们将Metric数值做成了可点击的按钮,用户点击即查询出关联到的TraceId列表,进一步点击可看到详细内容。 </p><p><img src="/img/remote/1460000044543015" alt="" title=""></p><h3>2.4 高效的Metric监控可视化与图表分析能力</h3><ul><li><strong>Metric可视化</strong>:我们使用Grafana来可视化Metric数据,设计了大量直观的Dashboard,维度包括应用总览,各组件如HTTP、RPC、Redis、数据库、MQ等的总览、异常、错误、请求执行的图表。如以下为某服务的请求总览Dashboard,用户可直观看到总量、P99、异常率、平均耗时、错误码、线程池等信息,非常方便。</li></ul><p><img src="/img/remote/1460000044543016" alt="" title=""></p><ul><li><p><strong>图表分析能力</strong>:在日常故障排查中,经常需要进行时间跨度和实例之间的比较分析。我们选型的Grafana虽然对时序数据的可视化支持很好,但对图表比较分析的支持较弱。因此我们对Grafana做了二次开发,支持了以下功能:</p><ul><li>环比分析:支持用户对监控项跨时间段比较;</li><li>多实例比较:支持用户同集群内的同监控项跨实例比较,还支持按照不同的数据指标排序、查看TopK的实例等;</li><li>指标分析:帮助研发一键计算曲线的数据指标,方便数据统计方面的需求。</li></ul></li></ul><p><img src="/img/remote/1460000044543017" alt="" title=""></p><h2>三、总结</h2><p>基于VictoriaMetrics的Metric监控目前已经在云音乐各业务线全面推广,目前支撑活跃时序近7亿。其带来的优势如下:</p><ul><li>Metric与Trace关联排障,打破信息孤岛;</li><li>应用层监控能力提升:补足应用层各维度Metric监控数据可视化,应用观测能力明显提升,可直接产出P99等指标,问题定位能力强;</li><li>大规模业务低成本Grafana可视化:利用Grafana的低代码配置,省去大量开发成本;</li><li>低成本解决大规模时序数据存储:基于VictoriaMetrics的存储方案成本低、性能高,经对比所占用资源仅需如M3DB等方案约三分之一。</li></ul><p>在未来我们将持续拓展监控能力,在智能分析、智能告警等方向持续深挖,为业务发展保驾护航。</p><h2>最后</h2><p><img src="/img/remote/1460000044543018" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=QHb%2FitN5ABYQUH7pi7xjOA%3D%3D.FG3iALovQBGHftgojRKKbxik0wqyBJQwFz3qmE%2F%2FK88%3D" rel="nofollow">https://hr.163.com/</a></p>
X6 在云音乐低代码流程编排中的实践
https://segmentfault.com/a/1190000044525415
2024-01-04T09:33:54+08:00
2024-01-04T09:33:54+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=1eM%2BciEXWBBiGErDudLSXQ%3D%3D.HG65bf7FaqWqtEYK2nqNB4OpetJ1FT2du76nMCdH0K8%3D" rel="nofollow">辰木</a></blockquote><p>本文通过介绍了当前云音乐 BFF 研发模式现状,阐述了对当前研发现状的一些思考以及总结了 X6 在低代码流程编排中的实践历程;通过阅读本文,可以快速了解和学习如何通过 X6 构建符合业务诉求的可视化流程编排产品。</p><h3>背景</h3><p>目前云音乐已经建立了基于 GraphQL 的 BFF 研发模式,具体介绍参见<a href="https://link.segmentfault.com/?enc=1Gfku1heiF2deE8qb4xWCw%3D%3D.U%2FyFTDwvERZZZBPEBrY7ocvuCnch%2FPqDQkyjKHM7JLAbDotYZ%2FdZ32Q2RGyx5XOO" rel="nofollow">《基于 GraphQL 的云音乐 BFF 建设实践》</a>,在探索前后端协同的 BFF 研发模式上,起到了一定的作用和影响。然而,这种研发模式并未解决业务侧研发人员的核心痛点,同时又引出了一些新的问题,主要体现在以下几方面:</p><h4>BFF 场景下业务逻辑的编排诉求</h4><ul><li><strong>逻辑编排能力低</strong>:由于 GraphQL 是用于 API 查询的 DSL,比较侧重数据聚合和选择;当存在一定的业务逻辑时,诸如对输入参数、输出结果做相应的处理,需要增加逻辑判断等,只能在 Groovy 脚本中实现;由于脚本实现<strong>没有相应规范</strong>,很容易导致大量业务逻辑会通过脚本实现,造成<strong>脚本滥用</strong>。</li><li><strong>资源开销成本高</strong>:不论是测试接口还是业务实现接口,都会产生机器资源开销,目前存在大量无流量应用和实例,造成<strong>资源浪费</strong>。</li><li><strong>交互复杂,上手成本高</strong>:由于产品概念较多且<strong>交互复杂</strong>,中间实现链路较长,导致<strong>接口调试不便,排查问题困难</strong>。</li><li><strong>角色分工不清晰,服务治理能力弱</strong>:前端研发人员主要负责 API 接口搭建、自测,从而完成接口的交付;但在引擎服务集群稳定性、流量水位方面缺少评估经验,而这部分内容服务端研发人员相对更擅长点;另一方面,由于前端和服务端研发人员需要关注的领域和内容不同,一些运维操作通常都需要跨平台使用,导致<strong>服务治理困难</strong>。</li></ul><h4>服务的沉淀与复用</h4><p>在活动玩法场景下已存在简单的轻量级流程编排能力,但由于接入复杂、平台使用困难,目前使用并不广泛;与此同时,在该场景下一些固化配置和逻辑处理也并未沉淀成为服务资产,导致相应业务逻辑的复用能力较低。</p><h4>全链路低代码建设</h4><p>另一方面,云音乐已经构建了 Tango 低代码搭建平台,具体介绍参见<a href="https://link.segmentfault.com/?enc=GYefnWervgLG9T1CTsSQgA%3D%3D.jnorQ5U1sNhg%2F6pvk5KGP7IRRF4w0Dbilpfle2eEdb3zdKUgGHCj2WFaHho7izaY" rel="nofollow">《网易云音乐 Tango 低代码引擎正式开源》</a>。Tango 在 UI 层已经极大地提升了需求交付和研发效率,但在基础逻辑编排、基础服务编排、乃至模型驱动 UI 的基础资源编排方面还是一片空白,构建服务端低代码产品,建立全链路低代码研发模式是重要建设目标。</p><h3>思考</h3><p>BFF 的应用场景是根据当前业务需要,对多个微服务接口返回的数据进行组装,会承载一些业务逻辑判断或数据格式转化,方便客户端(包括:PC Web,H5,App,小程序等)消费的架构模式,其主要是为了解决多访问终端业务耦合问题。</p><p>云音乐当前的 BFF 的研发模式只是交付 C 端部分业务场景的方式之一,其他 C 端场景以及大量 B 端场景也或多或少都存在相类似的诉求,但交付需求的方式依旧是传统的研发方式。相比较前端可通过组件、模块、页面模版、样板间、微前端架构等众多方式快速复用 UI 和交互能力,服务端想沉淀和快速复用一些服务资产时,存在诸多不便。这些不便主要体现在:接口实现规范较多,统一实施难以落地;微服务较多边界较模糊且占用资源不一,一些场景下又需要将多个微服务合并成一个微服务。</p><p>那么在云音乐当前研发现状下,有没有一种方式,即能实现自由组装服务资产,清晰地表达服务间依赖关系和对应业务场景逻辑;又能通过一定的手段沉淀和复用服务资产,在给定输入值后可自动调用依赖服务输出结果;与此同时,还可搭配 Tango 低代码搭建平台,在整体研发链路上进一步降低开发成本,提升交付质量和效率。</p><p>答案是存在的,那就是:<strong>基于流程编程 (Flow-based programming)</strong></p><h4>关于 FBP</h4><p><a href="https://link.segmentfault.com/?enc=OPAUaW3qUcFdigILhQCK8A%3D%3D.TaxK30MqrBO1XpGr%2FZfxHF6wREYy9U48jnlrDveYk2LD48WNpSRZVNKwFEn2xkf8HYBRLeXQS7%2F8MB7x%2BQ1LDk0KaehVtuVF5hgTfB3uoEmWyRW9y9K6HzGbQATFF%2FyP" rel="nofollow">基于流程编程</a>是一种特殊形式的<a href="https://link.segmentfault.com/?enc=N8Q%2FY2KOaoaouq7RiKsJPA%3D%3D.CMO87Jbb8Ya46MD2eR2yDbUDp7pV2COMAEULhXng4JE0oq7nj%2FkwfGuXA%2BZi5wYPBoN0SP8YYZ7zQM6XaDPvhVVsLVru%2BGDULnPhGec12ajsJIq4TcKsV1lfFiwEU1Vz" rel="nofollow">数据流程编程范式</a>,其可以将程序表达为具有输入和输出的有向图,图内每个节点具备一定的中间运算过程,并通过特殊逻辑关联将节点衔接起来,当给定输入时,就会自动执行并输出结果。</p><p>典型的 FBP 程序图表达如下图所示:</p><p><img src="/img/remote/1460000044525418" alt="FBP 程序图.png" title="FBP 程序图.png"></p><p>通过对 FBP 程序的图表达分析不难发现,这种有向图包含着明确的输入和输出节点、中间依赖节点、以及节点间连接关系,其是可以通过流程图的方式表达的。</p><h3>解法</h3><p>为了解决当前研发现状所面临的诸多问题,基于 FBP 的理念和流程图可视化编辑能力,云音乐公技低代码团队发起了 TangoFlow 项目。项目旨在通过组装式架构,整合云音乐服务端技术栈,提供基础逻辑编排能力,以网关 API、统一 SDK 等方式暴露编排结果;从长远目标来看,期望构建符合云音乐研发现状的服务端低代码平台,结合 Tango 搭建平台在提升需求交付效率和吞吐率、降低交付成本的同时,建立起完整的全链路低代码研发模式。</p><h4>架构设计</h4><p>我们期望开发者在平台创建好流程后,借助可视化搭建能力对服务资产进行编排组装,通过接口将图信息传给服务端;服务端得到图信息后再将其转化为 DSL 并发送给流程引擎,引擎在得到 DSL 后会自动解析和执行并以特定方式暴露编排结果,从而提供给客户端消费,用户使用流程大致如下:</p><p><img src="/img/remote/1460000044525419" alt="用户使用流程.png" title="用户使用流程.png"></p><p>那么基于以上思考、期望目标以及用户使用核心链路,我们明确了 TangoFlow 的产品架构,整体设计如下:</p><p><img src="/img/remote/1460000044525420" alt="TangoFlow 架构.jpg" title="TangoFlow 架构.jpg"></p><h4>技术选型</h4><p>构建基础逻辑编排能力,首当其冲是要实现可视化的流程图编辑能力。在对比了社区众多相关流程图编辑产品后,决定使用蚂蚁开源的 <a href="https://link.segmentfault.com/?enc=2wd%2B5q0XelqdvXirjtpTBA%3D%3D.f6jCnrRMBYasMTLZu8lqtzAdGx5ilQ8tOq9tyyGBTnI%3D" rel="nofollow">X6 图编辑引擎</a>,其主要有以下优势:</p><ul><li>核心功能稳定,持续迭代并完善自身能力</li><li>开箱即用,组件和插件完备,便于定制,也可通过相应注册机制灵活扩展能力</li><li>事件驱动,有完备的事件机制来处理相关交互逻辑</li><li>数据驱动,支持图内的节点和关系的序列化和反序列化</li><li>丰富的案例实现,可快速查看和在线调试运行</li></ul><h4>产品设计</h4><p>由于流程编排本质上是抽象输入、输出、服务为节点组件,通过可视化拖拉拽将这些组件按流程图方式组织,从而完成对应的逻辑表达需求。鉴于社区不乏相关优秀的产品,比如 <a href="https://link.segmentfault.com/?enc=wDXSGqiNflbZO6SRydj1MQ%3D%3D.WLgqn95t3aEAsMAC4qPm%2Fv6Dox5MJJG1nYV5Nc4%2FHw4c03A6zpQ%2Bt4G7RDPHwBmGTt3ncAtRWvgZfXlD3esSNA%3D%3D" rel="nofollow">XFlow</a>、<a href="https://link.segmentfault.com/?enc=J0PQHMMsWFGezsUQBLY6aQ%3D%3D.%2BHSCEqdCx2%2B%2BQSRVlEYm0ue4viwQeWVwdKGalGlF9ydbnzlGp%2FDWk1YwXhAVimDK" rel="nofollow">LogicFlow</a>、<a href="https://link.segmentfault.com/?enc=GuUhr6JyL%2BEWzu86Ba6Uyw%3D%3D.vECOJ5LO%2FMpB%2FZA9q4Fwv3F3XpvIW9gZdLg%2BOPimv4M%3D" rel="nofollow">ProcessOn</a>、<a href="https://link.segmentfault.com/?enc=kzZQLQj7Gd8j2p8Q%2Fjc8mQ%3D%3D.dRff6sW%2Bswatz1pQ6I88YOrfb0ACsyaQSwR4e%2FAHUDs%3D" rel="nofollow">Figma</a>、<a href="https://link.segmentfault.com/?enc=hD%2Fb%2B7Kh0g3kTzjiu9q0kQ%3D%3D.HTmCL0kjd50IH33rvAzOGSQnvy4QDEWDVujX8qWyZM8%3D" rel="nofollow">语雀</a>、<a href="https://link.segmentfault.com/?enc=y3lAd80lC7LESCgMc0SuRw%3D%3D.wGCgaPdb7XKL%2Fn63zPg0lillvqwsXuOXEhGfYO2xXZ4%3D" rel="nofollow">ioDraw</a>等, 通过对这类产品的抽象和总结,最终确定 TangoFlow 的可视化编排界面结构如下图所示:</p><p><img src="/img/remote/1460000044525421" alt="编排产品设计.png" title="编排产品设计.png"></p><h5>顶部导航</h5><p>顶部导航需要展示一些核心的信息,同时也需要承载一些核心操作以及其他跳转入口,主要体现在以下几方面:</p><ul><li>核心信息:所属应用、编排的流程、分支信息等</li><li>核心操作:分支切换、撤销/重做、画布缩放、 保存、发布等</li><li>其他入口:回到首页、前往 APM、问题反馈等</li></ul><h5>物料</h5><p>物料面板不仅需要显示有哪几类、哪些节点组件,同时也需要节点组件具备拖拽至画布内自动添加和显示的能力。通过对流程图的抽象分析,我们将流程中包含的节点分为以下几大类:</p><ul><li>触发器:流程对应的输入节点,其主要是暴露网关服务端 HTTP 服务</li><li>逻辑控制:一些常见的逻辑表达,如 if-else、switch、for 迭代逻辑等</li><li>基础服务:适配云音乐的服务资产,如:RPC 接口、Groovy 脚本、网关 API 接口等</li><li>数据结果:流程对应的结束节点,其主要是控制 BFF 服务输出的内容格式和数据结果。</li></ul><h5>画布</h5><p>画布作为流程编排的核心能力,不仅需要提供流程节点的展示、节点组合嵌套、节点连接关系表达外,同时也需要承载一些快捷交互能力,诸如节点信息编辑、节点菜单、画布菜单、边标签设置等。</p><h5>属性设置</h5><p>通过对流程中涉及每个节点的属性抽象,产出相应的节点属性配置;由于每个节点所对应的属性配置是不同的,每个属性在设置时所需的 UI 组件也不尽相同,这就要求属性设置是一个动态表单,且需要具备足够的灵活度以及扩展能力。</p><h5>控制台</h5><p>在对流程进行调试时,控制台区域不仅需要展示请求入参和输出结果,也需要展示引擎运行 DSL 时的调用过程,方便在调试出错时能快速定位具体是哪一部分发生异常。</p><h4>相关实现</h4><p>基于以上产品设计和一些核心功能要求,最终的的 TangoFlow 的编排界面展示如下:<br><img src="/img/remote/1460000044525422" alt="编排实现.png" title="编排实现.png"></p><h5>节点属性</h5><p>得益于 X6 的强大能力,很简单通过 json 配置便可实现一个节点的样式及相关交互能力。一个 IF 节点的 json 配置如下图所示:</p><p><img src="/img/remote/1460000044525423" alt="IF 节点配置.png" title="IF 节点配置.png"></p><ul><li><a href="https://link.segmentfault.com/?enc=%2FicqUsoUzhs6zmfTwgt72g%3D%3D.FP7rXgtqykzUgtFThZci8WVndaJcB2tlWPZtrAbh520qR2KUC3bncHfaCkk%2FJrM058DSemIB1XttxB9tXhSnGg%3D%3D" rel="nofollow">markup</a>: 指定了渲染节点时使用的 SVG 片段,表明在该片段存在那些标签元素</li><li>width:节点在画布中的宽度</li><li>height:节点在画布中的高度</li><li><a href="https://link.segmentfault.com/?enc=2rCUOWRzK1dlclc23dzZsA%3D%3D.Ka%2B9OyNWeZGjnVPsCjDp8jJqi1gNpYxjHdi70UY51FuFcqLKHqhAzASK7yt8rhn1FYF3lKXej%2FyTWmTJ3z%2FAcQ%3D%3D" rel="nofollow">attrs</a>:对 markup 中定义的元素选择器的 SVG 属性描述</li><li><a href="https://link.segmentfault.com/?enc=VIRK8frmovQkL9k1eJRPsQ%3D%3D.vVbBhy5uVyDuCKF5qDoayIT3ee%2FW8TnHvypdFksPhr%2BuWQhyJ1UK4PYJZO4iGJejtRE8npAEOdkSjd2FuB98wg%3D%3D" rel="nofollow">data</a>:与节点关联的业务数据,主要是抽象出来的节点名称、类别、业务属性等,可通过 props 透传给属性面板使用</li><li><a href="https://link.segmentfault.com/?enc=smjHhDgd0y8Q3uIxCKkc2Q%3D%3D.wLycRvor2nOC5cf88ayG0x8umqMljdz%2BHFI3W3eF%2BwdD60x1QV1WjriqRTPlxMJyLcrC2bkJYObIEs7WLnOZRQ%3D%3D" rel="nofollow">ports</a>:连接桩,即节点上的固定连接点</li><li><a href="https://link.segmentfault.com/?enc=JWuYxCJh%2FV990Ybc2%2BWshg%3D%3D.8ZNBlBi9%2BursExhQ7BxJ4VULzjchb6jG%2Fwp3JXB%2F%2BsVVAHhFkVOwTK%2B%2FiEM8RFM9yGcJTgUVCxECm1ZlDMRVcg%3D%3D" rel="nofollow">tools</a>:节点工具,可以增强节点的交互能力</li></ul><p>通过举一反三的方式其他节点的配置也是如此,整体便可组成所需节点的属性配置 list 数据;在明确节点属性后,便可使用注册方法来注册画布所需的节点了,画布组件的实现和节点注册示例代码如下:</p><p><img src="/img/remote/1460000044525424" alt="节点注册.png" title="节点注册.png"></p><h5>节点拖拽</h5><p>由于 X6 已提供了 <a href="https://link.segmentfault.com/?enc=WCQ5Bt1Sv4hI0JQsB2WvPQ%3D%3D.xWnI7SJz14W1KnNHmVEXj2yQzqLvWQHPBnFfPMf8iCQoG6o6R5gNoQozoStGaYqXXAr5JuCure5tEt3fDuy%2Bzg%3D%3D" rel="nofollow">Dnd 插件</a>,可快速实现往画布内拖拽节点自动显示的交互方式。整体实现思路是:在拖拽事件触发时,需要先调用创建节点方法,再通过节点对象进行节点属性修改和业务数据设置,最后调用 dnd.start() 方法。核心实现代码如下:</p><p><img src="/img/remote/1460000044525425" alt="节点拖拽.png" title="节点拖拽.png"></p><h5>节点/画布菜单</h5><p>当需要鼠标右键点击显示节点菜单时,可通过往节点添加<a href="https://link.segmentfault.com/?enc=MyFnZmEefvAWhVbnjaiRDw%3D%3D.%2BHD8p0zkhjAUaSQw8KrySj1pDNIeu3mVN9n%2BRPtp7ElWoCnIFYHvEjBWhyivlCMbep7R13Fk%2B%2BvsCpIyQYVe3At1KZc%2BGJmFIGjIAKbNtNw%3D" rel="nofollow">自定义工具</a>实现,效果图如下:</p><p><img src="/img/remote/1460000044525426" alt="节点菜单.png" title="节点菜单.png"></p><p>实现自定义工具的思路如下:</p><ul><li><p>Canvas 组件内设置固定的 dom 节点用以渲染相关内容,部分示例代码如下:</p><p><img src="/img/remote/1460000044525427" alt="节点菜单 dom.png" title="节点菜单 dom.png"></p></li><li><p>实现自定义菜单,可通过继承 ToolsView.ToolItem 来覆盖内部一些具体逻辑,核心实现代码如下:</p><p><img src="/img/remote/1460000044525428" alt="NodeToolMenu.png" title="NodeToolMenu.png"></p></li><li><p>在注册节点时,注册节点工具,示例代码如下:</p><p><img src="/img/remote/1460000044525429" alt="节点工具注册.png" title="节点工具注册.png"></p></li><li><p>配置节点工具 tools 属性,示例参考:</p><p><img src="/img/remote/1460000044525430" alt="节点 tools 配置.png" title="节点 tools 配置.png"></p></li></ul><h5>节点提示</h5><p>同样当需要鼠标 hover 显示节点提示信息时,也可通过往节点添加自定义工具实现,实现思路与实现节点菜单一致,此处不做过多赘述,展示如下:</p><p><img src="/img/remote/1460000044525431" alt="节点提示展示.png" title="节点提示展示.png"></p><p>实现节点提示时需要注意以下几点:</p><ul><li><p>Canvas 组件内设置固定的 dom 节点用以渲染相关内容,示例代码如下:</p><p><img src="/img/remote/1460000044525432" alt="节点提示 dom.png" title="节点提示 dom.png"></p></li><li>由于是自由画布,需要考虑提示框的显示位置,可相对于节点位置来显示。</li><li>需要注意节点鼠标进入和节点鼠标移出与节点鼠标按下、节点连接桩移入等其他事件的影响。</li></ul><p>节点提示组件的核心实现代码如下:</p><p><img src="/img/remote/1460000044525433" alt="节点提示实现.png" title="节点提示实现.png"></p><h5>节点组合设置</h5><p><a href="https://link.segmentfault.com/?enc=XtoG1qixBTvGLXJalFuvAA%3D%3D.DDgINuM6zNvHu9M9nk0xJcEv6TZtdHOwj5p2ssbslSKDxl08Df0AxIjVX0PP6O4WOyreNB3GE9kqgH7oMaLPez70q9apKTuvpkEKTmah69A%3D" rel="nofollow">embedding</a> 是实现节点组合嵌套的核心,具体配置可参考文档说明;当需要检查节点是否允许被组合嵌套时,可通过 <a href="https://link.segmentfault.com/?enc=rBZTuHyD6FPcndBSIQaNfg%3D%3D.qtlLeGpeHtysVIRWNj%2BZLUZcMYCLCO0JvLHUeO9LDHaxoJXXEf64cxDDIKVIUoV2Ocjt4%2F6T14Enrj1lvWl6Tg%3D%3D" rel="nofollow">validate</a> 方法实现,实现代码如下:</p><p><img src="/img/remote/1460000044525434" alt="validate 实现.png" title="validate 实现.png"></p><p>以下两类逻辑控制节点便是借助组合嵌套实现:</p><ul><li><p>Switch 判断:有固定的 I/O 连接桩,通过属性配置增加 case 枚举条件,仅支持嵌套 If 和基础服务节点,展示如下:</p><p><img src="/img/remote/1460000044525435" alt="Switch 示例.png" title="Switch 示例.png"></p></li><li><p>For 迭代器:有固定的 I/O 连接桩,可指定迭代对象、对象类型以及迭代返回值格式,支持嵌套 If、Break、Contine 和基础服务节点,展示如下:</p><p><img src="/img/remote/1460000044525436" alt="For 迭代器.png" title="For 迭代器.png"></p></li></ul><h5>边标签设置</h5><p><a href="https://link.segmentfault.com/?enc=QdckqPmVr07a0CAQwAdh1Q%3D%3D.fd%2BrJXarC1MQbVINrPTmq7wUGshdPQCcL%2FaQcvZOeyJvLgJQ56ZQx%2BezKl2tyoeh" rel="nofollow">边标签</a>设置是基于内置的 <a href="https://link.segmentfault.com/?enc=4Ug7uu4krPZRYNDYTohH1A%3D%3D.S4cz9X0yTtUvfXfbgCbHIEVXNcJl%2BQ0%2Fq5%2BzFFyO3IhajaqYL2O5YnOQMz8VJqRILvk9Ax9wDzPcPOaj0TCEJw%3D%3D" rel="nofollow">edge-editor</a> 实现的,通过双击边可自动添加边标签;不过由于该小工具并未判断限制,鼠标双击可创建多个标签;如果想每次只修改和保存一个,可在对应的 setText 方法内先删除再添加,示例代码如下:</p><p><img src="/img/remote/1460000044525437" alt="边标签设置.png" title="边标签设置.png"></p><h5>连接检查</h5><p><a href="https://link.segmentfault.com/?enc=%2BkKWA0UUatQIJYhh0jmGdw%3D%3D.%2F16eNw8gGGNnzaUtjcOE2iSG2QYpcZy4fwgFi4tqF%2FEhaVO0RbXJLFIZKQoPJYCL%2FnhKkHZ346wo8xU1xZgAE5Bwv1aWTLXK6y6yewjAw3s%3D" rel="nofollow">connecting</a> 是实现节点连接交互的核心,具体配置可查阅相关文档,当需要对节点间是否允许连接时,可通过 <a href="https://link.segmentfault.com/?enc=IYl8WYA27c2%2F8K5zQ0zD0g%3D%3D.dEzQ3KYRxjO5TPxaltCxbh4wqj%2FCYsp%2Fm2m1%2FjZwLULHy9ToLan0X3cGYeLbHrjpu8Y%2F6w%2B0LGbYJubint6mrQ%3D%3D" rel="nofollow">validateEdge</a> 来实现,避免一些非法连线如:节点回环、从输出到输入等,实现代码如下:</p><p><img src="/img/remote/1460000044525438" alt="validateEdge 实现.png" title="validateEdge 实现.png"></p><h5>属性面板</h5><p>在产品设计章节有提到属性面板需要具备足够的灵活度以及扩展能力,鉴于 <a href="https://link.segmentfault.com/?enc=nVf51GN7ZaXa27wAUFCvGA%3D%3D.UWKQFhVB2FqpxqxMipT37Prqg%2BQYIaFt6BZObcXJuLOgNLJFsQJ9%2B3bg6jMxnqwp" rel="nofollow">Tango</a> 已经实现相关属性表单能力,此处可直接引入使用,具体使用和实现方式参见 <a href="https://link.segmentfault.com/?enc=KUdDegLgxAxcIqzFiNHsug%3D%3D.nRKEpMM2uJVvWIvK0VrBLf64lNEmU4XZRk7pbE5zgAKvymfL87DuDFFg0X%2BNRcFvqHYuHRffif9kEkUwgo9jwx39Faq191LKicHt%2B0dKm6Y%3D" rel="nofollow">setting-form</a></p><h5>调试能力</h5><p>流程在搭建完成后,还需要在线调试和验证。实现调试的思路是:借助 <a href="https://link.segmentfault.com/?enc=VP6UC5VJiDjNLAt00NKAKA%3D%3D.5ZJxzrwU7HyTkN%2Fo33v8hGAsQCzjfnDtpyaz%2FoH9YHH24zmhawfPBNUN9q%2FBhLsD5FcaaODmIp2hKitlOBRmyYj1Jeb%2F1l5ynWl%2F0qHsHeU%3D" rel="nofollow">graph.toJSON()</a> 方法导出节点和边的 json 格式数据,在通过接口将这些节点数据传递给服务端,服务端拿到 json 数据后转化为 DSL 并执行,从而实现流程的在线调试能力。目前,TangoFlow 支持了以下三种方式的调试能力:</p><ul><li><p>流程调试,通过设置输入参数或者请求头信息,对整体流程进行调试,界面展示如下:</p><p><img src="/img/remote/1460000044525439" alt="流程调试.png" title="流程调试.png"></p></li><li><p>节点调试,对当前的服务节点进行在线调试,页面展示如下:</p><p><img src="/img/remote/1460000044525440" alt="节点调试.png" title="节点调试.png"></p></li><li>远程调试,即指定某一环境对应的集群内机器后进行流程调试</li></ul><h5>Mock 机制</h5><p>mock 的能力主要是为了方便在线调试,确保流程在调试过程尽可能的暴露问题和快速通过测试,在进行调试时设置的 mock 数据也会一并发送至服务端,服务端在进行 DSL 转化时,会自动读取 mock 数据并写入 DSL 内。</p><p>对于基础服务节点来说应具备请求参数 mock 和响应 mock(固定返回 mock 值),而对于输出节点只需具备响应 mock 即可,在开启 mock 能力后节点会自动显示 mock 标记,页面展示如下:</p><p><img src="/img/remote/1460000044525441" alt="mock 机制.png" title="mock 机制.png"></p><h5>发布卡点</h5><p>针对流程每次的发布,我们制定了严格的发布部署模型;流程发布会经过开发、回归、卡点、预发、线上、完成这六个阶段;在开发和回归环境,可以存在多个分支发布部署;当一个分支需要发布上线时,需要先经过卡点环节的各项检查;只有卡点环节通过,才会被允许进行预发和线上环境的部署,并且流程线上发布的通道内只能存在一个分支;在线上环境部署后,线上环境测试通过,点击完成即可结束当前分支的发布生命周期。</p><p>通过此发布部署模型,确保了流程发布的稳定性,整体实现如下图所示:</p><p><img src="/img/remote/1460000044525442" alt="发布卡点.png" title="发布卡点.png"></p><h3>总结</h3><p>以上是借助 X6 在构建云音乐低代码流程编排能力时的一些实践历程,其强大的图编辑和自定义能力,使得可快速实现符合业务需要的流程编排诉求。在实现某一具体场景的编排产品时,个人觉得需要注意以下几点:</p><ul><li>明确编排能力的核心诉求,并确定优先级</li><li>多参考和分析其他类似的优秀产品相关能力,然后多尝试用一些设计工具实现</li><li>做更多的技术预研,分析各个相关工具的优缺点,明确其擅长的应用场景</li><li>相比代码实现,前期的产品设计和技术预研至关重要</li></ul><h3>未来展望</h3><p>随着当前编排能力的趋于成熟稳定,在继续完善全链路低代码建设的同时,也会在 AIGC 方向探索更多的可能,不断地重塑产品能力,未来主要包含以下方面:</p><ul><li>继续整合服务端相关技术栈资源,借助编排的方式实现对相关资源的最大化利用</li><li>结合 Tango 完备的前端搭建体系,构建模型驱动 UI 的研发模式,进一步提高需求交付效率</li><li><p>集成 AIGC 能力,借助 AI Agent 能力根据用户的自然语言输入,自动识别用户意图从而完成一系列动作</p><h2>最后</h2></li></ul><p><img src="/img/remote/1460000044525443" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=S%2FNYHO2gKkOSLJUjGA8Rkw%3D%3D.2tjZHyRn8AW%2B4ae%2FXe5iKQ%2BfoGZFVhpFayWx9Z%2F3cU4%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐曲库读缓存实践分享
https://segmentfault.com/a/1190000044519889
2024-01-02T11:53:09+08:00
2024-01-02T11:53:09+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:伍佰(周斯航)</blockquote><p>云音乐曲库缓存经过多年的实践和改善,形成了一套自有的缓存使用体系,并取得了很好的效果。本文将以实战为主,介绍曲库缓存设计的动机和思路,帮助读者了解背后的原因,并在其他场景中借鉴相似的思路。</p><h2>背景知识</h2><h3>缓存基础介绍</h3><p>缓存是系统设计中,用于提升底层系统访问能力的一种技术手段,它同样作用于云音乐的各个系统中,一种常用的缓存使用调用链路如下:</p><p><img src="/img/remote/1460000044519891" alt="" title=""></p><p>转化为时序图,如下图所示:</p><p><img src="/img/remote/1460000044519892" alt="" title=""></p><p>整个缓存的数据放入,是采用<strong>懒加载</strong>的方式,先取缓存,取到则返回,取不到则透过到下一层,拿到后会回写当前层的缓存,这是整个云音乐缓存使用的整体思路。</p><p>在正式进入实战之前,介绍一些概念数据:</p><ul><li>一次简单的DB操作,耗时在 0.5~0.6ms</li><li>一次简单缓存操作(非本机),耗时在 0.5~0.6ms</li><li>一次简单的本机缓存操作,耗时在 0.2~0.3 ms</li></ul><p>云音乐曲库读是整个云音乐服务中接口调用量最高的几个之一,曲库读整体服务的rpc峰值调用qps能够达到 <strong>50w+</strong> (双机房累加),通过多种缓存使用的尝试及调优,并最终从以下角度进行考虑并实践,得到较好的效果。</p><h3>曲库数据的特点</h3><p>很多中间件、组件等设计,在考虑设计时,都会朝通用化方式去实现,而契合业务场景的特点,则更能将性能做到极致,曲库的缓存实现,是与曲库数据特性有着深度的联系,具体如下:</p><ul><li>读多写少</li><li>可以读写分离</li><li>数据变化秒级延迟用户不敏感</li><li>热点数据集中</li><li>通过List(列表)获取数据的场景很多,有大量 MultiGet 操作</li></ul><p>有上述特点的业务场景,都可以参考曲库的缓存使用姿势。</p><h2>实战场景讲解</h2><h3>实战场景1:缓存的高并发保障</h3><p>日常对曲库读服务的高并发保障中,主要会遇到以下两个问题:</p><ol><li>歌曲(尤其是热门歌曲)发布时,短时间内会出现大量热点请求,此时由于数据冷启动,缓存没有存储对应数据,会有大量请求直接访问数据库,引起数据库压力瞬间增大。</li><li>针对预售但暂未入库的歌曲,上游有持续不断的请求,此时由于数据库和缓存都没有数据,导致请求都进入数据库查询,给数据库带来极大的压力。</li></ol><p>以下是曲库读缓存服务针对这两个问题进行优化的策略。</p><h4>场景1:保障热点数据的获取</h4><p>曲库将缓存服务分两级进行部署:在最靠近数据库层部署了一套分布式Memcache作为中心缓存,用于缓存歌曲数据;在曲库读服务的主机侧部署本地Memcache缓存,用于缓存最热门的歌曲数据。为了防止发布瞬间出现的缓存击穿现象,曲库采用了<strong>缓存穿刺</strong>的做法,具体做法如下:</p><p>对于缓存中的 Key-Value ,将每个Value变成这样一个对象:</p><pre><code class="java">public static class HoleWrapper<T> implements Serializable {
private long expire; // 对象的过期时间
private T target; // 对象本身
}</code></pre><p>即每个在缓存中的对象,都带上自身的过期时间,这样在获取对象的时候,就知道缓存是否快过期了,如果能得到这个信息,结合业务特点 <strong>对于秒级延迟不敏感、热点数据集中</strong>,则可以这么进行设置,在曲库,我们称之为 <strong>穿刺</strong> :</p><ul><li>通过 key 获取 HoleWrapper</li><li>查看 HoleWrapper中的 expire 是否 快过期(快过期:可以定义5min、1h)</li><li>如果是,当前线程将获取到的 HoleWrapper 的 expire 时间延长,并放入缓存(此操作耗时较少)</li><li>当前线程向下穿透到下一层取数据,并将最新数据进行更新</li></ul><p>时序图如下:</p><p><img src="/img/remote/1460000044519893" alt="" title=""></p><p><strong>穿刺</strong> 体现在步骤3中,此处不能完全杜绝击穿的风险,但由于缓存操作远远快于DB操作,这样产生击穿的概率就下降了极多;有了穿刺,对于热点数据就能很好的做好防护,并且<strong>qps越高、越热点</strong>,越能体现优势。</p><h4>场景2:数据库不存在的数据请求的保障</h4><p>如何保障数据库不存在的数据请求,是缓存优化中比较经典的“防穿透”问题,又一个简单而通用的思路:</p><pre><code>从缓存取不到的数据,在数据库中也没有取到,这时也可以在缓存中写入一个特殊值进行标记,缓存时间的设置可以视情况确定(如果主动清理可以设置长一点、否则短一点)</code></pre><p>由于这种做法比较通用,故而在曲库封装的缓存代码中,将其通用化封装,即对于下面时序图,第四步进行设置:</p><p><img src="/img/remote/1460000044519895" alt="" title=""></p><h3>实战场景2:缓存扩缩容</h3><h4>场景1:缓存容量够,但性能不够时,如何进行扩容</h4><p>在热门歌曲或大型活动期间,此时缓存的容量足够存储需要缓存的数据,但缓存本身的性能可能会出现瓶颈(例如缓存上限qps是20w,此时系统压力达到30w),此时会新增多个缓存集群,每个集群缓存同样的数据内容,以提升缓存的性能,本方法也被称为 <strong>横向扩容(Scale Out)</strong> 。</p><p><img src="/img/remote/1460000044519896" alt="" title=""></p><p>横向扩容需要考虑以下两个问题:</p><ol><li>如何保障多组缓存数据是一样的?</li><li>新扩展的缓存集群冷启动,如何防止大量请求打到db的问题?</li></ol><p>为了解决这两个问题,曲库的最佳实践是设计了一个缓存代理,所有的缓存操作均通过代理进行执行,代理对于缓存命令的执行形式为:<strong>随机读、顺序写</strong></p><ul><li><p>读</p><p><img src="/img/remote/1460000044519897" alt="" title=""></p></li><li><p>写</p><p><img src="/img/remote/1460000044519898" alt="" title=""></p></li></ul><p>通过这种方式,可以保障在一定的时间范围内,多个缓存集群缓存的数据能够基本一致。</p><p>在解决了一致性问题后,还需要保障扩容阶段的系统稳定性。此时我们通过配置缓存访问权重的方式实现缓存预热,短时间内只有很少的读请求能够进入新集群,由于代理顺序写的逻辑,在一段时间后,新集群会缓存足够多的数据,此时再通过修改代理配置,使新缓存能够提供读请求。</p><p><em>注:曲库提供的这套横向扩容的缓存方案比较适合“读多写少”的场景,在频繁写的场景下,由于需要频繁的更新缓存,本套方案的性能可能会降低。</em></p><h4>场景2:缓存性能够,容量不够时,如何进行扩容</h4><p>随着曲库数据量的逐步变大,缓存的占用量也越来越高,扩容缓存一个简单的做法,就是在单个缓存集群上增加更多资源,以提升缓存的容量。这种办法被称为<strong>纵向扩容(Scale Up)</strong>。</p><p><img src="/img/remote/1460000044519899" alt="" title=""></p><p>纵向扩容最可能出现的问题是由于节点增多,如果使用普通哈希算法存储缓存,如果只有一组缓存(大部分场景都够用),可能会导致扩容后缓存全部失效,此时会导致极高的系统风险。下图对风险进行了详细介绍:</p><ul><li><p>扩容前:</p><p><img src="/img/remote/1460000044519900" alt="" title=""></p></li><li><p>扩容后:</p><p><img src="/img/remote/1460000044519901" alt="" title=""></p></li></ul><p>为了解决这个问题,我们采用了一致性哈希算法来进行缓存的存储,通过这种方法,可以降低缓存集群内节点扩缩容带来的系统风险。本文不过度赘述一致性哈希算法的原理,感兴趣的读者可以参考<a href="https://link.segmentfault.com/?enc=Ezi%2FPVLygFti39Ti%2Fvuzkw%3D%3D.z1O%2FndnqDbrSn5fW73tfMlDL0RXeRwuH6Heh4NYx7svEFXhdfjcnDcKOPx5uIJ9p" rel="nofollow">5分钟理解一致性哈希算法</a>。</p><h3>实战场景3:缓存清理</h3><p>曲库数据的特点是读多写少,且可以接受数据变更后秒级的延迟。基于这种特点,我们设计了异步缓存清理的方案。其中在设计缓存key-value时需要遵循这样的原则:</p><ul><li>所有的缓存清理,由于曲库数据支持秒级延迟的特点,可以进行异步清理</li><li>所有的缓存清理,由数据库变更(binlog消息)消息触发</li><li>所有关联的Key,可以由单条binlog生成</li></ul><p>只要遵循这样的设计,曲库缓存的清理就可以变得比较轻巧,可以采用监听数据库binlog的形式进行异步清理。</p><h4>场景1:缓存数据出现变化时,如何保障一致性</h4><p>场景1是比较基础的缓存清理场景,在此不做过多描述,需要注意的是如果是多级缓存,需要从缓存的部署形式分析,按离数据库从近到远的形式进行清理。(例如监听数据库binlog后,先清理中心缓存,再清理本地缓存。)</p><p>曲库的最佳实践是只有清理中心缓存的服务直接监听binlog消息,在清理完中心缓存后再将消息转发到另一个消息队列,清理本地缓存的服务监听新的消息队列,这样就能实现有序清理缓存的目的。在清理本地缓存时,我们提供了一个清理sdk插件,嵌入曲库读服务,每个服务在启动时会实例化一个独立的消费者,这样虽然对业务有部分侵入,但由于每个消费者只需要清理本地缓存,曲库读服务的扩缩容会变得异常简便,也更适用于当前容器化部署的形式。具体流程图如下:</p><p><img src="/img/remote/1460000044519902" alt="" title=""></p><h4>场景2:缓存数据结构出现变化时,如何保障一致性</h4><p>如果某个缓存对象的数据结构发生了变化(例如新加了一个字段),此时需要把该类型对应已缓存的对象全部清理。</p><p>在这里,我们采用了一个简单做法:不去主动清理已存在的缓存,而是想办法把这部分缓存“失效”掉(线上服务访问不到)。主要的做法是利用了构建缓存key的生成器,在生成缓存key的时候添加一个“缓存版本”。后续如果遇到需要清理所有缓存的时候,只需要把缓存版本进行升级,就可以达到访问不到老缓存,重新从数据库获取数据的效果。</p><p><em>注:通过升级版本号的方案其实是无法精确清理所有缓存对象的情况下的trade off,升级版本号后,在发布服务时需要注意缓慢灰度发布,否则可能会造成大规模的缓存雪崩现象。</em></p><h2>总结</h2><p>以上,是曲库缓存使用的实践历程,涉及的细节较多,不同业务场景可以参考不同的考虑方式进行部分借鉴。</p><p>后续曲库缓存的发展方向,是将元数据中额MetaData数据与状态数据分开,并将MetaData数据进行纯静态化处理,结合业务数据变化的特点,将状态部分数据的降级等引入考虑,进行更深度的缓存使用。</p><h2>最后</h2><p><img src="/img/remote/1460000044519903" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=z28Xaa%2FLgUwcpqkTrakxwg%3D%3D.VWRjmY7T2sLgbe2CM%2F2QfaKbON4apCs4NqUFxHYI%2BSo%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐D2C设计稿转代码建设实践
https://segmentfault.com/a/1190000044514340
2023-12-29T10:23:56+08:00
2023-12-29T10:23:56+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=TKbqM7DYncAHgGkp0%2FMgWA%3D%3D.LDNF3%2FtrtfPj88Pl1nQNg0ga7Brv%2BttlYmvzO%2BBvRRs%3D" rel="nofollow">魏慷</a></blockquote><p>本文从 UI 研发的痛点出发,谈一谈网易云音乐在解决 UI 研发效率上的思考和实践,包括「海豹 D2C」产品研发中的方案设计与技术挑战,并介绍如何使用「海豹 D2C」实现高效的 UI 研发。</p><h2>背景</h2><p>在产品交付链路中,UI 研发的高效与否直接关系到产品的上线节奏。在网易云音乐,我们发现,随着业务的发展以及技术体系的升级和迭代,UI 的研发过程也渐渐暴露出了一些问题。</p><p><img src="/img/remote/1460000044514342" alt="流程图" title="流程图"></p><p>常规的一次产品交付链路中,涉及到 UI 研发的过程主要有:</p><p>设计师使用 UI 设计软件进行设计,将设计稿交付给研发工程师;研发工程师手动将设计稿中的内容还原为代码,并交给设计师走查;接着,设计师提出修改意见,再次由研发工程师修改代码。待走查完毕后,方可发布项目。</p><p>在这个过程中,会存在这些情况:</p><ol><li>设计稿本身<strong>无法完整描述设计意图</strong>,需要通过标注另行说明;</li><li>研发工程师<strong>对于设计软件不熟悉</strong>,遗漏了设计稿中的一些关键点没有还原到代码中;</li><li>因为项目时间紧任务重,研发工程师<strong>赶工式开发</strong>,导致 UI 还原度低。</li></ol><p>这些问题导致在 UI 还原为代码的过程中,设计师需要通过标注表达设计意图,在走查过程中效率低,一般需要<strong>重复多次</strong>走查,方能让研发工程师修复所有问题;</p><p>另外,对于设计师而言,由于彼此的分工不同以及一些历史债务,导致设计师的设计工具并不统一,像 Figma / MasterGo / PhotoShop 都有在使用。</p><p>而对于研发工程师来说,例如在网易云音乐,为了支撑业务的快速发展,存在着较为多样化的 UI 技术体系,涉及到 H5、React Native、动态 DSL、动态图片等。为了应对不同的业务场景,研发工程师需要学习多种技术体系和平台下的 UI 还原。</p><p>如果我们对上面流程中的问题进行总结,就会发现网易云音乐 UI 研发的痛点主要为:</p><ol><li>沟通成本比较大</li><li>工作效率比较低</li></ol><p>具体而言,研发链路长就会导致沟通成本大,手写代码还原 UI、UI 还原度低、技术体系多样化导致工作效率低,而设计工具的多样化、UI 走查的低效既会导致沟通成本大,也会导致工作效率比较低。</p><p><img src="/img/remote/1460000044514343" alt="流程图" title="流程图"></p><p>在此背景下,我们希望有一个工具来代替前端人肉还原 UI,从而从繁冗的设计稿还原工作中解放出来。<br>我们将它命名为「<strong>海豹 D2C</strong>」(Design to Code),它的定位是一站式的智能 UI 研发解决方案,根据设计稿一键智能生成代码。</p><p>我们期望 D2C 的还原度是 <strong>99.9%</strong>, 相比于人肉还原的代码要更精准。此外,它还要去<strong>智能分析设计意图</strong>。这也有望让设计稿的标注和走查这两个流程节省下来,从而去节省开发和设计师间的沟通成本。</p><p>应用了「海豹 D2C」的新流程,我们希望,对于设计师,带来的收益有:</p><ol><li><strong>设计提效</strong>,让设计师免除了繁琐的标注工作,稿子画完即可交付</li><li><strong>沟通降本</strong>,让设计师省下了 UI 走查的时间,大大降低了与前端的沟通成本</li></ol><p>对研发的收益,我们希望能够达成:</p><ol><li><strong>研发提效</strong>,让机器替代人肉还原 UI,只需要不到 10 分钟即可搞定一张页面</li><li><strong>沟通降本</strong>,免除了因还原度不达标而与设计师反复沟通的问题。</li></ol><h2>产品设计</h2><h3>通用性</h3><p>我们希望「海豹 D2C」产品,它应当具备足够的通用性:</p><p><img src="/img/remote/1460000044514344" alt="通用性" title="通用性"></p><h4>输入阶段的通用性要求</h4><ul><li><p><strong>支持常用的设计工具</strong></p><p>需要支持包括 MasterGo, Figma, PhotoShop, Sketch 等在内的设计工具</p></li><li><p><strong>不对设计稿做要求</strong></p><p>在我们的前期调研中发现,设计师都有自己的一套设计风格,这个风格也包括了对 UI 设计软件的使用习惯上。由于使用 D2C 产品的用户往往是开发,他们无法要求设计师一定要按照某套规范来作图,如果强行对设计稿规范做要求,势必会导致产品难以推广落地,因为它不是一套通用的方案。</p></li></ul><h4>D2C 生成阶段的通用性要求</h4><ul><li><p><strong>组件识别支持任意组件库</strong></p><p>优先支持网易云音乐使用的海豚组件库。但是,对于组件的识别应当是通用方案,可以运用在任何组件库上。</p></li></ul><ul><li><p><strong>只做UI还原,不做逻辑识别</strong></p><p>在 UI 设计软件上,目前还很难表达逻辑、动画等等内容,D2C 的产物必然是静态页面。如果强行在 D2C 阶段增加对逻辑绑定等操作,一是<strong>不具备通用性</strong>,二是<strong>势必让产品操作流程变复杂</strong>。专业工作交给专业工具,我相信对于逻辑绑定这部分内容,应该在一个脱离 UI 设计软件的独立平台做会更方便,例如在网易云音乐,就有 <a href="https://link.segmentfault.com/?enc=BR3F%2BPcPCST7bDOZ1jTFIA%3D%3D.AcFGk%2BsLrF47cY5CBu%2FVBsTWgICm6BFwhEA8y8J4qP6w2bMFrZbcKH4l6BWe2dDk" rel="nofollow">Tango 低代码平台</a> 可以做这样的事情。</p></li></ul><h4>输出阶段的通用性要求</h4><ul><li><p><strong>支持多种技术体系和搭建平台</strong></p><p>要支持最常用的技术栈,并且应当有一个通用的、开放的方案,可以对接到搭建平台。也就是说,只要愿意,任意的搭建平台都可以消费「海豹 D2C」的产物。</p></li></ul><p>由此,我们设计出「海豹 D2C」整体流程。经过我们的反复迭代,目前它的流程是这样的:</p><p><img src="/img/remote/1460000044514345" alt="流程图" title="流程图"></p><p>在设计工具方面,支持目前广泛使用的 MasterGo, Figma, PhotoShop, Sketch 等多种格式的设计稿。</p><p>由于 UI 应用场景的不同,在技术体系上,网易云音乐主要涉及了 React, React Native 等多种技术栈,「海豹 D2C」需要支持这些类型代码的交付;</p><p>另外,网易云音乐也有通过「灵渠」DSL 搭建页面,和「云雀创意中心」动态合图的方式进行搭建交付,为此,「海豹 D2C」需要提供<strong>对接的开放能力</strong>,以无缝对接这些搭建平台,实现设计稿一键生成搭建物料。</p><p>对于这些通用性要求要如何实现,我们会在后面再做详细介绍。</p><h3>无损的信息提取</h3><p>D2C 的本质是从设计稿中进行信息提取,并转换成代码的过程。</p><p>之前有一些 D2C 产品,包括微软的开源方案,可以基于图像识别做信息提取。它的好处是不依赖设计稿,但是缺点也很明显:</p><p><img src="/img/remote/1460000044514346" alt="基于图像识别的优缺点" title="基于图像识别的优缺点"></p><ol><li>图层边缘信息易受其他图层干扰;</li><li>矢量数据丢失;</li><li>布局结构难做等。</li></ol><p>而假如使用的 UI 设计软件提供的 Open API,我们可以确保拿到所有的原始数据都是无损!通过图层本身信息的无损提取,做到 0.0001 px 精度还原。此外,UI 设计软件还提供了这些有效信息来帮助我们识别设计意图,生成更加友好的代码:</p><p><img src="/img/remote/1460000044514347" alt="基于 Open API 的优缺点" title="基于 Open API 的优缺点"></p><ol><li>布局结构,例如对于自动布局、约束的描述;</li><li>图层的分组信息;</li><li>组件信息;</li><li>token 信息。</li></ol><p>很简单的道理,当我们在 PhotoShop 中保存 PSD 格式文件,那么下次打开该文件仍然可以继续编辑,但是对于一张导出的 PNG 图片,想要二次编辑就会比较犯难。设计软件本身包含的结构化信息,一定是大于一张二维图片的。</p><h4>多软件适配</h4><p>使用 Open API 的唯一缺点,就是每个 UI 设计软件都有自己的一套标准,我们需要分别去适配。</p><p>我们最终考虑去支持 <a href="https://link.segmentfault.com/?enc=wrHMjS6Uj7UHvjeJuJCfoA%3D%3D.8y5UR9DwvfDzhIsTtWY0WNd3s0HD78V4bG%2FGo1VuCqU3uxmXWGl4nHgwqMFDRImY" rel="nofollow">Figma Plugin API</a> 和 <a href="https://link.segmentfault.com/?enc=cven81eAS7sTed9mPueKNw%3D%3D.6JvkIWpFG144PHNXIemAEAR5fo%2Br7P9k6oTVRem53h%2FWh6QToEsM5C9%2Bm9VSmh9L" rel="nofollow">MasterGo Plugin API</a>,也就是说「海豹 D2C」是以插件的形式运行在 Figma 和 MasterGo 中,这是一个运行在浏览器中的 iframe 页面,同时利用 Plugin API 与 UI 设计软件进行交互。</p><p>对于网易来说,除了 Figma, MasterGo,常用的设计软件还包括 PhotoShop, 早期还有 Sketch。由于 MasterGo 支持导入 Sketch, XD 等格式的设计稿。我们只要支持 MasterGo,无需额外开发也就间接也支持了这些设计稿。对于 PhotoShop 格式的设计稿,我们也间接进行了支持,实现思路如下:</p><p><a href="https://link.segmentfault.com/?enc=vLWua8BUn6xnYR086rGHDg%3D%3D.FLoofSBRaTiETJ%2B4ufc5t2iR0BrXmAVXp79FVVE2fHLtpJblAxLSH5WTVEsTp%2BV9" rel="nofollow">Adobe XD</a> (也可称为 Experience Design) 是由 Adobe 公司开发并发行的一款 UI 设计软件,尽管它有点冷门并且已处于维护模式,但它支持打开 PhotoShop 的 PSD 文件并保存为 XD 文件。而 MasterGo 恰恰又支持导入 XD 文件。最重要的是,XD 与 PhotoShop 同属于 Adobe 公司开发,可以保证在 PSD 文件转换为 XD 文件过程中的还原度。</p><p>所以,到这里解法就很清晰了,我们并不是直接支持 PhotoShop,而是采用了曲线救国的办法:先将 PSD 转成 XD,XD 转 MasterGo,然后由 D2C 消费 MasterGo 设计稿。「海豹 D2C」对 PhotoShop 的支持,不仅实现成本低,而且还原度也能得到保证。</p><p><img src="/img/remote/1460000044514348" alt="PS" title="PS"></p><p>最终下来,我们只要让我们的插件适配 Figma 和 MasterGo 就可以了。他们的 Plugin API 高度相似,这使我们节省了不少开发成本,往往只需要开发好一端的插件,再去适配另外一端的插件即可。当然,高度相似不代表完全一致,特别是一些细节实现上,总会有让人意想不到的差别。</p><h4>中间产物 Uniform UI Schema</h4><p>为了实现兼容多种设计稿和代码模式,我们制定了一个 D2C 中间产物的规范,叫 Uniform UI Schema。通过 Uniform UI Schema,就可以在不同格式的设计稿和不同的代码模式之间实现统一。比如,对于 Figma 设计稿而言,就可以提供一个 Figma Transformer,将其转换成 Uniform UI Schema,然后搭配不同的 Code Generator,便可以生成不同的代码。</p><p>Schema 统一方案,标准开放,支持流转到其他平台,支持多种代码,且可以快速对接支持新框架。</p><p><img src="/img/remote/1460000044514349" alt="Uniform UI Schema" title="Uniform UI Schema"></p><p>例如,在网易云音乐我们主要使用 React 技术栈,一开始没有支持 Vue。但在网易集团,存在其他事业部以 Vue 作为主要开发框架,我们便快速支持了 Vue 代码的生成,在这个过程中,并不是从 Figma/MasterGo 使用 Plugin API 信息提取到 Vue 代码输出的完全重写,而是 Uniform UI Schema 到 Vue 代码的转换,总体仅消耗 1 人日时间。</p><p>到后来,我们甚至提出了插件中的插件的概念——微插件。作为「海豹 D2C」插件的使用者,也可以参与到输出代码产物的过程中,通过开发一个微插件,介入到 Uniform UI Schema 到代码转换的过程中,从而产出「海豹 D2C」本体未支持的框架。</p><h3>所见即所得</h3><p>我们也可以基于 Uniform UI Schema 快速输出 HTML Code。由于 HTML Code 相比于 React 代码,可以不经过编译在浏览器中更快打开,适合作为我们 D2C 生成效果的预览。有什么好处呢?</p><ol><li>相比于常规的流程,我们从设计稿生成代码,最理想的情况,开发需要使用像 VS Code 这样的 IDE 将代码复制过去,编译运行,最终在浏览器中预览效果。如果因为设计稿本身的问题导致生成的代码有瑕疵,此时就要修改设计稿规避这种问题,就需要重新走 D2C 流程,这个过程略显繁琐。</li><li>另外,在 D2C 这一新鲜事物刚出来的时候,大家可能还是会持怀疑的态度,担心生成的代码还原度不好。如果在打开插件时,马上就能看到最终生成的效果预览,就可以根据生成的质量,再决定是否导出代码。</li></ol><p><img src="/img/remote/1460000044514350" alt="所见即所得" title="所见即所得"></p><p>因此,在我们的「海豹 D2C」插件的首页,我们提供了预览图,这个预览图并不是基于设计稿简单的导出图片,而是实打实通过我们的 D2C 出码生成的真实 HTML,可以直接看到 D2C 出码后的效果。不管效果是好还是不好,一目了然。</p><p><img src="/img/remote/1460000044514351" alt="海豹 D2C 插件首页" title="海豹 D2C 插件首页"></p><h3>设计稿优化</h3><p>前面说到,我们不对设计稿规范做要求。那么会有哪些问题呢?这里简单举几点:</p><p><img src="/img/remote/1460000044514352" alt="设计稿优化前" title="设计稿优化前"></p><ol><li><strong>设计稿中存在无用图层</strong>,例如已经隐藏,无实际有效填充,未在可视区域内等。这样导致生成的代码也包含了冗余元素。</li><li><strong>图片未指定导出的格式</strong>,比如是生成 PNG 格式的图片,还是生成 SVG 格式的图片更合理。这在代码生成时无法推断图片格式。</li><li>还有<strong>一些图层适合作为整体导出图片,但是没有设置导出</strong>,例如复杂的背景元素。在代码生成时,可能无法获知要整体导出,导致生成的代码过于复杂。</li><li><strong>部分容器适合使用响应式布局</strong>,但是设计稿中并没有设置。导致生成的代码无法响应不同设备尺寸,需要开发再去调整。</li><li><strong>设计稿未按照逻辑进行成组</strong>,通过父-子的图层关系结构去描述这种逻辑关系,而是一个扁平的图层结构。这导出生成的代码可读性不强,也会影响相对布局的定位。</li></ol><p>使用「海豹 D2C」插件的是我们的前端开发工程师,不会那么熟悉设计软件,上手学习需要一定的时间成本。那么要如何处理这些问题,让生成的代码符合我们的预期呢?我们先看下其他 D2C 产品是怎么处理的。</p><ul><li><p><strong>手动优化</strong></p><p>以 Figma Dev 模式下提供的 Figma To Code 为代表,原样还原设计稿中的信息,输出代码一定符合预期。如果设计稿有一些问题影响了代码的生成,就需要进行手动优化,<strong>优化的效果会保存在设计稿中</strong>。问题在于手动优化比较<strong>繁琐</strong>、<strong>耗时</strong>,并且<strong>有一定的学习成本</strong>。</p></li><li><p><strong>自动优化</strong></p><p>常见的 D2C 产品,往往会对设计稿做自动识别,<strong>无需人工介入</strong>。但完全不需要人工的问题就是,<strong>可能遗漏需要设置的内容</strong>,<strong>同时误设置不需要的内容</strong>。</p></li></ul><p>「海豹 D2C」提出了<strong>智能识别</strong>的概念。在自动识别的基础上,我们加入了<strong>人工介入</strong>审核内容,避免错误设置或误设置不需要的内容。当然了,对于遗漏设置的内容,我们还是支持手动优化的。</p><p>智能识别是我们的默认模式,如果觉得人工确认过于麻烦,你还是可以选择<strong>快速生成</strong>模式来生效自动识别。</p><p><img src="/img/remote/1460000044514353" alt="table" title="table"></p><p>通过智能识别,我们可以做到:</p><p><img src="/img/remote/1460000044514354" alt="设计稿优化后" title="设计稿优化后"></p><ol><li><strong>设计稿中存在无用图层</strong>:我们能够识别到这些图层并做移除;</li><li><strong>图层未指定导出图片或适合作为整体导出图片,但是没有设置导出</strong>:根据图层内容推荐导出图片,可在导出设置中生效;</li><li><strong>适合使用响应式布局,但是设计稿中并没有设置</strong>:我们能够识别到这些图层并做相应的设置;</li><li><strong>设计稿未按照逻辑进行成组</strong>:调整图层,使他们按照逻辑形成父-子的图层关系结构。</li></ol><p>基于此,我们的开发即使不熟悉设计软件的使用,也可以在「海豹 D2C」的引导下,对设计稿进行优化。</p><p><img src="/img/remote/1460000044514355" alt="" title=""></p><h2>技术挑战</h2><h3>C2D2C 组件识别</h3><p>网易云音乐有两种典型的页面类型:</p><ol><li>一种是活动页面,它创意性强,没有固定的设计规范,比如恋爱人格测试活动,摸鱼计算器活动等等。D2C 在还原这类页面时,无需识别它是具体哪个组件库;</li><li>另一种是产品功能页面,它强调 UI 的一致性,有固定的设计规范和交互逻辑,所以需要识别组件并将其转换为对组件库中组件的引用。</li></ol><p>而业界的组件识别方案一般有两种思路:</p><ol><li>一种<strong>直接在设计稿上进行人工标注</strong>,它的优势是技术实现成本低,但是缺点是工作量会转移到设计师,标注成本比较大。</li><li>另一种思路则是利用 <strong>CV 技术</strong>,也就是利用计算机视觉相关的图像识别算法对组件进行识别,它的优势是无需人工标注,模型自动识别组件,缺点是模型的训练和更新成本比较大,ROI 比较低。</li></ol><p>这两种方法都不适合网易云音乐的实际情况,于是我们探索出了<strong>基于 C2D2C 的组件识别方案</strong>。它的优势是<strong>无需人工标注</strong>即可识别组件,而且<strong>技术实现成本低</strong>,ROI 比较高,它的缺点是组件库<strong>有一定的接入成本</strong>,但是我们也提供了工程化的解决方案。</p><p>它的具体思路是,我们将组件的代码库,通过 C2D 技术,也就是 Code to Design,将其转换成设计软件的 Library,并同步诸如组件相关的元数据。这样设计师在使用 Library 的时候,通过元数据就自动实现了对组件的标注,最后在 D2C 的过程中将就会被识别出来。</p><p><img src="/img/remote/1460000044514356" alt="image.png" title="image.png"></p><p>具体而言,就是将 HTML 的元素,比如对于 div 标签、p 标签、svg 标签,可以依次映射成 Figma 的 Frame 节点、文字节点和矢量节点。按照其在 React 组件库中的组件名称,到 Figma 中,在 Library 中实现相应的组件。</p><p><img src="/img/remote/1460000044514357" alt="image.png" title="image.png"></p><p>这里截图显示的就是我们通过 C2D 技术生成的 Library,以 Button 为例,当设计师使用了 Library 中的 Button 后,借助组件变体和属性功能,便可以像使用 React 组件一样,随意更改组件的属性,并且能够在 D2C 阶段通过元数据识别出来。</p><p>在做 C2D 的时候,为什么要在图层中绑定元数据呢?其实就是用来做物料识别的。原理并不复杂,基于 C2D 的产出的设计稿,我们会解析设计图层和元数据,同时进行物料的识别,最后还原成代码。</p><p>比如,对于 Button 而言,C2D 在生成设计稿时,会为图层绑定组件的元数据,包括组件名、组件 Props,API 文档等,D2C 时,直接读取组件元数据,翻译成代码即可,其间不会丢失任何的设计细节。</p><p><img src="/img/remote/1460000044514358" alt="image.png" title="image.png"></p><h3>布局优化</h3><h4>层级调整</h4><p>部分设计师习惯采用扁平的图层结构,未按照业务逻辑进行良好的图层分组。如果直接基于此生成代码,尽管还原度也能够得到保障,但最终代码的<strong>可读性比较差</strong>,<strong>二次编辑也较为困难</strong>。</p><p>为了能够生成可读性好、能二次开发的代码,势必要对布局进行优化。而布局优化的本质就是将 <strong>扁平的结构</strong> 转换成 <strong>行列嵌套</strong> 结构。</p><p>例如下图中,左下角有个设计稿,包含 ABCD 四个节点,如果不进行布局优化,那么整个页面将是一个扁平的结构,生成的是绝对定位的代码。虽然还原度能够保证,但是可读性比较差。</p><p><img src="/img/remote/1460000044514359" alt="image.png" title="image.png"></p><p>而布局优化的过程,则是对 ABCD 进行分组,首先将页面分为 ABC 和 D 两行,然后将 ABC 分为 A 和 BC 两列,最后将 BC 分为 B 和 C 两行。</p><p>分好组后,通过新增三个布局容器,形成行列嵌套结构,这样最终生成的代码将符合开发者的直觉,具备较好的可读性。</p><p>不难发现,做布局优化,其实就是在做行列分割,人眼一眼就能看出来需要这么分割,那对程序来说,具体要如何实现呢?</p><p>我们独创了行列分割算法(<a href="https://link.segmentfault.com/?enc=irXyJLRijji6RkYWDQyRrQ%3D%3D.2tuLdLoxry0PwLZgTHFi8XUSu%2B4thVcqCmEZcc6%2BmQoyLxjjokFyQNHWUp3ctLxL" rel="nofollow">专利公布号:CN116861853A</a>)。整个流程,大致可以分为以下 5 步。</p><p><img src="/img/remote/1460000044514360" alt="" title=""></p><ol><li>首先,我们需要获取到待处理的节点坐标;</li><li><p>然后,进行节点关系的处理:判断它们是处于包含、还是相交还是相离关系</p><ul><li><strong>包含关系</strong>:将被包含的节点作为其子节点处理。</li><li><strong>相交关系</strong>:两者看做一个整体,且其中一个相对于整体作绝对定位处理。</li><li><strong>相离关系</strong>:不做额外处理。</li></ul></li><li>节点关系处理完成后,则是做二维空间投影,找到行列分割的依据。比如,通过纵向投影,我们就知道了 ABC 和 D 是属于不同的两行,通过横向投影,我们就知道了 A 和 BC 属于不同的两列。</li><li>接下就是做行列分割了,其主要工作就是依据二维投影信息,添加布局节点,进行分组。</li><li>最后就是样式的计算,生成包括 Flex 布局、绝对定位以及 Margin 偏移量等。</li></ol><h4>自动布局的识别</h4><p>在层级调整的基础上,我们还需要识别自动布局。自动布局转换为代码后,其实就是 Flex Box。通过 Flex Box,就能够让页面实现响应式,例如在屏幕变宽以后,一些元素弹性放大,或者是选择不放大,但是一行内能够容纳更多的元素。</p><p>那么如何识别自动布局呢?我们大胆猜测,自动布局往往是运用在由相似元素组成的列表。那么具体的实现算法就变成:</p><p><img src="/img/remote/1460000044514361" alt="自动布局的识别" title="自动布局的识别"></p><ol><li>识别相似元素,可以从尺寸、描边、背景填充、文本大小等角度去计算相似度,而文本的内容、图片的内容则认为是合理的差异,不应当参与计算。并且在实际的识别中,还需要对元素的子元素进行遍历,也做一遍相似度的计算;</li><li>可信度计算:由于这些元素最终组成了列表,还需要分析下元素所在的容器是否是一个正常的列表形态,包括元素相似度、元素间距、元素对齐方式等。如果元素间距完全不一致或者元素未按照某些方式对齐,则可信度较低,不像是一个列表;</li><li><p>识别自动布局中,元素的尺寸约束:约束主要分为三种,包括</p><ul><li>固定值 (Fixed):调整父框架大小时,如果我们不希望元素尺寸发生变化,可以选择这个来保持固定的尺寸;</li><li>填充容器 (Fill):自动调整尺寸,使之填充父框架的剩余可用空间;</li><li><p>拥抱内容 (Hug):当子元素也是自动布局框架,或者是一个文本类型的图层时,允许设置为Hug。对于文本类型,其拥抱内容的方法是,保持尽可能小的尺寸将其中的文本完整显示。</p><p>我们可以分析现有设计稿的设计意图,选择合适的约束。比如,目前元素总是占据父元素 100% 的宽度,可以认为是一个 Fill 的约束。但如果它是一个文本节点,则可以更正为 Hug 的约束。</p></li></ul></li></ol><p>当然,智能识别也可能无法准确识别出约束时,这时候就需要用户自己做决定了。</p><h3>如何让代码和手写的一样</h3><p>D2C 直接导出的代码,有一些问题。比如:</p><ul><li>className 使用无意义的数字,这会导致代码的可读性变差</li><li>重复样式多,未合并,当后期手工调整一些样式时,需要搜索到这多个重复样式分别修改,不太方便</li></ul><h4>className 语义化</h4><p>对于 className,我们希望进行语义化,我们想到了使用 ChatGPT 来实现。</p><p><img src="/img/remote/1460000044514362" alt="" title=""></p><p>只要将「海豹 D2C」生成的代码,交给 ChatGPT,并通过 prompt 告知需要对 HTML 中的 className 进行语义化即可。当然,如果需要更好的效果,需要将尽量多的图层信息,也交给 ChatGPT 进行分析。可以看下生成的效果,还是比较符合预期的:</p><p><img src="/img/remote/1460000044514363" alt="image.png" title="image.png"></p><p>基于此,className 从简单的以图层 ID 得到,变成了由更具语义化的词语组成,提升了代码的可读性,也有利于我们对代码进行二次修改。</p><h4>合并重复样式</h4><p>对于重复样式,我们也有尝试过使用 ChatGPT 进行优化,但是效果不太理想。</p><p>好在对于重复样式的识别,主观性其实没有那么强,即使不借助 AI,也可以提炼下算法来实现。一种实现思路是这样的:</p><p><img src="/img/remote/1460000044514364" alt="" title=""></p><p>对兄弟节点本身的样式(不包括他们的子节点)进行统计,如果重复样式的数量比较多,对这些样式合并到同一个选择器中。</p><p>如果发现有一些兄弟节点的样式高度一致,那么再遍历他们的子节点,注意应该是相同位置,也就是相同数组索引的子元素,也进行统计,按照前面的流程再走一遍,将相同的样式合并到同一个选择器中。</p><h2>总结与展望</h2><p>目前,「海豹 D2C」已在网易云音乐绝大多数业务场景中落地,对于我们的 UI 设计稿还原为代码,能够做到 <strong>99%</strong> 的准确度。在<strong>还原度</strong>、<strong>生成速度</strong>、<strong>易用性</strong>、<strong>平台支持度</strong>等方面,相比于业界其他 D2C 产品,具备一定优势;对于我们的研发工程师在 UI 还原方面,平均能够做到 <strong>30%</strong> 以上的提效。</p><p>由于 D2C 技术方案本身足够通用,我们的插件适用于任意场景的设计稿,「海豹 D2C」已在 Figma, MasterGo 社区中发布,已累计协助生成数千个页面。值得一提的是,「海豹 D2C」是MasterGo 插件社区中第一款也是目前唯一一款 D2C 产品。</p><p>在未来,我们会重点提升「海豹 D2C」对于自适应布局识别与还原的支持。对应的解决方案,其实在本文也已经做过介绍,已在内测中,不久就可以和大家见面。</p><p>我们也会考虑借助大模型技术在例如层级调整、组件识别、逻辑意图识别等方面让「海豹 D2C」达到更高的智能化水平。</p><p>此外,当前「海豹 D2C」解决的问题,本质上是 UI 研发过程中的效率问题和沟通问题,但是却没有触及到 UI 研发的上游,也就是 UI 生产的问题。所以我们希望,在 AIGC 的能力之下,我们的设计协同变成<strong>设计和生产一体化的设计协同</strong>,也就是先 <strong>AI2D</strong>,然后 <strong>D2C</strong>,设计协同将会从工程化阶段,全面迈向智能化阶段。在后续,我们将继续带来有关<strong>AI2D2C</strong> 的技术分享。</p><h2>相关链接</h2><p><a href="https://link.segmentfault.com/?enc=6tkRUaFRVZTwD5%2B%2BRg5%2B8w%3D%3D.lOVaJrrFA8BcDUQ3HUyl93foVbcO%2Fga%2BLp7o1uK7E%2Ft4Ecxzwdojlw2baR0fSWn6oeHPBUxFa7dbKeBJ2U6ljilFqUaaaaaClaNV8id04RQB5OYctXYBoTPUNWjX7HII" rel="nofollow">海豹 D2C Figma 插件</a></p><p><a href="https://link.segmentfault.com/?enc=WmzWIW5g%2FpY0cWzKwRvlzQ%3D%3D.VMkRjQBxfEms9NSwbqOc1%2BbbUEyv85ijjcMry5JJMiGPUm%2B2B%2FRFzja7UlUE0Xer6K8HQPbiC%2FADBC%2FUCfjCAw%3D%3D" rel="nofollow">海豹 D2C MasterGo 插件</a></p><p><a href="https://link.segmentfault.com/?enc=zyFAAil4xiEx4l6edZxl8g%3D%3D.v5O8k0pOCGLTffV8S7NXy8JcDde41DRF3sluz2fY2GI%3D" rel="nofollow">海豹 D2C 官网</a></p><h2>最后</h2><p><img src="/img/remote/1460000044514365" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=1yoYM8BGFkUSePAVqBrcWg%3D%3D.yABeUXMR1qGIikQLpZ3a9c7hLyuW%2FYRS9m%2FSeBxg8XU%3D" rel="nofollow">https://hr.163.com/</a></p>
网易云音乐设计协同演进之路
https://segmentfault.com/a/1190000044510425
2023-12-28T10:25:57+08:00
2023-12-28T10:25:57+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=xCcIv5Zv2Ir3rb8AE92YLA%3D%3D.ui0nPCn9MqdPNr9lGaxz3wQJcofHiuxHIPEPegNIwbI%3D" rel="nofollow">刘甲</a></blockquote><p>万字长文带你了解云音乐设计协同的演进之路,干货满满~</p><h2>序言</h2><p>前端和设计师一直以来都在致力于为用户提供出色的人机交互体验。在这个过程中,如何为双方提供高效的协同产品,降低设计师与前端的沟通成本,以及提升双方的工作效率,都是非常有价值的探索点。</p><p>笔者来自网易云音乐-公共技术部,目前是云音乐设计中台的技术负责人。从 2021 年 7 月入职网易到现在,一直在「前端与设计协同」领域里探索和实践,期间沉淀了若干经验和方法论,希望能和大家一起分享,于是就有了这篇文章。</p><p>本文将从问题出发,详细介绍云音乐设计协同的演进之路。按照时间的维度,云音乐设计协同的演进之路为:</p><ul><li>原始阶段 ➡️ 工程化阶段 1.0</li><li>工程化阶段 1.0 ➡️ 工程化阶段 2.0</li><li>工程化阶段 2.0 ➡️ 智能化阶段</li></ul><p><img src="/img/remote/1460000044510428" alt="" title=""></p><p>PS:本文很长,万字有余,赶时间的同学可在 ChatGPT 的陪同下阅读。</p><h2>背景</h2><p>提起「前端与设计协同」(后面简称「设计协同」),相信大家都不陌生。它伴随着互联网精细化的分工而出现,在 PC 互联网时代发展壮大,在移动互联网时代趋于成熟。</p><p>而所谓「设计协同」,其主要功能<strong>就是在设计师和前端的协同工作下,将产品需求转化成代码的过程</strong>。</p><p>所以,我们研究「设计协同」的目的,就是<strong>设法使协同流程变高效,缩短产品的交付时间</strong>。</p><p>为此,我们需要解决以下三个问题:</p><ol><li><strong>降低设计与前端的沟通成本</strong></li><li><strong>提高设计的工作效率</strong></li><li><strong>提高前端的工作效率</strong></li></ol><p><img src="/img/remote/1460000044510429" alt="" title=""></p><p>然而,同时解决这三件事并不容易。</p><p>因为,这三件事彼此互为关联,并不独立,在协同上下游上相互影响。如果单纯 Case By Case 地解决,很容易出现【解决了问题 A,但引发了问题 B】的尴尬情况。</p><p><strong>所以「设计协同」需要的不是单点方案,而是系统化的解决方案</strong>。</p><h2>原始阶段 ➡️ 工程化阶段 1.0</h2><h3>问题</h3><p>时间回到 2021 年,彼时云音乐的设计协同还较为初级,我称之为 <strong>「原始阶段」</strong>。</p><p>「原始阶段」存在的问题比较多,可以用下面这张图来说明:</p><p><img src="/img/remote/1460000044510430" alt="3.jpg750" title="3.jpg750"></p><p>首先,设计资产是通过人肉管理的,设计规范也是人肉同步,而且不同的设计团队之间设计标准不统一,设计资产存在重复建设。</p><p>此外,开发作为设计的下游,设计侧存在的问题,也会同步影响到开发侧,具体体现在:</p><ol><li>对于同一个组件,因为规范的不同而重复开发;</li><li>类似换肤的三端联动场景,开发需要重复实现三遍;</li><li>开发侧完全人肉还原 UI,效率低。</li></ol><h3>思考</h3><p>分析之后不难得出,以上问题存在的根本原因在于:</p><ol><li>没有统一的设计规范;</li><li>缺少工程化的管理和提效手段。</li></ol><p>而业界解决此问题的传统思路,一般是采取以 <strong>设计系统</strong> 为中心的「有损」设计协同。</p><p>作为背景知识,在这里容我先简单介绍一下<strong>设计系统</strong>。</p><p>设计系统(Design System)不是系统设计(System Design),前者是关于设计的系统,后者是关于系统的设计 🤣。</p><p>具体而言,<strong>所谓设计系统,就是一系列遵循严格设计规范的可复用的组件集合,由风格指南、模式库和组件库三部分组成。</strong> 而关于它的定义,最早可追溯到 <a href="https://link.segmentfault.com/?enc=CuCHTcgdABkye0V8AXJfVg%3D%3D.2lhoLUB8rcK5aIH7DQlpbUncn5BuHfilvQCsa04ad32KV%2FHB7eF76IYoK294LHHBjitBO7X8RL%2FzTxerP9yOIzR%2FhR3oV3i%2FPPdU3f7POwEGjgsFF1%2BwLOWyrhhUWD8cKCAC1q%2B%2FPMRKYkDt%2Bc44cxoZVNI6xd8G8h3gw3rGyHc%3D" rel="nofollow">Design Better - Introducing design systems</a> 这篇文章对其的介绍。</p><p>而以 <strong>设计系统</strong> 为中心的「有损」设计协同,具体而言,就是为设计系统提供<strong>两套组件实现</strong>:</p><ul><li>一套是给设计师使用的 Sketch 或 Figma 组件库</li><li>一套是给前端用的 React 或 Vue 组件库</li></ul><p>基于此的工作流程一般为:</p><p><img src="/img/remote/1460000044510431" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>这种做法能在 <strong>一定程度上</strong> 解决「沟通成本」和「开发效率」的问题,但同时也会在无形中造成设计意图传递的<strong>损耗</strong>。</p><p>为什么这么说呢?</p><p>这是因为,在以设计系统为中心的解决方案之中,<strong>设计规范存在两套相互独立的实现</strong>:</p><ul><li>实现一:设计资产</li><li>实现二:组件代码</li></ul><p><strong>这就导致二者并不同源</strong>。</p><p>因此,设计意图从设计师传递到前端的过程中,不可避免引入「信息损耗」,而这种「损耗」则必须通过人的沟通才能解决,从而导致了不确定性和时间成本的产生。</p><p><strong>所以,解决问题的关键在于,能否设法消除这种「信息损耗」?</strong></p><p>于是,我们提出了基于 C2D2C 的无损设计协同。其核心思路是:<strong>通过工程化的手段,打通设计和前端,统一协作语言</strong>。</p><p>具体做法为:</p><ol><li>设计系统的组件实现,<strong>有且仅有一套组件代码</strong>。</li><li>设计师使用的设计资产,利用组件代码,<strong>通过 C2D(Code to Design)技术动态生成的,以此保证两者的同源。</strong></li><li>C2D 生成设计稿时,<strong>自动注入组件元数据</strong>,包括组件名、组件参数等开发相关的细节;在 D2C 阶段,自动解析此「元数据」,便可以直接将设计稿翻译成组件代码。</li></ol><p>整体如下图所示:</p><p><img src="/img/remote/1460000044510432" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>此方案的好处主要有:</p><ol><li><strong>低维护成本</strong>,因为只需在开发侧维护一套组件库即可。</li><li><strong>降低沟通成本</strong>,因为设计细节无损保存在元数据中,免去了双方的反复确认。</li><li><strong>提高研发效率</strong>,因为前端可利用 D2C 一键还原 UI,免去人肉还原 UI 的繁琐过程。</li></ol><h3>解法</h3><p>为此,为了实现基于 C2D2C 的无损设计协同,我们构建了三个子产品:</p><ol><li>海豚设计系统</li><li>Fin 1.0 设计插件</li><li>海豹 D2C</li></ol><p>这个三个子产品共同实现了 C2D2C 的闭环流程:</p><p><img src="/img/remote/1460000044510433" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>这里有一个完整的演示,用来说明三者是如何相互联动, C2D2C 闭环的:</p><p><img src="/img/remote/1460000044510434" alt="" title=""></p><h4>海豚设计系统</h4><p>海豚设计系统是 C2D2C 的基石,但是构建起来并不容易。</p><h5>技术选型</h5><p>首先,由于云音乐 App 使用的跨端技术栈有两套:</p><ol><li>产品功能页面:React Native</li><li>营销活动页面:H5</li></ol><p>所以,海豚组件库需要同时支持 React Native 和 H5。在技术选型上,有两种方案可供选择。</p><p><strong>方案一: 分别为 React Native 和 H5 独立实现两套组件库</strong></p><ul><li><p>优势:</p><ul><li>架构简单</li><li>不用考虑兼容适配的问题</li></ul></li><li><p>缺点:</p><ul><li>开发工作量大</li><li>维护成本大</li></ul></li></ul><p><strong>方案二:只实现一套 React Native 组件库,利用 <a href="https://link.segmentfault.com/?enc=6iRXSao%2BuQHoSy%2FQqPkJAA%3D%3D.%2BBX6oeoej2yKfFHzMdQ8CAKJtBUrKqINUE%2BSS89%2B0tTRM2FAaX9J7gGyVONXhXgg" rel="nofollow">react-native-web</a> 实现 H5 的兼容</strong></p><ul><li><p>优势:</p><ul><li>最大化代码复用,显著减少开发量</li><li>维护成本低</li></ul></li><li><p>缺点:</p><ul><li>在组件架构上会引入复杂度</li><li>有一定的兼容适配成本</li></ul></li></ul><p>鉴于需要开发的组件数量较多(50 +),且开发资源有限(2 人),综合考虑投入产出比后,最终选用了方案二。</p><h5>架构设计</h5><p>好的技术架构决定产品的生命力,特别是像组件库这种生命周期长的产品。在选用了方案二后,摆在我们面前的问题有:</p><ul><li>虽然 <a href="https://link.segmentfault.com/?enc=Kuxg%2BXGMK3DuD0hPhxw8RA%3D%3D.LxrUdvM4iuFuEOyOkYp8R2pgmVYIqorf2zC%2FA4DfSckfJ2sqTgPmRz1LfJSLXxzS" rel="nofollow">react-native-web</a> 提供了 H5 的兼容方案,<strong>但并不是所有的 React Native 组件都可以转成 Web</strong>,比如一些业务上的 Native Component;</li><li><a href="https://link.segmentfault.com/?enc=czMicTfcO0671gFhHMVBHw%3D%3D.n8tksUeIxl9qmvwNMTksWrCnDW9%2BotIS1yjF2SgKcehXeleKym2aLg6sZBmn5LOj" rel="nofollow">react-native-web</a> 无法保证 100% 的 Web 兼容性,考虑到部分组件的兼容性成本,<strong>会存在着为同一组件分别提供 React Native 和 H5 两种实现</strong>,比如 Form 表单组件;</li><li><strong>存在着一些平台专有组件</strong>,比如 Charts、Table 这种 H5 Only 的组件。</li></ul><p>为了解决以上问题,我们设计了海豚组件库的<strong>三层架构</strong>:</p><p><img src="/img/remote/1460000044510435" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>它主要特点有:</p><ul><li>稳健的底层:提供完全与业务无关的核心能力,包括底层能力、元组件、原子 UI 组件、功能组件和复合 UI 组件等,<strong>能够被 RN 和 H5 复用</strong>。</li><li>强大的中层:基于底层能力,提供与业务相关的功能组件,<strong>能够被 RN 和 H5 复用</strong>。</li><li>灵活的上层:基于中层提供的业务组件,<strong>按照平台特性的不同,灵活地提供不同平台的专用组件包。</strong></li></ul><h5>配置化方案</h5><p>换肤是云音乐 App 的重要功能;此外,云音乐还存在着像直播、音街这类不同子品牌的 App。</p><p>所以,为了支持以上这些场景,海豚组件库需要:</p><ul><li>支持全局换肤</li><li>支持细粒度的品牌定制</li></ul><p>那具体要如何实现呢?</p><p>我们的核心思路是:<strong>抽象变与不变,描述组件的组成关系</strong>。</p><p>以海豚 Button 为例,决定 Button 样式变化的 4 个属性分别为:</p><table><thead><tr><th align="left">参数</th><th align="left">说明</th><th align="left">类型</th><th align="left">默认值</th></tr></thead><tbody><tr><td align="left">type</td><td align="left">类型</td><td align="left">'surface', 'outline', 'text'</td><td align="left">'surface'</td></tr><tr><td align="left">level</td><td align="left">层级</td><td align="left">'primary', 'secondary', 'normal'</td><td align="left">'primary'</td></tr><tr><td align="left">ghost</td><td align="left">是否是幽灵按钮</td><td align="left">boolean</td><td align="left">false</td></tr><tr><td align="left">size</td><td align="left">大小</td><td align="left">'xs', 's', 'm', 'l', 'xl', 'xxl'</td><td align="left">'l'</td></tr></tbody></table><p>所以,可以按照这四个维度,将 Button 拆解成 <strong>变化的视觉</strong> + <strong>不变的骨架</strong>。</p><p><img src="/img/remote/1460000044510436" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>变化的视觉为:</p><ul><li>Button 内间距</li><li>高度</li><li>圆角</li><li>...</li></ul><p>不变的骨架,是<strong>由这四个属性排列组合成的正交变体</strong>:</p><ul><li>surfaceSecondaryLight 变体</li><li>surfaceDark 变体</li><li>outlinePrimaryLight 变体</li><li>...</li></ul><p><img src="/img/remote/1460000044510437" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>通过将变化的视觉解构成两类 token:</p><ul><li><p><strong>全局 token</strong>:用于更改整体风格</p><ul><li><code>colorPrimary1</code></li><li><code>colorNeutral4</code></li><li><code>spaceModule1</code></li><li><code>spaceComponent1</code></li><li><code>fontSizeMedium1</code></li><li>...</li></ul></li><li><p><strong>组件 token</strong>:对全局 token 的引用或自定义,用于细粒度配置组件</p><ul><li><code>buttonPaddingXS</code>: <code>theme.spaceComponent5</code></li><li><code>buttonHeightXS</code>: <code>18</code></li><li>...</li></ul></li></ul><p>最后,<strong>只要配置不同的全局 token 和组件 token,就能实现全局换肤和组件粒度的品牌定制</strong>。</p><h4>Fin 1.0 设计插件</h4><p>Fin 1.0 设计插件的定位是<strong>提供给设计师的设计资产管理工具</strong>,让设计师可以利用 C2D 技术搭建出设计稿。</p><p>鉴于当时(2021 年)Sketch 还是主流设计软件,Figma 在设计团队中的使用也比较多,这就要求我们要同时支持 Sketch 和 Figma。</p><h5>跨平台的插件架构</h5><p>为了降低研发成本,我们设计了跨平台的插件架构,其核心思路就是<strong>用 Web 来承载 UI 和业务逻辑</strong>。</p><p>我们可以把插件分成 <strong>端容器 Client</strong> 和 <strong>Webview</strong>:</p><ul><li>容器负责渲染和通信</li><li>Webview 负责 UI 和业务逻辑</li></ul><p><img src="/img/remote/1460000044510438" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>这样拆分后,Sketch 和 Figma 便能完全复用 UI,仅需要针对「端容器」和「Webview」通信方式、设计稿渲染逻辑上的不同,在 Client 上做差异化处理即可。</p><h5>C2D 技术选型</h5><p>C2D 的本质是代码转设计稿。</p><p>在业界,目前做 C2D 一般有两种思路:</p><p><strong>思路一</strong>: 将 Sketch 或 Figma 作为 React 的一个端,利用类 RN 的语法,渲染出设计稿,比如 airbnb 的 <a href="https://link.segmentfault.com/?enc=sERLng6pB%2FtDgqg5F5kXaw%3D%3D.4VcEkhChZ6oCqHzAlSOfMFQEvEwQDL9sXxWyz4rty1eF10No7gkKDOLvBMD7KDG3" rel="nofollow">react-sketchapp</a>;</p><p><img src="/img/remote/1460000044510439" alt="" title=""></p><p><strong>思路二</strong>:直接将组件的 html 转设计稿,比如 ant-design 的 <a href="https://link.segmentfault.com/?enc=hc1PawjIvgizyjJi5axC8A%3D%3D.HmA2JU3MqDhJ5CpgV700ILeZ23SdHMidOPzbNp%2FUFZdyiV8eCwz05C0vuHH%2BvW4J" rel="nofollow">html2sketch</a>。</p><p><img src="/img/remote/1460000044510440" alt="" title=""></p><p>对 Sketch 而言,由于其发布的时间较早,C2D 的生态相对成熟,基于 html 的开源方案有 <a href="https://link.segmentfault.com/?enc=ipb8%2BCEGGlipFpWYCpFpjw%3D%3D.H2hOdc3Yt0vuebTK5qKXKkrNGSEXc98ZogIo%2F%2BksKhfxJYLTVdx2DzBZHCDpd%2B%2FG7zWACUARF9jd8J4iNQ04%2Fw%3D%3D" rel="nofollow">html-sketchapp</a>、 <a href="https://link.segmentfault.com/?enc=AgI2hJVpZnOzfG%2BOggRepw%3D%3D.smgfn%2FjTSGFtkY51RPGSMR01gt4lA5f3Nun5w%2BK3ype8OvJOvAGxYIDfaQTDMeWs" rel="nofollow">html2sketch</a>,但是 html2sketch 作为后来者在还原度上更佳,所以最终选用了 html2sketch 作为 Sketch 的 C2D 方案。</p><p>而 Figma 是 2016 年发布的,相对年轻,其 C2D 的生态还不够成熟,基于 html 的开源方案有 <a href="https://link.segmentfault.com/?enc=gVMly%2FmaL5k7xwPyhlhpbQ%3D%3D.wA6kYc28nCFmZp4891VS9nJHK9Am%2BNBDuYupr2%2BrnoFJHsnWcmqu7883sGhNrqvp" rel="nofollow">figma-html</a>,但是其还原度还不够好,所以我们参考了 figma-html 的思路,选择了自研 html2figma。</p><p><strong>html2figma 的本质,其实就是 DSL 的转换</strong>:将描述网页的 html, 转换成描述 Figma 设计稿的 Schema。</p><p>具体而言,就是将 html 的元素,比如 <code>div 标签</code>、<code>p 标签</code>、<code>svg 标签</code>,映射成 figma 的 frame 节点、文字节点和矢量节点。</p><p><img src="/img/remote/1460000044510441" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>举个 🌰:我们有一个 <code>div</code> 元素,长和宽为 <code>80px</code>,圆角为 <code>40px</code>,背景色为红色。</p><p>我们可以将其转换成 Figma Frame Schema:长和宽分别为 <code>80px</code>,填充色为红色,圆角为 <code>40px</code>。</p><p><img src="/img/remote/1460000044510442" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>转换之后,通过 Figma 的 Plugin API, 就可以将其渲染到画布上,可以看到,二者在视觉上完全一样。</p><p>更多转换细节就不再展开了,感兴趣的同学可参考 <a href="https://link.segmentfault.com/?enc=UbOV0FNn404Dqs57%2Fdwvhg%3D%3D.4uDIWEGjT1xC%2ByllmzZA3by9UPAhgn%2FUBe1C6BOTl%2BhRMEHMrbDvOg%2FeN9kkrF3%2BsnA1IkIS5MHbpG69g%2B%2F3qA%3D%3D" rel="nofollow">figma-html</a> 关于此转换的实现。</p><h4>海豹 D2C</h4><p>海豹 D2C 是 C2D2C 的最后一步:<strong>将设计稿转换成代码</strong>。</p><p>说到 D2C,想必大家也并不陌生,比如阿里巴巴的 <a href="https://link.segmentfault.com/?enc=npzLUyTSff4FpdWhe0vaww%3D%3D.05tX%2FaKhFDUHSl73eZBQ0BpZhOSoMxeYr9bxKXDKEvQ%3D" rel="nofollow">imgcook</a>,或者是京东的 <a href="https://link.segmentfault.com/?enc=zu%2Bf%2FS3iyksV2CBRuUO5kg%3D%3D.%2Fg6dsY5ttbUBUj7ou9wUPFqhT9RfhehAT09iBjHaJB8%3D" rel="nofollow">Deco</a> 。特别是 imgcook,由于其率先将基于 AI 的 D2C 用在生产环境,而且取得了不错的效果,这可能就会给大家造成一种误解:认为 D2C 就一定要用到 AI。</p><p><strong>其实,不一定。</strong></p><p>因为 D2C 的本质是<strong>将设计意图还原成代码</strong>,所以 D2C 的关键就在于<strong>如何让机器理解设计意图</strong>。</p><p>对于一张图片而言,由于其是非结构化的,它的所有信息完全包含在其二维像素平面内。对于这种场景,用基于 CV 技术的 AI 模型做组件识别,然后基于识别结果做 D2C 是非常合适的,但实现成本会比较高,因为<strong>会涉及到大量的数据标记和模型训练工作,整体 ROI 会较低</strong>。</p><p>但是,对于 Sketch 或 Figma 设计稿而言,因为其本身是<strong>结构化</strong>的,所以将其转换成代码是完全可行的,社区也有很多插件能做到这点,但真正的难点在于<strong>组件识别</strong>,也就是<strong>如何识别图层,将其与已有的组件库进行关联</strong>。</p><p>而海豹 D2C 的优势就在于,<strong>以较低的成本,实现了基于元数据的组件识别方案</strong>。</p><h5>基于元数据的组件识别方案</h5><p>在「Fin 1.0 设计插件」的介绍中,我们知道,通过 Fin 1.0 C2D 产出的设计稿,会默认注入组件元数据,所以在 D2C 的过程中,只需要检测当前图层是否包含元数据,便能实现组件识别功能。</p><p>具体的处理流程为:</p><ul><li>若包含元数据,则是识别成海豚组件,解析元数据,然后还原成组件引用;</li><li>若不包含元数据,但存在子节点,则深度遍历判断是否包含元数据;</li><li>若不包含元数据,也不存在子节点,则将节点还原成普通的 div、img 等。</li></ul><p><img src="/img/remote/1460000044510443" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><h6>布局优化</h6><p>由于设计稿是设计师在画布上通过<strong>拖拉拽</strong>搭建出来的,受设计师作图习惯的影响,设计稿中的元素一般都是平铺的。</p><p><strong>如果不进行布局优化,那么整个页面将是一个扁平的结构,生成的将是绝对定位的代码</strong>,虽然还原度能够保证,但是可读性会比较差,比如:</p><p><img src="/img/remote/1460000044510444" alt="" title=""></p><p>而布局优化的过程,则是对 ABCD 进行分组,首先将页面分为 ABC 和 D 两行,然后将 ABC 分为 A 和 BC 两列,最后将 BC 分为 B 和 C 两行。</p><p>分好组后,通过新增三个布局容器,形成行列嵌套结构,这样最终生成的代码将符合开发者的直觉,具备较好的可读性:</p><p><img src="/img/remote/1460000044510445" alt="" title=""></p><p>从上面优化的过程可知,布局优化,其实就是在做<strong>行列分割</strong>,完整的流程如下图所示:</p><p><img src="/img/remote/1460000044510446" alt="" title=""></p><p>第一步,获取待处理的所有节点坐标。</p><p>第二步,<strong>对所有节点做节点关系处理</strong>,判断它们是处于包含、还是相交还是相离关系。处理逻辑为:</p><ul><li>如果是包含关系,则将被包含的节点作为其子节点处理;</li><li>如果是相交关系,则将两者看做一个整体,且其中一个相对于整体作绝对定位处理;</li><li>如果是相离,则不做额外处理。</li></ul><p>第三步,对处理完成的节点做<strong>二维空间投影</strong>,找到行列分割的依据,例如:</p><ul><li>通过纵向投影,我们就知道了 ABC 和 D 是属于不同的两行;</li><li>通过横向投影,我们就知道了 A 和 BC 属于不同的两列。</li></ul><p>第四步,依据二维投影得到的信息,对节点做<strong>行列分割</strong>,然后添加布局节点,进行行列分组。</p><p>最后一步,就是计算样式,生成包括 Flex 布局、绝对定位以及 Margin 偏移量等。</p><p>由于篇幅有限,关于设计工程化阶段 1.0 更为详细的介绍,可参阅笔者在 GMTC 上的分享:<a href="https://link.segmentfault.com/?enc=orD1p39qh%2BKU2G%2F721n%2BTg%3D%3D.iaRVSSBjptu50iKdnaFyIXKeYOS22t0gHOr9TNShR1arg8I66xRsZ5LKV%2BYmHhNmqlNguTlFjQkHP9UmF4oGdg%3D%3D" rel="nofollow">《网易云音乐基于 C2D2C 的无损设计协同》</a></p><h2>工程化阶段 1.0 ➡️ 工程化阶段 2.0</h2><p>工程化阶段 2.0 是对工程化阶段 1.0 的补充和完善。</p><h4>背景</h4><p>为什么要做设计工程化阶段 2.0 呢?那肯定是 1.0 存在某些问题。(笑</p><p>随着 Fin 1.0(C2D) 和 D2C 落地的深入,一些问题也慢慢暴露出来。</p><h5>C2D 的问题</h5><p>在 2022 年中的时候,在线协同类设计软件慢慢崛起,Sketch 已是明日黄花,云音乐的设计团队已基本全面拥抱 Figma / MasterGo 这类在线协同类设计软件。</p><p>随着设计师对此类工具了解的加深,他们发现使用 Fin 1.0 C2D 来做设计稿存在以下问题:</p><ol><li>基于 Fin 1.0 的工作模式不符合设计师的工作习惯,使用成本较大。</li><li>设计师希望能够直接使用 Figma / MasterGo 的 Library 来做设计。</li></ol><h5>D2C 的问题</h5><p>在 D2C 最初的产品设计中,我们将云音乐的页面类型分为两种:</p><ul><li>产品功能页面</li><li>营销活动页面</li></ul><p><img src="/img/remote/1460000044510447" alt="" title=""></p><p>我们的判断是:</p><ul><li>产品功能页面强调 UI 的一致性,有固定的设计规范,需要识别组件。</li><li>营销活动页面创意性强,没有固定的设计规范,无需识别组件。</li></ul><p>所以,我们认为 C2D2C 非常优雅地解决了组件识别的问题。</p><p>但随着业务落地的深入,<strong>我们发现,对于营销活动页面而言,虽然没有既定的设计规范,但也会用到一些通用的 UI Pattern,比如弹窗</strong>:</p><p><img src="/img/remote/1460000044510448" alt="" title=""></p><p>对于此类场景,由于弹窗的样式并不稳定,无法沉淀成规范,这就导致:</p><ul><li>无法通过 C2D2C 进行还原;</li><li>直接 D2C 还原,出码产物将是 div 等基本元素的组合,只有样式,但是没有弹窗的逻辑。</li></ul><p>最后造成前端需要基于已有组件库(比如 antd)进行大量的样式复写,工作量大且低效。</p><p>另外,虽然 D2C 在出码阶段进行了布局优化,但是用户反馈生成的代码可读性还是存在一些问题,特别是生成的 className:</p><p><img src="/img/remote/1460000044510449" alt="" title=""></p><p>总结一下,工程化 2.0 面临的问题主要有 3 个:</p><ol><li><strong>需要提供一套对设计师友好的 C2D 方案。</strong></li><li><strong>需要解决营销活动组件 D2C 还原的问题</strong></li><li><strong>代码的可读性需要优化。</strong></li></ol><h4>思考</h4><h5>什么是对设计师友好的 C2D?</h5><p>在回答这个问题前,我们需要做一些拆解。</p><p><strong>对设计师友好的 C2D = 对设计师友好的工作方式 + C2D</strong></p><p>那什么是对设计师友好的工作方式呢?通过用户调研后发现,对设计师友好的工作方式,有以下几个特点:</p><ul><li>不引入额外成本,设计软件原生支持</li><li>简单好用、符合直觉</li><li>生态繁荣</li></ul><p>具体到设计稿生产,就是能利用 Library 来做设计。</p><p>那 Library 要如何与 C2D 进行结合呢?通过对 Library 调研后发现,Figma / MasterGo 原生提供的 Library 能力很强大,支持 <a href="https://link.segmentfault.com/?enc=Uuna8hx%2BHDdXi6%2F0AvLX9A%3D%3D.DxnDnryw1xmcUh3Ld0yI%2BrnSOssEW3LtDuICtCAW0lprUKV%2Fp2kzXv%2FUwyR%2B3NVeSiwu5L1806IHNqq9jC%2Fk8zOpyW%2BNEeAtZ8Gtxf44KocLOD72uYoiZq9Rr5i8ST6J" rel="nofollow">Component</a> 和 <a href="https://link.segmentfault.com/?enc=j5odE7HETvXLYA%2BVEjn%2F%2Fg%3D%3D.wUJrPBJLBp7ruQ5yfb1sIzOOzD9LX7PeP3Si%2FUycBBmocEYz%2Fh7bd2e2vekzR4pShsG5MxA1HFFpFyxq9AXikw%3D%3D" rel="nofollow">Variant</a> ,能实现设计稿与代码的一一对应:</p><p><img src="/img/remote/1460000044510450" alt="" title=""></p><p>且通过原生提供的属性配置面板,能非常高效便捷地完成组件的配置!</p><p><strong>因此,对设计师友好的 C2D,就是为设计师提供一套海豚组件的 Library,但是这套 Library 是通过 C2D 生成的!</strong></p><p>相应地,我们做 C2D 的思路,就从「<strong>运行时动态生成并注入元数据</strong>」变成了 <strong>「预构建 Library 并注入元数据」</strong>,由于设计稿元数据的格式没变,所以后续的 D2C 流程完全不受影响,完美!</p><h5>D2C 要如何还原营销活动类组件?</h5><p>对于这个问题,现有 D2C 难以解决的原因是:</p><ul><li>若走 C2D2C 的方案,则要求组件具备稳定的样式规范,但这就满足不了活动 UI 个性化的诉求;</li><li>若走常规 D2C 方案,能够解决活动 UI 个性化的问题,但只剩下单纯的视觉还原,丢掉了组件的交互逻辑。</li></ul><p>那有没有既能解决 UI 定制化的问题,又能保留组件的交互逻辑的方案呢?</p><p>于是,我们提出了<strong>基于 Headless UI 的 D2C 方案</strong>。</p><p>作为背景知识,首先简单介绍下 Headless UI。</p><p><a href="https://link.segmentfault.com/?enc=AUDbwkmQD%2FUUzxCNuM5oxQ%3D%3D.MY8PsIxRuYNk6tPIU7Ktg7mro4iilcNinDJrJclXunU%3D" rel="nofollow">Headless UI</a>,顾名思义就是没有样式的 UI,只保留了骨架和交互逻辑,样式完全依靠用户自定义。它可以看成「样式与逻辑分离」在组件库上的一种实践。</p><p><strong>所以,利用 Headless UI,将「样式与逻辑分离」的思想,应用在 D2C 上,不就可以实现了吗?!</strong></p><p><img src="/img/remote/1460000044510451" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><h5>如何做 D2C 产物语义优化?</h5><p>对于这个问题,若在前 LLM 时代,是非常难解的。</p><p>但随着 LLM 时代的到来,GPT 3.5、GPT 4 等大模型的成熟,这个问题变得非常简单了:直接丢给大模型做语义优化即可。</p><p>当然,由于大模型的黑盒性质 + 结果不稳定,需要通过一些工程实践来规避由此带来的不确定性。</p><h4>解法</h4><h5>基于 Library 的 C2D 方案</h5><p>以海豚的 Button 为例,和样式相关的 API 有:</p><table><thead><tr><th>参数</th><th>说明</th><th>类型</th><th>默认值</th></tr></thead><tbody><tr><td>type</td><td>按钮类型</td><td>"outline" \</td><td>"text" \</td><td>"surface"</td><td>surface</td></tr><tr><td>level</td><td>按钮层级</td><td>"primary" \</td><td>"secondary" \</td><td>"normal"</td><td>primary</td></tr><tr><td>ghost</td><td>是否为幽灵按钮</td><td>boolean</td><td>false</td></tr><tr><td>size</td><td>按钮大小</td><td>"m" \</td><td>"xs" \</td><td>"s" \</td><td>"l" \</td><td>"xl" \</td><td>"xxl"</td><td>l</td></tr><tr><td>disabled</td><td>是否禁用</td><td>boolean</td><td>false</td></tr><tr><td>leftIcon</td><td>左侧图标</td><td>ReactNode</td><td>-</td></tr><tr><td>rightIcon</td><td>右侧图标</td><td>ReactNode</td><td>-</td></tr><tr><td>loading</td><td>设置按钮载入状态</td><td>boolean</td><td>-</td></tr></tbody></table><p>如果要构建 Button 的 Library,则需要为 Button 的每一种不同的样式组合,在 Library 中提供一个对应的变体(Variant)。</p><p>我们可以简单估算一下,下面是 8 个与样式相关的参数的枚举值统计:</p><table><thead><tr><th>参数</th><th>枚举数量</th></tr></thead><tbody><tr><td>type</td><td>3</td></tr><tr><td>level</td><td>3</td></tr><tr><td>ghost</td><td>2</td></tr><tr><td>size</td><td>6</td></tr><tr><td>disabled</td><td>2</td></tr><tr><td>leftIcon</td><td>7(默认提供 7 种)</td></tr><tr><td>rightIcon</td><td>7(默认提供 7 种)</td></tr><tr><td>loading</td><td>2</td></tr></tbody></table><p>组合而成的变体数量为: <strong>3 *3*2*6*2*7* 7* 2 = 21168</strong> 个。</p><p>是不是很震惊?</p><p>一个 Button 就有上万个变体,50 多个组件全加在一起,数量将是巨大的。</p><p>人工来做完全不现实。</p><p>所以,肯定要借助<strong>工程化</strong>的手段,<strong>通过脚本来批量生产</strong>。</p><p>得益于我们在 Fin 1.0 中 C2D 技术上的积累,我们通过 html2figma 实现了 Library 的自动化生产。</p><p>以 Button 为例:</p><ol><li>首先,通过脚本在网页上渲染出所有的 Button 变体;</li><li>然后,利用 html2figma 技术,将其批量转换成组件变体,并注入元数据。</li><li>如果需要支持 MasterGo,利用 MasterGo 「导入 Figma 文件」的功能即可,不用重新实现。</li></ol><p>视频演示为:</p><p><img src="/img/remote/1460000044510452" alt="C2D2C 原理演示 - 10-18_2.gif750" title="C2D2C 原理演示 - 10-18_2.gif750"></p><p>这是我们采用此方案构建的海豚组件库 Library :</p><p><img src="/img/remote/1460000044510453" alt="" title=""></p><h5>基于 Headless UI 的营销活动类组件 D2C 方案</h5><p>由于营销活动的组件非常业务化,所以最好是可以将此能力开放出来,让用户自行定义、自行处理。</p><p>为此,我们设计了 D2C 的微插件方案,通过为用户暴露 D2C 生命周期各阶段的 Hook,让用户可以实现:</p><ul><li>自定义标注规范</li><li>出码产物二次处理</li></ul><p><img src="/img/remote/1460000044510454" alt="" title=""></p><p>然后,基于微插件,业务开发利用我们提供的 Headless UI 微插件脚手架,适配到自己的业务场景即可,具体的使用流程为:</p><p><img src="/img/remote/1460000044510455" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p>视频演示:</p><p><img src="/img/remote/1460000044510456" alt="" title=""></p><p><img src="/img/remote/1460000044510457" alt="" title=""></p><h5>基于 LLM 的 D2C 产物语义优化方案</h5><p>如果直接将 D2C 生成的 JSX 和 CSS 输入给 LLM,让其对 className 进行语义化,并输出 JSX 和 CSS,在大部分情况下能正常 work,但是存在两个潜在的问题:</p><ul><li>输入输出包含全部的 JSX 和 CSS,<strong>很容易 token 超限</strong>。</li><li>输出的内容可能是<strong>有损的</strong>(例如代码片段的丢失,输出的新的 className 名称发生了重复),<strong>容错能力差</strong>。</li></ul><p>为此,我们对此过程进行了改造:</p><ol><li>只用输入 JSX,不用输入全部代码</li><li>不再要求其输出完整代码,而是输出「<strong>className 优化前后的名称映射</strong>」</li><li>如果遗漏了某个 className 未优化或者发生了重名,将由后续代码逻辑来校验、兜底(只需要判断是否发生了重复,如果重复额外添加后缀,逻辑非常的简单),并不会对 HTML 文档造成破坏。</li></ol><p>具体的 Prompt 为:</p><pre><code class="markdown">You are a front-end technologist.
Help me process the incoming JSX code so that the className is well semanticised and overall readable.
Then output the mapping relationship before and after the className to me in JSON (direct JSON output). For example:
Input:
```jsx
import React from 'react';
import '. /index.css';
const App = () => {
return (
<div className="music_1_1">
<div className="music_1_2"> 上一步 </div>
<div className="music_1_3"> 下一步 </div>
</div>
);
};
export default App;
```
Output:
```json
{
"music_1_1": "main",
"music_1_2": "prev",
"music_1_3": "next"
}
```
Understood, please reply 1</code></pre><p>这样做的好处是:</p><ol><li>可最小化输入,<strong>节省 token</strong>;</li><li>可基于 LLM 的输出做<strong>容错处理</strong>,添加兜底逻辑。</li></ol><p><a href="https://link.segmentfault.com/?enc=DajNpoFwNmY1Ptd6XDhkdg%3D%3D.F47lqK%2B9Vd9FkdTlJu7CMdthJGgQjpsf2cwj3rIG3hTwN5SAYTZQgroKUHMTeKfbo0KRoJEd919ALFSVMoIZ11LbKy1PWGiiAKM09f6zQvw%3D" rel="nofollow">Prompt 演示</a>:</p><p><img src="/img/remote/1460000044510459" alt="" title=""></p><p>在 D2C 上的最终效果为:</p><p><img src="/img/remote/1460000044510460" alt="" title=""></p><h2>工程化阶段 2.0 ➡️ 智能化阶段</h2><h3>背景</h3><p>时间来到 2023 年。</p><p>回顾过往,设计工程化所解决的问题,主要集中在 <strong>「降低设计与前端的沟通成本」</strong> 和 <strong>「提高前端工作效率」</strong> 上,然而,在「提高设计工作效率」方面,设计工程化的贡献相对有限。</p><p>随着 AIGC 的火爆出圈,在了解到 AIGC 在「提高设计工作效率」上的潜力后,我们决定要利用 AIGC 搞一些事情 👻。</p><p>为此,我们对云音乐的设计师进行了多次田野调查,梳理出了当前的设计流程,并按照需求来源的不同,将其分为两种:</p><p><strong>1、需求源自策划的设计流程:</strong></p><p><img src="/img/remote/1460000044510461" alt="8.jpg750" title="8.jpg750"></p><p><strong>2、需求源自运营的设计流程:</strong></p><p><img src="/img/remote/1460000044510462" alt="7.jpg750" title="7.jpg750"></p><p>通过分析后发现,现有的设计流程存在以下痛点:</p><ol><li>沟通成本高</li><li>设计效率低</li><li>AIGC 使用少、门槛高</li></ol><p>具体情况如下图所示:</p><p><img src="/img/remote/1460000044510463" alt="" title=""></p><h3>思考</h3><p>我们都知道,<strong>问题能被解决的关键,在于是否能清晰地定义问题。</strong></p><p>因此,为了从根本上解决上述问题,我们必须回答:<strong>UI 设计的本质是什么?</strong></p><p>为此,我们可以对设计流程进行抽象和简化,如下图所示:</p><p><img src="/img/remote/1460000044510464" alt="2.jpg750" title="2.jpg750"></p><p>可以看到,UI 设计可以抽象成一个输入输出模型:<strong>输入是自然语言描述的需求,输出是设计稿</strong>。</p><p>因此,<strong>UI 设计的本质,就是一个「将自然语言描述的需求翻译成设计稿」的过程</strong>。</p><p>具体而言,就是将「自然语言描述的需求」翻译成由若干由「组件」、「图标」或「图片」组合而成的设计稿,这个过程可以用下面的公式来表达:</p><p><img src="/img/remote/1460000044510465" alt="" title=""></p><p>那么,<strong>造成的「UI 设计低效」的原因,就在于这个「翻译的过程」大部分是由人参与并执行的</strong>,这是因为:</p><ul><li>多人协作引发了沟通问题。</li><li>技能水平、专业门槛和生理限制导致了效率问题。</li></ul><p>所以,<strong>如果想从根本上解决 UI 设计的效率问题,就应该利用 AIGC 重构这个「翻译过程」</strong>:</p><ul><li>解放设计生产力,摆脱繁琐的设计细节,转而去关注产品的整体功能和体验。</li><li>赋能非专业设计师来做设计,突破职能限制,实现一专多能,「人人都是设计师」。</li></ul><p>而为了实现以上目标,我们需要解决以下三个「翻译问题」:</p><ol><li>文生图:如何让 AI 理解设计意图,生成图片?</li><li>文生 ICON: 如何让 AI 理解设计意图,生成图标?</li><li>文生稿:如何让 AI 理解设计意图,并用组件和素材(图标、图片)搭建出 UI ?</li></ol><h3>解法</h3><p>为此,我们推出了全新的产品——Fin 2.0,提供三大 AIGC 能力矩阵(文生稿、文生图、文生 ICON)+ AIGC 资产共享中心,赋能策划、运营、设计,降低沟通成本,提高设计效率,让业务创新变得简单。</p><h4>AIGC 能力矩阵</h4><p><img src="/img/remote/1460000044510466" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p><strong>文生稿</strong>:</p><ul><li>赋能产品 / 运营,将「文字需求稿」转换成高保真的设计稿,减少沟通环节。</li><li>赋能设计师,提供低成本的创意、灵感来源和竞品分析能力。</li></ul><p><strong>文生图</strong>:</p><ul><li>赋能设计师,降低插画生产的时间成本。</li><li>基于 DreamMaker 二次封装,提供易用的文生图功能,降低文生图使用门槛。</li><li>DreamMaker 为内部平台,消除了数据安全的隐患。</li></ul><p><strong>文生 ICON</strong>:赋能设计师,降低 ICON 生产的时间成本。</p><p><strong>AIGC 资产共享中心</strong>:对用户 AIGC 过程中产出的设计组件、提示词、图片和 ICON 进行回流沉淀,共享复用。</p><h4>未来工作模式</h4><p><strong>1、需求源自策划的设计流程</strong></p><p><img src="/img/remote/1460000044510467" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p><strong>新流程特点:</strong></p><ul><li>赋能策划,利用「文生稿」直接将文字需求转为 高保真初稿,避免了设计出多套方案 & 反复对焦,降低沟通成本。</li><li>设计基于 高保真初稿 进行二次修改,利用 「文生图」和「文生稿」生产物料,提高设计效率。</li><li>AIGC 的产物(图片、ICON、设计组件、提示词),最后都会回流进 DOLA AIGC 资产库,实现共享复用。</li></ul><p><strong>2、需求源自运营的设计流程</strong></p><p><img src="/img/remote/1460000044510468" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><p><strong>新流程特点:</strong> 赋能运营,基于「文生稿」功能,搭配「文生图」和「文生 ICON」直接出稿,免去了和设计之间沟通协作,提高了设计效率。</p><h3>产品设计</h3><p>对于 AI 驱动的应用而言,单纯的 AI 能力(GPT 3.5/4、Stable Diffusion)并不能构成产品的核心竞争力,因为大家都是调包工程师。(笑</p><p>所以,核心竞争力在于是否具备<strong>产品力</strong>,用大白话讲,就是<strong>是否能真正解决实际问题</strong>。关于这这一点,不管是内部产品还是外部产品,同样适用。</p><p>所以,一个好的产品方案至关重要。</p><p>为了实现这一目标,Fin 2.0 的产品设计遵循以下原则:</p><ol><li><strong>AI is the UI</strong></li><li><strong>小而美</strong></li></ol><h4>AI is the UI</h4><p>在 LLM 时代,AI 的内涵和外延都应该被重新定义:<strong>AI 既是一种技术,也是 UI 本身,是人与机器交互的终极方案。</strong></p><p>不管是 <a href="https://link.segmentfault.com/?enc=E4kjmRbSnHedn5lVGZQt%2FA%3D%3D.6QBvkCNtTN%2FY4Eo5a2shqTNKyVqzN8IgMABspK9mirU%3D" rel="nofollow">ChatUI</a>、 <a href="https://link.segmentfault.com/?enc=vVRCC1Zchf9mxGEAUAAZJA%3D%3D.jiXR2GaUSdYVw8IgSyiNKIZ%2BI7A3acmgwm4oQC%2FspEHQ%2FwRmDfmwPXkHbZ8ZFE2LPknNSYsyJVEszo%2B6FF1dOw%3D%3D" rel="nofollow">Conversational UI </a>还是 Dialog UI, 都是 AI 这种全新 UI 的实现。</p><p>某大佬曾言:在 LLM 时代,所有应用都值得被 AI 重做一遍。</p><p>我的理解是:这句话的本质,讲的其实就是将现有的 GUI 重构成 AI 这种 UI。正如在图形界面时代,所有的 CLI 应用被 GUI 重做一样。</p><p>所以,我们基于 AI 这种全新的 UI 来设计产品交互,通过自然语言对话的方式提供一个「超级入口」,轻松触达所有功能,比如:</p><ul><li>文生稿</li><li>文生图</li><li>文生 ICON</li><li>设计规范问答</li><li>换肤</li><li>字高修复</li><li>位图转矢量图</li><li>...</li></ul><p><img src="/img/remote/1460000044510469" alt="" title=""></p><p><img src="/img/remote/1460000044510470" alt="Kapture 2023-12-13 at 20.41.06.gif750" title="Kapture 2023-12-13 at 20.41.06.gif750"></p><p><img src="/img/remote/1460000044510471" alt="" title=""></p><h4>小而美</h4><p>「小而美」也是我们产品设计的一个重要理念。但是,我们需要明确一个事实:<strong>小而美是实现路径,而非目标,产品的目标永远是创造价值</strong>。</p><p>在产品从 0 到 1 的阶段里,小而美是为了控制成本,聚焦产品,是非常必要的:<strong>永远是做简单且完整的产品,不是复杂事物的 0.1 版,而是简单事物的 1.0 版</strong>。</p><h5>何为「小」?</h5><p><strong>1、信息架构简单且清晰</strong></p><p>充分利用对话式 AI 的优势,保证整体的信息架构简单且清晰,层级结构尽可能简单,2 层是极限。</p><p><img src="/img/remote/1460000044510472" alt="" title=""></p><p><strong>2、功能简单但完整</strong></p><p>简单且完整的功能,除了能解决问题外,还能带给人秩序感和愉悦感:</p><p><img src="/img/remote/1460000044510473" alt="" title=""></p><p><strong>3、聚焦</strong></p><p>通过聚焦,砍掉不必要的功能,降低研发投入:</p><p><img src="/img/remote/1460000044510474" alt="" title=""></p><h5>何为「美」?</h5><p><strong>1、精美的图标</strong></p><p><img src="/img/remote/1460000044510475" alt="" title=""></p><p><strong>2、合理的排版</strong></p><p><img src="/img/remote/1460000044510476" alt="" title=""></p><p><strong>3、流畅的动效</strong></p><p><img src="/img/remote/1460000044510477" alt="" title=""></p><p><strong>4、合理的引导和提示</strong></p><p><img src="/img/remote/1460000044510478" alt="" title=""></p><h3>核心功能演示</h3><h3>文生图</h3><p><img src="/img/remote/1460000044510479" alt="" title=""></p><h3>文生稿</h3><h4>生成视觉稿</h4><p><img src="/img/remote/1460000044510480" alt="" title=""></p><h4>生成线框稿</h4><p><img src="/img/remote/1460000044510481" alt="" title=""></p><h3>技术方案</h3><h4>Chat UI</h4><p>Fin 2.0 Chat UI 的技术架构为:</p><p><img src="/img/remote/1460000044510482" alt="" title=""></p><p>其基本流程是:</p><ol><li>用户通过自然语言与 Fin 2.0 对话</li><li>Fin 2.0 利用 Adora(云音乐 LLM 基建)进行意图识别,转换成 Action</li><li><p><strong>Dispatch Action</strong>,根据 Action Type 的不同,执行不同的操作:</p><ol><li>路由跳转</li><li>渲染图文消息</li><li>唤起微应用</li></ol></li></ol><p>以上流程中,最核心的部分是<strong>意图识别</strong>。</p><p>在前 LLM 时代,意图识别一般采用 <strong>NLP</strong> 来实现,<strong>其成本高,准确率低</strong>。<br>LLM 时代到来后,意图识别变得非常简单和直接。</p><p>比如,我希望用户在输入「文生图」后,可以识别此意图,并自动路由到「文生图」页面上。现在只需利用 GPT 的 Few-shot learning 能力, 给出类似下面的 Prompt 即可:</p><pre><code class="md">System:
You need to analyze the content of Inputs based on the information of Resources, follow the constraints of Constraints, and return data that conforms to the Response Format format
Input:
打开文生图
Constraints:
1.According to Inputs, match a most relevant command KEY
2.If there is no suitable match in the preset instructions, "NOOP" is used by default
Resources:
"TEXT_TO_IMG":[STRING]当用户想要打开「文生图」功能时命中,例如输入 「打开文生图」「文生图」「AI 生图」
"TEXT_TO_DESIGN":[STRING]当用户想要打开「文生稿」功能时命中,例如输入 「打开文生稿」「文生稿」「AI 生稿」
"TEXT_TO_ICON":[STRING]当用户想要打开「文生 ICON」功能时命中,例如输入 「打开文生 ICON」「文生 ICON」「文生 icon」「AI 生 ICON」
You should only respond in JSON format as described below
Response Format:
{
"payload": “the matched instruction key",
"type": "route",
}
Ensure the response can be parsed by Javascript JSON.parse();
For example:
Input:
文生图
output:
{
"payload": "TEXT_TO_IMG"
"type":"route"
}
Understood, please reply 1</code></pre><p>GPT 经过学习之后,就能充当一个非常好的<a href="https://link.segmentfault.com/?enc=BjW1vwzF0o9HNjluhv7R0A%3D%3D.dRmVv5DdlPp3AQmHeGn6sn7NrpalA7iOnWpSDOTWO03KkHy9fsj8KuQPWOUBZp6tDrKDaQSAc2e%2BXVCOcrHWNJLGwHhyGg%2FWgtl134UHkeg%3D" rel="nofollow">意图识别器</a>:</p><p><img src="/img/remote/1460000044510483" alt="" title=""></p><h4>文生图</h4><p>对于「文生图」而言,图片的 AIGC 已比较成熟,不管是闭源的 Midjourney,还是开源的 Stable Diffusion,都能生成效果非常棒的作品。</p><p>但正如在上面的「痛点」中所提到的:</p><ol><li>Midjourney 费用开销较大,对于保密项目存在着数据安全的风险。</li><li>内网部署的 Stable Diffusion(DreamMaker)参数配置复杂,使用门槛高。</li></ol><p>所以,综合考虑收益和成本后,最终的方案是:基于内网部署的 Stable Diffusion(DreamMaker)进行二次封装,提供简单易用的「文生图」方案。</p><p>更为详细的介绍,可以参考:<a href="https://link.segmentfault.com/?enc=iu8uNz7GG5leWLirIemCfA%3D%3D.18Sk1UXRgd4eD3v5ezFFkaGxCMunrFMRNOngiqaQfT1QjDId6CmDXVaOH6mkooV8" rel="nofollow">《如何使用 Fin2.0 文生图登上云音乐首页》</a></p><h4>文生 ICON</h4><p>对于「文生 ICON」 而言,SVG 矢量图标的 AIGC ,业界暂无成熟方案。</p><p>但是,学术界已有了相关尝试:借助 Stable Diffusion 和 <a href="https://link.segmentfault.com/?enc=EhBbVjRnU7fVWT7WPM3v9A%3D%3D.%2BtyFO2UL9TBLU2dtM2tKw4n8Q6c2TLSWRvMl%2Fb3EZcY%3D" rel="nofollow">VectorFusion</a> 技术,可以实现「文生 ICON」。然而,此方案仍处于实验阶段,暂无法用于生产。</p><p>所以,考虑到实际情况后,最终的解法是分阶段来实现:</p><ol><li>第一阶段(本期),整理优质的可商用的图标资源,并提供语义化检索功能,满足用户找图标的诉求;</li><li>第二阶段,待社区有了相关实践后,基于已有数据集,利用 Stable Diffusion + Lora 训练 ICON 的像素模型,并搭配 <a href="https://link.segmentfault.com/?enc=r9JpuploeennILZiaa9MHA%3D%3D.VGHORukig36GeoLDtlbTd%2F16VLwojM2HYkxDBGGtDoU%3D" rel="nofollow">VectorFusion</a>,实现生图标的需求。</li></ol><p>语义化检索最大的优势,就是根据语义进行检索,不是传统的「关键字匹配」,更好用,更符合人类直觉。</p><p>因为图标的数量很大,有接近 2 万个,要怎么用 ChatGPT 实现 语义化搜索呢?如果直接将其作为 ChatGPT 的上下文输入,必然会超限,而且也会存在较大的 IO 性能问题。</p><p>为此,我们采用 embedding API 来实现,其基本原理是:</p><p><img src="/img/remote/1460000044510484" alt="" title=""></p><p>首先,将所有的 ICON 数据标准化成下面的格式:</p><pre><code class="json">{
"id": 17246,
"name": "zoom",
"library": "icon-park",
"label": "滑动,侧滑,放大,zoom,Hands,手势动作",
"style": "outlined"
}</code></pre><p>然后, 通过 OpenAI 的 embedding API 进行向量化,并存储到向量数据库中,比如 pinecone,或者 chroma。</p><p>这里需要注意的是,<strong>由于 API 字符数的限制,需要使用文本分词器进行分批向量化</strong>。</p><p>最后,用户通过关键字进行语义搜索时,首先会对关键字进行向量匹配,向量数据库会按照相似度返回近似结果,然后将此结果连同用户的原始输入,一并提供给 ChatGPT,ChatGPT 就会返回在语义上最匹配的 ICON 了。</p><h4>文生稿</h4><p>对于「文生稿」 而言,问题就稍为复杂一点。</p><p>大语言模型 LLM 能很好地理解自然语言,但由于其输入输出是基于文本的,所以并不能直接生成设计稿。因此,这中间必然有一个 Text2Design 的过程。</p><p>于是,就有了下面两种方案:</p><p><strong>方案一:LLM 返回 HTML,通过 C2D 技术转成设计稿。</strong></p><p><img src="/img/remote/1460000044510485" alt="" title=""></p><ul><li><p><strong>优势</strong>:</p><ul><li>实现成本低</li><li>HTML 灵活,自由度大</li></ul></li><li><p><strong>缺点</strong>:</p><ul><li>难以与 Design System 关联,形成统一的样式规范</li><li>C2D(html2figma、html2mastergo) 还原度无法保证 100%</li></ul></li></ul><p><strong>方案二:LLM 返回自定义 DSL,解析 DSL 转成设计稿。</strong></p><p><img src="/img/remote/1460000044510486" alt="" title=""></p><ul><li><p><strong>优势</strong>:</p><ul><li>DSL 可以做到结构简单、精炼</li><li>能与 Design System 关联</li><li>不依赖 C2D 技术,避免了潜在的还原度问题</li></ul></li><li><strong>缺点</strong>:自行设计和实现 DSL 协议和渲染,有一定的开发成本,但是并不复杂。</li></ul><p>考虑到「文生稿」需要与设计系统结合,最终选用了方案二。</p><h5>DSL 的设计</h5><p>我们设计的 DSL 结构非常简单,每个节点只有两个属性,componentName 和 props:</p><pre><code class="ts">interface NodeDSL {
componentName: string;
props?: Record<string, any>;
}
type DSL = NodeDSL[];</code></pre><p>但是利用 Figma / MasterGo 的 Component 和 Variant 能力,就能释放强大的表达能力(有点类似前端的可视化搭建):</p><pre><code class="tsx">const page: Page = [
{
componentName: 'StatusBar',
props: {
title: '歌单列表 & 专辑卡片',
},
},
{
componentName: 'List',
props: {
title: '歌单列表',
content: [
{
title: '张杰新歌',
subTitle: '曲风:流行',
icon: '🎵',
},
{
title: '周杰伦经典',
subTitle: '曲风:流行',
icon: '🎧',
},
{
title: 'KTV 最爱',
subTitle: '曲风:流行',
icon: '🎤',
},
{
title: '说唱力 MAX',
subTitle: '曲风:说唱',
icon: '🔥',
},
{
title: '粤语老歌',
subTitle: '曲风:粤语',
icon: '🎵',
},
],
},
},
{
componentName: 'Card',
props: {
title: '推荐专辑',
content: [
{
title: '跨时代',
tag: '周杰伦',
icon: '🎧',
},
{
title: '周杰伦的床边故事',
tag: '周杰伦',
icon: '🎤',
},
{
title: 'Universe',
tag: '杨峰',
icon: '🎵',
},
{
title: 'F.A.M.E.',
tag: '马尔代夫',
icon: '🔥',
},
{
title: '语重心长',
tag: '林宥嘉',
icon: '🎵',
},
{
title: '灿烂人生',
tag: '林忆莲',
icon: '🎧',
},
],
},
},
];</code></pre><h5>Prompt & 意图识别</h5><p>为了能让用户用自然语言准确地描述设计需求,我们对 Prompt 进行了规范:</p><p><strong>Prompt = 动作 + 主体 + 主题色 + 设计风格 + 布局</strong></p><p>比如下面的 Prompt:</p><pre><code class="txt">设计一个音乐 App 首页,主题色为蓝色,扁平化风格,采用瀑布流</code></pre><p>我们利用 ChatGPT 实现了一个简单的意图处理器(和 Chat UI 部分意图识别类似,不再展开),可以将用户的输入转换成下面的结构化数据:</p><pre><code class="json">{
"actionType": "add",
"style": "flat",
"main": "一个音乐 App 首页",
"theme": "#0000ff",
"layouts": "flow"
}</code></pre><p>有了这样的结构化数据后,用户的意图就变得清晰了,方便后续利用不同风格的组件库、布局模版模仿人类来搭建设计稿。</p><h5>让 ChatGPT 学会使用组件</h5><p>通过上面所说的「意图识别」后,我们已经能够明确用户的设计需求了。那怎么让 ChatGPT 利用已有的物料模仿人类完成搭建呢?</p><p><strong>问题的关键在于让 ChatGPT 学会使用我们提供的组件库。</strong></p><p>因为 ChatGPT 拥有非常强大的文本理解能力,所以我们的做法其实非常简单:<strong>直接将组件的 API 文档作为上下文提供给 ChatGPT</strong>。</p><p>这种做法看似粗暴,但是效果出乎意料的好。下面是一个简化了的小 Demo:</p><p><img src="/img/remote/1460000044510487" alt="ChatGPT.png750" title="ChatGPT.png750"></p><p>具体的 Prompt 对话可见:<a href="https://link.segmentfault.com/?enc=p%2FQ5Kcj3U3ear%2B94rX%2Ft%2BQ%3D%3D.xMNYpAJOSUk2%2F8zREj2pSYGfCqRVmikhxvi1telJW7I%2FeWn%2F8hMyt%2BxBs58kESbLlVTEFLWg9Ew9qhKNTDbVHq51WFrwd54mp6jSeLIfyL8%3D" rel="nofollow">https://chat.openai.com/share/69aee90a-101f-4356-87fe-e59729e...</a></p><p>当然,实际在项目中的使用并没有这么简单,需要考虑很多工程上的问题,比如:</p><ul><li>token 超限的问题</li><li>组件隔离的问题</li><li>换肤的问题</li></ul><p>这些问题解决起来都不难,鉴于本文已经很长了 😅,就不再展开了。</p><h3>落地效果</h3><p>截止到今天(2023-12-26),Fin 2.0 已累计生图 11360+,产出设计稿 921+,覆盖云音乐 10+ 业务场景,综合提效 33% ~ 200%。</p><p><img src="/img/remote/1460000044510488" alt="" title=""></p><h2>总结展望</h2><p>网易云音乐的设计协同经历了原始阶段、工程化阶段 1.0 和 2.0,目前已进入智能化阶段。</p><p>尽管智能化刚刚起步,但充满了潜力和想象空间,尤其是近期 AI Agent 技术的蓬勃发展,将彻底重构现有的协同流程。</p><p>因此,在未来,我们将持续探索基于 AI Native 的智能化设计协同,打造云音乐设计生产一体化方案——AI2D2C 👏。</p><p><img src="/img/remote/1460000044510489" alt="Dolphin.jpg750" title="Dolphin.jpg750"></p><h2>鸣谢</h2><p>筚路蓝缕,以启山林,最后感谢为云音乐设计协同添砖加瓦的每一个人 ❤️,他们是:</p><ul><li>研发人员:葛星、刘甲、魏慷、李磊、章伟成、张永聪、徐超颖、尤振飞、邵锁</li><li>设计人员:吕峰、张渝堃、徐晓强、顾容玥、关昊斌、袁安、王孟锴</li></ul><p>感谢你们!</p><h2>相关链接</h2><ul><li><a href="https://link.segmentfault.com/?enc=sGtZiIt1USux9%2FriTeMNqA%3D%3D.Cr7lMaeZjG%2BTUNXrzedtvGYix%2FsJ5duEpluUZBVmB2CKkkZ26apgtcp7uD4JzuOOu%2Fr8HcHzb2nC6Er%2FLdgt%2Fc925eMScdEhHf1Qoqz7O80t20p1gb1r%2FDJyEOPvNzWS" rel="nofollow">海豹 D2C Figma 插件</a></li><li><a href="https://link.segmentfault.com/?enc=J6HHmDyksZSqGSw4VQkXGA%3D%3D.xSsRqy3NbTURYdx60aiuhKH%2Fo2hAPT5aekMye7QXnKDJYCwDgZYdHnMoucEeHrsYroMIR3YaY0OS1faYddyMlg%3D%3D" rel="nofollow">海豹 D2C MasterGo 插件</a></li><li><a href="https://link.segmentfault.com/?enc=Fts8%2FRWVpXNH0UBoKunYKA%3D%3D.E5P7%2BzQdPl40ZSYa9XMt9GqdBiXHhSoeTZSn7jOk3Ao%3D" rel="nofollow">海豹 D2C 官网</a></li><li><a href="https://link.segmentfault.com/?enc=tUtLRtWghc8%2F0r2T64v87g%3D%3D.3tBoJFpK3NpNz1yVllHohv2Zqm%2BeaBhufX3kSNN6mtW2%2Ful4qmPsfB8qA8puu6Spip%2BlgkUo3591IAKYnCYLwQ%3D%3D" rel="nofollow">《网易云音乐基于 C2D2C 的无损设计协同》</a></li><li><a href="https://link.segmentfault.com/?enc=9DpFNU1rUDhXjUwagQMVsA%3D%3D.pmztV9YejPe9nKBxdNKuLN%2F2IpQ9mc4%2Fddk8UhP65ySlkcWpjj3An89zYj6AStbb" rel="nofollow">《如何使用 Fin2.0 文生图登上云音乐首页》</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044510490" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=Cq9Nj9mHKgbtLxsxqmx0wg%3D%3D.Fm11jBoU3XKwlBV9NpfWmihzY5eqbIjcWglwNGtnNuc%3D" rel="nofollow">https://hr.163.com/</a></p>
网易云音乐 RN 升级前端篇
https://segmentfault.com/a/1190000044507038
2023-12-27T10:50:24+08:00
2023-12-27T10:50:24+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:黄喆</blockquote><p>本文将从前端的角度来聊一聊网易云音乐 RN 升级的实践与思考,以及其中一些决策的依据。</p><p>文章《网易云音乐 RN 新架构升级实践》总体介绍了云音乐 RN 在升级过程中遇到的问题及解决方案,本篇文章将会进一步聚焦,讲一讲前端在升级过程中做的一些工作。整个升级过程大致分为四个阶段:调研、方案设计、实施、分流验证。除了分流验证阶段,其余三个阶段前端都深度参与其中,接下来将按照顺序来逐一介绍。</p><h2>调研</h2><p>凡事预则立,不预则废。好的调研方案,可以明确收益和风险,便于我们评估 ROI,整体收益、风险前文已详细描述,这里不在赘述。<br>相对于客户端关注底层的一些变化,对前端业务开发来说关注的更多是 API 层面的 break change。因为 break change 会切实影响我们的升级方案,因此需要明确影响范围,并给出具体的解决方案,这样在正式升级时才能做到心中有数。</p><p>调研最开始我们使用 <a href="https://link.segmentfault.com/?enc=ywW2AVnG4cAZl6Cu3oGF1A%3D%3D.PLiKjDzuTdVEWpMSfn7bzgJfRGgDy1Pjgmt4VLQkD62qC%2FxviK2II7EwR3bs36YfoW%2BrHAnnUGv6pjgqi%2FgOiQ%3D%3D" rel="nofollow">react-native 升级工具</a> 查看需要升级的依赖,比如 babel、react,这里相对简单只给出了最基础的依赖。对于更多的三方依赖、内部组件则需要人肉一个个去筛查出来。</p><p>以筛查出来的基础依赖为根基,接着查看 RN 以及所有依赖的 changelog 和 commit 信息,梳理出版本升级全部的 break change,并根据业务使用情况整理出需要重点关注的 break change,并给出解法。当然实际情况远比这复杂,还需要考虑依赖之间的互相依赖情况,以及隐式依赖。由于实际依赖的情况异常复杂,调研是不可能面面具到的,但是调研的越仔细,对于后续的压力愈小,因此我们做了非常详细的调研。下面介绍一些在调研阶段就发现的 break change 及其解法。</p><h3>Break change</h3><p>不同的 break change 影响范围不同,兼容方法不同,升级策略也不同。这里介绍几个常用的升级策略,以及其适用的 break change 类型。</p><h4>patch-package 打补丁</h4><p>patch-package是一个用于修补(patch)npm软件包的工具,所谓打补丁是指在不修改原始 npm 包的情况下,对其进行补丁修复。<br>有些基础能力涉及范围特别广,几乎不可能一个个去改,但是使用起来相对简单,此时就可以用这个方法。<br>比如官方包移除了 Image.propTypes, Text.propTypes 等一系列 propTypes<br>总共移除了7个类似的 propTypes 我们调研发现大量的内外部依赖使用了这些能力,包括依赖的依赖,如果一个个修改起来是工作量是非常大的。针对这类问题我们使用 <a href="https://link.segmentfault.com/?enc=JlA9TX%2Fl84gQP3e%2FzCtGCA%3D%3D.4jPLb0s8SU5I%2FVkyq7%2FZfN47E7ryuSdajmIW%2BJKADO7napgHwCRTXQL6D%2BbGKj%2Bg" rel="nofollow">patch-package</a> 给 react-native 官方包来打补丁。当然不仅限于这些变动,对于一些不方便升级依赖都可以使用此方法打补丁兼容,具体步骤如下</p><ul><li>按照 patch-package 文档生成模板 patch 文件</li><li><p>在升级工程目录下创建如下 patch 文件(上一步生成的)</p><pre><code>diff --git a/node_modules/react-native/index.js b/node_modules/react-native/index.js
index d59ba34..1bc8c9d 100644
--- a/node_modules/react-native/index.js
+++ b/node_modules/react-native/index.js
@@ -435,32 +435,16 @@ module.exports = {
},
// Deprecated Prop Types
get ViewPropTypes(): $FlowFixMe {
- invariant(
- false,
- 'ViewPropTypes has been removed from React Native. Migrate to ' +
- "ViewPropTypes exported from 'deprecated-react-native-prop-types'.",
- );
+ return require('deprecated-react-native-prop-types').ViewPropTypes;
},
};
...</code></pre></li><li>项目依赖增加包 deprecated-react-native-prop-types</li><li>工程脚本增加 npm hook <code>"postinstall": "npx patch-package"</code></li></ul><h4>写法兼容</h4><p>所谓写法兼容,是指通过判断属性、方法是否存在来决定使用方式。比如 Animated 组件的 ref 移除 getNode 方法,在 RN@0.65 之前,获取 Animated 组件实例需要使用 ref 的 getNode 方法,在这之后,直接使用 ref 即可,参考下面示例</p><pre><code>...
if (this.scrollView.getNode) {
this.scrollView.getNode().scrollTo(...);
} else {
this.scrollView.scrollTo(...);
}
...
<Animated.ScrollView
ref={(scrollView) => { this.scrollView = scrollView; }}
...
>
<Animated.ScrollView></code></pre><p>由于该方法使用不多,直接在升级文档中标注了需要业务方按需自行修改,没有使用类 jscodeshift 的方式通过编译来解决。<br>类似的 break change 还有 <a href="https://link.segmentfault.com/?enc=i5SjGvaPXVA1vkcdJdVnqw%3D%3D.qguJkFHzpxu82iHlQrlA0nrPSqyfo3wSJQo4KSnlLrcT34F1QjiIfUh2RPue2MkGTQyUoWiZnuALALxNq4gbVnOEbtBzp5ISLJtryTwBJSEhc8dIc%2FNbvd5OklmLQGCA" rel="nofollow">移除removeListener</a>,<a href="https://link.segmentfault.com/?enc=n1Vq5v89IvuKquKtOB18eg%3D%3D.MP9UXLp43KnbOMRb3rAFExpnh9yFWZAIMeGZNwrxeY54zObUKpxYHXjLa8wuY5VzxA8k5HOvwQNAZLecPCOhC3RG5ejW4MDIZ5EJ0nDnnGyqMalxenJC0KVfbjKKkKsH" rel="nofollow">Image 组件移除 width,height 属性 </a> 等等。</p><h4>能力下沉</h4><p>对于无法通过写法来同时兼容 0.60、0.70 的 break change,可以将能力下沉到组件,用组件的两个版本分别适配 0.60、0.70,上层暴露相同的 API 来处理。<br>比如 react-native-pager-view 升级之后名称变为 @react-native-community/viewpager,接口也不再一致,而且 @react-native-community/viewpager 开启了 TurboModule,在 0.60 版本,构建时因为缺乏相应的 codegen 就会报错。这时可以将使用到 react-native-pager-view 的地方封装成组件提供给上层使用。</p><pre><code>// 0.70 版本
import ViewPager from '@react-native-community/viewpager'
const WrapperViewPager = () => {
// 组件实现
...
return (
<ViewPager {...props} >
)
}
// 0.60 版本
import ViewPager from 'react-native-pager-view'
const WrapperViewPager = () => {
// 组件实现
...
return (
<ViewPager {...props} >
)
}
// 业务使用
import WrapperViewPager from 'WrapperViewPager'</code></pre><h3>依赖升级</h3><p>break change 不仅会影响业务代码的实现,更多的其实是在依赖里面。依赖可以分成三类:</p><ul><li>第一类是官方包,比如 react-native、react、@react-native-community/cli、metro 等一系列 RN 配套。</li><li>第二类是云音乐常用的 RN 社区依赖,比如:react-navigation、react-native-svg、react-native-gesture-handler。</li><li>第三类就是内部封装的各类组件,可以分为基础依赖,比如:@music/mnb-rn (底层 bridge)、utils,以及各式各样的业务包。</li></ul><p>依赖是一环套一环的,第一类升级之后会影响第二第三类,第二类会影响第三类,第三类之间也可能互有影响,所以最后梳理下来需要升级的包有 60+。<br>当然这不是一个应用的,而是所有应用使用到的依赖集合,每个应用按使用情况略有不同。对于需要升级的依赖我们有一个基本原则所有修改尽量是在底层,底层做好兼容, 保证 API 不变,确保业务升级时是无感的。</p><h4>社区依赖升级</h4><p>由于我们需要升级到版本 0.70 刚推出没多久,大部分社区依赖还没有适配完成,部分依赖虽然完成适配,但其自身有大量 break change,这会造成适配工作的成倍增长。针对这种情况,我们将依赖分成三类来处理。</p><h5>依赖自身 API 变动非常大</h5><p>比如 react-navigation 我们当时使用的是 4.x 版本,当时社区已经迭代到了 7.x 版本了,从 4.x 到 7.x API 变动非常大,业务升级成本非常高。<br>经过评估 4.x 其实已经满足我们的业务需求,因此对于 react-navigation 我们仍使用 4.x 版本,同时为了适配 0.70 版本,我们将 react-navigation 私有化处理。</p><h5>API 变动小,但是没有适配 0.70</h5><p>还有一些依赖虽然未适配 0.70,但自身这些年也有诸多能力升级,且都是底层变动或者 bugfix,对业务适配影响不大,升级上来百利而无一害,这些我们选择了升级。对于不适配 0.70 版本的地方<br>通过私有化的方法来处理,比如:react-native-gesture-handler、react-native-linear-gradient 等。</p><h5>无需适配</h5><p>当然也有些神仙依赖什么都不用改,在 0.60 和 0.70 都可以运行,比如:react-native-screens、react-native-swiper。</p><h4>内部依赖升级</h4><p>内部依赖的升级主要是在两个方面,一个是前文提到的 break change 适配;一个是其自身依赖的升级,主要就是前面提到的官方依赖、社区依赖。这里重点说下依赖的升级,<br>在梳理内部依赖的过程中发现大量的历史债务(版本依赖不正确),比如 react-native 版本写死的 0.60,又或者不同的依赖使用不同版本基础依赖,导致最后打包进两份相同的依赖,在普通 H5 应用中或许不是大问题,但在 RN 中就会导致页面红屏。</p><p>其实对于云音乐里的 RN 应用来说因为使用的都是同一个容器,因此依赖的 react-native 版本完全是由容器来决定的,因此声明对 react-native 的依赖完全可以放入 peerDependencies,<br>版本用 * 描述,类似的还有 react-native-gesture-handler、react-native-linear-gradient 等有客户端依赖的组件。</p><pre><code>{
"dependencies": {
- "react-native": "0.60",
- "react-native-gesture-handler": "^1.3.0"
...
},
"devDependencies": {
+ "react-native": "*",
+ "react-native-gesture-handler": "*"
...
}
}</code></pre><h2>方案设计</h2><p>因为客户端在运行时 RN 0.60 和 RN 0.70 不能共存,使用 0.70 版本 or 0.60 版本没办法以 RN 应用是否升级完成决定。所以 100 多个 RN 应用需要同时完成升级,而同时业务不能停,相当于给飞驰的汽车换轮子,稳定性压力是非常大的,这就需要我们有很好的灰度验证方案。但前端又不同于客户端,RN 应用没办法分流验证,每次使用的都是同一套构建产物。鉴于这一特性最初考虑了两套方案。</p><h3>RN 应用按版本分叉</h3><p>此举就是和客户端分流逻辑保持一致,客户端灰度期间、RN 应用基于当前 master 拆分出一个分支,比如 0.70 单独升级维护,业务日常交付<br>依然使用原来的 master 分支,升级的分支 0.70 根据业务需要不定时同步 master 分支。待升级验证完成之后再将 0.70 修改同步到 master。<br>这套方案的最大好处就是,升级的代码分支不会影响现有业务运行,所有改动都在灰度的分支上。但可惜的是与我们的好多基础<br>设施不兼容,比如投放、部署、数据平台等,为了升级而去改动他们是不明智的,会导致影响范围的扩大化,不符合我们再最小集内完成升级的原则。</p><h3>一份代码两份 bundle</h3><p>源代码是同一套,但是同时产出 0.60 版本的 bundle 和 0.70 的 bundle, 客户端按需获取。支持 0.60 版本的客户端就拉 0.60 版本的 bundle;支持 0.70 版本的客户端就去拿 0.70 版本的 bundle。乍一听很疯狂,仔细想想也不是不可能,RN 自身的构建也是同一份源码分别产出支持 IOS、Android 的 bundle;其次经过我们前期的调研可以知道,RN 升级导致的 break change 是可枚举的,小部分可以通过写法来同时支持 RN 的 0.60 和 0.70 版本,而对于无法通过写法兼容的变动,可以转换为组件版本的切换问题。<br>因此这套方案的主要问题就是解决不同版本的依赖问题。基于此我们产出了如下的打包方案<br><img src="/img/remote/1460000044507040" alt="image" title="image"></p><p>在验证时很快发现这个方案有两个明显的问题。一个是由于我们的打包工具也是作为一个依赖放在 npm 包里的,在删除依赖时无法删除干净,导致再次打包 0.60<br>版本 bundle 时会出现各式各样莫名其妙的错误。</p><p>还有一个就是依赖的管理问题。在打 0.60 版本 bundle 时对于需要修改版本的依赖时无法确定其对应的 0.60 版本的依赖,<br>同时对于私有化的社区依赖,在引用时是使用未私有化的包名 react-navigation 还是私有化后的名字 @music/react-navigation,使用<br>react-navigation 时在打 0.70 bundle 时会报依赖找不到,反之则是在打 0.60 bundle 时找不到依赖。</p><h4>依赖提升</h4><p>针对第一个问题,我们使用了依赖提升的方案,将原先安装在 RN 应用工程包里的打包工具安装到打包机器上,每次构建时先全局安装打包工具。<br>因为打包工具提升到全局,这样删除应用工程依赖时可以做到删的干干净净。再次打包产出的 0.60 版本 bundle 也就没有问题了。</p><h4>依赖管理</h4><p>针对无法确定 0.60 版本 RN 的依赖版本问题,我们想到在 package.json 增加一个配置保存适配 0.60 版本的包版本。</p><pre><code>...
"degrade": {
"devDependencies": {
"@babel/core": "^7.5.5",
...
},
"dependencies": {
"react-native": "0.60.5",
...
}
}
... </code></pre><p>至于私有化依赖的问题,我们决定通过 babel-plugin-module-resolver 的 alias 功能来处理。针对 0.70 版本增加如下<br>babel 配置</p><pre><code>"alias": {
...
"react-navigation": "@music/react-navigation",
...
}</code></pre><p>同时在 0.60 版本打包时删除对应配置</p><p>最终我们的打包流程如下。其实这一块仍然有进一步的优化空间,比如打包时并行构建 0.60、0.70 的 bundle,提升构建速度。</p><p><img src="/img/remote/1460000044507041" alt="image" title="image"></p><p>同时为了配合客户端的 AB,整个技术方案如下</p><p><img src="/img/remote/1460000044507042" alt="image" title="image"></p><h2>升级</h2><h3>可行性分析</h3><p>前述的方案设计探讨的都是技术上的可行性,但在落地到具体实施上却又是另一番景象。<br>首先有两个不得不面对的问题:<br>一是业务不能停,虽然会投入一定人力来做升级这件事,但是业务同时是在快速迭代的。<br>二是 100 多个 RN 应用必须同时完成升级,在客户端进行灰度之前完成上线。</p><p>按照前述方案我们整理下,在基础功能完备(基础依赖升级完成、打包适配)之后升级一个 RN 应用需要多少步。</p><ol><li>生成并增加 patch 文件,package.json 增加 postinstall 脚本 <code>"postinstall": "npx patch-package"</code>.</li><li>按需升级依赖,并将当前版本放入 degrade,这点不难,难得是从60多个依赖中,准确找到要升级的依赖。</li><li>package.json 增加 preinstall 脚本 <code>"preinstall": "npx npm-force-resolutions@0.0.3"</code>,同时增加 resolutions 配置。</li><li>修改 babel 配置,增加私有化包的 alias</li><li>增加 vscode 相关配置,使用 vscode 调试(原来通过 Chrome 的调试方式已经不再支持)</li><li>部分业务代码中的 break change 使用兼容写法适配(较少)</li></ol><p>看起来每一步都不难,开始时我们用文档记录下来所有的改动点,结果执行时状况百出,<br>要么 patch 文件没有生成,要么脚本命令没添加,更多的是依赖的升级问题,需要把每个应用自己的依赖(十几到几十)和需要升级的依赖(60+)交叉比对,确定哪些依赖升级,并配置降级版本。<br>上面任意一步出了差错,不是应用本地无法启动,就是构建完成之后无法打开。对于有明确报错信息的,可以快速定位问题,但更多是没有明确报错信息的问题,叠加双端的容器也还在不断适配,<br>导致前期定位问题就需要耗费大量资源。</p><p>即使完全按照文档一步步升级下来,也能正常运行了,但是随着验证、测试的深入仍然会不断发现问题,这些大部分都是小范围的共性问题,如果仅仅使用文档来承接会非常低效,每个应用都需要从文档中筛选出自己需要的信息。<br>随着文档新增内容越来越多,对于每一个升级的 RN 应用来说显得噪音越来越多,无法快速知道哪些是必要的。</p><h3>自动化脚本</h3><p>鉴于此我们提供了一套脚本来沉淀我们的适配方案,并随着适配的进度不断更新完善,由脚本来沉淀我们的适配方案,对外只暴露一个升级命令,只需一个命令即可完成升级的绝大部分工作。考虑到脚本需要不断优化,因此需要脚本有动态更新的能力。<br>此时 Node.js 脚本配合 npx 毫无疑问是个绝佳的组合。Node.js 轻量、文件操作简单,脚本编写完成之后发到 npm 仓库。<br>配合 npx 的从 npm 的仓库中临时下载并运行指定的包的能力,可以实现脚本的动态更新,保证每次运行脚本使用到就是最新的。最后统计了一下适配脚本迭代了 110+ 次。</p><h2>暗礁</h2><p>每当我们觉得方案已经完美的时候,现实总是会给你当头一棒,会触碰到很多隐藏在水面之前的暗礁。</p><h3>消失的 JSON 文件</h3><p>RN 打包时会将所有资源分为两类,一类是代码,打包最终产物是 bundle;一类是静态资源,比如图片、视频,这种会直接 copy 放入最终的资源包。其中 JSON 比较特殊,其既作为一个文件存在,又作为代码的一部分打入 bundle。这个 JSON 文件在 RN 官方开源的场景下完全是多余的,于是 Metro 在一次更新中<a href="https://link.segmentfault.com/?enc=amhLsMbDrV4VB59Evwodew%3D%3D.GsoSCyxkUHiDWohbo%2FznI0sD4e8kSb3mXQxil3hCxjB9eY%2B7x07OUYvCitf9Ti0%2F" rel="nofollow">Remove JSON from default asset types</a>修复了这个问题,JSON 文件在打包后不再作为资源处理。</p><p>我们在接口预加载场景下客户端会依赖资源包里面的 JSON 文件读取接口配置信息,从而实现性能的提升。这个问题很隐蔽,一方面这不是个功能问题,容易忽略;二是接口预加载并不是全部开启的,所以缺失 JSON 文件并没有异常日志。客户端同学也是花了很久的时间才定位到是缺失了 JSON 文件,前端接力往下查为什么会缺失,跟着源码一步步下来才发现 Metro 的这个修复。</p><h3>Hermes 的雷</h3><h4>部分语法的不支持</h4><ul><li><code>Date.parse</code> 不再支持, 比如 <code>Date.parse('2023/3/30')</code> 会返回 NaN,需要自己手动实现此功能。</li><li>正则不支持命名捕获组,比如 <code>(?<Name>x)</code>,参考 <a href="https://link.segmentfault.com/?enc=k5cvecxH6eeu3SfoJsHD0Q%3D%3D.eRkSH2yTCdvdZ3ATvqOACciJ9OIbzSfxyXmexdxcqkvVagfPr1wsNnaB6%2BeBfdZX" rel="nofollow">Regex causes "Quantifier has nothing to repeat" </a></li></ul><h4>打入 bundle 的 sourceMap</h4><p>这其实是夸张的说法,之所以这么说因为默认情况下的 hbc bundle 中会保留原始的源代码结构和变量名。这和我们的 hermes 版本 是 0.7 有关,默认启用的是最低级别的优化,即关闭所有的优化,此时编译过的代码将保留很多源码信息,以便于调试和分析,但这些对于生产环境是负担。因此我们改为使用最高级别的优化,对于一些源码原始信息通过 sourceMap 保留,上传我们的云端,处理线上异常时再还原代码。</p><h4>庞大的 patch 包</h4><p>在升级过程中,我们发现开启了字节码的 bundle 的 patch 包会明显大于普通 js bundle 的 patch 包。其原因是我们默认使用的 diff 算法是 bsdiff,而 bsdiff 主要用于文本文件的差异生成,对于字节码文件来说,差异文件的生成和应用会变得复杂和不可靠。比如字节码对于位置信息更敏感,很简单的位置变更都可能导致 patch 包体积庞大。<br>针对这种情况可以在编译时使用增量编译的方案。即在编译时增加 <code>--base-bytecode previous.hbc</code> 参数,<code>previous.hbc</code> 是上次构建的产物。这样编译时将会检查输入文件的更改,这样一方面只编译那些发生更改的部分,减少构建时间;最重要是会生成描述信息用于重排,可以减少 diff Patch 体积。</p><h3>参差的依赖</h3><p>因为在升级之前的 RN 0.60 版本已经在线上运行了三年时间,不同时期创建的应用依赖版本千差万别,<br>在未升级之前因为 lock 文件的存在,问题暴露的还不明显。根据上文提到的打包过程可以知道打包 0.60 版本 bundle 时是需要删除 lock 文件的。因为 package.json 语义化版本的存在,重新安装时会有部分依赖自动升级而部分不会,这就导致版本不兼容,引出一系列问题。</p><ul><li><p>babel-runtime 版本太低找不到相应模块,<code>Unable to resolve module @babel/runtime/helpers/regeneratorruntime</code></p><p>需升级 babel-runtime到最新版本</p></li><li><p>部署完成后,如下报错 <code>Unhandled JS Exception: Unexpected identifier '_classCallCheck'. import call expects exactly one argument. no stack</code></p><p>升级 metro-react-native-babel-preset 到匹配 0.60 版本的最新版本</p></li><li><p>NativeCoponent 注册两次导致红屏, <code>Invariant Violation: Tried to register two views with the same name xxxx</code></p><p>这种需要梳理清楚依赖关系,或者强制锁定版本</p></li><li><p>React 兼容性问题 <code>Unable to resolve module react/jsx-runtime</code></p><p>升级 React 到16的最新小版本</p></li></ul><h2>总结</h2><p>以上就是云音乐 RN 升级前端工作的介绍,从调研开始至升级完成的整个过程。这次升级给我的感触有两个:一是虽然调研、方案已经做的足够翔实,但在升级过程中<br>不断会有问题涌现,此时要做的就是稳住心态不要慌,遇到一个解决一个。二是协作,这次升级涉及所有业务线,升级过程中不断有方案的调整,如果没有业务团队的支持,<br>和我们一起解决问题、完善方案,升级是不可能完成的。</p><h2>最后</h2><p><img src="/img/remote/1460000044507043" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=I5JDHTFAGGQHrXlkTVPMQg%3D%3D.9b6KtxSCRDiTyz%2B8c9vR7bLixCR7vCcAxpXizkcJWnk%3D" rel="nofollow">https://hr.163.com/</a></p>
网易云音乐 RN 低代码体系建设思考与实践
https://segmentfault.com/a/1190000044499937
2023-12-25T11:04:39+08:00
2023-12-25T11:04:39+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>作者:<a href="https://link.segmentfault.com/?enc=TXZXqOCur%2BTzQ2tVRyqdug%3D%3D.%2BPU9aAXQ9TTA4bxfIDsEb7TYRvu%2Bryh6riJmNfN87ss%3D" rel="nofollow">BoBo (沈萧寒)</a></blockquote><h2>前情回顾</h2><p>Tango 是一个用于快速构建低代码平台的低代码设计器框架,并以源代码为中心,执行和渲染前端视图,并为用户提供低代码可视化搭建能力,用户的搭建操作会转为对源代码的修改。借助于 Tango 构建的低代码工具或平台,可以实现 源码进,源码出的效果,无缝与企业内部现有的研发体系进行集成。</p><h3>开源情况</h3><p>目前 Tango 设计器引擎部分已经开源,正在积极推进中,可以通过如下的信息了解到我们的最新进展:</p><ul><li>Github 仓库:<a href="https://link.segmentfault.com/?enc=WQe1BgQSoapfkGRDEMRAEg%3D%3D.B5Iafv6XHosgKingvjDphOsEOIf%2FEA5aMVEJqqfrHlRVPKahexkTziD5YMvelf45" rel="nofollow">https://github.com/NetEase/tango</a></li><li>文档站点:<a href="https://link.segmentfault.com/?enc=B2e2VaQ1N4WlbOMN4%2FLipA%3D%3D.C8ds6tXy%2BUuz6cz6kmIfieAoghUJEsfMFuqLLc1hVmhSByAO%2B33yezsH0jvpNRlo" rel="nofollow">https://netease.github.io/tango/</a></li></ul><p>欢迎大家加入到我们的社区中来,一起参与到 Tango 低代码引擎的开源建设中。有任何问题都可以通过 <a href="https://link.segmentfault.com/?enc=R5f9RvwqW5atX12fybXBUw%3D%3D.3qxVI9d1y8JZjNDgS%2Fr3K3dKViIEADsxaMMDy2HbJmZo4jB963%2B7I0MwTHPINzX7" rel="nofollow">Github Issues</a> 反馈给我们,我们会及时跟进处理。</p><h3>往期系列文章</h3><ul><li><a href="https://link.segmentfault.com/?enc=HjNz1MZ8sG3NzX6QhZL9TQ%3D%3D.E%2By5ja7CfjYIX5aFb%2FQ4ErObnrXJxJzJxHeCmgN5r%2Bg0OhJNJyybCzVb4FB8rk68" rel="nofollow">手把手带你走进Babel的编译世界</a></li><li><a href="https://link.segmentfault.com/?enc=l3Mtp6FwNq4yq94%2BhYQ1fw%3D%3D.QHGMos1yWgHk8aQn10hrXYlVjks0kkdTn8iN4JRUgeP6zlnt3zN2U9oZ5QhpdB3D" rel="nofollow">网易云音乐低代码体系建设思考与实践</a></li><li><a href="https://link.segmentfault.com/?enc=j21ICnv0qH%2B1Lpy7xEuYug%3D%3D.HY8pbaZcP5pW6Nk26Ys5Ksw1CeOnn3COP2JIe1Druijg2fBTi99R6r4qnGtmYS4l" rel="nofollow">云音乐低代码:基于 CodeSandbox 的沙箱性能优化</a></li><li><a href="https://link.segmentfault.com/?enc=Tors7QP5A43%2BpbwtFm9%2FcQ%3D%3D.L5o%2Fba%2BzUNj6KJlVcuelbdhCwcRaIxrR8fPz%2FWv76zNBfW6rJYQMwHA7ioTJxrE5" rel="nofollow">云音乐低代码 + ChatGPT 实践方案与思考</a></li><li><a href="https://link.segmentfault.com/?enc=3J%2BGM13ihFIJOYPixe6T8A%3D%3D.%2FzICXx04Ll6LUtmqW7FkHuEgbAgBB3KL9LLiU88%2BOEGO0M4NmOYUyyB%2BOLJM9KAI" rel="nofollow">网易云音乐 Tango 低代码引擎实现揭秘</a></li><li><a href="https://link.segmentfault.com/?enc=C4iuF%2BFZpohda9ZomArKpQ%3D%3D.%2FKDWI4idmekFLeBGiEFaD92pohzcev2eSoZ28X8AEVK19DMhumH1K94xOWJofOAw" rel="nofollow">网易云音乐 Tango 低代码引擎正式开源</a></li><li><a href="https://link.segmentfault.com/?enc=q6I2Z45UI3GhWP0kzt8oxw%3D%3D.m1yOYRjt50ap5izAbXTcxTRuvyR3rFF6IkgwecqAE7Q8USnISbCMXzOtPKwTDlOI" rel="nofollow">低代码在云音乐数据业务中的落地实践与思考</a></li></ul><p>本文主要探讨基于 Tango 低代码在 RN 场景如何打造一套标准低码研发体系。</p><h2>为什么选择 RN 作为跨端方案</h2><h3>主流的跨端方案,建全的社区生态</h3><p>RN(React Native)是 Facebook 开发的一种基于React框架的移动应用开发框架。它可以用于同时开发 iOS 和 Android 平台的跨平台移动应用程序。</p><p><img src="/img/remote/1460000044499940" alt="" title=""></p><p>在 npm trends 上可以看到,RN 每周的下载次数,稳固上升,相比5年前,下载量已经翻了接近10倍之多,Github Star 数量也来到了 110K 之多,拥有非常庞大的社区生态。</p><h3>React 生态圈,支持动态更新</h3><p>RN 上手成本较低,对于前端开发同学,React 技术栈可以无缝迁移,学习成本较低,对于客户端同学,RN 方案省去了大量编译的时间,相比于传统的原生开发,RN 可以大大减少代码重复,提高开发效率。此外提供了丰富的组件库和API,开发者可以使用这些组件和API快速构建用户界面和实现各种功能。同时,RN还具有良好的性能表现,可以轻松实现原生应用的用户体验。</p><p>在国内,无论大公司、小公司都钟情于应用的动态更新。因为动态更新能降低产品的试错成本。如果产品策略有调整,可以立马上线,线上有小问题也可以快速修复。但能够既满足动态更新,又能跨端,还能满足复杂业务需求的只有 JavaScript 语言。</p><h3>新架构开启全新时代</h3><p>2022 年,对于 React Native 来说是一个大年,因为重构已久的 React Native 新架构已经确定会在今年正式推出,相对于老架构,新架构在最关键的性能问题上有了非常大的提升,这将会为 React Native 开启一个全新的阶段。</p><p><img src="/img/remote/1460000044499941" alt="" title=""></p><p>React Native 新架构默认用的 JavaScript 引擎是 Hermes 引擎。Hermes 是一款专为移动端打造的 JavaScript 引擎,它支持 JavaScript 的 AOT 预编译。带来了更好的启动性能,此外在渲染机制,通信性能上均有成倍的提升,云音乐也第一时间进行了“尝鲜”,详细迁移过程见:<a href="https://link.segmentfault.com/?enc=iGpQGzT6LTE2fY58hIJXqA%3D%3D.OJu7pQIdtmLS54ho%2BRyGUEnC01MMIaB1T4v6SVs4szN%2FvYUeJAtvQOTEBCR2TBFb" rel="nofollow">网易云音乐 RN 新架构升级实践</a>。</p><h2>云音乐 RN 研发现状</h2><p>RN 有着众多优势,云音乐也有相当多的 RN 需求,在需求不断地迭代,研发不断地投入过程中,还是暴露了一些问题。回顾一下 C 端场景的特点,往往是<strong>重视觉,重交互</strong>,我们来看一下目前 介入一个 RN 需求开发并最终交付上线的核心过程:</p><h3>研发过程</h3><h4>准备开发环境</h4><ul><li>Mac 电脑 / 手机设备 (<strong>依赖物理设备</strong>)</li><li>App 测试包 (<strong>依赖客户端打包</strong>)</li><li>模拟器 & XCode & ... (<strong>依赖运行环境</strong>)</li><li>RN Debugger (<strong>依赖调试工具</strong>)</li><li>RN 开发相关文档 & 平台 (<strong>依赖多个平台跳转</strong>)</li><li>编辑器 IDE</li></ul><h4>静态页面开发 & 还原视觉(交互)稿</h4><p><img src="/img/remote/1460000044499942" alt="" title=""></p><p>如图所示,开发第一阶段,还原设计稿,这个过程其实可以追溯到需求评审阶段:设计稿的页面构成,哪些已有现成组件,哪些需要定制开发,样式部分的代码如何编写等。</p><h4>进入业务开发阶段</h4><p>不同场景的 RN 需求对应的实际业务开发有所不同,以下三种是最为常见的业务开发类型。</p><ul><li>埋点开发: 页面曝光埋点,点击播放埋点等,需要开发者手动注入埋点,进行上报。</li><li>数据获取: 例如获取歌曲列表接口,编写代码获取数据,组件消费数据。其实这个过程往往是会被开发者忽略的环节,假设某个业务场景消费的是相同的数据和逻辑,那么这时候我们可能有两个选项:复制之前的代码,或是将其封装为工具包或者耦合至组件进行复用。</li><li>协议调用: 音视频播放等客户端协议调用联调,需查阅相关协议文档或工具组件文档。</li></ul><h4>项目验收/测试</h4><ul><li>视觉反复修改验证,多主题验证,多机型验证 (开发)</li><li>双端兼容性,多主题适配性 (开发 / 视觉)</li><li>视觉验收页面还原度 (视觉 / 策划)</li><li>业务方验收整体功能(QA / 策划)</li></ul><h3>较高的研发成本</h3><p><img src="/img/remote/1460000044499943" alt="" title=""></p><ul><li><strong>相对 H5 应用,RN 应用本地开发环境较重,依赖较多。</strong></li><li><strong>页面灵活度高,需要熟悉现有组件体系,识别组件,还原视觉。</strong></li><li><strong>研发链路周边生态零散,未整合,开发时平台跳转重,链路长。</strong></li><li><strong>相同业务逻辑代码跨项目复用率低,未得到有效处理。</strong></li><li>...</li></ul><p><strong>我们期望构建一套为低码为中心的在线研发体系,通过整套体系标准化来解决目前的问题,降低研发成本和门槛,提高效能</strong>。</p><h2>标准化的低码研发体系</h2><h3>研发链路前后对比</h3><p>RN 迭代从需求到交付涉及到多个核心环节,以下是目前开发现状和低码研发体系的对比。</p><p><img src="/img/remote/1460000044499944" alt="" title=""></p><p><strong>Tango 将提供以源码为中心的 RN 在线搭建能力,支持 RN 应用快速交付,并提供标准化的线上研发流程</strong></p><p><img src="/img/remote/1460000044499945" alt="" title=""></p><h3>传统移动端搭建的问题和瓶颈</h3><p>云音乐在移动端传统搭建上已经有了一些实践,但是在实际使用过程中遇到了一系列的问题。</p><p><strong>DSL 方案局限性</strong></p><p>首先传统搭建平台大多基于 DSL 驱动,再交由 DSL 解析器进行渲染,映射到对应的组件。DSL 本质上其实就是对代码的一种抽象,描述为一种 Schema 的形式进行可视化编排,<strong>最终还是要映射到真实的组件,组件消费 DSL 中携带的信息</strong>。</p><p>如果面向<strong>业务模式稳定的固化场景,进行深度垂直定制</strong>,在这个前提下一套 DSL 确实可以解决大部分场景,剩下的场景可以直接放弃(交由开发介入)。</p><p>但是移动端场景的特点就是<strong>灵活性高</strong>,而此类产品的特点往往<strong>面向运营等非开发角色进行无码搭建,快速交付。</strong> 在实际使用过程中,会遇到 <strong>DSL无法满足业务需求,需要开发介入定制DSL,升级组件</strong> 的情况。</p><p>这个过程中其实带来了较大的成本:</p><ul><li>搭建平台基于 DSL 驱动,随着业务的迭代,DSL 需要不断升级以满足需求的变化</li><li>DSL 的版本迭代和规范需要严格遵守,对应组件库和解析器等中间件的维护仍需要投入开发资源</li><li>DSL 映射为客户端组件时,DSL 的变更依赖客户端迭代,存在隐形风险,且容易出现 RN 对应一套标准客户端组件库,DSL 对应另一套客户端组件库的情况,维护成本非常高,侵入性强。</li><li>面向运营,最后很大概率是研发进行兜底开发,逐渐降低使用意愿</li></ul><p><strong>基于 AST 驱动</strong></p><p>Tango 通过 AST 驱动,可视化的修改实际上就是对源码进行修改,对源码的直接修改其实就跳过了 DSL 映射到源码的过程,这样做的好处是,没有中间产物的形成,不需要额外的开发资源维护,也不会耦合至其他环境,可以跟现有的 云音乐 RN 研发生态较好的融合。所以 <strong>Tango 主要面向研发同学,解决灵活场景下的 RN 开发,侧重对研发环节进行提效</strong>。<strong>在一些轻量场景,也可以作为 NoCode 平台,提供运营同学可视化搭建的能力。</strong></p><p><img src="/img/remote/1460000044499946" alt="" title=""></p><p>从上图可以看出:无论 DSL 还是 AST,最终都是映射到实际的组件,<strong>组件能力的强大与否会直接影响整个低码体系,以及需求交付的效率。组件,是非常重要的!</strong></p><h2>构建在线真机预览调试环境</h2><p>RN 和 Web 应用在线开发最大的区别在于 <strong>运行环境的不同</strong>,Web 场景可以基于 CodeSandbox 实时预览,RN 场景依赖 App 物理环境。</p><p>常见的 RN 应用调试环境:</p><table><thead><tr><th>方案</th><th>描述</th><th>优点</th><th>缺点</th></tr></thead><tbody><tr><td>Expo Snack</td><td>在浏览器中运行和预览 React Native 代码</td><td>无需安装任何本地环境</td><td>依赖于 Expo SDK,功能受限,不适用于业务场景</td></tr><tr><td>模拟器</td><td>使用本地模拟器(如 iOS 模拟器、Android 模拟器)</td><td>提供与实际设备相似的运行环境</td><td>需要安装和设置本地模拟器,可能占用较多资源</td></tr><tr><td>物理手机</td><td>在真实手机设备上运行和预览应用</td><td>提供与实际设备完全一致的运行环境</td><td>需要连接和配置实际设备,可能受限于设备的可用性和数量</td></tr><tr><td>RN for Web</td><td>在浏览器中运行和预览 React Native Web 代码</td><td>可以在多个平台(包括桌面浏览器)上预览应用</td><td>组件需要适配 Web, 原生 API 可能不可用或存在差异</td></tr></tbody></table><p>目前云音乐 RN 研发主要使用模拟器 + 真机扫码两种形式进行开发,起步我们考虑了相对轻量的方案:<strong>RN For Web 进行初步搭建及预览,再结合真机扫码进行实际联调</strong>,这样做的好处是在设计器运行沙箱上可以复用现有 CodeSandbox 能力,不需要做定制,但该方案马上<strong>暴露了一系列问题</strong>:</p><ul><li>现有组件并非均兼容 RN Web,接入存在较大适配成本,收益低</li><li>Web 环境无法模拟真机环境,协议无法调用,无法满足实际开发场景</li><li>RN Web 与真机视觉还原度存在一定差异,视觉存在二次回归成本</li><li>...</li></ul><p>综上,选用 Mac IOS 模拟器作为<strong>真机运行环境,完美贴合本地开发体验</strong>。也带来了更大的挑战,我们需要<strong>模拟一套在线开发环境(远程本地开发)</strong>:</p><ol><li>代码在哪里运行 ?</li><li>模拟器在哪里运行 ?</li><li>页面如何获取模拟器界面 ?</li><li>多人使用模拟器如何分配调度 ?</li><li>页面如何与模拟器通信交互 ?</li><li>App 内置的联调工具如何使用 ?</li><li>RN 运行日志如何透出 ?</li><li>与低代码平台怎么结合 ?</li></ol><p>下面我们来具体看一下如何解决这些问题。</p><h3>Metro 远端构建服务</h3><p>首先我们来解决第一个问题,回顾一下本地开发过程:启动 RN 项目,通过模拟器或者真机 App 访问 RN bundleUrl 进行调试预览,本地启动的 dev server 其实就是打包服务(metro dev server),产物为:</p><pre><code>xx://10.10.10.10:8081/index.ios.bundle</code></pre><p>那么远端构建其实就是将本地流程容器化:拉取项目代码,构建打包,输出产物。如图所示:</p><p><img src="/img/remote/1460000044499947" alt="" title=""></p><p>在低码平台初始化时将完整的代码推送至构建服务,构建服务分配一个实例进行上述构建过程,平台或者手机访问打包产物即可,代码变更时 patch 最新代码,触发 HMR 热更新即可。</p><h3>基于直播流的模拟器投屏方案</h3><h4>模拟器运行环境</h4><p>接下来第二个问题,模拟器运行环境可以使用 Mac 系列设备,包括不限于 Mbp,Mac mini,Mac Studio 等均可,在 Mac 物理机上对模拟器进行多开,实际可以并发启动的数量与设备性能正相关,相同规格的设备,推荐使用 ARM 架构的设备,性能会更加好。接下来对应第三个问题就是如何将模拟器的画面传输至页面。</p><h4>图传方案/投屏方案对比</h4><p>社区方案,<a href="https://link.segmentfault.com/?enc=vHWIO0tKIRMW2niHTXuhAg%3D%3D.Vf2I9yG%2BugtEhMBPgMvrC4Y7aDNIsg5%2BQMpgGgQzJ4suIsU3yneH2Ju73cnPHR76" rel="nofollow">Expo Snack dev</a>,支持真机预览 & Web 两种形式。</p><p><img src="/img/remote/1460000044499948" alt="" title=""></p><p>Expo 真机界面通过实时图传的方式进行返回 (如下图所示),我们也进行了类似方案的实践,实践结果是<strong>在 20 ~ 30FPS 帧率下实时截屏图传返回至 Web 再显示,Socket 存在时序和堆积问题,造成画面时序不一致且闪烁严重</strong>。</p><p><img src="/img/remote/1460000044499949" alt="" title=""></p><p>其实还有一种方式可以获取到屏幕的实时画面,<strong>通过直播推流的形式,将物理屏幕进行捕获推流,网页拉流播放即可,也就是传统意义上的"直播"</strong>。</p><p>起初使用 <a href="https://link.segmentfault.com/?enc=BX7swFCwX67BJ5CdW0ZRdw%3D%3D.RumYRDut6phjWTb7iZoHYWH2R37JQQJwtfOdaRtbqYVDKTZKp5HPySI%2FUfxlXsyl" rel="nofollow">ffmpeg</a> 进行画面捕获并推流,但由于同一台物理机上会多开模拟器,并且存在遮挡问题,模拟器窗口的定位,宽高的识别成本较高。</p><pre><code>ffmpeg -f x11grab -video_size 1280x720 -i :0.0+100,200 -f alsa -i default -c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac -f flv rtmp://your-streaming-server-url/your-stream-key</code></pre><p>最终采用 OBS 进行窗口捕获及推流,OBS 优势:<strong>自带窗口捕获,画布调整,完整的推流参数配置,以及内置 Web Socket 服务器可以进行直播控制</strong>。</p><h4>OBS 低延迟直播方案</h4><p>常见的直播方案如下:</p><table><thead><tr><th>方案</th><th>性能</th><th>响应速度</th><th>优缺点</th></tr></thead><tbody><tr><td>RTMP</td><td>高</td><td>快</td><td>优点:广泛支持、低延迟、稳定性较好;缺点:需要服务器支持、不适用于移动设备</td></tr><tr><td>HLS</td><td>中</td><td>快</td><td>优点:适用于移动设备、可实现自适应码率;缺点:较高的延迟</td></tr><tr><td>WebRTC</td><td>高</td><td>快</td><td>优点:低延迟、实时性好、支持点对点传输;缺点:浏览器兼容性较差</td></tr><tr><td>SRT</td><td>高</td><td>快</td><td>优点:低延迟、稳定性好、可靠性高;缺点:需要额外的配置和支持</td></tr><tr><td>DASH</td><td>中</td><td>较慢</td><td>优点:适用于移动设备、高度可定制;缺点:较高的延迟、需要额外的服务器支持</td></tr><tr><td>RTSP</td><td>中</td><td>较慢</td><td>优点:适用于视频监控、支持多种传输协议;缺点:延迟较高、不适用于移动设备</td></tr></tbody></table><p>由于云手机交互时效性要求,需要一套 <strong>低延迟直播方案</strong>,经过综合对比:</p><p><strong>选用 SRS 流媒体服务器进行转码,使用 OBS RTMP 进行推流,Web 使用 WebRTC 进行拉流得到云手机实时画面。</strong></p><h5>SRS 服务器</h5><p>SRS是一个开源的(MIT协议)简单高效的实时视频服务器,支持RTMP、WebRTC、HLS、HTTP-FLV、SRT、MPEG-DASH和GB28181等协议。 SRS媒体服务器和FFmpeg、OBS、VLC、 WebRTC等客户端配合使用,提供流的接收和分发的能力,是一个典型的发布 (推流)和订阅(播放)服务器模型。 SRS支持互联网广泛应用的音视频协议转换,比如可以将RTMP或SRT, 转成HLS或HTTP-FLV或WebRTC等协议。</p><p><img src="/img/remote/1460000044499950" alt="" title=""></p><p>使用官方 Docker 镜像,一键启动。<a href="https://link.segmentfault.com/?enc=oPQPHmb9JejHkeQgOHvfZg%3D%3D.sC0jUXwfFYViyGUp9oEQ41PYKaPTi%2Bh0Cq048bTE5ZPFE1SJO%2B9fiPrAjrkCDsIY8Ew1d01f5Yo30GWoHKUWivOhmH6vIQr6PHT%2BuQFT%2F%2FllO60qNSGcgmFe6lrGDXKy" rel="nofollow">详见 SRS 官方文档</a>。</p><pre><code>CANDIDATE="192.168.1.10"
docker run --rm -it -p 1935:1935 -p 1985:1985 -p 8080:8080 \
--env CANDIDATE=$CANDIDATE -p 8000:8000/udp \
registry.cn-hangzhou.aliyuncs.com/ossrs/srs:5 ./objs/srs -c conf/rtmp2rtc.conf</code></pre><h5>OBS 推流与拉流</h5><p>接下来对 OBS 进行一定的配置,Mac 设备优先选用 H264 硬件编码进行推流,码率和帧数控制在一定范围,推流地址设置为 rtmp://{your_ip}/live/{your_livestream_key} 即可。</p><p>网页端使用 WebRTC 播放器进行拉流,<em>WebRTC(Web Real-Time Communications)是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。</em></p><p>对 WebRTC 还不太熟悉的同学可以详细阅读一下 <a href="https://link.segmentfault.com/?enc=ni2J%2BOBJKW%2FP7ptqata%2BCA%3D%3D.A4VCBl%2FyhM5aI9%2FEMDLTvI%2FJRwD9hWvzZzLO%2Fmx2K0Jv4NFjWH3E5XuZ0hvxPRIsl%2F9u5BFocJXcP91vsLhYFg%3D%3D" rel="nofollow">Web RTC API</a>。</p><p>效果如图所示,<strong>平均响应速度在 0.5s 至 2s 内</strong>:<br><img src="/img/remote/1460000044499951" alt="" title=""></p><p>至此,我们已经获取到实时画面,模拟器摇身一变成为<strong>云手机</strong>,接下来的核心问题:<strong>解决云手机的通信和交互,以及多台云手机如何调度分配的问题</strong>。</p><h3>基于 Socket 网关的云手机调度 & 通信交互方案</h3><h4>基于调度中心的通信机制</h4><p>首先解决如何分配的问题,场景是:用户访问低码平台时,需要使用一台云手机,而一台物理机上可以启动多台云手机,并可以同时有多台物理机,需要正确的分配到一台设备。</p><p>有同学可能发现了,这个模式非常像"反向代理",那么顺着这个思路,我们需要实现一个<strong>虚拟网关,负责通信和调度</strong>,我们来看一下大致过程:</p><ul><li>将物理机中的设备主动上报到调度中心,并建立 <strong>Socket A</strong>,上报的设备存储在一个"虚拟设备池"中。</li><li>调度中心对低码平台暴露一个 <strong>/lock</strong> 接口,<strong>从设备池中获取可用设备并占用,返回占用的设备信息,同步设备池状态,完成分配</strong>。</li><li>低码平台与云手机建立 WebRTC 连接,获取到屏幕实时画面。</li></ul><p>这里还有一个问题,<strong>如何保证高并发下云手机与用户的一致性</strong>(不会出现同一个设备被重复分配的问题), 服务端的同学应该非常熟悉这个场景,关键词如:"库存","超卖","秒杀",”抢票“,“下单”,“抽奖”等场景均会涉及到这个问题,<strong>我们可以通过加锁来保证资源访问的单一</strong>,如 Redis 分布式锁,感兴趣的同学可以自行查阅一下相关资料。</p><p>此时用户已经分配到云手机,且可以看到实时画面。接下来就要解决如何通讯的问题,既然是通讯那么肯定首选长连接,我们需要与云手机建立 <strong>Socket B</strong>,该 Socket 可以将页面的消息发送至云手机 App 并将 App 中的数据返回至平台。上述流程如图所示:</p><p><img src="/img/remote/1460000044499952" alt="" title=""></p><p>此时云手机通讯机制已经建立,我们可以请求云手机加载某个 RN 页面,但此时云手机无法"使用",云手机需要支持基本的<strong>点击/滑动</strong>交互:</p><p><img src="/img/remote/1460000044499953" alt="" title=""></p><h4>在线调试能力</h4><p>基于已经建立的通讯连接,我们可以远程"操控"云手机并获取到 App 中运行的信息以及日志,在平台侧进行展示,为在线联调提供了<strong>通道</strong>,目前主要开放了以下几个能力:</p><p><img src="/img/remote/1460000044499954" alt="" title=""></p><p><strong>快捷工具栏</strong>:为了还原本地开发体验,我们将调试工具中常用的能力进行了可视化,在云手机一侧提供了工具栏,进行快速使用。</p><p><strong>运行状态栏</strong>: 在底部状态栏的左侧显示了目前云手机的设备信息以及当前 App 的信息。</p><p><strong>日志信息栏</strong>: 显示当前 <code>warning</code>,<code>error</code>的数量,点击后展开 Console 面板,查看当前 Metro 日志信息,对齐 Chrome Console 体验。</p><blockquote>至此,前文提到在线开发的7个问题均已解决,我们来看最后一个问题,如何与低码进行结合</blockquote><h2>多维度的可视化搭建</h2><h3>模拟节点选中效果,结构树可视化编排</h3><p>在 C 端场景 Tango 也保持了社区常见的交互形式,通过页面结构树面板可以对页面中的节点进行增删改查,调整位置等操作。</p><p>常见交互为大纲树和设计器都需要在点击后回显选中的节点,由于 RN 代码实际运行在客户端中,此处就带来了另一个问题: <strong>静态的 RN 代码节点与客户端运行时的节点如何映射</strong>,确实是一个比较有趣的问题,我们可以延续之前模拟点击交互传的思路,大致如下:</p><p><img src="/img/remote/1460000044499955" alt="" title=""></p><p>通过标记节点,客户端计算返回选中的节点坐标宽高信息,模拟选中框覆盖在云手机上,达到选中的效果。</p><h3>双模式切换,源码模式左看右写快速开发</h3><p>对于专业 RN 开发同学,或复杂场景需切换至源码进行开发,我们也对源码模式下的开发体验进行了增强,提供"左看右写"的模式,结合完善的在线调试工具,典型场景下,可以<strong>完全脱离本地开发环境,使用线上进行开发</strong>。</p><p><img src="/img/remote/1460000044499956" alt="" title=""></p><h3>多形式的代码生成</h3><p><img src="/img/remote/1460000044499957" alt="" title=""></p><p>Tango 本质上基于<strong>源码驱动</strong>,在 CMS B 端场景,CRUD 类型的页面可以通过数据模型驱动来快速初始化一个可用页面。<strong>由于 C 端场景的高灵活度,开发时大部分时间是在还原样式,实现静态页面。通过 D2C AIGC 模板市场等能力可以对设计稿,典型场景进行快速还原并得到初始代码,再结合低码平台进行二次搭建,来降低从 0 搭建成本。</strong></p><h2>低码生态建设</h2><p>Tango 低码的理念不仅限于"在线","可视化搭建",<strong>旨在构建一个以源码为中心,完整的低码研发生态体系</strong>。</p><h3>运行时框架 & 组件</h3><p>低码接入的组件能力完善与否也直接<strong>影响开发搭建效率</strong>,我们针对典型场景使用的高频组件进行细分,对此类组件进行"低码化"增强,减少原子组件重复低效使用的问题。</p><p>此外 Tango RN 也延续了 Tango Boot 的应用架构推崇 View-Model-Service 三层模型,演化为 Tango RN Boot 应用框架,其中模型层定义了 Observable States,视图层观察 Model 的变化而进行自动更新,服务层用来创建一组服务函数,供视图层和模型层消费。基于 Tango MVVM 理念,Tango RN Boot 在 Stores & Services 以外,对 RN 常见开发能力进行封装,让开发者可以快速构建 RN 应用。</p><p><img src="/img/remote/1460000044499958" alt="" title=""></p><h3>数据资产沉淀与可视化编排</h3><blockquote>这里先挖个坑,后续会有专门的文章详细介绍。</blockquote><p>...</p><h2>云手机还能做什么</h2><p>云手机顾名思义就是取代了物理手机,目前依赖物理手机的场景大部分都可以通过云手机进行平替,并且由于云手机拥有建全的<strong>通信机制,交互能力</strong>,可以畅享更多的可能性,以下罗列了一些比较常见的应用场景:</p><ul><li>扫码场景<br>所有扫码类的场景都可以接入云手机进行效果预览,通过云手机代替真实手机访问扫码结果</li><li>协议调用/服务化<br>客户端协议可以通过云手机进行远程联调调用,测试协议调用情况,可以借助云手机作为运行容器将协议的调用服务化,包装为 API 接口供三方平台使用。</li><li>视觉验收<br>可以使用云手机来查看应用程序在不同设备和屏幕尺寸上的显示效果,并确保界面元素的布局、颜色和交互行为的一致性。</li><li>测试回归<br>可以用于测试回归,测试团队可以使用云手机来运行自动化测试脚本,执行一些特定的任务。</li></ul><h2>低码的天平问题</h2><p>篇幅较长,如果您看到这里了,非常感谢,希望能给各位带来一些收获。最后还是想聊一点题外话,市面上大大小小的"低码"产品非常多,包括不限于各种<strong>可视化搭建平台,(X)aaS平台,代码插件工具</strong>等。</p><p>低码理念的出现本质上是为了解决某个(类)问题或某个(些)场景。在近几年社区低码概念的发展以及业务实践经验来看:</p><p>过于通用的方案: 不够贴合业务,无法开箱即用,接入成本高,门槛高,拓展性强。<br>过于垂直的方案: 贴合业务场景,可以开箱即用,接入成本低,门槛低,拓展性差。</p><p>这就是一个 "天平" 问题,如何寻找到平衡点是一个值得持续探讨的问题,低码的本质是提效,是解决问题,在这个大前提下,如何做到<strong>高内聚,低耦合的 T 型架构</strong>,是值得低码从业者持续思考和实践的 ~ 共勉 ~</p><h2>总结</h2><p>目前 RN 低代码研发体系建设正在持续进行,相关周边生态的能力正在不断完善,后续我们会将云手机能力下沉服务化,并逐步支持覆安卓云手机,以中台的形式开放给更多有需要的同学快速使用。之后我们会对核心模块的技术细节,以及可视化数据编排等进行更为详细的介绍,请持续关注我们的低码系列文章,感谢 ~</p><h2>参考链接</h2><ul><li><a href="https://link.segmentfault.com/?enc=Tywp10OJYh8J1YF9C24vHw%3D%3D.7im4bB6yU0TLTptGzidZWtAKgipJD8%2BEptWS10XzAEDhgp10b%2Fl4DAwztD3xn7M%2F" rel="nofollow">https://time.geekbang.org/column/article/499434</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044499959" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=zFYB1Cm4v38yBz%2FnGJgfpA%3D%3D.u4dqGpN8J%2FuB4H2MISHYE86lFqgm9MNusM2xK2m6Ml8%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐视频图像技术应用
https://segmentfault.com/a/1190000044490032
2023-12-21T12:00:48+08:00
2023-12-21T12:00:48+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:蔡苗苗</blockquote><p>互联网的快速发展引领了视频图像内容的需求和消费的急剧增长,大量的用户和流量催生了多元化的视频图像技术需求,用以满足创新内容创作需求、支持多样性社交互动、以及高效处理大量数据。本次我们将探讨云音乐中所运用的视频图像技术,通过了解这些技术,我们将更好地理解视频图像领域的发展动态,并了解如何利用这些技术为我们的业务注入更多的价值和可能性。</p><h2>一、背景介绍</h2><h3><strong>1. 当前现状</strong></h3><p> 随着互联网的飞速发展和智能设备的广泛普及,视频及图像内容的需求和消费呈现出爆炸式的增长趋势。这种现象在云音乐表现得尤为显著,基于庞大的用户群体和流量基础,云音乐不断衍生出多元化的视频图像技术需求。这些技术不仅在满足日益更新的内容创意方面发挥着关键作用,还为日新月异的社交互动玩法提供了强大的支持。同时,对于庞大的后台数据的高效处理,这些技术同样扮演着举足轻重的角色。本次文章将深入探讨云音乐中都运用了哪些视频图像技术,这些技术如何在云音乐的各项业务中发挥重要的作用。我们将详细介绍这些技术在提升用户体验、增强音乐可视化效果、优化社交互动以及高效处理数据等方面的具体应用。</p><p><img src="/img/remote/1460000044490034" alt="" title=""></p><h3>2. <strong>技术架构</strong></h3><p> 在业务应用的过程中,视频图像算法需要与其他环节进行紧密的耦合,以形成一个完整的技术价值链,从而实现与业务的协同效应并最大化其价值。因此,我们构建了一套全面的视频图像技术体系,其中包括以下模块:算法训练策略平台、基础算法库、算法服务端应用集群以及算法客户端应用引擎。这个技术体系实现了后台服务端和用户客户端之间的双向链路应用,从而在整个业务流程中发挥着综合且高效的作用。<br><img src="/img/remote/1460000044490035" alt="" title=""></p><h2><strong>二、算法方向</strong></h2><p> 基于上述视频图像技术体系,本文章主要介绍视频图像基础算法模块。基础算法模块又可归纳为:内容理解、智能生产、智能审核、视频交互四个方面,下面我们将对这4个方面分别进行详细介绍。</p><h3><strong>1. 内容理解</strong></h3><h5><strong>a. 视频分类</strong></h5><p> 视频分类,即对输入的长视频或短视频都需要进行分类。视频分类在一定意义具有很多的不确定性,因为有很多视频在分类过程中,不一定是视觉可分。单从视频本身可能无法准确定义出类型,因此云音乐使用跨模态方法进行视频分类,在整个分类过程中把音频信息和文本信息联合去做分类。<br><img src="/img/remote/1460000044490036" alt="" title=""></p><h5><strong>b. 乐谱识别</strong></h5><p> 乐谱随着时代、科技的进步被不断创新,而数字乐谱在新兴科技的催化下,已经演变成了一个集乐谱、音频、视频的多维、能适应未来多种场景并具有多功能的音乐表现体系。 乐谱识别加强了科学与艺术的交融,让“艺术越来越科学,科学越来越艺术”。而乐谱识别技术是基于图像识别的方法自动识别乐谱图片,提取其中的乐谱语义,结合歌词信息,一键生成相应的智能曲谱。一方面可以让一些珍贵的纸质乐谱转变为便于保存和传播的电子乐谱,另一方面又能让静止的图片乐谱动起来。我们研发了一套基于端到端的算法识别系统,从输入的乐谱图片中,基于分割算法对单行乐谱进行分割,并利用基于transformer的方法获取高精准的乐谱语义理解。<br><img src="/img/remote/1460000044490037" alt="" title=""></p><h5><strong>c. 歌单识别</strong></h5><p> 在一定等场景下,用户在平台内看到歌单内的部分歌曲或者其他平台上看到心仪的歌单想在站内构建一样的歌单,为了简化用户操作流程,我们将用户上传的歌单截图中的歌曲进行识别,并一键生成云音乐歌单,避免用户手动逐首歌曲进行歌单创建过程,降低用户使用成本。我们使用版面分析结合OCR识别技术对截图歌单歌曲文字内容进行识别,并利用NLP进行纠错处理,输出最后的截图上的各个歌曲信息,给用户自动创建相应歌曲的歌单。<br><img src="/img/remote/1460000044490038" alt="" title=""></p><h3><strong>2. 智能生产</strong></h3><h5><strong>a. 视频增强</strong></h5><p> 视频增强是指对输入的视频图像进行优化和提升,以改善其视觉效果,提高用户观看体验。在云音乐中,存在着一些老旧片源,如老版mv、早期用户上传的作品,或视频图像经反复缩编解码导致压缩噪声等,又或者由于用户设备、环境等原因导致拍摄的图像视频模糊、抖动、噪点、色彩昏暗等,都会促使站内资源中低质量视频图像存在。但是随着硬件设备的提升,这些低质量视频不能满足现在用户的观感需求,就需要对站内低质量视频进行视频增强,让画质清晰,提高用户观感体验。我们对使用场景切分,对不同的场景进行分别增强处理,并利用不同场景识别到的色彩系统动态调整亮度和色彩度,让画质看起来颜色明亮鲜明。同时相对于其他区域,人脸的增强对视觉感受的影响力最强,因此我们针对人脸区域单独做了人像增强,让人脸部分看上去皮肤光滑、细节清晰。<br><img src="/img/remote/1460000044494136" alt="" title=""></p><h5><strong>b. 智能封面</strong></h5><p> 在视频作品中,封面往往起着至关重要的作用,它决定了用户对整个多媒体内容的第一印象。一个好的封面可以吸引用户的眼球,提高视频的点击率,增加视频的曝光率,可以帮助视频更好地推广。智能封面技术主要是利用AI技术对视频帧进行智能分析,并选取最优的帧作为封面。我们针对输入的视频序列,首先采用关键帧图像动态搜索算法搜索到整个视频中最优关键帧排序,在候选关键帧中再根据图像中的人脸信息综合根据五官、角度等综合计算图像中人脸质量分数排序,同时根据不同的展示位置尺寸比例要求,裁剪出最合适的区域,综合评定出最合适的图像帧作为封面。</p><p><img src="/img/remote/1460000044490040" alt="" title=""></p><h5><strong>c. 高光片段</strong></h5><p> 在视频作品推广、直播间推荐等应用中,需要集中高效地传递信息,以迅速抓住用户的眼球,留住用户并促成点击。否则由于用户浏览速度快,如果不能在较短的时间内播放最可能吸引用户的精彩内容,那么就无法完成推广。动态封面相比静态封面,情节更丰富,让观众立即了解视频主题,具有更强的视觉冲击力和吸引力。而动态封面的生成需要使用提取视频中的最精彩的片段即为高光片段。我们以关键帧为基础,将视频切分成不同的视频片段序列,综合采用片段中的视频质量打分方法以及业务逻辑需求,提取最符合逻辑需求的高光片段。<br><img src="/img/remote/1460000044490041" alt="" title=""></p><h3><strong>3. 智能审核</strong></h3><p> 在社交业务应用中,往往对用户的头像有一定的要求,例如头像要是人且非公众人物,上传的图像需要保持清晰,不能过于模糊等。云音乐利用3000+的明星名人识别、人脸属性信息如人脸检测、人脸年龄、性别、颜值等对用户头像进行管理审核,极大提高了用户管理成本。同时采用人脸聚类算法进行黑产用户挖掘,打击黑色产业,净化社区环境,提高人工审核效率,改善用户体验。</p><p><img src="/img/remote/1460000044494137" alt="" title=""></p><h3><strong>4. 视频交互</strong></h3><h5><strong>1. 美颜美妆</strong></h5><p> 在社交直播业务中,美颜美妆技术对用户来说起着至关重要的作用,美颜美妆可以帮助主播改善外貌,以更好的状态与观众互动,吸引更多粉丝,增加营收。云音乐基于自研移动端上实时人脸检测、人脸关键点、五官分割等技术,为直播、社交互动、智能拍摄等应用场景提供完整的全套美颜美妆sdk,打造自然美颜、贴合真实的妆容效果。并结合上百种贴纸,为用户交互提供趣味性,提升用户体验。同时自研美颜美妆sdk在稳定性和低功耗方面有不错的表现,可支持复杂用户场景和360度人脸角度稳定持妆不掉妆,不同性能的机型流畅运行。</p><p><img src="/img/remote/1460000044494138" alt="" title=""></p><h5><strong>2. AI特效</strong></h5><p> 特效在短视频内容生产中扮演着重要的角色,它们能够为视频增添趣味性和视觉吸引力,从而促进内容的生产和消费。云音乐基于用户需求,研发了多种实时高效的AI特效,这些特效不仅丰富了视频内容,还提高了产品的吸引力和用户的参与度。在移动端上,云音乐的AI特效可以在拍摄或上传视频的同时进行实时处理和优化,使得特效更加真实、生动、有趣。这些特效为创作者提供了更多的创作空间和想象力,提高了产品的吸引力和用户的参与度,推动了视频内容的生产和消费。</p><p><img src="/img/remote/1460000044490044" alt="" title=""></p><p>除此之外,尚存在多样的视频技术,然而考虑到篇幅限制以及技术保密的重要性,我们在此不进行深入阐述。我们热切期待与各位进行专业交流,并诚挚欢迎任何形式的批评指正,以共同推动这一领域的进步。</p><h2><strong>三、未来展望</strong></h2><p> 我们身处在一个充满变革和机遇的时代。互联网技术正在以惊人的速度发展,尤其是近年AIGC为行业带来的新变革。视频图像技术在云音乐业务中的应用和规划,会更多探索多模态音视频创新,让用户可以更加生动地分享自己,分享生活,提高用户对产品的体验。同时,也会加强合作与交流,时刻保持行业敏锐度,共享资源和技术,共同推动视频图像技术的发展和创新。</p><p><img src="/img/remote/1460000044490045" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=3syi3a9ws5EDXS34YqN%2B9g%3D%3D.NcTSGfGJWXloJIs2Loe%2BcDAsUlHDcObrMVLCcEQzq6g%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐自研客户端UI自动化项目 - Athena
https://segmentfault.com/a/1190000044480353
2023-12-18T14:03:12+08:00
2023-12-18T14:03:12+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=QNhoXa7%2BmY7BWRyFWDv6aw%3D%3D.Zly9l9oVzrQ53ntcse3io%2B0ng1q9%2BtHRG1ZXZhjkag36WCq2%2FMlCX7y26eCHceoA" rel="nofollow">郑超</a></blockquote><p>本文介绍了云音乐面对客户端自动化测试命题下解决思路和方案,文章介绍了自动化主流框架的对比,云音乐自动框架的实现以及落地情况。</p><h2>背景</h2><p>网易云音乐是一款大型的音乐平台App,除了音乐业务外,还承接了直播、K歌、mlog、长音频等业务。整体的P0、P1级别的测试用例多达 3000 多个,在现代互联网敏捷高频迭代的情况下,留给测试回归的时间比较有限,云音乐目前采用双周迭代的模式,具体如下图所示:</p><p><img src="/img/remote/1460000044480355" alt="image" title="image"></p><p>每个迭代仅给测试留 <code>1.5</code> 天的回归测试时间,在此背景下,云音乐采用了一种折中的方式,即挑选一些核心链路的核心场景进行回归测试,不做全量回归。这样的做法实际是舍弃了一些线上质量为代价,这也导致时不时的会有些低级的错误带到线上。</p><p>在这样的背景下我们的测试团队也尝试了一些业内的UI自动化框架,但是整体的执行结果离我们的预期差距较大,主要体现在用例录入成本、用例稳定性、执行效率、执行成功率等维度上,为此我们希望结合云音乐的业务和迭代特点,并参考业内框架的优缺点设计一套符合云音乐的自动化测试框架。</p><h2>核心关注点</h2><p>接下来我们来看下目前自动化测试主要关心点:</p><ul><li><strong>用例录入成本</strong></li></ul><p>即用例的生成效率,因为用例的基数比较庞大,并且可预见的是未来用例一定会一直膨胀,所以对于用例录入成本是我们非常关注的点。目前业内的自动化测试框架主要有如下几种方式:</p><ol><li><p>高级或脚本语言</p><blockquote>高级或脚本语言在使用门槛上过高,需要用例录入同学有较好的语言功底,几乎每一条用例都是一个程序,即使是一位对语言相对熟悉的测试同学,每日的生产用例条数也都会比较有限;</blockquote></li><li><p>自然语言</p><pre><code>场景: 验证点击--点击屏幕位置
当 启动APP[云音乐]
而且 点击屏幕位置[580,1200]
而且 等待[5]秒
那么 全屏截图
那么 关闭App</code></pre><blockquote>如上这段即为一个自然语言描述的例子,自然语言在一定程度上降低了编程门槛,但是自然语言仍然避免不了程序开发调试的过程,所以在效率仍然比较低下;</blockquote></li><li><p>ide 工具等</p><blockquote>AirTest 则提供了ide工具,利用拖拽的能力降低了元素查找的编写难度,但是仍然避免不了代码编写的过程,而且增加了环境安装、设备准备、兼容调试等也增加了一些额外的负担。</blockquote></li><li><p>操作即用例</p><blockquote>完全摒弃手写代码的形式,用所见操作即所得的用例录制方式。此方式没有编程的能力要求,而且录入效率远超其他三种方式,这样的话即可利用测试外包同学快速的将用例进行录入。目前业内开源的solopi即采用此方式。</blockquote></li></ol><p>如上分析,在用例录入维度,也只有录制回放的形式是能满足云音乐的诉求。</p><ul><li><strong>用例执行稳定性</strong></li></ul><p>即经过版本迭代后,在用例逻辑和路径没有发生变化的情况下,用例仍然能稳定执行。</p><p>理论上元素的布局层次或者位置发生变化都不应该影响到用例执行,特别是一些复杂的核心场景,布局层次和位置是经常发生变化的,如果导致相关路径上的用例执行都不再稳定,这将是一场灾难(所有受到影响的用例都将重新录入或者编辑,在人力成本上将是巨大的)。</p><p>这个问题目前在业内没有一套通用的行之有效的解决方案,在Android 侧一般在写UI界面时每个元素都会设置一个id,所以在Android侧可以依据这个id进行元素的精准定位;但是iOS 在写UI时不会设置唯一id,所以在iOS侧相对通用的是通过xpath的方式去定位元素,基于xpath就会受到布局层次和位置变化的影响。</p><ul><li><strong>用例执行效率</strong></li></ul><p>即用例完整执行的耗时,这里耗时主要体现在两方面:</p><ol><li><p>用例中指令传输效率</p><blockquote>业内部分自动化框架基于webdriver驱动的c/s模型,传输和执行上都是以指令粒度来的,所以这类方式的网络传输的影响就会被放大,导致整体效率较低;</blockquote></li><li><p>用例中元素定位的效率</p><blockquote>相当一部分框架是采用的黑盒方式,这样得通过跨进程的方式dump整个页面,然后进行遍历查找;</blockquote></li></ol><p>用例执行效率直接决定了在迭代周期内花费在用例回归上的时间长短,如果能做到小时级别回归,那么所有版本(灰度、hotfix等)均能在上线前走一遍用例回归,对线上版本质量将会有较大帮助。</p><ul><li><strong>用例覆盖度</strong></li></ul><p>即自动化测试框架能覆盖的测试用例的比例,这个主要取决于框架能力的覆盖范围和用例的性质。比如在视频播放场景会有视频进度拖拽的交互,如果框架不具备拖拽能力,这类用例就无法覆盖。还有些用例天然不能被自动化覆盖,比如一些动画场景,需要观察动画的流畅度,以及动画效果。</p><p>自动化框架对用例的覆盖度直接影响了人力的投入,如果覆盖度偏低的话,没法覆盖的用例还是得靠人工去兜底,成本还是很高。所以在UI自动化框架需要能覆盖的场景多,这样才能有比较好的收益,业内目前优秀的能做到70%左右的覆盖度。</p><ul><li><strong>执行成功率</strong></li></ul><p>即用例执行成功的百分比,主要有两方面因素:</p><ol><li>单次执行用例是因为用例发生变化导致失败,也就是发现了问题;</li><li>因为一些系统或者环境的因素,在用例未发生改变的情况下,用例执行失败;</li></ol><p>所以一个框架理想的情况下应该是除了用例发生变化导致的执行失败外,其他的用例应该都执行成功,这样人为去验证失败用例的成本就会比较低。</p><h2>业内主流框架对比</h2><p>在分析了自动化框架需要满足的这些核心指标后,对比了业内主流的自动化测试框架,整体如下:</p><table><thead><tr><th>维度</th><th>UIAutomator</th><th>XCUITest</th><th>Appium</th><th>SmartAuto</th><th>AirTest</th><th>Solopi</th></tr></thead><tbody><tr><td>录入成本</td><td>使用Java编写用例,门槛高</td><td>使用OC语言编写,门槛高</td><td>使用python/java编写用例,门槛高,且调试时间长</td><td>自然语言编写,但是理解难度和调试成本仍然高</td><td>基于ide+代码门槛高</td><td>操作即用例,成本低</td></tr><tr><td>执行稳定性</td><td>较高</td><td>一般</td><td>一般</td><td>一般</td><td>一般</td><td>较高</td></tr><tr><td>执行效率</td><td>较高</td><td>较高</td><td>一般</td><td>一般</td><td>一般</td><td>较高</td></tr><tr><td>系统支持</td><td>单端(安卓)</td><td>单端(iOS)</td><td>是</td><td>是</td><td>是</td><td>单端(安卓)</td></tr></tbody></table><p><strong>注:因用例覆盖度和执行成功率不光和自动化框架本身能力相关,还关联到配套能力的完善度(接口mock能力,测试账号等),所以没有作为框架的对比维度</strong></p><p>整体对比下来,没有任何一款自动框架能满足我们业务的诉求。所以我们不得不走上自研的道路。</p><h2>解决思路</h2><p>再次回到核心的指标上来:</p><p><strong>用例录入成本</strong>:我们可以借鉴solopi的方式(操作即用例),Android已经有了现成的方案,只需要我们解决iOS端的录制回放能力即可。</p><p><strong>用例执行稳定性</strong>:因为云音乐有<a href="https://link.segmentfault.com/?enc=f2KrHHO48wQJ73CmvhpWLw%3D%3D.Omx7SmRYK4o5d9FT%2B9brS7fqgg%2FwEj9%2FL9DTG4t3GkI%3D" rel="nofollow">曙光埋点</a>(自研的一套多端统一的埋点方案),核心的元素都会绑定双端统一的点位,所以可以基于此去做元素定位,在有曙光点的情况下使用曙光点,如果没有曙光点安卓则降级到元素唯一id去定位,iOS则降级到xpath。这样即可以保证用例的稳定性,同时在用例都有曙光点的情况下,双端的用例可以达到复用的效果(定义统一的用例描述格式即可)。</p><p><strong>用例执行效率</strong>:因为可以采用曙光点,所以在元素定位上只要我们采用白盒的方式,即可实现元素高效的定位。另外对于网络传输问题,我们采用以用例粒度来进行网络传输(即接口会一次性将一条完整的用例下发到调度机),即可解决指令维度传输导致的效率问题。</p><p><strong>用例覆盖度&执行成功率</strong>:在框架能力之余,我们需要支持很多的周边能力,比如首页是个性化推荐,对于这类场景我们需要有相应的网络mock能力。一些用例会关联到账号等级,所以多账号系统支持也需要有。为了方便这些能力,我们在用例的定义上增加了前置条件和后置动作和用例进行绑定。这样在执行一些特定用例时,可以自动的去准备执行环境。</p><p>在分析了这些能力都可以支持之后,我们梳理了云音乐所有的用例,评估出来我们做完这些,是可以达到70%的用例覆盖,为此云音乐的测试团队和大前端团队合作一起立了自动化测试项目- <code>Athena</code>;</p><h2>设计方案</h2><h4>用例双端复用,易读可编辑</h4><p>首先为了达到双端用例可复用,设计一套双端通用的用例格式,同时为了用例方便二次编辑,提升其可读性,我们采用json的格式去定义用例。<br>eg:</p><p><img src="/img/remote/1460000044480356" alt="image" title="image"></p><h4>Android端设计</h4><p>因为 Solopi 有较好的录制回放能力,并且有完整的基于元素id定位元素的能力,所以这部分我们不打算重复造轮子,而是直接拿来主义,基于 Solopi 工程进行二次开发,集成曙光相关逻辑,并且支持周边相关能力建设即可。因为 Solopi 主要依赖页面信息,基于 Accessibility 完全能满足相关诉求,所以 Solopi 是一个黑盒的方案,我们考虑到曙光相关信息透传,以及周边能力信息透传,所以我们采用了白盒的方式,在 app 内部会集成一个 sdk,这个 sdk 负责和独立的测试框架 app 进行通讯。<br>架构图如下:<br><img src="/img/remote/1460000044480357" alt="image" title="image"></p><h4>iOS 端设计</h4><p>iOS 在业内没有基于录制回放的自动化框架,并且其他的框架与我们的目标差距均较大,所以在 iOS 侧,我们是从 0 开始搭建一整套框架。其中主要的难点是录制回放的能力,在录制时,对于点击、双击、长按、滑动分别 hook 的相关 api 方法,对于键盘输入,因为不在 app 进程,所以只能通过交互工具手动记录。在回放时,基于 UIEvent 的一些私有 api 方法实现 UI 组件的操作执行。</p><p>在架构设计上,iOS 直接采用 sdk 集成进测试 app 的白盒形式,这样各种数据方便获取。同时在本地会起一个服务用于和平台通讯,同时处理和内嵌 sdk 的指令下发工作。</p><p><img src="/img/remote/1460000044480358" alt="image" title="image"></p><h4>双端执行流程</h4><p>整体的录制流程如下:</p><p><img src="/img/remote/1460000044480359" alt="image" title="image"></p><p>回放流程:</p><p><img src="/img/remote/1460000044480360" alt="image" title="image"></p><p>录制回放效果演示:</p><table><thead><tr><th> </th><th> </th></tr></thead><tbody><tr><td><img src="/img/remote/1460000044483442" alt="" title=""></td><td><img src="/img/remote/1460000044483443" alt="" title=""></td></tr></tbody></table><h4>接口mock能力</h4><p>对于个性推荐结果的不确定性、验证内容的多样性,我们打通了契约平台(接口 mock 平台),实现了接口参数级别的方法 mock,精准配置返回结果,将各个类型场景一网打尽。主要步骤为,在契约平台先根据要 mock 的接口配置相应参数和返回结果,产生信息二维码,再用客户端扫码后将该接口代表,在该接口请求时会在请求头中添加几个自定义的字段,网关截获这些请求后,先识别自定义字段是否有 mock 协议,若有,则直接导流到契约平台返回配置结果。</p><p>mock 方案:</p><p><img src="/img/remote/1460000044480361" alt="image" title="image"></p><h4>平台</h4><p>saturn 平台作为自动化操作的平台,将所有和技术操作、代码调度的功能均在后台包装实现,呈现给用户的统一为交互式操作平台的前端。包括用例创建更改、执行机创建编辑、执行机执行、自定义设备、定时执行任务等功能;</p><p><img src="/img/remote/1460000044480362" alt="image" title="image"></p><p><img src="/img/remote/1460000044480363" alt="image" title="image"></p><h4>问题用例分析效率</h4><p>在用例执行时,我们会记录下相应操作的截图、操作日志以及操作视频为执行失败的用例提供现场信息。通过这些现场信息,排查问题简单之极,提缺陷也极具说服力,同时在问题分析效率上也极高。</p><p><img src="/img/remote/1460000044480364" alt="image" title="image"></p><h4>私有化云机房建设</h4><p>云音乐通过参考 android 的 stf、open-atx-server 等开源工程,结合自身业务特点,实现了即可在云端创建分发任务、又即插即用将设备随时变为机房设备池设备的平台,对 android 和 iOS 双端系统都支持云端操作,且具备去中心化的私有化部署能力。</p><p><img src="/img/remote/1460000044480365" alt="image" title="image"></p><p>私有化机器池:</p><p><img src="/img/remote/1460000044480366" alt="image" title="image"></p><h4>整体架构</h4><p><img src="/img/remote/1460000044480367" alt="image" title="image"></p><h2>落地情况</h2><p>在框架侧,我们的录入效率对比如下:</p><p><img src="/img/remote/1460000044480368" alt="image" title="image"></p><p>用例执行效率:</p><p><img src="/img/remote/1460000044480369" alt="image" title="image"></p><p>目前在云音乐中,已经对客户端 P0 场景的用例进行覆盖,并且整体覆盖率已经达到 73%。双端的执行成功率超过 90%。</p><p>具体覆盖情况:</p><p><img src="/img/remote/1460000044480370" alt="image" title="image"></p><p>具体召回的用例情况:</p><p><img src="/img/remote/1460000044480371" alt="image" title="image"></p><p>对于迭代周期中,之前 <code>1.5天</code> 大概投入 <code>15人日</code> 进行用例归回,现在花 <code>0.5天</code>,投入约 <code>6人日</code>,提效超过 <code>60%</code>。</p><p>现在 <code>Athena</code> 不光用在云音乐业务用例回归,在云音乐的其他业务中也在推广使用。</p><h2>总结</h2><p>本文介绍了云音乐在UI自动化测试上的一站式解决方案,采用录制的方式解决录制门槛高、效率低下的问题,在回放过程中前置准备用例执行环境以及结合曙光埋点提升用例执行的稳定性,并且会保留执行过程中的现场信息以便后续溯因。最后通过私有云部署,在云端即可统一调度Android和iOS设备来执行任务。目前该套方案在云音乐所有业务线均已覆盖,我们未来会在自动化测试方面继续探索和演进,争取积累更多的经验与大家交流分享。</p><h2>最后</h2><p><img src="/img/remote/1460000044480372" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=3NKh90dYbDOyX%2Fv3gSIKYw%3D%3D.Jf2c6ndFushWxl0ym1xnw5puAZ1%2FIHuVx%2F8UdwKXX8k%3D" rel="nofollow">https://hr.163.com/</a></p>
网易云音乐 RN 新架构升级实践
https://segmentfault.com/a/1190000044471761
2023-12-14T15:46:35+08:00
2023-12-14T15:46:35+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:王永亮</blockquote><p>本文介绍了从 RN 新架构源码实现角度出发,介绍了如何升级适配,以及网易云音乐在升级适配时遇到的问题及解决方案。</p><h3>一、背景</h3><p>网易云音乐从 ReactNative 0.33 版本开始接入,在 2019 年时开始把 RN 作为主要跨端方案进行建设,并从 0.33 升级到了 0.60,升级 0.60 时还只有十几个页面使用 RN 开发,时至 2022 年底已经有 100+ 业务模块使用 RN 开发,对应 100+ RN 项目,P0 级别项目占比超过四分之一。云音乐 RN 已经拥有完善的组件库和自定义协议库,并且建设了从开发脚手架、一站式开发平台、分发服务、端侧定制容器以及监控体系等一系列的配套设施。</p><p><img src="/img/remote/1460000044471763" alt="基建架构" title="基建架构"></p><p>虽然使用 RN 开发的页面越来越多,但受限于 JS 的解释执行速度以及 RN 的多线程通信架构等问题,RN 页面在启动性能和某些交互体验上和纯原生仍有一些差距,特别是核心场景,业务对页面打开效率和使用体验特别的敏感,这也导致跨端使用场景进一步扩大受阻。为了突破性能瓶颈,我们在横向对比了业界各主流 App 上使用的动态化跨端方案,同时考虑基于当前 RN 版本进行改造和引擎替换等方案,最终结合云音乐自身的特点和已有基建情况我们选选择了 ReactNative 新架构升级方案。本文主要介绍网易云音乐升级 RN 新架构遇到的一些问题和解决方案,希望给想要升级的同学提供一些可参考的信息。</p><h3>二、项目思路和方案</h3><h4>新架构调研</h4><p>升级前我们首先使用 Demo 对新老版本从性能、Bundle 包大小、客户端包大小、内存占用等多角度进行了详细的数据对比,测试机型为 iOS iPhone 6 iOS 12.5、iPhone 12 iOS 16.1,Android 小米8 SE 和红米 Note 9 Pro,测试环境为 RN 0.60 版本 + JavaScriptCore 引擎和 RN 0.70 版本 + Hermes + 字节码预编译,详细对比信息如下:</p><h5>性能对比</h5><p>经过对比,使用新架构 + Hermes 引擎 + 预编译后 Android 小米8 SE 首帧提升 71.5%,LCP 提升 40.1%;红米 Note 9 pro 首帧提升 77.3%,LCP 提升 41.9%;iPhone 6 首帧耗时提升 63%,iPhone 12 提升 42%;LCP iPhone 6 提升 48.5%,iPhone 12 提升 18.3%,详细数据如下表:</p><p>首帧时间:</p><table><thead><tr><th>版本</th><th>iPhone 6</th><th>iPhone 12</th><th>小米 8 SE</th><th>红米 Note 9 pro</th><th> </th></tr></thead><tbody><tr><td>RN 0.60</td><td>1563.66ms</td><td>189.66ms</td><td>987.2ms</td><td>743.4ms</td><td> </td></tr><tr><td>RN 0.70</td><td>578.66ms</td><td>110ms</td><td>281ms</td><td>168.4ms</td><td> </td></tr></tbody></table><p>LCP 时间:</p><table><thead><tr><th>版本</th><th>iPhone 6</th><th>iPhone 12</th><th>小米 8 SE</th><th>红米 Note 9 pro</th><th> </th></tr></thead><tbody><tr><td>RN 0.60</td><td>2482.2ms</td><td>886.5ms</td><td>1720ms</td><td>1358.6ms</td><td> </td></tr><tr><td>RN 0.70</td><td>1276.6ms</td><td>724.25ms</td><td>1030.2ms</td><td>788.4ms</td><td> </td></tr></tbody></table><h5>离线包大小影响</h5><p>Hermes 引擎的一大优势是预编译和字节码执行能力,但是将 JS 文本编译成字节码是有额外成本的,编译后相比编译前 demo 的 bundle 包的大小压缩前增加 18.1%,ZIP 压缩后相比于非字节码 ZIP 后增加了 57.6%,根据我们后续实际打字节码包的经验,JS Bundle 在字节码预编译后 ZIP 包会有 40% ~ 100% 不等的增加,在网络状态差的情况对离线包的到达会有一定的影响,需要采取一些优化措施, demo 详细数据如下:</p><table><thead><tr><th>是否压缩</th><th>bundle大小</th><th>bundle(bytecode)大小</th><th> </th></tr></thead><tbody><tr><td>ZIP前</td><td>2.7M</td><td>3.3M</td><td> </td></tr><tr><td>ZIP后</td><td>623kb</td><td>1.4M</td><td> </td></tr></tbody></table><h5>客户端包大小影响</h5><p>iOS新版本不引入 hermes.framework 时 IPA 包大小为 1.1MB,引入后为 3.1MB,增加了 2MB 包大小。<br>Android新版本依赖大小 6.12M,老版本 6.14M,影响较小</p><h5>内存占用影响</h5><p>使用 Demo 验证在内存(包含 App 本身的内存使用)使用上,RN 0.70 也比 RN 0.60 也有明显优化,iOS 新版本相比老版本内存占用减少了 50% 左右,Android 小米8 SE 内存占用减少 33.2%,红米 Note 9 Pro 内存占用减少31%,具体数据如下:</p><ul><li>iOS</li></ul><table><thead><tr><th>版本</th><th>iPhone 6</th><th>iPhone 12</th><th> </th></tr></thead><tbody><tr><td>RN 0.60</td><td>42.2MB</td><td>47.4MB</td><td> </td></tr><tr><td>RN 0.70</td><td>21.4MB</td><td>25.7MB</td><td> </td></tr></tbody></table><ul><li>Android</li></ul><table><thead><tr><th>版本</th><th>小米 8 SE</th><th>红米 Note 9 Pro</th><th> </th></tr></thead><tbody><tr><td>RN 0.60</td><td>208.4MB</td><td>223.1MB</td><td> </td></tr><tr><td>RN 0.70</td><td>139MB</td><td>153.9MB</td><td> </td></tr></tbody></table><h5>其他影响</h5><p>我们对通信耗时、长列表场景帧率和页面交互能力等场景也进行了 demo 验证,使用 TurboModule、FabricComponent 后通信性能有了 50% 以上的提升,长列表场景帧率无明显变化,页面交互能力和 Native 基本持平。</p><p>综上调研结果,RN 0.70 新架构 + Hermes 引擎 + 字节码预编译开启在各个方面上表现都要优于云音乐之前使用的 RN 0.60 + JavaScriptCore 引擎。</p><h4>新架构适配</h4><p>RN 新架构的核心主要有三方面的优化 —— Fabric、TurboModule 和 Hermes,分别对应组件渲染、信息通信和执行引擎,三项优化都可以独立开启和关闭,接入复杂度上 Hermes 接入适配成本相对最低;Fabric 和 TurboModule 都需要进行代码改造适配后才能启用,TurboModule 开启后 NativeModule 仍然可以使用,改造成本适中,Fabric 的开启最为复杂,由于 Fabric 开启后只支持渲染 FabricComponent,所以需要将原来的 NativeComponent 全部改造为 FabricComponent 才能使用,Fabric 在三者中适配成本最高。</p><p>这里从 Android 端的角度介绍下新架构的基本原理和适配:</p><h5>Hermes 升级适配</h5><p>Hermes 在 0.70 版本时开始被作为双端默认的 JavaScript 引擎,Hermes 引擎最大的优势是支持预编译能力,预编译将原本在端上解释执行时进行的抽象语法树解析、词法解析、以及各种编译优化放到了打包时,直接输出执行效率更高的字节码,具体原理可以参考官方图:</p><p><img src="/img/remote/1460000044471764" alt="Hermes 预编译" title="Hermes 预编译"></p><p>Hermes 支持执行纯文本 JS Bundle 和 JS Bundle 预编译后的字节码文件(HBC 文件),纯文本执行性能相比于其他引擎性能降低明显,但是执行预编译后的二进制文件时性能可以说有了质的提升,尤其是在 Android 系统上,比较直观的体现是 JS Bundle 预编译成字节码后页面首屏渲染速度的显著提升。<br>但是也带来了一些副作用,首先是 JS Bundle 预编译为二进制后体积增加 50% 以上,另外一个问题是 JS Bundle 预编译为字节码后使用 bsdiff 打出的差量包的大小相比于原来纯文本的 diff 包增加了 80% 以上,从几 kb、几十 kb 增加到了上百 kb,在一些弱网等场景包大小的增大可能会直接带来离线包下载失败率的提升。对于 diff 包增大的问题经过排查我们发现在打字节码包时增加 <code>-base-bytecode</code> 指令可以降低 diff 包的大小,指令如下:</p><pre><code>hermes -emit-binary -out bundle.hbc -base-bytecode bundle.hbc</code></pre><p>原理可以参考 hermes 的 <a href="https://link.segmentfault.com/?enc=e%2FR0gmZ5npuOrH5d%2FQ%2FobQ%3D%3D.PdfWyjOME0tRrzXY8Ml8YQMAzG2QVg0Qqmihe45w2rVIBzwmqG2WNmRYZurM9zDD" rel="nofollow">issue</a>,这里不得不吐槽下 Hermes 官方文档实在是内容太少了,没有对这方面内容的说明。</p><p>对于打字节码后包增大的问题,虽然对大部分场景用户都可以通过差量包进行升级,但是对于新用户和刚刚升级到 RN 0.70 的用户还是需要全量拉取的,为了解决这个问题我们对字节码离线包进行了剪裁和引入了新的压缩算法。</p><p>使用 Hermes 引擎后 JS 代码打包时会经过混淆、压缩和预编译等步骤,在之前文本打包的基础上,字节码预编译后会生成 HBC SourceMap 来关联字节码和 JS SourceMap,HBC SourceMap 大小在非 ZIP 情况下可以占到 HBC 包大小的 30% 左右,HBC SourceMap 主要作用是字节码执行出现异常时将字节码堆栈还原为纯文本堆栈,在运行时不需要使用,所以我们在打包时把 HBC SourceMap 从 HBC 包中移除并上传到了云存储,在异常监控平台解析堆栈使用时直接从云存储获取,通过 HBC 包的剪裁压缩后包大小可以缩小 10% ~ 20%。</p><p>另外经过调研和对比还引入了 XZ 压缩算法,XZ 压缩算法有更高的压缩比,相比于 gzip 压缩比提升 10% 以上,但是压缩时间和解压缩时间都增加了几十倍,压缩由于发生在打包时时长增加可以忽略不计,在中低端手机上测试解压缩时间从原来的 0.0x 毫秒上升到了几毫秒,时间增加完全可以接受。</p><p>经过两项优化后离线包整体大小缩小 30% 以上。</p><h5>TurboModule 升级适配</h5><p>TurboModule 提供了 JS 同步调用客户端代码的能力,原理上是以 C++ 代码作为桥梁实现不同语言间的通信,通过 JSI 和 JNI 实现跨语言的通信,代码中利用 JSI 能力在 C++ 代码中向 JSRuntime 注入了 “__turboModuleProxy”,通过 ”__turboModuleProxy“ JS 可以直接调用到 C++,C++ 则通过框架初始化时使用 JNI 注入的 TurboModuleManager Java 对象的引用获取 TurboModule Java 层实现,最后通过 Java 层获取到 C++ 层方法映射完成 TurboModule 的获取。</p><p>TurboModule 需要通过 Codegen 来生成,具体方法可以参考<a href="https://link.segmentfault.com/?enc=5iljsp9k1FN8xRNuW8AlFw%3D%3D.M%2Fyr01QhJlcBIO3MspBWwJE8gYLvNkkPoB%2BHC2bVIG6pLFKuB0tfBN%2BWgLhOG3OMu7glt6IBTEewXQ%2FLd2ufaq%2FxN5cTvLJGqkPGWzoAbIA%3D" rel="nofollow">官方文档</a>,使用 Codegen 生成的 TurboModule 包含 Java 代码和 C++ 代码两部分,C++ 代码中维护了当前 TurboModule JS 到 C++ 方法的映射,以及对实际实现 TurboModule 的 Java 对象的引用,最终调用 Java 层 TurboModule 方法时则通过 JavaTurboModule 的 invokeJavaMethod 统一中转到 Java 层,这里需要注意的是如果 TurboModule 中定义的方法如果返回值是 void 类型,则会自动转为异步调用方式,相关代码如下:</p><pre><code>
```
case VoidKind: {
TMPL::asyncMethodCallArgConversionEnd(moduleName, methodName);
TMPL::asyncMethodCallDispatch(moduleName, methodName);
nativeInvoker_->invokeAsync(
// 具体方法实现
);
TMPL::asyncMethodCallEnd(moduleName, methodName);
return jsi::Value::undefined();
}
```</code></pre><p>改造为 TurboModule 后,如果需要使用同步方法,则函数定义的返回值也需要改为非 Void。在 RN 新架构中虽然新增了 TurboModule,但是之前的 NativeModule 也还是可以使用的,并且新增的 TurboModule 也是向前兼容的,所以云音乐的做法是先将频繁使用的 NativeModule 改造为 TurboModule,降低改造成本和前端适配的成本。</p><h5>Fabric 升级适配</h5><p>Fabric 对渲染系统进行了重构,重构后渲染系统分为渲染、提交、挂载三个阶段,渲染阶段主要是运行 JS 渲染逻辑,为每个通过 React Fiber 框架计算生成的 Element 节点创建对应的 C++ 影子树节点(shadowNode),提交阶段使用 Yoga 引擎对前一阶段生成的影子树(ShadowTree)进行布局计算,挂载阶段在客户端 UI 线程中将计算好的布局信息和来自于 JS 的样式信息解析为客户端的视图树。新的渲染系统将影子树逻辑和相对应的 Yoga 布局计算直接放在了 C++ 中,优化掉了原来 Java 代码中的影子树和不必要的 YogaJNI 调用,提升了数据传输的效率,整体架构如下图:</p><p><img src="/img/remote/1460000044471765" alt="" title=""></p><p>Fabric 的适配成本相对来说还是比较高的,和 TurboModule 不同,由于代码中 UIManager 和 FabricUIManager 只能二选一,所以在一个 RN 应用中开启 Fabric 需要将这个 RN 应用依赖的所有 NativeComponent 都改造为 FabricComponent,否则在页面上使用该组件的位置会展示一个未实现组件的提示,FabricComponent 需要使用官方提供的 Codegen 工具生成,这里除了自研组件需要适配,依赖的社区开源的组件也需要升级和改造,这里依赖组件过多改造成本高的话也可以选择分页面逐步迁移以降低开发成本。</p><h5>前端代码适配</h5><p>RN 升级除了客户端 RN SDK 升级、NativeModule、NativeComponent 的改造外,前端的兼容适配也有比较大的工作量,首先要解决的是 RN 本身迭代导致的变更,跨越 0.60、0.70 版本不少改动和优化需要适配,另外就是新架构的 API 变更,开启 Fabric 后一些老的 API (如 findNodeHandle、setNativeProps 等) 已无法使用,API 迁移可以参考<a href="https://link.segmentfault.com/?enc=F2RwQBujPaepo97gBy5Kfg%3D%3D.3tn0p5%2BQWHRT6%2Fkj%2FpTBN4hR3jLlBrfLHA6JaWkps0qbJcowjC5A8Pd89IT9JYdTqR%2FIr77MOwDkr%2BcIWXpTyQ%3D%3D" rel="nofollow">官方文档</a>说明,这些 API 使用非常广泛,除了业务源码中使用外、我们自己开发的二方组件、社区的三方组件都需要进行适配或者更新,部分三方常用组件还没有新架构的适配版本需要我们自行进行适配,为了尽快完成升级工作,我们选择了先临时对三方库进行私有化,在私有化基础上进行适配改造,稳定性验证完毕后可以回馈给社区,这也带来了另一个问题,常用三方库除了直接在业务代码中依赖在其他三方库中也可能被依赖,这样就造成了私有化的连锁反应,为了解决这个问题我们通过 alias 方式避免依赖膨胀,后续会有前端篇文章专门来介绍。</p><h4>云音乐升级实战</h4><p>对于云音乐来说,RN 新架构升级这要有两个问题:</p><p><strong>1. 兼容成本高。</strong> 升级新架构,除了客户端之前的 NativeModule、FabricComponent 需要升级适配外,还要对在云音乐中已存在 100+ RN 应用进行逐个适配回归,这里面还包含一些营收广告之类的重要页面,回归和上线都需要格外谨慎。</p><p><strong>2. 新架构不确定性高,稳定性风险大。</strong> 项目开始时距离 RN 0.70 版本发布只过了 3 个月的时间,还没有有关大型 App 对新架构的使用和稳定性情况的消息,另外在老架构中我们就遇到一些 JSC 相关的出现概率不低偶现 Crash,新架构担心会有相同问题。</p><p>针对以上问题我们调研制定了比较稳健的升级上线方案,主要涉及工作如下图:</p><p><img src="/img/remote/1460000044471766" alt="" title=""></p><p>其中主要工作还是围绕降低升级成本和稳定性保障两个方面:</p><h5>降低升级成本</h5><ul><li><strong>自动化脚本减少适配工作量</strong></li></ul><p>在整个 RN 升级工作中工作量占比最高的就是业务的适配和回归工作,在没有遇到疑难问题的情况下熟手适配一个应用需要 0.5d,100+ 应用理想情况下预估完全适配完成可能需要 2~3 个月的时间,适配同时还需要兼顾各业务线的迭代排期,可能进一步拉长项目时间,并且已有页面还在不断的迭代中,时间越长适配成本就会越高,后期项目可能会失去掌控。<br>针对以上问题,我们经过分析发现除了少量 API 升级后参数出现变更,很难通过自动化改造外,大部分情况可以将改造点收敛到依赖中,升级依赖即可完成版本升级,所以针对这个特点我们实现了自动化升级脚本,大部分升级工作通过执行脚本完成,只有少量 API 改造和升级出现的 UI 适配问题需要投入人力,实际每个应用适配工作量缩短到 1h~2h 左右,整体升级成本大大降低。</p><ul><li><strong>RN新架构源码改造,降低改造成本</strong></li></ul><p>新架构中客户端一项重要的工作就是 NativeModule 和 FabricComponent 的改造,这块在新架构适配部分也有介绍,对于 NativeModule 我们选择了对部分高频使用的 Module 进行改造,比如我们的自定义协议传输的 Module,几乎所有业务的 JS 和 客户端通信都需要通过这里,这个 Module 改造完已经解决了大部分问题,对于 FabricComponent 我们通过分析源码发现虽然新架构源码中 FabricUIManager 必须使用 FabricComponent,但是仍然可以通过修改源码进行兼容,通过 Codegen 生成的 C++ 代码当前版本的主要作用是实现 Props 的类型定义,真正执行时还是会通过 TS 中的 RawProps 来操作需要变更的属性。所以最终我们通过更改 ComponentDescriptorRegistry.cpp 和 SurfaceMountingManager.java 的查找逻辑实现了兼容,重点代码如下:</p><p>ComponentDescriptorRegistry.cpp:<br><img src="/img/remote/1460000044471767" alt="" title=""></p><p>SurfaceMountingManager.java:</p><p><img src="/img/remote/1460000044471768" alt="" title=""></p><p>通过该更改节约了大量的自研组件升级带来的工作量。</p><ul><li><strong>新老版本一套代码、一次打包即可同时上线新老 RN 版本客户端</strong></li></ul><p>对于已经存在 100+ RN 项目的大型 App,RN 新架构升级这种量级的改动是无法直接线上全量的,需要通过 Android 分流、iOS AB 切换的方式逐步放量,放量时间短则一两个迭代,长则可能到一两个月,这时 RN 页面日常迭代发布就需要考虑 RN 0.60 和 RN 0.70 同时兼容的问题,对此我们设计了一套代码出双包的方案,使用该方案业务更改后,一套代码一次打包可以同时发布运行在线上使用 RN 0.60 版本的客户端,以及线上使用 RN 0.70 版本的客户端,整体方案通过自动化升级脚本和 RN 打包脚本改造实现,尽量做到具体业务最小的开发适配成本,改造后整体架构如下:</p><p><img src="/img/remote/1460000044471769" alt="" title=""></p><p>相对应的开发调试流程也需要相应的变化:</p><p><img src="/img/remote/1460000044471770" alt="" title=""></p><p>在打包发布平台上兼容模式是可配置的,RN 70 全量后,对于一些如营收相关的页面线上存量的 RN 0.60 版本客户端也非常重要,集成在 RN 0.60 版本客户端上的页面需要持续的维护,直到 RN 0.70 版本覆盖率到达到一定程度,到时才可以放弃少量的存量版本,这种情况可以一直保持 RN 0.60 版本和 RN 0.70 版本的配置,对于大部分应用在RN 0.70 全量后 RN 0.60 的兼容包就不需要再维护,则去掉打 RN 0.60 兼容包的配置即可。</p><h5>稳定性保障</h5><p>RN 升级需要适配页面众多,改造成本极大,新架构还带来了很多的不确定性,对此我们做了非常多的工作进行稳定性保障,RN 升级上线后做到了 0 线上问题。</p><ul><li><strong>源码改造,新增 Hermes、FabricComponent、TurboModule 降级能力</strong></li></ul><p>RN 新架构中 Hermes 引擎、FabricComponent 和 TurboModule 都还是比较新的东西,为避免出现线上问题,我们对 Hermes、Fabric、TurboModule 都增加了动态降级能力,通过配置的实时下发随时可以切换到降级模式,避免异常突增或某些业务突现 bug 造成诸如资损等严重问题。</p><ul><li><strong>iOS 双动态库方案,实现 AB 阶梯放量</strong></li></ul><p>在 RN 0.70 版本中,新架构和引擎都存在非常大的不确定性,根据我们之前的 RN 使用经验,老版本中Android JavaScriptCore 引擎在开发测试期间都比较稳定,但是上线后会有一些出现概率不低的引擎侧异常,iOS 切换为 Hermes 后很可能有相同问题,所以要提前设计好上线的方式和节奏,尤其是在 iOS 系统上,由于苹果应用市场的限制,几乎不可能做到和 Android 一样的灰度和逐个应用市场放量的能力,所以为了避免风险,我们设计了 RN 0.60 和 RN 0.70 版本双动态库方案,即保证了稳定性,又为 AB 数据实验做好了准备,详细实现可以关注后续文章。</p><h3>三、升级收益</h3><ul><li>性能提升<br><strong>升级后页面线上性能数据普遍提升,首次渲染白屏有效解决,低端机提升尤其显著</strong></li></ul><p>升级后 RN 页面 JS 渲染执行时的 loading 展示时间已完全不可见,除了体感的提升外,我们还对线上的性能数据进行了全面的统计,统计结果中各项性能数据均显著提升,其中 Android 最大元素渲染完成时间(LCP)提升 20%~50%,iOS LCP提升 10%~20%,在 Android、iOS 低端机上提升都更加显著,RN 升级后页面 LCP 时间基本都可以做到 1s 内,做到了页面秒开。</p><ul><li>稳定性提升<br><strong>升级后客户端各项稳定性数据均有提升</strong></li></ul><p>RN 升级后 Android 端稳定性得到显著提升,JavaSriptCore 引擎偶现崩溃得以解决,Hermes 偶现万分位异常,引擎带来的稳定性问题基本已被解决,由于新架构新引擎的内存占用减低,一些内存不足引起的异常也减少很多。</p><blockquote>相关链接:<br><a href="https://link.segmentfault.com/?enc=8AdW%2Bj0Jkitg8I%2FcCc5%2Ffg%3D%3D.F3MhMOGZ%2BHrpZr8%2FEWKkPrx5L%2Btco8BXLpSA6gd7paF2u%2BmEmjFsXEsvU6%2Fxo2ASGaP0C9wYumFEley5ms3cRg%3D%3D" rel="nofollow">https://reactnative.dev/docs/next/new-architecture-intro</a><br><a href="https://link.segmentfault.com/?enc=zDtonO7AadT4VeKHlK82OA%3D%3D.ewqTikSMRrxiV935hreI%2BeprTo%2BXg%2Bi%2F0617K1vmlEwwBnvL4G1g7iHycodJ1AljbU%2BRUmcKhxI9HRyMfSGxRg%3D%3D" rel="nofollow">https://reactnative.cn/architecture/fabric-renderer</a></blockquote><h2>最后</h2><p><img src="/img/remote/1460000044471771" alt="" title=""><br> 更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=1kj1TBvVjkq9bFrNpDyh%2Bg%3D%3D.lhSYkj78I5krU8bTPMGdlMekbnIV%2BVV8wn79U9U7ECs%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐 AI Agent 探索实践
https://segmentfault.com/a/1190000044463837
2023-12-12T10:49:55+08:00
2023-12-12T10:49:55+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者: <a href="https://link.segmentfault.com/?enc=%2BhjQW2KC8Kl1c6iJVNT7Xw%3D%3D.eiIF1%2BwEBzODtaY87Jkh2Pas%2FgU3xmxQEWbSCcJvHS8%3D" rel="nofollow">kkdev163</a></blockquote><h3>一. 前言</h3><p>本篇文章介绍了大语言模型时代下的 AI Agent 概念,并以 LangChain 为例详细介绍了 AI Agent 背后的实现原理,随后展开介绍云音乐在实践 AI Agent 过程中的遇到的问题及优化手段。通过阅读本篇文章,读者将掌握业界主流的 AI Agent 实现原理及实践优化手段,对应用自研 AI Agent 或理解 Open AI 最新提出的 Assistants API 都具有一定的参考价值。</p><h3>二. AI Agent 简介</h3><h4>2.1 什么是 AI Agent ?</h4><p>相信阅读这篇文章的读者,都在今年感受到了大语言模型带来的爆炸影响力,也都有过与之直接进行交互的使用经历,感受到了它的强大和无所不知。</p><p><!-- <img src="/img/remote/1460000044463839" alt="" title=""> --></p><p>但大语言模型也存在一些限制,比如:</p><ul><li>他的数学计算能力相对薄弱,对于复杂的运算可能会出现错误。(如问 3457 * 43216 = ?,它可能会回答 149,623,912。这是错的正确答案是 149,397,712)</li><li>训练的数据集不包含近期的数据,所以无法直接知道最近的天气和最近发生的新闻。(OpenAI 近期推出的 gpt-4-1106-preview 的训练数据集更新至23年4月)</li></ul><p><img src="/img/remote/1460000044463840" alt="" title=""></p><p>区别于直接与大语言模型进行对话,AI Agent 是通过工程化的手段,为大语言模型提供了获取外部工具、知识的能力。他是介于人类、大语言模型之间的代理。<br>当用户向 AI Agent 输入问题时,AI Agent 可以使用大语言模型作为推理引擎,将一个复杂的任务进行分解、给出任务执行规划。<br>之后 Agent 会调用外部工具获取结果,并将大语言的上次推理和工具调用结果返回给大语言模型,让大语言模型继续思考、规划。如此循环,直到将一个复杂的任务完成。</p><p><img src="/img/remote/1460000044463841" alt="" title=""></p><p>基于以上的理解,我们可以给 AI Agent 下一个定义:</p><blockquote>他是人与大模型之间的智能代理,在接到任务时,它会使用大语言模型作为推理引擎,进行自主的任务规划、执行调度。</blockquote><h4>2.2 AI Agent 的应用场景</h4><p>AI Agent 又有哪些应用场景呢?在<a href="https://link.segmentfault.com/?enc=klXkjCUDHK5qI6wQXfHpxQ%3D%3D.7tnFpdYSr44inTGq3tVf8zKu7eJOf2jRw%2Fb9nJA2kEdlHYh%2BypzNEpDOpCO59P4grTuppjj7%2FZHaVG%2BBvRdH9Q%3D%3D" rel="nofollow">《AI Agent 的千亿美金问题》</a>这篇文章中,作者详细介绍了 AI Agent 的应用场景,笔者从中引用3个大家可能比较熟悉的例子:</p><h5>1. AI 辅助编程场景</h5><p><strong>Cursor</strong></p><p>Cursor 将自己的产品称为 AI-first IDE,其产品 UI 与 VS Code 接近,加入了很多 LLM 原生的 feature,比 Github Copilot 能做得更深入。可以认为是 AI agent 化的 VS Code + Github Copilot.</p><p><strong>Vercel v0</strong></p><p>v0 是由 Vercel 团队打造的 AI 前端代码生成工具。其使用过程非常直接:用户使用自然语言描述需求,v0 根据需求描述来生成组件代码。然后用户继续对不满意的地方提出修改意见,将其迭代为 v1、v2... 直到满足用户的要求。当用户想将一个生成网页的标题改为渐变色时,只需要选择标题部分并提出“增加一个渐变色”,产品便会只对这一部分代码进行修改。<br><img src="/img/remote/1460000044463842" alt="" title=""></p><h5>2. 个人助理类场景</h5><p><strong>Lindy.AI</strong></p><p>Lindy.ai 是一款基于办公场景的智能个人AI助手产品,帮助用户智能化处理日常办公任务。它可以帮人类做日程规划预定、邮件起草发送、会议纪要撰写和总结等。</p><p><img src="/img/remote/1460000044463843" alt="" title=""></p><h3>三. 如何来构建 AI Agent ?</h3><p>对 AI Agent 做了简要的介绍后,我们接着来看,如何构建 AI Agent?目前市面上比较火的 Agent 相关的项目有 AutoGPT、BabyAGI、LangChain 等。</p><ul><li>AutoGPT 在今年3月份发布后取得了惊人的增长,目前已经是一个 152k start 的项目。</li><li>BabyAGI 则提出了 Plan and execute Agent,他的实现方式是: 一次性对任务做全局的规划,后续严格一步步执行,不再变更任务计划。</li><li>LangChain 则是一个通用的大语言模型应用层开发框架,提供了 Python、TS 两种语言库,内置各种 LLM 工具,在 Agent 领域,它也提供了多种 Agent 的实现思路,包括了 AutoGPT、BabyAGI 的实现,本文选择 LangChain 展开介绍。</li></ul><p><img src="/img/remote/1460000044463844" alt="" title=""></p><h4>3.1 LangChain Agent 使用示例</h4><p>前文提到 LLM 不擅长解决复杂数学计算,我们接着来看 LangChain 使用外部工具来增强 LLM 的数学运算能力的<a href="https://link.segmentfault.com/?enc=BErBQs2zlueBu04cq2gvCA%3D%3D.Vv4%2FkPLq8iNn95jLOpcUojIzTol5FS%2F%2FtVFWzUvkSdYFo%2Bz1qOkbgZJjXvFpY2EzsDcKZEFDTPwyBHkoYDDaG3qrUKnToO2wvXBjV3Nsi9U%3D" rel="nofollow">官方示例</a>。本示例的用户提问是: "5~10之间的随机数的平方是多少?" 。</p><p>一共分为4大步:</p><p><img src="/img/remote/1460000044463845" alt="" title=""></p><ol><li>初始化大语言模型接口,可以传入 modelName、temperature、maxTokens 等参数。</li><li><p>初始化工具列表,示例中使用了一个 LangChain 内置的计算器工具,以及动态构建工具。我们重点来看下这个动态生成工具:</p><ol><li>name 是工具的名字</li><li>description 是工具的介绍,是供大语言模型理解的。</li><li>schema 是工具的入参定义 ,这里定义了 low 和 hight 都是数字类型,分别代表 随机数的 下界 和 上界。</li><li>func 是工具的方法调用定义,参数是 schema 中的定义,函数体是一段 js 随机数生成代码。</li></ol></li><li>有了工具和大语言模型接口后,随后构造出了 Agent 执行器。</li><li>最后一步是将用户的输入传给 Agent 执行器。</li></ol><p>最后输出的随机数平方是 45.067</p><h4>3.2 LangChain Agent 执行步骤拆解</h4><p>在本地执行的过程中,Langchain 会输出详细的执行调度日志,如下图所示:<br><img src="/img/remote/1460000044463846" alt="" title=""><br><img src="/img/remote/1460000044463847" alt="" title=""></p><p>通过分析这些日志可以揭开 Langchain Agent 背后的运行原理。</p><p><strong>执行步骤一: 调用大语言模型</strong></p><p>如下图所示, Agent 执行的第一步是将用户的输入与一个系统的 prompt 进行组装,我们暂时先称其为 <strong>“魔法咒语”</strong>,后续会详细介绍。大语言模型会返回他的思考: "用户的问题是 5~10之间的随机数的平方。我可以使用「随机数生成工具」先生成一个随机数,然后使用计算器工具计算它的平方。" 并以 JSON 指示下一步采取的动作是: 调用「随机数生成工具」,入参为 low 5, high 10。</p><p><img src="/img/remote/1460000044463848" alt="" title=""></p><p><strong>执行步骤二: 调用工具-随机数生成器</strong></p><p>接着 Agent 执行器 会调用「随机数生成工具」入参为 <code>{low:5, hight:10}</code>,工具返回 <code>6.7132</code></p><p><img src="/img/remote/1460000044463849" alt="" title=""></p><p><strong>执行步骤三: 调用大语言模型</strong></p><p>如下图所示,Agent 执行器会把用户的原始问题,和上一步大语言模型的思考、工具调用和工具的输出做拼接,传给大语言模型继续思考。大语言模型回复说: 随机数是 6.71..,现在我可以使用计算器工具来计算它的平方值。并使用 JSON 格式指示下一步动作是: 调用计算器工具,入参是 6.71..的平方的数学描述。<br><img src="/img/remote/1460000044463850" alt="" title=""></p><p><strong>执行步骤四: 调用工具 计算器</strong></p><p>接着 Agent 执行器 会调用 计算器工具,入参为 <code>6.71...^2</code>,计算器工具返回的结果为 <code>45.06..</code></p><p><img src="/img/remote/1460000044463851" alt="" title=""></p><p><strong>执行步骤五: 调用大语言模型</strong></p><p>如下图所示: Agent 执行器将上一步的思考、工具调用、结果做拼接,传递给大语言模型继续思考。大语言模型回复说: 我知道了最终的结果,答案是 45.067<br><img src="/img/remote/1460000044463852" alt="" title=""></p><p>当我第一次运行 Agent 示例,看到 Agent 能如此丝滑地一步步思考,执行外部工具,并得到最终结果时,我非常惊叹于 Agent 的能力,也十分好奇背后的原理的是什么。经过一番探索,发现其核心原理就藏在魔法咒语里。 我们接着来看这里的魔法咒语是什么?</p><h4>3.3 LangChain Agent 的魔法咒语</h4><p><strong>魔法咒语片段一</strong></p><p>魔法咒语是由多个片段组成,片段一指示了大语言模型可以使用一些工具,但必须要遵循工具的 JSON Schema,然后给出了 合法的 JSON Schema 示例。紧接着给出了大语言模型可用的工具介绍,包含工具的名字、工具的描述和入参的 JSON Schema。</p><p><img src="/img/remote/1460000044463853" alt="" title=""></p><p><strong>魔法咒语片段二</strong></p><p>片段二主要指示大语言模型如何使用工具。需要通过一个 JSON markdown 格式包裹,包含 action 和 action_input 字段,action 必须为 Final Answer 或 工具名。并给出了 Action 的示例。<br><img src="/img/remote/1460000044463854" alt="" title=""></p><p><strong>魔法咒语片段三</strong></p><p>我们知道大语言模型是生成式 AI,而片段三指示了大语言模型生成的内容需要遵循的段落结构。分别是:</p><ul><li>Question 问题是什么</li><li>Thought 思考如何去解决</li><li>Action 下一步采取的行动</li><li>Observation: 行动的结果</li></ul><p>并指示生成的思考、行动、结果 是可以重复 N 次的。并指示 LLM 在知道最终的结果后,输出 Final Answer。</p><p>这一段是大语言模型能将复杂任务分解、逐步执行、继续思考如此循环的关键。而这一思考框架称为 <a href="https://link.segmentfault.com/?enc=GAHPcnPkaHHe4KBOOY2%2Fag%3D%3D.amuUvNWXvv3HyKTwKNZNtSEt0yizv%2B0xBR8kuwtlNvV7EV8fEn2jv9B23epCrcGs" rel="nofollow">ReAct</a>。</p><p><img src="/img/remote/1460000044463855" alt="" title=""></p><p>知道了 LangChain 背后的魔法咒语后,我们能否直接在 ChatGPT 中直接输入魔法咒语试下效果呢?答案是可以的。</p><p>我们把这段<a href="https://gist.github.com/kkdev163/9711474d50cd189c3e0757dc1382536f">魔法咒语</a>直接复制到 ChatGPT 上。我们看到大模型确实按照 Thought、Action、Observation 的段落格式进行生成输出。</p><p><img src="/img/remote/1460000044463856" alt="" title=""></p><p>但好像又有点问题,他返回的结果和此前步骤拆解中的步骤一不太一样。步骤一只返回 需要调用「随机数生成器工具」,随后 Agent 会介入工具调用,完成工具调用后再交由大语言模型进行思考,而这里大语言模型直接返回了后续的工具调用结果、下一步思考、下一步的行动,在多步重复后,把一个错误的结果输出给我们了,那么问题出在了哪里呢?</p><p>事实上在 Agent 执行器调用大语言模型时,有一个关键的参数 Stop Sequences,这个参数的作用是让大语言模型在准备生成这个词前就强制停住,不再往下生成。</p><p>Agent 会传入 Observation 作为这个参数的值,意思就是让大语言模型生成到 Observation 时就强制停止,这样控制权才会转交回给 Agent,Agent 可以继续调用外部工具、执行后续的步骤。</p><p>我们在 ChatGPT 上加上这个参数,这一次大语言模型的输出就符合预期了。以上就是 LangChain Agent 的核心原理。</p><p><img src="/img/remote/1460000044463857" alt="" title=""></p><h3>四. 云音乐 Adora 平台在 Agent 方面的实践</h3><p>Adora 是网易云音乐内部的智能数字助理搭建平台,提供 LLM 相关服务。内置专属 Chat UI 界面、配置中心,可轻松实现知识库管理、智能问答、意图识别、行为翻译等功能。帮助用户快速构建属于自己的智能助手。我们后续也会有文章介绍 Adora,各位读者敬请期待。</p><p><img src="https://p6.music.126.net/obj/wo3DlcOGw6DClTvDisK1/29094596887/49c4/4e2a/f6b6/19a0760d9ebde7288c1c255ddf1f7321.gif" height="500"/></p><h4>4.1 基础能力整合</h4><p>接着我们来看云音乐 Adora 平台在 Agent 方面的实践。首先是基础能力整合。</p><p><strong>步骤一</strong></p><p>我们还是基于这段官方示例进行扩展。这里的 ChatOpenAI 是 LangChain 提供的大语言模型接口,底层是调用的 OpenAI 官方 Client。由于各种原因,我们无法直接使用,所以要做下替换。</p><p><img src="/img/remote/1460000044463858" alt="" title=""></p><p>得益于 LangChain 的面向对象封装,我们只需继承 LangChain 的 ChatOpenAI 类,重写其中的一个函数即可。将 OpenAI 官方 Client 调用 替换为内部封装的 gpt-client 即可。</p><p><img src="/img/remote/1460000044463859" alt="" title=""></p><p><strong>步骤二</strong></p><p>第二步是将 Adora 平台在线录入的服务转换为 LangChain 的 Tools。我们在 Adora 原有的服务定义上,增加了 description_for_ai 字段,以及 input_params 字段,有了这些配置,我们就可以将 Adora 在线录入的服务,转换为 LangChain 的 Tool。<br><img src="/img/remote/1460000044463860" alt="" title=""></p><p>做完了以上的两步,再加上一些胶水代码,我们就为 Adora 平台整合入了 Agent 的能力。</p><p>Adora 平台的用户在创建 Agent 智能体时,只需在可视化界面上,选择 Agent智能体动作,并圈选这个Agent 所需的服务,即可完成一个 Agent 的构建。<br><img src="/img/remote/1460000044463861" alt="" title=""></p><p>在完成基础能力的整合后,我们还遇到了哪些问题,以及做了哪些优化呢?</p><h4>4.2 问题及优化手段</h4><h5>问题1: 如何高效地调试 Agent ?</h5><p>我们此前提到 Agent 在执行时会输出日志,对于我们理解 Agent 的执行逻辑很有帮助,但这些日志也存在一些冗余的信息,并且是平铺式的,难以快速提炼关键信息。<br><img src="/img/remote/1460000044463862" alt="" title=""></p><p>我们做的第一步是将这些输出日志做采集、提炼。将 Agent 的执行步骤,归纳为关键的 Thought 和 Tool 两大步骤,并以结构化的方式在前端做呈现。</p><p>如下图所示,在 Thought 中我们会展示此次调用大语言模型的 system prompt、human input,以及大语言模型的回答,并展示出整体的耗时。</p><p><img src="/img/remote/1460000044463863" alt="" title=""></p><p>在 Tool 环节,会展示 Agent 使用的工具、耗时。以及工具的入参和出参。</p><p><img src="/img/remote/1460000044463864" alt="" title=""></p><p><img src="/img/remote/1460000044463865" alt="" title=""></p><p>通过结构化的展示,我们将 Agent 执行的每一步,都可视化呈现在开发者眼前,若 Agent 的思考出错或工具调用传参不对,开发者都可以及时看到,并通过改进 prompt 优化整体效果。</p><p>值得一提的是 LangChain 官方出品的开发者平台 LangSmith,也将 Agent 的执行可视化作为了关键特性在宣传,可见可视化调试的重要性。<br><img src="/img/remote/1460000044463866" alt="" title=""></p><h5>问题2: 如何解决 Agent 执行的异常中断</h5><p>我们在调试过程中发现,当 LLM 返回的 action_input 不符合工具的 schema 定义时,Agent 会执行抛错,中断整体执行逻辑。 此外在外部接口调用返回异常时,tool 也会直接抛错,导致 Agent 的整体执行逻辑中断。</p><p>举例来说,正常情况下使用 「会议室查询」服务,需要有 buildingName、bookDay 两个参数,我们也在 Prompt 中提示了大语言模型这两个字段为必填项。</p><p>但 LLM 由于上下文信息过多,可能会出现遗忘的现象。导致输出的结果中,遗漏了 buildingName 字段。当前 LangChain 的默认处理是当 Schema 校验不通过时,直接抛错,这样 Agent 的执行就结束了。</p><p><img src="/img/remote/1460000044463867" alt="" title=""></p><p>我们的优化做法是改写 DynamicStructuredTool 逻辑,在入参不符合预期时,不直接抛错,而是给 LLM 返回错误提示,让其继续思考。这样 LLM 在看到上一次工具的输入、错误提示后,在下次思考时,就会尝试纠正自己,给出正确的工具入参。具体的改写代码如下所示:</p><p><img src="/img/remote/1460000044463868" alt="" title=""></p><p>同理在接口调用环节,如果遇到外部返回异常时,也可以采样同样的思路进行优化。比如会议预定接口,假设接口调用时传入了一个已被占用的时段,后端接口响应就会返回 <code>{ code: 400 ,message: 该时段已被占用}</code> , 此时在 request 中,遇到返回码非 200 时,不直接抛错,而是包装一个错误信息返回给 LLM,这样 LLM 在下次思考时,也会纠正自己,尝试给出合理的工具入参。参考代码如下所示:</p><p><img src="/img/remote/1460000044463869" alt="" title=""></p><h5>问题3 如何让 Agent 请求用户协助?</h5><p>我们此前提到,Agnet 的执行过程,只有思考、工具调用的重复循环,直到给出任务执行的最终结果。中间没有留给用户介入的机会。</p><p><img src="/img/remote/1460000044463871" alt="" title=""></p><p>但我们在一些场景,我们是希望能有用户介入的机会,比如在订咖啡、订会议室的场景,在上下文信息不足时,我们希望 Agent 能够向用户去征集偏好、选项,而不是自行决策,一条路走到黑,导致预定失败。</p><p>我们的做法是: 首先调整工具的描述,告知 LLM 在不知道参数时,需要向用户提问。</p><p><img src="/img/remote/1460000044463872" alt="" title=""></p><p>但只靠这一步,效果并不好,有时 LLM 的输出会不符合 Action 格式要求,所以我们还对系统提示词做了逐步的调整,以强化对 LLM 的提醒。</p><p><img src="/img/remote/1460000044463873" alt="" title=""></p><p>通过以上的 Prompt 优化,现在当输入 「今天下午有哪些会议室?」时,大语言模型会回复「请问您想要查询1号楼、2号楼还是3号楼的会议室?」。现在大语言能够正确地向用户提问了,把控制权交给了用户,后续用户回答 「2号楼」时,我们只需将上一轮的对话作为记忆带到下一轮的 Agent 执行中,就达成了人工介入 Agent 补充信息的效果。以会议室预定为例,详细的步骤如下所示: </p><p><img src="/img/remote/1460000044463874" alt="" title=""></p><p>最终实现的效果:</p><p><img src="/img/remote/1460000044463875" alt="" title=""></p><p>我们通过可视化调试界面加深下理解: 在第三轮对话的第一个 Thought 环节。第一条 system 为系统提示词,后续的 human、ai、human、ai 是前两轮的对话记忆,最后一个 human 才是第三轮对话的用户输入,这6消息整体作为入参 messages 发送给 LLM ,最后一条 ai 是这次调用 LLM 的返回结果。</p><p><img src="/img/remote/1460000044463876" alt="" title=""></p><p>会议预定 Agent 完整执行步骤如下:</p><p><img src="/img/remote/1460000044463877" alt="" title=""></p><p><img src="/img/remote/1460000044463878" alt="" title=""></p><p><img src="/img/remote/1460000044463879" alt="" title=""></p><p><img src="/img/remote/1460000044463880" alt="" title=""></p><h5>问题4 模型推理能力、响应速度</h5><p>在实践中,我们遇到的最大问题是模型的推理能力与响应速度无法兼得。举例来说,当我以 「帮我预定2号楼7楼 今天下午 3点到5点的会议室」 这个问题进行测试时,gpt-4.0-0613 模型分别以 19.07秒、24.78秒、19.01 秒完成任务,中间没有任何步骤推理出错。而使用 gpt-3.5-turbo-0613 模型时,在第一次测试时,Agent 调用的 tool 并不存在,导致任务失败,第二次测试时,Agent 第一步调用 tool 仍然不存在,但在第二步思考时,Agent 进行了纠正,整体完成任务耗时为 13.51秒。第三次测试时,Agent 一次性完成了任务,仅耗时 8.09秒。</p><p>下图为 gpt-3.5-turbo-0613 第二轮测试效果:</p><p><img src="/img/remote/1460000044463881" alt="" title=""></p><p>整体测试总结来看,gpt4.0-0613 可以以100%的正确率完成任务,但平均解题耗时需要 20+秒,而 gpt-3.5-turbo-0613 虽然任务完成率只有 66% ,但整体耗时仅为 10.8秒。</p><p><img src="/img/remote/1460000044463882" alt="" title=""></p><p>对于 gpt-4 的推理能力更强,应该是符合我们大家直觉的,但耗时更久却有点反直觉。我们随后查看了官方的文档,在文档中可以看到,gpt-4 的 出字速度确实是比 gpt-3.5 要慢上几倍,这是符合官方预期的。</p><p><img src="/img/remote/1460000044463883" alt="" title=""></p><p>受限于推理能力、响应速度难以兼得。当下想要将 Agent 正式投入生产环境,还是有一些挑战的。比如当我们把会议预定、咖啡预定 Agent 在公司 1024 的活动上推出时,部分用户身上表现出了一定的等待焦虑:「为什么还没有反应」「我还要等多久」「是不是挂了」。</p><p>在这里工程上能做的优化可能比较有限,比如除了 Loading 外,我们可以加入一些其他的响应提示,如 Agent 目前的思考步骤,以缓解用户的焦虑。</p><p>整体上,推理能力与速度的同步提升,还是较大依赖大模型厂商的逐步优化。正如 OpenAI 最新发布的 gpt-4-turbo-1106 在响应速度上就已经有了一些提升。我们相信随着推理能力和响应速度的提升,基于大语言模型实现的 AI Agent 在不远的未来会有大规模的落地的可能。</p><p><img src="/img/remote/1460000044463884" alt="" title=""></p><h3>五. 总结</h3><p>在 11 月的 OpenAI 的开发者大会上,官方同时也发布了最新的 <a href="https://link.segmentfault.com/?enc=hxdmUf%2FhOhNAcNowtBKVAw%3D%3D.7us5srz4Z2tPmlR7bvPJaX%2BHZBfL%2Bekoc36TLufav5kDg9pC9p8dg5Va3rk90VuLMfgsfsq%2BgVcSDqfvUb4pBA%3D%3D" rel="nofollow">Assistants API</a> ,为构建 AI Agent 提供了官方支持,使得 AI Agent 的构建更加简单、高效。虽然官方的方案可能会演变为最终方案,但我们相信对 LangChain Agent 的实践不会白费,他会加深我们对 Agent 发展脉络的理解,而且使用过后,我们就会发现 Assistants API 的封装 与 LangChain Agent 有许多共通之处。我们后续也会对此进行跟进、实践,请大家继续关注我们,我们会第一时间分享我们的实践经验。</p><p><strong>引用</strong></p><ul><li><a href="https://link.segmentfault.com/?enc=S%2BPIRcZPAOXI2NIisiZvmw%3D%3D.Sltf%2Bu%2F9%2FIiwGv%2FmoS%2BDz49UK%2B2z7HKx%2BM%2BY6MwfILQrQhf0zOkqF9zFFeP11U9fGc3RyPEsaLXjKGozCe3V6w%3D%3D" rel="nofollow">AI Agent的千亿美金问题:如何重构10亿知识工作职业,掀起软件生产革命?</a></li><li><a href="https://link.segmentfault.com/?enc=qkbM%2FvGYEK5UcuiI7IE60Q%3D%3D.zC4ou7K2Lc4hG%2BZbiQcKv2MUBXk9%2BmlcZW%2B%2FJJpP6MTNO1%2ByN7EwSk8l9YrspucEyKsPyKCL6W946znIL%2B9VL6mTHgoAt2OJGtCiQx6yATY%3D" rel="nofollow">LangChain Agent 示例</a></li><li><a href="https://link.segmentfault.com/?enc=UsKlId8CEI%2BdFRwT2vTpbg%3D%3D.P8aLCi1D%2BdlLAmbyVfIOMrrvt0cBeISCQvjI23W9XxX2VYw77tahF5ggOp3G%2FBCABkfOXiOm6a09WhaTWzkf4Q%3D%3D" rel="nofollow">Assistants API</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044463885" alt="" title=""><br>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=3r2Ou4VNNXIbyXxAplPiyg%3D%3D.ioW9wky2RXPVcB8tPUllyK7MsyIyfwnvXRlBFQgIVa8%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐签约平台建设实践
https://segmentfault.com/a/1190000044445477
2023-12-05T15:45:45+08:00
2023-12-05T15:45:45+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:袁晟</blockquote><p>云音乐签约平台系统为业务侧提供方便、快捷的进行文件签约的能力,本文介绍了平台建设中的一些问题与思路。</p><p>一、背景介绍<br>在音乐版权领域中,签约是时常发生的一件事情,云音乐需要通过合同签约来拿到对应艺人的歌曲授权,才可以对歌曲进行后续的上架等操作。<br>在没有电子签约能力的时候,商务正常的签约流程如下:</p><p><img src="/img/remote/1460000044445503" alt="图片" title="图片"></p><p>一个合同,经过上面一套过程下来,整体需要15-30天的签约周期。目前,云音乐内容技术部基于e签宝,搭建了一套文件签约流程,然而目前的流程在后续业务接入时存在较多问题需要改善。</p><p>签约模板发布不灵活:目前的签约模板基于代码生成,每次需求改动需要经历整个需求上线周期。如果出现线上文件内容需要紧急替换的,需要额外增加时间做发布操作,大大增加了线上问题修复的时间。</p><p><img src="/img/remote/1460000044445504" alt="图片" title="图片"></p><p>签约流程不灵活:在目前的版权域和音乐人域中,存在大大小小的签约场景几十种(如下图)。对于现有的逻辑体系中,无法做到在特定节点前后增减具体处理节点。目前对于这种场景只能新增实现类,通过拷贝或者继承等方式去处理额外逻辑。开发者需要评估逻辑是否兼容,而且对于后续修改代码的人会造成一些理解成本。</p><p><img src="/img/remote/1460000044445505" alt="图片" title="图片"></p><p>签约文件准确性无法保障:对于生成的签约文件,历史上发生过多次文件内容异常导致需要重签的情况。比如,签约文件中缺少了盖章内容或者文件中缺少歌曲信息等。<br>二、项目方案<br>2.1 模板配置能力<br>对于现有的合同内容来说,主要分为两块内容,一块是法务侧提供的静态内容数据。这块内容对于相同场景的每个合同来说都是一样的。但是其中还存在如图中的一些空缺位置,这块内容主要是此次签约的一些动态数据。比如,此次的签约时间、歌曲信息、艺人信息等。这块内容需要在每次签约发起的时候动态填写。并且在业务的发展过程中,时常存在模板内容变更的场景,我们需要能对模板内容进行动态的调整。<br>签约模板整体创建流程如下:</p><p><img src="/img/remote/1460000044445506" alt="图片" title="图片"></p><p>具体的配置页面如下,使用html + 动态参数填充生成具体的pdf内容:</p><p><img src="/img/remote/1460000044445507" alt="图片" title="图片"></p><p>Html转pdf的能力目前使用的是itextpdf来进行实现,由于部分协议中需要导入图片内容,存在图片过大导致超出pdf范围的可能性,所以需要对图片大小进行缩放</p><pre><code>ITextRenderer renderer = new ITextRenderer();
// 如果携带图片则加上以下两行代码,将图片标签转换为Itext自己的图片对象,Base64ImgReplacedElementFactory为图片处理类
renderer.getSharedContext().setReplacedElementFactory(new Base64ImgReplacedElementFactory());</code></pre><p>在Base64ImgReplacedElementFactory中,我们对传入的图片进行整体缩放逻辑处理</p><pre><code>public class Base64ImgReplacedElementFactory implements ReplacedElementFactory {
/**
* 实现createReplacedElement 替换html中的Img标签
*
* @param c 上下文
* @param box 盒子
* @param uac 回调
* @param cssWidth css宽
* @param cssHeight css高
* @return ReplacedElement
*/
public ReplacedElement createReplacedElement(LayoutContext c, BlockBox box, UserAgentCallback uac,
int cssWidth, int cssHeight) {
Element e = box.getElement();
if (e == null) {
return null;
}
String nodeName = e.getNodeName();
// 找到img标签
if (nodeName.equals("img")) {
String attribute = e.getAttribute("src");
FSImage fsImage;
try {
// 生成itext图像
fsImage = buildImage(attribute, uac);
} catch (BadElementException | IOException e1) {
log.warn("buildImage failed",e);
fsImage = null;
}
if (fsImage != null) {
double scale = 1.0;
// 10000大小 scale=1 就差不多了
if (fsImage.getWidth() > 8000) {
scale = fsImage.getWidth() / 8000.0;
}
fsImage.scale((int) (fsImage.getWidth() / scale), (int) (fsImage.getHeight() / scale));
return new ITextImageElement(fsImage);
}
}
return null;
}
}</code></pre><p>对于后期发布的版本,如果存在线上问题等情况需要临时回滚等操作,目前提供了多版本控制的能力。</p><p><img src="/img/remote/1460000044445508" alt="图片" title="图片"></p><p>目前模板配置已经应用于大部分的签约场景中,对于后续的开发和迭代上减少了大量人力成本。并且对于现有的部分前端内容,如一些用户手册等也已经接入,用于减少动态内容的开发效率。</p><p>2.2 流程配置能力<br>对于各种签约场景流程或者数据处理逻辑上各不相同,由于考虑到需要适配多种签约场景,为了更好的兼容现有的业务场景,以及后续能方便新场景的接入。所以在功能设计的时候不能和历史代码设计思路一样,将签约中的状态固定死,需要有对于签约链路的动态调整能力,将这块能力交由到业务方使其方便其灵活配置。这就需要在设计的时候考虑能对场景灵活的进行调整。这边设计时候的思路是基于签约状态+签约事件的概念,基于事件驱动的逻辑来触发签约流程的整体流转。</p><p><img src="/img/remote/1460000044445509" alt="图片" title="图片"></p><p>如上图为一个完整的签约流程,其中每个方框节点代表了签约流程中的一个状态(一个签约流程在某个时间点只会处于某一个状态),对于每个状态中间的连线,则代表了状态流转所需要触发的具体事件。在每个状态变更成功后,签约平台都会发送对应的MQ消息到对应的Topic中,业务方可以监听其进行后续的业务流转。</p><p>目前的流程配置很好解决了需求变更导致流程中需要增减节点的情况,而且可以避免前后发布时候的数据处理问题。</p><p>2.3 文件巡检能力<br>基于已生成的Pdf文件,需要确保文件内容的准确,所以定期需要对生成的文件内容进行解析和正确性检测。</p><p><img src="/img/remote/1460000044445510" alt="图片" title="图片"></p><p>对于历史数据,由于数据不可追溯,使用PaddleOCR库(<a href="https://link.segmentfault.com/?enc=TzI7bvUCXKrEqk6B%2Fos9pg%3D%3D.hLhb%2F0hU4ialS91bKwA7n23DWpS512w5psgiOkyA7eB0KnwII3dslMhw1z2RnpHx" rel="nofollow">https://github.com/PaddlePaddle/PaddleOCR</a>)对pdf进行图像识别,返回每行的文本数据已经文本匹配度,整体效果如下</p><p><img src="/img/remote/1460000044445511" alt="图片" title="图片"></p><p>目前线上文件巡检已经涵盖18种主要签约场景,目前匹配准确率约为97.9%。通过线上文件巡检,定位和发现3万+历史歌曲文件存在异常情况,及时排查和修复了业务逻辑,并对问题数据进行上报防止资损。</p><p>2.4 流程数据监控<br>对于不同的签约场景,会有不同的流程节点,我们需要去关注特定节点的block情况。比如音乐人签约中存在人审,人审的周期一般是1-2天,那我们就需要对于人审环节增加两天的预期阻塞,如果此节点阻塞超过两天就会被标记为一条签约异常数据。<br>整体处理流程如下:</p><p><img src="/img/remote/1460000044445512" alt="图片" title="图片"></p><p>数据报表页面部分内容如下:</p><p><img src="/img/remote/1460000044445513" alt="图片" title="图片"></p><p>基于签约流程数据监控,发现了较多阻塞的异常数据,并针对各个场景进行了数据分析,经过分析需要是产品设计上存在部分问题,提交了修改建议到产品侧进行优化。</p><p>三、成果和总结<br>以上就是签约平台的整体的设计思路。在项目的整体推进过程中,我们对于工具的兼容性等设计上的思路更加清晰了。在平台上线后,通过签约平台的开发和后续的业务接入,也帮助我们熟悉和梳理了音乐人/版权相关签约流程的业务逻辑,方便了后续业务问题的定位和排查。同时,我们也需要接入更多的签约场景,优化我们平台侧的对外能力,做到更好的服务业务方。<br>最后</p><p><img src="/img/remote/1460000044445479" alt="图片" title="图片"></p><p><a href="https://link.segmentfault.com/?enc=%2FapGK5eei6R38ljD8bnYsQ%3D%3D.fKqWVDLRAm0zGM%2FreBpttNp7%2FXwrSd3EIR%2BjkvjVxCg%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐基于代码关系的API文档管理实践
https://segmentfault.com/a/1190000044429302
2023-11-29T14:24:56+08:00
2023-11-29T14:24:56+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:胡亦萍</blockquote><p>业界有非常多优秀的API文档管理方案,大多都是基于IDE插件或maven插件的方式做集成。本文主要介绍云音乐自研的基于代码关系、中心化、自动化的API文档管理方案。</p><h2>背景</h2><p>随着微服务的发展,在前后端基于API协作的研发模式下,业界涌现了一批优秀的API文档管理工具,如网易自研的NEI、swagger、yapi、smart-docs等等,这些工具通过围绕API文档构建了一系列的能力,极大提升研发效率。<br>但随着研发流程的迭代更新,也对API文档管理提出了更高的要求。云音乐使用的NEI处于维护阶段,相关功能已经无法满足最新的研发要求,主要体现在:</p><ol><li>依赖手动更新,比较依赖研发人员,文档信息变更时容易存在通知遗漏,影响下游测试或对接方获取最新信息。</li><li>与研发流程结合度低,NEI是基于项目维护API文档信息,缺少研发流程信息,与研发流程的其他系统存在割裂。</li><li>API文档维护与代码分离严重,很多代码不维护API文档注释,信息只维护在NEI,随着时间的流逝信息的不一致也给维护带来了问题。</li></ol><p>所以我们希望通过构建一个全新的API文档管理平台,解决当前存在的问题,推进API标准化,同时增强API文档的生命周期管理,与研发流程更紧密结合,进一步促进研发提效。</p><h2>思路&方案</h2><h3>思路</h3><p>API文档管理平台首先需要确保API文档准确性,在此基础上降低研发人员的维护成本,所以我们需要解决以下几个关键问题:</p><ol><li>如何解决当前存在的文档与代码分离带来的维护问题?<br>业界的API文档管理工具基本上都给出了答案,使用javadoc注释。javadoc作为一个通用的类、方法、成员等注释提取标准,使用javadoc作为不需要额外的代码侵入,有天然的优势,而且市面上的AST工具也都支持javadoc提取;通过javadoc可以非常方便地实现代码即文档,降低维护成本。</li><li>如何及时完成API文档创建和更新,且同时保证等待耗时较小呢?<br>云音乐的代码都是使用gitlab进行管理,所以我们可以利用gitlab的webhook进行commit信息的推送,基于推送的变更信息做增量解析,避免全量代码的冗余处理;同时采用源码解析方式,减少编译带来的时间损耗;但这里也存在一个问题,就是依赖研发人员及时push代码。</li><li>增量的commit其实存在信息缺失的情况,如何保证完整的变更信息被有效识别?<br>API文档中的关系包含API与数据模型的关系、数据模型与数据模型的关系。如果我们能够有效地管理他们的关系,那在增量解析的时候就可以通过这些关系获取完整的信息,从而进行有效的API文档更新。使用传统的数据库无法高效地维护关系,但图数据库能很好地解决这个问题。当某一节点变更时,通过查询节点间的路径关系可以快速获取完整的受影响范围,从而进行有效的信息更新。</li></ol><p>基于上述思考,我们确定了以代码为依据,基于AST解析的API文档管理方案。</p><h3>整体方案</h3><p><img src="/img/remote/1460000044429305" alt="整体方案" title="整体方案"></p><h3>关键流程</h3><p><img src="/img/remote/1460000044429306" alt="关键流程" title="关键流程"></p><ol><li>新接入的应用(由研发协作平台通知)做全量的代码扫描,后续基于gitlab的推送进行增量代码扫描;全量扫描后生成基线的代码关系。</li><li>基于增量代码分析获取基础的变更信息,再基于代码关系获取完整的变更影响范围;每次变更分析后更新代码关系。。</li><li>基于完整的变更范围解析出完整的接口变更信息,更新API文档。</li><li>对更新的API文档进行变更分析,生成变化的差异表;下游可以通过差异表很容易获取变更的内容。</li><li>基于代码所属应用与需求的关系获取API的干系人并进行通知。</li></ol><h3>关系管理</h3><p>关系管理在是本方案中一个非常重要的支撑,如果关系无法有效管理,就无法实现高效准确的API文档更新。最终的关系如下图所示<br><img src="/img/remote/1460000044429307" alt="代码关系" title="代码关系"></p><p>图数据库中管理的关系包含:</p><ol><li>API节点与数据模型节点的关系:请求、响应等。</li><li>数据模型节点与数据模型节点关系:字段、关联、继承等。</li></ol><p>其中所有的节点都包含分支属性,分支属性在节点中的维护的关系如下:<br><img src="/img/remote/1460000044429308" alt="分支关系" title="分支关系"></p><ol><li>基线关系:master分支节点之间,如DtoA某个字段为DtoB,则关系为DtoA(master)-[field]->DtoB(master)</li><li><p>当进行分支开发时:</p><ul><li>当在dev分支中DtoB节点变更,则此时的关系为DtoA(master)-[field]->DtoB(master),DtoA(dev)-[field]->DtoB(dev)</li><li>如DtoA节点变更,则此时的关系为DtoA(master)-[field]->DtoB(master),DtoA(dev)-[field]->DtoB(master)。</li></ul></li><li>如果没有master分支,只有开发分支,则此时不存在基线关系,所有的分支都按相同分支名维护</li></ol><h3>功能展示</h3><h4>API文档详情</h4><p><img src="/img/remote/1460000044429309" alt="接口文档" title="接口文档"></p><h4>API文档变更内容</h4><p><img src="/img/remote/1460000044429310" alt="接口变更" title="接口变更"></p><h4>API文档变更通知</h4><p><img src="/img/remote/1460000044429311" alt="变更通知" title="变更通知"></p><h2>实践的过程遇到的一些问题</h2><ol><li>因为采用源码解析,如果API中引用了二方包,会导致API文档信息缺失<br>基本二方包的维护也是基于git仓库,所以我们约定采用相同分支名进行全局匹配的策略(存在极少的相同路径的情况使用特殊处理)。</li><li><p>虽然我们在定API标准的时候希望不要有非标的结构,但在实际的API文档的维护中,不可避免会有定义Map结构的场景。<br>针对这种情况,我们通过引入特殊的解析逻辑,如下所示</p><pre><code class="java">@Data
public class AppDTO {
/**
* 应用id
*/
private Integer id;
/**
* 测试map,
*
* @OxLink key1 描述1 {@link CanvasDTO}
* @OxLink key2 描述2 {@link AppDTO}
* @OxLink key3 描述3 {@link ComponentDTO}
*/
private Map<String, Object> addedMap;
}</code></pre><p><img src="/img/remote/1460000044429312" alt="解析结果" title="解析结果"></p></li></ol><h2>总结</h2><p>这种代码即文档的中心化API文档管理实践带来了许多好处。首先,开发人员只需要在代码中进行Javadoc的修改,就能自动更新API文档,大大简化了文档维护的工作。其次,这种方案使得大部分场景下的API文档更新可以在30秒内完成,提高了开发效率。最重要的是,开发人员将API信息维护在代码中,保证了文档与实际代码的一致性。</p><h2>最后</h2><p><img src="/img/remote/1460000044429313" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=M8ODfYwN8uxMvuvNPGFVFQ%3D%3D.OVHL2CKMmYhLzIV%2BlqR86q7CNIE9qaekBuRtWAh7VTc%3D" rel="nofollow">https://hr.163.com/</a></p>
2024年了,虚拟DOM该何去何从
https://segmentfault.com/a/1190000044422406
2023-11-27T15:03:48+08:00
2023-11-27T15:03:48+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:Hello</blockquote><h3><strong>诞生之初</strong></h3><h4>从命令式到声明式</h4><p>在上古流行的字符串拼接时代,<a href="https://link.segmentfault.com/?enc=BY96X5abfRaZKLZwCai%2BOg%3D%3D.8E%2FkNJ8QPz5Qx%2BUa28SBUKEsjEkk6r5xrCKSUGhPZLI%3D" rel="nofollow">jQuery</a>一家独大,当时 jQuery 的语法还是停留在那种命令式 DOM 操作之中,</p><pre><code class="js">$("ol li").click(function() {})
let li = $("<li>我是一个li</li>");
$("ol").append(li);</code></pre><p>而在 2013 年,Facebook 的 Jordan Walke 提出来了:把 2010 年 FaceBook 做出来的 XHP 的拓展功能迁移到 Javascript 中,形成以 JSX 作为拓展的新编码形式,并且把写法由命令式转变为声明式,像这样:</p><pre><code class="jsx">//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);</code></pre><p>而在声明式框架的建立之时,需要 DOM 操作这种 “行为”,交给框架处理,并引发一些思考:</p><ol><li>既然 DOM 操作集中交给框架了,那框架岂不是可以去 “批处理” DOM 操作,更好的减少开销?</li><li>既然开始写声明式了,那如何让数据和 DOM 关联起来?如果每次数据发生变化,该如何监听数据源?</li></ol><h4><strong>虚拟 DOM 乍现</strong></h4><blockquote>计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决. --------David Wheeler</blockquote><p>而当时虚拟 DOM,也就是在代码和实际 DOM 操作,由框架做了一层中间层,从而实现 代码 -> 虚拟 DOM 树 -> 真实 DOM 树;</p><p>这个概念是由 <a href="https://link.segmentfault.com/?enc=6QcOm6T%2BaLj0s1G5NhbCjQ%3D%3D.jOo28xMYh3tE0pFon9FoT7DCHPCvWJ0TpbF7mcaIWnI%3D" rel="nofollow">React</a>率先开拓,随后被许多不同的框架采用,并且当时有一本书《高性能的 javascript》,具体在第三章开头,里面有个观点就是:</p><blockquote>DOM scripting is expensive, and it's a common performance bottleneck in rich web applications</blockquote><p>而前 React 核心团队 Pete Hunt 也在 2013 年时,对 React 的宣传演讲中吐槽了一波重复性 DOM 操作的 “巨大开销”: <a href="https://link.segmentfault.com/?enc=8FUzrSM%2FERnspEG07sBrSQ%3D%3D.JBowxEqCm9l2zq4nzQoe2sGYTUupy0ONopaX0EzDhwmwbKybVjNvIGc20%2F1RGLyI" rel="nofollow">《重新思考典范实例的意义》</a>。</p><p><img src="/img/remote/1460000044422408" alt="img" title="img"></p><p>这套虚拟 DOM 的优势在于:</p><ul><li>打开函数式 UI 编程的大门,使得组件抽象化,使得代码更易维护</li><li>跨平台,因为虚拟 DOM 本质上只是一个 Javascript 对象,作为抽象层还能提供给其他应用使用,比如小程序、IOS 应用、Android应用等。</li><li>数据绑定,更新视图时,减少 DOM 操作:可以将多次 DOM 操作合并为一次操作,比如添加 100 个节点原来是一个一个添加,现在是一次性添加,减少浏览器回流(比如 1000 个节点的 DOM 操作,合并为 1 次,进行批处理)</li></ul><pre><code class="js">const fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
const div = document.createElement('div');
fragment.appendChild(div);
}
// 将文档片段一次性插入到目标容器中
const container = document.getElementById('container');
container.appendChild(fragment);</code></pre><ul><li><p>用相对轻量级的 Javascript 操作进行 DOM diff,避免大量查询和复杂的真实 DOM 的存储(包含大量属性)</p><ul><li>虚拟 DOM 借助 DOM diff 可以把多余的操作省略掉,减少页面 reflow、repaint。</li><li>缓存 DOM,更新 DOM 时保存节点状态。</li></ul></li></ul><h3><strong>虚拟 DOM 现状</strong></h3><p>为什么现在有部分框架开始摒弃虚拟 DOM?</p><p>上方 Pete Hunt 在发表演讲后遭到大量网友的抨击,随地马上做出了解释道:</p><blockquote>React 不是魔法。就像你可以使用 C 进入汇编程序并击败 C 编译器一样,如果你愿意,你可以进入原始 DOM 操作和 DOM API 调用并击败 React。但是,使用 C 或 Java 或 JavaScript 是性能的一个数量级改进,因为您不必担心...... 关于平台的细节。使用 React,您可以构建应用程序,甚至不考虑性能,默认状态很快。</blockquote><p>更有甚一些框架开始以 “无虚拟 DOM” 作为噱头,作为其 “优势”,所以我们要先先直视虚拟 DOM 的一些缺点:</p><ul><li>首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,理所当然会比 直接 innerHTML 插入慢</li><li>虚拟 DOM 需要在内存中的维护一份虚拟 DOM</li><li>面对频繁的更新,虚拟 DOM 将会花费更多的时间处理计算的工作</li></ul><p>所以当项目大起来之后,即使现代框架对此进行了优化,虚拟 DOM 的进行对比和计算,还有虚拟 DOM 树都是有一定开销的。</p><h4>一些评价</h4><p>Uber:当然有些企业,比如说<a href="https://link.segmentfault.com/?enc=wyP8hTUMxGRtxwUoAWUd%2Bg%3D%3D.5kPDCPsXdhgYt7SR7lCuWk0Jb9syb287NfUXMJ0OCLOKUyI1gy%2BCF1xqwEzWAGnK" rel="nofollow">Uber</a>,通过广泛手动使用 <code>shouldComponentUpdate</code> 来最大限度地减少对渲染的调用。</p><p>React:React 16 后面推出了 React fiber,通过对不同事件划分的优先级(lane 模型)的打断机制, 其中对虚拟 DOM 树每每深度遍历,继而阻塞主进程的问题,有一定程度的改善。</p><p>Vue:而尤雨溪在《Vue3 的设计》也提及到了致力于寻找对虚拟 DOM 瓶颈的突破,打破这种看起来比较野蛮的算法比较模式:</p><blockquote>The framework figures out which parts of the actual DOM to update by recursively walking two virtual DOM trees and comparing every property on every node. This somewhat brute-force algorithm is generally pretty quick, thanks to the advanced optimizations performed by modern JavaScript engines, but updates still involve a lot of unnecessary CPU work.</blockquote><p>Svelte:Svelte 作者 <a href="https://link.segmentfault.com/?enc=e93putWLC%2BLil9uWHt%2Bfmw%3D%3D.ibflkpbJfmfLYXfJeMz3YQA72ds%2BHF7RITWsbByv15g%3D" rel="nofollow">RICH HARRIS</a> 在 Svelte 的文档也出了一篇 <a href="https://link.segmentfault.com/?enc=FmSgvLGmEEFZXGSBd4j5og%3D%3D.jTSvlz7O71rnXk6ctLAfWDA%2FJbRVDvnTpF7gWFb3n5XTUmM7wMltmmPzsIQKftmpPzOTBrI15aBg5JMnxeP%2B6Q%3D%3D" rel="nofollow">《Virtual DOM is pure overhead》</a> 来讲述他对虚拟 DOM 这一数据驱动模型在某些情况下,亦或者一些频繁的更新带来的不必要的开销,而虚拟 DOM 也只是当初 React 想要以状态驱动 UI 开发的一种手法而已。</p><p>2024 年了,我们到底还需不需要虚拟 DOM 呢?</p><p><img src="/img/remote/1460000044422409" alt="" title=""></p><h3>现阶段无虚拟 DOM 主力军</h3><p>React 在迭代中不断尝试更合理的调度模式,Vue3 着重于对虚拟 DOM 的 diff 算法优化,ivi 和 Inferno 在引领着虚拟 DOM 框架的性能前沿,目前在虚拟 DOM 仍然盛行在主流框架,无虚拟 DOM 框架 Svelte、Solidjs 带领着他们的新的模式进入大众的视野。</p><p><img src="/img/remote/1460000044422410" alt="image.png" title="image.png"></p><h4>Svelte</h4><p><strong>Rich Harris</strong> 是 Svelte 的作者,也是 rollup 的作者,他把 rollup 关于代码打包策略的造诣带入了 Javascript 框架,并且在走一条自己的道路:</p><blockquote>the best API is no API at all ——Rich Harris</blockquote><p>这里我们一般讲的是 Svelte3,Svelte3 作出了巨大的改变,以一种更加轻量级的语法,更少的代码量,去做好响应式的 Javascript 框架。</p><p>实际上它在编译阶段,帮我们直接把声明式代码转化为更加高效的命令式代码,并且减少了运行时代码。</p><pre><code class="svelte"><script>
let count = 0;
function handleClick() {
count += 1;
}
$: {
console.log(`the current count is ${count}`);
}
</script>
<div class="x-three-year" on:click={handleClick}>
<div class="no-open" style={{ color: 'blue' }}>{`当前count: ${count}`}</div>
</div></code></pre><p>我们可以看到通过基本的声明,我便得到了一个响应式的变量,继而通过点击事件的绑定,得到一个通过点击驱动视图数据的普通组件</p><p><img src="/img/remote/1460000044422411" alt="" title=""></p><p>而此时通过 Svelte 的编译后会自动给响应式数据打上标记 <code>$$invalidate</code></p><pre><code class="js">function instance($$self, $$props, $$invalidate) {
let count = 0;
function handleClick() {
$$invalidate(0, count += 1);
}
$$self.$$.update = () => {
if ($$self.$$.dirty & /*count*/ 1) {
$: {
console.log(`the current count is ${count}`);
}
}
};
return [count, handleClick];
}</code></pre><h4>Vue Vapor mode</h4><p>尤雨溪曾在知乎上提及过 Vue2 时期引入虚拟 DOM 的问题(<a href="https://link.segmentfault.com/?enc=ligVq7ugSD4gaUGpgNYbkQ%3D%3D.kfQX7NTaW%2BhZQfIvfg0Tu5XzMfm08tzIbOT%2BZic3ETT%2FS3yYwY8Uo9N5WVdd9K4X" rel="nofollow">Vue 的理念问题</a>)</p><blockquote>React 的 vdom 其实性能不怎么样。Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。这一点是借鉴 React 毫无争议</blockquote><p>继 Svelte 将预编译这一套带入大众视野之后,Vue3 在编译时也有自身的编译优化 ---- “带编译时信息的虚拟 DOM”,详情可以在<a href="https://link.segmentfault.com/?enc=TtNpSudj2n9%2FF6ZU672h9w%3D%3D.usXw%2FPCIskowSXutLELQCoXG2s3cuS0Xtnw0im0fq1faTGElqJPfMsfbuUD5KaWj%2Bkyz%2FF0PiFIh7CRz0pR9K7dWJMlZPYtYhlWYnEjY1SQvBQICE6i5wcR0T63og2GF" rel="nofollow">官网的介绍</a> 中查看,其实也就是在编译阶段针对部分静态节点附带上编译信息,使得在虚拟 DOM 树遍历阶段减少不必要的开销,一定程度上优化了虚拟 DOM 带来的问题。</p><p>而在 2022 年稀土掘金开发者大会上,尤雨溪《2022 前端生态趋势》在演讲中便提及到对 “无虚拟 DOM” 的探索 —— Vue vapor 模式。</p><blockquote>虽然这并不是信号的必要特征,但如今这个概念经常与细粒度订阅和更新的渲染模型一起讨论。由于使用了虚拟 DOM,Vue 目前<a href="https://link.segmentfault.com/?enc=ezvDoX0%2B1l3hRihN6lEEIQ%3D%3D.HW5lKzFzml%2Fd2GYtKInt8Nh6ewKIK4OQoZW%2Fo%2FBlMB89nwTY3DvomzCnBHZsoMurnb97AWOgHJiEBbKDEdAeu40Ua4ZA2f2DFTqlj3WS1CRHh9nK%2B1mFV%2B9njOErRWJo" rel="nofollow">依靠编译器来实现类似的优化</a>。然而,我们也在探索一种新的受 Solid 启发的编译策略 (Vapor Mode),它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。</blockquote><p>这种预编译模式性能上先不说,首先体积上肯定是更偏向轻量级,其实也属于 vue 对未来前端框架的趋势一种新探索。</p><h4>Solidjs</h4><p>Soidjs,你也可以叫它Solid,它和 Svelte 同理,二者都是基于编译的响应式系统,Solidjs 的颗粒度响应是通订阅发布模式进行数据驱动的,并且曾在 <a href="https://link.segmentfault.com/?enc=pgvrDoe8BANiycFn1tsgTQ%3D%3D.WyrbBjfOoO0VS3%2FE1VZNk%2BBp%2BXPoQGmzt0ASIf%2Bl6nPfnxY9X5zXg2mYFl0fovpA5u6yt19Q2lTPi5YqkfGyig%3D%3D" rel="nofollow">js-framework-benchmark</a> 斩获榜首而以性能出名,其语法更接近 React,对 React 重度用户较为友好。</p><p>我们在 Solid 的官方 playground 上可以看到框架在编译阶段将 jsx -> html 的输出结果:</p><p><img src="/img/remote/1460000044422412" alt="" title=""></p><p>Solid 在官网上标为:“真正的响应式”,与其说是真正的响应式,倒不如说像 React,是根据状态变化,更改虚拟 DOM,重新 render(也有可能是父组件更新),对比起来 Solidjs、Svelte 响应单独针对的是数据级别的粒度,React 响应的体量是组件级别的粒度。</p><p>下面我们来看看,Solidjs 的 “颗粒度响应” 是的设计与实现。</p><h5>createSignal</h5><p>主要看下 <code>createSignal</code> 的状态管理,很多文章会以为 Solid 用的是基于 Proxy 的响应式,实则不然,只是部分 API 用了 Proxy,其响应式还是用的 Knockout 那一套发布订阅的数据响应。</p><p>首先我们得先知道 2 个重要的角色类型: <code>SignalState</code>、 <code>Computation</code></p><p>信号主要通过一个对象存储,类型为 type SignalState</p><ul><li>value:当前的值</li><li>observers:观察者数组, 类型为 type Computation</li><li>observerSlots:观察者对象在数组的位置</li><li>comparator:比较器,通过比较则更改 value,默认 false,浅比较</li></ul><pre><code class="js">export function createSignal<T>(
value?: T,
options?: SignalOptions<T | undefined>
): Signal<T | undefined> {
options = options ? Object.assign({}, signalOptions, options) : signalOptions;
const s: SignalState<T | undefined> = {
value,
observers: null,
observerSlots: null,
comparator: options.equals || undefined
};
if ("_SOLID_DEV_" && !options.internal) {
if (options.name) s.name = options.name;
registerGraph(s);
}
const setter: Setter<T | undefined> = (value?: unknown) => {
if (typeof value === "function") {
if (Transition && Transition.running && Transition.sources.has(s)) value = value(s.tValue);
else value = value(s.value);
}
return writeSignal(s, value);
};
return [readSignal.bind(s), setter];
}</code></pre><pre><code class="ts">export interface SignalState<T> extends SourceMapValue {
value: T;
observers: Computation<any>[] | null;
observerSlots: number[] | null;
tValue?: T;
comparator?: (prev: T, next: T) => boolean;
}</code></pre><p>我们可以看到在创建状态时,实际上就是创建了一个 SignalState,通过 <code>readSignal</code> 和 <code>writeSignal</code> 分别读取和改写 SignalState。</p><p>在全局下还有一个 Listener,用于暂存一个 Computation 类型的观察者,在组件渲染(<code>createRenderEffect</code>),或者在调用<code>createEffect</code>时,会通过一个叫 <code>updateComputation</code> 的方法对全局的 Listener 进行赋值,为后续的依赖追踪铺垫。</p><pre><code class="ts">let Listener: Computation<any> | null = null;</code></pre><pre><code class="ts">export interface Computation<Init, Next extends Init = Init> extends Owner {
fn: EffectFunction<Init, Next>;
state: ComputationState;
tState?: ComputationState;
sources: SignalState<Next>[] | null;
sourceSlots: number[] | null;
value?: Init;
updatedAt: number | null;
pure: boolean;
user?: boolean;
suspense?: SuspenseContextType;
}</code></pre><pre><code class="ts">function updateComputation(node: Computation<any>) {
if (!node.fn) return;
cleanNode(node);
const owner = Owner,
listener = Listener,
time = ExecCount;
Listener = Owner = node;
runComputation(
node,
Transition && Transition.running && Transition.sources.has(node as Memo<any>)
? (node as Memo<any>).tValue
: node.value,
time
);
//...
Listener = listener;
Owner = owner;
}</code></pre><p>由于对 signal 的读取,是通过函数调用的形式进行数据读取</p><pre><code class="html"> <div class="no-open" style={{ color: 'blue' }}>{`当前count: ${count()}`}</div></code></pre><p>所以在任何一个角落读取 SignalState 时,都会调用 <code>readSignal</code> 函数,并且把当前全局下被暂存的 “观察者” Listener,也就是引用到 SignalState 的地方,放入自身的 observers(观察者数组)中,并且把观察者源(source)指向当前 signal,实现数据绑定,并且返回对应的 SignalState。</p><pre><code class="js">export function readSignal(this: SignalState<any> | Memo<any>) {
//这里Transition可以先不用管,它用于 `useTransition` ,批量异步更新延迟提交使用的
const runningTransition = Transition && Transition.running;
if (
(this as Memo<any>).sources &&
(runningTransition ? (this as Memo<any>).tState : (this as Memo<any>).state)
) {
if ((runningTransition ? (this as Memo<any>).tState : (this as Memo<any>).state) === STALE)
updateComputation(this as Memo<any>);
else {
const updates = Updates;
Updates = null;
runUpdates(() => lookUpstream(this as Memo<any>), false);
Updates = updates;
}
}
//添加观察者,绑定数据
if (Listener) {
const sSlot = this.observers ? this.observers.length : 0;
if (!Listener.sources) {
Listener.sources = [this];
Listener.sourceSlots = [sSlot];
} else {
Listener.sources.push(this);
Listener.sourceSlots!.push(sSlot);
}
if (!this.observers) {
this.observers = [Listener];
this.observerSlots = [Listener.sources.length - 1];
} else {
this.observers.push(Listener);
this.observerSlots!.push(Listener.sources.length - 1);
}
}
if (runningTransition && Transition!.sources.has(this)) return this.tValue;
return this.value;
}</code></pre><p>对于信号的写入,则调用 <code>writeSignal</code> 函数,在闭包内改变当前 SignalState 后,遍历在在 <code>readSignal </code>阶段被收集的观察者数组,于当前 Effect 执行列表中推入观察者。</p><pre><code class="js">export function writeSignal(node: SignalState<any> | Memo<any>, value: any, isComp?: boolean) {
let current =
Transition && Transition.running && Transition.sources.has(node) ? node.tValue : node.value;
if (!node.comparator || !node.comparator(current, value)) {
if (Transition) {
const TransitionRunning = Transition.running;
if (TransitionRunning || (!isComp && Transition.sources.has(node))) {
Transition.sources.add(node);
.tValue = value;
}
if (!TransitionRunning) node.value = value;
} else node.value = value;
if (node.observers && node.observers.length) {
runUpdates(() => {
for (let i = 0; i < node.observers!.length; i += 1) {
const o = node.observers![i];
const TransitionRunning = Transition && Transition.running;
if (TransitionRunning && Transition!.disposed.has(o)) continue;
if (TransitionRunning ? !o.tState : !o.state) {
if (o.pure) Updates!.push(o);
else Effects!.push(o);
if ((o as Memo<any>).observers) markDownstream(o as Memo<any>);
}
if (!TransitionRunning) o.state = STALE;
else o.tState = STALE;
}
if (Updates!.length > 10e5) {
Updates = [];
if ("_SOLID_DEV_") throw new Error("Potential Infinite Loop Detected.");
throw new Error();
}
}, false);
}
}
return value;
}</code></pre><p>此时我们的 Effect 列表就保存了当时的观察者们,然后遍历执行 <code>runEffects</code>,进行消息的重新分发,然后在对应的节点(<code>Computation</code>)重新执行 <code>readSignal</code> 函数,此时我们就可以得到最新的数据结果了。</p><h5>createEffect</h5><p>而像 <code>createEffect</code> 这种自动追踪依赖的实现时调用时直接创建一个 computation 对象(<code>createComputation</code>),也就是一个观察者,随后被添加到 Effects 执行数组中。并且随后会和之前的流程一样,执行 <code>runEffects</code> -> <code>updateComputation</code> -> 去执行 createEffect 内部的代码逻辑。</p><pre><code class="ts">function createEffect<Next, Init>(
fn: EffectFunction<Init | Next, Next>,
value?: Init,
options?: EffectOptions & { render?: boolean }
): void {
runEffects = runUserEffects;
const c = createComputation(fn, value!, false, STALE, "_SOLID_DEV_" ? options : undefined),
s = SuspenseContext && lookup(Owner, SuspenseContext.id);
if (s) c.suspense = s;
if (!options || !options.render) c.user = true;
Effects ? Effects.push(c) : updateComputation(c);
}</code></pre><p>通过 <code>updateComputation</code> ,如上面所说 对 <code>Computation</code> 的介绍所说的,在 <code>updateComputation</code>时,在对全局的 Listener 进行赋值。</p><h5>组件的更新</h5><p>组件的更新和 <code>createEffect</code> 同理,只不过组件的引用是走 <code> createRenderEffect </code> -> <code>updateComputation</code></p><pre><code class="tsx">function App() {
const [count, setCount] = createSignal(0);
return (
<div class="x-three-year" onClick={() => setCount((pre) => pre + 1)}>
<div class="no-open">你有个蛋糕店待开业</div>
<div class="no-open">{count()}</div>
</div>
);
}</code></pre><p>在点击事件发生后,和我们上面所描述的<code>writeSignal</code> 行为一致,触发<code>updateComputation</code>,走到对 SignalState 的获取<code>readSignal</code>,整体调用栈如下:</p><p><img src="/img/remote/1460000044422413" alt="image-20231020202011674" title="image-20231020202011674"></p><h5>Solid 的一些需要注意的点</h5><p>一、Solid 不能使用 rest 和 spread 语法来拆分和合并 props,也就是不能直接对响应式的 props 数据解构。(但是直接传一个 signal 的调用方法则可以)</p><p>原因是通过解构的这种浅拷贝的形式(同样的<code>Object.assign</code> 这些方法也不可以),拷贝当时获取的值,会切断 signal 的更新,脱离追踪范围而失去响应。</p><blockquote>正因如此,请时刻记住不能直接解构它们,这会导致被解构的值脱离追踪范围从而失去响应性。通常,在 Solid 的 primitive 或 JSX 之外访问 props 对象上的属性可能会失去响应性。除了解构,像是扩展运算以及 <code>Object.assign</code> 这样的函数也会导致失去响应性。</blockquote><p>比如</p><pre><code class="tsx">//不行
function Other({count}) {
return (
<div>
<div>{count}</div>
</div>
);
}
//可以
function Other(props) {
return (
<div>
<div>{props.count}</div>
</div>
);
}
function App() {
const [count, setCount] = createSignal(0);
return (
<div class="x-three-year" onClick={() => setCount((pre: any) => pre + 1)}>
<div class="no-open">你有个蛋糕店待开业</div>
<div class="no-open">{count()}</div>
<Other count={count()}></Other>
</div>
);
}</code></pre><pre><code class="tsx">//可以
function Other({count}) {
return (
<div>
<div>{count()}</div>
</div>
);
}
function App() {
const [count, setCount] = createSignal(0);
return (
<div class="x-three-year" onClick={() => setCount((pre: any) => pre + 1)}>
<div class="no-open">你有个蛋糕店待开业</div>
<div class="no-open">{count()}</div>
<Other count={count}></Other>
</div>
);
}</code></pre><p>而且官方还提供 <code>mergeProps</code>、<code>splitProps</code> 这类 API 去让子组件修改响应式的 props 数据,内部实际上是通过 Proxy 代理做动态追踪。</p><p>二、Solid 的依赖追踪只能针对同步跟踪。</p><p>假设你在 <code>createEffect</code> 中使用 setTimeout 来异步直接获取 SignalState ,则无法追踪 SignalState 的更新,比如以下例子:</p><pre><code class="ts">const [count, setCount] = createSignal(100);
createEffect(() => {
setTimeout(() => {
// no way
console.log('打印count', count());
}, 100);
});</code></pre><p>实际上是因为此时走 readSignal 函数读取 Listener 的时候,基本流程已经走完,数据已经被清空(<code>Listener = null</code> <code> Owner= null </code>),所以在读取时无法对该 SignalState 进行追踪。</p><p>不过可以通过一定方式避免:</p><pre><code class="js">createEffect(() => {
const tempCount = count();
setTimeout(() => {
console.log('打印count', tempCount;
}, 100);
});
</code></pre><h3>框架对比</h3><h4>前端框架流行程度一览</h4><p><a href="https://link.segmentfault.com/?enc=AcpgDLxWw%2FmJ7U82nivi8Q%3D%3D.aIHDpbFOB7DIDH20BkMalemLTQ6RPtah7nwrJ7R4dLxrksyWJmjsrVSFYThsCxOe2dEVrRQlW3s7LMyqK0K0dvjzi0xaIpimtLylyucSDcRivwiOmCYVXj5VTzhbqre65jgWXmbT6nGAlk7aaJJ%2FMlS%2B0EFJnHptzmc3kLLfjKTYV9hizD2k%2FkrZsu6fwnAd" rel="nofollow">npm 下载量查询网址</a></p><p><img src="/img/remote/1460000044422414" alt="" title=""></p><p>目前 state of js 只有 2022 的数据(仅供参考),但是从数据上看使用度还是 React、vue、angular 三巨头独霸一方,但是满意程度确实两大无虚拟 DOM 主力军异军突起。</p><p><img src="/img/remote/1460000044422415" alt="" title=""></p><p><img src="/img/remote/1460000044422416" alt="" title=""></p><h4>Solid 和 Svelte</h4><blockquote>Svelte is to Vue as Solid is to React —— Leo Horie</blockquote><p>就像在国内两极派别的 Vue 和 React,Svelte 和 Solid 的崛起不仅带来了带来了无虚拟 DOM,在编译阶段做更多的事情,还让我们看到新的发展可能性</p><p>虽然两者都是无虚拟 DOM 的框架,但是从最新的 <a href="https://link.segmentfault.com/?enc=VhLuyzAk1zbBpIe1Zn5YtQ%3D%3D.IeEDxu3sO6s4tzNFlQZ3tadyzVcw%2B7BVTyMfgeP97oL7Og89F265A5VKM8johA%2FNNG%2BrAlp8Vnm6Bc7IuozjtA%3D%3D" rel="nofollow">js-framework-benchmark</a> 的公示状况(Chrome 119 - OSX)来看,两者的性能情况大差不差,在 DOM 操作时间,Solid 似乎相对有更好的性能数据,而在内存和启动时间,Svelte 有更好的数据。</p><p><img src="/img/remote/1460000044422417" alt="" title=""></p><h4>与其他框架的对比</h4><p>这边我摘取了 <a href="https://link.segmentfault.com/?enc=u7g03jAzbCx3AmcO9j5Taw%3D%3D.OgyrDCEsCg%2BQrqnY72e6XrfVvvxhRWDnkR2x2u2maxisCeD5O51TVau4eCZFHLDiqdVpGJ38eHUbkNeqewdJBQ%3D%3D" rel="nofollow">js-framework-benchmark</a> 的公示状况(Chrome 119 - OSX),并选择了 ivi、Inferno、Solid、Svelte、Vue、React 进行整体的对比,就结果上来看 Svelte、Solid 的性能是比我们最熟知的 Vue、React 更好一点的,但是对比 ivi、Inferno 这类以性能出名的虚拟 DOM 框架,并没有优势。</p><p><img src="/img/remote/1460000044422418" alt="" title=""></p><p>在<a href="https://link.segmentfault.com/?enc=o0MQ0lRvtS1Ejfk3klnwIQ%3D%3D.JhhkNXVdYRHnPKCGzxd8eRB8erSnBxSE6r1qS5o1Ndo%3D" rel="nofollow">Ryan Carniato</a> 的 <a href="https://link.segmentfault.com/?enc=3OgOR2R0hTYZ9OOTmX3jfQ%3D%3D.awBoSVGaPeYFOYqgiMNKJ6opjBOw4en%2FfmkBolb4RRFxX0B9ikVEELoEGthGR%2Fl5l5SHcksfDepAxA7B6SowI8Y%2BlC0M%2BEQzADQ%2BOVFGTxI%3D" rel="nofollow">The Fastest Way to Render the DOM</a> 中,他采用 jsx、标签模板和 HyperScript 三种渲染模版用 Solid 进行渲染,再与其他 在 <a href="https://link.segmentfault.com/?enc=m4PZJR2paqMt1uXTQkTY1w%3D%3D.9pQOsKVmyrJzt2TfLPTzaYGmovBedYNTmbdNB84wHv%2FU51e9SkaXnAUOFWMQEqefzzOl12XG6UCzLZJAcxHOwQ%3D%3D" rel="nofollow">js-framework-benchmark</a> 上性能表现良好,且相同渲染模版的的 Javascript 框架进行对比,以求更公平的性能对比;</p><p>而最后得到的结果 虚拟 DOM 框架 和 非虚拟 DOM 框架 从性能上来看是大差不差的(严格来说是针对一些性能良好的虚拟 DOM 框架),所以其实没有最好的技术,在历史不断修正和优化中,虚拟 DOM 并不慢,不断的探索是对技术最大的尊重。</p><blockquote>I will admit it was React’s rhetoric about the Virtual DOM’s performance that led me into this space in the first place. The ignorance of opinions going around was infuriating.</blockquote><h3>结语</h3><p>前端框架之争从 jQuery 到日不落 React,把虚拟 DOM 带入了我们的视野,再到如今 Javascript 框架的百家争鸣,更多的技术点在得到重视,改进、发展和探索。</p><p>2024 年虚拟 DOM 依旧是大头,但是无论是依赖追踪,还是在编译阶段做更多的事情 / 优化,是目前的发展趋势。</p><p>没有最好的技术,只有更好。</p><h3>参考</h3><p><a href="https://link.segmentfault.com/?enc=N6e3J523Ih3npCRyyMp7sg%3D%3D.3eTBw77pUg%2BGvXzxs3mAhXApT06E8KMOqbMfMliiuD9um76knUQ7FeJqfqsuNufPsSuAaB6ytA95J1%2F5PuXczuK58dA57u30BpKKD86xGow%3D" rel="nofollow">State of js 2022</a></p><p><a href="https://link.segmentfault.com/?enc=%2Bg2QNcaSB3szXY7EHAMS%2Fw%3D%3D.DEXDFkB1Cjy%2BZXTIUYtpbGewyc95ZSk3%2FnnGUxfDaQ%2FfNTMCrCmQiZttJgui4pahxHJz11XWdLE%2BeDThDbVnMOe2sVAYATHugzmSEejC77dkYZHcPfSP7HftGe1Y6%2B34" rel="nofollow">JavaScript UI Compilers: Comparing Svelte and Solid</a></p><p><a href="https://link.segmentfault.com/?enc=9xjWJu4xcdPksC8FAF2e9g%3D%3D.x2kZmUrrwwnOPfV7MWX44mSOBYxwbyBlbAlEJ1CNYn9UyBcWhz9czCLbVnHFWO%2Blizeeg1SicLrK7mj8Ee%2FpwN0wDwDf0TadnM9ghODPDwU%3D" rel="nofollow">The Fastest Way to Render the DOM</a></p><p>稀土掘金开发者大会 —— 2022 前端生态趋势</p><p><a href="https://link.segmentfault.com/?enc=mggbERDFV2XkAY2dhqkGFA%3D%3D.Y97BUpuI2haX0g8S8pISrDlxvmp74w%2BzpHAl1a%2FEN4Z4p4PV6zIMcDAiA%2B2saOWq" rel="nofollow">Pete Hunt:React:重新思考典范实例的意义</a></p><p><a href="https://link.segmentfault.com/?enc=VVNYnioEZ1%2Fvj9z3%2FLg87g%3D%3D.LDyZjbsaLTwGIWbaizizhxOxPF9OicsO3sZk%2Fq4wxwHMtpOcsnJ41ojxq8gkyLZ8zYciHORvbtK5ndyibwsQCg%3D%3D" rel="nofollow">Virtual DOM is pure overhead</a></p><p><a href="https://link.segmentfault.com/?enc=plGlCGTbGGISIXDVz2GBCg%3D%3D.thbfIr2zKQtgdJlaXKaClzEjFsaTthkThrkOZp3oVRE9veOG49aAPqIiMd3ON1aF" rel="nofollow">The process: Making Vue 3</a></p><p><a href="https://link.segmentfault.com/?enc=aIfjVCk67TK2V3kt%2B9rlpA%3D%3D.3MSk0v8mHPIPS9J18Kc9ybAdksKwbXvn41T0TmYA9OvUb3jBxi7FtVu8DmUbuKvN3IuRGCWmPlzCzu9R%2FByHqA%3D%3D" rel="nofollow">js-framework-benchmark</a></p><h2>最后</h2><p><img src="/img/remote/1460000044422419" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=GKQblWJjblKidsu38i%2F4qQ%3D%3D.nGpsP3GGiKZ%2BLt7deh1NeDqCxjZwurtikGkLIeIvKoQ%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐RPC稳定性建设与实践
https://segmentfault.com/a/1190000044410304
2023-11-22T14:47:56+08:00
2023-11-22T14:47:56+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:断空(王松松)</blockquote><p>在典型的微服务架构中,RPC框架扮演着连接各个服务、组件的关键角色。作为云音乐的基础组件之一,本文将分享我们在RPC稳定性建设过程中的经验和实践。</p><h3>背景</h3><p>在典型的微服务架构的架构中,RPC框架扮演着连接各个服务、组件的关键角色。作为云音乐的最基础组件之一,支撑了用户、会员、广告、数据平台等各个业务的平稳运行。然而随着当前云原生理念的快速发展落地,在当前整体降本增效的大背景下,对RPC也提出了新的挑战。一旦RPC框架出现问题,整个系统的可用性和性能都会受到影响,用户可能会面临播放中断、延迟增加等不良体验,导致用户的流失、客诉等。因此我们开始从架构、流程等各方面重新梳理,参考业界的优秀实践,结合音乐特殊的业务场景,我们开始了RPC的稳定性建设历程。</p><h3>整体架构</h3><p><img src="/img/remote/1460000044410306" alt="架构图" title="架构图"></p><p>由传统的单体应用迁移到微服务后,应用被拆分、部署到了独立的节点、集群,进程内的函数调用不再适用于分布式场景,于是RPC框架应运而生,但同样的也引入了一系列新的问题:</p><ul><li>服务发现: consumer如何快速发现provider所有的节点? provider下线后如何快速将通知下发到consumer</li><li>连接管理:单连接还是连接池? 网络抖动后如何重连、自愈?</li><li>面向云原生:容器化部署后,节点异常、宕机常态化,如何快速感知异常节点并快速熔断</li><li>重试:请求异常、超时应当选择怎样的重试策略合适?</li></ul><p>稳定性是一个非常复杂的话题,从故障的角度来看,可以分为以下几个阶段:</p><ul><li>故障前:这个阶段主要是预防,例如在问题上升为故障前,通过自动化测试、流程规范管控、监控报警、等手段快速发现。</li><li>故障中:这个阶段主要是发现、恢复和定位故障。需要快速发现系统出现的故障,及时采取措施进行恢复,并准确定位故障原因,最大化降低对用户的影响。</li><li>故障后:这个阶段主要对故障发生的原因进行分析和总结,找出不足之处并采取措施进行改进,思考通过标准化、流程化、自动化的手段,避免同样的故障二次出现。</li></ul><p>在后续的行文中,我们将从这几个方面来探讨稳定性的话题。</p><h3>故障前</h3><h4>SLO体系建设</h4><blockquote>You can't manage what you can't measure.</blockquote><p><img src="/img/remote/1460000044410307" alt="SLO" title="SLO"></p><p>就RPC而言,音乐内部有N套平台可以用于故障的发现、预警,例如异常日志、核心指标(线程池、CPU、内存、GC等)、自动化用例等。太多的指标会导致我们忽视真正核心的问题;而选择过少的指标,例如只有异常日志告警,会导致故障无法被及时发现。我们需要站在用户的角度思考问题,当用户使用一个系统是时候更关注的是哪些指标?例如CPU使用率达到80%是一个现象,我们更希望知道对用户的影响是什么,接口可用率下降还是RT升高?</p><blockquote><h5>SLO是什么?</h5><p>SLO是指服务等级的目标值或范围值,通常用于衡量服务健康状况。SLO提供了一种标准化的方式来描述、衡量和监控微服务应用程序的性能、质量和可靠性。SLO为应用开发和平台团队、运维团队提供了一个共享的质量基准,可作为衡量服务水平质量以及持续改进的参考。<br>示例如下:</p><ul><li>90%的RPC请求能够在200ms以内完成</li><li>99%的请求返回的code为200</li></ul></blockquote><p>对于RPC这种在线服务的场景来说,例如歌曲播放、查看评论、会员充值等场景,相对来说我们会更关注接口的成功率、延迟等核心指标,即接口能正常响应用户的请求吗? 花了多久? 因此前期我们选择了成功率、RT来作为SLO的数据源,并重分利用了SLO平台提供的监控、告警等能力,有效实现了对接口可用性的度量,当SLO出现波动时,开发需要及时介入排查。</p><h4>日志治理</h4><p>早期版本打印的日志非常混乱,大部分异常缺少说明,导致用户无从下手。因此我们针对日志进行了一系列治理措施。</p><ul><li>链路串联:将日志和trace打通,当业务排查问题时,通过traceId即可将上下游所有应用关联在一起,并快速跳转到APM平台</li><li>异步适配:RPC和单体应用时代不同,天生就具备了异步的特性,请求发出后,consumer等待,而当provider处理完成之后,框架内部根据requestId关联到对应的请求,并将结果返回。过程中会存在很多线程池切换,例如业务现场、IO线程、worker线程等,而线程池的切换可能会导致traceId丢失,从而影响问题的排查。因此我们针对内部所有的异步、线程切换等进行了统一的治理,有效避免类似问题的发生。</li><li>梳理&&完善核心链路日志</li><li>日志标准化,例如统一前缀、模块,错误码等信息,从而能够根据接口、ip等信息快速匹配到相关异常日志</li></ul><h4>异常大盘</h4><p><img src="/img/remote/1460000044410308" alt="异常大盘" title="异常大盘"><br>当前告警机制更多偏向应用、资源维度,例如应用的异常日志超出配置的阈值、线程池队列堆积等,而RPC作为中心化的组件,一旦出现问题爆炸半径往往不太可控。因此我们在思考如何能够快速的发现框架层的问题。</p><p>对于RPC来说,组件能的问题往往都会通过异常日志体现,例如超时、接口熔断、限流、服务上下线、未知异常等,因此我们通过梳理出所有的logger以及异常日志,并基于此构建了以RPC为中心的异常大盘。并提供了应用维度的topN计算、离群点检测、日志采样等丰富的能力,从而能够协助中心化负责人快速发现问题、确定受影响应用、并通过日志采样等功能进一步根因定位。</p><h3>故障中</h3><h4>降级</h4><p><img src="/img/remote/1460000044410309" alt="降级" title="降级"></p><p>降级平台目前在音乐内部广泛使用,当前已有数千应用接入,通过与降级平台的打通联动,RPC提供了丰富的降级能力:</p><ul><li>模板规则:用户无需触动配置,当错误率查出一定阈值时,自动触发降级</li><li>支持丰富的兜底策略,例如fallback方法调用、固定值,并与缓存平台打通,支持故障时返回缓存中的数据</li><li>监控告警:具备丰富的指标监控以及告警能力,对于BFF等类网关应用,支持支持将告警分发给对应的接口负责人</li><li>动态调整:降级规则支持秒级动态调整与下发,应用无需重启、发布</li><li>便捷:无需主动接入,平台配置后动态生效</li></ul><h3>限流</h3><p><img src="/img/remote/1460000044410310" alt="限流" title="限流"></p><p>通过接入内部的限流平台,提升应用对于异常流量的应对能力,并支持丰富的限流策略:</p><ul><li>单机、并发限流</li><li>分布式限流</li><li>参数限流</li><li>高频限流等</li></ul><h4>离群节点剔除</h4><p><img src="/img/remote/1460000044410311" alt="离群检测" title="离群检测"></p><p>在分布式系统中,宕机是常态时间,而站在服务消费者的层面来说,如果不能及时检测到服务提供者中的异常节点,并快速剔除,将会影响服务的可用性(SLO),进而导致客诉。因此组件层面提供了离群节点检测与剔除能力,当个别节点错误率不符合预期时会将该节点剔除,在指定时间窗口内不再将流量分配到该节点,并再探活成功后,将该节点重新加入服务列表中。</p><h4>线程池隔离</h4><p>若所有的接口都路由到同一个线程池,那么这些接口变会资源争抢的风险,接口A的RT抖动,会导致接口B的吞吐下降,因此对于一些核心的业务,我们希望能够进行线程池的隔离。因此我们对RPC框架的线程池使用进行了梳理之后,提供了丰富的隔离能力:</p><ul><li>产品:支持不同的产品配置路由到各自单独的集群,例如云音乐、直播等不同的APP路由到各自独立的集群、线程池,从而实现更好的隔离</li><li>应用、接口、方法:支持各个粒度的隔离</li><li>区分普通请求、重试请求:例如将重试请求放到单独的线程池中,避免阻塞正常流量</li></ul><h4>快速失败</h4><p>针对超时、堆积等场景,在执行业务逻辑前、执行业务逻辑后进行多层超时判断,例如超时配置为100ms,而provider在收到请求时会进行一次计算,若此时耗时已经大于100ms,则直接返回超时异常,而无需调用业务接口,从而有效避免无效请求。同时结合客户端、服务端提供的异步能力,更进一步减少线程资源的使用。</p><h4>注册中心弱依赖</h4><p><img src="/img/remote/1460000044410312" alt="多注册" title="多注册"></p><p>对于RPC框架来说最核心的依赖便是注册中心,当前云音乐内部使用Zookeeper作为主要的注册中心,而在之前的版本中对于Zookeeper是强依赖,导致Zookeeper一旦抖动或发生网络异常,provider便会大量下线,导致客户端路由不到正确的节点,影响整体的可用性。</p><p>从一致性的角度出发,Zookeeper底层基于Zab的类Paxos协议实现,更注重的是CP,即一致性,因此各种大数据、中间件都会利用Zookeeper来进行元数据、配置的管理。而对于注册中心的场景来说,对于一致性的要求并没有那么高,举个例子,服务提供者有一个节点OOM宕机,没有调用下线接口从Zookeeper中摘除,因此客户端会认为该节点没有下线,仍会保留在路由表中,如果仍将流量达到该节点,那么必然会导致请求失败,但RPC框架通常会具备探活能力,此时下游节点OOM宕机,网络链接断开不可用会直接被节点剔除,对可用率没有任何影响。我们发现对于注册中心来说,底层存储大多数时候只需最终一致即可,不需要这么强的一致性。以这个思路出发,我们对RPC框架进行了一系列专项改造:</p><h5>Zookeeper调优</h5><ul><li>配置、重试策略调优,例如sessionTimeout调整到30s,那么网络抖动只要在30s以内,由于session不会过期,provider也不会触发批量下线,对于服务的可用性几乎没有影响。这个参数需要结合实际的应用场景调整。</li><li>事件监听优化,区分sessionId,若sessionId未变化,说明session未过期,此时provider不需要重新注册。在服务规模较大时,能够减少大量无用的写入操作。</li><li>配置化:重试、超时等参数可动态配置,并将连接串统一管理,节点扩容下线业务无需感知</li></ul><h5>回收站</h5><p>当服务下线时,将该节点的元数据暂存到缓存中,并支持根据容量、过期时间等策略进行清理。当发生Zookeeper大规模故障时,支持从回收站中将已下线节点重新添加到路由表中。</p><p>这里需要注意的是,容器化部署后,节点的ip可能会被其他应用复用、回收站中的节点已经真实下线,因此不能无脑回收,需要对节点进行探活、异常节点检测,从而能够快速将离群节点剔除,最小化对于可用率的影响。</p><h5>自动化降级</h5><p>区别于dubbo、Nacos的推空保护,平台支持手动以及自动化的降级,当监控集群检测到Zookeeper服务端异常时,例如无法正常建立链接、写入失败等,则会根据配置自动打开注册中心的回收站机制,从而有效应对注册中心变更(扩容、修改配置)、网络分区等情况。</p><h5>多注册</h5><p>在新版本中,我们完善了RPC框架的多注册能力,支持将Nacos作为备,并支持动态调整,例如开关打开后,provider会同时往Zookeeper、Nacos注册,而客户端也支持了多订阅能力,并支持丰富的路由策略,例如优先从Nacos读、若Nacos不存在可用节点,从Zookeeper读取作为兜底。</p><p>同时由于平台默认支持了Zookeeper、Nacos的多注册能力,我们在启动时对Zookeeper、Nacos做了强弱依赖的管理,例如当进行Zookeeper扩容、迁移操作时,支持将Zookeeper作为弱依赖,即Zookeeper连接、注册、订阅失败不影响应用启动,框架底层会异步重试,从而更进一步降低RPC对于注册中心的依赖。</p><h3>故障后</h3><h5>经验库沉淀</h5><p><img src="/img/remote/1460000044410313" alt="经验库" title="经验库"></p><p>区别于业务异常,RPC组件内部的代码相对稳定,不会频繁变动,因此问题的定位、排查通常具备一定的套路。因此通过日常的答疑、故障定位等逐步积累了组件的经验库。例如当发生指定异常时,平台自动根据异常、堆栈等信息匹配到对应的经验库,进一步优化用户看到异常后无法自助处理的问题。</p><h3>总结</h3><p>RPC作为一个微服务框架来说,稳定性、性能是最基本的要素,需要我们持续的打磨、治理,同时我们需要思考在当前云原生、降本增效的大背景下,如何能够更好的支撑未来的架构,并给开发者提供更好的使用体验。</p><h2>最后:</h2><p><img src="/img/remote/1460000044410314" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=FVLSH7TF8CchKKsgWGLLlg%3D%3D.f8kcpyWoI3Ufy5PPXoCcBdJQWvCne%2FV5SxmxrzDFvg0%3D" rel="nofollow">https://hr.163.com/</a></p>
低代码在云音乐数据业务中的落地实践与思考
https://segmentfault.com/a/1190000044403630
2023-11-20T16:37:10+08:00
2023-11-20T16:37:10+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=pLa2HGSzy43Gpye9ziwFHg%3D%3D.ZI%2BiY4rOKndO0ptEAHA6EPwPADt%2FiGWGTwedQ%2Fzv4Ic%3D" rel="nofollow">凯尔希</a></blockquote><p>本文主要是介绍下云音乐低代码研发方式,在中后台领域的落地实战路径、成果总结</p><h3>前言</h3><p>笔者负责一个业务型的前端团队,支撑云音乐数据相关的B端产品,需求吞吐量一直是一个关注的重点<br> <img src="/img/remote/1460000044403634" alt="img" title="img"></p><p>但想要提升团队交付量,无非两个方向,增加人手,研发提效,加人显然不符合当前的经济环境,并且很有可能演变成 “面多加水,水多加面” 的人力黑洞,通过低代码的方式,对现有生产过程的进行改造,进而提升生产力,是一个相对可行的方案</p><h3>1.业务痛点</h3><h4>1.1 产品线较多,跨部门协同效率很低</h4><p>由于是跨部门支撑,缺乏其他职能角色,对接的流程比较乱,且后端团队规模远超前端,各业务组竞相锁定人力,团队割裂,目标混乱,前端很难做出价值</p><h4>1.2 团队水位低,需求吞吐量很难提升</h4><p>基层成员因能力受限,不能有效参与日常业务,需求大量积压在头部成员手中,导致交付吞吐量很难提升<br><img src="/img/remote/1460000044403635" alt="img" title="img"></p><h3>2.如何将低代码落地到业务中</h3><h4>2.1 外部协作流程重构</h4><h4>2.1.1 分类分级保障标准</h4><p>我们将过去混乱的产品线进行分类,将保障标准与业务价值锚定,将前端资源进行聚焦<br><img src="/img/remote/1460000044403637" alt="img" title="img"></p><hr><p><img src="/img/remote/1460000044403638" alt="img" title="img"></p><h5>2.1.2 研发元信息标准化</h5><p>为了进一步约束上游需求侧的产出,理清合作边界,减少业务侧对前端的强绑定,我们依托内部的技术产品对研发流程进行了元信息标准化,为低代码落地创造 “技术条件”</p><ul><li>Overmind:云音乐自研产品,具备排期、拆分任务等事项管理,人力可视化的能力</li><li>OX:云音乐自研产品,具备将 Java 代码解析为接口文档的能力,接口即文档</li></ul><p><img src="/img/remote/1460000044403639" alt="img" title="img"></p><h5>2.1.3 双周评审PK机制</h5><p>为了保证上述方案能够落地,前端主导发起双周评审PK,需求先在后端内部PK,再根据 “分级保障标准”,一部被分流给后端搭建,一部分被挤出,我们会为其提供必要的使用培训、落地辅导,为低代码落地创造 “机制保障”</p><p><img src="/img/remote/1460000044403640" alt="img" title="img"></p><h4>2.2 团队研发模式转型</h4><p>在处理完流程机制上的问题后,需要对内进行研发模式转型</p><h5>2.2.1 混合式架构迁移</h5><p>全盘的重建显然不现实,也没必要,基于微前端的混合交付依旧是最优解</p><h5>2.2.2 团队站位重分配</h5><p>为了提升基层人员的参与度,需要对各层级成员进行重新站位,将过去只能由少数人员才能解决的问题,通过复杂度抽离,进行下放,进行生产力改造<br><img src="/img/remote/1460000044403641" alt="img" title="img"></p><h4>2.3 团队的高阶在做什么?</h4><h4>2.3.1 面向前端开发的轮子</h4><p>我们的业务特征就是天天与数据打交道,可视化的诉求相当多,在传统的技术提效路径下,我们已经基于 ECharts 封装了 React 组件,做到面向大部分场景的开箱即用,让初级工程师、外包,在能够兜住底线的情况下,进行快速的交付<br><img src="/img/remote/1460000044403642" alt="img" title="img"></p><h4>2.3.2 面向后端的生码工具</h4><p>但是在低代码平台上,这个玩法是走不通的,因为前后端的搭建理念是不一致的,导致后端根本玩不转<br><img src="/img/remote/1460000044403643" alt="img" title="img"></p><p>基于这个发现,我们基于平台提供对 AST 的操作能力,诞生了面向后端的图表智能搭建助手,这种基于一定组合规则的引导式搭建,在玩法固定的交互设计下,是一种非常适合非前端研发角色的生码工具<br><img src="/img/remote/1460000044403644" alt="img" title="img"></p><h3>3. 阶段数据</h3><h4>3.1 团队需求吞吐量</h4><p>在前端团队结构劣化挑战下,依旧取得了需求吞吐量提升约 100% ,有效支撑了持续膨胀的业务<br><img src="/img/remote/1460000044403645" alt="img" title="img"></p><p>并且做了进一步的占比分析,上述举措确实能让基层人员有效承接业务需求,解决了长期“头重脚轻”的问题<br><img src="/img/remote/1460000044403647" alt="img" title="img"></p><h3>4. 能得到哪些有用的经验</h3><h4>4.1 LowCode 核心是让开发者享受到模板红利,部分新增需求可以通过模板快速交付</h4><blockquote>相信很多研发者看到低代码会觉得,浏览器中托拉拽的搭建方式看似高级,在可维护性、可拓展性上存在很大瓶颈,但我认为这只是产品层面的展示形式,Tango本身基于源码的低代码方案,这些问题都不大</blockquote><p>低代码的核心是让开发者享受模板红利,通过减少编码的工作量,来换取效率的提升<br><img src="/img/remote/1460000044403648" alt="img" title="img"></p><p>这种操作在ProCode时代是一个惯用操作,只不过我们选择了将模板进行在线化管理,打破过去的项目禁锢,将单个开发者可见,变成了全局可见,让模板红利变得更加普惠</p><h4>4.2 长尾需求可通过低代码模式 “换道超车”</h4><p>所有的项目都希望自己的需求能尽快上线,但资源有限,往往会导致长尾需求的积压,通过低代码的方式后端自闭环,让长尾需求 “换道超车”,让前端开发者专注于核心业务,而不是被长尾需求拖累</p><h4>4.2 依托 LowCode 的生产方式改造,是一个相对经济的解决方案</h4><p>怼人力是一个短期很有效的方式,如同玩游戏一样,大力氪金一定出活,但在现实中我们往往要面对招聘、落地、成长等一系列时间和经济成本,依托 LowCode 拉低门槛,让过去不能参加,不能有效参加的成员都能参加进来,是一个非常经济可行的解决方案</p><h3>5. 依旧存在的问题</h3><h4>4.1 业务侧资产的相对匮乏</h4><h5>举一个例子:为什么后端会觉得上手成本高?</h5><p><img src="/img/remote/1460000044403649" alt="" title=""></p><p>我认为直接原因上是由于平台侧提供的物料,大多都是原子化组件,页面的成型完全依赖开发者对组件的组合与配置,在当前业务侧资产的相对匮乏下,只能依赖前端编码来弥补这部分差距;</p><h5>把 “从零到一搭建” 转变为 “修改页面模板”,大幅减少页面成型的工作量</h5><p>我们希望改变后端搭建页面的流程,把 “从零到一搭建” 转变为 “修改页面模板”,大幅减少页面成型的工作量,其中需要大量的业务侧资产的沉淀(样板间、业务组件封装)</p><h2>最后:</h2><p><img src="/img/remote/1460000044403650" alt="" title=""></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=t%2FYIA5CWBKk4%2FigRU0YXNA%3D%3D.5Wz6I8De92Hq0ccN9bK49CVWhEk%2B0%2F4PG%2B77GnvfO4k%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐 FinOps 体系建设
https://segmentfault.com/a/1190000044394837
2023-11-16T17:24:32+08:00
2023-11-16T17:24:32+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=GvwW5snSRjPafp7U%2FwQQpA%3D%3D.bKasUv6%2FYNgUFo%2Bg2yrmaWgelXwsAJTsbR5o10qzwVs%3D" rel="nofollow">吴荣军</a></blockquote><p><em>云音乐设计研发了 FinOps 一站式平台,满足对成本洞察、优化和运营的需求,协同业务获得最大的投入产出比。</em></p><h3>背景</h3><p>当前互联网增长红利消失,要实现 "正循环中,做大用户规模",就需要关注企业经营毛利和利润,除去内容成本,技术侧 IT 成本是非常大的一块,过去一年(2022 年),云音乐开始了技术侧降本增效,其中云原生、容器化主要做的事情包含:</p><ul><li><a href="https://link.segmentfault.com/?enc=yqNxFzs7PBpGRUQ467ohlw%3D%3D.rEj3hHYlL5qqPEENdbhB0FLFCaLttJQT1C5JeDF0spv6YncS7jBzyhB1avgy9r2M" rel="nofollow">Horizon </a>一站式平台(云音乐自研且已开源的容器部署平台),全面推进业务云原生、容器化,实现资源精细化管理</li><li>Serverless 技术支持在离线混合调度大幅提升了资源的利用</li></ul><p>经历了一年技术降本的实践,总结发现仍然有很多挑战:</p><ul><li><strong>安迪 - 比尔定律</strong>:DevOps 提效,会出现典型的安迪 - 比尔定律,资源越容易获取,用得也越多</li><li><strong>成本关注较少</strong>:由于缺乏成本跟踪运营管理平台,所以业务线、平台方也主要关注研发、质量、业务增长,没有花太多的精力关注成本,而且对于没有货币化的资源用量,对业务和开发来说,其实相对模糊和没有 “概念” 的</li><li><strong>一本大账</strong>:底层基础服务现在都是一本大账算,权责不清,相关干系人很难盘活推进治理</li><li><strong>增长控制难</strong>:底层技术侧项目制的成本优化效果好,但是也很容易反弹,很难持续跟踪和控制成本增长</li><li><strong>缺乏平台支撑</strong>:缺乏统一的成本运营管理平台,数据散落,跟踪大多依赖 excel 传递,效果和效率都不好</li></ul><p>为了解决这些问题,结合音乐的现状,我们学习和借鉴了当前比较流行的 FinOps 云财务管理的理念:</p><blockquote>FinOps 是一种不断发展的云财务管理科学和实践,通过数据驱动支出决策帮助财务、技术和业务团队进行协作,使得组织能够获得最大的业务价值</blockquote><p>本文将介绍云音乐内部自研的 FinOps 平台,将从 成本洞察、成本优化、成本运营 三个角度说明 FinOps 提供的平台能力支撑,希望能给</p><p>一些希望开发类似平台的人一些经验参考和启发。</p><h2>介绍</h2><h3>名词解释</h3><ul><li>ROI:投资回报率(Return on Investment),是衡量投资项目盈利能力的指标。它通过计算投资项目的净利润与投资成本之间的比率来衡量投资的效益。公式为:ROI = (净利润 / 投资成本)* 100%。一般来说,ROI越高,说明投资项目的盈利能力越好</li></ul><h3>成本洞察</h3><p>成本洞察主要包含资源跟踪、成本可视化、成本分配和账单管理,也就是发现成本问题。</p><p>下图展示 FinOps 的基础架构图:</p><p><img src="/img/remote/1460000044394839" alt="image-20231116135508596" title="image-20231116135508596"></p><p>成本采集需要做到:</p><ul><li><strong>统一成本接入</strong>:集团内部服务、账单系统是多样纷杂,导致对应账单和用量数据格式也不统一,Finops 首先需要解决统一数据接入的问题,对接外部系统,然后归一化汇聚到 FinOps 系统。</li><li><strong>资源货币化</strong>:对业务和开发来说,资源用量的多少和对应的成本其实相对模糊和没有概念的,所以需要对内部服务产品进行定价、计费及统计,这样让业务和开发实际感知对应资源对应的成本,从而更好的驱动大家对项目 ROI 的评估。更多信息可查阅云音乐技术团队之前分享的这篇文章:<a href="https://link.segmentfault.com/?enc=MeM%2Be8PQNtuO23lHH1G7fw%3D%3D.gW%2BVMuXXAj3tupB29lgwk7BcSTBnZ%2F6Z60ZKNU6GR371b19ltzZJmGS2nfcPsEzeXdPMdIvhbP9%2BCTQfgNJ1jA%3D%3D" rel="nofollow">云音乐 KubeCost 助力 FinOps 降本增效</a></li><li><strong>大账拆小账</strong>:有很大一块成本都是没有拆分的大帐,例如计算资源(如一台物理机上部署了多个业务的云主机),内部的服务平台如发布平台和大数据处理平台等,相关成本都是直接归属相关负责平台的技术团队,上面业务并不感知这块计算资源的成本,所以也没有优化的动力</li></ul><h3>成本优化</h3><p>可以通过 FinOps 查看到资源的利用率等指标,评估资源是否存在浪费,最后执行相关优化动作。</p><p>以云原生为例,目前会给出 CPU 和 Memory 利用率的评级打分,如下表格,当前对于 CPU、内存利用率为<code>P 不合格</code>、<code>B 糟糕</code>、<code>M 合格</code>的,都建议进行优化到至少<code>A 良好</code>的标准</p><p><img src="/img/remote/1460000044394840" alt="image-20231116100806792" title="image-20231116100806792"></p><p>实际操作层面,基于<strong>负责人机制</strong>,我们设计了<strong>治理</strong>页面,针对云原生容器、大数据、PaaS服务等进行针对性的展示,如下为容器内存治理的界面:</p><p><img src="/img/remote/1460000044394841" alt="image-20231116101514944" title="image-20231116101514944"></p><p>每个进入页面的人会看到自己名下有哪些待治理的集群,昨日的峰值内存利用率如何,当前内存规格和推荐规格是多少,如果采用新的规格,每个月能节省的成本有多少,方便应用负责人针对性地进行优化,也展示了近14d待治理集群数的变化趋势,方便负责人验收治理效果。部门负责人可以看到本部门下所有待治理的集群,各个组员待治理集群数的排名,方便跟踪优化</p><h3>成本运营</h3><p><img src="/img/remote/1460000044394842" alt="img" title="img"></p><p>成本优化推进之后,如何长期控制增长,就依赖持续的成本运营,这里驱动的基本逻辑就是:</p><ul><li><p>首先 - <strong>平台服务方</strong>:这里主要包括容器、大数据、中间件等内部私有平台服务提供方</p><ul><li>定价计费:货币化所有资源成本和收入,让所有人切实感知成本,目前包括大数据、云原生、中间件日志服务等</li><li>模式转变:服务平台从成本部门转变为经营收益部门,驱动内部平台提供更有竞争力的服务,避免内部腐化</li></ul></li><li><p>然后 - <strong>业务线</strong>:</p><ul><li>通过内部和外部平台服务统一分账到业务线,业务线感知资源使用成本</li><li>再通过分析成本组成,进而可以计算业务 ROI</li><li>根据实际业务 ROI 情况,决策控制和优化资源用量</li></ul></li><li><p>最后 - <strong>开发</strong>:</p><ul><li>收到来自业务线和平台服务方治理优化的需求,然后根据 FinOps 提供的利用率等手段进行评估和优化</li></ul></li></ul><p><img src="/img/remote/1460000044394843" alt="img" title="img"></p><p>这其中的核心就是通过 <strong>治理分权</strong> + <strong>数据驱动</strong> 去盘活所有干系人参与进来,进而全面建立成本和用量意识,持续改进运营流程。</p><p>(1)治理分权:FinOps 首先通过 <strong>类别(Category)</strong> 的功能,可以实现<strong>任意数据范围的圈选</strong>,使得管理者、财务、业务负责人、一、二、三、四级部门负责人以及每一个开发都能看到自己相关成本和用量等数据,进而将治理分权下放给所有干系人</p><p>其中 Category 的核心逻辑是设计了如下 json 表达式来圈选数据范围:</p><pre><code class="json">{
"and": [
{
"tags": {
"key": "department",
"values": [
"技术中心"
]
}
}
]
}</code></pre><p>如上就表示圈选名为 “技术中心” 的部门下的所有成本、用量数据到一个类别下</p><p>(2)数据驱动:成本用量等数据贯穿整个运营的生命周期,所有干系人都根据数据指标来跟踪和指导下一步动作。例如业务负责人根据成本评估 ROI;或者开发,可以根据其提供服务使用资源的利用率数据,识别出哪些资源浪费,进而可以推动优化,简单的可以通过降配、闲置资源回收,复杂的可以升级架构来提升资源利用率,例如从固定副本数升级为 Serverless 弹性伸缩。</p><h3>未来规划</h3><ul><li><p>成本洞察:</p><ul><li>成本分配:目前音乐内部仅有部分服务,例如容器,大数据、物理机、RDS 等已经将成本拆分到部门和业务线,未来还会和更多的服务提供方合作(例如 CDN、中间服务日志、消息队列等),通过标签等方式,将资源用量和成本拆分业务线和部门,避免 “糊涂账”</li><li>资源生命周期管理:资源挂到人头上,确保业务线等必要的标签打上且正确,实现资源从生产、转移(业务变动、人员离职、转岗)到结束的整个生命周期跟踪</li></ul></li><li><p>成本优化:</p><ul><li>成本治理层面,目前探索了云原生容器领域的治理实践和闭环,后续将把经验拓展到PaaS服务、大数据任务等更多的场景</li><li>架构治理:架构师团队联合推动一些技术架构治理和升级:例如切换至 JDK 17 部署等</li></ul></li><li><p>成本运营:</p><ul><li>通过责任人机制的建立高效推进各项资源治理 (所有<strong>Poor</strong>评价以及以下治理到<strong>Accept</strong>评价以及以上)</li><li>运营机制:设计奖惩,激发自主降本,保持良性循序</li><li>和内部其他核心平台联动,如和CICD平台联动展示成本信息和优化建议,将成本治理变成大家日常都可以轻松完成的事情</li></ul></li></ul><h2>参考</h2><ul><li>OpenCost: <a href="https://link.segmentfault.com/?enc=QMs0oIvPlMW936XAKf4Utg%3D%3D.3XoArxodGqo3KEcaHuVErw3apJYfpKzlX2yL6jgMaFNukzr1beoEhZLUyiaf9Yw2wZFYDf11PkufmK65mwchnON7AtNitRu3AycUYksTPEQ%3D" rel="nofollow">https://github.com/opencost/opencost/blob/develop/spec/opencost-specv01.md</a></li><li>FinOps: <a href="https://link.segmentfault.com/?enc=cZDR73oY9qkzgERv3qoyOg%3D%3D.oSF1iMqII%2FtXMEuwlBnt1m3PeJ4TEBRQzlpR94NWvlht%2FEnrxMOK2BEHxy%2F4SzSnlUJcBFdgXxA8w4Ond6v5Ng%3D%3D" rel="nofollow">https://www.finops.org/introduction/what-is-finops/</a></li><li>Clickhouse: <a href="https://link.segmentfault.com/?enc=Vu3pba1wvNRnEIn8tmQe2w%3D%3D.j%2BNrjDOvMQIMHyOZBfd2lI7Dxbkmm1l6RKaC9VDIr1AW834MDg6HrcazCe4sO4u3" rel="nofollow">https://clickhouse.com/docs/en/intro</a></li><li>毛老师倾情分享B站FinOps实践思路: <a href="https://www.bilibili.com/video/BV1ca4y1T7q8/?vd_source=b9faf4307fb32e2acc499b5e719146d7">https://www.bilibili.com/video/BV1ca4y1T7q8/?vd_source=b9faf4...</a></li></ul><h2>最后</h2><p><img src="/img/remote/1460000044386808" alt="图片" title="图片"></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=BRyKHLG94ekbUcshgzN2QA%3D%3D.7OwGrKGJhHuo79m5QKys2iqaH3AU3nu0U0SHzerDIEc%3D" rel="nofollow">https://hr.163.com/</a></p>
如何使用 Fin2.0 文生图登上云音乐首页
https://segmentfault.com/a/1190000044386794
2023-11-14T11:43:55+08:00
2023-11-14T11:43:55+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:原草(李磊)</blockquote><p>Fin2.0 是一款由云音乐公共技术部开发的智能设计助手。产品愿景是:通过 AIGC 赋能设计过程,降低设计的门槛和成本,让业务创新变得简单。本文通过商务同学如何通过 Fin 2.0 的文生图功能完成了歌曲推广任务这一案例,为大家介绍如何使用文生图生成自己想要的图片,同时为大家带来 Fin2.0 文生图功能在设计和开发过程中的思考和实践。</p><h2>一、背景介绍</h2><h3>1.1 什么是 Fin2.0?</h3><p>Fin2.0 是一款由云音乐公共技术部开发的智能设计助手。产品愿景是:<strong>通过 AIGC 赋能设计过程,降低设计的门槛和成本,让业务创新变得简单</strong>。</p><p><img src="/img/remote/1460000044386797" alt="AIGC 能力矩阵" title="AIGC 能力矩阵"></p><p>我们利用 AIGC 能力矩阵:「文生图」、「文生 ICON」、「文生稿」,重构了整个设计流程。可以让策划、设计和运营,充分利用 AIGC 的相关能力来赋能设计过程,不仅能提高设计效率,还能降低沟通成本,同时可以避免使用外部服务造成数据安全的风险。</p><h3>1.2 事件背景</h3><p>Jersey 是云音乐电波工作室的一位商务同学,负责站内的歌曲推广。这天临时需要设计资源对新歌做推广,可是设计师大大们的档期已经约不到了。这种情况下,商务同学大多数时候会选择请求外部资源。</p><p>还好, Jersey 同学之前通过设计师室友了解到过 Fin2.0 产品,加上自己有过美术相关的设计功底,因此选择通过 Fin2.0 文生图,来生成设计原画,自己来做文字的排版和布局。</p><p><img src="/img/remote/1460000044386798" alt="banner 位图片资源" title="banner 位图片资源"></p><p>最终,Jersey 同学通过半天时间的文生图尝试,完成了两张高质量的 banner 位图片资源,很好完成了站内的歌曲推广工作。</p><h3>1.3 落地成果</h3><p>通过 Fin2.0 文生图功能,可以快速生成大量高质量的图片,大大节省了生产内容的时间和成本。不同类型的场景可以生成不同类型的内容,满足不同的设计和业务需求,扩大内容生产的覆盖面。</p><p><img src="/img/remote/1460000044386799" alt="日飙升榜「第 2 名」" title="日飙升榜「第 2 名」"></p><p>本次文生图案例,帮助 Jersey 同学完成了临时设计资源的需求,助力歌曲很好的进行站内流量转化,歌曲实现了日飙升榜「第 2 名」的好成绩。</p><h2>二、Fin2.0 文生图功能介绍</h2><p>在 Fin2.0 产品设计之初,我们经过了大量的走查调研,了解设计团队现阶段使用 AIGC 工具的方式,以及在使用 AIGC 生成图片的一些问题。</p><p><img src="/img/remote/1460000044386800" alt="使用 AIGC 生成图片的一些问题" title="使用 AIGC 生成图片的一些问题"></p><p>主要有以下三类问题:</p><ol><li>Dreammaker(公司内部署的 Stable Diffusion 服务)使用门槛较高,生图过程需要完成复杂的配置,比如:大模型、Lora、提示词、负向提示词、controlnet、采样器、VAE 等;</li><li>Midjourney 工具需要自费使用,每个团队都有大量生图资源和多账号需求,一般会选择采购多个账号,这对团队开销形成了一定压力;</li><li>对于保密项目,使用外部生图工具(例如:Midjourney)又会担心项目数据安全的问题;</li></ol><p>我们选择与 Dreammaker 合作, 使用其底层计算能力,这样所有的生图数据都会存在公司内部,不必要再担心使用外部设计工具存在的数据泄露问题。同时,Dreammaker 丰富的计算资源,也为 Fin2.0 的服务稳定和生图效率提供了保障。</p><h3>2.1 三步生成图片</h3><p>使用 Stable Diffusion 进行创作,一个文生图步骤,最少需要 30 多个配置参数,主要分为三大类:</p><ol><li>必须类型:</li></ol><table><thead><tr><th>参数名称</th><th>参数说明</th></tr></thead><tbody><tr><td>model_name</td><td>模型名称,底模</td></tr><tr><td>prompt</td><td>正向提示词</td></tr></tbody></table><ol start="3"><li>基础类型:</li></ol><table><thead><tr><th>参数名称</th><th>参数说明</th></tr></thead><tbody><tr><td>negative_prompt</td><td>负向提示词</td></tr><tr><td>sampler_name</td><td>采样方法</td></tr><tr><td>steps</td><td>采样迭代步数</td></tr><tr><td>width</td><td>图片宽度</td></tr><tr><td>height</td><td>图片高度</td></tr><tr><td>cfg_scale</td><td>提示词相关度</td></tr><tr><td>n_iter</td><td>迭代次数,图片数量</td></tr><tr><td>seed</td><td>随机种子</td></tr></tbody></table><ol start="5"><li>辅助类型:</li></ol><table><thead><tr><th>参数名称</th><th>参数说明</th></tr></thead><tbody><tr><td>enable_hr</td><td>是否开启高分辨率</td></tr><tr><td>hr_scale</td><td>高分辨率放大倍率</td></tr><tr><td>denoising_strength</td><td>重绘幅度</td></tr><tr><td>hr_upscaler</td><td>高分辨率放大算法</td></tr><tr><td>hr_resize_x</td><td>将宽度调整到</td></tr><tr><td>hr_resize_y</td><td>将宽度调整到</td></tr></tbody></table><p>LoRA:用于完成特定功能、特定风格、特定形象,一般和底模配合使用</p><table><thead><tr><th>参数名称</th><th>参数说明</th></tr></thead><tbody><tr><td>model_name</td><td>模型名称</td></tr><tr><td>text_encoder_weights</td><td>高分辨率放大倍率</td></tr><tr><td>denoising_strength</td><td>文本编码权重</td></tr><tr><td>unet_encode_weights</td><td>unet 编码权重</td></tr></tbody></table><p>ControlNet,用于特殊场景配置</p><table><thead><tr><th>参数名称</th><th>参数说明</th></tr></thead><tbody><tr><td>open</td><td>是否开启</td></tr><tr><td>processor</td><td>预处理</td></tr><tr><td>model</td><td>模型名称</td></tr><tr><td>weight</td><td>权重</td></tr><tr><td>pixel_perfect</td><td>是否使用 pixel_perfect</td></tr></tbody></table><p>我们总结了一些常用的设计场景:推广和礼物原画的设计场景、游戏图标设计场景、3D 设计场景、赛博朋克设计场景、中国风设计场景、绘画风设计场景等。配合上一个能力强大的基础模型 SDXL,汇合成应用场景下的选项。用于满足在设计和运营日常需求的大部分设计场景。</p><p>每个设计场景,都配置了一套上面的生图参数,每个场景的需求不同,使用的模型也不同。像是用于偏向原画生成会选择 nijiv5style 模型,用于人物生成会选择 MeinaMix 模型,用于写真场景会选择 revAnimatedv122 模型等。另外场景会配合用于生成特定功能、特定风格、特定形象 LoRA 进行微调。共同实现该场景下图片生成的工作。</p><p><img src="/img/remote/1460000044386801" alt="三步生成图片" title="三步生成图片"></p><p>这样,一个看上去复杂的生图工作,就被汇总为简单三步:书写提示词、选择应用场景、点击生成。即刻获得理想的设计素材。</p><h3>2.2 提示词模版</h3><p>在上文的三步生图步骤中,第一步就是提示词。提示词关系到了图片生成的内容、风格、角度等。提示词之于文生图,就仿佛剧本分镜之于电影,就仿佛草稿大纲之于小说,就仿佛说明规则方法之于 AutoGPT。是决定了一张图片灵魂与命运的主旋律。</p><p>但是,往往越是重要的东西,很多同学在使用的时候越是无从下手。因此,我们总结了一套公式,用于拼装组合成文生图的提示词。有了这套公式,在书写提示词也会知道如何下手,并且如何科学的修改提示词。</p><p><img src="/img/remote/1460000044386802" alt="提示词模版" title="提示词模版"></p><p>提示词万能公式 = 画面主体 + 主体修饰 + 镜头光影 + 风格设定</p><p>这套提示词主要包含四部分:</p><ul><li>画面主体:画面中主体内容,通常是人物、动物、物体。例如:少年、美少女、羊、湖泊、高山、礼物盒、黑胶、唱片机等;</li><li>主体修饰:接下来就是描述主体状态的词语。例如:五官(大眼睛、红嘴唇)、表情(微笑、困惑、叹息)、头发(长头发、粉色头饰)、服饰(牛仔裤、汉服、圣诞帽),动作(跑、跳、飞),环境(城市、草原、日出、花海、沙漠、戈壁、大海)</li><li>镜头光影:这部分主要就是成像的角度和光影,有摄影经验的同学会有体会。例如:镜头角度(中景、俯视图、水下拍摄、广角)、光线(氛围光、丁达尔效应、霓虹光)、画面质量(大师质量、高清画质)</li><li>风格设定:最后就是整体呈现的画面风格,这部分有些情况下也会由模型和 LoRA 来代替。例如:风格(吉卜力风格、皮克斯动画)、画面类型(照片、写实、纹理、中国风)</li></ul><p>如此按照上面的结构,就可以完成一个标准的文生图提示词。至于为什么需要这么做,我会在下面章节「如何写好提示词」进行进一步的阐述。</p><p>但是,像是 SDXL 这样的模型,已经完全摆脱了这样类似 tag 的描述方式。SDXL 完全支持通过语义化描述画面内容的方式,例如我在一次业务中使用到的描述:</p><blockquote>远处是沙漠,近处是胡杨树林,大面积的湖面,戈壁,少量羊,蒙古包,丰富细节,水粉画,远景,风景</blockquote><h3>2.3 高级设置、历史记录、素材库</h3><p>对于 Stable Diffusion 的高级玩家,或是对生图需要细致调节的用户,我们也准备了高级设置功能。</p><p><img src="/img/remote/1460000044386803" alt="高级设置" title="高级设置"></p><ul><li>尺寸-分辨率:常用模型尺寸围绕 512*512 进行配置,SDXL 围绕 1024*1024 进行修改,这是和模型训练时使用的资源相关的;</li><li>迭代次数:Stable Diffusion 是基于噪点图像生成图片的,每次的迭代会对比提示词和当前迭代结果,默认值即可,某些情况下增加迭代次数可以增加图片细节;</li><li>提示词强度:迭代过程与提示词的对比强度;</li><li>随机种子:代表起始生成的噪点图;</li></ul><p>我们还支持了生成历史和素材库两个功能。</p><p><img src="/img/remote/1460000044386804" alt="生成历史和素材库" title="生成历史和素材库"></p><p>「生成历史」包含了使用 Fin2.0 生图工具过程中生产的所有图片,收藏功能便于用户查找精品图片。</p><p>「素材库」是 Fin2.0 内部画廊,优秀作品的展出舞台。想要生成同款素材可以使用一键同款功能,复制提示词和参数,生成自己的素材。</p><h2>三、Fin2.0 生图经验分享</h2><p>在推广 Fin2.0 生图工具的过程,有各种各样的使用生图工具的姿势。最典型的一种就是只描述自己的需求,但是没有描述图片的具体内容。例如:</p><blockquote>我想要一个盲盒皮肤</blockquote><p>想要使用好工具,除了通过定制生图模型参数、科学配比提示词,最好的方式就是了解生图模型是如何运作的。这样,知其然知其所以然,才能更好的使用生图工具。下面我就通过自己的视角,来解释生图模型和提示词在其的作用。以及如何在使用最基本的生图模式的情况下,更好的完成复杂图片的生成。</p><h3>3.1 如何理解生图模型</h3><p>Stable Diffusion 的生图原则,就是将文字信息和图片信息通过噪声预测器进行转化。这样在文生图的时候,就可以把文字信息转化为图片信息。图生图同理,把图片信息加上一定的文字信息(作为修改)再转化为图片信息。</p><p><img src="/img/remote/1460000044386805" alt="Stable Diffusion 生图流程" title="Stable Diffusion 生图流程"></p><p>因为每张图片像素分布满足一定规律分布,比如人脸有眼睛鼻子嘴巴,汽车是长方体有轮子。因此可以利用文本信息作为指导,把一张纯噪声的图片逐步去噪,生成一张跟文本信息匹配的图片。</p><p>整个生图过程是一个组合系统,里面包含了多个模型子模块。</p><p>首先,把文本信息转化为数字信息,这里就用到了文本编码器 text encoder(蓝色模块),可以把文字转换成计算机能理解的某种数学表示,它的输入是文字串,输出是一系列具有输入文字信息的语义向量。</p><p>接下来,有了这个语义向量,就可以作为后续图片生成器 image generator(粉黄组合框)的一个控制输入,这也是 stable diffusion 的核心模块。图片生成器生成潜在图像(也就是噪声图片),噪声预测器根据语义向量估计噪声图片中的噪声,从噪声图片中减去预测出来的噪声,生成新的潜在图像。多次重复上面的「预测+去除噪声」过程,最终得到生成图片。</p><p>想要使用好文生图,理解到这里就够了。因此想要生成一张好的图片,最好的办法就是描述出图片中的信息,也就是描述清楚提示词。</p><h3>3.2 如何理解提示词</h3><p>上面提示词模版介绍了如何科学的书写提示词。那么根据上文中图片信息是由文字信息转化来的,这里的文字信息一般用 token 表示(对,就是 ChatGPT 里同样使用的 token)。因此,也就难怪为什么我们描述图片信息的时候,都是一个一个的单词或短语了。当然,最新的 SDXL1.0 模型已经支持用自然语言描述图片内容。</p><p>至于为什么这么写提示词,我们还是从训练模型的过程中找找原因。训练一个图片模型,一定会需要图片和文字信息成对存在,也可以称为打标签。接下来,我们来做个测试。下面是是一张我用 Fin2.0 文生图创建的图片,请描述下面的图片:</p><p><img src="/img/remote/1460000044386806" alt="测试图片" title="测试图片"></p><p>如果没有经过一定的训练,或是按照一定的标准。我想大家的描述可能会是这样的:</p><ul><li>戈壁图片,有山、有水、有羊;</li><li>黄色的草原氛围图;</li><li>新疆山水+胡杨树+羊;</li></ul><p>我实际采用的描述词是这样的:</p><blockquote>远处是沙漠,近处是大面积的胡杨树林,大面积的湖面,戈壁,绵羊,蒙古包,丰富细节,近景,风景,儿童水彩画,</blockquote><p>可以看到,一张图片在不同人的理解下,所使用的文字描述一定是不同的。但是,在进行模型训练的时候,大多都是采用 tag 的方式,按照画面主体、主体描述、风格设定、镜头光影,这样大致的分类来分层次描述。因此,这也是为什么采用上面的格式书写提示词,才是最高效的。</p><h3>3.3 如何制作复杂图片</h3><p>在实际使用文生图的过程中,已经按照提示词模版科学的书写提示词,可是画面还是不受控制,还是没有达到我想要的样子,这是为什么呢?</p><p>有一部分原因是对模型理解的不到位。比如:使用了一个擅长生成风景的模型来生成人物;使用了一个擅长生成国风风格的模型来生成漫画风;使用 1024*1024 尺寸来要求模型(训练时采用的 512*512 尺寸图片)生成图片,结果图片崩坏多头多手(SDXL1.0 是支持 1024*1024 尺寸图片生成的)。</p><p>因此,对一个新需求最快捷的完成方式是:一方面,查看模型的预览图,查找最合适的模型,按照常规 512*512 尺寸生图;另一方面,可以采用同一批提示词,对不同模型进行尝试,查找最符合自己需求的模型或场景。</p><p>选好了模型,再有就是给模型提供更多的信息。按照上文模型生图的原理,除了可以提供文字信息,也可以提供图像信息,通过图生图来生成。</p><p>下面,我就介绍下在没有 controlnet 或是其他 Stable Diffusion 插件的情况下,实际生产过程是如何如何生成复杂图片的。比如有这样一个需求:需要生成一个盲盒贴图,画面中有情侣头像、爱心、花朵、问号元素。如果简单把元素输入到提示词当中,那一定是抽盲盒似的,每次生成的内容都是不一样的,而且很少有图片能达到设计需求。</p><p><img src="/img/remote/1460000044386807" alt="制作复杂图片" title="制作复杂图片"></p><p>通过图生图生成图像,就好像为图片生成了一部分草稿,让模型按照我们的要求来生成图像。因此我先生成局部头像,然后采用设计工具 mastergo 或 figma 对图像进行拼装,配合上底色和关键元素问号。最后,使用这样一张草图让模型进行重新润色。这样只需几个简单的过程,就可以很快的得到理想的图片。</p><h2>四、总结</h2><p>现在 Fin2.0 文生图已经有大量的落地案例。例如:</p><ul><li>云音乐商务推广运营位图片</li><li>云音乐商城 H5 头图</li><li>社交直播盲盒贴图</li><li>社交直播称号背景图</li><li>...</li></ul><p>想用好一个工具,最好的办法还是多练习。本文只是从很基础的方式介绍了如何使用 Fin2.0 生图工具,如何填写提示词,如何理解模型、理解提示词,如何采用更高效的方式生成更复杂的图片。除此之外,Stable Diffusion 还有很多值得去学习的知识。例如:上面罗列的那么多文生图过程中使用的参数,对生成图像有什么影响?文中提到的 ControlNet 是什么东西?如何生成更高清画质的图像?</p><p>回到文章最初,Fin2.0 工具的愿景就是:<strong>通过 AIGC 赋能设计过程,降低设计的门槛和成本,让业务创新变得简单</strong>。接下来我们会持续收集用户在使用文生图过程中的反馈。持续迭代优化产品,通过 Fin2.0 为用户提供更多便捷易用的功能。</p><h2>参考链接</h2><ul><li><a href="https://link.segmentfault.com/?enc=foE%2Bx2mYTKWmwEtpTb2wUg%3D%3D.ei2q%2FFeCbSTvPJGlz0WUdTfhJOkTUh4r7YO9zD1%2ByTXsEq402VqCTIUL%2BAf0mLOXVW4YsPTAfSLDJXA5x5pwS79paRbIHpqKivKSwJVYK2U%3D" rel="nofollow">https://huggingface.co/docs/diffusers/api/pipelines/stable_di...</a></li><li><a href="https://link.segmentfault.com/?enc=L69xvgZEai0XdlzE4EIshQ%3D%3D.TfeTyK5t8%2Fhjzie33JQ%2BvJtbsUjglLTcNnt23FYMYbokwxP%2FpwlTIGTsKRA9WIMvo2KO1L2ehA%2FRGnExd6ri7pHAjBYZLwwUYti8hw7ZxUAEf9ZOixVnu%2BlRBZSNzqgf" rel="nofollow">https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/Features#prompt-matrix</a></li><li><a href="https://link.segmentfault.com/?enc=FUWilTHHWqgFGGtyOTGm1Q%3D%3D.r7xPQh48eUXW0H3%2BH54KLuS4QmXHBmfuR3JnHhUlW%2FU%3D" rel="nofollow">https://stablediffusionxl.com/</a></li><li><a href="https://link.segmentfault.com/?enc=TaKGdkvlvV450V4yuuIRoA%3D%3D.PyVKy1byYAMf7bLH0rsRcti5x%2BSVHVl5BZSnWyQ9AyuStrin36ylGrCfNZvkZ1Vw" rel="nofollow">https://github.com/CompVis/latent-diffusion</a></li><li><a href="https://link.segmentfault.com/?enc=MX%2Fy7oMJCjrhBDobYAKb7Q%3D%3D.XASft0AD63ZiOurKdjMVNwBJfeFKQrw9QDHomdQiPwzZyMmoYxAYQ8QtQbbJ4fL3" rel="nofollow">https://zhuanlan.zhihu.com/p/628714183</a></li><li><a href="https://link.segmentfault.com/?enc=3QV8tn%2FGvfWnVg26E8OIPw%3D%3D.9J%2FaciIiXxLZg8%2BoLuoRKTACb6MdGUfRcvWKoQREUwJqAEQ806scOI%2FKckKrdpFn" rel="nofollow">https://www.uisdc.com/lora-model</a></li></ul><p><img src="/img/remote/1460000044386808" alt="图片" title="图片"></p><p>更多岗位,可进入网易招聘官网查看 <a href="https://link.segmentfault.com/?enc=nQAnUaZkIwnjuUM2rJFfNA%3D%3D.27qOMOx8a0TQKt8Ady3U%2FhAvb2JX%2FaunRAYuZOIDIos%3D" rel="nofollow">https://hr.163.com/</a></p>
云音乐社交直播活动校验自动化
https://segmentfault.com/a/1190000044378413
2023-11-10T10:32:49+08:00
2023-11-10T10:32:49+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者: 赵浪、孙佛喜</blockquote><p>针对社交直播活动的特殊性,从日常任务榜单回归以及线上配置检查2个角度出发,搭建活动体系下的回归校验平台,提高人效,保障线上活动质量。</p><h2>背景</h2><p>活动在社交直播的业务中占比较重,且周期短、频次高、玩法复杂多样,在人力投入有限的情况下,活动的质量保障存在以下挑战:</p><ul><li><strong>回归投入人力高</strong>:对于运营类的日常活动,一次开发完成后,后续高频的日常的活动多以运营为主,但每次启用此类活动,都需要投入人力回归一遍。因此,寻求一套自动化的回归方案,变得必要且重要。</li><li><strong>配置校验易出错</strong>:活动有着复杂的玩法场景,但为了保证其运营的自由度,往往会存在大量的运营配置。运营一般是以文档的形式编写活动所需的所有配置,然后提供给各需求方查阅。一方面,大量的文本信息,查阅方难以准确的定位到各自业务关注的信息;另一方面,这些配置数据配置到线上各个后台后,需要再拉齐开发、QA和策划完成线上配置的人工检查,效率极低。</li></ul><p>针对以上两点,分别实现活动回归和配置校验的自动化流程。</p><h2>活动回归自动化</h2><p>运营类的日常活动,场景基本固定,回归流程也比较标准。但人工回归成本较高,且存在人员调整对业务不熟悉的情况。因此可按照标准回归流程,实现一套自动化回归的流程。</p><p>目前使用的GoAPI平台存在以下困难:</p><ul><li>构造活动场景的数据困难。活动数据的构造,需要一系列的配置辅助,目前GoAPI比较独立,无法形成联动。</li><li>场景用例较为复杂,GoAPI的场景能力无法支持。</li><li>结果校验,GoAPI自由度不够,校验有限。</li><li>无法生成活动的整体回归报告。</li></ul><p>因此,考虑通过平台调度+用例脚本的形式,实现从造数,到场景执行,再到校验,最后生成可视化报告的整体流程。流程如如下: <br><img src="/img/remote/1460000044378415" alt="" title=""></p><ul><li><p>任务管理系统:负责管理计划任务的创建、执行和可视化报告:</p><p><img src="/img/remote/1460000044378416" alt="" title=""></p></li><li>一类活动对应一套用例,维护在单独的脚本工程中,并通过Jenkins任务调度执行。</li><li>任务管理系统触发Jenkins任务,先执行前置脚本,完成整体的准备工作,再逐一执行用例脚本,完成该类活动的回归执行。</li><li><p>用例执行结果异步上报任务系统,再生成可视化报告(支持分组维度和结果维度):</p><p><img src="/img/remote/1460000044378417" alt="" title=""></p></li></ul><h2>活动配置校验自动化</h2><p>在一次大型的活动中,存在多场次不同类型玩法的组合,每个玩法是不同的运营人员来规划运营和协作,当前的配置和检查流程如下:</p><ul><li>各个运营人员按照各自的业务需求,在共享文档中,对各自业务的玩法配置做记录;</li><li>然后在各自在线上配置后台中完成活动配置数据的录入;</li><li>上线前,运营人员协同技术人员,通过人工检查的方式,对线上配置数据进行检查。</li></ul><p>以上流程存在的痛点:</p><ul><li>配置信息量大,没有标准化的配置文档,配置的层次结构不清晰,一方面无法统筹整个活动配置,一方面难以聚焦关注的配置数据。</li><li>线上配置分散在多个平台页面中,靠人工检查,容易遗漏。</li></ul><h3>方案</h3><p>为了解决以上配置非标准化带来的各种痛点问题,我们结合当前活动的业务场景,将配置规范化为四大模块:榜单、任务、抽奖、兑换。然后在此基础上,结合业务提供的配置查询能力,实现一整套活动配置校验的自动化。架构如下:</p><p><img src="/img/remote/1460000044378418" alt="" title=""></p><ul><li>文档:在活动的维度,以在线导图的形式,记录活动配置数据。支持大纲视图和导图两种预览形式。</li><li>Template:比对模板,配置数据中存在大量的属性数据,属性数据有些是需要校验,有些是不需要校验,因此转成统一的模板,来进行对比校验。</li><li>Validator:校验器,完成线上配置数据的比对模板与文档配置数据的比对模板的校验,并输出校验结果。</li></ul><h3>操作流程</h3><p>具体的后台操作上,我们考虑借鉴导图的模式,来实现统一的数据规划。通过实现在线结构化导图的形式,来替代非标准化的共享文档记录方式,既能够更清晰的统筹一次活动的所有配置数据,也能够快速聚焦到某一块关注的配置数据。具体操作界面如下:</p><ul><li><p>一次大型活动的所有配置称之为“文档”,或者说文档就是活动所有配置的集合体,统一在文档后台管理:</p><p><img src="/img/remote/1460000044378419" alt="" title=""></p></li><li><p>文档的具体配置中,以活动为根节点,借鉴在线导图的形式,逐级划分子活动,然后子活动中挂载具体的业务场景配置(也就是规范出来的四大模块),整体的结构如下:</p><p><img src="/img/remote/1460000044378420" alt="" title=""></p></li><li><p>导图中,节点支持增删改,双击模块节点可进入模块的配置面板。结合每个模块需要的业务配置项,将业务预期的结果数据,转化为标准的配置数据,具体如下:</p><p><img src="/img/remote/1460000044378421" alt=" title="任务模块配置面板"" title=" title="任务模块配置面板""></p><p><img src="/img/remote/1460000044378422" alt=" title="榜单模块配置面板"" title=" title="榜单模块配置面板""></p><p><img src="/img/remote/1460000044378423" alt=" title="抽奖模块配置面板"" title=" title="抽奖模块配置面板""></p><p><img src="/img/remote/1460000044378424" alt=" title="兑换模块"" title=" title="兑换模块""></p></li><li><p>文档管理后台触发校验后,后端异步执行校验,并将校验结果划分为四类:</p><ul><li>线上一致:某一项配置数据,文档和线上的配置一致。</li><li>线上缺失:某一项配置数据,文档中有,但线上没有配置。</li><li>线上多出:某一项配置数据,文档中没有,但线上有配置。</li><li>线上差异:某一项配置数据,文档和线上都有配置,但是配置数据不一致。</li></ul></li><li><p>校验报告同样按照 子活动-结果分类-具体配置项 的层级聚合展示,示例如下:</p><p><img src="/img/remote/1460000044378425" alt=" title="校验成功的报告示例"" title=" title="校验成功的报告示例""><br><img src="/img/remote/1460000044378426" alt=" title="校验失败的报告示例"" title=" title="校验失败的报告示例""></p></li></ul><p>至此,一整个配置和校验流程结束。</p><h2>总结</h2><ol><li>活动回归自动化,实现了计划管理、Jenkins任务调度、场景用例脚本执行和可视化报告的能力,完成了直播&社交直播下活动榜单和活动任务的核心场景接入。在借助平台的自动化回归能力,可快速完成榜单和任务模块的回归验证,从原有的人工手动验证1d降低至0.2d,在提升回归效率的同时又增加了活动核心场景的保障维稳手段。</li><li>活动配置校验自动化,实现了文档的管理能力、活动业务数据查询能力、文档配置与线上配置的比对校验能力。同时提供了丰富的可视化文档页面,可快速进行活动模块数据的查阅。在配置比对效率上,从原有的各项目参与同学线下拉会对齐(单人*0.5d),降低至单人0.1d,提高了整个活动团队的配置比对效率,在大型活动中效果更为显著。</li></ol><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 <a href="mailto:grp.music-fe@corp.netease.com">grp.music-fe@corp.netease.com</a>!</blockquote>
云音乐服务监控系统(Pylon APM)建设实践
https://segmentfault.com/a/1190000044369129
2023-11-07T10:45:11+08:00
2023-11-07T10:45:11+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:碧海(蒋星韬)</blockquote><p><br><br><em>云音乐服务监控系统(代号:Pylon APM)为业务提供服务监控,链路追踪,治理分析,问题诊断等能力,本文介绍了平台建设中的一些实践经验。</em><br><br></p><h2>一、背景介绍</h2><p>云音乐服务端原有的服务端应用监控体系,存在很多痛点和问题,导致出现线上问题时,定位的效率不太理想。服务端应用监控体系主要存在以下几个问题:</p><ul><li>Trace链路完整性问题:老的trace是通过组件sdk埋点的形式,进行trace的记录与输出,导致了trace的完整性依赖埋点逻辑,如果链路埋点处理不正确,会出现上下文异步透传丢失,trace数据冲突混乱的问题。同时,对于异常的非采样链路,在采集时,无法回溯上游来源,经常出现定位信息不足的问题。</li><li>Trace与Metric割裂问题:trace与metric之间缺少数据关联,metric服务监控数据依赖其他平台,导致慢请求,慢sql之类的问题场景定位时,找不到具体发生问题的trace。线上发生异常错误指标报警时,很难找到对应的错误链路,定位问题效率低下。</li><li>Trace与日志联动问题:业务服务产生ERROR日志时,有追溯异常调用的来源的需求。低采样率的线上场景,只能找到日志,而找不到请求的具体链路,对问题排查帮助很小。</li><li>版本升级迭代困难:版本升级依赖业务服务升级sdk,推进困难,功能迭代效率低。</li></ul><p><img src="/img/remote/1460000044369132" alt="" title=""></p><p>开源项目中Pinpoint和SkyWalking都是目前比较成熟的链路追踪方案,两者各有优劣,在对比中,我们发现Pinpoint与云音乐的链路模型更加接近,插件开发也更加友好,并且国内有多个基于Pinpoint的商业化落地项目落地,稳定性有保障。 </p><p>最终我们选择基于Pinpoint开源方案,进行了深度的自研改造和优化,希望达成以下目标:</p><ul><li>业务服务解耦:Java Agent形式实现应用监控功能,与服务代码完全解耦,业务无感知接入,无感知升级。插件化实现,能够在管控平台通过开关动态控制细粒度功能的开关。</li><li>保证链路完整性:通过异步上下文管理无感知解决了链路异步透传的问题,保证trace透传的完整性。同时通过TailBased方案,实现了异常错误链路完整采样的能力,最大限度的保证链路问题定位有效性。</li><li>集成Metric能力:通过集成prometheus组件,实现了应用服务监控的能力,开发相比哨兵监控项更加简单。同时实现了Metric监控联通Trace的能力,对于指定监控指标,能够根据监控值检索对应Trace。</li><li>问题快速诊断:不论是异常日志,还是错误、长耗时调用,都能通过元数据或监控数据关联到完整的链路,在平台快速下钻,提升问题定位效率。</li><li>问题诊断工具:提供自动异常现场采集能力,集成白屏化诊断工具,完善问题分析能力。</li></ul><h2>二、项目思路与方案</h2><h3>2.1 项目整体架构</h3><p><img src="/img/remote/1460000044369133" alt="" title=""></p><p>主要分为Agent和Console两个部分,Agent主要负责Trace生成与传递,Metric记录与上报,实现了一套字节码注入工具,以及数据处理框架,再通过插件化的形式,实现不同组件的trace与metric能力。Console主要负责数据的收集与存储,分析与展示,将Trace,metric,log联动的数据模型,通过链路问题定位能力串联起来,实现快速的问题诊断。</p><h3>2.2 基于Pinpoint开发的Java Agent</h3><p>开源的Pinpoint实现了插件化的Trace能力,并且实现了很多常用的中间件的插件,但是开源Pinpoint Agent依旧存在以下问题:</p><ul><li>Trace模型过于简单,对于部分Trace使用场景无法很好支持(比如消息队列多个消费者的场景,消费者之间无法区分),支持的链路类型有限,元数据管理不方便。</li><li>上下文透传能力支持不足,Trace上下文因为支持透传,很多时候业务上下文,可以复用这部分能力,不需要重复开发,Pinpoint这块支持不足。</li><li>异常链路回溯采样不完整,对于非采样链路,出现异常时,无法回溯采集上游,定位效率会大打折扣。</li><li>不支持Metric能力,无法关联监控数据,浪费了切面中的数据与状态结果。</li></ul><h4>2.2.1 扩展Trace数据模型</h4><p><img src="/img/remote/1460000044369134" alt="" title=""></p><p>基于Pinpoint Trace-Span-SpanEvent的模型,扩展了部分关联字段和透传字段,使得Trace能够支持多下游关联,异步下游关联,异步回调关联等能力。在上下文透传上,支持进程内字段透传,跨进程字段透传,跨进程字段反向透传,并提供专门的透传sdk供接入方使用。</p><h4>2.2.2 异常链路后置采样</h4><p>链路上发生单点异常时,如果只是把异常点及其下游链路采集上来是比较容易的,但是这样带来的问题定位收益并不高,很多时候不知道上游来源的话,问题定位无法继续下去。为了解决异常链路完整采样的问题,我们实现了一套TailBased的异常链路采集能力。具体方案示意如下图:</p><p><img src="/img/remote/1460000044369135" alt="" title=""></p><p>每个服务节点上,对于短时间内的Trace,会先全量输出到一个全量日志中,当链路上发生异常时,对应服务的Agent会将异常TraceId写入到中心化缓存中,并在Trace上下文信息中带上标记。独立的Tail线程会以一个稳定的延迟(30s~1min),扫描全量日志中的trace数据,发现存在于缓存中的异常TraceId后,将该TraceId关联的链路数据写入到最终的采集日志中,实现完整的链路采集。</p><h4>2.2.3 Prometheus监控集成</h4><p>我们在Agent端集成Prometheus sdk,用以记录和输出监控数据,服务端通过Pull请求定时拉取每台服务上的监控数据,进行数据的预聚合,最终写入到vm storage存储中。监控数据在记录过程中,还会与当前TraceId进行关联,输出到关联日志中,保证每项监控数据,都有一定的Trace链路数据进行关联定位与分析。关联示意图如下所示:</p><p><img src="/img/remote/1460000044369136" alt="" title=""></p><h4>2.2.4 自动Jstack采集</h4><p>线上服务在发生问题时,经常要面对抓不到现场的情况。我们对于有可能出现服务阻塞的场景,启动了异步监听任务。当调用方法执行时间超出设定阈值时,对当前线程执行一次Jstack堆栈采集,将当前的执行现场保存下来,同时关联TraceId和方法监控指标,便于追溯。流程示意图如下:</p><p><img src="/img/remote/1460000044369137" alt="" title=""></p><h3>2.3 APM产品设计</h3><p>开源的Pinpoint自带了pinpoint-web管控界面并不能满足我们的需求,我们重新开发了一套APM平台,以应用为中心视角,划分不同维度的监控指标,再到不同监控视角下,通过Trace,Metric,Log联动,来帮助快速定位线上问题,APM平台主要具备以下几个能力。</p><h4>2.3.1 链路详情诊断</h4><p>完整的展示从请求入口到下游所有节点的调用拓扑关系,以及请求耗时分布信息,是链路详情的基本功能。为了定位关键透传字段丢失的情况,验证链路上下文正确与否,平台链路详情中还包含透传字段以及部分请求参数,使用者可以选择全局视角或进程视角查看调用栈,状态参数帮助快速定位到异常节点。</p><p><img src="/img/remote/1460000044369138" alt="" title=""></p><p>在链路详情页可以查看关联的日志信息,实现与日志联动定位问题。</p><p><img src="/img/remote/1460000044369139" alt="" title=""></p><p>单个调用接口的详情页中,除了进程内调用栈,还有监控信息联动。</p><p><img src="/img/remote/1460000044369140" alt="" title=""></p><h4>2.3.2 应用监控图表</h4><p>平台以应用视角为中心,利用Agent集成的监控数据采集,构建了监控图表大盘,通过不同的元数据分类,平台支持HTTP,RPC,消息,数据库,缓存等各个独立视角的监控数据,以大盘曲线结合表格下钻的形式展现。</p><p><img src="/img/remote/1460000044369141" alt="" title=""></p><p>大盘图表在Grafana基础上,做了二次开发,支持同环比分析,多实例比较等实用的数据分析功能。</p><p><img src="/img/remote/1460000044369142" alt="" title=""></p><h4>2.3.3 异常错误长耗时关联分析</h4><p>为了解决目前查找异常监控点相关链路时,找不到可用链路,导致问题定位进展困难的问题。我们平台打通了监控数据与关联的TraceId,让使用方能够快速的找到关联链路,推进问题定位。平台提供了监控大盘图表,以及相关的下钻链路检索,用户可以在界面上定向检索关联的异常链路TraceId,每个TraceId下钻后,会到达详细的Trace详情页。</p><p><img src="/img/remote/1460000044369143" alt="" title=""></p><h4>2.3.4 耗时请求Jstack追踪</h4><p>触发了自动Jstack采集的方法,在平台上会给出提示文案。每个具体的Jstack采集结果,有详细的堆栈信息,关联的Trace上下文信息以及线程池信息。除开自动Jstack采集,平台还支持主动下发Jstack请求,主动抓取现场。</p><p><img src="/img/remote/1460000044369144" alt="" title=""></p><h4>2.3.5 Arthas在线诊断</h4><p>平台还集成了Jstack,Arthas等使用频率较高的定位工具。通过Agent连接,用户可以在平台上使用工具直接对服务进行信息采集。采集结果被收集后,平台提供更友好的展示和进一步分析的能力。</p><p><img src="/img/remote/1460000044369145" alt="" title=""></p><h2>三、项目总结</h2><p>在项目的开发学习过程中,我们积累沉淀了一些线上问题定位的方法论,总结了很多针对服务端问题定位的流程与工具。我们希望能够将这部分经验通过产品的形式呈现出来,来帮助面对问题无从下手的同学,通过路径引导快速得到问题信息。对于有一定问题定位经验同学,提供更加易用,更加高效定位工具,打通定位流程上的各个环节。最终达到快速定位发现线上问题,快速止血的目标。</p><p>当然,线上服务治理不是光靠单一平台就能完全覆盖的,Pylon大平台下还提供了业务日志,监控分析,告警治理,场景事件等多个子平台,来帮助我们更好的进行线上服务治理。我们会在后续的文章中,逐一介绍这些平台的建设实践。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们<br>grp.music-fe(at)corp.netease.com!</blockquote>
云音乐 CMS 平台 AIGC 实践与新特性实现总结
https://segmentfault.com/a/1190000044356773
2023-11-02T11:05:24+08:00
2023-11-02T11:05:24+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:辰木</blockquote><p>本文主要是介绍下云音乐 CMS 平台在 AIGC 方向的一些探索、实践以及相关能力的实现总结</p><h2>背景</h2><p>现如今随着 LLM 在实际业务生产中的不断尝试和实践落地,在中后台场景下以 ChatUI 为主要交互方式的智能助手,是必不可少的存在;这种通过聊天对话就能完成用户使用诉求的方式,在一定程度上极大地改变了用户传统的使用习惯。</p><p>目前由于云音乐 CMS 平台大都已使用 Tempo 框架,相关介绍参见上一篇分享《<a href="https://segmentfault.com/a/1190000043877440">云音乐 CMS UI 框架建设思考与实践</a>》,一些平台的个性化定制和移动办公诉求也接踵而至,这些诉求对 Tempo 提出了新的要求。</p><h2>现状</h2><p>为了更好更快地响应业务平台的一些个性化诉求,Tempo 通过不断抽象平台属性以完善其配置能力。对于诸如修改平台 Logo、Logo 跳转链接、标题、页脚信息、Layout 布局、自定义搜索内容等,期望都可在线一键修改配置完成,以便减少业务开发人员因增加或修改这类需求而带来的研发和时间成本。</p><p>由于 Tempo 已经在近 100+ 的主应用中落地使用,尽管主应用的发布频率较低,每次 Tempo 的升级都需要主应用升级相关依赖版本并在 Febase(PS:云音乐前端应用研发和部署平台)重新部署,而这种方式带来的升级成本也是很高的。</p><p>与此同时,鉴于内部日常的沟通和办公都是基于 Popo,其登陆态并未与 CMS 平台打通,平台移动端样式也未适配,导致一些业务场景办公只能在 PC 端完成,无法满足这类用户移动办公的诉求。</p><h2>问题</h2><p>通过对主应用的日常迭代需求和当前研发现状分析,不难发现一些问题:</p><ul><li>Tempo 的每次版本升级,都需要主应用需要更新依赖版本重新发布部署,无法做到<strong>免发布部署</strong>上线。</li><li>平台功能复杂,新用户使用<strong>上手成本高</strong>,<strong>无智能问答和交互</strong>能力</li><li>CMS 平台未支持登录态互通和<strong>多端样式适配</strong>,用户无法实现<strong>移动办公</strong></li><li>无论是对于简单还是复杂需求,都需要研发人员代码开发,无法做到<strong>在线配置,插件加载</strong></li></ul><h2>解法</h2><p>为了解决以上问题,Tempo 明确了一切新特性都需构建在免发布部署之上,从而重新定义了主应用的研发模式,并支持了几项新特性分别如下:</p><h3>免发布部署</h3><p>由于免发布部署是构建一切新特性的基础能力,通过对日常主应用迭代的需求做了总结和分类,明确主应用新的研发模式为:<strong>简单需求在线配置,复杂需求代码开发和插件加载,公共特性自动升级。</strong></p><ul><li>简单需求:修改平台 Logo、标题、页脚信息、Layout 布局、搜索内容自定义等</li><li>复杂需求:业务内特殊场景的自定义功能模块,如:无权限展示、人群圈选规则等</li><li>公共特性:智能助手接入、移动办公支持等</li></ul><p>由于 Febase 默认的构建部署以及静态资源服务能力,无法对构建后的 html 内容做自定义修改;尽管靠建设新的网关和渲染服务可以实现 html 内容的自定义和动态渲染,但对于想尽快上线免发布部署能力来说,这无疑是增加了更多的实现和后期运维成本。</p><h4>实现方案</h4><p>那么,有没有一种 ROI 较低的方案来实现免发布能力呢?答案是有的,即:通过在 Febase 云构建时 external 主应用中 Tempo 的依赖,在构建完成后修改 html 内容增加 meta 标签用以存储获取版本的关键参数和真实 entry 文件路径,并动态修改 entry 入口为 loader 脚本;该脚本的作用是通过接口获取主应用的配置信息和 Tempo 的最新版本,在获取到版本信息后,在动态加载 Tempo 的 umd 资源地址和真正 entry 文件路径即可。整体流程如下:<br><img src="/img/remote/1460000044356776" alt="Tempo 免发布能力" title="Tempo 免发布能力"></p><h4>成本收益</h4><p>通过这样的方式改造和实现后,后续免发布能力仅需维护 loader 和构建插件逻辑即可。带来的收益也是很可观,主要体现在以下 2 个方面:</p><ul><li><p>对于 Tempo 来说:</p><ul><li>仅需维护 loader 和构建插件逻辑,升级范围在一定程度内是可控</li><li>对于 Tempo 来说,在自身版本升级后,可全量推送新特性,无需推动已接入的主应用重新部署。</li></ul></li><li><p>对于接入 Tempo 的主应用来说:</p><ul><li>仅需一次部署,后续能力自动生效,对简单需求可做到在线一键配置直接发布,对复杂需求可通过异步加载插件的方式实现。</li><li>支持加载指定的 Tempo 版本,无需担心 BR 可能带来的任何问题</li><li>无需关注 Tempo 升级而带来的主应用发布部署成本,可直接享受升级后的最新公共特性能力。</li></ul></li></ul><p>当然,由于 Tempo 新版本默认打开免发布部署能力,考虑到一些主应用许久未更新 Tempo 版本,可能会出现一些未知的 BR。因此也支持关闭免发布部署能力,仅需在 chitu.config.js 中修改参数即可,示例代码如下:<br><img src="/img/remote/1460000044356777" alt="赤兔配置" title="赤兔配置"></p><h3>AIGC 探索与实践</h3><p>由于 ChatGPT 的横空出世,基于 LLM 的逻辑理解和推导能力以及对话式交互方式,掀起了 AIGC 的新一轮浪潮。相比较 C 端场景,在中后台场景下其交互和业务逻辑的复杂性使得平台本身具备一定的复杂度。当新用户想要快速了解和使用平台能力时,很多时候只能通过翻阅文档或摸索使用来确定平台具体的功能,这在一定程度上增加了平台的上手和理解成本。</p><p>而云音乐各类 CMS 平台也面临着类似的诸多问题,在这个背景下云音乐公技前端团队探索并构建了一个低成本接入 LLM 服务的产品方案;通过建设基础服务、收敛和沉淀通用服务、UI 交互和表达的方式,帮助业务快速地、低成本地构建知识库、智能问答、AI 驱动产品功能等一系列能力。</p><h4>智能识别</h4><p>相比较通过 NLP 来识别用户意图,LLM 的逻辑理解和推导能力更胜一筹;不仅能准确理解用户输入的内容,也能借助 Prompt 来提取用户输入的关键参数,具体展示如下:<br><img src="/img/remote/1460000044356778" alt="智能识别" title="智能识别"></p><h4>智能回答</h4><p>在识别到用户的意图后,可根据服务返回的结果类型展示不同的内容,具体展示如下:<br><img src="/img/remote/1460000044356779" alt="智能问答" title="智能问答"></p><h4>智能交互</h4><p>根据已识别的用户意图动作也可进一步与平台交互通信,比如打开页面、回填表单数据等,主要交互方式如下:<br><img src="/img/remote/1460000044356780" alt="智能交互" title="智能交互"></p><h3>多端样式适配</h3><p>为了满足业务人员移动办公的诉求,Tempo 支持多端自适应能力,并将用户的登录态与 PMS(PS:云音乐权限管理系统)做了打通处理,当在 Popo 内打开 CMS 平台应用链接时可直接免登成功。<br>在实现自适应能力时,主要考虑了以下三点:</p><h4>屏幕宽度区间</h4><p>根据不同屏幕大小的宽度,对屏幕宽度区间做了划分处理,整体分为三大类:</p><ul><li>PC 屏幕展示(Screen >= 1200px),Layout 菜单支持上-左、左、上的布局,内容弹性布局展示,显示如下:</li></ul><p><img src="/img/remote/1460000044356781" alt="PC 布局" title="PC 布局"></p><ul><li>Pad 屏幕展示(1200px > Screen >= 768px),Layout 菜单仅支持左侧布局,内容弹性布局展示,显示如下:</li></ul><p><img src="/img/remote/1460000044356782" alt="Pad 布局" title="Pad 布局"></p><ul><li>Phone 屏幕展示(768px > Screen),Layout 菜单默认不显示,通过点击 Logo 后左侧浮层唤出,表单内标题和控件各占一行显示,具体布局风格如下:</li></ul><p><img src="/img/remote/1460000044356783" alt="Phone 布局" title="Phone 布局"></p><h4>组件适配</h4><p>当确定了屏幕宽度区间后,就可以对依赖的组件进行样式适配,由于高频场景下都是一些容器、表格和表单、详情展示类组件,因此仅对这些高频组件做了相应的适配支持,主要包括:Modal、Table、Form、Layout、Description。</p><p>在对组件适配各端样式时,考虑 Pad 会有横屏模式,因此整体对端类型做了 5 种分类,实现了公共的 hook 以及获取屏幕类型、高度、宽度方法,具体方法实现如下:<br><img src="/img/remote/1460000044356784" alt="use-device" title="use-device"></p><h4>链接分享</h4><p>具体的免登实现是在在一个 H5 中转页面内做逻辑验证处理(具体实现不再此处过多说明),在 CMS 平台升级完成后,默认会在右下角增加分享当前页面的 Popo 链接地址入口,展示如下:<br><img src="/img/remote/1460000044356785" alt="链接分享" title="链接分享"></p><h3>在线配置</h3><p>在线配置能力是基于 PaaS 提供的主应用配置服务而实现的,Tempo 对一些常见的平台属性做了进一步的抽象和默认配置;不仅支持在线修改,也提供了相应的原子组件方便自定义动作或渲染逻辑;配置属性的优先级是:原子组件属性 > 主应用在线配置 > Tempo 默认配置,抽象的配置属性如下:<br><img src="/img/remote/1460000044356786" alt="应用配置" title="应用配置"></p><h3>插件加载</h3><p>异步加载插件是 Tempo 提供的另外一个能力,借助主应用在线配置能力,配置好目标插件的 umd 资源后,在平台初始化显示时自动加载该资源脚本。</p><p>当主应用或者子应用需要消费该插件时,可通过全局的 window 对象来获取,其对应的 key 为 umd 包的 library 名称。异步加载插件的组件核心实现如下:<br><img src="/img/remote/1460000044356787" alt="组件实现" title="组件实现"></p><h2>总结</h2><p>以上就是 Tempo 带来的新能力增强以及相关实现思路,通过免发布部署能力,让已接入 Tempo 的主应用具备自动升级特性,直接具备多端适配和移动办公能力;通过在线增加相关配置,可一键接入智能助手,这在一定程度上极大的降低了主应用因升级依赖而带来的研发部署成本。</p><p>未来,Tempo 会继续从业务实际场景出发,进一步封装和完善相应能力,为业务提效带来更多便利。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
Corona技术专题-时序数据分析
https://segmentfault.com/a/1190000044340443
2023-10-27T10:23:41+08:00
2023-10-27T10:23:41+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者: <a href="https://link.segmentfault.com/?enc=8HXFkEJxoRnELA90brGNzw%3D%3D.P%2BbhConkWCH2CYo77CYW6jIPlzFE22jRb5%2FQXt%2F9mHw%3D" rel="nofollow">kkdev163</a></blockquote><h2>Corona 技术专题系列文章:</h2><ul><li>1.<a href="https://link.segmentfault.com/?enc=iD89NgOlqVB0LZPmuHP55Q%3D%3D.I9KDu8p344eVKVyvRf1et%2BVEsXO1dDIIpNSpxTQ8f8KRmOLh67SJH%2FMAHaeOWLz822pWguXrKtlQClBRRCK5QB2oZMB%2BEF%2FUq5O7eo0svLagk4rOiYhQY1SbA77AhGkP" rel="nofollow">网易云音乐大前端监控体系(Corona)建设实践-开篇</a></li><li>2.<a href="https://link.segmentfault.com/?enc=61M%2FI6VKjxOPK9oLOCJ55Q%3D%3D.WCUzrQpb6cQgxArHIbYfB%2FYcX38Gx5WcE8y6SJDzJ3tznO78r6bYcMStYo2NIflFp9cey1ohZ3NDS64KCpvuH9otYR1xxC2flcLr0BWyGJtmTavl1bhDNdvng2W%2F348G" rel="nofollow">Corona技术专题-日志上报、采集、分流链路设计</a></li></ul><h2>一. 前言</h2><p>在 Corona 平台的技术体系建设中,时序数据库承担了时序数据的「存储」和「分析」 的关键作用。本文将介绍三款数据库在 Corona 时序分析场景下的应用。分别是 InfluxDB、ClickHouse、ElasticSearch。 无论您是大前端或是服务端开发同学,通过本文的阅读您都将掌握时序数据库的基本概念、特点,从而帮助您更好地理解和使用市面上的监控类产品,也为您创建类似的服务提供一些启发。</p><h2>二. 时序数据库简介</h2><h3>2.1 什么是时序数据?</h3><p>时序数据是按时间顺序排序的一组数字序列,它可以反应某一现象的变化规律。在我们的日常生活中时序数据随处可见,如「天气预报时序走势图」它反映了温度随时间变化的规律; 如「油价时序走势图」 它反应了油价随时间变化的规律:</p><p><img src="/img/remote/1460000044340445" alt="" title=""></p><p>在应用监控领域,时序走势图能够反应「应用健康度」随时间变化的趋势,是用户最为关注的几类图表之一:<br><img src="/img/remote/1460000044340446" alt="" title=""></p><p>除了分钟级粒度的数据,有时也需要按 「小时级」、「天级」 粒度查看走势数据:</p><p><img src="/img/remote/1460000044340447" alt="" title=""></p><p>除了整体维度,用户也可以按某个特征维度对走势数据做分类(下表对比了不同档次机型的加载时间走势):<br><img src="/img/remote/1460000044340448" alt="" title=""></p><p>对时序数据做上述分析的过程我们可以称其为时序数据分析。便于存储时序数据、提供时序数据分析能力的数据库我们称其为时序数据库。</p><h3>2.2 时序数据库中的基本概念</h3><p>1.<strong>时间列</strong></p><p>时序数据库的主要查询和分析能力与「时间」字段有较大关联,所以在设计时序数据库的表结构时通常会将「时间」字段作为「索引」字段。</p><p>这样的设计方便应用快速筛选出目标范围的时序数据,并且时序数据库也提供了一系列「时间」相关的工具函数,方便我们在时序数据上按不同的时间粒度(如 分钟、小时、天 )做聚合分析。</p><p>2.<strong>维度列</strong></p><p>在储存时序数据时,通常会附带上这条数据的维度信息,维度信息可以在后续分析时作为过滤或聚合的条件。如天气时序数据中,会有「城市」维度,维度值为 北京、上海、杭州 等。 油价时序数据中,会有「汽油标号」维度,维度值为 92、95、98 等。</p><p>在表结构设计时通常高频的查询和聚合维度也是建议作为「索引」字段存储。</p><p>3.<strong>数值列</strong></p><p>时序数据的数值,如天气时序数据中的「温度值」、油价时序数据中的「价格」,会作为数值列进行存储。</p><p>时序数据库会提供一系列的工具函数对数值列做分析计算。常见的分析函数有:</p><ul><li>avg 求平均值</li><li>max 求最大值</li><li>min 求最小值</li></ul><p>组合以上的基本概念,我们可以运用时序数据库就做一些常见的时序分析,如:</p><p>查询 2023 年 9 月份杭州每天的平均温度值走势 SQL:</p><pre><code class="SQL">SELECT toStartOfDay(time), avg(degree)
FROM table_temperature
WHERE
time>='2023-09-01' AND
time<'2023-10-01' AND
city='杭州'
GROUP BY toStartOfDay(time)</code></pre><p>查询最近 10 年各品类汽油每年的平均价格走势 SQL:</p><pre><code class="SQL">SELECT toYear(time), model, avg(price)
FROM table_gas
WHERE
time>='2013-01-01' AND
time<'2023-01-01'
GROUP BY toYear(time), model</code></pre><p>4.<strong>数据过期时间 TTL</strong></p><p>时序数据的另一个特点是关注近期的数据,距离当前比较久远的数据相对来说没那么重要,有时出于存储容量的考虑,我们甚至会希望自动删除老旧的数据。</p><p>时序数据库一般会提供 TTL (Time To Live) 功能,在设计数据库表结构时,一般会根据数据表的聚合粒度设置相应的过期时间。如原始数据或分钟级的数据保留 30 天, 小时或天级的聚合数据保留 1 年。</p><p>简要介绍完 时序数据分析 和 时序数据库 的基本概念后,下文将介绍三款经典数据库在 Corona 时序分析场景下的应用。分别是 InfluxDB、ClickHouse、ElasticSearch。</p><h2>三. InfluxDB</h2><h3>3.1 简介</h3><p><a href="https://link.segmentfault.com/?enc=UhHSTHVqiOP4cSuncQm93w%3D%3D.xa2lbEcgvrvKhK6DUKO4xLQ5MnfMZfINrpP4ga8AuNc4Cj4bejdHxSYcfkUZC3fjmhcnCRilrfyrju6UnqwaHg%3D%3D" rel="nofollow">InfluxDB</a> 是一款经典的开源时序数据库。在 InfluxDB 中有几个常用的概念</p><p>1.<strong>measurement</strong></p><p>measurement 是 InfluxDB 中的数据表。一张 measurement 中可包含一个时间列(time column)、多个维度列(tag column)、多个数值列(field column)。</p><p>用户无需手动使用 CREATE 语句创建 measurement,InfluxDB 会在写入的数据时动态创建 measurement、动态新增维度列与数据列。</p><p>2.<strong>tag</strong></p><p>tag column 是 InfluxDB 中的维度列,InfluxDB 会为所有的维度列建立索引。在设计表结构时,我们需要将经常作为查询条件、聚合条件的字段作为 tag 列进行存储。</p><p>在设计 tag 列时,需要特别留意的是 tag 列的潜在值是要可收敛的,不能是无限增长的。</p><p>举几个对比的例子:</p><table><thead><tr><th>Good Case</th><th>Bad Case</th></tr></thead><tbody><tr><td>监控页面的域名(location.host)</td><td>监控页面的 URL (location.href)</td></tr><tr><td>设备操作系统</td><td>设备 UUID</td></tr><tr><td>歌曲文件类型</td><td>歌曲 ID</td></tr></tbody></table><p>以监控页面的 URL 为例,它可能会带有 路径参数 或 query 参数,导致维度值非常离散,我们需要避免将这一类难以聚合的字段设计为 tag 列的原因是: InfluxDB 为了 查询/写入 性能,会为所有的 tag 列建立索引,而索引的规模直接影响内存的占用开销。若 tag 列设计不合理,极易造成 InfluxDB 的内存持续增长甚至出现 OOM 的情况。</p><p>3.<strong>field</strong></p><p>field column 是 InfluxDB 中的数值列,数据类型可以是数字、字符串型。在设计表结构时,我们需要将未来用于数值统计分析的字段作为 field 列存储。一些不常作为查询条件、无法收敛的额外信息也可以放到 field 列进行存储。</p><p>4.<strong>retention policy</strong></p><p>RP(retention policy) 数据保留策略,是 InfluxDB 的 TTL 实现机制。RP 可以在创建数据库后随时新增、变更。我们可以为一个数据库创建多个 RP。如:</p><ul><li><code>create retention policy one_week on apm_log duration 7d default;</code></li><li><code>create retention policy one_year on apm_log duration 365d;</code></li></ul><p>在数据写入时,我们可以根据数据的重要度、时效性显示地指定使用哪个 RP,数据在超过保留时间后,就会自动删除。</p><p>5.<strong>continue query</strong></p><p>CQ(continue query) 持续查询,可用于 数据归档、降采样。举例来说当我们采集的原始数据是分级的,我们可以使用 CQ 功能,将原始表的数据聚合写入小时级表。</p><pre><code class="SQL">CREATE CONTINUE QUERY "cq_event" ON "apm_log"
BEGIN
SELECT SUM("pv") as pv
INTO "one_year"."cq_hour_event"
FROM "one_week"."cq_minute_event"
GROUP BY time(1h), *
END</code></pre><p>创建完 CQ 任务后,InfluxDB 就会每小时执行一次聚合任务。这样后续在查询的时候,可以直接从聚合结果中查询,加快查询速度。</p><h3>3.2 在 Corona 中的应用场景</h3><p>InfluxDB 在 Corona 平台中主要有以下几个应用场景:</p><ul><li>存储 C 端用户上报的 访问量、性能 等「预聚合结果」数据</li><li>存储平台自身运行健康度的「原始」数据</li></ul><p>1.<strong>存储「预聚合结果」数据</strong></p><p>在平台上线初期,我们曾使用 InfluxDB 直接存储用户端上报的原始日志,并使用 CQ 功能聚合出 分钟级、小时级 粒度的聚合表。 但随着接入应用数的增多、上报日志量 的持续增长,CQ 功能查询的内存开销出现了成倍的增长,导致 InfluxDB 的查询性能骤降。</p><p>随后我们在架构中引入了流计算引擎 Flink , C 端上报数据经过外部计算引擎预聚合后,再存入 InfluxDB。 经过这样的调整后,InfluxDB 只存储 C 端用户 每分钟、每小时的 聚合结果,每分钟存储量只与 series 量级(group by 维度组合结果量级) 挂钩,不再与用户量直接关联。 InfluxDB 自身的查询性能也得到保障。</p><p><img src="/img/remote/1460000044340449" alt="" title=""></p><p>举例来说,我们可以在 Flink 中配置分钟级 PV 聚合任务:</p><pre><code class="SQL">SELECT
TUMBLE_START(PROCTIME(), INTERVAL '1' MINUTE) as wTime,
count(os) as pv,
os as osName,
moduleName as moduleName
FROM performance_log
WHERE
props['mspm'] = 'ReactNativeApplication'
GROUP BY
TUMBLE(PROCTIME(), INTERVAL '1' MINUTE),
os,
props['moduleName']</code></pre><p>我们将 Flink 的聚合结果,写入 InfluxDB 表中,表结构示例如下 (moduleName、osName 为 tag 列, pv 为 field 列):</p><table><thead><tr><th>time</th><th>moduleName</th><th>osName</th><th>pv</th></tr></thead><tbody><tr><td>2023-01-01 12:00:00</td><td>rn-app-1</td><td>android</td><td>10000</td></tr><tr><td>2023-01-01 12:00:00</td><td>rn-app-1</td><td>iphone</td><td>8000</td></tr><tr><td>2023-01-01 12:00:00</td><td>rn-app-2</td><td>android</td><td>5000</td></tr><tr><td>2023-01-01 12:00:00</td><td>rn-app-2</td><td>iphone</td><td>4000</td></tr><tr><td>2023-01-01 12:01:00</td><td>rn-app-1</td><td>android</td><td>10000</td></tr><tr><td>2023-01-01 12:01:00</td><td>...</td><td>...</td><td>...</td></tr><tr><td>...</td><td>...</td><td>...</td><td>...</td></tr></tbody></table><p>这样在查询 每小时、每天 PV 走势时,我们可以直接基于 分钟级表 的数据做分析,相较于查询 每个用户上报的原始日志,查询数据量级大幅降低、性能大幅提升。 (细心的读者可能想到了,这里的 Flink 与 之前介绍的 InfluxDB CQ 的作用其实是一致的)</p><p>后续我们可以这样查询 InfluxDB:</p><pre><code class="sql">SELECT
moduleName,
osName,
sum(pv) AS pv
FROM rn_minute_pv
WHERE
moduleName='rn-app-1' AND
osName='android' AND
time>='2023-01-01' AND
time<='2023-01-02'
GROUP BY time(1h)</code></pre><p>查询结果:</p><table><thead><tr><th>time</th><th>moduleName</th><th>osName</th><th>pv</th></tr></thead><tbody><tr><td>2023-01-01 12:00:00</td><td>rn-app-1</td><td>android</td><td>600000</td></tr><tr><td>2023-01-01 13:00:00</td><td>rn-app-1</td><td>android</td><td>600000</td></tr><tr><td>2023-01-01 14:00:00</td><td>rn-app-1</td><td>android</td><td>600000</td></tr><tr><td>...</td><td>...</td><td>...</td><td>...</td></tr></tbody></table><p>2.<strong>存储「原始」数据</strong></p><p>Corona 使用 InfluxDB 的另一个场景是存储平台自身运行健康度的「原始」数据,提升平台自身运行的可观测。 相较于 C 端场景的海量数据,机器、集群的健康度数据量级较为可控,我们可以使用 InfluxDB 进行存储、 CQ 计算。</p><p><img src="/img/remote/1460000044340450" alt="" title=""></p><p>例如当我们需要观测自建的「数据消费服务」的健康度时,我们使用 InfluxDB 采集每个进程每次批量处理的 事件数,同时包含 机器、进程、事件上报平台 等维度列。 表结构示例如下:</p><table><thead><tr><th>time</th><th>hostname</th><th>pid</th><th>platform</th><th>events</th></tr></thead><tbody><tr><td>2023-01-01 12:00:03</td><td>music-corona-worker-1</td><td>130616</td><td>web</td><td>10</td></tr><tr><td>2023-01-01 12:00:04</td><td>music-corona-worker-1</td><td>128204</td><td>android</td><td>50</td></tr><tr><td>2023-01-01 12:00:04</td><td>music-corona-worker-2</td><td>33096</td><td>ios</td><td>30</td></tr><tr><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td></tr></tbody></table><p>有了原始数据表,我们可以按 hostname 维度、platform 维度 观测集群的数据消费健康度。可视化方案推荐使用 <a href="https://link.segmentfault.com/?enc=vEAENBrGvdvzxeWp%2BYIJaQ%3D%3D.H76bhWx0VWul8Z%2F5EGBPxFp7w4xUC9%2F8C8ClKbJoFS9AYDvDrX4KYT0a2Bdka96F" rel="nofollow">Grafna</a> :</p><p><img src="/img/remote/1460000044340451" alt="" title=""></p><h2>四. ClickHouse</h2><h3>4.1 ClickHouse 简介</h3><p>ClickHouse 是 OLAP(On-Line Analytic Processing) 联机分析处理数据库。在数据分析时,可直接对亿级原始日志做在线的实时聚合计算,并且能在秒级给出聚合结果。</p><h3>4.2 在 Corona 中的应用场景</h3><p>Corona 在引入 ClickHouse 之初,是为了补充原有 性能监控架构 的分析能力(如多维的分位数 P50、P95 统计能力),随着我们对 ClickHouse 使用经验的积累 和 特性原理的认识,我们发现在 Corona 的性能分析应用场景上,ClickHouse 能够完全取代 Flink 、InfluxDB 的作用。并且整体的架构更加简洁,数据分析的方式也更加灵活、轻便。</p><p>目前 Corona 上的建设的性能监控指标,已完全由 ClickHouse 提供存储与数据分析的能力。主要的分析功能有:</p><p>1). 基于上报数据维度字段,提供多维的组合筛选能力</p><p>2). 在线实时聚合计算,统计 平均值、分位数、PV、UV 走势</p><p><img src="/img/remote/1460000044340452" alt="" title=""><br>3). 按照某个维度聚合,对比不同维度值的走势</p><p><img src="/img/remote/1460000044340453" alt="" title=""><br>4). 查看不同维度值的占比<br><img src="/img/remote/1460000044340454" alt="" title=""><br>5). 统计指标值的详细分布情况<br><img src="/img/remote/1460000044340455" alt="" title=""></p><h3>4.3 表结构设计及查询示例</h3><p>ClickHouse 在写入数据前,需要使用建表语句创建表结构。以 ReactNative 启动耗时监控为例, 以下为示例的表结构:</p><pre><code class="SQL">CREATE TABLE rn_monitor_cold_boot_stage_local
(
`appName` String, -- 应用名,如 云音乐
`osName` String, -- 操作系统名
`appVersion` String, -- 应用版本
`rnModuleName` String, -- ReactNative 模块名
`deviceTag` String, -- 设备性能分档
`uploadTime` DateTime, -- 日志到达服务端时间
`uid` String, -- 用户 uid
`stageName` String, -- 阶段名
`stageCost` Float32, -- 阶段耗时
)
ENGINE = MergeTree
PARTITION BY (appName, osName, toYYYYMMDD(uploadTime))
ORDER BY (rnModuleName, uploadTime)
TTL uploadTime + toIntervalDay(90)
SETTINGS index_granularity = 8192, use_minimalistic_part_header_in_zookeeper = 1</code></pre><p>在示例表结构中,uploadTime 为时间列, stageCost 为数值列,其他字段都为维度列。</p><p>MergeTree 是 ClickHouse 中最重要的表引擎,这种表引擎的特点是,数据在批量写入时,ClickHouse 会将数据写入新的临时分区中, ClickHouse 会在后台对 临时分区 与 已有的数据分区 做 Merge,以此来提高数据的写入性能。</p><p>PARTITION BY 数据的分区策略,示例表以 appName, osName, 上报时间(天) 所组成的联合键 建立分区。 ClickHouse 会为每个分区建立一个目录,合理的分区策略,可以让 ClickHouse 在后续查找数据时,直接选中分区目录,大大降低扫描的数据行数。</p><p>ORDER BY 数据的排序键,ClickHouse 默认会为排序键建立索引。</p><p>TTL 数据自动过期时间,此处设置了 90 天。</p><p>index_granularity 索引粒度为 8192 行(可理解为 8192 行数据,建立一条索引)。</p><p>示例数据如下:</p><pre><code class="JSON">{
"appName": "music"
"osName": "android",
"appVersion": "8.9.0",
"rnModuleName": "rn-playlistrank",
"deviceTag": "高端机",
"uploadTime": "2023-04-27 12:00:00",
"uid": "9999999",
"stageName": "render",
"stageCost": 1000
}</code></pre><p>查询示例:</p><pre><code class="SQL">SELECT
toStartOfDay(uploadTime) as "time",
avg(stageCost) AS "avg",
quantiles(0.5, 0.9)(stageCost) AS "quantiles",
count() AS "pv",
uniq(uid) AS "uv"
FROM rn_monitor_cold_boot_stage_shard
WHERE
uploadTime>=1682006400 AND
uploadTime<=1682611199 AND
stageName='render' AND
rnModuleName='rn-playlistrank'
GROUP BY toStartOfDay(uploadTime)
ORDER BY toStartOfDay(uploadTime) ASC</code></pre><p>查询结果示例:<br><img src="/img/remote/1460000044340456" alt="" title=""></p><p>以上的查询示例,包含了 平均值、分位值、PV、UV 的统计,是 Corona 性能监控分析最基础 SQL。其他的性能分析都是基于该 SQL 的变种。</p><h3>4.4 数据读写架构 及 配套建设</h3><p>得益于 ClickHouse 的高性能 (举例来说,当上述的示例 SQL 的扫描数据量级达到 6 亿行时,也仅需 2 秒就可以完成数据分析),<br><img src="/img/remote/1460000044340457" alt="" title=""><br>在绝大多数的场景,我们可以直接使用 ClickHouse 直接对原始数据做实时聚合分析,这也使得我们的性能分析架构变得简洁。</p><p><strong>数据写入</strong></p><p>在数据写入前,我们使用自建的「性能日志处理服务」订阅不同 type 的性能日志,每个消费者订阅一种日志类型,在预处理后,会根据每张表的建表分区规则,在服务端对数据做预分区,每个分区的数据单独批量写入 ClickHouse。以此达到 批量写入 同时又减少 ClickHouse 在后台对数据做再次分区的开销,提高写入性能。</p><p>数据批量写入时,使用了自建的集群版 ClickHouse NodejsClient,做数据 Schema 校验 并 随机请求集群中的 Node 达到数据均匀分片的目的。<br><img src="/img/remote/1460000044340458" alt="" title=""><br><strong>数据查询</strong></p><p>细心的读者可能发现了,我们在上面示例中,我们所建的示例表,是以 <code>_local</code> 结尾,而我们的查询示例表是以 <code>_shard</code> 结尾。</p><p>事实上,我们在建表时,会同时创建 local 表 与 shard 表。在数据写入时,性能日志处理服务是直连每个 ClickHouse node 向 local 表写入数据。可以理解为每个 node 只保存了 整个完整表的 1/4 行的数据。在查询时,查询任意一个节点的 shard 表,ClickHouse 会在后台自动汇总 4 个 node 的全部数据做分析。<br><img src="/img/remote/1460000044340459" alt="" title=""><br>注: 该图 local 表中的行号仅用于示意分片的数据量级,并非实际的存储或索引行号。</p><p>在自建的性能日志处理服务 和 可视化后台 上,我们也加入了一些监控指标,来观测 ClickHouse 集群的读写健康度。</p><p><strong>写入侧监控:</strong></p><ul><li>每分钟 批量写入的请求数</li><li>每分钟 批量写入的日志数</li><li>每分钟 不同分区的写入日志数</li><li>每分钟 忽略的日志数(Schema 校验不通过)<br><img src="/img/remote/1460000044340460" alt="" title=""></li><li>数据消费的延时</li><li>数据批量转换耗时</li><li>数据批量转换条数<br><img src="/img/remote/1460000044340461" alt="" title=""></li><li>数据分区转换并写入 ClickHouse 耗时</li><li>ClickHouse 写入请求耗时<br><img src="/img/remote/1460000044340462" alt="" title=""></li></ul><p><strong>查询侧监控</strong>:</p><ul><li>每分钟总查询次数</li><li>每分钟平均查询耗时</li><li>慢查询 SQL 详情<br><img src="/img/remote/1460000044340463" alt="" title=""></li></ul><h3>4.5 存在的痛点</h3><p>ClickHouse 在 Corona 的性能分析场景满足了我们绝大多数的诉求,如果非要让笔者想一个痛点的话,那可能是缺少像 InfluxDB 一样的 CQ ( Continue Query) 能力。什么情况下需要 CQ 呢?</p><p>ClickHouse 虽然具有强大的实时在线分析能力,但是他的处理性能也是有资源开销的。在机器资源有限的前提下,如果需要做时间跨度大,数据量级超几百亿的分析,也是有相当大的资源开销和等待时间的。</p><p>举例来说,在 Corona 比较分析 App 版本性能走势场景时,由于 App 发版时间跨度大,每个版本仅存在一段时间的高峰流量期,如果需要客观地对比每个 App 的性能,需要让每个版本的样本量尽可能大,我们如果还是选择在线分析的话,就需要把 时间跨度拉到好几个月,此时数据分析的等待时间就会特别长。</p><p>为了解决等待耗时长的问题,我们还是转为离线分析的思路,在应用层,每日对 Top3 日活的版本做性能归档快照。在分析 App 版本走势时,使用归档快照数据做分析。</p><p>如果 ClickHouse 原生具备 InfluxDB 的 Continue Query 能力,可能实现起来会相对容易些。</p><h2>五. ElasticSearch</h2><h3>5.1 简介</h3><p>Elasticsearch 是一款基于 Apache Lucene 的分布式搜索和分析引擎,用于全文检索、日志分析、数据可视化等场景。它支持实时搜索、数据聚合、自动化分片和复制等功能,并提供了 RESTful API 和丰富的插件生态系统。Elasticsearch 被广泛应用于企业级搜索和日志分析等领域。</p><h3>5.2 在 Corona 中的应用场景</h3><p>在设计 Corona 平台时,我们引入 ES 的主要目的是用于存储异常监控的原始日志,并借助 ES 的全文检索能力,提供丰富、灵活的日志搜索功能。</p><p>下图为 Corona 的搜索面板,在此处我们意图搜索包含 undefined 信息的错误日志。</p><p><img src="https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/27195122214/ecce/e54c/d851/cacd03354eaae9fa27a3777cb01a88f0.png" width="400"/></p><p>下图为 Corona 的搜索结果列表,展示了包含 undefined 错误信息的 Issue。</p><p><img src="https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/27195252659/b9d4/8b16/d67f/430607bd6805c276d19df601b0d7ff43.png" width="400"/></p><p>关于 ES 搜索的概念,在这篇文章中不作更多展开,感兴趣的读者可以查看笔者的<a href="https://link.segmentfault.com/?enc=fQECz61osmq0WAHHCrRVsw%3D%3D.%2BJzBAU9GjUlTT%2B2w1VEyuWd64jVY2%2FLAvzO3%2FEUa5SZi%2B%2F6Zq4at33GXJYcqQVSAWbXQurZe7Ywn%2ByfMHl09nQ%3D%3D" rel="nofollow">这篇文章</a>。除了日志的搜索功能外,Corona 也希望为用户展示异常发生的时序趋势图。由于原始日志的存储我们已经使用了 ES 进行存储,在设计时序分析功能实现时,我们其实是有两条技术实现路线可供选择:</p><ol><li>将原始日志另写入一份至消息队列 -> Flink 聚合 -> InfluxDB</li><li>使用 ES 的聚合能力,基于原始日志直接做时序数据分析。</li></ol><p>考虑到架构的简洁、减少依赖等因素,并参考了 ES 与 InfluxDB 的性能对比文章后,我们最终选择了方案二。 以下是使用 ES 做的一些时序分析功能演示:</p><p>下图为应用整体的异常趋势图:<br><img src="/img/remote/1460000044340464" alt="" title=""></p><p>下图为单条 issue 的异常趋势图:<br><img src="/img/remote/1460000044340465" alt="" title=""></p><h3>5.3 表结构设计及查询示例</h3><p>ES 在写入数据前,不要求建立表结构。ES 会根据写入的数据自动推断数据类型进行存储。但为了避免类型的错误推断导致后续查询功能不符合预期,建议是在写入数据前,对表结构进行约束。</p><p>ES 对表结构进行约束的方式是创建模板。模板中可包含索引匹配规则 (可理解为表名),表中的数据结构类型。</p><p>下面我们创建一个演示的模板,模板中的索引包含了 5 个字段</p><ul><li>project_id: 应用 ID,类型为 long</li><li>issue_id: 聚合错误 ID, 类型为 long</li><li>os: 上报操作系统,类型为 keyword</li><li>ts: 上报时间,类型为 date</li><li>error_obj: 错误详情对象,JSON 类型,JSON 中包含 message 字段,message 为文本类型,支持分词检索。</li></ul><pre><code class="JSON">PUT _template/template_web_demo
{
"indx_patterns": ["web_demo_*"],
"mappings": {
"_doc": {
"project_id": {
"type": "long"
},
"issue_id": {
"type": "long"
},
"os": {
"type": "keyword"
},
"ts": {
"type": "date"
},
"error_obj": {
"properties": {
"message": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
}</code></pre><p>以下是一些示例数据:</p><pre><code class="javascript">[
{
id: 1,
project_id: 1,
issue_id: 1,
os: "iphone",
ts: "2023-04-27 15:00:00",
error_obj: {
message: "Cannot read properties of undefined (reading 'providerLog')",
},
},
{
id: 2,
project_id: 1,
issue_id: 2,
os: "android",
ts: "2023-04-27 15:01:00",
error_obj: {
message: "e.forEach is not a function')",
},
},
];</code></pre><p>查询示例: 查询项目 id 为 1 的所有 issue 的最近 7 天每日上报量走势</p><pre><code class="JSON">{
"query": {
"bool": {
"filter": {
"bool": {
"must": [
{ // 指定查询的项目id 为 1
"term": {
"project_id": 1
}
},
{ // 指定查询时间范围 >= 2023-04-21 00:00:00
"range": {
"ts": {
"gte": 1682006400000
}
}
},
{ // 指定查询时间范围 <= 2023-04-27 23:59:59
"range": {
"ts": {
"lte": 1682611199000
}
}
}
]
}
}
}
},
"aggs": { // 聚合,按 issue_id 字段做聚合
"issueId": {
"terms": {
"field": "issue_id"
},
"aggs": { // 子聚合,按时间1天粒度做聚合
"series": {
"date_histogram": {
"field": "ts",
"interval": "1d",
"format": "yyyy-MM-dd HH:mm:ss",
"time_zone": "+08:00"
}
}
}
}
}
"size": 0, // 只统计聚合结果,不返回原文档
}</code></pre><p>对于首次接触 ES 的同学来看,这个查询条件看上去会比较地复杂。上面的查询如果用 InfluxDB SQL 的话其实就是:</p><pre><code class="SQL">SELECT COUNT()
FROM `web_demo`
WHERE
project_id = 1 AND
time>=1682006400000 AND
time <=1682611199000
GROUP BY issue_id, time(1d);</code></pre><h3>5.4 数据读写架构</h3><blockquote>本节我们只介绍 ES 在 Corona 时序数据场景下的应用层架构</blockquote><p><strong>1) 基于原始日志做时序分析</strong></p><p>Corona 平台的异常日志原始日志由异常日志清洗服务做预处理后批量写入 ES。可视化管理后台在后续可直接请求 ES 做时序数据分析。<br><img src="/img/remote/1460000044340466" alt="" title=""></p><p><strong>2) 基于聚合数据做时序分析</strong></p><p>在 Corona 的告警场景,考虑到查询聚合表会比查询原始表有更高的性能,并且为了方便追溯告警的历史走势,我们在应用层配置了定时任务做分钟级的数据聚合,告警任务在执行时,直接读取分钟级聚合表。</p><p><img src="/img/remote/1460000044340467" alt="" title=""></p><h3>5.5 存在的痛点</h3><p>Corona 使用 ES 做时序分析的场景相对来说还比较有限。对于 ES 在时序分析下的性能,是否存在瓶颈,尚未有深入的探索。我们的痛点主要是集中在使用姿势上。</p><p>通过 5.3 节 的示例,读者不难发现,在时序分析场景,ES 查询的请求体的书写 和 理解 相对于 InfluxDB 来说,具有一定的复杂度。 如果我们的项目需要用到 ES 来做时序分析,建议是在应用层封装一些 Utils 工具类,协助做请求体生成 和 数据解析。NodeJS 环境下推荐基于 <a href="https://link.segmentfault.com/?enc=Tif57apOYBpcTiEKfG5u6Q%3D%3D.UFcV9vbBYr3NPS%2FwbrmxbHzlJ39nW48HmHC09n6gNDOSyYhUjq2wn%2FhCQcg%2Fw6Mk" rel="nofollow">bodybuilder</a> 做上层的封装。</p><h2>六.小结</h2><p>本篇文章介绍了时序分析的基本概念,并结合 Corona 平台的应用场景,分别介绍了三款时序数据库的 基本概念 和 使用建议,下表是简要的总结,希望对读者有一些帮助和启发,限于笔者的个人水平,文中难免存在解释不到位或描述不准确的地方,欢迎读者留言讨论交流。</p><table><thead><tr><th>数据库</th><th>特点</th><th>痛点</th><th>适合 存储、分析 场景</th></tr></thead><tbody><tr><td>InfluxDB</td><td>使用便捷、部署低成本</td><td>官方仅开源单机版无高可用、内存敏感型</td><td>客户端侧预聚合后的性能日志、服务器侧的原始性能日志</td></tr><tr><td>ClickHouse</td><td>海量数据在线实时计算、列式存储压缩、使用便捷</td><td>部署规格高、无 CQ</td><td>客户端侧原始性能日志</td></tr><tr><td>ElasticSearch</td><td>具备强大的文本搜索功能</td><td>时序分析场景下的使用姿势较为复杂</td><td>具有搜索需求的文本型数据</td></tr></tbody></table><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
云音乐Android Cronet接入实践
https://segmentfault.com/a/1190000044319145
2023-10-19T17:46:46+08:00
2023-10-19T17:46:46+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:[答案]</blockquote><h2>背景</h2><p>网易云音乐产品线终端类型广泛,除了移动端(IOS/安卓)之外,还有PC、MAC、Iot多终端等等。移动端由于上线时间早,用户基数大,沉淀了一些端侧相对比较稳定的网络策略和网络基础能力。然而由于各端在基础能力上存在不对齐的现状:移动端双端在这些能力细节上有差异,同时PC、MAC这方面能力相较于移动端又略微滞后。为了避免各端在网络侧反复投入人力进行能力维护和定位解决问题,同时统一网络基础设置,将端侧稳定网络策略进行沉淀复用,经过调研,我们计划采用 Google chromium 项目的 Cronet 作为跨端通用网络库。Cronet 在chrome 中经过多年的打磨,稳定性得到了验证,同时 Cronet 支持 QUIC 协议,可以支持后期对弱网场景进行专项优化。安卓端作为 Cronet 的首先落地一端,已经全量在线上运行了一年多的时间,本文主要介绍接入方案和过程中解决的问题。</p><h2>介绍</h2><h4>Cronet 网络库</h4><p>Cronet是 google chromium 的网络组件,可单独编译成库提供给 Android/Ios 应用使用。Cronet在性能方面表现出色,目前已经有 Youtube、Goolge 全家桶等大量应用使用 Cronet 作为网络模块。</p><p>它有以下功能:</p><ul><li>支持 HTTP2/QUIC/websocket 协议</li><li>支持对请求设置优先级标签</li><li>可以使用内存缓存或磁盘缓存来存储资源</li><li>支持 Brotlin 压缩(有研究表明,对于文本文件,相同的压缩质量下,brotlin 通常比 gzip 高出了20%的压缩率)</li></ul><h4>接入方案</h4><p>目前项目中使用 Okhttp 作为网络基础库,Cronet 的对外接口和 Okhttp 无法兼容,在接入上主要有两个方向:</p><ol><li>网络业务接口根据新的 Cronet 接口重新封装接口层,逐步替换老接口;</li><li>业务侧不做改动的情况下,底层入口统一切换到 Cronet 侧,通过中间的胶水层来抹平差异。</li></ol><p>方向一需要对项目中的网络请求接口做改造,但由于项目中广泛使用了 Okhttp 的特性,例如 Interceptor、cookiestore、cache、eventlistener 等等,直接使用 Cronet 接口意味着这些特性全部需要重新实现,改造成本巨大;</p><p>方向二的实现思路是在 Cronet 的最底层通过创建 CronetInterceptor 来实现 Cronet 请求,并且将它放到 Okhttp Interceptors 的最末尾保证原有 interceptors 全部执行,同时通过适配层将 Okhttp 原有能力无缝桥接到Cronet实现,不对上层有任何侵入和改动,做到业务调用侧无感知。</p><p>P.S google 官方后来推出的 Cronet 接入库 <a href="https://link.segmentfault.com/?enc=sD0FMrhrgCcTVPQktwfGlg%3D%3D.vH%2FehA%2B49RWBkJ1Rvow%2B5BV4s%2F2aFX3BH035pzaiWz9xVKK3ZhswO5jFY32g5d9I8At3l6UwG%2FyPMTIXaPOV8A%3D%3D" rel="nofollow">https://github.com/google/cronet-transport-for-okhttp</a>,也是同样的思路。</p><p>结合我们的项目现状,我们决定使用方向二的思路来接入 Cronet。</p><h4>Android 网络库整体架构</h4><p><img src="/img/remote/1460000044319147" alt="" title=""></p><p>作为一个通用的网络模块,我们将整体抽象出了四层来展示,从底层到业务层方向分别为:协议层、通用能力、适配层、业务支撑层。</p><h5>协议层</h5><p>这部分主要是从 chromuim 中抽离出的 Cronet 源码部分,主要是 Cronet 的基础能力,包括了不同网络协议的实现以及 Cronet 内部的优化,是 Cronet 的最核心实现。这部分除了一些向上接口之外,通常不会对源码做过多的改动。</p><h5>通用能力层</h5><p>这一层主要包括我们从 java 侧沉淀到 C++ 层的一些通用网络策略和网络组件(APM、Httpdns等),这一层通过拆分出不同组件的方式相互隔离,共同依赖于协议层。</p><h5>适配层</h5><p>适配层定义为胶水层,主要目的是保证在上层接口无须做任何改动的情况下将底层实现在 Okhttp 和 Cronet 间进行切换。</p><h5>业务支撑层</h5><p>支撑端侧各种业务的能力,这层无须做改动。</p><h4>适配方案</h4><h5>okhttp接口适配</h5><p>1、interceptor 适配</p><p>前文提到,通过创建 CronetInterceptor 且放到 okhttp addInterceptor 的最末尾保证 interceptor 全部执行,但是仍有部分 interceptors 是覆盖不到的,那就是 Okhttp 内置的 interceptor。内置的主要有:</p><pre><code>RetryAndFollowUpInterceptor
BridgeInterceptor
CacheInterceptor
ConnectInterceptor
CallServerInterceptor</code></pre><p>其中</p><pre><code>RetryAndFollowUpInterceptor
BridgeInterceptor
CacheInterceptor</code></pre><p>这三个主要负责重定向、鉴权、cookie、缓存等逻辑,和 Okhttp 的接口息息相关,这部分逻辑是我们主要适配的内容。适配也非常简单,只需要把这些 interceptor 的核心逻辑移植到我们创建的 CronetInterceptor 即可,这样就能保证上层业务使用到的 cookiestore、cache 等 okhttp api 不受影响。</p><pre><code>ConnectInterceptor
CallServerInterceptor</code></pre><p>这两个 interceptor 主要负责的是核心的网络请求的全部后续细节,Cronet 有自己来接管自然无需适配。</p><p>2、eventlistener适配</p><p>由于 okhttp eventlistener 依赖的一些回调例如 connectEnd、dnsEnd 等是在这两个拦截器中调用的,虽然Cronet 有自己的是 Callback:</p><pre><code>
public abstract void onRedirectReceived(UrlRequest var1, UrlResponseInfo var2, String var3)
public abstract void onResponseStarted(UrlRequest var1, UrlResponseInfo var2)
public abstract void onReadCompleted(UrlRequest var1, UrlResponseInfo var2, ByteBuffer var3)
public abstract void onSucceeded(UrlRequest var1, UrlResponseInfo var2);
public abstract void onFailed(UrlRequest var1, UrlResponseInfo var2, CronetException var3);
</code></pre><p>但是没有 okhttp eventlistener 提供的全面,如果需要完整的实现 okhttp eventlistener,需要对 Cronet 的核心关键请求点做改造来透出给 java 层,考虑到成本和使用场景,我们没有对这部分做改造,而是直接采用 Cronet 的 callback 做桥接来实现了部分的核心 eventlistener 的 callback。</p><p>3、超时逻辑适配</p><p>业务侧指定请求的超时时间来做一些策略也是常见的操作,而 Cronet 并未提供超时相关的 api,于是我们基于Cronet 源码开发了建链超时和读流超时等能力</p><pre><code>void CronetURLRequest::SetOriginRequestID(uint32_t origin_request_id)
void CronetURLRequest::SetConnectTimeoutDuration(uint32_t connect_timeout_ms)</code></pre><p>并通过 jni 暴露给 java 层,java 层通过适配层桥接到 Okhttp 接口:</p><p><em>CronetUrlRequest.java类</em></p><pre><code>mRequestContext.onRequestStarted();
if (mInitialMethod != null) {
if (!nativeSetHttpMethod(mUrlRequestAdapter, mInitialMethod)) {
throw new IllegalArgumentException("Invalid http method " + mInitialMethod);
}
}
if (mRequestId > 0) {
nativeSetOriginRequestID(mUrlRequestAdapter, mRequestId);
}
// 将业务侧设置的超时时间传递到Cronet
if (connectTime > 0) {
nativeSetConnectTimeoutDuration(mUrlRequestAdapter, (int) connectTime);
}
// 将业务侧设置的超时时间传递到Cronet
if (readTime > 0) {
nativeSetReadTimeoutDuration(mUrlRequestAdapter, (int) readTime);
}</code></pre><p>这样上层业务侧无需任何改动既可继续使用 Okhttp 原有能力。</p><h5>网络请求适配</h5><p>1、请求维度适配</p><p>发起请求时,由原先的通过 Okhttp 内置 interceptor 发起请求切换到使用 Cronet 发起请求后,需要在 Okhttp 接口到 Cronet 接口间做一下请求和响应的适配转换。</p><p><em>网络请求切换示意</em>图</p><p><img src="/img/remote/1460000044319148" alt="" title=""></p><p>同时由于将之前的一些 java 层网络策略下沉到 C++ 实现,之前的一些 java 层的直接调用和传参我们通过基于CronetUrlRequest 进行扩展打通了向 Cronet 的 jni 调用</p><p>2、全局调用适配</p><p>下沉到 C++ 的网络策略,为尽可能做到和 Cronet 原有代码的解耦,在 C++ 以一个个独立插件形式存在。java 侧通过 CronetRequestContext 设置到 C++ 侧,然后向对应注册的组件进行分发,这个链路上涉及到 java、jni 和C++ 的代码改动,为了降低后续网络策略的开发维护成本,采用了类 JsBridge 的方法,开发了'CppBridge',将java 和 C++ 之间的方法调用协议化,通过 json 传递数据,这样避免了后续对插件做更新带来的 java 到 C++ 请求链路上繁琐的开发工作,且 C++ 策略可以通过java层的配置中心能力进行动态配置。</p><h4>解决问题</h4><p>1、线程优化</p><p>众所周知,网络请求需要在子线程中发起,在 Cronet 的官方文档介绍中,推荐通过传入 Executor 来负责执行网络请求:</p><p><img src="/img/remote/1460000044319149" alt="" title=""></p><p>然后在 okhttp interceptor 中已经是子线程的执行环境,如果仍然传入独立对 executor,会造成不必要的线程切换和时间消耗。通过查看 Cronet 源码,发现其 CronetHttpURLConnection 使用的 MessageLoop 类实现是在当前线程,使用 MessageLoop 即可减少不必要的多余线程引入。</p><p><em>通过 MessageLoop 请求生命周期</em></p><p><img src="/img/remote/1460000044319150" alt="" title=""></p><p>2、兼容性解决</p><p>不同网络库之间切换,兼容性问题在所难免。虽然同样遵循 http 协议,但是对于一些边界条件的处理不一致或处理严格程度不同也会引起兼容性偏差。篇幅所限,这里仅介绍几个兼容点:</p><ol><li>Cronet 库对于http链接数设置为了6个,如果有对于 http 请求的不当使用,例如不正常持有未释放,一旦达到了6个,后续的请求将会 block 直到前序连接资源释放,这在 http1.1 下更容易触发;</li><li><p>Cronet 对请求做了检测,如请求 body 未设置 Content-Type,将会直接抛出异常,</p><pre><code>if (!hasContentType) {
throw new IllegalArgumentException("Requests with upload data must have a Content-Type.");
}</code></pre></li></ol><p>在某些特殊设置情况下,存在有 request body 未设置 Content-Type 的情况将会直接导致请求抛异常;</p><ol start="3"><li>Cronet 请求返回4xx时,会直接抛出异常,而 okhttp 是通过将结果连带 code 返回到上层,交由使用者自己去处理。</li></ol><p>兼容性优化没有统一的解决办法,只能见招拆招,通常是向前保证兼容性或推动优化不合理代码来解决。</p><p>3、重定向问题解决</p><p>Http 请求发生重定向时,请求 header 中的 Host 字段需要更新为新的目标主机地址,否则服务端校验Host字段和实际请求的 host 不一致时会拒绝请求。首先看一下 Okhttp 是如何实现的这个功能:</p><p>okhttp 在 RetryAndFollowUpInterceptor 类中,302会触发重新构建请求对象:</p><p><img src="/img/remote/1460000044319151" alt="" title=""></p><p>之后在 BridgeInterceptor 中,重新设置 Host:</p><p><img src="/img/remote/1460000044319152" alt="" title=""></p><p>而 Cronet 在 android 侧的默认实现中,并未对此进行更新,查看cronet代码:</p><p><em>类:cronet_url_request.cc</em></p><p><img src="/img/remote/1460000044319153" alt="" title=""></p><p>可以看到,cronet 下层接口是支持对重定向时传入修改的 header 的,但是默认传入了空,也没有提供暴露给 java 侧的接口来进行设置。</p><p>解决方案:对 cronet 重定向时更新 header 的能力进行打通,新增设置接口:</p><pre><code>void CronetURLRequest::NetworkTasks::SetRedirectHeader(
const std::string& key,
const std::string& value) {
DCHECK_CALLED_ON_VALID_THREAD(network_thread_checker_);
DCHECK(url_request_.get());
if (redirect_request_headers_ == base::nullopt) {
redirect_request_headers_ = base::make_optional<net::HttpRequestHeaders>();
}
redirect_request_headers_->SetHeader(key, value);
}</code></pre><p>在重定向时将从 java 侧设置下来的 header 传入:</p><pre><code> @Override
protected void handleRedirectReceived(UrlRequest request, UrlResponseInfo info, String newLocationUrl) {
try {
Uri newUri = Uri.parse(newLocationUrl);
String host = newUri.getHost();
// 更新Host
request.setRedirectHeader("Host", host);
request.followRedirect();
} catch (Exception e) {
e.printStackTrace();
}
}</code></pre><p>cronet 执行 FollowDeferredRedirect (真正重定向的方法)时,将原有方法替换为传入重定向 header 的方法:</p><pre><code>void CronetURLRequest::NetworkTasks::FollowDeferredRedirect() {
DCHECK_CALLED_ON_VALID_THREAD(network_thread_checker_);
#if defined(WOW_BUILD)
url_request_->FollowDeferredRedirect(
this->redirect_request_headers_ /* modified_request_headers */);
#else
url_request_->FollowDeferredRedirect(
base::nullopt /* modified_request_headers */);
#endif
}</code></pre><h4>灰度&上线</h4><p>网络库切换牵扯业务的方方面面,影响面较大,上线需要格外谨慎:</p><ol><li>在上线前的开发阶段,在开发环境提前切换到 Cronet,如果有问题可以尽早暴露;</li><li>灰度阶段反复分流验证,结合稳定性平台和舆情信息反馈观察,确保 Cronet sdk 的稳定性;</li><li>技术上,为了防止有其他异常情况引起的网络不可用,对非网络抖动引起的网络请求异常自动降级到 Okhttp,达到一定次数后开始彻底降低回 Okhttp,并上报日志进行分析;对网络组件以最小粒度进行动态配置,保证根据任意的组件都可以按需更新/开闭以进行线上ab效果观测;对网络请求各阶段的进行全面端到端数据埋点。</li><li><p>上线后,拉长观测周期,分阶段放量。反复从各个维度比对网络性能数据,发现异常数据及时分析定位解决,确保数据是完全正向的。分析维度包括:</p><ul><li>首包时长/请求时长</li><li>错误率</li><li>长尾数据分析</li><li>业务体感数据</li></ul><p>这个阶段相对较为漫长,通常是从数据侧发现问题后,结合对应的业务场景去进一步定位问题,在针对不同具体错误类型的数据分析过程中,我们也发现了一些上层非正常使用带来的错误率问题,并一起促进优化降低了部分场景的错误率。</p></li></ol><p>目前 android cronet 已经线上全量稳定运行了一年多时间,从统计数据来看,主站api请求时长有16%的优化,错误率有4%的优化,cdn请求不同域名也有不同程度的优化。</p><h4>后续规划</h4><p>弱网场景的特殊优化是业务开发中经常遇到的,云音乐基于 Cronet 的 nqe 模块做二次开发,对外提供弱网检测通知能力(正在进行中);</p><p>Cronet 的一个核心功能便是支持 quic 协议,作为下一代的网络通信协议,quic 协议具有一系列的协议层面优化:</p><p>1、 更少的建链 RTT</p><p><img src="/img/remote/1460000044319154" alt="" title=""></p><p>2、链接迁移 </p><p>不同于 tcp 的四元组标识,quic 使用 cid 作为标识,cid 不变即可维持 quic 连接不中断</p><p>3、解决tcp队头阻塞(head of line blocking)问题</p><p>4、拥塞控制算法实现</p><p>将固化在操作系统实现的 tcp 拥塞控制等算法在应用层实现,无需升级操作系统即可实现对算法的升级</p><p>5、更好的安全性</p><p>2022年6月6日,IETF 正式发布了 http3 协议,云音乐在线上也小范围进行了 quic 协议的测试,在部分场景下quic 表现了更优秀的网络性能。当然在线上想充分利用 quic 的全部特性例如:连接恢复时的0RTT、链接迁移等特性,还需要对服务端前端机集群进行相应的改造。后续云音乐也会对这业界方面进展持续保持关注。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
云音乐如何「搭」出新首页
https://segmentfault.com/a/1190000044308465
2023-10-16T17:46:47+08:00
2023-10-16T17:46:47+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:当轩、郑友想</blockquote><p>描述:如何通过可视化搭建系统支撑云音乐新版首页这样的核心场景,并满足其对性能、动态化和精细化运营的诉求。</p><p>如今可视化搭建、低代码等通过拖拉拽生产页面的方案已经很常见,然而它们大多用于活动页搭建、中后台 CURD 场景等相对来说非核心的业务场景,主要原因是 C 端核心场景对于性能、灵活性等方面都有非常高的要求,大部分基于搭建的系统难以满足。</p><p>云音乐在过去半年到一年的建设中,建设了从搭建 UI 到投放数据的灵渠搭建能力,并在新首页改版中完全覆盖了新版首页发现流、音乐流两个流量最大的核心页面和 26 个全部模块,可以说新版首页完全就是「搭」出来的。</p><p>本文将介绍我们如何通过可视化搭建系统支撑云音乐新版首页这样的核心场景,并满足其对性能、动态化和精细化运营的诉求。从中也可以看到可视化搭建、低代码等解决方案理论上能够覆盖的场景比想象中更大。</p><h2>业务背景</h2><p>在云音乐新框架改版中,发现流和音乐流是两个核心的信息流页面。</p><p><img src="/img/remote/1460000044308467" alt="" title=""></p><p>首页作为最大的流量入口,不同的垂直业务都会在首页上提需新增模块,从而技术上面临几个核心问题:</p><ol><li><strong>无法动态化,依赖发版</strong>:我们尝试过在首页使用 ReactNative 卡片来实现动态化,然而这导致首页的性能劣化严重。所以首页新增模块仍然依赖发版,这导致业务的迭代周期很长,一次完整的价值验证常常需要经过多次「双端开发-发版-放量-数据验证」的过程。</li><li><strong>策略不能有效复用</strong>:流量分发场景投放的卡片往往是伴随着很多规则和策略的,例如针对某个人群投放、在某些时段下投放等等。这些策略大部分从原子角度看是重复的,但是不同业务却总是需要重复开发。</li><li><strong>视图层数据和服务端不解耦,服务端总是需要介入变更</strong>:由于负责视图层 UI 和数据接口的开发同学往往是前后端不同职能的,一旦 UI 发生改变,就很可能需要服务端一起介入变更,这导致沟通协调的成本很高。</li><li><strong>不同业务的配置后台能力需要重复建设</strong>:流量分发场景分发的资源和内容来自于不同垂直业务,而这些业务各自都需要通过自己的配置后台提供各种运营配置能力,从而最终支撑内容的投放,而这些能力建设很多相互重复。</li></ol><h2>搭投一体的需求交付流程</h2><p>在常规的需求交付链路中,不同角色各司其职完成不同部分的开发,然后联调、测试并且发版上线。</p><p><img src="/img/remote/1460000044308468" alt="" title=""></p><p>这样整个链路的沟通成本、重复开发成本、以及发版、放量周期带来的时间成本都非常高。</p><p>而在云音乐新版首页的交付中,我们在灵渠平台上通过结合搭建、投放、客户端动态化 DSL 引擎的能力建设,把整个需求交付链路重构为只需要一名开发(一般是大前端开发)就能独立完成的过程。</p><p><img src="/img/remote/1460000044308469" alt="" title=""></p><p>在需求交付后,灵渠平台也能直接通过开发者的配置提供面向运营的配置表单,提供通用的资源、内容、计划配置等通用能力。</p><h2>动态化 & 性能</h2><p>今天动态化能力已经是各大 App 厂商不可或缺的基础能力,相比于跨端技术能够把 Android、iOS 双份开发人力缩减到一份,动态化能力允许我们在不经过「发版-放量」的过程快速进行迭代调整,对业务迭代和价值验证来说无疑更加重要。</p><p>然而,动态化能力的增强几乎同时就意味着性能上的损耗,我们可以从当前主流的几种动态化方案的能力和其性能表现看出,并不存在一劳永逸能够满足所有场景诉求的动态化方案,我们需要针对我们的业务诉求做出合适的选择。</p><p>以首页为例,核心大流量场景往往需要给垂直业务场景分发流量,这种场景有几个特点:</p><ol><li>性能要求很高:接近 Native 水平。</li><li>偏展示,若交互:此类场景往往会把流量分发到各个垂直业务,而不是在当前场景直接完成所有的消费。</li><li>快速调整:各类业务都需要在流量入口布点,同时迅速迭代来达到业务预期的数据效果<br>所以对于流量分发类场景来说,客户端 DSL 是最合适的客户端动态化方案。</li></ol><p>所以<strong>对于流量分发类场景来说,客户端 DSL 是最合适的客户端动态化方案</strong>。</p><p><img src="/img/remote/1460000044308470" alt="" title=""></p><p>在具体的客户端 DSL 方案上,我们没有从头造轮子,而是基于优酷团队开源的 GaiaX 做了上层封装和定制开发。对接了云音乐内部的生态(如路由、RPC 等)。同时在此基础上封装了大量通用容器,如弹窗容器、RN 混排容器、图片分享容器等等。</p><h2>可视化搭建</h2><p>引入客户端 DSL 后,随之而来的问题是其带来的学习成本,GaiaX 的 DSL 本质上由三个文件 组成。</p><p><img src="/img/remote/1460000044308471" alt="" title=""></p><p>这样带来的问题是,DSL 的代码本身可读性并不好,和开发者过去熟悉的技术栈都不一致,会带来非常高的开发成本。于此同时,我们也需要建设对应的配套工具(例如预览、调试、发布流程)来支持开发者开发。</p><p>GaiaX 显然也意识到了这个问题,提供了 GaiaX Studio 这个基于可视化搭建的 DSL 开发工具,但可惜的是这个工具并不开源,我们无法在此基础上开发我们需要的能力(例如对接云音乐的换肤、RPC 等等)。</p><p><img src="/img/remote/1460000044308472" alt="" title=""></p><p>于是,开发一套具备可视化搭建能力的 DSL 搭建系统,同时在这个系统上去建设开发者配套能力就成为我们的首选项。</p><p>最终的产品形态是我们建设了一套在线的可视化搭建系统,同时支持直接扫码预览、错误检查、内部系统(换肤、图片素材管理)对接、发布流程、数据 Mock 等等开发者配套,使得不同技术栈的开发者(主要是大前端同学)可以直接在线通过拖拉拽直接开发出可投放的 DSL 卡片。</p><p><img src="/img/remote/1460000044308473" alt="" title=""></p><h2>数据源如何解耦</h2><p>无侵入性进行数据编排,通常的做法有 SPI,Groovy脚本,第三方系统 (类似选品系统 平台只提供对接)</p><ul><li>SPI 不灵活 - 不能满足各式各样的述求,不能满足数据转换需求</li><li>Groovy脚本 - 对RPC调用不适用</li><li>第三方系统 - 需要重新建设</li></ul><p>考虑到云音乐已经有一套完整的BFF能力, 具体可以参考之前发布的文章 <a href="https://link.segmentfault.com/?enc=rqVauTm9hfTiKjs1vErQYQ%3D%3D.kMviCuQWqNc6pIOzlxPDqDM7XWYDuigd50JoDB0Ro1vhirLY4fzPkBipqRZ60fc8" rel="nofollow">基于GraphQL的云音乐BFF建设实践</a>。<br>我们决定使用其能力,在搭建端可以选择BFF生产的对应数据源,并且在用户访问时自动完成对应的数据组装。</p><p>同时在搭建端我们也提供了可视化的数据字段选择、mock 等能力,通过这种方式让 UI 视图的开发者自己也可以开发对应 UI 字段的数据源后端。而业务后端的开发者只需要提供底层 service 即可。</p><p><img src="/img/remote/1460000044308474" alt="" title=""></p><h2>卡片如何投放出去</h2><p>完成了卡片的开发后,下一个问题是卡片如何被投放出去。</p><p>流量分发场景需要通过精准的目标受众定位、选择合适的投放形式、渠道和时机、设定合适的投放内容和时间,来实现投放效果最优化和投放效益的最大化。通过有效的投放策略,内容投放平台可以帮助提高资源曝光率、点击率和转化率,从而实现内容投放效果的最大化。</p><p>灵渠作为投放平台,提供了多种投放策略,以及策略规则组合配置。基于灵渠平台的策略能力,我们可以把「某个位置上面向某个人在某个时间应该出什么样的 UI」也通过策略化的能力承载。</p><p>灵渠平台具有多种投放策略,如客户端版本、人群圈选、频控策略、AB实验等,并且支持通过bff开发业务自定义策略,做到了策略的复用性以及灵活性。</p><p><img src="/img/remote/1460000044308475" alt="" title=""></p><h2>整页混排容器</h2><p>上面所说的都是 DSL 卡片从搭建、投放到最后端上渲染出来的链路,但并非整个页面都是由 DSL 搭建的,页面框架本身还需要考虑下拉刷新动作、数据缓存管理、列表 cell 复用等等问题。</p><p>而这些需求不仅仅是首页才有,在大部分信息流场景都是存在的,所以我们在端上封装了整页混排容器,把信息流页面大部分通用能力都封装到一起。之所以要考虑混排,是因为有时候 DSL 不能完全满足某些业务模块的诉求,所以我们允许在部分模块上使用 Native 或者 React-Native 进行开发。</p><p><img src="/img/remote/1460000044308476" alt="" title=""></p><h2>质量建设</h2><p>在承载了首页这样的大流量核心场景后,模块的数量、复杂度、参与人数的增加,都给稳定性带来更多的挑战。虽然引擎和系统本身的稳定性很少出问题,但是开发者在使用平台时却经常产生很多意料之外的问题。</p><p>灵渠搭建平台在不同阶段提供了不同的质量保障:</p><ol><li>开发阶段,提供了预览功能,能通过mock数据感知样式的变化。</li><li>发布阶段,发布时候会显示距上一次发布时,模板的变更情况,哪些人员进行了变更。并且发布具有环境概念,只有发布到对应环境,对应环境才会有数据,做到了环境隔离。</li><li>上线阶段,提供了严格的卡点功能,必须通过双端的真机扫码预览后才可以发布,对于扫码中有异常的情况,不予通过。</li><li>上线后,灵渠投放平台拥有数据的监控,以及流量波动情况有告警通知<br>出问题时,模板提供了模板回滚能力,能快速止血。并且提供发布记录对比,能快速对比搭建的UI模板差异。</li></ol><p><img src="/img/remote/1460000044308477" alt="" title=""></p><p>在质量保证上灵渠平台还提供了配置资源位兜底, 在下游发生一些异常等其他情况拿不到数据时候,能继续透出模板数据。配置如下图所示。另外还支持用户纬度的兜底数据配置,满足新首页推荐流中个性化模块兜底的场景,如最近播放。</p><h2>总结与展望</h2><p>本文介绍了云音乐如何通过可视化搭建系统支撑新版首页这样的核心场景,并满足其对性能、动态化和精细化运营的要求。文章还探讨了动态化能力的重要性和各种动态化方案的能力和性能表现,以及针对不同业务诉求做出合适选择的必要性。</p><p>展望未来,可视化搭建、低代码、客户端DSL等解决方案将会在更广泛的业务场景中得到应用,从而进一步提高开发效率和满足业务需求。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
网易云音乐 Tango 低代码引擎实现揭秘
https://segmentfault.com/a/1190000044283898
2023-10-08T15:17:04+08:00
2023-10-08T15:17:04+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:景庄</blockquote><p>我们在 8 月底<a href="https://link.segmentfault.com/?enc=%2BKp2HiVr3sukgDS8ICLCzQ%3D%3D.7L3admeb4ElJoW2lg5cxd1NIHT4hYG%2FV6FR0PwCF4I35XUIAzf4go%2B1qF8n2LtsK" rel="nofollow">正式开源了 Tango 低代码引擎</a>。Tango 是一个基于源码的低代码设计器框架,支持直接基于项目源码提供低代码可视化开发能力,可以无缝的与既有的本地开发工作流进行集成,从而提供渐进式的低代码开发能力。</p><p><img src="/img/remote/1460000044168767" alt="Tango 低代码引擎使用演示" title="Tango 低代码引擎使用演示"></p><p>按照计划,我们在 9 月底<a href="https://link.segmentfault.com/?enc=wn1cW00HizsFbNExSbjyiw%3D%3D.AIcX%2BY%2BW3WW6pZpfEKyZqEQ56rd8GriUd9d5V%2F2IhegR5jiYKmi%2FqnUta2jdkCaF" rel="nofollow">发布了 beta 版本</a>,在此版本中我们遵循 <strong>“最小内核”</strong> 的原则对 Tango 的核心实现进行了大幅的重构,剥离了大量冗余的代码实现。</p><p>为了帮助大家更近一步的了解 Tango 开源版本的核心构成与代码实现,本文将会详细揭秘 Tango 低代码引擎的设计思考与实现过程。</p><ul><li>Github 仓库:<a href="https://link.segmentfault.com/?enc=jYAMpr1Fe2bh2kZkPcqnxw%3D%3D.XrKmEVNLsSUWdlVRLQpiYgZ1XoIXjHQRUzl%2FWwd2lfMZUsCNaaWWvN%2FWU8eXOApw" rel="nofollow">https://github.com/NetEase/tango</a></li><li>发行历史:<a href="https://link.segmentfault.com/?enc=6AZ1815QU8l9ML8%2FaNVDYA%3D%3D.Wtwwld1bQR6X7%2B9Cdujps7rewR2Uix7vCeYJ1kQfqbtp%2FKXnPLgyS6A2Gk74a2%2FP" rel="nofollow">https://github.com/NetEase/tango/releases</a></li><li>文档站点:<a href="https://link.segmentfault.com/?enc=AyO6lge0x%2Fe0sxxQaLqp%2BA%3D%3D.%2BNlGvKfT5KFPTsp%2FgA2RNSmgSvpEnf74v9rCa0bsoM5CpQqh%2FCh0u0r87QBC5Ds5" rel="nofollow">https://netease.github.io/tango/</a></li></ul><h2>低代码可视化搭建之殇</h2><p>从实现上看,低代码搭建能力的核心是 UI 可视化编程。借助 UI 可视化编程,可以大大的弱化使用者对于代码编程的感知,但在真实的业务需求场景中,我们面临着大量的复杂的应用逻辑,使用者很难借助 UI 操作表达功能逻辑。例如下图中的合同管理,资金结算等页面。如果借助于传统的低代码方案,通常会发现,很容易一条路走到黑,没有回头路。所以,经常会有开发者抱怨,稍微复杂的场景下,低代码的效率甚至不如写代码。</p><p><img src="/img/remote/1460000044283900" alt="在实际业务场景中面临大量难以低代码开发的前端应用" title="在实际业务场景中面临大量难以低代码开发的前端应用"></p><h2>传统低代码方案的问题</h2><p>我们不妨先简单分析一下传统的低代码方案的问题。传统的低代码搭建方案往往采用定义私有 Schema 协议来可视化表达视图逻辑,也就是将代码逻辑转换为私有的描述,大致的原理可以参考下面这张图。</p><p><img src="/img/remote/1460000044283902" alt="基于 Schema 的低代码可视化搭建方案" title="基于 Schema 的低代码可视化搭建方案"></p><p>这类方案很容易面临不断膨胀的私有 JSON 协议。并且,私有协议扩展性和灵活性差,难以达到图灵完备状态。例如在我们的实际开发过程中,传统的低代码方案会面临各种各样的扩展性卡点。此外,开发能力往往受限于内置的组件和模板。且难以复用现有的前端资产,例如组件和代码等等。对于开发者而言,私有协议也导致问题定位难,调试难。</p><p>借助于私有协议的搭建方案通常适合于轻业务逻辑的简单类表单,营销类的活动页面等等,很难用于复杂的业务逻辑搭建场景,因为私有协议难以有效的应对这类场景的复杂性和灵活性需求。虽然,有些方案提供了协议转代码的能力,但通常只实现了单向转码,可视化开发和代码开发是两条完全割裂的路径。</p><p><strong>在此基础上,我们就需要重新思考低代码搭建协议的设计问题。</strong></p><h2>从私有搭建协议到公有协议</h2><p>那么,我们能否不使用私有协议,而是采用公有协议?</p><p>答案是,可以的!<a href="https://link.segmentfault.com/?enc=aiqBZeqwb06uK41kV51ZuA%3D%3D.rmnJRUROMFaLC7VbfBzIwhsa0xqyTfGvGTYkDz5u%2F3Mq0Xov1dynWDWeSvAVqFuU" rel="nofollow">ESTree</a> 规范作为主流的处理 JavaScript 源代码的标准社区协议,被广泛用于浏览器 JavaScript Parser 的实现。借助于 ESTree 协议,可以完美的实现对源码逻辑的描述,并且社区有大量的工具可以帮助我们完成这个过程。</p><p><img src="/img/remote/1460000044283903" alt="基于ESTree规范,实现双向互转的低代码搭建能力" title="基于ESTree规范,实现双向互转的低代码搭建能力"></p><p>因此,我们尝试使用 ESTree 规范来实现低代码搭建过程。借助于 ESTree 规范,我们无需定义私有的渲染描述协议,并且可以低成本的实现代码到协议,协议到代码到互转。借助于双向转码的能力,我们获得全新的低代码开发体验。</p><h2>Tango 低代码引擎实现原理</h2><p>基于这个思路,我们设计了基于 ESTree 规范的低代码引擎方案 -- Tango。可以通过下面这张图来简单的描述下实现逻辑:</p><p><img src="/img/remote/1460000044283904" alt="Tango 低代码引擎实现分析" title="Tango 低代码引擎实现分析"></p><p>首先将源代码解析为 AST。用户的拖拉拽等操作则映射为对 AST 的遍历和修改。最后将新的 AST 重新生成代码,交给设计器沙箱去渲染执行。而对 AST 的解析、遍历、修改、生成,则可以借助大量的社区工具,这里我们选择的是 babel!</p><blockquote>AST 的全称是抽象语法树,是一种分层的程序表达,根据编程语言的语法呈现源代码的结构。</blockquote><p><img src="/img/remote/1460000044283905" alt="大量的工具基于 AST 实现" title="大量的工具基于 AST 实现"></p><p>其实,数量众多的前端工具库都是基于 AST 操纵实现的。我们可以发现,在任意的前端项目中的 package.json 里的 devDependencies 里的很多工具包是基于 AST 解析操纵实现的,例如 JS 的转译,代码压缩,ESLint 等等,我们可以阅读这些工具的源码来进一步的学习。</p><p><img src="/img/remote/1460000044283906" alt="将源码转为 AST 描述的基本过程" title="将源码转为 AST 描述的基本过程"></p><p>如图所示,将源代码转为 AST 描述的基本过程包括词法分析和句法分析两个阶段:</p><ul><li>词法分析:借助词法分析器将代码字符串分割为标记列表。</li><li>句法分析:借助句法分析器将标记数据转为 AST 描述。</li></ul><p>最后,我们可以获得源代码的结构化描述树。有很多工具可以帮我们来实现这个过程,例如 babel -- 它可以帮助我们轻松的实现代码到 ast,ast 遍历修改,ast 到代码的过程。</p><h2>基于 AST 实现搭建的基本过程</h2><p>我们来看一下使用 ast 实现搭建逻辑的基本过程。<br>看一个具体的例子:通过修改 AST,在 Page 中插入一个 Section 节点。</p><p><img src="/img/remote/1460000044283907" alt="基于 AST 实现搭建逻辑" title="基于 AST 实现搭建逻辑"></p><p>中间这段代码,展示了核心的逻辑,通过遍历整个 AST 中的所有 JSXElement 节点,找到第一个 Page 元素,然后在 Page 元素的 children 里插入新的 Section 节点。这只是一段演示代码,具体的过程比这个要复杂的多,因为有很多的边际逻辑要处理。最后,我们可以将 ast 重新生成为代码,得到我们想要的结果。</p><h2>Tango 的数据变更流程设计</h2><p>了解了基本的实现原理后,我们来看一下低代码引擎的数据变更流程设计。</p><p><img src="/img/remote/1460000044283908" alt="数据变更流程设计" title="数据变更流程设计"></p><p>首先是引擎初始化。源码文件会被引擎内核解析进行状态初始化。接下来,对于用户的操作,会触发浏览器事件,引擎接收到相应的事件,触发内核中的状态变更,更新 AST。</p><p>然后,内核会基于新的 AST 的同步生成代码,由引擎将代码同步给渲染沙箱。渲染沙箱感知到代码变化后,会触发页面重新渲染,也就是沙箱的 HMR 过程。</p><h2>基于源码的在线渲染沙箱设计</h2><p>接下来,我们需要考虑的是如何在浏览器中执行 JavaScript 源码工程?有很多方案可以选择,我们选择的方案是 <a href="https://link.segmentfault.com/?enc=jYdfV2uz8qulmoAF1VVPrA%3D%3D.x1l%2BEWzDBYnhmqI78RaarCDExR24J3cJtrPukLJNXHjN3rC3G2QlQ7rmQGlibB90" rel="nofollow">sandpack</a>,它是由 CodeSandbox 开源的可以在浏览器中实时运行 JavaScript 项目的的工具库。在具体实现上,<a href="https://link.segmentfault.com/?enc=qMkRuLBPdR6bpuA0M1eVIA%3D%3D.qWfW0wsJXE3luxaGcsXI1eYs1E7ZhnsfekXN9EeTtO%2BDXOdqlE0%2Bt2lyWU4oGHt9" rel="nofollow">我们对 sandpack 进行了一系列的改造</a>,以满足低代码生产环境的需要。</p><p>基于 sandpack 的在线渲染沙箱方案如下图图所示。</p><p><img src="/img/remote/1460000044283909" alt="Tango 沙箱设计" title="Tango 沙箱设计"></p><p>在实现上,主要包括 3 个部分,分别是:</p><ul><li>低代码沙箱:它是一个开箱即用的前端组件,只需要传入源代码和构建配置信息即可完成前端项目的构建和执行。</li><li>在线 Bundler:是低代码沙箱的核心,用来在浏览器上构建和执行源代码,本质上是一个在浏览器端运行的简化版 webpack。</li><li>打包服务:是一个 node 服务,用来对 npm 包执行预构建和资源合并。</li></ul><p>从沙箱执行流程来看,首先 Sandbox 组件将项目的源代码和 compile 指令使用 postMessage 传递给在线 Bundler,在线 Bundler 在接收到 compile 指令后,bundler 会从 packager 打包服务加载项目的 npm 依赖,然后编译和执行代码,最后发送 success 消息给低代码沙箱。</p><h2>Tango 低代码引擎的构成</h2><p>结合上面的介绍,在构成上,Tango 低代码引擎主要包括 3 个核心组成部分,分别是:</p><ul><li>引擎内核:扶额建立文件,节点模型,提供输入输出能力。</li><li>拖拽引擎和可视化面板:提供可视化开发能力</li><li>渲染沙箱:提供源码在浏览器上的编译执行能力。</li></ul><p><img src="/img/remote/1460000044283910" alt="引擎构成" title="引擎构成"></p><p>借助于 Tango 低代码引擎,我们可以为开发者提供全新的在线开发体验,支持源码级的自定义能力。对可视化开发而言,可视化配置会触发 AST 的修改,进而会重新生成对应的源码。而对源码开发而言,修改源码后会同步更新 AST。</p><h2>开源版本发行计划</h2><p>目前我们已经完成了 Tango 核心实现的基本代码库的开源,包括核心引擎内核、沙箱、设置器、应用框架、物料协议等等。预计在 10 月底,我们将会发布 Tango 的社区 RC 版本,该版本将会获得更加稳定的 API 和更加完善的文档。</p><p><strong>正式版本</strong> 我们将在 <strong>2023 年 Q4 结束前</strong> 发布,届时我们会进一步完善我们的开源社区运营机制。</p><p>在云音乐,我们还在构建更加完善的面向生产场景的低代码研发体系,包括 RN 跨端应用的低代码研发,后端逻辑和服务的低代码编排能力,以及基于低代码的前后端研发工具链等等。随着相关能力的稳定和时间的成熟,后续我们将会持续向社区开源更多的内部实践。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
程序员旅程中的思维与精神
https://segmentfault.com/a/1190000044258367
2023-09-25T15:51:46+08:00
2023-09-25T15:51:46+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:E、T、F</blockquote><p>最近碎片时间有在看黑客与画家,看的过程中,有一个问题突然冒了出来,<strong>一个程序员到底应该具有哪些思维,哪些精神才算领悟了真谛?</strong> 除了程序员,生活在我们这个时代的每一个人又是否有借鉴之处呢?这里我们先撇去技术层面的知识不谈,更宏观地看下这个问题。 </p><p>陆奇曾在演讲中提到,<strong><code>动手去创造性地解决问题,代表了创造者一系列的核心行为和思想状态。</code></strong> </p><p>首先,一定是要<strong>动手去做</strong>。在当今这个大数据消费时代,很多人似乎“失去”了思考和学习的能力,被个性化推荐滋补投喂着“个人兴趣”,守着这一亩三分地再也没想着迈出去。 </p><p>有些人通过阅读书籍,学了很多知识,听了很多道理,看了很多的世界,却仅止步于理论,将知识停留在记忆中,甚至慢慢消退。有时候,每当别人发表观点,突然就仿佛唤醒了自己的记忆,说道:“诶?你不提我还真给忘了,这个我有印象,是那个啥啥啥吧,说的大概是balabala”。 </p><p>再进一步的人,会在看的过程中,记录自己的笔记,就像当年学生时代很多人抄歌词一样,一是当时情绪到位了,让当时的自己大受触动;二是留个念想,待到多少年后一看,满是青春的回忆。但绝大多数人也仅仅停留在记录的这一道,没有更进一步。 </p><p>然而,最难得的人呢,是那些<strong>看到这些知识内容后,会静下心来写出自己的想法,思考它在不同场景下是否仍然适用,最终融汇到自己思维体系中的人</strong>,在这期间,同一份知识内容他们可能会反复回过头去细品确认,最终得出自己的见解并在后续的日常中付出实践。 </p><p>当然,<strong>动手是需要勇气的</strong>,而且这往往要耗费大量的时间,也需要我们拥有专注和耐心。尤其是在这个快消费时代,30分钟的枯燥思考怎么抵得过30个欢快的短视频。 </p><p>有时候就是这样,道理我们都懂,但如果要付诸实践却困难重重,往往需要巨大的决心和意志力。反过来看,那些真正能思考后付出实践,甚至养成习惯的人,就显得多么高大。 </p><p>然后是<strong>创造性</strong>,我们传统层面对创新的理解一直是些高大上的东西,想着要从0到1发明创造个什么牛逼哄哄的东西。但创造性更多意味着不受束缚,敢于探索。<strong>从0到1是创新,从1到1.1也是创新</strong>。<strong><code>善于运用前人的智慧结晶才是大道</code></strong>。就像我们感觉古人们都会作诗,但其实除了凤毛麟角的圣贤外,大多数也是因为他们当时“考试”要考这个,也是学习模仿名人名句,在某些场景下有感而发写出了一首首诗词歌赋。 </p><p>另一方面,创造其实同设计和品味是密不可分的。许多人其实都具备设计的能力,但真正有品味的设计者却寥寥无几。<strong><code>设计作为需求和技术之间的桥梁,具有非常重要的地位</code></strong>。一个好的设计,可以不断满足需求,让技术发挥更大的潜力。真正能够创造价值的人,就是那些真正有品味的人,他们知道什么是好的,明白什么是需要优化的,同时这些人往往很有态度,不做到卓越誓不罢休。 </p><p>最后是要<strong>解决问题</strong>。解决问题不仅仅关乎技术层面,更关乎的是解决人的问题,满足人的需求。那如何才能发掘和找到这些需求呢? </p><p>近些年比较热的一个概念就是<strong>善于运用第一性原理</strong>。它最早是由古希腊哲学家亚里士多德提出,强调的是<strong><code>回归事物最基本的条件,将复杂问题不断拆分进行要素解构分析,从而找到实现目标最优路径的方法</code></strong>。那些最基本的事实或真理是不需要再被推导、证明的,而其他的知识和理论都可以根据这些原理来构建和推导。 </p><p>它可以帮助我们洞察事物的本质,更好地理解和发掘需求,并促使人类不断演进和发展。一个好的、更优的路径,可以让我们展望更远的未来。 </p><p>用一个可能不恰当的例子,如果说“算法 + 数据结构 = 程序”。我们看清了需求,可以认为找到了一种“解药”。<strong><code>但光有解决方案还不足以实现真正的变革,我们还需要一种容器来承载和实现这个解决方案</code></strong>。这个容器就是创造的工具。 </p><p>不同的时代,创造的工具有着显著的区别。<strong>随着人类社会的发展和技术的进步,创造工具的形式和功能也在不断演变</strong>。 </p><p>在古代,创造的工具主要是各种农业/手工艺工具。这些工具帮助我们提高了农作物的产量和效率,制作出精美的纺织品。 </p><p>随着工业革命的到来,创造工具经历了革命性的变化。发电机、蒸汽机等工业设备的出现,引领了新的工业时代。电力的运用使得生产力大幅提升,机械化生产取代了手工劳动。这些创造工具极大地推动了工业化进程,并对社会产生了深远的影响。 </p><p>而在当下这个时代,<strong>计算机、开发工具、编程语言等创造工具成为了重要的创新驱动力</strong>。不论是在程序员开发的软件、Web应用、移动应用中,还是最近大热的人工智能领域,编程语言、开发框架、数据分析处理工具都发挥着核心的作用。<strong><code>它们是当下创造者思考的基石,也是创造者进化和演变的核心环境</code></strong>。 </p><p>著名的心理学家、龙虾🦞教授 乔丹·彼得森(Jordan Peterson)在之前的采访和演讲中也再三说明了思考、写作、实践的重要性,甚至也强调了创作工具的地位。这种方法的底层逻辑其实也是在告诉我们:<strong>要思考,更要行动起来,用“正确的技术”去发现并解决问题</strong>。 </p><p>实际上,不仅仅是对于黑客、程序员,对于画家,对于生活在这个时代的每一个人来说,都是类似的。当我们的大脑停止思考时,命运的车轮也将停滞不前。如果我们一味坚持固化的观念和思维方式,终有一天也将变成那些我们当初眼中的“老顽固”。 </p><p>在这个大变革时代,我们都需要<strong><code>勇于行动、善于思考,善于发现适合自己的工具和方法,探索创新的边界</code></strong>。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
Android 增量构建的科技与狠活
https://segmentfault.com/a/1190000044234854
2023-09-20T10:25:49+08:00
2023-09-20T10:25:49+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:jungle</blockquote><h2>描述</h2><p>最近生活中大家遇到的科技与狠活较多,当android的构建用上科技与狠活会不会倒沫子呢,让我们拭目以待。</p><h2>前言</h2><p>对于 <code>Android</code> 应用,尤其是大型应用而言,构建耗时是令人头疼的一件事。动辄几分钟甚至十几分钟的时间更是令大部分开发人员苦不堪言。而在实际开发过程中面对最多的就是本地的增量编译,虽然官方对增量编译有做处理,但在具体项目,尤其是中大型项目中,效果其实都不太理想。</p><h2>背景</h2><p>目前网易云音乐及旗下 <code>look</code> 直播,心遇,<code>mus</code> 等 <code>app</code> 先后采取了公共模块 <code>aar</code> 化,使用最新 <code>agp</code> 版本等措施,但整体构建耗时依然很久,增量构建一般在 2-5 <code>min</code> 左右。由于本人当前主要是负责开发 <code>mus</code> 的业务,因此结合目前 <code>mus</code> 的实际构建情况对增量构建做了一些优化工作。</p><h2>耗时排查</h2><p>结合 <code>mus</code> 构建的具体情况来看,目前构建耗时的大头主要集中在一些 <code>Transform</code> 和 <code>dexMerge</code> ( agp 版本 4.2.1 )。</p><p>对于 <code>Transform</code> 而言,主要是一些例如隐私扫描,自动化埋点等工具耗时严重,通常增量时这些 <code>Transform</code> 的耗时就达到数分钟。</p><p>另外 <code>dexMeger</code> 任务也是增量构建时的大头,<code>mus</code> 增量 <code>dexMerge</code> 耗时约为 35-40s ,云音乐 <code>dexMerge</code> 增量构建耗时约 90-100s 。</p><h2>优化方向</h2><p>对于大型项目而言,最耗时的基本就是 <code>Transform</code> 了,这些 <code>Transform</code> 一般分为以下两类:</p><ol><li>功能型 <code>Transform</code>,移除只会影响自己的功能部分,不影响构建产物和项目运行。例如:埋点校验,隐私扫描。</li><li>强依赖型 <code>Transform</code> ,移除影响编译或项目正常运行。这部分通常是在 <code>apt</code> 中采集一些信息,然后在 <code>Transform</code> 执行时生成 <code>class</code> ,在运行时调用执行。</li></ol><p>功能型 <code>Transform</code> 可以通过编译开关和 <code>debug/release</code> 判断,避免在开发时调用执行。对于强依赖的 <code>Transform</code> 可以通过字节开源的 <code>byteX</code> 之类的工具将 <code>Transform</code> 流程拍平,对增量和全量编译都有效果。但是 <code>byteX</code> 的侵入性较大,需要将现有的 <code>Transform</code> 改成字节提供的 <code>Transform</code> 的子类。这里我们采用一种修改构建输入产物的轻量级方案来实现 <code>Transform</code> 增量构建的优化。</p><p>同时对于 <code>dex</code> 相关操作耗时的点,可以结合 <code>dexMerge</code> 的实际流程做增量优化,确保只有最小粒度的改动点会触发 <code>dex</code> 的 <code>merge</code> 操作。</p><h2>Trasnform 增量构建</h2><p>虽然 <code>mus</code> 目前依赖的大部分 <code>Transform</code> 的 <code>isIncremental</code> 配置返回 <code>true</code> ,但是实际的 <code>io</code> 和插桩很少有做增量逻辑的。</p><p>在增量构建时,大部分 <code>class</code> 在第一次构建时已经经过各 <code>Transform</code> 的处理,被插桩修改后移动到对应的下一级 <code>Transform</code> 目录了,增量时这部分已经处理过的产物其实没有必要再在各 <code>Transform</code> 之间执行插桩和 <code>io</code> 了。</p><p>目前大部分 <code>Transform</code> 的写法都是如下写法:</p><pre><code class="groovy">input.jarInputs.each { JarInput jarInput ->
ile destFile = transformInvocation.getOutputProvider().getContentLocation(destName , jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(srcFile, destFile)
}
input.directoryInputs.each { DirectoryInput directoryInput ->
File destFile = transformInvocation.getOutputProvider().getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
...
FileUtils.copyDirectory(directoryInput.file, destFile)
}</code></pre><p>这里在增量构建时应该做的是只对发生变化的产物做插桩和 <code>copy</code> 的操作:</p><pre><code class="Groovy">// 伪代码如下:
// jar 增量处理
if(!isIncremental) return
if (Status.ADDED ==jarInput.status || Status.CHANGED==jarInput.status){
File destFile = transformInvocation.getOutputProvider().getContentLocation(destName , jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(srcFile, destFile)
}
// class 增量处理
val dest = outputProvider!!.getContentLocation(
directoryInput.name, directoryInput.contentTypes,
directoryInput.scopes, Format.DIRECTORY
)
if(Status.ADDED ==dirInput.status || Status.CHANGED==dirInput.status){
dirInput.changedFiles.forEach{
// 插桩逻辑
...
// 只移动增量变化插桩后的class文件到对应目录下
copyFileToTarger(it,dest)
}
}</code></pre><p>当然由于一些历史原因,有些 <code>Transform</code> 的代码可能都找不到,无法改造,因此为了兼容所有情况,这边简单对 <code>Transform</code> 的输入产物做了简单的 <code>hook</code> 替换操作。</p><p>通常实现一个 <code>Transform</code> 都是新建一个类实现 <code>Trasnform</code> 的 <code>transform</code> 方法,在 <code>transform</code> 方法里执行具体操作,而 <code>Trasnform</code> 产物的入参正是在 <code>com.android.build.api.transform.TransformInvocation#getInputs</code> 的方法里:</p><pre><code class="java">public interface TransformInvocation {
Context getContext();
/**
* Returns the inputs/outputs of the transform.
* @return the inputs/outputs of the transform.
*/
@NonNull
Collection<TransformInput> getInputs();
...
}</code></pre><p>通过 <code>hook</code> 掉 <code>TransformInvocation#getInputs</code> 返回的 <code>JarInput</code> 和 <code>DirectoryInput</code> ,将 <code>JarInputs</code> 和 <code>Directory</code> 中未发生改变的产物移除。</p><p><img src="/img/remote/1460000044234856" alt="img" title="img"></p><p>经过上述优化后原来耗时几十秒到几分钟的 <code>Transform</code> 基本都能被压缩到1-2 s以内。</p><h2>DexMerge 增量优化</h2><p>事实上 <code>agp</code> 版本更新非常频繁,对于不同版本,<code>dex</code> 耗时不同。对于 3.x 的版本 <code>dex</code> 相关 <code>task</code> 主要耗时集中在<code>dexBuilder</code>上,而4.x的版本主要耗时则集中在<code>dexMerger</code>,由于目前 <code>mus</code> 等业务都使用 4.2 及以上版本的 <code>agp</code> ,研究发现 4.x 的版本实际上对 <code>dexBuilder</code> 有做了增量的处理,整体耗时不多,因此主要对4.2及以上版本 <code>dexMerger</code> 耗时做优化。</p><p>顾名思义,<code>dexMerge</code> 实际上是对已经打出的 <code>dex</code> 进行合并,将多个<code>dex</code> 或者 <code>jar</code> 合成一个较大的 <code>dex</code> 的流程。按照正常情况,<code>dex</code> 数量越多,应用的启动速度越慢,因此对于大型项目,<code>dexMerge</code> 也是必不可少的一步。</p><h3>dexMerge 流程</h3><p><code>dexMerger</code> 是有分桶操作的,桶的数量一般不额外配置使用默认值 16,通常桶的分配逻辑是按照包名来的,也就是说同一包名下的 <code>class</code> 会被分配到同一个桶里。</p><pre><code class="kotlin">fun getBucketNumber(relativePath: String, numberOfBuckets: Int): Int {
...
val packagePath = File(relativePath).parent
return if (packagePath.isNullOrEmpty()) {
0
} else {
when (numberOfBuckets) {
1 -> 0
else -> {
// 同一包名下class被分到同一个bucket里
val normalizedPackagePath = File(packagePath).invariantSeparatorsPath
return abs(normalizedPackagePath.hashCode()) % (numberOfBuckets - 1) + 1
}
}
}
}
public val File.invariantSeparatorsPath: String
get() = if (File.separatorChar != '/') path.replace(File.separatorChar, '/') else path</code></pre><p>实际的构建产物如下:</p><p><img src="/img/remote/1460000044234857" alt="img" title="img"></p><p>增量构建时,<code>agp</code> 会按照以下规则来执行 <code>dexMerge</code> 任务:</p><ol><li>如果有 <code>jar</code> 文件状态发生变更或者被移除了,即对应状态 <code>CHANGED</code> 或者 <code>REMOVE</code> ,这种情况所有的桶都要重新走 <code>dexMerge</code> 流程,通常默认的 <code>bucket</code> 数量是 16 个,也就是当构建时有一个<code>jar</code>文件发生变更时,所有的输入产物全部都会参与 <code>dexMeger</code> 流程。(虽然 <code>d8</code> 命令行工具对增量<code>dexMeger</code> 本身有一定优化,增量速度对比全量会有一定加快,但对于大型项目而言总体还是很慢。)</li></ol><p><img src="/img/remote/1460000044234858" alt="img" title="img"></p><ol start="2"><li>如果是只有新增的 <code>jar</code> 或者 <code>dex</code> 发生改变的<code>Directory</code>,那么会根据对应的包名获取到对应的桶的数组,只对找到的桶的数组进行增量的打包,这也就是我们说的 <code>dexMerge</code> 本身的增量操作。</li></ol><p><img src="/img/remote/1460000044234859" alt="img" title="img"></p><p>返回对应bucket id 数组的代码如下:</p><pre><code class="kotlin">private fun getImpactedBuckets(
fileChanges: SerializableFileChanges,
numberOfBuckets: Int
): Set<Int> {
val hasModifiedRemovedJars =
(fileChanges.modifiedFiles + fileChanges.removedFiles)
.find { isJarFile(it.file) } != null
if (hasModifiedRemovedJars) {
// 1. 如果有CHANGED或者REMOVE状态的jar,则返回全部bucket数组。
return (0 until numberOfBuckets).toSet()
}
// 2. 如果是新增jar,或者是directory中class发生变化,返回计算到的bucket数组。
val addedJars = (fileChanges.addedFiles).map { it.file }.filter { isJarFile(it) }
val relativePathsOfDexFilesInAddedJars =
addedJars.flatMap { getSortedRelativePathsInJar(it, isDexFile) }
val relativePathsOfChangedDexFilesInDirs =
fileChanges.fileChanges.map { it.normalizedPath }.filter { isDexFile(it) }
return (relativePathsOfDexFilesInAddedJars + relativePathsOfChangedDexFilesInDirs)
.map { getBucketNumber(it, numberOfBuckets) }.toSet()
}</code></pre><p>这种增量操作适用的是大部分代码囊括在壳工程中且不会频繁改动底层库的业务,不知道是不是因为国外包括 <code>google</code> 官方本身项目开发模式就是这样。对于大部分国内的项目,只要你做了组件化,甚至没做业务组件化但是有多个子模块类型的项目,只要有涉及到子模块的改动,所有的产物都要全部重新参与 <code>dexMerge</code> 。</p><p>对于 <code>mus</code> ,云音乐等组件化工程,通常构建时只有壳工程是以文件夹的形式作为输入产物在后续的 <code>Transform</code> 和 <code>dex</code> 相关流程里流转,而子模块通常是以 <code>jar</code> 的形式参与构建,而我们实际开发中基本就是对各业务模块的改动,对应上述第一种情况,所有的桶全部会重新走的 <code>dexMerger</code>,而第二种情况只有改动壳工程代码或者新增依赖或者模块之类的才会命中,这种情况偏少可以不用考虑。</p><p>针对上述问题解决方法主要有两种:</p><ol><li>将所有的 <code>jar</code> 拆解为文件夹,这样只有改动模块对应的分桶生效,但是这种问题在于哪怕只改动了一个模块中的两个类,由于 <code>bucket</code> 是按照包名固定分在同一个桶里,非相同包名则根据包名随机分桶,很可能也会连带着其他的 <code>bucket</code> 一起进行 <code>dexMerger</code> ,虽然可以适当扩大分桶的数量,但是同样的,也没法完全规避这种问题。</li><li>仅针对发生改变的输入产物进行重新的 <code>dexMerger</code>,将新生成的 <code>merge</code> 后的 <code>dex</code> 打进 <code>apk</code> 或者移到设备中确保运行时增量改变的这部分代码可以被执行。</li></ol><p>为了确保最小化单元的 <code>dex</code> 参与后续的 <code>dexMerge</code> 流程,我们采用第二种方式作为 <code>dexMerge</code> 增量构建的方案。</p><h3>增量构建产物的 dexMerge</h3><p>通过 <code>hook</code> <code>dexMerge</code> 的关键流程,我们可以获取到发生变化的 <code>jar</code> 文件和包含 <code>dex</code> 的文件夹,然后把 <code>dexMerge</code> 输入产物由原来的全部产物修改为我们 <code>hook</code> 之后的产物:</p><p>我们将所有发生变化的 <code>dex</code> 文件汇总移动到临时的文件目录内,然后将目标文件夹作为一个输入产物即可,对于发生变更的 <code>jar</code>,我们也将其加到输入的产物里,然后继续走原来的 <code>dexMerge</code> 流程。</p><p>打出来的增量 <code>dex</code> 产物如下:</p><p><img src="/img/remote/1460000044234860" alt="img" title="img"></p><p>同时我们需要变更增量 <code>dexMerge</code> 的输出目录,因为 <code>dexMerger</code> 正常运行时,在有代码修改的情况,所有的 <code>bucket</code> 都会被新的产物覆盖,哪怕新的产物是空文件夹。如果不更改文件目录就会覆盖掉之前全量打出的所有的 <code>dex</code> ,导致最终的 <code>apk</code> 包仅包含这次增量的 <code>dex</code> 从而无法正常运行。</p><p>同时由于每次增量构建变化的产物都不同,因此对每次构建产物的输出目录做了递增,同样是确保上次增量的产物不要被本次覆盖掉,这里每次的产物都对后续构建流程有作用,具体会在后续内容中说明。</p><p>当然,新的目录具体放在哪里,也跟我们选择的方案有关系。</p><h3>热更新方案</h3><p>因为有了增量的 <code>dex</code>,我们很容易联想到热更新的方案,即将增量构建出的 <code>dex</code> 推送到手机 <code>sd</code> 卡上,然后在运行时去动态加载。这种情况下增量 <code>merge</code> 的 <code>dex</code> 产物放在哪个目录下都可以,因为对后续构建流程已经没有什么太大影响了,影响的主要是运行时 <code>dex</code> 的加载逻辑。</p><h4>1. 增量 dex 临时产物</h4><p>上述虽然有了增量的构建产物,但是为了运行时方便排序仍然会每次把当次编译新增的 <code>dex</code> 移动到临时目录 <code>pulledMergeDex</code> 文件夹中。</p><p><img src="/img/remote/1460000044234861" alt="img" title="img"></p><p>然后通过 <code>adb</code> 每次批量清理设备中临时的 <code>dex</code> ,再将全部 <code>pulledMergeDex</code> 目录下的 <code>dex</code> 推送到设备中,这样做的目的是为了确保设备中 <code>dex</code> 的准确性,避免因为某次构建残留的 <code>dex</code> 产物运行影响现有的代码逻辑。</p><p><img src="/img/remote/1460000044234862" alt="img" title="img"></p><h4>2. 运行时动态加载 dex</h4><p>由于 <code>dex</code> 的加载是按照 <code>PathList</code> 加载 <code>dexElements</code> 数组的顺序从前往后加载的,因此只要按照 <code>dex</code> 的热更方案,在运行时反射替换 <code>PathClassLoader</code> 中的 <code>dexElements</code> 数组,将之前推送到手机目录中的数组,按照倒序先排列好,然后再插入在 <code>dexElements</code> 数组最前面即可,这里热更新的具体原理不再阐述。</p><p>接入项目中实测发现有些代码改动会不生效(主要是 <code>Application</code> 和 <code>Application</code> 直接引用到的 <code>class</code>),具体原因应该是 <a href="https://link.segmentfault.com/?enc=P3%2BlAno6ssJZf8U%2F9mycdQ%3D%3D.ifUBj9gKN%2F%2BHxUJTANyVQ46zoUeWIYuA4ZBGlDYPwLbQbD0jqMrrmOnTVe%2BeoCig%2Bedzb7yKM7iXGb9WabOCTvs4JL2zcJqeXdsVfswf8QMb5RB2hJV5LdJsCG4dzdOq%2FLwdJatKhHkUFF8iyATNeQ%3D%3D" rel="nofollow">Android N 对热补丁的影响</a>,本地在 <code>AndroidMainfest</code> 文件中加了 <code>safemode=true</code>,但在实际设备运行还是无效,不知道是不是现在设备的版本不支持了。另外一种可行的方式就是类似 <code>tinker</code> 的解决方案对 <code>Application</code> 进行改造,然后通过另外的 <code>ClassLoader</code> 加载后续的 <code>class</code> 了。</p><h3>Dex 重排方案</h3><p>除了在运行时加载 <code>dex</code>,我们也可以尝试在编译时将增量的 <code>dex</code> 打包到 <code>apk</code> 中。</p><p><code>gradle</code> 中对应的 <code>task</code> 都有对应的构建缓存,如果我们增量的 <code>dex</code> 放置在一个随机目录中,后续的 <code>task</code> 例如 <code>package</code>,<code>assemble</code> 等检测输入产物没有变化的情况下,是会直接走增量构建缓存的,也就不会再执行了。而我们期望我们增量的 <code>dex</code> 被打进 <code>apk</code> 中,后续的 <code>package</code> 等 <code>task</code> 必须要被执行。</p><p>这种情况下,构建产物的目录就比较有讲究了,我们可以取个巧,在之前 <code>dexMeger</code> 全量产物输出的目录下,增加一个 <code>incremental</code> 文件夹,专门做增量产物的 <code>dexMeger</code>,同样的每次增量的产物在该文件目录下按照 <code>index</code> 递增,这样确保每次增量 <code>dexMerge</code> 的产物没有冲突。</p><p><img src="/img/remote/1460000044234863" alt="img" title="img"></p><p>打包到 <code>apk</code> 中的 <code>dex</code> 同样也是会按照 <code>dex</code> 的排列顺序加载执行,因此我们需要将新增的 <code>dex</code> 在编译时就排列在 <code>apk</code> 的最前面。 <code>apk</code> 中 <code>dex</code> 的排序是在 <code>package</code> 任务中去执行的,因此我们需要尝试去 <code>hook</code> <code>package</code> 的关键路径,将我们新增的 <code>dex</code> 排在 <code>Apk</code> 内 <code>dex</code> 数组最前面。</p><h4>Android Package 流程 hook</h4><p><code>Android package</code> 负责将之前打包流程中的所有产物汇总打包到最终对外输出的 <code>apk</code> 产物里,<code>dex</code> 自然也不例外。<code>Android package</code> 会结合产物的变化对 <code>apk</code> 中发生变更的文件做更改,将 <code>apk</code> 中对比 <code>CHANGED</code> 和 <code> REMOVED</code> 的文件删除,然后将构建产物中 <code>ADDED</code> 和 <code>CHANGED</code> 的产物重新添加到 <code>apk</code> 中去。</p><pre><code class="java">public void updateFiles() throws IOException {
// Calculate packagedFileUpdates
List<PackagedFileUpdate> packagedFileUpdates = new ArrayList<>();
// dex 文件的变更
packagedFileUpdates.addAll(mDexRenamer.update(mChangedDexFiles));
...
deleteFiles(packagedFileUpdates);
...
addFiles(packagedFileUpdates);
}
private void deleteFiles(@NonNull Collection<PackagedFileUpdate> updates) throws IOException {
// 当前 CHANGED REMOVED 状态的文件 先移除apk
Predicate<PackagedFileUpdate> deletePredicate =
mApkCreatorType == ApkCreatorType.APK_FLINGER
? (p) -> p.getStatus() == REMOVED || p.getStatus() == CHANGED
: (p) -> p.getStatus() == REMOVED;
...
for (String deletedPath : deletedPaths) {
getApkCreator().deleteFile(deletedPath);
}
}
private void addFiles(@NonNull Collection<PackagedFileUpdate> updates) throws IOException {
// NEW CHANGED 状态的文件 添加进apk
Predicate<PackagedFileUpdate> isNewOrChanged =
pfu -> pfu.getStatus() == FileStatus.NEW || pfu.getStatus() == CHANGED;
...
for (File arch : archives) {
getApkCreator().writeZip(arch, pathNameMap::get, name -> !names.contains(name));
}
}</code></pre><p>文件关系则通过 <code>DexIncrementalRenameManager</code> 来维护,<code>DexIncrementalRenameManager</code> 每次会先去 <code>dex-renamer-state.txt</code> 去加载当前的 <code>dex mapping</code> 关系,结合变更的 <code>dex</code> 去对 <code>apk</code> 中文件做更改,同时每次排序完成后会将新的 <code>dex mapping</code> 更新在 <code>dex-renamer-state.txt</code> 文件中。</p><p><img src="/img/remote/1460000044234864" alt="img" title="img"></p><p>我们这边参考原来的 <code>mapping</code> 文件,在每次编译时,将构建产物中的 <code>dex</code> 路径和该 <code>dex</code> 对应 <code>apk</code> 中的实际 <code>dex</code> 的 <code>path</code> <code>classesX.dex</code> 关联起来做好 <code>mapping</code> ,然后存在单独记录的<code>dex_mapping</code>文件里。</p><p><img src="/img/remote/1460000044234865" alt="img" title="img"></p><p>每次增量编译有新 <code>merge</code> 的 <code>dex</code> 时,先将增量的 <code>dex</code> 按照 <code>classes.dex</code>,<code>classes2.dex</code>... 的顺序排列,然后将 <code>dex-mapping</code> 中的构建产物和 <code>apk</code> 中 <code>dex</code> 路径的关系加载到内存中,按照原有的顺序排列在增量的 <code>dex</code> 后面,最后通过 <code>hook</code> <code>package</code> 流程将变化的内容同步更新到 <code>apk</code> 文件中。</p><p>整体流程如下图:</p><p><img src="/img/remote/1460000044234866" alt="img" title="img"></p><p>在 <code>apk</code> 更新完成后,将最新的的 <code>dex</code> 和 <code>apk</code> 中 <code>dex</code> 路径的 <code>mapping</code> 关系重新写到 <code>dex_mapping</code> 文件记录最新的的 <code>dex</code> 和 <code>apk path</code> 的关系。为了避免每次 <code>dex</code> 全部参与重排,可以在 <code>classes.dex</code> 和 <code>classesN.dex</code> 中预留一定数量的空位,避免每次所有 <code>dex</code> 重排。</p><p>实测 <code>package</code> 会有部分耗时增加,总体应该在 1s 以内,<code>mus</code> 整体 <code>dexMerge</code> 耗时由 35-40 s 缩减到3 s 左右。</p><p>目前该增量构建组件两种方案都支持,可以根据开关配置,要注意的点是热更的方案可能涉及到<code>Application</code>的改造。</p><h2>优化效果</h2><p>经过上述方案的优化,实测在 <code>mus</code> 中理想情况下更改子模块中一行最简单的 <code>kotlin</code> 类中的一行代码 <code>task</code> 总耗时(不包含 <code>configure</code> )最快约 10s,实际开发情况来看基本在 20-40s 之间。这部分耗时主要是实际开发改动的 <code>class</code> 和模块会多一些,同时包含了<code>configure</code> 的耗时,这部分时间目前是无法避免的。同时也包含 <code>class</code> 编译和 <code>kapt</code> 等 <code>task</code> 一起的耗时,也会受到设备的 <code>cpu</code> ,实时内存等影响。</p><p><img src="/img/remote/1460000044234867" alt="img" title="img"></p><p>以上数据基于个人电脑,2.3 GHz 四核 Intel Core i7,32 GB 3733 MHz LPDDR4X,不同设备跑出的数据会有部分差异,但整体优化效果还是很明显的。</p><h2>总结</h2><p>结合上述的优化方案,增量构建速度整体在一个比较低的水平,当然例如kotlin编译,kapt,增量的判断等还有进一步的优化空间,期待后续和其他 <code>task</code> 的进一步优化完成时继续分享。</p><pre><code>> 本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com! </code></pre>
社交直播多级缓存一致性解决方案-缓存管道
https://segmentfault.com/a/1190000044216846
2023-09-14T10:57:20+08:00
2023-09-14T10:57:20+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:有内鬼</blockquote><h2>背景介绍</h2><p>1.2021年开始,社交直播活动中台因为需要支持的产品越来越多,优化过程中发现对于很多读场景来说中心缓存的读取已经成为了性能瓶颈,所以大量业务场景<br>开始采取二级缓存方案,将原来的中心 memcache 作为二级缓存,采用 guava、local memcache 作为一级缓存,来减少网络 IO、提升链路性能。</p><p>2.活动中台在发展过程中,因为需要支持多个产品,产品业务方的业务需求差异越来越大,统一运营后台已经无法满足业务方需求,于是决定将运营后台各业务线独立<br>应用开发维护,不再在领域服务中维护运营后台代码,并且拆离运营后台后也有利于领域服务稳定,但是这样就带来了不同应用清理缓存的问题。</p><p>3.常见的解决思路都是通过广播数据变更的思路去解决本地缓存更新的问题,如订阅数据库的 binlog ,基于注册中心响应方式。</p><p>首先我们决定采用的是订阅数据库表变更 binlog 广播消息的方案,当相关表发生变更后,通知所有相关领域服务,然后清理本地缓存。该种方案在实际使用一段时间后,<br>出现了一些问题:</p><p><code>①</code>运行层面上执行链路隐蔽,当出现缓存不一致问题时,很难快速定位到哪台实例的缓存是错误的。</p><p><code>②</code>代码层面上入口隐蔽,除了开发者团队其他成员无法快速得知相关缓存清理链路,导致维护成本越来越高。</p><p><code>③</code>运营后台随着不同业务独立开发后,各业务开发人员并不想再在运营后台工程中额外花费时间编写清理中心缓存的代码。</p><p>4.通过订阅数据库 binlog 方式的不良体验后,我们决定寻找更优雅的解决方案。首先调研了市面上的一些开源方案,发现都不尽满意:</p><p><code>①</code>中心缓存大多只支持 redis,并且依赖并不可靠的 pub / sub 不够健壮,并且团队使用的中心缓存是 memcache</p><p><code>②</code>框架年久失修、实现方式不优雅、不方便使用,模拟spring-cache的注解形式,同样会造成链路隐蔽的问题。</p><p>综上所述,团队决定自行开发一款能够应用于多级缓存一致性场景的框架,对这款框架的目标:</p><p><code>①</code>能够清晰的让团队成员了解所有缓存清理链路和多级缓存的使用情况,并且框架要足够简单易用,不会对团队成员使用造成困扰额外增加开发成本。</p><p><code>②</code>框架要足够健壮,首先要有异常重试机制,其次清理缓存的执行链路都要记录,便于排查问题。最后要能够支持更加复杂的业务场景,不限于只能支持二级缓存。</p><p><code>③</code>良好适配团队的技术栈。</p><p><img src="/img/remote/1460000044216848" alt="" title=""></p><h2>详细设计</h2><p>1.模型抽象</p><p>以一个最简单的二级缓存清理为例,当配置发生变更需要清理缓存时,需要先清理二级缓存即中心缓存,然后再清理一级缓存即本地缓存,整体链路可以抽象为一个单向链表。</p><p><img src="/img/remote/1460000044216849" alt="" title=""></p><p>一棵树在极端情况下会退化成一个链表,我们反向思考就代表我们可以将上面的单向链表复杂为一棵树,从而可以支持更加复杂的缓存清理场景。一个缓存清理事件不再只<br>清理一个中心缓存和一个本地缓存,层级也不再最多只能支持两层,理论上可以支持无数层。所以在缓存管道中,我们把缓存清理链路抽象为树结构,缓存清理事件作为树的<br>根节点,事件源下面是缓存清理链路的叶子节点,每条路径又可以看做一个单向链表。</p><p><img src="/img/remote/1460000044216850" alt="" title=""></p><p>2.缓存管道应用结构</p><p>缓存管道应用分为两大模块:节点发现模块、事件执行模块</p><p><code>①</code>节点发现模块:缓存管道通过监听固定zk路径下的变化事件,来维护执行缓存清理的节点信息,供执行缓存清理事件时使用。为了保证一致性,还会每隔一段时间全量<br>比对节点信息,保障节点信息的正确。</p><p><code>②</code>事件执行模块:当缓存管道接收到缓存清理事件后,会根据接收事件的编码,获取到由开发人员编排的缓存清理节点链路,根据链路结构通过长链接同步执行节点清理<br>缓存操作,失败后不会 fast-fail,会最多进行3次重试,完全失败后存入失败流水,供后续调度任务扫描继续重试。</p><p><img src="/img/remote/1460000044216851" alt="" title=""></p><p>3.执行节点</p><p><code>①</code>节点订阅与发现:引入缓存管道提供的执行器sdk包的领域服务,在启动过程中会向固定的 zookeeper 地址写入节点信息(所属事件源编码、ip、执行节点编码)。<br>缓存管道监听到该路径下的变化后,会在缓存管道的发现中心对节点信息进行维护,供后续清理缓存时调用。</p><p><code>②</code>节点调用与执行:缓存管道通过长链接与执行节点进行通信同步获得调用结果。调用节点分为两种策略,所有选一对应中心缓存的清理,在所有提供清理中心缓存清理功<br>能的节点中选取一个进行调用即可。第二种策略为选取所有对应本地缓存的清理,本地缓存的清理需要调用所有提供清理本地缓存的节点。</p><p><img src="/img/remote/1460000044216852" alt="" title=""></p><p>4.接入方式</p><p>缓存管道提供了两个 sdk,对应事件发送方和缓存清理执行方,让开发人员方便接入。</p><p><code>①</code>事件发送 sdk 一般接入方为缓存清理事件发起者,例如运营后台。当配置发生变更想要清理缓存时,直接调用提供的sdk即可产生缓存清理事件,然后由缓存管道去执行。</p><p><code>②</code>缓存清理执行 sdk 一般接入方为需要执行缓存清理的服务,即领域服务,他们在运行过程中会读缓存,当发生配置变更时,他们需要清理自己的缓存信息。</p><p>5.缓存事件节点结构配置</p><p>每个缓存清理事件都有自己唯一的事件编码,事件编码下就是不同缓存清理节点的节点编码结构关系。我们为这种关系维护提供了运营后台,开发人员可以在完成接入后在运营<br>后台自行编排缓存清理执行链路。这样所有开发人员也可在运营后台完全直观的看到所有多级缓存的缓存清理链路,不再隐藏在代码中。</p><p><img src="/img/remote/1460000044216853" alt="" title=""></p><h2>缓存管道全景图</h2><p><img src="/img/remote/1460000044216855" alt="" title=""></p><h2>总结</h2><p>社交直播缓存管道上线快1年时间,从派对房选取一个小业务作为试点开始,如今已经大规模应用在了社交直播活动业务中,很关键的支持了活动扩展点组件、活动交易平台的发展。<br>中间也经历过出海改造,海外配合降本部署方式从一个产品一个集群改造为一个机房一个集群。后续希望能够将该系统推广到更多的业务中去实践,让它继续发展。</p><blockquote>云音乐社交直播活动中台技术团队,主要负责社交直播相关产品的活动、增值、营收类业务研发,为 Look 直播、声波、心遇、HeartUp 等相关产品提供一站式的活动中台解决方案,重点围绕着社交、直播场景营收增值活动架构体系中台化建设为方向。欢迎有兴趣的同学一起交流。</blockquote>
社交直播游戏场景前端解决方案专栏(三): 通用资源管理器
https://segmentfault.com/a/1190000044194414
2023-09-08T10:02:58+08:00
2023-09-08T10:02:58+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:Gisercyw</blockquote><h2>背景</h2><p>应用程序包含两个部分,代码和资源,资源通常包括配置文件、图标、图片、字体等,他们都直接影响到应用程序的包大小并且一定程度会影响应用程序的运行速度。在社交直播业务开发中不难发现,以下的两类场景对资源管理的诉求会相对强烈:</p><ol><li>在游戏的开发过程中,一般需要使用到大量的图片、音频等资源来丰富整个游戏内容,而大量的资源就会带来管理上的困难, 一个好的资源管理也会为以后性能优化提供很大的帮助。</li><li>运营活动需要使用资源管理方式并配合浏览器缓存来完成活动资源的预加载/预请求, 以提升页面性能与用户体验。</li></ol><p>基于上面两个场景的目的,我们需要一个通用的资源管理方案,让我们在游戏或者活动开发中,无需关心资源加载的细节,只需要指定加载的资源,并且在对应的逻辑位置中添加相应的执行加载代码即可完成对项目资源的管理。</p><h2>调研</h2><p>游戏框架通常具有较完备的资源管理方案,而这些资源管理方案具备下面共性和功能:</p><ol><li>完善的资源加载基本机制,比如加载资源、查找资源、销毁资源、缓存资源</li><li>多资源配置文件管理与分组</li><li>支持资源进程状态监视</li><li>资源模块化</li><li>支持预加载/预请求</li><li>支持自定义资源处理,这样能够让加载更具有灵活性</li></ol><p>这里我分为两类管理器,一类是资源预加载库,一类是游戏的资源管理库,并根据资源管理具备的功能点对比下目前已有的资源管理方案的特点。</p><p><img src="/img/remote/1460000044194416" alt="research" title="research"></p><p>在调研完这些方案后,我认为上述方案并不完全适合我们,我们需要的是能够覆盖我们游戏与活动业务开发更为通用的资源管理方案,不会与任何游戏引擎绑定,这是与其他方案最本质的不同。其次在设计上需要考虑更多的是性能问题,采用插拔式的代码组织方式,在保证主包体积稳定的基础上,通过插件扩展特定场景的功能需求,例如将预请求功能可以作为核心功能,而面向各个场景的资源转换、缓存功能可以作为独立的插件。具体的不同如下:</p><ol><li>相对于游戏资源管理,大部分的游戏资源管理更多的目的是为其游戏引擎提供开箱即用的资源管理工作,跟游戏引擎耦合较深;另外虽然大型游戏引擎具有完备的资源管理体系,但是在预请求等场景下并不支持;</li><li>相对于这些预加载库,其作用主要是资源加载,而资源管理器的定位不只是资源的加载器,还包括资源管理,缓存,解析,转换等功能,并且在资源优先级等方面都做了完整的定义。另外在经过团队的测试后,发现 resource-loader 与 PxLoader 这类预加载库,在音视频资源的处理与加载上会存在一些兼容性问题,不支持 SSR/SSG 等服务端渲染等。</li></ol><h2>整体设计</h2><p>针对业务开发中的核心场景,在保持资源管理核心模块基础上,通过插件化架构,设计出资源管理器的整个体系,如下图所示。</p><p><img src="/img/remote/1460000044194417" alt="overall design" title="overall design"></p><h3>依赖能力</h3><p>资源管理器主要依赖 Extension 模块的注册能力和 WebWorker 的多线程能力。</p><p>资源解析与转换是一项耗时的操作,特别是在需要大量资源的游戏场景中,如果只是并发的加载、解析大量的资源,由于 Javascript 单线程的原因,会容易产生卡顿现象,导致页面无法及时响应,而 Web Worker 使得网页中进行多线程编程成为可能。当主线程在处理界面事件时,Worker 可以在后台运行,帮你处理大量的资源加载、解析、转换、缓存工作,当完成这些操作后,将加载结果或者缓存数据返回给主线程,由主线程更新UI。资源管理器内置了 WebWorker 来解析、加载资源,每种类型资源的处理都可以通过开启 Worker 通道来完成,默认是开启的,同时也提供了开启参数能够覆盖默认配置,需要注意的是并非所有的环境都支持 Workers,在一些场景下设置不开启可能更合适。如下所示,以处理 Image 资源转换 Buffer 为例,通过指定资源转换脚本的 URI 来执行 Worker 线程。</p><pre><code class="js">import WorkController from 'music/WorkController';
const MAX_WORKER_NUM = navigator.hardwareConcurrency || 6;
const loadBufferImageCode = `
async function loadBufferImage(url) {
const result = await fetch(url);
if (!result.ok)
{
throw new Error('failed to load');
}
const imageBuffer = await result.arrayBuffer();
return imageBuffer;
}
onmessage = async (e) =>
{
const {
data: {
uuid,
id,
}
} = e
try
{
const bufferImage = await loadBufferImage(e.data.data[0]);
postMessage({
data: bufferImage,
uuid,
id,
}, [bufferImage]);
}
catch(error)
{
postMessage({
error,
uuid,
id,
});
}
};
`;
let worker = WorkController.workerPool.pop();
if (!worker && WorkController.WorkersNumber < MAX_WORKER_NUM) {
const workerURL = URL.createObjectURL(
new Blob([loadBufferImageCode], { type: 'application/javascript' })
);
WorkController.WorkersNumber++;
worker = new Worker(workerURL);
worker.addEventListener('message', (event: MessageEvent) => {
WorkController.complete(event.data);
WorkController.next();
});
}</code></pre><p>插件注册能力是资源管理器的一个基本能力,方式是主功能通过主包引入,其他功能通过插件的形式按需引入,既能够保证主包的稳定,又能够减小整个包体积。资源管理器的核心功能是资源预加载,而针对特定类型资源的解析、缓存、转换则是通过对应插件来完成,插件模块的主要方法类型定义如下所示,提供了插件处理的基本功能。</p><pre><code class="js">declare const ExtensionModule: {
/**
* 移除插件
*/
remove(...extensions: Array<ExtensionOptionType>): any;
/**
* 注册插件
*/
add(...extensions: Array<ExtensionOptionType>): any;
/**
* 添加/删除扩展时的处理功能
*/
registerHandler(type: ExtensionType, onAdd: ExtensionHandler, onRemove: ExtensionHandler): any;
/**
* 处理插件列表
*/
handleExtensions(type: ExtensionType, list: any[]): any;
};</code></pre><h3>核心模块</h3><p>对于特定类型的资源,在资源管理器底层会经过资源检测、 资源映射、加载解析、资源缓存的流程,每个环节都是独立的,其中部分环节并不是必需的,因此不是每个资源都会完全走完这几步,例如如果是预请求资源,则不需要缓存,因为预请求利用的是浏览器缓存,对于需要使用的功能,可以通过插件或者参数设置开启。</p><p><img src="/img/remote/1460000044194418" alt="core-module" title="core-module"></p><h3>外部接口</h3><p>外部接口主要提供了两类接口,一类单独的资源接口(Resource),一类是缓存接口(Cache)。而资源为了满足模块化的场景,我们又将其分为 Resource 与 Bundle ,Resource 提供全局资源的操作,Bundle 提供模块化资源的操作。在这些简洁易用的接口基础上,我们可以轻松完成资源的预请求、资源预加载、手动加载与自动加载,资源缓存处理等操作。</p><h2>功能使用</h2><p>下面选取几种业务中常见的的场景来介绍资源管理器的实际使用方式,可以满足小游戏或者活动开发中资源加载与转换的需求。</p><h3>预加载</h3><p>预加载是一种浏览器机制,使用浏览器空闲时间来预先下载/加载用户接下来很可能会浏览的页面/资源,当用户访问某个预加载的链接时,如果从缓存命中,页面就得以快速呈现。预加载一般会配合loading或者加载页来呈现,合理的有效加载交互设计可以减少用户焦虑,减轻用户等待的压力,而每个阶段预加载资源的分配能够有效降低页面访问速度,减少页面切换时的闪烁问题,进而达到提升用户体验的目的。</p><p><img src="/img/remote/1460000044194419" alt="preload-example" title="preload-example"></p><p>资源管理器的预加载功能可以通过简洁的api来实现, 如下所示:</p><pre><code class="js">const loadAssets = [
{
src: 'https://someurl.png',
type: 'IMAGE', // 图片
},
{
src: 'https://someurl.mp3',
type: 'AUDIO', // 音频资源
},
{
src: 'http://someurl.mp4',
type: 'VIDEO', // 视频资源
},
{
src: 'https://someurl.ttf',
type: 'FONT', // 字体资源
subType: 'ttf',
},
{
src: 'https://someurl.json',
type: 'JSON', // JSON资源
}
];
const LoadingPage = () => {
const [progress, setProgress] = React.useState(0);
React.useEffect(() => {
const load = async () => {
const res = await Resource.loadResource(loadAssets, (progress) => {
setProgress(Number(progress.toString().match(/^\d+(?:\.\d{0,2})?/)) * 100);
});
};
load();
}, []);
return (
<div>资源加载进度:{progress}%</div>
);
};</code></pre><h3>资源模块化</h3><p>在游戏开发中,我们会需要将资源按照不同的功能和场景划分与使用,如下图所示,资源管理器中可以将图片,脚本,多媒体等资源指定为多个 Bundle,其中每种类型资源还可以根据页面划分成多个 Bundle,比如图片可以根据首屏图片、弹窗与浮层图片、非首屏图片分成多个 Bundle,然后在游戏运行过程中,按照需求去加载不同的 Bundle,以减少启动时需要加载的资源数量,从而减少首次下载和加载游戏时所需的时间。</p><p><img src="/img/remote/1460000044194420" alt="resource-modular" title="resource-modular"></p><pre><code class="js">
// 添加
Resource.addBundle('first-scene', {
mainBg: 'backgroundA.png',
avatar: 'avatarA.png',
font: 'fontA.ttf',
});
// 添加
Resource.addBundle('next-scene', {
mainBg: 'backgroundB.png',
avatar: 'avatarB.png',
font: 'fontB.ttf',
});
// 加载
const firstSceneResource = await Resource.loadBundle('first-scene');
// 加载
const nextSceneResource = await Resource.loadBundle('next-screen');</code></pre><h3>资源转换</h3><p>以图片类型资源转换为例,首先要启用图片转换插件,主要通过以下方式注册插件</p><pre><code class="js">import ResourceImagePlugin from 'resource-image-plugin';
Resource.addPlugin(ResourceImagePlugin)</code></pre><p>然后通过 formatType 参数指定转换类型,resource-image-plugin可以支持以下类型转换: <strong>Buffer、Blob、BitMap、PixiTexture</strong><br><strong>png转Bitmap</strong></p><pre><code class="js">const res = await Resource.loadResource(
{
src: 'https://p5.music.126.net/obj/wo3DlcOGw6DClTvDisK1/24086412116/de58/ecc0/3ef8/d0ce5485ed549eeb0e77b8a2e54bb4c4.png',
formatType: 'Bitmap',
}
);</code></pre><p><strong>png转Pixi Texture</strong></p><pre><code class="js">const res = await Resource.loadResource(
{
src: 'https://p5.music.126.net/obj/wo3DlcOGw6DClTvDisK1/24086412116/de58/ecc0/3ef8/d0ce5485ed549eeb0e77b8a2e54bb4c4.png',
formatType: 'Texture',
}
);</code></pre><h2>总结</h2><p>目前资源管理器已经社交直播多个业务中落地,其不仅为 Alice.js 底层提供开箱即用的资源管理能力,同时为社交直播运营活动提供了预加载的手段,未来还会针对内部其他场景适配与支持,例如支持3D资源/模型、智能化加载等。<br>本文主要分析了资源管理的现状与存在问题,在业务游戏化背景下,探索了符合社交直播业务发展的资源管理解决方案,并介绍了不同场景下的使用方式,如果您对此内容感兴趣,可以评论交流。</p><h2>参考资料</h2><ul><li>Cocos:<a href="https://link.segmentfault.com/?enc=z27SQcxRDReDYpukU0IPlQ%3D%3D.Lmo%2FkOkYmliaHCFQ6nGCfuUmRA3uGTPMKSfpYhLSSiDhiq64DoUxDG19SLp3b9G%2B" rel="nofollow">https://github.com/cocos/cocos-engine</a></li><li>Pixi.js:<a href="https://link.segmentfault.com/?enc=aeV172jYT%2BrxCjmZvGH3bQ%3D%3D.M1XCCnltND0hKDpZO8FHmGa9tyS9Xmcg0qFcdf4I%2BnywUCJQC0MK7Ccs68c7qQXA" rel="nofollow">https://github.com/pixijs/pixijs</a></li></ul><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
云音乐ICASSP2023最新成果
https://segmentfault.com/a/1190000044186751
2023-09-06T11:11:21+08:00
2023-09-06T11:11:21+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:成益</blockquote><p><strong>《TG-CRITIC: A TIMBRE-GUIDED MODEL FOR REFERENCE-INDEPENDENTSINGING EVALUATION》-以音色作为指导的无参考歌唱评价算法</strong></p><p><strong>论文作者</strong>:孙校珩、高月洁、林瀚峣(共同一作)、刘华平,均来自云音乐音视频实验室。</p><p><strong>论文下载</strong>:<a href="https://link.segmentfault.com/?enc=enC%2FQGvJZHImbs8KvsEoyg%3D%3D.oGtO8Tk6O4Ospc33MjNIJGtjsOFRJ7e4zv1PCTILEfGxaGl4OlVWtYy9wfOgICfT" rel="nofollow">https://arxiv.org/abs/2305.09127</a></p><p><img src="/img/remote/1460000044186753" alt="" title=""></p><p><strong>论文简介</strong>:本文提出一种歌唱评价算法,可以仅依靠一段演唱音频作为算法输入,判断歌手演唱水平</p><ul><li>算法输入:演唱音频(非rap类)</li><li>算法输出:好中差三分类/0-1的连续分数</li><li>评价尺度:完整音频/一首歌内不同片段的分数变化</li></ul><p>对于人类专家来说,即使听到一首完全陌生的歌曲,也可以从中判断出歌手的演唱水平。在歌唱评价算法中,这类不需要已知旋律线或已有演唱音频作为对比模板的评价方法,称为“无参考”歌唱评价。我们可以用更熟悉的一个词“开口跪”来描述这种感受。</p><p>人声的音色是影响歌唱感知的重要因素。我们首创的提出了一个音色为指导的歌唱评价模型:TG-Critic。实验结果表明,本模型在大多数情况下都优于现有的最先进模型。</p><p>模型的设计过程中引入了三个主要创新点: 1.首次在模型中显式引入音色信息辅助歌声评价 2.迁移高分辨率网络结构处理声谱特征 3.提出循环自动数据标注降低人工成本</p><p>作为目前准确率最高的端到端的算法,歌唱评价将不再依赖人力手工准备模板物料,且歌手不再需要模仿模板以获取高分,更鼓励歌手的个性化演绎。相比卡拉ok中的传统歌唱评价,有着更加丰富的使用场景,如歌曲分发、优质歌手挖掘、声音社交等。</p><p><strong>《TrOMR:Transformer-Based Polyphonic Optical Music Recognition》-基于Transformer的复调图像乐谱识别算法</strong></p><p><strong>论文作者</strong>:李宜烜、刘华平、金强、蔡苗苗、李鹏,均来自网易云音乐音视频实验室。</p><p><strong>论文下载</strong>:<a href="https://link.segmentfault.com/?enc=Dgo3M2To8%2BTbNG9G8hiPpQ%3D%3D.XeOEn6ENwIP1Leg6CK78ZrwbhJa%2BV1nCzjrkcsUDGk7KIWMnDTmcEdkzi%2BfX9cw9" rel="nofollow">https://arxiv.org/pdf/2308.09370.pdf</a></p><p><img src="/img/remote/1460000044186754" alt="" title=""></p><p><strong>论文简介</strong>:OMR(图像乐谱识别)和OCR(图像文字识别 )对应,目的在于识别图像中的乐谱。随着深度学习方法的应用,OCR近年得到了长足的进步,而OMR却始终处于研究应用的初级阶段。针对较复杂的乐谱图像,主流的做法更多采用基于目标检测的方式进行乐谱识别,整体算法流程相对繁琐,数据集制作成本高,泛化性较差,对于复调复杂乐谱(Polyphonic)识别精度差。</p><p>为了解决以上问题,本论文提出了端到端图像乐谱识别算法,主要创新如下:</p><p>1.首次将Transformer引入到乐谱识别任务中,提出TrOMR网络结构,该结构可以预测更长的音符序列,提升识别准确率。</p><p>2.将乐谱的标注维度从原来的音符节奏+音符时值,拆分为:乐谱符号全局表征+乐谱符号局部表征+音符音高。这样的拆分方式更利于机器理解和学习。</p><p>3.现有的OMR数据集通常使用图像处理方法来模拟真实环境,与实际应用场景存在差异。本文精心设计了一套乐谱图片拍摄的方案,使用手机作为拍照工具,模仿最真实的拍照场景,对明、暗光场景的纸质乐谱进行拍照,以及对显示在显示屏上的乐谱进行拍照。收集了大量的真实数据,希望可以更好的服务于真实场景。</p><p>实验结果证明,当前方案对于音符密集的乐谱有着更高的识别准确率。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
网易云音乐 Tango 低代码引擎正式开源!
https://segmentfault.com/a/1190000044168765
2023-08-31T13:45:25+08:00
2023-08-31T13:45:25+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
4
<h2>📝 Tango 简介</h2><p>Tango 是一个用于快速构建低代码平台的低代码设计器框架,借助 Tango 只需要数行代码就可以完成一个基本的低代码平台前端系统的搭建。Tango 低代码设计器直接读取前端项目的源代码,并以源代码为中心,执行和渲染前端视图,并为用户提供低代码可视化搭建能力,用户的搭建操作会转为对源代码的修改。借助于 Tango 构建的低代码工具或平台,可以实现 源码进,源码出的效果,无缝与企业内部现有的研发体系进行集成。</p><p><img src="/img/remote/1460000044168767" alt="Tango 低代码引擎开发效果" title="Tango 低代码引擎开发效果"></p><p>如上图所示,Tango 低代码引擎支持可视化视图与源码双向同步,双向互转,为开发者提供 LowCode+ ProCode 无缝衔接的开发体验。</p><h3>✨ 核心特性</h3><ul><li>经历网易云音乐内网生产环境的实际检验,可灵活集成应用于低代码平台,本地开发工具等</li><li>基于源码 AST 驱动,无私有 DSL 和协议</li><li>提供实时出码能力,支持源码进,源码出</li><li>开箱即用的前端低代码设计器,提供灵活易用的设计器 React 组件</li><li>使用 TypeScript 开发,提供完整的类型定义文件</li></ul><h3>🏗️ 基于源码的低代码搭建方案</h3><p>Tango 低代码引擎不依赖私有搭建协议和 DSL,而是直接使用源代码驱动,引擎内部将源码转为 AST,用户的所有的搭建操作转为对 AST 的遍历和修改,进而将 AST 重新生成为代码,将代码同步给在线沙箱执行。与传统的 <a href="https://link.segmentfault.com/?enc=Y7AB2aQf7HFPJtFTiEUQTQ%3D%3D.wpNrUPLBZdjhWS6OThaKeuVZt9FL6ow0CXYHRIM7hDO29Mtlg2ic5nPNT00c8XTQt1y2abZ%2B5Q0eOPpRpV69Fw%3D%3D" rel="nofollow">基于 Schema 驱动的低代码方案</a> 相比,不受私有 DSL 和协议的限制,能够完美的实现低代码搭建与源码开发的无缝集成。</p><p><img src="https://p5.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/13140534982/ee2e/f42c/cc9a/184e2918a011b57d46e6c64a2722fa44.png" /></p><h3>📄 源码进,源码出</h3><p>由于引擎内核完全基于源代码驱动实现,Tango 低代码引擎能够实现源代码进,源代码出的可视化搭建能力,不提供任何私有的中间产物。如果公司内部已经有了一套完善的研发体系(代码托管、构建、部署、CDN),那么可以直接使用 Tango 低代码引擎与现有的服务集成构建低代码开发平台。</p><p><img src="/img/remote/1460000044168768" alt="code in, code out" title="code in, code out"></p><h3>🏆 产品优势</h3><p>与基于私有 Schema 的低代码搭建方案相比,Tango 低代码引擎具有如下优势:</p><table><thead><tr><th>对比项</th><th>基于 Schema 的低代码搭建方案</th><th>Tango(基于源码 AST 转换)</th></tr></thead><tbody><tr><td>适用场景</td><td>面向特定的垂直搭建场景,例如表单,营销页面等</td><td>🔥 面面向以源码为中心的应用搭建场景</td></tr><tr><td>语言能力</td><td>依赖私有协议扩展,不灵活,且难以与编程语言能力对齐</td><td>🔥 直接基于 JavaScript 语言,可以使用所有的语言特性,不存在扩展性问题</td></tr><tr><td>开发能力</td><td>LowCode</td><td>🔥 LowCode + ProCode</td></tr><tr><td>源码导出</td><td>以 Schema 为中心,单向出码,不可逆</td><td>🔥 以源码为中心,双向转码</td></tr><tr><td>自定义依赖</td><td>需要根据私有协议扩展封装,定制成本高</td><td>🔥 原有组件可以无缝低成本接入</td></tr><tr><td>集成研发设施</td><td>定制成本高,需要额外定制</td><td>🔥 低成本接入,可以直接复用原有的部署发布能力</td></tr></tbody></table><h2>📐 技术架构</h2><p>Tango 低代码引擎在实现上进行了分层解藕,使得上层的低代码平台与底层的低代码引擎可以独立开发和维护,快速集成部署。此外,Tango 低代码引擎定义了一套开放的物料生态体系,开发者可以自由的贡献扩展组件配置能力的属性设置器,以及扩展低代码物料的二方三方业务组件。</p><p>具体的技术架构如下图所示:</p><p><img src="/img/remote/1460000044168769" alt="low-code engine" title="low-code engine"></p><h2>⏰ 开源里程碑</h2><p>Tango 低代码引擎是网易云音乐内部低代码平台的核心构件,开源涉及到大量的核心逻辑解藕的工作,这将给我们正常的工作带来大量的额外工作,因此我们计划分阶段推进 Tango 低代码引擎的开源事项。</p><ol><li>今天我们正式发布 Tango 低代码引擎的第一个社区版本,该版本将会包括 Tango 低代码引擎的核心代码库,TangoBoot 应用框架,以及基于 antd v4 适配的低代码组件库。</li><li>我们计划在今年的 <strong>9 月 30 日</strong> 发布低代码引擎的 <strong>1.0 Beta</strong> 版本,该版本将会对核心的实现面向社区场景重构,移除掉我们在云音乐内部的一些兼容代码,并将核心的实现进行重构和优化。</li><li>我们计划在今年的 <strong>10 月 30 日</strong> 发布低代码引擎的 <strong>1.0 RC</strong> 版本,该版本将会保证核心 API 基本稳定,不再发生 BREAKING CHANGE,同时我们将会提供完善翔实的开发指南、部署文档、和演示应用。</li><li><strong>正式版</strong>本我们将在 <strong>2023 年 Q4 结束前</strong> 发布,届时我们会进一步完善我们的开源社区运营机制。</li></ol><p><img src="/img/remote/1460000044168770" alt="milestones" title="milestones"></p><h2>🤝 社区建设</h2><p>我们的开源工作正在积极推进中,可以通过如下的信息了解到我们的最新进展:</p><ul><li>Github 仓库:<a href="https://link.segmentfault.com/?enc=7zj62%2BaCK4QaQhbjQbXMpw%3D%3D.VvqI589c15Mb2uiZD7NkjBZ2MvMKAnSsoi8GzKdpSVGhCVjdLUD9O7E2siALgphn" rel="nofollow">https://github.com/NetEase/tango</a></li><li>文档站点:<a href="https://link.segmentfault.com/?enc=CUuzYGJeWJc1lgMLw9AeHg%3D%3D.j2rEATP3UCAiSta7buq%2BgFMbBco6G4%2BtBzT7%2BPVdfK06tfA5pBNaQVXcry9suyf9" rel="nofollow">https://netease.github.io/tango/</a></li></ul><p>欢迎大家加入到我们的社区中来,一起参与到 Tango 低代码引擎的开源建设中来。有任何问题都可以通过 <a href="https://link.segmentfault.com/?enc=gCkKTnr5tVElDapbpHCLWQ%3D%3D.LJSCv10PTtd0TeEWCKhx7%2BTRbQ3%2FCEFY17RSwgWC1tD9yLTEfDN9a3bYMQdFDeQw" rel="nofollow">Github Issues</a> 反馈给我们,我们会及时跟进处理。</p><h2>💗 致谢</h2><p>感谢网易云音乐公共技术团队,大前端团队,直播技术团队,以及所有参与过 Tango 项目的同学们。</p><p>感谢 CodeSandbox 提供的 <a href="https://link.segmentfault.com/?enc=knA%2FOlifSyPC9XVnoqRLQQ%3D%3D.Tvtned9wUDAA%2BNzCAo%2FH2Fg0aLhQHpkXH8JlH5EQHGk5SO8vxKQiPOAGpux0IwVo" rel="nofollow">Sandpack</a> 项目,为 Tango 提供了强大的基于浏览器的代码构建与执行能力。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
社交直播游戏场景前端解决方案专栏(二):小游戏开发 The React Way
https://segmentfault.com/a/1190000044004586
2023-07-13T10:13:10+08:00
2023-07-13T10:13:10+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:小林</blockquote><h2>前言</h2><p>在系列上一篇文章中,我们介绍了自研 H5 小游戏引擎 Alice.js 的理念与架构设计,以及核心功能的实现。通过结合 React 生态与 WebGL 渲染能力,我们可以让熟悉 React 的开发人员低成本地入门 H5 游戏开发,在复用现有组件资产的同时,提供高性能的游戏画面,实现更复杂的视觉效果。</p><p>在本篇中,我们会结合一个实际的案例,来介绍如何通过 Alice,使用 React 的方式高效开发 H5 小游戏。</p><h2>一、场景构建</h2><p>在游戏开发中,<strong>场景</strong>(Scene)的搭建是十分重要的环节。就像电影中的一个场景一样,游戏场景是一系列游戏元素的集合,表达了游戏世界的一部分内容,也是我们开发时组织游戏内容的中心。</p><p>本节我们将借用 <a href="https://link.segmentfault.com/?enc=T9Vg4TJZsVG%2FmHJgBN%2FaoA%3D%3D.huZBg5Kf7wy1dxgdC%2FvQEMkg7jWH6CmowQpiB1OwQt8wY6uPBe%2Bp742EIf36ouZCnR%2F994RsQ48i4XqjGSjT8ILehRgX7opUUP9EQbHVIdM%3D" rel="nofollow">Cocos Creator</a> 官网的示例,制作一款简单的 2D 平台跳跃类游戏。游戏规则也很简单:</p><ul><li>开始游戏后,在空中随机生成一定数量的冰块,每两块冰之间可能空 1 格或者不空</li><li>企鹅一次可以向右跳 1 格或者 2 格</li><li>企鹅跳到冰块上不会掉下去,跳到空白处就会掉下去</li><li>跳完全部的格子即游戏通关,中途落下则游戏失败</li></ul><p><img src="/img/remote/1460000044004588" alt="小游戏完整预览图" title="小游戏完整预览图"></p><p><em>▲ 背景图素材来自 <a href="https://link.segmentfault.com/?enc=kfIc9EEkRLRgXEjqjVAaJw%3D%3D.PH3KAQKsedoTUujoG6iMIWWgLFq21p8kZ4kpLX4ljHKfoRXBeqoeTf2iJo38aYzriukX71qdoDbzxthnXzpw9w%3D%3D" rel="nofollow">OpenGameArt.org</a></em></p><h3>1.1 用 React 组件组织游戏物件</h3><p>作为熟悉现代前端框架的前端开发,拿到上面这样的一个页面,首先想到的是什么?没错,组件化!天上的云可以是组件,空中的冰块可以是组件,中间的企鹅也可以是组件,它们共同构成了这样的一个游戏场景。<strong>组件化、可复用</strong>是 React 的核心思想,这在 Alice 引擎中自然也适用。</p><p>将上面的场景抽象为组件树后,就是这样的:</p><p><img src="/img/remote/1460000044004589" alt="Scene Graph 示意图" title="Scene Graph 示意图"></p><p>这样的树状结构也称为<strong>场景图</strong>(Scene Graph)。<a href="https://link.segmentfault.com/?enc=s%2Bw81zuKDUWk%2FM4hyF4raw%3D%3D.%2BUs3dG%2FOBwAWp%2BAhtTmEnxOvChdo%2BFJHTYkpJYM7d01klupa9enG0ao08cHgIF21" rel="nofollow">场景图</a>是一种通用的数据结构,通常用于组织 2D/3D 图形场景中节点的逻辑与空间表示。怎么样,是不是和感觉和我们的前端框架有些共通之处呢?</p><p>让我们更进一步,将其拆分为具体的 React 组件。用 JSX 表示出来就是这样的:</p><pre><code class="jsx">// 背景图层
const Background = () => (
<View>
<Image src="assets/bg.png" />
<Image src="assets/cloud.png" />
<Image src="assets/tree.png" />
</View>
);
// 我们的主角小企鹅
const PenguinHero = () => (
<Image src="assets/penguin.png" />
);
// 冰块们
const IceBricks = () => (
<View>
{array.map(() => <Image src="assets/brick.png" />)}
</View>
);
// 组合成一个游戏场景
const Scene = () => (
<View>
<Background />
<PenguinHero />
<IceBricks />
</View>
);</code></pre><p>That's it! 在 Alice 中,创建、组合组件就是这么自然,一切都和你熟悉的一样。</p><h3>1.2 场景渲染与相机控制</h3><p>一个游戏通常由许多场景构成,比如主菜单是一个场景,游戏界面是一个场景,结算界面也是一个场景。那么在 Alice 中,我们要如何控制游戏场景之间的切换呢?</p><p>答案也很简单,React 怎么做,我们就怎么做。你可以 if-else 一把梭:</p><pre><code class="jsx">function Game() {
const [currentScene, setCurrentScene] = useState(SCENE.MAIN_MENU);
// 游戏场景
if (currentScene === SCENE.GAMEPLAY) {
return <Gameplay />;
}
// 结算界面
if (currentScene === SCENE.RESULT) {
return <Result />;
}
// 主菜单
return <MainMenu />;
}</code></pre><p>也可以直接前端路由走起,丰俭由人。因为我们通过 <a href="https://link.segmentfault.com/?enc=6550D6fs%2BZ6LiVxeZYP0qg%3D%3D.FowurOIBuc%2FhYpAJfD49Cgr4381u8Rel5VKsb8zXKZD3arxBkgpZ1NavSxxwA6It" rel="nofollow">react-reconciler</a> 实现了完整的自定义 React renderer(具体可以参考本系列的上一篇专栏),所以我们完全可以复用现有 React 生态中的成熟类库。</p><p>用户需要做的就是控制组件,剩下的交给 Alice 引擎完成:</p><pre><code class="jsx">import { BrowserRouter, Routes, Route } from 'react-router-dom';
function Game() {
return (
<BrowserRouter>
<Routes>
<Route path="main-menu" element={<MainMenu />} />
<Route path="gameplay" element={<Gameplay />} />
<Route path="result" element={<Result />} />
</Routes>
</BrowserRouter>
)
}</code></pre><p>除了场景的构建,在游戏中,相机控制也是一个重要的步骤。游戏中的「相机」概念类似于现实世界中的相机,主要用于捕捉场景画面,控制场景的呈现,如可视范围、投影、缩放等。</p><p><img src="/img/remote/1460000044004590" alt="场景相机示意图" title="场景相机示意图"></p><p>在 Alice 中,相机默认捕捉整个 Stage。不过这里我们需要让相机随着角色跳跃自动向前移动,也就是实现横向卷轴效果。步骤也很简单,将整个游戏场景都包裹在 <code><CameraView /></code> 中,并指定要跟随的元素即可:</p><pre><code class="jsx"><CameraView follow={penguinRef}>
{/* 不管整个场景有多大,我们的企鹅始终都固定在屏幕中间 */}
<Box ref={penguinRef} style={{ position: 'absolute' }}>
<Image src={resources.penguin} />
</Box>
{/* 剩下的地图场景组件…… */}
<Map />
</CameraView></code></pre><p>这里的底层原理是监听目标元素的位置变化,将其位移的相对量补偿到整个场景的容器上,即可实现跟随效果。同时配合剔除(Culling)技术,避免屏幕外不可见的对象浪费渲染与计算资源。这样我们就可以制作出比屏幕尺寸大得多的游戏场景,并让角色在其中自由行动。</p><h3>1.3 结合原生 DOM 编写 UI</h3><p>在游戏开发中,除了游戏场景、角色、动画这类频繁更新的元素之外,还有相对静态的 UI(用户界面)元素,它们共同构成了一个完整的游戏。UI 承载了游戏状态信息的展示,以及接受用户交互的功能,相关的元素包括标签、按钮、滑块、菜单、文本框等。</p><p>其中部分 UI 是静态的,或者很少更新,比如 HUD、跳转按钮、公告、设置页面等。如果是传统游戏引擎,我们可能需要使用引擎提供的 UI 组件将这些界面画出来,比较繁琐。既然我们选择了依托于 React 框架去开发游戏,那是否意味着我们也可以直接使用原生 React DOM 来编写这些 UI 呢?</p><p>答案是肯定的!Alice 支持 canvas 元素和 DOM 元素的混合开发,结合前者的高性能与后者开发速度快的优点。在开发中还可以直接复用现有的 React 组件库,编写 UI 高效快捷。</p><pre><code class="html"><div id="root">
<!-- HUD 叠加在游戏场景的上层 -->
<div className="hud-wrapper">
<p>Write any HTML here</p>
</div>
<!-- 我是分割线,下面就是 canvas -->
<Stage className="game-wrapper">
<Image src="xxx">
<Text>Inside game we are canvas elements!</Text>
</Stage>
</div></code></pre><p>但需要注意,由于渲染顺序的限制,DOM 元素只能出现在 canvas 的上层。</p><p><img src="/img/remote/1460000044004591" alt="Canvas 与 DOM 混合开发示意图" title="Canvas 与 DOM 混合开发示意图"></p><h2>二、节点元素</h2><p>在 Alice 引擎中,<strong>元素</strong>(Element)是游戏场景的基本组成单元,这一点和 HTML 类似。</p><p>在场景中,所有物件都由元素组成,其中包括容器、图片、文字、图形等基础元素,以及帧动画、Lottie、视频、骨骼动画等动效元素。所有元素组成了树状的 Scene Graph。</p><p><!-- 为了方便控制,我们希望所有的元素都派生自一个基类,并由一个统一的容器包裹,即 <code>GameObject</code>。这样的设计为将来的扩展性提供了保证(比如可视化编辑器就可以直接在其中添加操作柄与相关事件)。 --></p><h3>2.1 基础元素</h3><p>在 Alice 中,基础元素包括:</p><ul><li>基础容器 <code>Box</code></li><li>Flex 容器 <code>View</code></li><li>图片 <code>Image</code>(支持精灵图集 Spritesheet)</li><li>文本 <code>Text</code></li><li>图形 <code>Graphics</code></li><li>遮罩 <code>Mask</code></li><li>点九图 <code>NinePatch</code></li></ul><p>使用这些元素构建游戏界面就像编写传统 React 应用一样符合直觉:</p><p><img src="/img/remote/1460000044004592" alt="代码与场景对比示意图" title="代码与场景对比示意图"></p><p>因为这些元素都是规范的 React 组件,循环渲染、条件渲染等功能自然也不在话下:</p><pre><code class="jsx">{/* 放置一排冰块 */}
{map.map((isBrick, index) => (
<Image
key={index}
src={isBrick ? resources.brick : Texture.EMPTY}
style={{
position: 'absolute',
top: 25,
left: -35 + index * BOX_WIDTH,
width: 76,
height: 67,
}} />
))}</code></pre><h3>2.2 动效元素</h3><p>动效是游戏体验中十分核心的一环,适当的动效可以为游戏增色不少。Alice 目前支持添加以下格式的动效:</p><ul><li>序列帧动画 <code>FrameAni</code> & <code>Apng</code></li><li>Lottie 动画 <code>Lottie</code></li><li>普通视频 <code>Video</code></li><li>透明视频 <code>AlphaVideo</code></li><li>骨骼动画 <code>Spine</code> & <code>DragonBones</code></li><li>基于关键帧的过渡动画</li></ul><p>用户可以根据需要,选择不同的动效格式。各种格式的对比大致可以参考下表:</p><table><thead><tr><th> </th><th>序列帧</th><th>Apng</th><th>Lottie</th><th>普通视频</th><th>AlphaVideo</th><th>骨骼动画</th><th>过渡动画</th></tr></thead><tbody><tr><td>视觉还原度</td><td>高</td><td>高</td><td>高</td><td>中: 不支持透明通道</td><td>高</td><td>高</td><td>低: 开发实现</td></tr><tr><td>资源文件大小</td><td>中: 不适合大尺寸动图</td><td>大: 压缩比不高</td><td>小</td><td>中: 看码率</td><td>中</td><td>小</td><td>很小</td></tr><tr><td>JS 体积</td><td>小</td><td>中: 需要引入解码模块</td><td>很大: 需要引入播放库</td><td>很小</td><td>小</td><td>大</td><td>小</td></tr><tr><td>内存占用</td><td>小</td><td>大: 需要额外存储解码帧</td><td>中</td><td>小</td><td>小</td><td>中</td><td>很小</td></tr><tr><td>渲染性能</td><td>很高</td><td>低</td><td>中</td><td>高</td><td>高</td><td>中</td><td>很高</td></tr><tr><td>内容动态替换</td><td>否</td><td>否</td><td>是</td><td>否</td><td>否</td><td>是: 非常灵活</td><td>是</td></tr><tr><td>兼容性</td><td>好</td><td>差: 低端机可能内存不足</td><td>好: 取决于播放库</td><td>好</td><td>中 *WebGL</td><td>好</td><td>很好</td></tr></tbody></table><p>在本系列的后续文章中,我们会介绍是如何将这些动效接入 Alice 引擎的。从我们团队的实践经验来看,在使用了 Spritesheet 格式的情况下,序列帧解析简单、实现方便、渲染性能好。在动效较短时,推荐使用序列帧作为首选动效格式(可以使用 TexturePacker 或者 Free Texture Packer 等工具生成)。</p><p>这里我们使用一个动态的企鹅动画,替换掉之前的静态图:</p><pre><code class="diff">-<Image
- src="assets/penguin.png"
+<FrameAni
+ src="assets/penguin-spritesheet.json"
+ ref={aniRef}
+ loop
+ autoplay
+ onComplete={() => console.log('播放完成')}
style={{
scale: 0.5,
anchor: 0.5,
}} />
+aniRef.current.play()
+aniRef.current.stop()
+aniRef.current.currentFrame
+aniRef.current.totalFrames</code></pre><h2>三、属性与变换</h2><p>现在,我们已经可以通过类似 HTML 的语法组织游戏元素了。那么你可能会想,既然如此,那能不能用类似 CSS 的语法来控制这些元素的样式呢?</p><p>可以!Alice 支持了大部分的基础 CSS 样式和关键帧动画,甚至提供了基于 Flexbox 的动态布局能力。</p><h3>2.1 CSS 样式转换</h3><p>要实现使用 CSS 编写样式,其核心就是将 CSS 的语法转换为底层的 PixiJS 对应属性。比如:</p><ul><li><code>font-size</code> -> <code>PIXI.Text#style.fontSize</code></li><li><code>height</code> -> <code>PIXI.Sprite#height</code></li><li><code>left</code> -> <code>PIXI.DisplayObject#position.x</code></li><li><code>opacity</code> -> <code>PIXI.DisplayObject#alpha</code></li><li><code>background-color</code> -> <code>PIXI.Sprite#tint</code></li></ul><p>为此,我们编写了专门的样式转换器,用户可以使用类似 React Native 的语法直接书写大部分的 CSS,无需额外的学习成本。</p><pre><code class="jsx"><Text
style={{
fontFamily: "Chalkboard, 'Comic Sans MS', sans-serif",
// 以下几种写法等价
// color: 0xf0f8ff,
// color: '#f0f8ff',
// color: 'rgb(240, 248, 255)',
// color: 'hsl(208, 100%, 97%)',
color: 'aliceblue',
fontSize: 30,
fontWeight: 'bold',
fontStyle: 'italic',
}}>
Will be rendered with Canvas2D internally
</Text></code></pre><h3>2.2 Flex 布局系统</h3><p>在传统小游戏引擎中,元素的排布一般使用绝对定位(写死宽度、高度、XY 坐标),或者支持有限的自动布局。既然我们样式属性已经可以用 CSS 来写了,那么元素布局是不是也能用 CSS 的 <a href="https://link.segmentfault.com/?enc=%2BhUTDLVaOClO%2FI6OgcqC%2Bg%3D%3D.hvZ5L34Gtk0GdKDatsoLkfx1SaXhkn1PdXSjW%2F8BlIrWN0hGrf0tI7OWkLIkKdVeeriorsR5VO1AHeGCRbkPqvjniHKxKKS4zVUXSSQpxK0%3D" rel="nofollow">Flexbox 弹性布局</a>那一套呢?</p><p>可以!Alice 底层接入了 React Native 所使用的跨平台高性能 Flex 布局引擎,即 <a href="https://link.segmentfault.com/?enc=AJ2PkBxjQ%2BxxJNs9iYyRWw%3D%3D.GwriNyhvTzbsAjZqZ3ZpH%2Fs%2BOyX20Oxa%2Bmrp2D9mBwE%3D" rel="nofollow">Yoga Layout</a>,并集成在框架中作为可选功能提供。Yoga 引擎提供了完善的 Flex 布局功能,支持 WebAssembly,以及在旧版本浏览器上回退到纯 JS 版本。配合 <code>justifyContent</code>、<code>alignItems</code> 等 CSS 语法,几乎可以完美再现传统 React 应用的开发体验。</p><pre><code class="jsx"><View
name="OuterView"
style={{
width: 500,
height: 500,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'center',
padding: 50,
}}>
<View
name="ProfileCard"
style={{
width: 300,
height: 80,
marginBottom: 30,
flexDirection: 'row',
}}>
<View
style={{
width: 80,
height: 80,
backgroundImage: 'assets/avatar.png',
}} />
<View
style={{
justifyContent: 'space-around',
padding: '10 0 10',
marginLeft: 20,
}}>
<Text style={{ fontSize: 20, color: '#0f172a' }}>
Lorem Ipsum
</Text>
<Text style={{ fontSize: 16, color: '#64748b' }}>
Alice in Wonderland
</Text>
</View>
</View>
<View name="Content" style={{ flexWrap: 'wrap', flexDirection: 'row' }}>
{Array(14).fill(0).map((_, index) => (
<Image
key={index}
style={{ width: 60, height: 60, margin: 10 }}
src="assets/marshmallow.png" />
))}
</View>
</View></code></pre><p><img src="/img/remote/1460000044004593" alt="Flex 布局效果图" title="Flex 布局效果图"></p><p><em>▲ 以上所有元素都渲染在 canvas 中。图片素材来自 <a href="https://link.segmentfault.com/?enc=pE4EbUu0AfDdbTgCp8dV%2FA%3D%3D.1zan0TDnFjiysUIdaSqeMql2xuoMJTcPatkMzB%2Fjrn5hyQp%2FX5gn5tugG2WvaeG4" rel="nofollow">eiyoushi-hutaba.com</a></em></p><p><img src="/img/remote/1460000044004594" alt="布局系统原理" title="布局系统原理"></p><p>在这里,我们通过为每一个 Flex 子元素创建伴生的 Yoga 节点的方式,构造了一棵与组件树同构的布局树。当组件的布局属性发生修改(大小、位置、排列方式等)或者有节点增删时,就可以从布局树中计算出对应节点的布局信息。在本系列后续文章中,我们会另花篇幅介绍如何接入 Flex 布局引擎,并将其与 React、PixiJS 融合,敬请期待。</p><h3>2.3 关键帧动画</h3><p>说到属性与变换,自然绕不过关键帧动画这一概念。简单来说,关键帧动画就是在一组给定的「关键帧」之间(定义了时间点与属性值),对属性值的变化做插值和过渡处理。比如说这样的一个动画:</p><ul><li>keyframe[0]: 0ms, x: 0, y: 120</li><li>keyframe[1]: 20ms, x: 0, y: 40</li><li>keyframe[2]: 40ms, x: 0, y: 120</li></ul><p>就代表这个元素在 0~20ms 的区间内,y 值从 120 过渡到 40;在 20~40ms 的区间内,y 值从 40 过渡到 120。也就是说,这个元素原地蹦跶了一下。如果没有关键帧之间的插值和过渡,那么你看到的可能是这个元素突然闪现到了上面,又突然闪现了回来。而有了过渡帧,这个过程就是平滑的。</p><p><img src="/img/remote/1460000044004595" alt="属性变化曲线示意图" title="属性变化曲线示意图"></p><p>除了上文介绍的动效元素之外,关键帧动画也是常用的游戏动效形式之一。因为关键帧动画是直接根据缓动函数修改某时间点对应的属性值,可以说它有着所有动效中最好的性能(不过这也意味着它只能用于实现一些较为简单的动画)。在 Alice 中,我们可以通过类似 CSS3 <code>@keyframe</code> 的形式定义关键帧动画:</p><pre><code class="js">const { play } = useAliceTransition(penguinRef, {
jump: {
0: {
position: [0, 120],
rotation: 0,
tween: 'linear',
},
300: {
position: [40, 40],
rotation: 180,
tween: 'linear',
},
600: {
position: [80, 120],
rotation: 360
},
},
});
// 类比 CSS 代码:
// @keyframes jump {
// 0% { transform: rotate(0deg) translate(0, 120); }
// 50% { transform: rotate(180deg) translate(40, 40); }
// 100% { transform: rotate(360deg) translate(80, 120); }
// }
// .penguin {
// animation-name: jump;
// animation-timing-function: linear;
// animation-duration: 600ms;
// }</code></pre><p>随后,在用户点击屏幕时触发播放定义好的关键帧动画即可:</p><pre><code class="jsx">onClick={() => play('jump')}</code></pre><p>过渡动画也支持传入自定义参数,这里我们定义企鹅跳一步和跳两步的动画方法:</p><pre><code class="js">const { play } = useAliceTransition(
penguinRef,
{
// 允许传入自定义参数,跳一步和跳两步的距离、高度不同
jump: ({ currentX, targetX, jumpHeight }) => ({
0: {
position: [currentX, 0],
tween: 'linear',
},
300: {
position: [(currentX + targetX) / 2, -jumpHeight],
tween: 'linear',
},
600: {
position: [targetX, 0],
},
}),
// 可以同时播放多个动画,当跳两步的时候就让企鹅旋转跳跃闭着眼~
rotate: {
0: { rotation: 0 },
500: { rotation: 360 },
},
},
(name, args) => {
// 动画结束后的回调,在这里可以判断企鹅有没有掉下去
if (name === 'jump') {
onJumpEnd(args.jumpSteps);
}
}
);
// 跳一步(第二个参数是播放次数)
play('jump', 1, {
jumpSteps: 1,
jumpHeight: 40,
currentX: penguin.position.x,
targetX: penguin.position.x + BOX_WIDTH,
});
// 跳两步
play('rotate');
play('jump', 1, {
jumpSteps: 2,
jumpHeight: 60,
currentX: penguin.position.x,
targetX: penguin.position.x + BOX_WIDTH * 2,
});</code></pre><p>另外,除了事先定义好的关键帧,Alice 也支持通过 <code>tween()</code> 缓动函数直接运动指定的元素。比如说,当缓动的属性与时间值经常变化时,使用缓动函数会更加灵活。上述两种方法主要是写法上的差异,在功能上是一致的。</p><p>这里我们定义企鹅踩空后掉下去的动画方法:</p><pre><code class="js">// 企鹅掉出屏幕外,游戏结束
const fallToGround = useCallback((cb) => {
const penguin = penguinRef.current;
// 内部是 tween.js 的简单封装,在 500ms 内将 y 从原始位置运动到屏幕外
tween({ y: penguin.position.y })
.to({ y: 250 }, 500)
.easing(TweenEasing.Cubic.In) // 加速度
.onUpdate((obj) => { penguin.position.y = obj.y; })
.onComplete(cb)
.start();
}, []);</code></pre><h2>四、脚本与事件</h2><p>游戏场景搭建得差不多了,现在我们需要让角色动起来,这就涉及到脚本与交互事件的处理。</p><p>脚本是游戏引擎中不可缺少的一部分,它的主要用途是响应玩家的输入,并做出对应的处理,如控制场景中游戏对象的行为。或者通过注册特定的回调函数,来创建、更新、销毁元素等。比如玩家点击屏幕,企鹅需要向前跳动一格,这里的操作就是由脚本完成的。</p><p>在 Alice 中,我们没有设计独立的「脚本」类型,而是将其融入了 JavaScript 与 React Hooks 中。比如我们希望在玩家点击左侧屏幕时,企鹅跳 1 步,点击右侧屏幕时,企鹅跳 2 步:</p><pre><code class="jsx">const Scene = () => {
// 游戏分数
const [score, setScore] = useState(0);
// 游戏结果
const [gameResult, setGameResult] = useState('ready');
// 保存位置状态
const [currentPos, setCurrentPos] = useState(0);
// 当前游戏的地图信息,true 表示有冰块,false 表示空气
const [map, setMap] = useState([]);
// 游戏重新开始后,重置分数和位置,生成新的随机地图
useEffect(() => {
resetGame();
}, [resetGame]);
// 跳跃和旋转的动效
const { play } = useAliceTransition(/* ... */);
// 企鹅跳方法
const jump = useCallback((steps) => {
if (gameResult !== 'playing') {
return;
}
const penguin = penguinRef.current;
if (!penguin) return;
// 上锁,防止连点
if (lockRef.current) return;
lockRef.current = true;
// 跳一步和跳两步的动画参数不一样
if (steps === 1) {
play('jump', 1, { /* ... */ });
} else {
play('rotate');
play('jump', 1, { /* ... */ });
}
}, [gameResult, play]);
return (
<React.Fragment>
{/* 游戏场景略 */}
<CameraView />
{/* 触控区域,一层透明的热区盖在最上层 */}
<View name="TouchPanel">
<View onClick={() => jump(1)} />
<View onClick={() => jump(2)} />
</View>
</React.Fragment>
);
};</code></pre><p>Alice 支持 <code>click</code>、<code>pointerup</code>、<code>pointerdown</code>、<code>pointermove</code> 等用户输入事件,事件的监听也和 React 一样简单。跳跃结束后,还需要判断当前游戏是否结束,也就是企鹅是不是掉下去了或者跳完了所有方块:</p><pre><code class="js">const onJumpEnd = useCallback((steps) => {
// 找到跳到的格子
const targetBlock = map[currentPos + steps];
setCurrentPos(s => s + steps);
lockRef.current = false;
// 是否跳完全部的格子
if (currentPos + steps >= map.length) {
setGameResult('win'); // 这会触发 DOM 层的弹窗展示
setScore(s => s + steps);
return;
}
// 掉下去了
if (!targetBlock) {
fallToGround(() => {
setGameResult('lose');
});
return;
}
// 没跳完也没掉下去,更新游戏分数
setScore(s => s + steps);
}, [map, currentPos, setCurrentPos, setScore, setGameResult, fallToGround]);</code></pre><p>如果希望在多个组件之间复用这些「游戏脚本」,同样可以遵循 <strong>The React Way</strong> —— 封装成 Hooks/HoC。在传统游戏引擎中,我们使用可复用的脚本组件为实体添加交互等能力,在 Alice 中我们则是通过 Hooks/HoC 为组件添加能力,他们底层的逻辑其实是类似的,composition over inheritance。</p><p>加上跳跃后的效果如下(GIF 动图):</p><p><img src="/img/remote/1460000044004596" alt="跳跃演示 GIF" title="跳跃演示 GIF"></p><h2>五、调试</h2><p>任何软件的开发都离不开调试,游戏自然也是一样。因为我们的渲染层基于 PixiJS 与 WebGL,可以使用现有的工具构建我们的调试流程。</p><ul><li>浏览器 DevTools</li><li>React DevTools</li><li><a href="https://link.segmentfault.com/?enc=57yxm8p%2F2X3rsnzGigfbzQ%3D%3D.DVTkFfx9hIcnv1TQmonyv1lBL1IvB%2FfBRTkmhOSzvqXuhsVRctIhVff4pIhASIyX" rel="nofollow">pixi-inspector</a></li><li><a href="https://link.segmentfault.com/?enc=5nPRCrZRbeoH%2Bz8DpCLBUA%3D%3D.8u0ytSXake2IjmoocNu935UYBUEYyUWtzUY%2Fgm3wyt4%3D" rel="nofollow">Spector.js</a></li></ul><p>Alice 实现了 React custom renderer,因此可以直接使用 React DevTools 查看组件树、状态等调试信息。配合 pixi-inspector,可以很方便地查看当前场景下的所有底层元素和层级关系,快速检查和修改物件的属性值:</p><p><img src="/img/remote/1460000044004597" alt="pixi-inspector 调试示意图" title="pixi-inspector 调试示意图"></p><p>Spector.js 可以分析当前 canvas 在渲染一帧中发起的所有 WebGL 指令、用到的顶点着色器与片元着色器、纹理、Draw Call 的次数与调用参数等。WebGL 程序的渲染性能与 Draw Call 息息相关,所以这个工具在做性能优化时非常好用:</p><p><img src="/img/remote/1460000044004598" alt="Spector.js 调试示意图" title="Spector.js 调试示意图"></p><h2>结语</h2><p>到这里,我们的平台跳跃小游戏就基本成型了,是不是感觉和传统的 React 开发其实并没有特别大的区别呢?</p><p>而且因为我们的渲染基于高性能的 canvas 与 WebGL,可以实现很多传统 DOM 难以实现的效果。比如将企鹅跳跃动画换成骨骼动画、纸娃娃换肤系统、添加粒子效果、蒙皮和网格等等,甚至是渲染超级大的地图(demo 来自 <a href="https://link.segmentfault.com/?enc=cAwCy%2BEmmVyj4VqQbAbnrQ%3D%3D.D%2B9Iw381XNKBbhdUJnW%2BHp3x2lpgZiQ5wzqXDZc7PNQr6EfUFnSw3fAnP08MaBFJ" rel="nofollow">gl-tiled</a>):</p><p><img src="/img/remote/1460000044004599" alt="tilemap" title="tilemap"></p><p>同时,Alice 基于 React 也带来了这些好处:</p><ul><li>团队学习成本低,上手无需学习新技术新语法</li><li>存量项目快速接入、渐进式接入,试错成本低</li><li>复用已有的 H5 打包构建流程,无需额外流水线</li><li>可复用团队现有的 React 组件库等资产</li><li>etc.</li></ul><p>当然用 React 写游戏肯定也不是所有东西都和以前一样,还是有一些需要额外注意的地方。比如 React 状态的使用,众所周知在 React 中状态的更新会导致组件重渲染,引发 Fiber Tree 的更新(render/commit phase),以及 side-effects 副作用的执行。然而在一个每秒都要更新 60 甚至更多次的游戏中,为了减少不必要的性能开销,过于频繁的组件重渲染是应该避免的。例如,更新频率高的属性可以考虑使用 ref 保存,或者使用 <a href="https://link.segmentfault.com/?enc=MvyQhLwXM6jKyMqsbHxfLg%3D%3D.jWMTr5cHtpK8NRvYx%2BwuiCSnSKsBVl2hj%2FeUu2NRMY5F4zCkirWDkxqOzpWVa0PI" rel="nofollow">zustand</a> 等支持 Transient updates 的状态解决方案。</p><p>篇幅有限,这里其实还有很多相关的内容没有讨论,比如:性能优化、资源管理与预加载、场景分包、渲染性能优化、降级渲染,等等。这些问题我们会尝试在本系列的后续文章中继续探讨。</p><p>总体来说,Alice 游戏引擎通过结合 React 理念与基于 WebGL 的高性能渲染管道,提供了丰富的游戏开发元素、熟悉的使用方法与心智模型,可以应对我们在直播游戏化趋势中遇到的绝大部分中小型 H5 游戏开发需求。</p><p>目前,Alice 已经在云音乐社交直播团队的多个项目中落地。在未来,我们会持续探索 React + WebGL 游戏开发的可能性,优化框架的功能性与易用性,希望为 H5 游戏开发的场景提供新的思考与实践。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
Corona技术专题-日志上报、采集、分流链路设计
https://segmentfault.com/a/1190000043990240
2023-07-10T10:18:45+08:00
2023-07-10T10:18:45+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
1
<blockquote>本文作者:轻山</blockquote><h2>一.前言</h2><blockquote>关联阅读 <a href="https://link.segmentfault.com/?enc=GZvj1BI%2BUt7mmDRNySEn8w%3D%3D.AzyalzQJPBPCo5LmdlNcNod7GLYMmgjwl4cJNSKhQRhmezKePB6QkC4yUVlUW0mz" rel="nofollow">网易云音乐大前端监控体系(Corona)建设实践-开篇</a> 。</blockquote><p>Corona 是网易云音乐的大前端监控产品。Corona 的 SDK 在应用中捕获到各种类型日志后,经由上报、采集、分流链路到达 Corona 的消费服务进行处理。这条链路会直接影响到<strong>数据实时性</strong>和<strong>系统稳定性</strong>,如何高效、稳定、便捷的将应用产生的日志交由服务端消费就显得尤为重要。本文详细介绍 Corona 中的这条链路的设计与实践。</p><h2>二.在 Corona 整体架构中的位置</h2><p>在系统架构中,日志上报、采集、分流链路所处的位置如下(图中蓝色部分):</p><p><img src="/img/remote/1460000043990242" alt="" title=""></p><p>整条链路是在网易集团&云音乐的公共服务基础上做的能力整合,同时为了能够服务于集团其他事业部或者在其他事业部私有化部署,对依赖的云音乐服务节点做了可替换的设计。本文内容是这条链路进行详细的展开介绍。</p><h2>三.具体实现</h2><h3>3.1 通过云音乐日志服务上报日志</h3><blockquote>Corona 各端 SDK 通过云音乐日志上报服务提供的 http 接口上报日志</blockquote><p>云音乐的应用通过日志服务上报日志的链路如下:</p><p><img src="/img/remote/1460000043990243" alt="" title=""></p><p>其中:</p><ul><li><p><strong>网络层</strong></p><ul><li>不同类型的应用上报日志的方式是差异化的,比如 Android、iOS 应用是每产生一条日志就写入到本地文件,然后异步的把日志文件上传到日志服务,而 Web 前端应用是以普通的 HTTP POST 请求上传;</li><li>日志服务的接口设计了业务专属域名,命名规则就是在各个业务的主域名上添加约定的前缀。因为各个业务的 Cookie 是种在主域名下的,这样设计可以使得 SDK 采集日志后自动携带 Cookie 上报,从而使得日志信息更加丰富。</li></ul></li><li><p><strong>日志传输层</strong></p><ul><li><p>日志服务集群接收到不同业务、不同应用类型上报的日志后,首先会对日志做预处理,包括:</p><ul><li>解压缩日志文件(来自 Android、iOS 等应用)、解密日志(有些加密上报的场景),最终使得各个来源的日志格式是一致的;</li><li>解析 Cookie,获取用户信息(UID)等业务数据塞入日志;</li><li>解析 ip 塞入日志;</li><li>记录日志上报到服务器的时间;</li><li>...</li></ul></li><li>日志预处理完成后,会先暂存在服务器本地磁盘中。为了方便日志传输到不同的目的地,会根据<code>日志类型</code>、<code>应用类型</code>、<code>业务</code>等关键信息进行归档后写入到不同的日志文件。</li></ul></li></ul><p>这条链路属于云音乐专属的日志上报通道,为方便业务接入使用做了一些定制设计(比如业务专属日志域名)。</p><h3>3.2 日志采集服务</h3><p>上文讲到应用的日志上报后存储在日志服务集群的本地磁盘上,如何高效、稳定、便捷的采集这些日志就显得尤为重要。Corona 使用网易集团数据科学中心自研的一款日志采集服务实现日志实时采集、ETL 归档等功能。</p><p><img src="/img/remote/1460000043990244" alt="" title=""></p><p>如图所示,<code>Agent</code> 是日志采集服务核心的采集程序,部署在云音乐日志服务集群的应用服务器上,用户通过管理后台进行 Agent 管理,并由 Manager Service 下发采集规则到 Agent 程序。</p><p>采集规则是稳定的、极少变动的,会将一个业务下所有日志都采集上来,即使新增的日志类型也会被自动采集,尽量避免变更采集规则而对上层应用造成不可预知的风险。</p><p>Agent 程序会根据规则读取指定文件的日志,记录文件采集位置,并将采集到的日志送往 2 个目的地:</p><ol><li>DWD 明细数据层,是一个 HBase 数据库,用于原始日志的备份、提供给数据分析师使用、或者开放给开发者自助分析;</li><li>写入 kafka 消息队列,经过一系列流转后最终会流入 ADS 层数据库(应用数据层),被 Corona 的日志消费服务订阅;这里不同的业务和日志类型会有专属的 kafka topic,这样设计的原因是因为方便在云音乐内部给业务其他服务、产品去订阅消费,做一些自定义的功能、业务指标统计等;</li></ol><p>以上是对日志采集过程的简单介绍,想了解日志采集服务更多细节的同学可以查看 Apache 的顶级开源项目 <a href="https://link.segmentfault.com/?enc=W72EhPY7byabIjQ6TissIw%3D%3D.krol7sJWDiicIYp%2FHKfV2mC0w%2FbP3CF13GnCmTYQEdI%3D" rel="nofollow">flume</a>。</p><h3>3.3 面向数据消费层实现日志分流</h3><p>3.2 中由日志采集服务写入 kafka 的日志,包含一个业务(or 应用)所有监控日志类型,比如设备活跃日志、异常日志、性能日志等,并且这个日志种类非常动态,随时会因为新增监控指标而增加。Corona 的架构设计里,在数据消费层不同类型的日志有不同的消费服务,如果让每个消费服务直接订阅上述 kafka topics 的话,会带来极大的资源浪费以及稳定性风险,假设有如下 2 个业务场景:</p><p><img src="/img/remote/1460000043990245" alt="" title=""></p><p>如图所示,每个消费服务能承载的日志流量上限,必需按所有日志类型的累计流量来设计,在服务内部用代码逻辑过滤丢弃不需要的日志,并且一旦某个日志类型出现了流量突增,那么所有消费服务都需要承担流量峰值带来的风险。</p><p>为了解决这个问题,Corona 使用 Flink 对日志做分流,确保到达应用层各个消费服务的日志是单个消费服务所需要的日志。Flink 实时计算能力由 <a href="https://link.segmentfault.com/?enc=Tp2JQzOsGF%2FzRapc964MoQ%3D%3D.zGU7KRnwxWRRvNSjNeBRIog4QRRPD74AbKRZIMZsJO5xSW03IuBAQJQeiNtIxBNO977e1JwYT8Hu%2BNE0vFMVwQ%3D%3D" rel="nofollow">云音乐数据平台</a> 提供。</p><p>用 Flink 实时任务实现日志分流的过程如下:</p><p><img src="/img/remote/1460000043990246" alt="" title=""></p><p>从日志服务集群上、按业务采集的日志会流入一张流表中,然后 Flink 实时流任务根据下游不同消费服务的需要去流表中查询相关日志。可以简单理解为,Corona 针对异常监控、性能监控、实时流量监控场景有 3 个日志消费服务,Flink 会根据这 3 个服务所需要的日志类型去查询日志,然后写入 3 个新的消息队列。</p><p>至此,每个消费服务承接的流量就变成了下图:</p><p><img src="/img/remote/1460000043990247" alt="" title=""></p><p>每个消费服务在设计时只需要考虑相关日志类型的流量上限。一方面减少了服务器资源的浪费,另一方面单一日志类型的突增也只会影响单个消费服务的稳定性,不会导致整个系统不可用。</p><h3>3.4 极端大流量的应对措施</h3><p>经过上述日志分流任务处理后,虽然已经保证了单一日志类型的突增只会影响单个消费服务的稳定性,但单个消费服务的宕机也会导致产品部分功能不可用,这对于用户来说是不可接受的,也不符合监控产品本身高可用的目标。</p><p>前置链路中日志异步采集以及 kafka 消息队列已经能够起到削峰的作用,但在极端大流量下还不足以保障下游应用的稳定性以及实时性。</p><p>在经历多次线上日志流量异常突增的紧急处理之后,目前 Corona 在日志链路层采取以下应对措施:</p><ol><li>数据消费服务增加日志类型流量监控、告警;</li><li>收到告警后,在 Flink 分流任务中,配置针对大流量日志的过滤规则,直接丢弃;</li></ol><p>日志分流任务计算逻辑比较轻量,因此对流量并不敏感,而且 Flink 的实时任务扩容很方便,无需运维参与,开发者直接调整参数重启即可。</p><p>以上过程需要开发者参与响应流量告警,因为极端大流量场景比较少见,所以人工响应成本并不大。</p><p>在 Corona 的消费服务设计中,也开放支持用户自主设计过滤规则、丢弃应用日志,同时应用层的服务也有针对日志波峰的自动响应策略,在本文中不作展开介绍了,未来会有专门文章介绍消费服务的设计。</p><p>至此,对上述日志分流链路图略作补充,增加极端大流量的响应、处理链路:</p><p><img src="/img/remote/1460000043990248" alt="" title=""></p><h3>3.5 独立日志上报通道</h3><p>3.1 所述的日志上报链路属于云音乐专属通道,有方便云音乐业务接入使用的定制设计,并不适合集团其他事业部接入。出于未来商业化考虑,为了能快速接入其他 BU 的应用,Corona 也提供了独立的日志上报服务。</p><p>Corona Log Receiver 是一个 Node.js 应用,上报到此服务的日志并不会携带业务方的 Cookie。如果接入方需要更多业务信息(比如用户 id 等信息),可以使用 SDK 提供的 api 进行全局设置(在介绍 SDK 的文章中会详细展开),之后 SDK 在上报日志时就会携带这些额外信息。</p><p>Corona Log Receiver 在接收到日志后会将额外信息与原始日志进行重组、以及添加用户 ip 信息等操作,然后将日志写入独立的 kafka topic,直接对接分流服务。</p><p>我们对上述数据流图略作补充,得到以下的 Corona 日志上报、采集、分流链路图:</p><p><img src="/img/remote/1460000043990249" alt="" title=""></p><h2>四.总结</h2><p>本文分 5 小节介绍了云音乐大前端监控产品 Corona 的日志上报、采集、分流链路,以及极端大流量场景下的应对措施。将以上内容串联后可以得到以下完整的日志流转链路图:</p><p><img src="/img/remote/1460000043990250" alt="" title=""></p><p>本文是 Corona 技术专题的第二篇,欢迎大家多多交流。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
轻量化的iOS动画框架实现
https://segmentfault.com/a/1190000043978623
2023-07-06T17:37:47+08:00
2023-07-06T17:37:47+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:有恒</blockquote><h4>一、背景</h4><p>日常开发过程中,经常需要对视图做动画,假如需要对一个 view 进行动画操作:3s 淡入,结束后,1s 放大,很容易写出这样的代码:</p><pre><code class="swift">UIView.animate(withDuration: 3, animations: {
view.alpha = 1
}, completion: { _ in
UIView.animate(withDuration: 1) {
view.frame.size = CGSize(width: 200, height: 200)
}
})</code></pre><p>如果,是更多串行的动画需要完成呢?</p><pre><code class="swift">UIView.animate(withDuration: 3, animations: {
......
}, completion: { _ in
UIView.animate(withDuration: 3) {
......
}, completion: { _ in
UIView.animate(withDuration: 3) {
......
}, completion: { _ in
......
}
}
})</code></pre><p>这样的回调地狱代码,很难维护也不优雅。</p><p>业界也有一些现成的动画库,比较知名的有:</p><ul><li>Spring: 轻量级的、基于 Swift 实现的动画库,它提供了多种弹簧效果动画效果。缺点是功能相对较少,不能满足所有的动画需求。</li><li>Hero:一个高度可定制化的 iOS 动画库,它支持多种动画效果,如过渡动画、视图转场等。缺点是对于复杂的动画效果可能需要编写大量的代码。</li><li>TweenKit:一个轻量级的、基于 Swift 实现的动画库,它提供了多种动画效果,如渐变效果、旋转效果等。TweenKit 的优点是易于使用,对于入门级的开发者很友好,但缺点是功能相对较少,不能满足所有的动画需求。</li></ul><p>以上动画库各有优点和缺点,总的来说都有书写相对复杂不够优雅的缺陷,那有没有方便开发和维护的代码格式?</p><p>动画串行执行:</p><pre><code class="swift">view.at.animate(
.fadeIn(duration: 3.0),
.scale(toValue: 1.2, duration: 0.5)
)</code></pre><p>动画并行执行:</p><pre><code class="swift">view.at.animate(parallelism:
.fadeIn(duration: 3.0),
.scale(toValue: 1.2, duration: 1)
)</code></pre><p>如果是多个视图组合动画串行执行呢?</p><pre><code class="swift">AT.animate (
view1.at.animate(parallelism:
.fadeIn(duration: 3.0),
.scale(toValue: 1.2, duration: 1)
)
view2.at.animate(parallelism:
.fadeIn(duration: 3.0),
.scale(toValue: 1.2, duration: 1)
)
)</code></pre><h4>二、实现方案</h4><p><img src="/img/remote/1460000043978625" alt="" title=""></p><h5>Animator</h5><p>动画执行器</p><pre><code class="swift">public protocol Animator {
associatedtype T
mutating func start(with view : UIView)
func pause()
func resume()
func stop()
}</code></pre><p>封装 UIViewPropertyAnimator,CAKeyframeAnimator,CABasicAnimator,遵循 Animator 协议,实现不同类型的动画执行器。</p><h5>Animation</h5><p>Animation 提供动画执行参数:</p><p>为不同的 Animator 制定不同的 Animation 协议:</p><pre><code class="swift">public protocol AnimationBaseProtocol {
var duration : TimeInterval { get }
}
protocol CAAnimationProtocol : AnimationBaseProtocol {
var repeatCount : Float { get }
var isRemovedOnCompletion : Bool { get }
var keyPath : String? { get }
var animationkey : String? { get }
}
protocol ProPertyAnimationProtocol : AnimationBaseProtocol {
var curve : UIView.AnimationCurve { get }
var fireAfterDelay : TimeInterval { get }
var closure : (UIView) -> Void { get }
}
protocol CABaseAnimationProtocol: CAAnimationProtocol {
var fromValue: Any { get }
var toValue : Any { get }
}
protocol CAKeyFrameAnimationProtocol: CAAnimationProtocol {
var keyTimes: [NSNumber]? { get }
var timingFunction: CAMediaTimingFunction? { get }
var valuesClosure: ((UIView) -> [Any]?) { get }
}</code></pre><p>需要注意的是,动画执行器支持多种实现,用到了范型,动画执行器作为返回值,使用时需要对其进行类型擦除。</p><h5>类型擦除</h5><p>类型擦除的作用是擦除范型的具体信息,以便在运行时使用:</p><p>定义一个范型类:</p><pre><code class="swift">class Stack<T> {
var items = [T]()
func push(item: T) {
items.append(item)
}
func pop() -> T? {
if items.isEmpty {
return nil
} else {
return items.removeLast()
}
}
}</code></pre><p>如果这样使用:</p><pre><code>// 实例化一个 Stack<String> 对象
let stackOfString = Stack<String>()
stackOfString.push(item: "hello")
stackOfString.push(item: "world")
// 实例化一个 Stack<Int> 对象
let stackOfInt = Stack<Int>()
stackOfInt.push(item: 1)
stackOfInt.push(item: 2)
let stackArray: [Stack] = [stackOfString, stackOfInt]
</code></pre><p>会有一个错误:</p><p><img src="/img/remote/1460000043978626" alt="" title=""></p><p>因为这是两种类型。</p><p>如何进行擦除?</p><pre><code class="swift">class AnyStack {
private let pushImpl: (_ item: Any) -> Void
private let popImpl: () -> Any?
init<T>(_ stack: Stack<T>) {
pushImpl = { item in
if let item = item as? T {
stack.push(item: item)
}
}
popImpl = {
return stack.pop()
}
}
func push(item: Any) {
pushImpl(item)
}
func pop() -> Any? {
return popImpl()
}
}</code></pre><p>这样执行下面代码就可以正常编译使用:</p><pre><code class="swift">let stackArray: [AnyStack] = [AnyStack(stackOfString), AnyStack(stackOfInt)]</code></pre><p>回到 Animator 的设计,同样的原理,这样就解决了形参类型不一致的问题。</p><h5>具体实现</h5><pre><code class="swift">extension Animator {
public static func fadeIn(duration: TimeInterval = 0.25, curve:UIView.AnimationCurve = .linear , fireAfterDelay: TimeInterval = 0.0, completion:(()-> Void)? = nil) -> AnyAnimator<Animation> {
let propertyAnimation = PropertyAnimation()
propertyAnimation.duration = duration
propertyAnimation.curve = curve
propertyAnimation.fireAfterDelay = fireAfterDelay
propertyAnimation.closure = { $0.alpha = 1}
return Self.creatAnimator(with: propertyAnimation,completion: completion)
}
public static func scale(valus: [NSNumber], keyTimes: [NSNumber], repeatCount: Float = 1.0, duration: TimeInterval = 0.3, completion:(()-> Void)? = nil) -> AnyAnimator<Animation> {
let animation = CAFrameKeyAnimation()
animation.keyTimes = keyTimes
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.valuesClosure = {_ in valus}
animation.repeatCount = repeatCount
animation.isRemovedOnCompletion = true
animation.fillMode = .removed
animation.keyPath = "transform.scale"
animation.animationkey = "com.moyi.animation.scale.times"
animation.duration = duration
return AnyAnimator.init(CAKeyFrameAnimator(animation: animation,completion: completion))
}
/// 自定义Animation
public static func creatAnimator(with propertyAnimation : PropertyAnimation, completion:(()-> Void)? = nil) -> AnyAnimator<Animation> {
return AnyAnimator.init(ViewPropertyAnimator(animation:propertyAnimation,completion:completion))
}
}
</code></pre><p>CAAnimation 是 Core Animation 框架中负责动画效果的类,它定义了一系列动画效果相关的属性和方法。可以通过创建 CAAnimation 的子类,如 CABasicAnimation、CAKeyframeAnimation、CAAnimationGroup 等来实现不同类型的动画效果。</p><p>其中,keypath 是 CAAnimation 的一个重要概念,用于指定动画效果所作用的属性。keypath 的值通常为字符串类型,在指定属性时需要使用 KVC(键值编码)来进行访问。</p><p>更多关于 CAAnimation 的内容可以参考引用中相关链接,不是本文重点不再展开。</p><h5>AnimationToken</h5><p>AnimationToken 是视图和动画执行器的封装,用于视图的动画处理。</p><p>然后对 UIView 添加串行、并行的扩展方法:</p><pre><code class="swift">extension EntityWrapper where This: UIView {
internal func performAnimations<T>(_ animators: [AnyAnimator<T>] , completionHandlers: [(() -> Void)]) {
guard !animators.isEmpty else {
completionHandlers.forEach({ handler in
handler()
})
return
}
var leftAnimations = animators
var anyAnimator = leftAnimations.removeFirst()
anyAnimator.start(with: this)
anyAnimator.append {
self.performAnimations(leftAnimations, completionHandlers: completionHandlers)
}
}
internal func performAnimationsParallelism<T>(_ animators: [AnyAnimator<T>], completionHandlers: [(() -> Void)]) {
guard !animators.isEmpty else {
completionHandlers.forEach({ handler in
handler()
})
return
}
let animationCount = animators.count
var completionCount = 0
let animationCompletionHandler = {
completionCount += 1
if completionCount == animationCount {
completionHandlers.forEach({ handler in
handler()
})
}
}
for var animator in animators {
animator.start(with: this)
animator.append {
animationCompletionHandler()
}
}
}
}</code></pre><p>completionHandlers 是动画任务的结束的回调逻辑,类似 UIView 类方法 animate 的 completion 回调,这样就有了动画结束的回调能力。</p><p>给 UIView 添加扩展,实现 view.at.animate () 方法:</p><pre><code class="swift">extension EntityWrapper where This: UIView {
@discardableResult private func animate<T>(_ animators: [AnyAnimator<T>]) -> AnimationToken<T> {
return AnimationToken(
view: this,
animators: animators,
mode: .inSequence
)
}
@discardableResult public func animate<T>(_ animators: AnyAnimator<T>...) -> AnimationToken<T> {
return animate(animators)
}
@discardableResult private func animate<T>(parallelism animators: [AnyAnimator<T>]) -> AnimationToken<T> {
return AnimationToken(
view: this,
animators: animators,
mode: .parallelism
)
}
@discardableResult public func animate<T>(parallelism animators: AnyAnimator<T>...) -> AnimationToken<T> {
return animate(parallelism: animators)
}
}</code></pre><p>AT.animate () 对 AnimationToken 进行串行管理,不再赘述。</p><h4>三、总结</h4><p>本文只是对动画回调嵌套问题的轻量化解决方案,让组合动画的代码结构更加清晰,方便开发和后续迭代修改。实现方案还有许多可以改进的地方,欢迎参考指正。</p><h4>四、参考资料</h4><ol><li>图源:<a href="https://link.segmentfault.com/?enc=ddFoO5oY0yduyYmmEXjT3A%3D%3D.LVgzHWXvlik8Izv1Rkk10zTUskvO7FPYduc7kI%2FqMP1%2BK0yywSalNikKr3SNtpCY" rel="nofollow">https://unsplash.com/photos/PDxp-AItBMA</a></li><li>[Declarative animation]<a href="https://link.segmentfault.com/?enc=OW62u1nrjQPUwgxWuOSpaA%3D%3D.zum2y4F6w2RYuydM0uWqHCuWSHy%2B5DRBveDE%2BN3duaZlalY7au7WLaiQ%2B0uICXgQUmXzrKH6z%2BDg0k7164nGc%2B%2BoyzNxhB%2BWDe%2Bz062rxCEPtg6%2FeCv1FoMKPdWj6xcWgwPF00SOacHKvlJ1D3ZR3A%3D%3D" rel="nofollow">https://www.swiftbysundell.com/articles/building-a-declarativ...</a></li><li>[CAAnimation] Apple Inc. Core Animation Programming Guide. [About Core Animation](About Core Animation)</li><li>[CAAnimation] 王巍. iOS 动画高级技巧 [M]. 北京:人民邮电出版社,2015.</li><li>[CAAnimation]<a href="https://link.segmentfault.com/?enc=e2kcoJ05Pt70hOzAcMog8A%3D%3D.iOtS6E9%2Fp%2FZtzc7F5YBSVghilU1lcI%2BFqurLX12zuZ4nzcOskjRLb388lcXkZcHvLcxoZoDrnGIxPBWzGWAR%2BTYJZ%2FADYZBZHgQQX%2BMwVwk%3D" rel="nofollow">https://developer.apple.com/documentation/quartzcore/caanimat...</a></li><li>[CAAnimation]<a href="https://link.segmentfault.com/?enc=ftalICME3%2FlOYfELtrOEPg%3D%3D.e8kPWKxEzcSxz4RXMbedTndpToOmoyUTmII8KLQt%2B1qN03KmRMsiHHBS%2Bzbq3CsafEufEWEW7hhv4SFC%2BJucpmvltgvOW08IdNKpsOvzV45umoM%2FVkvjNWruiG2qqG2Qm6WapOU%2FgZlDd6EbZt5TS8fM76A2MMofq%2F7YBFFAITqtEsiqsyMG6YAkwXzxpeCwoq4RHVGW03XHGPBj13IqjbObGiRLweFFdpKuklXMcdtQ6FozR74%2BNGkKO4kwCdjfUp4P8swWqwL649HnGEMtQ33CjoB2I%2BAJGQIXT7FEmNBEVMT%2Fq7vRLCylKPixGhmSQIVKqPss2qdK%2BXCeBnO3qQ%3D%3D" rel="nofollow">https://github.com/pro648/tips/blob/master/sources/CAAnimation%EF%BC%9A%E5%B1%9E%E6%80%A7%E5%8A%A8%E7%94%BBCABasicAnimation%E3%80%81CAKeyframeAnimation%E4%BB%A5%E5%8F%8A%E8%BF%87%E6%B8%A1%E5%8A%A8%E7%94%BB%E3%80%81%E5%8A%A8%E7%94%BB%E7%BB%84.md</a></li><li>[UIViewPropertyAnimator]<a href="https://link.segmentfault.com/?enc=6h4FxidkOQGc1s2o8cK52Q%3D%3D.2K%2Fdmnd1L%2BUQbGwo45pKGVLsoBAJN%2BG5%2FGad4OzCvv1hA7MruKPOE77kFmmJkjGO4uFBtX7m9cYLBOEYE7yD278f0owR8SAmB%2F1hbafxivM%3D" rel="nofollow">https://developer.apple.com/documentation/uikit/uiviewpropert...</a></li><li>[使用 UIViewPropertyAnimator 做动画]<a href="https://link.segmentfault.com/?enc=z8FXD3cZGleC5Z3SejP1Aw%3D%3D.4izilBQae9pMVpR3PES0NyKeDafcndgTidYI6v66Wp91HGKaVQGvY%2FOILUmF7MvBZ6%2FD7AaaMJ4QNtFpMmWnNxQs%2BZOLOfk7Wg2HL0C4PJQ%3D" rel="nofollow">https://swift.gg/2017/04/20/quick-guide-animations-with-uivie...</a></li></ol><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe (at) corp.netease.com!</blockquote>
云音乐 GitOps 最佳实践
https://segmentfault.com/a/1190000043961778
2023-07-03T10:27:14+08:00
2023-07-03T10:27:14+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:kiloson</blockquote><p>近些年随着微服务、kubernetes 等技术的发展,越来越多的厂商将单体架构的项目进行微服务化。但是随着原有项目的不断拆分,微服务的数量越来越多,部署的频率也越来越高,传统手工运维的劣势越发明显,效率低、部署质量没有保证。在云原生时代,是否有一种更加高效、稳定的部署方式,可以帮助我们改进部署和管理流程呢?</p><p>随着我们对运维方法的调研,我们发现 GitOps 能够很好的解决这些问题。GitOps 是一种符合 DevOps 思想的运维方式,GitOps 以 Git 仓库作为唯一的事实来源,储存声明式配置,并通过自动化工具实现环境和应用的自动化管理。Git 实现了版本控制、回滚、多人协作;声明式配置保证了配置的可读性和事务性;自动化部署消除了人为错误,调高了部署效率和准确性,同时也保证了多环境的一致性。所以 <strong>GitOps + 声明式配置</strong> 能够很好的解决传统运维的痛点,提高部署效率,保证部署质量。</p><h2>主机部署的缺陷</h2><p>在传统的云主机部署模式下,通过工单创建运维请求,运维人员接收到工单后,通过 Ansible 等运维工具手动进行运维操作。这种方式在实际操作过程中遇到了许多问题,比如由于 Ansible 基于 SSH 下发文件,所以需要给每台机器配置 SSH;因为机器底层的异构,导致运维需要修改配置文件;或是因为脚本执行顺序错误,导致需要重新执行整个部署流程;手工操作,导致部署效率低,容易出错,无法保证部署质量。</p><p>总的来说,云主机时代运维存在以下缺陷:</p><ol><li><strong>环境不一致</strong>:需要step-by-step的编写脚本,设想目标环境中的各种情况,编写脚本时需要考虑各种情况,比如机器是否已经部署过,机器是否已经配置过 SSH,机器是否已经安装过依赖等等。并且脚本运行在不同环境中可能会有不同的结果。</li><li><strong>无事务保证</strong>:安装脚本不能被打断,如果中途遇到问题,服务可能处于不可用的中间状态。</li><li><strong>协作困难</strong>:需要另行编写文档描述运维流程,如果多人同时维护一个脚本,协作往往非常困难。</li><li><strong>回滚困难</strong>:部署流程难以回滚,如果部署过程中出现问题,需要手动执行逆向操作。</li><li><strong>权限管控与审核</strong>:通常运维需要目标主机的 root 权限,难以限制运维人员的权限,同时也难以对整个运维动作进行审核。</li></ol><h2>云原生时代部署特点</h2><p>云原生的代表技术包括容器、服务网格、微服务、不可变基础设施和声明式API。云原生时代,微服务架构应用成为了主流。微服务架构的特点是将应用拆分成多个服务,每个服务都有自己的数据库和配置文件。每个微服务都是独立部署的,这大大提高了部署的频率,带来了新的挑战:</p><ol><li><strong>部署频繁</strong>:微服务应用被拆分成了多个服务,每个服务都需要独立部署,部署频率大大提高,需要更高的部署效率</li><li><strong>多副本</strong>:微服务通过扩容副本的方式来提高可用性,通常需要部署多副本,有时甚至需要部署数百个副本</li><li><strong>多环境</strong>:通常需要部署多个环境,比如开发环境、测试环境、预发环境和生产环境</li></ol><p>为满足以上需求,我们需要一种全新的部署方式</p><ol><li>其应该有较高的<em>自动化</em>水平,能够减少人工参与,减少出错,提高部署效率</li><li>应该有良好的<em>版本控制</em>,方便<em>回滚</em></li><li>应该保证多环境<em>一致</em>,快速在多个环境中拉起相同的应用,方便测试和验证</li><li>应该能够保证<em>事务</em>,避免部署过程中出错,导致服务不可用</li><li>便于<em>多人协作</em>,提高部署效率</li></ol><h2>什么是GitOps</h2><p>如果我们需要自己实现一种满足需求的部署方式,我们需要自己实现一个版本管理系统,这需要很大的工作量。但是事实上市面上已经存在一个十分优秀的版本管理系统,那就是 Git。<br>能不能直接基于 Git 进行部署呢?我们顺着这个思路继续调研基于 Git 的部署方式,最终发现了 GitOps,GitOps 能够很好的满足以上需求。<br>那么究竟什么是 GitOps 呢?<br>GitOps 的关键是使用 <strong>Git 仓库</strong>储存<strong>声明式配置</strong>,通过<strong>自动化工具</strong>将 Git 仓库中的配置应用到目标环境中。<strong>Git 仓库</strong>满足了对于<em>版本管理</em>、<em>回滚</em>、<em>多人协作</em>的需求,<strong>声明式</strong>配置满足了对于<em>事务性</em>、<em>一致性</em>的需求,而<strong>自动化</strong>工具提高了部署的<em>自动化</em>水平。所以 GitOps 能够很好的满足云原生时代的部署需求,是一种优秀的部署方式。</p><h3>Git仓库</h3><p>Git 仓库所有开发者都很熟悉,它是一个分布式的版本控制系统,可以方便的进行版本管理和回滚。在 GitOps 中,Git 仓库作为唯一的事实来源,储存所有的配置信息。<br>使用 Git 仓库储存配置,可以方便的进行<strong>版本管理</strong>和<strong>回滚</strong>,并且天然支持<strong>多人协作</strong>,同时修改配置文件。并且通过 Pull Request 提交修改,可以基于 <strong>Code Review</strong> 保证修改的正确性和质量。</p><h3>声明式配置</h3><p>声明式配置使用配置文件直接描述系统的期望状态,使用者不需要考虑执行流程和目标环境的差异,易于编写、理解、代码 review 和进行版本管理。并且声明式配置天然具备幂等性,可以重复应用而不会导致系统状态发生变化。具备<strong>事务性</strong>,要么全部应用成功,要么什么都不做。以 Kubernetes 资源配置文件为例,使用者只需要指定 CPU 和 Memory 的大小,而不需要关心底层执行细节和环境差异,保证了各个环境中部署<strong>一致</strong>。</p><pre><code class="yaml">apiVersion: apps/v1
kind: Deployment
metadata:
name: example-deployment
spec:
replicas: 3
selector:
matchLabels:
app: example-app
template:
metadata:
labels:
app: example-app
spec:
containers:
- name: example-container
image: example-image
resources:
limits:
cpu: 1
memory: 512Mi
requests:
cpu: 500m
memory: 256Mi</code></pre><h3>自动化工具</h3><p>GitOps 中的自动化工具负责将 Git 仓库中的配置应用到目标环境中。自动化工具可以是Gitlab CI、 Github Action 这类流水线工具,也可以是 Argo CD 这类专门用于 GitOps 的工具,自动化工具可以根据 Git 仓库中的配置,自动化的完成部署、回滚、监控、告警等工作。</p><p>以 ArgoCD 为例,用户只需要创建 Git 仓库和 ArgoCD Application,ArgoCD 就会自动的将 Git 仓库中的配置应用到目标环境中。并且 ArgoCD 会实时监听 Git 仓库的变化,一旦 Git 仓库中的配置发生变化,ArgoCD 也可以进行自动同步。</p><p><img src="/img/remote/1460000043935182" alt="argocd" title="argocd"></p><p>自动化工具能够提高部署<strong>自动化</strong>水平、效率,减少人工参与,减少出错,提高部署效率。满足了我们对高自动化水平的需求。</p><h2>GitOps 实践</h2><p>经过上面的描述,相信大家已经对 GitOps 有了一个初步的认识。下面,我们将通过云音乐内部的实践,展示 GitOps 在生产环境中的应用与优势。<br>在云音乐全面推进容器化的过程中,需要安装部署许多的管控面组件,比如Grafana、Argo Rollout等,并且需要在多个环境中安装相同的应用。出于对 GitOps 理念的认同,我们设计了设计了一套基于 Gitlab CI 流水线的 GitOps 部署体系。</p><h3>管控面运维</h3><p>在云音乐的容器化过程中,我们使用了许多开源的管控面组件,比如 Prometheus、Grafana、Argo Rollout 等。我们有许多 kubernetes 集群,很多 kubernetes 集群中都需要安装相同的组件,通过手工安装费时费力,而且很难保证所有环境的配置一致。并且,在运维过程中,可能会出现多人修改同一个应用配置的情况,如何避免修改冲突和覆盖?Kubernetes 十分强大,但同时其也非常复杂,有海量的配置项可以修改,如何保证配置的正确性?</p><p>为了解决以上问题,我们设计了一套基于 GitOps 的自动化运维流程。每个线上组件都会有两到三个对应的仓库,分别是:代码仓库、配置仓库、Helm Chart 仓库。其中,代码仓库存放组件的源代码,如果是开源组件,则直接使用开源的 release,没有对应仓库;Helm Chart 仓库存放组件的 Helm Chart,配置仓库通过 <a href="https://link.segmentfault.com/?enc=raJ2hhHDEREngVSNa27rxw%3D%3D.cWRUyYy8eOhSLwJF4fkUErFuJFBQNR7ofmmn30OuiOlHOVTziChdFuO2K5Tx2b0p" rel="nofollow">Helm Dependency</a> 引用 Helm Chart 仓库中的 Chart,并存放了 values.yaml 文件,用于配置组件的参数。<br>将通用配置抽出来,放到同一个 Helm Cart 中,并在部署仓库中引用该 Chart,可以有效避免因多环境导致的配置不一致问题。</p><p>当需要修改配置时,开发者只需要修改配置仓库中的 values.yaml 文件,并向原仓库提交 MR,这时会触发流水线,验证修改是否正确。通过验证后,开发者需要请求团队中的其他人帮忙 review。通过检查后,将 MR 合并到 master 分支,这时会触发流水线,运行修改之后的配置,使用 Helm Upgrade 命令将组件更新到环境中。发布完成后,如果开发者本次更新升级有任何问题,可以通过运行上一次的部署流水线,将组件回滚到上一次的版本。</p><p><img src="/img/remote/1460000043935183" alt="push" title="push"></p><p>为了强制执行以上过程,通常会回收开发者对于 master 分支的更新权限。开发者只能通过向原仓库提交 MR 的方式,来配置修改。这样,就可以保证配置和环境的一致性。为了避免开发者没有合入权限,我们开发了一个 review 机器人,当开发者提交 MR 时,机器人会在评论区进行评论,要求其他人 review 并投票。当该 MR 获得足够的票数(通常是两票)后,机器人会自动合入 MR。 </p><p><img src="/img/remote/1460000043935184" alt="review rebot" title="review rebot"></p><p>需要注意的是,这里选择了使用一个仓库对应一个环境,但是实际上,也可以使用一个仓库对应多个环境,只需要在仓库中创建多个分支,每个分支对应一个环境,然后在流水线中,根据分支名称,选择对应的环境。为什么选择仓库对应环境的方式而不是分支对应环境的形式呢?主要是因为仓库对应环境的形式在权限管控方面比较有优势,因为开发者可能需要不同环境拥有不同的权限,如果选择分支对应环境,那么就需要在部署流程中对不同环境的权限进行管控,这样很麻烦。</p><p>并且通过引入测试流水线和 Reviewer 降低了出错的概率,通过重新运行部署流水线完成了快速回滚,通过 commit 记录每次部署的操作者,通过自动化部署减少了人工介入,解决了绝大部分传统部署过程中的问题。</p><h3>Horizon 应用部署</h3><p>以上实践,部署少量的管控面组件还是比较方便的,但当部署的应用数量增多时,就会变得比较麻烦。因为每个应用都需要创建对应的仓库和创建流水线,这样就会导致仓库、流水线数量过多,维护成本过高。<br>为了优化云音乐大量应用的部署流程,我们开发了 Horizon CD 平台,Horizon 基于 GitOps、ArgoCD 部署应用。通过 Horizon,开发者只需要在 Horizon 平台上创建应用,配置应用的参数,就可以完成应用的部署,并且也可以享受到 GitOps 带来的好处。</p><p>开发者通过填写表单即可在 Horizon 上创建应用,表单中包含了应用的基本信息和部署信息,例如应用名称、应用描述、镜像地址、副本数、部署环境等。Horizon 会为应用创建对应的 GitOps 仓库,并将用户输入以及其它创建应用必要的信息一并写入到 GitOps 仓库中。GitOps 仓库中的每个分支对应不同的环境,方便管理多环境。<br>用户可以根据以上创建的应用,创建对应的应用实例,应用实例对应 Kubernetes 中的一系列相关资源。Horizon 会为该应用实例创建 GitOps 仓库和 ArgoCD Application。 该 GitOps 仓库有两个 branch —— master 和 gitops。master 和 gitops 分支都存放了应用的配置。用户修改应用配置后,Horizon 会将修改记录到 gitops 分支中。用户发布应用时,Horizon 会将 gitops 分支合并到 master 分支中,并触发 ArgoCD 同步,将 GitOps 仓库中的配置应用到 Kubernetes 中。这样,就完成了一次部署。</p><p>如果有人手动修改了 kubernetes 中相关资源或者修改了 GitOps 仓库但并未执行同步,ArgoCD 会感知到 master 分支配置与 kubernetes 中资源配置不一致,会将该应用标记为 <code>OutOfSync</code>,在 Horizon 上,用户也可以观察到该应用状态不正常,方便用户及时发现问题,并与 Horizon 管理员联系,及时排查解决。</p><p><img src="/img/remote/1460000043935185" alt="pull" title="pull"></p><p>Horizon 依赖于 GitOps 仓库实现回滚,Horizon 的每次发布会在 master 分支生成一条 commit 记录,当用户需要回滚应用实例时,只需要找到当时的部署记录,即一条 Pipelinerun 记录,代表了一次流水线运行。Horizon 通过该 Pipelinerun 记录找到对应的 commit 记录,然后将该记录之后的所有 commit 记录revert,最后触发 ArgoCD 的同步,这样就完成了一次回滚。</p><p><img src="/img/remote/1460000043935186" alt="rollback" title="rollback"></p><h4>GitOps 仓库</h4><p>Horizon 通过拓展 Helm Chart,设计了一套 Template 系统。Template 包含三个部分,<a href="https://link.segmentfault.com/?enc=ljVnJ4FC45t%2BTtBL1PIuqQ%3D%3D.Niz9Z2ozojAmDLoKHgMlq3DB7avBDzdtDFq6jz%2FzeJd18SRF08PrskKmT8Q%2BP8ejT5HE6r%2Fm1W80PQibh%2BrDTg%3D%3D" rel="nofollow">Helm Chart</a>,<a href="https://link.segmentfault.com/?enc=yCKZRkse0L4IxoaVEXQyNg%3D%3D.L5enptWV2SjvctDZZHLc6JAApocIPYjeuUk8yN1SIyI%3D" rel="nofollow">JsonSchema</a> 和 <a href="https://link.segmentfault.com/?enc=bUtw5TzLORpDjiwnYN3YEA%3D%3D.%2FE7y2vkyU8UhTCru4IjXIjul58tA5LCFHC5tTRIAIv5sYvJYFHAL3LlzxTJ0iOIGFw5GosTE%2FZgIg5ZXlkRnOg%3D%3D" rel="nofollow">ReactJsonSchemaForm</a>。Horizon 会通过 ReactJsonSchemaForm 渲染表单,获取用户输入,并使用 JsonSchema 验证用户输入,确认无误后,记录到 GitOps 仓库的相关文件中。GitOps 仓库是一个 Helm Chart 仓库,部署时,ArgoCD 通过 Helm 渲染 Manifest,并将 Manifest 应用到 Kubernetes 中。Horizon 管理员可以通过自定义 Template,实现部署各种类型的应用,非常灵活。</p><p>以下为 GitOps 仓库结构</p><p><img src="/img/remote/1460000043935187" alt="gitops-repo-tree" title="gitops-repo-tree"></p><p><code>Chart.yaml</code> 文件是 Helm Chart 的标准,通过 dependency 字段,引用预先定义的 Horizon Template。</p><pre><code class="yaml">apiVersion: v2
name: demo
version: 1.0.0
dependencies:
- name: deployment
version: v0.0.1-ec06d596
repository: https://horizon-harbor-core.horizon.svc.cluster.local/chartrepo/horizon-template</code></pre><p><code>application.yaml</code> 包含了用户通过 ReactJsonSchemaForm 表单填写的数据。</p><pre><code class="yaml">deployment:
app:
envs:
- name: test
value: test
spec:
replicas: 1
resource: x-small</code></pre><p><code>pipeline-output.yaml</code> 包含了 CI 阶段的输出,因为在 Horizon 中 CI 也是可以自定义的,所以该文件的内容也是不固定的。默认的 CI 脚本输出如下:</p><pre><code class="yaml">deployment:
image: library/demo:v1
git:
branch: master
commitID: 28992d8f35a6ef38d59181080b3728df9540d8d6
url: https://github.com/horizoncd/springboot-source-demo.git</code></pre><table><thead><tr><th>参数</th><th>描述</th></tr></thead><tbody><tr><td>.Values.image</td><td>CI 阶段构建 image 的全路径</td></tr><tr><td>.Values.git.{ref}</td><td>源代码仓库的引用类型,可以是 branch、tag、commit</td></tr><tr><td>.Values.git.commitID</td><td>构建代码的 commit ID</td></tr><tr><td>.Values.git.url</td><td>源代码的引用链接</td></tr></tbody></table><p><code>pipeline.yaml</code> 包含了 CI 阶段的配置信息,Horizon 管理员可以通过自定义 CI 以支持更多的构建类型</p><pre><code class="yaml">pipeline:
buildType: dockerfile
dockerfile:
path: ./Dockerfile</code></pre><table><thead><tr><th>参数</th><th>描述</th></tr></thead><tbody><tr><td>.Values.buildType</td><td>该应用的构建类型,默认是“dockerfile”</td></tr><tr><td>.Values.dockerfile.path</td><td>dockerfile 相对于源代码仓库的路径</td></tr><tr><td>.Values.dockerfile.content</td><td>dockerfile 的内容</td></tr></tbody></table><p><code>sre.yaml</code> 包含了一些管理员配置,比如ingress、默认的超售比等等。使用 <code>sre.yaml</code> 文件修改管理员配置,既可以做到关注点分离,又保证了 GitOps 应用修改的体验统一。比如可以通过配置<code>nodeAffinity</code>,将应用部署到特定的节点上。SRE在修改<code>sre.yaml</code>后,也需要提交 PR 到 GitOps 仓库,并通过 review 机器人完成多人审核,合入到发布分支,最后在 Horizon 上执行发布,即可完成变更。</p><pre><code class="yaml">deployment:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cloudnative/demo
operator: In
values:
- "true"</code></pre><p>system目录下的文件记录了部署的元信息</p><p><code>env.yaml</code> 记录了部署环境相关的信息</p><table><thead><tr><th>参数</th><th>描述</th></tr></thead><tbody><tr><td>.Values.env.environment</td><td>环境名</td></tr><tr><td>.Values.env.region</td><td>Kubernete 名</td></tr><tr><td>.Values.env.namespace</td><td>Namespace</td></tr><tr><td>.Values.env.baseRegistry</td><td>image 仓库的地址</td></tr><tr><td>.Values.env.ingressDomain</td><td>ingress 域名</td></tr></tbody></table><pre><code class="yaml">deployment:
env:
environment: local
region: local
namespace: local-1
baseRegistry: horizon-harbor-core.horizon.svc.cluster.local
ingressDomain: cloudnative.com</code></pre><p><code>horizon.yaml</code> 包含了该应用在 Horizon 中的信息</p><table><thead><tr><th>参数</th><th>描述</th></tr></thead><tbody><tr><td>.Values.horizon.application</td><td>应用名</td></tr><tr><td>.Values.horizon.clusterID</td><td>集群ID(这里的集群指应用实例,应用为配置集合)</td></tr><tr><td>.Values.horizon.cluster</td><td>集群</td></tr><tr><td>.Values.horizon.template.name</td><td>模板名</td></tr><tr><td>.Values.horizon.template.release</td><td>模板版本</td></tr><tr><td>.Values.horizon.priority</td><td>优先级</td></tr></tbody></table><p><code>restart.yaml</code> Horizon 通过修改该文件内容,重启所有 Pod</p><table><thead><tr><th>参数</th><th>含义</th></tr></thead><tbody><tr><td>.Values.restartTime</td><td>重启时间</td></tr></tbody></table><pre><code class="yaml">deployment:
restartTime: "2023-01-06 18:28:49"</code></pre><h2>结语</h2><p>通过以上部署模式,我们可以很方便的管理数十个环境上百管控面管控面组件的部署,而且每个环境的配置都是独立的,修改对应的配置仓库不会影响其他环境的部署。同时,基于 Horizon 平台,我们实现了部署的自动化,用户发布应用时,只需要填写表单,即可完成应用发布,无需运维人员介入,运维效率提升 10 倍以上。Horizon 平台如今每天的发布数量已经达到了 1000+,这是传统运维模式难以企及的。在达成高效发布的同时,也保证了发布的质量,每次发布都有对应的流水线记录,可以方便的回滚到任意版本。Horizon 将开发、运维、测试等多个团队的工作流程串联起来,实现了 DevOps 的理念。</p><p>GitOps 在云音乐的实践中,表现出了非常好的效果,但同时在实践中我们也发现了一些问题:</p><ol><li>修改不便:设想,如果我们有多个环境,每个环境都对应一个配置仓库,那么一旦需要修改一个统一的值,那么需要修改所有仓库。</li><li>密码管理:Git 仓库中数据都是明文显示,并且 Git 仓库会记住所有的历史修改,所以放在 Git 仓库中的明文信息应该加密。虽然社区里面开发了一些类似于 <a href="https://link.segmentfault.com/?enc=Y8iBAcY6bWhWWsSD9GrtkQ%3D%3D.fTVEK4CBzX6bim%2B668ig%2BglkX1qRt3LCsZRAYJ1EAt2%2FVz32jjUWvYDZSoe68KLF" rel="nofollow">git-secret</a> 的工具,但使用起来还是不太方便。这里需要注意的是,密码管理指将密钥放置于 Git 仓库中,和 Gitlab、Github secret并不一致。将密码放在 secret 中,就失去了 Git 仓库提供的 版本管理、回滚、审计等能力。</li><li>标准不统一:对于回滚的实现,到底是修改配置,reset 到对应版本;还是通过运行 CI,重新部署到环境中?对于不同环境的相同应用部署,到底是选择多个仓库,还是一个仓库多个环境?这些都没有统一的标准,需要根据自身情况选择。</li></ol><p>所以 GitOps 并不是银弹,使用者任需要基于自身情况判断,选择最适合自己的方案。 但是 GitOps 作为随着云原生出生的 DevOps 方法,还在快速发展中,相信以上提到的问题,以后都会逐渐被解决。我们也会持续关注、尝试 GitOps 领域的最新技术和解决方案。如果你对 Horizon 或者 GitOps,可以<a href="https://link.segmentfault.com/?enc=LimMprgf6a5LULr7Cg7Nlg%3D%3D.0Tg6JgjHjBSjuD31gzjUMai7aY21yCx5S9Uc3QsYG4I3eUnDTMYP7w2zy6b%2Bv%2Fik" rel="nofollow">加入我们</a>,和我们一起讨论。</p><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>
云音乐 KubeCost 助力 FinOps 降本增效
https://segmentfault.com/a/1190000043947811
2023-06-29T09:49:13+08:00
2023-06-29T09:49:13+08:00
云音乐技术团队
https://segmentfault.com/u/musicfe
0
<blockquote>本文作者:<a href="https://link.segmentfault.com/?enc=wKwzMHBsA4ynH%2FGjpcrotw%3D%3D.eBVAui%2FXD4rnRp4G4j5XvwBRJIUIxrWyYTpq%2Fh4QfME%3D" rel="nofollow">木心</a></blockquote><h3>背景</h3><p>目前很多互联网公司都告别了过去流量和业务迅猛增长的时期,进入了一个相对稳定发展的新阶段,业务增长可预期,成本控制成为了一个重要的议题。</p><p>在典型的互联网公司的成本组成中,IT 成本占比并不低,技术成本与人力成本的比例差不多在 1:2 ~ 1:2.5 左右, 降低 IT 成本显然能带来立竿见影的效果。</p><p>10 年来云计算、云原生、容器、Kubernetes、DevOps 等技术的高速发展,使得 IT 成本的管理变得更加复杂,也给成本的管理带来了更多的挑战。</p><p>目前大多数互联网公司,都基于 Kubernetes 实现资源的统一管控,实现统一的大池子,基于此的统一调度、分配、混合云等都是过去降本增效的重要手段。</p><p>在网易云音乐,我们通过 2 年多的时间完成了在线业务几乎 100% 的容器化,通过超售、统一调度、混合云、混合部署等行之有效的手段使得在线资源峰值利用率提升到 50%+,每年为公司带来数千万的成本节省。</p><p>但是,随着成本治理的深入,我们会发现,资源治理团队的压力会越来越大。因为研发一侧 DevOps 很容易获取资源,导致资源的增长也依然非常地快,并且在流程上缺乏管控(因为本质上从 DevOps 角度希望提效,传统的工单审批机制被摈弃)。</p><p>在云原生时代,随着资源池化之后,成本默认归属到了技术中心部门,业务部门对成本没有感知,同时缺乏有效的手段针将成本拆分到业务线,出现了典型的 <strong>大账问题</strong> ,导致无法有效评估业务 ROI。</p><p><img src="/img/remote/1460000043947813" alt="cost-challenge.png" title="cost-challenge.png"><br>总结一下存在的变化与挑战:</p><ul><li><strong>去中心化</strong>:随着云和云原生应用的蓬勃发展,传统的集中式财务预算和 IT 管理模式在向以业务为导向的分布式决策转型</li><li><strong>动态变化</strong>:云上的动态环境和弹性能力导致费用随业务负载不断变化</li><li><strong>过剩浪费</strong>:对资源和服务的即时访问使创新成为可能,但往往导致供应过剩</li></ul><p>这也就驱动了云音乐推进 <strong>FinOps</strong> 系统建设,即通过数据驱动工程、财务、技术、业务团队协作,实现对成本的洞察、优化和运营,驱动建立更广泛更多角色参与得经营责任制,协助组织实现 ROI 的最大化。</p><p><img src="/img/remote/1460000043947814" alt="finops-framework.png" title="finops-framework.png"></p><p>云音乐的 FinOps 系统目前还在内部使用,建设以及完善,后续我们会择机开源出来,共享给社区。</p><p>在 FinOps 开源之前,我们第一阶段先介绍下 "基于 Kubernetes 实现资源货币化,协助推进大账拆分小账" 的组件 <strong>KubeCost</strong>。</p><h2>KubeCost</h2><p>KubeCost 是一个基于 Kubernetes 的资源成本分析工具,通过对 Kubernetes 集群资源的动态分析,将成本动态的分配到业务线,让业务线更加地关注成本,从而更好地利用资源,提高资源利用率,提高业务 ROI。</p><h3>功能介绍</h3><h4>1 支持多种计费方案</h4><p>比如包年包月、按量计费。</p><ul><li><strong>包年包月</strong>:目前大多数互联网企业都是按照包年包月的方式购买云资源,或者拥有内部固定的专有资源池。在固定拥有资源池的情况下,本质上企业需要按照业务峰值购买资源。自然需要按照业务峰值向业务分摊费用。</li><li><strong>按需计费</strong>:在固有资源池情况下,往往有很多低峰期的资源是较为浪费的,为了提高资源利用率, 需要通过技术手段去充分利用低谷资源,比如在音乐场景,一些音频转码,音频特征分析等可以接受 T+1 的业务场景往往可以填补这些低谷。在这种场景下,需要按照业务实际使用的资源进行费用分摊,而不是按照常驻峰值。</li></ul><p>备注:SPOT 资源(比如为了引导用户使用夜间资源,不同时间点价格不同)暂时不支持,后续会支持。</p><h4>2 支持混合云多云计费</h4><p>除了使用内部固有资源,云音乐也在使用公有云资源,比如阿里云,AWS 等。针对这种混合云以及多云场景,需要支持不同环境资源采用不同的计费单价。</p><h4>3 计费模型</h4><p>遵循 OpenCost 标准的计费模型,<a href="https://link.segmentfault.com/?enc=O7i6rQZT9AkpiqYEsy57PQ%3D%3D.X7OZzXaDcBEn31YD8wiHPOLaZ2Lbc4w63ZEcqBLbWrhRkREjzsIiYCTEPZTvvkhcgcga%2F3Q0k5R582XSqu5Dxp0clvxjcvygZI07hLeZl7A%3D" rel="nofollow">OpenCost Specification</a></p><p>基本的原则就是, <strong>allocate = Max(Usage, request)</strong></p><p>为了确保 <strong>计费稳定、可靠、可回赎、可重复</strong>, 基础计费单元,默认按照 10min,并且按照墙上时间对齐作为稳定的基础计费数据来源。</p><p>下图为基础的计算过程示例:<br><img src="/img/remote/1460000043947815" alt="kubecost-calculate.jpg" title="kubecost-calculate.jpg"></p><h4>4 支持的计费资源类型</h4><ul><li>CPU</li><li>Mem</li><li>GPU</li><li>等等</li></ul><p>在 Kubernetes 中,交付的 workload 非常多样,无法使用云厂商虚拟机的按照既定的规格分配进行计费。因此目前是按照不同资源的单价对资源实体,比如 POD 进行资源核算进行独立计费,分别计算出 CPU 费用,内存费用等,再聚合为 POD 的总费用。进一步汇总到某个应用微服务的费用。</p><h4>5 支持丰富地过滤以及聚合</h4><ol><li><strong>支持按照 Label 进行过滤</strong>:提供类似 kubernetes 接口的 label filter 机制,方便用户按照自己的业务场景(label)进行过滤</li><li><strong>支持 Label 聚合</strong>:按照 Namespace、Cluster,以及 POD 的 Label 进行聚合。</li></ol><p>比如如下为查询,所有通过云音乐标准 DevOps(<a href="https://link.segmentfault.com/?enc=rT74kX6wJWXhhBQKI%2FEsew%3D%3D.rk9i0AirbK%2F0OY7MHdvWaphqZQLYPy4loGseM2OUjKM%3D" rel="nofollow">HorizonCD</a>)系统接入的应用的成本的接口。</p><pre><code>POST http://localhost:8080/queryrange
Content-Type: application/json
{
"startTime": 1685894400,
"endTime": 1685980800,
"labelSelector": {
"matchExpressions": [
{
"key": "label_cloudnative_music_netease_com_application",
"operator": "Exists"
}
]
},
"groupBy":{
"groupDefinition":[
{
"type": "label",
"key": "label_cloudnative_music_netease_com_cluster"
},{
"type": "time",
"key": "10m"
}
]
}
}
</code></pre><h3>架构</h3><p><img src="/img/remote/1460000043947816" alt="kubecost-architecture.jpg" title="kubecost-architecture.jpg"></p><p>KubeCost 的架构如上图所示,架构设计主要考虑几点:</p><ol><li><strong>低侵入</strong>:方案尽量做到更低的侵入性,保障对业务流程的影响最小化,所以未考虑使用 webhook 或者 sidecar 等方案,而是基于旁路指标采集的方案。</li><li><p><strong>可靠性</strong>:确保系统组件故障对整个计费系统的影响最小化</p><ul><li>ApiServer + etcd: 3 副本以上部署,一定程度保障可靠性。另外管控面挂了,基本等同于管控面关了,无法新增 POD,也就是无法新增成本。历史资源申请数据都已经采集到 Prometheus 中。</li><li>prometheus:多实例,数据双备份存储。</li><li>Kubelet:故障之后,相关节点的 usage 数据获取不到。但主要也是某个节点没有数据,影响范围较小。</li></ul></li><li><strong>扩展性</strong>: 核心提供最小力度的原子成本数据,通过 OpenAPI 拓展支持各种计费方式,比如日 95 线峰值的计费、按需、包年包月等等不同的计费资源类型。</li><li><strong>大规模</strong>:支持 10w+,甚至百万以上的 POD 的成本数据统计和查询,数据存储选用使用 ClickHouse 进行数据的存储。按照测试 12w 两级别的 POD,10min 核算一次成本情况下,一个月压缩后存储量大约在 20GB 左右,本地一块 SSD 即可轻松保存几年的数据。</li><li><strong>易使用</strong>:可以灵活的通过各种不同的方式进行过滤和聚合。</li></ol><p>如上基础架构确保最简单最原始的数据的可靠保障,架构可以容忍 KubeCost 不断迭代和更新,结合底层数据幂等支持,可以方便地实现故障情况下简单重试,系统鲁棒性较高,也很方便进行数据正确性验证。另外复杂的计费逻辑可以放在 Plugin 中实现,保障系统的可扩展性和故障隔离性。</p><h3>底层数据模型</h3><p>如下为底层数据模型,采用 ClickHouse ReplacingMergeTree 方式,使得目前计算模式下故障情况下可以快速重试,而不会重算,大大减少故障情况下的手工运维。</p><pre><code>CREATE TABLE IF NOT EXISTS kubecost.kube_billing_infos
(
create_time Int64 COMMENT 'record create time',
start_time Int64 COMMENT 'billing start time',
end_time Int64 COMMENT 'billing end time',
item String COMMENT 'billing item, example: cpu, mem, gpu, etc',
cost Float64 COMMENT 'billing cost',
currency String COMMENT 'billing currency',
entity_primary_key String COMMENT 'entity primary key, cluster/namespace/pod/container',
usage_info Map(String, Float64) COMMENT 'etc:usage,request,allocate',
label_info Map(String, String) COMMENT 'basic labels',
price_info String COMMENT 'cost price info'
) Engine = ReplacingMergeTree(create_time)
PARTITION BY toYYYYMM(FROM_UNIXTIME(start_time))
ORDER BY (start_time, end_time, item, entity_primary_key)
</code></pre><h3>最后</h3><p>目前云音乐内部已经上线了第一版的 Finops 和 KubeCost 系统,这对于一些对成本比较敏感的团队是一个有效的支撑工具,他们可以基于部门、业务线等各种维度快速定位到自己关心的成本范围, 对于更好地评估 ROI 起到了关键作用。<br>另外为了驱动建立更广泛更多角色参与得经营责任制,我们设计了 <strong>Category</strong> 模型,支持根据标签任意圈选 Finops 里汇聚的任意范围成本、用量和预算数据,非常灵活有效。<br>后续我们将对 Finops 设计和实现上的方方面面进行整理总结,最终贡献到开源社区,欢迎大家过来交流。</p><p>最后欢迎各位关注了解云音乐标准 DevOps(<a href="https://link.segmentfault.com/?enc=cRdjaPnstEubQtSY5bQ%2BbQ%3D%3D.A1Q6tGnW%2FUBLepNVV5t8KzHQFMhrOXVZqQ%2F8q0XGcec%3D" rel="nofollow">HorizonCD</a>)系统,已在今年一月份开源,其受 ArgoCD、AWS Proton 启发,实践 Gitops 理念,通过模板体系进行最大实践,并且有完善的系统管理、权限、外部系统集成体系,<br>可点击 <a href="https://link.segmentfault.com/?enc=2eRcFOA5IOn1FfTFieKpcw%3D%3D.yZKZjrVO3GTIshdQGRY5i6SXDVpZZ6sZ%2Bact%2BWPgj9c%3D" rel="nofollow">官网地址</a> 了解更多详情,欢迎关注,提PR、issue,加入我们的社区,一起打磨完善产品,为中国云原生领域的发展做出贡献。</p><h2>参考</h2><ul><li>OpenCost: <a href="https://link.segmentfault.com/?enc=9V5ae11OdcBPHubJC4xdng%3D%3D.inER%2Fr%2FrSr6beIKOYEI%2B5OOIoY2Aq50lUPn8qB1nXOEbhy9v%2BziP0O8MGmf2vHj8Fwyymtx%2F4BzmgqGhlQRrRmCYfnPOFW1xyent9kWil2M%3D" rel="nofollow">https://github.com/opencost/opencost/blob/develop/spec/opencost-specv01.md</a></li><li>FinOps: <a href="https://link.segmentfault.com/?enc=J%2Fyi7WogILoGzJKTF9w7cg%3D%3D.bot%2BrwiGk%2FLH260FIvQbkfJBSuuB1ybtSNq3s5cZTYO9Khsox9CUHbc6Y3OPujzHoPfQ2Kec58kK%2B8lmcl4gPA%3D%3D" rel="nofollow">https://www.finops.org/introduction/what-is-finops/</a></li><li>Labels and Selectors: <a href="https://link.segmentfault.com/?enc=pYXbQzTxPYBn2mw%2Bne6rZg%3D%3D.N%2FLW18yA9ME9Uoq4dm8DuuPwNWM3vrSszP9GeCSr7W0IAGpdjhp9bS5Ybs2ny22eHC4BJFUUvk3nO4fH16QL2PmZLZSdUcv3QIS9R0RH6F8%3D" rel="nofollow">https://kubernetes.io/docs/concepts/overview/working-with-obj...</a></li></ul><blockquote>本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!</blockquote>