本节摘要:安全消除术不只靠语言机制,也靠工程默认值。本节从零搭一个 Kotlin 优先的安卓工程:Gradle 构建配置怎么写、协程与生命周期依赖怎么选、viewBinding 怎么取代 findViewById、以及目录组织如何让"防御成为默认、危险必须显式"。一个骨架搭对,后面七章的机制才有落脚点。
多数教程把"环境搭建"写成软件安装说明,这一节想多回答一个问题:工程里哪些默认值决定了事故发生率。
想想 Java 时代的常见骨架:findViewById 返回的 View 视图引用靠人肉保证与布局文件同步;网络回调里持有着 Activity,页面销毁后回调照常执行;所有的异步都堆在匿名 Thread 或 Handler 里,没有人负责取消。这些不是"写法风格",是埋在骨架里的事故种子。
反过来,Kotlin 时代的工程骨架可以把几条防线设为默认:视图绑定在编译期校验控件存在性、协程作用域跟生命周期自动取消、依赖注入让可空边界集中在一处。本节就按这个思路搭骨架。
Android Studio 新建项目时选 Empty Views Activity 模板(本文基于 Android Studio 的近期版本),语言选 Kotlin,构建配置选 Kotlin DSL(Generate Build Configuration with Kotlin Script 勾选)。生成的工程根下会出现两个关键的构建脚本文件,扩展名是 kts,也就是用 Kotlin 写 Gradle。
模块级构建脚本里,android 块之外先确认插件部分:
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") }
android 块里开启 viewBinding:
android { namespace = "com.example.safeapp" compileSdk = 34 defaultConfig { applicationId = "com.example.safeapp" minSdk = 24 targetSdk = 34 } buildFeatures { viewBinding = true // 编译期生成视图绑定类 } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } }
依赖部分,本册后续章节会用到的最小集合:
dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4") implementation("androidx.activity:activity-ktx:1.9.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") }
每一行都对应一条后面章节要用的防线:core-ktx 提供一批 Kotlin 扩展属性(第 6 章的主角);两个 lifecycle 与 activity 的 ktx 变体提供 lifecycleScope、viewModelScope 这类生命周期感知的作用域(第 5 章);coroutines-android 提供主线程调度器。装依赖的顺序就是铺防线的顺序——这是骨架先于语法的第二个理由。
Java 时代的经典 NPE 之一:
TextView tvName = findViewById(R.id.tv_name); // 控件已在布局里删除 tvName.setText(user.getName()); // 崩溃:控件不存在返回 null
布局文件改了、Java 代码没跟上,findViewById 返回 null,运行时崩溃。Kotlin 工程开启 viewBinding 后,构建系统为每个布局文件生成一个绑定类,控件成为类型上的属性:
class ProfileActivity : AppCompatActivity() { private lateinit var binding: ActivityProfileBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityProfileBinding.inflate(layoutInflater) setContentView(binding.root) binding.tvName.text = user?.name ?: "未登录" } }
这里同时预演了两个机制:binding.tvName 是生成类上的非空属性,控件不存在时编译不过——布局里删掉 tvName,这行代码立刻标红;lateinit 与 ?: 分别属于第 2 章的初始化时机与 Elvis 操作符,先混个脸熟。
对照 Java 旧写法,事故类别整个消失:不是"更难写错",是"写错了出不了包"。
Java 时代最隐蔽的一类崩溃:异步任务持有 Activity 引用,页面已销毁,回调照常执行:
new Thread(() -> { final Profile p = api.fetchProfile(id); // 网络请求 3 秒 runOnUiThread(() -> binding.tvName.setText(p.getName())); // 用户在第 1 秒就退出了页面:View 已销毁,崩溃或泄漏 }).start();
Kotlin 骨架里的对应物是 lifecycleScope:
lifecycleScope.launch { val p = api.fetchProfile(id) // 挂起,不阻塞主线程 binding.tvName.text = p.name // 页面销毁则作用域已取消,这行不会执行 }
lifecycleScope.launch 启动的协程绑定 Activity 生命周期,页面销毁时作用域自动取消——"忘记取消"这个事故类别被结构性地消灭了。原理在第 5 章展开,这里只需要记住骨架约定:主线程异步一律 lifecycleScope,ViewModel 内一律 viewModelScope,永不裸启线程。
语言机制之外,目录结构是另一层"默认安全"。推荐按层分包而不是按组件类型分包:
com.example.safeapp ├── data 数据层:网络与本地源的实现,可空边界集中在此 ├── domain 领域层:纯 Kotlin 模型与用例,零安卓依赖 ├── ui 界面层:Activity、Fragment、适配器 └── util 仅放真正全局的工具(目标是空的或接近空)
这个组织方式的要点在 domain 层:纯 Kotlin、无安卓依赖的模型与业务逻辑放这里,意味着它们处于空安全类型系统的完全保护之下——没有平台类型、没有生命周期、没有隐式可空。ui 层和 data 层负责与"不安全的世界"对接,可空性收容在边界上(第 7 章展开)。util 包的存在是个提醒:第 6 章会讲扩展函数如何让这个包趋近于空。
下图把上述配置放进一张分层透视:左侧是工程结构,右侧标注每层承担的防线与对应章节。

混用 Groovy 与 Kotlin DSL。新工程直接用 kts;老工程迁移时可以两种脚本并存(Groovy 与 Kotlin 脚本可以在同一构建里混用),但新文件一律 kts,避免团队在两套语法间摇摆。第 6 章 DSL 一节会展示 kts 相对 Groovy 的类型安全收益。
minSdk 拍脑袋定太高。Kotlin 标准库对旧系统支持良好,minSdk 24 覆盖绝大多数存量设备即可;协程与 viewBinding 对 minSdk 无额外要求。真正需要权衡的是 desugaring(Java 8 时间 API 等),在 compileOptions 里开 coreLibraryDesugaringEnabled 即可,不要用提高 minSdk 的方式解决。
忘记处理 AndroidManifest 与 ProGuard 的联动。骨架阶段还不需要混淆,但建议在 release 块里先把 isMinifyEnabled = false 显式写上并注释计划,避免后期开启混淆时把反射用的类一刀切掉——那是另一类"编译期看不见、运行时才爆"的事故,第 8 章静态防线一节会回到这个话题。
依赖版本散落。kts 里可以用版本目录(libs.versions.toml)集中管理版本号。这不是 Kotlin 特有的话题,但混合依赖越多、平台类型越多,版本管理混乱的代价越高——第 7 章会看到同一个库的不同版本对空安全注解支持程度不同带来的坑。
三个快速自检,任何一个不成立就回头补:
lifecycleScope.launch 里写一个两秒的延时后更新界面,中途按返回键销毁页面,Logcat 不应出现更新动作(协程被取消);String? 的函数并在 ui 层直接调用 .length,编译器应当拒绝。三个检查分别对应三条防线的"通电测试"。骨架的意义就在这里:机制只有在骨架里通电,才是活的防线;否则只是语法知识。
骨架配置完成后,值得把模板生成的第一个 Activity 完整走查一遍,确认每一行都认识、都知道它在防线里的位置。模板生成的类大致如下(略有精简):
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.fab.setOnClickListener { v -> lifecycleScope.launch { val draft = loadDraft() // 假想的挂起函数 binding.fab.text = draft ?: "空草稿" } } } private suspend fun loadDraft(): String? { delay(200) return null } }
逐行看防线:binding 用 lateinit 而不是可空类型,是安卓视图初始化的标准姿势(onCreate 之前无法赋值,但逻辑上 onCreate 之后必不为空——这是"用 lateinit 表达时序约定"的典型场景,第 2 章专节讨论);binding.root 是非空属性,直接传给 setContentView 无需判空;点击监听用了 lambda 而不是匿名内部类,消灭了 Java 时代 View.OnClickListener 的样板;lifecycleScope.launch 里的 delay 挂起而不阻塞主线程,页面销毁则整个块自动终止;loadDraft 返回 String?,调用点用 Elvis 消解——注意这一行同时用了两条规则:可空必须消解、消解方式要选最贴语义的那个。
对照 Java 版本,同样功能需要匿名内部类、findViewById、判空三层 if、以及一个担心页面销毁的 Handler——四类风险点在 Kotlin 版本里分别被 lambda、绑定类、类型系统、作用域取消接走。这就是"骨架通电"之后代码该有的样子。
顺带立一个命名纪律:绑定类属性统一叫 binding、视图访问一律从 binding 出发、作用域一律写明是 lifecycleScope 还是 viewModelScope。骨架期养成的命名习惯,比任何规范文档都更早地决定代码库十年后的可读性。
问:kts 脚本和 Groovy 脚本能混用吗,迁移老工程必须一次性全换吗?
可以混用,且这正是官方支持的路径。Gradle 允许同一构建里 Groovy 与 Kotlin 脚本并存,老工程的模块构建脚本可以逐个转换。实践建议是:先转根脚本与最稳定的模块,最后转改动最频繁的模块;每转一个就提交一次,出问题好回滚。转换期最大的坑是语法差异里最不起眼的那批——Groovy 里可以省略的等号、单引号字符串、隐式的方法调用链,在 kts 里都必须按完整 Kotlin 语法写。第一次转换建议用 IDE 的转换预览而不是手抄。
问:为什么骨架里不放 RxJava 或 LiveData,而把协程放到第一优先?
这是 2019 年之后官方架构指南给出的方向,也是本册的技术立场:协程是语言级方案,与结构化并发、生命周期作用域、挂起函数的顺序写法天然咬合;RxJava 作为库级方案学习曲线陡、泄漏面大,适合遗留项目继续用而不是新项目首选。LiveData 则被 StateFlow 逐步替代(第 8 章给出对照)。骨架的意义就是把这些选型在第一天定死,避免团队同时养三套异步方案——那是比任何单一技术债都贵的债。
骨架立好了,下一节补齐书写底座:val、函数与表达式化语法——它们是后面每一章代码的地基符号。