移动开发跨平台方案之RN/Flutter/KMP/CMP

1、跨平台方案出现的背景

在移动应用开发的早期,有Android和ios两大阵营(还短暂出现过Windows Phone),为了能覆盖住所有用户,一个应用就要做Android版和ios版两种类型的APP,维护两个团队,随着业务越来越复杂,显然不管开发工作量、维护工作量还是人员团队,都会越来越庞大,成本越来越高。所以,跨平台通用方案的需求就逐渐显现出来。

在早期,也确实有一些可跨平台的方案,比如,使用各平台的WebView以网页形式展示应用,但是这种方案很难调用设备底层硬件,性能也不够好,所以使用体验差,有些功能还无法实现;再后来,出现了**Hybrid混合开发方案,**它用WebView加载网页,再通过插件调用部分设备能力。相比纯网页性能有所提升,但在复杂交互和动画上仍卡顿,体验不流畅。

时间到了 2015 年,这一年Fackbook开源了一种跨平台开发的方案,也就是React Native (RN)。

2017年,Google将Flutter首次正式介绍给全球开发者,提出为iOS和Android构建高质量原生性能级界面的核心理念;2018年12月,Flutter 1.0稳定版发布。

2017年底,Kotlin 1.2引入了 Multiplatform 项目模型;2023年11月1日,JetBrains KMP首个稳定版发布,KMP的目标是业务共享,UI原生。

2020年,JetBrains 开始推进Compose跨平台;2021年12月,Compose Multiplatform 1.0 正式发布。

2、四种方案的比较

命令式UI:需要自己一步步地亲自去实现,手把手地告诉系统"我的UI长这样"。当需要设置一处文字时,你需要在目标位置设置一个文本控件,给它设置文本值,当文本值变化时你再调用xx.setText()去修改它的文本,当不需要它展示出来时,你再去调用xx.setVisibility()使其不可见......每个改动都需要开发者去实现:事无巨细,开发者全程实操。

声明式UI :只需要直接说出需求,让声明式UI框架或系统去替你实现。例如,说 我需要在XX位置放置一个多大尺寸的文本框,它显示的文字来自哪个参数、XX情况下显示/YY情况下不显示 等信息,剩下的就由框架或系统去实现了,当参数的值变化了,框架或系统会自动修改文本框显示的文字,当显示条件变化时,框架或系统自动设置文本框的可见性。可见声明式UI大大降低了UI实现的成本,提高了效率。

Android XML传统方式:单单从XML文件的角度来看,其实是一种声明式UI方案,但到了修改属性的阶段,由于通常需要先findViewById,然后再调用控件的API改变属性,所以又是一种命令式UI,所以Android传统的XML布局UI方案是一种混合方案。

2.1 React Native
2.1.1 产生的背景

React Native源自React框架。React 是 Facebook(现 Meta)在 2013 年开源的一个 JavaScript库,专门用于构建网页用户界面。在 2013 年前后,前端开发主流的模式(如 MVC)在面对 Facebook 这样体量巨大、交互频繁的产品时,性能和代码维护都遇到了瓶颈。后来Android XML方式的UI实现方式面对的其实是同样的问题 Android UI为什么由XML转向Compose

(1)参数变了,所有用到该参数的地方都要一个个找出来逐个修改(在DOM上体现出来),效率低,易遗漏、出错;

(2)一小块变化往往需要刷新大块甚至整块的DOM来更新,性能低;

(3)业务和UI混在在一起,边界不清,不易维护。

DOM:(Document Object Model,文档对象模型) 是浏览器将HTML文档解析成一个由对象组成的树形结构 ,并提供了操作这棵树的**接口(API)。**浏览器呈现出的界面就是这个DOM图形化的结果,对这个DOM做修改,界面也就能变化。例如,一个文本框要展示的文字由"ABC"变成了"DEF",那就去DOM里修改对应的字符串为"DEF"即可,之后浏览器就会根据修改后的DOM把界面更新过来。

这些问题,在互联网初期和产品初期阶段,由于业务简单、页面简单,并没有显现出来。为了解决这些问题,后来也出现了一些过度方案,比如数据绑定方案,但其实现原理是观察者模式,仍然是需要开发者考虑周全并实现,仍然需要大量的协调。

