一个 KernelSU 模块:让 WiFi 与移动数据真正同时可用,解决 HyperOS / MIUI 上「仅使用移动数据」的应用在连接 WiFi 后断网的问题。
内置 WebUI,可按应用强制分流,并在状态栏常驻通知实时显示当前应用走的是流量还是 WiFi。
在 手机管家 / 安全中心 → 网络助手 里,把某个应用设为「仅使用移动数据」后,一旦连上 WiFi,这个应用就断网了。
原因不是移动数据断了 —— 实测连上 WiFi 后:
ip -4 addr show
# inet 10.196.216.142/30 scope global rmnet_data3 ← 移动数据仍然有 IP
# inet 192.168.31.127/24 scope global wlan0 ← WiFi 也持有地址
dumpsys telephony.registry | grep mDataConnectionState
# mDataConnectionState=2 ← 数据连接保持 CONNECTED链路是活的。真正断网发生在应用层:网络助手只做到了「禁止该应用走 WiFi」,却没有把它的流量导到移动数据上。WiFi 成为默认网络后,这些应用的连接走 WiFi 被拦掉,于是表现为「没网」。
本模块做的就是补上这一步:给指定应用的流量打上内核路由规则,让它们在 connect() 阶段直接选中移动数据的源地址,完全绕开 WiFi。
ip rule add from all uidrange <uid>-<uid> lookup rmnet_dataX pref 15060
这一步决定了成败。必须在 socket 建立连接前就选对路由表,否则源地址会被定死在 WiFi 的 IP 上。
iptables -t mangle -I OUTPUT -m owner --uid-owner <uid> -j MARK --set-xmark 0x1006d/0x1ffff给已经建立的连接补充标记,覆盖部分边缘场景。
分流规则覆盖设备上所有用户,不只是主空间:
| 用户 | 说明 | 微信的 UID |
|---|---|---|
| 0 | 主空间 | 10336 |
| 10~14 | 第二空间 / 小云空间 | 1010336 ~ 1410336 |
| 999 | 应用分身(XSpace) | 99910336 |
UID 由 userId × 100000 + appId 得出,直接通过 pm list packages -U --user <id> 读取 PackageManager 的真实值,不靠字符串拼接推算(早期版本用 "1$uid" 拼接,遇到 4 位 appId 会算错)。
应用列表同样合并了所有用户的第三方应用,按包名去重。
WebUI 中的勾选按包名记录,一次勾选会同时作用于该应用在所有空间下的副本。若需要「主空间走 WiFi、分身走流量」这类精细控制,需改成按「包名 + 用户 ID」记录,目前未实现。
分流是否真的生效,用数据说话。模块在 filter 表为每个白名单 UID 挂了一条计数器:
iptables -I OUTPUT -m owner --uid-owner <uid> -o wlan0 \
-m comment --comment dn_wifi_probe -j RETURN守护进程每 30 秒读一次。计数增长就说明有包从 WiFi 漏出去了,会写日志并发一条通知(10 分钟冷却),WebUI 的「路由标记」卡片也会显示累计漏包数。
实测正常情况下恒为 0:
0 包 uid 10403 拼多多
0 包 uid 10336 微信
可能导致漏包的三种情况:
- App 主动绑定 WiFi:
bindProcessToNetwork(WIFI)会让 netd 添加pref 15040的规则,优先级高于我们的15060 - 接口切换空窗:
rmnet_data3→rmnet_data4重建期间,规则仍指向旧表 - 规则被 netd 重置:网络模式切换可能清掉 ip rule,靠 30 秒轮询 + 10 分钟兜底重建
- 维护
mobile_data/mobile_data_always_active/mobile_data_always_on三个设置项 - 常驻进程通过
ConnectivityManager.requestNetwork(CELLULAR)向系统声明「有应用需要蜂窝网络」,阻止 WiFi 连接时 PDN 被回收
模块从不调用
svc data disable/enable。早期版本尝试过「检测到接口消失就重连」,结果 WiFi 切换瞬间的接口抖动会触发误判,反复关开数据反而把网络弄断了。
| 功能 | 说明 |
|---|---|
| 强制走移动数据的应用 | 勾选后,该应用全部流量从 rmnet_dataX 出,不依赖系统绑定 |
| 中文应用名 | 通过 PackageManager.getApplicationLabel() 取本地化名称,支持中文搜索 |
| 应用图标 | 真实图标以 webp + base64 内联,按版本号缓存,不重复传输 |
| 公网 IP 查询 | 分别通过 WiFi / 蜂窝网络请求,显示两条链路的实际出口 IP |
| 状态栏常驻通知 | 实时显示当前前台应用走流量还是 WiFi,带应用图标 |
| WiFi 漏包监控 | 按 UID 统计白名单应用从 WiFi 出去的包,非 0 时发通知告警 |
| 详细日志 | 记录每次前台切换的应用与所用网络,自动轮转不会无限增长 |
| 秒开 | 应用列表与图标均三层缓存(后端文件 → localStorage → 版本校验) |
# 1. 推送模块
adb push dualnet /data/local/tmp/dualnet
adb push build/install.sh /data/local/tmp/install.sh
# 2. 安装
adb shell su -c "sh /data/local/tmp/install.sh"或者手动复制到 /data/adb/modules/dualnet_keep/,重启手机由 KernelSU 自动拉起。
安装后在 KernelSU 管理器 → 模块 → 双网络守护 → 打开 进入 WebUI。
- 在 WebUI 里勾选那些「只允许用移动数据」的应用
- 勾选即保存,30 秒内自动生效(也可点刷新立即应用)
- 状态栏通知会显示当前前台应用的分流状态
CTL=/data/adb/modules/dualnet_keep/scripts/ctl.sh
su -c "sh $CTL status" # 查看状态
su -c "sh $CTL apps" # 应用列表
su -c "sh $CTL apply" # 立即重建规则
su -c "sh $CTL log" # 运行日志
su -c "sh $CTL publicip" # 两条链路的公网 IP配置文件在 /data/adb/dualnet/:
| 文件 | 作用 |
|---|---|
apps.conf |
每行一个包名,强制走移动数据 |
keep_data |
存在则维护数据开关 |
force_route |
存在则启用分流规则 |
notify_off |
存在则关闭状态栏通知 |
daemon.log |
守护进程日志(800 行上限,超出保留后半段) |
events.log |
应用网络事件:时间 + 应用名 + 走的网络(600 行上限) |
WebUI 调用的应用名、图标、公网 IP、保活等功能依赖内嵌的 dex(dualnet/tools/label.dex)。修改 java/dualnet/*.java 后需要重新编译:
# Windows
powershell -ExecutionPolicy Bypass -File build/build-dex.ps1
# Linux / macOS
bash build/build-dex.sh需要 JAVA_HOME 与 Android SDK(ANDROID_SDK_ROOT),会用到 android.jar 和 d8。
| 源文件 | 作用 |
|---|---|
LabelDump.java |
导出 包名|应用名|uid |
DumpIcons.java / DumpIcon.java |
导出应用图标(全部 / 单个) |
PublicIp.java |
分别经 WiFi 与蜂窝查询出口公网 IP |
KeepAlive.java |
常驻请求蜂窝网络,阻止 PDN 被释放 |
Notify.java |
早期尝试用 NotificationManager 发通知(保留作参考) |
记录几个非显然的问题,供后来者参考。
最初只用 iptables -t mangle -j MARK,结果应用所有连接卡在 SYN_SENT:
192.168.31.127:9D1A → 6.6.6.6:443 state=02 (SYN_SENT) # 源地址是 WiFi 的 IP
包确实被打了 mark,但源 IP 在 connect() 那一刻就由路由表定死了,mangle 阶段再改标记也改不了源地址。必须用 ip rule + uidrange 在选路阶段介入。
改用后同一应用:
10.196.216.142:DA74 → ...:443 state=01 (ESTABLISHED) # 源地址变成移动数据
cmd appops get root POST_NOTIFICATION
# Uid mode: POST_NOTIFICATION: ignoreroot 的通知权限是被禁的,cmd notification post 在 su 下静默失败。最终方案是降权到 shell 用户:
busybox setuidgid 2000 cmd notification post -S bigtext -t "标题" tag "内容"WiFi 连接后蜂窝图标消失是 SystemUI 的 Java 逻辑决定的,root 改不动已经编译进 APK 的分支,需要 LSPosed hook(可试试 HyperCeiler)。该图标仅影响显示,不影响实际功能。
adb push dualnet /data/local/tmp/dualnet # 第二次执行会变成 /data/local/tmp/dualnet/dualnet覆盖部署前先 rm -rf 目标目录。
在以下环境验证:
- 设备:Redmi 25102RKBEC
- 系统:HyperOS OS4.0.4.0(Android 17)
- Root:KernelSU(官方
me.weishu.kernelsu)
原理上适用于所有支持 ip rule uidrange 的 Android 版本,但 KernelSU WebUI 的 ksu.exec 在部分版本是 callback 风格而非 Promise,webroot/app.js 已做双兼容处理。
KernelSU 管理器里卸载,或:
su -c "rm -rf /data/adb/modules/dualnet_keep /data/adb/dualnet"uninstall.sh 会清理 iptables 规则、ip rule 和常驻进程。
修改网络路由与系统设置存在风险,请自行评估。因使用本模块导致的任何问题(包括但不限于流量超额、系统异常、设备故障)由使用者自行承担。

