moobile

    MoonBit UI for mobile: Android, iOS and Web from one rabbita (TEA) app, rendered by React Native

    moonbit
    mobile
    android
    ios
    web
    cross-platform
    react-native
    rabbita
    UI
    TEA
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    7 hours ago
    Downloads
    21

    Dependencies

    #moobile

    用 MoonBit 写一次 UI,跑在 Android / iOS / Web。

    moobile 是 rabbita(MoonBit 的 TEA 声明式 UI 框架) 的 React 渲染后端:Model / Msg / update / view 与 @html DSL 都不变, 只把最后一跳从"操作 DOM"换成"产出 React 元素"。于是同一份 MoonBit 代码, 经 React Native 上 Android / iOS,经 react-native-web 上浏览器。

    布局、排版、文字引擎全部由 React / RN 负责 —— moobile 不自己实现渲染。

    MoonBit 包moon add XiLaiTL/moobile@0.2.2
    宿主(JS)npm install moobile-host(React Native / Expo)
    已实测Web ✅ | Android 真机 ✅(Android 14 / x86_64)
    未实测iOS(宿主工程可生成,本机无法构建验证)| 桌面(未提供宿主)
    目标平台只支持 js 目标(库本身编到 JS,再交给 React)
    许可Apache-2.0(内含 rabbita fork,见 THIRD-PARTY-NOTICE.md)


    #1. 快速上手

    三段:MoonBit 写应用 → 宿主接上 → 跑。

    #1.1 MoonBit 侧

    # app/moon.mod name = "you/myapp" version = "0.1.0" preferred_target = "js" import { "XiLaiTL/moobile@0.2.2", }

    // app/moon.pkg
    import {
    "XiLaiTL/moobile" @moobile, // 库本体
    "XiLaiTL/moobile/style", // 类型化样式(写视图的入口)
    "XiLaiTL/moobile/html", // @html DSL
    "XiLaiTL/moobile/cmd", // Cmd / Emit
    "XiLaiTL/moobile/sub", // Sub(订阅:定时器、传感器…)
    }

    options(
    link: {
    "js": { "format": "esm", "exports": [ "app" ] },
    },
    )

    要用到的包就这五条。fork 住在 vendor/rabbita/,但使用者看不到那一层: 根上的 html/ cmd/ sub/ http/ 是转发包(由 tools/gen_forwarders.py 生成), 所以 XiLaiTL/moobile/html 这种短路径照旧可用。

    fn view(m : Model, emit : @cmd.Emit[Msg]) -> @html.Html {
    @html.div(
    attrs=@html.Attrs::build().styles(
    @style.Style::new().font_size(16.0).padding_horizontal(@style.px(12.0)),
    ),
    [ @html.button(on_click=emit(Bump), "+1") ],
    )
    }

    /// 应用入口:交出句柄表(start / snapshot / subscribe / element)。
    pub fn app() -> @moobile.JsValue {
    @moobile.handlers(model=initial(), update~, view~)
    }

    update 与 rabbita 同款:(Model, Msg, Emit[Msg]) -> (Model, Cmd), 还可以挂 subscriptions? 做持续数据流(定时器、传感器…)。 首帧之前就要干的事(例如"先从本地库读回清单")用 handlers_with_init(init=..., update~, view~)。

    #1.2 宿主侧(React Native / Expo)

    // App.js —— 全部手写代码就这几行 import { mountApp } from 'moobile-host'; import { app } from './myapp.js'; // MoonBit 编译产物 import { registry } from './registry.generated.js'; // npx moobile-host regen 生成 export default mountApp(app, { registry });

    MOBILE_HOST 契约(React、5 个基础组件、调度钩子、后端地址)、契约版本比对、 能力注册表都在这个包里。没有能力依赖时 mountApp(app) 就够。

    #1.3 跑起来

    moon build --target js cp _build/js/debug/build/<你的模块>/<你的模块>.js <Expo 工程>/myapp.js cd <Expo 工程> && npx expo start --port 8081 # 浏览器打开 http://localhost:8081 # 真机/模拟器:adb reverse tcp:8081 tcp:8081 之后扫码或用 expo run:android

    完整的可跑示例(本地库 + 网络同步 + 多页面 + 一个 MoonBit 写的后端): examples/apps/todo-app/。


    #2. 能力边界

    上手前值得看一眼 —— 尤其是不报错但没效果的那几行。

    项现状
    支持的标签44 条 HTML 标签有映射(div→View、span→Text…);img video audio canvas svg table iframe select details summary dialog marquee 明确不支持
    未收录标签兜底渲染成 View(不崩),但会被计数,便于你发现迁移漏项
    样式类型化:Attrs::styles(Style::new().font_size(16.0))。class= 与 style="…" 在 RN 上不生效(不报错,只是没效果)
    事件click→onPress、input→onChangeText 这类映射可用,落点由宿主决定(可覆盖,组件库的回调靠这个接上);老的 on_* 处理器在 React 后端载荷是零值(能写、不崩、拿不到坐标);要真实值就用 Attrs::on_raw + @html.Payload 提取器(text() / json() / num() / bool() / field()),受控组件走这条
    第三方组件库两条路:① 手写——标签写 库名:组件名(如 antd:Button)直通宿主注册的 React 组件,props 走 Attrs::prop_*(结构化值传 JSON 文本)、回调走 on_raw 拿真实值;② 生成——npx moobile-host libgen 从组件库的类型定义生成清单 + 宿主注册 + MoonBit DSL 包,于是调用点写成 @antd.button(type_="primary", on_click=…, "加一条")(与 @html 同款形状、写错 prop 名是编译错误)。antd 6.6.4 已在 Web 宿主上端到端跑通:试金石 26 项(库本体)+ 全组件 demo 24 项(71 个组件、受控 Input 回填、复合子组件 Form.Item)
    生成器的边界它读的是类型定义,所以有边界(都在 demo 的 README 里写清了):渲染型回调(itemRender)过不来;children 挂在组件类型上而没进 props 接口的(Splitter)认不出;多参数回调只给得到第一个参数;string | string[] 这类联合按规则走 JSON 通道(调用点要写 JSON 文本)
    副作用 / 订阅Cmd(@cmd.perform 等)与 subscriptions? 都可用;持续型原生流建议走 @sub.custom_sub
    原生能力生态里有现成 RN / Expo 包的(数据库、剪贴板、文件、相机…)→ 在 MoonBit 里写绑定即可,不需要写 Kotlin/Swift;需要自研原生模块时才要
    平台库与 RN 版本无关;换平台通常等于换一个宿主,而不是改库

    已提供的能力(宿主侧实现 + MoonBit 入口):

    能力提供者MoonBit 入口说明
    db 本地数据库expo-sqliteXiLaiTL/moobile/sqliteAndroid 走预编译 AAR;Web 官方标 alpha,本库只用异步 API

    能力清单不手写:npm install <包> 之后跑 npx moobile-host regen, 它会读 package.json 生成注册表,并在启动时核对(缺什么就点名报错)。


    #3. 工作原理

    你的 MoonBit 应用 Model / Msg / update / view ← 与 rabbita 同款 TEA 写法 │ │ 视图用 @html DSL;样式用 @style(类型化,不是 CSS 字符串) ▼ rabbita 运行时(vendor fork) VNode 树 + Cmd / Sub + 事件派发 + 异步 effect │ │ moobile 的翻译层:① 标签表 ② 事件映射 ③ 样式 map → RN style ▼ React 元素(React.createElement 的产物,不是 DOM 节点、也不是 HTML 字符串) │ ├──► React Native ──────► Android / iOS 原生视图 └──► react-native-web ──► 浏览器 DOM

    四条设计取舍(决定了上面这张图):

    1. 不实现渲染 —— 布局、排版、文本引擎交给 React / RN。
    2. 只换挂载层 —— Cmd / Emit / 订阅 / 异步 effect 仍由 rabbita 自己的运行时执行。
    3. 样式必须走类型化通道 —— RN 没有 CSS 类、也不吃 CSS 字符串。
    4. 事件解码是可替换的策略 —— DOM 事件在 RN 上拿不到,所以解码点做成查表, 换来的是"不崩、可降级",不是"载荷等价"。


    #4. 例子、文档与贡献

    想做什么去哪
    看一个真应用怎么写examples/apps/todo-app/(含 MoonBit 后端 examples/services/todo-server/)
    用第三方 React 组件库(antd 等)docs/design/DESIGN-COMPONENT-LIBRARY.md(机制与缺口)|试金石 examples/apps/antd-spike/(怎么跑、判据)
    把库接进自己的项目(宿主包细节、契约、兼容表)npm/moobile-host/README.md
    跑起来 / 排错 / 环境DEV.md
    改这个库(构建、验证、发版、文档规矩)CONTRIBUTING.md
    架构与契约(分层、宿主契约、发布形态)docs/ARCHITECTURE.md
    为什么这么设计 / 实测过什么docs/FINDINGS.md、docs/design/
    我们对 rabbita 改了什么(14 个 patch)FORK.md
    接下来打算做什么PLAN.md
    全部文档的三条路线docs/README.md


    #5. 许可证与第三方

    Apache License 2.0,见 LICENSE。

    本模块内含 rabbita 的 fork (同为 Apache-2.0,来源版本 0.15.4):版权声明、修改声明与分发形态见 THIRD-PARTY-NOTICE.md,改动明细见 FORK.md。

    Frame

    pub struct Frame {
    element : JsValue
    store : Store
    }

    一帧的产物。

    pub 只是为了让 Mount 这个公开类型能持有它 —— 它是一个引用类型, 于是 on_frame 的闭包可以写进去,而 Mount 之后再读出来。

    JsValue

    #external
    pub type JsValue

    JS 侧的不透明值。整套后端只经由这一层与宿主对话。

    Mount

    一个已挂载的应用。

    关键点:Cmd / Emit / 订阅 / 异步 effect 全部由 rabbita 自己的运行时执行 (@runtime.ReactHost 是 fork 出来的 React 后端), moobile 只负责最后一跳:VNode → React 元素。

    于是 on_click=emit(Msg) 这种原样照抄 yi 的写法可以直接跑 —— 这正是"渲染可以整体外包"的含义:连抽干循环都留着,只换挂载层。

    Mount::element

    fn Mount::element(self : Mount) -> JsValue

    当前帧的 React 元素。还没画过时是 JS null(React 能直接渲染)。

    Mount::handles

    fn Mount::handles(self : Mount) -> JsValue

    把一次挂载打包成一张句柄表交给宿主 —— 这是 H2「单导出」的实现(PLAN §3.7)。

    为什么库能替应用做这件事:Mount 是非泛型的具体类型, "必须有泛型"的只是构造它的那一刻;构造完就退化成 {contract, start, snapshot, subscribe, element} 这张表。 于是应用侧的导出名从 4 个降到 1 个,宿主也不必知道 Model / Msg 是什么。

    Mount::snapshot

    fn Mount::snapshot(self : Mount) -> Int

    给 useSyncExternalStore 的快照。

    Mount::start

    fn Mount::start(self : Mount) -> Unit

    同步抽干一次并画出首帧。调用后 element() 立刻有内容。

    Mount::subscribe

    fn Mount::subscribe(self : Mount, f : (Int) -> Unit) -> (() -> Unit)

    给 useSyncExternalStore 的订阅。返回退订闭包。

    Store

    pub struct Store {
    version : Int
    next_id : Int
    subscribers : Map[Int, (Int) -> Unit]
    }

    极简版 Val:一个版本号 + 订阅者表。

    对齐设计文档 §4.2 —— React 的 useSyncExternalStore 要的就是 "订阅 + 一个不可变快照"。这里快照就是版本号:变了就整棵树重建, 交给 React 去 diff(§4.3 的取舍:本类应用更新频率低,全树 diff 不是问题)。

    Store::bump

    fn Store::bump(self : Store) -> Unit

    版本号 +1 并通知所有订阅者。

    通知前先拷贝一份订阅者列表:回调里可能发生退订,直接遍历会踩到迭代中修改。

    Store::new

    fn Store::new() -> Store

    Store::snapshot

    fn Store::snapshot(self : Store) -> Int

    Store::subscribe

    fn Store::subscribe(self : Store, f : (Int) -> Unit) -> (() -> Unit)

    订阅,并返回取消订阅的闭包 —— React 需要拿到它。

    component_namespace_sep

    let component_namespace_sep : String

    外部组件库的命名空间分隔符(antd:Button、paper:Card)。

    为什么用冒号,而不是 x- 这种前缀:x-foo 在 HTML 里是自定义元素的既有写法, 而本层的取舍恰恰是"表外的 HTML 语义一律回落 View 并计数"(迁移诊断的依据就在这个计数上)。 冒号在 HTML 标签名里不合法 —— 于是"这是不是外部组件"看一眼就知道, 也不必去动那 42 条表的语义。

    excluded_tags

    fn excluded_tags() -> Array[(String, String)]

    刻意不收的标签 + 理由。

    这张表的存在意义:不把"其实不能跑"的东西默默映射成 View 让它看起来能跑。 想用这些能力,得走 RN 生态的对应组件,而不是假装标签表能覆盖。

    handler_count

    fn handler_count() -> Int

    handlers

    挂载并直接交出句柄表(应用侧的推荐入口)。

    应用只需要一个导出:
    pub fn app() -> @moobile.JsValue { @moobile.handlers(model=initial(), update~, view~) }

    handlers_with_init

    带初始命令的单导出入口(清单类应用多半要这个:首帧之前先读本地库)。

    host_contract_version

    let host_contract_version : Int

    宿主契约版本。

    宿主(npm 包 moobile-host)与库各自声明自己实现的契约版本,宿主在启动时比对, 不等就直接抛错并同时报出两个版本号 —— 否则"npm 包与 mooncakes 包两条版本线各走各的" 会表现成"某个函数莫名其妙是 undefined",极难查。改 MOBILE_HOST 的形状时必须 +1。

    版本史:
    • 1 —— 四件套:react / components / scheduleTask / scheduleFrame(+ apiBase / 能力键)。
    • 2 —— 组件库接入:components 的键空间允许命名空间名(antd:Button), 新增可选的 events(按标签覆盖事件 prop 名),新增可选的 wrapRoot(Provider 包裹)。 库侧对应行为:带命名空间的标签直通、查不到就点名报错(render.mbt / host.mbt)。

    is_library_tag

    fn is_library_tag(tag : String) -> Bool

    这个标签是不是"外部组件库的组件"。判据只有一条:含冒号。

    js_null

    fn js_null() -> JsValue

    真正的 JS null。用它在"还没有内容"时占位 —— 比 Option[JsValue] 更可靠,因为 React 能直接渲染 null。

    map_event

    fn map_event(tag : String, event : String) -> String

    事件映射。必须知道目标标签,不能只看事件名。

    三段式,前一段优先:
    1. 宿主覆盖(MOBILE_HOST.events,支持 '*' 通配)—— 组件库的落点由宿主/适配器声明;
    2. 默认表(default_event_prop)—— RN 基础组件的语义;
    3. 默认表内部再兜底到 camelCase。

    map_tag

    fn map_tag(tag : String) -> String

    查表 → 外部组件直通 → 计数回落。

    三档的顺序是刻意的:
    1. 命中原表(42 条)→ 老行为,交出宿主基础组件名;
    2. 带命名空间(antd:Button)→ 原样直通,交由宿主的组件注册表解析 (MOBILE_HOST.components["antd:Button"])。名字写错、或那个库没装, 由宿主点名报错(见 host.mbt 的 js_host_component),而不是悄悄回落成 View —— 后者的表现是"渲染出一个空盒子",最难查;
    3. 其余(img / table 这类)→ 回落 View + 计数:不崩,但计数会暴露它。

    mount

    挂载(无初始命令)。

    签名与上游 rabbita.elmish 对齐(见 internal/rabbita/top.mbt):
    • update 返回 (Model, Cmd) —— "改状态顺便干件事"(读本地库、发请求)可以直接写在 update 里;
    • subscriptions? 透传给运行时 —— 持续数据流(传感器、网络状态、返回键)能挂上来。

    这两条在 0.1.0 里是缺的(update 只能返回 Model,且没有 subscriptions), 0.2.0 补上。缺口的代价很具体:update 里发起不了副作用、持续型原生能力一个都接不上 —— 见 PLAN.md §3.6(N1)。

    mount_with_init

    挂载(初始就带命令)。

    对齐上游 create_state_with_init:首帧之前要做的事(例如"从本地数据库读上一次的清单") 写在这里,而不是塞进 view,也不是塞进模块初始化(那时宿主还没装好 MOBILE_HOST)。

    render_html

    真实 @html.Html → 一个 React 元素。

    render_node

    翻译层的核心:一个 4 分支递归 —— 对真实的 rabbita VNode。

    • Elem → 查标签表;Children 有 Array / Map / RawHtml 三个构造器,都要管
    • Text → 包一层 Text(RN 里裸字符串不能当 View 的子节点)
    • Frag → React.Fragment
    • Thunk → 强制求值后继续

    注意这里没有 diff、没有布局、没有文字排版 —— 那些都是 React / RN 的活。

    tag_table

    fn tag_table() -> Array[(String, String)]

    可移植标签表 —— HTML 标签 → 宿主组件名。

    这就是 DESIGN §4.1 里的 tags/ 层。rabbita/html 有 116 个标签, 其中不少在 RN 上没有对应物,所以 moobile 自己维护这张表。

    收不收的判据只有一条:两端都有等价物。

    unmapped_tag_count

    fn unmapped_tag_count() -> Int

    unmapped_tag_names

    fn unmapped_tag_names() -> String

    unsupported_count

    fn unsupported_count() -> Int