Numable 创作文档同一份内容也在终端里:`numable docs <主题>`English

af —— 交互流(.af)与 action 全表

读者:做工具的用户,和替他干活的 AI。两者读同一份。

它是什么

.af 是用户动了手指之后跑的那条流:点组件、长按编辑、页面上按一个按钮、提交一张表单。它能做副作用 —— 弹 toast、震一下、开页面、写本地数据、把参数写回组件、让组件重新取数。

它和取数流 .df 是同一套 ActionFlow 引擎、同一套写法,分界只有一条:.df 是白名单子集,零 UI、零导航、零 App 注入能力;.af 是全集。取数写 .df,副作用写 .af,后缀就是能力声明,写错后缀App 里会直接回「flow not found」(check G12b)。取数流的写法见 numable docs df。

引擎按载体再切一刀,这刀比后缀更硬:

事件流 渲染流
判据 用户手指参与 取反
入口 仪表盘点整组件 · 点 RCN cell · 桌面小组件点击回放 onClick · 深链 · XPage 节点 events · 表单 onSubmit .xwidget 的 canvas.depends · XPage 节点 depends · banner flow
ui.* / nav.* / widget.* 可用 类型上就不存在,调不到
data.* / request 可用 可用
网络白名单 强制 强制

所以「取数的时候顺手弹个 toast / 顺手刷新一下自己」是做不到的,写了不会报错,只是那一步不存在。

最小可用示例

页面里按「保存」跑的那条流(page/flow/save.af)。写盘 + 让本包的组件重新取数,这是最常见的一种 .af。

{
  "version": 1,
  "actions": [
    { "action": "ui.haptic", "params": { "type": "tap" } },
    { "op": "set", "props": { "key": "lk", "value": "log_${id}" } },
    { "op": "set", "props": { "key": "sk", "value": "stat_${id}" } },
    { "action": "data.set", "params": { "key": "${lk}", "value": "${log}" } },
    { "action": "data.set", "params": { "key": "${sk}", "value": "${stat}" } },
    { "action": "data.set", "params": { "key": "agg", "value": "${agg}" } },
    { "action": "widget.refresh" }
  ]
}

逐行:ui.haptic 在 actions[0](按下到画面变化之间没有别的反馈)· op:set 先把动态键名落成变量 · 三个 data.set 写盘 · 最后 widget.refresh 让这个包的组件真取数重渲。所有写盘动作必须排在 widget.refresh 之前,否则刷新读到的是旧盘。

文件格式

{
  "version": 1,                                  // 必填,恒 1
  "inputs": ["secid", "period"],                 // 可选,声明期望入参,只做静态校验
  "i18n": { "zh-CN": { "k": "文案" },            // 可选,本文件自己的 A 表,用 ${@i18n.k} 取
            "en-US": { "k": "text" } },
  "actions": [ /* 步骤数组 */ ]
}

_note 字段可写在任何位置,是给人和 AI 的注释,引擎忽略。

步骤的三种形状

形状 写法 说明
action 步 { "id": "resp", "action": "request", "params": { … } } 调一个 action;id = 结果键
operation 步 { "op": "set", "props": { "key": "x", "value": "…" } } 控制流与赋值,子节点放 items
复合步 { "action": "concurrent", "items": [ … ] } sequential / concurrent 写在 action 位,不是 op 位

复合步写成 {"op":"concurrent"} 会被整块静默跳过 —— 流照样 success,里面一个 action 都没跑。

policy(可选,写在步骤上):wait(默认,等它完成)/ detach(不等)/ skip(跳过)。

params 递归求值:字符串走表达式引擎,对象的键也求值,数组逐元素求值;@[…] 整值引用求不出来时按字面量透传。表达式与方法见 numable docs methods。

id = 结果键

带 id 的 action 步,结果以 id 为键写进作用域,后面用 ${id.xxx} 取。两种情况结果被丢弃:没写 id;结果是 null 或 undefined。

后者是最常见的静默失效来源:data.get 取不到值又没写 default,那个键根本不出现在作用域里,下游 ${k} 恒空且不报错。data.get 一律写 default。

除 concurrent 分支外,写入后下一步立刻可见。

内联 flow 绑定的四种形态

