Administrator
发布于 2026-08-26 / 29 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 96 篇:亿级 App 性能治理复盘:从 800ms 到 200ms 的全链路改造

前言

2024 年底我接到一个"性能救火"项目——某 Top 内容平台 Android 客户端的核心页 P99 从 600ms 退化到 800ms。每周线上有 0.8% 的用户遇到白屏、卡死、双击重放。老板说:"你能不能把它压回去?"

压力在于:

  1. 那是 50 万行代码的工程,已经迭代了 4 年;

  2. 同时有 8 个业务线并行开发;

  3. 没有"冻结期",意味着改性能的 PR 要能在一周内合进去。

三个月后我们把核心页 P99 压到 220ms,无任何线上事故。这篇文章是复盘。

一、先用数据定义"慢"

最大的失败是"凭感觉优化"。我们第一步搭了一个基线采集系统:

class PerfTracer private constructor(private val tag: String) {
  private val start = SystemClock.elapsedRealtime()
  private val sections = mutableListOf<Pair<String, Long>>()
  private var last = start

  fun next(label: String) {
    val now = SystemClock.elapsedRealtime()
    sections.add(label to (now - last))
    last = now
  }

  fun end() {
    val total = SystemClock.elapsedRealtime() - start
    // 上报到 APM
    EventBus.post(PerfEvent(tag, total, sections))
  }

  companion object {
    inline fun <T> trace(tag: String, block: () -> T): T {
      val t = PerfTracer(tag)
      try {
        block()
      } finally {
        t.next("end")
        t.end()
      }
      return block()
    }
  }
}

业务里只需 PerfTracer.trace("FeedPager.firstPaint") { ... }。这是基线 KPI:所有改动都要"敢"上报。

二、瀑布分析:先找到最长的短板

60% 的慢来自 20% 的代码。我们拿 Systrace 拉了 12 帧的瀑布:

阶段

主线程耗时

其它线程耗时

备注

Application.onCreate

210ms

80ms

单例多

SplashActivity.inflate

90ms

—

嵌套布局

Splash→Main 数据预取

60ms

150ms

阻塞 IO

Main 首页冷首帧

180ms

—

Compose

Feed 首屏数据

140ms

240ms

网络+解析

列表 RecyclerView

110ms

—

复用问题

结论:90% 的优化集中在前 5 项——并且只有 2 个在主线程上。

三、第一刀:Application 启动瘦身

读 Application 看,单例有 17 个。其中 6 个一启动就在做 IO 或读 SP:

// 改造前
class App : Application() {
  override fun onCreate() {
    UserManager.init(this)         // 10ms 同步 IO
    PushManager.init(this)         // 25ms 注册 FCM
    AnalyticsManager.init(this)    // 18ms 数据库连接
    DeviceInfoManager.init()       // 12ms
    ConfigManager.init()           // 30ms 远端拉
    BuglyManager.init()            // 35ms
    // ... 还有 11 个
  }
}

把"启动就必须的"和"延迟可接受的"切开:

class App : Application() {
  override fun onCreate() {
    // 1. 主线程必需
    BuglyManager.init()           // 安全网
    CrashHandler.install()
    ProcessLifecycleObserver.start()

    // 2. 立即推到后台线程
    GlobalScope.launch(Dispatchers.IO) {
      UserManager.warmInit()      // 首次访问时再实例化
      PushManager.init()          // 推到 WorkManager 初始任务里
      AnalyticsManager.init()
      ConfigManager.fetchAsync()
    }

    // 3. lazy 化(按需加载)
    registerActivityLifecycleCallbacks(AppLazyInit)
  }
}

实测优化:启动 Application 阶段 -180ms。

四、第二刀:SplashActivity 用 ViewStub 减负

原本 SplashActivity 用了三层 ConstraintLayout 嵌套,加 6 个 ImageView。系统测下来这屏幕 90ms 用来构建视图层级。

<FrameLayout>
  <ImageView android:id="@+id/logo" />
  <ViewStub android:id="@+id/loading_stub"
            android:layout="@layout/loading_view"
            android:inflatedId="@+id/loading" />
</FrameLayout>

ViewStub 只在 logo 动画结束后 inflate loading 视图。无 inflate 时整屏 inflate 60ms,直接砍到 30ms。

