capabilities —— 能做什么、做不到什么、各平台差在哪
读者:做工具的用户,和替他干活的 AI。两者读同一份。 动手之前先扫一遍这章:下面写「不支持」的地方,不是 bug,是那个平台没有这条路——设计时别依赖它。下面说的平台是 iPhone / iPad / Mac · Android · 鸿蒙 · Windows 桌面版。 怎么做见
numable docs workflow。
组件
组件是包的主体:一份 .rcn 画法 + 一份 .df 取数,所有平台用同一份源渲成同一张位图。
| 能力 | 状态 | 对你意味着什么 |
|---|---|---|
| RCN 渲染(几何 + 样式 + 活数据) | 各平台一致 | 写一份 .rcn 所有平台通用,不按平台分支 |
尺寸档位 layout(两位网格码,十位=宽格、个位=高格) |
App 内各平台任意码;桌面能放的档位各平台不同(见下节) | w = 90×列 − 22、h = 98×行 − 38。常用:22=158×158、21=158×60、42=338×158、44=338×354 |
| 相对布局 | 各平台一致 | 尺寸用 {parent.w} / {parent.h},写死 158 换一档 layout 就错位 |
| 明暗主题 | 各平台一致 | 主题是纯渲染参数,切换只重渲、不重新取数——所以颜色必须写 浅|深 双分支,不能靠取数分支(check G7) |
| 「跟随系统」档(日落自动切) | 各平台一致;Android 桌面小组件在 App 进程还活着时可能仍是旧主题 | App 内一律正确,不需要你做任何事 |
| 组件实例参数 | 各平台一致 | 同一个组件可以在仪表盘上摆多份、各带各的参数(城市、股票代码);默认值写在 .xwidget.params |
| 组件上的可交互控件 | 不支持 | 组件是一张位图。整个组件点击有(events.onClick),组件内按钮、输入框、滚动都没有——需要交互就开一张页面 |
| 动画 | 不支持 | 组件是静态一帧。用户点下去到页面打开约 250ms 无反馈,所以点击流第一步要写 ui.haptic(check G22) |
| 换了包内容,组件会自动换新 | 各平台一致 | App 按包的版本号分代缓存:manifest.version 一 +1,旧的渲染图与旧的落盘数据一起失效。所以改完包必须 +1 —— 不 +1 就是「改了没反应」,而且不报错 |
| 改包之后先白一下再出新内容 | 各平台一致 | 这是分代缓存的正常表现,不是 bug:新一代没有缓存图可贴,于是走骨架、当场真取数。别为了消掉这一下去复用旧图 |
| 取不到数据时 App 画什么 | 各平台一致 | 取数失败但有上次的数据:照旧渲上次的数据,App 不加标记(过期提示是你的时间锚的事)。失败且没有任何数据:App 用空数据渲你的 .rcn,再在底部叠一枚胶囊「加载失败 · 重试」,没网时是「网络不可用」。所以 .rcn 必须在空数据下渲得出来(numable render --states empty 那张),别在空态上自己再画一套错误提示 |
| 缺凭证、超出免费额度 | 各平台一致 | 声明了 required: true 凭证而用户没绑:不取数,叠「需要凭证」胶囊。免费版用户盘上超过 6 个组件时,第 7 个起不再更新,叠「已暂停更新」胶囊。这两种都由 App 画,包里不用处理 |
新版本删掉或改名了某个 .xwidget |
各平台一致 | 组件的文件名就是它的身份。用户盘上引用旧文件名的组件会显示「组件已下线」,包更新时被清掉。要换组件,新增一个文件,别改名 |
桌面小组件
桌面上那个组件,和 App 内仪表盘里的是同一张 .xwidget,但刷新机制完全不同,这是平台之间差异最大的一处。
| iPhone / iPad / Mac | Android | 鸿蒙 | Windows | |
|---|---|---|---|---|
| 有没有桌面小组件 | 有 | 有 | 有 | 没有 |
| 桌面能放的档位 | 22 / 42 / 44 |
4×4 网格内 16 档(含 21 / 11) |
8 档(11 21 22 32 33 42 44 46) |
— |
| 小组件会不会自己取数 | 会(到期时自己跑取数 + 联网) | 会 | 不会(组件进程只读 App 上次渲好的图) | — |
| 数据新鲜度上限 | 系统给的时间线预算,大致 15 分钟量级 | 系统心跳 | 只在用户打开主 App 并渲过仪表盘之后才更新 | — |
| 刷新频率 | 系统决定 | 系统决定 | 用户选三档(120 / 60 / 30 分钟),下限 30 | — |
| 明暗跟谁 | 跟 App 的有效明暗,不是跟系统 | 同 | 同 | — |
| 资源上限 | 小组件进程 30MB 硬上限,超了直接被杀且不报错 | 无此限制 | 只读图,不涉及 | — |
| 显示态取图(首帧、系统重绘、选择列表缩略图) | 缓存优先,秒显 | 同 | 只有这一条路 | — |
| 刷新态取图(到期刷新) | 跳过图片缓存直接取数,失败才回落上一张旧图 | 同 | 不适用 | — |
| 切主题 / 切语言 | 连带刷新桌面小组件 | 同 | 同 | 不适用 |
| 「到期时把主 App 叫醒去取数」 | — | — | 系统不允许,不是没做 | — |
对你意味着什么:
refresh.interval是下限与意图,不是承诺。写"60"在桌面上不会每分钟刷新;在 App 内仪表盘里才接近你写的节奏。- 鸿蒙上「刷新档位」是耗电旋钮,不是数据新鲜度旋钮——它只决定多久重读一次封面图。给鸿蒙用户看的文案不要承诺「30 分钟更新一次数据」。
- 别把包做成「只有桌面才有价值」。Windows 版没有桌面小组件,鸿蒙上桌面数据滞后于主 App;组件在仪表盘里也要立得住。
- 桌面组件上不要放大图,iPhone / iPad 上小组件只有 30MB 可用。
- 桌面组件永远不会变空:到期取数失败时贴回上一个旧图,所以组件上必须有自己的时间锚,否则用户分不清「这是刚取的」还是「这是三小时前那张」。
- 放到桌面是用户的动作:系统不允许 App 替用户把小组件放上桌面。你能做的是把组件做好,并在页面里放一个「加到仪表盘」按钮(
widget.pick)。 - 每个包至少给一张
22:它是桌面最小档,也是仪表盘密度最高的档。发布档还要求至少 3 个组件且有42或44(checkG6)。
仪表盘刷新
| 能力 | 状态 | 对你意味着什么 |
|---|---|---|
声明式节奏 refresh |
各平台一致 | interval 支持时间窗 "09:30-16:10@60"(收盘后不空转)与裸秒 "3600";at 指定时点;tz 指定时区 |
| 刷新下限 | 各平台一致 | 仪表盘按最早到期的那个组件醒来,两次真取数之间最短 3 秒,写得再小也按 3 秒走。免费版用户的周期刷新会被放慢到每 5 分钟,Pro 用户按你写的节奏走;手动更新、首次加载、切语言、widget.refresh 不受影响。这是 App 打开时仪表盘上的节奏;桌面小组件各平台另有下限(iOS 15 分钟、Android 1 分钟、鸿蒙只显示 App 上次画好的图)。写多快,看上游接口的限流扛不扛得住 |
| 下拉刷新 | iPhone / iPad · Android · 鸿蒙 有;Mac 没有下拉 | Mac 用户靠长按组件手动更新,别在页面里写「下拉刷新」这种引导文案 |
| 长按组件 →「立即更新」并给成败反馈 | 各平台一致 | 用户有确定的手动出口,你不需要在组件上自己画刷新按钮 |
| 「上次更新 / 上次失败 / 停了多久」 | 各平台一致 | App 已经记账;你要做的是组件上有自己的时间锚,让用户一眼看出数据是什么时候的 |
widget.refresh 原语 |
各平台一致 | 在交互流里主动刷组件:self / widget / bundle 三档,默认 bundle;只能刷本包的组件;5 秒内重复调用直接返回成功且不动作 |
| 提醒(到点就发,内容布防时就定死) | iPhone / iPad · Mac · Android 交给系统定时,App 没开着也照响;鸿蒙需要先拿到代理提醒权益,拿到之前降级成「打开 App 时补响」;Windows 版靠常驻进程,退出后不响 | 能写成「几点几分说这句话」的提醒就写成这种,它是四个平台里最可靠的一档 |
| 提醒(到点取数、判条件才知道响不响)与后台任务 | 都是尽力而为:执行机会 = 打开 App · 桌面小组件刷新 · 系统给的后台时间片;Android 上用户打开「实时监测」(常驻一条通知)后能到点即查;鸿蒙主要靠桌面组件那一拍,Windows 版靠常驻定时器 | 节律写 interval,免费版用户下限 30 分钟(写更小也会被抬到 30),Pro 用户按你写的节律;文案不许写「每天准时」「实时」,写「每天至多一次、有机会就补」。详见 numable docs alerts |
页面
页面是点组件之后打开的东西,放在 page/ 下,由 router.json 登记。
| 页型 | 状态 | 对你意味着什么 |
|---|---|---|
html(随包分发的网页) |
各平台一致 | 写 HTML/CSS/JS 最自由;但页面里 fetch / XHR 被容器焊死(注入了 connect-src 'none'),取数一律经桥调 .df,见 numable docs bridge |
xpage(整页级 RCN,不用网页) |
各平台一致 | 渲染最快、与组件同一套 DSL;叶子只有 Canvas 与 input,容器支持 list / grid / flex / waterfall 等布局 |
form(.xform 表单页) |
各平台一致 | 收用户输入的标准做法,14 种字段组件 |
| 外链(非本包的 http(s) 页) | 有差异:iPhone / iPad · Android · 鸿蒙 在容器内开一个带域名和锁标的单页壳;Mac 与 Windows 版交给系统浏览器 | 别把第三方站点当成你的包的一部分来设计导航,用户可能是在另一个浏览器里看它 |
页面的共同约束:
| 约束 | 检查方式 | 对你意味着什么 |
|---|---|---|
容器顶部有浮动胶囊(‹ / ••• / ✕),恒浮在内容之上 |
check G17(xpage)/ 人审(html) |
XPage 根写 "paddingTop": "${@contentInset.top}pt";H5 的 body 写 padding-top: calc(var(--xb-content-top) + 16px)。不让位就是顶部一截被压住,而且各平台都不报错 |
| 容器不画标题栏 | — | 页面标题写在 router.json 的 title,不要自己画一条假导航栏 |
| 内容恒定手机宽 | 人审 | 大屏上页面是居中的窄列,不要做响应式断点 |
| 浮层(sheet / dialog)以容器为边界,高度固定比例 | — | sheet 恒占容器高的 0.8、dialog 恒 0.6,不随内容伸缩;内容多了内部滚动,少了留白 |
| H5 输入控件字号 ≥ 16px | check G15 |
小于 16px 时 iOS 聚焦会把整页放大 |
容器与返回栈
| 事实 | 对你意味着什么 |
|---|---|
| 页面开在一个容器里,容器有自己的返回栈 | 从 A 开 B、从 B 开 C,一路压栈;‹ 逐层退回,✕ 一次关掉整个容器 |
容器顶部最多三个按钮(‹ / ••• / ✕),不画标题栏 |
标题写 router.json 的 title;自己再画一条导航栏 = 两条并排 |
| ‹ 只在栈里真有上一页时出现 | 首页上没有 ‹,别把「返回上一页」当成用户一定有的出口 |
| Android / 鸿蒙的系统返回键与 ‹ 同义 | 不需要你接;也不要用页面里的 JS 去劫持它 |
| 从组件、深链、外部进来时可能没有栈 | 那种情况下呈现 page 的子页会先立一个容器再压栈,行为与栈内一致 |
外链(非本包的 http(s))进的是另一种壳:只有 ✕ 和一个域名,没有 ••• |
别指望外链页还能用包里的导航;用户 ✕ 掉之后回到原来那页 |
XPage 能做什么
xpage 是不写网页的整页级 RCN。它与组件同一套 DSL,但多了交互与状态。
| 能力 | 对你意味着什么 |
|---|---|
| 八种容器布局 | absolute stack flex flow list pager grid waterfall;grid / waterfall 用 columnCount 定列数 |
| 叶子只有两种 | Canvas(一块 RCN 画布)与 input(裸输入框,零视觉,外观靠底下垫一块 Canvas) |
| 页面状态 pageState | 一页一份的键值表,节点用 ${键} 读;流里用 xpage.patchState 改 |
| 局部重画 | xpage.redraw 只重画一个节点、不重跑取数;整页重画、软重载、硬重进各有一个 action |
| 节点事件 | onClick / longClick / onChange / onSubmit / onReachEnd 等,值是一条 .af |
| 节点长按菜单 | 节点上写 menu 数组;同时写了 longClick 则菜单永远不弹 |
条件显隐 visible |
判假的节点整个不进页面:不占位、也不跑它的取数 |
逐字段写法见 numable docs page,能用的 action 见 numable docs af。
交互
交互写成 .af 流,挂在组件的 events 或页面节点的 events 上。能用什么 action,取决于载体:
| 载体 | 判据 | 能用 | 不能用 |
|---|---|---|---|
| 事件流 | 用户手指参与(点组件、点节点、深链) | UI 原语、导航、表单、写回、刷新、data.* |
— |
渲染流(depends 里的取数) |
取反 | 请求、解析、data.* |
一切 UI 与导航;widget.updateParams / widget.refresh 会被硬拒 |
事件流有 15 秒超时,等用户的那几个 action(确认框、开页面、跳外部 App)会暂停计时。
常用原语:
| 能力 | 状态 | 对你意味着什么 |
|---|---|---|
整组件点击 events.onClick |
各平台一致 | 值是字符串 → 当导航串走;是结构体(@[file://…af] 或内联)→ 当交互流走。整组件 onClick 的流改不了自己的参数 |
参数编辑 events.onEdit |
各平台一致 | 两种写法:跳一张页面(html / xpage / form)让用户填,或直接挂一条 .af。目标必须真的具备写回能力,否则 check G12d / G12e 拦下 |
widget.updateParams 写回参数 |
各平台一致 | 逐键合并,值为 null 表示删键回落默认;只接受标量,给对象/数组直接失败 |
startPageForResult / singleValue |
各平台一致 | 开一张子页(page / sheet / dialog)拿一个返回值,返回 {value, cancelled}。startPageForResult 不写 container 时缺省 sheet;singleValue 要显式写 |
ui.toast / ui.alert / ui.confirm / ui.haptic / ui.showLoading |
各平台一致(仅事件流) | 别在页面里用 window.alert / window.confirm,它们会逃出容器且各平台不一致 |
| 节点长按菜单 | 各平台一致 | XPage 节点写 menu 数组;节点同时有 longClick 时菜单不弹 |
ui.presentSheet / nav.openForResult |
各平台一致(仅事件流) | startPageForResult 的两个底层原语,返回的是裸结果或 null。新写的流用 startPageForResult 就够了 |
input.focus / blur / clear / selectAll / setValue |
各平台一致(仅 xpage) | 用流去操作 input 节点(按节点 id);组件事件流里调是空转 |
xpage.patchState / setState / redraw / redrawPage / reloadPage / reenterPage |
各平台一致(仅 xpage) | 改页面数据 + 决定重画多少。日常只用 patchState + redraw;setState 是整份替换,没写进来的键会被清掉 |
installBundle(提议安装另一个包) |
各平台一致 | 只弹安装面板,装不装是用户的动作,流拿不到结果。别在它之后提示「已安装」 |
page.close / page.setResult |
各平台一致 | 关掉本页 / 带值关掉本页。setResult 自己会关页,不用再补 page.close |
widget.pick(打开「加到仪表盘」面板) |
iPhone / iPad / Mac · Android · Windows 版可用;鸿蒙从组件的事件流里调恒失败 | 添加组件入口放在页面里,而不是组件上;一个按钮就够,别手抄一份组件目录(check G24 / G27) |
跳外部 App(nav.open 第三方 scheme) |
各平台一致 | 第一次跳会弹确认框,用户点头后按包记住;自动链路(取数、定时刷新)发起的跳转一律静默拒绝,必须是用户点出来的 |
目标 App 没装时回落网页(nav.open 的 fallback) |
iPhone / iPad / Mac · Android · Windows 版可用;鸿蒙上不触发(系统自己弹提示) | fallback 只认 http(s)(check G16);被用户拒绝时不回落 |
取数
| 能力 | 状态 | 对你意味着什么 |
|---|---|---|
request(HTTP + 解析) |
各平台一致 | 支持 queryParams / header / body / formData / timeout;formatType 缺省 string,要 JSON 显式写 "json" |
域名白名单 manifest.network |
各平台一致,run 同一套判据 |
集合必须与 .df 里实际请求的 host 完全相等(check G3)。安装时这份清单会展示给用户看 |
| 逐跳重定向守卫 | 各平台一致,run / render 同一套判据 |
请求中途跳出白名单会被拒(network_blocked:redirect_escaped:<host>)。用短链或会 302 到 CDN 的接口时,优先直接请求跳到的地址;确实要经过跳转,再把跳到的那个 host 也声明上 |
| 服务端代理 | 不支持 | 数据从用户设备直连数据源。被墙的源在中国大陆取不到;要登录态 cookie 的站点做不了 |
按地区选源 ${@app.region} |
各平台一致(.df / .af) |
值是 cn(中国大陆)/ overseas / 空串(说不准)。有被墙的兜底源时,cn 下跳过它,别让用户先白等一轮超时 |
htmlParse / xmlParse |
各平台的解析引擎不同 | 别依赖 xmlParse;XML 用 formatType:"string" 拿原文再 split:: 切 |
data.get / set / remove / has / merge / keys / getAll / clear |
各平台一致 | 包内持久化,跨启动;命名空间恒为本包。没有 scope 参数,写了静默忽略 |
| 首屏缓存(有缓存先渲、后台校新) | 各平台一致 | 页面消费的 .df 该带缓存,否则每次进页都白等一轮网络(发布档 check G23 会提醒)。组件的 .df 默认不缓存——组件缓存了,刷新就等于没刷。需要凭证的源:把 credential.state 返回的 fp 拼进缓存键,用户换绑后旧缓存自然失效 |
| 取数失败的出口 | check G26 |
组件的 .df 只要有请求就必须有 action:"error" 出口,否则失败时流报成功、空数据覆盖好数据 |
| 表达式方法集 | 各平台一致,但比直觉小 | 没有 abs / avg / filter / groupBy / push;未知方法一律静默求空,不报错。全表见 numable docs methods |
首屏缓存的六条判据(页面消费的 .df 逐条对):
| 判据 | 对你意味着什么 |
|---|---|
| 缓存必先出 | 只要有一份没过期太久的数据,首屏就用它渲,不许因为它不新鲜就先渲骨架 |
| 穿透必真取 | 下拉刷新那条路要先删缓存键再取,否则下拉又命中缓存,数字纹丝不动 |
| 必须有时间锚 | 先出的旧数据要在屏上说出自己有多旧。没有时间锚的旧数字比空白更糟 |
| 失败不覆盖 | 写回缓存前判一下关键字段非空;取数失败就不写、继续渲旧值。一次超时写进一份空数据,那个组件会一直空到下次恰好成功 |
组件的 .df 绝不缓存 |
组件的刷新意图就是「跳过缓存直接取数」,数据层再压一层缓存 = 刷了等于没刷。页面与组件共用一份 .df 时,缓存由入参开关控制、默认关 |
| 结构变了要能识别 | 缓存里存一个自己的结构版本号,读的时候先比结构再比时间。对不上就当没有缓存 —— 否则升级后第一眼是坏的、再看又正常,最难查的那种 |
TTL 分两个量:ttl 决定「多旧算不新鲜、要后台校新」,hardTTL 决定「多旧就不配再出场、退回骨架」。分档按这个域的数据自己多久变一次来定:
| 域特征 | ttl |
hardTTL |
|---|---|---|
| 连续行情(7×24) | 60 秒 | 6 小时 |
| 天气 | 10 分钟 | 12 小时 |
| 内容榜单 | 5 分钟 | 24 小时 |
| 平台统计 / 个人面板 | 15 分钟 | 24 小时 |
| 日更 / 工作日更 | 1 小时 | 48 小时 |
写法见 numable docs df。
多语言
两张表,判据一句话:宿主读来直接显示的字段用 B 表,包内容自己引用的文案用 A 表。
| 表 | 写在哪 | 怎么用 | 用于 |
|---|---|---|---|
| A 表(内容文案) | 资产自己的顶层 i18n: {locale: {key: 文本}} |
正文写 ${@i18n.key} |
.rcn · .xform · .af · .df · .xpage |
| B 表(元数据) | 裸字段是基准语言,旁挂 i18n: {locale: {字段: 值}} |
宿主自己挑 | manifest 的 title / subtitle / description / category · .xwidget 的 title / sub · router.json 的 title · .xjob 的 title / sub / message / form |
| 事实 | 对你意味着什么 |
|---|---|
| 切语言会重新取数 | 仪表盘对每个包做一次 widget.refresh;要中英两版数据,.df 里直接读 ${@app.language}。数据缓存按语言分开,切完先出骨架、取到再显示。.df 也可以有顶层 i18n 表,写 ${@i18n.key} 直接出成品文案 |
兜底链:精确匹配 → 语言前缀 → en-US → 基准语言 |
manifest.lang 声明裸字段是哪门语言 |
| A 表里的空串 = 刻意留白,不兜底;B 表里的空串 = 缺 | 不想显示就写空串,别删键 |
发布档必需门是 zh + en |
其余语言缺覆盖只是提醒 |
XPage 节点层的 ${@i18n.x} 只认这一页顶层 i18n 表里的 key,.rcn 表里的 key 在节点层看不到 |
节点上用的 key 写进 .xpage 顶层 i18n;组件内文案仍住 .rcn 的表(check G35 会拦缺 key) |
| iOS 桌面小组件的系统选择列表 | 那一层的文字只能跟系统语言,不跟 App 语言 |
细节见 numable docs i18n。
发布
| 形态 | 怎么装 | 验签 | 对你意味着什么 |
|---|---|---|---|
| 个人自用 | 文件直接放进 App 工作区目录 | 免验签,明文直读 | 边写边看,改一个字就生效;组件上会标出这是本机包 |
| 上架分发 | 从商店下载 | Ed25519 签名 + 逐文件 sha256,验不过直接拒绝渲染 | 发布后不能再手改包内文件;要改就改源、重新发一版 |
- 两者是同一个包、同一个 ULID:自用包上架后,用户长按组件就能升到已发布的版本,数据保留。
- 有账号就能发包,不需要额外申请资格。
- 商店分层
community/featured是策展信号,不是能力闸——两层的包能力完全一样。 - 发布前
numable check --profile publish必须零 error,流程见numable docs publish。
备份与还原
| 事实 | 对你意味着什么 |
|---|---|
App 能导出 / 导入 .nbk 备份文件,各平台互通 |
用户换机不会丢你的包 |
备份内容:本地创作包、下载包、data.* 里的数据、仪表盘布局与实例参数、App 设置 |
用 data.* 存的用户数据(打卡记录、自选列表)会跟着走 |
| 不备份:登录 token、凭证、外部 App 授权记忆、缓存、桌面小组件的系统绑定 | 还原后用户要重新把小组件放回桌面;别把「一次性授权过」当成永久状态来设计 |
| 同一个包已存在时,默认不覆盖 | 还原不会把用户手上更新的版本改回去 |
用户开了多设备同步时,data.* 在设备之间按顶层键合并,同一个键两边都改了留后写的那份 |
用户亲手记下、丢了补不回来的记录(打卡、服药、体重),一条一个键(log.2026-09-23、dose.<时间戳>):两台设备各记一笔,两笔都在;整份放进一个键,两台设备各记一笔会丢一笔。后台任务按日期记的取数结果可以放在一个键里(data.merge 一天一格):同一天两边写的是同一份数据,最坏是很少同步的设备上缺一天 |
规划中,现在不能用
下面这些在设计中,现在写进包里不会生效。不要给用户承诺,也不要围着它们设计流程。
| 能力 | 现在能做的替代 |
|---|---|
| 鸿蒙桌面小组件的真后台刷新 | 按「主 App 打开时才更新」来设计文案 |
.xmenu 作为包内可写的菜单文件 |
菜单写节点上的 menu 内联数组 |