做一个跨平台搜索页,最容易跑通的是「输入关键字,下面显示结果」。麻烦通常出现在后面。中文输入法正在组词,列表能不能等文字上屏再刷新?系统把字号调大后,输入框和搜索按钮还能排在一行吗?到了桌面端,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 只有一个计数信号。把样式和重置按钮省掉,读取和写入分别发生在标签和按钮里。
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 的统一渲染管线更贴合这个目标,原生地图、相机预览等页面则值得单独验证。
可以把开头的搜索页做成一个小验收。每套实现用同一份本地数据、相同的列表和查询触发规则,在同一台目标设备上分别跑一遍。假设产品要求「中文上屏后再查」,就这样测。
- 输入法组词。输入拼音
zhongwen,先停在候选条,再选中「中文」。记录文本事件和查询次数。预期是组词期间零次查询,上屏后一次。 - 刷新列表。先滚动到一条在查询前后都存在的结果,再修改关键词。检查输入框能否继续输入,以及列表是否按同一规则保留可见位置。如果产品要求回到顶部,三套实现就统一按这个预期验收。
- 字号与键盘。把系统字号调大,观察输入框和按钮是否出现遮挡。桌面端再用 Tab 走一遍焦点顺序,确认输入框、按钮和结果区域都能按预期操作。
把结果记成「系统与框架版本、实际表现、需要补的代码」。如果某个平台要为组词状态另写适配,或每次列表更新都要手工恢复焦点,这些代码会比计数器的写法更直接地影响后续维护。