
Modaal:共享行为、各写界面,一致性交给一条会变红的检查
Modaal 用一份共享的功能逻辑同时长出 SwiftUI 与 Jetpack Compose 两套原生界面,再用逐字节比对的重放检查,把两个平台的行为分叉变成一次构建失败。
Modaal 是一款 macOS 应用,用来把一段文字描述变成能上架的原生 iOS 和 Android 应用。Modaal 站在你和编程 agent 之间做编排:接上你已经付费的 Claude Code、Codex、Cursor、Gemini CLI 等 13 种 agent,再补齐终端里的 agent 缺的那几件事——把一个新文件注册进 Xcode target、把构建报错结构化地读回来、驱动模拟器,以及在一次上下文清空之后还记得住项目架构。1 2026 年 9 月 17 日,Modaal 在 Product Hunt 上线 Android 支持;在此之前它只做 iOS,现在同一个项目可以同时产出 Xcode 工程和 Android Studio 工程。2
跨平台工具的惯常选择是让一套界面代码在两个系统上渲染出同样的画面。Modaal 这次站到了相反的一边:界面各写一份,只把行为合并成一份,再用一条构建时的检查确认两边的行为逐字节相同。官方的说法是
shares the logic, not the screens。2 这套机制叫 Duet,框架本身开源,当前是 0.7.0 的预发布版本。3把共享层往上挪一层,界面留给两个系统
先看清楚它放弃的是什么。Flutter 和 React Native 共享的是界面:一套控件同时决定两个平台的观感和手感。这个交换在很多应用上是划算的,代价也写在明处——界面成为两种设计语言的折中,平台发布新特性时要等框架跟上,平台专有的行为只能通过逃生舱接进来。4
Duet 共享的位置比界面高一层。两个平台只在一件事上达成一致:此刻屏幕上该出现哪个界面,而这件事被写成一个值,放在功能状态里。至于这个界面怎么画——半屏还是一整页、转场用什么曲线、圆角多大、导航栏长什么样——两边互不相干,也不允许泄漏进共享状态。官方文档举过一个例子:Android 的
ModalBottomSheet 和 iOS 的 .sheet 在应用逻辑里是同一个界面,在用户眼里是两件完全不同的事,而这正是对的,因为 Android 用户期待的就是一块从底部升起的面板。4这条界线由一份五层的契约写死,其中只有下面三层是共享的。5
- 结构层:导航代数只有四个原语——可返回的压栈
path、每个节点唯一的模态槽slot、tabs、整屏阶段stage。这个集合是封闭的,想加第五个要走一次版本化的契约变更,默认答案是不加;四个原语够不够用,官方在里程碑上做一次普查来回答——把所有 reducer 的状态扫一遍,看导航类字段有没有落在四个原语之外。 - 种类层:每个能出现的界面是应用自定义枚举的一个 case,payload 只放 id、配置、开关这类纯值。
- 动作层:任何改动导航结构的操作都要经过一个有名字的动作,各平台的外壳只能报告用户做了什么,不能自己决定跳到哪儿。契约把这条写成
no silent transitions。 - 方式层:共享到此结束。动画曲线、圆角、半屏高度、导航栏样式全部归平台,项目可以自由发挥;如果这类信息出现在功能状态里,比如状态里冒出一个
detent字段,那就是违约。 - 岛:相机、视频播放器、可缩放的图片浏览这类内部状态没法也不该塞进状态机的区域,Duet 允许它们存在,但要求声明边界、记进账本。
前三层共享,第四层和第五层各自自由,中间那条线怎么划,契约给了一条可以直接拿来用的判据:fixture 看得见的东西共享并设闸门,只有用户看得见的东西留给平台自己画。5
这条判据在一个真实应用上有个很直观的落点。官方文档用 Memory Lane 做例子:它是一款共享回忆的应用,iOS 版已经上架,之后用 Duet 补了一套完整的 Android 版。5 Memory Lane 的拍摄入口在共享状态里只是一个模态槽,槽上挂着十个 kind——照片来源、相册、相机、视频来源、录像、视频相册、录音、位置、好友、日期。iOS 用
.confirmationDialog、.sheet、.fullScreenCover 三种 SwiftUI 呈现通道,加上系统的 Photo Picker 和 AVFoundation;Android 一边用 Material 的 AlertDialog 与 ModalBottomSheet,一边把拍摄整件事交给系统应用。两端的呈现方式几乎没有交集,而 reducer、状态形状和录制下来的 fixture 逐字节相同。5能这样,是因为两端的外壳都不被允许自己做导航决定:它们只能上报用户做了什么,由共享的 reducer 判断那意味着什么。5
界面各写一份,直觉上两边会长成各自平台默认的样子。Modaal 在这里做了一个明确的选择:Android 用的是一套对齐 iOS 配色的 Material 3 方案,同一组暖纸色与深棕作为主色,明暗各一份,由 Modaal 在这个项目里调出来;组件仍然是各自原生的 Compose 和 SwiftUI。官方对这个取舍的说法是,两个应用看起来像同一个产品,各自又都是原生的。如果你要的是每个平台最大程度贴近自己的默认外观,这条取舍值得先知道,Duet 服务的是前一种需求。4