React的作者Jordan Walke在面对这些问题时思考到 既然手动处理数据和DOM的对应关系这么痛苦,能不能干脆放弃直接操作DOM------ 每次数据变化,就重新生成一个DOM。但是显然,这样性能会非常非常差,所以行不通,但是这个想法已经有了声明式UI的雏形:开发者只需关注数据,界面由一个生成HTML的方法去实现(即每次都由该方法根据数据去生成新的DOM),每次数据变化不必再去修改DOM------是不是已经接近了"开发者描述UI长什么样即可,剩下的交由框架或系统去实现"。为了解决性能问题,Jordan Walke继续改进:引入虚拟DOM。

(1)在内存中用轻量级的JavaScript对象描述DOM结构。

(2)数据变化时,先产生新的虚拟DOM树,然后通过高效算法(Diffing)比较新旧树的差异,最后只把实际变化的部分更新到真实DOM上。

思考:这不就比直接修改原DOM树还多了几步吗?岂不是既更麻烦也更性能低了吗?

需要先来介绍一下基础背景。浏览器为了能及时呈现正确的数据(即保持与DOM树同步),在每次有DOM树修改时,浏览器都可能会立即重绘甚至重排。虽然现代浏览器也有了批量更新的机制,但也并不能保证所有修改都会批量更新;而引入虚拟DOM后,可以批量对DOM进行操作,然后做一次批量提交,浏览器就可以只用一次重绘或带上重排就可以更新了。综合看来,性能仍然是大幅提升的效果。

思考:引入了虚拟DOM,然后......就可以叫声明式UI了?

显然不是。虚拟DOM只是react的优化手段。react之所以叫声明式UI,是因为它做了其他的适配。JSX 是 JavaScript 的一个语法扩展,它让你可以在 JavaScript 文件中写类似 HTML 的标记,用来描述用户界面。React 使用 JSX 描述 UI 长什么样;JSX 被编译后就变成了普通的JavaScript代码,这些代码被React执行后生成虚拟 DOM 树;React 随后将虚拟 DOM 树转换为真实 DOM 树并渲染到页面上。看:声明式UI的效果达到了。

说回React Native。当时移动开发正面临着Android、ios各自为政、独立开发的问题,跨平台通用开发方案的需求日益强烈。FaceBook想到的一个方案便是能否用JavaScript语言来调用原生平台的API,从而编写UI。React的作者Jordan Walke受React的启发,启动了React Nativie(RN)

2.1.2 React Native的原理

与React类似,也是使用JSX来描述UI长什么样子,而且无论是在android还是ios,使用JSX写出的代码都是一样的,因为JSX与平台无关,这样就在最上层实现了跨平台的效果;之后由RN编译器将其编译为javascript代码,到这一步仍然是跨平台的,因为javascrip语言也是与平台无关的。当程序运行时,RN Runtime执行这些javascript代码生成虚拟DOM树,然后通过 桥接(旧架构)JSI(新架构) 把 UI 指令发送到原生端,最终由原生端创建、布局并渲染出真正的原生控件。所以一直到生成虚拟DOM树这一步,两个平台上都是无差异的,最根本的差异就发生在生成本地控件这一步,这一步由RN 框架的"渲染器" 完成**。**

思考:所以RN是一个UI跨平台的实现方案,而不能实现业务逻辑跨平台?

并不是。 当用 RN 开发时,包括 UI 描述(JSX),所有用 JavaScript/TypeScript 编写的业务逻辑(比如状态管理、数据处理、API 请求、本地存储、用户认证逻辑、工具函数等)都是一份代码,同时运行在 iOS 和 Android 上。只有当需要直接编写原生代码(Java/Kotlin/ObjC/Swift)来扩展 RN 的功能时,那部分才需要为每个平台单独实现。

2.2 Flutter
2.2.1 产生的背景

和RN产生的背景一样,Google在移动开发领域同样也面临着跨平台的需求,而且Flutter还借鉴了RN声明式UI的思想。此时RN虽然已经诞生,但是跨平台还是存在着一些问题:

(1)一致性差。 如React Native仍需桥接(Bridge)原生控件,带来通信开销的同时,也难以保证不同平台上UI效果的完全一致,开发者仍需处理平台差异。

(2)性能差。仍然难以提供媲美原生应用的流畅性能和操作体验。

