JavaScript 语言凭着简单易用等特性以及强大的生态,成为行业内最受欢迎语言之一。V8 作为最强大的 JavaScript 虚拟机引擎,其性能、稳定性等指标受到了业务的关注。UC 内核团队多年深耕 V8 引擎,做过很多优化。本文将结合我们所做的优化,讲述 U4 内核的 V8 引擎是如何超越官方,实现快、稳、强特征的。
本文约 8500 字,阅读耗时约 30 分钟。
在介绍 UC 内核对 V8 引擎所做的优化之前,我们先来看一下 V8 执行 JS 代码的整个过程:
JS 在 V8 中的运行流程
若有函数被多次调用,成为热点代码,V8 则会使用其 TurboFan 优化编译器,结合前面收集的类型信息,对函数进行重新编译,生成更高效的本地代码(即汇编)运行。
在整个 JS 执行过程中,其中一个比较耗时的过程是从 JS 源码编译成字节码,这个过程对于相同的 JS 源码来说,每次都是重复的。为了加速 JS 的编译过程,减少不必要的重复编译,我们针对 V8 实现了 UC Disk Code Cache 和 JS AOT 两个优化功能。此外,绝大部分 JS 代码一般都是以解释执行的方式运行的,而 V8 解释器的字节码处理程序是使用其后端编译器 TurboFan 生成的。TurboFan 生成代码时优化力度不大,生成的代码质量不够理想,运行效率不高。为了加速 JS 的解释执行过程,我们将 V8 本地编译生成字节码处理程序时的优化编译器 TurboFan 换成了 UC LLVM Compiler,使得生成更高效和简洁的字节码处理程序,提升运行性能。
下面,将分别介绍上述的几个性能优化点,讲述我们是如何一次又一次地突破 V8 引擎的局限、提升 JS 运行性能的。
UC Disk Code Cache
UC Disk Code Cache 是针对于原生的 Chromium Code Cache 的一个改进版本,相对于 Chromium Code Cache,UC Disk Code Cache 能缓存更多的函数,使得运行更加高效。
在执行一个 JS 时,V8 会先对其进行编译。V8 使用了延迟编译的技术,最开始只会编译生成顶层函数(Top Level)的字节码,随后在 JS 运行过程中,执行到未编译过的内部函数时,才会对该内部函数进行即时编译。
在 JS 运行完成后,Chromium 会立即将之前编译过的所有函数进行序列化,生成 Code Cache,保存到磁盘文件。后续再次运行该 JS 代码时,直接从磁盘文件中读取 Code Cache、进行反序列化,不需要重头编译,以此提升运行效率。
然而,JS 代码执行结束,并不代表着该源码的使命已经完成。JS 在运行过程中,通常会注册一些 DOM 事件、设置定时器、使用 Promise 等发起异步任务,在这些事件和异步任务的处理过程中,会调用到更多之前未被执行过的函数,而这些函数的编译耗时,对整体性能以及用户操作体验上的影响也至关重要。
为了缓存更多的函数,UC Disk Code Cache 将 Code Cache 的生成时机尽可能推后,只有当退出或跳转等页面生命周期结束时,才会生成 Code Cache 并保存。UC Disk Code Cache 相对于 Chromium Code Cache 有如下几点不同:
支持范围:由于 UC Disk Code Cache 使用 JS 源码内容的 SHA1 作为标识匹配,因而可以支持 eval 以及 HTML 中内联的 <script> 代码;而 Chromium 使用 URL 去匹配,因而无法提供类似的支持。
UC Disk Code Cache 虽是 Chromium Code Cache 的改进版,但它的实现与 Chromium 完全不同。Chromium Code Cache 的实现基本全在 blink 中,而 UC Disk Code Cache 将关键实现下沉到 V8 里面,能够对 Code Cache 进行更优的控制,以及更好地做到功能和代码复用(如下面将介绍的 JSI,也对接并复用了 UC Disk Code Cache 的相关实现)。
UC Disk Code Cache 的实现,主要涉及以下改动和优化:
生成&保存时机
在跳转、退出、切后台等页面生命周期暂停或结束时生成 Code Cache,并使用后台线程保存到文件。
多线程&多进程并发
U4 内核使用的是多进程架构,同时,业务方可能会在多个 Service 进程中同时使用 U4 WebView,因而存在多个进程或多个线程同时访问同一个 Code Cache 的场景。我们对缓存文件的并发读写进行了优化,保证缓存操作安全的前提下,最大地提升并发性能。
Neon/SHA1 指令优化 SHA1
由于使用 JS 源码的 SHA1 Hash 作为唯一标识,SHA1 Hash 的计算性能也至关重要。不同的手机设备支持的 CPU 指令集不尽相同,我们分别使用 Neon 指令和 SHA1 专用指令实现 SHA1 Hash 算法,将线上大部分 JS 源码 SHA1 Hash 的计算耗时下降到了一毫秒以内。值得特别说明的是,使用 Neon 指令计算 SHA1 Hash,业界暂无开源实现,属于 U4 首创。
LRU 缓存管理
使用 LRU 算法管理缓存,根据预设的缓存总大小上限,优先对长时间没有被使用的缓存进行淘汰。
特殊 JS 优化
在普通的页面中,存在着许多较短小的 JS 代码,它们重新编译的时间非常短,同时缓存加载需要读磁盘,耗时波动较大不稳定,因而没有必要为它们生成 Code Cache,每次重新编译是最优的选择。另一方面,有页面可能使用了一个非常大的 JS 代码,它们生成的 Code Cache 也非常大,当加载该 Code Cache 时,会占用较多的磁盘 IO 资源,同时由于 V8 需要对 Code Cache 进行反序列化,在反序列化过程中,会创建大量的临时对象,这个过程可能会导致 V8 的堆内存急剧上升,频繁触发 GC,进而导致其耗时比重新编译更久,得不偿失。更有甚者,反序列化过程中 GC 次数达到上限时,V8 会放弃继续反序列化,退回到重新编译,而这之前的反序列化操作可能耗费了 10 多秒甚至更长时间。
使用线上 Top 30 多个站点进行测试表明,使用 UC Disk Code Cache 可使得 JS 执行总时间减少 49%,其中 JS 编译时间减少 19.9%,运行时间减少 51.8%。
JS AOT
JS AOT 的主要目的,是为了解决 UC Disk Code Cache 无法覆盖首次运行的问题,因为 UC Disk Code Cache 要在首次运行时生成 Code Cache,第二次运行时才可用上 Code Cache。
方案选择一:生成本地代码
AOT,即 Ahead of Time。说到 AOT,很多人一般会很自然地将其与本地代码(汇编)联系到一起。那么是否可以将 JS 预编译成本地代码,然后直接在手机上运行本地代码呢?
对于 JS 来说,编译为本地代码运行性能并不能得到提升,反而会引入一些新问题。如下:
• 体积大
由于 JS 语言的动态性,一个简单的 JS 代码在生成本地代码后,会变得非常大。比如获取对象中的某个属性,对于 C++ 语言,可以使用对象的地址加偏移量通过一条 ldr 指令即可完成;而对于 JS 语言,它需要先在对象自身中查找,若没找到,则需要在其 __proto__ 属性中查找,若仍没找到,则继续沿原型链向上查找,若最终仍然没有找到,则返回 undefined。将 JS 的这个属性查找逻辑转化为汇编去实现,将会变成非常多的指令。
再比如简单的 a + b,对于 C++ 通常也是一条汇编指令就能实现,而对于 JS 语言,它的实现标准非常复杂,如下图:
将上述的逻辑转化为汇编实现,其大小可想而知。
• 内存高
由于将 JS 转化成本地代码后,其体积变大很多,是 JS 源码的好几倍,这也会导致存储本地代码的内存占用也会变多。对于低端机,内存等资源十分短缺,这是不可承受的。
• 性能差
虽然从直观的感觉来看,运行本地代码比解释执行会更快,但是由于 JS 语言弱类型的特性,生成本地代码的 size 过大,会导致对于相同的操作, CPU 需要执行更多的指令,CPU 的缓存也更容易失效,使得 JS 的运行效率不升反降。另一方面,大部分 JS 函数只会被执行一次,本地代码对于需要被重复多次运行的函数来说才会比较有效。
• CPU架构不通用
目前在 Android 中,一般至少需要同时支持 arm 和 arm64 两种 CPU 架构,这就需要为不同的 CPU 架构生成不同的 AOT 文件,维护成本较高。
总结来说,将 JS 编译成本地代码运行,效果并不是想像中那么美好,它其实就是 V8 最初版本所实现的 Full Codegen,该方案早已被 V8 所废弃,历史证明它并适用于 JS。
方案选择二:Full Code Cache
编译成本地代码不可行,那是否可以将 JS 源码都提前全部编译为字节码,在运行时就不需要编译,从而获益呢?
经过对线上站点的调研,我们发现线上站点所使用的 JS 代码中,仅有 43% 的函数被运行过,其它大部分函数都不会被执行。如果将所有的代码全编译为字节码,生成 Full Code Cache ,会使得不需要被运行的函数也在反序列化时被加载进内存,造成 CPU 和内存资源浪费。
另外,线上站点的 JS 在编译成 Full Code Cache 后,生成的 Full Code Cache 大小平均是原 JS 代码大小的 2.6 倍左右。前面也介绍过,当 Code Cache 的 Size 过大时,加载会变慢,对性能有负作用。
我们使用某业务做了对比测试,发现 Full Code Cache 比 UC Disk Code Cache 慢 22.7% 左右。Full Code Cache 并不是最佳的选择。
方案选择三:Code Cache
Full Code Cache 的主要问题,是由于较大的 JS 生成 Code Cache 后会比较大,加载和反序列化过程将变得耗时。为了避免这个问题,可以采取同 UC Disk Code Cache 一样的方案,使用基于 PGO 的方式生成 Code Cache,仅生成需要被执行的 JS 函数的 Code Cache。
不过,与线上运行时生成的 UC Disk Code Cache 不一样的是,JS AOT 将在线下离线生成 Code Cache,这个方案会遇到一些新的问题:
机型通用性:为了照顾低端机,在低端机上少用内存等资源,V8 支持在初始化时设置低端机标识。而这类运行时标识的设置,会影响到 V8 的行为以及 Code Cache 的校验,使得 Code Cache 可能在不同的机器中不能通用。
我们在设计 AOT 方案时,综合权衡了以上方案的优势以及存在的问题,最终我们选择使用 Code Cache 作为 JS AOT 的内容,并提供在线生成和离线生成两种方案,分别支持不同应用场景的业务。具体方案如下:
方案一:在线生成
为了避免 V8 版本匹配的问题,我们提供了在线生成 AOT 的方案。在线生成 AOT,需要在 JS 被正式执行前,在后台线程进行预热生成 AOT。在 JS 预热时,有两种策略用以辅助生成 Code Cache。
策略一:默认规则
小型 JS 代码:对于短小的 JS 代码,存在 Code Cache 加载时间比编译时间长的问题,因而对小型 JS 代码不做 AOT,每次直接使用源码编译运行;
中等 JS 代码:中等大小的 JS 代码,其函数运行覆盖度高达 80% 以上,可以使用 Full Code Cache,将所有的函数都预编译为字节码;
大型 JS 代码:对于较大的 JS 代码,默认只生成 Top 3 层的函数,其它内部函数在正式运行时即时编译;
增量更新:在 JS 正式运行后,如果有较多的内部函数被新增编译,那么在页面退出时,会更新 Code Cache,以缓存更多的函数。
经过线上 Top 站点的验证,使用默认规则生成的 JS AOT 可使用 JS 运行性能平均提升 33.9% 左右。
策略二:基于 PGO
同 UC Disk Code Cache 类似,不过需要在业务上线前,在本地先访问一次页面,收集页面运行过程中所执行的函数信息,并上报到服务端。服务端收到数据后,会对上报上来的数据进行处理、修复部分被 GC 的函数、简化相关信息,然后提供给业务内置到发布包中。当用户启动应用后,JS AOT 在后台预热时,会根据函数信息,只生成指定函数的 Code Cache。
由于该策略同 UC Disk Code Cache 一样基于 PGO,其缓存的函数相同,因而性能提升也同 UC Disk Code Cache。
在线生成 AOT 的方案,一般只适用于应用安装后非立即使用的业务,需要有后台预热时机。
方案二:离线生成
由于在线生成只支持有后台预热时机的业务场景,而对于应用安装后需要立即使用的场景,则需要使用离线生成的方案。
在服务端使用 U4 内核提供的工具,可离线生成任意 JS 代码的 AOT。与在线生成一样,离线生成时也支持默认规则和基于 PGO 两种策略。离线生成的 AOT 文件,需要由业务内置于发布包或业务 Bundle 中,并在运行前将其传递给 V8 引擎。
有了离线生成,很多人自然地会问到能否在发布包或业务 Bundle 中只带上 JS AOT,去掉 JS 源码以减少整体 Size 呢?先给出答案:不行。原因如下:
Full Code Cache:去掉 JS 源码,就意味着需要使用 Full Code Cache,因为线下编译进来的函数可能和线上运行的不完全相同,而对于较大的 JS 使用 Full Code Cache 会有性能惩罚。
Function.prototype.toString(): JS 语言有个同其它语言不一样的特殊功能,它可以使用 toString() 获取任意 JS 函数的源代码字符串,如下图。如果去掉了 JS 源码,这部分功能就会缺失,依赖于这个功能的 JS 则会运行异常。
离线生成 AOT 的方案,一般适用于比较固定、不需要频繁更新的 JS,比如一些框架的代码,同时最好是内置到发布包中,不要动态更新,以避免 V8 版本匹配失败造成性能下降。
UC LLVM Compiler
UC LLVM Compiler 的主要目标是为了解决 V8 TurboFan 生成的代码质量差的问题。
在具体介绍 UC LLVM Compiler 之前,我们先看一下 V8 编译生成的动态库的主要组成:
动态库 libv8.so 的代码段(.text) 中,Builtins 和 Wasm 等部分的代码是 V8 在 PC 中编译时使用 TurboFan 离线生成的,它们主要作用于 JS 和 Wasm 的解释执行(V8 使用了 Embedded Builtins 技术,将 Builtins 集成到了动态库的代码段中)。关于 TurboFan,它有两个用途,一是在 PC 中编译 V8 时生成字节码和 WebAssembly 处理程序,二是在线上(手机中)对热点 JS 函数进行优化重编译,生成高效的本地代码。为了兼顾线上优化编译和运行时间,TurboFan 生成代码时优化力度不大,生成的代码质量不够理想,运行效率不高。
为了加速 JS 的解释执行过程,我们将 V8 本地编译生成字节码处理程序 Builtins 的优化编译器 TurboFan 换成了 UC LLVM Compiler。LLVM 是一个开源的项目,它凭借着强大的寄存器分配和指令选择能力,结合强大的优化流水线,对于各种 case,能够生成非常高效、简洁的本地代码。
虽然 UC LLVM Compiler 非常强大,但是要将它用于 V8 中,需要进行较多的改动和适配。简单地说,需要让 LLVM 能认识 V8 TurboFan 的 IR,并按 V8 TurboFan 的调用约定生成相应的本地代码。我们对 LLVM 进行许多底层的改动,例如:
使用 UC LLVM Compiler,可以让 V8 的 Builtins 大小减少。线上 TOP 站点性能提升。其中 UC 信息流、神马搜索相关业务、淘宝相关站点均有明显的性能提升。
此外,通过淘宝研发的基于 WebAssembly 的播放器 测试表明,每帧图像解码时间平均能提升约 20%。
U4 内核除了作为 UC 浏览器和夸克的 Web 引擎外,还被手淘、支付宝等 APP 作为 WebView 使用。U4 内核被应用的场景复杂多样,V8 引擎所处的环境和不确定性非常多,这些复杂的环境会触发一些奇怪的、疑难的崩溃问题。我们对这些问题进行了深入的研究,确定根源并彻底解决,保障 U4 内核在各种场景下都能稳健地运行。
下面将介绍我们所解决的几个较典型的崩溃问题。
CPU Cache Flush
V8 在执行 JS 过程中,当有 JS 代码重复多次运行成为热点时,V8 会启动 TurboFan 优化编译器进行 JIT,生成优化版的本地代码,在将本地代码写入到内存后,V8 会使用 CPU Flush 指令刷新 CPU 缓存,并为该内存增加可执行权限,然后执行。
我们发现,线上存在较多奇怪的崩溃崩在 JIT 代码中,如下:
通过上述崩溃信息可以看到,崩溃时 PC 所在的内存段是 V8 JIT 内存,是有可执行权限的,同时它所指向的指令并不可能产生崩溃信号。这个问题是 CPU Cache 和内存中数据不一致导致,也就是 CPU Cache Flush 失败。虽然这类问题出现的概率较低,但是由于 U4 内核使用量非常大,每天会上报大量的此类崩溃。
经过我们许多轮不懈的修复尝试,经过无数次的灰度验证,历时长达三年以上,最终我们终于找到一套安全、可靠的 CPU 缓存刷新方案,依照 Linker 加载动态库的方式,全面彻底地解决了此类问题。该问题的发现与解决也得益于 UC Crash SDK 的强大捕获能力以及丰富的崩溃现场定位信息。由于 Chromium 的崩溃捕获能力不完善,目前仍未能发现此问题的存在。
高通 SDM660/636 CPU BUG
此问题崩溃在 V8 内存拷贝的逻辑中,具体崩溃信息如下:
崩溃时的指令为 vstl.8 {d0}, {r0},即向 r0 寄存器指向的内存写入数据时出现 SIGSEGV 崩溃。通过 LogAnalyzer 日志分析工具发现,r0 所指向的为 V8 的堆内存,有写入权限,而这与 SIGSEGV 信号的产生本身相矛盾。通过分析更多日志,我们发现崩溃时写入数据的地址都为页起始(即为 0x1000 的倍数),同时使用 岳鹰 的自定义聚合分析功能发现,产生此崩溃的机器的 CPU 只有高通的 SDM660 和 SDM636 这两种。由此我们猜测此问题是高通的这两款 CPU 本身存在问题,在处理页错误中断时有 BUG 导致。为了规避该崩溃问题,我们在使用这两款 CPU 的机器上,当 V8 分配虚拟内存空间时同时申请物理内存,从而避免了此问题。
V8 中出现的崩溃问题,一般都是高难度的问题,它不一定是 V8 代码的 BUG,而有可能是由于 Linux 系统或者 CPU 的 BUG 导致。分析、定位和解决此问题通常需要耗费大量的时间和精力,同时需要一支精锐的团队提供各种好用的工具支持,而这一切正是我们团队所具备的。
U4 内核团队在 Native 稳定性方面多年深耕,积累了大量的经验和工具,对 V8 Top 的疑难问题一一攻克,使得我们的 V8 引擎安全、可靠。
为了方便业务定位解决线上的一些疑难问题,如 JS 卡死、JS OOM 等,同时也为了方便业务能更好地进行开发调试,我们在 V8 引擎中做了一些功能扩展。
JS 卡死检测
我们基于 V8 StackCheck 的机制,使用 Thread WatchDog 在检测到线程卡住较长时间时,判断当前是否处于 JS 执行状态,并且计算当前进入 JS 执行的时长,若超出阈值,则生成卡死日志,卡死日志中同时包含当前 JS、C++、Java 的调用栈信息。另外,也支持在检测到卡死后抛 JS 异常中断 JS 的执行(不过由于考虑到中断 JS 执行可能会导致业务后续逻辑混乱,故默认暂未开启此问题)。该功能在支付宝今年 2.14 的活动页面中得以有效验证。
OOM 内存信息
JS OOM 问题是线上业务遇到的第一大问题,JS OOM 通常会导致 Native 崩溃。为了方便业务更快地定位 JS OOM,我们增加了一系列的内存相关信息。主要内容如下:
JS 堆内存详情:
JS 堆内存信息中包含了各类 JS 对象的内存占用,以及占用内存较大的对象详情。如下:
GC 信息:
包含在 JS OOM 前,V8 的 GC 信息。如下:
调用栈:
包含 JS OOM 时,当前的 JS 调用栈详情。如下:
通过这些信息,就可以有效地辅助业务定位问题。
JS API 扩展
除了在特定的场景时,提供相应的辅助信息帮助定位问题外,我们还支持在任何时候,在 JS 中通过扩展的 ucRuntime API 去获取当前 JS 堆内存信息、当前进程的内存分组信息,以及可以在 JS 中生成 Chromium Trace。
调用 ucRuntime.getJSHeapStatistics() 可以获取到 V8 的堆内存信息:
调用 ucRuntime.getProcessMemoryStatistics() 可以获取到当前进程的内存分组信息:
我们为 U4 内核在 V8 引擎中做了许多的优化和功能,与此同时集团内有大量的业务有独立使用 JS 引擎的需求,为此我们将 U4 内核的 V8 引擎独立封装,开发出了一款名为 JSI (JavaScript Engine Interface) 的 SDK。JSI 与 U4 内核共用同一个 V8 引擎的二进制动态库文件,避免了新引入一个 JS 引擎带来的包 Size 增长。
JSI 的主要功能及特性如下:
跨平台
JSI 除了支持 Android 和 iOS 以外,还支持 Windows, Linux 和 Mac PC 系统。为了规避苹果应用商店审核风险,JSI 在 iOS 上使用的是系统 JSC;在其它平台上,统一使用 U4 内核的 V8 引擎。
统一 API
JSI 提供了统一的 C++ API,使得接入方能一次接入,多端运行。同时,JSI 的 API 与具体 JS 引擎无关,目前对接了 V8 和 JSC,后续可能会接入更多的 JS 引擎。另外,为了方便 Android 和 iOS 开发者快速接入,我们提供了与 C++ API 功能对等的 Java API,以及 Objectiv-C API。
Inspector
JS 调试能力是业务开发过程中的一大强诉求,JSI 也在这方面进行了大力投入,除了支持 USB 有线调试外,还支持无线调试,以及远程扫码调试。
同时 Inspector 中的 Trace 和 JS Profile 等功能,JSI 也一应俱全。
JS AOT
同 U4 内核的 WebView 一样,JSI 也适配了 JS AOT,同时支持离线生成和在线生成两种方式。
Java 注入
为了让业务用 JS 引擎用得更加随心所欲,JSI 实现了强大的 Java 注入功能。使用 Java 注入功能,可以将任意的 Java 对象、Java 类注入到 JS 中,JS 中也可以通过 importClass 主动导入任意的 Java 类到 JS 中。当对象或类注入到 JS 中后,可像在 Java 中一样编写 JS 代码。如下:
/* JS 代码 */
importClass("android.view.View.OnClickListener");
importClass("com.alibaba.jsi.shell.R");
var btn = androidActivity.findViewById(R.id.execute_js);
btn.setOnClickListener(new OnClickListener({
onClick: function(view) {
var sourceView = androidActivity.findViewById(R.id.input_source);
var src = sourceView.getText().toString();
var res = eval(src);
var resultStr = "undefined";
if (res) {
resultStr = res;
}
androidActivity.findViewById(R.id.output_result).setText(resultStr);
}
}));
除此之外,还可以将任意的 JS 对象转为特定的 Java 接口类型,以 Java 接口的方式调用 JS 的功能。例如有如下 JS 对象:
/* JS 代码 */
var myJSObject = {
hello : function() {
return "hello from js";
},
add : function(a, b) {
return a + b;
}
};
在 Java 中可以如下调用:
/* Java 代码 */
interface MyInterface {
String hello();
int add(int a, int b);
String add(String a, int b);
}
void testJSObject(JSContext context) {
JSSupport support = context.getJSSupport();
MyInterface js = support.get("myJSObject", MyInterface.class);
Log.i("java", js.hello()); // hello from js
Log.i("java", "1 + 2 = " + js.add(1, 2)); // 1 + 2 = 3
Log.i("java", js.add("number ", 345)); // number 345
}
更多功能
JSI 同 U4 WebView 一样,也适配了 UC Disk Code Cache 功能,以加速 JS 的执行。另外,由于 JS 引擎是不支持 Timer 异步操作的(Timer 是 HTML5 的标准),而其又是业务开发必不可少的能力之一,因而我们将 setTimeout、setInterval、clearTimeout 和 clearInterval 内置到 JSI 中作为一项标准的功能开放。
此外,由于 AR、VR 等技术的兴起,部分业务需要使用 WebAssembly 实现相关算法,为此我们对接实现了标准的 wasm-c-api ,让业务使用 WebAssembly 更加顺畅。目前 JSI 被集团内 20 多个业务方接入使用,包含手淘、支付宝、UC 等 APP。
Chromium 的 V8 引擎在持续的优化和演进中,U4 内核的 V8 引擎也在不断地从业务的角度出发,探索更多方向。目前,我们持续在如下几个主要方面探索:
凭借着 U4 内核团队的强大技术实力和深厚的技术底蕴,我们将不懈努力,用技术为业务带来更大、更多的价值!
U4内核致力于打造性能最好、最安全的web平台,让web无所不能。
关注请长按二维码
喜欢请分享到朋友圈