节点的 depends、events 的值、onSubmit 的绑定,都用这四种形态之一:

"depends": [
  { "flow": "@[file://page/flow/quote.df]", "params": { "secid": "${secid}" } },  // ① 文件引用 + 入参(推荐)
  { "flow": { "version": 1, "actions": [ … ] }, "params": { "x": "${y}" } },      // ② 内联对象
  "@[file://page/flow/noop.df]",                                                   // ③ 裸引用,入参 = {}
  { "op": "set", "props": { "key": "cards", "value": [] } }                        // ④ 裸内联单步
]

flow 写成数组无效。裸引用形态传空入参 —— 需要入参却写成③,流仍 success、组件渲一片 --(check G12c)。不参与取数但要显示的键(比如城市名)也必须列进 params。

@[file://…] 的路径基准跟载体走

载体 基准 写法
.xwidget 的 events · RCN cell 事件 xWidget/ @[file://flow/mark-today.af]
页面(router.json 指向的页、页内节点、onSubmit) 包根 @[file://page/flow/save.af]

基准写错的表现是「点了没反应」,App 里读不到 actions 就静默结束(check G12d)。

action 全表

系统内置(.df 一列 = 渲染流 / 取数流里也能用)

action 入参 出参 .df
cancel — — ✅
error errorMsg — ✅
finish — — ✅
sleep timestamp(毫秒,写成字符串 "1500";参数名就叫 timestamp) — ✅
log log — ✅
resultFilter / resultfilter keys[] 过滤后的结果集 ✅
request url(必填)· method(默认 GET)· queryParams(对象)· header(对象)· formData(multipart 数组)· body(字符串)· formatType(string 默认 / json / xml / base64 / tsv / csv)· columns(字符串数组,只对 tsv / csv 生效)· credential(凭证声明 id,字面量)· timeout(毫秒) 响应 ✅
clearCookie pattern(正则) — ✅
htmlParse content · rules(规则数组,写法见 numable docs df) 结构化结果 ✅
xmlParse content · encoding · rules 结构化结果 ✅
showLoading progress — ❌
hideLoading status · message · delay — ❌
toast message · style = success / warning / error — ❌
data.get key · default 值 / default / null ✅
data.set key · value 写入的值 ✅
data.remove key null ✅
data.has key 布尔 ✅
data.merge key · value(必须是对象) null ✅
data.keys — 键数组 ✅
data.getAll — 整个对象 ✅
data.clear — null ✅

data.* 没有 scope 参数,写了静默忽略;数据域按包隔离,由宿主注入身份。

页面里要 loading / toast,用下面 App 注入的 ui.showLoading / ui.toast(能拿到文案与类型),别用内置的 showLoading / toast。

⚠️ 这两个 toast 参数名和取值都不一样,写混了不报错、只是没样式:

参数名 取值
内置 toast style success / warning / error
注入 ui.toast type info(默认)/ success / error

也就是说 ui.toast 没有 warning 这一档,而内置 toast 没有 info;把 style 写到 ui.toast 上,或者把 type 写到内置 toast 上,都会被当成没写、落回默认样式。事件流里一律用 ui.toast。

App 注入(只在事件流里存在)

action 入参 出参 xpage .xwidget 事件流 渲染流
xpage.const 任意对象,逐键写进作用域 — ✅ ✅ ❌
xpage.setState 对象,整体替换页面状态 — ✅ 空转 ❌
xpage.patchState 对象,增量合并 — ✅ 空转 ❌
xpage.redraw id — ✅ 空转 ❌
xpage.redrawPage — — ✅ 空转 ❌
xpage.reloadPage — — ✅ 空转 ❌
xpage.reenterPage — — ✅ 空转 ❌
page.setResult 任意对象 = 返回值 — ✅(含 html 页) 空转 ❌
page.close — — ✅ 空转 ❌
ui.showLoading text · progress(0–1 的小数;不写就是转圈的不定进度) — ✅ ✅ ❌
ui.hideLoading — — ✅ ✅ ❌
ui.toast message(可写 ${@i18n.k})· type = info(默认)/ success / error · duration(毫秒,不写用平台默认) — ✅ ✅ ❌
ui.haptic type = tap(默认)/ impact / success / warning / error / selection — ✅ ✅ ❌
ui.alert title · message — ✅ ✅ ❌
ui.confirm title · message · okText · cancelText · destructive 布尔 ✅ ✅ ❌
ui.presentSheet source · params 子页 page.setResult 的值 / null ✅ ✅ ❌
ui.dismissKeyboard — — ✅ 空转 ❌
input.focus / input.blur / input.clear / input.selectAll id — ✅ 空转 ❌
input.setValue id · value — ✅ 空转 ❌
nav.open url(必填)· container(page 默认 / sheet / dialog)· params · fallback 见下文五枚 ✅ ✅ ❌
nav.openForResult url · params 子页结果 / null ✅ ✅ ❌
startPageForResult page(必填)· container(缺省 sheet)· params { value, cancelled } ✅ ✅ ❌
singleValue 整个 params 就是单值配置,必须含 container { value: { value }, cancelled } ✅ ✅ ❌
widget.updateParams 顶层对象就是要合并的 params { params } ✅ ✅(仅 onEdit) ❌ 硬拒
widget.refresh scope(bundle 默认 / widget / self)· widgetId · desktop { refreshed } ✅ ✅ ❌ 硬拒
widget.pick items?: [{ id, params? }] { listed } ✅ ✅ ❌
installBundle id(必填)· version? · ref? null ✅ ✅ ❌
alert.add id(必填,本包 xJob/<id>.xjob)· params?(预填值,用户可改) 裸串 ok / cancel / quota;id 在本包找不到(或那条不是提醒)→ 流失败,错误 rule_not_found,不开面板 ✅ ✅ ❌
alert.skip id(必填)· params?(省略 = 这条规则的全部实例)· until = today(默认)或一个 ISO 时刻 { skipped },跳过了至少一条时另带 { until }(生效截止,毫秒) ✅ ✅ ❌
alert.remove id(必填)· params?(子集匹配:给出的键相等就删;省略 = 这条规则的全部实例) { removed };没有匹配 / 规则不存在 = { removed: 0 },不算失败。只删提醒不动后台任务,不弹面板;需要 minEngine ≥ 3 ✅ ✅ ❌

「空转」= 调得到但什么都不做(组件宿主上没有页面可改)。

别再写的名字

见到 改写成
inputValue singleValue(没有别名,写老名字直接找不到 action)
inputForm 用 startPageForResult 打开一张 form 页
xpage.setResult page.setResult
nav.back 没有这个 action;返回用 page.close,或让宿主的返回手势处理

operation 节点

op props items 说明
if val 条件为真时执行 条件键是 val,不是 cond
for count · index 循环体 定次循环,index 是计数变量名
forEach items · key · index 循环体 遍历数组;注意 items 在 props 里是被遍历的数据
set key · value — 求值后写进作用域
remove key — 删一个键
include dsl(另一条 flow) — ⚠️ 别用:在 .af / .df 里恒展开成空

op:include 是这张表里唯一一个「写了也不会跑」的:流引擎不实现它的展开,那一步恒展开成零个子步骤,而整条流照报 success。想复用公共步骤,只有两条路:把那几步复制过去,或者拆成独立的 .df,在 depends 里多绑一条(见 numable docs xwidget 的多条 depends)。

两条必须记住的:

  1. op:if 的条件键是 props.val。 写成 cond 的后果是条件恒假、整个分支体静默不执行、流仍报 success、分支外的动作全部正常 —— 只有分支里的写盘什么都没发生(check G30)。
  2. op:if 分支里 op:set 出来的键,出了分支取不到。 分支退出会恢复外层绑定。所以:算值用表达式嵌套 if::,op:if 只用来分派动作。

events:哪里能挂 .af

.xwidget 的 onClick / onEdit

events 只有这两个键,写在 .xwidget 的外壳上(不在 canvas 里)。字段细节见 numable docs xwidget。

"events": {
  "onClick": "/detail?secid=${secid}",
  "onEdit": "/pick?ref=board&base=${base}"
}

值解析后是字符串 → 当导航串走;是结构体(@[file://x.af] 或内联对象)→ 当 ActionFlow 跑。不许写裸的 flow 文件名。

四种可用写法:

写法 例子
相对路由 path "/detail?secid=${secid}"
开本包首页 "numable://self"
开本包某页 "numable://self/page/item?id=${itemId}"
af 引用 "@[file://flow/mark-today.af]"

整组件 onClick / onEdit 跑 af 时的入参 = 这个组件的实例 params + @i18n(只有这个 af 文件自己的表)+ @app,没有 @event。 组件整体被点,没有「哪个元素」可言。

@event.*(XPage 节点 / RCN cell / 表单)

触点 能读到
onClick / longClick / 菜单项 @event.id
input 的 onChange / onBlur / onSubmit @event.value
onPageChange @event.page
.xform 的 onSubmit @event.value = { 字段名: 值 }
searchSelect 的 dataSource @event.keyword
dynamicCascader 的 dataSource @event.path / @event.level
onLoad / onReachEnd 无 payload

@i18n 的取法见 numable docs i18n。

常用原语

widget.refresh —— 让组件真取数

{ "action": "widget.refresh", "params": { "scope": "bundle", "widgetId": "streak", "desktop": true } }
scope 作用范围 备注
self 触发这条流的那个组件 需要宿主给出组件实例上下文
widget 本包里某个组件的所有实例 widgetId 必填
bundle 本包全部组件(缺省) 最常用

widget.updateParams —— 把值写回组件实例

{ "action": "widget.updateParams", "params": { "city": "${r.value.value}", "sub": null } }

典型串法:onEdit: "@[file://flow/edit.af]" → singleValue 或 startPageForResult 收一个值 → widget.updateParams 写回。整个过程不需要做一张页。

startPageForResult —— 开一张页,等它回值

{
  "id": "picked",
  "action": "startPageForResult",
  "params": { "page": "/for-result-pick", "container": "sheet", "params": { "secid": "${secid}" } }
}

输出恒为 { value, cancelled },读 ${picked.value.xxx} 与 ${picked.cancelled}。

container 语义 高度
page 有栈就压栈;没有栈就先立一个容器再压栈 铺满
sheet(缺省) 贴容器底的浮层 容器高的 0.8,固定
dialog 容器内居中浮层 容器高的 0.6,固定

xpage / html / form 三种页型通吃。子页回值的写法:

子页里做了什么 结果
page.setResult({...}) 关闭并回传;这张页若是被常规路由打开的,则纯空转
page.close() 关闭,cancelled: true;常规路由页则真的关页
什么都没做 / 流失败 页面保持挂载,值还在,可以再提交

用户按 ✕、‹、点遮罩、系统返回手势 = 取消,不经过 onSubmit,宿主直接兑现 { value: null, cancelled: true }。取消是正常路径,一定要判 cancelled 再用 value。

ui.confirm —— 二次确认,拿一个布尔

{ "id": "ok", "action": "ui.confirm",
  "params": { "title": "清空全部记录?", "message": "这一步不可撤销", "okText": "清空", "destructive": true } }

后面直接拿 ${ok} 进 op:if:

{ "op": "if", "props": { "val": "${ok}" },
  "items": [ { "action": "data.clear" }, { "action": "widget.refresh" } ] }

ui.alert —— 单钮提示,等用户点掉

{ "action": "ui.alert", "params": { "title": "今天已经打过组件了", "message": "明天再来" } }

没有返回值。要「告诉一句就走」用 ui.toast(不打断);要用户明确点头才继续,才用 ui.alert。

ui.presentSheet —— 贴底弹一张子页

{ "id": "r", "action": "ui.presentSheet",
  "params": { "source": "/detail", "params": { "id": "${id}" } } }

返回的是子页 page.setResult 传回来的那个对象本身,子页被关掉则是 null。它是 startPageForResult 的底层原语 —— 一般写 startPageForResult:那个的返回值统一成 { value, cancelled },不用自己判 null 到底是「取消了」还是「回了个空」。

nav.openForResult —— 压栈打开一张页,等它回值

{ "id": "picked", "action": "nav.openForResult",
  "params": { "url": "/city-pick", "params": { "cur": "${city}" } } }

同样是 startPageForResult 的底层原语,只是恒压栈(相当于 container: "page"),返回值也是裸的结果 / null。新写的流一律用 startPageForResult,这两个原语留着是给已有的包。

xpage.setState 与 patchState —— 改页面数据

两个的入参都是「整个 params 就是那张表」,不套 value:

{ "action": "xpage.patchState", "params": { "tab": "week", "loading": "0" } }
语义 用在哪
xpage.patchState 浅合并:只覆盖写出来的键,其余原样保留 日常都用这个
xpage.setState 整份替换:没写进来的键会被清掉 只在「整页重置」时用

最常撞的坑是拿 setState 当 patchState 用:改一个 tab 值,把页面上其它键一起清空,现象是别处的内容突然全空了。

改完 state 再决定重画多少:

action 重画范围 会不会重跑 depends
xpage.redraw 只有 params.id 那一个节点 不会
xpage.redrawPage 整页 不会
xpage.reloadPage 整页,页面 state 与滚动位置延续 会,onLoad 不重跑
xpage.reenterPage 等同关掉重进:清 state、首帧走骨架 会,onLoad 也重跑
{ "action": "xpage.patchState", "params": { "tab": "week" } }
{ "action": "xpage.redraw", "params": { "id": "chart" } }

还有一个 xpage.const:它写的是这条流自己的作用域顶层变量(后面的步骤用 ${名} 读),不写进页面 state,页面上的节点看不见。要页面看得见就用 patchState。

input.* —— 命令式操作输入框

input 节点的值平时是自己静默写进页面 state 的(键名 = props.bindKey,不写就是节点 id)。下面五个是「用流去动它」:

{ "action": "input.focus",     "params": { "id": "kw" } }
{ "action": "input.setValue",  "params": { "id": "kw", "value": "${@i18n.preset}" } }
{ "action": "input.clear",     "params": { "id": "kw" } }
{ "action": "input.selectAll", "params": { "id": "kw" } }
{ "action": "input.blur",      "params": { "id": "kw" } }

widget.pick —— 打开「加到仪表盘」面板

{ "action": "widget.pick",
  "params": { "items": [ { "id": "today", "params": { "habit": "water" } }, { "id": "streak" } ] } }

installBundle —— 提议安装另一个包

{ "action": "installBundle", "params": { "id": "01M050AARQ0R08T9EHGDSGZBHJ" } }

id 必填(目标包的 ULID),version / ref 可选。它只是弹出安装面板,装不装由用户在面板里决定;流拿不到「装没装成」的结果(返回恒 null)。同 widget.pick:调用之后不要提示「已安装」。

page.close —— 关掉本页

{ "action": "page.close" }

无参。常规路由打开的页 = 真的关掉这一页;被 startPageForResult 打开的子页 = 收起浮层并兑现 { value: null, cancelled: true }。要带值关闭用 page.setResult,那个会自己关页,不用再补一个 page.close。

表单输入

单个值用 singleValue,一次收多个字段做一张 .xform 页。

{
  "id": "r",
  "action": "singleValue",
  "params": {
    "container": "page",
    "title": "列表选择",
    "desc": "副标题",
    "confirmTxt": "确定",
    "component": {
      "type": "select",
      "value": "b",
      "props": { "items": [ { "label": "选项 A", "value": "a" }, { "label": "选项 B", "value": "b" } ] }
    }
  }
}

container 必填(page / sheet / dialog)。返回 { value: { value: <字符串 | 数组 | null> }, cancelled },值在 ${r.value.value} —— 多套了一层。

.xform 是页型 form 的页,用 startPageForResult 打开,自己不写 container(三态由调用方裁决);form 的键就是结果键,声明顺序就是渲染顺序;onSubmit 绑定的 flow 只看得见 @event.value。字段类型全表、props、值形状见 numable docs params。

nav.open 与跳外部 App

{
  "id": "r",
  "action": "nav.open",
  "params": { "url": "futunn://quote/00700", "fallback": "https://www.futunn.com/stock/00700-HK" }
}

判定次序:危险 scheme 黑名单(javascript / file / data / intent / about / blob,check G16)→ 手势门(不是用户手势引发的 10 秒窗外静默拒绝,连弹窗都不给)→ 该包对该 scheme 的授权记忆 → 首跳确认弹窗。授权按(包, scheme)记,不进备份,卸载即清。

返回值五枚:opened · no-handler · denied · fallback · presented(container 为 sheet / dialog 时不等待,立即返回这个)。

规则(违反 = 返工)

规则 检查方式 违反时的现象 修法
op:if 的条件键必须是 props.val check G30 条件恒假、分支静默不执行、流仍 success 把 cond 改成 val
concurrent 之后紧邻的节点不得引用并发分支的 id check G4b 并发结果晚一拍,那几个字段静默取空,组件上全是 -- 中间插一个屏障:{"op":"set","props":{"key":"_b","value":"1"}}
parseDate 的 pattern 去掉 token 后不许再剩字母 check G4c 手机上整条解析失败返 null,算出荒唐的天数 先 subString:: 切出日期段再 parseDate::
onEdit 的 af 只认包内 .af、禁 ..、文件必须存在(基准 xWidget/);走路由的必须是裸 path 且在 router.json 里 check G12d 「点了没反应」/ 打开一张空白页且不报错 补文件,或把 numable://… 改成 /pick
onEdit 的 af 必须含 widget.updateParams;目标页必须有写回能力 check G12e 打得开、按了不会有任何变化 af 里补写回;form 页在 onSubmit 里写回,xpage 在节点 events 里写回
危险 scheme 禁用;fallback 只认 http(s);第三方 scheme URL 里的 ${} 要 urlEncode check G16 App 里直接拒绝跳转 / 参数带空格特殊字符时跳错地方 见上一节
cell 事件绑的 .af,actions[0] 应是 ui.haptic check G22(警告) 按下到画面变化约 250 毫秒无反馈,用户会再点一次 把 ui.haptic 挪到第一位
写盘(data.set / data.merge / data.remove)的 .af 里必须有 widget.refresh,且所有写盘排在它前面 check G37(警告) 存了,但组件上还是旧值,要等下一次自然刷新 流尾补 widget.refresh;页面自己的刷新链写了 xpage.reloadPage 或 widget.updateParams 的不算违反
op:include 不要用 人审 那一步恒展开成空,流仍 success 把步骤复制过去,或拆成独立 .df 多绑一条 depends
sleep 的参数名是 timestamp,值写成字符串 人审 · check G52(写成数字) 不等待,直接往下跑;写成数字时鸿蒙上这一步不执行 改参数名;数字加引号
数组要用真的数组字面量,先 op:set 落地再引用 人审 方法参数里读不到,取空 先 {"op":"set","props":{"key":"list","value":[…]}}
op:if 分支里 op:set 的键出了分支不可见 人审 分支外读那个键恒空 算值用表达式嵌套 if::,op:if 只分派动作
等用户的 action 之外,事件流有 15 秒硬超时 run 层 流跑到一半被掐,后面的写盘没发生 拆流,或把长耗时放进 .df
复合步写在 action 位 run 层 写成 op 会被整块静默跳过 {"action":"concurrent","items":[…]}
data.get 必须写 default run 层 键不进作用域,下游 ${k} 恒空不报错 补 "default": ""

出错怎么办

现象 最可能的原因 先做什么
点组件/按钮没任何反应 af 文件路径基准写错(.xwidget 事件的基准是 xWidget/ 不是包根),或文件名后缀不对 跑 numable check,看 G12d;确认文件真在 xWidget/flow/ 下
参数编辑页打得开,填完保存组件一点没变 那条路径上没有 widget.updateParams,或它在整组件 onClick 里(拿不到组件实例) 跑 numable check 看 G12e;把写回放进 onEdit 的 af 或参数页
数据存了,组件还是旧的 流尾少了 widget.refresh,或它排在 data.set 前面 把 widget.refresh 挪到所有写盘之后
弹窗/表单出来了,但流在那之后就断了 事件流 15 秒硬超时 —— 等用户的那一步前后堆了长耗时动作 把取数挪进 .df,事件流只留交互与写盘
某个分支怎么都不执行,日志还干净 op:if 写成了 cond 改成 props.val,跑 numable check 看 G30
并发之后的字段全空 concurrent 结果晚一拍 插屏障 op:set,或退回串行
组件上部分字段恒 -- depends 写成裸 @[file://…],入参被吞成空 改成 {"flow":…,"params":{…}}

更多静默失效见 numable docs pitfalls。

相关