在几乎同一时期,为了能有一个能达到 120 FPS 刷新率的高性能UI框架,Google Chrome团队创建了一个代号为"sky"的内部项目,编程语言为Dart,并于2014年在github上悄然公开了源码;2015年在Dart峰会上公开亮相;2015年10月 ,经过一年的发展,Sky项目正式更名为"Flutter";2017年,Flutter首次正式被介绍给全球开发者,提出为iOS和Android构建高质量原生性能级界面的核心理念;2018年12月Flutter 1.0稳定版发布。此后官方提出了更加准确的描述:Flutter 是 Google 开源的一个 UI 工具包,帮助开发者通过一套代码库,为移动、Web、桌面等多个平台构建精美的、原生编译的应用。

sky项目最初并不是为了解决跨平台问题而创建的,只是后来团队发现,可以在sky的基础上,结合当时跨平台面临的问题继续完善sky,并最终演化成了Flutter。

2.2.2 Flutter的原理

RN最终其实调用各平台的原生UI API来实现的,使用的是各系统(Android/ios)本身的图形引擎,而Flutter为了追求一致性和高性能,自己研制了一套图形引擎。它不依赖移动操作系统(如iOS、Android)的原生控件,而是直接在应用的画布上高效地绘制所有界面元素。

Flutter 采用了清晰的分层架构,从上到下依次是Framework (框架层)、 Engine (引擎层)和Embedder (嵌入层)

**Framework (框架层)**这是开发者直接打交道的UI工具箱,是一个用Dart语言实现的高层UI SDK。它提供了丰富的组件(Widgets),用于构建不同风格的界面。

Engine (引擎层) 这是Flutter的动力核心,主要由C++编写,它最大的作用就是屏蔽平台差异 ,提供统一的运行和渲染环境。它包含

Skia图形引擎 负责责将所有UI绘制指令转化为屏幕上像素的绘图引擎。最新Flutter上是 Impeller 引擎。

Dart运行时 负责执行Dart代码,包括垃圾回收(GC)等。

文本渲染引擎 专门负责文字的排版和渲染。

**Embedder (嵌入层)**这是Flutter与底层操作系统的翻译官,针对不同平台的适配层,每个平台独立实现。它用适合各平台的语言(如Android用Java/C++,iOS用Swift/Objective-C)编写,负责协调渲染Surface、管理线程和事件循环,使Flutter应用能以原生程序的方式运行,简单来说它把 Flutter 渲染的画面嵌入平台窗口,处理原生交互(屏幕、键盘、输入法)。

与RN类似,Flutter也存在着多层转化的情形,称为三棵树模型

Widget 树: 描述UI长什么样,相当于RN里的JSX层。

Element 树: 程序运行起来后,Flutter框架依据Widget树创建出来的,类似RN里的虚拟DOM。它是Widget 和 RenderObject之间的纽带,管理Widget生命周期,是Widget树真实结构的体现。

RenderObject 树:与Element树同步形成,每创建一个Element时,Element都会同步创建一个RenderObject。RenderObject负责实际的计算布局(Layout)和执行绘制(Paint)指令,并最终输出到屏幕上。类似RN里的真实DOM。

Flutter的执行流程就是 由Dart语言编写的UI描述(即Widget)代码一执行,根据描述,生成 一棵Widget树,框架再根据Widget树构建或更新Element树,Element再按需构建或更新RenderObject树,之后RenderObject开始绘制,但是绘制完成也并不能马上呈现在屏幕上,这里的绘制只是相当于拍了照,把界面该长什么样定下来了,但是还需要把绘制完毕的RenderObject继续交给Flutter的自绘引擎,再由自绘引擎去跟系统底层打交道,最终才能呈现在屏幕上。

Flutter没有局部更新机制吗?

**当然有。**这也是Element树存在的原因。Element树就相当于RN里的虚拟DOM。当状态发生变化时(比如调用setState),Flutter 并不会直接去修改界面,而是先把当前发生变化的 Element 标记为需要更新(dirty),等到下一帧到来时,再从这个 Element 开始重新执行对应 Widget 的build方法,生成一棵新的 Widget 子树。生成完成后,框架会拿新的 Widget 树和旧的 Widget 树进行对比(Diff),看看哪些地方发生了变化。对于没有变化的节点,直接复用原来的 Element 和 RenderObject;对于发生变化的节点,则更新对应的 Element 或 RenderObject。完成更新后,RenderObject 会重新进行必要的布局(Layout)和绘制(Paint),生成最新的渲染结果。最终这些渲染结果会被提交给 Flutter 的自绘引擎(Skia/Impeller),再由引擎与系统底层的图形接口进行交互,最终把更新后的画面显示到屏幕上。