五、第三刀:Feed 列表 P99 优化

这是最大的一块儿。

5.1 多级缓存

层级

命中条件

失效条件

内存(LRU 200 条)

已在 RecyclerView 出现过

强刷

磁盘(Room + JSON)

命中且 TTL 未到

后台内容更新时

网络

兜底

—

理论上能命中 75% 的"上一页/下一页"操作完全跳过网络。

5.2 列表项复用

每条 Feed 内含 3 张图(图说+1 缩略图+1 头像)+ 5 个文本块+1 交互按钮。我们改用 itemViewType 5 类:

override fun getItemViewType(position: Int): Int = when (feed) {
  is Feed.Video -> TYPE_VIDEO
  is Feed.ImageGroup -> TYPE_IMAGE_GROUP
  is Feed.Article -> TYPE_ARTICLE
  is Feed.Ad -> TYPE_AD
  else -> TYPE_TEXT
}

复用错位率从 23% 降到 4%,对应滑动稳定 FPS 提升 15。

5.3 离屏渲染与预拉

首页可见 4 个 Item,我们预渲染第 5、6 个:

RecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
  override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
    val lastVisible = layoutManager.findLastVisibleItemPosition()
    if (lastVisible >= totalItemCount - 2) {
      // 触发下一页预拉
      FeedPaging.requestNext()
    }
  }
})

预拉限制:只在 Wi-Fi + 电量充足时开启,移动网络降级到预渲染下一项。

六、第四刀:Compose 与 View 混用的雷区

主页用 Compose + View 混合。我们发现:

  • 从 Compose 跳 View 会触发 ComposeView 的 dispose,导致 每一次回退都会重新 attach。

  • ViewHolder 重置时调用 setter 触发了 8 次刷新。

// 改造:用 DisposableEffect 缓存而不是每次创建
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
  val state by viewModel.uiState.collectAsState()
  AndroidView(
    factory = { ctx ->
      // 关键:factory 只在第一次创建,必须缓存
      FeedRecyclerView(ctx).apply {
        adapter = FeedAdapter()
        layoutManager = LinearLayoutManager(ctx)
      }
    },
    update = { view ->
      view.submitList(state.items)
    }
  )
}

关键点:update 不再创建新 View,而是复用现有的 RecyclerView 实例。

七、改动落地:怎么让 PR 一周合进去

性能优化的 PR 没人愿意合(破坏稳定)。我们用三层防御:

  1. 影子测试:同一份代码 A/B 上线,50% 流量走新逻辑,50% 走老逻辑;线上对比 P99。

  2. 灰度开关:在 Config 里用远端开关控制是否启用,周一开 5%、周三 20%、周五 100%。

  3. 回滚预案:任何 PR 必须有"关闭开关后行为等于老逻辑"的代码对照表。

上线日

流量

P99

备注

Day 1

5%

720ms

优化未生效

Day 3

20%

540ms

Application 优化命中

Day 7

50%

320ms

列表复用命中

Day 14

100%

220ms

Compose 优化命中

八、剩下的 580ms

三个月压到 220ms 后,剩下的 580ms 都是"结构性"问题——方案性太大、影响面广,需要后续迭代:

  • 整体从 MVC 迁到 MVI(涉及 50+ Activity);

  • 把首页首屏的 7 个远程接口合并为 1 个 GraphQL;

  • 自建二进制协议替换 JSON。

这些后面再说。一次性能优化的成熟标志是:你敢在优化结束后停下,剩下的留给下一次。

九、复盘要点

教训

怎么避免

"凭感觉"优化

必须有基线 KPI

上来就搞大重构

先 1 处 1 PR 验证

忽略数据上报

没有 KPI 等于没优化

让所有人同时改

灰度 + 影子测试

过度承诺时限

重新定义"完成"=APM P99 达标

小结

性能优化本质是"把看不见的慢,变成看得见的指标,再把指标压下去"。流程化、可视化、可灰度,三件事缺一不可。下一篇文章我们聊聊 Android 工程师怎么从"做项目"走到"做开源",把影响力做出 App 外。

参考阅读

  • 《Systems Performance》— Brendan Gregg

  • Android Vitals 与 Baseline Profiles 实践

  • 大厂 APM 开源项目 Matrix / ARG の


评论