ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

You Don‘t Know JS 系列解读:JavaScript 性能基准测试与调优(Benchmarking Tuning)

You Don‘t Know JS 系列解读:JavaScript 性能基准测试与调优(Benchmarking  Tuning) 教程文档【免费下载链接】you-dont-know-js-ru Russian translation of You Dont Know JS book series项目地址https://gitcode.com/gh_mirrors/yo/you-dont-know-js-ru点击查看免费下载导读本文围绕 You Dont Know JS: Async Performance 系列本仓库为俄语译本见 async performance/README.md第六章《Benchmarking Tuning》展开。前五章分别解决了异步/并发层面的编码模式性能第 14 章与程序架构层面的宏观性能第 5 章详见 async performance/ch5.md本章则将镜头对准微性能micro performance如何准确、可靠地测量单条表达式/语句的执行速度判断哪些性能指标真正重要、哪些只是幻觉以及如何区分二者。读完本文你将掌握一套从错误的Date.now()计时法到基于 Benchmark.js 的统计可靠测试、再到TCO 尾调用优化的完整方法论。一、为什么绝大多数人写错了基准测试1.1 从一段教科书式错误的计时代码说起大多数 JS 开发者如果被要求测量某段操作的执行时间第一反应几乎都是这样写var start (new Date()).getTime(); // 或 Date.now() // do some operation var end (new Date()).getTime(); console.log( Duration:, (end - start) );这段代码看似直观实则充满了陷阱计时器精度不足某些平台并没有毫秒级精度而是以更大的粒度刷新计时器。例如老版本 Windows以及对应的 IE只有 15ms 的精度——这意味着被测操作至少要运行 15ms 才能得到一个非0的读数。报告出0并不代表不到 1ms而可能只是没到 15ms。单次运行无统计意义无论报出什么时长你唯一能确定的是这一次恰好跑了这么久。你几乎无法确认它每次都会这么快——引擎或系统在那个瞬间可能恰好没有干扰也可能恰好有干扰。读数不可信报出4也未必代表 4ms取start/end两个时间戳之间本身就可能夹杂其他延迟。测试过于理想化JS 引擎可能为这个被隔离的测试用例专门做了优化而在真实程序中这种优化会被稀释甚至完全失效导致实际运行比测试慢得多。结论是用这种方式做出来的基准测试基本无用而且危险——它不仅给你虚假的信心也会误导那些不思考测试条件的人。1.2 重复就能救回来吗——循环与统计学那我在外面套一层循环让整体测试时间变长不就行了把操作重复 100 次总共 137ms除以 100 得单次平均 1.37ms不对。单纯的数学平均值不足以支撑你对整个应用性能做外推判断。100 次迭代中哪怕只有一两个离群值高或低就会扭曲平均值再把这个结论反复应用到各处偏差会被无限放大。更合理的是按时间跑而不是按次数跑让测试循环持续到累计一定时间为止。但跑多久取决于计时器的精度——精度越差需要跑得越久才能把误差百分比压下来。文中给出两个关键数据15ms 计时器要把误差压到 1% 以下每个测试周期需要跑 750ms而 1ms 计时器只需要 50ms就能获得同样的置信度。单一样本仍然不够你需要大量样本做平均还需要了解最慢样本多慢、最快样本多快、两者差距多大——不仅要一个跑得多快的数字还要一个可量化的这个数字有多可信的度量。这些只是起步的底线。换句话说如果此前你对基准测试的态度不够严肃那么你还不了解什么是正确的基准测试。二、Benchmark.js把统计交给专业工具标准偏差、方差、误差幅度margin of error——这些统计学概念如果你没有真正理解就没有资格自己手写基准测试逻辑。幸运的是John-David Dalton 与 Mathias Bynens 等研究者已经写好了统计可靠的基准测试工具Benchmark.js作者在文中给出的建议非常直接就用那个工具。2.1 基本用法function foo() { // operation(s) to test } var bench new Benchmark( foo test, // 测试名称 foo, // 被测函数只测函数体内容 { // .. // 可选额外选项详见官方文档 } ); bench.hz; // 每秒执行的操作次数operations per second bench.stats.moe; // 误差幅度margin of error bench.stats.variance; // 样本方差 // ..Benchmark.js 替你处理了搭建公平、可靠、有效基准测试所需的全部复杂细节。如果要对比方案 X 与方案 Y只需把两个测试放进一个SuiteBenchmark.js 的组织单元让它们头对头运行再比较统计量得出结论。2.2 超越浏览器性能回归测试Benchmark.js 既能在浏览器中运行配合后文 jsPerf.com也能在 Node.js 等非浏览器环境中运行。文中特别提到一个被低估的使用场景在 Dev/QA 环境中针对应用关键路径critical path跑自动化的性能回归测试——就像部署前跑单元测试套件一样把当前结果与历史基准对比持续监控性能是改善了还是恶化了。2.3 Setup/Teardown 的经典陷阱前文代码中略过了{ .. }选项对象但其中setup与teardown两个选项值得单独讨论——它们定义了测试用例运行前/后要调用的函数。极其重要的理解setup与teardown代码不会在每个测试迭代iteration前运行正确的理解是存在一个外层循环重复周期 cycle和一个内层循环重复测试迭代。setup/teardown只在每个外层周期的开始/结束运行一次而不是在内层循环内部。看这个例子a a w; b a.charAt( 1 );你写的setup是var a x;直觉告诉你每次迭代开始时a都是x。不是它只在每个周期开始时是x随后重复的 w拼接会让a越变越长——虽然你只访问1位置的字符w。这个陷阱最常见的翻车场景是 DOM 副作用你以为父元素每次都是空的实际它却被不断添加子元素结果被严重污染。结论side effect 类操作必须谨慎放进被测代码或妥善放在 setup 之外。三、上下文为王Context Is King3.1 20% 的差异真的有意义吗假设测试显示 X 每秒执行 10,000,000 次操作Y 每秒 8,000,000 次。数学上你可以说Y 比 X 慢 20%——数学没错但结论的含金量远低于你的想象10,000,000 ops/s 10,000 ops/ms 10 ops/µs即单次操作约 0.1µs 100ns。人眼通常无法分辨低于100ms的差异——这比 100ns 慢了一百万倍。即使采用更激进的最新研究结论人脑最快约 13ms 处理一个事件X 仍然比人脑可感知的速度快 125,000 倍。X 与 Y 之间 2,000,000 ops/s 的差距折算下来约20ns在最好情况下也只是人脑可感知间隔的 65 万分之一。作者毫不客气地给出结论这些性能差异根本无关紧要。除非该操作要在紧循环里连续执行 650,000 次甚至 5,000,00010,000,000 次才勉强接近可能被感知的量级。而像xvsx这种微型操作的绝大多数基准测试结果对应该选 X 还是选 Y的结论而言基本是废纸。3.2 引擎优化的黑箱你测的并不是你写的你无法可靠地外推隔离测试里 X 比 Y 快 10µs所以 X 永远更快。现代引擎远比这复杂。文中用了一个纯假设的例子var twelve 12; var foo foo; // test 1 var X1 parseInt( twelve ); var X2 parseInt( foo ); // test 2 var Y1 Number( twelve ); var Y2 Number( foo );即便你熟悉parseInt(..)与Number(..)的语义差异结果仍可能出乎意料因为引擎可能发现twelve/foo只被使用一次于是**内联inline**这些常量值把Number(12)直接替换成12触发死代码消除dead-code elimination发现X、Y变量根本没人用于是两个测试什么都没干基于固定输入做优化——而真实程序中输入是变化的优化决策可能完全不同甚至不生效因为基准工具让代码跑了成千上万次才触发优化而真实程序只跑几百次引擎判定不值得优化。关键结论不是不要测试而是测试不真实的代码只能得到不真实的结果。在可行范围内应该测试真实、非琐碎non-trivial的代码片段并尽量贴近真实运行条件。像xvsx这类微基准几乎可以默认其结论是假的。四、jsPerf.com跨环境众包测试4.1 为什么必须跨环境单靠 Benchmark.js 在自己的环境里测是不够的高端台式机的 Chrome 与智能手机上的 Chrome 移动版性能天差地别满电手机与只剩 2% 电量、正在关闭射频和处理器核心的手机又不一样。要做出X 比 Y 快这样跨环境的断言就必须在尽可能多的真实环境中测试并对照你的用户画像交叉验证。jsPerf正是为此而生它基于前面提到的 Benchmark.js运行统计准确、可靠的测试并把测试放在公开 URL 上供人分享每次运行的结果都被收集并持久化页面会累计绘制结果曲线供所有人查看。4.2 创建测试的小技巧创建测试时初始有两个测试用例框你可以按需增删。一个实用技巧如果只想测单一方案而非头对头可以在首次创建时给第二个输入框填占位文本然后编辑测试并留空第二个框将其删除。页面级 setup导入库、定义工具函数、声明变量等可在页面初始化时配置周期级 setup/teardown 的语义与上文 Benchmark.js 一节完全一致。4.3 理智检验常见的有缺陷测试jsPerf 资源虽好但上面大量已发布的测试在仔细分析后漏洞百出。文中给了三个典型案例案例 A把循环写进被测代码// Case 1 var x []; for (var i0; i10; i) { x[i] x; } // Case 2 var x []; for (var i0; i10; i) { x[x.length] x; } // Case 3 var x []; for (var i0; i10; i) { x.push( x ); }需要反思的点开发者习惯把自己的for循环塞进测试却忘了Benchmark.js 已经替你完成了所有重复——这些for循环极可能是完全多余的噪音。x的声明与初始化被放进每个测试用例可能不必要。回忆前面的陷阱如果x []放在setup里它不会在每个迭代前运行而是每个周期开头运行一次——x会持续增长得很大而不是for循环暗示的大小10。那么测试意图是只测引擎在小数组size 10下的行为吗如果是你是否又过度聚焦于内部实现细节还是测试拥抱了数组会实际增长得很大的真实上下文是想测x.length或x.push(..)追加操作的额外开销吗这或许是个合理的测试但push(..)是函数调用当然比[..]索引访问慢——案例 1、2 比案例 3 更公平。案例 B内联函数表达式带来的苹果对橙子// Case 1 var x [John,Albert,Sue,Frank,Bob]; x.sort(); // Case 2 var x [John,Albert,Sue,Frank,Bob]; x.sort( function mySort(a,b){ if (a b) return -1; if (a b) return 1; return 0; } );表面意图是测自定义比较器比内置比较器慢多少但第二个用例把mySort(..)写成内联函数表达式——它不仅测了自定义 JS 函数还顺带测了每次迭代创建新函数表达式的开销相关测试表明仅隔离创建内联函数表达式 vs 引用预先声明的函数这一项前者可能慢2%20%。更公平的写法是把mySort声明放在页面 setup 里然后在测试用例中按名称引用x.sort(mySort)。案例 C不同结果直接宣告测试无效// Case 1 var x [12,-14,0,3,18,0,2.9]; x.sort(); // Case 2 var x [12,-14,0,3,18,0,2.9]; x.sort( function mySort(a,b){ return a - b; } );内置sort(..)的比较器会把值强制转成字符串做字典序比较即做了mySort没有的额外工作导致两个用例输出不同结果前者得到[-14, 0, 0, 12, 18, 2.9, 3]后者更符合直觉意图得到[-14, 0, 0, 2.9, 3, 12, 18]。两个用例结果不同几乎必然使整个测试失效——你得到的任何数据都是假的。案例 D细微的不对称// Case 1 var x false; var y x ? 1 : 2; // Case 2 var x; var y x ? 1 : 2;意图或许是测? :对非布尔x做布尔强制的开销参见本书系列 types grammar 分册。但微妙的问题在于用例 1 执行了赋值用例 2 没有——你实际上在两个用例中做了不对称的工作。为消除这潜在的虽然微小偏差// Case 1 var x false; var y x ? 1 : 2; // Case 2 var x undefined; var y x ? 1 : 2;现在两边都有赋值真正想测的是否发生强制转换才被更准确地隔离出来。五、如何写出好测试好测试的创作要求对两个用例之间的差异做细致的分析思考这些差异是有意的还是无意的有意的差异当然正常且没问题但无意差异太容易制造并扭曲结果必须极其小心。你可能确实有意制造了差异但对其他读者并不显而易见——他们会因此错误地怀疑或轻信你的测试。解决办法是写更好、更清晰的测试并花时间用 jsPerf 的 Description 字段和代码注释明确记录测试意图标注出有意差异帮助他人和未来的自己发现可能扭曲结果的无意差异。把与测试无关的东西在页面或测试 setup 中预先声明让它们处于被测计时范围之外。与其从真实代码中抠出一小段脱离上下文单独测不如包含更大但仍相关的上下文来测试——这种测试通常更慢但你在其中发现的任何差异在真实上下文中都更相关。六、微性能Microperformance6.1 引擎可能运行与你写的不一样的代码面对性能基准测试你需要接受的第一件事是你写的代码并不总是引擎真正运行的代码。第 1 章讨论过编译器的语句重排statement reordering这里更进一步编译器有时不只改变执行顺序还会改变代码的实质内容。看这个例子var foo 41; (function(){ (function(){ (function(baz){ var bar foo baz; // .. })(1); })(); })();你可能觉得最内层函数对foo的引用需要做三层作用域查找——但《Scope Closures》分册见 scope closures讲过编译器通常会缓存这类查找跨作用域引用foo并不会真的多花钱。更深一层如果编译器发现foo只在这一个地方被引用、且值永远就是41它完全可以删除foo变量并内联这个值(function(){ (function(){ (function(baz){ var bar 41 baz; // .. })(1); })(); })();同理baz也可能被编译器做类似分析和重写。当你把 JS 代码看作给引擎的提示或建议而非字面要求时就会意识到对琐碎语法细节的执念多半是站不住脚的。再看经典的阶乘递归function factorial(n) { if (n 2) return 1; return n * factorial( n - 1 ); } factorial( 5 ); // 120同样的代码用 C 语言配合高级优化编译编译器会发现factorial(5)可以直接替换成常量120把函数和调用整个消除。另外一些引擎有递归展开unrolling recursion实践可能把递归重写成循环function factorial(n) { if (n 2) return 1; var res 1; for (var in; i1; i--) { res * i; } return res; } factorial( 5 ); // 120所以如果你曾纠结于n * factorial(n-1)和n * factorial(--n)谁更快、甚至为此做了基准测试你可能忽略了在更大上下文里引擎可能根本不跑这两行代码——因为它把递归展开了。6.2--nvsn--典型的无效执念--n常被引证为比n--更快理论上在汇编层面少一点工作。这在现代 JavaScript 中基本是无稽之谈——这类事应该交给引擎处理。对比下面三个for循环// Option 1 for (var i0; i10; i) { console.log( i ); } // Option 2 for (var i0; i10; i) { console.log( i ); } // Option 3 for (var i-1; i10; ) { console.log( i ); }即便你有些理论认为选项 2 或 3 比选项 1 快一点点这本身就很可疑选项 3 因为要配合前置i而从-1起步明显更令人困惑选项 1 与 2 的差异则完全无关紧要。引擎完全可能在看到i的地方安全地替换成等价的i——你花在选 A 还是选 B 上的时间纯属浪费。6.3 缓存x.length理论很美实测无效var x [ .. ]; // Option 1 for (var i0; i x.length; i) { // .. } // Option 2 for (var i0, len x.length; i len; i) { // .. }理论是x.length既然不变缓存到len可以省掉每次迭代查询的代价。但如果你真的跑基准测试就会发现理论上听起来不错实践中任何测量差异在统计上完全无关紧要。而且有证据表明在 v8 等引擎里预先缓存长度反而可能让事情稍微变糟。作者的建议很直白别试图比你的 JS 引擎更聪明在性能优化这件事上你大概率会输。6.4 并非所有引擎都一样不同浏览器里的 JS 引擎都可以符合规范但对代码的处理方式却可能截然不同——JS 规范几乎不要求任何性能相关的东西唯一的例外就是本章后面要讲的 ES6 TCO。引擎可以自由决定把优化资源倾注给某个操作而牺牲另一个操作。社区尤其是 Node.js 使用者中有一股潮流深入分析 v8 的内部实现细节写出专门适配 v8 的代码。这类努力确实可能获得相当可观的性能回报但文中提醒我们注意你的代码真的只打算跑在一个引擎上吗即便现在只面向 Node.js能保证 v8 永远是那个引擎吗几年后会不会选择另一个服务端 JS 平台届时你当初的优化可能在新引擎上变成更慢的写法。即使一直跑在 v8 上v8 也可能在某个版本改变某些操作的实现让过去的快变成慢。文中举了两个真实的历史教训字符串拼接的join()神话曾经把多个字符串放进数组再join()拼接比直接用快——这与当时字符串在内存中的存储管理实现细节有关最佳实践随之风靡业界。但后来引擎改变了内部管理方式专门为拼接做了优化沿着牛道铺路——按既有广泛用法去标准化/优化某种做法于是一夜之间所有用join(..)拼字符串的存量代码都变成了次优方案。Opera 的 String 对象建议曾有一段时间Opera 在原始包装对象的装箱/拆箱处理上与其他浏览器不同于是建议开发者用String对象而非原始string值来访问length或charAt(..)。这个建议在当时对 Opera 或许正确但对其他主流浏览器则完全相反——它们恰恰针对string原始值而非对象包装器做了优化。作者的态度是纯基于引擎实现细节尤其是单一引擎的细节做大规模性能优化要非常谨慎。反过来也要警惕不要为了绕开某个引擎的性能短板而改代码——历史上 IE 常是这类妥协的牺牲品但为一个浏览器的问题改变写法可能让代码在所有其他浏览器中都次优。更务实的做法是写正确的代码同时把性能问题反馈给浏览器厂商多数浏览器都有公开 bug tracker。作者给出 Tip只有当某个浏览器的性能问题是真正的致命阻断器show-stopper而非单纯烦人时才值得绕开并且要仔细确认该 hack 不会在另一个浏览器中产生明显的负面副作用。毕竟没有什么比一个临时 hack 更永久——你现在为绕过性能 bug 写的代码很可能活得比浏览器里的那个 bug 还久。6.5 大局观关键路径Critical Path才是王道与其纠结微性能细节不如把注意力放在大局优化上。判断标准首先是你是否知道代码是否处于关键路径上——不在关键路径上的优化几乎不值钱。著名的 Knuth 名言过早优化是万恶之源常被断章取义文中给出了完整上下文程序员浪费了大量时间思考或担心程序中非关键部分的速度而这些效率尝试在考虑调试和维护时会产生强烈的负面影响。我们应该忘记小的效率大约 97% 的时间里都是如此过早优化是万恶之源。然而我们不应该放弃那关键的 3%里的机会。重点为作者所加合理的转述是非关键路径优化是万恶之源。关键在于判断你的代码是否在关键路径上——在就该优化不在就不该。作者甚至提出更绝对的说法花在关键路径优化上的时间再多都不算浪费哪怕省下的很少而花在非关键路径上的优化无论如何都不合理哪怕省下很多。关键路径的例子会被反复运行的热代码或用户能直接感知的 UX 关键位置——比如动画循环、CSS 样式更新。文中给出一个动画循环里把字符串转数字的例子var x 42; // 需要数字 42 // Option 1: 让隐式强制转换自动发生 var y x / 2; // Option 2: 使用 parseInt(..) var y parseInt( x, 0 ) / 2; // Option 3: 使用 Number(..) var y Number( x ) / 2; // Option 4: 使用 一元运算符 var y x / 2; // Option 5: 使用 | 一元运算符 var y (x | 0) / 2;判断要点parseInt(..)能完成任务但做的工作多得多——它是在解析字符串而非单纯强制转换因此几乎可以确定是较慢的选项应尽量避免。但如果x可能是需要解析的值比如来自 CSS 样式查询的42px那parseInt(..)就是唯一合适的选择。Number(..)是函数调用行为上与一元运算符等价但可能需要更多机制来执行函数调用实际可能略慢——当然引擎也可能识别出这种行为对称性直接为你内联掉Number(..)等价于x。但请记住在大多数情况下纠结xvsx | 0很可能白费力气。这是典型的微性能问题不应让它支配或降低程序的可读性——在若干性能大致相近的选项中可读性应该是另一个重要考量。更完整的类型强制转换机理见 types grammar 分册。七、尾调用优化Tail Call OptimizationTCO7.1 什么是尾调用ES6 包含一项涉足性能领域的强制性要求尾调用优化。所谓尾调用是指出现在另一个函数尾部的函数调用——该调用结束后除了可能返回其结果值外再无其他事情要做。function foo(x) { return x; } function bar(y) { return foo( y 1 ); // 尾调用 } function baz() { return 1 bar( 40 ); // 不是尾调用 } baz(); // 42foo(y1)在bar(..)中是尾调用因为foo(..)结束后bar(..)也就结束了只是返回foo(..)的结果。而bar(40)不是尾调用——它结束后其结果还要先加1baz()才能返回。7.2 栈帧复用与递归解放调用一个新函数需要预留一块管理调用栈的内存称为栈帧stack frame。上面的片段通常需要同时为baz()、bar(..)、foo(..)各保留一个栈帧。但如果 TCO 能力的引擎识别出foo(y1)处于尾位置tail position——bar(..)基本已经完成——那么调用foo(..)时就不必新建栈帧而是复用bar(..)的现有栈帧。这不仅更快而且更省内存。这种优化在简单片段里无足轻重但对递归却是天翻地覆的没有 TCO 时引擎必须对不同递归深度施加任意且各不相同的限制来防止内存耗尽有了 TCO处于尾位置的递归调用可以基本无界运行因为始终只用一个栈帧7.3 重写阶乘为 TCO 友好形式前面那个递归版factorial(..)并不是 TCO 友好的return n * factorial(n-1)里乘法发生在尾调用之后。把它改写成累积器accumulator风格function factorial(n) { function fact(n,res) { if (n 2) return res; return fact( n - 1, n * res ); } return fact( n, 1 ); } factorial( 5 ); // 120这个版本仍然是递归但内层两次fact(..)调用都处于尾位置因此可被 TCO 优化。注意TCO 只在真正存在尾调用时生效。如果你写的递归函数没有尾调用性能仍会退化为常规的逐帧分配引擎对递归深度的限制依然适用。许多递归函数可以像上面这样改写但需要细心。7.4 为什么 ES6 要把 TCO 变成必须ES6 之所以要求引擎实现 TCO 而非放任自流是因为缺乏 TCO往往会降低开发者用递归实现某些算法的意愿——他们害怕调用栈深度限制。如果缺乏 TCO 只是让所有情况优雅地退化为较慢性能ES6 大概不会把它列为硬性要求正因缺乏 TCO 可能让某些程序变得不可行它才更像语言的重要特性而非隐藏的实现细节。ES6 自此保证所有 ES6 合规浏览器中开发者都可以依赖这项优化。这是 JS 性能的一次胜利。延伸阅读TCO 的完整语法形态Proper Tail Calls, PTC、手动重写技巧、运行时特性检测通过递归 try..catch探测引擎是否支持 TCO以及如何据此做元编程取舍见本系列 es6 beyond/ch7.md 的 Tail Call Optimization (TCO) 一节。八、总结有效测量一段代码的性能尤其是与同段代码的另一种写法对比需要极其细致的关注。不要自己手写统计可靠的基准逻辑——直接用 Benchmark.js但要注意测试的编排方式因为太容易构造出看似有效实则漏洞百出的测试哪怕极小的差异也会把结果扭曲到完全不可信。尽可能从尽可能多的不同环境收集测试结果以消除硬件/设备偏差jsPerf.com 是众包基准测试运行的绝佳平台。许多常见性能测试不幸地执着于xvsx这类无关紧要的微性能细节。写好测试意味着理解如何聚焦大局优化关键路径同时避开不同引擎实现细节这类陷阱。尾调用优化TCO是 ES6 起的一项强制优化让某些在 JS 中原本不可能的递归模式变得切实可行——尾位置的函数调用无需额外资源即可执行引擎不再需要为递归算法人为限制调用栈深度。本文基于仓库内 async performance/ch6.md 编写全系列目录见 async performance/toc.md章节导航见 async performance/README.md。本分册第 5 章关于程序级性能Web Workers、SIMD、asm.js的内容可参考 async performance/ch5.md。赞分享教程文档【免费下载链接】you-dont-know-js-ru Russian translation of You Dont Know JS book series项目地址https://gitcode.com/gh_mirrors/yo/you-dont-know-js-ru点击查看免费下载相关推荐深入理解JavaScript ES6及新特性——《You Dont Know JS》核心解读深入理解JavaScript ES6及新特性——《You Dont Know JS》核心解读 JavaScript ES6是JavaScript语言的革命性升文档教程浏览器内ADB调试Tango如何革新Android开发体验浏览器内ADB调试Tango如何革新Android开发体验 你是否曾为Android开发调试的繁琐环境配置而烦恼传统ADB客户端需要复杂的本地环境设置限制开发工具通信移动开发CLIJetifier-standalone 使用指南JAR/AAR/ZIP 文件的 AndroidX 转换Jetifier standalone 使用指南JAR/AAR/ZIP 文件的 AndroidX 转换 Jetifier standalone 是一款强大的上一篇如何免费把微信聊天记录永久导出WeChatMsg完整指南下一篇CN_GreenLumaGUI 开源项目使用手册创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表