注册

跨端技术总结

通过淘宝weex,微信,美团kmm,天猫Waft 等不同项目来了解目前各家公司在跨平台方向上有哪些不同的项目,用了什么不同技术实现方式,然后在对比常用的react native ,flutter 和WebAssembly具体在技术上的区别在哪里。


image.png


客户端渲染执行逻辑


android


层次的底部是 Linux,它提供基本的系统功能,如进程管理,内存管理,设备管理,如:相机,键盘,显示器等内核处理的事情。体系结构第三个部分叫做Java虚拟机,是一种专门设计和优化的 Android Dalvik 虚拟机。应用程序框架层使用Java类形式的应用程序提供了许多的更高级别的服务。允许应用程序开发人员在其应用程序中使用这些服务。应用在最上层,即所有的 Android 应用程序。一般我们编写的应用程序只被安装在这层。应用的例子如:浏览器,游戏等。


image.png


绘制流程




  1. 创建视图




ui生成就是把代码中产生的view和在xml文件配置的view,经过measure,layout,dewa 形成一个完整的view树,并调用系统方法进行绘制。Measure 用深度优先原则递归得到所有视图(View)的宽、高;Layout 用深度优先原则递归得到所有视图(View)的位置;到这里就得到view的在窗口中的布局。Draw 目前 Android 支持了两种绘制方式:软件绘制(CPU)和硬件加速(GPU),会通过系统方法把要绘制的view 合成到不同的缓冲区上


最初的ui配置


image.png


构建成内存中的view tree


image.png


2.视图布局


image.png


3.图层合成


SurfaceFlinger 把缓存 区数据渲染到屏幕,由于是两个不同的进程,所以使用 Android 的匿名共享内存 SharedClient 缓存需要显示的数据来达到目的。 SurfaceFlinger 把缓存区数据渲染到屏幕(流程如下图所示),主要是驱动层的事情,这 里不做太多解释。


image.png


4. 系统绘制


image.png


绘制过程首先是 CPU 准备数据,通过 Driver 层把数据交给 CPU 渲 染,其中 CPU 主要负责 Measure、Layout、Record、Execute 的数据计算工作,GPU 负责 Rasterization(栅格化)、渲染。由于图形 API 不允许 CPU 直接与 GPU 通信,而是通过中间 的一个图形驱动层(Graphics Driver)来连接这两部分。图形驱动维护了一个队列,CPU 把 display list 添加到队列中,GPU 从这个队列取出数据进行绘制,最终才在显示屏上显示出来。


ios:


架构


image.png



  1. Core OS layer


  • 核心操作系统层包括内存管理、文件系统、电源管理以及一些其他的操作系统任务,直接和硬件设备进行交互


  1. Core Services layer


  • 核心服务层,我们可以通过它来访问iOS的一些服务。包含: 定位,网络,数据 sql


  1. Media layer


  • 顾名思义,媒体层可以在应用程序中使用各种媒体文件,进行音频与视频的录制,图形的绘制,以及制作基础的动画效果。


  1. Cocoa Touch layer


  • 本质上来说它负责用户在iOS设备上的触摸交互操作
  • 包括以下这些组件: Multi-Touch Events Core Motion Camera View Hierarchy Localization Alerts Web Views Image Picker Multi-Touch Controls.

ios 的视图树


image.png


ios的 绘制流程:


image.png


image.png


image.png


显示逻辑



  • CoreAnimation提交会话,包括自己和子树(view hierarchy)的layout状态等;
  • RenderServer解析提交的子树状态,生成绘制指令;
  • GPU执行绘制指令;
  • 显示渲染后的数据;

提交流程


image.png


1、布局(Layout)


调用layoutSubviews方法; 调用addSubview:方法;


2、显示(Display)


通过drawRect绘制视图; 绘制string(字符串);



每个UIView都有CALayer,同时图层有一个像素存储空间,存放视图;调用-setNeedsDisplay的时候,仅会设置图层为dirty。 当一个视图第一次或者某部分需要更新的时候iOS系统总是会去请求drawRect:方法。


以下是触发视图更新的一些操作:



  • 移动或删除视图
  • 通过将视图的hidden属性设置为NO
  • 滚动消失的视图再次需要出现在屏幕上
  • 视图显式调用setNeedsDisplay或setNeedsDisplayInRect:方法

