Android 架构边界:组件化、双形态、进程隔离与可插拔

1 阅读39分钟

架构演进的本质,是在正确的位置划一条线。

一个库在本地跑得好好的,一旦被别的 App 当插件加载,就开始莫名崩溃——资源 ID 失效、ClassLoader 不对、Application 没初始化。这不是个例,而是"运行环境被假设成唯一"的必然后果。

另一类现场更隐蔽:今天用甲厂商的语音识别,明天换成乙厂商,业务层却写满了 when (厂商) 特判,换一次就重写一次;或者把鉴权逻辑和界面放在一起,宿主一改动态化就把校验线绕开了;再或者团队死守"全工程统一 MVVM",结果一个一次性的坐标换算被强行包了一层"假状态流",代码又绕又难读。

这些坑看着互不相关,根子却是一个词:边界。这篇把五种边界——模块边界、运行时边界、进程边界、模式边界、厂商边界——按"从代码内到进程外"的顺序串起来,讲清楚每条线该划在哪、用什么手段划、划完要付什么代价。统一判断是:架构演进不是把代码重写得更漂亮,而是在正确的位置划一条线,让线两边的变化互不传染。

下面先把五道边界摆在同一张图上,看清它们从"代码内"到"进程外"的层次关系——前四者层层外推,模式边界与它正交,不在这条纵向链上:

flowchart TD
    A["业务代码"] --> B["模块边界 服务路由表"]
    B --> C["运行时边界 环境门面"]
    C --> D["进程边界 AIDL Binder"]
    D --> E["厂商边界 语音门面"]
    F["模式边界 MVVM MVP 展示层"] -.->|"与以上正交"| A

五道边界各自的隔离对象、代价与适用场景,先给一张总表,后文再逐一展开:

边界隔离什么代价适用场景
模块边界组件实现类,调用方不 import 实现运行时装配、少编译期校验十几模块、一套内核多产品
运行时边界宿主 / 插件环境的差异一处环境判断的维护成本同代码多运行时:插件、单机联机、灰度
进程边界敏感逻辑(鉴权)与主进程IPC 序列化、样板代码、重连敏感且需长活、被多方复用
模式边界MVVM 与 MVP 的展示层差异团队需懂两种模式一次性操作与持续状态并存
厂商边界厂商 SDK 与错误码方言门面 / 适配器开发量底层实现常换:语音、地图、支付

一、模块边界:用服务路由表让调用方不 import 实现

组件化的第一道边界,发生在"代码之内、编译之后":它要解决的是"模块 A 想用模块 B 的能力,但不该认识模块 B 的实现类"这件事。很多团队一谈组件化就上重框架——注解处理器生成注入代码、字节码插桩做路由、再配一套编译器插件。功能是强了,可一出包,"组件之间到底谁编译谁"就够人头疼半天。

这里要拆的,是一套反其道而行的实现:整个"组件框架"只有几个类,不依赖任何编译期代码生成,用一份放在资源目录里的文本清单 + 一张接口路由表,就把模块的装配、初始化和调用编排起来。它特别适合"不需要动态下发、但想保持模块边界清晰"的中型 App。

1.1 "组件"到底是什么

在这套框架里,一个组件就是一个普通的类。它不继承什么神奇基类,也没有注解,全部职责只有一件:在启动时把业务能力注册进框架。

class DeviceConnectionModule : Module {
    override fun init(context: Context) {
        // 注册对外服务实现
        serviceRegistry.register(GnssDeviceService::class) { GnssDeviceServiceImp() }
        // 初始化设备管理层
        DeviceManager.start(context)
        // 初始化本地数据库
        AppDatabase.init(context)
    }
}

你会发现它非常"朴素":没有 @AutoService、没有 @Router,就是实现了两个方法。那框架怎么知道有这个组件?答案是清单文件。框架启动时去读一份固定路径下的文本清单(每行一个组件全限定名),逐个用反射 Class.forName 加载并实例化,最后调用 init:

object ModuleRegistry {
    private val moduleMap = LinkedHashMap<String, Module>()

    fun loadFromManifest(context: Context, assetPath: String) {
        context.assets.open(assetPath).bufferedReader().useLines { lines ->
            lines.filter { it.isNotBlank() }
                .forEach { className ->
                    val module = Class.forName(className).getDeclaredConstructor().newInstance() as Module
                    module.init(context)
                    moduleMap[className] = module
                }
        }
    }

    fun unRegister(moduleName: String): Boolean {
        if (moduleName.isNotEmpty() && moduleMap.containsKey(moduleName)) {
            moduleMap.remove(moduleName)
            return true
        }
        return false
    }
}

核心就这几十行。清单文件决定"哪些组件活",代码决定"组件各自注册了什么"。注意这里用的是 LinkedHashMap 而不是普通 HashMap——useLines 是按清单顺序逐行加载的,保留插入顺序意味着组件的初始化顺序可预测、可依赖,这对"组件 A 必须在组件 B 之前就绪"的隐性约束很重要。

1.2 为什么不用注解 + 编译器插件

注解处理 + 编译器插件固然能在编译期生成路由表,但它换来三笔账:

  • 构建链路变复杂,多一个注解处理器就多一层调试成本,编译报错往往指向生成的代码而非你写的代码;
  • 组件必须参与同一个编译单元,做不到"运行时才知道有没有这个模块";
  • 对多宿主 / 多形态(下一节会讲宿主与插件)的场景,编译期绑定反而是负担——你没法在打包时决定"这份产物里有没有这个组件"。

这份清单文件的本质是把"装配关系"从代码里挪到数据里。好处是:改组件清单不需要改代码;不同产品线只要换一份 modules.list,就能拼出不同的 App——这正是"一套内核、多产品"最常见的诉求。换言之,装配关系一旦数据化,它就从"编译期不可变"变成了"打包期可配置",二者灵活性差出一个维度。

1.3 服务路由表:调用方只依赖接口

组件之间不能互相 new 对方的实现类,否则边界就崩了。框架的第二块拼图是一张接口路由表:

object ServiceRegistry {
    private val registry = HashMap<Class<*>, () -> Any>()

