Appearance
Android 信号 KSP 注解改造
用
@UnityMessage注解 + KSP 在编译期生成信号注册码,替代运行时反射扫描和手写注册模板,整条信号注册架构重构 90%。
架构重构90%
注册模板代码-60%
信号名检查时机编译期
背景
一张图 / SR 渲染引擎里,Unity 与 Android 原生之间是双向信号通信:
- 下行(Android→Unity):业务调
sendMsgToUnity→AndroidUnityProxy按信号选 Strategy 做类型转换 →AndroidUnityBridge走 JNI 进 Unity。 - 上行(Unity→Android):Unity JNI 回调 →
UnityMsgRegistry按信号名找到对应的处理器 →sendMsgToAndroid派发到 Flow/回调。
问题出在中间那层"信号名 → 处理器"的映射表。每新增一个信号,Android 侧都要做三件重复劳动:Unity 端定义信号名、Android 端写 handler、再把信号名和 handler 手工注册进 SignalDispatcher:
kotlin
// 手写注册(每个信号一遍,纯重复劳动)
SignalDispatcher.register("unity_map_center", MapCenterHandler())
SignalDispatcher.register("unity_camera_state", CameraStateHandler())
SignalDispatcher.register("unity_layer_switch", LayerSwitchHandler())
// ... 20+ 个信号,全部手写问题
- 模板代码膨胀:20+ 个信号每个都写一遍注册,handler 类本身的业务代码没几行,注册模板倒占了大头。
- 运行时才发现错误:信号名是裸字符串,两端各维护一份清单。拼错一个字符,编译能过、运行时静默丢消息,排查成本极高。
- 注册动作容易漏:新增信号忘了注册,表现是"消息发了没反应",没有报错可跟。
- 反射扫描不可靠:如果改成运行时扫 classpath 自动找 handler,启动耗时随类数量增长,且扫没扫到要运行起来才知道。
本质上,这是一个"信息在编译期就已知,却被推迟到运行时才组装"的问题——典型的该用编译期代码生成解决、却被手工 + 反射扛着的场景。
方案
思路:让每个 handler 类用注解自己声明"我处理哪个信号",KSP 在编译期收集所有注解,生成一份集中的注册代码。注册表从"运行时拼出来"变成"编译产物的一部分"。
mermaid
flowchart LR
A["Handler 类<br>@UnityMessage(信号名)"] --> B["KSP Processor<br>编译期收集符号"]
B --> C["生成 SignalRegistry.kt<br>registerAllSignals()"]
C --> D["启动时直接调用<br>零反射 零扫描"]
E["旧方案:手写注册<br>或运行时反射扫描"] -.被替代.-> B选 KSP 而不是 KAPT 的原因:KSP 直接读 Kotlin 符号,不需要先生成 Java stub,编译开销小,且对 Kotlin 源码的注解处理是一等公民。
关键实现
注解定义
kotlin
@Target(AnnotationTarget.CLASS)
annotation class UnityMessage(val signal: String)handler 只需声明自己处理的信号名:
kotlin
@UnityMessage("unity_map_center")
class MapCenterHandler : SignalHandler {
override fun handle(data: String) {
// 处理 Unity 发来的地图中心数据
}
}KSP Processor
kotlin
class UnityMessageProcessor(
private val codeGenerator: CodeGenerator
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(UnityMessage::class.qualifiedName!!)
// 按注解里的信号名生成注册代码
val file = FileSpec.builder("com.xpeng.signal", "SignalRegistry")
.addFunction(generateRegisterFunction(symbols))
.build()
file.writeTo(codeGenerator, aggregating = false)
return emptyList()
}
}编译后自动生成 SignalRegistry.kt:
kotlin
// 自动生成,勿手动修改
fun registerAllSignals() {
SignalDispatcher.register("unity_map_center", MapCenterHandler())
SignalDispatcher.register("unity_camera_state", CameraStateHandler())
// ... 全部由 KSP 生成
}编译期校验是最大收益
Processor 不只是"生成代码",更重要的是在生成前做校验:
- 信号名重复:两个 handler 声明同一个信号名,直接编译失败,报出两个类的位置——以前是运行时后者覆盖前者。
- handler 类型错误:注解标在没实现
SignalHandler的类上,编译失败。 - 信号名集中可查:生成的
SignalRegistry.kt就是全量信号清单,两端对表时看这一个文件即可。
指标
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 新增一个信号 | 写 handler + 手写注册 + 对两端清单 | 加一个 @UnityMessage 注解 |
| 信号名拼写错误 | 运行时静默丢消息 | 编译期失败 |
| 注册模板代码 | 每信号一份手写模板 | 减少 ~60%,全部由 KSP 生成 |
| 架构重构范围 | — | 90%(注册链路整体替换) |
复盘
- 编译期已知的信息,就不要留到运行时组装。注册表、路由表、依赖注入这类"静态映射",代码生成几乎总是优于反射扫描:错误提前暴露、启动零开销、产物可审查。
- Processor 的价值一半在生成,一半在校验。只生成不校验,等于把手写模板换成了自动模板;把重复信号名、类型错误挡在编译期,才是这次重构的主要收益。
- 生成物本身就是文档。
SignalRegistry.kt意外地成了两端对信号清单的单一事实来源,比任何维护在文档里的表格都可靠。 - 遗留问题:信号名的字符串本身仍靠人肉与 Unity 端对齐,后续可以把两端信号清单收敛到一份 proto/IDL,从定义处生成两端的常量,把最后一处手工同步也消掉。