视图系统都会自动触发重新绘制。对于自定义视图,就必须重写drawRect:方法去执行所有绘制。视图第一次展示的时候,iOS系统会传递正方形区域来表示这个视图绘制的区域。


在调用drawRect:方法之后,视图就会把自己标记为已更新,然后等待下一次视图更新被触发。



3、准备提交(Prepare)


解码图片; 图片格式转换;


4、提交(Commit)


打包layers并发送到渲染server;


递归提交子树的layers;


web :


web内容准备阶段


web 通常需要将所需要的html,css,js都下载下来,并进行解析执行后才进行渲染,然后是绘制过程,先来看下前期工作


image.png


一个渲染流程会划分很多子阶段,整个处理过程叫渲染流水线,流水线可分为如下几个子阶段:构建DOM树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。每个阶段都经过输入内容 -->处理过程-->输出内容三个部分。



  1. 渲染进程将HTML内容转换为能够读懂的DOM树结构

image.png



  1. 渲染引擎将CSS样式表转化为浏览器可以理解的styleSheets(生存CSSDOM树),计算出DOM节点的样式

image.png


styleSheets格式
image.png



  1. 创建布局树(LayoutTree),并计算元素的布局信息。

我们有DOM树和DOM树中元素的样式,那么接下来就需要计算出DOM树中可见元素的几何位置,我们把这个计算过程叫做布局。根据元素的可见信息构建出布局树。


image.png
4. 对布局树进行分层,并生成分层树(LayerTree)。


image.png



  1. 为每个图层生成绘制列表,并将其提交到合成线程。

image.png



  1. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。

合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图


image.png


image.png



  1. 合成线程发送绘制图块命令DrawQuad给浏览器进程。浏览器进程根据DrawQuad消息生成页面,并显示到显示器上

image.png


web 在android 中的绘制


WebView实际上是一个ViewGroup,并将后端的具体实现抽象为WebViewProvider,而WebViewChromium正是一个提供基于Chromium的具体实现类。


再回到WebView的情况。当WebView部件发生内容更新时,例如页面加载完毕,CSS动画,或者是滚动、缩放操作导致页面内容更新,同样会在WebView触发invalidate方法,随后在视图系统的统筹安排下,WebView.onDraw方法会被调用,最后实际上调用了AwContents.onDraw方法,它会请求对应的native端对象执行OnDraw方法,将页面的内容更新绘制到WebView对应的Canvas上去。


draw()先得到一块Buffer,这块Buffer是由SurfaceFlinger负责管理的。


然后调用view的draw(canvas)当view draw完后,调用surface.java的unlockAndPostCanvas().


将包含有当前view内容的Buffer传给SurfaceFlinger,SurfaceFlinger将所有的Buffer混合后传给FrameBuffer.至此和native原有的view 渲染就是一样的了。


image.png


成熟的框架的底层原理:


react :


RN 的 Android Bridge 和 IOS Bridge 是两端通信的桥梁, 是由一个转译的桥梁实现的不同语言的通信, 得以实现单靠 JS 就调用移动端原生 APi


架构


image.png



  • RN 的核心驱动力就来自 JS Engine, 我们所有的 JS 代码都会通过 JS Engine 来编译执行, 包括 React 的 JSX 也离不开 JS Engine, JavaScript Core 是其中一种 JS 引擎, 还有 Google 的 V8 引擎, Mozilla 的 SpiderMonkey 引擎。
  • RN 是用类 XML 语言来表示结构, 用 StyleSheet 来规划样式, 但是 UI 控件调用的是 RN 里自己的两端实现控件(android 和 IOS)


  • JavaScript 在 RN 的作用就是给原生组件发送指令来完成 UI 渲染, 所以 JavaScript Core 是 RN 中的核心部分


  • RN 是不用 JS 引擎的 UI 渲染控件的, 但是会用到 JS 引擎的 DOM 操作管理能力来管理所有 UI 节点, 每次在写完 UI 组件代码后会交给 yoga 去做布局排版, 然后调用原生组件绘制
  • bridge 负责js 和native的通讯,以android为例:Java层与Js层的bridge分别存有相同一份模块配置表,Java与Js互相通信时,通过bridge里的配置表将所调用模块方法转为{moduleID,methodID,args}的形式传递给处理层,处理层通过bridge的模块配置表找到对应的方法执行,如果有callback,则回传给调用层

