规则树:把「如果……则……否则」做成能存下来的东西

作者:

在

RoutineX 解决的是「到点做一件事」。用了一个月,缺口很明显:它不会判断。

连上家里 Wi-Fi 该静音,回公司要恢复响铃,这种规则它表达不了。三星的「模式和日常程序」里这功能叫作条件,藏在一堆动作中间,用起来很自然;小米这边要么自己写脚本,要么认了。

所以另起了一个应用,叫规则树。定位很窄:只做条件分支,不碰定时,不跟 RoutineX 抢活。

先定格式,再写界面

这个项目最早的产出是 DATA-MODEL.md,代码排在它后面。

原因很实际:界面可以随时改,数据格式一旦发出去就改不动了。用户的规则存在手机上,升级时得能读回旧的。所以第一版就得把结构定到能撑住以后几年的程度,哪怕当时只有一两个功能。

最后定下来是三种节点:

action   叶子,真正干活:改铃声、调音量、开应用、发通知
if       条件 + then 一组 + else 一组
seq      按顺序执行一组子节点

关键在于 then、else、children 里放的是节点数组,而节点又能是 if。嵌套是格式自带的,不需要为「支持嵌套」做任何额外设计。

一条规则长这样:

{
  "id": "demo-0001",
  "name": "回家静音",
  "enabled": true,
  "trigger": { "type": "wifi_state", "params": { "watch": "any" } },
  "tree": {
    "type": "if",
    "condition": { "type": "wifi_connected", "params": { "ssid": "Home-5G" } },
    "then": [ { "type": "action", "action": "ringer_mode", "params": { "mode": "silent" } } ],
    "else": [ { "type": "action", "action": "ringer_mode", "params": { "mode": "normal" } } ]
  }
}

触发器单独放在外面,只管「什么时候跑」;判断全在树里。这样同一个触发器可以接不同条件的规则,逻辑不重复。

格式还留了两个习惯:文件顶层带 version,将来升级先读版本号再决定怎么迁移;读到不认识的节点类型直接跳过并记日志,不抛异常。前者保证老数据能升上来,后者保证新数据掉到老版本上不会炸。

界面也是递归的

模型能嵌套之后,界面一开始只做了一层:一个条件配两组动作。这是故意的,先把格式固定住,编辑体验慢慢来。

后来补上了递归。现在任何一组动作里都能再嵌一层「条件分支」,缩进显示,点「改条件」单独设置它的判断:

条件成立时
  1. 铃声切到静音              删除
  2. 如果连着 Wi-Fi,则发通知,否则等待 1 秒   改条件  删除
     成立时
       1. 发通知:嵌套生效       删除
       + 添加
     不成立时
       1. 等待 1 秒              删除
       + 添加
  + 添加动作或分支

条件不成立时
  1. 铃声切到正常响铃           删除
  + 添加动作或分支

渲染逻辑只有一个递归函数,节点是 if 就把它的两组子节点继续画一遍,深度加一,左边多缩进一点。树有多深,界面就能编多深,代码没有额外分支。

条件本身支持取反。与其为每个条件再造一个反向类型(「没连 Wi-Fi」「不在充电」),不如统一加个 negate 开关,省一半的类型。

条件也能组合

动作树能嵌套之后,条件这边还是老样子:一条,最多取反。想表达「连家里 Wi-Fi、电量还够、而且没在充电」,只能拆成三条规则,还得指望它们不互相打架。

条件其实也是树,只是比动作那棵小:

{ "type": "group", "op": "all",
  "children": [
    { "type": "wifi_connected", "params": { "ssid": "Home-5G" } },
    { "type": "battery_below", "params": { "pct": 20 }, "negate": true },
    { "type": "charging", "negate": true }
  ] }

op 是 all 或 any,children 里能放叶子,也能再放一个 group。老规则里只有叶子条件,读进来照旧,一个字段都不用迁。

界面上是这么走的:条件那一行点进去,选项里多一个「组合条件」,进去先选「并且」还是「或者」,再一条条往里加,每条都能单独取反;加的时候还能选「组合」再套一层,点进去单独编。弹窗叠弹窗,深度不限,代价是三层以上会有点绕。

