【车载 Android】从 AAR 到 Plugin:Launcher 卡片插件化解耦实践

0 阅读10分钟

随着智能座舱功能越来越丰富,多媒体、车设、天气等业务都开始以卡片形式集成到 Launcher 中,Launcher 也逐渐变成了各类座舱业务的聚合入口。早期卡片数量较少时,还可以由 Launcher 团队直接开发,简单场景则通过 Widget 实现。随着卡片越来越多,我们不得不将卡片交给各个业务 Owner 开发,再以 AAR 的方式集成到 Launcher。

但 AAR 本质上仍然是编译期集成,业务与 Launcher 之间依然存在较强的版本和发布耦合。当卡片规模进一步扩大以后,Launcher 的调试、维护都变得非常困难,一个更自然的问题就出现了:

能不能让业务卡片不再作为 Launcher 的一部分参与编译,而是像“插件”一样,由独立 APK 提供实现,在运行时被 Launcher 动态发现、加载和使用?

这也就引出了 Android 中一个很有意思的架构设计——Plugin(插件)机制。通过 Plugin,我们可以尝试把 Launcher 从“所有业务代码的承载者”,转变为“插件的宿主和调度者”:Launcher 只定义接口、生命周期和展示容器,具体的卡片能力则由不同业务插件分别实现,从而进一步降低模块之间的耦合。


早期做互联网 Android 应用的朋友,对“插件化”这个概念应该并不陌生。不过这里要介绍的 Plugin,并不是常见的应用层插件化框架,而是 Android 原生系统中已经存在的一套插件机制。例如博主早年介绍过的 车载 Android 应用开发与分析 - 初试 SystemUI Plugin,就是其中比较典型的应用。

本文将尝试移植 SystemUI Plugin Framework,并在自定义 Launcher 中实现对第三方 Widget 插件 APK 的动态加载。

这次移植主要有三个目标:

  • 验证 宿主 APK 动态加载插件 APK 在车机环境中的可行性;
  • 验证 Launcher 卡片由独立 APK 提供并动态加载 的架构方案;
  • SystemUI Plugin Framework 从 SystemUI 工程中剥离,封装为可独立维护和复用的 SDK。

一、整体架构与效果演示

工程一共包含四个 Gradle Module:

image.png

Module类型作用
plugin-apiLibraryPlugin 接口、Listener、生命周期接口和版本注解
hostApplication插件宿主,负责发现、加载和生命周期管理
media-pluginApplication多媒体卡片插件
weather-pluginApplication天气卡片插件

1.1 plugin-api:宿主和插件之间的 Sdk

plugin-api 不包含任何具体实现,只定义双方都需要遵守的接口。

主要包括:

接口 / 注解作用
Plugin所有插件的基础接口
PluginListener插件生命周期回调
PluginLifecycleManager控制单个插件实例的加载与卸载
WidgetViewPluginLauncher 卡片插件接口
LauncherOverlayPluginLauncher Overlay 插件接口
@Requires声明依赖接口版本
@ProvidesInterface声明 Plugin API 版本

对于 Launcher 来说,它并不需要知道“天气卡片”或者“音乐卡片”具体是怎么实现的,只需要认识:WidgetViewPlugin,然后调用类似:

View createView(Context pluginContext);

即可获取插件提供的 View。这也是整个 Plugin 架构最核心的思想:

Host 依赖接口,而不是依赖具体业务实现。


二、插件是怎么被发现的

AOSP 的设计非常有意思。插件实现类会在 Manifest 中声明成一个 <service>

<service
    android:name=".widget.MediaWidgetViewPlugin"
    android:exported="false">

    <intent-filter>
        <action android:name="com.android.systemui.action.PLUGIN_WIDGET_VIEW" />
    </intent-filter>

</service>

但这个 Service 不会真正被启动。它只是借用了 PackageManager 已有的 Intent 查询机制,把 Service 当成一个“插件注册表”。

AOSP 源码中的注释说得非常直接:

This isn't actually a service and shouldn't ever be started, but is a convenient PM based way to manage our plugins.

Host 只需要:

Intent intent = new Intent(action);
List<ResolveInfo> result =
        packageManager.queryIntentServices(intent, 0);

即可找到所有声明了对应 Action 的插件组件。

整个过程可以概括成:

image.png

这里需要注意:PackageManager 查询负责“发现候选插件”,真正是否允许加载,则由 PluginActionManager 后续继续校验。

例如 AOSP 会进一步检查:

mPm.checkPermission(PLUGIN_PERMISSION, packageName)

没有 Plugin 权限的 APK,即使被查询出来,也不会进入后续加载流程。


三、插件是怎么被加载的

SystemUI 并不会直接使用宿主 ClassLoader 去加载插件,而是为插件创建独立的:PathClassLoader

核心结构大致如下:

image.png

3.1 ClassLoader 隔离

核心代码来自 PluginInstance

List<String> zipPaths = new ArrayList<>();
List<String> libPaths = new ArrayList<>();