image.png


通讯机制


Java -> Js: Java通过注册表调用到CatalystInstance实例,通过jni,调用到 javascriptCore,传递给调用BatchedBridge.js,根据参数{moduleID,methodID}require相应Js模块执行。


Js -> Java: JS不主动传递数据调用Java。在需要调用调Java模块方法时,会把参数{moduleID,methodID}等数据存在MessageQueue中,等待Java的事件触发,再把MessageQueue中的{moduleID,methodID}返回给Java,再根据模块注册表找到相应模块处理。


事件循环


JS 开发者只需要开发各个组件对象,监听组件事件, 然后利用framework接口调用render方法渲染组件。


而实际上,JS 也是单线程事件循环,不管是 API调用, virtural DOM同步, 还是系统事件监听, 都是异步事件,采用Observer(观察者)模式监听JAVA层事件, JAVA层会把JS 关心的事件通过bridge直接使用javascriptCore的接口执行固定的脚本, 比如"requrire (test_module).test_methode(test_args)"。此时,UI main thread相当于work thread, 把系统事件或者用户事件往JS层抛,同时,JS 层也不断调用模块API或者UI组件 , 驱动JAVA层完成实际的View渲染。JS开发者只需要监听JS层framework定义的事件即可


react 的渲染流程


image.png


首先回顾一下当前Bridge的运行过程。当我们写了类似下面的React源码。


<View style={{ backgroundColor: 'pink', width: 200, height: 200}}/>

JS thread会先对其序列化,形成下面一条消息


UIManager.createView([343,"RCTView",31,{"backgroundColor":-16181,"width":200,"height":200}])

通过Bridge发到ShadowThread。Shadow Tread接收到这条信息后,先反序列化,形成Shadow tree,然后传给Yoga,形成原生布局信息。接着又通过Bridge传给UI thread。UI thread 拿到消息后,同样先反序列化,然后根据所给布局信息,进行绘制。


从上面过程可以看到三个线程的交互都是要通过Bridge,因此瓶颈也就在此。


image.png


首次渲染流程



  1. Native 打开 RN 页面
  2. JS 线程运行,Virtual DOM Tree 被创建
  3. JS 线程异步通知 Shadow Thread 有节点变更
  4. Shadow Thread 创建 Shadow Tree
  5. Shadow Thread 计算布局,异步通知 Main Thread 创建 Views
  6. Main Thread 处理 View 的创建,展示给用户

image.png


react native 新架构


image.png



  • JSI:JSI是Javascript Interface的缩写,一个用C++写成的轻量级框架,它作用就是通过JSI,JS对象可以直接获得C++对象(Host Objects)引用,并调用对应方法

另外JSI与React无关,可以用在任何JS 引擎(V8,Hermes)。有了JSI,JS和Native就可以直接通信了,调用过程如下:JS->JSI->C++->ObjectC/Java



  • Fabric 是 UI Manager 的新名称, 将负责 Native UI 渲染, 和当前 Bridge 不同的是, 可以通过 JSI 导出自己的 Native 函数, 在 JS 层可以直接使用这些函数引用, 反过来 Native 可以直接调用 JS 层, 从而实现同步调用, 这带来更好的数据传输和性能提升

image.png


对比


image.png


flutter:


生产环境中 Dart 通过 AOT 编译成对应平台的指令,同时 Flutter 基于跨平台的 Skia 图形库自建了渲染引擎,最大程度地保证了跨平台渲染的一致性


image.png



  • embedder: 可以称为嵌入器,这是和底层的操作系统进行交互的部分。因为flutter最终要将程序打包到对应的平台中,对于Android平台使用的是Java和C++,对于iOS和macOS平台,使用的是Objective-C/Objective-C++。


  • engine:Flutter engine基本上使用C++写的。engine的存在是为了支持Dart Framework的运行。它提供了Flutter的核心API,包括作图、文件操作、网络IO、dar运行时环境等核心功能。Flutter Engine线程的创建和管理是由embedder负责的。


  • Flutter framework: 这一层是用户编程的接口,我们的应用程序需要和Flutter framework进行交互,最终构建出一个应用程序。

Flutter framework主要是使用dart语言来编写的。framework从下到上,我们有最基础的foundational包,和构建在其上的 animation, painting和 gestures 。