这套做法要付的账是界面写两遍。Duet 把第二遍交给 agent:官方说第二套界面的实际开销主要是 agent 的时间,因为底下的行为已经写好、录好、也通过了检查,不需要第二次设计。6 平台真的做不到的能力也不必假装。Apple 登录在 Android 上没有对应,Memory Lane 让环境把那条路径映射到共享 reducer 已经处理过的失败分支上,按钮根本不存在,契约也不需要为此改动。5
一致性由机器证明,判断留给人
「两个平台行为一致」这句话在任何跨平台工具的宣传里都能看到。Duet 的版本是可以执行的:每个功能的行为录成 fixture 文件——一组交互场景与规范化之后的状态,能拿去做重放比对;两个平台重放同一批 fixture,比较逐字节相等,不相等就是一次构建失败,失败落在引入分叉的那次提交上。36
逐字节比较能成立,靠的是一条硬约束:reducer 只持有状态,不带环境——没有时钟、没有随机 ID、没有网络。所有不确定的东西作为 effect 走出去,再以 action 的形式回来。6 这一条是重放能对上的前提。
顺序上还有一条规定,是这套东西里最容易被忽略的一条:先录 fixture,再写界面。两个平台对「这个界面该怎么画」还没有形成意见之前,先把预期的行为钉下来,共享模型就只关于「是什么」,平台留着「怎么画」的全部自由。45
需要不一样的地方,Duet 的做法是要求把差异写在明处。项目里有一份
parity/manifest.yaml 账本,两个入口:manner waiver 用于同一个界面在两个平台上用不同类别的呈现方式,比如一边是全屏对话框、一边是半高面板,只是圆角或动画曲线不同不需要登记;island 用于内部状态活在状态树之外的区域。账本由 lockstep-lint 检查,而它只核对形状和交叉引用——每个条目要对应真实存在的功能、合法的平台、ISO 格式的日期。这条差异值不值得,lint 刻意不判断,留给人来审。5官方公布了一组来自 Memory Lane 的实测结果。十六个 Android 界面写完,改动的功能模块数量是零;
duet verify 停在 134/134,fixture 语料一次都没有重录;账本上留下 0 条 waiver、6 条 island 条目,也就是 3 个区域乘 2 个平台。官方把六处刻意做出来的平台差异也列了出来:Android 不做 Lottie 开场动画,不做 Apple 登录按钮,不做反应飞行动画,不做视频转码,用 Compose 的分页 Dialog 代替 UIKit 轮播,标签先只出文字。这六处每一处最多花掉一行日志。5机器能证明的到这里为止。另一边是写代码之前的那道闸门:第一轮里,agent 读完你的描述、PRD、截图或者 Figma 链接,在项目根目录写下
PRD.md,再把第一个功能规划成一份规格,固定包含概述、用户场景、技术方案、实现步骤、要新建或修改的文件、风险与未决问题、测试策略七节。你读完、改完、批准,才动第一行代码。规格文件留在项目里,所以第五十个功能是跟第一个功能对着同一份文档规划的。67这道闸门要求人真的去读。Modaal 联合创始人 Ivan Misuno 在 Product Hunt 的介绍里说,他 80% 的时间花在工作和审计划上,换来的是 agent 写出来的代码对他没有意外;他还说应用长大之后,过去的计划跟写下来的代码一样重要,因为它们让你能引导项目。8

现状也要说清楚。Duet 还在预发布,当前版本 0.7.0:Swift 包按 tag 用 URL 解析,Kotlin 制品从项目自己的静态 Maven 仓库取,1.0 之前按约定会有破坏性变更,所以要精确固定版本。3 Android 是开放 beta,iOS 才是成熟路径。9 架构在创建项目时选定,之后跟随项目终身:两个 Duet 口味里,「iPhone 先上、Android 以后加」用纯 Swift 包,不需要 Kotlin 工具链;「iPhone 和 Android 一起」用 Kotlin Multiplatform 的
commonMain 放共享逻辑,Mac 上还需要 Android SDK 和 JDK。免费版只能建一个平台,两个平台同时建属于 Pro,按年付每月 9 欧元。16值得搬走的是那两道检查
Modaal 这两处设计落在同一条线上。共享层往上挪了一层,界面各写一份;相应地,它得用别的东西换回「两边一致」这个保证——一份逐字节比较的 fixture 语料、四个原语的封闭集合、一条「导航改动必须经过有名字的动作」的规则。当生成代码这件事足够便宜,值得买的东西换成了更多能被机器检查的契约。5
References
- 1Pricing — Modaal
modaal.dev
- 2Modaal — Product Hunt
producthunt.com
- 3modaal-agent/duet — GitHub
github.com
- 4
- 5
- 6Duet:build native iOS and Android apps from one shared core
docs.modaal.dev
- 7
- 8Hello ProductHunt! — Modaal Discussion Forums
producthunt.com
- 9Modaal — the AI app builder for native iOS and Android apps
docs.modaal.dev
This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.
