本文基于 Magisk 官方开发者文档整理并结合作者的实际模块维护经验写成,所有关键机制均以官方文档为准,文末附参考链接。文中代码可直接复制使用。
写在前面
每一个折腾过 Android 的人,迟早会与 Magisk 相遇。刷机论坛里那些琳琅满目的模块——全局去广告、替换系统字体、注入调试工具、修改动效与音量步进——它们是怎么被写出来的?为什么装上就能生效、删掉就能还原,甚至不用重刷一次系统?
这篇文章把 Magisk 模块开发从原理到实战完整地讲一遍:分区与 systemless 机制、模块的目录结构、module.prop 的每一条字段、安装器与 customize.sh、五个脚本的执行时机、文件替换与删除的三种语义、在线更新、WebUI 与 Zygisk 的边界,最后用两个完整实战收尾——一个从零开始写的教学模块,和一个我本人长期维护的小米 HyperOS 字体模块的真实经验。读完这篇,你应该可以独立完成一个模块的开发、调试与发布。
阅读前提:会用 adb / fastboot,懂基本 Linux shell,手上有一台已解锁 Bootloader 并刷入 Magisk 的设备。没有也没关系,前半部分原理照读无碍,只是跟着做实战时需要一台"实验机"——不要拿主力机做第一个模块的试验场。
一、为什么是 Magisk:Systemless 的核心思想
1.1 传统改机的困境
在 Magisk 出现之前,想改系统就得真的去改系统:把 /system 分区重新挂载为可写、替换文件后重刷分区镜像。这条路有三个致命问题:
- OTA 升级基本报废。系统分区被改动后,官方增量包的完整性校验必然失败,往往只能线刷整包,且通常伴随数据清除。
- 只读分区寸步难行。现代 Android 普遍启用 AVB/dm-verity 完整性校验(启动时逐块校验分区内容与哈希树是否一致,被改动的分区直接验证失败),
/system、/vendor、/product、/system_ext以只读方式挂载;即便拿到 root 强行 remount 写入,也会破坏校验链,轻则改动被回滚,重则设备拒绝启动。 - 不可回滚。改坏了某个文件,轻则个别功能异常,重则卡开机,恢复成本极高。
此外,如今越来越多的完整性检测机制(如 Google Play Integrity)会读取系统分区状态,物理改动几乎等于自报家门。
1.2 Magisk 的解法:一个字节都不动 system
Magisk 的思路是把所有修改从"改分区"变成"改挂载":
- 安装 Magisk 时,只修补引导镜像——多数设备是
boot.img(内核 + ramdisk),Android 13 起采用 GKI 的新设备则多为独立的init_boot.img,注入自己的守护进程与执行环境; - 所有用户修改集中存放在数据分区的
/data/adb/modules/下; - 每次开机早期,Magisk 把每个模块的文件以挂载的方式"叠加"到真实文件系统对应路径上。
对系统和应用来说,看到的 /system/fonts/xxx.ttf 就是模块里那个文件;而 system 分区本身从头到尾没有被写入过任何一个字节。这套机制就是所谓的 systemless(无系统修改)。它的收益是结构性的:
- 想撤销?删掉模块,下次开机就是纯净系统;
- OTA 前担心冲突?卸载 Magisk 时选择"恢复原厂镜像(Restore Images)"即可还原被修补的分区;
- 修改以模块为单位分发、安装、卸载,形成了整个刷机社区的插件生态。
1.3 必要的分区知识:模块路径如何映射多分区
现代 ROM 的系统文件散落在四个分区:
| 分区 | 典型内容 |
|---|---|
/system | 框架、系统 App、字体、build.prop |
/vendor | 厂商 HAL、传感器配置 |
/product | 预装应用、运营商定制 |
/system_ext | 从 system 拆分出的扩展组件 |
关键在于:不同机型上,这些分区可能是独立的物理分区,也可能被合并挂载在 /system 之下(例如 /vendor 实际挂在 /system/vendor)。Magisk 对模块开发者屏蔽了这种差异——你只需要把文件放进模块 system/ 目录下的对应子路径:
模块内路径 真实目标
system/fonts/Foo.ttf → /system/fonts/Foo.ttf
system/vendor/etc/bar.conf → /vendor/etc/bar.conf
system/product/app/Baz/Baz.apk → /product/app/Baz/Baz.apk
system/system_ext/framework/x.jar → /system_ext/framework/x.jar
官方文档的说法是:无论这些目录是否为独立分区,Magisk 都会透明地处理挂载。另外要记住一条规矩:模块目录里可能出现的 vendor、product、system_ext 三个入口是 Magisk 自动生成的符号链接,开发者不要自己创建,统一从 system/ 子路径出发。
1.4 开机时序:模块在哪个环节登场
把一次开机中与模块有关的节点串起来,是理解后面所有脚本行为的基础:
记住两个分界线:post-fs-data.sh 在模块挂载之前运行(所以它可以动态调整模块内容),service.sh 在系统大部队启动之后运行(所以它适合做"开机后"的事情)。这条时间轴会在第六节反复出现。
二、模块的本质:/data/adb/modules 下的一棵目录树
一个"已安装"的 Magisk 模块,就是 /data/adb/modules/<模块id>/ 下的一棵普通目录树。没有注册表、没有数据库,一切皆文件。完整布局如下:
/data/adb/modules/<id>/
├── module.prop # 模块信息(唯一必需的文件)
├── system/ # 要叠加到系统分区的文件树
├── post-fs-data.sh # 早期脚本(阻塞阶段执行)
├── service.sh # 晚期脚本(late_start 阶段执行)
├── action.sh # Magisk App 中按钮触发的脚本
├── uninstall.sh # 模块被卸载时执行
├── system.prop # 以 resetprop 方式追加的系统属性
├── sepolicy.rule # SELinux 规则补充
├── zygisk/ # Zygisk 模块的原生库(如开发 Zygisk 模块)
├── webroot/ # WebUI 静态页面(v27+ 支持的约定)
├── skip_mount # 标记文件:存在则不挂载本模块的 system/
├── disable # 标记文件:存在则模块整体禁用
└── remove # 标记文件:存在则下次重启时移除模块
除了 module.prop,其余全部可选。一个只包含 module.prop 和 system/ 的模块,已经是一个能用的模块了。
三个标记文件(skip_mount、disable、remove)是 Magisk 的"开关与刹车",它们不是给安装器用的,而是给运行时和你自救用的——比如某个模块导致无法开机,你可以通过 ADB 或 Recovery 执行:
# 禁用而不是删除,便于排查(/data/adb 归 root 所有,需要 root shell)
adb shell su -c 'touch /data/adb/modules/<id>/disable'
# 或直接标记为下次重启移除
adb shell su -c 'touch /data/adb/modules/<id>/remove'
关于挂载机制多说一句:官方把这套按路径逐文件叠加的机制称为 Magic Mount,其内部实现随 Magisk 版本持续演进(绑定挂载、overlay 等),但模块开发者完全不需要关心内部实现——你唯一需要遵守的就是"把文件放到 system/ 下对应的路径",剩下的交给 Magisk。
三、module.prop:模块的身份证
module.prop 是模块唯一必需的文件,格式是极简的 key=value:
id=hello_bootcount
name=Hello Boot Counter
version=v1.0.0
versionCode=10000
author=your_name
description=一个用于演示的开机计数模块
updateJson=https://example.com/hello_bootcount/update.json
各字段规则与注意事项:
| 字段 | 必填 | 规则与建议 |
|---|---|---|
id | ✅ | 必须匹配正则 ^[a-zA-Z][a-zA-Z0-9._-]+$:以字母开头,之后是字母/数字/./_/-。合法如 a_module、module-101;非法如 1_module、-a-module、a module(含空格)。这是模块的唯一标识,一旦发布就不要再改——改了 id 等于一个新模块,老用户升级会变成"并存双模块" |
name | ✅ | 展示名,随便起,支持中文,但建议附英文(部分管理器对非 ASCII 兼容不佳) |
version | ✅ | 展示用版本串,建议语义化版本(v1.2.0),只给人看 |
versionCode | ✅ | 必须是整数,Magisk 用它判断新旧,每次发版必须递增。常用习惯是从 10000 起步,按 10100、10200 递进,为小修留空间 |
author | ✅ | 作者名 |
description | ✅ | 一句话描述,会显示在模块列表里,别写营销文案 |
updateJson | ⬜ | 指向一个 JSON 的 URL,让 Magisk App 具备在线检查更新能力,见第九节 |
此外,部分社区管理器(如 MMRL 等)还识别 donate、support 之类的扩展字段,属于生态约定而非官方规范,需要时再了解即可。
三个实战教训,全部来自真实翻车:
- 换行符必须是 LF。在 Windows 上用记事本编辑
module.prop,存出来是 CRLF,解析出来的 id 末尾会带不可见字符,模块直接安装失败或行为诡异。这是 Windows 上开发模块的第一大坑。 - 文件编码用 UTF-8(无 BOM)。
description里写中文时尤其注意。 - 别动 update-binary。安装器脚本会被 Magisk App 强制替换为官方版本,任何自定义修改都是无用功(下一节展开)。
四、安装器与 customize.sh:安装期你能做什么
4.1 模块 zip 的结构
把模块打包成 zip,通过 Magisk App 的"从本地安装"刷入。结构上有一处非常容易踩坑的地方——zip 的根目录就是模块文件本身:
# ✅ 正确:zip 根目录直接是 module.prop、system/ ...
hello.zip
├── module.prop
├── customize.sh
├── system/
│ └── ...
# ❌ 错误:多套了一层目录,安装器找不到 module.prop
hello.zip
└── hello/
├── module.prop
└── ...
在 Windows 资源管理器里右键压缩文件夹时,几乎必然得到第二种结构——因为压缩的是"文件夹"而不是"文件夹的内容"。用命令行打包可以杜绝这个问题。
另外两个文件仅在需要支持第三方 Recovery(如 TWRP)刷入时才需要:
META-INF/com/google/android/update-binary # 官方 module_installer.sh 原样放入
META-INF/com/google/android/updater-script # 内容只有一行字符串: #MAGISK
update-binary 是官方模板提供的通用安装器,所有模块共用同一个文件,不要在里面添加任何自定义逻辑——Magisk App 安装模块时会用自己内置的最新版强行替换它。用 Magisk App 安装模块则完全不需要这两个文件,只要有 module.prop 就能识别。
4.2 customize.sh:被"source"而不是被执行
customize.sh 是模块安装期的钩子。它不是被独立执行的,而是被安装器 source 进当前 shell——所以你可以直接使用安装器注入的变量和函数,不需要自己 export 什么。
安装器提供的变量:
| 变量 | 含义 |
|---|---|
MAGISK_VER / MAGISK_VER_CODE | 当前 Magisk 版本,可用于最低版本检查 |
BOOTMODE | 是否在系统内通过 Magisk App 安装(true/false),Recovery 刷入时为 false |
MODPATH | 本模块的安装目标路径(安装期指向 /data/adb/modules_update/<id> 暂存目录,重启后才转正为 /data/adb/modules/<id>) |
TMPDIR | 安装临时目录 |
ZIPFILE | 本次安装的 zip 文件路径 |
ARCH | 设备架构:arm / arm64 / x86 / x64 / riscv64 |
IS64BIT | 是否 64 位 |
API | Android API 级别(如 34 对应 Android 14),做版本分支判断用 |
安装器提供的函数:
| 函数 | 用途 |
|---|---|
ui_print <msg> | 把信息打印到安装界面。在 Recovery 里 echo 是看不见的,一律用 ui_print |
abort <msg> | 报错并终止安装。不要用 exit——会跳过安装器的清理逻辑 |
set_perm <目标> <属主> <属组> <权限> [context] | 设置单个文件/目录的权限,默认 SELinux context 为 u:object_r:system_file:s0 |
set_perm_recursive <目录> <属主> <属组> <目录权限> <文件权限> [context] | 递归设置权限 |
默认情况下,安装器已经把文件权限统一处理为:属主/属组 0:0,目录 0755、文件 0644。绝大多数模块不需要额外动权限;需要可执行位或特殊属主时再用 set_perm 微调。
4.3 一个标准的 customize.sh
ui_print "======================================"
ui_print " Hello Boot Counter v1.0.0"
ui_print "======================================"
ui_print "- 架构: $ARCH (Android API $API)"
ui_print "- Magisk: $MAGISK_VER (code $MAGISK_VER_CODE)"
ui_print "- 系统内安装: $BOOTMODE"
# 最低版本门槛:低于 Magisk 26 的环境直接拒绝安装
if [ "$MAGISK_VER_CODE" -lt 26000 ]; then
abort "本模块需要 Magisk v26.0 及以上版本"
fi
# 按架构做分支是模块的常见需求
if [ "$ARCH" != "arm" ] && [ "$ARCH" != "arm64" ]; then
abort "本模块仅支持 arm / arm64 设备"
fi
# 默认权限不满足需求时手动调整
set_perm_recursive "$MODPATH" 0 0 0755 0644
set_perm "$MODPATH/service.sh" 0 0 0755
set_perm "$MODPATH/action.sh" 0 0 0755
ui_print "- 安装完成,重启后生效"
三条铁律(全部来自官方文档的明确警告):
- 不要修改
update-binary(会被强制替换,理由见 4.1); - 不要在 zip 里放名为
install.sh的文件(会与安装流程约定冲突); customize.sh结尾不要写exit——用abort处理错误,正常结束让它自然返回,否则清理逻辑被跳过,安装状态可能残留。
4.4 SKIPUNZIP、REPLACE 与 REMOVE
三个进阶工具,能显著减少样板代码:
SKIPUNZIP=1——在 customize.sh 开头声明,安装器就不再自动解压 zip,整个解压流程由你接管。适合需要按架构挑选文件、动态生成配置的模块。接管后你需要自己完成解压,安装器提供的 unzip 函数可用:
SKIPUNZIP=1
unzip -o "$ZIPFILE" "system/*" -d "$MODPATH" >/dev/null
REPLACE 列表——声明需要"整体替换"的目录(自动生成 .replace 标记,语义见下一节):
REPLACE="
/system/app/YouTube
/system/priv-app/MiuiSystemUI
"
REMOVE 列表——声明需要"删除"的文件或目录(自动生成空字符设备标记):
REMOVE="
/system/app/YouTube/YouTube.apk
/system/etc/permissions/android.hardware.telephony.xml
"
这两个列表本质是第五节三种修改语义的语法糖——手工摆文件和写列表,效果完全等价,选可读性更好的那种。
五、system/ 目录:改文件的三种语义
模块对系统的文件级修改全部通过 system/ 子树表达,共三种语义:替换/新增(默认合并)、整体覆盖目录(.replace)、删除(空字符设备)。
5.1 合并(默认)
把文件按路径放进 system/,开机挂载后,同路径的系统文件被模块文件顶替,系统里不存在的路径则成为"新增文件"。逐文件生效、逐目录合并,不会影响同目录下的其他文件:
system/fonts/CustomFont.ttf → /system/fonts/CustomFont.ttf
system/etc/hosts → /system/etc/hosts
system/app/MyTool/MyTool.apk → /system/app/MyTool/MyTool.apk (新增的"系统应用")
5.2 整体替换目录:.replace
在 system/ 下的某个目录里放一个名为 .replace 的空文件,该目录不再与系统目录合并,而是整个被模块目录替换——原目录里所有内容一刀切消失,只剩下模块目录里的东西。想"彻底干掉某个系统 App"时最常用:
system/app/YouTube/.replace → /system/app/YouTube 整个目录被替换为空,App 消失
与上一节 REPLACE 列表等价。注意区别:.replace 是目录级别的操作,杀伤半径是整个目录,想精细删除单个文件用下一种。
5.3 删除单个文件:mknod 字符设备
Magisk 约定:system/ 下如果某个路径是一个主次设备号均为 0 的字符设备文件,挂载后对应的真实文件将被"抹除"(表现为不存在):
# 在 customize.sh 中,等价于 REMOVE 列表的效果(mknod 需要父目录已存在)
mkdir -p "$MODPATH/system/app/YouTube"
mknod "$MODPATH/system/app/YouTube/YouTube.apk" c 0 0
手工打包时也可以用 shell 事先造好这种文件。不过实践中更推荐 REMOVE 列表写法——不必在仓库里维护特殊文件。
5.4 skip_mount
如果模块目录下存在 skip_mount 标记文件,Magisk 将完全跳过该模块 system/ 子树的挂载。纯脚本型模块(只有 service.sh 做事,不改任何系统文件)可以带上它,明确表达意图、避免无谓的挂载开销。
六、五个脚本:执行时机决定一切
Magisk 模块共有五个钩子脚本,各自在完全不同的时机、以不同的约束运行。选错时机是模块 bug 的第一大来源,这一节值得反复对照。
| 脚本 | 执行时机 | 是否阻塞 | 典型用途 |
|---|---|---|---|
post-fs-data.sh | /data 挂载解密后、模块挂载之前、Zygote 之前 | 阻塞(开机暂停等它,上限约 40 秒后强制放行) | 极少数必须"赶在一切之前"的准备动作 |
service.sh | late_start 服务阶段,系统启动大部队之后 | 非阻塞,后台运行 | 绝大多数模块的默认选择 |
action.sh | 用户在 Magisk App 模块卡片点按钮时 | 由用户触发 | 手动执行动作、刷新配置 |
uninstall.sh | 模块被 Magisk 移除时 | — | 清理模块在模块目录之外产生的数据 |
customize.sh | 安装时(上一节已详述) | — | 安装期逻辑 |
6.1 post-fs-data.sh:快,但要小心
官方文档对这个阶段的定性原文是:阻塞(BLOCKING),开机流程会停下来等脚本跑完,或者等满约 40 秒。同时它在模块 system/ 挂载之前执行——所以你可以在这个脚本里动态生成、调整模块即将挂载的文件。
两个硬性禁忌:
- 绝对不要在 post-fs-data.sh 里用
setprop,会直接死锁开机流程。官方明确要求改用resetprop -n <名称> <值>——-n表示绕过 property_service 直接设置属性,不触发可能卡死开机的属性变更事件。 - 不要做任何耗时操作(网络请求、大文件处理)。这个阶段网络一定不可用,且你的每一秒都是用户盯着开机动画的一秒。
什么才值得放进 post-fs-data?官方建议是"非必要不使用"。真实案例:根据某个属性值决定今天要不要挂载某个文件——这种"必须赶在挂载前做的决策"才是它的主场。
6.2 service.sh:默认选项
late_start 服务阶段意味着:Zygote 已启动、系统服务正在并行拉起、你的脚本与其他模块脚本并行在后台跑。对绝大多数模块,service.sh 就够了——开机自启动的服务、定时任务、检测逻辑全放这里。
两个细节:
- 此阶段 UI 与网络大概率已可用,但"完全就绪"不保证。需要等系统完全启动,用属性轮询:
until [ "$(getprop sys.boot_completed)" = "1" ]; do
sleep 1
done
- 官方文档同时提到可以用
resetprop -w等待属性(resetprop -w <属性名> <值>),具体语义以设备上resetprop --help为准;轮询写法虽然朴素,但行为最透明,社区模块里也最常见。
6.3 通用脚本环境:BusyBox 与 MODDIR
所有模块脚本由 Magisk 内置的 BusyBox ash 解释,且运行在 standalone 模式:脚本里调用的 ls、grep、sed 等都解析为 BusyBox applet,不受系统 PATH 污染——这保证了脚本在不同 ROM 上行为一致,你可以放心使用 BusyBox 提供的丰富命令,而不必依赖各 ROM 自带 toybox/toolbox 的实现差异。
一条官方强调的编码惯例:
MODDIR=${0%/*}
用脚本自身路径推导模块目录,永远不要硬编码 /data/adb/modules/<id>。顺带一提,老教程里常见的 /sbin/magisk 路径在 Android 11+ 上已不可靠(Magisk 的 tmpfs 挂载点随版本变化,可能位于 /debug_ramdisk 下),凡引用 Magisk 运行时路径的脚本都需要以此为准重新审视。
环境变量方面:若设备启用了 Zygisk,模块所有脚本里 ZYGISK_ENABLED 会被置为 1,可以据此做兼容分支。
6.4 action.sh 与 uninstall.sh
action.sh 在用户点击 Magisk App 里模块卡片上的执行按钮时运行,脚本的输出通常会在 App 界面里展示(不同管理器版本的行为可能略有差异,拿不准就同时写入日志)。注意这里是 echo 的世界,ui_print 是安装器的函数,别带过来。适合"立即刷新一次配置""手动跑一次清理"这类动作。
uninstall.sh 在 Magisk 移除模块时执行,用来清理模块在自身目录之外产生的文件——比如你的 service.sh 往 /data/local/tmp 写过的缓存。注意:模块目录本身由 Magisk 负责删除,不用你管。
6.5 system.prop 与 sepolicy.rule
两个"声明式"配置文件,放好了就生效,不需要脚本:
system.prop:key=value格式,逐行声明要修改的系统属性(等价于resetprop的批量声明)。改 DPI、关摄像头声音这类属性级修改用它最干净。sepolicy.rule:一行一条magiskpolicy语句,为你的脚本/程序补充 SELinux 放行规则。遇到avc: denied相关问题时按需添加,格式示例:
allow <源context> <目标context> <类别> <权限>
排查流程:adb logcat | grep avc 找到 denial 行,按其内容写规则。sepolicy 规则写得过宽会引入安全风险,只放行确实需要的最小权限。
最后一条官方约定:Magisk 还有面向"非模块"的通用脚本目录 /data/adb/post-fs-data.d/ 与 /data/adb/service.d/。模块不要往这两个目录安装脚本——那是给用户手动放临时脚本用的,模块的一切都该待在自己的目录里。
七、实战一:从零写一个完整模块(全流程)
目标:写一个"开机计数器"模块。麻雀虽小,覆盖安装器、service.sh、action.sh、打包、安装、验证、卸载全流程,且零风险——它只在自己的目录里写日志,不碰任何系统文件。
7.1 目录结构与全部代码
hello_bootcount/
├── module.prop
├── customize.sh
├── service.sh
└── action.sh
module.prop(注意:LF 换行、UTF-8 无 BOM):
id=hello_bootcount
name=Hello Boot Counter
version=v1.0.0
versionCode=10000
author=your_name
description=教学示例:记录每次开机时间与累计次数,验证模块全链路
customize.sh(4.3 节模板的精简版:省去了版本与架构检查。安装器默认权限本就是属主 0:0、目录 0755、文件 0644,这里显式声明一遍,是为了给两个脚本补上可执行位):
ui_print "======================================"
ui_print " Hello Boot Counter v1.0.0"
ui_print "======================================"
ui_print "- 架构: $ARCH (Android API $API)"
ui_print "- Magisk: $MAGISK_VER"
set_perm_recursive "$MODPATH" 0 0 0755 0644
set_perm "$MODPATH/service.sh" 0 0 0755
set_perm "$MODPATH/action.sh" 0 0 0755
ui_print "- 安装完成,重启后生效"
service.sh:
MODDIR=${0%/*}
# 等待系统完全启动
until [ "$(getprop sys.boot_completed)" = "1" ]; do
sleep 1
done
NOW=$(date '+%Y-%m-%d %H:%M:%S')
COUNT=$(( $(cat "$MODDIR/count" 2>/dev/null || echo 0) + 1 ))
echo "$COUNT" > "$MODDIR/count"
echo "[$NOW] 第 $COUNT 次开机,模块运行于 $MODDIR" >> "$MODDIR/boot.log"
action.sh:
MODDIR=${0%/*}
NOW=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$NOW] 用户在 Magisk App 中按下了执行按钮" >> "$MODDIR/boot.log"
echo "已追加一条记录,当前累计开机 $(cat "$MODDIR/count" 2>/dev/null || echo 0) 次"
7.2 打包
关键点只有一个:在模块目录内部打包,让 zip 根目录直接是模块文件。
Git Bash / Linux / macOS:
cd hello_bootcount
zip -r9 ../hello_bootcount-v1.0.0.zip .
Windows 用户特别注意:不要用资源管理器右键"压缩成 zip 文件"(必然多套一层目录),也不建议用 PowerShell 的 Compress-Archive(它生成的 zip 条目路径使用反斜杠分隔符,属于非标准实现,BusyBox 的 unzip 等解压端可能把文件解到错误位置)。稳妥选择:7-Zip(进目录全选再压缩)或 Git Bash 自带的 zip。
打包后自查结构(两种环境任选):
unzip -l hello_bootcount-v1.0.0.zip
# 第一层就应该看到 module.prop、customize.sh、service.sh、action.sh
7.3 安装与验证
# 推到手机并安装(或直接在 Magisk App 里选择本地文件)
adb push hello_bootcount-v1.0.0.zip /data/local/tmp/
在 Magisk App → 模块 → 从本地安装,选中 zip。安装界面应当输出 customize.sh 里每一条 ui_print。注意此时模块处于"已安装、待重启"状态(安装到 /data/adb/modules_update/,重启后才转正到 /data/adb/modules/)。重启后:
# 确认模块就位且处于激活状态(无 disable/remove 文件)
adb shell su -c 'ls /data/adb/modules/hello_bootcount'
# 查看脚本产出
adb shell su -c 'cat /data/adb/modules/hello_bootcount/boot.log'
# 期望输出: [2026-08-15 10:23:45] 第 1 次开机,模块运行于 /data/adb/modules/hello_bootcount
再到 Magisk App 的模块卡片上点一次执行按钮,然后重新 cat 日志,应当多出 action.sh 写入的一行。至此,安装器、开机脚本、手动脚本三条链路全部验证通过。
7.4 卸载与"假死"实验
建议完整走一遍:
- Magisk App 里卸载模块,重启,确认
/data/adb/modules/hello_bootcount不存在;若模块实现了uninstall.sh,卸载时就是它被执行的时机,负责清理模块目录之外的产物; - 重新安装后,
adb shell su -c 'touch /data/adb/modules/hello_bootcount/disable',重启,确认日志不再新增——这就是排查问题时的"软停用"; - 同样方式试一次
remove标记,理解它与disable的区别(下次重启彻底移除)。
八、实战二:维护一个 HyperOS 字体模块的真实经验
第二个实战来自我长期使用的真实场景:给小米 HyperOS 设备更换全局字体。这类模块在 MIUI/HyperOS 玩家群里非常流行,它的维护经验比教学示例更能体现"工程问题"。
8.1 原理与路径的现实
字体替换的原理很直白:系统渲染文字时按固定顺序在字体目录里查找字体文件,用模块把目标 ttf 顶替成同名文件即可全局换字体。/system/fonts/ 下的通用字体路径是公开且稳定的。
但 MIUI/HyperOS 有自己的主题字体机制(主题商店的"字体"就是走私有通道),不同 ROM 版本、不同机型上私有路径存在差异。成熟的做法是参考社区里同机型的现成模块,确认它在你的 ROM 版本上替换的具体文件,再动手做自己的版本——不要凭网上的零散教程盲猜路径。这也是字体模块"机型/ROM 版本适配"问题的根源:换一台设备、升一次大版本,路径就可能变化。
8.2 瘦身:模块体积也是体验
很多字体模块是从别处转打包的:原包为了覆盖所有语言区域,带着十几个按语言细分的 ttf 变体,而绝大多数中文用户实际用到的只有主字体一个。做瘦身版时把用不到的语言变体全部剔除,最终 21.8 MB 的模块包相比原包小了一大圈——对每次更新都要重新下载模块的用户来说,下载体积直接决定更新体验。
裁剪的原则:先在实机上验证默认语言渲染正常,再逐个剔除变体并验证设置里切换语言的场景(如果你确实需要多语言)。字体文件的版权也要留意:个人使用与公开分发是两回事,把商用授权字体打包进公开模块分发存在侵权风险。
8.3 升级策略:id 不变,原地升级
这是维护期最重要的设计。我的做法:
- id 永不变。它既是模块目录名,也是升级判定的锚点。id 一变,老用户升级时会装出一个并行的新模块,旧模块还赖在系统里;
- 每次更新只递增
versionCode(version展示串同步改)。Magisk 靠整数比较判断新旧,忘了递增就是"更新了个寂寞"——App 会提示已是最新; - 日常换字体的最快路径:直接把新 ttf 换进模块目录覆盖同名文件,重启生效,连重装都不用(
/data/adb归 root 所有,adb push直写会报权限错误,先推到临时目录再用su拷入):
adb push NewFont.ttf /data/local/tmp/
adb shell su -c 'cp /data/local/tmp/NewFont.ttf /data/adb/modules/<你的字体模块id>/system/fonts/目标.ttf'
adb reboot
同名覆盖 + 重启,挂载的就是新文件。这个技巧对所有"纯文件替换型"模块都成立,非常实用;
- 正式发版则走完整流程:改
module.prop版本 → 打包 → 上传 → 更新updateJson(第九节),用户在 App 内一键升级。
8.4 Windows 环境的两个高频翻车点
再强调一次它们,因为字体模块的维护脚本最容易踩:
module.prop被编辑器悄悄存成 CRLF 或 UTF-8 BOM,导致 id 解析异常;- 打包时多套一层目录。字体模块目录里文件又多又大,出问题时排查非常痛苦,打包命令务必固定成脚本而不是每次手敲。
九、进阶能力:在线更新、WebUI 与 Zygisk 的边界
9.1 updateJson:一行配置换在线更新
在 module.prop 里配置 updateJson 指向一个可公网访问的 JSON,Magisk App 就会自动检查并提示升级:
{
"version": "v1.1.0",
"versionCode": 10100,
"zipUrl": "https://example.com/hello_bootcount/v1.1.0.zip",
"changelog": "https://example.com/hello_bootcount/changelog_v1.1.0.md"
}
四个字段:version 展示串、versionCode 整数(App 靠它判断要不要提示更新,必须与 zip 内 module.prop 一致且大于已装版本)、zipUrl 新版 zip 直链、changelog 更新日志链接。把 JSON 和 changelog 托管在稳定直链上(GitHub Releases、Gitee、对象存储均可),就得到了一个零后端的自动更新体系。
9.2 WebUI:给模块一个操作界面
从 Magisk v27.0 起,App 支持 KernelSU 风格的模块 WebUI:在模块目录放一个 webroot/ 子目录,入口为 webroot/index.html,用户点击模块即可打开这个内嵌页面。页面里可以配合套接字与模块后台脚本通信,实现配置开关、状态展示这类图形界面。
需要说明的是,这套约定源自 KernelSU 生态,Magisk 的管理器支持相对较晚且早期功能有限,社区里 WebUI-X、MMRL 等第三方管理器对 WebUI 的兼容往往更好。如果你的模块重度依赖 WebUI 交互,README 里建议同时推荐这些打开方式。
9.3 Zygisk 模块:另一条技术路线
本文讲的都属于"脚本 + 文件挂载"型模块。另一类模块基于 Zygisk——通过在 Zygote 进程注入原生库,实现在每个应用进程里运行代码(做 Hook、按应用隐藏 root、注入框架等)。它有几个显著特征:
- 代码是放在模块
zygisk/目录下的各 ABI 原生库(arm64-v8a.so、armeabi-v7a.so等),用 C/C++ 开发,遵循 Zygisk API; - 与脚本模块完全不是一个开发范式,开发门槛高得多,官方提供了专门的示例仓库(zygisk-module-sample);
- 模块脚本里可通过
ZYGISK_ENABLED环境变量感知 Zygisk 是否开启,据此做优雅降级; - 若原生库加载失败(例如与设备不兼容),Magisk 会在模块目录留下
unloaded标记文件。
一句话划界:改文件、跑脚本用本文的体系;要在应用进程里做进程内 Hook,才需要 Zygisk。此外还有面向 boot ramdisk 定制的 overlay.d 机制(替换或追加 .rc、向 Magisk tmpfs 塞文件),属于更底层的少数派需求,官方文档有专门章节,此处不展开。
十、调试、自救与发布
10.1 调试手段
- 脚本自带日志:所有后台动作落到自己模块目录下的日志文件(如本文示例的
boot.log),这是成本最低、收益最大的习惯; - Magisk App 日志页:能看到安装与脚本执行的全局日志;
- logcat:
adb logcat是系统级问题的第一现场,SELinux 问题用adb logcat | grep avc定位; - 分阶段验证:改完一个脚本先手动执行验证语法(
adb shell su -c 'sh /data/adb/modules/<id>/service.sh'——注意手动跑不会复现开机时序,但能排除低级错误),再重启验证真实链路。
10.2 模块把手机搞挂了怎么办
每个模块开发者都会经历一次"卡开机"。救援阶梯从轻到重:
- 等:post-fs-data 脚本卡住时,约 40 秒后会被强制放行,先等够这个时间;
- 进安全模式:多数设备在开机动画期间长按音量下键可进入安全模式,随后用 Magisk App 卸载问题模块;
- ADB 标记:设备能被 ADB 识别时,执行
adb shell su -c 'touch /data/adb/modules/<id>/remove'(或disable)后重启;确认是模块问题但拿不准是哪个时,官方还提供了一键移除全部模块的命令adb shell su -c 'magisk --remove-modules'; - Recovery 兜底:第三方 Recovery 挂载 data 后同样可以创建标记文件;
- 最后的手段:卸载 Magisk 并通过它"恢复原厂镜像",或用 fastboot 刷回原厂引导分区、线刷整包,此处不展开。
把这条阶梯写进模块的 README,是对用户负责的一部分。
10.3 发布检查清单
打包发布前逐条核对:
| 检查项 | 说明 |
|---|---|
| zip 根目录无嵌套 | unzip -l 第一层就是 module.prop |
| LF 换行 / UTF-8 无 BOM | module.prop 与所有 .sh,Windows 开发者重点自查 |
versionCode 已递增 | 与 updateJson 内一致 |
| id 未变更 | 升级发布的前提 |
| 权限最小化 | 可执行位只给需要的脚本,sepolicy.rule 只放行必要规则 |
| Recovery 兼容(可选) | 需要时补 META-INF 两件套 |
| README 完整 | 用途、适用机型与 ROM 版本、安装/卸载方法、升级说明、救援方法、免责声明 |
分发渠道:国际社区以 GitHub Releases + XDA 为主;国内访问 GitHub 不稳定,可同步 Gitee 或网盘,并在 README 注明。开源模块的仓库里放源文件与打包脚本、Release 里放 zip,是社区的标准做法。
10.4 一点工程哲学
模块开发的特殊性在于:你的代码运行在别人每天要用的主力机上。系统级的写权限意味着一个 rm 的路径写错就可能是灾难。因此模块工程的默认姿势是保守的:
- 只改必要的文件,能声明式(
system/、system.prop)就不命令式(脚本); - 所有副作用可追溯(日志)、可撤销(模块卸载即还原);
- 发布前在自己设备上跑完整升级路径(旧版 → 新版),而不只测全新安装。
十一、结语
Magisk 模块开发的全部核心知识,其实就三句话:文件放对地方(system/ 三种语义)、脚本挑对时机(post-fs-data 阻塞早期 / service 非阻塞后期)、元数据守规矩(module.prop 的 id 与 versionCode)。剩下的——安装器的变量与函数、updateJson、WebUI、Zygisk——都是围绕这三件事的增强。
从原理走到发布,一个下午足够完成第一个模块。之后你会发现,限制你的不再是"会不会写模块",而是"想解决什么问题"。去读几个社区里口碑好的模块的源码(很多都开源),对照本文的框架看它们如何组织文件与脚本,是进阶最快的方式。
玩得开心,也善待每一位安装你模块的用户。
参考资料
- Magisk 官方开发者指南(模块与脚本的全部机制权威出处):https://topjohnwu.github.io/Magisk/guides.html
- Magisk 官方模块模板仓库:https://github.com/topjohnwu/magisk-module-template
- Zygisk 官方示例模块仓库:https://github.com/topjohnwu/zygisk-module-sample
- Magisk 官方更新日志(WebUI 等特性的引入版本):https://topjohnwu.github.io/Magisk/changes.html
- resetprop 等内置工具文档(Magisk 仓库 docs/tools.md):https://github.com/topjohnwu/Magisk/blob/master/docs/tools.md
声明:本文为原创内容,转载请注明出处。Root 与模块修改可能影响设备保修,部分金融类应用会检测设备完整性,请自行评估风险;字体等资源文件务必注意版权,仅分发拥有授权的内容。

462

被折叠的 条评论
为什么被折叠?