再上面就是rendering层,rendering为我们提供了动态构建可渲染对象树的方法,通过这些方法,我们可以对布局进行处理。接着是widgets layer,它是rendering层中对象的组合,表示一个小挂件。


Widgets 理解


Widgets是Flutter中用户界面的基础。你在flutter界面中能够观察到的用户界面,都是Widgets。大的Widgets又是由一个个的小的Widgets组成,这样就构成了Widgets的层次依赖结构,在这种层次结构中,子Widgets可以共享父Widgets的上下文环境。在Flutter中一切皆可为Widget。


举例,这个Containerks 控件里的child,color,Text 都是Widget。


  color: Colors.blue,
child: Row(
children: [
Image.network('http://www.flydean.com/1.png'),
const Text('A'),
],
),
);

Widgets表示的是不可变的用户UI界面结构。虽然结构是不能够变化的,但是Widgets里面的状态是可以动态变化的。根据Widgets中是否包含状态,Widgets可以分为stateful和stateless widget,对应的类是StatefulWidget和StatelessWidget。


渲染和绘制


渲染就是将上面我们提到的widgets转换成用户肉眼可以感知的像素的过程。Flutter代码会直接被编译成使用 Skia 进行渲染的原生代码,从而提升渲染效率。


代码首先会形成widgets树如下,这些widget在build的过程中,会被转换为 element tree,其中ComponentElement是其他Element的容器,而RenderObjectElement是真正参与layout和渲染的element。。一个element和一个widget对应。然后根据elementrtree 中需要渲染的元素形成RenderTree ,flutter仅会重新渲染需要被重新绘制的element,每次widget变化时element会比较前后两个widget,只有当某一个位置的Widget和新Widget不一致,才会重新创建Element和widget。 最后还会一个layer tree,表示绘制的图层。



四棵树有各自的功能



image.png


Flutter绘制流程


image.png



  • Animate,触发动画更新下一帧的值
  • Build,触发构建或刷新 Widget Tree、Element Tree、RenderObject Tree
  • Layout,触发布局操作,确定布局大小和位置信息
  • CompositeBits,更新需要合成的 Layer 层标记
  • Paint,触发 RenderObject Tree 的绘制操作,构建 Layer Tree
  • Composite,触发 Layer Tree 发送到 Engine,生成 Engine LayerTree

在 UIThread 构建出四棵树,并在 Engine 生成 Scene,最后提交给 RasterThread,对 LayerTree 做光栅化合成上屏。


Flutter 渲染流程


image.png




  • UIThread


    UIThread 是 Platform 创建的子线程,DartVM Root Isolate 所有的 dart 代码都运行在该线程。阻塞 UIThread 会直接导致 Flutter 应用卡顿掉帧。




  • RasterThread


    RasterThread 原本叫做 GPUThread,也是 Platform 创建的子线程,但其实它是运行在 CPU 用于处理数据提交给 GPU,所以 Flutter 团队将其名字改为 Raster,表明它的作用是光栅化。


    C++ Engine 中的光栅化和合成过程运行在该线程。




  • C++ Engine 触发 Platform 注册 VSync 垂直信号回调,通过 Platform -> C++ Engine -> Dart Framework 触发整个绘制流程




  • Dart Framework 构建出四棵树,Widget Tree、Element Tree、RenderObject Tree、Layer Tree,布局、记录绘制区域及绘制指令信息生成 flutter::LayerTree,并保存在 Scene 对象用以光栅化,这个过程运行在 UIThread




  • 通过 Flutter 自建引擎 Skia 进行光栅化和合成操作, 将 flutter::LayerTree 转换为 GPU 指令,并发送给 GPU 完成光栅化合成上屏显示操作,这个过程执行在 RasterThread




整个调度过程是生产者消费者模型,UIThread 负责生产 flutter::Layer Tree,RasterThread 负责消费 flutter::Layer Tree。


flutter 线程模型


image.png


