同一行代码,百种渲染:Asuka、React Native 与 Flutter 跨平台渲染机制深度拆解

做一个跨平台搜索页,最容易跑通的是「输入关键字,下面显示结果」。麻烦通常出现在后面。中文输入法正在组词,列表能不能等文字上屏再刷新?系统把字号调大后,输入框和搜索按钮还能排在一行吗?到了桌面端,Tab 键又会把焦点移到哪里?

这些细节由不同的代码负责。同一个输入框,在 Asuka 里由 Go 声明,交给平台宿主实现。在 React Native 里,它是 React 组件树的一部分,最后落到宿主视图。在 Flutter 里,常用的 TextField 由 Flutter 自己布局和绘制,同时与平台输入法交互。这个分工决定了遇到问题时要去哪里改,也影响一套界面能复用多少。

下图把三套框架的路径排在一起。从左往右看,最后一格是屏幕上的输入框由谁呈现。

Asuka 和 React Native 的界面最终落到宿主控件。Flutter 的常用控件由自己的渲染管线呈现,输入法仍由平台提供。

Asuka 把布局留在 Go,把控件交给宿主

Asuka 是 EGOIST 开发的 Go UI 框架。仓库目前将项目标为 Alpha,目标平台覆盖 iOS、Android、macOS、Windows、Linux 和 Web。iOS 用 UIKit,Android 用 Android Views 和 Material 3,macOS 用 AppKit,Windows 用 Win32 和通用控件实现 Windows 11 风格,Linux 用 GTK 4 和 libadwaita。Web 版把 Go 编译为 WebAssembly,宿主操作浏览器的 DOM。

点一次按钮,Go 会发出什么指令

仓库的 examples/counter 只有一个计数信号。把样式和重置按钮省掉,读取和写入分别发生在标签和按钮里。

go 复制代码
count := signal.New(0)
label := ui.Text(signal.Func(func() string {
    return fmt.Sprintf("Count: %d", count.Get())
}))
add := ui.Button(ui.ButtonProps{
    Title: ui.Val("Add"),
    OnPress: func() {
        count.Update(func(n int) int { return n + 1 })
    },
})
return ui.Column(ui.Style{}, label, add)

count.Get() 在计算标签文本时登记依赖,count.Update() 通过 Set() 写回新值。接下来还有一层判断。ui/components.go 的 bind() 比较本次和上次算出的文字。如果标签只显示奇偶性,计数从 1 变成 3 时,计算会重跑,但由于文字仍是「奇数」,宿主收不到新的属性写入。如果标签从「Count: 9」变成「Count: 10」,ui.Text() 会把 PropTextText 写进操作批次,并请求重新测量。

宿主量尺寸,Go 决定摆放位置

ui/root.go 的 Root.Flush() 先通过 host.Host.Apply() 提交创建和属性操作。运行布局时,Go 通过 Measure() 向宿主查询控件在约束内需要多大空间。算出位置和尺寸后,框架再把变化的帧提交给宿主。

同一组 Go 组件到了 macOS,host/darwin/views.go 会把 KindButton 映射成 NSButton。到了浏览器,host/web/host.go 会把操作批次编码到 WebAssembly 内存,JavaScript 读取后更新 DOM。下图只展开这两个宿主,便于跟踪按钮从声明到显示的过程。

这个分工也解释了细粒度更新的边界。仓库的 TestOneSetOneSetProp 用假宿主测试,把 Ada 换成 Grace 时只记录到一次属性写入和零次帧更新。把文字换成 100 个 x 后,仍只写一次属性,但记录到了两次帧更新。信号只通知了订阅者,文字换行却能改变下面控件的位置。Asuka 源码拆解与测试记录

热重载会把哪些信号写入快照

asuka dev 重载时,signal/keep.go 通过调用路径、创建位置、类型和序号匹配新旧信号。保存前,它会做一次 JSON 序列化和反序列化,再用信号的相等函数确认还原后的值一致。下一次运行创建信号时,框架会尝试按同一标识取回快照里的值。这里的定向测试验证了写入环节。整数 7 成功进入快照,带非零未导出字段的结构体经过 JSON 往返后丢了字段值,整条信号因此被跳过。

