Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Android 应用开发面试学习清单

共 13 个主题分组、64 篇内容

面向 中级(3-5 年)Android 应用开发岗 的系统复习资料。

你的背景画像:设备指纹 / 风控 SDK 开发者,强项 C/C++ 与 NDK/JNI、系统底层、逆向视角; 短板在 UI 层(View / Compose)、Kotlin 协程 / Flow、Jetpack 全家桶、应用架构。 副技能 iOS 基础。

本资料策略:重点补短板(★),把强项包装成差异化亮点(☆),iOS 作为跨端谈资(辅)。


如何使用这套资料

  1. 先读 01-面试路线与自我定位.md,想清楚怎么把“风控 SDK 背景“讲成应用岗的优势。
  2. 按下面“复习路线“的优先级顺序学,先攻短板(协程 → UI → Jetpack → 架构 → 测试体系)。
  3. 每篇结构统一:知识点讲解 → 高频面试题 + 参考答案要点 → 易错点 / 追问
  4. 学完一个知识点,回到本页的“自测清单“打勾 - [x],追踪进度。

复习路线(按优先级)

阶段重点对应文档
第一优先(短板,必攻)协程 / Flow、View 体系、Compose、Jetpack、架构、测试体系、并发、深度扩展04, 08, 09, 10, 13, 14, 16, 20, 52
第二优先(高频,巩固)Kotlin 核心及进阶、Java/JVM、四大组件、系统原理、性能、RxJava02, 03, 06, 18, 21, 05, 51
第三优先(亮点,包装)NDK/JNI、主流第三方库、APM、推送/保活、音视频、跨端与动态化、SDK与调试42, 39, 26, 37, 49, 44-45, 48, 43, 46
第四优先(加分,速览)iOS 基础、KMP、项目经验与软技能、复盘与追问防御44, 45, 59, 60, 61
AI 辅助开发(趋势话题)Vibe Coding、Harness Engineering、AI Coding 工程化进阶62, 63, 64
贯穿始终(每日刷题)LeetCode Hot 100 算法清单与解析50
算法补充(查漏补缺)排序/位运算/并查集/前缀和差分/设计题、业务算法场景51, 53
算法亮点(差异化)海量数据处理(结合你的 SDK 背景)52
计算机基础(必考四大件)网络排障、设计模式、操作系统与数据库进阶29, 56, 54, 30, 55
安全亮点(核武器)Android 安全与逆向、移动安全防护体系(你的本行)40, 41
工程化与设计(中高级)Gradle构建与发布体系、鉴权订单隐私合规、性能工具、系统设计31, 32, 33, 35, 36, 34, 25, 57, 58

自测清单(学完打勾)

01 面试路线与自我定位

  • STAR 法则讲项目
  • 怎么把风控 SDK 项目讲成应用岗亮点
  • 常见 HR / 反问环节
  • 算法准备方向

02 Kotlin 语言核心

  • val/var、空安全(?. ?: !!)、平台类型
  • 扩展函数 / 扩展属性的原理(静态分发)
  • 高阶函数、Lambda、inline/noinline/crossinline
  • 作用域函数 let/run/with/apply/also 区别
  • data class / sealed class / object / 伴生对象
  • 属性委托 by lazy / Delegates.observable / 自定义委托
  • 泛型型变 in/out、reified

03 Kotlin 进阶专题

  • inline / reified / value class / sealed 类型的面试表达
  • Kotlin 泛型、协变逆变、委托与 DSL 设计能力
  • 协程状态机、Flow 操作符与性能陷阱的深入理解

04 Kotlin 协程与 Flow ★

  • suspend 原理:CPS 变换与状态机
  • 结构化并发:CoroutineScope / Job / 父子关系
  • Dispatchers(Main/IO/Default)与 withContext
  • launch vs async、Deferred
  • 取消机制(协作式)与异常传播
  • CoroutineExceptionHandler / supervisorScope / SupervisorJob
  • 冷流 Flow vs 热流;StateFlow vs SharedFlow
  • flowOn / buffer / conflate / collectLatest

05 RxJava 与响应式编程

  • Observable / Observer / Subscriber 核心模型
  • 变换(map/flatMap)、过滤(filter/debounce)、合并(merge/zip)
  • 线程切换 SubscribeOn / ObserveOn 原理
  • 背压机制 Flowable / Single / Completable
  • 生命周期管理与内存泄漏防护

06 Java 与 JVM 基础

  • HashMap 原理(扩容、红黑树、并发问题)、ConcurrentHashMap
  • synchronized vs ReentrantLock、volatile、CAS、AQS
  • 线程池 ThreadPoolExecutor 七参数与拒绝策略
  • JMM 内存模型、happens-before
  • JVM 内存区域、GC 算法与垃圾回收器
  • 类加载机制、双亲委派

07 Android 四大组件与基础

  • Activity 生命周期、启动模式、taskAffinity
  • Fragment 生命周期与 commit/commitNow、懒加载
  • Service(前台/绑定)、IntentService、JobScheduler/WorkManager
  • BroadcastReceiver(静态/动态、有序、LocalBroadcast)
  • ContentProvider 与跨进程数据共享
  • Intent、序列化 Serializable vs Parcelable、Context 类型

08 UI 体系 - View 与自定义 View ★

  • View 绘制三大流程 measure / layout / draw
  • MeasureSpec 与 onMeasure、wrap_content 处理
  • 事件分发 dispatch / intercept / onTouchEvent
  • 滑动冲突(外部 / 内部拦截法)
  • 自定义 View / ViewGroup、自定义属性
  • invalidate vs requestLayout、硬件加速
  • Window / DecorView / ViewRootImpl 关系

09 UI 体系 - Jetpack Compose ★

  • 声明式 UI 思想、@Composable 原理
  • 重组(Recomposition)与跳过、稳定性
  • 状态:remember / mutableStateOf / state hoisting
  • 副作用:LaunchedEffect / DisposableEffect / SideEffect / derivedStateOf
  • CompositionLocal、Modifier 原理与顺序
  • 三大阶段 Composition / Layout / Drawing
  • 与 View 互操作、性能优化(key、lambda)

10 Compose 深水区

  • 重组跳过、稳定性、remember key 与状态提升
  • Modifier、Layout、SubcomposeLayout、自定义绘制核心机制
  • Compose 性能分析、列表优化与 View 互操作边界

11 Android 版本适配

  • Android 版本演进中的行为变更与兼容策略
  • targetSdkVersion 升级风险、灰度验证与回滚方案
  • 权限、后台限制、通知、前台服务等高频适配点

12 图片加载与缓存

  • 图片加载链路:请求、解码、变换、缓存、展示
  • 内存缓存 / 磁盘缓存 / 网络缓存的命中与失效策略
  • 大图、列表滑动、生命周期绑定与 OOM 防护

13 Jetpack 架构组件 ★

  • ViewModel 原理、生命周期、SavedStateHandle
  • Lifecycle / LifecycleObserver、LiveData 原理与粘性事件
  • Room(实体 / DAO / 迁移 / 协程&Flow 支持)
  • Navigation、Hilt 依赖注入(vs Dagger)
  • DataStore(替代 SharedPreferences)、WorkManager、Paging3

14 应用架构 - MVVM 与 MVI ★

  • MVC / MVP / MVVM / MVI 演进与对比
  • 单向数据流、State / Intent / Effect
  • 分层(data / domain / ui)、Repository 模式
  • UseCase、依赖倒置、可测试性

15 App 架构落地案例

  • 分层架构在真实业务中的模块职责划分
  • Repository、UseCase、ViewModel 与 UI State 协作链路
  • 架构取舍、团队协作、可测试性与演进成本表达

16 测试体系

  • 单元测试 JUnit、Mockito、Robolectric
  • UI 测试 Espresso、UI Automator
  • 测试驱动开发 TDD 原理与实践

17 组件化路由与模块通信

  • 组件化拆分、依赖管理与 ARouter 原理
  • 模块间通信、接口暴露与服务发现
  • 资源冲突处理、公共库提取与解耦策略

18 Android 系统原理

  • Handler / Looper / MessageQueue、同步屏障、IdleHandler
  • Binder 机制(一次拷贝、mmap、驱动)
  • AMS / 应用启动流程 / 进程创建(Zygote)
  • 类加载与热修复 / 插件化原理
  • 进程间通信方式对比

19 Binder 与 IPC 深入

  • Binder 通信模型、驱动、ServiceManager 与代理对象
  • AIDL、Messenger、ContentProvider 等 IPC 方式对比
  • 大数据传输、死亡监听、线程池与安全校验边界

20 多线程并发专题

  • Java/Kotlin 并发原语、线程池、锁与协程调度对比
  • 共享状态、可见性、竞态条件与死锁排查方法
  • Android 主线程约束、后台任务调度与取消传播

21 性能优化

  • 启动优化(冷 / 热启动、Splash、延迟初始化)
  • 内存泄漏(常见场景、LeakCanary 原理)、内存抖动
  • 卡顿与掉帧、Choreographer、ANR 原理与定位
  • 包体积优化(R8/ProGuard、资源、so)
  • 布局优化、工具(Profiler / Perfetto / Systrace)

22 启动优化专项

  • 冷启动 / 温启动 / 热启动指标与阶段拆解
  • Application 初始化治理、懒加载与异步预热边界
  • SplashScreen、Baseline Profile、启动链路监控与回归验证

23 内存优化与泄漏排查

  • Android 内存模型、Java 堆 / Native 堆 / 图形内存差异
  • 常见泄漏场景与 LeakCanary / MAT 分析路径
  • 内存抖动、Bitmap、缓存上限与线上告警指标

24 ANR 与卡顿排查

  • ANR 类型、触发阈值、traces 与主线程阻塞定位
  • Choreographer、VSync、掉帧指标与 Perfetto 分析
  • 锁竞争、IO、Binder 调用、布局过深等排查路径

25 性能工具专题

  • Android Studio Profiler、Perfetto、Systrace 的适用场景
  • Macrobenchmark、Baseline Profile 与启动/滚动性能验证
  • 线上性能指标、采样策略、问题复现与闭环治理

26 APM 与线上监控

  • 崩溃监控、错误堆栈聚合与分发
  • 性能指标采集:FPS、启动时间、网络耗时
  • 线上治理体系与告警策略

27 存储体系与 Scoped Storage

  • 内部 / 外部 / App 专属目录与 MediaStore 使用场景
  • Scoped Storage 迁移路径、兼容开关与权限边界
  • 大文件、缓存、数据库、日志文件的存储治理方案

28 网络协议

  • HTTP / HTTPS / HTTP2 / QUIC 原理与握手流程
  • TCP vs UDP、三次握手、滑动窗口与拥塞控制
  • DNS 劫持、证书校验与网络安全防护

29 网络排障专项

  • DNS、TCP/TLS、HTTP/2、QUIC 等链路问题定位
  • OkHttp 日志、抓包、证书、代理与弱网复现方法
  • 超时、重试、连接池、缓存、网关错误的排查流程

30 数据库进阶

  • SQLite 索引、事务、WAL、锁与查询优化原则
  • Room 迁移、缓存一致性、分页与离线数据同步
  • 数据库排障:慢查询、损坏恢复、并发写入与监控

31 Gradle 与工程化

  • Gradle 生命周期、Task Graph、增量构建与缓存机制
  • 插件开发、Transform API、依赖管理技巧
  • 项目工程化最佳实践

32 Gradle 构建性能专题

  • Configuration Cache、Build Cache、并行构建适用条件
  • 构建耗时定位、插件治理与 CI 构建优化策略

33 CI/CD 与发布体系

  • CI 流水线阶段:检查、测试、构建、签名、发布
  • 灰度发布、渠道包、回滚与版本门禁设计
  • 自动化质量门禁、制品管理与敏感信息保护

34 隐私合规与权限治理

  • 权限申请时机、最小化原则与用户拒绝后的降级体验
  • 隐私政策、数据采集清单、SDK 合规审计流程
  • 敏感信息脱敏、加密、日志治理与跨境/第三方共享风险

35 登录鉴权与账号体系

  • 登录态、Token、Refresh Token 与会话续期机制
  • OAuth2 / 单点登录 / 设备绑定的核心流程与风险点
  • 账号安全、风控拦截、退出登录与多端一致性处理

36 支付订单与状态机

  • 订单状态流转、幂等、防重放与超时关闭机制
  • 支付渠道回调、客户端轮询、服务端对账协作
  • 异常订单、补偿任务、退款与状态机设计表达

37 推送 / 长连接 / 保活

  • 三方推送厂商集成与 FCM、保活策略
  • 长连接协议、心跳设计与断线重连机制
  • 消息送达率优化与推送链路监控

38 埋点与数据采集 SDK

  • 埋点模型、事件属性、会话、用户标识与采样策略
  • 数据缓存、批量上报、重试、压缩与弱网治理
  • 隐私合规、SDK 性能开销与线上质量监控

39 主流第三方库

  • OkHttp(拦截器链、连接池、缓存)
  • Retrofit(动态代理、协程适配)
  • Glide / Coil(三级缓存、生命周期绑定)
  • Gson / Moshi / kotlinx.serialization
  • EventBus / 依赖注入框架对比

40 Android 安全与逆向

  • APK 结构、反编译、脱壳与签名校验原理
  • 常用逆向工具链、静态/动态分析、Frida 挂钩
  • 安全编码实践与加固保护方案

41 移动安全防护体系

  • 应用加固、反调试、完整性校验与风险分层思路
  • 数据加密、密钥管理、通信安全与本地存储防护
  • 设备指纹、风控 SDK、防刷防薅羊毛的应用岗表达

42 NDK 与 JNI ☆

  • JNI 数据类型映射、签名规则
  • 局部 / 全局 / 弱全局引用、引用表溢出
  • JNIEnv vs JavaVM、线程 AttachCurrentThread
  • 静态注册 vs 动态注册 RegisterNatives
  • native crash 捕获(signal / breakpad)
  • CMake / ABI / so 加载与裁剪、现代 C++(RAII / 智能指针)

43 NDK Native 调试与 Crash 定位

  • tombstone、addr2line、符号表、so 映射与崩溃还原
  • lldb / ndk-stack / breakpad 等 Native 调试工具链
  • JNI 边界、线程、信号处理与线上 Native Crash 归因

44 iOS 基础速览(辅)

  • Swift 基础、可选值、ARC 内存管理
  • UIViewController 生命周期、UIKit vs SwiftUI
  • GCD、与 Android 概念对照

45 Kotlin Multiplatform

  • KMP 架构模型、expect/actual 关键字与共享策略
  • 跨端业务逻辑复用、依赖管理与构建流程
  • KMP 在移动端的落地场景与优劣分析

46 跨端技术对比

  • Flutter、React Native、KMP、WebView Hybrid 的架构差异
  • 性能、研发效率、生态、包体积与团队成本取舍
  • 跨端落地中的平台能力、调试、发布与降级策略

47 WebView 与 Hybrid

  • WebView 生命周期、缓存、进程、性能与内存治理
  • JSBridge 通信模型、线程切换与安全白名单校验
  • Hybrid 页面加载优化、异常监控与降级兜底

48 插件化 / 热修复 / 动态化

  • 插件化类加载、资源加载、组件代理基本原理
  • 热修复方案对比、兼容风险与发布安全边界
  • 动态化收益、性能成本、合规风险与降级策略

49 音视频 / Media3 / ExoPlayer

  • 编解码、渲染流程与 Media3/ExoPlayer 核心架构
  • 预加载、缓存、倍速、软硬解切换与性能监控
  • 业务场景下的播放优化、异常处理与功能定制

50 LeetCode Hot 100 算法清单

  • 数组 / 哈希表 / 链表 / 栈与队列常用算法
  • 树 / 图 / 递归与回溯、动态规划、贪心策略
  • 二分搜索、滑动窗口、位运算、并查集

51 算法补充专题

  • 排序算法对比、复杂数据结构原理
  • 深度优先搜索 DFS / 广度优先搜索 BFS 进阶
  • 常见大厂面试题复现与思维模型

52 海量数据处理

  • 分治、MapReduce 思想与海量数据过滤/排序
  • 布隆过滤器、BitMap、Trie 树适用场景
  • Top K 问题、外排序、内存受限下的算法方案

53 Android 业务算法场景

  • LRU、滑动窗口、优先队列等算法在业务中的应用
  • 推荐、搜索、去重、限流、风控规则的算法化表达
  • 复杂度分析、边界条件与工程落地取舍说明

54 操作系统与数据库基础

  • 进程 / 线程 / 调度 / 内存管理与 Android 运行时关系
  • 数据库索引、事务、ACID、范式与规范化
  • 文件系统、锁、信号、系统调用在移动端的应用

55 操作系统进阶

  • 虚拟内存、mmap、文件 IO、Page Cache 与性能影响
  • 操作系统内核机制、同步原语与 Android 底层关系

56 设计模式与 Android 源码应用

  • 创建型、结构型、行为型模式在 Android 源码中的体现
  • 观察者、工厂、适配器、代理等高频模式的实践
  • 设计原则 SOLID、解耦与可扩展性评估

57 系统设计场景题

  • 即时通信、Feed、推送、离线缓存等移动场景设计框架
  • 客户端架构、数据同步、弱网容错与服务端协作边界
  • 容量估算、性能指标、监控告警与灰度降级方案

58 移动端系统设计题库

  • 核心业务场景的系统化表达、架构图绘制
  • 应对三高(高并发/高可用/高性能)的客户端方案

59 项目经验与软技能

  • 项目价值、技术难点、个人贡献与业务闭环表达
  • 团队沟通、冲突处理、技术视野与职业规划

60 项目复盘专题

  • STAR / 背景目标行动结果结构化复盘项目
  • 指标提升、故障处理、协作冲突与技术取舍表达
  • 失败经验、重做方案、个人贡献边界与证据准备

61 简历追问防御清单

  • 简历每个技术点准备原理、实践、指标与追问答案
  • 区分亲自负责、参与协作、学习了解的边界表述
  • 针对夸大风险、数据真实性、项目细节进行反问演练

62 Vibe Coding

  • AI 辅助下的快速原型、实验性开发流程
  • 与 AI 协作的编码风格、意图表达与反馈循环

63 Harness Engineering

  • AI 辅助开发中的自动化验证、反馈闭环设计
  • 为 AI 构建上下文、工具链集成与质量门禁

64 AI Coding 工程化进阶

  • Context Engineering、任务拆解与 AI 代码审查闭环
  • MCP、工具调用、自动化验证与可追溯开发流程
  • AI 生成代码的风险:幻觉、越权修改、测试缺口与治理

面试路线与自我定位

这一篇不讲技术,讲策略。你不是普通的应届/转行选手,你是“底层强、应用层有空白“的特殊画像。打法和别人不一样。

一、你的优势与劣势盘点

优势(面试中要主动亮出来)

能力普通应用开发者
C/C++ / NDK / JNI大多不会或只会调用能独立写 SDK
系统底层 / so / 内存模糊熟悉
逆向 / 安全 / 对抗视角几乎没有是你的饭碗
性能极限优化意识一般强(SDK 对体积/性能敏感)

这些是你的护城河。很多 App 团队正缺懂 native、懂性能、懂安全的人(音视频、IM、支付、出海 App 尤其需要)。

劣势(面试中会被重点拷问,必须补)

  1. UI 层:View 绘制流程、事件分发、自定义 View、Compose —— 应用岗的核心,你接触少。
  2. Kotlin 协程 / Flow:现代 Android 异步基石,你不熟。
  3. Jetpack 全家桶:ViewModel/Room/Navigation/Hilt/DataStore,应用开发日常。
  4. 应用架构:MVVM/MVI、单向数据流、分层 —— 中级必考。
  5. 主流业务库实战:Retrofit/Glide/OkHttp 的工程化用法。

二、转型叙事:怎么讲你的故事

面试官一定会问:“你之前做风控 SDK,为什么转应用开发?能行吗?” 准备好这套话术:

“我在风控 SDK 做的是 Android 最底层的部分——native 采集、对抗、性能与体积优化。这让我对系统机制(Binder、进程、内存、so 加载)有比一般应用开发者更深的理解。我想往上走,把这份底层功底用在完整的产品上。UI 和上层框架是我最近重点补的,我已经系统学了协程、Compose、Jetpack 和 MVVM 架构。”

关键三点:

  • 不贬低过去:底层经验是稀缺的,要包装成“比别人更懂系统“。
  • 承认短板但展示行动:“我已经系统学了 X、Y、Z”(配合本资料的学习成果)。
  • 给团队一个理由:“我能补团队在 native / 性能 / 安全上的短板”。

三、目标岗位选型(扬长避短)

优先投这些方向,你的底层背景是加分项而非累赘。每个方向都按“为什么契合 → 面试会问什么 → 怎么包装经历 → 可能追问“准备:

  • 音视频 / 直播 App
    • 为什么契合:音视频链路常接触 FFmpeg、编解码、OpenGL/音频采集、native crash 与性能调优,你的 C++/NDK/JNI 经验能直接迁移。
    • 面试会问:JNI 调用开销怎么控制?native 崩溃怎么定位?音视频卡顿如何拆解到采集、编码、网络、渲染各环节?
    • 怎么包装:把风控 SDK 里的 native 模块讲成“在宿主 App 约束下做稳定、低开销、可回滚的 native 能力“,突出跨层调用、线程模型、so 体积和崩溃治理。
    • 可能追问:如果没做过播放器,不要硬装;回答“音视频业务链路我需要补,但 native 性能、JNI 边界、crash 定位这些底层问题我能快速接住“。
  • IM / 通讯
    • 为什么契合:IM 重视连接稳定性、弱网、消息可靠性、离线/重连和端侧性能,很多问题不是纯 UI,而是协议与状态机。
    • 面试会问:弱网下如何保证体验?长连接断线重连怎么设计?消息去重、ACK、重试和本地落库怎么取舍?
    • 怎么包装:强调你做 SDK 时对宿主环境、网络/设备差异、异常采集和后台约束的敏感度,把它转成“端侧可靠性“语言。
    • 可能追问:若被问到完整 IM 架构,先按“连接层 → 消息状态 → 本地缓存 → UI 同步 → 失败补偿“分层,不要一上来堆大规模服务端名词。
  • 出海 App / 工具类
    • 为什么契合:出海和工具类常受包体积、机型兼容、启动速度、隐私合规、多语言环境影响,SDK 背景天然关注“不能拖累宿主“。
    • 面试会问:包体积怎么拆?so ABI 如何收敛?低端机启动/内存如何优化?兼容性问题怎么排查?
    • 怎么包装:讲你如何在 SDK 约束里做延迟初始化、按需加载、ABI/符号裁剪、异常兜底;这些都能转化为 App 侧工程质量。
    • 可能追问:遇到隐私/合规问题时,用“最小化采集、用户授权、可配置开关、日志脱敏“这类工程原则回答,不要编造具体合规项目。
  • 支付 / 金融 App
    • 为什么契合:支付金融对安全、风控、稳定性和审计非常敏感,你的对抗视角能帮助团队提前发现风险点。
    • 面试会问:如何防 Hook/篡改/重放?敏感数据怎么保护?安全能力如何避免影响性能和用户体验?
    • 怎么包装:把风控经验翻译成“安全风险识别 + 端侧防护 + 性能可控 + 可观测“;强调防御和工程治理,而不是炫攻击细节。
    • 可能追问:被追具体安全方案时,保持防御视角,说清威胁模型、边界和误报成本;不要夸大成“完全防住“。
  • 大厂基础架构 / 性能团队
    • 为什么契合:基础架构和性能团队关注启动、APM、稳定性、包体积、native 质量和跨业务复用,与你的 SDK 工程经验最接近。
    • 面试会问:启动耗时如何拆解?APM 指标怎么定义?native crash 符号化怎么做?一个优化如何灰度和回滚?
    • 怎么包装:突出“做给多个宿主/业务使用“的 SDK 思维:接口稳定、兼容性、监控、降级、性能预算、发布质量。
    • 可能追问:如果问到业务产品经验不足,回应“我更适合先从性能/稳定性/native 基建切入,同时补齐 UI 和业务架构“。

谨慎投:纯营销活动类、纯 UI 堆叠的业务岗 —— 这类岗位你的优势用不上,而短板暴露无遗。

四、典型面试流程与各轮重点

轮次考察重点典型问题回答结构易翻车点
一面(基础)Kotlin、四大组件、UI、协程、集合并发“协程取消怎么生效?”“View 事件分发怎么走?”“Activity 重建怎么保状态?”先给一句结论,再按“机制 → 场景 → 易错点“三段答;不会展开源码时,至少说清生命周期/线程/状态边界只会背概念、不敢承认 UI/Jetpack 短板;把底层强项当借口跳过基础题
二面(深入)架构、性能、原理、项目细节“一个页面你怎么分层?”“启动慢怎么定位?”“SDK 怎么不拖累宿主?”用“问题现象 → 指标拆解 → 定位工具 → 方案取舍 → 验证结果“讲;项目题用 STAR,结果处只填真实数据讲项目只有职责没有决策;性能优化只说“异步/缓存“不讲指标和验证
三面(亮点)难题、系统设计、native、稳定性“native crash 怎么治理?”“设计一个端侧 APM/埋点 SDK”“安全能力如何灰度?”把主场拉到“NDK/JNI → 稳定性 → 性能预算 → 监控回滚“;系统设计按需求、约束、模块、数据流、失败处理讲炫技过多,忽略业务约束;安全话题讲成攻击教程;承诺无法验证的效果
HR 面稳定性、薪资、转型动机“为什么转应用?”“短板怎么补?”“未来 2-3 年规划?”用第二节叙事:肯定过去价值,承认上层短板,展示学习行动和岗位匹配;薪资提前准备区间抱怨前公司/旧方向;把转型说成逃离;用空话证明学习能力

每轮都要准备一个“桥接句“:我过去做的是底层 SDK,现在补的是应用层 API 和架构实践;底层经验能帮助我在性能、稳定性、安全问题上比普通应用开发者更快定位根因。 这句话不是万能答案,而是把话题从“你没做过完整 App“拉回“你能给团队补什么能力“。

五、两个月冲刺计划(参考)

  • 第 1-2 周:Kotlin 核心 + 协程/Flow(02 篇,最薄弱,先攻)。
  • 第 3-4 周:UI 体系 View + Compose(05、06 篇)。
  • 第 5 周:Jetpack + 架构(07、08 篇)。
  • 第 6 周:系统原理 + 性能优化(09、10 篇)。
  • 第 7 周:复习强项 NDK/JNI 做亮点(11),刷主流库(12)。
  • 第 8 周:项目复盘(14)、算法、模拟面试、iOS 速览(13)。

六、心态提醒

你不是从零开始。底层功底是很多面试官给不出、也很羡慕的硬实力。你要补的是“上层广度“,这是可以在两个月内系统补齐的知识型短板,而不是需要多年积累的经验型短板。把姿态放在“我是带着系统功底来做应用“的位置,而不是“我是个 UI 新手“。

Kotlin 语言核心

Kotlin 是现代 Android 的第一语言。你会用,但要把“会用“升级到“讲得清原理“。本篇覆盖中级面试高频语法点的底层机制

一、空安全(Null Safety)

Kotlin 把 null 检查提前到编译期。

  • val a: String 不可空;val b: String? 可空。
  • 安全调用 b?.length:b 为 null 时整体返回 null。
  • Elvis b?.length ?: 0:为 null 时给默认值。
  • 非空断言 b!!:为 null 时抛 NPE(慎用)。
  • 平台类型 String!:来自 Java 的类型,Kotlin 不知其可空性,调用方负责

易错:lateinit var 用于非空且延迟初始化(只能用于 var、非基本类型),访问前未初始化抛 UninitializedPropertyAccessException,可用 ::x.isInitialized 判断。by lazy 用于 val,线程安全延迟初始化。

二、扩展函数 / 扩展属性

fun String.lastChar(): Char = this[length - 1]

原理(高频追问):扩展函数编译成静态方法,接收者作为第一个参数传入。所以:

  • 扩展函数是静态分发,不是多态——调用哪个由声明类型决定,不是运行时类型。
  • 不能真正修改类、不能访问 private 成员。
  • 扩展属性没有 backing field,只能定义 get/set。

三、高阶函数与 inline

inline fun <T> measure(block: () -> T): T { ... }
  • Lambda 默认会被编译成 Function 对象,有对象创建开销。
  • inline 把函数体和 lambda 直接内联到调用处,消除对象分配,还能让 lambda 内的 return 直接返回外层函数(非局部返回)。
  • noinline:某个 lambda 参数不内联。
  • crossinline:禁止该 lambda 非局部返回(用于会在别处调用的场景)。
  • reified:配合 inline,让泛型类型在运行时可见(T::class),解决泛型擦除。
  • 边界:reified 只让内联函数调用点能拿到 T 的运行时类型,不能恢复集合元素的完整泛型实参;例如仍无法把 List<String>List<Int> 的元素类型当作普通运行时类型安全区分。

四、作用域函数(let/run/with/apply/also)

函数引用对象返回值典型用途
letitlambda 结果非空判断后操作 x?.let { }
runthislambda 结果配置对象并计算结果
withthislambda 结果对一个对象多次操作(非扩展)
applythis对象本身初始化配置 Paint().apply { }
alsoit对象本身副作用(打日志)不改链式

记忆法:返回结果用 let/run/with,返回自身用 apply/also;用 it 是 let/also,用 this 是 run/with/apply。

五、class 家族

  • data class:自动生成 equals/hashCode/toString/copy/componentN。注意 copy 是浅拷贝;只有主构造参数参与生成。
  • sealed class / sealed interface:密封类型,子类受限在同一模块。配合 when 可穷尽分支(无需 else),适合表达状态(Loading/Success/Error)。
  • object:单例;companion object 伴生对象(类级别成员,可实现接口、可命名)。
  • enum:枚举,可带属性和方法。
  • 嵌套 vs 内部类:Kotlin 嵌套类默认是静态的;加 inner 才持有外部类引用。

六、委托(Delegation)

  • 类委托:class B(b: Base) : Base by b,把接口实现委托给成员,组合优于继承。
  • 属性委托:
    • by lazy { }:首次访问时计算,默认 SYNCHRONIZED 线程安全。
    • Delegates.observable:值变化时回调。
    • Delegates.notNull():非空但延迟赋值。
    • by map:从 Map 读取属性。
    • 自定义委托需实现 getValue / setValue

七、泛型型变

  • out T(协变):只能作为输出(生产者),List<out T>,List<String> 可赋给 List<Any>
  • in T(逆变):只能作为输入(消费者),Comparator<in T>
  • * 星投影:不关心具体类型时使用。
  • PECS:Producer-Extends(out),Consumer-Super(in)。面试可用一句话落地:只从容器里读 Tout,只往容器里写 Tin;既要读又要写具体 T 时通常不要加型变,否则编译器会限制不安全操作。

高频面试题

Q1:===== 的区别? == 比较值(调用 equals),=== 比较引用。Java 的 == 对应 Kotlin 的 ===

Q2:lateinitby lazy的区别? lateinit 用于 var、非空、可多次赋值、不能用于基本类型、由开发者负责初始化时机;lazy 用于 val、首次访问自动初始化、线程安全可配置。

Q3:扩展函数能被重写吗?为什么? 不能。扩展函数是静态分发,编译成静态方法,调用哪个由声明类型决定而非运行时类型,所以没有多态。

Q4:inline 一定能提升性能吗? 不一定。inline 消除 lambda 对象分配,适合高阶函数;但内联会增大字节码,对大函数滥用反而增加体积、降低性能。Kotlin 编译器会对大 inline 函数告警。

Q5:Kotlin 的 UnitNothingAny 区别? Any 是所有非空类型的根(类比 Object);Unit 表示无返回值(类比 void,但是真实对象);Nothing 表示永不返回(抛异常或死循环),是所有类型的子类型。

Q6:data class 用作 HashMap 的 key 安全吗? 安全(自动生成了 hashCode/equals),但若字段可变,作为 key 后修改字段会导致查找失败 —— 应保证 key 不可变。

Kotlin 进阶专题

Kotlin 进阶不是背语法糖,而是能把语法、编译器改写、JVM 字节码和 Android 工程边界串起来。面试回答要从“怎么用”升级到“为什么这样设计、代价是什么、和 Java/协程怎么交互”。

一、inline / noinline / crossinline 的真实作用

inline 的核心是让编译器把函数体和可内联 lambda 展开到调用点,减少高阶函数的对象分配与虚调用开销,同时支持 lambda 内的非局部返回。

inline fun <T> around(name: String, block: () -> T): T {
    val start = System.nanoTime()
    return try {
        block()
    } finally {
        println("$name cost=${System.nanoTime() - start}")
    }
}
  • inline:适合小型高阶函数、频繁调用路径、DSL、类型检查工具函数。
  • noinline:某个 lambda 需要被保存、传递给其他函数或作为对象使用时,不能内联。
  • crossinline:lambda 可能在另一个对象/线程/回调中被调用,禁止 return 直接返回外层函数,避免控制流不安全。
  • 代价:内联会复制字节码,函数太大或调用点太多会增加包体积、影响指令缓存和 R8 优化空间。

面试追问可以补一句:Kotlin 标准库很多集合/作用域函数都依赖 inline,所以 list.forEach { } 并不一定比手写循环多出 lambda 对象。

二、reified 与泛型擦除

JVM 泛型默认擦除,运行时通常不知道 T 是什么。reified 必须配合 inline,因为编译器在调用点展开函数时可以把真实类型填进去。

inline fun <reified T> Any?.castOrNull(): T? = this as? T

inline fun <reified T> requireType(value: Any) {
    check(value is T) { "Expected ${T::class.java.name}" }
}

注意边界:

  • reified T 可以做 value is TT::classT::class.java
  • 它不能彻底恢复嵌套泛型实参,例如 List<String>List<Int> 的元素类型仍受擦除影响。
  • public inline API 会把实现暴露给调用方字节码,库开发要注意二进制兼容和实现细节泄露。

三、委托:类委托、属性委托与工程价值

委托强调“组合优于继承”。类委托把接口实现转交给成员对象,属性委托把 getter/setter 的通用逻辑抽出去。

类型写法典型场景易错点
类委托class Repo(ds: DataSource) : DataSource by ds包装、增强、替换实现覆盖的方法不会自动影响被委托对象内部的自调用
lazyval x by lazy { ... }延迟初始化重对象默认同步锁有开销,可配置 LazyThreadSafetyMode
observablevar p by Delegates.observable(...)状态变更回调回调里再改同一属性要避免递归
notNullvar id by Delegates.notNull<Int>()非空但延后赋值访问前未赋值会抛异常
自定义委托operator fun getValue埋点、配置、缓存、校验关注线程安全和异常边界

Android 中常见落地是 ViewBinding 委托、SharedPreferences/DataStore 属性包装、配置中心读取和页面参数校验。

四、sealed interface、value class 与领域建模

sealed class / sealed interface 用来表达有限状态集合,让 when 具备穷尽检查。sealed interface 比 sealed class 更灵活:实现类仍可继承其他类,也能让枚举、data class、object 同时表达同一协议。

sealed interface LoginState {
    data object Idle : LoginState
    data object Loading : LoginState
    data class Success(val token: Token) : LoginState
    data class Failed(val message: String) : LoginState
}

@JvmInline
value class Token(val raw: String)

value class 用单字段包装领域概念,在很多场景下可被编译为底层字段,减少运行时对象包装。它适合 UserIdOrderIdToken 这类“类型不同但底层都是 String/Long”的值,能减少参数传错。

限制也要会讲:

  • value class 只能有一个主构造属性,不能有 backing field 的额外状态。
  • 遇到泛型、可空、接口装箱、反射等场景可能仍会 box。
  • Java 调用时可能看到 mangled 方法名或底层类型,需要 @JvmName、API 设计和文档约束。

五、context receivers 概览

context receivers 让函数声明“需要哪些上下文能力”,调用点处于对应上下文时才能调用。它适合 DSL、依赖环境显式化、避免参数列表过长。

// 概念示例:实际可用性取决于 Kotlin 版本和编译选项
context(Logger, CoroutineScope)
fun launchTracked(name: String, block: suspend () -> Unit) {
    log("start $name")
    launch { block() }
}

面试中不要把它讲成 Android 必备能力,而是讲成“更显式的上下文依赖建模”。工程落地要看团队 Kotlin 版本、IDE 支持、可读性和 Java 互操作成本。

六、JVM 字节码角度看 Kotlin

Kotlin 很多高级语法都会落到 JVM 的普通类、静态方法、字段和状态机上。理解这一点能解释性能、混淆、Java 互操作和调试问题。

Kotlin 语法JVM 视角面试意义
top-level functionFileNameKt 静态方法Java 调用名、@JvmName
extension function静态方法,接收者是第一个参数静态分发,不能多态重写
default argument生成 $default 辅助方法和 bitmaskJava 不天然支持默认参数
object单例类 + INSTANCE初始化时机、反射/混淆
suspend function多一个 Continuation 参数协程状态机、异常栈理解
inline/value class调用点展开或底层值传递性能收益和字节码膨胀边界

因此分析 Kotlin 问题时可以用 javap、Android Studio bytecode viewer 或反编译 Java 结果辅助理解,但不要机械迷信反编译代码,因为它只是语义近似。

七、Java 互操作与空安全陷阱

Kotlin 的空安全在纯 Kotlin 里很强,但 Java 互操作会出现平台类型 String!,编译器无法判断可空性。

  • Java 方法无注解返回 String 时,Kotlin 看到的是 String!,你可以当可空或非空用,风险由调用方承担。
  • Java 集合可把 null 塞进 MutableList<String> 的底层对象,导致 Kotlin 侧遍历时 NPE。
  • @Nullable / @NonNull、JSR-305、AndroidX 注解能改善推断,但依赖库注解质量不一。
  • Kotlin 默认参数、命名参数、顶层函数、internal、value class 对 Java 调用并不总是友好,公共 SDK 要专门设计 Java API。

经验回答:跨 Java 边界时要把平台类型当“不可信输入”,在边界层做 null 归一化、参数校验和异常转换,不要让平台类型扩散到核心业务层。

八、更深一层的协程状态机

suspend 不等于切线程,它表示函数可挂起。编译器会把 suspend 函数改写成 Continuation Passing Style(CPS):多一个 Continuation 参数,局部变量被保存到 Continuation 派生对象字段里,挂起点用 label 区分。

suspend fun load() {
    val user = api.getUser()   // label 0 挂起点
    db.save(user)              // label 1 挂起点
}

// 直觉模型:
// when(label) {
//   0 -> 调用 getUser,挂起则返回 COROUTINE_SUSPENDED
//   1 -> 恢复 user,继续 save
// }

深入点:

  • 挂起时不阻塞线程,只是把后续执行封装进 Continuation,等待回调恢复。
  • 局部变量跨挂起点会变成状态机字段,所以大对象跨挂起点存活可能延长生命周期。
  • 异常传播仍遵循协程 Job 层级,不是普通线程未捕获异常那一套。
  • withContext(Dispatchers.IO) 是切换协程恢复的调度器,不是直接创建新线程。
  • 调试协程栈时要结合 Coroutine Debugger、结构化并发父子关系和业务日志。

高频面试题

Q1:inline、noinline、crossinline 分别解决什么问题? inline 把函数和 lambda 展开到调用点,减少对象分配并支持非局部返回;noinline 用于需要把 lambda 当对象保存或传递的参数;crossinline 用于 lambda 会在其他上下文调用时禁止非局部返回,保证控制流安全。

Q2:reified 为什么必须配合 inline? 因为 JVM 泛型擦除后运行时没有普通 T 的类型信息。inline 会在调用点展开代码,编译器能把真实类型写入调用点,所以 T::classvalue is T 才可用。

Q3:value class 一定没有对象分配吗? 不一定。它在很多直接使用场景可用底层值表示,但遇到泛型、可空、接口、多态、反射等场景可能装箱。回答时要强调它主要提升类型安全,性能收益要看字节码和基准测试。

Q4:Kotlin 空安全为什么仍可能 NPE? 主要来自平台类型、!!、lateinit 未初始化、Java 集合污染、反射/序列化以及并发时序。跨 Java 边界要做显式 null 校验,不要让平台类型扩散。

Q5:suspend 函数底层是什么? 编译器把 suspend 函数改写成带 Continuation 参数的状态机,挂起点用 label 记录进度,跨挂起点局部变量保存到状态机字段里。挂起不是阻塞线程,恢复由调度器和 Continuation 驱动。

易错点 / 追问

  • 不要说 inline 一定更快;它减少 lambda 开销,但可能造成字节码膨胀。
  • 不要说 reified 能完整解决所有泛型擦除;嵌套泛型实参仍有限制。
  • sealed interface 适合有限状态建模,但跨模块扩展边界和二进制兼容要提前设计。
  • value class 首要价值是领域类型安全,不是“零成本对象”的绝对承诺。
  • Java 互操作时平台类型要当作风险边界处理,而不是盲信 Kotlin 的非空类型。

Kotlin 协程与 Flow ★

这是你的头号短板,也是中级面试的高频核心。 协程是现代 Android 异步编程的基石,几乎必问。本篇从原理到实战到面试题完整覆盖。

一、协程是什么(先建立心智模型)

协程不是线程。它是一种可挂起/恢复的计算,由编译器 + 运行时在用户态调度。

  • 挂起(suspend):不阻塞线程。协程挂起时,它所在的线程被释放去做别的事,等条件满足再恢复执行。
  • 核心价值:用同步顺序的写法表达异步逻辑,消灭回调地狱。
// 回调写法
api.login(user) { token ->
    api.getProfile(token) { profile -> updateUI(profile) }
}
// 协程写法
val token = api.login(user)        // suspend,挂起不阻塞
val profile = api.getProfile(token) // suspend
updateUI(profile)

二、suspend 原理(高频追问:协程为什么不阻塞线程?)

suspend 函数由 Kotlin 编译器做 CPS 变换(Continuation-Passing Style):

  1. 编译器给每个 suspend 函数隐式加一个参数 Continuation(回调),返回值变成 Any?
  2. 函数体被编译成一个状态机:每个挂起点是一个状态。
  3. 挂起时函数返回特殊标记 COROUTINE_SUSPENDED,线程被释放。
  4. 当结果就绪,调用 continuation.resumeWith(result),状态机从上次挂起点恢复执行。

所以协程“挂起不阻塞“的本质:把后续代码包装成回调,挂起时退出函数让出线程,恢复时再回来。这是面试最爱的深挖点。

// 你写的:
suspend fun login(user: User): Token
// 编译后近似:
fun login(user: User, cont: Continuation<Token>): Any?

三、结构化并发(Structured Concurrency)

协程必须在 CoroutineScope 中启动。核心:协程有父子层级,父协程等待所有子协程完成,父被取消则子全部取消,避免协程泄漏。

  • CoroutineScope:协程作用域,持有 CoroutineContext
  • CoroutineContext:元素集合,关键有 Job(生命周期)、CoroutineDispatcher(线程)、CoroutineNameCoroutineExceptionHandler
  • Job:协程生命周期句柄,可 cancel() / join(),状态有 Active/Completing/Cancelled/Completed。
  • 父子关系:协程内启动的子协程,其 Job 是父 Job 的子节点。

Android 常用现成 scope:

  • viewModelScope:绑定 ViewModel,onCleared 自动取消。
  • lifecycleScope:绑定 Lifecycle。
  • GlobalScope:不推荐,脱离结构化并发,易泄漏。
class MyViewModel : ViewModel() {
    fun load() = viewModelScope.launch {       // ViewModel 销毁自动取消
        val data = repo.fetch()                 // suspend
        _state.value = data
    }
}

四、Dispatchers 与 withContext

Dispatcher用途线程池
Dispatchers.MainUI 操作主线程
Dispatchers.IO网络 / 磁盘 IO共享弹性池(默认上限 64)
Dispatchers.DefaultCPU 密集(排序/解析)核数大小
Dispatchers.Unconfined不限定(测试/特殊)当前线程,恢复后随挂起点
viewModelScope.launch {                         // Main
    val data = withContext(Dispatchers.IO) {    // 切到 IO
        api.fetch()
    }
    textView.text = data                        // 自动回到 Main
}

withContext 切换上下文并挂起等待结果返回,是最常用的线程切换方式。IO 与 Default 共享底层线程池,两者间切换不一定真正换线程(优化)。

五、launch vs async

  • launch:返回 Job,不返回结果,适合“发射后不管“的副作用;异常立即传播给父。
  • async:返回 Deferred<T>,await() 取结果,适合并发计算多个值;异常在 await() 时抛出。
// 并发请求,总耗时 = max 而非 sum
val a = async { api.getA() }
val b = async { api.getB() }
val result = a.await() + b.await()

六、取消机制(协作式)

协程取消是协作式的:取消只是把 Job 标记为 Cancelling,真正停止需要协程代码配合检查

  • 所有 kotlinx.coroutines 的 suspend 函数(delay 等)在恢复时会检查取消状态,抛出 CancellationException
  • 纯 CPU 循环不会自动响应取消,需手动检查 isActive / ensureActive() / yield()
val job = scope.launch {
    while (isActive) {        // 配合检查,否则取消无效
        doHeavyWork()
    }
}
job.cancel()                  // 标记取消

易错点:

  • CancellationException 是正常的取消信号,不要在 catch 中吞掉它,否则破坏取消。try/catch (e: Exception) 会误捕获它,应 catch (e: CancellationException) { throw e } 或只 catch 具体异常。
  • 取消后想做清理用 try/finally,但 finally 中若要再调挂起函数,需用 withContext(NonCancellable)

七、异常处理

  • launch:异常会立即向上传播给父 Job,触发父及所有兄弟取消。
  • async:异常被封装在 Deferred,await() 时才抛出。
  • CoroutineExceptionHandler:只对 launch 的根协程生效,作为“兜底“处理未捕获异常。
  • SupervisorJob / supervisorScope:子协程失败不影响兄弟和父协程(单向传播)。适合多个独立任务的场景(如同时加载多个卡片,一个失败不拖垮其他)。
supervisorScope {
    launch { riskyA() }   // A 失败不影响 B
    launch { riskyB() }
}

对比记忆:普通 Job 一个子失败全家取消;SupervisorJob 子失败各自负责。

八、Flow(冷流)

Flow 是协程版的“异步数据流“,可以发射多个值(协程的 suspend 函数只返回一个值)。

  • 冷流(Cold):Flow 默认是冷的——没有收集者就不执行,每个 collect 都重新触发 上游。类比“按需播放的录像“。
  • 构建:flow { emit(x) }flowOf()asFlow()
  • 操作符:map/filter/transform(中间操作,惰性),collect/first/toList(末端操作,触发执行)。
  • 线程切换:flowOn(Dispatchers.IO) 只影响上游;收集所在线程由 collect 处的 scope 决定。不要在 flow{} 里用 withContext 切线程(会报错),要用 flowOn。
  • 背压:buffer()(并发缓冲,让上游发射和下游收集可在缓冲容量内解耦)、conflate()(下游慢时只保留最新值,适合 UI 进度/状态)、collectLatest(新值到来取消上个收集块,适合搜索联想/列表刷新)。边界是:这些操作符优化的是“生产快、消费慢“的处理方式,不应掩盖下游耗时任务本身;被取消的收集块仍要遵守协作式取消。
flow {
    emit(fetchFromNetwork())   // 上游在 IO
}.flowOn(Dispatchers.IO)
 .map { it.toUiModel() }
 .collect { render(it) }       // 收集在调用方线程(如 Main)

九、StateFlow 与 SharedFlow(热流)

热流(Hot):不管有没有收集者都“活着“,发射独立于收集者。用于状态/事件,是 Android MVVM 中替代 LiveData 的主力。

StateFlow

  • 持有一个最新值,新收集者立即拿到当前值(类似 LiveData)。
  • 必须有初始值,value 可读可写(MutableStateFlow)。
  • 去重:值相等(equals)时不发射(conflate 语义)。
  • 适合表达 UI State
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
_uiState.value = UiState.Success(data)

SharedFlow

  • 可配置 replay(给新订阅者重放几个值)、extraBufferCapacityonBufferOverflow
  • 无初始值,不去重。
  • 适合一次性事件(导航、Toast、SnackBar)—— 用 replay=0 避免事件重放。

对比表

维度LiveDataStateFlowSharedFlow
Jetpack协程协程
初始值必须
生命周期感知自带repeatOnLifecyclerepeatOnLifecycle
去重
粘性可配 replay
适用老项目状态新项目状态事件

Android 正确收集姿势(避免后台浪费):

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

进阶补充:Channel、callbackFlow 与测试

Channel 与 callbackFlow

Channel 更像协程里的阻塞队列,适合一对一事件传递;callbackFlow 用于把回调式 API 包装成 Flow。

fun locationFlow(): Flow<Location> = callbackFlow {
    val listener = LocationListener { location -> trySend(location) }
    locationClient.addListener(listener)
    awaitClose { locationClient.removeListener(listener) }
}

关键点:awaitClose 必须释放监听器,否则会泄漏。

combine / zip / flatMapLatest

操作符行为场景
combine任一上游变化就用最新值组合表单状态、多个配置源
zip等两边一一配对两个一次性结果合并
flatMapLatest新请求来时取消旧请求搜索框、筛选条件变化

stateIn / shareIn

stateIn 把冷 Flow 转成有当前值的 StateFlow;shareIn 把冷 Flow 共享给多个订阅者。面试要说明 scope、started 策略和 replay。

协程测试

使用 runTestStandardTestDispatcher 和虚拟时间,不要在测试里真实 delay

**追问:**为什么 callbackFlow 里要写 awaitClose?因为 Flow 被取消时需要注销外部回调,否则回调继续持有对象导致泄漏。


高频面试题

Q1:协程和线程的区别? 线程是 OS 调度的重量级资源;协程是用户态的轻量“可挂起计算“,多个协程可复用少量线程。协程挂起不阻塞线程。一个线程能跑成千上万协程。

Q2:suspend 关键字做了什么?协程凭什么不阻塞线程? 编译器对 suspend 函数做 CPS 变换,加 Continuation 参数,函数体编译成状态机。挂起时返回 COROUTINE_SUSPENDED 并释放线程,结果就绪后通过 resumeWith 从挂起点恢复。所以是“挂起协程“而非“阻塞线程“。

Q3:launch 和 async 区别?async 不 await 会怎样? launch 返回 Job 无结果,async 返回 Deferred 有结果。async 启动后若不 await,异常不会抛出(被封装),可能静默丢失。

Q4:协程的取消是怎样的?为什么有时 cancel 无效? 协作式取消。cancel 只标记状态,suspend 函数恢复时检查并抛 CancellationException。纯 CPU 循环不调用 suspend 函数,不会响应取消,需手动 isActive/ensureActive/yield。

Q5:Job 和 SupervisorJob 区别? 普通 Job:子协程异常会取消父和所有兄弟。SupervisorJob:子异常只影响自己,不向上传播取消兄弟。多个独立任务用 supervisorScope。

Q6:CoroutineScope、CoroutineContext、Job 的关系? Scope 持有 Context;Context 是元素集合(Job、Dispatcher 等);Job 管理生命周期与父子层级。三者共同实现结构化并发。

Q7:Flow 冷热的区别?StateFlow 和 SharedFlow 怎么选? 冷流无收集者不执行、每次 collect 重新触发;热流独立于收集者。状态用 StateFlow(有初值、去重),事件用 SharedFlow(replay=0 防重放)。

Q8:为什么用 StateFlow 替代 LiveData?LiveData 有什么坑? StateFlow 不依赖 Android、可在纯 Kotlin 层用、操作符丰富、与协程统一。LiveData 的坑:粘性事件(新观察者收到旧值)、只能主线程 setValue、observeForever 易泄漏。但 StateFlow 不感知生命周期,需 repeatOnLifecycle。

Q9:flowOn 和 withContext 区别?能在 flow{} 里 withContext 切线程吗? 不能。flow{} 内 emit 必须在收集协程的上下文,直接 withContext 会抛异常(违反上下文保留)。要切上游线程用 flowOn,它只影响上游操作符。

Q10:repeatOnLifecycle 解决什么问题? 解决“App 退后台时仍在收集 Flow 浪费资源/可能崩溃“。它在进入指定状态(如 STARTED)时启动收集,离开时取消,再次进入重启,是官方推荐的安全收集方式。

RxJava 与响应式编程

RxJava 在新项目里常被协程/Flow 替代,但大量老 Android 项目仍在使用。中级面试经常考“你能不能维护老项目,并解释迁移取舍“。

一、响应式编程心智模型

RxJava 把异步事件看成数据流:上游发射、操作符转换、下游订阅、调度器切线程。

二、Observable、Single、Maybe、Completable、Flowable

类型含义适用
Observable0..N 个事件UI 事件、普通流
Single1 个结果或错误网络请求
Maybe0 或 1 个结果缓存查询
Completable只关心完成/失败写入、删除
Flowable支持背压高频数据流

三、调度器与线程切换

subscribeOn 影响上游订阅线程,通常只第一个生效;observeOn 切换下游观察线程,可多次使用。

api.getUser()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(::renderUser, ::showError)

四、常用操作符

  • 变换:mapflatMapconcatMap
  • 过滤:filterdistinctUntilChanged
  • 组合:zipcombineLatestmerge
  • 错误:onErrorReturnretryWhen
  • 生命周期:takeUntil、AutoDispose/RxLifecycle

五、背压与资源释放

背压问题来自上游生产快、下游消费慢。Flowable 可配置 BUFFERDROPLATEST 等策略。Android 页面销毁时必须 dispose,否则可能泄漏 Activity。

六、RxJava 与协程/Flow 对比

维度RxJava协程/Flow
学习成本高,操作符多Kotlin 原生,结构化并发
取消DisposableJob/coroutine cancellation
背压Flowable 专门处理Flow 挂起/缓冲操作符
老项目生态新项目更主流

高频面试题

Q1:subscribeOn 和 observeOn 区别? 答:subscribeOn 决定订阅发生在哪个线程,通常第一个生效;observeOn 决定后续观察者在哪个线程执行,可以多次切换。

Q2:RxJava 为什么容易内存泄漏? 答:订阅链持有 observer/lambda,如果页面销毁后未 dispose,异步结果回来仍可能持有 Activity/View。

Q3:老项目 RxJava 怎么迁移到协程? 答:先从边界层开始,比如 Retrofit 支持 suspend;内部复杂链路可逐步迁移,不要一次性重写全部业务流。

易错点 / 追问

  • 不要把所有异步都写成复杂操作符链。
  • 不要忘记错误分支,否则链路会终止。
  • 不要在主线程做重 map/flatMap 计算。

Java 与 JVM 基础

即便项目用 Kotlin,面试官仍常问 Java/JVM——因为字节码、并发、GC 是共通的底层。这一篇覆盖高频考点。

一、集合框架

HashMap(必考)

  • 结构:数组 + 链表 + 红黑树(JDK8)。链表长度 ≥ 8 且数组长度 ≥ 64 转红黑树;退化阈值 6。
  • 初始容量 16,负载因子 0.75,扩容翻倍(2 倍)。容量始终是 2 的幂,便于用 (n-1) & hash 取代取模。
  • 扰动函数:hash = h ^ (h >>> 16),让高位参与运算,减少碰撞。
  • JDK8 头插改尾插:JDK7 头插法在并发扩容时会形成环形链表导致死循环;JDK8 改尾插缓解,但 HashMap 仍非线程安全
  • 并发问题:多线程 put 可能丢数据、扩容期间读到 null。

ConcurrentHashMap

  • JDK7:分段锁 Segment;JDK8:CAS + synchronized 锁单个桶头节点,粒度更细,并发度更高。
  • size() 用 baseCount + CounterCell 分散统计。

其他

  • ArrayList(动态数组,扩容 1.5 倍)vs LinkedList(双向链表)。
  • HashSet 底层是 HashMap;LinkedHashMap 维护插入/访问顺序(可做 LRU)。

二、并发

synchronized

  • 对象头 Mark Word 记录锁状态、GC 年龄、hashCode 或指向锁记录/monitor 的指针。synchronized 进入临界区时会围绕 Mark Word 做 CAS 或 monitor 竞争,所以面试讲锁升级不能只背流程,要能说清“对象头里记录了什么“。
  • 无锁:对象未被线程持有;第一次进入同步块时,运行时会尝试在 Mark Word 中记录当前锁形态或把对象头复制到线程栈上的 Lock Record。
  • 偏向锁:面向“同一线程反复进入同一把锁“的场景,Mark Word 记录偏向线程 ID,后续同线程进入几乎不需要 CAS。出现其他线程竞争、调用 hashCode() 等需要占用 Mark Word 的信息时,可能触发偏向撤销或重偏向。版本边界要说清:JDK 15 起偏向锁被废弃并默认关闭,JDK 18 之后 HotSpot 已移除相关实现,新版本面试更应把它当历史优化理解。
  • 轻量级锁:多线程交替进入但竞争不激烈时,线程在栈帧创建 Lock Record,用 CAS 把对象 Mark Word 指向该记录;失败后可能自旋,希望持锁线程很快退出,避免立即阻塞到内核态。
  • 重量级锁:竞争持续、线程自旋失败或需要阻塞/唤醒时,锁膨胀为 ObjectMonitor,未抢到锁的线程进入阻塞队列,由操作系统互斥量参与调度,吞吐更稳定但上下文切换成本更高。
  • 升级边界:常见路径可概括为无锁 → 偏向锁 → 轻量级锁 → 重量级锁,但这主要描述 HotSpot 早期到 JDK 14 默认配置下的优化路径;锁可以膨胀,退出同步块后不等于立刻恢复到最轻状态,具体是否偏向、是否自旋和阈值会受 JDK 版本与 JVM 参数影响。
  • 修饰实例方法锁 this,静态方法锁 Class,代码块锁指定对象。

volatile

  • 保证可见性(写立即刷主存,读从主存)和有序性(禁止指令重排,插入内存屏障)。
  • 不保证原子性(如 i++ 仍不安全)。
  • 经典用途:双重检查锁单例的实例字段。

CAS 与 AQS

  • CAS(Compare-And-Swap):无锁原子操作,compareAndSwap(内存值, 期望值, 新值),失败自旋重试。底层是 CPU 指令。
  • ABA 问题:值从 A→B→A,CAS 误判没变。用版本号(AtomicStampedReference)解决。
  • AQS(AbstractQueuedSynchronizer):用 volatile int state + CLH 队列实现的同步框架,ReentrantLock、CountDownLatch、Semaphore 都基于它。

线程池 ThreadPoolExecutor

七个参数:corePoolSize(核心线程)、maximumPoolSize(最大)、keepAliveTimeunitworkQueue(任务队列)、threadFactoryhandler(拒绝策略)。

执行流程:核心线程未满 → 创建核心线程;满了 → 入队;队列满 → 创建非核心线程到 max;再满 → 触发拒绝策略(AbortPolicy 抛异常 / CallerRunsPolicy 调用者执行 / DiscardPolicy 丢弃 / DiscardOldestPolicy 丢最老)。

三、JVM 内存与 GC

内存区域

  • 线程私有:程序计数器、虚拟机栈、本地方法栈。
  • 线程共享:堆(对象实例,GC 主战场)、方法区/元空间(类信息,JDK8 后用本地内存)。
  • 注意 Android 用 ART/Dalvik 而非标准 JVM,但内存模型概念相通。

GC

  • 判活:引用计数(有循环引用问题)vs 可达性分析(GC Roots,主流)。
  • GC Roots:虚拟机栈引用、静态变量、常量、JNI 引用等。
  • 回收算法:标记-清除(碎片)、复制(浪费空间,适合新生代)、标记-整理(适合老年代)。
  • HotSpot 分代模型:服务端 JVM 常按新生代/老年代理解,Eden + Survivor、Minor GC、Major/Full GC 是典型面试语言;但比例(如 8:1:1)和具体算法不是 Java 语言规范,会受 JVM 版本、收集器和参数影响。
  • HotSpot 回收器边界:Serial 适合小堆/单核或客户端场景;Parallel 关注吞吐;CMS 关注低停顿但有碎片和浮动垃圾问题,已在 JDK 9 标记废弃、JDK 14 移除;G1 将堆划分为 Region,兼顾可预测停顿;ZGC/Shenandoah 面向大堆低停顿。回答时应说“这些是 HotSpot 收集器“,不要直接套到 Android。
  • Android ART 区分:Android App 运行在 ART(早期为 Dalvik)上,不是标准 HotSpot Server VM。ART 也有堆、线程栈、JNI 引用、可达性分析和并发/分代等 GC 思路;Android Runtime 文档里的 CMS/CC 是 ART 自己的 GC plan,不等同于 HotSpot CMS/G1/ZGC,普通应用也不是通过 HotSpot collector 参数来选择它们。Android 面试更关注内存泄漏、对象分配抖动、Bitmap/native 内存、GC pause 对掉帧的影响。
  • 安全表述:讲 JVM 基础时可用 HotSpot 解释 Serial/Parallel/G1/ZGC;讲 Android 性能时应切到 ART 语境,用“ART 的具体 GC 策略随 Android 版本和设备实现演进“这类条件措辞,避免把服务端 JVM 参数经验当作移动端结论。

类加载

  • 过程:加载 → 验证 → 准备 → 解析 → 初始化。
  • 双亲委派:类加载请求先委托父加载器,父无法加载才自己加载。保证核心类不被篡改(如自定义 String 不会覆盖系统的)。
  • 打破双亲委派:SPI、热修复、插件化、Tomcat 的 WebappClassLoader。

高频面试题

Q1:HashMap 为什么线程不安全?ConcurrentHashMap 怎么优化? HashMap 并发 put 会丢数据,JDK7 扩容还会成环死循环。ConcurrentHashMap JDK8 用 CAS + synchronized 锁桶头,只锁单个桶,并发度高。

Q2:volatile 能保证原子性吗? 不能。只保证可见性和有序性。i++ 是读-改-写三步,volatile 不能保证复合操作原子,需用 Atomic 类或锁。

Q3:synchronized 锁升级过程? 先讲对象头 Mark Word:它保存锁标志位、GC 年龄、hashCode 或指向 Lock Record/ObjectMonitor 的指针。典型 HotSpot 路径是无锁 → 偏向锁(同一线程反复进入,Mark Word 记录线程 ID)→ 轻量级锁(栈上 Lock Record + CAS,少量竞争时自旋)→ 重量级锁(ObjectMonitor,竞争线程阻塞/唤醒)。边界是:偏向锁在 JDK 15 起废弃并默认关闭,JDK 18 后 HotSpot 移除;新版本里不要把偏向锁当成必经阶段。锁膨胀后通常不会在退出同步块时立刻退回最轻状态,具体策略受 JVM 版本和参数影响。

Q4:线程池核心线程会被回收吗? 默认不会。设置 allowCoreThreadTimeOut(true) 后核心线程空闲超时也回收。

Q5:为什么用线程池?核心线程数怎么定? 复用线程降低创建销毁开销、控制并发数、统一管理。CPU 密集型设为核数+1;IO 密集型设为核数×2 或更高(经验值,需压测)。

Q6:双亲委派的作用?为什么 Android 热修复要打破它? 保证类的唯一性和安全性。热修复需要让补丁类优先于原类被加载,所以要把补丁 dex 插到 classloader 的 dexElements 数组前面,本质是绕过/利用加载顺序。

四大组件与基础

四大组件是 Android 的根基,中级面试常深挖生命周期、启动模式、跨进程。

一、Activity

生命周期

onCreate → onStart → onResume →(运行)→ onPause → onStop → onDestroy;onRestart 用于从 onStop 返回。

关键场景:

  • A 启动 B:A.onPause → B.onCreate → B.onStart → B.onResume → A.onStop(B 的 onResume 在 A 的 onStop 之前)。
  • 透明/Dialog 主题的 B 覆盖 A:A 只 onPause 不 onStop。
  • 横竖屏旋转:默认销毁重建,走完整生命周期 + onSaveInstanceState/onRestoreInstanceState;配 configChanges 可避免重建只回调 onConfigurationChanged

启动模式(LaunchMode)

  • standard:默认,每次新建实例,入当前任务栈。
  • singleTop:栈顶复用,栈顶是它则走 onNewIntent,否则新建。(防通知重复打开)
  • singleTask:栈内唯一,已存在则复用并清除其上的 Activity(clearTop),走 onNewIntent。(主页/首页)
  • singleInstance:独占一个任务栈。(来电、闹钟)

taskAffinity 指定任务栈归属;Intent flag(FLAG_ACTIVITY_NEW_TASK 等)可动态控制。

二、Fragment

  • 生命周期比 Activity 多:onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume ...→ onDestroyView → onDestroy → onDetach
  • commit vs commitNow vs commitAllowingStateLoss:commit 异步;commitNow 同步;前两者在 onSaveInstanceState 后调用会抛 IllegalStateException,allowingStateLoss 容忍但可能丢状态。
  • 现代用 FragmentStateAdapter + ViewPager2 替代旧懒加载;getViewLifecycleOwner 观察 LiveData 防泄漏。
  • 为什么用 Fragment 而非多 Activity:轻量、复用、共享 ViewModel、单 Activity 架构配合 Navigation。

三、Service

  • 启动方式:startService(独立运行,需手动 stopSelf/stopService)vs bindService(绑定,随调用者销毁,可通信)。
  • 前台服务:必须 startForeground 显示通知(Android 8+ 后台限制),用于音乐、定位、下载。Android 12+ 还有前台服务类型限制。
  • IntentService 已废弃:用 WorkManager 或协程替代后台任务。
  • Service 不是线程:默认运行在主线程,耗时操作仍需开子线程,否则 ANR。

四、BroadcastReceiver

  • 注册方式:
    • 静态注册(Manifest):适合系统或跨应用广播;Android 8.0 起对很多隐式广播做了限制,普通应用不要依赖静态注册来长期唤醒进程。
    • 动态注册(registerReceiver):跟随 Activity/Fragment/Service 生命周期,常在 onStart/onResume 注册、onStop/onPause 反注册,用于只在界面可见或组件存活时接收。
  • 分发流程:sendBroadcast/sendOrderedBroadcast → ActivityManager/系统广播队列匹配 IntentFilter → 找到静态/动态 Receiver → 目标进程已在则直接回调,未启动且允许则拉起进程 → 主线程调用 onReceive
  • 类型边界:
    • 普通广播:异步分发,多个 Receiver 接收顺序不应作为业务依赖。
    • 有序广播:按优先级依次分发,前一个 Receiver 可通过 setResult* 改结果,也可在允许的场景中 abortBroadcast() 终止后续接收;因此更像一条责任链。
    • LocalBroadcastManager / 粘性广播:都已不推荐。应用内事件优先用 StateFlow/SharedFlow、回调、Lifecycle-aware observer 或明确的 Repository 状态流。
class BatteryReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        // onReceive 运行在主线程,只做轻量解析;耗时任务交给 WorkManager/协程/Service
        val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
        Log.d("BatteryReceiver", "level=$level")
    }
}

private val receiver = BatteryReceiver()

override fun onStart() {
    super.onStart()
    registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}

override fun onStop() {
    unregisterReceiver(receiver)
    super.onStop()
}

常见错误:

  • onReceive 中做网络/数据库等耗时操作,导致主线程卡顿甚至 ANR;需要异步任务时用 goAsync() 争取短暂收尾,或转交 WorkManager/前台 Service。
  • 动态注册后忘记反注册,导致组件泄漏或重复收到广播。
  • 把广播当作应用内事件总线,导致来源不清、生命周期难控;中级面试更推荐说明“跨组件/跨应用通知可以用广播,应用内状态流优先用 Flow”。
  • 忽略 Android 8.0+ 隐式广播限制,把 Manifest 静态注册当成稳定后台唤醒手段。

面试答题结构:先说“注册方式与生命周期”→ 再说“普通/有序广播分发差异”→ 补“8.0+ 限制与现代替代”→ 最后强调“onReceive 主线程、轻量处理、及时反注册”。

五、ContentProvider

  • 定位:跨进程数据共享的标准组件,用 content://authority/path/id 形式的 URI 寻址,对外暴露 query/insert/update/delete/openFile 等接口。系统通讯录、媒体库就是典型例子。
  • 对象关系:
    • ContentResolver:调用方入口,不直接依赖 Provider 实现类。
    • ContentProvider:被调用方组件,负责权限校验、URI 分发、数据读写。
    • UriMatcher:把不同 URI path 映射到不同业务分支,避免手写大量字符串判断。
  • 跨进程调用流:调用方 ContentResolver.query(uri) → 系统根据 authority 找到目标 Provider → 必要时启动目标进程并创建 Provider → 通过 Binder 进入 Provider 的 query/insert/update/delete → Provider 访问 SQLite/文件/内存数据 → Cursor/结果返回调用方
class UserProvider : ContentProvider() {
    private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI("com.example.user", "users", 1)
        addURI("com.example.user", "users/#", 2)
    }

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?,
    ): Cursor? = when (matcher.match(uri)) {
        1 -> queryAllUsers()
        2 -> queryUser(uri.lastPathSegment!!.toLong())
        else -> throw IllegalArgumentException("Unknown uri: $uri")
    }
}

// 调用方只依赖 URI + ContentResolver
val cursor = context.contentResolver.query(
    Uri.parse("content://com.example.user/users/42"),
    null,
    null,
    null,
    null,
)
  • 为什么能早于 Application 初始化:系统在创建应用进程时会先安装并创建该进程声明的 ContentProvider,然后再进入 Application.onCreate()。Jetpack App Startup 利用这个时机做库初始化,但现代工程应收敛自动初始化,避免启动链路不可见、冷启动变慢。
  • 权限与边界:对外暴露 Provider 要配置读写权限、exported、URI grant 或签名级权限;内部初始化 Provider 不应暴露敏感数据。Provider 方法可能被跨进程并发调用,数据库访问要考虑线程安全和事务。

常见错误:

  • 只记“CRUD + URI”,说不清 ContentResolver → authority → Provider → Binder 的调用链。
  • 把 Provider 当普通单例初始化器滥用,导致 SDK 隐式启动、冷启动耗时难定位。
  • 忽略 exported/permission/grantUriPermission,把内部数据暴露给其他应用。

面试答题结构:先解释“URI + ContentResolver/Provider 解耦”→ 再画出“跨进程 Binder 调用链”→ 举“通讯录/媒体库/App Startup”例子 → 最后补“权限、并发、启动成本”的坑。

六、序列化与 Context

  • Serializable vs Parcelable:Serializable 是 Java 反射序列化,慢、产生大量临时对象;Parcelable 是 Android 专为内存/IPC 设计,快但代码繁琐(Kotlin 用 @Parcelize 自动生成)。跨进程/Intent 传对象用 Parcelable。
  • Context 类型:Application Context(全局,生命周期最长,不能用于 UI/Dialog)、Activity Context(带主题,可弹窗,注意泄漏)。getApplicationContext 用于单例避免持有 Activity。

进阶补充:现代组件 API 与版本限制

Activity Result API

startActivityForResult 已不推荐。Activity Result API 把启动和结果回调绑定到 lifecycle,避免配置变更后的回调丢失。

运行时权限

Android 6.0 后危险权限运行时申请;Android 13 引入通知权限;Android 14 对前台服务类型和后台启动有更多限制。面试要按版本讲,不要只背一套老流程。

Fragment 回退栈与状态丢失

commit() 是异步;commitNow() 立即执行但不能加入 back stack。onSaveInstanceState 后再提交可能 state loss。setMaxLifecycle 常用于 ViewPager2 控制页面生命周期。

前台 Service 类型

前台服务必须有用户可见通知,新版本要求声明类型,如 location、mediaPlayback、dataSync。不能把前台服务当万能保活工具。

**追问:**为什么 Fragment 会出现状态丢失?因为系统已保存 Activity 状态后,再提交 Fragment 事务无法保证恢复一致性。


高频面试题

Q1:singleTask 和 singleInstance 区别? singleTask 在所属任务栈内唯一,可与其他 Activity 共栈;singleInstance 独占一个任务栈,栈内只有它一个。

Q2:横竖屏切换 Activity 生命周期?如何保存数据? 默认销毁重建(onPause→onStop→onDestroy→onCreate→…)。用 onSaveInstanceState 保存临时数据,或用 ViewModel(配置变更时存活)保存。配 android:configChanges 可避免重建。

Q3:onSaveInstanceState 何时调用?和 onPause 顺序? 在 Activity 可能被系统销毁前调用(如旋转、内存不足、切后台)。Android P 后在 onStop 之后调用。正常返回键退出不调用(因为是用户主动销毁)。

Q4:Serializable 和 Parcelable 怎么选? 内存传递、IPC、性能敏感场景用 Parcelable;持久化到磁盘或简单场景可用 Serializable。Kotlin 用 @Parcelize 减少模板代码。

Q5:为什么不能用 Application Context 弹 Dialog? Dialog 需要 Activity 的主题和 Window token,Application Context 没有,会抛 BadTokenException。

Q6:Service 和 Thread 区别?Service 能做耗时操作吗? Service 是组件,运行在主线程,不是线程;它的意义是“后台运行、优先级高于普通线程、可被系统管理“。耗时操作必须在 Service 内另开线程,否则 ANR。

Q7:8.0 之后后台限制有哪些? 后台 Service 受限(需前台服务 + 通知)、隐式广播大量禁止静态注册、后台定位受限。推荐用 WorkManager 处理可延迟的后台任务。

UI 体系 - View 与自定义 View ★

你的重点短板。 View 体系是应用开发的日常,绘制流程、事件分发是中级面试必考的硬核题。

一、View 绘制三大流程

ViewRootImpl.performTraversals() 触发,依次走:

  1. measure(测量):确定 View 的宽高。
  2. layout(布局):确定 View 在父容器中的位置。
  3. draw(绘制):把 View 画到画布上。

measure 与 MeasureSpec

MeasureSpec 是 32 位 int:高 2 位是模式,低 30 位是尺寸。三种模式:

  • EXACTLY:精确值(match_parent 或具体 dp)。
  • AT_MOST:最大不超过(wrap_content)。
  • UNSPECIFIED:不限制(ScrollView 子 View)。

父 View 的 MeasureSpec + 子 View 的 LayoutParams 共同决定子 View 的 MeasureSpec。 自定义 View 必须处理 wrap_content:否则 AT_MOST 模式下表现得和 match_parent 一样(因为默认用了父给的最大尺寸),需在 onMeasure 里给 wrap_content 一个默认尺寸。

layout

onLayout 中调用子 View 的 layout(l, t, r, b) 确定位置。View 的 getWidth/getHeight(布局后的实际尺寸)与 getMeasuredWidth/Height(测量尺寸)区别:正常情况相等,但可被 layout 强行改变。

draw 顺序

  1. 绘制背景 drawBackground
  2. 绘制内容 onDraw
  3. 绘制子 View dispatchDraw
  4. 绘制前景/滚动条 onDrawForeground

二、事件分发机制

三个核心方法,贯穿 Activity → ViewGroup → View:

  • dispatchTouchEvent:分发事件,返回 true 表示消费。
  • onInterceptTouchEvent:仅 ViewGroup 有,返回 true 拦截,交给自己的 onTouchEvent。
  • onTouchEvent:处理事件,返回 true 表示消费。

传递规律(U 型):事件从 Activity 向下分发(dispatch),ViewGroup 可在 intercept 拦截;子 View 不消费则向上回传(onTouchEvent 冒泡)。

关键规则:

  • 一旦某 View 在 ACTION_DOWN 返回 true 消费了事件,后续 MOVE/UP 都直接交给它(形成事件序列)。
  • 若 DOWN 没被消费,后续事件不再传给它。
  • requestDisallowInterceptTouchEvent(true):子 View 请求父不要拦截(滑动冲突解决用)。

三、滑动冲突解决

当内外层都能滑动(如 ViewPager 嵌 ListView、横滑嵌竖滑),需解决冲突:

  • 外部拦截法(推荐):重写父容器 onInterceptTouchEvent,按需要判断是否拦截。DOWN 不拦截(否则子 View 收不到),MOVE 时根据方向决定。
  • 内部拦截法:父容器默认拦截所有,子 View 通过 requestDisallowInterceptTouchEvent 动态控制父是否拦截,配合父重写 onInterceptTouchEvent 对 DOWN 不拦截。

判断依据:水平/垂直距离比较、速度、业务规则。

四、自定义 View

三种方式:

  1. 继承现有 View(如 TextView):扩展功能。
  2. 继承 View:完全自绘,重写 onMeasure(处理 wrap_content)+ onDraw。
  3. 继承 ViewGroup:自定义布局,重写 onMeasure(测量子 View)+ onLayout(摆放子 View)。

要点:

  • 自定义属性:attrs.xml 定义 → obtainStyledAttributes 读取。
  • 支持 padding(onDraw 中考虑 paddingLeft 等)。
  • 避免在 onDraw 中 new 对象(每帧调用,造成内存抖动),Paint 等提前创建。
  • 状态保存:重写 onSaveInstanceState/onRestoreInstanceState。

五、invalidate vs requestLayout

  • invalidate():触发重绘(只走 draw),不重新测量布局。UI 内容变了用它。必须在主线程;子线程用 postInvalidate()
  • requestLayout():触发重新 measure + layout(不一定 draw),尺寸/位置变了用它。
  • 硬件加速:GPU 渲染,部分 Canvas API 不支持(老版本),可按 View 关闭。

六、Window / DecorView / ViewRootImpl

  • Window:抽象窗口,PhoneWindow 是唯一实现。
  • DecorView:Window 的顶层 View(含 status bar、content)。
  • ViewRootImpl:连接 WindowManager 和 DecorView,是绘制流程的发起者、事件分发的入口。
  • 关系:Activity → PhoneWindow → DecorView → ViewRootImpl 驱动绘制。
  • 触发时机:View attach 到窗口后,requestLayout/invalidate 等请求会经 ViewRootImpl 调度下一帧 traversal;面试说到这里即可,不要把具体内部调度函数当稳定 API 背诵。

进阶补充:嵌套滑动、帧管线与 RecyclerView

NestedScrolling

嵌套滑动解决父子 View 都想消费滑动的问题。核心是 child 先询问 parent 是否参与,滚动前后分发消耗量。

典型场景:CoordinatorLayout + AppBarLayout + RecyclerView。

Choreographer 与 VSync

Choreographer 接收 VSync 信号,驱动 input、animation、traversal。60Hz 下每帧约 16.6ms,超时会掉帧。卡顿排查要看主线程是否阻塞、布局是否过重、GPU 是否过载。

RecyclerView 复用与预取

  • ViewHolder 复用减少创建成本。
  • DiffUtil 减少无效刷新。
  • GapWorker 负责预取。
  • onBindViewHolder 不做重 IO/复杂计算。

GestureDetector 与 VelocityTracker

复杂手势不要全靠手写坐标判断。点击、长按、fling 可用 GestureDetector;速度计算可用 VelocityTracker。

**追问:**为什么 RecyclerView 滑动卡顿?常见是 bind 太重、图片加载无占位/无取消、布局层级深、频繁全量刷新。


高频面试题

Q1:View 的绘制流程?从哪里开始? 从 ViewRootImpl.performTraversals 开始,依次 performMeasure(measure)→ performLayout(layout)→ performDraw(draw)。measure 确定大小,layout 确定位置,draw 绘制内容。

Q2:MeasureSpec 是什么?三种模式? 32 位 int,高 2 位模式 + 低 30 位尺寸。EXACTLY(精确,match_parent/具体值)、AT_MOST(最大,wrap_content)、UNSPECIFIED(不限,如 ScrollView 子 View)。由父 MeasureSpec + 子 LayoutParams 共同决定。

Q3:自定义 View 直接继承 View,wrap_content 不生效怎么办? 在 onMeasure 中判断模式为 AT_MOST 时,给一个默认尺寸(不能直接用父给的最大值,否则等同 match_parent)。

Q4:事件分发三个方法?返回值含义? dispatchTouchEvent(分发)、onInterceptTouchEvent(ViewGroup 拦截)、onTouchEvent(处理)。返回 true 表示消费,事件序列后续都给它;返回 false 向上回传。

Q5:滑动冲突怎么解决? 外部拦截法(父重写 onInterceptTouchEvent 按需拦截,DOWN 不拦截)或内部拦截法(子用 requestDisallowInterceptTouchEvent 控制)。核心是判断滑动方向/距离。

Q6:invalidate 和 requestLayout 区别?可以在子线程调用吗? invalidate 重绘(走 draw),requestLayout 重新测量布局。invalidate 必须主线程,子线程用 postInvalidate。

Q7:getWidth 和 getMeasuredWidth 区别? getMeasuredWidth 是 measure 后的测量值,getWidth 是 layout 后的实际值(= right - left)。通常相等,但 layout 可强行改变实际尺寸使其不等。

Q8:onDraw 里能不能 new 对象? 不能。onDraw 每帧调用,频繁创建对象导致内存抖动、频繁 GC、卡顿。Paint/Path 等应在构造时创建并复用。

UI 体系 - Jetpack Compose ★

你的重点短板,且 2024-2025 中级面试几乎必问。 Compose 是 Android 声明式 UI 的未来,新项目首选。即便面试官项目还在用 View,也常问 Compose 的理解。

一、声明式 UI 思想

  • 命令式(View):你持有 View 引用,手动 findViewById、setText、setVisibility 去“改“UI。
  • 声明式(Compose):你描述“UI 在某状态下长什么样“,状态变了框架自动重绘。UI = f(State)
@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name")   // 不持有引用,name 变了自动重组
}

二、@Composable 与重组(Recomposition)

  • @Composable 函数不返回 UI 对象,而是向 Composition(组合树)发射(emit) 描述节点。
  • 直觉上可把 @Composable 理解为“带有组合上下文的函数”:编译器会改写调用以便框架记录调用位置、参数和状态读取,从而决定哪里需要重组;具体改写细节属于实现层面,面试重点是理解“状态读取驱动局部重组”。
  • 重组:当 Composable 读取的 State 变化时,框架重新执行受影响的 Composable 来更新 UI。
  • 智能跳过:参数未变(且类型稳定)的 Composable 会被跳过,不重新执行。
  • 重组特性(必须理解,易踩坑):
    • 可能频繁发生(每帧),所以 Composable 要无副作用、幂等、快。
    • 执行顺序不保证、可能并行
    • 不要在 Composable 体内直接做副作用(网络、写变量),要用副作用 API。

稳定性(Stability)

  • 编译器判断类型是否 @Stable/@Immutable。不稳定类型(如 List 接口、含 var 的 class)会导致无法跳过,引发不必要重组。
  • 优化:用 data class + 不可变属性、kotlinx.collections.immutableImmutableList

三、状态管理

  • remember { }:在重组间记住值(存在 Composition 中),重组不重置。
  • mutableStateOf():创建可观察状态,读取它的 Composable 会在它变化时重组。
  • remember { mutableStateOf(x) }:最常见组合。rememberSaveable 还能跨配置变更/进程恢复。
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }
  • 状态提升(State Hoisting):把状态移到调用者,Composable 变成无状态(stateless),通过参数接收 value + 回调 onValueChange。利于复用、测试、单一数据源。

四、副作用 API(Side Effects)

副作用是“在 Composable 之外发生的影响“。因为重组随时可能发生,副作用必须用专门 API 管理生命周期:

  • LaunchedEffect(key):进入组合时启动一个协程,key 变化时取消重启,离开组合时取消。用于一次性挂起操作(加载数据、监听 Flow)。
  • rememberCoroutineScope():拿到一个绑定组合生命周期的 scope,在事件回调(如 onClick)里启动协程。
  • DisposableEffect(key):需要清理的副作用(注册/反注册监听器),提供 onDispose {}
  • SideEffect:每次重组成功后执行,用于把 Compose 状态同步给非 Compose 代码。
  • derivedStateOf:从其他 state 派生计算值,只有结果变化才触发重组(避免过度重组,如根据滚动位置算“是否显示回顶按钮“)。
  • rememberUpdatedState:在长生命周期 effect 中引用最新值而不重启 effect。
  • produceState / snapshotFlow:State 与 Flow 互转。
LaunchedEffect(userId) {              // userId 变才重新加载
    viewModel.load(userId)
}

五、CompositionLocal 与 Modifier

  • CompositionLocal:隐式向下传递数据(主题、Context),避免逐层传参。如 LocalContext.currentMaterialTheme
  • Modifier:装饰 Composable(尺寸、padding、点击、背景)。顺序敏感:padding().background()background().padding() 效果不同(前者内边距区域无背景色)。Modifier 是链式不可变的。

六、三大阶段

Compose 渲染分三阶段:

  1. Composition(组合):执行 Composable,构建/更新 UI 树(发射什么)。
  2. Layout(布局):测量 + 摆放(多大、放哪)。
  3. Drawing(绘制):画到屏幕。

理解阶段有助于性能优化:只改绘制层的东西(如颜色)不必触发重组——用 lambda 形式的 Modifier(如 graphicsLayer { }drawBehind)可跳过 Composition/Layout,直接在 Draw 阶段更新。

七、与 View 互操作 & 性能

  • Compose 中嵌 View:AndroidView { }
  • View 中嵌 Compose:ComposeView.setContent { }
  • 性能优化要点:
    • 用稳定类型,避免不必要重组。
    • LazyColumn 给 key 提升 diff 效率。
    • 把状态读取推迟到最小范围(defer reads),用 lambda Modifier。
    • 避免在 Composable 里做重计算(用 remember 缓存)。
    • @Stable/@Immutable 标注帮助编译器跳过。

高频面试题

Q1:Compose 和传统 View 的本质区别? View 是命令式、保留模式(retained,持有可变 View 树,手动改);Compose 是声明式、即时模式思想(描述 UI=f(state),状态变自动重组)。Compose 无 findViewById、无 XML、Kotlin 写 UI。

Q2:什么是重组?它有什么注意事项? State 变化时重新执行受影响的 Composable 更新 UI。注意:可能频繁/并行/乱序执行,所以 Composable 必须无副作用、幂等、快,副作用要用专门 API。

Q3:remember 和 rememberSaveable 区别? remember 在重组间保留值,但配置变更(旋转)/进程重建会丢失;rememberSaveable 额外通过 Bundle 保存,能跨配置变更恢复(类型需可 Parcelize)。

Q4:LaunchedEffect 和 rememberCoroutineScope 区别? LaunchedEffect 在组合期启动协程、随 key 重启、离开自动取消,用于进入界面就执行的副作用;rememberCoroutineScope 返回 scope,在事件回调里手动启动协程。

Q5:为什么会发生不必要的重组?怎么优化? 参数类型不稳定(List 接口、含 var 的类)导致无法跳过。优化:用不可变类型、@Stable/@Immutable、derivedStateOf、给 LazyColumn 加 key、defer state reads。

Q6:Compose 的三个阶段是什么? Composition(发射 UI 树)、Layout(测量摆放)、Drawing(绘制)。只影响绘制的变化可用 lambda Modifier 跳过前两阶段。

Q7:Modifier 顺序为什么重要? Modifier 链按顺序应用,每个包裹前一个。padding 在 background 前后效果不同。size、padding、clickable 顺序都会改变最终表现。

Q8:状态提升是什么?为什么重要? 把状态从 Composable 内移到调用方,使 Composable 无状态(接 value + onValueChange)。好处:单一数据源、可复用、可测试、可被多处控制。

Compose 深水区

Compose 深水区考的不是“会写 Text/Button”,而是你是否理解重组、稳定性、Snapshot、Modifier、LazyColumn 性能和测试,能不能把声明式 UI 写得稳定、可测、不卡。

一、Recomposition: 状态读取驱动局部重组

Compose 的核心是 UI = f(State)。当某个 Composable 在组合阶段读取了 State,这个读取关系会被记录;State 改变后,相关范围进入重组。

  • 重组是重新执行 Composable,不是重新创建整个 Activity/View 树。
  • 重组可能很频繁,Composable 必须幂等、无副作用、执行快。
  • 不要在 Composable 体内直接发网络请求、写数据库、改全局变量。
  • 把状态读取推迟到最小范围,减少被重组的 UI 面积。
@Composable
fun ProfileScreen(state: ProfileState, onRetry: () -> Unit) {
    when {
        state.loading -> Loading()
        state.error != null -> ErrorView(state.error, onRetry)
        else -> ProfileContent(state.user)
    }
}

二、Stability 与 Skippable

Compose 编译器会判断参数是否稳定,稳定且参数未变化时,Composable 可以被跳过(skippable)。

类型/写法对跳过的影响建议
不可变 data class(val 字段)更容易稳定UI State 尽量不可变
List<T> 接口常被视为不稳定用不可变集合或稳定 wrapper
var 的普通 class不稳定改为 State 驱动或不可变模型
lambda 每次新建可能导致参数变化必要时 remember 或上提

怎么答:优化 Compose 性能不是“少写 Composable”,而是让状态和参数稳定,让框架能精准重组和跳过。

三、remember / rememberSaveable / derivedStateOf

remember 把值保存在 Composition 中,重组不丢;rememberSaveable 额外通过 Bundle/Saver 支持配置变更和进程恢复;derivedStateOf 用于从一个或多个 State 派生计算结果,只有派生结果变化时才通知。

val listState = rememberLazyListState()
val showTopButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 3 }
}

使用边界:

  • remember:缓存计算结果、对象实例、滚动状态等组合内状态。
  • rememberSaveable:输入框、tab、筛选条件等需要旋转后恢复的轻量状态。
  • derivedStateOf:高频变化中只关心派生布尔/分组结果,如滚动位置。
  • 不要滥用 derivedStateOf,普通字符串拼接/低频计算没有必要。

四、Snapshot 系统

Compose 的 mutableStateOf 背后是 Snapshot 状态系统。它记录状态读取和写入,在写入提交后通知受影响的组合范围。

  • Snapshot 让 Compose 能知道“谁读了这个状态”。
  • 状态写入应发生在主线程或受 Snapshot 管理的上下文中,避免并发写冲突。
  • snapshotFlow { } 可以把 Compose State 读取转换成 Flow,适合观察滚动状态等。
  • 不要直接修改普通可变集合后期待 UI 更新;要替换 State 值或使用 Snapshot-aware 集合。
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .collect { index -> /* analytics or load trigger */ }
}

五、Modifier 顺序与布局语义

Modifier 是链式包装,顺序会改变测量、绘制、点击区域。

写法效果
background().padding()背景覆盖 padding 外层区域,内容向内缩
padding().background()只有 padding 后的内部区域有背景
clickable().padding()点击区域包含 padding 前的范围
padding().clickable()点击区域通常只覆盖 padding 后内容

怎么落地:先想“尺寸/约束”,再想“点击/语义”,最后想“绘制”;可访问性相关 semantics 要放在能覆盖正确节点的位置。

六、LazyColumn 性能

LazyColumn 不是所有问题的自动解,关键是 key、contentType、状态位置和 item 复杂度。

  • 给稳定业务 ID 作为 key,避免插入/删除导致 item 状态错位。
  • 使用 contentType 帮助复用相同类型 item。
  • 避免在 item lambda 里做重计算、创建大对象或直接收集 Flow。
  • 分页加载用列表滚动状态 + derivedStateOf/Paging,避免每帧触发请求。
  • item 内状态要么跟业务 ID 绑定,要么上提到 ViewModel。
LazyColumn(state = listState) {
    items(
        items = users,
        key = { it.id },
        contentType = { "user" },
    ) { user ->
        UserRow(user)
    }
}

七、View 互操作与生命周期收集

Compose 和 View 混用在迁移期很常见。

  • Compose 中嵌 View:用 AndroidView,在 update 中同步参数,不要每次重组都重新创建重对象。
  • View 中嵌 Compose:用 ComposeView.setContent,并设置合适的 ViewCompositionStrategy
  • 收集 Flow:优先用 collectAsStateWithLifecycle(),避免后台生命周期仍持续收集。
  • 副作用:进入组合加载用 LaunchedEffect(key),注册监听用 DisposableEffect(key) 清理。
场景推荐 API注意点
Compose 嵌地图/广告 ViewAndroidViewfactory 创建,update 更新
Fragment 嵌 ComposeComposeView销毁 View 时释放 Composition
Flow 渲染 UIcollectAsStateWithLifecycle需要 lifecycle-runtime-compose
注册监听器DisposableEffectonDispose 反注册

八、Compose Testing

Compose 测试关注语义树而不是具体 View ID。

  • createComposeRule() 设置内容。
  • 通过 onNodeWithTextonNodeWithTagonNodeWithContentDescription 查找节点。
  • 给关键节点设置 Modifier.testTag()
  • 对异步状态变化使用 waitUntil 或测试时注入可控 Dispatcher/Repository。
composeTestRule.setContent { LoginScreen(state, onSubmit = {}) }
composeTestRule.onNodeWithTag("login_button").assertIsDisplayed()

怎么答:Compose 测试不是截图测试优先,而是验证语义、状态渲染和用户交互;业务逻辑仍应在 ViewModel/UseCase 里用普通单元测试覆盖。


高频面试题

Q1:什么会触发 Compose 重组? Composable 在组合阶段读取的 State 变化会触发相关范围重组。重组是局部重新执行函数,不是整棵 UI 全量重建。Composable 要幂等、无副作用、快速执行。

Q2:稳定性和 skippable 是什么? 如果 Composable 参数稳定且值没变,编译器/运行时可以跳过它,减少重组成本。不稳定类型如可变 class、普通 List 接口会降低跳过概率,应使用不可变 UI State 和稳定集合。

Q3:remember、rememberSaveable、derivedStateOf 怎么区分? remember 保留组合内状态,重组不丢但配置变更会丢;rememberSaveable 通过 Bundle/Saver 支持恢复;derivedStateOf 用于从高频状态派生低频结果,结果不变时不触发下游重组。

Q4:Snapshot 系统解决什么问题? 它跟踪 Compose State 的读取和写入,让框架知道哪些组合范围依赖某个状态,写入提交后精准通知重组。普通可变集合不受 Snapshot 自动追踪,直接 mutate 可能不刷新 UI。

Q5:LazyColumn 怎么优化? 提供稳定 key 和 contentType,避免 item lambda 重计算和直接收集 Flow,把状态按业务 ID 管理,分页触发用 derivedStateOf/snapshotFlow 控制频率,不要每次滚动都发请求。

易错点 / 追问

  • 在 Composable 函数体里直接发请求或写外部状态,会因重组重复执行。
  • remember 不能跨配置变更保存状态,输入框/筛选条件要考虑 rememberSaveable 或 ViewModel。
  • Modifier 顺序会改变背景、padding、点击区域和语义范围,不是随便链式调用。
  • 普通 mutableList.add() 不一定触发 UI 更新,应替换 State 或使用 Snapshot-aware 状态集合。
  • LazyColumn 不加稳定 key 时,插入/删除可能导致 item 状态错位和不必要重组。

Android 版本适配

★ 版本适配是考察 Android 基础扎实度和工程经验的试金石。不要死记硬背每个 API,重点讲清“为什么改”以及“线上怎么平滑过渡”。

一、Android 6.0 - 9.0 核心适配回顾

虽然年代久远,但这是现代 Android 权限和后台限制的基石:

  • Android 6.0 (M): 动态权限机制(Runtime Permissions)。核心思想:敏感权限必须在用到时申请,不能全靠安装时授权。
  • Android 7.0 (N): FileProvider。严禁在 Intent 中传递 file:// URI,必须使用 content://,提升跨应用文件共享的安全性。
  • Android 8.0 (O):
    • 后台执行限制:应用在后台时不能随便启 Service,推荐用 JobScheduler 或 WorkManager。
    • 通知渠道(Notification Channels):通知必须分类,把控制权交给用户。
  • Android 9.0 (P): 限制 Http 明文请求(需配置 networkSecurityConfig),以及非 SDK 接口限制(深反射受限)。

二、Android 10 - 11:存储与隐私大修

这是近年来适配痛点最集中的版本,核心是分区存储与包可见性

  • Android 10 (Q):
    • Scoped Storage (分区存储):应用默认只能访问自己的沙盒目录和公共媒体集合(MediaStore)。不能再随便遍历 /sdcard
    • 后台定位限制:细分了前台定位和后台定位权限。
  • Android 11 (R):
    • 强制分区存储:TargetSDK 30 时 requestLegacyExternalStorage 失效,必须彻底适配 MediaStore 或 SAF。
    • 包可见性 (Package Visibility):不能再无脑 getInstalledPackages()。必须在 Manifest 中用 <queries> 声明你要交互的应用包名,防流氓应用拉取用户应用列表。

三、Android 12:用户体验与安全加固

  • SplashScreen API:系统强制的闪屏规范。启动时自动接管显示 App 图标,需适配新的主题属性,否则会出现“双闪屏”。
  • PendingIntent 可变性:必须显式声明 FLAG_IMMUTABLEFLAG_MUTABLE,防止组件劫持。
  • 精确闹钟限制AlarmManager.setExact() 需要申请 SCHEDULE_EXACT_ALARM 权限,防止滥用唤醒系统。
  • 蓝牙权限细分:分离了定位和蓝牙权限,扫蓝牙不再必须申请定位权限(需声明 neverForLocation)。

四、Android 13:细粒度权限时代

  • 通知运行时权限:发通知不再是默认开启的,必须动态申请 POST_NOTIFICATIONS 权限。
  • 细化的媒体权限:废弃了粗放的 READ_EXTERNAL_STORAGE,拆分为:
    • READ_MEDIA_IMAGES
    • READ_MEDIA_VIDEO
    • READ_MEDIA_AUDIO
  • 照片选择器 (Photo Picker):无需任何存储权限即可让用户选择图片,强烈建议替换掉自研的图片选择器。

五、Android 14:前台服务与后台行为收紧

  • 前台服务类型强制:启动 Foreground Service 必须声明对应的类型(如 location, mediaPlayback, dataSync),且必须附带具体的原因和对应权限。
  • 隐式 Intent 限制:内部组件通信必须使用显式 Intent 或指定 package,防止被外部恶意截获。
  • 精确闹钟默认拒绝:TargetSDK 34 的非日历/闹钟类应用,精确闹钟权限默认关闭,需引导用户去设置页开启。

六、版本适配速查矩阵与面试策略

为了在面试中快速调取记忆,可以参考以下核心版本适配矩阵:

版本与 TargetSDK核心变更面试常问痛点常见解决方案与对策
Android 10 (29)分区存储初步引入、后台定位读写 SD 卡崩溃声明 requestLegacyExternalStorage 过渡,切沙盒目录
Android 11 (30)强制分区存储、包可见性第三方分享/支付失败Manifest 添加 <queries> 标签声明依赖的包名
Android 12 (31)PendingIntent 可变性、四大组件 Exported通知点击崩溃、安装失败补充 FLAG_IMMUTABLE,强声明 android:exported
Android 13 (33)通知权限、细粒度媒体权限发不出通知、读图失败动态申请 POST_NOTIFICATIONS,接入 PhotoPicker
Android 14 (34)前台服务类型、精确闹钟收缩后台服务崩溃补充 foregroundServiceType 及对应权限,转用 WorkManager

面试实战:版本适配的回答结构

当被问到“讲讲你做过哪些版本适配”时,用 STAR 法则 构建你的回答:

  1. 情境(Situation): “在升级 TargetSDK 到 33 时…”
  2. 任务(Task): “…我们遇到了图片选择和通知发送失效的问题。”
  3. 行动(Action): “…我将存储权限拆分请求,并接入了官方的 Photo Picker,同时利用一套版本判断工具类统一封装了通知权限的申请逻辑。”
  4. 结果(Result): “…不仅解决了崩溃,还把包体积减小了(因为移除了臃肿的第三方相册组件),并且减少了向用户索要危险权限的次数,合规性提升。”

高频面试题

Q1:你们是如何平滑过渡分区存储(Scoped Storage)的? 答:我们分了两步走。首先在 TargetSDK 29 时通过 requestLegacyExternalStorage="true" 作为过渡方案。其次,梳理了所有文件读写场景:将应用内缓存切到 Context.getExternalFilesDir();将需要分享给用户的图片/视频,改为通过 MediaStore API 插入;对于需要用户选择外部文件的场景,接入了 Storage Access Framework (SAF)。

Q2:Android 12 的 PendingIntent 崩溃问题怎么排查和解决? 答:这是因为 TargetSDK 31 后未声明可变性。我们首先通过全局搜索 PendingIntent.getActivity/getBroadcast 找到所有调用点。对于只用于拉起界面的点击通知,统一加上 PendingIntent.FLAG_IMMUTABLE;对于需要回传输入结果(如 Direct Reply)的场景,使用 FLAG_MUTABLE。同时检查了第三方推送 SDK 是否已升级到兼容版本。

Q3:如何处理 Android 14 前台服务的限制? 答:梳理业务中所有的 Foreground Service。比如定位打卡服务,在 Manifest 声明 foregroundServiceType="location",并在启动前确保已获得前台定位权限。如果是数据同步,尽量将其迁移到 WorkManager,因为 Android 14 极度不推荐长驻的后台同步服务。

易错点 / 追问

  • TargetSDK 与 CompileSDK 混淆:CompileSDK 是编译时环境,TargetSDK 是运行时的兼容模式开关。只有提升了 TargetSDK,系统才会用新的限制策略约束你。
  • 第三方 SDK 拖后腿:自己业务适配了,但旧版第三方 SDK 仍用老 API 导致崩溃。追问解决方案时,可以说“推动 SDK 更新、使用 ASM 字节码插桩拦截修改、或寻找替代方案”。
  • 忽略条件判断:在调用新 API 时没有包裹 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU),导致在老机器上发生 NoSuchMethodError 崩溃。

图片加载与缓存

★ 图片是移动端内存消耗的第一大户,也是 OOM 的万恶之源。面试题通常从“怎么用 Glide”深入到“如果让你手写一个图片框架,你会怎么设计三级缓存”。

一、Bitmap 内存模型与大小计算

一张 Bitmap 占多大内存? 公式分辨率宽 × 分辨率高 × 每个像素占用的字节数(还要考虑放错 drawable 文件夹带来的缩放系数)。

  • ARGB_8888(默认):每个像素 4 字节。高质量,带透明度。
  • RGB_565:每个像素 2 字节。无透明度,省一半内存。

注意:Android 8.0 之后,Bitmap 的像素数据移到了 Native 内存中,减少了对 JVM 堆的压力,降低了 Java 层的 OOM,但总内存占用并没变。

二、采样压缩:inSampleSize 的奥秘

加载 4K 高清图到 100x100 的 ImageView,直接加载必爆内存。必须使用 BitmapFactory.Options 进行采样压缩。

// 1. 只读取图片宽高,不将像素加载到内存
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeResource(resources, R.id.my_image, options)

// 2. 根据目标 View 尺寸计算采样率 (inSampleSize,必须是 2 的幂)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)

// 3. 关闭只读边界,真正加载图片
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeResource(resources, R.id.my_image, options)

三、经典三级缓存设计

  1. 内存缓存 (LruCache):速度极快,空间小。基于强引用,利用 LinkedHashMap 记录访问顺序,满时淘汰最近最少使用的图片。
  2. 磁盘缓存 (DiskLruCache):速度中等,空间大。保存下载的原始文件或转换后的图。
  3. 网络获取:最慢,消耗流量。内存和磁盘都没命中时才走网络。

流程:读(内存 → 磁盘 → 网络),写(网络回来后写入磁盘和内存)。

四、Glide 的多级缓存机制剖析

Glide 的缓存比经典三级缓存更精细:

  1. 活动资源 (ActiveResources):当前正在屏幕上显示的图片。使用弱引用持有,防止 GC 时被回收导致闪烁,同时分担 LruCache 压力。
  2. 内存缓存 (LruCache):刚被移出屏幕,但可能马上要用到的图片。
  3. 磁盘缓存 (DiskCache)
    • Resource:缓存经过转换、裁剪后的图(直接拿来就能显示)。
    • Data:缓存网络下载的原始全尺寸数据。

五、列表滑动与超长图优化

  • RecyclerView 滑动卡顿优化
    • onBindViewHolder 里绝对不能在主线程 decode 图片。
    • 滑动时暂停加载 (Glide.with(context).pauseRequests()),停止滑动时恢复加载。
    • 给 ImageView 固定宽高,避免多次 measure。
  • 巨图加载 (长图/清明上河图)
    • 使用 BitmapRegionDecoder 局部解码,结合手势监听,滑到哪里只加载那一部分的内存。

高频面试题

Q1:如何设计一个图片加载框架?(架构题) 答:可以拆分为四大模块:

  1. 请求封装层:对外提供流式 API (如 with().load().into()),封装 Request 对象。
  2. 任务调度层:管理线程池,负责将加载任务派发给 IO 线程,把结果回调到主线程。并与生命周期绑定,在 Activity 销毁时取消任务。
  3. 缓存拦截层:实现 ActiveResource -> LruCache -> DiskCache -> Network 的责任链/拦截器模式。
  4. 解码/变换层:负责网络流到 Bitmap 的解码,处理 inSampleSize 采样,以及圆角、模糊等 Transformation。

Q2:排查线下和线上 OOM 崩溃,你的思路是什么? 答:线下通常通过 Android Profiler 监控内存,或者集成 LeakCanary,当触发 dump 后,通过 MAT 或 Shark 分析堆栈,找出是哪个组件持有大对象。 线上 OOM 通常缺乏具体堆栈。我们会分析 APM 收集的机型和发生页面,重点排查该页面的大图加载是否有采样压缩、列表图片是否使用了 ARGB_8888、是否有未关闭的动画或大 Bitmap 引用泄露。可以上报 OOM 时的内存使用统计,辅助定位。

Q3:Glide 的生命周期管理是怎么做的?为什么 Activity 销毁时它能停止加载? 答:Glide 通过向当前传入的 Context(Activity/Fragment)偷偷注入一个无 UI 的隐藏 Fragment。因为系统会回调这个 Fragment 的生命周期方法,Glide 借此感知到了宿主的 onStartonStoponDestroy,从而自动暂停、恢复或取消图片加载任务,防止内存泄漏和无效流量浪费。

易错点 / 追问

  • 混淆 drawable 文件夹的缩放规则:把大图放在 drawable-mdpi,在高密度屏幕手机上加载时,系统为了保持物理尺寸一致,会对其进行放大采样,导致内存成倍暴增。图片应该尽量提供 xxhdpi,或者放在 drawable-nodpi 中。
  • 误解 LruCache 的原理:追问 LruCache 底层数据结构时,必须答出是 LinkedHashMap 并且开启了 accessOrder=true 模式,每次 getput 都会把元素移到双向链表尾部。
  • 忽略 Bitmap 回收机制:虽然 Bitmap 数据在 Native,但 Java 层的 wrapper 对象仍靠 GC。追问优化时可以提 inBitmap 属性,即复用已分配的旧 Bitmap 内存块来加载新图片,避免频繁申请和销毁内存。

Jetpack 架构组件 ★

你的重点短板。 Jetpack 是应用开发日常,中级面试必问 ViewModel/LiveData/Room/Hilt 原理。

一、ViewModel

  • 作用:持有 UI 相关数据,在配置变更(旋转)时存活,与 UI 解耦。
  • 存活原理:对外契约是同一 ViewModelStoreOwner 在配置变更后可取回同一个 ViewModel,直到该 owner 真正结束才调用 onCleared()。实现层面可理解为 ViewModelStore 被保留并在重建后重新关联,历史实现中会经过 Activity 的 NonConfigurationInstance 保留链路;面试中不要把某个内部方法名当成稳定 API 保证。
  • 不要持有 View/Context 引用(会泄漏);需要 Context 用 AndroidViewModel(持 Application)。
  • SavedStateHandle:在进程被杀重建后恢复关键数据(配合 savedState)。
  • viewModelScope:绑定 ViewModel 的协程作用域,onCleared 时取消。

二、Lifecycle

  • LifecycleOwner:Activity/Fragment 实现它,暴露 Lifecycle。
  • LifecycleObserver:观察生命周期事件,把生命周期相关逻辑(如开始/停止定位)从组件中解耦出去。
  • 现代用 DefaultLifecycleObserverlifecycleScope.launch { repeatOnLifecycle(STATE) { } }

三、LiveData

  • 生命周期感知的可观察数据持有者:只在活跃状态(STARTED/RESUMED)通知观察者,组件销毁自动移除观察,避免泄漏和崩溃
  • setValue(主线程)/ postValue(任意线程)。
  • 粘性问题:新观察者会立即收到最新值,用于“事件“场景会重复触发(用 SingleLiveEvent/Flow 替代)。
  • MediatorLiveData:合并多个源。
  • 现代趋势:新项目用 StateFlow/SharedFlow 替代,但老项目仍大量使用,必须懂。

四、Room

  • SQLite 之上的 ORM,编译期校验 SQL。
  • 三要素:@Entity(表)、@Dao(数据访问接口)、@Database(数据库持有者)。
  • 支持 协程 suspendFlow 返回(数据变化自动推送)。
  • 数据库迁移 Migration:版本升级写 Migration,否则崩溃;开发期可 fallbackToDestructiveMigration(清库)。
  • 查询返回 Flow 时,表数据变化会自动重新发射,天然支持响应式 UI。

五、Navigation

  • 定位:单 Activity 多 Fragment/Composable 架构的导航框架,把“目的地、参数、Action、Deep Link、回退栈”集中声明,减少手写 FragmentTransaction/Intent flag 的分散逻辑。
  • 对象关系:
    • NavHost:承载导航内容的容器,Fragment 场景常见 NavHostFragment,Compose 场景是 NavHost Composable。
    • NavController:执行 navigate/popBackStack 的控制器,维护当前目的地和 back stack。
    • NavGraph:目的地和跳转关系的图,可来自 XML,也可在 Compose 中用 Kotlin DSL 声明。
    • NavBackStackEntry:回退栈条目,携带 arguments、Lifecycle、SavedStateHandle,也可作为 ViewModel 作用域边界。
  • 导航调用流程:View 点击/Intent → 调用 NavController.navigate(route/action) → 按 NavGraph 匹配目的地与参数 → 创建或恢复目的地 → 新 NavBackStackEntry 入栈 → 目标 Fragment/Composable 渲染 → 返回时 popBackStack 出栈并恢复上一 entry
  • 回退栈 API:
    • navigate() 入栈新目的地;可用 popUpTo 清理中间页面,登录后清空登录流常用。
    • popBackStack() 返回上一目的地;指定 destination/route 时可弹到某个节点。
    • launchSingleTop 避免重复点击把同一目的地压多份。
  • Deep Link:可以在图中声明 URI/Action/MIME 匹配。外部 Intent 进入后由 Navigation 匹配目标目的地并补齐必要的 back stack;面试要强调参数校验和未登录拦截,不要只说“配置一个 deepLink”。
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            HomeScreen(onOpenDetail = { id -> navController.navigate("detail/$id") })
        }
        composable(
            route = "detail/{id}",
            arguments = listOf(navArgument("id") { type = NavType.StringType }),
        ) { entry ->
            DetailScreen(id = entry.arguments?.getString("id") ?: return@composable)
        }
    }
}

常见错误:

  • 在多个页面散落手写跳转字符串,导致参数名/route 不一致;可用常量、封装 route builder 或 Safe Args 降低风险。
  • 登录、首页、详情都压入同一栈后不清理,返回路径混乱;需要明确 popUpToinclusive
  • 把导航事件放进持久 State,旋转后重复 navigate;一次性导航应使用 Effect/SharedFlow。
  • Deep Link 只做跳转不做鉴权和参数兜底,容易打开非法状态页面。

面试答题结构:先说 “NavGraph + NavHost + NavController” 三件套 → 再讲 back stack entry 的入栈/出栈流程 → 补 Compose/XML 两种写法 → 最后说 deep link、参数安全、重复导航与登录栈清理。

六、Hilt(依赖注入)

  • 基于 Dagger 的 Android 专用 DI 封装,大幅减少模板代码。
  • 核心注解:@HiltAndroidApp(Application)、@AndroidEntryPoint(注入到组件)、@Inject(构造注入)、@Module + @Provides/@Binds(提供无法构造注入的依赖)、@HiltViewModel(注入 ViewModel)。
  • 作用域:@Singleton@ActivityScoped@ViewModelScoped 等,控制实例生命周期。
  • Hilt vs Dagger:Hilt 预定义了 Android 组件的标准注入点和组件层级,开箱即用;Dagger 更灵活但配置繁琐。
  • DI 的意义:解耦、可测试(可替换 mock 实现)、统一管理对象创建。

七、DataStore / WorkManager / Paging

  • DataStore:替代 SharedPreferences。基于协程 + Flow,异步、事务安全、无 ANR 风险。两种:Preferences DataStore(键值)、Proto DataStore(类型安全)。SP 的问题:同步 apply 仍可能阻塞、getXXX 可能主线程 IO、无类型安全。
  • WorkManager:可延迟、保证执行的后台任务(即使 App 退出/重启)。支持约束(网络、充电)、链式、周期任务。底层根据系统版本选 JobScheduler/AlarmManager。适合上传日志、同步数据。
  • Paging3:分页加载大列表,核心是 PagingSource → Pager → Flow&lt;PagingData&lt;T&gt;&gt; → UI Adapter/Compose
    • PagingSource<Key, Value>:定义 load(params) 如何按页加载数据,返回 LoadResult.Page/Error;getRefreshKey() 决定刷新时从哪里继续。
    • Pager:接收 PagingConfigpagingSourceFactory,把分页请求包装成 Flow&lt;PagingData&lt;T&gt;&gt;
    • PagingData:一次分页会话的数据流,UI 侧通过 collectAsLazyPagingItems()PagingDataAdapter.submitData() 消费。
    • RemoteMediator:网络 + 本地数据库场景的协调器,负责判断何时从网络拉新页、写入 Room,UI 实际从 Room 的 PagingSource 读取,形成单一数据源。
class UserPagingSource(private val api: UserApi) : PagingSource<Int, User>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> = try {
        val page = params.key ?: 1
        val users = api.users(page = page, size = params.loadSize)
        LoadResult.Page(
            data = users,
            prevKey = if (page == 1) null else page - 1,
            nextKey = if (users.isEmpty()) null else page + 1,
        )
    } catch (t: Throwable) {
        LoadResult.Error(t)
    }

    override fun getRefreshKey(state: PagingState<Int, User>): Int? =
        state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
        }
}

val users: Flow<PagingData<User>> = Pager(
    config = PagingConfig(pageSize = 20, prefetchDistance = 5),
    pagingSourceFactory = { UserPagingSource(api) },
).flow.cachedIn(viewModelScope)

RemoteMediator 流程:UI 触发刷新/滑到底 → Paging 判断需要加载 → RemoteMediator.load(REFRESH/PREPEND/APPEND) 请求网络 → 事务写入 Room + remote keys → Room 的 PagingSource 失效并重新发射 → UI 收到新的 PagingData。这种方式适合离线缓存、列表恢复、网络与本地一致性要求高的页面。

UI 集成要点:

  • RecyclerView 用 PagingDataAdapter + LoadStateAdapter 展示刷新、加载更多、错误重试。
  • Compose 用 collectAsLazyPagingItems()LazyColumn.items(count);根据 loadState.refresh/append 渲染首屏 loading、尾部 loading、错误重试。
  • 在 ViewModel 中 cachedIn(viewModelScope),避免旋转屏幕后重新创建分页流导致重复请求。

常见错误:

  • PagingSource 同时读网络和本地且没有一致性策略,刷新/翻页状态难维护;复杂缓存场景应考虑 RemoteMediator + Room
  • 忘记实现合理的 getRefreshKey,刷新后列表跳到错误位置。
  • 不处理 LoadState,用户看不到加载、错误、重试状态。
  • 每次 UI 重组/重建都新建 Pager,导致重复加载;应让分页流由 ViewModel 持有并缓存。

面试答题结构:先说 “PagingSource 负责加载一页,Pager 产出 PagingData Flow,UI 订阅渲染” → 再补 “RemoteMediator 用于网络 + 数据库单一数据源” → 最后讲 cachedInLoadState、刷新 key、错误重试这些工程坑。


高频面试题

Q1:ViewModel 为什么能在旋转屏幕后存活? 旋转是配置变更,Activity/Fragment 会重建,但同一 ViewModelStoreOwner 的 ViewModelStore 会在配置变更后重新关联,因此可取回同一个 ViewModel 实例。历史实现可从 NonConfigurationInstance 保留链路理解,但对外稳定契约是“配置变更存活、owner 真正结束时 onCleared”,不要依赖内部方法名。

Q2:ViewModel 能持有 Context 吗? 不能持有 Activity/View Context(泄漏)。需要 Application Context 时用 AndroidViewModel。原则:ViewModel 不感知 UI。

Q3:LiveData 的粘性事件问题是什么?怎么解决? 新观察者注册时会立即收到最新值,对“一次性事件“(导航/Toast)会重复触发。解决:SingleLiveEvent、事件包装类、或改用 SharedFlow(replay=0)。

Q4:LiveData 和 StateFlow 怎么选? 新项目优先 StateFlow(纯 Kotlin、操作符丰富、与协程统一),需配 repeatOnLifecycle 做生命周期安全收集;老项目/简单场景 LiveData 自带生命周期感知更省事。

Q5:Room 相比直接用 SQLite 的优势? 编译期校验 SQL、减少样板代码、对象映射、原生支持协程和 Flow(数据变化自动推送)、迁移管理。

Q6:为什么用 DataStore 替代 SharedPreferences? SP 有主线程 IO 风险、apply 仍可能阻塞、无类型安全、无错误信号。DataStore 基于协程+Flow,异步、事务、类型安全(Proto)。

Q7:Hilt 和 Dagger 关系?DI 解决什么问题? Hilt 是 Dagger 的 Android 封装,预置组件层级和注入点。DI 解决对象创建与依赖管理:解耦、便于替换实现做单元测试、避免手动 new 的耦合。

Q8:什么任务适合 WorkManager?和协程/Service 怎么区分? 适合“可延迟但必须保证执行“的后台任务(日志上传、数据同步),即使进程被杀/重启也能续。即时 UI 相关异步用协程;需要持续运行的用前台 Service。

Q9:Navigation 的回退栈怎么理解? NavController 根据 NavGraph 导航时会把目的地包装成 NavBackStackEntry 入栈,entry 携带参数、生命周期和状态。返回时 popBackStack 弹出当前 entry 并恢复上一个;登录流、首页清栈要用 popUpTo/launchSingleTop 控制,避免重复页面和错误返回路径。

Q10:Paging3 的三件套是什么?RemoteMediator 解决什么? PagingSource 负责按 key 加载一页数据,Pager 把它包装成 PagingData 流,UI 用 PagingDataAdapter 或 Compose LazyPagingItems 渲染。RemoteMediator 用于网络 + 本地数据库:网络结果写入 Room,UI 从 Room 读,实现离线缓存和单一数据源。

进阶补充:Hilt、Room、WorkManager 与 Compose 生命周期

Hilt 组件层级与作用域

常见层级:SingletonComponentActivityRetainedComponentViewModelComponentActivityComponentFragmentComponent。作用域要和生命周期匹配,不要把短生命周期对象注入成单例。

Qualifier 与 Assisted Injection

同类型多个实现用 @Qualifier 区分;运行时参数可用 assisted injection 或 ViewModel 的 SavedStateHandle。

Room 迁移与 TypeConverter

Room migration 要显式描述 schema 变化,不能依赖删除重建。复杂类型用 TypeConverter,关系查询要注意 N+1 和事务一致性。

collectAsStateWithLifecycle

Compose 收集 Flow 推荐用 lifecycle-aware API,避免页面不可见时仍持续收集。

WorkManager 约束和退避

WorkManager 适合可延迟、需保证执行的后台任务。约束包括网络、充电、空闲;失败可配置 backoff 和 unique work policy。

**追问:**为什么 Hilt scope 不能乱用?因为对象生命周期过长会造成泄漏,过短会导致重复创建和状态丢失。

应用架构 - MVVM 与 MVI ★

你的重点短板,中级必考。 面试常问“你项目用什么架构?为什么?“,要答得出演进逻辑和取舍。

一、架构演进

架构核心痛点
MVCActivity 既是 View 又当 Controller职责混乱,Activity 臃肿(“上帝类”)
MVPPresenter 持有 View 接口,逻辑抽离接口爆炸、Presenter 持 View 易泄漏、需手动解绑
MVVMViewModel 暴露可观察数据,View 订阅数据流方向多、状态分散难追踪
MVI单一 State + 单向数据流模板代码多、小页面偏重

核心趋势:职责分离 → 解耦 View 与逻辑 → 数据驱动 UI → 单向数据流

二、MVVM

  • View(Activity/Fragment/Composable):只负责展示和转发用户操作,订阅 ViewModel 的数据。
  • ViewModel:持有 UI 状态和业务逻辑入口,不引用 View,通过 LiveData/StateFlow 暴露数据。
  • Model:数据层(Repository + 数据源)。
  • 数据绑定:View 观察 ViewModel 的可观察数据,数据变 UI 自动更新。
class UserViewModel(private val repo: UserRepo) : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()
    fun load(id: String) = viewModelScope.launch {
        _user.value = repo.getUser(id)
    }
}

MVVM 的问题:多个 LiveData/StateFlow 分散表达状态(loading/data/error 各一个),状态可能不一致,难追踪“当前完整 UI 状态“。MVI 就是来解决这个的。

三、MVI(Model-View-Intent)

核心思想:单一数据源 + 单向数据流(UDF)

  • State:用一个不可变对象描述整个 UI 状态(data class UiState(val isLoading, val data, val error))。
  • Intent(也叫 Event/Action):用户意图(点击、刷新),是进入系统的唯一入口。
  • 单向流:Intent → ViewModel 处理 → 产出新 State → View 渲染。状态只能由 ViewModel 产出,View 不能直接改。
  • Effect / SideEffect:一次性事件(导航、Toast),用 Channel/SharedFlow 表达(不放进 State,避免重放)。
data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null,
)
sealed interface UiIntent {
    data object Refresh : UiIntent
    data class Click(val id: String) : UiIntent
}
class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()
    fun onIntent(intent: UiIntent) = when (intent) {
        UiIntent.Refresh -> refresh()
        is UiIntent.Click -> openDetail(intent.id)
    }
}

四、官方推荐分层架构

Google 推荐三层(配合单 Activity + Jetpack),但它是可按复杂度裁剪的参考架构,不是所有页面都必须机械套满三层:

  • UI 层:Composable/Fragment + ViewModel + UiState。只做展示和事件转发。
  • Domain 层(可选):UseCase 封装单一业务用例,复用复杂逻辑,降低 ViewModel 体积。
  • Data 层:Repository(对外唯一数据入口)+ DataSource(网络/本地)。Repository 决定数据来源、缓存策略、做单一数据源(SSOT)。

依赖方向单向向下:UI → Domain → Data,上层依赖下层抽象(依赖倒置)。

五、可测试性

  • ViewModel 不依赖 Android 框架(不持 Context/View),可纯 JUnit 测试。
  • Repository/UseCase 通过接口注入(Hilt),测试时替换为 fake/mock。
  • 协程测试用 runTest + TestDispatcher,Flow 测试用 Turbine。

高频面试题

Q1:MVP 和 MVVM 区别? MVP:Presenter 持有 View 接口,双向手动调用,需解绑防泄漏,接口多。MVVM:ViewModel 不持有 View,通过可观察数据(LiveData/StateFlow)单向通知,View 订阅,解耦更彻底。

Q2:MVVM 和 MVI 区别?MVI 解决什么? MVVM 状态分散在多个 LiveData/StateFlow,可能不一致、难追踪。MVI 用单一不可变 State 描述整个 UI、单向数据流、Intent 作唯一入口,状态可预测、易调试、易回放。代价是模板代码多。

Q3:为什么 ViewModel 里不能直接更新 UI? ViewModel 不应感知 View(否则耦合+泄漏+不可测)。它只产出状态,由 View 订阅后自行渲染,保持单向数据流。

Q4:Repository 模式的作用? 作为数据层唯一入口,对上层屏蔽数据来源(网络/缓存/数据库),实现单一数据源、缓存策略、离线支持,使 ViewModel 不关心数据从哪来。

Q5:UseCase(Interactor)有必要吗? 非必须。当业务逻辑复杂、需被多个 ViewModel 复用、或 ViewModel 过于臃肿时引入,封装单一业务用例,提升复用和可测试性。简单页面可省略。

Q6:一次性事件(Toast/导航)为什么不能放进 State? State 是持久状态,旋转重建后会重新渲染,若把事件放进 State 会重复触发(重复弹 Toast/重复导航)。应用 Channel/SharedFlow(replay=0)表达一次性 Effect。

Q7:你项目用什么架构?为什么这么选?(开放题) 结合实际答:中小页面 MVVM 足够轻量;复杂交互/状态多的页面用 MVI 保证可预测性。强调“按复杂度选型“,并说明分层(UI/Domain/Data)、单一数据源、依赖注入带来的可测试性收益。

进阶补充:端到端架构、MVI 状态机与 Offline-first

推荐端到端分层

ui(Screen/Fragment/Composable)
  -> ViewModel(State/Effect)
  -> UseCase(业务规则)
  -> Repository(数据协调)
  -> Local/Remote DataSource(Room/Retrofit)

依赖方向只能向内/向下的抽象走,UI 不直接依赖 Retrofit/Room。

MVI Reducer

MVI 的核心是事件驱动状态变化:

data class LoginState(val loading: Boolean = false, val error: String? = null)

fun reduce(state: LoginState, event: LoginEvent): LoginState = when (event) {
    LoginEvent.Submit -> state.copy(loading = true, error = null)
    is LoginEvent.Failed -> state.copy(loading = false, error = event.message)
}

一次性 Effect

Toast、导航、弹窗不适合放进持久 State,通常用 Effect/Channel/SharedFlow 处理。

Offline-first

离线优先通常以本地数据库为 single source of truth,网络同步更新本地,UI 观察本地。难点是冲突解决、同步状态、失败重试。

何时 MVVM 足够,何时 MVI 过重

简单表单/详情页 MVVM 足够;复杂交互、多状态、多事件流页面适合 MVI。不要为了追新把所有页面都写成复杂 reducer。

**追问:**Repository 是不是越多越好?不是。Repository 应按数据/业务聚合边界划分,过细会变成无意义转发层。

App 架构落地案例

架构题不要只背 MVVM/MVI 名词。面试官更想听到:一个真实页面从 UI 到 ViewModel、UseCase、Repository、DataSource 怎么流动,异常、分页、表单、离线缓存怎么统一收口。

一、完整页面流: UI → ViewModel → UseCase → Repository → DataSource

以“商品列表 + 筛选 + 收藏 + 分页”为例,推荐链路是单向的:

Screen/Fragment/Composable
  -> ViewModel: 接收 UI Intent,维护 UiState/Effect
  -> UseCase: 封装业务规则,如筛选参数校验、收藏权限判断
  -> Repository: 决定网络/缓存/数据库数据来源,合并多源
  -> RemoteDataSource / LocalDataSource: Retrofit/Room/DataStore

核心原则:

  • UI 不直接访问 Retrofit/Room,只渲染 UiState 并发送用户意图。
  • ViewModel 不写复杂数据来源选择,把业务规则交给 UseCase/Repository。
  • Repository 对上层暴露稳定模型,隐藏网络 DTO、数据库 Entity 的差异。

二、统一 UI 状态: loading / error / empty / content

不要用多个互相独立的 Boolean 到处散落,推荐一个不可变 UiState 表达完整页面状态。

data class ProductListState(
    val isLoading: Boolean = false,
    val isRefreshing: Boolean = false,
    val items: List<ProductUi> = emptyList(),
    val error: UiError? = null,
    val hasMore: Boolean = true,
) {
    val isEmpty: Boolean get() = !isLoading && items.isEmpty() && error == null
}
状态UI 怎么渲染ViewModel 怎么产出
首次 loading骨架屏/全屏 loadingisLoading=true, items=[]
content列表内容items 非空,error 清空
empty空态文案 + 引导按钮请求成功但数据为空
error错误态 + 重试首屏失败时设置 error
refresh error保留旧列表 + toast/snackbar用 Effect 提示,State 保留 items

三、一次性 Effect: Toast、导航、弹窗

一次性事件不要放进持久 State,否则配置变更或重新收集可能重复触发。常见做法是 ChannelMutableSharedFlow(replay = 0)

  • State:页面长期状态,旋转后重新渲染也合理。
  • Effect:一次性动作,如 Toast、导航、打开弹窗、提交成功返回。
  • Intent/Action:用户输入,如刷新、点击、提交、加载更多。

怎么答:ViewModel 处理 Intent 后同时可能产出新 State 和一次 Effect;UI 分别 collectAsStateWithLifecycle() 渲染 State,用 LaunchedEffect 收集 Effect。

四、分页: 刷新、加载更多与错误恢复

分页不是简单 append,要区分首次加载、下拉刷新、加载更多失败。

  1. Refresh:重置页码/游标,请求第一页,成功后替换列表。
  2. LoadMore:使用下一页 key,成功后 append,失败时保留旧列表并显示底部错误。
  3. 去重:按业务 ID 去重,避免刷新和加载更多交叉导致重复。
  4. 并发控制:同一时间只允许一个分页请求,或用 collectLatest 取消旧请求。
场景推荐状态面试追问点
首屏失败error != null, items=[]显示全屏错误,可重试
加载更多失败items 保留,loadMoreError 或 Effect不清空已有内容
无更多hasMore=false避免继续触发下一页
参数变化清空旧分页并刷新防止旧筛选结果混入

五、表单提交: 校验、幂等与防重复点击

表单页面适合体现 UseCase 价值。ViewModel 收集输入状态,UseCase 做业务校验和提交编排,Repository 执行网络/本地持久化。

  • 本地校验:手机号、验证码、必填项在提交前快速反馈。
  • 提交中状态:submitting=true 禁用按钮,防重复点击。
  • 服务端错误:映射成字段错误或页面错误,不要直接把接口文案散落到 UI。
  • 幂等:订单/支付/注册类提交要有 requestId 或服务端幂等键。
  • 成功 Effect:提交成功通常是导航/Toast,用 Effect 发出。

怎么排查重复提交:看按钮是否禁用、ViewModel 是否丢弃 submitting 期间的新 Intent、接口是否具备幂等键。

六、Offline-first 与离线缓存

离线优先不是“网络失败读缓存”这么简单,更推荐本地数据库作为 Single Source of Truth:

UI 观察 Room Flow
Repository 触发网络刷新
Remote 成功 -> 写入 Room
Room 变化 -> UI 自动更新
Remote 失败 -> UI 保留本地数据 + Effect 提示

优点:页面旋转、进程重建、短暂断网都能稳定展示;Repository 统一控制刷新策略、过期时间、同步状态。难点是冲突解决、脏数据标记、失败重试和缓存淘汰。

七、多源数据合并

真实页面经常需要合并用户信息、配置、列表、埋点开关、缓存状态。Repository/UseCase 可以用 Flow 组合多源:

  • combine:多个数据源任一变化都重新产出 UI 模型。
  • flatMapLatest:筛选条件变化时取消旧查询。
  • zip:严格一一配对,业务上较少用于 UI 持续状态。
  • 本地 + 远端:本地先出首屏,远端刷新后写库再更新。
多源类型合并位置注意点
用户权限 + 菜单配置UseCase权限变化要触发 UI 重新计算
Room 缓存 + 网络刷新Repository网络只更新库,UI 观察库
表单输入 + 服务端校验ViewModel/UseCase避免每个字符都打接口,加 debounce

八、落地检查清单

一个页面架构是否靠谱,可以按这张表自查:

检查项好的表现坏味道
数据流UI 只发 Intent、收 State/EffectUI 直接调 Repository/Retrofit
状态单个 UiState 表达完整页面loading/error/data 多处散落
错误Domain/UI error 统一映射到处 try-catch + Toast
分页刷新/更多/失败/无更多分开失败就清空列表
测试ViewModel/UseCase 可纯 JVM 测试业务逻辑写在 Fragment/Composable

高频面试题

Q1:请讲一个页面从 UI 到数据层的完整链路。 UI 负责渲染 State 和发送 Intent;ViewModel 接收 Intent,维护 UiState/Effect;UseCase 封装业务规则;Repository 决定网络、本地缓存和多源合并;DataSource 只负责具体 Retrofit/Room/DataStore 操作。依赖方向单向,上层不直接感知底层实现。

Q2:loading、error、empty 怎么统一管理? 用一个不可变 UiState 表达完整页面,例如 isLoading/items/error/hasMore。首屏错误显示全屏错误;刷新失败保留旧数据并发 Effect;请求成功但列表为空显示 empty,避免多个 Boolean 分散导致状态互相矛盾。

Q3:Toast/导航为什么不放进 State? State 是持久状态,配置变更或重新收集会再次渲染。如果把 Toast/导航放进 State,可能重复弹出或重复跳转。一次性动作应放 Effect,用 Channel 或 SharedFlow(replay=0) 发送。

Q4:分页加载更多失败时怎么处理? 不要清空已有列表。保留 items,记录底部错误或发一次 Effect,允许用户重试当前 pageKey;同时用 hasMore/loadingMore 防止重复触发并发请求。

Q5:Offline-first 怎么落地? 以 Room 作为 Single Source of Truth,UI 观察本地 Flow;Repository 触发网络刷新,成功后写入 Room,UI 因数据库变化自动更新;失败时保留本地数据并提示。难点是冲突、过期策略和失败重试。

易错点 / 追问

  • 不要让 UI 直接依赖 Retrofit/Room,否则页面难测、数据策略分散。
  • 不要把一次性事件塞进 UiState,旋转屏幕或重新订阅会重复消费。
  • 分页失败要区分首屏失败和加载更多失败,后者不能清空已有内容。
  • UseCase 不是越多越好;简单 CRUD 可省略,复杂业务规则/复用逻辑再引入。
  • Offline-first 的核心是本地单一数据源,不是简单“catch 网络异常后读缓存”。

测试体系

★ 测试体系是中级 Android 面试的短板高频区。能讲清“怎么测“,比只会说 MVVM/MVI 更能证明工程能力。

一、测试金字塔与 Android 测试分层

层级目标工具典型对象
单元测试快速验证纯逻辑JUnit / Truth / MockKUseCase、Repository、Reducer
集成测试验证多层协作Robolectric / fake data sourceViewModel + Repository
UI 测试验证用户路径Espresso / Compose Test页面交互、导航、错误提示

原则:越靠下越多、越快、越稳定;越靠上越少、越接近真实用户。

二、单元测试:Junit、断言与 Mock

  • JUnit 负责组织测试生命周期。
  • Truth/AssertJ 让断言可读。
  • MockK/Mockito 用于隔离外部依赖,但不要 mock 一切。
class LoginUseCaseTest {
    @Test
    fun `blank username returns validation error`() {
        val useCase = LoginUseCase(fakeRepository)
        val result = useCase.execute(username = "", password = "123456")
        assertThat(result).isEqualTo(LoginResult.InvalidUsername)
    }
}

三、协程与 Flow 测试

  • runTest 控制虚拟时间。
  • StandardTestDispatcher 替换真实 dispatcher。
  • Flow 可用 Turbine 验证 emit 顺序。
@Test
fun `flow emits loading then success`() = runTest {
    repository.userFlow().test {
        assertThat(awaitItem()).isEqualTo(UiState.Loading)
        assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
        awaitComplete()
    }
}

四、ViewModel / Repository 怎么测

ViewModel 测试重点不是测试 Android 框架,而是测试输入事件到 UI State 的转换。

对象测什么不测什么
ViewModelstate/effect、错误处理、重试具体控件绘制
Repository缓存策略、数据源切换Retrofit/Room 本身
UseCase业务规则外部 IO

五、UI 测试:Espresso 与 Compose Test

  • Espresso 适合 View 体系页面。
  • Compose Test 适合声明式 UI,优先通过 semantic matcher 找节点。
  • UI 测试要覆盖核心路径,不要把所有边界都堆在 UI 层。

六、可测试性如何反推架构质量

如果一个 ViewModel 很难测,通常说明它持有太多 Android 依赖、业务逻辑没有下沉、状态/副作用没有分离。

高频面试题

Q1:你项目里怎么做测试分层? 答:核心业务规则放单元测试,ViewModel/Repository 做集成测试,关键用户路径做 UI 测试。比例上单元测试最多,UI 测试最少。

Q2:为什么 Repository 不应该直接测 Retrofit/Room? 答:Retrofit/Room 是框架能力,业务测试应关注 Repository 的缓存、错误兜底、数据源切换。框架集成可少量用 integration test 验证。

Q3:协程测试为什么不用真实 delay? 答:真实 delay 会让测试慢且不稳定;runTest 用虚拟时间推进,可稳定验证超时、重试、debounce 等逻辑。

易错点 / 追问

  • 不要为了覆盖率 mock 所有东西,那会测到实现细节。
  • UI 测试不要依赖真实网络和随机数据。
  • Compose 测试要给关键节点加稳定语义,否则 matcher 容易脆弱。

组件化路由与模块通信

组件化不是把包拆成很多 module,而是让业务边界、依赖方向和发布协作更清晰。中高级面试常追问路由、通信和依赖治理。

一、组件化解决什么问题

问题组件化目标
业务耦合feature 边界清晰
编译慢模块增量构建
多团队协作独立开发/测试
依赖混乱依赖方向可控

二、模块拆分方式

  • app 壳工程:组装入口。
  • feature module:业务功能。
  • core/common:基础能力。
  • api/contract:跨模块接口。

依赖方向应从上层依赖抽象,避免 feature 之间直接互相依赖。

三、路由框架原理(以 ARouter 类框架理解)

核心流程:编译期扫描注解 → APT/KSP 生成路由表 → 运行时按 path 查找目标 → 拦截器处理登录/权限/降级 → 跳转或返回服务实例。

interface UserService {
    fun currentUserId(): String?
}

跨模块不要直接调用实现类,而是依赖 UserService 这样的 contract。

四、模块通信方式

方式适用风险
路由跳转页面级跳转path 字符串错误
接口下沉同步能力调用contract 膨胀
事件总线广播式通知难追踪、生命周期问题
Result API页面结果回传复杂链路难维护

五、路由拦截、降级与灰度

登录校验、权限校验、A/B 实验、页面不存在降级都适合放在拦截器链里。路由失败必须有兜底,不能直接 crash。

六、和 Gradle 工程化的关系

组件化设计关注业务边界;Gradle 工程化关注构建配置、依赖版本、产物和性能。两者相关但不是一回事。

高频面试题

Q1:组件化和模块化区别? 答:模块化偏代码物理拆分,组件化更强调业务组件独立开发、独立测试、按契约通信和可组装。

Q2:ARouter 这类框架大概怎么实现? 答:编译期注解处理生成路由表,运行时初始化加载,按 path 找目标 class/provider,再通过拦截器处理统一逻辑。

Q3:跨模块通信为什么不直接依赖实现? 答:直接依赖会导致 feature 耦合、编译依赖变重、循环依赖风险;接口下沉能保持依赖方向稳定。

易错点 / 追问

  • 不要为了组件化制造过多小 module。
  • 不要把所有通信都塞进 EventBus。
  • 路由 path 要集中治理,否则重构时容易断链。

Android 系统原理

这一篇偏底层,对你来说相对友好(你本来就懂系统)。面试常问 Handler、Binder、启动流程,用你的底层功底能答得比一般应用开发者深。

一、Handler / Looper / MessageQueue

Android 的线程间通信与主线程消息循环核心机制。

  • Looper:每个线程最多一个 Looper,内部持有 MessageQueue,loop() 死循环不断取消息分发。主线程的 Looper 由系统在 ActivityThread.main 中创建。
  • MessageQueue:消息队列,按时间排序的单链表;next() 取消息,无消息时通过 epoll 阻塞(不耗 CPU)。
  • Handler:发送(sendMessage/post)和处理(handleMessage)消息;Message 持有 target(发送它的 Handler)。
  • ThreadLocal:Looper 通过 ThreadLocal 与线程绑定,保证一个线程一个 Looper。

为什么主线程死循环不卡死/不 ANR? loop() 是死循环,但无消息时阻塞在 epoll_wait 让出 CPU,有事件(触摸、绘制)再唤醒。ANR 是消息处理太久,不是循环本身。

进阶:

  • 同步屏障(Sync Barrier):插入屏障后,同步消息被拦截,优先处理异步消息(如 UI 绘制 doFrame),保证及时刷新。
  • IdleHandler:队列空闲时回调,可做延迟初始化(不阻塞关键路径)。

二、Binder 机制

Android 跨进程通信(IPC)的核心,理解它能体现你的底层深度。

  • 为什么用 Binder 而非传统 IPC? 性能:常见说法是一次拷贝(传统管道/socket 通常需要用户态/内核态多次拷贝)。安全:内核知道调用方 UID/PID,服务端可做权限校验。
  • 一次拷贝原理(实现层面直觉):Binder 驱动为进程维护映射区,传输时把发送方 Parcel 数据拷贝到驱动管理的 binder_buffer;接收进程可通过映射区读取,从而少一次“内核缓冲区 → 接收方用户空间”的显式拷贝。不同 Android 版本的驱动细节可能调整,面试回答用“当前主流实现/实现层面”表述更稳。
  • 结构:Client、Server、ServiceManager(类似 DNS,管理服务注册查找)、Binder 驱动(/dev/binder,核心)。
  • 代理模式:Client 拿到的是 BinderProxy,调用经驱动转发到 Server 的 Binder 实体。AIDL 自动生成这套 Stub/Proxy 代码。

一次 Binder 调用怎么走

  1. Client 调用 AIDL 生成的 Proxy 方法,把参数写入 Parcel
  2. transact() 进入 Binder 驱动,驱动根据 handle 找到目标 Binder 实体和目标进程。
  3. 目标进程的 Binder 线程池取到事务,回调 Stub 的 onTransact() 反序列化参数并执行服务端方法。
  4. 同步调用会把返回值写回 reply Parcel,Client 线程在结果返回前阻塞;oneway 调用不等待业务返回,但仍受队列和线程调度影响。
面试追问稳妥回答
“Binder 真的零拷贝吗?”不是。普通 Binder 事务常说“一次拷贝”,不是零拷贝;大块数据应走共享内存/文件描述符等方案。
“能传多大数据?”Android 文档提到 Binder transaction buffer 当前约 1MB,且是进程内并发事务共享;工程上要把单次 IPC 控制得更小,避免 TransactionTooLargeException
“为什么容易死锁?”同步 Binder 会阻塞调用线程,服务端也可能回调客户端。持锁发 Binder 或主线程发耗时 Binder,都可能导致锁等待/ANR。
“AIDL 和 Binder 的关系?”AIDL 是代码生成工具,生成 Stub/Proxy/Parcel 编解码;底层事务仍通过 Binder 驱动。

三、应用启动流程

点击图标到界面显示的大致链路:

  1. Launcher 捕获点击,通过 Binder 调用系统服务请求启动 Activity。Android 10 之后 Activity 启动职责更多落在 ATMS(ActivityTaskManagerService),但面试可把 AMS/ATMS 作为同一条系统调度链讲清楚。
  2. 系统检查目标进程是否存在,不存在则经 Zygote 连接发送 fork 命令;历史和实现细节里常见 socket/zygote command 描述,不要把具体通信细节当公开 API 契约。
  3. Zygote fork 出应用进程,子进程执行 ActivityThread.main(),创建主线程 Looper。
  4. 新进程通过 Binder 向系统 attachApplication,系统再回调创建 Application 和目标 Activity。
  5. 执行 Application.onCreate → Activity.onCreate → onResume,ViewRootImpl 触发首帧绘制。

Zygote:所有应用进程的“母体“,预加载了核心类和资源,fork 时通过 COW(写时复制)共享,加速启动、省内存。

冷启动拆解与可优化点

阶段机制面试可讲的优化边界
进程创建Zygote fork,继承预加载类/资源,COW 降低重复加载成本App 无法直接优化 fork 本身,重点是减少 fork 后主线程工作。
ActivityThread.main()准备主线程 Looper,进入消息循环主线程消息循环不是卡顿来源,卡顿来自消息处理超时。
bindApplication创建 Application、加载 Provider、初始化 SDK收敛 ContentProvider 自启动,延迟/按需初始化 SDK。
Activity 首帧创建 Activity、inflate、measure/layout/draw减少首屏布局层级和同步 IO,用 Perfetto/Startup Timing 量化。

回答启动流程时要区分公开生命周期系统实现细节:对业务开发者稳定的是 Application/Activity 生命周期与进程可能被系统创建/杀死;AMS/ATMS、Zygote 命令格式、预加载列表属于实现层面,可作为理解但不宜写成永久不变的 API。

四、类加载与热修复 / 插件化

  • 类加载器:PathClassLoader(加载已安装 APK)、DexClassLoader(可加载外部 dex/jar,热修复/插件化基础)。它们持有 DexPathList,内部是 dexElements 数组。
  • 热修复原理:把修复后的补丁 dex 插到 dexElements 数组最前面,根据双亲委派+数组顺序查找,补丁类先被加载,从而“覆盖“有 bug 的旧类(如 Tinker、QFix)。AndFix 则走 native 方法替换。
  • 插件化:动态加载未安装的 APK,需解决类加载、资源加载(AssetManager.addAssetPath)、组件生命周期(Hook AMS / 占坑 Activity)三大问题。

热修复方案对比

方案核心机制优点风险/边界
类加载补丁反射拿到宿主 ClassLoader 的 pathList.dexElements,把补丁 dex 的 element 前插不直接改 ART 方法结构,兼容性相对好;适合 Java/Kotlin 逻辑修复已加载类通常不能被重新定义,常需要冷启动生效;受 multidex、混淆、校验和厂商改动影响。
native 方法替换修改 ART/Dalvik 方法入口或 native bridge,让旧方法跳到新实现可做到即时生效的效果强依赖运行时内部结构,Android 版本/厂商 ROM 兼容风险高,安全合规要求更高。
资源补丁构造新的 AssetManager/Resources,通过 addAssetPath 或等价实现加入补丁资源包可修复图片、布局、字符串等资源资源 ID、主题、缓存对象可能已被解析;高版本隐藏 API 限制和厂商差异需评估。

dexElements 前插流程可以按四步讲:下载补丁并校验签名/版本 → 用 DexClassLoader 加载补丁 dex → 反射合并补丁与宿主的 dexElements → 重启进程或在安全时机让新类优先命中。重点不是“反射代码背诵”,而是说明类查找顺序改变

插件化三大难点

难点常见做法面试边界
类加载为插件创建独立 DexClassLoader,或把插件 dex 合入宿主 ClassLoader独立加载隔离性好但共享类/类型转换要小心;合并加载简单但冲突风险高。
资源加载为插件创建 Resources,把插件 apk 路径加入 AssetManager,再处理主题/资源 IDaddAssetPath 属实现层面方案,隐藏 API/资源缓存/AssetLoader 演进会影响兼容。
组件生命周期manifest 未注册的 Activity/Service 不能直接被系统识别,常用“宿主占坑组件 + Hook Intent/Instrumentation/AMS 调度”Hook 系统调用链风险高,受 Android 版本和厂商 ROM 影响;线上要有降级与灰度。

占坑思路:宿主 manifest 预注册一个透明/通用 Activity,启动插件 Activity 前把 Intent 替换成占坑 Activity;系统校验通过后,在回调到应用侧时再把真实插件 Intent 换回来,由插件框架接管生命周期与资源。这个概念能说明“为什么插件 Activity 不在 manifest 也能跑”,但面试中要补一句:这不是官方组件模型,兼容性和合规风险需要框架兜底。

工程风险清单:

  • 补丁必须做签名、版本、灰度、回滚,不能把远端 dex 加载当成无约束能力。
  • 反射/隐藏 API 在高版本可能受限制,厂商 ROM 对 ClassLoader/Resources 实现也可能不同。
  • native 替换与组件 Hook 改动运行时内部结构,要用设备矩阵验证,不要承诺“所有版本通用”。
  • 热修复适合止血,根因仍要通过正常发版修复;插件化适合业务动态化,不应绕过系统权限和安装模型。

五、进程间通信方式对比

方式特点场景
Binder一次拷贝、安全、面向对象Android 主流 IPC、AIDL
Socket通用、两次拷贝、慢跨设备、Zygote 通信
共享内存零拷贝、最快、需同步大数据(匿名共享内存 Ashmem)
管道/消息队列传统 Linux IPC较少用
文件/ContentProvider简单、慢持久化数据共享

高频面试题

Q1:主线程 Looper 死循环为什么不会卡死 App? loop() 是死循环,但 MessageQueue 无消息时通过 epoll 阻塞,让出 CPU 不占用资源;有消息(触摸/绘制等)再唤醒处理。ANR 是单条消息处理太久,不是循环本身导致。

Q2:Handler 内存泄漏怎么产生?如何避免? 非静态内部类 Handler 隐式持有外部 Activity,若有延迟消息未处理,Activity 无法回收。解决:静态内部类 + 弱引用,或在 onDestroy 中 removeCallbacksAndMessages(null)。

Q3:Binder 为什么只需一次拷贝? 实现层面可理解为 Binder 驱动维护映射区和 binder_buffer,发送方 Parcel 数据拷贝到驱动缓冲后,接收方通过映射区读取,省去一次显式拷贝。它不是零拷贝,且事务缓冲有大小限制,大数据要走共享内存/文件描述符等方案。

Q4:为什么用 Binder 而不是传统 IPC? 性能:一次拷贝优于管道/socket 的两次。安全:内核自动附带可靠的 UID/PID,支持身份鉴权,传统 IPC 需自行在数据层鉴权。

Q5:Zygote 的作用?为什么用 fork? Zygote 预加载核心类库和资源,作为所有应用进程的母体。fork 通过写时复制共享已加载内容,加速进程创建、减少内存占用(避免每个进程重复加载framework)。

Q6:热修复的基本原理? 利用类加载机制,把补丁 dex 插入 ClassLoader 的 dexElements 数组前面,使补丁类优先于有 bug 的旧类被加载。属于“类加载方案”,通常需要重启或确保旧类尚未加载;另有 native 方法替换方案,但强依赖 ART 内部结构,兼容风险更高。

Q7:ThreadLocal 在 Looper 里起什么作用? 保证一个线程只有一个 Looper。Looper.prepare 把 Looper 存入 ThreadLocal,各线程独立互不干扰,getMainLooper 拿主线程的。

Binder 与 IPC 深入

Binder 是 Android 系统面试里最能拉开层次的题:不要只背“一次拷贝”,要能把 mmap、ServiceManager、AIDL、线程池和大数据边界讲成一条工程链路。

一、Binder 总体模型

Binder 是 Android 最核心的 IPC 机制,把跨进程调用包装成“像调用本地对象一样调用远端服务”。它同时解决三件事:

  • 通信:Client 通过 Binder 驱动把 Parcel 发给 Server。
  • 寻址:ServiceManager 负责服务注册与查询,类似系统服务的“电话簿”。
  • 安全:Binder 驱动天然知道调用方 UID/PID,服务端可以做权限校验。
Client(BinderProxy)
  -> /dev/binder 驱动
  -> Server(Binder Stub / Binder 实体)
  -> Binder 线程池执行 onTransact

面试回答要强调:Binder 不是单个类,而是 用户态 Stub/Proxy + Binder 驱动 + ServiceManager + 线程池调度 的组合机制。

二、一次拷贝模型与 mmap

常见说法是 Binder “一次拷贝”,不是零拷贝。传统 socket/pipe 往往需要“发送方用户态 → 内核缓冲区 → 接收方用户态”两次拷贝;Binder 通过内核维护的映射区减少一次显式拷贝。

  • Server 进程启动 Binder 线程池时,会和 Binder 驱动建立映射区(mmap)。
  • Client 把参数序列化到 Parcel,调用 transact() 进入驱动。
  • 驱动把数据拷贝到目标进程可通过映射区访问的 Binder buffer。
  • Server 的 Binder 线程从映射区读取 Parcel 并分发到 onTransact()
说法面试稳妥表达
Binder 零拷贝不准确。普通 Binder 事务仍有一次用户态到内核/驱动缓冲的拷贝。
Binder 一次拷贝可以作为高层理解,核心是 mmap 映射减少“内核到接收方用户态”的额外拷贝。
Binder 适合传大图/大文件不适合。大数据应走共享内存、文件描述符、ContentProvider stream 等。

三、ServiceManager 与服务注册查找

ServiceManager 是 Binder 世界里的特殊服务,负责把服务名映射到 Binder handle。

  1. SystemServer 创建系统服务,把 Binder 实体注册到 ServiceManager。
  2. Client 通过服务名查询,拿到的是远端 Binder 的代理对象。
  3. 后续调用不再直接找服务名,而是通过 handle 让 Binder 驱动路由事务。

怎么答得更工程化:ServiceManager 解决“我怎么拿到远端服务入口”的问题;Binder 驱动解决“这个入口怎么跨进程调用”的问题;AIDL 解决“业务接口怎么自动生成序列化和代理代码”的问题。

四、AIDL、Messenger 与 ContentProvider IPC

不同 IPC 方式适合不同粒度,不要把 AIDL 当成唯一答案。

方式本质适合场景注意点
AIDL编译期生成 Stub/Proxy,底层 Binder多方法、强类型、需要回调或并发调用的服务接口版本兼容、线程安全、权限校验
MessengerHandler + Binder 封装,消息串行处理简单命令、低并发、只需传 Message单线程串行,不适合高吞吐
ContentProvider系统组件 + Binder,以 URI 暴露数据跨应用共享结构化数据、文件流权限、URI 授权、查询不要阻塞主线程

AIDL 调用链

  1. .aidl 定义接口和 Parcelable 数据类型。
  2. 编译器生成 StubProxyonTransact()asInterface()
  3. Client 调用 Proxy 方法,参数写入 Parcel。
  4. Server Binder 线程执行 Stub 的业务方法,再把返回值写回 reply。

五、Binder 线程池与调用风险

Binder 回调不是自动跑在主线程。服务端通常由 Binder 线程池处理事务,这带来两个面试重点:

  • 线程安全:多个 Client 可并发调用同一服务方法,共享状态要加锁或串行化。
  • 阻塞风险:同步 Binder 会阻塞调用方线程;如果主线程发耗时 Binder,可能导致 ANR。
  • 线程池耗尽:服务端 Binder 线程被慢任务占满后,新事务排队,上游看起来像“系统服务卡住”。
  • 反向调用死锁:Client 持锁调用 Server,Server 回调 Client 又等待同一把锁,容易死锁。

怎么落地:服务端 Binder 方法里只做参数校验和轻量分发,耗时工作转到业务线程池;Client 侧避免主线程同步调用不可信远端服务。

六、DeathRecipient 与远端进程死亡

Binder 可以监听远端服务死亡,常用 linkToDeath() 注册 DeathRecipient

  • 用途:远端进程崩溃/被杀后,Client 能清理缓存代理、重连服务、更新 UI 状态。
  • 触发:binderDied() 在 Binder 线程里回调,不要直接更新 UI 或做重活。
  • 清理:服务恢复或不再需要监听时调用 unlinkToDeath()

常见落地流程:

获取远端 Binder -> linkToDeath
调用失败或 binderDied -> 标记服务不可用 -> 清理代理
后台重连/重新 bind -> 成功后重新注册 DeathRecipient

七、TransactionTooLargeException 与 Parcelable 边界

Binder transaction buffer 通常按约 1MB 级别理解,而且是进程内并发事务共享,不是“每次调用都稳定可用 1MB”。因此大 Bundle、大 Bitmap、大列表都可能触发 TransactionTooLargeException

  • Activity/Fragment 传参:不要把大对象塞进 Intent/Bundle,传 ID,详情从数据库/Repository 取。
  • Parcelable:适合轻量结构化对象,不是大数据通道;字段要控制数量和嵌套深度。
  • 大文件/图片:用 Uri、FileDescriptor、ContentProvider openFile、共享内存等方式。
  • 并发影响:多个事务同时发生时共享缓冲区,单次数据即使小于 1MB 也可能失败。

怎么排查:看异常堆栈里的组件跳转/状态保存路径,重点检查 Intent extrasonSaveInstanceState、AIDL 返回列表、Provider Cursor Window。

八、IPC 设计与排查模板

设计一个稳定 IPC 接口时,按下面清单回答会更像工程落地:

维度怎么设计怎么排查
数据大小传 ID/分页/FD,避免大 Parcelable统计 Parcel/Bundle 大小,压测并发事务
线程模型Binder 方法轻量,耗时转线程池看 Binder 线程堆栈是否被 IO/锁占住
生命周期linkToDeath + 重连检查远端进程死亡后代理是否清理
安全校验 UID/PID/签名/权限确认 exported、permission、调用方身份
兼容AIDL 字段只增不乱改语义老新版本互调测试

高频面试题

Q1:Binder 为什么说是一次拷贝?mmap 起什么作用? 普通 Binder 事务不是零拷贝。Server 与 Binder 驱动建立 mmap 映射区后,驱动把 Client Parcel 数据拷贝到目标进程可访问的 Binder buffer,接收方通过映射区读取,减少一次“内核缓冲区到接收方用户态”的显式拷贝。

Q2:ServiceManager 的作用是什么? 它负责服务注册和查询,把服务名映射到 Binder handle。Client 先通过 ServiceManager 找到远端服务代理,后续事务由 Binder 驱动根据 handle 路由到目标 Binder 实体。

Q3:AIDL、Messenger、ContentProvider 怎么选? AIDL 适合强类型、多方法、高并发服务;Messenger 是 Handler + Binder,适合简单串行消息;ContentProvider 适合跨应用共享结构化数据或文件流。它们底层都可能走 Binder,差异在抽象层和使用场景。

Q4:Binder 线程池会带来什么问题? 服务端方法可能被多个 Binder 线程并发调用,共享状态要线程安全。耗时任务占满 Binder 线程池会导致后续事务排队;主线程同步调用慢 Binder 还可能 ANR。

Q5:TransactionTooLargeException 怎么避免? 不要在 Intent、Bundle、AIDL、Parcelable 里传大对象。传 ID、Uri、分页数据或 FileDescriptor;图片/文件走 ContentProvider stream 或共享内存。还要记住 Binder buffer 是并发共享的。

易错点 / 追问

  • 把 Binder 说成“零拷贝”是常见错误,更稳妥是“普通事务一次拷贝,大数据另走共享内存/FD”。
  • AIDL 方法默认可能在 Binder 线程并发执行,不要默认它运行在主线程或天然串行。
  • 持锁发同步 Binder、在 Binder 回调里再反向调用,是死锁和 ANR 高频追问。
  • Parcelable 只解决序列化效率,不突破 Binder transaction buffer 限制。
  • binderDied() 不是 UI 回调,要切线程并做轻量清理/重连。

多线程并发专题

Android 并发题要同时答 Java 基础和移动端场景:线程池参数背得出只是起点,还要知道 HandlerThread、IntentService、协程调度、锁、CAS、ANR/OOM 怎么落地排查。

一、Android 线程模型与 HandlerThread

主线程负责 UI、输入事件和生命周期回调,耗时任务必须离开主线程。HandlerThread 是带 Looper 的后台线程,适合串行处理一类任务。

val thread = HandlerThread("worker").apply { start() }
val handler = Handler(thread.looper)
handler.post { /* 串行后台任务 */ }

特点:

  • 一个 HandlerThread 对应一个 Looper/MessageQueue,任务按消息顺序串行执行。
  • 适合相机回调、轻量 IO、SDK 内部串行状态机。
  • 不适合大量并行任务;任务太慢会阻塞后续消息。
  • 退出时调用 quitSafely(),避免线程泄漏。

二、IntentService / JobIntentService 的边界

IntentService 本质是 Service + HandlerThread,按 Intent 串行执行,执行完自动停止。它曾适合后台串行任务,但在后台执行限制增强后使用场景变窄。

机制特点现状/边界
IntentService后台线程串行处理 Intent已不推荐作为新方案,受后台限制影响
JobIntentService兼容低版本,高版本走 JobScheduler 思路也不是长期首选,复杂任务更推荐 WorkManager
WorkManager可约束、可重试、可持久化延迟/保证执行类后台任务首选

怎么答:如果是页面内短任务,用协程/线程池;如果是可延迟、需约束、进程死后仍要执行的任务,用 WorkManager,不要滥用 Service 常驻后台。

三、Executor 与 ThreadPoolExecutor

线程池用于复用线程、限制并发、隔离任务类型。ThreadPoolExecutor 关键参数不是背诵,要能讲执行流程:

提交任务
  -> 工作线程数 < corePoolSize: 新建核心线程
  -> 否则尝试入队 workQueue
  -> 队列满且线程数 < maximumPoolSize: 新建非核心线程
  -> 仍无法处理: RejectedExecutionHandler
参数含义Android 坑点
corePoolSize核心线程数太大增加调度和内存压力
maximumPoolSize最大线程数配无界队列时通常不起作用
workQueue等待队列无界队列可能堆积导致 OOM
keepAliveTime非核心线程存活时间可回收突发线程
rejectedHandler拒绝策略要可观测,不要静默丢任务

四、协程 Dispatchers 与线程池关系

Kotlin 协程不是“没有线程”,它是挂起/恢复的调度模型,最终仍运行在线程上。

  • Dispatchers.Main:主线程,更新 UI。
  • Dispatchers.IO:IO 密集型任务,适合网络、磁盘,底层有弹性线程池策略。
  • Dispatchers.Default:CPU 密集型任务,如排序、JSON 大计算、图片算法。
  • withContext:切换执行上下文并等待结果。

怎么落地:Repository 做网络/数据库可用 withContext(IO);CPU 计算不要丢到 IO;UI 收集 Flow 用生命周期感知 API,避免页面销毁后继续更新。

五、锁、volatile、CAS 与 Atomic

并发控制要区分“可见性、原子性、有序性”。

工具解决什么适合场景注意点
synchronized互斥 + 可见性简单临界区持锁不要做 Binder/IO
ReentrantLock可中断/可尝试/公平锁复杂锁控制必须 finally unlock
volatile可见性 + 禁止部分重排状态标记、双检锁引用不能保证复合操作原子性
CAS/Atomic无锁原子更新计数、状态机引用ABA、自旋开销
private val running = AtomicBoolean(false)

fun startOnce() {
    if (running.compareAndSet(false, true)) {
        // only one caller can enter
    }
}

六、死锁与线程安全排查

死锁通常满足互斥、持有并等待、不可抢占、循环等待。Android 里高频场景是“主线程等后台锁,后台线程切回主线程”或“持锁发同步 Binder”。

排查路径:

  1. 抓 ANR traces 或线程 dump,看主线程卡在哪把锁/哪个 Future/Binder。
  2. 找持锁线程,看它是否等待主线程、IO、网络或另一个锁。
  3. 检查锁顺序是否全局一致,是否在锁内做耗时操作。
  4. 用超时、tryLock、缩小临界区或串行队列替代复杂嵌套锁。

怎么答:不要只说“加锁解决线程安全”,还要补“锁粒度、锁顺序、锁内不做耗时/跨进程调用”。

七、线程池导致 ANR/OOM 的坑

线程池配置错会从“优化”变成“事故”。

  • ANR:主线程等待线程池结果,但线程池被长任务占满;或回调切主线程后主线程被阻塞。
  • OOM:无界队列堆积大量 Runnable/闭包持有 Activity/Bitmap;线程数过多导致栈内存暴涨。
  • 优先级反转:低优先级长任务占满池,高优先级 UI 相关任务排队。
  • 任务泄漏:页面销毁后任务仍持有 View/Context。
规避方式
newCachedThreadPool 滥用限制最大线程数,按任务类型隔离线程池
无界 LinkedBlockingQueue设置有界队列和拒绝策略
主线程 Future.get()用回调/协程挂起,不要阻塞主线程
IO/CPU 混用一个池IO 与 CPU 任务隔离,避免互相饿死

八、并发设计落地模板

面试讲项目时可以按这个模板说明并发方案:

  1. UI 事件进入 ViewModel,用协程保证生命周期自动取消。
  2. Repository IO 任务切到 Dispatchers.IO,CPU 计算切到 Default
  3. SDK 内部串行状态用 HandlerThread 或单线程 Executor。
  4. 共享状态用不可变快照、Atomic 或小粒度锁。
  5. 线程池设置有界队列、命名线程、异常日志和拒绝策略。

高频面试题

Q1:HandlerThread 适合什么场景?和线程池区别? HandlerThread 是一个带 Looper 的单后台线程,任务按消息串行执行,适合相机、SDK 状态机、轻量 IO 等需要顺序的任务。线程池适合多个独立任务并发执行,但要控制队列和线程数。

Q2:ThreadPoolExecutor 的执行流程是什么? 先看工作线程是否小于 corePoolSize,是则建核心线程;否则入队;队列满且线程数小于 maximumPoolSize 时建非核心线程;仍处理不了就走拒绝策略。无界队列会让 maximumPoolSize 基本失效。

Q3:volatile 能保证 i++ 线程安全吗? 不能。volatile 保证可见性和一定有序性,但 i++ 是读-改-写复合操作,不具备原子性。需要 synchronized、Lock 或 AtomicInteger/CAS。

Q4:协程 Dispatchers.IO 和 Default 怎么选? IO 用于网络、磁盘、数据库等阻塞 IO;Default 用于 CPU 密集计算。协程最终仍在线程上执行,选错调度器会导致线程饥饿或 CPU 争用。

Q5:线程池为什么会导致 ANR 或 OOM? 主线程等待线程池结果会 ANR;线程池被长任务占满会让关键任务排队。无界队列堆积大量 Runnable、线程数过多导致栈内存增长、任务闭包持有大对象,都可能 OOM。

易错点 / 追问

  • 协程不是替代线程的魔法,挂起恢复最终仍依赖 Dispatcher 背后的线程。
  • volatile 不能保证复合操作原子性,回答时要区分可见性和原子性。
  • 锁内不要做 IO、网络、同步 Binder 或切主线程等待,这是死锁/ANR 高频点。
  • 无界队列 + 大量任务比“线程数很多”更隐蔽,也更容易拖到 OOM。
  • IntentService/JobIntentService 不是现代后台任务万能解,可延迟可靠任务优先考虑 WorkManager。

性能优化

性能优化是中级面试的高频区,也是你的潜在主场——风控 SDK 对体积/性能极度敏感,你的经验比一般应用开发者更有说服力。

一、启动优化

启动分类:

  • 冷启动:进程不存在,需创建进程 + Application + Activity,最慢。
  • 温启动:进程在但 Activity 重建。
  • 热启动:Activity 还在,仅恢复,最快。

冷启动耗时点 + 优化:

  • Application.onCreate 过重:第三方 SDK 初始化阻塞。优化:异步初始化、延迟初始化、按需初始化(用 Jetpack App Startup 统一管理依赖顺序)。
  • 闪屏页(Splash):用 Android 12+ SplashScreen API,避免白屏/黑屏。
  • 主线程 IO:首屏不读大文件/SP。
  • 测量:adb shell am start -W 看 TotalTime;Perfetto/Systrace 看主线程占用。
  • 进阶:类预加载、ContentProvider 初始化收敛(很多 SDK 用 ContentProvider 自启,拖慢启动)。

二、内存优化

内存泄漏(常见场景)

  • 非静态内部类/匿名类持有外部 Activity(Handler、Runnable、监听器)。
  • 静态变量持有 Context/View。
  • 单例持有 Activity Context(应用 Application Context)。
  • 未注销监听器/广播/观察者。
  • 资源未关闭(Cursor、Stream、Bitmap)。

LeakCanary 原理(以 LeakCanary 2.x 思路理解):默认会 watch Activity、Fragment、Fragment View、ViewModel 等应被回收的对象。对象销毁后交给 ObjectWatcher,内部用弱引用和引用队列观察;延迟一小段时间后触发/等待 GC,若对象仍未被回收,就认为是 retained object。达到阈值后 dump HPROF,再由 Shark 分析 GC Roots 到泄漏对象的最短强引用链。

步骤看到的证据面试边界
watch 销毁对象对象带 description 和 watch time不是所有 retained 都是业务泄漏,可能是系统/库已知泄漏或调试期延迟。
等待 + GC对象没有进入 ReferenceQueueGC 触发时机不是业务可精确控制的 API,不要说“立刻判定”。
heap dumpHPROF 文件、分析耗时dump 会暂停进程/占内存,通常只在 debug 或内测环境开。
Shark 分析引用链、Library Leak 标记结论要结合生命周期判断,不是看到 Activity 就机械删除引用。

内存抖动

短时间大量创建小对象(如 onDraw / 循环里 new),频繁 GC 导致卡顿。优化:对象复用、避免在高频路径分配、用对象池。

三、卡顿与 ANR

掉帧原理

屏幕 60fps → 每帧 16.6ms 内必须完成 measure/layout/draw。超时就丢帧(jank)。Choreographer 接收 VSync 信号驱动每帧渲染,可监控帧耗时。

卡顿优化

  • 布局优化:减少层级(ConstraintLayout 扁平化)、<merge>/<ViewStub>、避免过度绘制(overdraw)。
  • 主线程不做耗时(IO、解析、复杂计算),用协程切后台。
  • RecyclerView:复用、DiffUtil、避免在 onBindViewHolder 做重活、预取。

ANR(Application Not Responding)

触发条件:

  • 主线程阻塞:常见记忆口径是 Input 事件约 5s 未响应、前台 BroadcastReceiver 约 10s、后台广播约 60s、前台 Service 约 20s、后台 Service 约 200s。但这些阈值会受 Android 版本、前后台状态、组件类型和厂商策略影响,面试要说“常见阈值/典型场景”,不要当成所有版本的硬契约。 定位:
  • /data/anr/traces.txt 看主线程堆栈。
  • 看是否主线程 IO、锁等待、Binder 阻塞、死锁。
  • 工具:ANR-WatchDog、线上监控(主线程卡顿监控)。

卡顿/ANR 排查链路

症状可能原因工具关键证据修复方向取舍
冷启动白屏久Application/Provider 初始化重、主线程 IO、首屏 inflate 慢am start -W、Android Studio Startup/Profiler、PerfettoTotalTime 增大、main thread 上长任务、bindApplication/launchActivity slice 慢延迟/按需初始化、Provider 收敛、首屏最小化延迟初始化可能把耗时转移到首个功能入口,要配合预热和埋点。
滑动掉帧onBind 重活、图片解码过大、布局层级深、频繁 GCProfiler、Perfetto Frame Timeline、Layout Inspector帧耗时超过预算、UI thread/RenderThread slice 长、GC 频繁DiffUtil、异步解码、固定尺寸、减少层级/overdraw过度缓存会增加内存,异步化要处理取消和错位。
偶发长卡顿主线程锁等待、同步 Binder、磁盘 IO、类加载Perfetto/Systrace、StrictMode、线程 dumpmain thread blocked/runnable 状态、binder transaction、disk read/write缩小锁范围、后台线程、缓存预热、异步 IPC后台化不等于无限开线程,要限制并发和生命周期。
ANR主线程长时间不取消息、广播/服务超时、死锁ANR traces、Perfetto、日志埋点、线上 ANR 平台traces 中 main 堆栈、锁 owner、Binder 对端、CPU/IO 状态移除主线程阻塞、拆分任务、避免持锁跨 Binder只看 main 堆栈可能误判,还要看 CPU 饥饿和对端进程。
内存泄漏Activity/Fragment 被静态对象、监听器、协程持有LeakCanary、Memory Profilerretained object、GC Root 引用链、实例数量持续上涨解绑监听、用 application context、取消协程/请求弱引用不是万能解,优先修正生命周期所有权。

四、包体积优化

  • 代码:R8/ProGuard 混淆 + 裁剪无用代码(shrinkResources)。
  • 资源:无用资源删除、图片用 WebP、矢量图、资源混淆(AndResGuard)。
  • so 库:按 ABI 拆分(abiFilters 通常只留 arm64-v8a)、动态下发。这是你的强项——SDK 开发对 so 体积敏感。
  • dex:移除未用依赖、避免方法数膨胀。
  • App Bundle(aab):按设备下发,Google Play 强制。

五、工具链

  • Android Studio Profiler:适合开发期快速看 CPU / Memory / Network / Energy。证据是方法耗时、分配热点、网络请求时间线;局限是插桩/采样会有开销,线上问题要结合 trace/埋点。
  • Memory Profiler:看 Java/Kotlin 堆、对象数量、分配调用栈、dump HPROF。用于“内存上涨/频繁 GC/疑似泄漏”,修复后要对比同一路径的实例数量是否回落。
  • Perfetto / Systrace:系统级 trace,看 main thread、RenderThread、Binder、锁、调度、Frame Timeline。Perfetto 是现代首选;Systrace 常用于老项目/旧资料口径。证据是具体 slice 和线程状态,不是“感觉变流畅”。
  • LeakCanary:自动发现 retained objects 并给出引用链。适合 debug/内测定位泄漏;不要在生产默认开启 heap dump。
  • Layout Inspector:看视图层级、约束、重叠与 Compose/View 混合结构。适合布局层级深、过度绘制、首屏 inflate 慢的问题。
  • StrictMode:开发期检测主线程磁盘/网络、资源未关闭等。适合把“偶发卡顿”提前暴露;惩罚策略可从 log 开始,不要在生产随意 crash 用户。

工具选择速答表

你观察到先用再用面试表达
“启动慢”am start -W 定量Perfetto 找 bindApplication/首帧长任务先量化,再定位主线程 slice,最后验证优化前后。
“滑动卡”Frame Timeline/ProfilerLayout Inspector + 图片库日志区分 UI 线程、RenderThread、GC、图片解码。
“内存涨”Memory ProfilerLeakCanary/heap dump看趋势和引用链,不要只看一次快照。
“线上 ANR”ANR traces/平台聚合Perfetto/埋点复现主线程栈 + 锁/Binder/CPU 状态一起看。
“怀疑主线程 IO”StrictModePerfetto disk slice开发期用 StrictMode 前置发现,trace 证明耗时。

高频面试题

Q1:冷启动优化有哪些手段? 异步/延迟/按需初始化 SDK、收敛 ContentProvider 自启动、避免主线程 IO、用 SplashScreen API、类与资源预加载。用 am start -W 和 Perfetto 量化。

Q2:内存泄漏常见场景?LeakCanary 怎么检测的? 非静态内部类/Handler 持 Activity、单例持 Context、未注销监听、资源未关。LeakCanary 2.x 可理解为 ObjectWatcher 监听销毁对象,用弱引用+引用队列观察是否回收;延迟和 GC 后仍 retained,再 dump HPROF 并用 Shark 分析引用链。结论要结合生命周期,不要把所有 retained 都当业务泄漏。

Q3:为什么 16.6ms 是关键?掉帧怎么排查? 60fps 下每帧预算 16.6ms,超时丢帧。用 Choreographer 监控帧耗时,Profiler/Perfetto 看主线程哪段超时,优化布局层级和主线程耗时操作。

Q4:ANR 触发条件?怎么定位? 主线程阻塞超阈值会触发 ANR,常见口径是输入约 5s、前台广播约 10s、前台 Service 约 20s,后台场景通常更长,但具体阈值和触发条件随版本/前后台/厂商策略变化。定位看 ANR traces 主线程堆栈,结合锁 owner、Binder 对端、CPU/IO 状态,排查主线程 IO、锁竞争、死锁、同步 Binder。

Q5:包体积优化怎么做?(你的强项,展开答) R8 裁剪混淆、资源压缩、WebP/矢量图、so 按 ABI 拆分(只留 arm64-v8a)+ 动态下发、移除无用依赖、用 App Bundle 按需下发。结合 SDK 经验讲 so 瘦身和符号裁剪更有说服力。

Q6:RecyclerView 卡顿如何优化? DiffUtil 局部刷新、onBindViewHolder 不做耗时、setHasFixedSize、预取(prefetch)、图片异步加载、减少 item 布局层级、复用 ViewHolder。

Q7:Serializable 和 Parcelable 哪个对性能更好,为什么? Parcelable。Serializable 用反射、产生大量临时对象触发 GC;Parcelable 专为内存/IPC 设计,无反射,速度快约一个数量级。

启动优化专项

启动速度是用户对 App 的第一印象。作为 SDK 开发者,你需要知道如何做到既不拖累宿主,又能精准测量性能瓶颈。

一、启动分类与指标

启动可划分为三种典型场景,各自的耗时与指标不同:

  • 冷启动(Cold Start):最慢。系统为 App 创建进程 -> 实例化 Application -> 实例化首个 Activity -> 渲染第一帧。
  • 温启动(Warm Start):较快。App 进程存在,但 Activity 被回收需重新创建,或者用户从退出后返回重新将后台转前台并重建视图。
  • 热启动(Hot Start):最快。App 进程和 Activity 都在后台,仅将后台应用切到前台。

关键监控指标

  • 首帧时间 (TTID):首个可见 Activity 的第一帧渲染完成的时间。
  • 可交互时间 (TTFI):主线程空闲,用户可以开始操作界面的时间。

二、Application 初始化治理

很多冷启动问题源头是 Application.onCreate 堆积了过多第三方 SDK 的初始化操作。

延迟与异步初始化

并非所有 SDK 都必须在 Application.onCreate 主线程同步初始化:

  • 异步初始化:网络库、日志库等可在子线程初始化。
  • 延迟/按需初始化:地图、分享等 SDK,等用户真正点击进入相关页面前再初始化。

Jetpack App Startup

用来管理库初始化的官方方案:

  • 原理:利用一个统一的 ContentProvider 集中分发和按序初始化各组件。
  • 优势:减少多个第三方 SDK 各自带 ContentProvider 自启带来的性能损耗(每个 Provider 的创建和调用都有 IPC 与生命周期开销)。

三、ContentProvider 自动初始化陷阱

很多 SDK(如早期的 Firebase、WorkManager 等)为了对开发者透明,悄悄在库内声明 ContentProvider

  • 问题:系统启动应用时,会先实例化所有声明的 Provider,再调 Application.onCreate。过多 Provider 会严重拖慢启动。
  • 解法:通过 tools:node="remove" 在 manifest 中剔除不需要自启的 Provider,转为手动在适当的时机初始化。

四、排查路径与流程(本地 vs 线上)

本地诊断与线上监控是有本质区别的:

阶段关注点工具 / 手段
本地诊断找出具体的耗时方法与阻塞点adb shell am start -W 定量、Android Studio Profiler (CPU/Trace)、Perfetto / Systrace
线上监控掌握宏观大盘数据与用户体验情况Macrobenchmark、自定义埋点上报 TTID/TTFI、APM 平台聚合数据

定位流程:

  1. 定量分析:本地通过 adb shell am start -W 包名/Activity 获取 ThisTimeTotalTime,或线上看 APM 启动耗时分位数。
  2. 抓取 Trace:使用 Perfetto 抓取启动阶段的 trace 文件。
  3. 分析主线程 (Main Thread):在 Perfetto 中查看主线程的 slice,寻找导致阻塞的长任务(如文件 IO Open / Read、锁等待、长计算等)。
  4. 改造与验证:针对长任务进行异步、懒加载改造,再用 Macrobenchmark(避免 JIT 与系统状态差异)进行 A/B 对比测试。

五、SplashScreen API (Android 12+)

Android 12 引入了标准的 SplashScreen API,应用冷/温启动时系统会自动显示闪屏。

  • 优势:规范了启动体验,消除了应用自己实现闪屏页带来的白屏/黑屏,且首帧可立即进入业务 Activity,减少中间 Activity 跳转耗时。
  • 适配:通过配置主题属性(如 windowSplashScreenBackgroundwindowSplashScreenAnimatedIcon)修改系统闪屏样式,或使用 androidx.core:core-splashscreen 兼容低版本。

高频面试题

Q1: 冷启动的整个系统流程是怎样的? 从点击桌面图标 -> Launcher 通过 Binder 通知 AMS -> AMS 创建进程请求 -> Zygote fork 进程 -> ActivityThread.main -> 实例化 Application -> 实例化 Activity -> setContentView -> 渲染完成第一帧。

Q2: 为什么有些第三方 SDK 接入后会拖慢冷启动,即使用户还没调用它? 可能是 SDK 内部通过 ContentProvider 实现了自动初始化。系统在进程创建后、调用 Application.onCreate 之前,会先实例化并调用所有注册 Provider 的 onCreate,若里面有耗时操作或 IO,就会阻塞启动。

Q3: 如何用工具定位启动慢的具体代码行? 使用 Perfetto 或 Android Studio CPU Profiler (Call Chart/Flame Chart) 录制启动阶段。如果是代码插桩模式能看到具体方法耗时,如果是 System Trace 模式,重点看 Main Thread 上的长耗时 Slice 和线程状态(Blocked/Runnable 等)。

易错点 / 追问

  • 易错点: 把异步初始化当万能药。如果首屏 UI 恰好强依赖某个被异步到子线程初始化的数据/SDK,会导致数据还没准备好 UI 已经展示,或产生竞态崩溃。
  • 追问: 如果启动优化已经把能异步的都异步了,还能怎么优化?(答:类预加载、资源预加载、减少首屏布局层级/延迟加载非首屏 Fragment、甚至更底层的 PGO 等技术)。
  • 误区: 线上监控直接使用 System.currentTimeMillis() 算差值。线上容易受到系统休眠、时间跳变影响,建议用 SystemClock.uptimeMillis()

内存优化与泄漏排查

内存问题直接影响 App 的存活率(OOM)和流畅度(频繁 GC 导致卡顿)。对于底层背景开发者,native 内存泄漏排查会是很好的加分项。

一、常见的内存泄漏场景

内存泄漏(Memory Leak)的本质是:长生命周期的对象(如单例、静态变量、系统级线程)持有了短生命周期对象(如 Activity/Fragment)的强引用,导致短生命周期对象无法被 GC 回收

Java / Kotlin 泄漏重灾区

  1. 静态变量 / 单例:静态变量持有 Activity 的 Context 或 View 实例。
  2. 非静态内部类 / 匿名类:如 Handler 延时消息未移除、Runnable 在子线程还在跑,它们隐式持有外部类的引用。
  3. 未注销监听器:广播接收器 BroadcastReceiver、EventBus 等未在 onDestroy 注销。
  4. 协程 / Flow:未跟随组件生命周期(如在 Activity onDestroy 后协程仍在运行),或全局 ViewModel 持有了 UI 元素。
  5. 资源未关闭CursorStreamFileDescriptor 没有 close,或者 Bitmap 未及时 recycle

二、内存抖动与 GC 机制

  • 内存抖动 (Memory Churn):短时间内频繁创建大量临时小对象(如在 onDraw 或大循环内 new 对象),导致可用内存迅速耗尽,触发频繁 GC。
  • 影响:Dalvik/ART 的 GC 过程可能会产生应用停顿(Stop-The-World,尽管 ART 有并发 GC,但仍有停顿和 CPU 开销),从而引发界面卡顿甚至掉帧。
  • 优化:使用对象池复用对象,避免在高频调用的路径(如 UI 渲染)中进行堆分配。

三、Bitmap 与 Native 内存

现代 Android (8.0+) 的 Bitmap 像素数据存放于 Native 内存中,这大大缓解了 Java 堆 OOM 的压力,但也带来了 Native 泄漏的风险。

  • Native OOM:当系统总可用物理内存不足或虚拟地址空间耗尽时,Native 层同样会发生分配失败(OOM)。
  • 排查手段:Native 内存的追踪较难,可以通过 malloc_debugHWASan/ASan 等底层工具进行内存越界和泄漏排查。

四、排查路径与流程(本地 vs 线上)

诊断策略需区分开发期和生产环境:

诊断层级工具与手段适用场景
本地定位LeakCanary、Memory Profiler、MAT开发期主动发现泄漏、详细分析引用链
线上监控内存阈值报警、KOOM(快手开源的线上 OOM 治理)、APM 平台捕获真实用户场景 OOM、轻量级 dump 和裁剪上报

泄漏排查流程:

  1. 现象发现:观察到应用内存占用持续走高或收到 OOM 奔溃报告。
  2. 触发 Dump:在开发阶段,利用 Memory Profiler 点击强制 GC 并抓取 Java Heap (hprof)。LeakCanary 会在后台对象保留超时后自动 dump。
  3. 寻找 GC Roots:将 hprof 导入 Memory Profiler 或 MAT (Memory Analyzer Tool)。寻找怀疑泄漏的对象实例(如 MainActivity)。
  4. 分析引用链 (Reference Chain):查看从 GC Root 到该泄漏实例的最短强引用路径
  5. 切断引用:修改代码,将该路径上的长生命周期引用置为 null、改用弱引用 (WeakReference),或在合适的生命周期节点手动解绑。

五、LeakCanary 原理剖析

LeakCanary 是如何自动发现泄漏的?

  1. Hook 生命周期:自动注册 Application.ActivityLifecycleCallbacks(或利用 Fragment/ViewModel 钩子)监听对象销毁。
  2. 弱引用观察:对象销毁后,用它构建一个带有 ReferenceQueue 的弱引用,交由 ObjectWatcher 观察,并标记个超时时间(如 5s)。
  3. 判断回收:超时后强制触发一次 GC,如果该弱引用没有出现在 ReferenceQueue 中,说明对象还被强引用,标记为 retained
  4. 抓取分析:若 retained 对象数达阈值,dump 整个 hprof 文件,交由内嵌的 Shark 库解析 hprof,找出最短强引用链并发送通知。

高频面试题

Q1: 内部类一定会导致内存泄漏吗? 不一定。只有非静态内部类才会隐式持有外部类的引用。且只有当内部类的生命周期长于外部类时(比如被 static 变量引用,或跑在一个没结束的子线程里),才会造成外部类无法释放,形成泄漏。静态内部类不持有外部类引用。

Q2: Handler 导致的内存泄漏怎么解决? 将 Handler 声明为静态内部类,内部通过 WeakReference<Activity> 持有 Activity 以便更新 UI。更关键的是,在 Activity onDestroy 中调用 handler.removeCallbacksAndMessages(null) 清除所有未执行的消息和任务。

Q3: Android 8.0 之后 Bitmap 内存存在哪里?如果 OOM 了怎么排查? 存放于 Native 内存中。Java 层面的 OOM (OutOfMemoryError) 和 Native 内存溢出不同。排查时可以抓取 hprof,看是不是应用中有大量的 Bitmap 引用没有被回收。线上可以用 KOOM 等库,利用 fork 子进程的方式 dump 内存,减少对主进程的影响。

易错点 / 追问

  • 误区: “使用弱引用可以解决所有内存泄漏”。很多时候泄漏是因为生命周期管理错乱,比如网络请求没取消导致回调还在等。粗暴地改成弱引用可能掩盖逻辑漏洞(回调突然没反应了)。正确的做法是绑定生命周期及时取消任务。
  • 追问: LeakCanary 会影响生产环境的性能吗?(答:会,抓取 hprof 时进程会发生长达数秒的冻结,且分析过程耗 CPU 和内存,所以通常只在 debug 环境或灰度阶段启用。)
  • 易错点: 分析 MAT 时死盯所有引用链。只需关注强引用 (Strong Reference),软/弱/虚引用不会阻止对象被垃圾回收。

ANR 与卡顿排查

ANR (Application Not Responding) 和卡顿 (Jank) 是导致用户流失的直接原因。作为中高级开发者,仅仅知道 ANR 的触发时间是不够的,必须具备看懂 traces.txt 和系统调度的能力。

一、ANR 的本质与类型

ANR 的触发条件往往被记忆为几个固定阈值(如输入事件 5s),但底层本质是:主线程无法在系统规定的时间预算内,响应系统派发的特定事件或完成相应的生命周期调度。

常见的 ANR 类型:

  • KeyDispatchTimeout:输入事件(点击/触摸)超过 5 秒无响应。
  • BroadcastTimeout:前台广播 10 秒,后台广播 60 秒未执行完毕。
  • ServiceTimeout:前台 Service 20 秒,后台 Service 200 秒未启动/绑定完成。
  • ContentProviderpublish 超时(通常为 10 秒)。

二、ANR / 卡顿的核心诱因

ANR 和卡顿(丢帧)在表现上严重程度不同,但诱因往往相似——主线程被占据。主要原因可分为三大类:

  1. 主线程做耗时操作:如直接在主线程解析大 JSON、读写 SharedPreferences (尤其是老版本)、或者加载庞大的布局导致 measure/layout 时间过长。
  2. 锁等待 (Lock Wait) 与死锁:主线程等待工作线程释放某个锁,而工作线程迟迟不释放;或者主线程与工作线程互相等待对方的锁(Deadlock)。
  3. 系统层面阻塞
    • Binder 阻塞:主线程发起同步 IPC 调用,对端进程卡住或系统负载过高导致超时。
    • CPU 饥饿:后台大量高优先级线程抢占 CPU,或者系统整体处于高负载,分配给当前 App 主线程的时间片极少。

三、ANR 与卡顿 (Jank) 的区别联系

  • 卡顿 (Jank):主线程并未完全死锁,只是单次处理事件/渲染耗时超过了 16.6ms(60Hz 屏幕),导致丢帧。用户觉得“不跟手”。
  • ANR:主线程彻底罢工或执行超长任务(如长达 5 秒甚至更久),完全失去响应能力。
  • 联系:持续的严重卡顿,若碰巧阻塞了系统的输入事件或广播调度,就会升级为 ANR。

四、排查路径与流程(本地 vs 线上)

解决此类问题同样依赖不同阶段的针对性工具。

环境分析目标工具使用
本地定位发现掉帧原因、死锁确认StrictMode、Systrace/Perfetto、Android Studio Profiler
线上监控大盘 ANR 聚类、卡顿率统计ANR-WatchDog、线上监控平台、抓取 /data/anr/traces.txt 提取上报

ANR 定位流程:

  1. 获取 Traces 文件:当 ANR 发生时,系统会生成 traces.txt(位于 /data/anr/ 下,高版本可能聚合成了 Bugreport 格式)。
  2. 分析 Main Thread 堆栈:打开 traces 文件,首先寻找 main 线程的调用栈。
    • 状态是 Runnable?可能是死循环或大计算量代码。
    • 状态是 Blocked?说明在等锁,需要顺藤摸瓜找当前占有这把锁的线程(held by owner)
  3. 结合 CPU / IO 状态:看 traces 头部,检查当时的 CPU 负载(User/System/IOWait)。如果 IOWait 极高,说明主线程可能阻塞在磁盘操作;如果 CPU 总体负载达到 100%,哪怕主线程是简单的运算也会因分配不到时间片而 ANR。
  4. 验证 Binder 对端:如果卡在 BinderProxy.transact,需分析当时的系统服务或其他进程状态是否异常。

五、ANR-WatchDog (线上监控思路)

线上由于权限限制,难以直接去 /data/anr/ 拉取完整的 traces。 常见的应用层监控手段是基于 WatchDog 的思路:

  • 启动一个后台线程(WatchDog 线程)。
  • 每隔固定时间(如 5 秒),向主线程的消息队列 MessageQueue 抛一个任务(修改某个标志位)。
  • WatchDog 线程休眠 5 秒后醒来,检查标志位是否被修改。如果没有修改,说明主线程 5 秒内都没能处理这个极其简单的消息,说明主线程卡死了。
  • 此时由 WatchDog 线程主动去 dump 主线程的堆栈信息并上报服务器。

高频面试题

Q1: 看 traces.txt 定位 ANR,主线程处于 Runnable 状态就一定是代码在死循环吗? 不一定。Runnable 只代表在操作系统的就绪队列中,或者正在运行。如果系统 CPU 资源极度紧张被其他进程抢占,主线程虽然是 Runnable 但实际上得不到 CPU 时间片,也会发生 ANR。

Q2: 什么是 Binder 阻塞引起的 ANR? 当主线程调用诸如获取系统服务(如 ActivityManagerPackageManager)的方法时,通常是一个跨进程的同步调用(IPC)。如果当时系统服务端负载过高或卡死,主线程就会一直等对端返回,最终导致 ANR。

Q3: 如何用工具查出是布局层级过深导致的滑动卡顿? 可以使用 Layout Inspector 查看 View 树的层级结构。同时,抓取一段滑动的 Perfetto (或 Systrace) 文件,观察 RenderThread 和 Main Thread 上的 Choreographer#doFrame 切片,看里头的 measure/layout 子片段是否消耗了大量时间(远超 16ms)。

易错点 / 追问

  • 误区: “发生 ANR 一定是我的代码写错了。” 很多时候线上的 ANR 是由于手机老化、后台其他应用疯狂抢占资源(CPU/IO)导致的,或者厂商系统的 Bug 导致 Binder 响应缓慢。
  • 追问: 如果 traces 看到主线程卡在底层的 epoll_wait 或者 MessageQueue.nativePollOnce,说明什么?(答:这通常说明主线程是空闲的,在等待新的消息,并没有阻塞。如果此时发生了 ANR,极有可能是 ANR 发生瞬间主线程刚好处理完了阻塞任务进入空闲,或者是系统组件派发消息出现了时序错乱。)
  • 易错点: 将卡顿监控的阈值设得太低。线上网络和设备环境复杂,过于激进的卡顿阈值会产生海量无效上报,淹没真正的性能瓶颈。

性能工具专题

性能优化不是“凭感觉改几行代码”,而是先观测、再定位、再验证。工具章节的面试重点是:每个工具看什么、适合什么问题、输出如何转化为工程动作。

一、工具选型总览

问题类型首选工具看到什么常见动作
启动慢Android Studio Profiler / Perfetto / MacrobenchmarkCPU、主线程、启动阶段耗时延迟初始化、异步化、Baseline Profile
卡顿掉帧Perfetto / Systrace / gfxinfoChoreographer、RenderThread、帧耗时减少主线程阻塞、优化布局/绘制
内存泄漏LeakCanary / Memory Profiler / meminfo引用链、堆大小、PSS解除生命周期引用、缓存上限
I/O/网络违规StrictMode / Trace sections主线程磁盘/网络、慢调用切线程、缓存、预加载
Native 热点simpleperf / PerfettoCPU sample、调用栈、符号优化算法、减少 JNI 往返
线上复现困难dumpsys / bugreport / 自定义 trace系统状态快照关联日志、设备维度排查

工具回答要避免“列名词”,而要说清楚“我怀疑什么 → 用什么采样 → 看哪个指标 → 怎么验证改动”。

二、Android Studio Profiler

Android Studio Profiler 适合本地开发阶段快速观察 CPU、Memory、Network、Energy。

  • CPU Profiler:看主线程是否有长任务、方法调用耗时、线程调度。可用 Sampled/Instrumented/Java/Kotlin Method Trace,采样开销更低,插桩更细但扰动更大。
  • Memory Profiler:看 Java/Kotlin 堆、对象分配、GC 频率、Heap Dump。适合发现短时间内对象抖动和明显泄漏。
  • Network Profiler:看请求时序、流量大小、频率,但复杂线上网络问题仍要配合 OkHttp event listener、服务端日志和抓包合规流程。
  • Energy Profiler:关注 wakelock、定位、网络等耗电行为。

局限:Profiler 连接调试进程会带来观测扰动,结论要用 release-like 包、Macrobenchmark 或 Perfetto 再验证。

三、Perfetto、Systrace 与 Trace sections

Perfetto 是现代 Android 系统级 tracing 工具,可观察 CPU 调度、Binder、线程状态、Choreographer、SurfaceFlinger、RenderThread、I/O 等。Systrace 是旧工具链,很多概念仍沿用,但新项目优先用 Perfetto。

import androidx.tracing.Trace

fun bindFeed(items: List<Item>) {
    Trace.beginSection("Feed.bind")
    try {
        adapter.submitList(items)
    } finally {
        Trace.endSection()
    }
}

Trace sections 的价值是把业务阶段写进系统 trace,让你在 Perfetto 里看到“哪段业务代码”对应主线程长任务。命名要稳定、短、能定位模块,避免把用户敏感信息写入 trace 名称。

看 Perfetto 的常见路径:

  1. 先看主线程是否 Running 太久或频繁 Blocked。
  2. 看耗时段是否跨过 VSync 导致 missed frame。
  3. 看 RenderThread/GPU 是否被复杂绘制或纹理上传拖慢。
  4. 看 Binder、I/O、锁等待是否阻塞主线程。
  5. 结合自定义 Trace sections 还原业务阶段。

四、Macrobenchmark 与 Baseline Profile

Macrobenchmark 用独立测试 APK 驱动目标 App,在接近真实环境下测启动、滚动、页面跳转等宏观性能。它适合做性能回归门禁,比普通单元测试更贴近用户体验。

@Test
fun startup() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(StartupTimingMetric()),
    iterations = 10,
    startupMode = StartupMode.COLD
) {
    pressHome()
    startActivityAndWait()
}

Baseline Profile 是把关键路径的类和方法提前提供给 ART,安装后可更早编译热点代码,改善冷启动和首帧性能。典型流程是用 profileinstaller + baseline-prof Gradle 插件生成/合并 profile,再用 Macrobenchmark 验证收益。

面试要点:

  • Macrobenchmark 关注稳定设备、固定版本、足够迭代次数和噪声控制。
  • Baseline Profile 不是万能优化,主要改善解释执行/JIT 预热带来的启动和关键路径抖动。
  • 性能数据要保存历史趋势,否则无法判断回归。

五、LeakCanary 与内存定位

LeakCanary 自动观察 Activity、Fragment、ViewModel 等对象生命周期,对象应被回收却仍被引用时触发 heap dump 并给出泄漏引用链。

常见泄漏原因:

  • 静态单例持有 Activity/Context。
  • Handler/Runnable/Coroutine 未随生命周期取消。
  • Adapter、Listener、Callback 未解绑。
  • Dialog/PopupWindow/Animator 持有 View。
  • 全局缓存无上限或 key/value 持有页面对象。

Memory Profiler 更适合看对象分配和堆变化,LeakCanary 更适合自动发现泄漏。线上内存问题则要结合 OOM 日志、dumpsys meminfo、业务埋点和设备分布分析。

六、StrictMode 与开发期红线

StrictMode 用来在开发/测试阶段发现主线程磁盘 I/O、网络、资源未关闭、Activity 泄漏等问题。

if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .build()
    )
}

它不是线上性能监控方案,而是把“本不该发生的开发期违规”尽早暴露。不要为了消除告警简单 permitAll,应该定位调用链并把 I/O 移到合适线程或启动阶段之外。

七、dumpsys、gfxinfo、meminfo

dumpsys 是 Android 系统服务状态入口,适合连接真机后快速拿到系统级快照。

命令关注点用法场景
adb shell dumpsys gfxinfo <pkg>帧耗时、Janky frames、渲染统计页面滑动/动画掉帧
adb shell dumpsys meminfo <pkg>PSS、Java/Native/Graphics 内存OOM、内存增长
adb shell dumpsys activity <pkg>Activity/Service/进程状态生命周期、进程保活排查
adb shell dumpsys batterystats耗电归因后台任务、网络、唤醒

gfxinfo framestats 可导出每帧各阶段时间,适合脚本化对比优化前后。meminfo 的 PSS 更接近进程实际占用视角,但仍要结合 Android 版本、厂商和图形内存差异理解。

八、simpleperf 与 Native CPU 热点

simpleperf 是 Android 官方 native profiling 工具,可采样 CPU 指令、调用栈和符号,适合 NDK/JNI、音视频、加解密、图像处理等热点定位。

典型关注:

  • native 函数是否占用异常 CPU。
  • JNI 往返是否过多。
  • 锁竞争、内存拷贝、算法复杂度是否是瓶颈。
  • so 是否保留符号或能通过符号表/映射文件还原调用栈。

面试表达可以说:Java/Kotlin 层先用 Perfetto/Profiler 定位到 native 调用段,再用 simpleperf 下钻 native 热点,最后用基准测试和真实场景回归验证。


高频面试题

Q1:Profiler、Perfetto、Systrace 怎么选? Profiler 适合开发期快速看单进程 CPU/内存/网络;Perfetto 适合系统级时序分析,能看调度、渲染、Binder、I/O;Systrace 是旧链路但概念相通。复杂卡顿优先 Perfetto,内存泄漏优先 LeakCanary/Memory Profiler。

Q2:Trace.beginSection 有什么价值? 它把业务阶段标记进系统 trace,让 Perfetto 里能把主线程长任务和具体模块对应起来。注意 section 名称要稳定、简洁、无敏感信息,并保证 begin/end 成对。

Q3:Macrobenchmark 和普通 benchmark 区别? Macrobenchmark 从 App 外部驱动真实启动、滚动、跳转等宏观场景,更贴近用户体验;普通 microbenchmark 更适合测小函数/算法。性能门禁通常用 Macrobenchmark 看趋势和回归。

Q4:Baseline Profile 解决什么问题? 它把启动和关键路径热点提前提供给 ART 编译,减少冷启动/首帧阶段解释执行和 JIT 预热成本。它不是替代业务优化,仍要用 Macrobenchmark 验证收益。

Q5:LeakCanary 报出泄漏后怎么处理? 先看泄漏对象和引用链,判断是否生命周期结束后仍被持有;再定位静态引用、回调、协程、Handler、Adapter 等持有者;修复后重新进入/退出页面并确认泄漏不再出现。

易错点 / 追问

  • 不要只贴工具截图,要能说明指标含义和下一步工程动作。
  • 不要在 debug、插桩、连接 Profiler 的环境下直接宣称线上性能收益。
  • Perfetto 看到主线程长任务后,要继续区分 CPU 忙、锁等待、I/O、Binder 等原因。
  • Baseline Profile 需要随关键路径变化更新,否则 profile 会逐渐失效。
  • StrictMode 告警不能靠关闭规则解决,应修正线程和资源使用。

APM 与线上监控

☆ APM 是把“性能优化“从本地经验升级成线上体系的关键。你的风控 SDK 背景可以重点讲 native crash、采样、灰度和宿主影响控制。

一、APM 监控什么

类型指标关键证据
CrashJava crash、native crash、崩溃率堆栈、版本、机型、ABI
ANR主线程阻塞、输入超时、广播/Service 超时traces、主线程栈、锁等待
卡顿慢帧、冻结帧、帧率Choreographer/FrameMetrics/Perfetto
启动冷启动、首帧、可交互时间launch trace、业务埋点
网络成功率、耗时、错误码、弱网interceptor、DNS/TLS/connect/read 分段

二、Crash 监控:Java 与 Native

Java crash 常通过 Thread.setDefaultUncaughtExceptionHandler 捕获;native crash 需要 signal handler 或 Breakpad/Crashpad 这类方案。

面试边界:不要说“所有 native crash 都能优雅恢复“。多数情况下只能采集现场、下次启动上报、灰度回滚。

三、ANR 与卡顿监控

  • ANR:系统判定,重点是拿到 traces 和主线程阻塞证据。
  • 卡顿:应用侧可用主线程 watchdog、Choreographer 帧回调、FrameMetrics 监控。
  • 线上采样必须控制开销,避免监控本身制造卡顿。

四、启动与网络监控

启动监控要区分进程创建、Application、首 Activity、首帧、业务首页可交互。网络监控要拆 DNS、TCP、TLS、请求、响应、解析,不要只报一个总耗时。

五、上报链路、采样与隐私

  • 本地缓存:避免 crash 当场丢失日志。
  • 批量上报:减少电量和流量开销。
  • 采样:高频事件不能全量。
  • 隐私:不上传明文 token、手机号、身份证、设备敏感字段。

六、怎么把 SDK 经历讲成亮点

可以这样组织:“SDK 嵌入宿主 App 后,我关注的不只是功能成功,还要保证不拖累宿主稳定性。我们会监控 native crash、初始化耗时、线程/网络开销,灰度阶段看指标,异常时能按版本/ABI/机型聚合定位。”

高频面试题

Q1:线上卡顿怎么监控? 答:轻量方案是主线程 watchdog 或 Choreographer 统计慢帧;深入定位要结合 Perfetto/trace。线上只采样关键指标,本地复现再做完整 trace。

Q2:native crash 怎么定位? 答:采集 signal、寄存器、线程栈、so build id/版本/ABI,结合未 strip 符号或 symbol server 还原堆栈,按版本和机型聚类。

Q3:APM SDK 会不会影响性能? 答:会,所以要采样、异步、批量、延迟上报,关键路径不做重 IO,监控逻辑本身也要被监控。

易错点 / 追问

  • 不要把本地 Profiler 等同于线上 APM。
  • 不要上传敏感业务数据。
  • 不要只讲采集,还要讲聚合、告警、灰度回滚和修复闭环。

存储体系与 Scoped Storage

★ 数据持久化是客户端的根本。面试不仅考“怎么存”,更考“存哪里最安全”、“大文件怎么管”以及“多进程怎么防丢数据”。

一、轻量级 KV 存储:SharedPreferences 与 DataStore

特性SharedPreferences (SP)Preferences DataStore
线程安全是(但容易造成主线程阻塞)是(基于协程与 Flow)
类型安全弱(可能抛 ClassCastException)弱(但有 Type DataStore 支持强类型)
异步 APIapply() 异步,且可能阻塞生命周期完全原生异步操作
多进程支持MODE_MULTI_PROCESS 已废弃,极不靠谱支持,配合 MMKV 等替代方案更佳

SP 的核心痛点commit() 同步阻塞;apply() 虽然异步,但在 Activity/Service 销毁时,系统层(QueuedWork)会等待所有 apply 的磁盘写入完成,极易导致 ANR。

二、关系型数据库:SQLite 与 Room

  • Room 的优势:编译时 SQL 语法检查;与协程/Flow/LiveData 无缝结合;基于注解的类型转换与实体映射。
  • WAL 模式 (Write-Ahead Logging):开启后 (setWriteAheadLoggingEnabled),读写可以并发执行,极大提升并发性能。写操作先追加到 -wal 文件,稍后再 Checkpoint 同步到 .db
  • 数据库升级 (Migration)
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
    }
}

三、文件目录与沙盒机制

Android 的私有目录分为内部存储和外部存储的私有部分,应用卸载时都会被删除:

  1. 内部存储 (Internal Storage): Context.getFilesDir(), Context.getCacheDir()。空间非常有限,绝对不能放图片视频等大文件。
  2. 外部私有存储 (External Storage - App-specific): Context.getExternalFilesDir(), Context.getExternalCacheDir()。空间较大,适合放应用的业务文件。不需要存储权限即可访问。

四、分区存储 (Scoped Storage) 与 SAF

在 Android 10+ 引入,为了防止应用在 SD 卡上“拉屎”。

  • 自己产生的业务文件:放沙盒 (getExternalFilesDir),不需要权限,卸载自动删。
  • 需要分享/保存给用户的多媒体:通过 MediaStore 插入(图片、视频、音频)。插入不需要权限,读取别的应用插入的媒体需要 READ_MEDIA_IMAGES 等权限。
  • 让用户选择文件/非媒体文件:使用 SAF (Storage Access Framework),即 ACTION_OPEN_DOCUMENT。由系统选择器 UI 授权,返回一个不可伪造的 Document URI。

五、安全存储:Keystore 与 EncryptedSharedPreferences

  • 敏感信息(如 Token、密码)怎么存? 不能明文写在 SP 或文件里。推荐使用 Jetpack Security 库中的 EncryptedSharedPreferences
  • 底层原理:它利用 Android Keystore 系统生成并保管主密钥(Master Key),主密钥存放在设备的硬件安全模块(TEE/SE)中,极难被提取。然后用主密钥加密真实的数据。

高频面试题

Q1:SharedPreferences 为什么会导致 ANR?怎么解决? 答:因为 apply() 提交的任务会进入系统级的 QueuedWork 队列。在组件(如 Activity)执行 onStop 时,系统为了保证数据不丢,会强制等待队列中的磁盘写入任务完成。如果写入量大或磁盘 IO 慢,就会阻塞主线程导致 ANR。 解决:迁移到 DataStore 或 MMKV;或者通过反射清理 QueuedWork(黑科技,不推荐)。

Q2:如何优雅地处理几十上百 MB 的大文件下载与缓存清理? 答:

  1. 存储位置:放在 Context.getExternalCacheDir() 下,千万别放内部存储。
  2. 清理策略:实现 LRU 算法的磁盘缓存工具,设定最大容量(如 200MB)。在每次写入后检查总大小,淘汰最旧的未访问文件。
  3. 系统干预:可以覆写 Application 的 onTrimMemory,在设备空间不足时主动清理。

Q3:Room 数据库在跨版本升级时,如果用户跨了好几个版本(比如 V1 直接升级到 V4)怎么处理? 答:Room 支持定义多段 Migration,如 1-2, 2-3, 3-4。在构建 Database 时 addMigrations() 传入所有规则。Room 内部会自动寻找最短的迁移路径(按序应用 1-2,然后 2-3,然后 3-4)。如果没有找到路径且没有配置 fallbackToDestructiveMigration(),App 会崩溃。

易错点 / 追问

  • 混淆 MediaStore 与 File API:在 Android 11+,即使你拿到了 MediaStore 中的文件真实绝对路径 (_data 字段),用 File API 去读写也可能被拒绝,必须使用 ContentResolver.openInputStream() 处理 URI。
  • 忽略多进程 SP 的危险:多进程读写 SP 是数据损坏和丢失的重灾区。追问时一定要提到 MMKV 的共享内存 (mmap) 与文件锁机制是目前多进程 KV 存储的最佳实践。
  • 数据库主线程读写:初学者常在主线程做 DB 查询。必须借助协程调度到 Dispatchers.IO

网络协议

网络是中级面试必考硬通货,也是你的潜在主场——做风控/抓包对抗,HTTPS、证书、TLS 你比一般应用开发者懂得深,能讲到中间人、证书校验、双向认证的层面。

本篇含知识点讲解 + 高频面试题。

一、HTTP 基础

  • 无状态:每次请求独立,靠 Cookie/Session/Token 维持状态。
  • 请求结构:请求行(方法 + URL + 版本)+ 请求头 + 空行 + 请求体。
  • 常用方法:GET(查,幂等)、POST(增,非幂等)、PUT(全量改,幂等)、DELETE、PATCH(部分改)、HEAD、OPTIONS(预检)。
  • GET vs POST:GET 参数在 URL、有长度限制、可缓存、幂等;POST 参数在 body、无长度限制、不可缓存。语义区别 > 技术区别。

状态码

  • 1xx 信息;2xx 成功(200、201、204)。
  • 3xx 重定向:301(永久)、302(临时)、304(协商缓存命中)。
  • 4xx 客户端错:400、401(未认证)、403(无权限)、404、405、429(限流)。
  • 5xx 服务端错:500、502(网关错误)、503(不可用)、504(网关超时)。

缓存

  • 强缓存:Cache-Control(max-age)、Expires,不发请求直接用本地。
  • 协商缓存:ETag/If-None-MatchLast-Modified/If-Modified-Since,发请求由服务端判断,命中返 304。

二、HTTP 版本演进

版本关键特性解决的问题
HTTP/1.0短连接,每次请求新建 TCP
HTTP/1.1长连接(keep-alive)、管线化、Host 头、分块传输复用连接
HTTP/2二进制分帧多路复用(一个连接并发多请求)、头部压缩(HPACK)、服务端推送队头阻塞(应用层)
HTTP/3基于 QUIC(UDP)、0-RTT、连接迁移TCP 队头阻塞、握手延迟
  • HTTP/1.1 队头阻塞:同一连接请求需按序响应,前一个慢会卡后面。HTTP/2 多路复用在应用层缓解,但 TCP 层仍有队头阻塞;HTTP/3 基于 QUIC/UDP,通过独立 stream 降低 TCP 层队头阻塞影响。
  • WebSocket:基于 HTTP 升级(Upgrade)的全双工长连接,用于实时通信(IM、推送)。

三、HTTPS 与 TLS(你的强项,讲深)

HTTPS = HTTP + TLS/SSL,解决三个问题:加密(防窃听)、完整性(防篡改)、身份认证(防冒充)。

TLS 握手:RSA 密钥交换 vs ECDHE

TLS 的核心思想是:先认证身份并协商出对称会话密钥,后续用对称加密传 HTTP 数据。对称加密快,但首次密钥分发困难;非对称/密钥交换负责解决首次协商与身份认证。

维度RSA 密钥交换(TLS 1.2 旧式)ECDHE 密钥交换(现代主流)
证书公钥用途客户端用证书里的 RSA 公钥加密 pre-master secret证书用于验证服务端身份;临时 ECDHE 公钥用于密钥交换
会话密钥来源pre-master secret + 双方随机数派生双方临时椭圆曲线私钥/公钥计算共享秘密 + 随机数派生
前向保密没有。服务端私钥泄露后,历史抓包可能被解密。临时私钥握手后丢弃,长期证书私钥泄露也难解历史流量
面试风险点容易把“证书公钥加密密钥”当成所有 TLS 的固定流程要说清“证书认证身份,ECDHE 协商密钥”

TLS 1.2 RSA 简化流程

  1. Client Hello:客户端发支持的 TLS 版本、加密套件、随机数。
  2. Server Hello:服务端选择套件、返回随机数、下发证书(含公钥)
  3. 客户端验证证书(CA 链、域名、有效期、吊销状态等),生成 pre-master secret,用服务端 RSA 公钥加密后发送。
  4. 服务端用私钥解密,双方用 pre-master secret + client_random + server_random 派生对称会话密钥。
  5. 双方发送 Finished 校验握手完整性,之后用对称加密通信。

TLS 1.2 ECDHE / TLS 1.3 直觉

  • ECDHE:服务端发送临时 ECDHE 公钥并用证书私钥签名,客户端验证签名后也生成临时公钥;双方各自用“自己的临时私钥 + 对方临时公钥”算出共享秘密。网络上没有直接传输会话密钥。
  • TLS 1.3:移除静态 RSA 密钥交换等旧套件,把常规握手压到 1-RTT:ClientHello 携带 key share,ServerHello 返回 key share 和证书相关消息,双方很快得到密钥。
  • 0-RTT:客户端基于上次会话恢复提前发送早期数据,降低重连延迟;边界是早期数据可能被重放,所以只适合幂等 GET/查询类请求,不适合支付、下单、转账、登录态变更等非幂等操作。

面试一句话:旧 RSA 像“用证书公钥包住会话密钥”,现代 ECDHE/TLS 1.3 更像“证书负责证明你是谁,临时密钥交换负责生成本次会话密钥”,因此具备前向保密。

证书与信任链

  • 证书由 CA 逐级签发,设备内置根 CA。验证时沿链校验到可信根。
  • 为什么不能只用对称加密? 密钥分发难题:首次怎么安全传密钥?用非对称解决。
  • 为什么不全用非对称? 慢。所以只用它协商对称密钥。

安全实战(你能讲、别人讲不了)

  • 中间人攻击(MITM):攻击者伪造证书。防御靠客户端严格校验证书。
  • 证书锁定(SSL Pinning):App 内置服务端证书/公钥指纹,只信任它,防抓包/MITM——风控/金融 App 标配。
  • 双向认证(mTLS):服务端也验证客户端证书。
  • 抓包对抗:Charles/Fiddler 装根证书抓 HTTPS;App 可用 Pinning + 检测代理对抗。这是你做风控的实战领域。

四、TCP / UDP

TCP 三次握手

  1. Client → SYN(seq=x)
  2. Server → SYN+ACK(seq=y, ack=x+1)
  3. Client → ACK(ack=y+1) 为什么三次? 确认双方收发能力都正常;两次无法确认客户端的接收能力,且防止历史失效连接请求建立连接。

TCP 四次挥手

  1. Client → FIN 2. Server → ACK 3. Server → FIN 4. Client → ACK 为什么四次? 关闭是双向的,服务端收到 FIN 后可能还有数据要发,所以 ACK 和 FIN 分开。 TIME_WAIT(2MSL):主动关闭方等待,确保最后 ACK 到达 + 让旧报文消散。

可靠性机制

序列号 + 确认应答、超时重传、滑动窗口(流量控制)、拥塞控制。

拥塞控制:看懂 cwnd / ssthresh 转换

  • cwnd(congestion window):发送端根据网络拥塞程度维护的拥塞窗口,限制“未确认在途数据量”。
  • ssthresh(slow start threshold):慢启动阈值,决定从指数增长切到线性增长。
  • 实际发送窗口通常取 min(cwnd, rwnd):拥塞控制看网络承载能力,流量控制看接收端缓存能力。
阶段触发/进入条件cwnd 变化退出条件
慢启动连接刚建立或超时后重新探测每个 RTT 近似翻倍(指数增长)cwnd >= ssthresh 转拥塞避免;或丢包
拥塞避免cwnd 达到 ssthresh每个 RTT 近似 +1 MSS(线性增长)丢包/重复 ACK
快重传收到 3 个重复 ACK,推测某段丢失但网络仍有流动不等超时,立即重传疑似丢失段进入快恢复
快恢复快重传之后通常把 ssthresh 设为丢包前 cwnd 的一半,cwnd 降低后线性恢复新 ACK 到达后回到拥塞避免

超时 vs 快重传的区别:

  • 超时重传说明网络可能严重拥塞或 ACK 完全回不来,处理更保守:常见做法是 ssthresh = cwnd / 2,然后 cwnd 回到很小值重新慢启动。
  • 3 个重复 ACK说明后续包还能到达,网络没有完全断流,所以只减半窗口并快恢复,比超时温和。
新连接/超时
  ↓ cwnd 指数增长
慢启动 ── cwnd >= ssthresh ──> 拥塞避免(线性增长)
  │                              │
  └── 超时:ssthresh=cwnd/2,cwnd 重置 ─┘
                                 │
                                 └── 3 dup ACK:快重传 → 快恢复 → 拥塞避免

面试答题流:先区分可靠性(序号/ACK/重传)与拥塞控制(保护网络),再解释 cwnd/ssthresh,最后用“超时更严重、快重传更温和”讲阶段转换。

TCP vs UDP

TCPUDP
连接面向连接无连接
可靠可靠有序不承诺可靠性
速度
场景HTTP、文件音视频、DNS、QUIC

高频面试题

Q1:HTTP 和 HTTPS 区别? HTTPS = HTTP + TLS,提供加密、完整性、身份认证。HTTP 明文 80 端口,HTTPS 加密 443 端口,需证书,有握手开销但安全。

Q2:TLS 握手为什么用非对称 + 对称结合? 非对称/密钥交换解决首次协商和身份认证,对称加密负责后续高性能传输。旧 RSA 是客户端用证书公钥加密 pre-master secret;现代 ECDHE/TLS 1.3 用临时密钥交换生成会话密钥,证书主要证明服务端身份,并提供前向保密。

Q3:TCP 为什么三次握手不是两次? 三次才能确认双方收发能力都正常,并防止已失效的历史连接请求突然到达导致错误建连。两次无法确认客户端接收能力。

Q4:TIME_WAIT 是什么?为什么等 2MSL? 主动关闭方在四次挥手后进入 TIME_WAIT,等 2 倍报文最大生存时间:确保最后的 ACK 能到达对端(否则对端重传 FIN),并让本连接的旧报文在网络中消散。

Q5:HTTP/2 相比 1.1 的核心改进? 二进制分帧、多路复用(一个连接并发多请求,解决应用层队头阻塞)、头部压缩 HPACK、服务端推送。

Q6:什么是 SSL Pinning?为什么风控 App 要用?(你的强项) 客户端内置服务端证书或公钥指纹,握手时只信任它而非系统 CA,防止中间人用伪造证书抓包/篡改。金融、风控 App 用它对抗抓包和 MITM。

Q7:输入 URL 到页面展示发生了什么? DNS 解析 → 建立 TCP 连接(三次握手)→ TLS 握手(HTTPS)→ 发 HTTP 请求 → 服务端响应 → 客户端解析渲染 → 关闭/复用连接。

进阶补充:DNS、QUIC、TCP 状态与 TLS 细节

DNS 解析链路

域名访问前通常经历缓存查询、本地 DNS、递归解析。移动端排查网络慢时要区分 DNS、TCP、TLS、请求、响应各阶段。

HTTP/3 与 QUIC

HTTP/3 基于 QUIC(运行在 UDP 上),把传输层和 TLS 1.3 握手整合,改善队头阻塞和弱网迁移。面试不要说“UDP 就不可靠“,QUIC 在用户态实现可靠传输。

TIME_WAIT / CLOSE_WAIT 排障

状态常见原因排查方向
TIME_WAIT 多主动关闭方等待旧包消失连接复用、服务端参数
CLOSE_WAIT 多本端未 close socket代码资源释放、连接池

TLS 补充:SNI、ALPN、OCSP、会话恢复

  • SNI:客户端握手时告诉服务端目标域名。
  • ALPN:协商 HTTP/1.1、HTTP/2 等应用协议。
  • OCSP:检查证书吊销状态。
  • Session Ticket/PSK:减少重复握手成本。

**追问:**HTTP/2 和 HTTP/3 都解决什么问题?HTTP/2 多路复用仍受 TCP 队头阻塞影响;HTTP/3 基于 QUIC 改善连接迁移和队头阻塞。

网络排障专项

网络排障的核心不是背 HTTP 状态码,而是把一次请求拆成 DNS → TCP → TLS → HTTP → 业务解析 → 本地缓存/重试。Android 面试尤其看你能否用 OkHttp、Charles、日志和弱网策略定位真实线上问题。

一、移动端网络排障总流程

先按链路分层,不要一上来就改超时或重试。每一层都要有证据:耗时、错误码、异常类型、抓包结果、服务端日志。

用户反馈慢/失败
  ↓
确认网络类型/Wi-Fi/蜂窝/代理/VPN
  ↓
DNS 解析 → TCP 连接 → TLS 握手 → HTTP 请求/响应
  ↓
OkHttp EventListener/Interceptor 日志
  ↓
Charles/服务端 trace 对照
  ↓
重试、降级、缓存或服务端修复
现象优先看什么常见原因
首次请求慢DNS/TCP/TLS 阶段耗时DNS 慢、冷连接、证书链慢
只有 HTTPS 失败TLS 与证书证书过期、SNI、Pinning、系统时间错误
4xx请求与鉴权token 过期、参数错、权限不足、限流
5xx服务端/网关上游超时、发布故障、容量不足
弱网下大量失败超时/重试/连接池超时太短、非幂等重试、连接复用异常

二、DNS 慢与解析失败

DNS 问题常表现为“首包慢、部分地区失败、切 Wi-Fi/蜂窝表现不同”。Android 端要区分 DNS 解析耗时和后续连接耗时。

  • 原因:运营商 DNS 慢/污染、IPv6/IPv4 选择问题、DNS 缓存过期、内网域名不可达、DoH/HTTPDNS 配置异常。
  • 证据:OkHttp EventListener.dnsStart/dnsEnd 耗时、解析到的 IP、网络类型、地区、失败异常。
  • 策略:合理 DNS 缓存、HTTPDNS/DoH、Happy Eyeballs、失败 IP 黑名单、按域名维度监控。
  • Android 注意:不要在主线程解析域名;多域名、多 IP 兜底要避免无限重试导致电量和流量浪费。

面试话术:先证明是不是 DNS 慢,再谈替代方案;HTTPDNS 能绕过运营商 DNS,但要处理 HTTPS 证书域名校验、调度准确性和缓存过期。

三、TLS 失败、证书错误与 Pinning 调试

TLS 失败要按“证书链、域名、时间、协议套件、Pinning、代理抓包”逐项排除。

错误类型可能原因排查方向
证书过期/未生效服务端证书时间错误检查证书有效期与设备时间
Hostname verification failed证书 SAN 不含域名检查域名、SNI、CDN 证书
Trust anchor not found自签/链不完整补齐中间证书、network security config
Pinning failure公钥/证书指纹不匹配检查发布环境 pin 列表与轮换策略
Handshake failedTLS 版本/套件不兼容老设备、服务端协议配置、ALPN

Charles 调试与 Pinning 策略:

  1. 开发/测试包可使用 debug-only network_security_config 信任用户 CA。
  2. Pinning 必须区分 debug/release:debug 可关闭或使用测试 pin,release 严格校验。
  3. 不要为了抓包在线上包硬编码关闭 Pinning;应该用构建变体、白名单测试域名或内部证书。
  4. Pinning 要支持证书轮换:至少保留当前和备用公钥 pin。

四、HTTP 4xx/5xx 与业务错误定位

HTTP 状态码要先分清“协议层状态”和“业务层 code”。移动端常见误区是看到 500 就重试,看到 401 就清登录态,但真实原因可能更细。

  • 400:参数格式、签名、时间戳、序列化字段缺失。
  • 401:token 过期、刷新 token 失败、设备被踢、匿名接口误带错误凭证。
  • 403:无权限、风控拦截、地区/灰度策略不允许。
  • 404/405:路径、环境、方法不一致,常见于测试环境配置错。
  • 429:限流,客户端要退避而不是立刻重试。
  • 500/502/503/504:服务端、网关、上游依赖或超时;客户端要带 traceId 找服务端对日志。

Android 实践:

  • Interceptor 统一注入 traceId、版本、网络类型,便于端云对齐。
  • 401 刷新 token 要做单飞(single flight),避免并发请求同时刷新。
  • 4xx 多数不应盲重试;5xx 可对幂等请求有限退避重试。

五、弱网络重试、连接池与超时设置

弱网策略要平衡成功率、电量、流量和业务副作用。不是“失败就重试三次”这么简单。

val client = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(15, TimeUnit.SECONDS)
    .writeTimeout(15, TimeUnit.SECONDS)
    .callTimeout(30, TimeUnit.SECONDS)
    .retryOnConnectionFailure(true)
    .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
    .build()
  • connectTimeout:TCP 建连超时,过短会误伤弱网,过长会拖慢失败反馈。
  • readTimeout/writeTimeout:读写单次阻塞超时,适合控制传输阶段。
  • callTimeout:一次 call 总预算,避免 DNS/TLS/重试叠加无限拉长。
  • 连接池:复用 TCP/TLS 连接降低握手成本;但域名/IP/证书变化、长时间 idle、网络切换会导致旧连接失效。
  • 重试原则:GET/查询类可退避重试;下单、支付、登录态变更必须依赖幂等 key 或服务端去重。
  • 退避策略:指数退避 + jitter,避免所有客户端同时重试放大故障。

六、OkHttp Interceptors、EventListener 与 Charles

OkHttp 排障工具分两类:Interceptor 看请求/响应内容,EventListener 看阶段耗时。两者结合才能回答“慢在哪里”。

  • Application Interceptor:业务层,适合加公共 header、日志、签名、token、业务错误处理。
  • Network Interceptor:网络层,能看到重定向、网络响应、缓存细节。
  • EventListener:记录 DNS、connect、secureConnect、requestHeaders、responseHeaders 等阶段耗时。
  • Charles:验证请求是否发出、header/body 是否正确、TLS 证书链、代理环境下服务端响应。

排障 checklist:

  1. 打印 URL、method、traceId、状态码、异常类型、各阶段耗时。
  2. Charles 对照请求头、body、证书和响应。
  3. 断网/弱网/代理/VPN/IPv6 场景复现。
  4. 和服务端用 traceId 对齐网关日志。
  5. 修复后保留监控指标,避免同类问题复发。

高频面试题

Q1:用户反馈接口慢,你怎么定位? 先拆阶段:DNS、TCP、TLS、请求上传、服务端处理、响应下载。用 OkHttp EventListener 记录阶段耗时,Interceptor 打 traceId,Charles/服务端日志对照,再判断是客户端网络、证书、连接池还是服务端问题。

Q2:TLS 证书错误常见原因有哪些? 证书过期、设备时间错误、证书链不完整、域名与 SAN 不匹配、老设备 TLS 套件不兼容、Pinning 指纹不匹配、Charles 代理证书未被 debug 包信任。

Q3:弱网重试怎么设计? 只对幂等或有幂等 key 的请求重试,设置总 callTimeout,使用指数退避和 jitter,限制次数,网络恢复后再补偿。非幂等业务必须服务端去重,不能客户端盲重发。

Q4:OkHttp Interceptor 和 EventListener 区别? Interceptor 适合改请求、加 header、签名、日志和处理响应;EventListener 更适合记录 DNS/TCP/TLS/请求/响应各阶段耗时,用于定位慢在哪里。

易错点 / 追问

  • 易错:把 5xx 全部归因客户端;应带 traceId 找服务端/网关日志。
  • 追问:HTTPDNS 访问 IP 时 HTTPS 证书怎么校验?仍要用原始域名做 SNI/Hostname verification,不能简单把 URL host 改成 IP 后跳过校验。
  • 易错:为了 Charles 抓包关闭 release Pinning;正确做法是 debug-only 策略或测试域名。
  • 追问:为什么 429 不该立即重试?它表示限流,立即重试会放大压力,应按服务端提示或指数退避。

数据库进阶

移动端数据库面试不是背 SQL,而是解释清楚 SQLite 在单文件、弱资源、强一致本地存储下如何工作。能把索引、事务、WAL、锁、Room 迁移和缓存一致性串起来,就能从“会用 Room”升级成“能治理本地数据层”。

一、SQLite 索引与查询优化

SQLite 常用 B+Tree 组织表和索引。Android 端最常见的优化目标是:列表页首屏快、搜索条件可命中索引、分页不扫全表、写入不要被过多索引拖慢。

主题面试要点Android/Room 关联
单列索引加速 where userId = ?order by time@Index("userId")
联合索引遵循最左前缀,适合多条件查询@Index(value=["uid","createdAt"])
覆盖索引查询列都在索引里,减少回表列表摘要页只查必要字段
索引代价占空间、写入/更新变慢埋点/日志表不要给每个字段建索引

索引失效常见场景:

  • 对索引列做函数或计算:where date(createdAt)=?
  • like '%keyword' 前缀模糊无法利用普通 BTree 范围。
  • 联合索引 (a,b,c) 跳过 a 只查 b
  • 隐式类型转换,如文本列传数字参数。

二、事务、WAL 与持久性

事务保证一组本地状态变更要么一起成功,要么一起回滚。移动端常见场景是“网络响应入库 + 更新本地缓存版本 + 删除旧分页游标”必须在同一事务内完成。

@Transaction
suspend fun replacePage(page: Int, items: List<ItemEntity>, nextKey: String?) {
    remoteKeyDao.upsert(RemoteKeyEntity(page, nextKey))
    itemDao.deletePage(page)
    itemDao.insertAll(items)
}
  • Rollback Journal:修改前先备份旧页,提交后删除 journal;读写互斥更明显。
  • WAL(Write-Ahead Logging):先写入 WAL 文件,读者可继续读旧快照,写者追加日志,读写并发更好。
  • Checkpoint:把 WAL 内容合并回主库;WAL 过大可能影响磁盘与启动恢复。
  • Room 实践:批量 insert/update 用事务包住,避免每条 SQL 都 fsync,显著降低耗时和卡顿风险。

三、锁、并发与 Android 线程模型

SQLite 是嵌入式数据库,不是多进程数据库服务器。它的锁粒度会影响“UI 查询、后台同步、埋点写入”之间的互相阻塞。

  1. 共享锁:多个读事务可并发读。
  2. 保留锁/待提交锁:写事务准备提交时逐步升级。
  3. 排它锁:真正写主库时需要排它访问。
  4. WAL 模式下读写并发更好:读者看快照,写者追加 WAL,但同一时间通常仍只有一个 writer。
  5. Android 约束:不要在主线程做大查询或大事务;Room 默认禁止主线程数据库访问是为了防 ANR。

面试回答可以强调:SQLite 适合本地轻量存储,但不适合把所有模块都当成高并发中心库;日志、缓存、业务状态最好按表职责拆清楚,写入队列化。

四、Explain Query Plan 排查慢查询

EXPLAIN QUERY PLAN 用来确认 SQL 是否走索引、是否全表扫描、是否临时排序。它不是“猜测优化”,而是用证据定位慢查询。

EXPLAIN QUERY PLAN
SELECT id, title, createdAt
FROM message
WHERE conversationId = ?
ORDER BY createdAt DESC
LIMIT 20;

-- 关注输出里是否出现:
-- SEARCH TABLE message USING INDEX index_message_conversation_createdAt
-- 避免: SCAN TABLE message 或 USE TEMP B-TREE FOR ORDER BY

Android 排查路径:

  1. 先用日志记录慢 SQL、参数、耗时和线程。
  2. EXPLAIN QUERY PLAN 判断是否 SCAN TABLE
  3. where + order by 组合设计联合索引。
  4. 只查询 UI 需要的列,避免把大字段一次性读出。
  5. 回归验证首屏、翻页、搜索三个关键路径。

五、Room Migration 与数据演进

Room 迁移考的是“线上旧数据怎么安全升级”,不是只会改 entity。中级面试要说明 schema 版本、迁移 SQL、回滚策略和测试。

  • 显式 Migration:从版本 N 到 N+1 写清 ALTER TABLE、建新表、搬数据、删旧表。
  • AutoMigration:适合简单加列/改名,复杂数据变换仍建议手写。
  • 破坏性迁移风险:fallbackToDestructiveMigration() 会清库,只适合非核心缓存库。
  • 迁移测试:用旧版本 schema 创建数据库,插入旧数据,跑 migration,再校验新 DAO 能正常读写。
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE User ADD COLUMN riskLevel INTEGER NOT NULL DEFAULT 0")
        db.execSQL("CREATE INDEX IF NOT EXISTS index_User_riskLevel ON User(riskLevel)")
    }
}

六、N+1 查询、分页与缓存一致性

N+1 查询:先查 1 次列表,再对每个 item 查一次关联数据,列表 50 条就变成 51 次数据库访问。Android 列表滑动时会放大成卡顿。

  • 用 JOIN、@Relation + @Transaction、批量 where id in (...) 解决。
  • 对 RecyclerView/Compose 列表只暴露聚合后的 UI model,不要在 bind 阶段查库。
  • Room Flow 监听表变化时要避免过宽查询,否则任意字段变更都触发大列表重算。

分页策略:

  • Offset 分页:LIMIT 20 OFFSET 10000 越往后越慢,因为仍要跳过大量行。
  • Keyset/Cursor 分页:where createdAt < ? order by createdAt desc limit 20,适合消息/Feed。
  • Paging3 + RemoteMediator:网络页、本地 Room、RemoteKey 统一管理,离线也能展示。

缓存一致性:

  1. 单一可信源:UI 优先观察 Room,网络结果先入库再由数据库驱动 UI。
  2. 版本号/时间戳:解决本地缓存和服务端增量同步冲突。
  3. 事务更新:数据表和分页 key 同事务提交。
  4. 过期策略:TTL、etag、服务端版本号结合,不要永久相信本地缓存。

高频面试题

Q1:SQLite WAL 为什么能提升并发? WAL 把写入追加到日志文件,读事务继续读主库旧快照,因此读写不必像 rollback journal 那样频繁互斥。但通常仍只有一个 writer,且需要 checkpoint 把 WAL 合并回主库。

Q2:如何排查 Room 列表查询慢? 先记录 SQL 耗时和线程,再用 EXPLAIN QUERY PLAN 看是否全表扫描或临时排序;根据 where/order by 设计联合索引,只查必要列,最后用首屏和分页场景回归。

Q3:Room Migration 为什么不能随便 destructive migration? 线上用户的本地业务数据、离线缓存、登录态关联数据可能被清空。除非是可重建缓存库,否则要写显式 migration 并用旧 schema 数据测试。

Q4:N+1 查询在 Android 为什么危险? 它把列表渲染放大成大量小查询,在 RecyclerView/Compose 滑动和 Flow 重新计算时容易造成 IO 抖动和掉帧。应改成 JOIN、批量查询或一次性聚合。

易错点 / 追问

  • 追问:联合索引 (uid, createdAt) 能否支持只按 createdAt 查?一般不能,因为不满足最左前缀。
  • 易错:以为 WAL 下写入完全并行;实际多读一写更友好,但 writer 之间仍会竞争。
  • 追问:Offset 分页为什么越翻越慢?数据库仍要扫描/跳过前面大量记录,移动端大表应优先考虑 keyset 分页。
  • 易错:把 Room @Transaction 只理解成注解,忽略它对一致性和批量写性能的价值。

Gradle 与工程化

中级面试常问构建系统和工程化能力,体现你能不能搭/维护一个中大型项目。你做 SDK 对构建、依赖、产物体积本就敏感,这块容易讲出深度。

一、Gradle 基础

  • Gradle:基于 JVM 的构建工具,用 Groovy/Kotlin DSL(.kts) 写脚本。Android 用 AGP(Android Gradle Plugin)。
  • 构建生命周期三阶段:
    1. 初始化(Initialization):确定哪些模块参与构建(settings.gradle)。
    2. 配置(Configuration):执行所有 build.gradle,构建任务依赖图(Task DAG)。这阶段慢会拖累整体
    3. 执行(Execution):按依赖图执行需要的 Task。
  • Task:构建的最小执行单元,有输入/输出,支持增量。
  • 核心文件:settings.gradle(.kts)(模块声明)、build.gradle(.kts)(模块配置)、gradle.properties(全局配置)。

二、依赖管理

  • 依赖配置:
    • implementation:依赖不传递(只本模块可见),编译隔离、加快构建,首选。
    • api:依赖传递给上层模块(谨慎用,会扩大编译范围)。
    • compileOnly:只编译期(如注解处理、provided)。
    • runtimeOnlytestImplementationkapt/ksp(注解处理)。
  • Version Catalog(libs.versions.toml):集中管理依赖版本,多模块统一,现代推荐。
  • 依赖冲突:Gradle 默认选最高版本;可用 resolutionStrategy 强制版本、exclude 排除传递依赖。
  • BOM:统一一组库的版本(如 Compose BOM)。

三、构建变体与产物

  • buildTypes:debug / release(混淆、签名、是否可调试)。
  • productFlavors:多渠道/多环境(免费版/付费版、国内/海外),组合成 variant
  • 签名配置 signingConfigs:配置 keystore。
  • manifestPlaceholders / BuildConfig 字段:按变体注入不同配置(如不同 API 域名、key)。

四、组件化 / 模块化

中大型项目的核心架构:

  • 为什么组件化:编译解耦(改一个模块不全量编)、并行开发、复用、可独立运行调试。
  • 分层:app 壳 → 业务模块(feature) → 基础库(common/network/ui)。
  • 模块通信(解耦):模块间不直接依赖,用路由(ARouter) + 接口下沉(sink) + DI 组合;Hilt 更偏依赖注入,不是页面路由本身。
  • 路由 ARouter 机制:
    1. 业务页面用注解声明路径,如 @Route(path = "/user/profile")
    2. 编译期 APT 扫描注解,为每个模块生成路由表类,记录 path → Activity/Fragment/Provider 的映射。
    3. App 启动或首次使用时加载各模块路由表。
    4. 调用 ARouter.getInstance().build("/user/profile").withString("id", id).navigation() 时,框架按 path 找到目标并完成参数注入/Intent 跳转。
  • 接口下沉(sink):把跨模块能力抽到公共 API 模块,例如 :user-api 只放 UserService 接口和数据模型,:feature-order 依赖接口而不依赖 :feature-user 实现。实现模块通过路由 Provider 或 DI 绑定暴露能力。
  • 与 Hilt/DI 的区别:路由解决“页面/服务如何按路径发现和跳转”,DI 解决“对象依赖如何创建和注入”。组件化里常组合使用:ARouter 做跨模块入口,Hilt 给模块内部或接口实现注入依赖。
// :user-api
interface UserService {
    fun currentUserId(): String?
}

// :feature-order 只依赖 user-api,不依赖 feature-user
class OrderViewModel(
    private val userService: UserService
) : ViewModel()

五、构建优化(你的兴趣点)

  • 配置阶段优化:避免在配置期做耗时操作、用 configuration cache
  • configuration cache 边界:它缓存配置阶段产物,命中时可跳过配置阶段;但构建脚本、Version Catalog、gradle.properties、环境变量/系统属性读取、配置期文件读取、自定义插件逻辑等配置输入变化都会导致失效。采用前要修复 Gradle 报告的 configuration cache problems,不要只开开关。
  • 增量编译 / 构建缓存:org.gradle.caching=true,复用未变模块的产物。
  • 并行构建:org.gradle.parallel=true,多模块并行。
  • 守护进程:Gradle Daemon 常驻避免 JVM 重启。
  • KSP 替代 KAPT:KSP 直接分析 Kotlin 符号,比 KAPT(生成 Java stub)快很多。
  • 模块化:拆模块 + implementation 隔离,减少改动的重编范围。
  • AGP 升级:新版本构建性能持续优化。
  • 产物优化:R8 裁剪、资源压缩、so 按 ABI 拆分(见 10、11 篇,你的强项)。

六、测试

  • 单元测试:JUnit + Mockito/MockK(mock 依赖),测纯逻辑(ViewModel、UseCase)。
  • 协程测试:runTest + TestDispatcher(StandardTestDispatcher/UnconfinedTestDispatcher),Turbine 测 Flow。
  • UI 测试:Espresso(View)、Compose Test Rule(createComposeRule)。
  • 测试金字塔:大量单元测试 + 适量集成 + 少量 UI 测试。
  • 可测试性:依赖注入 + 接口抽象,让逻辑可替换 mock(见 08 篇架构)。

七、Android 版本适配(高频)

  • 运行时权限(6.0+):危险权限动态申请;后续版本继续细分一次性权限、后台定位、照片/视频/音频等权限边界。
  • 分区存储 Scoped Storage(10/11+):App 只能自由访问自己目录 + MediaStore,访问其他需 SAF;具体强制行为和兼容开关与 Android 10/11、targetSdk 有关,升级时要按官方行为变更表核对。
  • 后台限制(8.0+):后台 Service 受限、隐式广播限制、用 WorkManager/JobScheduler;Android 12(API 31) 起对 target 31+ 的后台启动前台服务限制更严格,不满足例外会抛异常。
  • 通知渠道(8.0+):必须建 NotificationChannel;Android 13(API 33) target 33+ 需要申请 POST_NOTIFICATIONS,用户拒绝后普通通知不可见,但前台服务仍会在系统规定位置保留可见性。
  • targetSdk 升级:每次升级要处理对应行为变更,不要只改数字。
    • Android 12 / API 31:target 31+ 创建 PendingIntent 必须显式声明 FLAG_IMMUTABLEFLAG_MUTABLE;后台启动 FGS、通知 trampoline、精确闹钟等也有新限制。
    • Android 13 / API 33:通知运行时权限、细分媒体权限、部分组件导出/Intent 安全要求需要排查。
    • Android 14 / API 34:target 34+ 前台服务必须声明合适的 foreground service type;隐式 Intent 发送到应用内部未导出组件、mutable implicit PendingIntent 等行为更受限制。
  • 适配排查清单:先读官方 behavior changes → 全局搜索权限/通知/FGS/PendingIntent/存储/API 调用 → 加兼容分支和自动化测试 → 用灰度观察崩溃、ANR、权限拒绝率。
  • 64 位要求:Google Play 强制 arm64,需提供 64 位 so。

高频面试题

Q1:implementation 和 api 区别? implementation 依赖不传递,只本模块可见,编译隔离、加快构建;api 会把依赖暴露给上层模块(传递依赖),扩大编译范围。优先 implementation,仅需对外暴露时用 api。

Q2:Gradle 构建有哪几个阶段?哪个容易成为瓶颈? 初始化(定模块)、配置(执行所有 build.gradle 建任务图)、执行(跑 Task)。配置阶段会执行所有模块脚本,模块多/脚本重时成为瓶颈,可用配置缓存优化。

Q3:为什么要组件化?模块间怎么通信? 编译解耦(加快构建)、并行开发、复用、独立调试。模块间不直接依赖,通过路由(ARouter)+ 接口下沉 + 依赖注入通信,避免循环依赖。

Q4:怎么优化 Gradle 构建速度? 开启构建缓存、并行构建、配置缓存、Daemon;用 KSP 替代 KAPT;模块化 + implementation 隔离减小重编范围;升级 AGP/Gradle。

Q5:KAPT 和 KSP 区别? KAPT 为兼容 Java 注解处理器会生成 Java stub,慢;KSP 是 Kotlin 原生符号处理,直接分析 Kotlin 代码,速度快数倍,Room/Hilt 等已支持。

Q6:分区存储是什么?带来什么变化? Android 10+ 限制 App 只能自由访问自己的外部目录和 MediaStore,访问其他文件需通过 SAF 或 MediaStore API,不能再随意读写整个外部存储,提升隐私。

Q7:targetSdk 升级要注意什么? 每个版本有行为变更需适配,而且很多只对达到对应 targetSdk 的 App 生效。重点排查权限、存储、后台启动、通知、PendingIntent、前台服务类型、隐式 Intent 等;做法是对照官方 behavior changes 建清单,搜索代码命中点,加兼容分支和回归测试,灰度观察崩溃/ANR/权限拒绝率。

Gradle 构建性能专题

“构建工具是提高工程效率的核心。深刻理解 Gradle 生命周期和缓存机制,才能在大型工程里解决‘为什么编译这么慢’的痛点。”

面试策略: 结合你负责 SDK 开发的背景,聊聊你在解决依赖冲突、优化模块化编译速度、甚至推动 KSP 替代 KAPT 等方面的实战经验。

一、Gradle 构建生命周期

Gradle 构建的核心是确定执行图并执行,主要分为三个阶段:

  1. 初始化阶段 (Initialization): 决定哪些项目/模块参与构建,解析 settings.gradle(.kts),创建 Project 实例。
  2. 配置阶段 (Configuration): 执行所有参与构建的 build.gradle(.kts) 脚本,配置 Project 对象,最关键的是构建出 Task 的依赖有向无环图 (DAG)
    • 优化点: 这一阶段是容易拖慢构建的地方。不要在脚本里写耗时的 IO 或网络操作。
  3. 执行阶段 (Execution): 根据 DAG 确定执行顺序,执行具体的 Task 操作,生成构建产物。

二、Gradle 核心概念:Task、Plugin 与 Variant

  • Task: 构建的最小工作单元。每个 Task 有输入 (Inputs) 和输出 (Outputs)。
    • 增量构建 (UP-TO-DATE): 当 Task 的 Inputs 和 Outputs 没有发生变化时,Gradle 会跳过此 Task,这是加快构建的核心机制。
  • Plugin: 插件是 Task 的集合并封装了配置逻辑。AGP (Android Gradle Plugin) 就是将编译 Android 相关的 Tasks 注入到工程中。
  • Variant (构建变体): 由 Build Type(如 debug/release)和 Product Flavor(如免费版/付费版)组合而成,影响最终输出的 APK/AAB 结构。

三、依赖管理与冲突解决

随着项目增长,依赖管理往往成为灾难,特别是传递依赖导致的冲突。

策略/工具说明适用场景
implementation vs apiimplementation 隐藏内部依赖,不向上传递,能避免雪崩式的重新编译api 则传递给依赖方。绝大多数情况用 implementation
依赖排除 (exclude)在引入库时,手动剔除内部的某个冲突的子依赖。解决具体的库版本冲突,如旧版 support 包。
resolutionStrategy在全局强制指定某个库的特定版本,或者遇到冲突时失败(Fail on version conflict)。团队协作防止非预期的版本升级。
Version Catalog (libs.versions.toml)将所有依赖的版本号统一在一处管理,支持类型安全的访问。现代多模块项目的标配。
BOM (Bill of Materials)强制一组相关的库使用协同测试过的版本(如 Compose BOM)。Compose、Firebase 等庞大的库集合。

四、构建性能深度优化

构建优化通常分为“配置期“与“执行期“优化。

  1. 开启 Configuration Cache
    • 缓存配置阶段的 Task Graph。第二次构建时,如果 build.gradle 没有变化,直接跳过耗时的配置阶段
    • 要求极高:脚本中不能读取外部不可追踪的状态、自定义 Task 必须声明清楚输入输出等。
  2. 构建缓存 (Build Cache) 与并行 (Parallel)
    • org.gradle.caching=true: 复用之前或者其他机器(远程构建缓存)编译好的 Task 输出。
    • org.gradle.parallel=true: 多核并行执行互相没有依赖的 Project/Task。
  3. 守护进程 (Daemon) 与 JVM 参数
    • 保证 Daemon 开启(默认已开启),复用 JVM 以减少启动时间和 JIT 预热开销。
    • 调整 org.gradle.jvmargs 给出充足的 Heap 空间。

五、KSP vs KAPT 与多模块治理

  • KAPT (Kotlin Annotation Processing Tool):
    • 传统注解处理。需要生成 Java Stub 文件,然后让 Java 的 AP 去处理。这会导致 Kotlin 编译变慢,并拖累整体速度。
  • KSP (Kotlin Symbol Processing):
    • 直接在 Kotlin 符号层面上解析注解,避免生成 Java Stub,构建速度能提升 2 倍甚至更多。目前 Room、Glide、Hilt 等都已支持。
  • 多模块优化:
    • 将大仓拆分更细粒度的业务模块和基础模块。
    • 使用 implementation 严格进行模块间隔离,当修改底层模块的非公开 API 时,上层模块不会触发重新编译。

高频面试题

Q1: 为什么有时候修改了一行代码,整个项目都要重新编译? 因为依赖隔离没做好。如果底层模块对外暴露了 API(使用了 api 声明依赖或者修改了 public 的方法签名),依赖它的上层模块在 ABI(应用二进制接口)发生变化时,都会触发重新编译。另外也可能是因为使用了不兼容增量编译的旧版 AP。

Q2: 讲讲 Gradle 中的 UP-TO-DATE 是怎么判断的? Gradle 在执行 Task 前,会对比该 Task 的 Inputs(文件内容哈希、属性值等)和 Outputs(生成的文件)。如果两次构建的 Inputs 和 Outputs 的哈希值完全一致,说明没有改动,Task 就会被标记为 UP-TO-DATE,直接跳过执行。

Q3: KSP 相比 KAPT 为什么快那么多? KAPT 为了复用 Java 的注解处理器,需要先解析 Kotlin 代码,生成临时的 Java Stub 文件,这涉及到类型的推导和转换,非常耗时。KSP 是原生为 Kotlin 设计的,直接访问 Kotlin 的编译器符号树(AST 的上层),不需要生成 Stub,省去了解析和转换的巨大开销。

易错点 / 追问

  • 忽略 Configuration Cache 的报错: 强行开启但忽略不兼容警告,可能导致拿到旧配置,打出来的包是错的。
  • 过度拆分模块: 模块化虽然能并行,但每个模块配置和处理也会引入额外开销,拆分太碎反而可能拖慢配置阶段。
  • 滥用 api 代替 implementation: 图省事把依赖全写成 api,导致任何一个库的更新引发全工程重编。

CI/CD 与发布体系

“从代码提交到用户手里的安装包,中间的自动化流水线不仅是效率工程,更是质量把控的最后防线。”

面试策略: 讲清楚持续集成(CI)的核心质量卡点,以及持续交付/部署(CD)中的版本、签名和多渠道策略,这能展现你对工程闭环的把控力。

一、CI/CD 核心概念与工具

  • CI (Continuous Integration, 持续集成): 开发者频繁提交代码,流水线自动触发编译、静态代码检查(Lint/SonarQube)和自动化测试。目标是尽早发现缺陷
  • CD (Continuous Delivery/Deployment, 持续交付/部署): 自动将打好的包发布到测试环境,或者打包带有生产环境签名的正式包上传到应用商店/分发平台。
  • 常用工具:
    • Jenkins:老牌开源,可高度定制,适合企业内网部署。
    • GitLab CI / GitHub Actions:基于 YAML 配置文件直接存在代码库里(Pipeline as Code),开箱即用,越来越流行。

二、APK 与 AAB 构建体系

产物格式核心原理特点与应用场景
APK (Android Package)包含所有的资源、ABI、DEX 文件。设备安装时全盘拷贝。适合国内通过 CDN 下载直装、企业内部测试使用。容易出现包体积臃肿。
AAB (Android App Bundle)一种发布格式(不能直接安装),Google Play 会根据下载设备(屏幕密度、ABI、语言)按需生成动态 APK。大幅降低用户下载体积。Google Play 强制要求,适合海外出海应用。

三、渠道包与版本策略

  • 多渠道打包 (Flavors):
    • 国内通常需要向不同的应用商店(华为、小米、应用宝等)发布,使用 Flavor 或第三方工具(如 Walle/VasDolly)写入渠道标识,以便服务端统计各个渠道的激活率和转化率。
  • 版本治理 (Version Code / Version Name):
    • versionCode 必须递增,用于系统判断是否允许覆盖安装。
    • versionName 只是展示给用户的字符串。在 CI 中,通常将 versionCode 与 Git commit count 或者 CI 系统的 Build Number 绑定,保证其唯一和自增。

四、安全签名与构建产物管理

  • 签名安全:
    • 绝对不能把 Release Keystore 和密码放在代码库里。
    • CI 中通常使用系统注入环境变量(Secrets),构建时通过临时解密或安全文件服务来签名。
  • 产物归档 (Artifacts):
    • 构建成功后,不仅要保留 APK/AAB,还要归档 Mapping 文件(混淆后的符号表)和 Native 符号表 (.so 的 debug symbol)。如果没有它们,线上的崩溃堆栈将是一本无字天书,无法定位问题。

五、灰度发布、降级与质量门禁

发布绝不是一键上线那么简单,需要配合严密的策略控制风险。

  1. 自动化质量门禁 (Quality Gates):
    • PR (Pull Request) 阶段强制触发 CI。如果不满足:单元测试通过率 > 80%、静态扫描无 Critical 级警告,禁止合并代码。
  2. 灰度发布 (Gray Release):
    • 先放量 1% 的用户,配合 APM 监控崩溃率、ANR 和核心业务指标。观察平稳后再放量 10%、50% 到全量。
  3. 兜底与回滚:
    • 客户端发布覆水难收(旧版本无法撤回),必须要有热修复补丁(Hotfix)机制,或者通过配置中心下发开关进行功能降级,关闭引起崩溃的新功能。

高频面试题

Q1: AAB 相比 APK 有什么优势?如果国内应用商店不支持 AAB 怎么优化体积? 优势:AAB 将打包和分发的职责分离,Google Play 会根据用户设备具体情况(如只需要 hdpi 图片、arm64-v8a 库、中文语言包)裁切出特定的 APK,大幅减少体积。 国内不支持 AAB 时:可以利用 Gradle 构建配置分别打包特定 ABI 的分包(APK Splits),或者使用资源混淆(AndResGuard)、动态下发非核心动态库和大型资源等方式优化。

Q2: 如何保证 CI 环境打包和本地开发打包的结果一致性? 使用固定的基础镜像环境(Docker);强制使用统一版本的 JDK 和 Gradle Wrapper;禁止在脚本中读取个人计算机的环境变量;版本号和依赖全部锁定版本。

Q3: 线上出现严重的崩溃,无法发新版(审核慢),有哪些应急预案? 首先利用功能开关(Feature Toggle)从配置中心下发指令,关闭引发崩溃的入口(降级);如果无法关闭,利用热修复(Tinker、Robust)下发补丁修复逻辑;同时对未升级用户的应用商店渠道包进行下架或暂停灰度放量。

易错点 / 追问

  • Mapping 文件丢失: 打完包丢弃了本次构建的 mapping 文件,导致崩溃上报后无法解混淆,这是灾难性的工程事故。
  • 混淆配置未同步: CI 开启了 R8 混淆但没有在测试环境验证,直接上生产,由于反射或 JNI 类被裁减导致秒退。
  • 密钥泄露: 将签名文件或 API Key 随代码 Push 到了公网 Github,安全风险极高。

隐私合规与权限治理

“在强监管时代,因为隐私合规不达标导致 App 被全面下架的惨痛教训比比皆是。合规不再是边角料工作,而是生命线工程。”

面试策略: 结合你 SDK 开发的经历,强调你在收拢采集入口、治理第三方 SDK 以及设备标识符(OAID 等)演进上的经验。

一、隐私合规的“生死红线“

国内工信部和网信办、海外的 GDPR 对隐私保护的底线要求非常严苛,核心原则是:未经同意,不取一毫;最小必要,严禁滥用。

  • 隐私弹窗: 用户首次打开 App 时,必须弹窗展示《隐私政策》和《用户协议》,在用户点击“同意“之前,绝对不允许执行任何代码去读取敏感信息或初始化敏感 SDK。
  • 默认勾选: 注册/登录页面的协议同意框,绝不能默认帮用户打勾。
  • 超范围收集: 不能因为是一个手电筒 App 就要求读取通讯录和位置信息,这违反“最小必要“原则。
  • 频次限制: 不允许在后台高频次地(如每秒一次)获取用户位置或读剪切板。

二、Android 权限治理演进

Android 系统本身的权限管理也顺应合规趋势,逐年收紧:

权限变化里程碑影响与适配策略
Android 6.0 动态权限危险权限(相机、位置等)不能在安装时一揽子授权,必须在使用时动态弹窗申请。
Android 10 分区存储限制对 SD 卡的粗放读取。App 只能自由读取自己的目录和媒体库(通过 MediaStore),读取其他文件需请求特定授权。
Android 11 单次授权引入“仅限这一次”选项。App 进入后台后,该权限可能被系统立刻收回。代码里不能缓存授权状态,每次使用前都要检查。
Android 12 近似位置用户可以选择只给你“大致位置”(精确度几平方公里)而不是 GPS 坐标。位置功能必须兼容这种精度。
Android 13 通知权限发通知不再是默认开启的,需要显式申请 POST_NOTIFICATIONS 权限。
Android 14 媒体部分授权允许用户只授权照片库中的“几张指定图片”,而不是全量相册读取权限。

三、设备标识符的演变与 OAID

作为做设备指纹出身的开发者,这块是你的强项:

  • MAC 地址 / IMEI: 曾经用来唯一追踪用户的神器。随着隐私收紧,Android 10 以后非系统级应用彻底无法获取真实的 IMEI 和全局不变的 MAC 地址(系统返回全 0 或抛出异常)。
  • Android ID: 恢复出厂设置或签名改变会发生变化。在 Android 8.0 之后,不同的 App 读到的同一个设备的 Android ID 甚至是不一样的(基于签名的作用域隔离)。
  • OAID (匿名设备标识符):
    • 国内由于 Google 服务的缺失,由移动安全联盟 (MSA) 推出的替代方案。
    • 特性:用户可以在系统设置中手动重置 OAID。
    • 它满足了广告归因的需求,又给了用户重置追踪的权利,是目前国内合规体系下获取设备标识的主流手段。

四、第三方 SDK 合规治理

因为第三方 SDK 的违规采集导致主 App 被下架,这叫“背锅”。所以大型 App 会对接入的 SDK 进行严苛治理。

  • 延迟初始化: 所有第三方 SDK(尤其是广告、推送、统计、风控)的初始化动作,必须放在隐私弹窗“同意”按钮的回调之后。
  • 采集点收口: 收拢项目里的定位、剪切板、设备信息读取入口,封装成统一的 Manager 代理。在 Manager 中增加鉴权、缓存、频次控制和合规拦截。
  • 静态扫描与字节码 Hook:
    • 对于不受控的第三方 SDK 或陈旧的 Jar 包,在打包阶段(或运行时阶段,利用 ASM 插件 或类似 Epic 的机制),对 TelephonyManager.getDeviceId() 等敏感 API 调用进行 Hook 或替换,在未同意隐私政策时返回空数据。

五、敏感行为的安全保障

  • 剪切板滥用:
    • iOS 14 和 Android 12 开始,App 读取剪切板系统会在顶部弹出明显提示。
    • 如果应用为了淘口令等功能无脑轮询读取剪切板,会让用户极其反感甚至遭遇投诉。正确做法是切前台时且发现特定格式的文本才进行解析。
  • 日志安全 / 脱敏:
    • 本地日志绝不允许明文打印用户的手机号、身份证、真实姓名、密码。必须进行脱敏(如 138****1234)或强加密。
  • 权限被拒处理:
    • 不能因为用户拒绝了位置权限,就不让用户使用“扫一扫”功能,不能搞权限捆绑。

高频面试题

Q1: 在用户点击同意隐私政策前,App 到底能不能访问网络? 不能。任何网络请求都会向外暴露用户的 IP 地址等基础设备信息,因此在严格合规要求下,在隐私弹窗同意前,不允许发起任何向外部的请求,更不能拉起各种后台 Service 提前预热 SDK。

Q2: 怎么防止项目中接入的第三方广告 SDK 在后台偷读用户剪切板或位置信息?

  1. 商务层面: 选用正规、经过官方审核的版本,并在应用内隐私政策明确披露该 SDK。
  2. 工程层面: 不在后台启动和保活该 SDK 所在的组件;通过 ASM 字节码插桩,在打包期间将该 SDK 调用敏感 API 的方法替换为我们自己的代理方法,在代理中加入合规状态判断和频次拦截。

Q3: Android 10+ 无法获取 IMEI 后,风控和广告归因怎么做? 国内推荐使用 MSA 提供的 OAID 库获取匿名设备标识;海外使用 Google Advertising ID (AAID)。同时,风控系统转为采用弱特征组合设备指纹(通过分辨率、系统版本、传感器特征等计算生成),不再依赖单一的绝对不变标识符。

易错点 / 追问

  • 粗暴对待权限拒绝: 用户拒绝权限后不断弹窗骚扰,或者直接 finish() 应用,这会导致极差体验并被商店警告。
  • 缓存单次授权状态: 在 Android 11+ 把获得的权限记录在 SharedPreferences 中,下次直接用,结果权限已被系统回收导致崩溃。
  • 过度索权: 开发图省事,不管用不用,先把 Manifest 里的权限申请一大堆,上架时很容易被拒。

登录鉴权与账号体系

“账号是所有业务的基石,一次优秀的登录系统设计,需要兼顾安全防护、无缝体验以及应对多设备、状态同步的复杂性。”

面试策略: 把这道题当作架构设计题来答。不要只讲发送密码存一下 token,要从 双 Token 体系、状态机控制、安全存储 三个维度,展现对登录流程严密性的思考。

一、Token 与鉴权基础 (OAuth2/JWT)

移动端通常不再使用传统的 Session-Cookie 模型(有 CSRF 风险和跨端限制),主流方案是基于 Token 的鉴权机制。

  • JWT (JSON Web Token): 自包含的令牌,服务端签发后无需查询数据库即可校验(只要验证签名)。
    • 结构: Header(算法)+ Payload(用户 ID/过期时间)+ Signature(防篡改签名)。
    • 弱点: 一旦签发,在过期前服务端难以主动使其失效(除非引入黑名单,但这会失去 JWT 无状态的优势)。
  • OAuth2 核心流程: 用于第三方授权登录(微信/Google 登录),获取 Access Token 来访问资源。

二、双 Token 体系与无感刷新

为解决 JWT 无法撤销与长期有效带来的安全风险,业界标准是采用 双 Token (Access Token + Refresh Token) 机制:

  1. Access Token (AT): 生命周期极短(如 2 小时),每次网络请求放在 Header 中 (Authorization: Bearer <token>)。即使泄漏,风险时间也短。
  2. Refresh Token (RT): 生命周期较长(如 30 天),仅用于获取新的 AT。绝不能在普通业务接口中传输

无感刷新流程设计 (并发拦截控制): 当业务请求收到 401 Unauthorized(AT 过期)时:

  • 网络层拦截器捕获到 401。
  • 挂起当前及后续其他需要鉴权的请求。
  • 发起使用 RT 换取新 AT 的请求。
  • 刷新成功后,保存新 token,并自动重试刚才挂起的业务请求。
  • 如果 RT 也过期(返回特定错误),则清空本地登录状态,跳转到登录页。

三、登录状态机管理

客户端的登录状态往往很乱(如闪屏页、其他请求触发掉线),必须引入状态机或单向数据流进行集中管理。

未登录 (LoggedOut) 
  ↓ (输入账号密码/授权)
登录中 (LoggingIn) → 失败回 [未登录]
  ↓ (获取 Token 成功)
已登录 (LoggedIn) 
  ↓ (401触发刷新)
刷新中 (Refreshing) → 成功回 [已登录],失败去 [未登录]

使用单一可信源(如全局的 StateFlow/LiveData)来分发当前状态,UI 根据这个状态决定是展示个人中心还是弹出登录框。避免到处散落 if (isLogin())

四、本地安全存储与风险对抗

Token 是用户的钥匙,保存在本地必须做安全防护:

存储方案风险级别防御手段 / 面试加分项
明文 SharedPreferences / SQLite极高,Root 后一览无余坚决禁止
自定义加密存储中等,密钥可能被逆向提取密钥需经过混淆、分段或者配合动态下发
Android Keystore (EncryptedSharedPreferences)低,基于硬件 TEE 的密钥管理推荐方案。即使拿到磁盘文件也无法解密,密钥不出安全区

设备绑定与风控检查: 登录不仅仅是密码匹配。服务端通常还会结合你收集的设备指纹进行判定:异地登录、新设备登录、同一设备高频切换账号等,需要触发短信验证码、滑块验证等二次认证机制。

五、多设备登录与单点登录 (SSO)

  • 单点登录 (SSO): 企业内部多 App 互通登录状态。通常由一个主应用/网页提供统一鉴权,返回临时 Ticket,其他端用 Ticket 去认证中心换自己的 Token。
  • 多设备互踢:
    • 服务端维护一张 [用户ID - 设备ID - Token] 映射表。
    • 用户在 B 设备登录,服务端使得 A 设备的 Token 失效,或者通过 WebSocket/Push 主动推一条“踢出下线“指令给 A 设备。
    • A 设备收到通知,清空本地状态,弹窗提示“您的账号在其他设备登录“。

高频面试题

Q1: Access Token 和 Refresh Token 的机制是什么?为什么要用两个 Token? 为了平衡安全用户体验。单一长效 Token 泄漏风险极大且难以撤销;短效 Token 频繁过期会让用户反复登录体验极差。双 Token 用短效 Access Token 降低泄漏后的损失窗口,用长效 Refresh Token 实现静默续期,且 Refresh Token 只发往专门的刷新接口,截获概率低。

Q2: 在协程/RxJava 拦截器中,怎么处理多并发请求导致的多次 Token 刷新问题? 利用并发锁或者协程 Mutex。当第一个 401 触发刷新时,上锁,后续的 401 请求判断正在刷新中,则挂起等待。刷新成功后,通知所有等待的请求用新 Token 重试;如果刷新失败,则全部抛出未登录异常跳转登录页。

Q3: App 卸载重装后,如何保持依然处于登录状态(免密登录)? 通常通过将加密 Token 或者账号绑定凭证备份到系统级的机制中,如 Android 的 KeyChain、AccountManager,或者依靠读取稳定的硬件设备指纹作为辅助免密凭证。但出于安全合规考虑,现在的 App 大多重装后仍要求重新登录或进行验证码二次确认。

易错点 / 追问

  • Token 存在内存中没有持久化: 导致 App 被系统杀进程重启后状态丢失。
  • 未处理多进程的 Token 同步: 很多 App 有独立推送或后台进程,单进程刷新了 Token 没通知其他进程,导致 401 死循环。
  • 退出登录未通知服务端: 本地清空了 Token,但 JWT 仍然没有过期,截获这段 JWT 的黑客仍能继续请求(应当让服务端将该 Token 暂时加入黑名单)。

支付订单与状态机

“支付业务的核心是‘绝不能丢钱,也绝不能多扣钱’。网络可能是不可靠的,但我们的系统必须是可靠的。”

面试策略: 在这部分重点体现你对分布式系统异常情况的理解。突出幂等性、重试、轮询、超时取消,以及状态机控制这五个关键武器。

一、客户端支付全链路流程

一次完整的第三方支付(如微信/支付宝)交互绝不是客户端拿着金额去请求 SDK 这么简单,它涉及三方交互:

  1. 客户端发起订单: 客户端请求自己服务器,传递商品信息。
  2. 服务端生成预支付单: 业务服务器调用微信/支付宝生成预支付交易单,将包含签名等核心信息的 PayInfo 返给客户端。
  3. 客户端拉起收银台: 客户端使用 SDK,传入 PayInfo 唤起支付 App 完成支付。
  4. 客户端获取同步结果: 支付 SDK 回调给客户端支付结果(成功/取消/失败)。此结果仅供 UI 展示参考,不能作为最终发货依据
  5. 服务端接收异步通知: 微信/支付宝服务器将真实的支付成功通知发给你的业务服务器。
  6. 客户端轮询/长连同步真实结果: 客户端主动拉取或接收服务器推送,确认支付最终状态,展示成功页。

二、订单状态机设计

复杂业务中,订单绝不能只有“成功“和“失败“。必须用严谨的状态机约束状态流转,防止非法倒流(如“已取消“的订单被发货)。

           [待支付]
          /    |   \
 (超时未付)   (支付)  (用户主动取消)
    /          |        \
[已取消]    [支付中]     [已取消]
               |
      (服务端接收回调成功)
               |
           [已支付/待发货]
               |
           [已发货] → [已完成]
  • 核心原则: 状态只能单向推进,或根据特定规则流转。客户端 UI 根据状态机的当前状态来渲染按钮(如:待支付展示“去支付“,已取消展示“重新购买“)。

三、网络异常与重试机制 (幂等性)

移动端面临弱网、断网、重切等各种网络异常。当发出“确认购买“请求,但遇到了网络超时,此时钱扣了吗?

  • 幂等性 (Idempotency): 无论接口被调用多少次,产生的业务结果应该和调用一次相同。
  • 防重点击机制:
    • 前端 UI 防止连点(按钮变灰/Debounce拦截)。
    • 核心防线在服务端: 客户端生成全局唯一的单号或携带防重 Token,服务端依赖数据库唯一索引或 Redis 分布式锁拦截重复请求。客户端重试时必须带上同样的 ID。

四、支付结果确认与轮询机制

如第一节所述,客户端 SDK 返回成功,不代表真的成功了(可能是网络劫持伪造的响应)。

  • 确认机制: 支付完成后,客户端展示“支付确认中“的加载框。
  • 轮询 (Polling):
    • 客户端定时向业务服务器发起请求查询订单真实状态(比如:延时 1s、2s、4s 递增查询,最多查询 5 次)。
  • 兜底策略: 如果轮询一直未确认,不能告诉用户“支付失败“,应该提示“结果确认中,请稍后查看订单列表“。服务端会在后续收到异步通知时更正订单状态。

五、超时取消与库存回退

  • 当订单处于“待支付“状态,业务逻辑往往已经预占了商品的库存。
  • 如果用户迟迟不付,必须有超时取消机制(例如 15 分钟未支付自动关闭),释放占用的库存。
  • 客户端倒计时: 倒计时的计算一定要以服务端返回的时间戳为基准,切勿使用客户端本地的 System.currentTimeMillis(),因为用户可以随便修改手机时间。

六、安全边界与反作弊

  • 金额不可信: 客户端绝不能自己提交 amount=100 给支付 SDK。金额必须由业务服务端通过商品 ID 和促销规则计算生成,客户端只拿组装好的签名串。
  • 拦截抓包与篡改: 防止用户抓包拦截服务端的预支付单,修改成别人的订单号或极小金额。这里结合第 20 篇提到的签名、HTTPS 双向校验和风控逻辑发挥。

高频面试题

Q1: 支付完 SDK 告诉客户端成功了,此时可以立刻更新本地状态为“已支付”并给用户发货吗? 绝对不可以。客户端环境不可控,SDK 返回的结果可能被篡改(如破解包或劫持回调)。发货的唯一凭证是业务服务器收到微信/支付宝的官方异步回调校验成功。客户端结果只用于触发向服务端查询的动作。

Q2: 弱网下用户点击“立即支付”由于没有响应,狂点了三下,怎么保证不产生三个订单?

  1. 客户端防重: 按钮点击后 disable,通过 RxBinding 或协程防抖。
  2. 状态机控制: 本地记录正在请求中,不响应二次点击。
  3. 服务端唯一防重: 客户端生成或服务端提前下发的 UUID 作为本次交易的凭据(幂等 Key),服务端收到多次相同 Key 的请求时,只处理一次,其余的直接返回旧订单信息。

Q3: 如果支付完成后回到 App,网络断了,无法轮询服务端拿到结果,应该怎么处理? 展示“订单处理中”或“网络异常,稍后请在订单列表查看”。绝不能显示“支付失败”(万一钱扣了会引发严重客诉),也绝不能显示“支付成功”(万一真没成功则产生资损)。等待网络恢复后,用户进入订单列表时再次向服务器同步最新状态。

易错点 / 追问

  • 倒计时依赖本地时间: 利用修改手机系统时间可以无限延长支付时间或卡出倒计时负数 bug。
  • 本地订单状态与服务端不一致: 客户端由于进程被杀等原因错过状态流转,重入时一定要从服务器重新拉取当前最新状态机节点。
  • 支付 SDK 冲突: 集成多方支付 SDK 导致依赖库冲突(通常通过 exclude 或使用精简版 SDK 解决)。

推送 / 长连接 / 保活

☆ 推送、长连接和保活经常和 Android 后台限制、厂商策略、功耗合规一起考。你的风控/对抗经验可以讲“知道边界、尊重系统、用监控验证“。

一、三类实时方案

方案优点缺点适用场景
轮询简单耗电、实时性差低频状态刷新
长轮询兼顾兼容性服务端连接占用简单实时通知
WebSocket/TCP 长连接实时、双向心跳、重连、保活复杂IM、协作、行情

二、WebSocket 心跳与重连

心跳不是越频繁越好。移动端要在实时性、功耗、网络切换、后台限制之间取舍。

sealed class SocketState {
    object Disconnected : SocketState()
    object Connecting : SocketState()
    object Connected : SocketState()
    data class Backoff(val retryAfterMs: Long) : SocketState()
}

重连策略:指数退避 + 网络恢复触发 + 前后台状态感知 + 最大重试保护。

三、系统推送:FCM 与国内厂商通道

  • 海外优先 FCM。
  • 国内通常接入小米/华为/OPPO/vivo/荣耀等厂商推送。
  • 应用内长连接适合前台实时,系统推送适合后台触达。

四、Android 后台限制与保活边界

Android 8.0 以后后台 Service、隐式广播、后台执行都有严格限制。面试要强调合规策略:前台服务、WorkManager、厂商推送、用户可见任务,而不是无限拉活。

五、进程保活与拉活的现实策略

历史方案包括双进程守护、1 像素 Activity、JobScheduler、账号同步、厂商白名单等,很多已被系统或厂商限制。中级面试更看重你能否说明风险:耗电、合规、用户体验、应用商店审核。

六、排障与监控

关注连接成功率、平均在线时长、心跳超时、重连次数、消息到达率、厂商通道回执。

高频面试题

Q1:IM 为什么不能只靠推送? 答:推送适合后台触达,不保证强实时和完整双向通信;前台聊天通常需要长连接保证低延迟、顺序和 ACK。

Q2:心跳间隔怎么定? 答:根据 NAT 超时、服务端成本、功耗和业务实时性实验确定,并区分前后台/网络类型,不能拍脑袋固定 5 秒。

Q3:怎么回答保活问题才安全? 答:讲系统允许的前台服务、WorkManager、厂商推送和业务必要性;历史黑科技可以说明了解但不建议作为长期方案。

易错点 / 追问

  • 不要承诺后台永远在线。
  • 不要忽略厂商 ROM 差异。
  • 不要把推送到达率问题全归因于客户端。

埋点与数据采集 SDK ☆

埋点 SDK 的价值不是“多采集”,而是“准、稳、少打扰、可合规”。 你有设备指纹/风控 SDK 背景,面试时可以把采集准确性、隐私边界、离线队列和宿主性能控制讲成工程亮点。

一、埋点体系:手动埋点与自动埋点

埋点用于理解用户行为、业务转化和产品质量。常见分为手动埋点、自动埋点和可视化埋点。

类型原理优点缺点
手动埋点业务代码显式调用 SDK 上报事件语义准确,参数可控研发成本高,容易漏埋/错埋。
自动埋点通过生命周期、View 点击、页面曝光自动采集接入成本低,覆盖广业务语义弱,去重和误采集难。
可视化埋点后台圈选控件,客户端匹配路径非研发可配置控件路径易变,复杂列表/动态 UI 难稳定。
代码生成/编译期埋点AOP、Transform、字节码插桩侵入低,一致性好构建复杂度和兼容性成本高。

成熟方案通常是“手动埋点保证核心业务,自动埋点补足基础行为,服务端配置控制采样和开关”。SDK 要提供统一事件模型,例如 eventName、timestamp、sessionId、pageName、properties、device/app context。

{
  "event": "product_click",
  "time": 1710000000000,
  "page": "HomePage",
  "session_id": "s_abc",
  "properties": {
    "product_id": "masked-id",
    "position": 3
  }
}

二、曝光与点击采集

点击采集相对直观,但曝光采集更容易出错。曝光通常要求“元素进入可见区域 + 可见比例达到阈值 + 停留时间达到阈值 + 去重策略满足”。

场景采集要点常见坑
Button 点击绑定 View ID/业务 ID/页面上下文只采 View 文案会受多语言影响。
RecyclerView 曝光监听滚动和可见 item,结合 adapter 数据 ID复用导致位置变化,要用业务主键去重。
Compose 点击/曝光Modifier 或业务组件封装声明式重组会导致重复注册。
Fragment 页面曝光生命周期 + 可见性 + ViewPager 状态onResume 不等于用户可见。

自动点击埋点可通过 View.OnClickListener 包装、Window callback、字节码插桩等方式实现。工程上要尽量避免破坏宿主原有监听器,不要在主线程做复杂序列化,并允许业务对敏感控件关闭自动采集。

三、Crash 前日志与上下文采集

Crash 前日志用于回答“崩溃前用户做了什么、页面状态是什么、关键接口是否失败”。它不应无限制采集,而应维护一个轻量环形缓冲区。

  1. 记录最近 N 条关键事件:页面进入/退出、点击、网络错误、业务状态变更、SDK 关键状态。
  2. Crash 发生时将缓冲区快照随崩溃报告上传或落盘等待下次上传。
  3. 日志字段脱敏,避免 token、手机号、身份证、精确定位等敏感数据进入崩溃上下文。
  4. 控制大小,避免 crash report 过大影响上传成功率。
RingBuffer(size=100)
  -> page_view: Home
  -> click: PayButton
  -> api_error: /order/create 500
  -> sdk_state: queue_size=42
  -> crash: attach last 100 breadcrumbs

这种设计对排查线上问题很有价值,但要明确它是诊断上下文,不是完整行为录像。

四、批量上传、采样与本地队列

数据采集 SDK 不能每条事件都立即上报,否则会放大网络、电量和宿主性能成本。常见策略是本地入队、批量上传、失败重试、采样控制。

策略说明目的
批量上传满 N 条、满 T 秒、App 退后台时触发降低请求数和耗电。
采样按用户、会话、事件或错误等级采样控制成本,保留统计代表性。
重试退避失败后指数退避,限制最大次数避免弱网下打爆网络。
优先级队列Crash/关键转化高优,普通点击低优保证关键事件及时性。
压缩gzip/zstd 等压缩批量 payload降低流量,但要控制 CPU 成本。

本地队列通常使用内存队列 + 持久化存储。写入要异步,并设置容量上限;超过上限时按优先级丢弃或采样,不能无限增长。进程退出、断网、弱网、服务端限流都要可恢复。

五、离线缓存与可靠性

离线缓存解决“用户断网或服务端不可用时事件不丢失”。但可靠性不是越强越好,因为无限保留会带来隐私、磁盘和上传风暴问题。

  1. 容量上限:按条数、字节数、天数三重限制。
  2. 过期清理:超过有效期的行为数据直接删除。
  3. 分片存储:避免单个文件过大导致读写失败。
  4. 幂等标识:每批事件带 batchId/eventId,服务端去重。
  5. 启动削峰:App 启动后延迟上传,避免和冷启动抢资源。
  6. 网络约束:按 Wi-Fi/蜂窝、前后台、低电量模式调整上传策略。

面试可以强调:采集 SDK 的“可靠”是有边界的可靠,要在数据完整性、用户体验、隐私合规和资源成本之间平衡。

六、隐私、合规与最小化采集

隐私是数据采集 SDK 的红线。设计时要把“采什么、为什么采、保存多久、给谁用、如何撤回”说清楚。

合规点工程做法
用户知情同意隐私协议同意前不启动非必要采集;提供开关和撤回路径。
最小化采集只采业务需要字段,敏感字段默认不采或脱敏。
数据脱敏手机号、邮箱、设备标识、定位等做哈希、截断或分级授权。
存储期限本地缓存和服务端数据设置过期清理。
权限隔离SDK 不主动申请无关权限,需要宿主显式授权。
跨境/共享按公司和地区政策做数据分区、审计和用途限制。

对于设备标识、IP、定位、剪贴板、通讯录等敏感能力,必须按法规和平台政策处理。SDK 不能绕过宿主隐私弹窗自行采集,也不能把业务传入的敏感参数原样写入日志。

七、宿主性能影响控制

宿主性能是埋点 SDK 能否长期接入的关键。优秀 SDK 要“默认轻量、可观测、可关闭”。

  • 主线程控制:事件组装、序列化、加密、压缩、磁盘写入、网络请求都应放到后台线程。
  • 内存控制:队列有上限,大字段截断,避免持有 Activity/View/Context 强引用。
  • CPU 控制:批量压缩和加密要限频;自动曝光计算要节流。
  • 网络控制:批量、采样、退避、前后台策略,避免高频小包。
  • 启动控制:延迟初始化非关键模块,不要阻塞 Application onCreate
  • 可观测性:SDK 自身要上报队列长度、丢弃数、上传耗时、失败原因和资源占用。

对宿主来说,SDK 应提供远程开关、采样率、黑名单、最大缓存、上传周期等配置,当线上异常时可以快速降级。


高频面试题

Q1:手动埋点和自动埋点怎么选? 核心业务转化用手动埋点,因为语义准确、参数可控;页面浏览、基础点击、曝光可用自动埋点补充覆盖。大型项目通常混合使用,并通过配置中心控制开关和采样。

Q2:曝光埋点怎么避免重复和误报? 用可见比例、停留时长、页面可见状态和业务 ID 去重。RecyclerView 不能只按 position 去重,因为 item 会复用和移动;Fragment/ViewPager 也不能只看 onResume,要结合真实可见性。

Q3:埋点 SDK 如何保证离线不丢数据? 事件先进入内存队列并异步持久化,网络可用时批量上传;失败使用退避重试,每批带幂等 ID。与此同时设置容量、天数和优先级上限,避免无限缓存影响隐私和磁盘。

Q4:Crash 前日志怎么设计? 维护轻量环形缓冲区,记录最近页面、点击、接口错误和 SDK 状态;Crash 时附带快照。日志要脱敏、限长、限量,不能把它做成完整行为录屏或敏感数据仓库。

Q5:如何控制埋点 SDK 对宿主性能的影响? 主线程只做轻量入队,序列化/压缩/加密/IO/网络放后台;队列和缓存有上限;曝光计算节流;上传批量化、采样和退避;SDK 自身提供监控和远程降级开关。

易错点 / 追问

  • 不要为了“数据完整”无限缓存,这会带来隐私、磁盘和上传风暴风险。
  • 不要在隐私协议同意前启动非必要采集,也不要默认采集敏感字段。
  • 不要在点击回调里同步写数据库或发网络请求,会直接伤害宿主性能。
  • 追问“自动埋点为什么不准”:控件复用、动态 UI、页面可见性和业务语义缺失都会导致误差。
  • 追问“采样会不会影响分析”:会,所以要按用户/会话稳定采样,并对关键错误或转化事件保留更高优先级。

主流第三方库

中级面试常问“X 库的原理“,尤其网络层和图片库。你做 SDK 偏底层,这些上层库要补实战理解。

一、OkHttp

Android 网络底座(Retrofit 默认基于它)。

  • 核心:拦截器链(责任链模式)。请求依次经过拦截器:
    1. 应用拦截器(开发者添加,如加 header、log)。
    2. RetryAndFollowUp(重试、重定向)。
    3. Bridge(补全 header、gzip)。
    4. Cache(缓存)。
    5. Connect(建立连接)。
    6. 网络拦截器(开发者添加)。
    7. CallServer(真正发送收发)。
  • 连接池(ConnectionPool):复用 TCP/HTTP 连接,常见默认配置是最多空闲连接约 5 个、保活约 5 分钟,但这是 OkHttp 版本/构造参数相关的可配置默认值。复用可减少 TCP/TLS 握手;HTTP/2 下同一连接还能多路复用多个 stream。
  • 缓存:基于 HTTP 缓存语义(Cache-Control、ETag),磁盘 LRU。
  • 分发器(Dispatcher):异步请求用线程池执行,控制最大并发和单 host 并发;常见默认值是 maxRequests=64maxRequestsPerHost=5,可通过属性调整。WebSocket 等特殊连接是否计入限制要看版本文档。

OkHttp 请求链路与调优

机制解决什么问题面试追问
Dispatcher控制异步 Call 何时进入执行队列,避免无限并发打爆线程/服务端并发不是越大越好,要结合服务端限流、HTTP/2、业务优先级调整。
ConnectionPool复用连接,减少 TCP/TLS 握手和慢启动空闲连接会占 fd/内存;移动端网络切换时要能重建连接。
HTTP/2 multiplexing一个 TCP 连接上并发多个 stream,减少同域名多连接开销仍受服务端支持、流控、丢包影响;TCP 层队头阻塞没有完全消失。
Cache利用 HTTP 语义减少网络请求只对可缓存响应生效;动态接口要和服务端协商 Cache-Control/ETag。
val client = OkHttpClient.Builder()
    .dispatcher(Dispatcher().apply {
        maxRequests = 32
        maxRequestsPerHost = 8
    })
    .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
    .build()

上面数字不是“最佳实践模板”,只是说明默认项可配置。真实项目要根据接口耗时、弱网、服务端限流、HTTP/2 支持和页面优先级做压测。

二、Retrofit

把 HTTP API 封装成 Kotlin/Java 接口。

  • 核心:动态代理(Proxy.newProxyInstance)create() 为接口生成代理对象,调用方法时被 InvocationHandler 拦截。
  • 解析方法上的注解(@GET/@POST/@Query/@Body)生成 ServiceMethod/RequestFactory,构造 OkHttp 的 Request。
  • CallAdapter:适配返回类型(Call、协程 suspend、Flow、RxJava)。协程版可理解为把 OkHttp Call 的异步回调桥接为挂起恢复,取消协程时也要取消底层 Call。
  • Converter:序列化转换(Gson/Moshi/kotlinx.serialization),负责请求体/响应体和业务对象之间的转换。
  • 协程用法:接口方法加 suspend 直接返回 data class,无需 Call.enqueue。

Retrofit 三层扩展点

扩展点负责内容常见坑
注解解析URL、HTTP method、Query/Header/Body 参数动态 URL、编码、空参数要按接口契约处理。
ConverterJSON/XML/Proto 与对象互转Kotlin 非空/默认值、混淆字段名、泛型类型擦除。
CallAdapterCall<T> 适配成 suspend/RxJava/Result 包装错误处理边界要统一:HTTP error、网络异常、解析异常不要混在一起。

面试回答结构:先说“接口不是实现类,Retrofit 用动态代理拦截方法调用”;再说“注解生成请求,Converter 负责数据转换,CallAdapter 负责返回类型”;最后补“底层网络仍交给 OkHttp”。

三、图片库(Glide / Coil)

  • 三级缓存:内存(ActiveResources 弱引用 + LruCache)→ 磁盘(DiskLruCache)→ 网络。
  • 生命周期绑定:Glide 官方 API 强调传入 Activity/Fragment 后会随宿主销毁自动清理请求。实现层面常见解释是注入无 UI 的 RequestManagerFragment/等价生命周期组件监听宿主生命周期;这是库内部实现,版本可能演进,面试要说“实现层面”。Coil 基于协程 + Lifecycle,更贴近 Kotlin 项目。
  • Bitmap 复用(BitmapPool):复用可复用的 Bitmap 内存,减少频繁分配和 GC;硬件位图、尺寸/配置不匹配时不一定能复用。
  • Coil:Kotlin 优先、协程实现、API 轻量,Compose 场景友好;Glide 生态成熟、缓存/变换/集成丰富,老项目更多。
  • 优化点:指定尺寸(override)避免加载过大图、合适的缓存策略、占位图。

图片加载机制拆解

机制Glide 侧重点Coil 对照注意点
生命周期Glide.with(activity/fragment) 绑定宿主,停止/销毁时暂停或清理请求ImageLoader + coroutine/lifecycle,Compose 集成自然不要在长生命周期 context 上加载短生命周期 View。
缓存 key通常由 model、尺寸、变换、选项、签名等共同决定同样需要区分 data/size/transformation“同 URL”不代表同一缓存条目,尺寸和变换会影响命中。
内存缓存ActiveResources + MemoryCachememoryCache内存命中快,但占用大;列表页要控制目标尺寸。
磁盘缓存原始数据/转换后资源策略可配diskCache磁盘命中省网络但有 IO;策略按图片来源选择。
BitmapPool复用 Bitmap 内存降低 GCbitmap pool/解码组件实现不同高版本硬件位图不可随意复用/修改。

RecyclerView 中的核心不是“手动取消所有请求”,而是每次绑定都给可复用 View 发起新请求或显式 clear。Glide 文档也提示,如果某个复用 View 不再加载图片,要 clear() 避免旧请求回调把旧图设置回来。

四、序列化库

  • Gson:反射解析,易用但运行时反射有性能开销、不感知 Kotlin 默认值/空安全(可能给非空字段塞 null)。
  • Moshi:Square 出品,支持 codegen(编译期生成 adapter,无反射),对 Kotlin 友好。
  • kotlinx.serialization:Kotlin 官方,编译期插件生成,类型安全、多平台、无反射,新项目推荐。@Serializable 注解。
机制适合场景面试注意
Gson运行时反射为主老项目、简单 Java modelKotlin 非空/默认值坑多,混淆要保留字段/注解。
Moshi反射或 codegen adapterKotlin + Square 生态codegen 性能和类型安全更好,但要配置 KSP/KAPT。
kotlinx.serialization编译期插件生成 serializerKotlin/多平台/Compose 新项目需要 @Serializable,与 Retrofit 配合要加 converter。

五、其他常见库

  • EventBus:发布订阅解耦组件通信。现代趋势用 Flow/SharedFlow 替代(类型安全、可控生命周期)。
  • 依赖注入:Hilt(主流,见 07)、Koin(纯 Kotlin、运行时解析、轻量但无编译期校验)。DI 的核心价值不是“少写 new”,而是把对象创建、作用域和替换点显式化,方便测试和模块解耦。
  • 协程之外的响应式:RxJava(老项目多,操作符丰富但学习曲线陡),新项目协程 + Flow 已基本替代。
模式/库解决的问题现代替代/边界
EventBus跨组件发布订阅,调用方不直接依赖接收方容易隐藏数据流、难追踪生命周期;页面内优先 LiveData/StateFlow/SharedFlow,跨模块事件要定义清晰契约。
Hilt编译期 DI、作用域管理、Android 组件注入适合中大型项目;需要理解 Component/Scope,不要滥用单例。
KoinDSL 声明依赖,上手快运行时解析,错误可能到运行时才暴露;适合小项目或快速迭代。
RxJava复杂异步流组合老项目维护常见;新代码可优先协程 Flow,但迁移要逐步做。

高频面试题

Q1:OkHttp 的拦截器链是什么设计模式?顺序? 责任链模式。顺序:应用拦截器 → RetryAndFollowUp → Bridge → Cache → Connect → 网络拦截器 → CallServer。每个拦截器处理后调 chain.proceed 传递。

Q2:应用拦截器和网络拦截器区别? 应用拦截器在最外层,只调用一次,能看到原始请求(重定向前),不一定有网络;网络拦截器在 Connect 之后,可能多次调用(重定向/重试),能看到真实网络请求和中间响应。

Q3:Retrofit 的核心原理? 动态代理。create() 用 Proxy 为接口生成代理对象,调用方法时 InvocationHandler 拦截,解析注解构造 OkHttp Request,经 CallAdapter 适配返回类型(协程/Call/Flow),Converter 做序列化。

Q4:Retrofit 怎么支持协程的? 方法加 suspend 后,Retrofit 内部把 OkHttp Call 的异步回调适配成协程挂起恢复,常见实现会通过类似 suspendCancellableCoroutine 的方式处理取消,直接返回结果或 Response,无需手动 enqueue。

Q5:Glide 如何感知生命周期防止泄漏? Glide.with(activity/fragment) 返回与宿主生命周期绑定的 RequestManager,宿主停止/销毁时可暂停或清理请求。实现层面常见解释是注入无 UI 的 RequestManagerFragment/生命周期组件监听回调,但这是库内部实现细节,回答时不要说成永久 API 契约。

Q6:Glide 三级缓存? 内存(活动资源弱引用 + LruCache)、磁盘(DiskLruCache,可缓存原图或转换后)、网络。命中内存最快,逐级回退。

Q7:Gson 在 Kotlin 里有什么坑? Gson 用反射、不走构造函数,可能绕过 Kotlin 的非空检查和默认值,给非空字段赋 null 导致后续 NPE。建议用 Moshi 或 kotlinx.serialization。

Android 安全与逆向 ☆

这一篇是你最大的差异化武器。 你做设备指纹/风控 SDK,签名、加固、抓包对抗、反调试、反 Hook 是你的本行,而 99% 的应用开发者答不出。

面试策略:投支付/金融/出海/风控相关团队时,主动把话题引到这里。即便普通应用岗,聊到安全也能瞬间拉开差距。这是你“从底层来做应用“叙事的最强证据。

一、APK 签名机制

签名用于校验 APK 完整性来源可信(防篡改、防二次打包)。

方案原理特点
v1(JAR 签名)对每个文件摘要写入 MANIFEST,签名 META-INF慢、不保护 ZIP 元数据、易被 hook
v2(APK 签名块)对整个 APK 做摘要签名,插入签名块快、保护整体、防篡改强
v3v2 基础上支持密钥轮转(换签名密钥)可升级密钥
v4基于 Merkle 树,增量签名,配合 ADB 增量安装支持流式校验
  • 签名校验流程:安装时系统校验签名;App 运行时也可自校验签名防二次打包。
  • 面试可讲:v1 的弱点(可往 META-INF 塞文件不破坏签名,渠道包/恶意注入利用过),v2 怎么解决。

二、加固与脱壳

  • 加固目的:防止反编译拿到源码逻辑(DEX)、保护 so、防调试。
  • DEX 加固演进(防御/面试视角):
代际核心机制保护目标主要边界
整体加固(一代)APK 中 DEX 加密,启动后由壳代码解密并交给自定义 ClassLoader 加载防静态反编译直接看到业务代码运行时必须出现可执行代码形态,对动态分析防护有限
方法抽取(二代)方法体被抽离/替换为空实现或壳逻辑,运行时按需还原关键方法降低整包还原收益,保护核心函数复杂度、兼容性和性能开销更高,仍要关注崩溃率
VMP / 指令虚拟化(三代)把部分 Dalvik/native 指令转换成私有虚拟指令,由自定义解释器执行提高理解核心算法的成本体积、性能、可维护性成本最高,通常只保护高价值逻辑
  • 脱壳对抗的原则比较:从防守角度看,一代主要防静态读取,二代把攻击面缩到关键方法还原时机,三代把“读代码”变成“理解私有虚拟机”。面试只讲原理、成本和风险,不提供可执行流程。
  • so 加固:字符串加密、符号裁剪/隐藏、控制流混淆、完整性校验、必要时对极少数核心算法做 native 侧虚拟化。注意 native 保护会引入兼容性、性能、可观测性问题,上线前要做灰度和崩溃监控。
  • 你的发言点:讲你在 SDK 里怎么做 so 保护、防止采集逻辑被逆向,这是真实经验。

三、混淆

  • R8 / ProGuard:重命名类/方法/字段(a.a())、删除无用代码、内联、优化。keep 规则保留反射/JNI 入口。
  • 资源混淆:AndResGuard,缩短资源路径减体积 + 增加逆向难度。
  • 控制流混淆 / 字符串加密:打乱执行流、加密敏感字符串(API key、URL),运行时解密。
  • 注意:JNI 注册的 native 方法、反射调用的类、序列化字段要 keep,否则崩溃。

四、抓包与防抓包(你的实战领域)

  • 抓包原理:Charles/Fiddler/mitmproxy 装根证书做中间人,解密 HTTPS;Wireshark 抓底层包。
  • 防御手段:
    • SSL Pinning(证书锁定):App 内置服务端证书/公钥指纹,只信任它,系统装的抓包根证书无效。
    • 双向认证(mTLS):服务端也校验客户端证书。
    • 代理检测:检测系统代理设置、VPN。
    • 抓包工具检测:检测 Charles/Fiddler 证书、特征。
  • Pinning 的边界:Pinning 能显著提高 MITM 成本,但不能作为单点方案。高安全 App 会把校验、参数签名、重放防护、完整性校验和风控策略组合使用,并把关键决策放到服务端复核。
  • 关键参数加密 / 签名:即便被抓包,请求参数加密 + 签名(sign)防篡改和重放。

五、反调试与反 Hook

  • 定位:反调试/反 Hook 不是为了单点阻止分析,而是为高价值动作提供环境风险信号,辅助服务端做分级处置(放行、二次验证、降级、拒绝)。
检测方向原理直觉局限与边界防御用法
TracerPid / 调试状态读取进程状态,判断是否存在调试附加迹象系统版本、权限、工具行为会影响可靠性;单点信号容易误判作为低成本信号,不单独决定封禁
ptrace 占位让进程进入“已被跟踪”状态,提高再次附加成本兼容性和稳定性风险较高,不同内核/ROM 表现可能不同仅用于高风险 SDK/核心逻辑,需灰度验证
时间差/断点异常单步调试会放大关键路径耗时设备性能、GC、调度抖动也会造成耗时波动结合统计阈值和多次采样,避免一次命中即处置
Frida/Xposed/注入痕迹观察进程模块、类加载、堆栈或运行环境异常工具版本和加载方式会变化,特征库需要维护只输出风险等级,与完整性/行为风控合并判断
inline hook 完整性对关键 native 函数入口或代码段做完整性校验编译优化、热补丁、自更新都可能改变代码布局对少量关键函数做校验,异常时降级或上报
  • 反 Hook:关注“是否有运行时插桩/代理/代码段异常”的风险信号,而不是背具体工具特征。工具特征随版本变化,面试要强调多信号融合 + 服务端决策 + 误伤控制
  • 完整性校验:运行时校验 APK 签名、DEX/so 的 CRC,发现篡改就退出/降级。
  • 官方安全能力补充:可结合 Play Integrity API / Play Protect / App Access Risk 等信号判断“是否为可信 App、可信设备、低风险环境”。这些能力也不是唯一依据,更适合接入服务端风控策略,并对高价值动作按风险分层处理。
  • 这是你的主场:这些正是设备指纹/风控 SDK 的核心对抗,你能讲实战细节,面试官会眼前一亮。

六、运行环境检测(风控核心)

  • Root 检测:su 文件、Magisk、busybox、可写系统分区、危险属性。
  • 模拟器检测:特征文件、CPU 架构(x86)、传感器缺失、IMEI/型号特征、qemu 痕迹。
  • Hook 框架检测:见上(Frida/Xposed)。
  • 多开/虚拟环境检测:包路径异常、进程名、/proc 特征。
  • 设备指纹:综合硬件/系统/行为特征生成唯一标识——你的本职工作,可深入讲采集维度、稳定性、对抗篡改。

七、数据安全

  • 敏感数据存储:用 EncryptedSharedPreferences / KeyStore 加密,不明文存 token。
  • Android Keystore:密钥存于硬件安全模块(TEE/StrongBox),不出安全区。
  • 传输安全:HTTPS + Pinning + 参数加密 + 签名防重放。

高频面试题

Q1:APK v1 和 v2 签名区别?v1 有什么漏洞? v1 是 JAR 签名,逐文件摘要、不保护 ZIP 元数据,可往 META-INF 加文件不破坏签名(被渠道包/注入利用)。v2 对整个 APK 签名块做校验,保护整体、更快、防篡改强。v3 加密钥轮转,v4 基于 Merkle 树支持增量。

Q2:App 加固有哪几代?VMP 为什么最难逆向? 一代整体 DEX 加密、二代方法抽取/按需还原、三代 VMP 指令虚拟化。VMP 把标准指令语义迁移到私有虚拟机里,分析者即使看到运行过程也需要理解解释器和私有指令语义,成本最高;但它也有性能、体积、兼容性成本,通常只保护核心逻辑。

Q3:什么是 SSL Pinning?它的边界是什么? 客户端只信任内置的服务端证书或公钥指纹,降低伪造 CA/MITM 抓包风险。边界是客户端逻辑始终可能被动态分析或篡改,所以高安全场景要组合 native 加固、完整性校验、参数签名、重放防护和服务端风控复核,不能只靠 Pinning 一个点。

Q4:怎么检测 App 被 Frida hook 了? 从防御视角看,可以收集运行时注入、模块加载、堆栈、代码段完整性、异常调试状态等风险信号;但具体特征会随工具版本和加载方式变化,不能当最终结论。工程上应多信号融合、服务端分级处置,并控制误伤。

Q5:怎么做反调试? 常见方向包括调试状态、关键路径时间异常、运行环境完整性和 native 代码段完整性等。回答重点不是背某个检测点,而是说明它们都有版本/ROM/工具差异和误判风险,应作为风控信号参与分级决策,而不是单点封禁。

Q6:设备指纹怎么提升唯一性和稳定性?(你的本职) 综合多维特征(硬件、系统、网络、行为)加权生成,容忍部分特征变化(系统升级/重置),并结合风险信号对抗篡改和模拟。讲清采集维度、降级策略、对抗思路即可——这是你的真实经验,放开讲。

Q7:敏感数据怎么安全存储? 用 Android Keystore 管理密钥(存 TEE/StrongBox 硬件区),用 EncryptedSharedPreferences 加密存储,绝不明文存 token/密钥,传输再叠加 HTTPS + Pinning。

进阶补充:逆向工具链、Native 分析与面试表达边界

常见工具链定位

工具主要用途
jadx / jadx-guiJava/Kotlin 层反编译阅读
apktool资源、Manifest、smali 处理
Frida运行时 hook、参数/返回值观察
Xposed / LSPosed模块化 hook 框架
IDA / Ghidranative so 静态分析
objection基于 Frida 的移动安全辅助工具

Native so 分析要点

关注 ELF 结构、导出符号、JNI_OnLoad、RegisterNatives、字符串/xref、PLT/GOT、反调试和混淆。符号被 strip 后,可结合字符串、常量、调用图和动态 trace 恢复语义。

OWASP Mobile Top 10 视角

面试中可以从不安全存储、不安全通信、认证授权、代码篡改、逆向风险、敏感信息泄露等角度回答安全设计。

设备可信与服务端风控

客户端检测只能提高攻击成本,不能单独作为信任根。更稳妥的设计是客户端采集信号 + 服务端风控决策 + 灰度策略 + 异常监控。

面试表达边界

讲原理、风险、检测、防护和合规边界;避免细讲绕过步骤、攻击脚本和可直接滥用的操作流程。

**追问:**为什么不能只靠客户端反调试?因为攻击者控制运行环境,客户端防护只能增加成本,关键决策要在服务端完成。

移动安全防护体系

移动安全的成熟回答不是“客户端能绝对防住攻击”,而是“客户端提高成本、服务端做最终风控、工程上控制误伤和可用性”。本章只从防御、检测、加固和风险联动角度讲,不提供绕过脚本或攻击步骤。

一、防护体系的分层模型

移动端运行在用户可控设备上,不能把客户端当绝对可信根。合理体系要分层:

层级目标常见手段边界
传输安全降低中间人和篡改风险HTTPS、SSL Pinning、请求签名、重放防护客户端逻辑可被分析,不能单点依赖
应用完整性发现二次打包/篡改签名校验、DEX/so 完整性、R8/ProGuard需兼容热修复、渠道包和灰度
环境风险识别高风险设备root/hook/emulator/Frida 检测、设备可信信号单点误判高,特征会变化
代码保护提高逆向成本混淆、字符串加密、native obfuscation、shell/packing性能、稳定性、可观测性成本
服务端风控最终决策风险评分、设备指纹、行为模型、二次验证依赖数据质量和策略治理

面试总原则:客户端防护用于“采集信号 + 提高成本 + 延迟攻击”,高价值决策必须回到服务端风控。

二、root / hook / emulator / Frida 检测

环境检测的目标不是“发现一个特征就封禁”,而是形成风险画像。常见信号包括:

  • Root 检测:su/Magisk 痕迹、危险系统属性、可写系统分区、异常 SELinux 状态、敏感目录权限。
  • Hook 检测:可疑框架痕迹、异常类加载、调用栈异常、运行时注入迹象、关键函数入口完整性异常。
  • 模拟器检测:硬件/传感器缺失、qemu/虚拟化特征、CPU/ABI、设备型号和系统属性组合异常。
  • Frida 检测:进程、端口、模块、线程名、内存映射、行为时序等风险信号的组合观察。

防御表达要强调限制:

  1. 特征会随工具版本变化,需要远端配置和灰度。
  2. 厂商 ROM、测试设备、无障碍工具、企业 MDM 可能造成误判。
  3. 检测结果应是风险分,而不是唯一封禁依据。
  4. 高风险动作可触发二次验证、降级、延迟处理或服务端复核。

三、SSL Pinning 与传输防护

SSL Pinning 是客户端内置服务端证书或公钥指纹,连接时只信任预期身份,降低用户安装恶意根证书后的 MITM 风险。

传输安全组合拳:
HTTPS/TLS
  + SSL Pinning(证书或公钥)
  + 请求参数签名(timestamp + nonce + body digest)
  + 重放防护(服务端校验 nonce/时间窗)
  + 高风险接口二次校验(设备风险 + 行为风险)

工程注意:

  • Pinning 要支持证书轮换,通常 pin 公钥或准备备份 pin。
  • 失败策略要区分网络异常、证书异常、系统时间错误和灰度问题。
  • 不要把密钥硬编码当唯一保护,客户端 secret 只能提高成本。
  • 与 OkHttp/Network Security Config/自研网络层配合时要有测试覆盖。

四、R8/ProGuard 与 Java/Kotlin 层加固

R8/ProGuard 的基础能力是压缩、优化、混淆,安全收益是增加静态分析成本并减少可读语义。

  • 重命名:类、方法、字段改短名,降低可读性。
  • 优化/内联:删除无用代码、调整调用结构,增加还原成本。
  • keep 规则:反射、序列化、JNI、路由、依赖注入入口必须保留,否则线上崩溃。
  • mapping 管理:mapping 是还原崩溃栈的关键敏感资产,要按版本安全保存。

Kotlin 项目还要注意 data class、默认参数、协程状态机、序列化字段、Compose/Hilt/Room 生成代码的 keep 要求。安全和稳定必须平衡,不能为了“混得更狠”破坏运行时反射/JNI 入口。

五、native obfuscation、shell/packing 与完整性检查

Native 层常用于保护高价值算法、设备指纹采集、加解密和环境检测。常见防护包括符号隐藏、字符串加密、控制流混淆、关键代码段完整性校验和少量核心逻辑 native 化。

shell/packing(加壳/壳保护)的概念是把 DEX/so 或关键方法以加密、抽取、虚拟化等方式保护,运行时再加载或还原。面试只需讲目标、成本和边界:

  • 目标:提高静态反编译和批量篡改成本。
  • 成本:启动耗时、兼容性、崩溃定位、包体积、灰度复杂度。
  • 边界:代码运行时总要以某种形式执行,客户端无法做到绝对不可分析。

完整性检查常覆盖 APK 签名、安装来源、DEX/资源摘要、so 代码段、关键配置和运行时内存异常。对热修复、插件化、渠道包要设计白名单和版本策略,否则容易误伤。

六、device trust 与服务端风控联动

设备可信可以结合 Play Integrity API、厂商安全能力、设备指纹、账号行为、网络环境和业务风险事件。关键是把客户端信号传给服务端做统一决策。

客户端采集:
  设备指纹 + 完整性 + root/hook/emulator + 网络风险 + 行为摘要
        ↓
服务端风控:
  规则/模型评分 + 账号历史 + 交易/登录上下文 + 黑白名单
        ↓
分级处置:
  放行 / 降级 / 二次验证 / 延迟审核 / 拒绝 / 人工复核

服务端联动的优势:

  • 风控策略可动态调整,不依赖发版。
  • 能结合账号、设备、IP、行为、交易等多维数据。
  • 可以做灰度、AB、阈值回滚和误伤监控。
  • 客户端只上报必要风险信号,避免暴露完整策略细节。

七、安全工程化与合规边界

安全防护要纳入工程流程,不是临上线才加检测点。

  • 威胁建模:识别登录、支付、设备绑定、优惠券、风控 SDK 等高价值资产。
  • 分级防护:普通页面不应引入高成本防护,核心链路才做更强检测和加固。
  • 可观测性:记录风险命中、误伤、崩溃、性能影响和策略版本。
  • 隐私合规:设备指纹和环境信号要遵守最小必要、告知同意、用途限定和数据安全要求。
  • 应急响应:证书轮换、密钥泄露、加固兼容性事故、误封回滚都要有预案。

面试表达边界:讲检测、防护、限制、误伤控制和服务端风控,不要讲绕过流程、攻击脚本、hook 代码或可直接复现的利用步骤。


高频面试题

Q1:客户端 root/hook/Frida 检测能完全防住攻击吗? 不能。客户端处于用户可控环境,检测只能提高成本并提供风险信号。工程上应多信号融合、服务端风控决策、灰度策略和误伤监控,不能单点封禁。

Q2:SSL Pinning 的作用和边界是什么? 作用是降低伪造 CA、中间人抓包和传输篡改风险。边界是客户端校验逻辑仍可能被分析或篡改,所以要结合请求签名、重放防护、完整性校验和服务端风险联动。

Q3:R8/ProGuard 和加壳有什么区别? R8/ProGuard 主要做压缩、优化、重命名混淆,属于基础构建能力;加壳/packing 更强调加密、抽取、运行时加载或虚拟化,保护更强但兼容性、性能和排障成本更高。

Q4:为什么安全决策要放服务端? 因为客户端环境不可完全可信,本地策略容易被分析和篡改。服务端能结合账号、设备、行为、IP、历史风险等多维信息,动态调整风控策略并控制误伤。

Q5:怎么在面试中安全地讲 Frida 检测? 只讲防御视角:运行时注入、模块、线程、堆栈、内存映射、代码完整性等风险信号的组合;强调特征变化、误判、服务端分级处置。不要提供 hook 脚本或绕过步骤。

易错点 / 追问

  • 不要承诺“客户端绝对安全”,正确说法是提高攻击成本并联动服务端风控。
  • 不要把 root、hook、emulator 任一单点命中当封禁依据,要考虑误伤和灰度。
  • SSL Pinning 要考虑证书轮换和失败策略,否则可能造成大面积不可用。
  • 混淆/加固要和 crash 符号化、mapping 管理、性能监控一起设计。
  • 设备指纹和环境检测涉及隐私合规,必须遵守最小必要和用途限定。

NDK 与 JNI ☆

这是你的强项,也是你的差异化武器。 一般应用开发者只会调用别人的 so,你能独立写 SDK。这一篇帮你把这份功底结构化成面试语言,在三面/技术亮点环节碾压竞争者。面试讲这块时要主动、自信、深入。

一、JNI 基础与类型映射

JNI(Java Native Interface)是 Java/Kotlin 与 C/C++ 互调的桥梁。

类型映射

Java 类型JNI 类型签名
booleanjbooleanZ
bytejbyteB
charjcharC
intjintI
longjlongJ
floatjfloatF
doublejdoubleD
ObjectjobjectL 全限定名;
StringjstringLjava/lang/String;
int[]jintArray[I
voidvoidV

方法签名

格式 (参数)返回值,如 (ILjava/lang/String;)V 表示 (int, String) -> void。用 javap -s 可查看签名。

二、引用管理(高频深挖)

JNI 有三种引用,管理不当会泄漏或崩溃:

  • 局部引用(Local Reference):默认创建的引用(如 FindClass、NewObject 返回值)。方法返回后自动释放,不能跨方法/线程缓存。JNI 规范保证进入 native 方法前至少可创建 16 个局部引用;ART/设备上的引用表容量和扩展行为可能不同,不要把“512”当通用契约。循环中大量创建不释放仍可能 ReferenceTable overflow —— 要及时 DeleteLocalRef,或用 EnsureLocalCapacity / PushLocalFrame 管理容量。
  • 全局引用(Global Reference):NewGlobalRef 创建,跨方法/线程有效,必须手动 DeleteGlobalRef 否则泄漏。用于缓存常用 jclass、jobject。
  • 弱全局引用(Weak Global Reference):NewWeakGlobalRef,不阻止 GC。使用前推荐通过 NewLocalRef 提升成强局部引用,若返回 NULL 说明对象已回收;单纯 IsSameObject(ref, NULL) 只能做瞬时判断,之后仍可能被 GC。

实战经验(面试可讲):缓存 jclass/jmethodID 用全局引用提升性能;在 native 循环处理数组时注意局部引用释放,避免引用表溢出。

引用类型生命周期典型用途易错点
Local当前 native 调用/当前 local frame临时对象、字符串、数组元素循环中不删会堆积;不能保存到全局变量或跨线程用。
Global手动删除前有效缓存 jclass、跨线程回调对象忘记 DeleteGlobalRef 会泄漏 Java 对象。
Weak Global对象未被 GC 前可观察缓存可被回收的 Java owner使用前要提升为强引用,不要假设检查后就安全。
// 批量处理对象数组:用 local frame 一次释放本轮临时引用
for (jsize i = 0; i < count; i += 64) {
    if (env->PushLocalFrame(64) < 0) return; // OOM pending
    for (jsize j = i; j < count && j < i + 64; ++j) {
        jobject item = env->GetObjectArrayElement(array, j);
        // ... 使用 item ...
    }
    env->PopLocalFrame(nullptr); // 释放本 frame 内局部引用
}

三、JNIEnv 与 JavaVM、线程

  • JavaVM:进程唯一,代表整个虚拟机,可跨线程共享
  • JNIEnv:线程私有,每个线程一份,不能跨线程缓存使用。
  • native 线程访问 JVM:在 native 自己创建的线程里要调用 Java,必须先 JavaVM->AttachCurrentThread 拿到该线程的 JNIEnv,线程退出前 DetachCurrentThread,否则可能造成线程相关资源泄漏。由 JVM 调进 native 的线程已经 attached,通常不应随意 detach。
  • 拿 JavaVM 的方式:JNI_OnLoad 回调里保存全局 JavaVM 指针。
JavaVM* g_vm;
jint JNI_OnLoad(JavaVM* vm, void*) {
    g_vm = vm;                       // 保存,供后续线程 attach
    return JNI_VERSION_1_6;
}
// native 线程中:
JNIEnv* env;
g_vm->AttachCurrentThread(&env, nullptr);
// ... 调用 Java ...
g_vm->DetachCurrentThread();

更稳的工程写法是 RAII 包一层,确保异常路径/提前 return 也会 detach:

class ScopedJniEnv {
public:
    explicit ScopedJniEnv(JavaVM* vm) : vm_(vm) {
        if (vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_EDETACHED) {
            attached_ = (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK);
        }
    }
    ~ScopedJniEnv() {
        if (attached_) vm_->DetachCurrentThread();
    }
    JNIEnv* get() const { return env_; }
private:
    JavaVM* vm_ = nullptr;
    JNIEnv* env_ = nullptr;
    bool attached_ = false;
};

四、静态注册 vs 动态注册

  • 静态注册:按命名约定 Java_包名_类名_方法名 命名 native 函数,运行时按名查找。缺点:函数名长、首次调用需查找、暴露包名(易逆向)。
  • 动态注册:在 JNI_OnLoad 里用 RegisterNatives 把 Java 方法和 native 函数指针映射注册。优点:函数名自由、效率高、隐藏映射关系(更安全,SDK/加固常用)
static JNINativeMethod methods[] = {
    {"nativeFoo", "(I)I", (void*)foo_impl},
};
env->RegisterNatives(clazz, methods, 1);

面试可讲:做 SDK/对抗时用动态注册隐藏 native 入口,增加逆向难度——这是你风控背景的天然加分点。

维度静态注册动态注册
绑定方式函数名按 Java_package_Class_method 约定匹配RegisterNatives 显式绑定 Java 方法名/签名/函数指针
可维护性简单直观,Demo/少量方法够用集中注册,重构包名时更可控
性能/加载首次调用按名解析JNI_OnLoad 一次注册,调用路径更直接
安全/逆向导出符号暴露 Java 包名和方法可隐藏 C++ 函数名,但不能替代混淆/加固/权限校验

动态注册要检查 FindClassRegisterNatives 返回值和 pending exception;注册失败如果继续运行,后续 Java 调 native 会变成难定位的 UnsatisfiedLinkError

五、native 调用 Java 与异常

  • 调用流程:FindClass → GetMethodID/GetStaticMethodID → CallXxxMethod
  • 异常处理:JNI 调用后若 Java 抛异常,native 不会中断,必须 ExceptionCheck/ExceptionOccurred 检查并 ExceptionClear,否则后续 JNI 调用行为未定义。

六、native crash 捕获(你的强项可深讲)

  • native 崩溃(SIGSEGV/SIGABRT)不走 Java 的 try/catch,默认直接挂掉进程。
  • 捕获方式:注册信号处理器(sigaction) 捕获 SIGSEGV、SIGABRT、SIGBUS 等,在 handler 里 dump 寄存器、调用栈(unwind)、maps。
  • 成熟方案:Google Breakpad / Crashpad、字节的 xCrash。它们生成 minidump,符号化后定位。
  • 难点:信号处理器中只能调用 async-signal-safe 函数;栈展开(unwind)需结合 .eh_frame/.ARM.exidx;符号化需保留带符号 so。
  • 工程流程:线上包可 strip 符号减体积,但 CI/符号服务器必须保存同 build-id 的未裁剪 so;拿到 tombstone/minidump 后用 addr2linendk-stack 或 Breakpad/Crashpad 工具映射到函数、文件和行号。

七、CMake / ABI / so

  • 构建:CMakeLists.txt + externalNativeBuild;add_librarytarget_link_librariesfind_library(log)
  • ABI:arm64-v8a(主流)、armeabi-v7a(老设备)、x86/x86_64(模拟器)。abiFilters 控制打包哪些,通常只留 arm64-v8a 减体积。
  • so 加载:System.loadLibrary("name") 加载 libname.so,触发 JNI_OnLoad
  • 现代 C++:RAII(资源随对象析构释放)、智能指针(unique_ptr 独占 / shared_ptr 共享 / weak_ptr 防循环引用)、移动语义(std::move 转移资源所有权)。NDK 用这些写出更安全的 native 代码。

现代 C++ 在 NDK 里的落地

场景推荐写法为什么
管理 native buffer/句柄std::unique_ptr + 自定义 deleter,或封装 RAII class避免异常/return 分支漏 free/close/DeleteGlobalRef
多模块共享对象明确所有权后再用 std::shared_ptr引用计数有成本,还可能循环引用;默认优先 unique_ptr
回调持有 Java ownerC++ 侧弱引用 + Java/Kotlin 生命周期取消避免 native 长生命周期对象强持 Activity。
大对象返回/转移移动构造/移动赋值,必要时 std::move减少拷贝,但 moved-from 对象只应处于可析构/可重新赋值状态。
struct GlobalRefDeleter {
    JavaVM* vm;
    void operator()(jobject ref) const {
        if (!ref) return;
        ScopedJniEnv scoped(vm);
        scoped.get()->DeleteGlobalRef(ref);
    }
};

using UniqueGlobalRef = std::unique_ptr<_jobject, GlobalRefDeleter>;

移动语义陷阱:std::move 只是强制把对象当右值,真正移动取决于类型是否实现移动构造/赋值;对还要继续读取的对象不要随手 move。JNI 场景中尤其要避免把 JNIEnv*、局部引用这类线程/生命周期受限资源包装后跨线程 move。


高频面试题

Q1:JNI 的局部引用和全局引用区别?什么时候会引用表溢出? 局部引用方法返回或 local frame 弹出后释放、不可跨线程;规范保证至少 16 个局部引用,具体容量/扩展行为看 VM 实现,不要死记 512。全局引用需手动 DeleteGlobalRef、可跨线程缓存。循环中大量创建局部引用不及时 DeleteLocalRef/PushLocalFrame,就可能触发 ReferenceTable overflow。

Q2:JNIEnv 和 JavaVM 区别?native 线程怎么调 Java? JavaVM 进程唯一可共享;JNIEnv 线程私有不可跨线程。native 自建线程要调 Java,需先 AttachCurrentThread 拿当前线程的 JNIEnv,线程退出前 DetachCurrentThread;从 Java 调进来的线程已经 attached,不要误 detach。

Q3:静态注册和动态注册区别?为什么 SDK 喜欢动态注册? 静态按 Java_包名_方法名 命名约定查找;动态用 RegisterNatives 在 JNI_OnLoad 注册函数指针映射。动态注册函数名自由、效率高、隐藏入口映射,增加逆向难度,适合 SDK/加固。

Q4:native 崩溃为什么 Java 的 try/catch 抓不到?怎么捕获? native 崩溃是 OS 信号(SIGSEGV 等),不经过 JVM 异常机制。需注册 sigaction 信号处理器捕获,在 handler 中 dump 栈和寄存器,用 Breakpad/xCrash 生成 minidump 后符号化定位。

Q5:JNI 调用 Java 方法后为什么要检查异常? JNI 调用若使 Java 抛异常,native 代码不会自动中断,异常处于 pending 状态,此时继续调用其他 JNI 函数行为未定义。必须 ExceptionCheck 后 ExceptionClear 处理。

Q6:为什么大多 App 只打包 arm64-v8a? 现代设备基本都是 arm64,只留 arm64-v8a 可显著减小 so 体积;armeabi-v7a 仅老设备需要。按 ABI 拆分 + App Bundle 动态下发是体积优化常用手段。

Q7:智能指针怎么选?(现代 C++) 独占所有权用 unique_ptr;确实有共享所有权才用 shared_ptr(引用计数有成本);打破 shared_ptr 循环引用用 weak_ptr。默认优先 unique_ptr 和 RAII,把 JNI 全局引用、fd、native buffer 等资源包装进析构函数,避免裸 new/delete 和异常路径泄漏。

NDK Native 调试与 Crash 定位 ☆

Native 能力是你的差异化强项,但面试要讲成“稳定性与诊断能力”,不是炫底层。 本章把 JNI 引用、线程 attach、RegisterNatives、so 加载、ABI/CMake、tombstone、addr2line、ndk-stack、RAII 和 native 泄漏串成一套线上排障语言。

一、JNI 引用与线程模型复盘

Native 崩溃和泄漏里很大一部分来自 JNI 生命周期误用。引用类型要和生命周期严格匹配。

引用类型生命周期常见用途风险
Local Reference当前 native 调用或 local frame临时对象、字符串、数组元素循环中不释放会引用表溢出;不能跨线程缓存。
Global ReferenceDeleteGlobalRef 前有效缓存 jclass、跨线程回调对象忘删会泄漏 Java 对象和 Activity。
Weak Global Reference对象未被 GC 前可观察缓存 owner、监听对象使用前要提升为 Local,否则可能已被回收。

JNIEnv* 是线程私有的,不能跨线程保存;JavaVM* 进程唯一,可以保存后在 native 线程里 AttachCurrentThread 获取当前线程的 JNIEnv*,线程结束前 DetachCurrentThread

class ScopedAttach {
public:
    explicit ScopedAttach(JavaVM* vm) : vm_(vm) {
        if (vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_EDETACHED) {
            attached_ = (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK);
        }
    }
    ~ScopedAttach() {
        if (attached_) vm_->DetachCurrentThread();
    }
    JNIEnv* env() const { return env_; }
private:
    JavaVM* vm_ = nullptr;
    JNIEnv* env_ = nullptr;
    bool attached_ = false;
};

面试高频追问是“为什么不能保存 JNIEnv 到全局变量”:因为它绑定当前线程,换线程使用会导致未定义行为甚至崩溃。

二、RegisterNatives、so 加载与初始化顺序

Native 方法注册分静态注册和动态注册。工程 SDK 更常用 RegisterNatives,在 JNI_OnLoad 中集中绑定 Java 方法和 C/C++ 函数。

环节关键点常见问题
System.loadLibrary加载 libxxx.so,触发 JNI_OnLoad库名不匹配、ABI 缺失、依赖 so 未找到。
JNI_OnLoad保存 JavaVM、查找类、注册 native 方法FindClass 失败、签名错误、pending exception 未处理。
RegisterNatives方法名 + 签名 + 函数指针绑定混淆后未 keep、签名写错、返回值未检查。
初始化顺序Java 层先加载 so 再调用 native多 so 依赖顺序、懒加载导致首次调用失败。

UnsatisfiedLinkError 常见原因包括:没有打包对应 ABI 的 so、System.loadLibrary 名称错误、native 方法签名不匹配、R8 混淆改了 Java native 方法名、依赖库加载失败。定位时先看 logcat 的 linker 错误和 ABI 路径,再查 JNI_OnLoad 返回值和注册日志。

三、ABI、CMake 与构建产物

ABI 决定 so 能在哪类 CPU 上运行。现代 Android 主流是 arm64-v8a,但模拟器、老设备和第三方 SDK 可能需要其他 ABI。

cmake_minimum_required(VERSION 3.22)
project(native_demo)

add_library(native_demo SHARED native_demo.cpp)
find_library(log-lib log)
target_link_libraries(native_demo ${log-lib})
维度面试回答
ABI 选择通常优先 arm64-v8a;是否保留 armeabi-v7a/x86 看用户设备和 SDK 依赖。
符号保留线上 so 可 strip 减体积,但 CI 必须保存同 build-id 的未裁剪符号文件。
CMake 配置关注 add_librarytarget_link_libraries、编译选项和 STL 选择。
依赖管理多 so 依赖要保证打包完整,避免运行时 linker 找不到。
体积优化ABI 拆分、只打必要架构、裁剪无用符号和资源。

构建产物治理很重要:没有符号文件,线上 native crash 只能看到地址,无法高效定位到源码行。

四、tombstone 与 native crash 现场

Android native crash 通常由信号触发,例如 SIGSEGV、SIGABRT、SIGBUS。系统会生成 tombstone 或在 logcat 中输出 crash 现场。

一份 tombstone 重点看:

  1. signal:崩溃类型,如 SIGSEGV 空指针/非法地址访问,SIGABRT 主动 abort。
  2. fault addr:访问的非法地址,0x0 附近通常是空指针。
  3. thread name / tid:崩溃线程,判断是主线程、渲染线程还是业务线程。
  4. registers:寄存器现场,可辅助判断参数和访问地址。
  5. backtrace:so 名称、偏移地址、函数符号。
  6. memory map:模块加载基址,用于符号化和判断版本。
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
backtrace:
  #00 pc 0000000000012344  /data/app/.../lib/arm64/libdemo.so (foo+24)
  #01 pc 0000000000015678  /data/app/.../lib/arm64/libdemo.so (bar+80)

分析时先确认 so 版本和 build-id 是否对应,再做符号化。不要只凭函数名猜结论,要结合入参、线程、最近业务行为和复现路径。

五、addr2line / ndk-stack 符号化流程

符号化的目标是把 tombstone 中的 pc 地址映射到函数、源码文件和行号。常用工具包括 addr2linendk-stack、Crashpad/Breakpad 符号化工具。

# 使用未 strip 的同版本 so,将 pc 偏移映射到源码行
$ANDROID_NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-addr2line \
  -f -C -e obj/local/arm64-v8a/libdemo.so 0000000000012344

# 对包含 backtrace 的日志整体符号化
ndk-stack -sym obj/local/arm64-v8a -dump tombstone.txt
工具适用场景注意事项
addr2line / llvm-addr2line单个地址快速定位输入应是相对 so 的 pc 偏移,so 版本必须匹配。
ndk-stack对 tombstone/logcat 批量符号化-sym 指向未 strip 符号目录。
Breakpad/Crashpad线上 minidump 平台化处理需要符号服务器和 build-id 管理。
IDE Debugger本地复现调试适合断点、变量观察,不替代线上符号化。

如果符号化结果对不上,优先检查:线上包和符号文件是否同一版本、ABI 是否一致、地址是否需要减去加载基址、so 是否被重新打包或裁剪。

六、RAII 与 native 资源安全

Native 代码没有 Java/Kotlin GC 帮你管理所有资源。RAII(Resource Acquisition Is Initialization)的思想是“资源在构造时获取,析构时释放”,用对象生命周期减少异常路径和提前 return 的泄漏。

资源推荐管理方式易错点
malloc/new 内存std::unique_ptrstd::vector、智能指针裸指针多分支释放容易漏。
文件描述符自定义 RAII fd wrapper忘记 close 导致 fd 泄漏。
JNI GlobalRefunique_ptr + 自定义 deleter析构时要拿当前线程 JNIEnv。
mutexstd::lock_guard / std::unique_lock手动 lock/unlock 容易死锁。
native handle封装 owner class所有权不清导致 double free。

RAII 还能提升面试表达:你不仅会写 native,还知道如何让 native 在异常、崩溃和复杂生命周期下更稳定。

七、native 泄漏与调试方法

Native 泄漏包括内存泄漏、fd 泄漏、线程泄漏、JNI 全局引用泄漏和图形/音视频资源泄漏。它们不一定出现在 Java heap 中,所以只看 LeakCanary 不够。

排查思路:

  1. 确认现象:RSS/PSS 持续增长、fd 数增长、线程数增长、崩溃前内存压力。
  2. 区分 Java/native:Android Studio Profiler、dumpsys meminfo、heapprofd、Perfetto。
  3. 定位分配点:debug 包可用 malloc 调试、ASan/HWASan、heapprofd 采样。
  4. 检查 JNI 引用:全局引用是否释放,循环局部引用是否 DeleteLocalRef。
  5. 检查所有权:对象是否 double free、use-after-free、跨线程释放。
  6. 修复后回归:压测相同路径,观察内存/fd/线程曲线回落。

线上治理要保存符号、增加 native 关键路径日志、对高风险模块做灰度,并用崩溃率和内存指标观察修复效果。


高频面试题

Q1:JNIEnv 和 JavaVM 有什么区别? JavaVM 进程唯一,可以跨线程保存;JNIEnv 是线程私有的,只能在当前线程使用。native 自建线程要调用 Java,必须先 AttachCurrentThread 获取 JNIEnv,退出前 DetachCurrentThread。

Q2:RegisterNatives 有什么好处? 它在 JNI_OnLoad 中集中注册 Java native 方法与 C/C++ 函数指针,避免静态注册的长函数名和包名暴露,重构更可控。工程上要检查 FindClass、签名和 RegisterNatives 返回值,并配置混淆 keep。

Q3:拿到 tombstone 后怎么定位 native crash? 先看 signal、fault addr、崩溃线程和 backtrace;确认 so 版本、ABI 和 build-id 匹配;再用 addr2linendk-stack 将 pc 地址符号化到函数和源码行,最后结合入参、业务路径和最近日志分析根因。

Q4:为什么线上一定要保存未 strip 的 so? 线上包为了体积会 strip 符号,crash 里常只有 so 偏移地址。没有同版本未 strip 符号文件,addr2line / ndk-stack 无法准确映射源码行,定位效率会大幅下降。

Q5:RAII 在 NDK 中解决什么问题? RAII 把资源释放绑定到对象析构,可以避免 return 分支、异常路径或错误处理遗漏释放。它适合管理 native 内存、fd、mutex、JNI GlobalRef 等资源,能显著降低泄漏和 double free 风险。

易错点 / 追问

  • 不要跨线程缓存和使用 JNIEnv*,跨线程只保存 JavaVM*
  • 不要忘记删除 GlobalRef,也不要把 LocalRef 存到全局变量里。
  • 不要只看 tombstone 函数名就下结论,必须确认符号文件、ABI、build-id 和业务上下文匹配。
  • 追问“addr2line 结果不对怎么办”:检查 so 版本是否一致、地址是否为相对偏移、ABI 是否匹配、符号是否被裁剪。
  • 追问“native 泄漏怎么发现”:看 PSS/RSS、fd、线程、heapprofd/Perfetto/ASan,并结合 JNI 引用和所有权审查。

iOS 基础速览 (辅)

这是辅助篇,目标不是把你培养成 iOS 工程师,而是让你在面试里接得住 iOS 相关提问,展示“跨端潜力“。重点是用 Android 概念类比,快速建立对照。

一、Swift 语言基础

  • 变量:let(常量,类比 Kotlin val)、var(变量)。类型推断。
  • 可选值(Optional):var name: String?,概念等同 Kotlin 可空类型。
    • 可选绑定 if let x = name { } / guard let x = name else { return }
    • 强制解包 name!(类比 Kotlin !!)。
    • 可选链 name?.count(类比 ?.)。
    • 空合运算 name ?? "default"(类比 Elvis ?:)。
  • 闭包(Closure):类比 Kotlin Lambda,{ (x: Int) -> Int in x * 2 },尾随闭包语法。
  • 协议(Protocol):类比接口,但更强大(可扩展、面向协议编程 POP)。
  • 结构体 vs 类:struct 是值类型(拷贝),class 是引用类型。Swift 鼓励用 struct。
  • 枚举:带关联值的枚举很强大,类比 Kotlin sealed class。

二、内存管理 ARC

  • ARC(Automatic Reference Counting):编译期自动插入 retain/release,不是 GC(无运行时回收线程)。引用计数为 0 即释放。
  • 循环引用:两个对象互相强引用导致无法释放(类比内存泄漏)。
  • 解决:weak(弱引用,可为 nil)、unowned(无主引用,不为 nil)。闭包捕获用 [weak self](类比避免 Handler 持有 Activity)。
  • 与 Android 对比:Android 用 GC 可达性分析自动回收;iOS 用 ARC 引用计数,开发者需主动打破循环引用。

三、UI 体系

  • UIViewController 生命周期:viewDidLoad(类比 onCreate)→ viewWillAppearviewDidAppearviewWillDisappearviewDidDisappear
  • UIKit:命令式 UI(类比 View 体系),用 Storyboard/XIB 或纯代码。
  • SwiftUI:声明式 UI(类比 Jetpack Compose),@State/@Binding/@ObservedObject 管理状态,概念和 Compose 高度相似——你学了 Compose 就好理解。

四、并发 GCD

  • GCD(Grand Central Dispatch):iOS 的并发框架,基于队列。
  • 主队列:DispatchQueue.main,UI 操作(类比 Android 主线程/Dispatchers.Main)。
  • 全局队列:DispatchQueue.global(qos:),后台任务(类比 Dispatchers.IO/Default)。
  • 异步/同步:async(不阻塞,类比 launch)、sync(阻塞当前)。
  • 现代 Swift 也有 async/await + Task(类比 Kotlin 协程),概念可迁移。
DispatchQueue.global().async {        // 后台
    let data = loadData()
    DispatchQueue.main.async {         // 回主线程更新 UI
        self.label.text = data
    }
}

五、Android / iOS 概念对照表

概念AndroidiOS
页面ActivityUIViewController
列表RecyclerViewUITableView/UICollectionView
声明式 UIJetpack ComposeSwiftUI
异步主线程切换Handler/协程GCD/async-await
内存管理GC(可达性分析)ARC(引用计数)
空安全Kotlin 可空类型Optional
接口interfaceprotocol
依赖管理GradleCocoaPods/SPM
本地存储SharedPreferences/DataStoreUserDefaults
包格式APK/AABIPA

高频面试题(iOS 通常只是辅助考察)

Q1:iOS 的内存管理和 Android 有什么不同? iOS 用 ARC,编译期插入 retain/release 做引用计数,计数为 0 即释放,无运行时 GC 线程,需手动用 weak/unowned 打破循环引用。Android 用 GC 可达性分析在运行时回收,无需手动管理引用计数。

Q2:weak 和 unowned 区别? 都不增加引用计数。weak 引用对象释放后变 nil(可选);unowned 假设对象在引用期间一直存在,释放后访问会崩溃(非可选)。生命周期不确定用 weak,确定不短于自身用 unowned。

Q3:struct 和 class 区别? struct 是值类型(赋值/传参拷贝,栈),class 是引用类型(共享,堆,有 ARC)。Swift 优先用 struct(线程安全、无副作用)。

Q4:SwiftUI 和 Compose 像吗? 非常像。都是声明式、状态驱动、UI=f(state)、单向数据流。@State 类比 remember+mutableStateOf,@Binding 类比状态提升。理解了 Compose 基本能迁移过去。

Q5:你做过 iOS 吗?能转吗?(开放题) 诚实说做过部分 iOS,理解 Swift/UIKit/ARC 基础,有跨端经验;强调底层(C/C++)和概念是相通的,跨端学习成本可控。不要夸大成“精通“。

Kotlin Multiplatform

☆ KMP 是跨端趋势话题。你有 Android + iOS 基础,可以把它讲成“共享业务逻辑,保留原生体验“的跨端判断力。

一、KMP 解决什么问题

KMP 不是用一套 UI 替代所有平台,而是优先共享业务逻辑、网络、数据、算法、领域模型,UI 仍可保持原生。

二、expect / actual 机制

公共代码声明 expect,各平台提供 actual 实现。

// commonMain
expect class DeviceInfoProvider {
    fun deviceModel(): String
}
// androidMain
actual class DeviceInfoProvider {
    actual fun deviceModel(): String = android.os.Build.MODEL
}

三、推荐分层

是否适合共享说明
domain/model很适合业务规则稳定
data/network适合Ktor/serialization 可共享
platform capability部分适合用 expect/actual 封装
UI看项目Compose Multiplatform 或原生 UI

四、和 Flutter / React Native 的区别

KMP 更偏共享逻辑,不是强制统一 UI;Flutter/RN 更偏统一 UI 技术栈。选择取决于团队能力、UI 一致性诉求、原生能力复杂度。

五、与 iOS 的互操作

KMP 可生成 framework 给 iOS 调用,但要注意 Swift/Objective-C 暴露、协程到 iOS async 的桥接、异常模型、包体积和调试体验。

高频面试题

Q1:KMP 适合什么场景? 答:适合共享业务逻辑、协议、数据层和算法;如果项目主要难点是高度定制 UI 或平台能力差异很大,要谨慎。

Q2:KMP 和 Flutter 怎么选? 答:想统一 UI 且团队接受新渲染栈可考虑 Flutter;想保留原生 UI、共享核心逻辑可考虑 KMP。

Q3:expect/actual 的意义是什么? 答:公共层定义平台能力抽象,各平台提供实际实现,既复用业务逻辑又不牺牲平台差异。

易错点 / 追问

  • 不要说 KMP 能无成本一套代码跑所有端。
  • 不要把平台差异强行塞进 commonMain。
  • iOS 调试、构建、二进制大小都要提前评估。

跨端技术对比 ☆

跨端选型不是站队,而是在业务目标、团队能力、体验要求和长期维护成本之间做取舍。 面试中要能把 Flutter、React Native、Compose Multiplatform、WebView Hybrid、小程序容器、KMP 和原生开发放在同一张决策表里比较。

一、跨端技术解决的核心问题

跨端技术的目标通常有三类:降低多端重复开发、提升发布效率、统一体验或业务逻辑。但不同方案共享的层次不同,不能简单说“一套代码跑所有端”。

方案主要共享内容UI 渲染典型诉求
FlutterUI + 业务逻辑自绘 Skia/Impeller高一致性 UI、快速迭代、多端统一。
React Native / RNUI 描述 + JS 业务逻辑Native 组件桥接/FabricWeb/前端团队复用经验,保留部分原生体验。
Compose MultiplatformCompose UI + Kotlin 逻辑Compose 渲染Kotlin 技术栈统一,桌面/移动共享 UI。
WebView HybridWeb 页面 + Native 容器能力WebView运营活动、内容页、快速发布。
小程序容器小程序 DSL/运行时容器渲染生态开放、插件式业务接入。
KMP业务逻辑/数据层/算法原生 UI 或 CMP共享核心逻辑,保留平台体验。
原生开发少量公共协议/设计规范Android/iOS 原生极致体验、复杂平台能力、稳定长期维护。

选型时先问:要共享的是 UI、业务逻辑、数据层,还是发布能力?答案不同,方案就不同。

二、Flutter:自绘 UI 与高一致性

Flutter 使用 Dart 编写,通过自绘渲染实现跨平台 UI 一致性。它的优势是开发体验完整、热重载、组件体系统一、动画和复杂 UI 表达强。

维度Flutter 表现
UI 一致性强,同一套 Widget 在多端表现接近。
性能大多数业务足够好,复杂页面要关注首帧、shader、列表和图片。
原生能力通过 Platform Channel 接入,复杂能力需要原生桥接。
包体积通常比纯原生更大,要评估引擎和资源成本。
团队要求需要 Dart/Flutter 经验和原生兜底能力。

Flutter 适合 UI 一致性强、需要快速多端交付、团队愿意接受新技术栈的业务。不适合只想在原生 App 中轻量嵌几页,或大量依赖复杂原生 SDK 且团队没有桥接能力的场景。

三、React Native / RN:前端生态与原生桥接

React Native 用 JavaScript/TypeScript 描述 UI 和业务逻辑,通过桥接或新架构 Fabric/TurboModules 与 Native 交互。它的优势是前端生态、动态性和招聘/团队迁移成本。

RN 简化链路:
JS/TS 业务逻辑
  -> React 渲染描述
  -> Fabric/TurboModules/Bridge
  -> Android/iOS Native 组件与模块

RN 的核心取舍是桥接成本和运行时复杂度。高频 UI 更新、复杂手势动画、大量 Native 模块通信都要谨慎设计。新架构改善了旧 Bridge 的一些瓶颈,但并不意味着所有性能问题自动消失。

适合场景:已有 React/前端团队、业务 UI 中等复杂、需要一定动态发布能力、原生模块边界清晰。不适合极致动画、重度图形、复杂平台底层能力密集的模块。

四、Compose Multiplatform 与 KMP

KMP(Kotlin Multiplatform)关注共享业务逻辑,Compose Multiplatform(CMP)进一步尝试共享 UI。二者都适合 Kotlin 生态团队,但成熟度和平台覆盖要按项目评估。

技术共享层次优势风险
KMPdomain、data、network、算法、平台抽象保留原生 UI,共享核心逻辑,适合 Android 团队扩展 iOSiOS 互操作、构建、调试、异常模型需要治理。
Compose MultiplatformCompose UI + Kotlin 逻辑Kotlin/Compose 技术栈统一,适合内部工具/桌面/部分移动场景移动端生态和复杂平台 UI 适配仍需评估。

KMP 的成熟回答是:它不是替代 Flutter/RN 的“统一 UI”方案,而是“共享核心逻辑 + 平台 UI 自治”。如果团队最痛的是重复写接口、模型、业务规则和算法,KMP 很合适;如果最痛的是两端 UI 开发成本,则要考虑 Flutter、RN 或 CMP。

五、WebView Hybrid 与小程序容器

WebView Hybrid 依赖 WebView 承载页面,Native 提供 JSBridge、离线包、权限、路由和监控。它发布快、成本低,适合活动页、运营页、内容页和低复杂交互。

小程序容器则是更完整的运行时:用统一 DSL、组件、权限模型和包管理承载第三方或内部轻应用。

维度WebView Hybrid小程序容器
发布效率高,且更标准化。
体验受 WebView 和前端性能影响比普通 H5 更受控,但仍受容器能力限制。
Native 能力JSBridge 按需开放权限模型和 API 网关更体系化。
适合场景活动、内容、表单、轻业务平台生态、商家/插件接入、可治理轻应用。
风险白屏、Bridge 安全、缓存错配容器研发成本、标准制定和生态治理。

面试时要强调 Hybrid 和小程序容器都是“受控运行时”,核心不只是加载页面,还包括离线包、预加载、权限、监控、灰度、回滚和安全边界。

六、原生开发仍然不可替代的场景

原生开发成本高,但在很多场景仍是最稳的选择:

  1. 高性能图形、音视频、游戏、实时通信、复杂动画。
  2. 深度系统能力:相机、传感器、蓝牙、支付、安全、风控 SDK、NDK。
  3. 对启动、内存、包体积、稳定性有极致要求的核心链路。
  4. 平台差异很大,强行跨端会制造大量条件分支。
  5. 长期维护团队以 Android/iOS 为主,跨端技术栈缺少 owner。

原生不是“落后”,跨端也不是“银弹”。成熟团队常见组合是:核心链路原生,活动和内容 Hybrid,业务逻辑部分 KMP,独立新业务可尝试 Flutter/RN。

七、选型决策框架

选型可以用“业务、体验、团队、工程、风险”五维评估。

维度关键问题倾向方案
业务形态内容/活动/表单还是复杂交易/实时交互?轻业务 Hybrid/小程序;核心交易原生/KMP。
UI 一致性是否要求多端像素级一致?强一致 Flutter;平台体验优先原生/KMP。
发布效率是否需要高频动态发布?Hybrid/小程序/RN;高风险逻辑仍需原生发版。
团队能力团队熟悉前端、Kotlin 还是原生?React 团队 RN;Kotlin 团队 KMP/CMP;原生团队原生。
原生能力是否大量调用平台 SDK/NDK/安全能力?原生或 KMP 共享逻辑,跨端 UI 慎选。
长期成本是否有容器、监控、桥接、灰度 owner?没有治理能力时不要贸然引入重跨端。

一个可用于面试的简洁结论:如果要统一 UI,看 Flutter/RN/CMP;如果要共享业务逻辑,看 KMP;如果要快速发布轻业务,看 Hybrid/小程序;如果要极致体验和复杂平台能力,坚持原生。


高频面试题

Q1:Flutter 和 RN 最大区别是什么? Flutter 更偏自绘 UI,用 Dart 和自己的 Widget/渲染体系保证多端一致性;RN 用 JS/TS 和 React 思想描述 UI,更多依赖 Native 组件和桥接。Flutter UI 一致性强,RN 更容易复用前端生态,二者都需要原生能力兜底。

Q2:KMP 和 Flutter 怎么选? KMP 适合共享业务逻辑、数据层、协议和算法,同时保留 Android/iOS 原生 UI;Flutter 适合统一 UI 和多端一致体验。如果团队最痛是重复写业务逻辑,选 KMP;如果最痛是多端 UI 重复开发,再评估 Flutter。

Q3:Hybrid 为什么适合活动页但不适合所有核心链路? Hybrid 发布快、成本低,适合内容、活动、表单和低复杂交互。但它受 WebView 性能、白屏、Bridge 安全、离线包一致性影响,对强性能、强安全、复杂原生能力的核心链路要谨慎。

Q4:Compose Multiplatform 的定位是什么? 它尝试用 Compose/Kotlin 共享 UI 和逻辑,对 Kotlin 团队有吸引力。当前选型要看目标平台、组件生态、调试体验和团队经验,不能简单等同于成熟 Flutter 替代品。

Q5:跨端选型最重要看什么? 先看共享目标是 UI、业务逻辑还是发布能力;再看体验要求、原生能力复杂度、团队技术栈、治理能力和长期维护成本。不要为了技术流行牺牲核心链路稳定性。

易错点 / 追问

  • 不要说跨端一定省成本:桥接、容器、监控、兼容性和人才成本都要算进去。
  • 不要把 KMP 说成“一套 UI 跑所有端”;它更典型的价值是共享业务逻辑。
  • 不要忽略原生兜底能力:Flutter/RN/Hybrid 都会遇到平台 SDK、权限、性能和稳定性问题。
  • 追问“为什么不用全 Hybrid”:核心链路体验、白屏风险、Bridge 安全和复杂原生能力会限制它。
  • 追问“团队怎么渐进式引入”:先选低风险模块试点,建立监控、桥接规范和回滚机制,再扩大范围。

WebView 与 Hybrid ☆

Hybrid 的本质是“用 Web 的发布效率承载部分业务,用 Native 的能力兜住体验和安全”。 面试时不要只会说 JSBridge,要能讲生命周期、缓存、白屏排查、权限下载、离线包和容器治理。

一、WebView 生命周期与基础配置

WebView 是一个重量级组件,既有 View 生命周期,也有页面加载、渲染进程、JS 执行和网络缓存状态。Activity/Fragment 中使用 WebView 时,要把生命周期显式转发并在销毁时释放资源。

阶段常见处理原因
创建统一 WebSettings、UA、Cookie、WebViewClient、WebChromeClient避免各业务页配置不一致。
onResumewebView.onResume()、恢复 JS timer防止后台回来后页面定时器异常。
onPausewebView.onPause()、暂停音视频/动画降低后台耗电和资源占用。
onDestroy从父容器移除、停止加载、清空 client、destroy()避免持有 Activity 导致泄漏。
override fun onDestroy() {
    (webView.parent as? ViewGroup)?.removeView(webView)
    webView.stopLoading()
    webView.webChromeClient = null
    webView.webViewClient = null
    webView.destroy()
    super.onDestroy()
}

基础配置要遵循“最小权限”原则:不需要 JS 就不开启 JavaScript;不需要文件访问就关闭 file/content 访问;混合内容、第三方 Cookie、调试开关都要按环境和业务域名控制。

二、JSBridge 设计与 addJavascriptInterface 风险

JSBridge 负责 JS 与 Native 双向通信。常见模式包括 addJavascriptInterface 注入对象、URL scheme 拦截、postMessage/evaluateJavascript 回调。

方案原理优点风险/限制
addJavascriptInterface注入 Java 对象给 JS 调用使用简单,同步语义直观低版本历史安全风险;暴露面大;必须控制域名和方法。
URL schemeJS 跳转自定义 URL,Native 拦截解析兼容性好,隔离清晰参数长度、编码、异步回调复杂。
evaluateJavascriptNative 执行 JS 字符串回传结果适合 Native 主动通知需要主线程;要处理转义和页面状态。
WebMessage标准消息通道边界更清晰版本兼容和封装成本。

addJavascriptInterface 的核心风险是把 Native 能力暴露给不可信页面。安全设计要做到:

  1. 只允许可信 HTTPS 域名使用 Bridge,页面跳转后重新校验 origin。
  2. Bridge 方法白名单化,参数做类型、长度、业务权限校验。
  3. 不暴露通用反射、文件、命令、账号 token 等高危能力。
  4. Debug 包和 Release 包区分 WebView 调试能力。
  5. 所有敏感调用写审计日志,便于定位异常页面行为。

面试表达边界:只讲风险、隔离和校验原则,不要给出绕过或攻击脚本。

三、缓存、Cookie 与离线资源

WebView 缓存涉及 HTTP cache、Service Worker、DOM Storage、IndexedDB、Cookie 和 App 自己的离线包。面试中要区分“浏览器内建缓存”和“Hybrid 容器离线包”。

缓存类型适用内容控制点
HTTP CacheJS/CSS/图片等静态资源服务端 Cache-Control、ETag、版本号。
DOM Storage / IndexedDB页面本地数据容量、清理策略、隐私合规。
Cookie登录态、会话SameSite、Secure、HttpOnly、同步时机。
离线包H5 应用资源包包签名、版本、灰度、回滚、增量更新。

离线包通常在 App 启动或空闲时下载,校验签名和摘要后解压到私有目录;页面加载时通过 URL 映射或请求拦截优先命中本地资源,未命中再走网络。这样能提升首屏速度和弱网可用性,但要避免缓存污染、版本错配和敏感数据落盘。

四、白屏诊断与性能优化

WebView 白屏通常不是单一原因,要按“容器、网络、资源、JS、渲染、业务”分层排查。

白屏排查路径:
1. 容器是否创建成功:WebView 初始化、渲染进程、内核版本、硬件加速。
2. URL 是否正确:重定向、scheme、证书、DNS、代理、HTTP 状态码。
3. 资源是否可用:HTML/JS/CSS 是否 200,离线包是否命中正确版本。
4. JS 是否异常:console error、bridge 超时、Promise rejection、入口脚本未执行。
5. 首屏是否被阻塞:大 JS、同步 Bridge、主线程长任务、图片过大。
6. 业务状态是否异常:登录态丢失、接口失败、AB 配置错误。

性能优化重点包括:WebView 预创建/预热、DNS/连接预取、离线包、骨架屏、关键资源内联、减少同步 JSBridge、控制首屏 JS 体积、监控 FCP/LCP/JS error/bridge timeout。预加载要有池大小和生命周期控制,否则会用内存换速度并引入泄漏。

五、文件选择、权限、下载与上传

Hybrid 容器经常承接上传头像、拍照、选择文件、下载附件等能力,这些能力要通过 WebChromeClient、权限回调和下载监听统一封装。

  • 文件选择:实现 onShowFileChooser,根据 accept type 决定相册、文件、相机入口,并处理 Activity Result。
  • 权限申请:摄像头、麦克风、定位等要结合 Android runtime permission 和 WebChromeClient 的 permission request,展示清晰授权说明。
  • 下载处理:通过 DownloadListener 或拦截响应交给系统 DownloadManager/业务下载器,校验文件类型、大小和来源。
  • 上传安全:限制可上传文件类型和大小,避免把私有路径或敏感文件暴露给页面。
  • 兼容性:Android 版本、厂商文件选择器、Scoped Storage 都会影响 Uri 读取。

权限和文件能力是容器的高风险边界,要按业务域名、用户授权、最小能力和审计日志来治理。

六、URL 拦截、路由与安全边界

Hybrid 容器常通过 URL 拦截承接 App 内跳转、登录、支付、分享和资源替换。设计时要明确哪些 URL 交给 WebView,哪些交给 Native Router,哪些直接拒绝。

拦截点用途注意事项
shouldOverrideUrlLoading页面导航、scheme 跳转不要误拦截普通 http(s) 导航;校验来源。
shouldInterceptRequest资源替换、离线包命中不要阻塞过久;注意 MIME、编码和缓存头。
onReceivedSslError证书错误处理生产环境不要无条件 proceed,应失败并上报。
onRenderProcessGone渲染进程崩溃恢复展示降级页,清理旧 WebView 并重建。

路由设计建议:业务页面使用 HTTPS URL 作为稳定入口;Native 私有能力使用明确 scheme 和白名单;参数签名或一次性 token 用于高价值动作;所有外跳都经过风险校验和用户确认。

七、Hybrid 容器设计:离线包、预加载与治理

一个可维护的 Hybrid 容器通常包含以下模块:

  1. 页面配置中心:域名白名单、Bridge 权限、离线包版本、降级策略。
  2. 离线包管理:下载、签名校验、解压、版本切换、失败回滚。
  3. Bridge 框架:方法注册、权限校验、异步回调、超时、日志。
  4. 预加载池:WebView 预创建、常用页面预取、内存上限、生命周期清理。
  5. 监控系统:白屏率、首屏耗时、JS error、资源失败、Bridge 成功率。
  6. 安全合规:隐私协议、权限最小化、敏感 API 审计、调试开关隔离。

成熟回答要强调 Hybrid 不是“把网页塞进 App”,而是一个受控运行时:发布快,但必须用容器能力把安全、性能、权限和回滚治理起来。


高频面试题

Q1:JSBridge 常见实现方式有哪些? 常见有 addJavascriptInterface、URL scheme 拦截、evaluateJavascript 和 WebMessage。回答时要说明同步/异步、兼容性和安全边界,尤其是 Bridge 方法白名单、域名校验和参数校验。

Q2:addJavascriptInterface 有什么风险?怎么规避? 它会把 Java 对象暴露给 JS,如果页面不可信或方法设计过宽,可能让网页调用敏感 Native 能力。规避方式是只给可信 HTTPS 域名开放、方法白名单、参数校验、最小权限、Release 关闭调试、敏感调用审计。

Q3:WebView 白屏怎么排查? 按容器初始化、URL/网络、资源加载、JS 异常、渲染进程、业务接口和登录态分层排查。关键监控包括 HTTP 状态、资源失败、console error、Bridge 超时、首屏指标和 onRenderProcessGone

Q4:离线包怎么设计? 服务端下发版本和资源包,App 下载后做签名/摘要校验,解压到私有目录;加载时通过 URL 映射或请求拦截优先读取本地资源,失败再降级网络。必须支持灰度、回滚、过期清理和版本兼容。

Q5:Hybrid 容器如何处理文件上传和权限? 通过 onShowFileChooser、Activity Result、runtime permission 和 WebChromeClient 权限回调统一封装。要限制域名、文件类型、大小和敏感路径,并给用户清晰授权说明。

易错点 / 追问

  • 不要在生产环境对 onReceivedSslError 无条件 proceed,证书错误应失败、降级并上报。
  • 不要把所有 Native 能力都挂到 JSBridge;Bridge 是权限边界,不是工具箱。
  • 不要只靠 WebView 缓存做“离线包”;离线包需要签名、版本、灰度和回滚。
  • 追问“预加载越多越好吗”:不是,WebView 很重,要用池大小、内存阈值和页面优先级控制。
  • 追问“页面跳转后 Bridge 权限是否还有效”:需要重新校验当前 URL/origin,不能只在首次加载时校验。

插件化 / 热修复 / 动态化 ☆

动态化不是“炫技”,而是为发布效率、风险控制和业务扩展服务。 面试中要把 ClassLoader、资源加载、组件代理和热修复边界讲清楚:能解释原理,也能说明为什么今天大多数团队会更谨慎地使用它。

一、ClassLoader 与 Android 类加载

Android 运行时通过 ClassLoader 加载 DEX 中的类。理解插件化和热修复,先要理解“类从哪里来、按什么顺序找、找到后能不能替换”。

ClassLoader典型来源主要用途面试要点
PathClassLoader安装包内的 apk/dex/so 路径加载宿主 App 正常代码系统默认用于已安装应用,通常不直接加载外部 dex。
DexClassLoader指定 dex/jar/apk 路径和优化目录插件、动态下发代码、部分 SDK 容器可加载外部 dex,但要关注安全校验、兼容性和启动性能。
BaseDexClassLoader二者共同父类维护 DexPathListdexElements热修复常围绕 dexElements 顺序做文章。

Android 的类查找大致是“父加载器优先 + 当前 DexPathList 顺序查找”。补丁方案常把修复 dex 插到 dexElements 前面,让同名类优先命中补丁版本。但这并不等于所有代码都能无条件替换:已经加载过的类不能简单卸载重载,类结构变化、反射/JNI/混淆都可能引入风险。

宿主 PathClassLoader
  └── DexPathList
        ├── patch.dex       # 热修复:排在前面,优先找修复类
        ├── classes.dex
        └── classes2.dex

插件 DexClassLoader
  └── plugin.apk/classes.dex # 插件:独立路径,通常配合代理/容器运行

二、DexClassLoader / PathClassLoader 的工程边界

PathClassLoader 更适合“安装时已确定”的代码,DexClassLoader 更适合“运行时确定”的插件或动态模块。真正工程落地时,重点不是能不能 load,而是如何保证可控:

  1. 来源可信:动态包必须做签名、摘要、版本和灰度校验,不能把任意外部文件交给 DexClassLoader
  2. 依赖隔离:插件依赖和宿主依赖可能类名冲突,要约定公共 API 层,避免插件直接依赖宿主内部实现。
  3. 生命周期控制:插件类、线程、单例和缓存要能随插件卸载或宿主退出释放,否则容易泄漏 Activity/Context。
  4. 性能控制:首次加载 dex 会有校验和优化成本,要结合预下载、空闲预热、灰度开关。
  5. 兼容性控制:Android 版本、厂商 ROM、hidden API 限制会影响反射修改内部结构的稳定性。

三、资源加载与 AssetManager

插件不仅有代码,还有 layout、drawable、string 等资源。插件化常见做法是通过反射或公开能力创建新的 AssetManager,把插件 apk 路径加入资源搜索路径,再构造 Resources 对象供插件使用。

问题常见方案风险点
插件资源 ID 与宿主冲突插件独立编译,运行时用插件 Resources 查找不能把宿主 R.xxx 和插件 R.xxx 混用。
主题 / 样式不生效用插件 Context 包装 Resources 和 ThemeActivity/Window 主题链要处理完整。
资源更新后缓存旧值插件版本化路径 + 清理旧 Resources 引用全局 Drawable/Bitmap 缓存可能持有旧资源。
多语言 / 深色模式同步宿主 Configuration配置变化时要通知插件刷新。

面试回答资源加载时,可以用一句话概括:代码靠 ClassLoader,资源靠 AssetManager/Resources,四大组件靠代理或 Hook,三者都要有版本、隔离和回滚机制。

四、Activity 插件化与组件代理

Android 四大组件需要在 Manifest 中声明,而插件 Activity 往往没有安装到系统。传统插件化会通过“坑位 Activity + 代理分发”解决这个问题。

  1. 宿主 Manifest 预先声明一个或多个 ProxyActivity。
  2. 启动插件页面时,实际启动 ProxyActivity,并在 Intent 中携带插件 Activity 类名。
  3. ProxyActivity 创建插件实例,把生命周期、Context、Window、资源访问转发给插件。
  4. 插件页面只实现约定接口或继承插件基类,不直接暴露给系统 AMS。

这种方式的难点在于生命周期和系统能力不是简单函数转发:启动模式、onActivityResult、权限回调、Fragment、主题、横竖屏、进程恢复都要适配。更激进的方案会 Hook Instrumentation / AMS 相关路径,但面试中应强调维护成本和系统版本风险,不要把 Hook 当默认答案。

五、Tinker / Robust 热修复原理

热修复主要分两类:类级别补丁和方法级别补丁。

方案核心思路优点限制
Tinker 类方案生成差分补丁,合成补丁 dex,让补丁类优先加载覆盖面较广,可修复 Java/Kotlin 逻辑和部分资源/so通常需要重启生效;已加载类、四大组件声明变化受限。
Robust 方法方案编译期给方法插入跳转逻辑,运行时分发到补丁实现可做到较快生效,方法粒度明确插桩有性能/包体积成本;未插桩代码无法修。
Instant Run / Apply Changes 类方案开发期快速替换代码提升开发效率不是线上热修复方案。

Tinker 的关键是“补丁包校验 + dex 合成/加载 + 类加载顺序调整 + 重启后生效”。Robust 的关键是“编译期埋好可替换入口,运行时判断是否走补丁”。二者都不是万能药,都要配合灰度、监控和回滚。

六、热修复限制与上线治理

热修复最容易被问到“哪些不能修”。可以按下面维度回答:

  • 类已加载:同名类已被加载后,不能简单用新 dex 覆盖当前 Class 对象。
  • 结构变化:字段、方法签名、继承关系变化可能影响反射、序列化、混淆和运行时验证。
  • Manifest 变化:新增 Activity/Service/Provider、权限、进程等系统声明通常不能靠普通补丁完整解决。
  • native 变化:so 替换涉及 ABI、加载时机、符号兼容,比 Java 补丁更需要谨慎。
  • 合规与商店政策:动态下发代码必须符合平台政策和企业安全要求,不能绕过审核分发高风险逻辑。

工程治理上要做到:补丁签名校验、版本匹配、灰度发布、崩溃监控、失败回滚、补丁过期清理,并在下个正式版本合入源码,避免长期依赖补丁堆叠。

七、动态容器与现代替代方案

今天的动态化更多会走“容器化 + 配置化 + 服务端控制”的路线,而不是无限扩张传统插件化。

方向适合场景取舍
插件化容器大型 App 业务模块隔离、SDK 扩展能力强,但维护成本高、兼容性风险大。
Hybrid / 小程序容器活动页、运营页、轻业务发布快,但体验和原生能力受容器限制。
Server Driven UI表单、配置页、低交互页面安全可控,但复杂 UI 表达能力有限。
Play Feature Delivery / 动态特性海外 Google Play 场景官方能力更稳,但依赖分发渠道。
远程配置 / AB 实验策略、开关、文案、流程风险最低,但不能替换任意代码。

面试中的成熟表达是:动态化要根据业务价值选择最小可行方案。能用配置解决就不要下发代码;必须下发代码时,先设计安全校验、兼容性矩阵、灰度回滚和观测体系。


高频面试题

Q1:PathClassLoader 和 DexClassLoader 有什么区别? PathClassLoader 主要加载已安装 APK 内的 dex/so,是宿主默认类加载器;DexClassLoader 可以指定外部 dex/jar/apk 路径,常用于插件或动态代码。面试重点是说明二者都继承 BaseDexClassLoader,内部通过 DexPathList 查找类,动态加载一定要做来源校验和版本控制。

Q2:热修复为什么常说“补丁 dex 要排在前面”? 类查找按 DexPathList 中 dexElements 的顺序进行。把补丁 dex 插到前面后,同名类会优先从补丁中加载,从而覆盖原实现。但如果原类已经加载,或者类结构变化很大,就不能保证安全替换。

Q3:插件 Activity 没有在 Manifest 注册,为什么还能启动? 常见做法是宿主预注册 ProxyActivity,系统实际启动代理页面;代理再根据 Intent 中的插件类名创建插件对象,并分发生命周期、资源和 Context。难点在启动模式、权限、主题、Fragment 和进程恢复等系统行为适配。

Q4:Tinker 和 Robust 的核心差异是什么? Tinker 更偏类/包级补丁,通过补丁 dex 优先加载和差分合成修复问题,通常重启后生效;Robust 更偏方法级补丁,编译期插桩,运行时把方法调用分发到补丁实现。二者都需要灰度、监控、回滚,不是替代正常发版。

Q5:动态化方案怎么保证安全? 动态包要做签名、摘要、版本、渠道和灰度校验;加载前后要有完整日志、崩溃监控和回滚;插件只暴露受控 API,避免直接访问宿主内部敏感能力。同时要遵守商店政策和隐私合规要求。

易错点 / 追问

  • 不要把插件化等同于热修复:插件化解决模块动态加载,热修复解决线上缺陷快速止血。
  • 不要说“ClassLoader 能替换所有代码”:已加载类、Manifest、so、资源缓存都有边界。
  • 不要只讲 Hook AMS/Instrumentation:面试更看重你是否知道兼容性、灰度和回滚成本。
  • 追问“为什么现在插件化少了”:可以答官方动态特性、Hybrid/小程序、远程配置和合规要求分流了需求。
  • 追问“补丁失败怎么办”:启动保护、失败计数、禁用补丁、回滚到宿主稳定版本,并上报诊断信息。

音视频 / Media3 / ExoPlayer

音视频不是所有 Android 岗都深考,但一旦岗位涉及播放器、短视频、直播,Media3/ExoPlayer、首帧、卡顿和缓存就是硬门槛。

一、音视频基础概念

概念含义
容器MP4、MKV、FLV,负责封装音视频轨道
编码H.264/H.265/AAC,负责压缩
解码将压缩数据还原为可播放帧
帧率/码率影响流畅度、清晰度、带宽
PTS/DTS播放时间戳/解码时间戳

二、Android 播放链路

数据源 → demux 解封装 → audio/video decoder → audio track / surface 渲染 → 同步播放。

三、Media3 / ExoPlayer 核心组件

组件职责
Player播放控制 API
MediaItem媒体描述
MediaSource数据源与 timeline
Renderer音频/视频渲染
LoadControl缓冲策略
DataSource网络/文件读取

四、首帧、卡顿与缓冲优化

  • 首帧:DNS/TCP/TLS、manifest、首包、解码器初始化都会影响。
  • 卡顿:网络波动、缓冲不足、解码耗时、渲染阻塞。
  • 优化:预加载、合适 buffer、缓存、降码率、错误重试。

五、缓存与离线播放

缓存要考虑 key、清理策略、空间上限、版权/加密、断点续传。不要把短视频缓存策略简单套到长视频。

六、常见业务场景

  • Feed 自动播放:静音、预加载、滑出暂停、资源释放。
  • 直播:低延迟优先,容忍少量画质波动。
  • 长视频:稳定和清晰度优先,可接受较大缓冲。

高频面试题

Q1:首帧慢怎么排查? 答:拆链路看网络建连、首包、manifest、缓存命中、解码器初始化、surface ready,用埋点分段定位。

Q2:Media3 和 ExoPlayer 什么关系? 答:Media3 是 Jetpack 媒体库集合,ExoPlayer 已迁移到 Media3 命名空间,核心思想仍是 Player/MediaSource/Renderer 等组件。

Q3:Feed 视频怎么避免滑动卡顿? 答:控制同时播放数量、提前 prepare、复用 player 或控制 player 池、滑出及时释放 surface/解码资源,不要在 bind 阶段做重初始化。

易错点 / 追问

  • 不要混淆容器格式和编码格式。
  • 不要只说“加缓存“,要说明缓存 key、大小和清理策略。
  • 低延迟和稳定性通常是 tradeoff。

LeetCode Hot 100 算法清单

本清单即 LeetCode 热题 HOT 100,共 100 题,业界公认的标准刷题集。分类与顺序合理:按「数据结构由易到难 + 算法思想递进」组织。

针对你的定位(中级 Android 转应用):Android 算法面试以中等题为主。下方每个分类标注了 ⭐(高频必刷)和 △(偏难/低频,可缓刷),按优先级投入精力。

如何使用

  • 第一轮:只刷 ⭐ 高频题,建立每类的解题模板(约 40 题),覆盖大部分中小厂。
  • 第二轮:补齐其余中等题,冲大厂。
  • 第三轮:△ 难题(困难/低频)按目标公司选刷。
  • 每类先吃透 1-2 道“母题“模板,其余是变体。重在模板内化而非数量。

进度自测

勾选格式 - [x]。⭐=高频必刷,△=可缓刷。

哈希表(3)

  • ⭐ 1. 两数之和(简单)
  • 49. 字母异位词分组(中等)
  • 128. 最长连续序列(中等)

双指针(4)

  • ⭐ 283. 移动零
  • ⭐ 11. 盛最多水的容器
  • ⭐ 15. 三数之和
  • △ 42. 接雨水(困难)

滑动窗口(5)

  • ⭐ 3. 无重复字符的最长子串
  • 438. 找到字符串中所有字母异位词
  • ⭐ 560. 和为 K 的子数组
  • △ 239. 滑动窗口最大值(困难)
  • △ 76. 最小覆盖子串(困难)

普通数组(5)

  • ⭐ 53. 最大子数组和
  • ⭐ 56. 合并区间
  • ⭐ 189. 轮转数组
  • 238. 除自身以外数组的乘积
  • △ 41. 缺失的第一个正数(困难)

矩阵(4)

  • 73. 矩阵置零
  • ⭐ 54. 螺旋矩阵
  • 48. 旋转图像
  • 240. 搜索二维矩阵 II

链表(14)

  • ⭐ 160. 相交链表
  • ⭐ 206. 反转链表
  • 234. 回文链表
  • ⭐ 141. 环形链表
  • ⭐ 142. 环形链表 II
  • ⭐ 21. 合并两个有序链表
  • 2. 两数相加
  • ⭐ 19. 删除链表的倒数第 N 个结点
  • 24. 两两交换链表中的节点
  • △ 25. K 个一组翻转链表(困难)
  • 138. 随机链表的复制
  • 148. 排序链表
  • △ 23. 合并 K 个升序链表(困难)
  • ⭐ 146. LRU 缓存

二叉树(15)

  • ⭐ 94. 二叉树的中序遍历
  • ⭐ 104. 二叉树的最大深度
  • ⭐ 226. 翻转二叉树
  • ⭐ 101. 对称二叉树
  • 543. 二叉树的直径
  • ⭐ 102. 二叉树的层序遍历
  • 108. 将有序数组转换为二叉搜索树
  • ⭐ 98. 验证二叉搜索树
  • 230. 二叉搜索树中第 K 小的元素
  • 199. 二叉树的右视图
  • 114. 二叉树展开为链表
  • 105. 从前序与中序遍历序列构造二叉树
  • 437. 路径总和 III
  • ⭐ 236. 二叉树的最近公共祖先
  • △ 124. 二叉树中的最大路径和(困难)

图论(4)

  • ⭐ 200. 岛屿数量
  • 994. 腐烂的橘子
  • ⭐ 207. 课程表
  • 208. 实现 Trie (前缀树)

回溯(8)

  • ⭐ 46. 全排列
  • ⭐ 78. 子集
  • 17. 电话号码的字母组合
  • 39. 组合总和
  • 22. 括号生成
  • 79. 单词搜索
  • 131. 分割回文串
  • △ 51. N 皇后(困难)

二分查找(6)

  • ⭐ 35. 搜索插入位置
  • 74. 搜索二维矩阵
  • ⭐ 34. 在排序数组中查找元素的第一个和最后一个位置
  • ⭐ 33. 搜索旋转排序数组
  • 153. 寻找旋转排序数组中的最小值
  • △ 4. 寻找两个正序数组的中位数(困难)

栈(5)

  • ⭐ 20. 有效的括号
  • ⭐ 155. 最小栈
  • 394. 字符串解码
  • ⭐ 739. 每日温度
  • △ 84. 柱状图中最大的矩形(困难)

堆(3)

  • ⭐ 215. 数组中的第K个最大元素
  • ⭐ 347. 前 K 个高频元素
  • △ 295. 数据流的中位数(困难)

贪心(4)

  • ⭐ 121. 买卖股票的最佳时机
  • ⭐ 55. 跳跃游戏
  • 45. 跳跃游戏 II
  • 763. 划分字母区间

动态规划 - 单维(10)

  • ⭐ 70. 爬楼梯
  • 118. 杨辉三角
  • ⭐ 198. 打家劫舍
  • 279. 完全平方数
  • ⭐ 322. 零钱兑换
  • 139. 单词拆分
  • ⭐ 300. 最长递增子序列
  • 152. 乘积最大子数组
  • 416. 分割等和子集
  • △ 32. 最长有效括号(困难)

动态规划 - 多维(5)

  • ⭐ 62. 不同路径
  • 64. 最小路径和
  • ⭐ 5. 最长回文子串
  • ⭐ 1143. 最长公共子序列
  • △ 72. 编辑距离

技巧题(5)

  • ⭐ 136. 只出现一次的数字
  • ⭐ 169. 多数元素
  • 75. 颜色分类
  • 31. 下一个排列
  • 287. 寻找重复数

知识点解析

下面按分类讲解每类的核心思想、解题模板、复杂度、常见陷阱。吃透模板比刷题数量更重要。

1. 哈希表

核心思想:用 O(1) 查找换取时间,典型“空间换时间“。统计频次、判断存在、查找配对。

模板(两数之和):边遍历边查表,查 target - num 是否已出现。

fun twoSum(nums: IntArray, target: Int): IntArray {
    val map = HashMap<Int, Int>()          // value -> index
    for (i in nums.indices) {
        val need = target - nums[i]
        if (map.containsKey(need)) return intArrayOf(map[need]!!, i)
        map[nums[i]] = i
    }
    return intArrayOf()
}
  • 49 字母异位词分组:排序后的字符串作 key,或用字符计数数组作 key。
  • 128 最长连续序列:用 HashSet,只从“序列起点“(num-1 不存在)开始向后扩展,保证 O(n)。
  • 复杂度:多为 O(n) 时间、O(n) 空间。
  • 陷阱:128 题必须从起点扩展,否则退化成 O(n²)。

2. 双指针

核心思想:用两个指针协同移动,避免暴力双层循环。常见三类:快慢指针、左右对撞、同向滑动。

模板(左右对撞 - 盛最多水):

fun maxArea(height: IntArray): Int {
    var l = 0; var r = height.size - 1; var ans = 0
    while (l < r) {
        ans = maxOf(ans, minOf(height[l], height[r]) * (r - l))
        if (height[l] < height[r]) l++ else r--   // 移动短板
    }
    return ans
}
  • 283 移动零:快慢指针,慢指针指向待填位置。
  • 15 三数之和:排序 + 固定一个数 + 双指针,注意去重(跳过重复值)。
  • 42 接雨水(△):左右指针 + 维护 leftMax/rightMax;也可用单调栈或 DP。
  • 陷阱:三数之和的去重是高频踩坑点;移动短板的贪心要想清楚为什么不移长板。

3. 滑动窗口

核心思想:双指针的进阶,维护一个 [left, right] 窗口,右扩入、左缩出,在 O(n) 内解决子串/子数组问题。

通用模板:

fun slidingWindow(s: String): Int {
    val window = HashMap<Char, Int>()
    var left = 0; var ans = 0
    for (right in s.indices) {
        window[s[right]] = (window[s[right]] ?: 0) + 1
        while (/* 窗口不满足条件 */ window[s[right]]!! > 1) {
            window[s[left]] = window[s[left]]!! - 1   // 左缩
            left++
        }
        ans = maxOf(ans, right - left + 1)             // 更新答案
    }
    return ans
}
  • 3 无重复最长子串:窗口内无重复字符。
  • 438 找异位词 / 76 最小覆盖子串(△):用计数 + need/valid 计数判断窗口达标。
  • 560 和为 K 的子数组:注意这题不是滑窗(有负数),要用前缀和 + 哈希表
  • 239 滑动窗口最大值(△):用单调队列(双端队列),不是普通滑窗。
  • 陷阱:560 和 239 是“伪滑窗“,真正要用前缀和/单调队列,别套错模板。

4. 普通数组

核心思想:原地操作、前缀和、边界处理、贪心。看似基础,实则技巧密集。

  • 53 最大子数组和:经典 DP / Kadane —— dp[i] = max(num, dp[i-1]+num),维护全局最大。
fun maxSubArray(nums: IntArray): Int {
    var cur = nums[0]; var ans = nums[0]
    for (i in 1 until nums.size) {
        cur = maxOf(nums[i], cur + nums[i])
        ans = maxOf(ans, cur)
    }
    return ans
}
  • 56 合并区间:按起点排序,遍历合并重叠区间。
  • 189 轮转数组:三次反转法(整体反转 → 前 k 反转 → 后 n-k 反转),O(1) 空间。
  • 238 除自身以外乘积:前缀积 × 后缀积,不用除法。
  • 41 缺失第一个正数(△):原地哈希,把 num 放到 nums[num-1] 位置,O(1) 空间是难点。
  • 陷阱:41 题要求 O(1) 空间 + O(n) 时间,只能用原地置换;189 的三次反转要记牢。

5. 矩阵

核心思想:二维坐标变换、原地标记、利用有序性。考验空间想象力。

  • 73 矩阵置零:用首行首列做标记位,O(1) 额外空间(进阶);简单版用两个 set。
  • 54 螺旋矩阵:维护上下左右四个边界,按层收缩遍历。
  • 48 旋转图像:先转置(沿主对角线)再水平翻转,等价于顺时针 90°。
  • 240 搜索二维矩阵 II:从右上角开始,大则左移、小则下移,O(m+n)。
  • 陷阱:54 螺旋矩阵的边界判断和退出条件是 bug 高发区;48 旋转要记住“转置+翻转“组合。
// 48 旋转图像:转置 + 水平翻转
fun rotate(m: Array<IntArray>) {
    val n = m.size
    for (i in 0 until n) for (j in i + 1 until n) {
        val t = m[i][j]; m[i][j] = m[j][i]; m[j][i] = t   // 转置
    }
    for (row in m) row.reverse()                           // 每行翻转
}

6. 链表

核心思想:指针操作的细节功底。三大利器:虚拟头节点 dummy快慢指针递归。面试重中之重。

反转链表(母题,务必背熟):

fun reverseList(head: ListNode?): ListNode? {
    var prev: ListNode? = null
    var cur = head
    while (cur != null) {
        val next = cur.next
        cur.next = prev      // 反转指向
        prev = cur
        cur = next
    }
    return prev
}
  • dummy 头节点:删除/合并/插入时,在头前加哨兵节点,统一处理头节点边界(21、19、2、24、25)。
  • 快慢指针:141/142 判环(Floyd 龟兔,142 求入环点要推数学公式)、19 删倒数第 N 个(快指针先走 N 步)、找中点(148 排序、234 回文)。
  • 160 相交链表:双指针走到头切到对方链表,相遇即交点。
  • 138 随机链表复制:哈希表映射原节点→新节点,或原地交错复制。
  • 148 排序链表:归并排序(快慢找中点 + 合并),O(n log n)。
  • 23 合并 K 个(△):小顶堆 或 分治两两合并。
  • ⭐ 146 LRU 缓存:哈希表 + 双向链表——这题和你工作里的 LruCache 直接相关,面试高频,必须手写到熟。get/put 都要 O(1),双向链表维护访问顺序,哈希表定位节点。
  • 陷阱:142 入环点的数学推导;25 K 个一组翻转的边界;改指针时先存 next 防丢失。

7. 二叉树

核心思想:递归是灵魂。明确“以当前节点为根,左右子树要返回什么“,写好递归三要素(返回值、终止条件、单层逻辑)。

遍历模板:

// 递归中序
fun inorder(root: TreeNode?, res: MutableList<Int>) {
    if (root == null) return
    inorder(root.left, res)
    res.add(root.`val`)        // 中序:左-根-右
    inorder(root.right, res)
}
// 层序(BFS,用队列)
fun levelOrder(root: TreeNode?): List<List<Int>> {
    val res = mutableListOf<List<Int>>()
    val queue = ArrayDeque<TreeNode>()
    root?.let { queue.add(it) }
    while (queue.isNotEmpty()) {
        val level = mutableListOf<Int>()
        repeat(queue.size) {                  // 固定本层节点数
            val node = queue.removeFirst()
            level.add(node.`val`)
            node.left?.let { queue.add(it) }
            node.right?.let { queue.add(it) }
        }
        res.add(level)
    }
    return res
}
  • 递归类:104 深度、226 翻转、101 对称、543 直径、110 平衡——都是“后序+返回子树信息“。
  • BST 性质:98 验证(中序递增)、230 第 K 小(中序第 k 个)、108 有序数组转 BST(取中点为根)。
  • 构造/视图:102 层序、199 右视图(每层最后一个)、105 前序+中序建树、114 展开为链表。
  • 路径/祖先:236 最近公共祖先(LCA 递归)、437 路径总和 III(前缀和)、124 最大路径和(△,后序+全局更新)。
  • 陷阱:98 验证 BST 不能只比父子,要用上下界或中序;124/543 要区分“经过节点的路径“和“向上返回的贡献“。

8. 图论

核心思想:DFS/BFS 遍历 + 状态标记。网格类把矩阵当图,每个格子是节点。

  • 200 岛屿数量:遍历网格,遇到陆地 DFS/BFS 把整片淹掉(标记访问),计数。
  • 994 腐烂的橘子:多源 BFS,所有腐烂橘子同时入队,按层扩散记录分钟数。
  • 207 课程表:拓扑排序,建图 + 入度,BFS(Kahn)或 DFS 判环。能完成所有课 = 无环。
// 200 岛屿 DFS 淹没
fun dfs(grid: Array<CharArray>, i: Int, j: Int) {
    if (i !in grid.indices || j !in grid[0].indices || grid[i][j] != '1') return
    grid[i][j] = '0'                       // 标记已访问
    dfs(grid, i+1, j); dfs(grid, i-1, j)
    dfs(grid, i, j+1); dfs(grid, i, j-1)
}
  • 208 Trie 前缀树:每个节点 26 个子指针 + isEnd 标记,insert/search/startsWith。
  • 陷阱:207 必须判环(有环则无法完成);994 是多源 BFS 不是单源。

9. 回溯

核心思想:系统穷举解空间。模板 = 选择 → 递归 → 撤销选择。画出决策树就清晰了。

通用模板:

fun backtrack(path: MutableList<Int>, choices: ...) {
    if (/* 满足结束条件 */) { res.add(ArrayList(path)); return }
    for (choice in choices) {
        if (/* 剪枝:不合法跳过 */) continue
        path.add(choice)              // 做选择
        backtrack(path, ...)          // 进入下一层
        path.removeAt(path.size - 1)  // 撤销选择
    }
}
  • 排列 vs 组合:全排列(46)用 used 标记;子集(78)/组合(39)用 start 下标避免重复。
  • 39 组合总和:可重复选,递归传 i(不是 i+1);需排序剪枝。
  • 22 括号生成:左括号数 < n 可加左,右 < 左可加右。
  • 79 单词搜索 / 51 N皇后(△):网格/棋盘回溯 + 状态标记 + 回撤。
  • 131 分割回文串:每个切点判回文后递归。
  • 陷阱:结果要 ArrayList(path) 拷贝(否则引用同一个被清空);组合问题用 start 去重,排列用 used。

10. 二分查找

核心思想:有序(或部分有序)空间折半。难在边界:开闭区间、< vs <=mid±1坚持一种写法练到肌肉记忆

模板(闭区间):

fun search(nums: IntArray, target: Int): Int {
    var lo = 0; var hi = nums.size - 1       // 闭区间 [lo, hi]
    while (lo <= hi) {
        val mid = lo + (hi - lo) / 2          // 防溢出
        when {
            nums[mid] == target -> return mid
            nums[mid] < target -> lo = mid + 1
            else -> hi = mid - 1
        }
    }
    return -1
}
  • 35 搜索插入位置:返回 lo 即插入点。
  • 34 查找首尾位置:两次二分找左边界、右边界。
  • 33/153 旋转数组:判断哪半有序,再决定收缩方向。
  • 74 搜索二维矩阵:展平成一维做二分。
  • 4 两正序数组中位数(△):二分切分,O(log(m+n)),Hot100 最难之一,可缓刷。
  • 陷阱:mid = lo + (hi-lo)/2 防溢出;旋转数组要先判断有序半区。

11. 栈

核心思想:LIFO 后进先出。匹配类、表达式类用普通栈;“找下一个更大/更小元素“用单调栈

  • 20 有效括号:左括号入栈,右括号匹配栈顶。
  • 155 最小栈:辅助栈同步存当前最小值,getMin O(1)。
  • 394 字符串解码:双栈(数字栈 + 字符串栈)处理嵌套。
  • 739 每日温度:单调递减栈,存下标,出栈时算距离。
  • 84 柱状图最大矩形(△):单调递增栈,找每根柱子左右第一个更矮的。
// 739 每日温度:单调栈
fun dailyTemperatures(t: IntArray): IntArray {
    val res = IntArray(t.size)
    val stack = ArrayDeque<Int>()             // 存下标,栈内温度单调递减
    for (i in t.indices) {
        while (stack.isNotEmpty() && t[i] > t[stack.last()]) {
            val j = stack.removeLast()
            res[j] = i - j
        }
        stack.addLast(i)
    }
    return res
}
  • 陷阱:单调栈存下标还是值要想清楚;84 题常加哨兵简化边界。

12. 堆(优先队列)

核心思想:动态维护极值。Top K、数据流问题首选。Kotlin 用 PriorityQueue

  • 215 第 K 大:小顶堆维护 K 个最大(堆顶是第 K 大);或快速选择 O(n) 平均。
  • 347 前 K 高频:哈希计数 + 小顶堆按频次,或桶排序 O(n)。
  • 295 数据流中位数(△):大顶堆(左半)+ 小顶堆(右半) 对顶,中位数从堆顶取。
// 215 小顶堆维护 K 个最大值
fun findKthLargest(nums: IntArray, k: Int): Int {
    val heap = java.util.PriorityQueue<Int>()    // 小顶堆
    for (n in nums) {
        heap.offer(n)
        if (heap.size > k) heap.poll()            // 弹出最小,留下 K 个最大
    }
    return heap.peek()
}
  • 陷阱:求第 K 大用小顶堆(反直觉),求第 K 小用大顶堆;295 双堆要维护大小平衡。

13. 贪心

核心思想:每步取局部最优,期望达到全局最优。关键是证明贪心成立(否则要用 DP)。

  • 121 买卖股票:遍历记录历史最低价,每天算“今天卖“的最大利润。
  • 55 跳跃游戏:维护能到达的最远下标,遍历中若当前位置超过最远则失败。
  • 45 跳跃游戏 II:贪心找每一步能跳到的最远范围,边界处步数+1。
  • 763 划分字母区间:记录每个字母最后出现位置,遍历扩展当前区间右界,到界则切分。
// 55 跳跃游戏
fun canJump(nums: IntArray): Boolean {
    var farthest = 0
    for (i in nums.indices) {
        if (i > farthest) return false        // 到不了 i
        farthest = maxOf(farthest, i + nums[i])
    }
    return true
}
  • 陷阱:贪心不是万能,要确认局部最优能推全局;55/45 的“最远可达“思路要分清。

14. 动态规划

核心思想:把大问题拆成重叠子问题,用 dp 数组存历史结果避免重算。五步:定义状态 → 状态转移方程 → 初始化 → 遍历顺序 → 返回值。DP 是面试天花板,务必吃透母题。

单维 DP

  • 70 爬楼梯:dp[i] = dp[i-1] + dp[i-2](斐波那契),DP 入门母题。
  • 198 打家劫舍:dp[i] = max(dp[i-1], dp[i-2] + nums[i])(偷或不偷)。
  • 322 零钱兑换:完全背包,dp[amount] = min(dp[amount - coin] + 1)
  • 300 最长递增子序列:dp[i] = 以 i 结尾的 LIS,O(n²);进阶贪心+二分 O(n log n)。
  • 139 单词拆分:dp[i] = 前 i 个字符能否拆分。
  • 152 乘积最大子数组:同时维护最大、最小(负负得正)。
  • 416 分割等和子集:01 背包,目标 sum/2。
  • 32 最长有效括号(△):dp[i] = 以 i 结尾的最长有效长度,转移较绕。
// 322 零钱兑换(完全背包)
fun coinChange(coins: IntArray, amount: Int): Int {
    val dp = IntArray(amount + 1) { amount + 1 }   // 初始化为不可达
    dp[0] = 0
    for (i in 1..amount)
        for (c in coins)
            if (c <= i) dp[i] = minOf(dp[i], dp[i - c] + 1)
    return if (dp[amount] > amount) -1 else dp[amount]
}

多维 DP

  • 62 不同路径:dp[i][j] = dp[i-1][j] + dp[i][j-1](网格路径母题)。
  • 64 最小路径和:同上取 min + 当前值。
  • 5 最长回文子串:区间 DP,dp[i][j] = s[i..j] 是否回文;或中心扩展。
  • 1143 最长公共子序列(LCS):dp[i][j],相等 +1,否则取两侧 max——序列型 DP 母题
  • 72 编辑距离(△):dp[i][j] = word1 前 i 转 word2 前 j 的最少操作(增删改三选一取 min)。
// 1143 LCS:序列型 DP 母题
fun longestCommonSubsequence(a: String, b: String): Int {
    val dp = Array(a.length + 1) { IntArray(b.length + 1) }
    for (i in 1..a.length) for (j in 1..b.length) {
        dp[i][j] = if (a[i-1] == b[j-1]) dp[i-1][j-1] + 1
                   else maxOf(dp[i-1][j], dp[i][j-1])
    }
    return dp[a.length][b.length]
}
  • 陷阱:dp 数组大小常为 n+1(含空状态);初始化和遍历顺序错是主要 bug;先吃透 LCS、01背包、完全背包三个母题,其余是变体。

15. 技巧题

核心思想:发现问题的特殊性质,用位运算、数学、特定结构巧解。考察活用能力。

  • 136 只出现一次的数字:异或,相同抵消,剩下的就是答案,O(1) 空间。
  • 169 多数元素:摩尔投票,众数计数抵消法,O(1) 空间。
  • 75 颜色分类:荷兰国旗,三指针(0/1/2)一次遍历原地排序。
  • 31 下一个排列:从右找第一个升序对,交换 + 反转后缀。
  • 287 寻找重复数:把数组看成链表,快慢指针找环(Floyd),不修改数组、O(1) 空间。
// 136 只出现一次:异或
fun singleNumber(nums: IntArray): Int {
    var x = 0
    for (n in nums) x = x xor n      // a^a=0, a^0=a
    return x
}
  • 陷阱:这类题“知道就秒、不知道想破头“,务必记住每题的 trick(异或、摩尔投票、Floyd 判环)。

刷题策略总结

高频母题(背到能默写)

反转链表、二叉树三种遍历 + 层序、二分模板、回溯模板、滑动窗口模板、01背包/完全背包/LCS。这 7 类模板覆盖了 Hot 100 的 70%。

针对中级 Android 的优先级

  1. 第一梯队(必刷,中小厂够用):本文 ⭐ 标记的 ~40 题。尤其 146 LRU(和 LruCache 直接相关,Android 超高频)、链表全套、二叉树遍历、岛屿数量、爬楼梯/打家劫舍/零钱兑换、买卖股票、20 有效括号。
  2. 第二梯队(冲大厂):其余中等题。
  3. 第三梯队(△ 困难,选刷):42 接雨水、4 中位数、23 合并K个、25 K个一组、84 柱状图、124 最大路径和、51 N皇后、72 编辑距离。时间不够可跳过,面到再说。

方法论

  • 分类刷,不要顺序刷:一次攻一个专题,内化模板。
  • 二刷三刷:第一遍看题解理解,第二遍独立写,第三遍默写模板。
  • 手写 + 口述复杂度:面试要边写边讲思路和时间/空间复杂度。
  • 限时:中等题目标 20-30 分钟内 AC。
  • 错题本:记录卡壳点和 trick,面试前快速过。

算法补充专题

Hot 100 没专门成章、但面试(尤其国内)高频会问的几类经典主题。每节含知识点 + 模板代码 + 高频题 + 陷阱

优先级:排序手写 ⭐ 和位运算 ⭐ 几乎必问,优先吃透;并查集、前缀和/差分、设计题中频,看公司。

进度自测

  • ⭐ 手写快速排序(分区、复杂度、最坏情况)
  • ⭐ 手写归并排序(稳定、O(n log n))
  • ⭐ 手写堆排序(建堆、下沉)
  • 排序对比:稳定性 / 复杂度 / 适用场景
  • ⭐ 位运算基础(& | ^ << >> >>>)
  • n & (n-1)、判 2 的幂、统计 1 的个数
  • 出现 3 次只出 1 次、子集枚举、状态压缩
  • 并查集模板(find 路径压缩 + union 按秩)
  • 并查集应用:省份数量 547 / 冗余连接 684
  • 一维前缀和 / 二维前缀和
  • 差分数组(区间增减)
  • 设计题:LFU 460 / 用栈实现队列 232 / 循环队列 622

1. 手写排序(⭐ 国内几乎必问)

面试常让你手写并分析,而不只是调 API。重点掌握快排、归并、堆排,能说清复杂度、稳定性、适用场景。

快速排序

分治:选 pivot → 分区(小的在左、大的在右)→ 递归两边。平均 O(n log n),最坏 O(n²)(已有序且固定选端点),不稳定,原地。

fun quickSort(a: IntArray, lo: Int, hi: Int) {
    if (lo >= hi) return
    val p = partition(a, lo, hi)
    quickSort(a, lo, p - 1)
    quickSort(a, p + 1, hi)
}
fun partition(a: IntArray, lo: Int, hi: Int): Int {
    val pivot = a[hi]                    // 取末尾为基准
    var i = lo
    for (j in lo until hi) {
        if (a[j] < pivot) { a[i] = a[j].also { a[j] = a[i] }; i++ }
    }
    a[i] = a[hi].also { a[hi] = a[i] }   // pivot 归位
    return i
}

优化:随机选 pivot 避免最坏;三数取中;小数组转插入排序。

归并排序

分治:对半拆 → 递归排序 → 合并两个有序数组。稳定,O(n log n) 恒定,需 O(n) 额外空间。链表排序(148)首选。

fun mergeSort(a: IntArray, lo: Int, hi: Int, tmp: IntArray) {
    if (lo >= hi) return
    val mid = lo + (hi - lo) / 2
    mergeSort(a, lo, mid, tmp); mergeSort(a, mid + 1, hi, tmp)
    var i = lo; var j = mid + 1; var k = lo
    while (i <= mid && j <= hi) tmp[k++] = if (a[i] <= a[j]) a[i++] else a[j++]
    while (i <= mid) tmp[k++] = a[i++]
    while (j <= hi) tmp[k++] = a[j++]
    for (x in lo..hi) a[x] = tmp[x]
}

堆排序

建大顶堆 → 反复把堆顶(最大)换到末尾并下沉。原地,不稳定,O(n log n)。理解它就理解了优先队列。

fun heapSort(a: IntArray) {
    val n = a.size
    for (i in n / 2 - 1 downTo 0) sink(a, i, n)      // 建堆 O(n)
    for (end in n - 1 downTo 1) {
        a[0] = a[end].also { a[end] = a[0] }          // 堆顶换到末尾
        sink(a, 0, end)                               // 缩小范围下沉
    }
}
fun sink(a: IntArray, i: Int, n: Int) {
    var root = i
    while (true) {
        var largest = root; val l = 2*root+1; val r = 2*root+2
        if (l < n && a[l] > a[largest]) largest = l
        if (r < n && a[r] > a[largest]) largest = r
        if (largest == root) break
        a[root] = a[largest].also { a[largest] = a[root] }
        root = largest
    }
}

排序对比(高频问)

算法平均最坏空间稳定备注
快排O(n log n)O(n²)O(log n)实际最快,通用
归并O(n log n)O(n log n)O(n)稳定/链表/外部排序
堆排O(n log n)O(n log n)O(1)Top-K、原地
插入O(n²)O(n²)O(1)小数组/近乎有序快
冒泡O(n²)O(n²)O(1)仅教学
  • 稳定:相等元素相对顺序不变(归并/插入/冒泡稳定;快排/堆排不稳定)。
  • 陷阱:快排最坏 O(n²) 的触发条件(已有序+固定 pivot)和随机化优化是必答点;Java 的 Arrays.sort 基本类型用双轴快排、对象用 TimSort(稳定)。

2. 位运算专题(⭐)

核心运算:&(与)、|(或)、^(异或)、~(取反)、<<(左移)、>>(算术右移,补符号位)、>>>(逻辑右移,补 0)。

异或三大性质(高频):a^a=0a^0=a、满足交换/结合律。用于找单身数、交换变量、加密。

必背技巧:

n and (n - 1)            // 消除最低位的 1
n and (-n)               // 取最低位的 1(lowbit,树状数组用)
(n and (n - 1)) == 0     // 判断是否 2 的幂(n>0)
x shr i and 1            // 取第 i 位
x or (1 shl i)           // 置第 i 位为 1
x and (1 shl i).inv()    // 清第 i 位
x xor (1 shl i)          // 翻转第 i 位

经典题:

  • 统计 1 的个数(191):while (n != 0) { n = n and (n-1); count++ },循环次数 = 1 的个数。
  • 2 的幂(231)/4 的幂:n > 0 && (n and (n-1)) == 0
  • 只出现一次(136):全员异或。
  • 出现 3 次只出 1 次(137):按位统计模 3,或用两个状态变量。
  • 只出现一次 II(260,两个数):全异或得 a^b,用 lowbit 分组再各自异或。
  • 子集枚举(78):用 0..(1<<n)-1 每个 bit 表示选/不选。
  • 状态压缩 DP:用 int 的 bit 表示集合状态(如旅行商、棋盘覆盖)。
// 78 子集:位运算枚举
fun subsets(nums: IntArray): List<List<Int>> {
    val res = mutableListOf<List<Int>>()
    val n = nums.size
    for (mask in 0 until (1 shl n)) {          // 2^n 种状态
        val sub = mutableListOf<Int>()
        for (i in 0 until n) if (mask shr i and 1 == 1) sub.add(nums[i])
        res.add(sub)
    }
    return res
}
  • 陷阱:Kotlin 用 and/or/xor/shl/shr/ushr 关键字而非符号;>>>>> 区别(负数右移);判 2 的幂别忘了 n > 0

3. 并查集(Union-Find)

核心思想:维护不相交集合,支持两个近 O(1) 操作:find(找代表元/根)、union(合并两集合)。专治连通性 / 分组问题。

模板(路径压缩 + 按秩合并):

class UnionFind(n: Int) {
    private val parent = IntArray(n) { it }   // 初始各自为根
    private val rank = IntArray(n)             // 树高(秩)
    var count = n; private set                 // 连通分量数

    fun find(x: Int): Int {
        if (parent[x] != x) parent[x] = find(parent[x])  // 路径压缩
        return parent[x]
    }
    fun union(a: Int, b: Int) {
        val ra = find(a); val rb = find(b)
        if (ra == rb) return
        when {                                  // 按秩合并:矮树挂高树
            rank[ra] < rank[rb] -> parent[ra] = rb
            rank[ra] > rank[rb] -> parent[rb] = ra
            else -> { parent[rb] = ra; rank[ra]++ }
        }
        count--
    }
    fun connected(a: Int, b: Int) = find(a) == find(b)
}

应用:

  • 547 省份数量:把相连城市 union,最后数连通分量。

  • 684 冗余连接:加边时若两端已连通,该边就是多余的。

  • 200 岛屿数量:也可用并查集(陆地格子 union 相邻陆地)。

  • 判环 / 等式方程可满足性(990)

  • 复杂度:加上路径压缩 + 按秩合并后,单次操作近乎 O(α(n))(反阿克曼,可视作常数)。

  • 陷阱:别忘路径压缩(否则退化成链 O(n));带权并查集是进阶变体。

4. 前缀和 & 差分

互为逆运算的一对技巧。前缀和:O(1) 查区间和(适合“多次查询、不修改“)。差分:O(1) 做区间增减(适合“多次区间修改、最后查询“)。

一维前缀和

pre[i] = nums[0] + ... + nums[i-1],区间 [l, r] 和 = pre[r+1] - pre[l]

val pre = IntArray(n + 1)
for (i in 0 until n) pre[i + 1] = pre[i] + nums[i]
// 区间 [l, r] 之和:
val sum = pre[r + 1] - pre[l]
  • 560 和为 K 的子数组:前缀和 + 哈希表,找 pre[j] - pre[i] = k,即统计 pre[j] - k 出现次数。
  • 724 寻找中心下标304 二维区域和

二维前缀和

pre[i][j] = 左上角到 (i-1,j-1) 的矩形和。容斥:

区域和 = pre[r2+1][c2+1] - pre[r1][c2+1] - pre[r2+1][c1] + pre[r1][c1]

差分数组

diff[i] = nums[i] - nums[i-1]。对区间 [l, r] 同时加 val,只需 diff[l] += val; diff[r+1] -= val,最后对 diff 求前缀和还原。

// 给区间 [l, r] 都加 val(多次操作)
diff[l] += value
if (r + 1 < n) diff[r + 1] -= value
// 最后还原:对 diff 求前缀和
for (i in 1 until n) diff[i] += diff[i - 1]
  • 1109 航班预订统计1094 拼车:典型差分。
  • 陷阱:前缀和数组开 n+1 长度用 0 哨兵简化边界;差分注意 r+1 越界判断;560 题有负数不能用滑窗,只能前缀和。

5. 设计题

数据结构组合 + 工程抽象能力,和你做 SDK 的强项契合。核心套路:用合适的数据结构组合,让每个操作达到要求的复杂度。

LRU 缓存(146,Hot 100 已有,这里强调)

哈希表 + 双向链表:哈希表 O(1) 定位节点,双向链表维护访问顺序(头=最近,尾=最久)。get/put 均 O(1)。和你工作中的 LruCache 直接相关,务必手写到熟。

LFU 缓存(460,难)

使用频次淘汰,频次相同淘汰最久未用。结构:key→节点 哈希 + freq→双向链表 哈希 + 维护 minFreq。比 LRU 复杂一档,大厂常考。

用栈实现队列(232)/ 用队列实现栈(225)

  • 栈→队列:两个栈(in 栈、out 栈),out 空时把 in 全部倒过来。摊还 O(1)。
  • 队列→栈:入队后把前面元素轮转到后面。

设计循环队列(622)

固定数组 + 头尾指针 + 取模。判空 head == tail,判满留一个空位或用 size 计数。

设计 HashMap(706)/ 设计 Trie(208)

  • HashMap:数组 + 链表(拉链法)处理冲突,理解负载因子和扩容。
  • Trie:见 Hot 100 图论节,26 叉树 + isEnd。
// 232 两个栈实现队列
class MyQueue {
    private val inStack = ArrayDeque<Int>()
    private val outStack = ArrayDeque<Int>()
    fun push(x: Int) = inStack.addLast(x)
    fun pop(): Int { transfer(); return outStack.removeLast() }
    fun peek(): Int { transfer(); return outStack.last() }
    fun empty() = inStack.isEmpty() && outStack.isEmpty()
    private fun transfer() {
        if (outStack.isEmpty())
            while (inStack.isNotEmpty()) outStack.addLast(inStack.removeLast())
    }
}
  • 陷阱:设计题先和面试官确认接口和复杂度要求(像做 API 设计),再选数据结构;LFU 维护 minFreq 是难点;循环队列的判空判满边界。

小结:补充专题优先级

  1. 必吃透(几乎必问):手写快排/归并/堆排 + 三者对比、位运算技巧。
  2. 建议掌握:并查集模板 + 547、前缀和/差分(尤其前缀和配哈希)。
  3. 加分:设计题(LFU、栈队互换)——展示工程抽象,和你 SDK 背景契合。

海量数据处理

这一篇是你的差异化武器。 海量数据题考的是“内存放不下时怎么办“的系统思维——而你做设备指纹 SDK,天天和内存约束、大规模数据、性能极限打交道。一般应用开发者答不深,你能结合底层经验讲透,这是面试加分点。

核心套路就四招:分治(哈希拆分)、位图、布隆过滤器、堆/外部排序

进度自测

  • 哈希分治(大文件拆小文件)
  • 位图 BitMap(去重/排序/查存在)
  • 布隆过滤器(原理/误判/删除问题)
  • Top-K(小顶堆 / 分治 / 快速选择)
  • 外部排序(多路归并)
  • 经典场景:40亿整数判存在 / 10亿URL去重 / Top100热词

一、核心思想:内存放不下怎么办

面试给的经典约束:数据量远超内存(如 40 亿整数、100GB 日志,但内存只有 1GB)。解题主线:

  1. 能不能压缩表示? → 位图(1 个整数用 1 bit)。
  2. 能不能拆分? → 哈希分治(相同 key 必落同一小文件,分而治之)。
  3. 只要近似/允许误判? → 布隆过滤器(极省空间)。
  4. 只要前 K / 排序? → 堆 / 外部多路归并。

二、位图 BitMap

思想:用一个 bit 表示一个数是否存在。40 亿个 int 若用 int 数组要 16GB,用位图只需 40亿/8 ≈ 500MB,省 32 倍

判断整数 x 是否存在:
  字节下标 = x / 8,位下标 = x % 8
  set:  bitmap[x/8] |= (1 << (x%8))
  get:  bitmap[x/8] &  (1 << (x%8))
  • 应用:40 亿无符号整数判某数是否存在 / 去重 / 排序(置位后顺序扫描)。
  • 进阶 Bitmap(2-bit 图):每个数用 2 bit,表示“出现 0 次 / 1 次 / 多次“,可解“找出现一次的数 / 找重复的数“。
  • 局限:只适合整数且范围有限;数据稀疏时浪费(此时用哈希或 Roaring Bitmap 压缩位图)。
  • 联系你的背景:这正是底层/SDK 常用的空间压缩手段,可主动提及“做指纹去重时用过类似位压缩思路“。

三、布隆过滤器(Bloom Filter)

思想:位图 + 多个哈希函数。判断元素“一定不存在“或”可能存在“。极省空间,代价是有误判率(false positive)。

1. 核心机制与近似公式

插入 x:用 k 个哈希函数算出 k 个位置,全部置 1。
查询 x:k 个位置全为 1 → 可能存在;任一为 0 → 一定不存在。
  • 误判率 p 的近似公式:p ≈ (1 - e^(-kn/m))^k
    • m: 位数组的长度(bit 数)
    • n: 预计插入的元素个数
    • k: 哈希函数的个数
  • 最优 k 值的直觉推导:k ≈ (m/n)·ln2 ≈ 0.7·(m/n)
    • 为什么 k 不能太小? 如果哈希函数太少,位图中会有大量闲置的 0 没被利用,区分度低,导致误判率变高。
    • 为什么 k 不能太大? 如果哈希函数过多,每次插入都会将大量的 bit 置为 1,位图很快就被填满,导致后续查询大概率全命中 1,误判率急剧上升。

2. 空间估算示例(Android 面试语境)

假设在风控 SDK 中,我们需要在本地拦截 10 万个恶意设备黑名单(n = 100,000),且要求误判率低于 1%(p = 0.01)。 根据估算公式 m ≈ -n·ln(p) / (ln2)^2:

  • 所需位数组大小 m 约为 96 万 bit。
  • 折算成内存: 960,000 / 8 / 1024 ≈ 117 KB。
  • 面试话术: “比起把 10 万个 32 字节的 String 设备指纹存入内存(约 3MB),使用布隆过滤器可以将黑名单压缩到 100KB 左右,这对 Android 端内存极其友好,且 O(k) 的查询速度完全满足主线程流畅度要求。”

3. 删除与计数权衡 (Counting Bloom Filter)

  • 不支持删除: 标准布隆过滤器如果将某个位置 0,可能会影响其他同样映射到该位的元素。

  • 解法 (Counting Bloom Filter): 将原本的 1 个 bit 扩展为一个计数器(例如 4-bit 数组)。插入时计数器 +1,删除时计数器 -1。

  • 空间权衡: 虽然支持了删除,但空间开销直接膨胀(如 4-bit 计数器使占用翻 4 倍),且计数器有溢出风险。需在“是否必须删除“与“内存占用上限“间做取舍。

  • 应用:缓存穿透防护(Redis 前挡一层)、爬虫 URL 去重、垃圾邮件过滤、判断 key 是否可能在数据库。

  • 联系你的背景:风控/反作弊里黑名单判断、设备去重就常用布隆过滤器,这是你能讲实战的点。

四、哈希分治(分而治之)

思想:大文件按 hash(key) % N 拆成 N 个小文件,相同 key 必进同一小文件。每个小文件能进内存后单独处理,再汇总。

模板流程:

  1. 遍历大文件,按哈希把记录分到 N 个小文件。
  2. 对每个小文件单独用哈希表/堆处理(统计频次、去重、Top-K)。
  3. 合并各小文件的结果(如各自 Top-K 再归并出全局 Top-K)。
  • 应用:10 亿 URL 去重、统计每个词的频次、求两个大文件的交集。
  • 要点:分治的前提是“相同 key 落同一桶“,所以必须用 key 的哈希分,不能随便切。

五、Top-K 问题

三种解法按场景选:

  • 小顶堆(数据流/超大数据):维护大小为 K 的小顶堆,堆顶是第 K 大,O(n log K) 时间、O(K) 空间。海量数据首选(不用全载入)。
  • 快速选择(数据能进内存):基于快排分区,平均 O(n) 找第 K 大,但会修改/需载入数据。
  • 哈希分治 + 堆(数据放不下):先哈希分治统计频次,各桶取局部 Top-K,再归并。

→ Top-100 热搜词:哈希分治统计词频 + 每桶小顶堆 + 归并。

六、外部排序

思想:数据放不下内存时的排序。分块 + 多路归并:

  1. 把大文件切成能进内存的小块,各块在内存排序后写回磁盘(生成“顺串“)。
  2. 多路归并(K 路败者树/小顶堆)把有序小块合并成全局有序。
  • 应用:100GB 日志按时间排序、超大文件去重后排序。
  • 联系:归并排序的磁盘版,体现你对 I/O 与内存权衡的理解。

七、经典场景速答

场景解法
40 亿整数判断某数是否存在位图(~500MB)
40 亿整数找只出现一次的2-bit 位图
10 亿 URL 去重哈希分治 / 布隆过滤器(允许误判)
100GB 日志 Top-100 热词哈希分治统计词频 + 小顶堆 + 归并
求两个超大文件的交集各自哈希分治到对应桶,桶内求交
100GB 文件排序外部排序(分块 + 多路归并)
缓存穿透防护布隆过滤器挡在缓存前
数据流求中位数大顶堆 + 小顶堆对顶(Hot 100 #295)

面试怎么讲(结合你的优势)

回答海量数据题时,先问清约束(数据量、内存、是否允许误判、要精确还是近似),再选招式,最后说权衡。这套“先问约束再设计“的思路本身就是工程素养。

你可以主动关联:

“我做设备指纹 SDK 时,设备去重和黑名单判断都涉及大规模数据 + 内存约束。用过位压缩做去重、布隆过滤器做快速存在性判断,对空间/时间/误判率的权衡有实战体会。”

这一句话就把算法题变成了你的项目亮点,是普通应用开发者给不出的答案。

Android 业务算法场景

Android 面试里的算法不只 LeetCode。真实业务更常问:图片缓存怎么淘汰、埋点怎么采样、请求怎么限流、日志怎么去重、Feed 分页怎么合并、设备指纹怎么做相似度。本篇把算法落到移动端工程场景。

一、LRU cache 与图片缓存淘汰

LRU(Least Recently Used)适合“最近访问还会再访问”的局部性场景。Android 图片库、页面数据缓存、解码 Bitmap 复用池都常用 LRU 或近似 LRU。

场景KeyValue淘汰依据注意点
内存图片缓存URL + resize + transformBitmap/Drawable占用字节数防 OOM,按 maxMemory 比例设置
磁盘图片缓存安全 hash 后的 URL文件总大小/最近访问避免文件名过长,写入原子性
页面接口缓存route + paramsJSON/EntityTTL + LRU过期和一致性比命中率更重要
BitmapPoolwidth/height/config可复用 Bitmap大小分桶避免频繁分配导致 GC
class BitmapMemoryCache(maxBytes: Int) : LruCache<String, Bitmap>(maxBytes) {
    override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
}

图片缓存面试要讲完整链路:内存 LRU → 磁盘 LRU → 网络下载 → 解码采样 → 写缓存 → 生命周期取消。只讲“用 HashMap + 双向链表”不够,要补 Android 内存预算、列表复用和 OOM 风险。

二、日志采样、去重与压缩上报

埋点/APM/Crash SDK 都会遇到“数据太多不能全传”的问题。业务算法目标是降低流量、电量和服务端压力,同时保留可分析性。

  • 固定比例采样:按随机数或用户 hash 采样,适合普通埋点。
  • 稳定采样:按 hash(userId/deviceId) % 100 < rate 决定,同一用户长期一致,便于漏斗分析。
  • 分层采样:错误、Crash、支付链路高采样;普通曝光低采样。
  • 去重:短时间重复日志用 (eventName, page, keyParams) 做 fingerprint,窗口内只保留一次或计数。
  • 批量压缩:本地队列按数量/大小/时间触发 gzip 上报,失败后退避重试。
event → fingerprint → sliding window dedup
      → sampling decision
      → local queue(Room/file)
      → batch gzip upload with retry

三、限流、滑动窗口与重试保护

限流(rate limiting)在移动端用于保护接口、SDK 回调、日志上报、按钮连点和弱网重试。常见算法要结合业务选择。

算法机制适用场景缺点
固定窗口每个时间窗最多 N 次简单按钮防抖、低风险接口窗口边界可能突刺
滑动窗口记录最近 T 时间内请求数登录、验证码、上报保护需要维护时间队列
令牌桶固定速率生成 token,允许突发网络请求、日志上报参数要调优
漏桶匀速流出平滑上传队列突发吸收能力弱

滑动窗口移动端实现直觉:

  1. 用队列保存事件时间戳。
  2. 新事件到来时移除超过窗口的旧时间。
  3. 队列大小小于阈值则允许,否则拒绝或延迟。
  4. 对持久化场景可只存计数桶,避免内存无限增长。

四、设备指纹相似度、布隆过滤器与本地风控

设备指纹/风控 SDK 常需要“快速判断是否见过、是否相似、是否命中黑名单”。这类题能把你的业务背景讲成算法亮点。

  • 指纹相似度:把设备属性向量化,对稳定字段加高权重(硬件、系统特征),对易变字段低权重(IP、网络);用加权 Jaccard/余弦相似判断是否同设备族。
  • SimHash/局部敏感哈希:把高维特征压成指纹,海明距离小表示相似,适合快速近似匹配。
  • 布隆过滤器:本地黑名单、已上报 ID、去重 key 的快速存在性判断;回答“一定不存在/可能存在”和误判率。
  • Counting Bloom Filter:需要删除时使用计数器,但空间变大。
  • 隐私注意:指纹算法要服务于合规风控,最小化采集、脱敏、加密存储,不能无限收集敏感信息。

五、TopK、热点统计与本地搜索

移动端也有 TopK:热门搜索词、最近联系人、异常日志 TopN、耗时接口 TopN。核心是不要全量排序。

  • 小顶堆 TopK:维护大小 K 的堆,新元素大于堆顶才替换,O(n log K)。适合日志/性能指标本地聚合。
  • Space Saving/Misra-Gries:近似高频统计,适合内存很小但数据流很大的场景。
  • Trie/倒排索引:本地搜索联系人、城市、商品名;前缀匹配用 Trie,关键词搜索用倒排。
  • 拼音/模糊搜索:联系人搜索要建立 name、pinyin、首字母索引,并做结果排序。
  • Room FTS:正文搜索可用 SQLite FTS,比 like '%x%' 更适合大文本。
本地搜索索引:
keyword/token → [entityId1, entityId2, ...]
查询:分词/拼音归一化 → 取倒排列表 → 交并集 → 按权重排序

六、分页合并、去重与一致性

Feed、IM、订单列表常见问题:分页返回重复、刷新和加载更多交错、服务端数据更新导致顺序变化。本质是有序流合并与去重。

  1. 唯一 key 去重:用 itemId/serverId 去重,不要用 position。
  2. 游标分页优先:使用 cursor/lastId/createdAt,比 offset 更稳。
  3. 本地合并:新页与旧列表按排序 key 归并,重复 item 更新内容。
  4. 状态分离:refresh、append、prepend 分别维护 loading/error/cursor。
  5. Room + Paging3:RemoteMediator 把网络页落库,UI 观察数据库,降低进程死亡和旋转带来的状态丢失。

常见坑:服务端删除或置顶会改变排序,客户端只 append 可能出现缺失或重复;要定期 refresh 或使用版本号/增量同步。


高频面试题

Q1:设计图片内存缓存为什么用 LRU? 列表和详情页存在时间局部性,最近展示过的图片很可能再次出现。LRU 能在固定内存预算下保留热点 Bitmap,但要按字节数计算 size,并结合磁盘缓存、采样解码和生命周期取消。

Q2:埋点日志太多怎么上报? 用稳定采样控制比例,错误链路提高采样;对短时间重复事件做 fingerprint 去重或合并计数;本地队列批量 gzip 上报,失败指数退避,并设置磁盘上限防止撑爆存储。

Q3:移动端限流怎么实现? 按钮防抖可固定窗口,接口/上报更适合滑动窗口或令牌桶。要限制次数、设置退避,对非幂等请求避免自动重发,必要时让服务端用幂等 key 去重。

Q4:布隆过滤器适合哪些 Android 业务? 适合本地黑名单、已上报事件去重、缓存穿透保护、设备指纹快速存在性判断。它能回答“一定不存在/可能存在”,有误判但不会漏判,需要删除时用 Counting Bloom Filter。

易错点 / 追问

  • 易错:只背 LRU 的 HashMap+双向链表,不讲 Bitmap 字节数、OOM、磁盘缓存和生命周期。
  • 追问:滑动窗口和固定窗口区别?滑动窗口按最近 T 时间精确限制,固定窗口边界可能出现双倍突刺。
  • 易错:TopK 直接全量排序;数据流或日志聚合应使用大小为 K 的小顶堆或近似高频算法。
  • 追问:分页去重为什么不能按 position?刷新、插入、置顶会改变位置,必须用稳定业务 id。

操作系统与数据库基础

计算机基础四大件的另外两件(网络在 18 篇)。操作系统这块你底层强,容易拿分;数据库移动端偏 SQLite,掌握核心概念即可。

第一部分:操作系统

一、进程、线程、协程

进程线程协程
资源独立地址空间共享进程内存共享线程,用户态
调度OSOS用户/运行时
开销
通信IPC共享内存(需同步)直接
  • 进程:资源分配的基本单位,有独立内存空间。
  • 线程:CPU 调度的基本单位,共享进程资源,需同步(锁)。
  • 协程:用户态轻量“线程“,由程序调度,挂起不阻塞内核线程(见 02 篇 Kotlin 协程)。

二、进程间通信(IPC)

管道、消息队列、共享内存(最快,需同步)、信号量、Socket、信号。Android 主用 Binder(见 09 篇,一次拷贝 + 安全)。

三、内存管理

  • 虚拟内存:每个进程有独立虚拟地址空间,通过页表映射到物理内存,实现隔离 + 按需加载。
  • 分页:内存分固定大小页,虚拟页↔物理页帧映射,缺页中断时从磁盘加载。
  • 页面置换:内存不够时选择淘汰哪个页,目标是降低未来缺页率。

页面置换算法

算法机制优点缺点/坑点Android/Linux 关联
FIFO淘汰最早进入内存的页实现简单可能出现 Belady 异常:分配页框更多反而缺页更多面试用来说明“简单策略不等于命中率高”
LRU淘汰最长时间未访问的页符合时间局部性精确维护访问顺序成本高系统常做近似 LRU,App 侧 LruCache 是同类思想
LFU淘汰访问次数最低的页适合长期热点稳定场景旧热点可能因历史计数过高难淘汰,需衰减更常见于缓存策略讨论,OS 页面置换较少直接精确使用
Clock/二次机会页形成环,访问位为 1 则清零并跳过,为 0 才淘汰近似 LRU,实现成本低只能粗略表达“最近是否访问过”操作系统常用近似策略,兼顾成本与效果

Belady 异常:FIFO 不考虑局部性,在某些访问序列中增加页框会改变淘汰顺序,导致缺页次数反而上升;LRU 属于栈算法,不会出现这种异常。

面试答题流:先讲缺页中断和淘汰目标,再用 FIFO/LRU/Clock 对比“实现成本 vs 命中率”,最后联系 Android:App 内存压力会触发 LMK/进程回收,而 Linux 内核页回收也会用近似 LRU 思路维护活跃/非活跃页。

  • MMU / TLB:硬件做地址翻译,TLB 缓存页表项加速。
  • 用户态 vs 内核态:特权级隔离,系统调用/中断时从用户态陷入内核态。

四、并发与同步

  • 死锁四条件:互斥、持有并等待、不可剥夺、循环等待。破坏任一即可避免(如按序申请资源破坏循环等待)。
  • 临界区:互斥访问共享资源的代码段。
  • 同步原语:互斥锁、信号量(Semaphore)、条件变量、读写锁。
  • CPU 调度:在多个可运行任务之间分配 CPU,目标通常是吞吐、响应时间、公平性和实时性之间折中。

CPU 调度算法

算法机制优点缺点/饥饿问题适用直觉
FCFS先来先服务,非抢占简单、无饥饿短任务可能被长任务堵住(护航效应)批处理直觉,交互系统不理想
SJF / SRTF优先执行预计时间最短任务;SRTF 是抢占版平均等待时间低难准确预估执行时间,长任务可能饥饿理论题常考,实际需估算
时间片轮转(RR)每个任务运行一个时间片,到期切换响应公平,适合交互时间片太小切换开销大,太大退化为 FCFSUI/交互系统强调响应
优先级调度高优先级先运行能表达重要性/实时性低优先级可能饥饿,需 aging 提升等待过久任务Android 线程优先级、后台任务降级
多级反馈队列(MLFQ)多队列不同优先级/时间片,任务按行为升降级兼顾交互与吞吐参数复杂,可能被行为模式影响通过反馈识别短交互任务与长 CPU 任务

Linux/Android 关联:普通 Linux 任务主要由 CFS(Completely Fair Scheduler) 调度,它不是简单 RR,而是用虚拟运行时间 vruntime 近似公平:谁“用得少”谁更容易被调度。Android 在此基础上叠加线程优先级、cgroup/cpuset、前后台进程调度策略;UI 线程应避免长时间占 CPU,否则即使能被调度也会错过 16.6ms 帧预算。

面试答题流:先说目标(公平/响应/吞吐),再逐个算法讲机制和缺点,最后补“真实 Linux/Android 用 CFS + 优先级/cgroup,不是直接套书本算法”。

五、I/O 模型(进阶,可选)

阻塞 I/O、非阻塞 I/O、I/O 多路复用(select/poll/epoll)、信号驱动、异步 I/O。Android 的 Looper 底层就用了 epoll(无消息时阻塞等待,见 09 篇)。


第二部分:数据库

一、SQL 基础

  • 增删改查:INSERT / DELETE / UPDATE / SELECT。
  • JOIN:INNER(交集)、LEFT(左全保留)、RIGHT、FULL。
  • 聚合:COUNT/SUM/AVG/MAX/MIN + GROUP BY + HAVING。
  • 子查询、UNION、ORDER BY、LIMIT

二、索引(高频)

  • 作用:加速查询,空间换时间。底层多用 B+ 树
  • 为什么 B+ 树而非 B 树/红黑树? B+ 树矮胖(减少磁盘 I/O 次数)、叶子节点链表(范围查询快)、非叶子只存索引(单页存更多键)。
  • 聚簇索引 vs 非聚簇索引:聚簇索引叶子存整行数据(主键),非聚簇叶子存主键需回表。
  • 最左前缀:联合索引 (a,b,c) 从左匹配,where b=x 用不上。
  • 索引失效:对索引列函数运算、隐式类型转换、like '%x' 前缀模糊、OR 部分无索引。
  • 代价:占空间、拖慢写入(增删改要维护索引)。

三、事务(ACID)

  • A 原子性:全成功或全回滚。
  • C 一致性:事务前后数据完整性约束不破坏。
  • I 隔离性:并发事务互不干扰。
  • D 持久性:提交后永久保存。

隔离级别(解决并发问题)

级别脏读不可重复读幻读
读未提交可能可能可能
读已提交可能可能
可重复读(MySQL默认)可能(InnoDB 用 MVCC+间隙锁基本解决)
串行化
  • 脏读:读到别的事务未提交的数据。
  • 不可重复读:同一事务两次读同一行结果不同(被别人 update)。
  • 幻读:同一查询两次返回行数不同(被别人 insert)。

四、SQLite / 移动端

  • SQLite:嵌入式、单文件、无服务进程,Android 本地存储核心(Room 基于它)。
  • WAL 模式:写前日志,提升并发(读写不互斥)。
  • 移动端实践:用 Room 而非裸 SQLite(编译期校验 + 协程/Flow);大数据量分页;事务批量写;索引优化查询;数据库迁移 Migration。

高频面试题

Q1:进程和线程区别? 进程是资源分配单位有独立地址空间;线程是 CPU 调度单位共享进程资源。进程间隔离开销大,线程间共享内存需同步。

Q2:死锁产生的四个条件?怎么避免? 互斥、持有并等待、不可剥夺、循环等待。破坏任一即可:如资源一次性申请(破坏持有并等待)、按固定顺序申请资源(破坏循环等待)。

Q3:为什么数据库索引用 B+ 树? B+ 树矮胖减少磁盘 I/O,非叶子节点只存键能存更多、降低树高,叶子节点链表利于范围查询和排序。相比红黑树/B 树更适合磁盘存储。

Q4:事务隔离级别?分别解决什么问题? 读未提交→读已提交(解决脏读)→可重复读(解决不可重复读)→串行化(解决幻读)。级别越高越安全但并发越低。

Q5:什么情况索引会失效? 索引列做函数运算/类型转换、like '%x' 前缀模糊、不满足最左前缀、OR 连接非索引列。

Q6:Looper 为什么不会因为死循环耗尽 CPU?(联系 OS) 底层用 epoll,MessageQueue 无消息时阻塞在 epoll_wait 让出 CPU,有消息再唤醒,不是忙等。

Q7:虚拟内存的作用? 给每个进程独立的地址空间(隔离 + 安全)、突破物理内存限制(按需分页 + 换页)、简化内存管理(连续虚拟地址映射到不连续物理页)。

操作系统进阶

Android 应用跑在 Linux 之上,面试里的 OS 题不是纯书本题:进程、线程、协程会落到主线程/线程池/协程调度;虚拟内存、mmap、page fault 会落到 Binder、文件映射和启动性能;epoll 会落到 Looper 与网络连接。

一、进程、线程、协程的进阶对比

维度进程线程协程
资源边界独立虚拟地址空间共享进程地址空间运行在线程上
调度者Linux 内核Linux 内核Kotlin/运行时协作调度
切换开销高,涉及地址空间等上下文中,保存寄存器/栈等低,挂起恢复状态机
Android 例子App 进程、WebView 多进程、remote serviceUI 线程、RenderThread、OkHttp Dispatchersuspend、Flow、ViewModelScope
  • 进程解决隔离:一个 App crash 不应拖垮系统;多进程可隔离 WebView、推送、风控 SDK。
  • 线程解决并行:CPU 密集放 Default/计算线程,IO 放 IO/线程池,UI 只能主线程更新 View。
  • 协程解决异步表达:挂起不等于新线程,suspend 只是把回调/状态机写成顺序代码。
  • 面试关键句:协程不是 OS 调度单位,最终仍要落在线程上执行;阻塞式 IO 放错调度器仍会占住真实线程。

二、虚拟内存、mmap 与 page fault

虚拟内存让每个进程看到连续独立地址空间,由页表映射到物理页。Android 中它直接影响大文件读取、so 加载、Dex/OAT 映射、Binder 缓冲区等。

进程虚拟地址
  ↓ 页表 / MMU / TLB
物理内存页
  ↑
缺页(page fault)时由内核把文件页/匿名页调入内存
  • mmap:把文件或设备映射到进程虚拟地址,按需加载,减少显式 read/copy;Binder 驱动也利用 mmap 建立用户态可访问缓冲区。
  • page fault:访问的虚拟页尚未在物理内存中,CPU 触发异常进入内核处理。轻微缺页可能只建映射,重大缺页可能要读磁盘。
  • 启动性能关联:冷启动读取 dex、resources、so 时可能产生大量缺页;Baseline Profile、预加载和减少冷路径大文件访问都能降低抖动。
  • 内存压力关联:匿名页、文件页、Ashmem/共享内存都会参与系统回收;低端机上大 Bitmap 和大 mmap 文件都要考虑峰值。

三、文件描述符、I/O 与 epoll

Linux 把文件、socket、pipe、eventfd 等都抽象成文件描述符(fd)。Android 的网络、数据库、日志、Looper 唤醒都离不开 fd。

I/O 机制特点Android 关联
阻塞 I/O调用线程等待结果主线程网络/磁盘会 ANR
非阻塞 I/O没数据立即返回需要轮询或事件通知
select/poll监听多个 fd,但扩展性一般传统多路复用方案
epoll事件驱动,适合大量 fdLooper、网络框架、native event loop

Looper 为什么不忙等? MessageQueue 没消息时,底层通过 epoll_wait 阻塞等待 fd 事件或超时;有消息、Binder、输入事件、定时器到期时再被唤醒。这也是“死循环不等于耗 CPU”的经典追问。

fd 常见问题:

  • 文件/网络流未关闭导致 fd 泄漏,最终 Too many open files
  • 日志、图片、数据库 Cursor 未及时 close。
  • 连接池过大或泄漏导致 socket fd 占用异常。

四、调度、优先级与 Android 卡顿

Linux 普通任务主要由 CFS 调度,目标是公平分配 CPU;Android 在此基础上叠加进程优先级、线程 nice 值、cgroup/cpuset、前后台策略。

  1. UI 线程不是绝对优先:它仍要参与调度,如果自己执行长任务或系统 CPU 被打满,就会错过 16.6ms 帧预算。
  2. 线程优先级要谨慎:后台下载/日志压缩不应抢 UI;音视频、渲染、输入链路要避免被低价值任务干扰。
  3. 协程调度器不是魔法:Dispatchers.Default 适合 CPU,Dispatchers.IO 适合阻塞 IO,乱用会导致线程饥饿。
  4. ANR 本质:主线程长时间无法处理输入、广播、服务生命周期等消息,可能是锁等待、IO、CPU、Binder 调用卡住。

五、锁、死锁与并发安全

死锁四条件:互斥、持有并等待、不可剥夺、循环等待。Android 中常见死锁并不只来自 synchronized,还来自主线程等待后台、后台反向切主线程、Binder 同步调用互等。

Thread-A: 持有 dbLock → 等待 networkLock
Thread-B: 持有 networkLock → 等待 dbLock
结果:循环等待,两边都无法推进

实践建议:

  • 固定锁顺序,避免 A→B 与 B→A 混用。
  • 不在持锁期间做网络、磁盘、Binder 或回调外部代码。
  • 优先缩小临界区,读多写少用读写锁或不可变快照。
  • Kotlin 协程中区分 Mutex 与 JVM 锁,不要在 synchronized 内调用可能挂起的逻辑。
  • 主线程不要等待后台锁;后台也不要同步等待主线程回调。

六、Android Linux 基础速记

  • Zygote:预加载类和资源后 fork App 进程,降低启动成本;fork 后进程拥有独立虚拟地址空间,通过 COW 共享只读页。
  • Binder:Android 主要 IPC,结合驱动、mmap、线程池和引用计数,比 Socket 更适合系统服务调用。
  • cgroup/cpuset:系统按前后台、任务类型限制 CPU/资源分配,解释为什么后台任务可能变慢。
  • LMK/内存回收:低内存时系统按进程重要性回收;App 要保存状态,不能假设进程永生。
  • SELinux/权限模型:限制进程访问系统资源,移动安全和文件访问都要考虑沙箱边界。

高频面试题

Q1:进程、线程、协程怎么区分? 进程是资源隔离单位,线程是内核调度单位,协程是用户态/运行时的异步抽象。协程挂起不阻塞线程,但执行仍要占用线程,因此阻塞操作放错调度器仍会影响性能。

Q2:mmap 和普通 read 有什么区别? read 把数据从内核缓冲复制到用户缓冲,mmap 把文件映射到虚拟地址空间,访问时按页加载,可减少拷贝和简化随机访问。但 mmap 仍可能触发 page fault,不是“免费加载”。

Q3:Looper 底层为什么用 epoll? Looper 要同时等待消息队列、输入、Binder/管道等 fd 事件。epoll 能高效等待多个 fd,无事件时阻塞让出 CPU,有事件再唤醒,避免忙等。

Q4:Android 死锁如何排查和避免? 看线程堆栈确认谁持有什么锁、谁在等待;避免嵌套锁顺序不一致、持锁做耗时操作、主线程同步等待后台。必要时用超时、锁顺序规范和异步化拆环。

易错点 / 追问

  • 易错:把协程说成轻量线程;准确说协程不是内核线程,它运行在线程之上。
  • 追问:page fault 是否一定是坏事?不是,按需分页依赖它;但冷启动大量重大缺页会带来磁盘 IO 抖动。
  • 易错:以为 epoll 只用于服务端高并发;Android Looper 和 native 事件循环同样依赖 fd 多路复用思想。
  • 追问:为什么主线程没有死循环占 CPU?因为 MessageQueue 空闲时阻塞在 epoll_wait,不是 while true 忙轮询。

设计模式与 Android 源码应用

设计模式几乎每场中级面试都问,且面试官爱问**“在 Android 源码/常用库里的实际应用”**——光背定义不够,要能举出框架里的真实例子。本篇按这个思路组织。

一、六大设计原则(SOLID)

原则核心含义Android 反例 → 正例面试落点
单一职责(SRP)一个类只承担一个变化原因Activity 同时写 UI、网络、缓存、埋点 → 拆成 ViewModelRepositoryTracker不是“类越小越好”,而是变更原因隔离
开闭原则(OCP)对扩展开放,对修改关闭支付页新增渠道就改 when(type) → 抽 PaymentStrategy,新增渠道只加实现用多态/注册表替代反复改老代码
里氏替换(LSP)子类可替换父类而不破坏调用方预期自定义 View 重写 onMeasure 却不尊重 MeasureSpec → 保持父类契约继承要遵守父类语义,否则组合优先
接口隔离(ISP)接口最小化,不强迫实现无用方法UserModuleService 同时暴露登录、头像、支付状态 → 拆 AuthService/ProfileService组件化接口下沉时尤其重要
依赖倒置(DIP)高层依赖抽象,不依赖具体实现ViewModel 直接 new RetrofitApi → 依赖 UserRepository 接口,由 Hilt/工厂注入实现DI 的理论基础,也是可测试性的基础
迪米特法则最少知识,只和直接朋友通信页面跨模块拿 OrderManager.userManager.tokenStore.token → 通过 AuthService.getToken()减少调用链泄漏,降低跨模块耦合

Android 重构口诀:Activity 变薄(SRP),新增业务走扩展点(OCP),继承守契约(LSP),跨模块接口要小(ISP),高层依赖接口(DIP),不要跨层级“摸内部对象”(迪米特)。

二、创建型模式

单例(最高频)

确保一个类只有一个实例。Kotlin 用 object 天然单例。Java 重点是双重检查锁(DCL):

class Singleton private constructor() {
    companion object {
        @Volatile private var instance: Singleton? = null
        fun get(): Singleton =
            instance ?: synchronized(this) {
                instance ?: Singleton().also { instance = it }
            }
    }
}
  • volatile 的作用:防止指令重排导致拿到未初始化完成的对象。
  • Android 应用:Application、各种 Manager(getSystemService 返回的)、OkHttpClient 单例。

工厂 / 抽象工厂

封装对象创建,调用方只描述“我要什么”,不关心具体构造细节。

  • 角色:调用方(Client)→ 工厂(Factory)→ 具体产品(Product)。
  • Android 应用:BitmapFactory.decodeStream() 根据输入流/Options 创建 Bitmap;LayoutInflater.from(context) 根据 Context 找到合适 inflater;Executors 根据方法创建不同线程池。
  • 为什么匹配:调用方不直接 new Bitmap(...),而把复杂创建逻辑、兼容分支、缓存/复用细节交给工厂。

建造者(Builder)

链式构造复杂对象,适合可选参数多、构建过程需要校验的场景。

  • 角色:Builder 暂存配置,build()/show() 生成最终对象。
  • Android 应用:AlertDialog.Builder.setTitle().setPositiveButton().show()OkHttpClient.Builder.addInterceptor().build()Retrofit.Builder.baseUrl().addConverterFactory().build()Notification.Builder、Glide 请求构建。
  • 为什么匹配:避免超长构造函数,把“配置过程”和“不可变/可执行对象”分离;面试讲 OkHttp/Retrofit 时可顺带讲 Builder。

原型(Prototype)

通过拷贝已有对象创建新对象,适合“保留大部分配置,只改少数字段”。Android 应用:Intent.clone()/复制 Bundle 后修改 extras。它匹配原型模式,因为新对象来自已有对象快照,而不是从零组装。

三、结构型模式

代理(Proxy)

为真实对象提供一个代理入口,在调用前后做控制、延迟、跨进程或增强。

  • 角色:接口 Subject、真实对象 RealSubject、代理 Proxy。
  • Retrofit:retrofit.create(Api::class.java)Proxy.newProxyInstance 生成接口实现;调用 api.getUser() 时进入 InvocationHandler,解析注解并创建 HTTP 请求。
  • Binder AIDL:客户端拿到的是 Stub.Proxy,方法调用先写入 Parcel,再通过 Binder 驱动发给服务端 Stub.onTransact()
  • 为什么匹配:调用方以为自己在调普通接口,实际被代理拦截并转成网络/跨进程调用。

适配器(Adapter)

把一个接口转换成调用方需要的另一个接口。

  • 角色:目标接口(Target)、被适配者(Adaptee)、适配器(Adapter)。
  • RecyclerView.Adapter:数据源可能是 List<Item>,但 RecyclerView 需要 getItemCount/onCreateViewHolder/onBindViewHolder 这组接口;Adapter 把数据转换成可复用的 ViewHolder 绑定流程。
  • 为什么匹配:RecyclerView 不直接认识业务数据,只认识 Adapter 契约;业务侧实现契约即可接入框架。

装饰器(Decorator)

在不修改原对象的情况下动态叠加能力。

  • 角色:组件接口 Component、被装饰对象 ConcreteComponent、装饰器 Decorator。
  • Android 应用:ContextWrapper 持有一个 base Context 并转发大多数调用;ContextThemeWrapper 在 base 能力上增加 theme 解析能力。
  • 为什么匹配:外部仍按 Context 使用,但功能被包装增强;这与继承扩展不同,装饰可以按需组合。

外观(Facade)

提供统一高层接口,隐藏子系统复杂度。Android 应用:Context.getSystemService() 把 ActivityManager、ClipboardManager、LayoutInflater 等系统能力收口到统一入口;调用方不需要知道服务发现和 Binder 细节。它匹配外观模式,因为 Context 是简化入口,不是具体业务实现本身。

四、行为型模式

观察者(Observer,高频)

一对多依赖,被观察者状态变化时通知观察者。

  • 角色:Subject 保存观察者列表,Observer 接收回调。
  • Android 应用:LiveData.observe(owner) {} 在生命周期活跃时通知观察者;OnClickListener 是 View 事件的观察回调;Adapter.notifyDataSetChanged() 通知 RecyclerView 数据变化。
  • 为什么匹配:数据/事件源不直接依赖具体页面逻辑,只通知已注册观察者;注意 Flow 更偏响应式流,面试可类比观察者但要说明它还有冷/热流、背压、协程上下文语义。

责任链(Chain of Responsibility,高频)

请求沿链传递,每个节点决定处理、增强或继续传递。

  • OkHttp:RealInterceptorChain.proceed(request) 把请求交给下一个拦截器;应用拦截器、重试重定向、桥接、缓存、连接、CallServer 等节点逐层处理。
  • View 事件分发:Activity.dispatchTouchEvent → ViewGroup.dispatchTouchEvent/onInterceptTouchEvent → View.dispatchTouchEvent/onTouchEvent,每层决定拦截、消费或继续下发/回传。
  • 为什么匹配:发送方不需要知道最终谁处理,链上每个节点只关心自己职责;这也是和 12 篇 OkHttp 的跨章重复点,这里讲模式原因即可。

策略(Strategy)

封装可互换算法,调用方依赖统一接口。Android 应用:属性动画依赖 TimeInterpolator.getInterpolation(input),线性、加速、回弹等插值器都可替换。它匹配策略模式,因为动画框架不改主流程,只替换“时间进度如何映射为动画进度”的算法。

模板方法(Template Method)

父类/框架定义流程骨架,子类填充步骤。Android 应用:ActivityThread/框架按生命周期调度,业务 Activity 重写 onCreate/onStart/onResume;自定义 View 重写 onMeasure/onDraw。它匹配模板方法,因为整体流程由框架控制,应用只实现钩子步骤。AsyncTask 也体现该模式但已废弃,面试只作历史例子。

命令(Command)

把请求封装成对象,便于排队、延迟、取消或跨线程传递。Android 应用:Handler.post(Runnable) 把一段动作封装进 MessageQueue;Message.what/obj/callback 描述要执行的命令。它匹配命令模式,因为发送方只投递命令对象,执行时机由 Looper 决定。

其他

  • 迭代器:集合的 Iterator
  • 备忘录:onSaveInstanceState

五、记忆法:按“框架反推模式“

面试被问设计模式,从你熟的框架反推最稳:

  • OkHttp 拦截器 → 责任链
  • Retrofit create → 代理(动态代理)
  • 各种 Builder → 建造者
  • LiveData/Flow → 观察者
  • RecyclerView.Adapter → 适配器
  • 事件分发 → 责任链
  • Glide.with → 建造者 + 单例

高频面试题

Q1:手写一个线程安全的单例。 用 DCL(双重检查锁)+ volatile,或静态内部类(依赖 JVM 类加载的线程安全初始化 + 懒加载),Kotlin 直接 object。volatile 防止指令重排拿到半初始化对象。

Q2:DCL 里 volatile 为什么必须加? instance = Singleton() 不是原子操作,分为分配内存、初始化、赋值引用三步。指令重排后其他线程可能拿到已赋值但未初始化完成的对象。volatile 禁止重排。

Q3:OkHttp 用了什么设计模式? 责任链(拦截器链,核心)、建造者(OkHttpClient.Builder)、工厂、外观。重点讲责任链:请求依次经过各拦截器,每个调 chain.proceed 传递。

Q4:Retrofit 怎么用设计模式把接口变成实现? 动态代理(代理模式)。create() 用 Proxy.newProxyInstance 生成接口代理,方法调用被 InvocationHandler 拦截,解析注解构造请求。

Q5:观察者模式在 Android 哪里用到? LiveData/Flow 的数据观察、RxJava、事件监听器、BroadcastReceiver。一对多,被观察者变化时通知所有观察者。

Q6:事件分发是什么设计模式? 责任链。事件沿 View 树(Activity→ViewGroup→View)传递,每层决定拦截处理还是向下/向上传递。

Q7:MVC/MVP/MVVM 算设计模式吗? 算架构模式(架构层面的模式),不是 GoF 23 种设计模式。MVVM 内部用到观察者(数据绑定)。区分“设计模式“(类级别)和“架构模式“(应用级别)。

系统设计场景题

中高级面试的开放设计题,考架构思维而非标准答案。重点是展示分析过程:先问清需求/约束 → 分模块 → 定接口 → 讲权衡 → 提优化。你的底层 + 性能 + 架构知识能在这里综合发挥。

一、答题通用框架(套路)

任何“设计一个 X“都按这个结构答:

  1. 澄清需求:功能边界、量级(QPS/数据量)、性能/内存约束、平台。先问再答,体现工程素养。
  2. 整体架构:分几层/几个模块,各自职责。
  3. 核心模块详细设计:数据结构、关键算法、接口设计。
  4. 关键问题处理:并发、缓存、生命周期、异常、内存。
  5. 权衡与优化:为什么这么选,有哪些 trade-off,怎么扩展。

记住:面试官要看思路和沟通,不是要你写完整代码。 边画图边讲。

二、设计图片加载库(最高频,如自研 Glide)

需求澄清:支持网络/本地、缓存、列表滑动场景、防 OOM、生命周期安全。

整体架构:

请求(with/load/into) → 生命周期绑定 → 缓存查找(内存→磁盘) 
→ 未命中则下载 → 解码(采样压缩) → 缓存写入 → 显示

核心设计:

  • 三级缓存:内存(LruCache,弱引用存活动资源)→ 磁盘(DiskLruCache)→ 网络。
  • 生命周期绑定:注入空 Fragment 监听宿主,onStop 暂停、onDestroy 取消请求(防泄漏)。
  • 防 OOM:按目标 View 尺寸 inSampleSize 采样压缩;Bitmap 复用池(BitmapPool)。
  • 线程模型:下载/解码在后台线程(线程池/协程),回调切主线程。
  • 请求管理:列表复用时取消旧请求(ImageView tag 绑定请求)。
  • 设计模式:建造者(请求构建)+ 单例(引擎)+ 观察者(回调)。

权衡:内存缓存大小(占内存 vs 命中率)、缓存淘汰策略(LRU)、采样精度 vs 质量。

三、设计网络请求框架(如自研 Retrofit + OkHttp)

  • 分层:接口定义层(注解/动态代理)→ 请求封装层 → 拦截器链 → 连接/IO 层。
  • 拦截器链(责任链):日志、重试、缓存、鉴权、加解密可插拔。
  • 连接池:复用 TCP 连接,减少握手。
  • 线程/异步:回调 / 协程 suspend / Flow。
  • 可扩展:Converter(序列化)、CallAdapter(返回类型)可替换。
  • 你的加分点:加解密拦截器、证书 Pinning、参数签名(结合你的安全背景)。

四、设计断点续传下载器

需求:大文件、断点续传、多线程加速、暂停/恢复、进度。

核心设计:

  • 分片下载:按文件大小切成 N 段,每段用 HTTP Range: bytes=start-end 请求,多线程并发。
  • 断点记录:持久化每段已下载偏移(数据库/文件),恢复时从断点续传。
  • 合并:各段写入同一文件的对应偏移(RandomAccessFile.seek)。
  • 状态管理:等待/下载中/暂停/完成/失败,状态机管理。
  • 完整性校验:下载后校验 MD5/SHA。
  • 优化:动态调整线程数、失败重试、弱网降级、用 WorkManager 保证后台续传。

五、设计 IM / 即时通讯

需求与约束:支持单聊/群聊、保证消息不丢不重不乱序、支持弱网、省电省流。

整体架构:

  • 客户端层:UI 层 → 本地存储层(SQLite) → 消息同步层 → 连接层。
  • 服务端层:接入层(负载均衡/长连网关) → 逻辑层(单聊/群聊) → 存储层(MySQL存会话, Redis存状态)。

数据流与状态机 (消息生命周期):

  1. 发送:生成本地唯一 ClientMsgID,状态设为发送中,存入本地数据库。
  2. 传输:通过长连接发送给服务端,服务端返回 ServerMsgID 确认接收。
  3. 确认:客户端收到 ACK 后,更新本地状态为发送成功;若超时未收到,状态转为发送失败(显示红点)。
  4. 接收:接收方收到 ServerMsgID,发送 ACK 确认,然后渲染到界面并存库。

核心问题处理:

  • 连接层选型:
    • WebSocket / TCP:主流采用自研 TCP 长连接或 WebSocket,心跳保活(按网络状态智能调节心跳间隔,减少电量消耗)。
  • 可靠性与去重 (ACK 与 Idempotency):
    • 必须有双向 ACK 机制。发送端根据 ClientMsgID 去重,接收端根据 ServerMsgID 去重。
  • 有序性:
    • 依赖服务端的递增序列号 (Seq) 保证消息严格有序。
  • 离线与同步机制:
    • 采用 推拉结合:服务端推送新消息通知,客户端拿着本地最大 Seq 拉取增量消息(Sync)。
  • 弱网与存储优化:
    • 采用本地 Room 数据库持久化聊天记录;断网时可看历史记录,发消息仅改本地状态,恢复网络后后台重发。

六、设计短视频 / Feed 流

需求与约束:无限滑动、秒开、播放不卡顿、降低 OOM 概率与发热。

客户端架构:

  • UI 控制层(RecyclerView) → 预加载策略层 → 播放器池(PlayerPool) → 本地缓存层。

核心数据流与 Paging3 集成:

  • 使用 Paging3 实现分页流:数据层通过 PagingSource 从网络或本地数据库拉取,ViewModel 将流转为 PagingData 喂给 UI,UI 层滑动触发下一页。

核心问题处理:

  • 预加载策略 (Preload):
    • 提前请求下 N 条(如 3 条)视频的元数据与首帧图片。
    • 视频缓冲:提前下载当前视频后的 1-2 个视频的前 1MB 块(Moov Box 优先)。
  • 播放器复用池 (Player Reuse):
    • 绝不能给每个 Item 创建 MediaPlayer/ExoPlayer。全局维护 1-3 个播放器实例池,滑动切换时动态将 DataSource 挂载到目标 Surface 上,降低内存分配与重建开销。
  • 缓存策略:
    • 边下边播(通过本地代理如 AndroidVideoCache):未缓存部分请求网络并写入文件,已缓存部分直接读本地。
  • 性能与内存隔离:
    • 滑出屏幕立即停止解码、释放播放器资源到池中。
    • 控制预加载深度:防止疯狂往下滑动导致内存暴增与大量废弃请求。
  • 失败与降级:
    • 网络波动时降级播放低码率;拉取失败时展示已缓存内容并提示网络异常。

七、其他常见设计题

  • 设计本地缓存框架:LRU + 磁盘 + 过期策略 + 序列化。
  • 设计埋点/APM SDK:数据采集 → 本地队列 → 批量上报 → 失败重试。和你 SDK 背景契合,可深入讲采集、性能影响最小化、上报策略。
  • 设计组件化路由:APT 生成路由表 + 按路径分发 + 拦截器。

答题要点总结

  1. 先问后答:澄清需求和约束是第一步,直接写代码是大忌。
  2. 分层分模块:展示你能把复杂系统拆解。
  3. 讲权衡:每个选择说清 trade-off(内存 vs 速度、一致性 vs 可用性)。
  4. 结合你的优势:涉及性能、内存、安全、SDK 的设计题,主动用你的底层经验加分——比如设计图片库讲 Bitmap 内存复用、设计网络框架讲 Pinning 和加解密、设计埋点 SDK 讲对宿主性能影响最小化。这些是普通应用开发者讲不出的深度。
  5. 画图沟通:边画架构图边讲,面试官看的是思路和表达。

进阶补充:标准追问清单、状态机与可靠性设计

每道系统设计题的追问清单

回答完主方案后,主动补 5 类追问:

  1. 容量与性能:QPS、并发、缓存、线程池。
  2. 失败与重试:超时、重试、幂等、降级。
  3. 状态机:任务有哪些状态,如何迁移,异常如何恢复。
  4. 一致性:本地/服务端状态如何同步,冲突如何处理。
  5. 可观测性:日志、指标、trace、告警。

下载器状态机示例

Idle -> WaitingNetwork -> Downloading -> Paused -> Downloading -> Completed
                         -> Failed -> Retrying -> Downloading

每个状态迁移都要说明触发条件、持久化字段和失败恢复。

限流、熔断、降级、幂等

机制解决什么
限流防止瞬时流量打爆系统
熔断下游持续失败时快速失败
降级非核心能力失败时保核心路径
幂等重试不会产生重复副作用

Android 特有追问

  • 进程死亡后如何恢复任务?
  • 前后台切换如何处理?
  • 弱网/断网如何重试?
  • 页面旋转或配置变更如何保存状态?
  • 本地缓存和服务端数据冲突时谁优先?

面试表达模板

“我会先定目标和约束,再拆模块,然后补状态机和失败处理。比如这个下载器,核心不是只把文件下下来,还要保证断点续传、进程死亡恢复、重复点击幂等、弱网重试和可观测性。”

**追问:**为什么系统设计不能只画模块图?因为真实系统最容易出问题的是状态迁移、失败恢复和边界条件,这些必须在设计里说明。

移动端系统设计题库

移动端系统设计题没有唯一答案,面试官看的是你能否 澄清需求、拆模块、讲状态机、处理弱网/缓存/生命周期、说明权衡。本篇给一套可直接复述的题库骨架,覆盖 Android 应用和 SDK 场景。

一、移动端系统设计答题框架

任何题都按同一套路展开,避免一上来堆模块名。

1. 澄清需求:用户规模、数据量、离线、实时性、平台约束
2. 画端侧架构:UI/API/存储/任务/监控/安全
3. 讲核心流程:请求、缓存、状态机、失败恢复
4. 补移动端约束:生命周期、弱网、进程死亡、电量、隐私
5. 讲权衡:一致性 vs 性能、内存 vs 命中率、实时 vs 省电
维度必答点Android 关键词
生命周期页面销毁、进程死亡、前后台ViewModel、WorkManager、Service
存储缓存、离线、一致性Room、DataStore、文件、Paging3
网络弱网、重试、幂等OkHttp、WebSocket、退避
性能内存、线程、卡顿LruCache、线程池、Profiler
安全合规权限、隐私、加密Pinning、最小化采集、加密存储

二、题 1:设计图片加载框架

澄清:支持网络/本地/资源图?是否要 GIF/WebP/缩略图?列表滑动场景规模?内存上限?

骨架:

  • API 层:with(context).load(url).placeholder().into(imageView)
  • 请求管理:按生命周期绑定 Activity/Fragment,页面销毁取消。
  • 缓存:内存 LRU、磁盘 LRU、活动资源弱引用/引用计数。
  • 解码:按 View 尺寸采样,BitmapPool 复用,后台线程解码。
  • 并发:下载线程池、同 URL 请求合并、列表复用 tag 防错图。

权衡:内存缓存越大命中率越高但 OOM 风险越大;磁盘缓存提升离线体验但占存储;精确采样省内存但可能牺牲清晰度。

三、题 2:设计埋点/Tracking SDK

澄清:采集哪些事件?实时还是批量?是否跨进程?隐私合规要求?宿主接入成本?

骨架:

  • 采集层:手动埋点、自动页面曝光、点击、性能指标。
  • 本地队列:Room/文件追加写,限制大小,进程死亡不丢关键事件。
  • 采样去重:按用户稳定采样,短窗口 fingerprint 去重。
  • 上报层:批量 gzip,弱网退避,只在合适网络/电量条件下上传。
  • 可观测:SDK 自身错误、丢弃原因、队列长度、上报成功率。

权衡:实时性 vs 电量/流量;自动采集覆盖高但侵入和误报风险高;隐私字段必须最小化、脱敏和可配置。

四、题 3:设计断点续传下载器

澄清:单文件还是多任务?是否后台下载?文件多大?是否多线程分片?是否需要校验?

骨架:

  • 任务模型:taskId、url、目标路径、状态、已下载字节、etag、分片信息。
  • 下载层:HTTP Range 分片,RandomAccessFile 写指定偏移。
  • 状态机:Idle → Downloading → Paused/Failed/Completed,失败可 Retrying。
  • 持久化:Room 保存分片进度,进程死亡后恢复。
  • 后台:WorkManager/ForegroundService 处理长任务和通知。

权衡:多线程分片更快但占连接和服务端压力;校验更安全但耗 CPU;后台下载要平衡系统限制和用户可见性。

五、题 4:设计 IM 长连接与消息同步

澄清:单聊/群聊?是否强实时?消息是否端到端加密?离线多久?多端同步?

骨架:

  • 连接层:WebSocket/TCP 长连接,心跳,断线重连,网络切换重连。
  • 消息模型:clientMsgId、serverMsgId、conversationId、seq、状态。
  • 可靠性:发送 ACK、接收 ACK、超时重发、幂等去重。
  • 有序性:服务端 seq,客户端按 seq 拉增量补洞。
  • 本地存储:Room 保存会话、消息、未发送队列,UI 观察本地库。

权衡:心跳越频繁实时性越好但耗电;推拉结合比纯推送更可靠;严格有序会增加服务端和端侧状态复杂度。

六、题 5:设计离线缓存框架

澄清:缓存接口响应、图片、配置还是业务数据?一致性要求?离线可编辑吗?

骨架:

  • 缓存策略:Cache-Aside、单一可信源、TTL、版本号/etag。
  • 存储层:Room 存结构化数据,文件存大对象,DataStore 存轻量配置。
  • 同步层:网络成功后事务入库;离线修改进入待同步队列。
  • 冲突处理:服务端版本、本地更新时间、业务合并规则。
  • 清理:LRU/TTL/空间上限/用户登出清理。

权衡:强一致需要更多同步和冲突处理;最终一致更适合移动弱网;缓存越久离线体验越好但过期风险越高。

七、题 6:设计 Crash SDK 与日志系统

澄清:Java/Kotlin crash、native crash、ANR 都要吗?是否符号化?是否要求启动早期捕获?

骨架:

  • 捕获:UncaughtExceptionHandler、native signal handler、ANR watchdog/系统 traces。
  • 持久化:crash 发生时写最小安全信息,下次启动补充上报。
  • 符号化:Java mapping、native so buildId/symbol 文件、版本匹配。
  • 日志:环形缓冲记录关键 breadcrumb,避免无限写磁盘。
  • 上报:启动后延迟上传,去重、采样、压缩、退避。

权衡:crash 时环境不安全,不能做复杂分配/网络;日志越详细越利于定位但越占隐私和存储。

八、题 7:设计权限治理框架

澄清:面向宿主 App 还是 SDK?覆盖 Android 版本适配吗?是否要合规审计?

骨架:

  • 权限声明:集中注册每个业务使用的权限、目的、触发时机。
  • 请求编排:按场景弹说明页,再调系统权限,处理拒绝/永久拒绝。
  • 版本适配:通知、相册、蓝牙、定位、后台定位按系统版本差异处理。
  • 审计:记录权限请求来源、结果、时间,给隐私合规看板。
  • SDK 约束:SDK 不直接弹权限,由宿主授权后传入能力。

权衡:集中治理降低滥用但接入成本更高;权限前置会转化差,按需申请更符合合规和用户体验。

九、题 8:设计组件化 Router

澄清:只做页面跳转还是跨模块服务调用?是否支持降级、鉴权、动态化?

骨架:

  • 路由表:编译期注解生成 path → Activity/Handler 映射。
  • 参数:类型安全参数解析,必填校验,默认值。
  • 拦截器:登录、权限、风控、灰度、降级。
  • 跨模块:接口下沉到 api module,实现由业务模块注册。
  • 监控:路由耗时、失败原因、未命中 path。

权衡:编译期生成性能好但灵活性低;运行时注册灵活但可能漏注册;路由太万能会变成隐藏依赖中心。

十、题 9:设计短视频 Feed

澄清:信息流还是沉浸式上下滑?是否直播?视频码率?离线缓存?推荐实时性?

骨架:

  • 数据:Paging3 拉取 feed,Room 缓存元数据和游标。
  • 播放:播放器池复用 1-3 个 ExoPlayer,滑动切换 Surface。
  • 预加载:预取下 N 条元数据、封面、视频头部片段。
  • 缓存:边下边播,磁盘 LRU,弱网切低码率。
  • 性能:控制同时解码数量,滑出释放,监控首帧、卡顿、耗电。

权衡:预加载越多秒开越好但流量和内存更高;播放器复用降低成本但状态管理复杂。

十一、题 10:设计登录状态与 Token 刷新

澄清:单端/多端登录?access token/refresh token?是否支持游客态?安全等级?

骨架:

  • 状态:未登录、游客、已登录、刷新中、过期、被踢。
  • 存储:token 加密存储,用户信息 Room/DataStore 分层。
  • 刷新:401 后单飞刷新,并发请求等待同一刷新结果。
  • 安全:设备绑定、Pinning、风控校验、敏感操作二次验证。
  • 清理:登出/被踢清理用户数据、取消队列、通知 UI。

权衡:token 有效期越长体验越好但风险越高;刷新太频繁增加服务端压力;多端策略要和业务安全一致。

十二、题 11:设计设备指纹 SDK

澄清:用于风控、反作弊还是统计?允许采集哪些字段?离线可用吗?合规边界?

骨架:

  • 采集:系统、硬件、网络、App 环境等合规字段,按权限最小化。
  • 归一化:字段清洗、稳定性分级、缺失值处理。
  • 指纹生成:加权特征、hash/签名、相似度匹配,支持版本演进。
  • 安全:本地加密、防篡改、请求签名、Pinning。
  • SDK 工程:异步初始化、缓存、超时降级、宿主性能监控。

权衡:字段越多识别率越高但隐私风险越大;完全离线响应快但模型更新慢;端云结合更准但依赖网络。

十三、题 12:设计移动端配置/实验下发系统

澄清:配置实时性?是否灰度/AB 实验?失败默认值?是否影响启动?

骨架:

  • 拉取:启动异步拉、前后台切换拉、长轮询/推送可选。
  • 缓存:本地保存版本、etag、生效时间、默认兜底。
  • 灰度:按 userId/deviceId hash 分桶,保证稳定命中。
  • 安全:配置签名校验,防止中间人篡改关键开关。
  • 回滚:服务端版本回退,客户端过期降级。

权衡:启动同步拉能拿最新配置但拖慢启动;异步拉体验好但首次可能用旧配置;强安全配置需要签名和审计。


高频面试题

Q1:移动端系统设计题第一步说什么? 先澄清需求和约束:功能边界、数据量、实时性、离线要求、Android 版本、内存/电量/隐私限制。直接画模块容易漏掉关键约束。

Q2:设计 SDK 和设计 App 功能有什么不同? SDK 更强调低侵入、宿主性能影响、初始化时机、可配置、隐私合规、版本兼容和故障隔离。SDK 不能随意弹权限、开线程或抢占宿主资源。

Q3:移动端系统设计为什么要讲状态机? 因为弱网、进程死亡、页面销毁和用户重复操作都会造成中间态。状态机能说明任务如何暂停、恢复、失败、重试和完成,比静态模块图更接近真实工程。

Q4:如何体现中高级工程思维? 每个方案都补权衡和可观测性:为什么选这个缓存/重试/同步策略,失败如何降级,怎么监控成功率、耗时、队列长度和错误原因。

易错点 / 追问

  • 易错:只画端侧模块,不讲弱网、进程死亡、生命周期和数据一致性。
  • 追问:如果用户清后台后任务如何恢复?需要持久化状态,用 WorkManager/前台服务或下次启动恢复。
  • 易错:把所有题都回答成“加缓存 + 加重试”;要说明缓存过期、幂等、冲突和退避策略。
  • 追问:系统设计如何结合你的风控 SDK 背景?在埋点、设备指纹、网络安全、Crash/日志 SDK 中讲隐私、性能、Pinning、端云协同和可观测性。

项目经验与软技能

技术答得好,项目讲不清照样挂。这一篇帮你把风控 SDK 经历讲成应用岗的财富,以及应对软性问题。

一、STAR 法则讲项目

回答项目/经历类问题的黄金结构:

  • S(Situation 背景):项目是做什么的、你的角色、规模。
  • T(Task 任务):你具体负责什么、面临什么挑战/目标。
  • A(Action 行动):你怎么做的——重点,体现技术深度和决策思考。
  • R(Result 结果):量化成果(性能提升 X%、体积减少 Y MB、崩溃率下降 Z%)。

讲项目时多用真实数字。“优化了启动速度“很弱,“冷启动从【真实值】降到【真实值】,降幅【真实比例】“才有说服力。没有真实数据时,宁可讲验证方法,不要临场编数字。

二、怎么把风控 SDK 讲成亮点

你的项目天然带“难度光环“,会讲就是加分。核心不是背一段固定话术,而是把“风控 SDK“翻译成面试官关心的语言:底层硬实力 + 性能优化 + 安全视角 + 工程化能力

使用提醒:下面都是 STAR 模板,【项目背景】【我的动作】【量化指标】 等占位符必须在面试前替换成你真实经历和真实数据。没有真实数据就说“当时用内部指标验证“或“我会补充复盘数据“,不要把占位符当事实讲。

模板 1:讲底层深度(NDK/JNI/native 稳定性)

  • S 背景:【项目背景】:例如“某风控/设备指纹 SDK 需要在多个宿主 App 中稳定运行,同时兼顾采集完整性、兼容性和体积“。
  • T 任务:【我的职责】:说明你负责的模块边界,如 native 采集、JNI 桥接、so 加载、crash 捕获、动态注册等。
  • A 行动:【我的动作】:按 2-3 个动作讲清技术决策:为什么用 C++/JNI、如何控制跨层调用频率、如何处理线程/异常、如何做 ABI/符号/资源收敛。
  • R 结果:【量化指标】:替换为真实结果,如“so 体积从【真实值】降到【真实值】“、“native crash 率从【真实值】降到【真实值】”、“覆盖【真实机型/宿主范围】”。没有数据就只说“通过日志/灰度监控验证没有明显回归“。
  • 转应用岗落点:这不是只会写底层,而是证明你能处理 App 里最难定位的一类问题:native crash、so 兼容、跨线程/跨语言边界和发布质量。

示例结构:

“【项目背景】。我负责【我的职责】。难点是【技术难点】,如果处理不好会影响宿主 App 的【启动/稳定性/体积/兼容性】。我的做法是:第一【动作 1】,第二【动作 2】,第三【动作 3】。最后用【量化指标/验证方式】确认效果。这个经验迁移到应用开发,就是我能在 native、稳定性和性能问题上直接下钻定位。”

模板 2:讲性能意识(不拖累宿主 App)

  • S 背景:【项目背景】:SDK 嵌入宿主 App,用户感知由宿主承担,所以启动、内存、线程、网络都要有预算。
  • T 任务:【性能目标】:用真实目标描述,如“降低初始化对冷启动影响“、“减少常驻内存”、“避免主线程 I/O”。
  • A 行动:【我的动作】:可按“延迟初始化/按需加载 → 后台线程与任务编排 → 缓存与采样 → 监控回滚“组织。
  • R 结果:【量化指标】:只能填真实数据,如“主线程耗时【真实值】“、“内存峰值【真实值】”、“灰度期间无新增【真实指标】”。没有真实数值时,说清你用什么指标看效果,不要编 X ms。
  • 转应用岗落点:App 性能优化同样先定指标,再拆链路,最后用监控验证;SDK 经历能证明你不是只会口头说“异步“。

面试官追问“你怎么证明优化有效“时,不要只说“感觉不卡“。按这个顺序答:指标口径 → 对照组/灰度 → 工具或日志 → 回滚策略 → 剩余风险

模板 3:讲对抗/安全视角(防御导向)

  • S 背景:【项目背景】:风控 SDK 面临模拟器、Hook、篡改、调试、环境伪造等风险,但安全能力必须兼顾误报、性能和用户体验。
  • T 任务:【防护目标】:说清目标是识别风险、提高攻击成本、保护关键链路,不要宣称“完全防住“。
  • A 行动:【我的动作】:用防御视角描述威胁建模、信号采集、完整性校验、异常上报、策略开关、灰度观察;避免讲可操作的攻击/绕过步骤。
  • R 结果:【量化指标】:替换成真实的风险识别、误报、稳定性或灰度指标;没有数据就说明“该类效果依赖服务端策略/内部评估,面试中只能讲端侧工程动作“。
  • 转应用岗落点:支付、金融、出海和隐私敏感业务都需要安全意识;你的价值是能提前识别风险并把安全能力做成可控、可观测、可降级的工程能力。

模板 4:讲工程化能力(SDK 产品思维)

  • S 背景:【项目背景】:SDK 面向多个宿主/版本/机型,调用方不一定理解内部细节。
  • T 任务:【工程目标】:稳定接口、降低接入成本、保证兼容、控制发布风险。
  • A 行动:【我的动作】:讲 API 设计、配置开关、日志分级、异常兜底、灰度发布、版本兼容、文档和接入排查。
  • R 结果:【量化指标】:填真实接入规模、问题收敛、发布质量或反馈周期;无数据就讲“建立了可复用流程/排查清单“,不要编造宿主数量。
  • 转应用岗落点:完整 App 也需要这种工程化意识:模块边界清楚、可观测、可回滚、对调用方友好。

常见坑:

  • 不要只讲名词:JNI、Hook、加固、APM 都要落到你做了什么决策、解决了什么问题。
  • 不要编指标:所有 【量化指标】 必须来自真实记录;不确定就讲验证方法。
  • 不要把安全讲成攻击教程:面试目标是证明防御意识和工程边界,不是展示绕过能力。
  • 不要贬低应用开发:你的叙事是“底层能力上移“,不是“应用层简单“。

三、转应用开发的高频追问应对

Q:“你没怎么做过 UI/完整 App,能胜任吗?”

“UI 和上层框架确实是我之前接触少的部分,所以最近我系统学了 Compose、Jetpack、协程和 MVVM,也做了练手项目。我学得快,因为底层原理我都懂——比如 View 绘制、事件分发、Handler 消息机制这些,我从系统层就理解。补的是 API 熟练度,不是认知。”

追问处理:不要只说“我学得快“,要拿行动证据回应。可以按“已补知识 → 练习产物 → 下一步补齐“说:例如“我已经系统复盘了【真实学习内容】,用【真实练习/项目/代码仓库】验证了 Compose/Jetpack/MVVM 的基本链路;如果入职,我会优先从明确边界的页面或性能/稳定性任务切入,同时在 code review 中补齐团队规范“。括号里的内容必须是真实可展示的材料。

Q:“为什么不继续做风控/SDK?”

“风控 SDK 让我打下了很扎实的底层功底,但它的业务面窄、偏单点。我希望参与完整产品的构建,从架构到体验都能贡献,成长空间更大。”

Q:“你的优势是什么?”

“我比一般应用开发者更懂系统底层和性能,遇到 native 崩溃、内存、卡顿、so 体积这类硬问题我能直接下到底层解决;同时我有安全和对抗的视角,能帮团队规避风险。”

如果面试官继续质疑“这些和业务开发有什么关系“,按“业务价值“回答:底层能力不是替代业务开发,而是在复杂问题出现时缩短定位链路;你仍然要按团队规范写 UI、架构和业务代码,只是额外带来性能、稳定性、安全这三个补位能力。

四、反问环节(面试官问“你有什么想问的“)

永远要问,不问显得没兴趣。好问题示例:

  • 团队目前的技术栈?Compose 用得多吗?有没有 native 模块?
  • 团队现在最大的技术挑战是什么?
  • 这个岗位期望我半年内达到什么状态?
  • 团队的代码质量保障流程(CI、Review、测试覆盖)?

避免一上来只问薪资、加班(放到 HR 面或 offer 阶段)。

五、算法准备方向

中级 Android 算法通常不会太难(LeetCode 中等为主),重点:

  • 数组/字符串双指针、滑动窗口。
  • 哈希表、链表(反转、环检测)。
  • 二叉树遍历(递归/迭代)、BFS/DFS。
  • 排序、二分查找。
  • 简单 DP(爬楼梯、最长子序列类)。

建议:LeetCode Hot 100 过一遍,手写常见题(快排、二分、链表反转)。大厂会考,中小厂偏少。

六、HR 面常见问题

  • 离职原因:讲成长诉求,不抱怨前公司。
  • 职业规划:技术深耕 + 逐步扩展广度,2-3 年成为团队能独当一面的工程师。
  • 期望薪资:提前调研市场,给区间,留余地。
  • 优缺点:优点结合岗位需求;缺点讲真实但在改进的(如“之前 UI 经验少,正在系统补齐“)。

七、临场提醒

  • 不会的题诚实说,但展示思路:“这个我没深入研究过,但我推测是……,因为……”。
  • 被怼住时别慌,可以说“让我理一下思路“。
  • 全程体现你的底层思维学习能力——这是你转型的最大卖点。

项目复盘专题

项目复盘不是流水账,而是把经历讲成背景清楚、责任明确、难点可信、取舍有依据、结果可验证的面试表达。所有涉及规模、收益、耗时、事故、用户量、业务线的数据,必须替换为真实记录;没有真实数据就讲验证方法,不要现场编。

一、项目复盘的总框架

项目类问题建议按“一条主线 + 三个证据“组织:

  1. 项目背景:项目服务谁、解决什么问题、为什么当时重要。
  2. 我的责任:我负责哪些模块、决策、交付物,边界在哪里。
  3. 技术难点:难点来自性能、稳定性、兼容性、安全、协作还是业务规则。
  4. 方案取舍:为什么选 A 不选 B,代价是什么,如何兜底。
  5. 结果证据:指标、灰度、日志、监控、线上反馈、复盘文档。

可直接套用的 STAR+复盘模板:

背景:【项目名】属于【业务线】,目标是解决【真实问题】,当时约束是【真实值】。
责任:我负责【模块/链路】,边界是【我负责什么】,不负责【其他团队/模块】。
行动:我先拆了【关键链路】,再针对【技术难点】做了【方案】,同时准备【监控/灰度/回滚】。
结果:上线后用【指标】验证,【真实值】从【真实值】变为【真实值】;如果没有指标,说明用【日志/灰度/测试】验证。
复盘:这件事沉淀了【流程/工具/规范】,如果重做我会提前补【风险点】。

二、项目背景与责任边界怎么讲

背景要回答“为什么做“,责任要回答“你到底做了什么“。中级 Android 面试最忌讳把团队成果全说成个人成果,也忌讳只说“参与开发“。

维度应该讲不要讲
背景【项目名】服务【业务线】,要解决【真实问题】“公司让做的”
规模真实接入范围、版本、机型、调用量或验证环境编造 DAU、收入、QPS
责任我负责的模块、接口、排查、上线、文档“整个项目都是我做的“但说不出细节
边界哪些由服务端、客户端、产品、测试负责把不了解的链路硬讲成自己的

表达模板:

“这个项目的背景是【项目名】在【业务线】里承担【真实职责】。我主要负责【客户端模块/SDK 模块/性能链路】,包括【接口设计/核心实现/排查上线/监控补充】。服务端策略、产品规则或运营配置由【真实协作方】负责,我这边重点保证端侧链路稳定、可观测、可回滚。”

三、技术难点与方案取舍

技术难点要讲“为什么难“,不是堆名词。方案取舍要讲“当时为什么这样选“,不是事后包装。

  1. 性能难点:启动、内存、包体、卡顿、线程调度。讲指标口径和预算,如主线程耗时、P95、峰值内存,用 【指标】【真实值】 替换。
  2. 稳定性难点:崩溃、ANR、弱网、进程死亡、兼容性。讲复现路径、日志补点、灰度观察、回滚开关。
  3. 安全/风控难点:环境异常、Hook、篡改、数据可信度。讲防御和工程化,不要讲可操作绕过细节。
  4. 协作难点:跨端、服务端、测试、产品口径不一致。讲接口契约、验收标准、对齐机制。

取舍回答公式:

“我们当时有两个方案:A 是【方案 A】,优点是【优点】,但代价是【代价】;B 是【方案 B】,优点是【优点】,但风险是【风险】。最后选【真实选择】,因为当时最重要的约束是【真实约束】。为了控制风险,我补了【灰度/开关/监控/回滚】。”

四、问题、事故与线上指标复盘

项目复盘一定要准备“出过什么问题“。没有严重事故也可以讲灰度问题、兼容问题、测试漏测、指标波动,但必须真实。

事故/问题表达不要甩锅,按下面顺序:

  1. 现象:什么指标或反馈异常,影响范围是多少。例:【时间】 发现 【指标】 异常,影响 【真实值】
  2. 定位:如何缩小范围,用了哪些日志、监控、灰度分组、复现设备。
  3. 处置:临时止血、开关回滚、版本修复、数据修复。
  4. 根因:代码、配置、流程、测试覆盖、依赖变更还是沟通问题。
  5. 预防:新增监控、用例、检查项、发布门禁、文档沉淀。

指标不要只说“提升明显“。至少准备三类证据:

  • 业务/使用证据:覆盖【业务线】、接入【真实值】版本、使用【真实值】天/周/月。
  • 技术指标:崩溃率、ANR、启动耗时、内存、包体、成功率、失败率,统一写成 【指标】=【真实值】
  • 过程证据:灰度批次、监控面板、日志字段、测试报告、复盘文档、Code Review 记录。

五、协作、推进与冲突处理

中级岗位会追问你是否能推动事情,不要只讲技术实现。协作复盘可以按“目标一致 → 契约明确 → 风险透明 → 结果闭环“说。

  • 和产品:确认成功标准,避免只按口头需求开发。可说“我把验收口径从’体验更好’拆成【指标】和【真实场景】“。
  • 和服务端:对齐接口字段、幂等、重试、错误码、灰度策略。强调契约和联调清单。
  • 和测试:补充兼容机型、弱网、异常输入、升级覆盖、回滚路径。
  • 和客户端同事:明确模块边界、代码 Review、公共能力沉淀。

冲突回答模板:

“当时分歧点是【真实分歧】。我没有直接争论实现偏好,而是把风险拆成【性能/稳定性/排期/可维护性】几项,用【真实数据或验证方式】对比。最后我们选择【真实方案】,并保留【降级/灰度/后续优化】。”

六、如果重做,你会怎么改

“如果重做“不是自我否定,而是展示复盘能力。回答要具体,不能只说“更早沟通”。

可以从 5 个方向选 2-3 个真实点:

  1. 更早定义指标:上线前就确定 【指标】、采集口径和看板,避免上线后补证据。
  2. 更细灰度和回滚:按版本、机型、渠道、业务线分层灰度,准备开关和降级。
  3. 更完善测试矩阵:覆盖弱网、低端机、进程死亡、升级、异常配置。
  4. 更清晰边界文档:把接口契约、错误码、调用时机、线程模型写清。
  5. 更早做风险评审:提前评估隐私合规、性能预算、安全误报、服务端依赖。

推荐回答:

“如果重做,我会先补两件事:第一,在开发前就把【指标】口径定下来,避免只靠上线后观察;第二,把【风险点】提前放进灰度和回滚方案。代码实现本身不是最大问题,真正容易出问题的是指标口径、协作边界和异常场景。”

高频面试题

Q1:请介绍一个你最有代表性的项目。

答题要点:按“背景 → 责任 → 难点 → 方案 → 结果 → 复盘“讲,控制在 3-5 分钟。所有数字替换为 【真实值】,例如“【指标】从【真实值】到【真实值】“;没有数字就说清验证方式。

Q2:这个项目最大的技术难点是什么?

答题要点:只选 1-2 个最能体现能力的难点。先讲难点来源,再讲方案取舍,最后讲验证。不要把所有技术栈都报一遍。

Q3:这个项目上线后出过什么问题?你怎么处理?

答题要点:讲真实问题,按“发现 → 定位 → 止血 → 根因 → 预防“回答。即使问题不大,也要体现监控、灰度、回滚和复盘意识。

Q4:你在项目里具体负责什么?哪些不是你负责的?

答题要点:主动划清边界,例如“我负责端侧【模块】和【指标】,服务端策略由【协作方】负责,但我参与了接口联调和异常码定义“。边界清楚反而更可信。

Q5:如果现在让你重做这个项目,会怎么优化?

答题要点:不要说“没什么问题“。从指标、灰度、测试、文档、风险评审中选真实改进点,说明为什么当时没做以及现在会怎么补。

易错点 / 追问

  • 易错点:把团队成果全揽到自己身上。 追问一到接口细节、日志字段、灰度策略就露馅;要明确自己的负责范围。
  • 易错点:编造指标。 面试官会追问口径、看板、样本量、时间窗口;没有真实数据就讲验证方法和补数计划。
  • 易错点:只讲成功不讲问题。 中级面试更看重事故处理和复盘能力,要准备真实故障或灰度问题。
  • 易错点:方案取舍讲成标准答案。 要回到当时约束,说明为什么在【时间】、人力、风险下做那个选择。
  • 追问:你怎么证明这是你做的? 准备代码提交、设计文档、日志字段、联调记录、复盘文档等真实证据。
  • 追问:项目结果和业务有什么关系? 用【业务线】、用户路径、稳定性、安全、效率或成本解释技术指标的业务意义。

简历追问防御清单

简历上的每一句项目描述都可能被追问。防御不是背话术,而是把真实参与、真实使用、真实指标、真实失败、真实取舍准备完整。凡是用户量、收益、性能、事故、业务规模、上线时间,都用 【真实值】【项目名】【指标】【时间】 等占位符替换,面试前再填真实证据。

一、5 分钟项目故事

5 分钟项目故事要能让面试官听懂“你做过、做深过、复盘过“。建议控制节奏:

  1. 30 秒背景:【项目名】属于【业务线】,解决【真实问题】。
  2. 45 秒职责:我负责【模块/链路】,交付【接口/能力/文档/上线】。
  3. 90 秒难点:选择 1-2 个难点,如性能、稳定性、兼容、安全、协作。
  4. 90 秒方案:讲拆解、取舍、实现、灰度、回滚。
  5. 45 秒结果:用【指标】和【真实值】说明结果;没有数据就讲验证链路。
  6. 20 秒复盘:讲失败、遗憾或如果重做会怎么改。

可背结构,不可背假内容:

【项目名】是【业务线】里的【项目定位】,当时要解决【真实问题】。
我负责【真实负责范围】,不是全链路都由我做,但我主导/参与了【真实动作】。
最大的难点是【技术/协作难点】,因为【真实约束】。
我的方案是【关键方案】,在【取舍点】上选择了【真实选择】,同时准备【监控/灰度/回滚】。
结果用【指标】验证,【真实值】;如果重做,我会提前补【改进点】。

二、真实使用证明与证据链

面试官怀疑“项目是不是包装的“时,最有效的不是强调“我真的做了“,而是拿出证据链。证据不一定是截图,也可以是你能讲清的细节。

追问方向可信证据回答重点
是否真实上线【时间】上线、版本号、灰度批次、回滚记录讲发布路径和风险控制
是否真实使用接入【真实值】业务/版本/渠道/宿主讲使用方、调用时机、限制条件
是否你负责代码提交、接口设计、日志字段、联调记录讲你的模块边界和关键决策
是否有结果【指标】、监控、测试报告、复盘文档讲指标口径,不编收益
是否遇到问题缺陷单、事故复盘、灰度问题讲处理流程和预防动作

防御性回答模板:

“这个项目可以从几个细节证明是真实做过的:第一,它在【时间】接入【真实使用范围】;第二,端侧关键日志有【字段/事件名】用于看【指标】;第三,我负责的是【真实范围】,所以我能讲清【接口/线程/异常/灰度】,但服务端【策略/运营配置】不是我主导。”

三、指标、失败案例与在线问题

简历上的“优化、提升、降低、支撑“最容易被追问。每个动词都要配一个真实口径。

  1. 优化了性能:准备优化前后 【指标】=【真实值】,工具来源是 Profiler、Perfetto、APM、日志还是内部看板。
  2. 提升了稳定性:准备崩溃率、ANR、失败率、重试成功率、灰度问题数量等真实指标。
  3. 支撑了业务:准备接入范围、版本、渠道、业务线、上线周期,不要编用户数或营收。
  4. 降低了成本:准备包体、网络流量、服务调用次数、排查耗时等真实数据。
  5. 保障了安全/合规:准备风险类型、检测/上报链路、误报控制、开关降级,不要夸口“完全防住“。

失败案例比成功更能证明真实经验。建议准备一个“小而真“的问题:

  • 现象:【时间】 灰度时 【指标】 异常或用户反馈【真实问题】。
  • 影响:影响范围是【真实值】;不知道精确范围就说“当时通过【方式】估算“。
  • 处置:先【开关/回滚/降级】止血,再定位【根因】。
  • 复盘:补了【监控/测试/发布检查/文档】。

四、方案取舍与 owned scope 防御

面试官会通过取舍题判断你是否真正参与决策。不要只说“用了某框架“,要讲为什么不用别的方案。

常见 owned scope 追问:

  • “这个核心方案是谁定的?” 如果不是你定的,就说“方案由【角色/团队】主导,我负责端侧落地和风险验证“;再讲你自己的贡献。
  • “服务端怎么做的?” 不懂不要硬编。可以说“服务端细节不是我负责,我对齐的是【接口契约/错误码/重试/幂等】“。
  • “你写了多少代码?” 不用报行数,讲模块、接口、关键类、测试、上线动作。
  • “为什么不选另一个方案?” 回到当时约束:【时间】、兼容性、性能预算、团队熟悉度、可回滚性。

owned scope 回答公式:

“我不把整个项目都说成自己做的。我的 owned scope 是【真实范围】,我做了【真实动作】,对结果负责的指标是【指标】。其他部分由【协作方】负责,但我通过【接口文档/联调/监控/灰度】保证端到端闭环。”

五、反假简历追问清单

下面这些问题要提前逐条准备,答不上来就把简历表述改窄。

简历表述面试官可能追问准备方式
负责【项目名】核心模块核心类、接口、线程模型、异常处理是什么准备 3 个实现细节和 1 个坑
优化【指标】口径、样本、时间窗口、对照组是什么准备【真实值】和工具来源
支撑多业务接入哪些业务线、版本、接入差异准备【业务线】和边界
解决线上问题什么问题、影响多大、怎么止血准备一次真实问题复盘
主导架构设计为什么这样分层,替代方案是什么准备 trade-off 和风险兜底

如果发现某条简历描述没有证据,立刻降级表达:

  • “主导“改成“参与设计并负责端侧落地”。
  • “显著提升“改成“通过【指标】观察到【真实值】变化”。
  • “高并发/海量“改成“在【真实规模】下验证”。
  • “全链路负责“改成“负责客户端【模块】,参与端到端联调”。

六、量化影响与表达边界

量化不是必须有漂亮数字,而是必须有真实口径。没有真实数据时,可以讲“当时如何验证“,但不要编。

推荐说法:

  1. 有真实指标:“上线后【指标】从【真实值】到【真实值】,统计窗口是【时间】,来源是【监控/日志/测试报告】。”
  2. 只有阶段性验证:“这个项目没有对外业务大盘指标,我能提供的是端侧验证:【测试范围】、【灰度范围】、【日志观察】。”
  3. 指标归因不完全确定:“这个结果受服务端策略和业务流量影响,我不把它全部归因到端侧。我负责的部分主要影响【端侧指标】。”
  4. 不能披露具体数字:“具体数值不方便展开,但口径是【指标】,我可以讲采集方式和优化路径。”

边界感会增加可信度。面试官更相信一个能说“这部分不是我负责“的人,而不是每个问题都说自己全做了的人。

高频面试题

Q1:你简历上说负责核心模块,核心体现在哪里?

答题要点:从调用链位置、故障影响、接口稳定性、性能预算、协作依赖解释“核心“。再讲自己负责的关键类/接口/日志/灰度,不要只说“业务很重要“。

Q2:你怎么证明这个项目真的上线并被使用了?

答题要点:用【时间】、版本、灰度、接入范围、日志指标、问题反馈证明。无法展示内部材料时,讲清证据链和细节,但不泄露敏感信息。

Q3:这个指标提升是不是你一个人的贡献?

答题要点:不要硬揽。说明端侧负责【指标】的哪一段,服务端/产品/测试分别影响什么,最后讲你可归因的贡献。

Q4:项目失败或效果不如预期时怎么办?

答题要点:讲真实失败案例,重点是止血、复盘、补监控、补测试、调整方案。不要说“没有失败过“。

Q5:如果我追代码细节,你能讲哪些?

答题要点:准备 3 个细节:核心接口、线程/生命周期、异常处理/降级、日志字段、测试用例。答不上来的细节不要写进简历。

易错点 / 追问

  • 易错点:用大词包装小参与。 “主导、核心、架构、全链路“都要有证据;没有证据就降级成准确表达。
  • 易错点:指标没有口径。 面试官会追问统计窗口、样本、对照组、工具来源;提前准备【指标】口径。
  • 易错点:不了解协作方却硬讲。 服务端、算法、产品策略不了解就说边界,转回接口契约和端侧验证。
  • 易错点:只讲成功故事。 至少准备一个真实失败/线上/灰度问题,否则像背模板。
  • 追问:你离开项目后它还在用吗? 讲交接、文档、监控、版本维护人;不知道就说最后已知状态是【时间】的【真实值】。
  • 追问:如果不能披露公司数据怎么办? 讲指标口径、相对变化、验证方法,不要泄露敏感信息也不要编虚假数字。

Vibe Coding

Vibe Coding 是 2025–2026 年非常热门的 AI 辅助开发话题。面试里不要把它讲成“让 AI 随便写“,而要讲成“用自然语言快速探索,再用工程纪律收口“。

一、什么是 Vibe Coding

Vibe Coding 指开发者用自然语言描述目标,让 AI 快速生成代码、页面、脚本或方案,再通过运行、审查、修改不断迭代。它强调速度和探索,但最终质量仍由开发者负责。

二、典型工作流

  1. 描述目标和约束:明确平台、现有模式、不可改范围。
  2. 让 AI 生成初版:适合脚手架、样例、重复代码、探索方案。
  3. 本地运行验证:构建、测试、手动体验。
  4. 人工审查:边界、异常、安全、可维护性。
  5. 小步迭代:每轮只改明确问题。

三、适合和不适合的场景

适合不适合
原型、Demo、重复样板支付、安全、隐私核心逻辑无人审查
文档、测试样例、重构草稿不了解上下文的大范围改造
API 使用探索生产事故直接盲改

四、质量与风险控制

  • 幻觉:AI 可能编不存在的 API。
  • 过度设计:生成看似完整但难维护的抽象。
  • 安全风险:泄露密钥、忽略权限、引入不安全依赖。
  • 上下文缺失:不了解项目约束时容易破坏现有模式。

控制方式:提供真实文件上下文、要求小步 diff、跑测试/构建、人工 code review。

五、Android 场景怎么用

  • 生成 ViewModel/State/Effect 的样板。
  • 写单元测试和边界用例。
  • 帮助解释崩溃堆栈、Perfetto trace、Gradle 报错。
  • 草拟面试答案,再用自己的项目经历替换占位。

六、面试怎么答

可以说:“我会用 AI 做探索和提效,比如生成测试样例、梳理源码调用链、草拟重构方案。但生产代码一定会经过构建、测试和人工审查。我不把 AI 当替代判断力的工具,而是当加速器。”

高频面试题

Q1:你怎么看 Vibe Coding? 答:它适合快速探索和提效,但不能替代工程纪律。真正关键的是把 AI 产物纳入测试、review、验证闭环。

Q2:AI 生成代码最大风险是什么? 答:上下文不足导致错误假设,以及生成看似合理但不可维护/不安全的代码。解决方式是给上下文、小步修改、强制验证。

Q3:你用 AI 写过什么? 答:回答时结合真实经历:测试样例、文档、脚手架、错误排查、代码解释。不要声称核心安全逻辑完全交给 AI。

易错点 / 追问

  • 不要把 Vibe Coding 包装成“不用懂代码“。
  • 不要把未验证的 AI 输出直接进主分支。
  • 面试时强调判断力、验证和责任归属。

Harness Engineering

Harness Engineering 是比“会用 AI 写代码“更高一层的能力:给 coding agent 搭建可靠的上下文、工具、验证和边界,让 AI 能长期、稳定、可审计地工作。

一、什么是 Harness Engineering

Harness 是围绕 AI coding agent 的工程脚手架。它不是模型本身,也不是简单 prompt,而是让 agent 在真实项目里知道规则、记住状态、正确使用工具、按验证闭环完成任务的一整套系统。

二、五个子系统

子系统类比作用
Instructions菜谱架AGENTS.md/CLAUDE.md、项目规则、架构说明
State备餐台feature_list.json、progress.md、session-handoff.md
Verification出餐质检口测试、lint、build、验收证据
Scope任务边界one-feature-at-a-time、Definition of Done
Lifecycle班次管理init.sh、启动检查、交接流程

三、为什么 AGENTS.md / CLAUDE.md 重要

这类文件是 agent 的项目级路由层,用于告诉 AI:

  • 先读哪些文档。
  • 哪些文件能改、哪些不能改。
  • 运行什么验证命令。
  • 完成前必须做什么检查。
  • 当前项目的架构和命名约定。

四、状态与会话连续性

长任务容易跨会话,所以需要持久状态:

  • feature_list.json:记录功能、状态、依赖、证据。
  • progress.md:记录当前进度和阻塞。
  • session-handoff.md:让下一轮 agent 接上上下文。

原则:能从代码/仓库推导出的内容不放进记忆;只记录决策、进度、验证结果和非显然约束。

五、验证工作流与 completion gate

Harness 必须定义“什么时候算完成“:

  1. 相关测试通过。
  2. 构建/lint/type check 通过。
  3. diff 符合需求,没有范围外改动。
  4. 验证证据被记录。
  5. 代码审查级验收通过。

只说“测试过了“不够,还要说明跑了什么命令、输出是什么、哪些没跑以及原因。

六、Scope 控制与安全

Agent 最常见问题是过度改动、忘记上下文、为了修一个问题重构半个项目。Harness 通过权限、任务边界、计划、提交粒度和 review gate 限制风险。

七、面试怎么讲

可以说:“我不只是用 AI 生成代码,还会给 AI 建执行边界:项目说明、任务状态、验证命令、完成门禁和 handoff。这样 AI 适合做重复、检索、草稿和局部实现,但质量由工程化流程兜底。”

高频面试题

Q1:Harness Engineering 和 prompt engineering 区别? 答:prompt engineering 关注一次对话怎么问;Harness Engineering 关注长期项目里 agent 的规则、状态、工具、安全、验证和生命周期。

Q2:一个最小可用 harness 包含什么? 答:AGENTS.md/CLAUDE.md 项目规则、明确验证命令、任务状态记录、完成定义、session handoff。复杂项目再加多 agent 协作和权限控制。

Q3:为什么需要 completion gate? 答:防止 agent 只凭感觉宣布完成。完成必须有构建/测试/审查证据,并确认没有范围外改动。

易错点 / 追问

  • 不要把 harness 讲成“写一个超长 prompt“。
  • 不要让记忆保存可从仓库推导的信息。
  • 不要让 agent 在没有验证命令的项目里自由发挥。

AI Coding 工程化进阶

AI Coding 的进阶能力不是“让模型多写代码”,而是把 prompt、上下文、工具、验证、代码审查和安全治理接成闭环。面试里要强调:AI 是加速器,工程师负责边界、判断和最终质量。

一、Prompt Engineering:把需求讲清楚

Prompt Engineering 关注单次任务如何描述清楚。好的 prompt 至少包含目标、上下文、约束、验收标准和禁止事项。

任务:为登录页 ViewModel 补充单元测试。
上下文:使用 Kotlin + Coroutines + Turbine,现有测试风格参考 LoginViewModelTest。
约束:只改测试文件,不改生产代码;覆盖成功、密码错误、网络异常。
验收:./gradlew testDebugUnitTest 通过;说明新增用例。
禁止:不要引入新依赖,不要重构 ViewModel。

常见技巧:

  • 用“角色 + 任务 + 输入 + 输出格式 + 验证命令”减少歧义。
  • 对大任务先让 AI 产出计划,再分步执行。
  • 明确不可改文件、兼容版本、安全边界。
  • 要求 AI 解释关键取舍,方便人工 review。

二、Context Engineering:让 AI 拿到正确上下文

Context Engineering 比 prompt 更重要。它解决“模型需要知道什么,不该知道什么,如何持续保持上下文准确”。

上下文类型示例作用风险
代码上下文相关类、测试、接口定义避免编不存在 API上下文太多会稀释重点
项目规则AGENTS.md、架构约束、命名规范避免破坏结构规则过期会误导
历史决策ADR、issue、notepad、handoff继承非显然约束记忆污染或重复记录
验证信息测试命令、lint、构建结果完成门禁未实际运行会造成假通过
禁止范围不改 README、不动依赖、不改生成物控制 scope写得不清会范围膨胀

面试可说:我会先给 AI 最小但充分的相关文件,让它按现有模式改,完成后用测试、diff 和 review 校验,而不是让它凭记忆猜项目结构。

三、Agentic Coding 与工具调用

Agentic Coding 指 AI 不只是回答问题,还会读文件、搜索、编辑、运行测试、根据结果迭代。MCP(Model Context Protocol)和 tool calling 的价值是把外部系统能力以标准工具暴露给模型,例如代码仓库、浏览器、Figma、数据库、日志平台或移动设备。

典型闭环:

  1. 读取真实文件,确认现有模式。
  2. 制定小步计划和验收标准。
  3. 修改代码或测试。
  4. 调用构建、测试、lint、静态检查工具。
  5. 根据失败日志修复。
  6. 输出证据和剩余风险。

注意:工具调用提升能力,也放大风险。必须限制权限、敏感数据、可写目录和危险命令,并要求每次修改后有可复现验证。

四、AI Code Review 与防止结构性破坏

AI 生成代码常见问题不是语法错误,而是结构损伤:重复抽象、跨层调用、绕过既有架构、隐式全局状态、错误生命周期、测试只覆盖快乐路径。

AI Code Review 可以作为第一层筛查:

  • spec review:是否满足需求,是否做了范围外功能。
  • architecture review:是否符合分层、依赖方向和模块边界。
  • security review:是否引入敏感日志、明文密钥、不安全网络或权限滥用。
  • test review:是否覆盖边界、异常、并发和回归场景。
  • maintainability review:命名、复杂度、重复、错误处理是否可维护。

防止 AI 结构 damage 的方法:

  • 小 diff,一次只改一个明确目标。
  • 先读现有实现和测试,禁止“重写式修复”。
  • 让 AI 说明为什么改这些文件,并检查是否有更小改法。
  • 用架构规则、lint、自定义检查和人工 review 双重门禁。
  • 对核心模块要求先写失败测试,再实现。

五、AI Test Generation 与验证闭环

AI 很适合生成测试草稿、边界清单和 mock 数据,但测试有效性仍需人工确认。

Android 场景常见用法:

  • ViewModel 状态机测试:输入 Intent,断言 State/Effect。
  • Repository 测试:mock API/DAO,覆盖缓存、错误和重试。
  • Coroutine/Flow 测试:使用 runTest、TestDispatcher、Turbine。
  • UI 测试:生成 Espresso/Compose test 的骨架。
  • 性能/稳定性:生成 Macrobenchmark 场景或 monkey 前置检查清单。

验证闭环必须包括:

AI 生成测试
  → 人工检查断言是否真的验证需求
  → 先确认测试能失败(避免无效测试)
  → 实现/修复代码
  → 运行测试 + lint/build
  → 记录命令和结果

易错点是 AI 可能写“只验证方法被调用”的弱测试,或者为了通过测试修改生产代码行为。工程上要优先验证用户可见行为和业务不变量。

六、AI 安全风险与治理

AI Coding 带来的安全风险包括代码风险、数据风险和供应链风险。

风险例子防护
敏感信息泄露把 token、客户数据、日志上传给外部模型脱敏、最小上下文、本地模型/企业网关
不安全代码关闭证书校验、明文存储、宽权限安全规则、SAST、人工安全 review
依赖投毒引入不存在或低信誉依赖锁定依赖审批、SBOM、漏洞扫描
许可证风险复制不明来源代码代码来源审查、许可证扫描
过度自动化agent 自行改发布配置/密钥权限隔离、只读默认、审批门禁

安全治理原则:不给 AI 不必要的秘密;不让 AI 绕过 review;不让 AI 单独决定安全策略;对生成代码使用和人工代码同等级别的审查、测试和审计。

七、移动开发工作流集成

在 Android 团队里,AI Coding 可以嵌入日常流程,但要和移动端特有验证结合。

  • 需求阶段:让 AI 从 PRD 提取状态机、异常分支、埋点和权限影响。
  • 开发阶段:生成 ViewModel、UseCase、Repository、Compose 预览、测试样例,但保持架构边界。
  • 调试阶段:解释 crash stack、ANR trace、Perfetto 片段、Gradle 依赖冲突。
  • 测试阶段:补充单元测试、UI 自动化、兼容性矩阵和回归清单。
  • 发布阶段:检查隐私权限、混淆 keep、Baseline Profile、灰度开关和回滚预案。
  • 安全阶段:辅助做敏感日志扫描、网络配置检查、权限最小化和依赖漏洞梳理。

移动端尤其要提醒 AI:生命周期、线程、协程取消、配置变更、低端机性能、Android 版本兼容、隐私合规和应用商店政策,这些是通用代码模型容易忽略的约束。

八、面试表达:从“会用 AI”到“能管 AI”

可以这样回答:

我会把 AI 放进工程闭环里:Prompt Engineering 负责把任务讲清楚,Context Engineering 负责给真实项目上下文,agentic coding 负责读写和运行验证,AI code review/test generation 提高覆盖率,最后由测试、lint、build、人工 review 和安全规则做 completion gate。我不把 AI 当成替代工程判断的工具,而是把它当成可审计、可回滚、受约束的协作者。

这类回答比“我用 AI 写页面很快”更高级,因为它体现了质量、验证、安全和团队协作意识。


高频面试题

Q1:Prompt Engineering 和 Context Engineering 有什么区别? Prompt Engineering 关注单次怎么问,让目标、约束和输出清楚;Context Engineering 关注给 AI 哪些真实项目上下文、规则、历史决策和验证信息,并控制上下文不过载或污染。

Q2:什么是 agentic coding? 它指 AI 能通过工具读文件、搜索、编辑、运行测试并根据结果迭代,而不是只生成文本。关键风险是权限和范围扩大,所以必须有工具权限、可写范围和验证门禁。

Q3:MCP / tool calling 对 AI Coding 的意义是什么? MCP 和 tool calling 把外部系统能力标准化地提供给模型,例如代码仓库、日志、浏览器、移动设备或设计系统。意义是让 AI 基于真实环境行动,但也需要权限控制、审计和敏感数据保护。

Q4:AI 生成测试靠谱吗? 可以作为草稿和边界补充,但不能盲信。要人工检查断言是否验证真实需求,最好先看到测试失败,再实现代码,最后运行测试、lint、build 形成证据。

Q5:如何防止 AI 破坏代码结构? 限制小 diff 和可改范围,要求先读现有模式,禁止重写式修复;用架构规则、review、测试和静态检查兜底;对核心模块先写失败测试,再让 AI 实现最小改动。

易错点 / 追问

  • 不要把 AI Coding 讲成“模型替我负责”,责任仍在工程师和团队流程。
  • 不要给 AI 过多无关上下文或敏感数据,Context Engineering 讲究最小充分。
  • 不要接受未验证的 AI 输出,必须有测试、lint、build 或人工 review 证据。
  • MCP/tool calling 不是越多越好,工具权限和审计比炫技更重要。
  • AI 生成测试要防止弱断言和为了通过测试而改坏业务语义。