Mobile平台上面每一个Engine实例启动的时候会为UI,GPU,IO Runner各自创建一个新的线程。所有Engine实例共享同一个Platform Runner和线程。




  • Platform Task Runner


    Flutter Engine的主Task Runner,可以理解为是主线程,一个Flutter应用启动的时候会创建一个Engine实例,Engine创建的时候会创建一个线程供Platform Runner使用。改线程不仅仅处理与Engine交互,它还处理来自平台的消息。




  • UI Task Runner Thread(Dart Runner)


    UI Task Runner被Flutter Engine用于执行Dart root isolate代码,Root isolate运行应用的main code。负责触发构建或刷新 Widget Tree、Element Tree、RenderObject Tree,生成最终的Layer Tree。


    Root Isolate还是处理来自Native Plugins的消息响应,Timers,Microtasks和异步IO(isolate是有自己的内存和单线程控制的运行实体,isolate之间的内存在逻辑上是隔离的)。




  • GPU Task Runner


    GPU Task Runner中的模块负责将Layer Tree提供的信息转化为实际的GPU指令,执行设备GPU相关的skia调用,转换相应平台的绘制方式,比如OpenGL, vulkan, metal等。GPU Task Runner同时也负责配置管理每一帧绘制所需要的GPU资源




  • IO Task Runne




IO Runner的主要功能是从图片存储(比如磁盘)中读取压缩的图片格式,将图片数据进行处理为GPU Runner的渲染做好准备


Dart 是单线程的,但是采用了Event Loop 机制,也就是不断循环等待消息到来并处理。在 Dart 中,实际上有两个队列,一个事件队列(Event Queue),另一个则是微任务队列(Microtask Queue)。在每一次事件循环中,Dart 总是先去第一个微任务队列中查询是否有可执行的任务,如果没有,才会处理后续的事件队列的流程。


image.png


isolate机制尽管 Dart 是基于单线程模型的,但为了进一步利用多核 CPU,将 CPU 密集型运算进行隔离,Dart 也提供了多线程机制,即 Isolate。每个 Isolate 都有自己的 Event Loop 与 Queue,Isolate 之间不共享任何资源,只能依靠消息机制通信,因此也就没有资源抢占问题。Isolate 通过发送管道(SendPort)实现消息通信机制。我们可以在启动并发 Isolate 时将主 Isolate 的发送管道作为参数传给它,这样并发 Isolate 就可以在任务执行完毕后利用这个发送管道给我们发消息。


如果需要在启动一个 Isolate 执行某项任务,Isolate 执行完毕后,发送消息告知我们。如果 Isolate 执行任务时,同时需要依赖主 Isolate 给它发送参数,执行完毕后再发送执行结果给主 Isolate这样的双向通信,让并发 Isolate 也回传一个发送管道即可。


weex:


架构:


image.png



  1. 将weex源码生成JS Bundle,由template、style 和 script等标签组织好的内容,通过转换器转换成JS Bundle
  2. 服务端部署JS Bundle ,将JS Bundle部署在服务器,当接收到终端(Web端、iOS端或Android端)的JS Bundle请求,将JS Bundle下发给终端
  3. WEEX SDK初始化,初始化 JS 引擎,准备好 JS 执行环境
  4. 构建渲染指令树,Weex 里都使用 DOM API 把 Virtual DOM 转换成真实的Element 树,而是使用 JS Framework 里定义的一套 Weex DOM API 将操作转化成渲染指令发给客户端,形成客户端的真实控件
  5. 页面的 js 代码是运行在 js 线程上的,然而原生组件的绘制、事件的捕获都发生在 UI 线程。在这两个线程之间的通信用的是 callNative 和 callJS 这两个底层接口。callNative 是由客户端向 JS 执行环境中注入的接口,提供给 JS Framework 调用。callJS 是由 JS Framework 实现的,并且也注入到了执行环境中,提供给客户端调用。

渲染过程


Weex 里页面的渲染过程和浏览器的渲染过程类似,整体可以分为【创建前端组件】-> 【构建 Virtual DOM】->【生成“真实” DOM】->【发送渲染指令】->【绘制原生 UI】这五个步骤。前两个步骤发生在前端框架中,第三和第四个步骤在 JS Framework 中处理,最后一步是由原生渲染引擎实现的。 页面渲染的大致流程如下


image.png


各家项目的实现方式:


淘宝新⼀代⾃绘渲染引擎 的架构与实践


Weex 技术发展历程


image.png


Weex 2.0 简版架构


最上层的前端生态还是没变的,应该还是以vue的响应式编程为主。


image.png


2.0多了js和c++的直接调用,减少js引擎和布局引擎的通讯开销。


image.png


image.png