发布时可以用 asuka build 和 asuka publish,具体按平台配置签名和账号。Linux 宿主可在不依赖 cgo 的情况下调用 GTK,运行时需要相应的 GTK 和 libadwaita 库。

React Native 用 Fabric 更新宿主视图

React Native 同样会用到平台视图。应用用 JavaScript 或 TypeScript 写 React 组件,界面更新经过 Fabric 的 Render、Commit 和 Mount。以一次状态更新为例,渲染器构建新的 Shadow Tree,计算布局,比较前后两棵树,再把生成的操作应用到相应的宿主视图上。官方示例里,一次背景色修改最后可以只更新一个视图的颜色属性。React Native 渲染流程

官方的数据流图把职责分成了三块。React 接收事件,Fabric 构建树、计算布局并提交,宿主平台完成挂载。图右侧的箭头标出了布局和平台事件回到事件处理器的路径。

图源见 React Native 渲染流程。

老资料常用异步 Bridge 解释 React Native。看新架构时,Fabric 是渲染器,JSI 则是 JavaScript 与原生代码互操作的接口。它们解决的问题不同。拿搜索框来说,输入事件、列表状态更新和动画也各有调度成本。如果快速输入时列表掉帧,得看页面实际在哪一步花了时间。仅凭「用了 JSI」推不出帧率,也不能从架构名推断业务页面的延迟。React Native 新架构

有 React 团队和现成依赖时,比较实际的做法是拿最复杂的页面试一下。比如表单用了地图、相机或扫码模块,先验证对应库在目标平台和项目版本上能否正常接入。项目如果还要上桌面或 Web,也应把扩展实现的维护成本算进来。

Flutter 把常用控件的外观交给自己的渲染管线

Flutter 用 Dart 描述界面。Widget 描述配置,Element 维护界面树中的实例和位置,RenderObject 负责布局、绘制等工作。常见按钮和文本框走 Flutter 的渲染管线,因此产品可以在各平台共享大量控件样式、动画和布局。字体、显示缩放及平台输入习惯仍会影响最终体验。渲染后端也随目标平台而异,具体可查 Impeller 的平台说明。Flutter 架构概览 · Impeller 平台支持

在 Flutter 的分层图里,Widgets、Rendering 和手势处理属于 Dart 框架,下面的 Engine 负责渲染与合成,Embedder 接入各平台的渲染表面和事件循环。

再把视角缩到一个页面。三棵树的图把 Widget 配置、Element 实例和实际参与布局绘制的 RenderObject 对照起来。

两张图均来自 Flutter 架构概览。

需要复用原生地图 SDK 的视图时,Flutter 可以用 Platform Views。例如在 Android 上把原生 Google Maps 放进 Flutter 页面。官方文档列出了不同合成方式对滚动、无障碍和性能的影响。做这种页面时,用目标设备验证地图和浮层怎么叠,比争论整个框架是不是自绘更能帮助选型。Flutter Android Platform Views

同一次状态变化怎么更新

假设搜索结果从 9 条变成 10 条,只看结果数标签怎样更新。

  • Asuka :结果数信号写入 10 后,订阅它的文本计算生成新字符串,bind() 将属性更新写进批次。文字需要重新测量时,由宿主报告尺寸,再由 Go 布局决定标签和相邻控件的位置。排查时可以分别看属性写入和帧更新。
  • React Native :结果数作为 React 状态更新,Fabric 在提交阶段用 Yoga 计算 Shadow Tree 的布局,文本测量可以调用宿主实现。挂载前比较前后的树,再把文字或位置的变化写进宿主视图。排查时可以区分组件重新渲染、布局计算和最终挂载的操作。React Native 渲染流程
  • Flutter :如果结果数保存在页面状态里,setState() 会安排对应的 Widget 重建。新的文本配置沿 Element 更新到渲染对象,需要时再执行布局和绘制。排查时先定位哪一部分重建,再看文字尺寸变化是否触发了布局。Flutter 架构概览