求值走短路。all 遇到第一个不成立就停,any 遇到第一个成立就停。日志里能直接看出来:

23:10:32  条件[连着 Wi-Fi] = false
23:10:32  条件[全部满足:连着 Wi-Fi、电量低于 20% 等 3 项] = false

第一步就 false,后面两条连算都没算。

这里踩了个自己挖的坑。第一版把「当前在编哪个条件」放进一个成员变量,内层组点「完成」时,那个变量已经被「往组里加条件」这个动作改成了指向内层组自己,于是新组被塞进了自己肚子里。存是存下去了,可一描述就无限递归,应用当场闪退。改法很土:开弹窗那一刻就把「完成时挂到哪儿」记进局部变量,之后外面怎么变都不影响。

顺序可以拖了

组合条件一开始是「删掉重加」:想调整顺序,只能把后面几条删了再按新顺序加一遍。短路求值下顺序其实是有意义的,all 遇到第一个 false 就停,把最容易判否的条件放前面能少算几次。

改成列表之后拖动就是顺手的事。编辑器从一行行往 LinearLayout 里加,换成 RecyclerView 配 ItemTouchHelper:

ItemTouchHelper.SimpleCallback cb = new ItemTouchHelper.SimpleCallback(UP | DOWN, 0) {
    public boolean onMove(rv, from, to) {
        Collections.swap(group.children, from, to);
        adapter.notifyItemMoved(from, to);
        return true;
    }
    public void clearView(rv, vh) { super.clearView(rv, vh); render(); }
};

拖动把手放在行首,一个 ≡,长按下去调 startDrag,右边的文字和删除按钮照旧。松手时 clearView 里再刷新一次,主界面那句摘要才会跟着变。

有个细节改过一次:弹窗外面原来套着 ScrollView,拖动时手势会被外层抢走,拖两下就变成滚页面。把 ScrollView 去掉、给列表一个固定高度让它自己滚,就正常了。

顺序改完直接写回 children 数组,没有单独的排序字段。

小米上的三个坑

一、隐式广播收不到

第一版很自然地写了 manifest 接收器:

<action android:name="android.net.wifi.STATE_CHANGE" />

装上去,开 Wi-Fi、关 Wi-Fi,日志一个字都没有。系统日志里能看到广播确实发了,就是没递到我们这边。

小米对第三方应用的隐式广播限制比 AOSP 更狠。改成在前台服务里注册 ConnectivityManager.registerNetworkCallback,事件立刻就有了。

manifest 那个接收器还留着,当成别的 ROM 的兜底,反正不碍事。

二、onAvailable 不等于「已连接」

接上回调之后第一次测试,关 Wi-Fi 铃声恢复正常,开 Wi-Fi 铃声还是正常。条件判反了。

日志是这样的:

21:29:13  Wi-Fi 变为 已连接(来源 网络回调)
21:29:13  条件[连着 Wi-Fi] = false
21:29:13  走 else(1 步)

回调说连上了,紧接着查状态却查不到。原因是 onAvailable 只代表网络可用,这一刻 getActiveNetwork() 往往还没切换过来,getNetworkCapabilities 拿到的还是旧网络。

改法是连上事件先等 1.2 秒再确认一次:

final int token = ++wifiSeq;
handler.postDelayed(() -> {
    if (token != wifiSeq) return;          // 等待期间状态又变了
    if (!wifiConnected()) return;          // 还是没连上,这次不算
    Engine.fire(this, "wifi_state", "connected");
}, 1200);

断开事件不用等,立刻执行。顺手加了个序号,等待期间状态又变就作废,避免「连上又断开」时把过期的事件补发出来。

三、通知栏那个常驻通知

第一版一打开应用就有个「规则树在监听」的通知,哪怕一条规则都没有。这是前台服务的代价,只要服务在跑就必须有通知。

但一条规则都没有的时候,服务本来就不该跑。改成一个简单的同步:

public static void sync(Context c) {
    int enabled = 0;
    for (Model.Rule r : RuleStore.load(c)) if (r.enabled) enabled++;
    if (enabled > 0) start(c); else stop(c);
}

主界面每次回来、开关切换、规则保存删除、开机,全走这一个入口。没有启用的规则,服务不起,通知栏干净。

