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
发表回复