    fun <T : Any> register(api: Class<T>, factory: () -> T) {
        registry[api] = factory
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> get(api: Class<T>): T? {
        return registry[api]?.invoke() as? T
    }
}

调用方只依赖接口,运行期才取实现:

val service = ServiceRegistry.get(GnssDeviceService::class)
service?.queryState()

这一层让"谁实现、何时实现"完全对上层透明。它比框架的 @Inject 简单得多,但对"把模块切成可插拔的积木"这个目标来说,已经足够。关键在于 register 存的是 () -> T 工厂而不是 T 实例——这意味着实现是懒创建的,没被用到的组件不会白白占用内存,也避免了初始化顺序引发的"还没注册就被取"的竞态。

用一张流程图把"清单驱动装配 + 接口路由取实现"的两条线画清楚:

flowchart LR
    M[&#34;assets/modules.list 清单&#34;] --> L[&#34;ModuleRegistry.loadFromManifest&#34;]
    L -->|&#34;Class.forName 反射实例化&#34;| I[&#34;module.init 注册&#34;]
    I --> R[&#34;ServiceRegistry.register 接口到工厂&#34;]
    C[&#34;调用方代码&#34;] -->|&#34;ServiceRegistry.get 接口&#34;| R
    R -->|&#34;invoke 工厂 懒创建&#34;| S[&#34;具体实现实例&#34;]

1.4 这套思路适合什么规模(取舍判断)

别把它和完整 DI 容器、微内核架构比——定位完全不同。具体的取舍判据:

  • 适合:模块数量十几个、关系树稳定、想低成本保持边界的中型工程;
  • 适合:需要"一套内核出多个形态"(换清单拼装)的产品线;
  • 不适合:需要运行时动态下发代码、热替换、跨版本兼容的插件化大型 App——那种场景值得上真正的插件框架。

值得一提的是,清单加载是启动期一次性的。如果某个模块的 init 比较重(比如建数据库连接、预热算法模型),集中初始化会直接拖慢冷启动。这类"朴素编排"最容易踩的坑,就是忘了给重模块做延迟初始化。改法很简单:清单只负责"登记",真正昂贵的初始化挪到首次被 get 时再触发,或显式提供一个 lazyInit 入口由调用方在合适的时机(比如用户进入对应页面)触发:

object ServiceRegistry {
    private val registry = HashMap<Class<*>, () -> Any>()
    private val lazyIniters = HashMap<Class<*>, () -> Unit>()

    fun registerLazy(api: Class<*>, factory: () -> Any, lazyInit: () -> Unit) {
        registry[api] = factory
        lazyIniters[api] = lazyInit   // 重初始化延后到首次使用
    }

    fun ensureInit(api: Class<*>) {
        lazyIniters.remove(api)?.invoke()  // 取用时再执行一次重活
    }
}

一句话:轻量方案换来了构建简单、边界清晰,但牺牲了编译期校验与动态下发能力。划这条线之前,先确认你的变化频率撑得起"无编译期保障"的代价。

1.5 路由查不到时的缺省降级

get 返回可空,意味着调用方每次都得判空,漏一次就是一次线上崩溃。更稳妥的做法是给路由表配一层"缺省实现":某个接口即便没人注册,也能返回一个安全的空实现,而不是让调用方在 null 上翻车。

object ServiceRegistry {
    private val registry = HashMap<Class<*>, () -> Any>()
    private val defaults = HashMap<Class<*>, () -> Any>()

    // 注册接口的同时,挂一个降级实现
    fun <T : Any> registerWithDefault(api: Class<T>, factory: () -> T, fallback: () -> T) {
        registry[api] = factory
        defaults[api] = fallback
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> getOrFallback(api: Class<T>): T {
        return (registry[api]?.invoke() ?: defaults[api]?.invoke()) as T
    }
}

缺省实现的语义要慎重:它不该"假装成功",而该返回一个让调用方能感知"能力缺失"的对象——比如一个 NoOpGnssService,它的 queryState() 返回"未就绪",而不是抛异常或返回伪造的已连接状态。这样上层既不用到处判空,又能在日志里看到"这个能力当前没接进来"。这里降级分支仍要走 as T 强转,所以 fallback 的返回类型必须和 api 一致,否则运行期 ClassCastException 比 null 更隐蔽。

1.6 这条边界什么时候不该划

反例比正例更能说明边界的位置。如果你的工程模块常年就三五个、彼此还总是一起改,那清单 + 路由表这套"解耦机制"本身就成了负担——每次加能力都要改两份文件(清单 + 接口),收益覆盖不了维护成本。另一个反例是强一致性场景:两个模块必须在同一编译单元里保证类型与版本严格对齐(比如共享大量不可序列化的内存对象),这时硬用接口路由拆开,反而丢掉了编译期能拦住的错误。模块边界只适合"变化节奏不同、想各自演进"的组件,不适合"永远绑死的一对"。

二、运行时边界:同一份代码跑进两种运行时

模块边界管的是"代码之内谁认识谁",运行时边界管的是"同一份代码,被丢进两种运行环境时,怎么不崩"。

写 Android 库的人常碰到一个尴尬:你在本地跑得好好的功能,一旦被集成进别人的 App,就开始莫名崩溃。更麻烦的是,如果你这块能力既想独立发布成一个 App,又想作为插件被宿主加载,同一份代码就要面对两种完全不同的运行环境。

2.1 两种形态到底差在哪

先说插件形态。当代码被第三方插件框架加载时,运行时会发生几个"不打招呼"的变化:

  • 类加载器不是系统默认的,而是插件自己的,Class.forName 可能找不到宿主类;
  • 资源 ID 被重新分配,你 R.string.xxx 拿到的 ID 在宿主动态加载时可能失效;
  • Application 实例是你宿主 Application 的真实实例,但你的库代码拿到的不一定是它;
  • 需要"跳去用宿主里的某个能力"时,你无法直接 new,得通过插件框架提供的通道。

而独立运行(宿主形态)时,以上全都按 Android 默认规则来。同一份代码要同时兼容两套规则,这就是难点。最典型的崩溃现场是:库里某处直接 context.applicationContext as MyApplication 强转,宿主形态下没问题,插件形态下拿到的根本不是那个类,当场 ClassCastException。

2.2 根因:运行时是隐式的,代码必须显式感知

很多崩溃的根因是"代码假设了某种运行环境",而环境其实是隐式给定的。比如直接 context.applicationContext 就假设进程里只有一个 App;直接 context.createPackageContext 又假设包名对得上。

解法不是绕开这些 API,而是在代码最开头先问一句"我现在是什么形态",然后按形态选择正确路径:

object RuntimeEnv {
    // 插件框架是否在进程中生效
    fun inPlugin(): Boolean = ... // 探测插件运行时标志
    fun inHost(): Boolean = !inPlugin()

    // 取宿主 Application:插件形态从插件框架拿,宿主形态直接用系统
    fun hostApp(): Context = if (inPlugin()) pluginContext() else systemApp()
}

关键就一句话:把"我是谁、我在哪"提前显式化,后面的所有分支都基于这个判断,而不是到处猜。隐式环境 + 显式假设 = 必然偶发崩溃;把环境变成显式输入,崩溃就从"偶发"变成"可控"。

2.3 把环境差异收敛到一层

更进一步的封装,是把"需要感知环境的操作"全部收敛到一个门面里。以"获取宿主上下文"为例:

object EnvFacade {
    fun appContext(): Context =
        if (RuntimeEnv.inPlugin()) {
            // 插件框架提供的真实宿主 Context
            PluginBridge.hostContext()
        } else {
            // 独立运行时,直接返回应用上下文
            ApplicationProvider.getApplicationContext()
        }

    fun loadClass(name: String): Class<*> =
        if (RuntimeEnv.inPlugin()) {
            PluginBridge.classLoader().loadClass(name)
        } else {
            Class.forName(name)
        }
}

这样业务代码永远只调 EnvFacade.appContext(),不关心底层是插件还是宿主。凡是"拿上下文、加载类、解析资源"这类高危操作,都被这一层罩住。跨进程要调宿主的能力时,也通过这层转一道,避免直接依赖宿主实现。

这套"门面 + 环境感知"的做法,本质上和"依赖倒置"一脉相承:把不稳定的一侧(运行环境)变成可替换的抽象,稳定的一侧(业务逻辑)只依赖抽象。注意 RuntimeEnv.inPlugin() 的结果应当缓存——插件形态在进程生命周期内不会变,每次都去探测标志位既没必要也容易因为探测逻辑复杂引入新 bug。

2.4 双形态思维不限于插件

"同一份代码、多种运行时"不止出现在插件场景:

  • 独立 SDK + 集成 Demo:同一个库既要能单独 debug,又要能被宿主集成;
  • 单机版与联机版:同一业务逻辑,数据源在本地与云端之间切换;
  • 灰度与正式:同一功能在不同配置下行为不同。

它们的共性是:不要在所有代码里散落 if (环境) 判断,而是定义一个"环境感知层",把变化收敛进一层,业务代码保持纯净。判断环境本身要尽早、要集中、要可注入——"可注入"这一点常被忽略,但它直接决定这套设计能不能测:

interface EnvDetector {
    fun inPlugin(): Boolean
}
// 测试时注入一个永远返回 false 的实现,业务分支就能在单元测试里覆盖
object RuntimeEnv {
    var detector: EnvDetector = RealDetector()
    fun inPlugin(): Boolean = detector.inPlugin()
}

把 RuntimeEnv 的探测逻辑抽成可注入的 EnvDetector,测试时换一个 mock,双形态代码就能在本地 JVM 里跑单测,而不是非得打两个包才能验证。这是"环境感知层"比"到处写判断"多出来的隐藏收益。

2.5 插件态的资源解析与 id 冲突

门面把"拿上下文"收敛了,但"解析资源"还有一层更隐蔽的坑:宿主和插件各自编译,资源 ID 是两套独立编号空间。你在插件里写 R.layout.dialog_xxx,编译期是个数字,运行时宿主重新分配 ID 后,这个数字可能指向宿主里一个完全不同的布局,甚至根本不存在,于是 Resources.NotFoundException。

正确的做法是所有资源访问都走插件自己的 Resources 对象,而不是宿主的:

object EnvFacade {
    fun getLayoutResId(context: Context, pluginResName: String): Int {
        return if (RuntimeEnv.inPlugin()) {
            // 用插件自己的 packageName + Resources 去查,避免撞上宿主 ID
            val res = PluginBridge.pluginResources()
            res.getIdentifier(pluginResName, "layout", PluginBridge.pluginPackage())
        } else {
            context.resources.getIdentifier(pluginResName, "layout", context.packageName)
        }
    }
}

这里的关键不是"拿到 ID",而是"用一个确定的 Resources 来源"。插件态下必须用插件框架给的 Resources,宿主态下用系统 Resources,二者不能混。更进一步,如果插件和宿主有同名资源,靠 ID 永远分不清是谁的,必须靠 packageName 限定。这也是为什么双形态代码里,凡涉及资源的地方都不能图省事直接 R.xxx,而要统一收口到门面。

把宿主态与插件态在资源解析上的差异画成一张对照图:

flowchart LR
    A[&#34;代码里写 R.layout.xxx&#34;] --> B{&#34;inPlugin?&#34;}
    B -->|&#34;否 宿主态&#34;| C[&#34;系统 Resources 按宿主包名解析&#34;]
    B -->|&#34;是 插件态&#34;| D[&#34;插件 Resources 按插件包名解析&#34;]
    C --> E[&#34;ID 唯一 不会撞&#34;]
    D --> F[&#34;必须与宿主 ID 空间隔离 否则撞 ID&#34;]

2.6 插件态的失败路径:缺权限、缺资源

双形态方案最常见的线上故障不是"崩",而是"静默失效"。插件被宿主加载时,宿主可能没在它自己的 manifest 里声明插件需要的权限——比如插件要用录音,宿主没声明 RECORD_AUDIO,运行到 AudioRecord 才抛 SecurityException,而且只在那一台宿主上复现。另一类是资源缺失:宿主裁剪了某些 drawable 密度目录,插件引用了一个宿主没有的密度资源,低密度机型上直接 NotFoundException。这两类问题都没法在宿主态自测发现,只能在"真·插件环境"里跑。应对手段是把资源/权限访问也收口到门面,并在取用时做存在性校验(资源 getIdentifier 返回 0 即视为缺失,降级到内置兜底图),而不是裸调系统 API。

2.7 这条边界什么时候不该划

反例:如果你的库这辈子只会被集成进唯一一个宿主、且从来不独立发包,那"双形态"这层抽象就是纯开销——你永远走同一分支,却为每个 EnvFacade 方法多付一次 if 判断和一层间接。环境感知层的价值来自"运行时真的会变",不变就不该提前付这个复杂度。

三、进程边界:把鉴权关进独立的进程

运行时边界管的是"同一进程内的环境差异",进程边界再往外一步:把一段逻辑彻底搬出主进程,用 OS 的进程隔离当物理防火墙。

做 App 的人对"进程"往往又爱又怕:多进程能隔离崩溃、稳定长活,但 IPC 的序列化、Binder 回调、进程被杀重连,任何一个都够写好几篇踩坑记录。

3.1 为什么鉴权要单独占一个进程

这里拆的是一套把授权鉴权(判断产品号是否可用、模块是否免费开放)单独放进一个进程的实现。它用 AIDL 定义跨进程接口,主进程通过 Binder 代理调用远端服务。这套设计的价值不在于"跨进程"本身,而在于它用进程隔离给鉴权逻辑划了一条物理边界——业务再怎么演进,都碰不到这条校验线。

鉴权逻辑往往是 App 里最"金贵"的一段:它判断"当前产品序列号是否够格使用某高级功能""某模块是否已授权"。这类逻辑有几个特点:

  • 敏感,不希望被普通业务代码误改误调;
  • 要被多方复用,宿主、插件、多个功能都可能要查;
  • 可能长期存活,比如"保持软件存活"这类守护行为,不适合跟着 Activity 一起走。

把这些放主进程,业务一旦出问题(崩溃、回收)就可能把它一并拖死。放到独立进程 + 系统绑定服务里,就能得到 OS 层面的生命周期保证,且调用方天然只能通过定义好的接口访问,无法触碰内部实现。这条线划在"进程边界"上,代价最高,但隔离得最彻底——连内存都不共享,自然无从误改。

3.2 结构:一个 Binder 服务 + 死亡通知

跨进程的第一步是定义接口契约。用 AIDL 描述"这个远端服务能干什么":

// 接口契约:自启、设置自启信息、保持一次存活
interface ISystemGuard {
    fun autoStart()
    fun setAutoStartInfo(name: String, flag: Boolean)
    fun keepAliveOnce()
}

主进程通过绑定拿到代理,调用就像本地方法一样:

class GuardClient(private val context: Context) {
    private var service: ISystemGuard? = null
    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
            service = ISystemGuard.Stub.asInterface(binder)
            // 注册死亡通知:远端崩了立刻重连
            binder?.linkToDeath({ reconnect() }, 0)
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            service = null
            reconnect()
        }
    }
}

关键点在 linkToDeath:远端进程一旦死掉,本地立刻知道并自动重连,而不是等下次调用才发现接口已失效。这是长活服务必须处理的"死亡感知"问题。这里有个细节:onServiceDisconnected 在正常解绑时也会回调,但系统杀掉远端进程时往往走的是 linkToDeath 的回调路径,二者都要兜住重连,不能只处理一个。reconnect() 内部应当做去重——避免死亡通知和断开回调先后到达导致重复 bindService。

用时序图把"绑定—死亡通知—查询—重连"这条链路画清楚:

sequenceDiagram
    participant C as 主进程 GuardClient
    participant B as Binder 驱动
    participant S as 独立进程 ISystemGuard
    C->>B: bindService 绑定服务
    B->>S: 启动远端服务
    S-->>C: onServiceConnected 返回代理
    C->>C: linkToDeath 注册死亡通知
    C->>S: autoStart / keepAliveOnce 调用
    S-->>C: 执行结果
    Note over C,S: 远端崩溃时 linkToDeath 触发 reconnect
    S-->>C: 进程死亡 死亡通知回调
    C->>B: reconnect 重新 bindService

3.3 鉴权判定:服务端算、客户端问

真正干活的校验逻辑,其实还在"拥有授权数据的一端"——也就是常说的 VIP 鉴权中心。它对外暴露几个判定接口:

interface LicenseChecker {
    fun isProductAvailable(productNum: String): Boolean
    fun isModuleFree(module: String): Boolean
}

客户端拿到的不是布尔值那么简单,往往还要带上有效期 / 剩余次数这类信息,所以返回值会封装成一个状态对象:

data class LicenseResult(val granted: Boolean, val expireAt: Long, val reason: String)

到这里,"授权数据放哪、谁来算、怎么防篡改"都在服务端进程内完成,客户端只做"问一下、拿结果"。这样即使宿主通过动态化改造了界面逻辑,也无法绕开这条鉴权线——这正是"隔离"二字最实在的价值。把判定留在服务端,还顺带解决了"客户端伪造授权"的问题:因为校验从不发生在可信度低的客户端进程里。

3.4 什么时候值得上独立进程

别把"多进程"当银弹。它引入的序列化开销、AIDL 样板代码、以及"回调跨进程"的复杂度都是真实成本。具体的判据:

  • 逻辑敏感且需要长期稳定存活(守护、鉴权、后台任务);
  • 需要被多方复用,且不希望被调用方误触内部;
  • 业务复杂到"和主进程一起挂"的代价不可接受。

反过来说,如果只是主进程内部一段普通工具逻辑,硬拆进程只会徒增 IPC 负担。另一个常被忽视的坑:Binder 调用有事务缓冲区大小上限(约 1MB,且是所有并发调用共享),一次跨进程传超大对象(比如整张位图、超长日志列表)会直接抛 TransactionTooLargeException。所以跨进程接口的参数和返回值要刻意保持"小"——只传判定所需的 key 和结果状态,绝不把大对象塞进 IPC。linkToDeath 的第二个参数是 flags,传 0 即可,别误传成别的值导致死亡通知不生效。

决策顺序必须先问"值不值得隔离",再问"怎么隔离",顺序不能反。

3.5 死亡通知与重连去重的完整写法

前面用 linkToDeath({ reconnect() }, 0) 一笔带过,生产里它要更严谨。DeathRecipient 是一个独立接口,比起匿名 lambda 更利于复用和测试;重连逻辑必须去重,否则死亡通知回调与 onServiceDisconnected 先后到达,会触发两次 bindService,第二次往往直接抛 IllegalArgumentException。

class GuardClient(private val context: Context) {
    private var service: ISystemGuard? = null
    private var connecting = false
    private val deathRecipient = DeathRecipient { reconnect() }

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
            connecting = false
            binder?.linkToDeath(deathRecipient, 0)
            service = ISystemGuard.Stub.asInterface(binder)
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            service = null
            reconnect()
        }
    }

    @Synchronized
    fun reconnect() {
        if (connecting) return          // 去重:避免重复 bind
        connecting = true
        context.bindService(guardIntent(), connection, Context.BIND_AUTO_CREATE)
    }
}

connecting 标志 + @Synchronized 把"重复绑定"挡在门外。还有一点:如果远端正常解绑,linkToDeath 注册后要先 unlinkToDeath 再清引用,否则死亡回调可能在你已经不关心的时候又烧一次。DeathRecipient 的另一个好处是它能脱离 ServiceConnection 单独持有,便于在单元测试里直接验证"收到死亡信号后确实触发了重连"。

3.6 跨进程调用的失败路径:超时、崩溃与进程回收

linkToDeath 只解决"远端死了我知道",解决不了三种更刁钻的情况。第一是调用超时:AIDL 调用默认是同步阻塞的,如果远端卡死在某次耗时计算上,主进程调用线程会被一直挂住,甚至触发 ANR。耗时调用要包一层"带超时的异步"——用协程 withTimeout 或自行起线程 + Future.get(timeout),超时即视为失败并走降级,绝不让主线程干等。第二是服务端崩溃在调用途中:binder 调用会抛 RemoteException,客户端必须把它当普通异常捕获,而不是让它冒泡到 UI 线程。第三是进程被系统回收后重新拉起:独立进程跟所有后台进程一样会被 LMK 杀掉,杀掉后下一次 bindService 会重新拉起远端——前提是你在 Service 里没把初始化做成"只跑一次就放弃",重连时要能重新建好鉴权状态,而不是依赖一份已经随进程消亡的内存数据。

3.7 这条边界什么时候不该划

反例:把纯界面逻辑或纯本地计算塞进独立进程,是典型的过度隔离。这类逻辑不敏感、不常驻、失败影响面只限当前页面,拆出去换来的只有序列化开销和重连复杂度,没有任何隔离收益。进程边界只该用在"敏感、需长活、被多方复用、挂了不能连累主进程"的组合场景,四者缺其一都要重新掂量。

四、模式边界:MVVM 与 MVP 共存

前面三道边界都在"隔离外部变化",模式边界有点特殊——它隔离的是"团队内部的范式之争"。它和前四者正交:无论模块怎么切、进程怎么隔,展示层里依然可能同时跑着两套模式。

很多团队把"统一架构"奉为铁律:要么全部 MVVM,要么全部 MVP,谁引入第二种谁就挨批。理由听起来很充分——降低理解成本、保证一致性。可现实里,架构选型跟"问题域"强相关:有的页面天然适合数据驱动(状态多、响应式),有的页面本质是一次性操作(点一下、回个结果),硬套一种模式反而别扭。

4.1 两种模式各自的舒适区

先说MVP(Model-View-Presenter)。它的核心是把"逻辑"从"视图"里抽到 Presenter,视图只做展示和转交事件,逻辑结果通过接口回调回传:

interface TransformView {
    fun onResult(r: Result)
}

class TransformPresenter(private val view: TransformView) {
    fun compute(x: Double, y: Double) {
        val result = Transformer.transform(x, y)   // 同步算法
        view.onResult(result)                      // 直接回传
    }
}

它适合"一次性、命令式"的场景:点个按钮、算个坐标、返回结果。状态少,用接口回传最直白,代码量最小。

再说MVVM(Model-View-ViewModel)。它的核心是"数据驱动":ViewModel 持有一堆可观察状态,视图订阅这些状态自动刷新,逻辑与视图通过"观察"而不是"调用"连接:

class DeviceViewModel : ViewModel() {
    private val _state = MutableStateFlow(DeviceUiState())
    val state: StateFlow<DeviceUiState> = _state.asStateFlow()

    fun refresh() {
        viewModelScope.launch {
            _state.value = repository.load().let { DeviceUiState(it) }
        }
    }
}

它适合"状态多、响应式、生命周期长"的场景:设备列表、设置面板、实时状态,ViewModel 能扛住配置变更(旋转、切后台),状态不丢。注意 StateFlow 配合 repeatOnLifecycle 能自动在视图不可见时停止收集,这是 MVP 里要手写大量样板才能勉强达到的效果——MVVM 在这里省的不是一行两行,而是一整类生命周期bug。

4.2 为什么不能一刀切

看上面两个例子就明白:选型的本质是"视图和逻辑之间是同步问答还是持续观察"。

  • 坐标换算这种,逻辑是同步的、一次性的,用 MVP 的接口回传最省事,代码量最小;
  • 设备状态这种,状态是持续变化的、要响应式刷新、还要在配置变更后保留,用 MVVM 的状态流最合适。

硬把坐标换算塞进 MVVM,你得包一层"假装的状态流",绕;硬把设备列表塞进 MVP,你得自己处理生命周期和状态保留,累。架构是工具,不是信仰——按问题域选,比"全工程统一"更能写出可维护的代码。

4.3 并存的关键:守住边界

两套模式并存之所以不混乱,靠的不是运气,而是清晰的边界约定:

  1. 模式选型在页面(Feature)粒度决定,不跨层混用——一个 Feature 内部只用一种模式;
  2. 底层数据访问统一,不管哪个页面,都走同一套仓储接口,模式差异只体现在"展示层";
  3. 命名与目录能一眼看出模式,看到某目录结构就明白这是 MVP 还是 MVVM,不用猜。

这样"模式"被限制在很薄的展示层,底层共享同一套 Model 和 Repository,切换成本反而低。这也是"新旧并存而不重构"能长期成立的前提:不是不管,而是用约定把差异钉死在展示层。只要底层仓储是同一套,展示层换模式就像换皮肤,不会影响数据正确性。

4.4 选型决策链

与其纠结"统一架构",不如建立一条决策链:

  1. 先问问题域:这个功能是"一次性问答"还是"持续状态"?
  2. 再看生命周期:是否需要跨配置变更保留状态?需要 → MVVM;
  3. 再看复杂度:逻辑是否简单到用一个回调函数就能说清?是 → MVP 更省;
  4. 最后看团队:同一模块多人维护,优先选团队最熟的模式。

记住:架构的一致性在于"决策有依据",而不是"模式唯一"。一个团队能说清楚"为什么这里用 MVVM、那里用 MVP",比机械地全工程统一更有价值。真正的重构,应该发生在"某一层已经撑不住它要表达的问题"时,而不是"我看它不顺眼"时。

把两条模式的取舍落进一张对比表,选型时照着打钩即可:

维度MVPMVVM
交互本质一次性命令式问答持续观察响应式
状态数量少多
生命周期不需跨配置变更保留需跨旋屏 / 切后台保留
代码量最小(接口回传)略多(状态流 + 订阅)
典型场景坐标换算、单次计算设备列表、设置面板、实时状态

4.5 两种模式各自的持有与释放

模式并存最容易被忽视的是生命周期收尾:Presenter 和 ViewModel 怎么来、怎么走,写错就是内存泄漏或空指针。

MVP 的 Presenter 由视图自己 new,视图销毁时必须主动解绑,否则 Presenter 还攥着 Activity 引用,屏幕一转就泄漏:

class TransformFragment : Fragment(), TransformView {
    private var presenter: TransformPresenter? = null

    override fun onViewCreated(view: View, saved: Bundle?) {
        presenter = TransformPresenter(this)
    }

    override fun onDestroyView() {
        presenter?.detach()      // 切断对 view 的引用
        presenter = null
        super.onDestroyView()
    }
}

MVVM 的 ViewModel 由系统通过 ViewModelProvider 托管,配置变更后自动保留、真正销毁时由框架调用 onCleared(),你不用手动 null 它;但它在 viewModelScope 里发起的协程,要配合 repeatOnLifecycle 在视图不可见时停止收集,否则后台还在往一个已经 detached 的 UI 灌数据:

class DeviceFragment : Fragment() {
    private val vm by viewModels<DeviceViewModel>()

    override fun onViewCreated(view: View, saved: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                vm.state.collect { render(it) }
            }
        }
    }
}

二者的释放哲学不同:MVP 是"视图负责把 Presenter 放走",靠人记着 detach;MVVM 是"框架负责把 ViewModel 收走",人只管在 onCleared 里清资源。并存时这条差异必须写进团队约定,否则容易把 MVP 当成 MVVM 来用——以为框架会帮你释放,结果忘了 detach,泄漏照旧。

五、厂商边界:门面、可替换接口与错误归一化

最后一道边界在最外层:当能力的"另一头"是第三方厂商 SDK 时,你控制不了它的接口名、参数、回调,更控制不了它的错误码。这道边界要用"门面 + 可替换接口 + 错误适配器"三件套,把厂商差异关进笼子。

做语音助手最头疼的不是"实现",而是"可替换"。今天用甲厂商的识别,明天领导说换个乙厂商——如果业务代码到处 if (是甲厂商) 特判,这次替换就是一场灾难。

5.1 问题长什么样:一个助手要应付多少种"方言"

语音助手对外提供的动作其实很固定:

按住说话 / 松开结束 / 手动唤醒 / 手动休眠 / 开始识别 / 文字转语音 / 停止播报

但同一个动作,不同厂商的 SDK 叫法、参数、回调都不一样:

动作甲厂商 SDK乙厂商 SDK
按住说话start()beginListening()
结束说话stop()endListening()
唤醒回调onWakeup()onHotwordDetected()

更糟的是错误码。甲厂商用 10120 表示网络问题,乙厂商可能用 -30001;甲厂商的 20001 是网络,乙厂商的 20001 可能是别的意思。如果业务层直接比较这些魔法数字,代码就会写成一堆 when (厂商) 特判,换厂商就崩。

5.2 根因:业务层和厂商 SDK 深度耦合

问题的根源是没有中间层。业务代码直接调用厂商 SDK、直接处理厂商的错误码,等于把"我要什么"和"厂商怎么给"焊死在一起。

  • 业务层想的是"识别失败,给用户提示网络错误",但手里拿的是厂商的原始错误码,得自己翻译;
  • 换厂商时,接口名、回调名、错误码全变,业务层被改得面目全非。

解耦的本质是:业务层只依赖"抽象",不依赖"具体厂商"。 一旦业务层直接 import 了厂商 SDK 的类,这条边界就已经破了——后续所有"换厂商"都会变成"改业务"。

5.3 解法一:统一门面

门面是一个单例,把"初始化、说话、唤醒、理解、播报"等所有能力统一收敛成一套对外方法。业务层完全不接触任何厂商 SDK,只调门面:

object SpeechAssistant {

    // 注入具体实现(哪个厂商,由外层决定)
    fun init(appContext: Context, impl: ISpeechAssistant, config: Map<String, Any>? = null) {
        this.appContext = appContext
        realImpl = impl
        realImpl.init(appContext, config)
    }

    fun startSpeaking(): Boolean = realImpl.startSpeaking()
    fun stopSpeaking() = realImpl.stopSpeaking()
    fun manualAwake(): Boolean = realImpl.manualAwake()
    fun manualSleep() = realImpl.manualSleep()

    fun startSpeechText(text: String, onFinish: (() -> Unit)? = null) {
        realImpl.startSpeechText(text, onFinish)
    }
}

门面本身不实现任何厂商逻辑,只是"转手"——把业务层的调用转发给当前注入的实现。换厂商 = 换注入的实现对象,业务层一行不改。 门面用的是 object 单例,意味着全局只有一个助手实例;这同时带来了 5.6 节要讲的重复初始化风险。

5.4 解法二:可替换接口

门面背后,是三个可独立替换的接口维度。每个厂商只要分别实现这三个接口,就能接入:

  • 语音转文字:负责"按住说话、唤醒、识别出文字、音量回调";
  • 意图理解:负责"把文字解析成标准指令";
  • 文字转语音:负责"播报、停止播报、是否正在播报"。

三者合起来就是完整助手能力:

// 三个能力维度合并成一个"助手能力"接口
interface ISpeechAssistant : ISpeechToText, INlu, ITextToSpeech

这样设计的好处是:意图理解这一环尤其容易换——本地规则理解、第三方 NLU、大模型理解,它们实现的都是同一个 INlu 接口,可以像换插件一样替换,而语音识别、播报完全不动。把能力按"变化频率"拆成三个维度,是这道边界能长期不破的关键:识别引擎、理解引擎、播报引擎三者的迭代节奏根本不同,拆开后各自换,互不牵连。

5.5 解法三:错误归一化

这是最容易被忽略、却价值最高的一环。做法是引入错误适配器:每个厂商写一个适配器,把自家的一堆原始错误码,翻译成业务层能识别的统一错误对象。

统一错误对象用"语义码 + 统一文案"表达,并保留原始错误码便于查文档:

class UnifiedError(
    val code: Int,          // 统一语义码,业务层只认这个
    val message: String     // 统一文案,可直接展示给用户
) {
    var originCode: Int? = null     // 厂商原始码,debug 用
    var originMessage: String? = null // 厂商原始信息
}

// 预定义好的统一错误:网络、初始化失败、未初始化、认证失败、录音失败...
val NETWORK_ERROR = UnifiedError(103, "网络连接失败,请检查网络")
val NOT_INIT_ERROR = UnifiedError(102, "语音助手未初始化")

厂商适配器只需做一次 when 映射,把原始码归一到统一码:

// 某厂商的适配器:把它的原始错误码翻译成统一错误
object VendorAErrorAdapter : ErrorAdapter() {
    override fun adapt(originCode: Int?, originMessage: String?): UnifiedError {
        val error = when (originCode) {
            10120, 10114, 20001, 20002, 20003 -> NETWORK_ERROR   // 网络相关一堆码
            11201, 11202 -> OVER_USER_ERROR                        // 用量超限
            20006        -> RECORD_ERROR                           // 录音失败
            22001        -> NOT_INIT_ERROR                         // 未初始化
            10407        -> VERIFY_ERROR                           // 认证失败
            else         -> UNKNOWN_ERROR                          // 兜底
        }
        error.originCode = originCode
        error.originMessage = originMessage
        return error
    }
}

业务层拿到的一定是 UnifiedError,只按统一码判断,永远不碰厂商的魔法数字。换厂商,只需换适配器,业务层的错误处理逻辑一字不动。 这里 else -> UNKNOWN_ERROR 的兜底分支不可省——厂商随时可能新增错误码,没有兜底就会让未知错误变成 null 判断漏洞。统一码用固定常量而非魔法数字,也避免了业务层自己再定义一套语义码造成二次分裂。

把门面、可替换接口与错误适配器的关系画成类图:

classDiagram
    class ISpeechAssistant {
        <<interface>>
    }
    class ISpeechToText {
        <<interface>>
    }
    class INlu {
        <<interface>>
    }
    class ITextToSpeech {
        <<interface>>
    }
    ISpeechAssistant ..|> ISpeechToText
    ISpeechAssistant ..|> INlu
    ISpeechAssistant ..|> ITextToSpeech
    class SpeechAssistant {
        +init(impl)
        +startSpeaking()
        +manualAwake()
    }
    SpeechAssistant --> ISpeechAssistant : realImpl
    class VendorAImpl {
        +init()
        +startSpeaking()
    }
    VendorAImpl ..|> ISpeechAssistant
    class ErrorAdapter {
        <<abstract>>
        +adapt() UnifiedError
    }
    class VendorAErrorAdapter {
        +adapt()
    }
    ErrorAdapter <|-- VendorAErrorAdapter
    class UnifiedError {
        +code: Int
        +message: String
        +originCode: Int
    }

5.6 门面内部:初始化防重复坑

门面背后往往还藏着一个初始化细节:同一实现对象可能被 init 多次(比如页面重建、配置变化)。如果每次都新建底层管理器而不释放旧的,会泄漏资源(录音、网络连接、观察者)。所以实现层在 init 时要先判重、先销毁旧实例再建新的:

override fun init(appContext: Context, config: Map<String, Any>?) {
    if (::manager.isInitialized) {  // 已经初始化过?
        manager.destroy()           // 先释放旧资源
    }
    manager = Manager(appContext, config) // 再建新的
}

这个"重复初始化要先释放"的细节,能避免很多线上才暴露的资源泄漏。::manager.isInitialized 是 Kotlin 对 lateinit / 属性的内建判断,只对 lateinit var 或已声明未赋值的可空属性安全——用在这里正好能区分"首次"和"重建"。如果 init 是多线程可能并发调用的,还得加锁或加 volatile 标志,否则两个线程同时通过判重、各自建了管理器,前一个就泄漏了。

5.7 面向抽象编程的通用推广

这套三层架构的本质,是软件工程里最经典的一条原则:依赖倒置——面向抽象(接口)编程,而不是面向具体(厂商)编程。它可以推广到任何"底层实现可能更换"的系统:

  • 地图服务:高德 / 腾讯 / 百度,业务只依赖"地图门面";
  • 支付通道:支付宝 / 微信 / 银行卡,业务只依赖"支付门面";
  • 推送服务:极光 / 个推 / 厂商通道,业务只依赖"推送门面"。

每一个的套路都一样:定义统一接口 → 门面收敛能力 → 各实现方注入 → 错误码归一化。判断一个可插拔架构好不好,就看一个灵魂拷问:"把甲厂商换成乙厂商,业务代码要改几行?" 答案应该是"0 行(只改装配那一处)"。如果答案是"十几行散布在各页面",说明门面或错误归一化没做到位,边界线还是漏的。

把五道边界里最容易踩的坑,收进一张"现象 / 根因 / 解法"对照表,方便上线前逐条过一遍:

现象根因解法
改一个组件,全工程重编组件参与同一编译单元、编译期绑定清单文件数据化装配
插件态资源 ID / ClassLoader 失效代码假设单一运行环境环境感知层 + 门面收敛
主进程崩,鉴权也跟着死敏感逻辑放主进程无隔离独立进程 + Binder 死亡通知
坐标换算硬套 MVVM,包一层假状态流选型不看问题域,强制统一按问答 / 观察决策,展示层守界
换语音厂商,业务层一堆 when 厂商业务直接耦合厂商 SDK 与错误码门面 + 接口 + 错误适配器
门面被重复 init,录音 / 连接泄漏重建时未释放旧实例init 先判重并 destroy 旧实例

5.8 错误映射表与"不可归类"兜底

前面 VendorAErrorAdapter 用 when 硬写映射,厂商码一多、一变,分支就爆炸。更可维护的写法是用一张"原始码 → 统一错误"的映射表驱动,新增码只改表不改逻辑:

abstract class ErrorAdapter {
    private val map = mutableMapOf<Int, UnifiedError>()

    protected fun map(code: Int, to: UnifiedError) { map[code] = to }

    fun adapt(originCode: Int?, originMessage: String?): UnifiedError {
        val hit = originCode?.let { map[it] }
        val error = hit ?: UNKNOWN_ERROR     // 不可归类:一律兜底,绝不返回 null
        error.originCode = originCode
        error.originMessage = originMessage
        return error
    }
}

object VendorAErrorAdapter : ErrorAdapter() {
    init {
        map(10120, NETWORK_ERROR); map(10114, NETWORK_ERROR)
        map(20001, NETWORK_ERROR); map(11201, OVER_USER_ERROR)
        map(20006, RECORD_ERROR); map(22001, NOT_INIT_ERROR)
        map(10407, VERIFY_ERROR)
    }
}

"不可归类"兜底分支是这张表的安全网:厂商某天加了个新错误码,没在表里,就归到 UNKNOWN_ERROR,业务层统一按"未知错误,请重试或联系客服"处理。它和前面 else -> UNKNOWN_ERROR 的语义一致,只是从"写死分支"升级成"查表 + 默认",扩展性更好。关键纪律是:适配器的出口永远是非空 UnifiedError,任何源头都不准让 adapt 返回 null——null 一旦流进业务层,就变成了另一种"厂商方言",边界又漏了。

5.9 厂商 SDK 回调跑错线程

最后一类隐蔽故障:厂商 SDK 的回调常常不在主线程。识别结果、音量变化、唤醒事件,很多 SDK 在它自己内部的线程池里吐回调,你若在回调里直接 setText、弹 Dialog,轻则界面不刷新,重则 CalledFromWrongThreadException 崩在主线程之外、日志还难查。门面在转发回调时必须做一次线程归一化:

object SpeechAssistant {
    private val mainHandler = Handler(Looper.getMainLooper())

    fun startSpeechText(text: String, onFinish: (() -> Unit)? = null) {
        realImpl.startSpeechText(text) {
            // 不论厂商在哪个线程回调,统一抛回主线程
            mainHandler.post { onFinish?.invoke() }
        }
    }
}

这层"线程收口"和前面的"环境收口""错误收口"是同一套思路:厂商给的东西不可信,门面要替业务层把所有不确定项(接口名、错误码、回调线程)都先消化掉。否则业务层每接一个厂商回调都得自己想"这次它在哪根线程",边界就又从门面漏到了业务代码里。

六、五条边界怎么选:一张决策清单

五条边界讲完,落到"我该不该划这条线、划在哪"上,其实可以收口成同一张判断表。无论哪条边界,判定都围绕四个维度:变化频率(谁动得更勤)、生命周期(谁该活得更久)、外部依赖(谁是我控制不了的)、失败影响面(谁挂了不能连累谁)。把五条线放进这张表,选型就有了统一入口:

边界变化频率维度生命周期维度外部依赖维度失败影响面维度不该划的反例
模块边界各组件独立演进、版本节奏不同随宿主启动一次性装配无强外部依赖单模块故障不拖全工程模块仅三五个且永远一起改
运行时边界运行时形态会切换进程内贯穿全程宿主 / 插件环境不可控环境误判导致整页崩溃只集成唯一宿主、从不单发
进程边界逻辑本身稳定少改需长期常驻、跨页面不依赖主进程 UI挂了不能拖死主进程纯 UI / 纯本地计算逻辑
模式边界页面问题域各不相同状态是否跨配置保留无外部依赖错选模式累积样板债全工程同一类问题域
厂商边界底层厂商频繁更换门面常驻、实现可换厂商 SDK 完全不可控换厂商不波及业务层底层实现永不变更

用法很简单:先从左四列看"这条线有没有必要划"——四列里只要有两列以上回答"是"且变化真实存在,这条边界就值得划;再看最后一列"反例",如果当前场景正好踩中反例,就先别划,等变化真的出现再加。四维之间没有优先级,但失败影响面往往是一票否决项:只要"挂了会连累一大片"成立,哪怕其他维度不明显,也该优先隔离。

把"要不要划"的决策也画成一张流程图,照着走一遍就能定位:

flowchart TD
    Q1{&#34;失败影响面大吗 挂了连累一片&#34;} -->|&#34;是&#34;| Y[&#34;优先划边界&#34;]
    Q1 -->|&#34;否&#34;| Q2{&#34;外部依赖不可控吗&#34;}
    Q2 -->|&#34;是&#34;| Y
    Q2 -->|&#34;否&#34;| Q3{&#34;变化频率高吗 两边节奏不同&#34;}
    Q3 -->|&#34;是&#34;| Y
    Q3 -->|&#34;否&#34;| Q4{&#34;生命周期差异大吗&#34;}
    Q4 -->|&#34;是&#34;| Y
    Q4 -->|&#34;否&#34;| N[&#34;暂不划 等变化出现再加&#34;]

七、小结

  • 五道边界从"代码内"到"进程外"依次是:模块边界(路由表)、运行时边界(环境门面)、进程边界(AIDL 隔离)、模式边界(展示层并存)、厂商边界(门面 + 归一化),前四者层层外推,模式边界与之正交。
  • 架构演进不是把代码重写得更漂亮,而是在正确的位置划一条线,让线两边的变化互不传染。
  • 划线的判据只有四条:变化频率(谁动得更勤)、生命周期(谁该活得更久)、外部依赖(谁是我控制不了的)、失败影响面(谁挂了不能连累谁)。
  • 模块边界适合"一套内核多产品",代价是失去编译期校验;进程边界适合"敏感且需长活",代价是 IPC 开销与重连复杂度;厂商边界适合"底层实现常换",代价是门面与适配器开发量。
  • 这五条线都不靠"重框架"取胜:清单文件、环境感知层、Binder 死亡通知、展示层约定、错误适配器,都是用最小机制守住边界。

你现在的工程里,哪条边界是靠"口头约定"守着、而不是靠"架构机制"守着的?那条线,是不是该往下挪一层了?