Weex 2.0 重写了渲染层的实现,不再依赖系统 UI,改成依赖统一的图形库 Skia 自绘渲染,和 Flutter 原理很像,我们也直接复用了 Flutter Engine 的部分代码。底层把 Weex 对接过的各种原生能力、三方扩展模块都原样接入。对于上层链路,理论上讲业务 JS 代码、Vue/Rax、JS Framework 都是不需要修改的。在 JS 引擎层面也做了一些优化,安卓上把 JavaScriptCore 换成了 QuickJS,用 bytecode 加速二次打开性能,并且结合 Weex js bundle 的特点做针对性的优化。


字节码编译原理


image.png


渲染原理


渲染引擎通用的渲染管线可以简化为【执行脚本】-->【构建节点】-->【布局/绘制】--> 【合成/光栅化】--【上屏】这几个步骤。Weex 里的节点构建逻辑主要在 JS 线程里执行,提交一颗静态节点树到 UI 线程,UI 线程计算布局和绘制属性,生成 Layer Stack 提交到 GPU 线程。


image.png


天猫:WAFT:基于WebAssembly和Skia 的AIoT应用开发框架


整体方案


image.png


为什么选择WebAssemy?


支持 AOT 模式,拔高了性能上限;活跃的开源社区,降低项目推进的风险;支持多语言,拓宽开发者群体。


WebAssembly(又名wasm)是一种高效的,低级别的编程语言。 它让我们能够使用JavaScript以外的语言(例如C,C ++,Rust或其他)编写程序,然后将其编译成WebAssembly,进而生成一个加载和执行速度非常快的Web应用程序


WebAssembly是基于堆栈的虚拟机的二进制指令格式,它被设计为编程语言的可移植编译目标。目前很多语言都已经将 WebAssembly 作为它的编译目标了。


image.png


waft 开发方式


可以看到是采用类前端的开发方式,限定一部分css能力。最后编译为WebAssembly,打包成wasm bundle。 在进行aot 编译城不同架构下的机器码。


image.png


运行流程


可以看到bundle 加载过程中,会执行UI区域的不同的生命周期函数。然后在渲染过程则是从virtual dom tree 转化到widget tree,然后直接通过skia 渲染库直接进行渲染。


image.png


Waft 第二阶段成果-跨平台


image.png


美团KMM在餐饮SaaS中的探索与实践


KMP:Kotlin Multiplatform projects 使用一份kotlin 代码在不同平台上运行


KMM:Kotlin MultiplatformMobile 一个用于跨平台移动应用程序的 SDK。使用 KMM,可以构建多平台移动应用程序并在 Android 和 iOS 应用程序之间共享核心层和业务逻辑等方面。开发人员可以使用单一代码库并通过共享数据层和业务逻辑来实现功能。其实就是把一份逻辑代码编译为多个平台的产物编译中间产物,在不同平台的边缘后端下转为不同的变异后端产物,在不同平台下运行。


image.png


IR 全称是 intermediate representation,表示编译过程中的中间信息,由编译器前端对源码分析后得到,随后会输入到后端进一步编译为机器码


IR 可以有一系列的表现方式,由高层表示逐渐下降(lowering)到低层


我们所讨论的 Kotlin IR 是抽象语法树结构(AST),是比较高层的 IR 表示类型。


有了完备的 IR,就可以利用不同的 后端,编出不同的目标代码,比如 JVM 的字节码,或者运行在 iOS 的机器码,这样就达到了跨端的目的


image.png


对比


image.png


总结


当前存在4种多端方案:



  1. Web 容器方案
  2. 泛 Web 容器方案
  3. 自绘引擎方案
  4. 开放式跨端框架

image.png


引用文章:


zhuanlan.zhihu.com/p/20259704​​


zhuanlan.zhihu.com/p/281238593​​


zhuanlan.zhihu.com/p/388681402​​


juejin.cn/post/708412…​​


guoshuyu.cn/home/wx/Flu…​​


oldbird.run/flutter/u11…​​


w4lle.com/2020/11/09/…​​


blog.51cto.com/jdsjlzx/568…​​


http://www.devio.org/2021/01/10/…​​


gityuan.com/flutter/​​


gityuan.com/2019/06/15/…​​


zhuanlan.zhihu.com/p/78758247​​


http://www.finclip.com/news/f/5

作者:美好世界
来源:juejin.cn/post/7249624871721041975
035…​​

0 个评论

要回复文章请先登录注册