LoadedApk.makePaths(
        null,
        true,
        appInfo,
        zipPaths,
        libPaths);

ClassLoader classLoader = new PathClassLoader(
        TextUtils.join(File.pathSeparator, zipPaths),
        TextUtils.join(File.pathSeparator, libPaths),
        getParentClassLoader(baseClassLoader));

其中:

LoadedApk.makePaths(...)

负责获得插件 APK、Native Library 等加载路径。然后创建:PathClassLoader 加载插件代码。

3.2 ClassLoaderFilter

插件的 Parent ClassLoader 并不是直接暴露整个 Host,而是包了一层:ClassLoaderFilter 。在我的移植版本中,只开放了几个必要的包:

androidx.constraintlayout.widget
com.android.systemui.common
com.android.systemui.log
com.android.systemui.plugin

其目的不是做真正意义上的“安全沙箱”,而是限制插件能够直接访问的 Host 实现类。

更准确地说,它提供的是:

ClassLoader 层面的类可见性隔离。

插件仍然运行在 Host 进程,因此不能把它理解为进程级安全隔离。


3.3 反射创建插件

找到插件类以后,通过插件 ClassLoader 进行反射:

ClassLoader loader = mClassLoaderFactory.get();

Class<T> instanceClass = (Class<T>) Class.forName(
        mComponentName.getClassName(),
        true,
        loader);

T result = (T) mInstanceFactory.create(instanceClass);

至此:

Plugin APK
    ↓
PathClassLoader
    ↓
Class.forName()
    ↓
Plugin 实例

插件代码正式进入 Host 进程执行。


3.4 加载资源

仅仅加载 Java 类还不够,插件通常还需要访问自己的:

layout
drawable
string
color
style

因此 Host 还需要为插件创建独立的 Application Context:

Context context =
        mContext.createApplicationContext(appInfo, 0);

随后再包一层:

PluginContextWrapper

关键点是重写:

@Override
public ClassLoader getClassLoader() {
    return mClassLoader;
}

同时对 LayoutInflater 做:

cloneInContext(this)

这样插件在:

LayoutInflater.inflate(...)

自己的 XML 时,遇到自定义 View 也能够通过插件自己的 ClassLoader 找到对应类。

最终:

PluginContext
├── Resources → Plugin APK
└── ClassLoader → Plugin PathClassLoader

插件的代码和资源才真正连在一起。


四、插件的生命周期

插件并不是简单地:

load → 永久存在

而是拥有完整生命周期:

onPluginAttached
        ↓
onPluginLoaded
        ↓
onPluginUnloaded
        ↓
onPluginDetached

对应关系大致如下:

image.png

其中:

4.1 onPluginAttached

Host 已经发现 Plugin,但插件实例不一定马上加载。

Listener 可以返回:

false

实现延迟加载。

4.2 onPluginLoaded

插件已经完成:

ClassLoader
→ Instance
→ PluginContext
→ Version Check

此时 Host 可以正式使用插件。

4.3 onPluginUnloaded

插件实例被释放。

这里应该:

  • Remove View
  • Cancel Animator
  • Remove Handler Callback
  • 释放 Listener

4.4 onPluginDetached

PluginInstance 生命周期彻底结束。


五、插件 APK 更新后发生什么

PluginManagerImpl 会监听:

PACKAGE_ADDED
PACKAGE_CHANGED
PACKAGE_REPLACED
PACKAGE_REMOVED
USER_UNLOCKED

例如插件执行:

adb install -r plugin.apk

系统发送:

PACKAGE_REPLACED

Host 收到以后会:

PACKAGE_REPLACED
      ↓
clear ClassLoader
      ↓
reloadPackage()
      ↓
removePkg()
      ↓
queryPkg()
      ↓
创建新的 PluginInstance
      ↓
加载新插件

对应时序:

image.png

理论上,这条链路可以实现:

插件 APK 更新,而 Host APK 不重新安装。

如果框架实现完整,Host 本身也不应该依赖 force-stop 才能看到新代码。

如果实际调试中仍然必须:

adb shell am force-stop com.android.launcher3

才能更新,则通常说明还有:

  • 旧 ClassLoader 没有清理;
  • Plugin View 仍被持有;
  • PluginInstance 生命周期没有完整退出;
  • 静态对象持有旧插件;
  • Context / Listener / Animator 存在引用;

等问题。

因此 force-stop 更适合作为调试兜底手段,而不应该成为正式 Plugin 热更新机制的一部分。


六、安全与稳定性

Plugin 最大的优势是灵活,但最大的风险也来自这里:

插件代码最终运行在 Host 进程里。

如果 Host 是:

android.uid.system

那么插件代码进入 Host 进程以后,实际上也是在这个进程的权限边界内执行。

因此这套机制不适合加载真正意义上的“不可信第三方 APK”。

它更适合:

OEM 内部业务插件
系统预装插件
平台签名插件
受控软件生态

AOSP 本身也做了多层保护。

6.1 Signature Permission

Host 定义:

<permission
    android:name="com.android.systemui.permission.PLUGIN"
    android:protectionLevel="signature" />

Plugin 申请:

<uses-permission
    android:name="com.android.systemui.permission.PLUGIN" />

然后 Host 在加载插件之前再次检查:

mPm.checkPermission(PLUGIN_PERMISSION,packageName)

因此流程其实是:

PackageManager 发现候选插件
        ↓
PLUGIN permission 校验
        ↓
Plugin Enabled 校验
        ↓
版本校验
        ↓
加载

在这个 PoC 中,Host 和 Plugin 使用同一套平台签名,从而限制能够接入 Plugin Framework 的 APK 范围。


6.2 Plugin API 版本校验

SystemUI Plugin 并不是简单判断:

versionCode

而是通过:

@ProvidesInterface
@Requires

描述 Plugin API 之间的依赖关系。

随后由 VersionChecker 进行匹配。这样 Host 可以判断:

  • Plugin API 太老
  • Plugin API 太新
  • 依赖接口版本不匹配

而不是等到真正调用方法时才出现:NoSuchMethodError、LinkageError,对于长期演进的 Launcher Plugin API,这一点非常重要。


6.3 Crash 熔断

插件和 Host 运行在同一个进程,一个插件 Crash 就可能直接拖垮整个 Launcher。

因此 AOSP 设计了一套 Plugin Disable 机制。如果能够从堆栈中定位到某个 Plugin,则禁用对应 Plugin。如果无法判断是谁导致 Crash,则:

for (PluginActionManager<?> manager : mPluginMap.values()) {
    manager.disableAll();
}

也就是:

找不到肇事插件时,宁可全部关闭。

对于 SystemUI 或 Launcher 这种核心系统进程,这种设计是合理的。

核心目标不是保证插件一定运行,而是:

优先保证宿主进程能够恢复。


6.4 Production Build 限制

AOSP Plugin Framework 本身对生产版本非常谨慎。

核心逻辑类似:

if (!mIsDebuggable && !isPluginPrivileged(component)) {
    return null;
}

即:

userdebug / eng
    → 可以用于普通 Plugin 调试

user
    → 只允许 privileged Plugin

因此这套框架本身就不是为“任意第三方 APK 动态执行代码”设计的。

它更接近:

受控环境下的系统模块动态扩展机制。


七、多个插件如何共存

这里还有一个比较值得注意的问题。PluginActionManager 默认:

allowMultiple = false

当同一个 Action 查询到多个 Plugin 时,会认为出现冲突。

因此最初:

example-plugin
weather-plugin

都声明:PLUGIN_WIDGET_VIEW 时,并不能同时加载。本项目使用不同 Action 区分不同卡片类型:

PLUGIN_WIDGET_VIEW
PLUGIN_WIDGET_VIEW_WEATHER

Host 分别注册:

Listener A
Listener B

形成:

image.png

并通过:

Map<String, View> mPluginViews;

维护每个 Plugin 对应的 View 生命周期。

实际日志:

PluginInstance: Created plugin:
com.example.plugin.widget.ExampleWidgetViewPlugin

ExampleWidgetViewPlugin: onCreate
ExampleWidgetViewPlugin: createView

PluginInstance: Created plugin:
com.example.plugin.weather.WeatherWidgetViewPlugin

证明两个 Plugin 已经同时进入同一个 Host 进程。


八、总结

把 SystemUI Plugin 从 AOSP 中真正拆出来以后,会发现它的核心其实并不复杂。整套机制可以浓缩成几步:

Plugin API
    ↓
Manifest Service + Action
    ↓
PackageManager 发现
    ↓
Permission / Version 校验
    ↓
PathClassLoader
    ↓
PluginContext
    ↓
Reflection
    ↓
Plugin Lifecycle

但真正有价值的并不是“动态加载 APK”本身,而是它解决了一个架构问题:

传统 Launcher

Launcher APK
├── Media Card
├── Weather Card
├── Vehicle Card
├── Navigation Card
└── ...


Plugin 化 Launcher

Launcher Host
├── Plugin API
├── Plugin Manager
└── Card Container

Media Plugin APK
Weather Plugin APK
Vehicle Plugin APK
Navigation Plugin APK

Launcher 从原来的:

所有业务代码的承载者

逐渐转变为:

Plugin 的宿主、调度者和展示容器。

业务卡片则可以拥有独立的代码、资源、版本和生命周期。对于传统互联网 App,这种依赖隐藏 API、运行在宿主进程内的 Plugin 方案显然不合适。

但对于能够掌控 Framework、系统签名以及整体软件生态的 智能座舱平台 来说,这套 Plugin 机制反而非常适合作为模块解耦的一种手段。理解并掌握其实现原理后,它的应用场景也不必局限于 Launcher 和 SystemUI,同样可以扩展到其他需要动态扩展和独立交付的系统模块中。

本文由 ChatGPT-5.6 Sol 辅助编写,代码由 Deepseek V4-Flash 生成,KiMi-K3 二次 Review。

源码:github.com/linxu-link/…