界面上的两处补丁

权限改成了单独一页。之前塞在主界面一张卡片里,只写「还差两项」,说不清楚。现在五项各占一行,写清用途和当前状态,点一下跳到对应设置:定位、通知、勿扰访问、电池优化、自启动。

「自启动」那一行永远显示「需手动」,因为小米没有公开接口能查状态,与其假装知道,不如直说。

Wi-Fi 名字能选了。之前要一个字一个字敲,还得敲得和系统里显示的一模一样。现在弹一个列表:先是当前连着的,然后是扫到的附近网络,最后一项是「手动输入」。列表要定位权限(Android 从 8.1 起把 SSID 归到定位里),没授权时给个提示,可以退回去手输。

触发器补上蓝牙和定时

Wi-Fi、电源、通知之外还缺两条腿:蓝牙和时间点。这两个是问得最多的场景——「连上车机蓝牙就开导航」「每天早上七点半把铃声调回来」。

定时走闹钟,不走轮询

定时最容易想到的实现是每分钟看一眼有没有到点,太费电,也容易错过。用 AlarmManager:

am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pi);

每条定时规则一个 PendingIntent,requestCode 用规则 id 的哈希,extra 里带规则 id。到点后 TimeReceiver 查回规则、跑一遍,然后立刻再排下一次:

23:57:00.006  定时到点:测试:定时23:57
23:57:00.007  ── 开始执行「测试:定时23:57」
23:57:00.009  定时「测试:定时23:57」下次 10-19 23:57
23:57:00.011  ✓ 发通知:定时到了

不重排的话它只会响一次。

重排的策略是「全量」:规则一改,先把库里所有规则的闹钟取消,再给启用的定时规则各排一个。规则量就十几条,每次全扫一遍的开销可以忽略,换来的是不用维护「哪条改过时间」的差异状态。删除规则时也是同一个入口,旧闹钟不会留在系统里。

Android 12 起精确闹钟要单独授权。没给就退化成 10 分钟窗口,到点可能晚几分钟,日志里会记一句。这不是偷懒,SCHEDULE_EXACT_ALARM 是少数几个用户能在设置里单独关掉的权限,应用只能接受。

开机走的是同一个 sync:开机广播 → 拉前台服务 → 重排所有定时。

蓝牙要挂在前台服务里

蓝牙的连接广播是 BluetoothDevice.ACTION_ACL_CONNECTED,它属于只能运行时注册的那类广播,写在 manifest 里收不到。好在监听服务本来就在跑,挂上去就行。

Android 12 之后读设备名和 MAC 需要 BLUETOOTH_CONNECT。拿不到权限时的处理是保守的:设备信息读到 null,限定设备的规则就匹配不上,宁可不触发,也不要乱触发。没限定设备的规则照常工作,只知道「有设备连上了」。

真机上验证到接收器确实挂上了:

ReceiverList{2f4c4e6 3742 com.z7ui.ruletree/10303/u0 #receivers=2}
  Filter #0: BroadcastFilter{61a1a27}
    Action: "android.bluetooth.device.action.ACL_CONNECTED"
    Action: "android.bluetooth.device.action.ACL_DISCONNECTED"

这台测试机当前没有已配对设备,ACL 又是受保护广播(am broadcast 会被系统拒绝),真实连接触发只能等下次有设备可用时再补。

把三星那两百多个动作过了一遍

0.6 之后我重新插上三星,把两个包又拉了一遍:Routines 5.0.03.22(79 MB,系统 priv-app)、 日常程序+ 1.1.28(18 MB)。版本和上一轮逆向时一样,525907 行包树里, preloadproviders 下面仍然是 241 个类,分在 34 个目录里。

241 个类拆开看,能落地的没有想象中多。把它分成三类之后就很清楚了。

第一类,公开 API 就能做。 条件分支、延迟、二次确认、通知触发、定时、铃声与各路音量、 免打扰、媒体键、剪贴板、打开网址、朗读、振动、手电筒,还有无障碍那套(返回、主屏、锁屏、 点击、滑动)。这一批没有任何门槛,写就是了。

第二类,要 root 或 WRITE_SECURE_SETTINGS。 飞行模式、移动数据、热点、NFC、 Wi-Fi 和蓝牙开关、亮度、屏幕超时、自动旋转、字体大小、分辨率、分应用音量、强制停止应用。 这些从 Android 6.0 起就不给普通应用碰了,三星能做是因为它 sharedUserId=android.uid.systemui, 拿着平台签名。第三方应用想要这些,只有 root 一条路。

第三类,三星生态专属。 Bixby、SmartThings、Samsung Health、Spotify 控制、Android Auto、 三星日历和时钟、AI 图片与文本、天气。不是权限问题,是对方没有开放接口,小米这边也没有对应服务。

所以「两百多个动作全都能实现」是不成立的。真正的上限大概在 60 到 70 个动作, 其中一半要 root 才摸得到。

0.9 把这两头都往前推了一步:A 类补齐,B 类靠 root 落地。动作从 31 加到 51,条件加到 17。

系统(root)  飞行 / 移动数据 / Wi-Fi / 蓝牙 / NFC / 定位 / 热点
              亮度 / 超时 / 自动旋转 / 深色模式 / 字体 / 密度 / 分辨率
              充电不息屏 / 立刻熄屏 / 强行停止应用 / 任意 shell
手势          截屏、手势序列录制与回放
条件          当前应用是

root 那一层是个二十行的 Root 类:ProcessBuilder("su","-c",cmd),15 秒超时,输出进日志。 没有 root 就返回失败并写清楚原因,不会静默什么都不做。

有个细节值得记:KSU 的 su 对没授权的应用是直接 EACCES,连弹窗都没有。 所以不能用「文件存不存在」判断有没有 root,只能真跑一次看结果——我第一版就是那么写的, 日志里全是「没有 su」,其实 su 就在 /system/bin/su。

这轮把第一类基本补完了:动作从 6 个加到 31 个,条件从 5 个加到 16 个。

声音   铃声三态 / 四路音量 / 免打扰开关 / 振动 / 媒体键三个
显示   手电筒开、关
文字   打开网址 / 写剪贴板 / 朗读 / HTTP 请求
手势   返回 / 主屏 / 最近任务 / 通知栏 / 快捷设置 / 锁屏 / 点击 / 滑动
流程   等待 / 二次确认 / 等解锁 / 主动停止

动作一多,菜单就没法平铺了。改成先选分类再选动作,而分类和参数都写在 Capabilities 这一个类里,界面按它生成。以后加动作是「加一条记录 + 加一个 case」, 不用再动界面代码。

实测

动作一多就得一条条验。写了一条规则,九个动作串起来定时跑:

20:59:00.013  定时「测试:点击坐标」下次 10-20 20:59
20:59:00.014  点击 (600,500) → true
20:59:00.016  ✓ 点击 (600, 500)
20:59:01.226  已发通知: 点击已派发
20:59:01.227  ── 「测试:点击坐标」完成,耗时 1215ms

点击这一下不是「返回 true 就算过」——跑完去看栈顶,PermissionsActivity 真的被点开了。 这才是验证。

验证多了就嫌烦:为了看一个动作生效,得先建个一两分钟后的定时,再干等。 所以加了个测试模式,规则卡片和编辑页都能选「立刻 / 5 / 10 / 30 / 60 秒后」,也能自己填。 延迟跑走的是另一个闹钟,到点就算应用在后台、屏幕锁着也照跑:

21:11:21.302  测试闹钟已排:10 秒后
(按 Home 退出,再按电源键锁屏)
21:11:31.305  测试触发:测试模式验证
21:11:31.312  已发通知: 测试模式生效

延迟跑之前会先把当前编辑内容落盘,因为闹钟到点得能从存储里把规则找回来。

上一轮那条更长的规则里,手电筒开、手电筒关、媒体键、HTTP 请求(200 从自己站上取回首页)、 免打扰开、免打扰关、stop 全部按顺序生效,stop 之后那句「不该出现」的通知确实没发。

两个失败项也记一下。TTS 报「引擎没起来」,查了一下这台机器 pm list packages | grep tts 是空的,系统里根本没装语音引擎,代码没问题,环境没有。 无障碍第一次也是 false,原因是设置里那条服务被系统清掉了,重新打开就正常了。

root 那批是单独验的。一条规则串起来跑,日志里每个动作都去看系统设置有没有真的变:

12:34:11.550  root[0] svc wifi enable
12:34:11.551  ✓ Wi-Fi 打开
12:34:11.554  settings.screen_brightness = 76
12:34:11.555  ✓ 亮度设为 30%
12:34:12.363  settings.accelerometer_rotation = 0
12:34:12.365  ✓ 自动旋转关闭
12:34:13.375  截图已存相册:Pictures/RuleTree/RuleTree-1792416941223.png
12:34:13.376  ✓ 截屏

手势录制是这么验的:进录制页,在屏幕上点两下、划一下,顶部计数变成「已录 3 步」, 点完成回到编辑页多出一条「回放手势 3 步」;保存后跑一遍,日志里三步全是 true。

这里也踩了个小坑:录制页的操作条一开始贴着屏幕最顶部,被状态栏盖住,点「完成」没反应。 把操作条往下挪一个状态栏高度就好了——状态栏会吃掉它下面那几十像素的触摸。

跟小米自带的自动任务比

写到这里得回答一个绕不开的问题:小米自己不是有「自动任务」吗?

有。它在手机管家里,包名 com.miui.autotask,不是独立应用。我把手机管家拆开看了一遍,198 个类,条件和动作都比现在的规则树多得多。

它的任务是这么存的:

Task(userId, sceneId, channel, enable, triggerRule, List<Condition>)
Condition(instanceId, typeId, jsonStr)

一个扁平列表,每项记一个 typeId 和参数。没有树。

26 种条件:应用使用、离开应用、电量降到、蓝牙开关、连接指定蓝牙、充电、自定义时间段、耳机插入、热点、通话中、到达或离开地点、锁屏、会议、静音状态、NFC、打开某应用、WLAN 开关、连接或断开指定 WLAN。

28 种动作,基本是系统开关的集合:飞行模式、蓝牙、WLAN、移动数据、热点、NFC、定位、静音、勿扰、护眼、驾驶模式、省电、深色模式、方向锁定、自动亮度、防闪烁、息屏显示、系统触感、拨号按键音、亮度、音量、锁屏、打开应用、字体。

关键差别在分支。我把它的中文资源全捞出来搜过,「否则」「分支」「嵌套」一个都没有。它替代 else 的办法是两个字段:退出条件,加上「退出时恢复默认设置」。

于是「连上家里 Wi-Fi 就静音」这个场景,它做得比规则树还省事:进入条件选 WLAN,动作选静音,再勾上退出时恢复默认,三项搞定。

三星 小米自动任务 规则树 0.7
条件 100 多个 26 个 16 个
动作 200 多个(含生态) 28 个 31 个
分支 if / then / else,可嵌套 无 if / then / else,可嵌套
else 里能做什么 任意动作 只能恢复系统默认 任意动作
延迟 有 无 有
退出逻辑 自己写 内置恢复默认 自己写

所以规则树的价值不在「能不能连 Wi-Fi 静音」,那件事小米做得更好。它的价值在两处:嵌套,和 else 里能执行任意动作。

「连上 Wi-Fi 时,如果电量低于 20% 就开省电,否则只静音」这种规则,小米要拆成好几条任务,还得担心互相打架;规则树一条就够。

至于条件和动作的数量,比不过,也不该比。小米是系统应用,能直接拧各种开关;第三方应用拿不到那些权限,硬拼数量是必输的。

实测

小米 17 Pro,Android 17。先是单层的两条分支:

21:31:19  Wi-Fi 变为 已断开  →  条件[连着 Wi-Fi] = false  → 走 else → 铃声 normal(2)
21:31:29  Wi-Fi 变为 已连接  →  条件[连着 Wi-Fi] = true   → 走 then → 铃声 silent(0)

后来补上嵌套,又跑了一次。规则是「连上 Wi-Fi 就静音,同时如果还连着 Wi-Fi 就发通知,否则等 1 秒」:

22:05:21  条件[连着 Wi-Fi] = true
22:05:21  走 then(2 步)
22:05:21  铃声模式已设为 silent(当前=0)
22:05:21  ✓ 铃声切到静音
22:05:21  条件[连着 Wi-Fi] = true
22:05:21  走 then(1 步)
22:05:21  已发通知: 嵌套生效

外层的两步里,第二步是一个内层的 if,它自己又判断了一次、走了自己的 then。剪掉一步不会影响另一步,这就是树和列表的区别。

铃声值是从系统设置里读的,不是看界面猜的。

现在能做什么

触发器:Wi-Fi 连上、断开、任意变化;插上电源、拔掉、任意变化;收到通知(可按应用和关键词筛);蓝牙设备连上、断开(可指定某台);每天固定时间(可指定星期几);手动。

条件:连着 Wi-Fi(任意或指定名字)、充电中、电量高于或低于某个值、时间段(支持跨零点)、 星期几、屏幕亮着、锁屏中、免打扰、三种铃声模式、深色模式、连着蓝牙、插着耳机、当前应用是。 单条能取反,多条能用「并且」「或者」组起来,组里还能再套组,条目能拖动调顺序。

动作:铃声三态、四路音量、免打扰开关、振动、媒体键、手电筒、打开网址、写剪贴板、朗读、 HTTP 请求、打开应用、发通知、等待、执行前确认、等解锁、主动停止, 无障碍那组(返回、主屏、最近任务、通知栏、快捷设置、锁屏、点击坐标、滑动、截屏、录手势回放), 以及 root 那组系统开关(飞行模式、移动数据、Wi-Fi、蓝牙、NFC、定位、热点、亮度、超时、 旋转、深色模式、字体、密度、分辨率、熄屏、强制停止、任意 shell 命令)。

分支:任意层级嵌套,界面上就能编,不用手改 JSON。

每条规则的卡片上会写「上次 10-19 22:16 · 成功 · 7ms」这种一行,失败时写「有动作失败」。以前只能翻日志猜,现在一眼就能看出是哪条不干活。

规则存在 files/rules.json,可以直接拷出来改,导入导出界面还没做,格式是公开的。

0.11:把它做成能长期用的东西

0.10 之前一直在加能力,0.11 补的是「用久了会不会烦」。

规则能带走了。 菜单里多了导出和导入,导出的是一份 JSON,导入时校验格式、id 撞车会换新的、不覆盖你本地的规则。以前换机只能 adb 拷 rules.json。

卡片能长按了。 长按出来五个选项:上移、下移、复制一条、设置分组、删除。设了分组的规则在列表里带小标题排在一起。复制这个最实用——建一条差不多的规则,不用从头再填一遍。

触发器补了四种。 屏幕亮起 / 熄灭、耳机插拔、打开某个应用、每月某日。其中「打开某个应用」用的是无障碍的事件流,只记包名,不读界面内容。

动作补了「等某个状态」。 等连上 Wi-Fi、等开始充电、等屏幕亮着、等解锁,最多等 N 秒。以前只能靠猜秒数,等 5 秒再往下走,网慢的时候照样失败。

节点补了 repeat。 循环里放几步,重复 N 次。界面上加得进去、也能改次数。

实测:一条 repeat 3 次的规则真的发了 3 条通知;wait_until 命中后立刻往下走;每月某日排到了 11-01 07:00。

没验到的:导入导出走的是系统文件选择器,那几步要在真机点。这台测试机的触摸注入被系统拦住了(MIUI 的防误触层),点不动,所以文件选择器这一段没实际走通,逻辑和文档是齐的。

还不能做什么

  • 热点、NFC、飞行模式的具体命令跟 ROM 有关,只在这台小米上试过其中几项
  • 蓝牙触发还没在真机上跟到一次完整的连接事件,测试机没有已配对设备
  • TTS 朗读依赖系统里的语音引擎,测试机上没装,代码走通了但没听到声音
  • 手势序列只能录点按和滑动,录不了按键和等待
  • 系统开关这一组离了 root 就不工作,装之前得想清楚
  • 没有导入导出界面,rules.json 可以直接拷

代码在 https://github.com/z7ui/RuleTree,版本 0.11。仓库里那份 CAPABILITY-MAP.md 把三星 34 个能力域逐条标了三类,想自己判断某个功能能不能做, 看那张表比看功能列表快。

项目作者:Deepseek · 视频 B站 @z7ui

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注