Android 开发从入门到精通:环境搭建、Kotlin、Compose、架构、系统能力与发布全指南
- 约 45 分钟读完 - 全文 17940 字Android 开发从入门到精通:环境搭建、Kotlin、Compose、架构、系统能力与发布全指南
目标读者:想进入 Android 开发的新人,或者写过一阵 Java/旧版 XML 布局、想切换到现代技术栈(Kotlin + Compose)的同学。
知识骨架参考《Android 编程权威指南》(Big Nerd Ranch Guide),结合 2026 年的技术现状做了取舍,按「由浅入深」重新编排。
姊妹篇:《前端 UI 开发从入门到精通(Vue 技术栈)》——写 UI 的心法,两端是相通的。
全文共八篇,顺序就是学习的顺序——每一篇都依赖前一篇打下的基础:
flowchart LR
A["一 环境搭建<br/>能跑起 Hello World"] --> B["二 Kotlin 基础<br/>语法 + 协程"]
B --> C["三 Compose 界面<br/>写出第一个页面"]
C --> D["四 架构与生命周期<br/>从写页面到写应用"]
D --> E["五 网络与存储<br/>数据从哪来 存到哪去"]
E --> F["六 系统能力<br/>定位 通知 唤醒 适配"]
F --> G["七 质量与发布<br/>测试 调试 签名上线"]
G --> H["八 AI 时代<br/>让 AI 写 80% 的代码"]
style H fill:#e8f4e8
为什么是这个顺序?前三篇解决「能不能写出来」;第四篇解决「代码多了以后会不会散架」;第五、六篇解决「能不能和服务器、和操作系统打交道」;第七篇解决「敢不敢发给真实用户」;第八篇解决「如何持续快下去」。
第一篇 环境搭建:90% 的新人倒在这一关
先泼一盆冷水再给你信心:Android 开发的门槛不在写代码,而在环境。新人放弃 Android 的第一大原因不是学不会语法,而是装了三天 Android Studio 跑不起来一个 Hello World。所以环境放在最前面,并且写得足够细。
1.1 你需要安装的三样东西
flowchart TD
A["JDK 17+<br/>编译 Kotlin/Java 的底座"] --> D["Android Studio"]
B["Android SDK<br/>系统 API + 平台工具 adb"] --> D
C["Android Emulator<br/>或一台真机"] --> D
D --> E["能跑起 Hello World<br/>= 入门完成一半"]
好消息是 2026 年的 Android Studio 已经是「全家桶」安装:下载安装包一路 Next,JDK(内嵌 JBR)、SDK、模拟器全都有了。真正需要你动手的只有三件事:
- 下载地址认准官方:developer.android.google.cn(国内可直连)。别去下载站,装到捆绑了全家桶的旧版本是新人常见悲剧。
- 第一次启动时让它下载 SDK,默认勾选 Android SDK Platform、SDK Build-Tools、Android Emulator 即可。
- 配环境变量(命令行构建和 AI 工具都会用到):
# Windows: 系统环境变量里添加
ANDROID_HOME=C:\Users\你的用户名\AppData\Local\Android\Sdk
# Path 里追加
%ANDROID_HOME%\platform-tools # adb 在这里
%ANDROID_HOME%\cmdline-tools\latest\bin验证:新开一个终端跑 adb version,有输出即成功。
1.2 第一个项目:别折腾,用模板
New Project → Empty Activity(注意选 Empty Activity,这是 Compose 模板;不要选带 “Views” 后缀的,那是旧 XML 布局)。点 Run,等模拟器起来,看到 “Hello Android” 就算通关。
1.3 踩坑故事一:Gradle 同步转了一小时的圈
现象:新建项目后底部状态栏 “Gradle Sync” 转圈一小时不动,或者报 Could not resolve com.android.tools.build:gradle:x.x.x。
原因:Gradle 要从国外 Maven 仓库下载几百 MB 的构建工具和依赖,国内直连极慢。这是国内 Android 新人的「成人礼」。
解法:给项目根目录的 settings.gradle.kts 换上国内镜像(阿里源):
// settings.gradle.kts
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/google")
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
}
}教训:镜像要放在 google() 前面,Gradle 按顺序找,先命中镜像就不用走官方源了。同样的思路适用于一切包管理器(npm、pip、Maven)——「换源」是国内开发者的通用生存技能。
1.4 踩坑故事二:AGP、Gradle、JDK 的三角恋情
现象:拉下同事的项目,构建报错 Unsupported class file major version 65 或 Minimum supported Gradle version is x.x。
原因:Android 开发有三个版本必须互相匹配,这是新人最懵的地方:
flowchart TD
A["Android Gradle Plugin (AGP)<br/>项目级 build.gradle 里声明"] -->|"要求"| B["Gradle 版本<br/>gradle-wrapper.properties"]
A -->|"要求"| C["JDK 版本<br/>Android Studio 内嵌或系统"]
B -.->|"版本对照表<br/>查官方文档"| C
style A fill:#fff3cd
- AGP 8.x 要求 Gradle 8.x + JDK 17。
- 报错时不要瞎改,打开 Google 官方的「Android Gradle 插件版本说明」对照表,三者对齐。
教训:项目里 gradle-wrapper.properties 锁定了 Gradle 版本是有深意的——它保证任何同事、任何 CI 机器拉下代码构建结果一致。新人最容易犯的错就是全局升级 Gradle 然后所有老项目全炸。
第二篇 Kotlin 基础:写给会别的语言的人
环境通了,接下来是语言。如果你会 Java 或 JavaScript,这一篇很快;如果是零基础,建议搭配《Kotlin 核心编程》或官方教程把例子都敲一遍。
2.1 十分钟速览:和 Java/JS 的核心差异
// 1. val / var:不可变优先
val name = "方糖猫" // 不可变,类似 const
var age = 3 // 可变
// age = "三" // ❌ 类型推导为 Int,改 String 编译报错
// 2. 空安全:类型系统层面消灭 NPE
var nickname: String? = null
// nickname.length // ❌ 编译不过,必须处理 null
val len = nickname?.length ?: 0 // ?. 安全调用 + ?: 默认值
// 3. when 表达式:加强版 switch
val desc = when (age) {
in 0..2 -> "幼猫"
3 -> "壮年"
else -> "老猫"
}
// 4. data class:一行顶 Java 五十行
data class User(val id: Long, val name: String, val email: String = "")
// 自动生成 equals/hashCode/toString/copy
// 5. 函数式:lambda 和集合操作
val names = users.filter { it.name.isNotBlank() }
.map { it.name }
// 6. 扩展函数:给别人的类加方法
fun String.isEmail(): Boolean = this.contains("@")
"ftmao@example.com".isEmail() // true空安全是 Kotlin 最值得吹的设计。Java 程序员的一生之敌 NullPointerException,在 Kotlin 里被类型系统提前拦下——String? 和 String 是两个不同的类型,编译器强迫你处理 null。
2.2 协程:异步代码同步写
App 开发的核心动作是「发起网络请求 → 更新界面」,而且网络请求绝不能跑在主线程(否则界面卡死,系统直接弹 ANR 杀掉你的 App)。协程让这件事写得像同步代码:
// viewModelScope 是 ViewModel 自带的协程作用域,页面销毁自动取消
fun loadUsers() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
// withContext(Dispatchers.IO) 切到 IO 线程执行耗时操作
val users = withContext(Dispatchers.IO) {
api.getUsers() // 挂起函数:等结果,但不阻塞线程
}
_uiState.value = UiState.Success(users) // 回到主线程更新 UI
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "未知错误")
}
}
}sequenceDiagram
participant UI as 主线程(UI)
participant CO as 协程
participant IO as IO 线程池
UI->>CO: viewModelScope.launch
CO->>IO: withContext(IO) 发起网络请求
Note over UI: 主线程不阻塞<br/>界面照常响应触摸
IO-->>CO: 请求返回
CO-->>UI: 切回主线程更新状态
UI->>UI: Compose 自动重组界面
思考:协程最难的不是语法,是「作用域」的概念——协程挂在谁的 scope 上,决定了它什么时候被取消。用 viewModelScope 而不是裸 GlobalScope.launch,你的协程就会随页面销毁自动取消,不会内存泄漏。这是面试高频题,更是线上事故高频源。现在只需要记住结论,第四篇讲完生命周期后你会真正理解为什么。
第三篇 用 Compose 写出第一个界面
语言有了,马上写界面——学习正反馈来得越早越好。
3.1 从 XML 到 Compose:一次范式革命
旧时代的 Android UI 是「XML 写布局 + Java/Kotlin 找控件 + 手动 setText」,命令式的,状态和视图靠人肉同步。Compose 是声明式的:UI 是状态的函数。
flowchart LR
subgraph old["旧时代:命令式"]
A1["XML 布局"] --> B1["findViewById"] --> C1["手动 setText/setVisibility"]
C1 --> D1["状态变了?<br/>记得手动再 set 一遍<br/>(漏了就出 bug)"]
end
subgraph new["Compose:声明式"]
A2["状态 State"] -->|"状态变化自动触发重组"| B2["@Composable 函数<br/>UI = f(state)"]
end
style new fill:#e8f4e8
写过 Vue/React 的人会秒懂——这和前端框架的响应式是同一种思想,只是语法换成了 Kotlin。
3.2 主流 UI 组件「库」:答案比前端简单
前端有 Element Plus / Ant Design / Naive UI 群雄割据,Android 的答案简单得多:
flowchart TD
Q{"做什么样的 App?"}
Q -->|"绝大多数情况"| A["Material 3 (material3)<br/>官方出品 随 SDK 维护"]
Q -->|"要高度自定义设计"| B["Compose Foundation 无样式组件<br/>+ 自建 Design System"]
Q -->|"跨端 iOS + Android"| C["Compose Multiplatform<br/>或 Flutter(另一条技术栈)"]
- Material 3(
androidx.compose.material3):官方组件库,Button、Card、TextField、Dialog、Scaffold、NavigationBar 一应俱全,且深度适配系统特性(深色主题、动态取色)。先用它,用到不够用再谈别的。这和前端「别和组件库对着干」是同一个道理——对抗官方设计体系的自定义,维护成本极高。 - accompanist 已成历史:早年 Compose 功能不全,靠 accompanist 库补(下拉刷新、权限等),2026 年这些能力已全部并入官方库,新项目不要再引入。
- 第三方生态里值得一提的:Coil(图片加载,事实标准)、Compose Destinations(类型安全导航)。
3.3 Demo:一个五脏俱全的登录页
把 Kotlin、协程、Compose、Material 3 串起来——这是每个 App 都有的页面:
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
val state by viewModel.uiState.collectAsState()
Scaffold { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
label = { Text("邮箱") },
isError = state.emailError != null,
supportingText = { state.emailError?.let { Text(it) } },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("密码") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
Button(
onClick = viewModel::login,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.loading) {
CircularProgressIndicator(Modifier.size(20.dp))
} else {
Text("登录")
}
}
}
}
}对应的 ViewModel,注意状态管理的写法:
data class LoginUiState(
val email: String = "",
val password: String = "",
val emailError: String? = null,
val loading: Boolean = false,
)
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun onEmailChange(v: String) {
_uiState.update { it.copy(email = v, emailError = null) }
}
fun login() {
val s = _uiState.value
if (!s.email.contains("@")) {
_uiState.update { it.copy(emailError = "邮箱格式不对") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(loading = true) }
try {
delay(1500) // 假装发请求
// api.login(s.email, s.password)
_uiState.update { it.copy(loading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(loading = false) }
}
}
}
}设计要点(这些比语法值钱):
- 单一数据源:整个页面的所有状态装进一个
UiStatedata class,UI 只读它。改状态用update { it.copy(...) }不可变更新,和前端 Redux/Pinia 一个思想。 - UI 状态的状态机:登录按钮有「正常 / 加载中 / 禁用」形态,错误有「无错 / 有错」形态——「UI 本质是状态机」,在任何平台都成立。
3.4 踩坑故事三:列表滑动卡成 PPT
现象:LazyColumn 展示 500 条消息,滑动掉帧明显。
排查与解法,按出现频率排序:
flowchart TD
A["LazyColumn 卡顿"] --> B{"Composable 里<br/>有耗时计算吗?"}
B -->|"有,最常见"| C["用 remember 缓存计算结果<br/>remember(list) { expensive(list) }"]
B -->|"没有"| D{"item 有稳定的 key 吗?"}
D -->|"没写 key"| E["items(list, key = { it.id })<br/>没 key 时位置变动全量重组"]
D -->|"写了"| F{"在 Debug 包测的?"}
F -->|"是,冤枉了"| G["换 Release 包测!<br/>Debug 包 Compose 性能差数倍"]
style G fill:#ffe4e1
教训:第三条最坑——Compose 的 Debug 包性能不代表线上。无数新人在 Debug 包测出「卡顿」,花两天优化,打完 Release 包发现根本不卡。性能问题永远以 Release 包为准。
3.5 踩坑故事四:旋转屏幕,数据全没了
现象:页面填了一半表单,用户一转屏幕,输入全没了。
原因:旋转屏幕默认会销毁并重建 Activity。存在 Composable 局部变量里的状态随重建灰飞烟灭。
解法分三层,按持久程度选:
// 1. remember:只管重组不丢,不管重建 —— 最弱
var text by remember { mutableStateOf("") }
// 2. rememberSaveable:重建也能恢复(进 Bundle,有大小限制)
var text by rememberSaveable { mutableStateOf("") }
// 3. ViewModel + StateFlow:配置变更(旋转、深浅色切换)都不丢 —— 正道
// 状态放 ViewModel 里,Activity 重建 ViewModel 不重建感悟:Android 的「生命周期地狱」本质上是系统在告诉你:UI 是廉价的,随时会被销毁;状态是珍贵的,要放在比 UI 活得久的地方。这个坑也是下一篇的入口——旋转只是「销毁」的一种,更狠的还在后面。
第四篇 架构与生命周期:从「写页面」到「写应用」
前三篇你能写出一个页面了。但真实 App 是几十个页面、在几亿台不可控的设备上被随意切换、随时被杀。这一篇建立组织代码的地基——《Android 编程权威指南》全书真正想教的就是这一篇的东西。
4.1 Activity 生命周期:不是八股,是保命符
生命周期回调几乎每个教程都有,但多数人只背了图没理解「什么时候会走哪条边」:
stateDiagram-v2
[*] --> onCreate: 启动
onCreate --> onStart
onStart --> onResume: 获得焦点,可交互
onResume --> onPause: 被半透明遮挡(弹窗)
onPause --> onResume: 回到前台
onPause --> onStop: 完全不可见(跳别的页面/按Home)
onStop --> onRestart: 从后台回来
onRestart --> onStart
onStop --> onDestroy: 用户退出或系统回收
onDestroy --> [*]
真正值钱的是这两条规则:
- 可见性配对:
onStart/onStop管「看不看得见」(注册/注销广播监听放这里),onResume/onPause管「能不能交互」(相机、传感器这类独占资源放这里)。放错层级是资源泄漏的常见来源。 onDestroy不保证被调用:进程被系统直接杀死时,什么回调都不会来。重要数据不能指望「退出时再保存」。
踩坑故事五:切后台半小时,回来从头再来
现象:用户在表单页填到一半,切出去回了个微信,半小时后切回来,App 重新从启动页加载,填的内容全没了。
原因:这不是 3.5 节旋转屏幕那种配置变更,而是进程死亡(Process Death)——App 在后台久了,系统内存不足时直接杀掉整个进程。ViewModel 也跟着进程一起死了,3.5 节的三层方案全部失效。
解法:ViewModel 里的关键状态用 SavedStateHandle,它会把数据写入进程死亡也能存活的 Bundle:
class FormViewModel(private val state: SavedStateHandle) : ViewModel() {
// 进程被杀再恢复,draft 还在
var draft: String
get() = state["draft"] ?: ""
set(value) { state["draft"] = value }
}怎么测试:开发者选项里打开「不保留活动(Don’t keep activities)」,或者在 App 切后台后执行 adb shell am kill <包名> 模拟进程死亡。不测这一关,你永远不知道自己的状态恢复写对了没有——这是权威指南反复强调而多数教程略过的点。
4.2 四大组件与 Intent:Android 的「宪法」
flowchart TD
A["Activity<br/>一页界面"] --- E["Intent 把它们串起来"]
B["Service<br/>无界面的后台工作<br/>(今天多由 WorkManager/前台服务承担)"] --- E
C["BroadcastReceiver<br/>监听系统/应用事件<br/>开机完成 网络变化"] --- E
D["ContentProvider<br/>跨进程共享结构化数据<br/>联系人 媒体库"] --- E
应知应会的点不在概念,而在分寸:
- Intent 分显式/隐式:显式指定类名跳自己 App 的页面;隐式声明「我想看一张图片 / 打开一个网页」,系统找能干的 App 来接。隐式 Intent 配合 intent-filter,就是 Android 应用间协作的全部基础——第六篇的 App 唤醒全靠它。
- Task 与返回栈:启动的 Activity 层层叠在一个任务栈里,返回键出栈。第六篇的
launchMode就是在控制这个栈的行为。 - 今天的比重:新项目里 Service 大多被 WorkManager 和协程替代,BroadcastReceiver 的系统广播被限制得越来越严,ContentProvider 多数时候你只是「使用者」(读联系人、读相册)。概念要懂,但别再像十年前的教程那样上来先写三个 Service。
4.3 Fragment:Compose 时代怎么看它
权威指南的老版本用了大量篇幅讲 Fragment——它是 View 时代组织页面的核心。2026 年的答案:
- 新项目:单 Activity + Compose + Navigation 库,页面就是 Composable,不需要 Fragment。
- 绕不开的场景:维护老项目、集成只有 View 版本的第三方 SDK(某些地图、支付 SDK),以及系统级 UI(如
PreferenceFragmentCompat)。
所以对待 Fragment 的正确姿势是:看懂生命周期比 Activity 多一层(view 的生命周期和 Fragment 本身分离),够用即可,不必深入。把省下的时间投给 Compose。
4.4 架构:MVVM + Repository + 单向数据流
权威指南从 MVC 讲到 MVVM 的演进,核心思想收敛成今天 Google 官方推荐的这张图:
flowchart TD
subgraph UI层
A["Composable<br/>只负责渲染和上报事件"]
end
subgraph 状态层
B["ViewModel<br/>持有 UiState(StateFlow)<br/>暴露事件处理函数"]
end
subgraph 数据层
C["Repository<br/>数据的唯一入口<br/>决定走网络还是缓存"]
D["Retrofit<br/>远程数据"]
E["Room / DataStore<br/>本地数据"]
end
A -->|"用户事件"| B
B -->|"StateFlow 数据流"| A
B --> C
C --> D
C --> E
三条铁律,比记住「MVVM」三个字母重要得多:
- UI 不直接碰网络和数据库——Composable 里出现 Retrofit 调用,就是架构腐烂的开始。
- 数据向下流,事件向上流(UDF):UI 只读状态、只发事件,ViewModel 改状态。3.3 节的登录 Demo 就是这个模式。
- Repository 是数据的唯一入口:ViewModel 不关心数据来自网络还是 Room 缓存,这让「离线优先」的 App 成为可能——先展示 Room 里的旧数据,网络回来再刷新。
注意这张图里数据层的两个角色——Retrofit 和 Room。它们就是下一篇的主角。
第五篇 网络与存储:数据从哪来,存到哪去
架构定了,现在填数据层的肉。这一篇学完,你就能写出「联网 CRUD + 离线缓存」的完整 App 了。
5.1 联网的第一步:权限与明文拦截
踩坑故事六:请求发出去了,App 里却报 CLEARTEXT 错误
现象:Retrofit 请求发出去了,模拟器浏览器也能打开接口地址,App 里却报 CLEARTEXT communication not permitted。
原因,两个新手必踩的组合拳:
AndroidManifest.xml忘了声明网络权限(Android 不像前端,联网要申请):
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>- Android 9+ 默认禁止 HTTP 明文请求,测试环境是
http://就会撞上。开发期放行:
<application
android:usesCleartextTraffic="true" <!-- 仅限 debug 包这么干 -->
...>教训:上线前把明文流量关掉,让后端上 HTTPS。安全这类事情,「先放行以后再收」基本等于永远不收。
5.2 Retrofit:网络层的事实标准
移动端网络层的标准组合:Retrofit(定义接口)+ OkHttp(发请求)+ 协程(写异步)。
// 1. 接口定义:挂起函数 + 注解,Kotlin 协程原生支持
interface ApiService {
@GET("users")
suspend fun getUsers(@Query("page") page: Int): ApiResponse<List<User>>
@POST("login")
suspend fun login(@Body body: LoginRequest): ApiResponse<LoginData>
}
// 2. OkHttp:拦截器统一带 token、打日志、配超时
val okHttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.addHeader("Authorization", "Bearer ${TokenHolder.token}")
.build()
chain.proceed(req)
}
.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
.build()
// 3. Retrofit 组装
val api = Retrofit.Builder()
.baseUrl("https://api.example.com/") // ⚠️ 必须以 / 结尾,见踩坑故事七
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)踩坑故事七:baseUrl 结尾少了一个斜杠
现象:Retrofit 初始化直接 crash:IllegalArgumentException: baseUrl must end in /。
教训:几乎每个 Android 开发者都撞过一次。顺手记住另一个相关坑:@GET 里的相对路径如果以 / 开头,会丢弃 baseUrl 的路径段——baseUrl("https://x.com/api/") 配 @GET("/users") 实际请求的是 https://x.com/users,/api 没了。
踩坑故事八:拦截器里打印了 body,业务层就拿不到数据了
现象:在自定义拦截器里写了 response.body()!!.string() 打日志,结果业务层解析永远失败。
原因:OkHttp 的响应体是流,只能消费一次。
解法:打日志用官方 HttpLoggingInterceptor(上面 Demo 里已带);自定义拦截器里要读内容,用 peekBody:
val bodyText = response.peekBody(1024 * 1024).string() // ✅ 复制一份读,不影响原流移动端特有的提醒:手机用户会进电梯、进地铁、切 Wi-Fi 和流量,弱网和断线是常态而不是异常。超时必须配、失败要给重试入口、一次抖动失败别急着弹「网络异常」的全屏错误——静默重试一次,体验天差地别。
5.3 本地存储:四个层级,各管各的
「存东西」在 Android 里不是一件事,先想清楚存什么:
flowchart TD
Q{"要存什么?"}
Q -->|"键值对<br/>设置项 / token"| A["DataStore<br/>(SharedPreferences 的接班人)"]
Q -->|"结构化数据<br/>离线缓存 / 消息列表"| B["Room 数据库<br/>(下一节详解)"]
Q -->|"图片 / 日志 / 大文件"| C["App 私有目录<br/>context.filesDir"]
Q -->|"要存进相册 / 共享给用户"| D["MediaStore API<br/>(不要直接写 /sdcard)"]
键值存储的 Demo,协程化的 DataStore 用法:
val Context.dataStore by preferencesDataStore(name = "settings")
val KEY_TOKEN = stringPreferencesKey("token")
suspend fun saveToken(context: Context, token: String) {
context.dataStore.edit { it[KEY_TOKEN] = token }
}
// 读取是 Flow,可以在 ViewModel 里直接 collect
val tokenFlow: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }踩坑故事九:SharedPreferences 引发的 ANR
现象:老项目用户反馈「切换页面偶尔卡死」,ANR 日志里堆栈停在 QueuedWork.waitToFinish()。
原因:SharedPreferences.apply() 号称异步,但系统会在 Activity 停顿时把磁盘写入队列 flush 完才放行——数据多了,主线程就在这等着。另外首次 getSharedPreferences() 的读盘也发生在主线程。
解法:新项目一律 DataStore(协程挂起,天然不阻塞主线程);老项目遇到这类 ANR,迁移 DataStore 是治本方案。
踩坑故事十:targetSdk 升到 30,读不到文件了
现象:原来「把文件存到 /sdcard/Download」的代码,升级 targetSdk 后全部失败。
原因:Android 10 引入分区存储(Scoped Storage),App 再也不能随意读写公共目录了。
解法分级:
- App 自己的文件 → 用
context.filesDir/cacheDir,不需要任何权限,卸载自动清理。 - 图片视频要进相册 → 走
MediaStoreAPI。 - 让用户选文档 → 走 SAF(系统文件选择器)。
- 想申请
MANAGE_EXTERNAL_STORAGE一把梭?Google Play 会拒审,除非你是文件管理器类 App。
感悟:分区存储当年被骂得很惨,但回头看是对的——它终结了「每个 App 都在 SD 卡根目录拉一坨文件夹」的蛮荒时代。适配它的正确姿势不是对抗(到处找绕过方案),而是按数据的归属选 API:属于自己的放私有目录,属于用户的走 MediaStore。
5.4 Room:编译期检查 SQL 的数据库
结构化数据(消息列表、账单、离线缓存)的答案。完整三件套 Demo:
// 1. Entity:一张表
@Entity(tableName = "expenses")
data class Expense(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val amount: Double,
val createdAt: Long,
)
// 2. DAO:操作表的方法,SQL 在编译期校验
@Dao
interface ExpenseDao {
@Query("SELECT * FROM expenses ORDER BY createdAt DESC")
fun observeAll(): Flow<List<Expense>> // 返回 Flow:数据变了 UI 自动更新
@Insert
suspend fun insert(expense: Expense)
@Delete
suspend fun delete(expense: Expense)
}
// 3. Database:单例持有
@Database(entities = [Expense::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun expenseDao(): ExpenseDao
}
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()@Query 返回 Flow 是 Room 最优雅的用法——数据库一变,Flow 发射新值,Compose 自动重组,「增删改 → 界面刷新」这条链路不用写一行通知代码。
踩坑故事十一:加了个字段,老用户升级就闪退
现象:新版给 Expense 加了一个 category 字段,自己测没问题,上线后老用户打开就崩:IllegalStateException: A migration from 1 to 2 was required。
原因:Room 用 version 管理表结构。老用户手机里的库还是 version 1 的结构,新代码按 version 2 读,Room 要求你提供 Migration(怎么从 1 迁移到 2),你没有提供。
解法:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE expenses ADD COLUMN category TEXT NOT NULL DEFAULT ''")
}
}
Room.databaseBuilder(...)
.addMigrations(MIGRATION_1_2)
// 开发期偷懒可以用 .fallbackToDestructiveMigration()(清库重建)
// 上线前必须删掉 —— 那是拿用户数据开玩笑
.build()教训:数据库字段变更和接口变更一样,是对存量用户的承诺。每次改 Entity,先想「老用户的库里有什么」。
第六篇 系统能力:定位、通知、唤醒与平台适配
到这里,你的 App 已经「自成一体」了。这一篇让它和操作系统、和其他 App、和真实世界的几亿台设备打交道。系统能力的坑有一个共同特征:Demo 里都好好的,一到用户手机上就出事。
6.1 定位:别再用 LocationManager 了
官方推荐的是 Google Play 服务里的 FusedLocationProviderClient——它自动在 GPS、基站、Wi-Fi 之间选最优来源,比你手写 LocationManager 监听省心得多。
权限申请的关键流程(Android 12 之后有变化):
flowchart TD
A["请求定位权限<br/>ACCESS_FINE + ACCESS_COARSE"] --> B{"用户怎么选的?"}
B -->|"精确授权"| C["高精度定位<br/>米级"]
B -->|"Android 12+ 只给『大致位置』"| D["只有约 3km 精度<br/>⚠️ 代码必须兼容这种情况"]
B -->|"拒绝"| E["优雅降级<br/>引导手动选择城市"]
B -->|"需要后台定位"| F["必须再单独申请<br/>ACCESS_BACKGROUND_LOCATION<br/>且只能跳设置页让用户手动开"]
获取定位的 Demo(配合 kotlinx-coroutines-play-services 的 await()):
val fused = LocationServices.getFusedLocationProviderClient(context)
suspend fun getCurrentLocation(): Location? {
return try {
fused.getCurrentLocation(
Priority.PRIORITY_HIGH_ACCURACY,
CancellationTokenSource().token,
).await()
} catch (e: SecurityException) {
null // 权限被收回
}
}踩坑故事十二:定位永远是 null,或者位置是三天前的
现象:getLastLocation() 一会儿返回 null,一会儿返回几个小时前的旧坐标。
原因:这个方法只是读系统缓存——最近有别的 App 定位过才有值,它自己不会发起定位。
解法:像上面 Demo 那样用 getCurrentLocation() 主动请求一次新定位。需要持续追踪(跑步轨迹这类)再用 requestLocationUpdates()。
踩坑故事十三:坐标偏了五百米,人都站到河里了
现象:GPS 拿到的经纬度画到高德地图上,位置偏移了几百米,用户「站在」马路对面的河里。
原因:国内坐标系的经典天坑——世界上同时存在三种坐标:
flowchart LR
A["GPS 芯片原始坐标<br/>WGS-84"] -->|"国测局加密算法"| B["GCJ-02 火星坐标<br/>高德/腾讯/Google 国内用"]
B -->|"百度再加密一次"| C["BD-09<br/>百度地图专用"]
A -.->|"直接画到高德地图<br/>❌ 偏移 300-600 米"| D["线上事故"]
style D fill:#ffe4e1
教训:用什么地图 SDK 就转成什么坐标。FusedLocationProviderClient 返回的是 WGS-84,上高德地图前先用高德 SDK 的 CoordinateConverter 转 GCJ-02;接百度地图就转 BD-09。永远不要自己写转换算法,加密参数不公开,网上的「破解版公式」精度堪忧。
6.2 消息通知:两段历史决定了今天的写法
通知 API 看着简单,但它是 Android 版本行为变更的重灾区。先记住这两个分水岭:
flowchart LR
A["Android 8 (API 26)<br/>通知必须归属<br/>NotificationChannel"] --> B["Android 13 (API 33)<br/>通知变成运行时权限<br/>POST_NOTIFICATIONS"]
B --> C["今天的完整姿势<br/>建渠道 + 申请权限<br/>缺一不可"]
完整 Demo:
// 第 1 步:建渠道(App 启动时建,重复调用无副作用)
val channel = NotificationChannel(
"order", // 渠道 id
"订单消息", // 用户在系统设置里看到的名字
NotificationManager.IMPORTANCE_HIGH,
)
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
// 第 2 步:Android 13+ 运行时申请权限
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> /* 记录用户选择,别循环弹 */ }
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
// 第 3 步:发通知
val notification = NotificationCompat.Builder(context, "order")
.setSmallIcon(R.drawable.ic_notification) // 必须,否则直接抛异常
.setContentTitle("订单已发货")
.setContentText("您的包裹正在派送")
.setAutoCancel(true) // 点击后自动消失
.build()
NotificationManagerCompat.from(context).notify(1, notification)踩坑故事十四:通知死活不显示
按出现概率排查:
flowchart TD
A["通知不显示"] --> B{"建 Channel 了吗?"}
B -->|"没建(Android 8+ 直接静默丢弃)"| C["补上 createNotificationChannel"]
B -->|"建了"| D{"Channel 的 importance<br/>是不是 LOW/MIN?"}
D -->|"是"| E["⚠️ 渠道等级一旦创建<br/>代码里改不了!<br/>只能换渠道 id 或引导用户去设置"]
D -->|"否"| F{"Android 13+ 申请<br/>POST_NOTIFICATIONS 了吗?"}
F -->|"没申请"| G["补运行时权限申请"]
style E fill:#ffe4e1
第二个分支最阴:渠道等级创建后被系统记住,你在代码里把 IMPORTANCE_LOW 改成 IMPORTANCE_HIGH 毫无作用,要么换一个新的渠道 id,要么引导用户去系统设置里改。
关于推送(Push)多说两句:本地通知只是通知的一半,另一半是服务器推消息下来。这里国内生态是割裂的:
- FCM(Google 推送)在国内不可用。
- 国内正道是厂商通道:华为、小米、OPPO、vivo、荣耀各自的推送 SDK。它们是系统级通道,App 被杀也能收到。
- 第三方聚合(个推、极光)可以一套代码接多家厂商通道,小团队首选。
- 只靠自己的长连接保活推消息?国产 ROM 杀后台从不手软,重要消息(订单、支付)务必走厂商通道。
6.3 App 唤醒:Deep Link、拉起与被拉起
4.2 节讲了隐式 Intent 和 intent-filter 的原理,这一节把它用起来。「唤醒」分两个方向:别人拉起你,你拉起别人。
flowchart TD
Q{"从哪拉起你的 App?"}
Q -->|"网页/短信里的 https 链接"| A["App Links<br/>需域名资产验证 体验最好"]
Q -->|"其他 App / 二维码"| B["自定义 Scheme<br/>myapp://detail/123<br/>简单但可被抢注"]
Q -->|"通知点击"| C["PendingIntent<br/>配 FLAG_IMMUTABLE"]
Manifest 里声明可唤起入口(Android 12+ 必须显式 android:exported):
<activity
android:name=".DetailActivity"
android:exported="true"
android:launchMode="singleTask"> <!-- ⚠️ 见踩坑故事十五 -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="ftmao.com"
android:pathPrefix="/app" />
</intent-filter>
</activity>踩坑故事十五:从链接唤起后,页面参数是旧的,或者叠了三四层
现象:App 已在后台时点击链接,打开的还是上次的旧内容;多点几次链接,返回键要按四下才退出去。
原因:默认 launchMode="standard" 每次唤醒都新建一个 Activity 实例,叠在任务栈顶(4.2 节的返回栈);而且热启动时新参数走 onNewIntent() 回调,不走 onCreate()——只在 onCreate 里读 intent 的代码自然漏掉。
解法:singleTask + 两个入口都处理:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent) // 热启动的参数在这里
}
private fun handleDeepLink(intent: Intent?) {
val id = intent?.data?.lastPathSegment ?: return
// 跳详情页
}反方向(你拉起别的 App)的版本坑:Android 11 引入包可见性限制,packageManager.getPackageInfo("com.tencent.mm") 会抛 NameNotFoundException——不是你代码错了,是系统假装微信不存在。需要在 Manifest 里声明 <queries>:
<queries>
<package android:name="com.tencent.mm" />
</queries>6.4 平台适配 tips:碎片化才是 Android 的本体
前端适配的是几个浏览器,Android 适配的是「几十种 ROM × 十几个活跃系统版本 × 无数种屏幕」。以下是我项目里反复用到的适配清单:
屏幕与手势
- 尺寸单位永远
dp/sp,别碰px;字号用sp才能跟随用户的系统字体设置。 - Android 15 起强制 edge-to-edge:内容会画到状态栏和手势导航条下面。用 Compose 的
Scaffold+Modifier.padding(paddingValues)或WindowInsets处理,别自己猜状态栏高度。 - 折叠屏和平板:用
WindowSizeClass判断屏幕档位切换单栏/双栏布局,别写死「宽度大于 600dp 就是平板」。
深色模式
- 颜色全部走
MaterialTheme.colorScheme,一个Color.White都不要写死。写死颜色的页面在深色模式下就是一块刺眼白斑,而且这种 bug 只在用户半夜打开 App 时才暴露。
版本策略
flowchart LR
A["minSdk 26<br/>覆盖 95%+ 活跃设备"] --> B["targetSdk 跟着<br/>Google Play 年度要求走"]
B --> C["行为变更按版本处理<br/>Build.VERSION.SDK_INT 分支"]
C --> D["能力检测优于版本判断<br/>能查权限就查权限"]
踩坑故事十六:小米上好好的,华为上定时任务就是不执行
现象:提醒功能在小米、Pixel 上正常,部分华为、OPPO 用户反馈「到点不提醒」。
原因:国产 ROM 的后台管控比原生 Android 激进得多——自启动权限、电池优化白名单、后台弹出界面权限,每家厂商的名称、入口、默认策略都不一样。原生 API 层的 AlarmManager、前台服务,到了这些 ROM 上随时可能被「省电策略」干掉。
解法:
- 周期性、可延迟的任务用 WorkManager——它是 Google 专门为解决各厂商后台差异做的兼容层,会根据系统能力自动选底层实现。
- 必须精确到点的(闹钟类),检测并引导用户跳转对应设置页(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS等),文案讲清楚「不开会怎样」。 - 重要消息走 6.2 节说的厂商推送通道,别指望自己保活。
感悟:适配的终极心法只有一句——不要假设环境,永远做能力检测。if (Build.VERSION.SDK_INT >= 33)、if (checkSelfPermission(...) == GRANTED)、if (packageManager.resolveActivity(intent) != null) 这些防御性代码写多了,你就从「在我手机上能跑」毕业到「在所有人手机上能跑」。这也是 Android 开发和前端开发气质上最大的不同:前端的环境是相对可控的浏览器,Android 的环境是几亿台你从没见过的手机。
第七篇 质量与发布:从「能跑」到「敢上线」
功能做完只是上半场。这一篇是把 App 交给真实用户之前的最后一道关。
7.1 测试:少但要有
测试金字塔在移动端同样成立,按性价比排:
flowchart LR
A["单元测试 多写<br/>ViewModel / 工具函数<br/>毫秒级 不依赖设备"] --> B["Compose UI 测试 精选<br/>核心页面渲染与交互"]
B --> C["端到端 极少<br/>发版前跑主流程"]
单元测试 Demo(ViewModel + 协程,kotlinx-coroutines-test):
class LoginViewModelTest {
@Test
fun `邮箱格式错误时不发起登录`() = runTest {
val vm = LoginViewModel()
vm.onEmailChange("not-an-email")
vm.login()
assertEquals("邮箱格式不对", vm.uiState.value.emailError)
assertFalse(vm.uiState.value.loading)
}
}Compose UI 测试 Demo:
class LoginScreenTest {
@get:Rule
val rule = createComposeRule()
@Test
fun 点击登录按钮触发校验提示() {
rule.setContent { LoginScreen() }
rule.onNodeWithText("登录").performClick()
rule.onNodeWithText("邮箱格式不对").assertIsDisplayed()
}
}务实建议:别追求覆盖率数字。给「钱相关的计算、状态机的转移、数据解析」这三类写单元测试,给「登录、主列表、下单」这三条主链路写 UI 测试,投入产出比最高。
7.2 调试三板斧:Logcat、adb 与 Layout Inspector
遇到问题时的三板斧,按使用频率排:
- Logcat:分级日志
Log.d/e/w,配上明确的 TAG。经验:TAG 用类名,日志里带上关键 id(订单号、用户 id),半年后排查线上问题你会感谢自己。 - adb 命令行,高频就这几条:
adb devices # 设备列表
adb install app-debug.apk # 安装
adb shell am start -n 包名/.MainActivity # 拉起页面
adb shell am kill 包名 # 模拟进程死亡(4.1 节测试用)
adb logcat -s 你的TAG # 只看自己的日志- Layout Inspector:Compose 时代的「布局透视镜」,实时看每个 Composable 的重组次数——重组次数异常飙升,就是 3.4 节性能问题的第一现场。
7.3 国际化与无障碍:专业完成度的分水岭
这两件事区分了「能用的 App」和「专业的 App」:
国际化(i18n)
- 所有文案进
res/values/strings.xml,代码里用stringResource(R.string.xxx),一个中文字符串都不要硬编码——今天不国际化,明天产品说「做个英文版」你就要全文搜索替换。 - 复数用
<plurals>(英文的 1 item / 2 items 规则不同),别用字符串拼接数字。
无障碍(a11y)
- 图片和图标按钮加
contentDescription(视障用户的屏幕阅读器 TalkBack 靠它朗读):
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}- 可点击区域不小于 48dp——Material 组件默认满足,自定义 Canvas 绘制按钮时最容易违规。
- 最后打开 TalkBack 把自己的 App 主流程走一遍,十分钟,胜过读十篇规范。
7.4 发布:签名、混淆与 AAB
1. 签名(Keystore):Android 用签名识别「同一开发者」。生成 release keystore 后立刻备份到至少两个安全的地方——丢了它,你的 App 永远无法更新,只能换包名重新上架,用户和评分清零。这不是理论风险,是每个公司都流传过的真实事故。
2. 混淆(R8):Release 包默认开启,压缩、优化、混淆代码。
踩坑故事十七:Debug 包好好的,Release 包登录一直失败
现象:Debug 一切正常,打完 Release 包,接口返回的 JSON 解析出来所有字段都是 null。
原因:R8 把 data class 的字段名混淆成了 a、b、c,而 Gson 默认靠字段名反射匹配 JSON 的 key——名字没了,匹配全落空。
解法,二选一:
// 方案 A:给序列化模型加 keep 规则(proguard-rules.pro)
-keep class com.example.app.data.model.** { *; }
// 方案 B:换 Moshi/kotlinx-serialization,编译期生成适配器,不靠反射教训:所有验证都必须用 Release 包做最后一遍。Debug 和 Release 的差异(混淆、压缩、日志)大到足以让「我测过了」变成一句空话——3.4 节的性能测量如此,7.4 节的功能验证同样如此。
3. 打包 AAB:Google Play 要求上传 Android App Bundle(.aab)而非 APK,由 Play 按用户设备分发精简后的安装包。versionCode 单调递增(系统靠它判断更新),versionName 给人看。
第八篇 AI 时代:怎么少写一半代码把 App 做出来
前七篇是「内功」,这一篇是「兵器」。我的结论先放在这:AI 没有让 Android 开发变简单,它让「懂行的人」快了 3-5 倍,让「不懂行的人」更快地写出能跑但烂尾的代码。区别在于你怎么用它。
8.1 AI 在 Android 开发各环节的胜任度
flowchart LR
subgraph good["AI 做得极好 放心交出去"]
A1["样板代码<br/>ViewModel / data class / Repository"]
A2["单页面 UI<br/>给我画一个设置页"]
A3["单元测试"]
A4["解释报错信息"]
end
subgraph mid["AI 能做 但你要盯着"]
B1["跨页面架构改造"]
B2["性能优化"]
B3["依赖升级适配"]
end
subgraph bad["AI 做不好 还得靠人"]
C1["环境/构建问题<br/>(它看不到你的机器)"]
C2["真机/特定 ROM 才出现的 bug"]
C3["产品取舍<br/>这个交互要不要做"]
end
8.2 实战工作流:需求 → App 的 AI 协作五步法
这是我自己做一个新 App 时的真实流程:
第一步:先写「项目宪法」,再让 AI 写代码。 在项目里放一个 AGENTS.md / .cursorrules,写明技术栈和约定:
# 项目约定
- Kotlin + Jetpack Compose + Material 3,不用 XML 布局
- 架构:MVVM + Repository,单向数据流,状态全部走 ViewModel + StateFlow + UiState
- 网络:Retrofit + OkHttp + 协程,挂起函数返回 ApiResponse<T>
- 本地:Room 存结构化数据,DataStore 存键值,定时/后台任务用 WorkManager
- 图片加载用 Coil,导航用 Navigation Compose
- 禁止 GlobalScope;禁止硬编码颜色和文案,一律走 MaterialTheme 和 strings.xml
- Room 改 Entity 必须同步提供 Migration 并升 version没有这份文件,AI 每次生成代码的风格都随机漂移;有了它,AI 的输出稳定得像团队里最守规矩的那个工程师。注意这份清单里的每一条,都对应着前面某一篇的某个坑——项目宪法本质上是踩坑笔记的浓缩。
第二步:页面级生成,而不是 App 级生成。 不要对 AI 说「给我写一个记账 App」,要说「按项目约定写一个记账首页:顶部本月支出汇总卡片,下面是 LazyColumn 账单列表,每条显示分类图标、备注、金额」。一次一个页面、一个模块,AI 的输出质量和你的可控性都最高。
第三步:让它先写数据层和状态,再写 UI。 先生成 Entity/DAO/Repository + UiState + ViewModel,review 通过后再让它写 Composable——这正是第四、五篇的架构顺序。状态设计错了,UI 写得再漂亮也是白写。
第四步:编译错误直接喂回去。 Android Studio 现在集成了 Gemini,编译报错选中 → 问 AI → 应用修复,循环两三次大部分构建问题都能解决。但 1.3、1.4 那种环境级问题 AI 帮不了你——它看不到你本地的 SDK 装在哪个盘;6.4 那种「只在华为手机上复现」的问题它更帮不了——它没有真机。这就是为什么 AI 篇必须排在最后:它是放大器,放大的是你前七篇的水平。
第五步:人工 review 的四个必查点。 AI 生成的 Android 代码,我每次都只细查四处:① 协程作用域是否用了 viewModelScope(内存泄漏重灾区);② 状态是否放进了 ViewModel 而不是 remember 在深层 Composable 里(旋转和进程死亡丢数据);③ 网络/IO 是否切了 Dispatchers.IO(ANR 来源);④ 权限和版本分支有没有做能力检测(AI 爱写「假设权限已授予」的代码)。其他代码粗看即可——review 的精力要花在 AI 的已知短板上。
8.3 一个真实对比:Demo App 的工时变化
3.3 节那个登录页 + 一个用户列表页的小 Demo,三年前我要写一整天(查 API、调布局、处理状态)。2026 年用 AI 的实际耗时:
pie title 一个双页面 Demo 的 2 小时去哪了
"写 AGENTS.md 和提示词" : 20
"AI 生成 + 我 review 修改" : 60
"环境构建问题排查(AI帮不上)" : 25
"真机测试与细节打磨" : 15
注意那张饼图里最大的启示:省下来的时间没有消失,它转移到了「描述需求」和「审查代码」上。AI 时代的核心能力从「打字速度」变成了两样:
- 把模糊需求拆成精确技术任务的能力——你对第四篇架构的理解越深,给 AI 的指令就越准。
- 快速判断代码对错的能力——这恰恰要求你懂前七篇讲的所有基本功。看不懂协程作用域的人,review 不了 AI 写的 Android 代码。
8.4 思考:AI 是放大器,不是替代品
每次技术圈出现「AI 能不能取代程序员」的争论,我都想起一个类比:计算器没有取代会计,它淘汰了「只会打算盘的会计」,成就了「用计算器做财务分析的会计」。AI 对 Android 开发者的影响一样:
- 它消灭了记忆型知识的价值(某个 API 的参数顺序,查得再快也不如 AI 直接生成)。
- 它放大了判断型知识的价值(这个状态该放 ViewModel 还是 Composable?这个版本冲突该升 AGP 还是降 Gradle?这个定时任务该用 WorkManager 还是 AlarmManager?这次 Room 改表要不要写 Migration?)。
所以这篇文章前七篇的内容,在 AI 时代不但没有过时,反而更重要了——它们是你在 AI 写完代码后,能说「对」或者「不对」的全部依据。
结语:学习路线收个尾
flowchart LR
A["第 1 周<br/>环境搭建 + 跑通模板<br/>+ Kotlin 语法"] --> B["第 2-4 周<br/>Compose 基础<br/>写 5 个静态页面"]
B --> C["第 2-3 个月<br/>架构 + 网络 + Room<br/>写一个完整的<br/>联网 CRUD App"]
C --> D["第 3-6 个月<br/>定位/通知/唤醒/适配<br/>测试 签名 发布上架"]
D --> E["持续<br/>把 AI 变成<br/>你的初级工程师"]
三条反直觉的建议作为结尾:
- 第一周死磕环境不丢人。每个 Android 老兵都经历过 Gradle 转圈的下午,这不是你笨,是这个生态的真实成本。
- 用真机开发,而且要用不止一台。模拟器掩盖了低端机的性能问题,单品牌真机掩盖了 ROM 碎片化问题——你的用户分布在十几种厂商的手机上。
- 让 AI 写代码,让自己写笔记。每踩一个坑记下「现象、根因、下次怎么一眼认出」,这些笔记是 AI 时代你不可被替代的资产——因为 AI 知道所有答案,但它不知道你的项目昨天为什么炸。
祝你的 Gradle 永远秒同步,keystore 永远有备份,App 在华为和小米上都一样坚挺。