date: 2025-09-30 tags:

  • Android
  • 架构
  • 启动优化
  • AppWidget
  • AIDL
  • 多证书签名
  • Key Rotation

面向客户端与平台工程团队的实施手册:启动路由与“已启动跳转”、服务型应用(无图标)、App Widget 托管的“近似内嵌”体验,以及多证书签名与密钥轮换流水线。

1)启动路由:RouterActivity + SplashScreen

Manifest(仅一个入口)

<activity
  android:name=".RouterActivity"
  android:exported="true"
  android:launchMode="singleTask"
  android:theme="@style/Theme.Router.NoDisplay">
  <intent-filter>
    <action android:name="android.intent.action.MAIN"/>
    <category android:name="android.intent.category.LAUNCHER"/>
  </intent-filter>
  <intent-filter>
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>
    <data android:scheme="myapp"/>
  </intent-filter>
</activity>

Kotlin

class RouterActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    installSplashScreen() // 仅影响启动期
    super.onCreate(savedInstanceState)
    route(intent)
  }
  override fun onNewIntent(i: Intent) { super.onNewIntent(i); setIntent(i); route(i) }
  private fun route(i: Intent) {
    val loggedIn = /* 读取登录态 */
    val route = i.getStringExtra("route") ?: i.data?.host
    val target = when {
      !loggedIn -> Intent(this, LoginActivity::class.java)
      route == "chat" -> Intent(this, ChatActivity::class.java)
        .putExtra("convId", i.getStringExtra("convId") ?: i.data?.getQueryParameter("convId"))
      else -> Intent(this, HomeActivity::class.java)
    }
    target.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
    startActivity(target); finish()
  }
}

要点

  • 已启动时跳转:靠 onNewIntent();系统 Splash 仅在启动期生效。
  • 避免白屏:入口主题使用 NoDisplay/透明预览禁用。
  • 从通知/外链重置返回栈:NEW_TASK | CLEAR_TASK

2)无图标“服务型”应用(只提供能力,不出现在启动器)

Manifest

<service
  android:name=".ApiService"
  android:exported="true"
  android:permission="com.example.permission.USE_MY_SERVICE">
  <intent-filter>
    <action android:name="com.example.MY_SERVICE"/>
  </intent-filter>
</service>
 
<permission
  android:name="com.example.permission.USE_MY_SERVICE"
  android:protectionLevel="signature"/>

Service

class ApiService : Service() {
  private val binder = object : IMyApi.Stub() {
    override fun ping(): String = "pong"
  }
  override fun onBind(intent: Intent): IBinder = binder
}

注意

  • 不声明 MAIN/LAUNCHER 的 Activity ⇒ 启动器无图标。
  • Android 12+ 所有带 intent-filter 的组件必须显式 android:exported
  • 建议使用 signature 级权限显式绑定(包名+类名);多应用协作时最稳。

3)“把别的 App 页面嵌进来”的现实替代:App Widget 托管

为何选择 App Widget

  • 公开 API 支持的跨进程 UI方式;无需共享代码;托管方将对方的 RemoteViews 以 AppWidgetHostView 形式嵌入到自身页面。
  • 限制:控件集受限、动画/手势有限、不可直接长文本输入;但按钮、开关、列表、跳转足够覆盖多数“嵌卡片”需求。

提供方(Widget App)

  • AppWidgetProvider + 多尺寸布局(小/中/大);
  • 列表用 RemoteViewsService + RemoteViewsFactory
  • 点击交互:setOnClickPendingIntent(),或 Android 12+ 的响应 API。

托管方(主 App)

val appWidgetId = appWidgetHost.allocateAppWidgetId()
val ok = appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, providerComponent)
if (!ok) {
  val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_BIND).apply {
    putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
    putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, providerComponent)
  }
  startActivityForResult(intent, REQ_BIND) // 一次授权
}
val view = appWidgetHost.createView(this, appWidgetId, appWidgetManager.getAppWidgetInfo(appWidgetId))
container.addView(view)

体验优化

  • 刷新策略事件驱动(数据变更时主动 updateAppWidgetnotifyAppWidgetViewDataChanged),避免固定 updatePeriodMillis
  • 图片统一缩放与缓存,避免大 Bitmap 传输;深色/动态配色(Material You)与主 App 视觉对齐。

4)一个 APK 多证书签名 & 密钥轮换

目标:同一 APK 同时由多把证书签(多签),或平滑更换证书(轮换)。

多签(multi-signer)

步骤:构建未签名 APK → apksigner 添加多个签名者。

apksigner sign \
  --ks ks1.jks --ks-key-alias alias1 --ks-pass pass:*** --key-pass pass:*** \
  --next-signer \
  --ks ks2.jks --ks-key-alias alias2 --ks-pass pass:*** --key-pass pass:*** \
  --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
  app-release-unsigned.apk
 
apksigner verify --print-certs --verbose app-release-unsigned.apk

Gradle 后处理

tasks.register("addSecondSigner", Exec) {
  dependsOn("assembleRelease")
  doFirst {
    def apk = file("$buildDir/outputs/apk/release/app-release.apk")
    commandLine(
      "${android.sdkDirectory}/build-tools/34.0.0/apksigner", "sign",
      "--next-signer",
      "--ks", file("keystores/ks2.jks"), "--ks-key-alias", "alias2",
      "--ks-pass", "pass:***", "--key-pass", "pass:***",
      "--v1-signing-enabled", "true", "--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
      apk.absolutePath
    )
  }
}

注意

  • 确保 v1 与 v2/v3 使用相同的签名者集合(兼容老系统);
  • 后续版本保持相同集合,或使用 v3 的轮换机制。

密钥轮换(Key Rotation / v3 lineage)

  • 使用 v3 签名谱系(lineage.bin)声明新旧钥的承接关系;
  • 过渡期可同时含旧/新证书签名,之后仅用新证书 + 同一 lineage;
  • 若使用 Google Play App Signing(AAB):最终安装包由 Play 的密钥签名,多签策略改为按 Play 的密钥升级流程执行。

5)实践清单

  • Router 入口 singleTask + onNewIntent() 处理已启动跳转;
  • Splash 仅限启动期;NoDisplay 主题避免白屏;
  • 无图标服务:无 MAIN/LAUNCHER,组件声明 exported,signature 权限隔离;
  • App Widget:事件驱动刷新、列表用 RemoteViewsService、点击深链回主 App;
  • 多签:统一 v1/v2/v3 的签名者集合;轮换使用 v3 lineage;
  • Play 分发:遵循 App Signing 密钥升级流程,而非本地多签。