Flutter仅是UI跨平台,无法业务跨平台吗?

Fluttr与RN一样,也可以实现业务逻辑共享,但是也跟RN一样,当涉及到摄像头、蓝牙、NFC、定位等本地能力时,也需要调用原生API。

Flutter为什么采用Dart语言?

(1)Dart是Google自己开发的语言,拥有绝对的控制权。

(2)Flutter团队和Dart团队的办公地点很近,能够确保最高效的沟通与协作。

(3)Dart最初是为取代javascript而生的,其垃圾回收机制很适合 Flutter 频繁创建销毁 Widget 的模式。

(4)Dart 可以预先编译成本地原生机器码,对于Flutter追求高性能的需求非常贴合。

2.3 KMP
2.3.1 产生的背景

Kotlin Multiplatform(KMP)是继RN、Flutter之后又出现的一种跨平台方案。Flutter 和 React Native 虽然提供了 UI + 业务一体化的跨平台能力,但在企业实际落地中,UI 层往往涉及产品体验、平台特性和既有原生体系,公司也不愿意放弃对UI的控制权,而且部分场景下不如原生UI的性能好,因此迁移成本较高、接受意愿较低。而此时Kotlin已具备多平台编译能力,于是JetBrains决定使用Kotlin构建一套业务共享+UI原生的方案,KMP应用而生,此后又得到了Google的大力支持和推广。

KMP的诞生其实有一种妥协的成分,因为当时推动UI跨平台难度很大。如果使用跨平台UI,意味着产品体验不再完全由原生团队控制,把部分控制权交到了Flutter、RN等这类跨平台框架的手里,一些手势细节、无障碍支持等与平台相关的特性也可能丢失,已经成熟的Android团队、ios团队要整合等等。所以市场上仍然有保持原生UI(至少是部分原生UI)+跨平台共享业务的需求。

2.3.1 KMP的原理

KMP的整体思路是用 Kotlin 编写公共代码,然后针对不同平台编译成各自能够运行的原生代码,并通过 Expect/Actual 机制解决平台差异。例如,同一份 Kotlin 代码,编译成 JVM 字节码运行在 Android,编译成 Native 二进制运行在 iOS,也可以编译成 JS、Wasm 等,因此实现代码复用。所谓的Expect/Actual机制其实就是在公用代码中定义接口,但由各平台自己去实现接口,以此抹平平台差异。

Kotlin语言最常见的应用情形是将Kotlin代码编译为字节码然后运行在JVM上,但是Kotlin也可以将Kotlin代码编译为二进制的native代码运行在ios这样的平台上而不必再需要JVM,也可以编译成为其他形式的代码而摆脱JVM,这是KMP能够跨平台的根本能力。KMP的互操作是零成本的。它基于平台的二进制接口规范(ABI),通过直接函数调用 实现。Kotlin的类型会直接映射到Swift/Obj-C的类型,例如List<String> 直接变成Array<String>。同时,所有跨语言调用都发生在编译期,没有额外的运行时代理或序列化开销。

2.4 CMP
2.4.1 产生的背景

Google看到RN、Flutter声明式UI方案带来的明显优势,决定改良Android XML这种传统的UI实现方式,于是推出了Compose这种声明式UI方案作为Android的原生UI方案。Compose 于 2019 年首次发布预览版,2021 年正式发布 1.0 版本。

前面已经提到过,KMP采用Kotlin语言来实现,而Compose也是使用的Kotlin语言,而此时JetBrains在KMP成熟后也恰好需要补齐一下UI跨平台的短板,JetBrians将目光集中大了Compose身上,于是Compose Multiplatform(CMP)诞生了。

2.4.2 CMP的原理

CMP(Compose Multiplatform)的整体思路是在 KMP 的多平台编译能力基础上,将 UI 也纳入共享范围。开发者使用 Compose 编写 UI 和业务代码,然后针对不同平台编译成各自能够运行的原生产物,从而实现 UI 与业务逻辑的双重复用。