三条路径最终都能改掉同一个标签。比较更新范围时,可以用两组输入观察:先让新文字仍能放进原来的空间,再换成会触发换行的长文字,看看相邻控件是否也需要重新摆放。

放在同一张表里看

维度 Asuka React Native Flutter
UI 和业务主要用什么写 Go JavaScript / TypeScript Dart
常用 UI 怎么呈现 Go 算布局,宿主实现平台控件 React 组件经 Fabric 提交宿主视图更新 Flutter 组件经自身渲染管线绘制
状态如何抵达界面 信号按读取依赖触发属性计算 React 状态更新触发渲染,挂载阶段应用视图差异 状态变化触发相关 Widget 重建,后续按需布局和绘制
平台差异主要在哪处理 宿主控件、能力适配和各平台测试 原生模块、平台组件和各平台测试 控件主题、平台集成和嵌入视图
接入项目先核对什么 Alpha API 的变动与关键控件覆盖 现有依赖与架构、目标平台的兼容性 Dart 上手成本与原生能力接入

包体积、内存和帧率适合用同功能的发布构建来比。比如固定设备和系统版本,分别测搜索页首次打开的耗时、连续输入时的掉帧,再看安装后的大小和稳定运行时的内存。开发预览、空白模板和带地图的业务包,测出来回答的是不同问题。

先把最难的页面跑通

已有 Go 代码、想给内部工具加一个界面时,Asuka 值得从真实页面试起。它把业务、状态和界面声明都留在 Go 里,能减少团队切换技术栈的成本。项目要长期维护或依赖特殊控件时,先固定一个版本,把输入法、键盘操作和目标平台的关键能力测过。

已有 React 团队,主要发 iOS 和 Android,可以先试 React Native,把时间花在跑通最重要的原生依赖和高频交互上。产品更看重品牌界面的一致性,准备做大量定制控件和动画,Flutter 的统一渲染管线更贴合这个目标,原生地图、相机预览等页面则值得单独验证。

可以把开头的搜索页做成一个小验收。每套实现用同一份本地数据、相同的列表和查询触发规则,在同一台目标设备上分别跑一遍。假设产品要求「中文上屏后再查」,就这样测。

  1. 输入法组词 。输入拼音 zhongwen,先停在候选条,再选中「中文」。记录文本事件和查询次数。预期是组词期间零次查询,上屏后一次。
  2. 刷新列表。先滚动到一条在查询前后都存在的结果,再修改关键词。检查输入框能否继续输入,以及列表是否按同一规则保留可见位置。如果产品要求回到顶部,三套实现就统一按这个预期验收。
  3. 字号与键盘。把系统字号调大,观察输入框和按钮是否出现遮挡。桌面端再用 Tab 走一遍焦点顺序,确认输入框、按钮和结果区域都能按预期操作。

把结果记成「系统与框架版本、实际表现、需要补的代码」。如果某个平台要为组词状态另写适配,或每次列表更新都要手工恢复焦点,这些代码会比计数器的写法更直接地影响后续维护。

相关推荐
Penge66629 分钟前
JavaScript 核心语法速通
前端
杨凯凡1 小时前
IT日报|2026-11-13:GitHub 大规模凭据攻击、Signal 形式化验证、Claude 新模型与 AI 安全诉讼
人工智能·安全·github
wangruofeng2 小时前
写给开发者的〈重构 UI〉实战笔记:从改好一个设置页开始,告别“程序员审美”
前端·aigc
程序员鱼皮2 小时前
格局!刚刚 DeepSeek 正式开放全家桶 API,咱都能复刻 DeepSeek App 了?
前端·aigc·ai编程
ZhengEnCi3 小时前
登录页电脑端怎么设计才不空:5 种布局方案与可粘贴 AI 提示词
前端·css·设计
Penge6663 小时前
JavaScript:DOM 树
前端
Penge6663 小时前
CSS 核心布局指南:盒模型、Flex 布局与 Position 定位
前端
Penge6664 小时前
HTML基础:HTML 标签属性
前端
Penge6664 小时前
HTML 基础:块级元素、行内元素与行内块元素详解
前端