具体来说,开发者编写的 @Composable 函数本质上仍然是 Kotlin 代码,因此可以和普通 Kotlin 代码一样放在 commonMain 中共享。编译时,KMP 负责将共享代码编译到不同目标平台,而 Compose Runtime 则负责管理状态、触发重组(Recomposition)以及驱动 UI 更新。详细原理见我另一篇博客 Android UI为什么由XML转向Compose

skia绘制引擎是Android系统底层的一部分,所以在Android上,compose在最后的绘制阶段由于是交给Android系统底层去处理的,所以会间接交给skia处理;在windows、ios等平台上,CMP里本身集成了skia,所以compose也是自绘的,并不是平台原生的UI;在web上则不一定是skia引擎,而是可能交给浏览器去渲染。总之,CMP 的核心原理(Compose Runtime + 声明式 UI + Recomposition)相对稳定,但其具体渲染后端会随着平台和版本不断演进,因此不同平台上的最终渲染链路可能存在差异。

既然Google已经有了Flutter,为什么又要推出Compose和CMP?

Compose的诞生并不是为了实现跨平台,它是受声明式UI的启发加上Android传统的XML方式的弊端日益凸显而应运产生的,目的是取代XML方式而成为Android原生UI的实现方式;Flutter则是为了跨平台而生的,不仅仅局限在Android上;而CMP是JetBrains推出的,并不是Google的,是JetBrains为了补齐KMP无法UI跨平台而设计的。

3、各方案的总结

如果仔细梳理它们的时间线其实可以发现它们几乎是在同一时期发源,又都经过数年的摸索和试验才最终发布了稳定版。

RN:UI+业务双跨平台。最终UI为原生。JSX -> 编译为javascript -> RN Runtime转化 -> 虚拟DOM -> 真实DOM -> 原生UI。

Flutter: UI+业务双跨平台。最终UI为自绘UI。dart代码 -> Widget树 -> Element树 -> RenderObject树 -> 自绘引擎 -> 自绘UI。

KMP:业务跨平台。Kotlin代码 -> 编译为适配平台的代码 -> 平台运行。

CMP:UI + 业务双跨平台。Kotlin代码 -> 编译为适配平台的代码 -> 交由CMP自带引擎绘制或平台自己绘制。

性能:RN需要层层转换后间接调用原生UI;Flutter也是层层转换,但最终调用自己的引擎,Flutter的skia引擎经过专业的优化,在部分场合下确实比原生UI性能更好;KMP/CMP则是直接编译为本地代码,再交由skia或本地引擎绘制。在综合性能上 KMP/CMP > Flutter > RN ,具体场景则可能又有差异,例如启动速度上 KMP/CMP ≈ 原生 > Flutter > RN;在UI渲染流畅度、动画表现等"图形"维度 Flutter ≈ 极致优化的原生 ≥ KMP/CMP (原生UI) > RN。

生态:KMP/CMP作为后起之秀,生态上还不如另外两者。Flutter ≈ RN > KMP/CMP。

热更新:RN>Flutter(官方不支持,第三方方案也有限制)>KMP/CMP(不支持)。

相关推荐
●VON3 小时前
AtomGit Flutter鸿蒙客户端:安全JSON解析
安全·flutter·华为·json·harmonyos·鸿蒙
●VON3 小时前
AtomGit Flutter鸿蒙客户端:项目架构概览
flutter·华为·架构·harmonyos·鸿蒙
●VON4 小时前
AtomGit Flutter鸿蒙客户端:OAuth2认证与登录
flutter·华为·跨平台·harmonyos·鸿蒙
●VON4 小时前
AtomGit Flutter鸿蒙客户端:Tab导航架构
flutter·华为·架构·harmonyos·鸿蒙
逻极4 小时前
Hermes Agent深度解析:从ReAct到多智能体系统架构实战
llm·agent·react·rag·多智能体系统
一个假的前端男16 小时前
windows flutter 适配鸿蒙
windows·flutter·harmonyos
2501_912784081 天前
跨境自建站踩坑总结:放弃开源商城二开,改用成熟 Taocarts SaaS 落地跨境项目
react·taocarts·跨境saas
SoaringHeart1 天前
Flutter进阶|源码修改:DecorationImage 添加网络图片占位图
前端·flutter
jingling5551 天前
Flutter | 商城项目鸿蒙(OpenHarmony)适配实战
android·开发语言·前端·flutter·华为·harmonyos