学 Kotlin 的人大多都有过这么一段经历:语法文档翻得飞起,println会写了,listOf、mapOf也看得懂,可一到打开 Android Studio 新建项目,反而不知道代码到底该往哪儿放。这套教程的第二部分,恰好就卡在所有 Android 初学者最容易迷路的那个拐点上:不再停留于语法演示,而是把 Kotlin 真正架到 Android 开发这条路上来。从空安全、面向对象思维到协程异步,再到 Activity、ViewModel、ViewBinding 这些组件的实战写法,一整条线串下来,属于典型的“语法学完,准备写第一个正经项目”时的必读进阶篇。
这套内容适合两类人。一类是刚学完 Kotlin 基础语法、Java 也不怎么熟的同学,需要一个引路人告诉你“这些语法到项目里到底怎么用”;另一类是写了好几年 Java Android、正准备转 Kotlin 的老开发,别以为 Kotlin 只是语法糖,空安全和协程这两关不重新过一遍,你写出来的代码大概率还是“Java 味的 Kotlin”。我不是说你得先把整本书啃完才开始写项目,而是强调第二篇里这些内容,是你动手之前最值得先补完的“地基”。
1. 第二篇到底在解决什么问题
1.1 只看懂语法和写得动项目,中间隔着一条大沟
我见过太多自学 Kotlin 的人卡在同一个地方:书上的例子每个都能看懂,什么class、interface、lambda,看起来都不难,但自己一写项目就卡住。原因很简单,语法是一颗一颗珠子,项目是一条项链,中间缺的是一根把珠子串起来的线。
这套教程的编排思路很典型:第一篇把环境搭好,基础语法过一遍,能写简单控制流和集合操作;第二篇则开始解决“怎么用 Kotlin 组织代码”的问题。你会发现它在这里突然引入了大量新概念,比如数据类、密封类、扩展函数、属性委托、作用域函数、协程,其实是在替你补那根线。这些概念单独看都很简单,但它们组合起来,才是 Kotlin 这套语言真正的工作方式。
换个比喻更好理解:学 Java 的时候,你只需要会“类、接口、继承、多态”就能把 Android 项目写起来,因为 Java 的框架思维很固定。但 Kotlin 不一样,它给开发者更多“表达方式”,同样是封装一个布局操作,你可以写工具类,也可以写扩展函数;同样是处理一个有可能是空的值,你可以写一堆if,也可以用?.链一路带到底。第二篇就是在帮你建立这种“表达习惯”,一旦习惯建立起来,后面写项目才会顺手。
1.2 一份能力自检清单,照着打勾比盲目刷语法强
你可以把这部分内容当成一份能力清单,学完第二篇,下面这些项应该都能独立做到才叫过关:
- 能正确处理 Kotlin 可空类型与 Java 互操作时的平台类型,不会因为一个空值在运行时炸掉。
- 能用
data class设计数据模型,用sealed class管理 Ui 状态,知道什么时候该用enum、什么时候该用密封类。 - 能写出自己的扩展函数和属性委托,不依赖一堆像
findViewById这样的低效样板代码。 - 能在 ViewModel 或 Fragment 里用协程处理网络请求、数据库读写,并理解
lifecycleScope、viewModelScope为什么不能乱用。 - 能读懂别人项目里的
by lazy、by viewModels、apply、let、run这些写法,自己也能在合适场景用出来。 - 能独立搭建一个带 ViewBinding 和 ViewModel 的最小页面,而不是只会写
setOnClickListener。
我给自学者的建议是,别急着把后面 Jetpack 全家桶章节全都看完,先把这份清单上的能力练到手。理想节奏是每天两小时的话,两周内把第二篇过完,同时手里跟做一个极小的 Demo,比如一个待办清单 App。一开始你可能会觉得速度慢,但等你到了写生命周期、控制异步任务的时候,就会发现前面这些底子没有白打。
2. 核心语法进阶:这几块不补,后面全是暗坑
2.1 空安全不是“多加个问号”这么简单
很多初学者觉得 Kotlin 的空安全就是声明的类型后面加?,取值的时候用?.,顶多再配一个?:给个默认值。这个理解没错,但太浅了。真正写 Android 项目时,空安全是贯穿整个代码设计的问题。
看一段最常见的代码:
var userName: String? = null fun showName() { val display = userName?.trim()?.takeIf { it.isNotEmpty() } ?: "游客" textView.text = display }这段代码看着没什么问题,但实际项目里空安全真正的难点在于“一个空值从网络层一路传到 UI 层”。比如服务端返回的 JSON 里有个字段是null,如果你在数据模型里把它声明成非空String,反序列化时可能直接抛异常,或者拿到一个非预期的默认值。所以我一直建议数据模型里那些确实可能为空的字段,老老实实声明成String?、Int?,而不是图省事写非空类型。
更隐蔽的是 Java 互相调用时的“平台类型”。你在 Kotlin 里调用一段 Java 老代码,拿回来的对象类型会显示成String!,这个叹号的意思是“我也不知道它到底可不可以为空”。Kotlin 不会强制你做空检查,但如果你当成非空用,就有可能在运行时收到空指针。处理办法很简单,拿不准的 Java 返回值,统一按照可空类型来接收。
我自己的习惯是:凡是跨模块边界传递的数据,默认用可空类型;凡是进到函数之后再自己加工的数据,再做空值清理和收口。空安全不只是一堆符号,它更像是把“这里可能没值”这件事放到类型系统里,让错误更早暴露。
2.2 数据类和密封类:数据模型和界面状态的正解
data class应该是 Kotlin 被用得最多的特性之一。它帮我们自动生成equals、hashCode、toString、copy和结构化的componentN方法,写一个实体类比 Java 的 Bean 干净得多。
data class NewsItem( val id: Long, val title: String, val summary: String?, val coverUrl: String?, val publishedAt: Long ) val old = NewsItem(1, "标题", null, null, 1234567890L) val updated = old.copy(title = "新标题")copy方法在更新局部字段时特别好用,尤其是数据类有很多字段的时候,不必重新 new 一个完整对象。解构声明也很有用,比如从列表里取数据时可以这样写:
val (id, title, _, _, _) = newsItemsealed class则更适合描述“同一类事物的不同状态”。我在项目里最常见的用法是包装界面状态:
sealed class UiState { object Loading : UiState() data class Success(val newsList: List<NewsItem>) : UiState() data class Error(val message: String) : UiState() }配合 Kotlin 的when表达式,可以做到分支穷尽:
when (state) { is UiState.Loading -> showLoading() is UiState.Success -> showNews(state.newsList) is UiState.Error -> showError(state.message) }注意这里不需要else。如果你以后在UiState里再新增一个分支类型,忘记处理的话,编译器会直接报错,逼着你去更新所有对应的地方。这种“用编译器帮你找出遗漏”的体验,才是 Kotlin 真正提效的地方。如果只是用enum或者一个普通的class加布尔标记,很难做到这种程度的约束。
2.3 扩展函数和属性委托:把样板代码从项目里赶走
扩展函数让你可以给已有类增加新方法,不用继承,不用装饰器模式。Android 开发里最常见的用途就是给View、Context写便捷方法。
fun View.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() fun View.gone() { visibility = View.GONE } fun View.visible() { visibility = View.VISIBLE }这样在代码里你可以直接写:
button.gone() textView.setPadding(16.dp(), 8.dp(), 16.dp(), 8.dp())纯 Java 时代要写一堆ViewUtils.dp2px(context, ...)之类的静态方法,到 Kotlin 里一个扩展函数就解决。你可能觉得这只是省了几行代码,但项目大了以后,这种小函数能显著减少阅读成本。唯一要注意的是扩展函数本质上还是静态方法,它不能访问私有成员,也不能被重写,所以别把它当成真正的成员方法去用,更不要在一个项目里到处给万能类加扩展函数,否则最后会变得难以维护。
属性委托则是另一种消除样板代码的方式。最常用的是by lazy:
private val adapter by lazy { NewsAdapter() }第一次访问adapter时才初始化,之后复用同一个实例。很典型的懒加载写法,适合那些创建成本高、又不想在构造器里提前创建的对象。
更有意思的是自定义委托。你可以把“读取和保存本地配置”封装成一个委托:
class StringPreference( private val context: Context, private val name: String, private val default: String = "" ) { private val prefs by lazy { context.getSharedPreferences("app_config", Context.MODE_PRIVATE) } operator fun getValue(thisRef: Any?, property: KProperty<*>): String { return prefs.getString(name, default) ?: default } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { prefs.edit().putString(name, value).apply() } } var token: String by StringPreference(context, "token", "")之后读写token就像操作普通变量一样,底层自动走 SharedPreferences。如果项目中多一些这类封装,业务代码会干净不少,不过委托的getValue和setValue签名先照着写就行,不用死记,用多了自然有感觉。
2.4 作用域函数和集合操作符:Kotlin 写起来顺手的关键
很多 Kotlin 项目代码里到处是let、apply、run、also、with,初学者往往会晕。我的判断标准很简单:
- 想在对象初始化之后继续配置并返回对象本身,用
apply。 - 想对可空对象做处理,用
let。 - 想在代码块里执行一段操作,最后返回代码块的值,用
run。 - 想“顺手”做点副操作(比如日志),用
also。 - 想对同一个对象连续调用多个方法,少写点对象名,用
with。
val config = Config().apply { isDebug = true cacheSize = 100 } textView?.let { it.text = "文件已下载" it.visibility = View.VISIBLE } val length = run { val prefix = "Kotlin" "$prefix Android".length }集合操作符也很值得熟练掌握。map、filter、groupBy、firstOrNull、sumOf这一套,会让你处理列表数据时思路清晰很多。我见过有人用一堆 for 循环处理一个列表,看得头大;换成链式操作以后,几十行代码变成几行,可读性还更强:
val adultNames = users .filter { it.age >= 18 } .sortedByDescending { it.age } .map { it.name }这部分的重点不只是记住函数名,而是要形成“把数据加工看成管道”的思考方式。后面写协程也好,写 Flow 也好,底层都是这类链式变换的思想。
3. 协程与异步:这部分才是第二篇真正拉开差距的内容
3.1 挂起函数到底在做些什么
Kotlin 协程最大的认知门槛,是理解suspend函数到底是什么。你可以先做一个粗糙但有用的类比:普通函数是一条直线,从入口一路执行到出口;挂起函数则是一条可以“暂停”的线,中间遇到耗时操作时,人先跑去干别的,耗时操作完成后,再回到原来暂停的位置继续往下走。
suspend fun fetchNews(): List<NewsItem> { // 模拟 IO 耗时 delay(1000) return api.fetchNews() }在 ViewModel 里用协程调用它:
fun loadNews() { viewModelScope.launch { val news = fetchNews() _uiState.value = UiState.Success(news) } }如果只看表面,这和你平时写的同步代码没什么区别,但关键在于launch后面的代码块不会阻塞主线程。协程遇到delay或 IO 时,会把当前执行权交出去,等到完成后再恢复。所以 UI 不会卡住,但代码又保持着“直接往下写”的直观感。对比 Java 时代写回调或者Handler的老办法,这种心智负担的下降是非常明显的。
不过我要提醒一句:别真的以为挂起函数不需要线程调度。suspend只是一个能力标记,真正切线程的是调度器。把耗时的网络请求放在没有指定调度器的协程里,它跑在哪个线程,取决于协程启动时的上下文。所以我们得养成习惯:凡是有网络、磁盘、数据库操作,都明确用withContext(Dispatchers.IO)切走。
3.2 作用域和调度器选得不合适,稳定性直接打折扣
协程里最常背锅的问题,就是作用域没选对。很多人图省事,在 Fragment 或 Activity 里写:
GlobalScope.launch { // ...耗时操作 }这句话恶心在:协程的生命周期完全不受页面控制。用户可能已经退出页面了,协程还在后台跑,跑完了还会继续回调你的 UI 更新逻辑。轻则内存泄漏,重则直接崩溃。
正确的做法是使用与生命周期绑定的作用域:
- 在 ViewModel 里用
viewModelScope,ViewModel 销毁时自动取消。 - 在 Fragment 里用
viewLifecycleOwner.lifecycleScope,Fragment 视图销毁时自动取消。 - 在 Activity 里用
lifecycleScope,Activity 销毁时自动取消。
class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow<UiState>(UiState.Loading) val uiState: StateFlow<UiState> = _uiState.asStateFlow() fun loadNews() { viewModelScope.launch { _uiState.value = UiState.Loading _uiState.value = try { UiState.Success(repository.fetchNews()) } catch (e: Exception) { UiState.Error(e.message ?: "unknown error") } } } }调度器的选择也有固定套路:网络、磁盘、数据库操作用Dispatchers.IO,复杂的计算和排序用Dispatchers.Default,更新 UI 用Dispatchers.Main。大多数情况不需要自己建线程池,默认这几个就够了。withContext是让代码从主线程切到 IO 线程,做完再切回来的最直接方式,在 UI 层更新结果时特别常见:
lifecycleScope.launch { val data = withContext(Dispatchers.IO) { repository.loadFromDb() } uiAdapter.submitList(data) }3.3 一个网络请求加 UI 刷新的完整改造实例
我举个例子,把 Java 时代的“老写法”和 Kotlin 协程版本对比一下。假设现在要从服务器拉一个新闻列表,然后显示到 RecyclerView。
老 Java 逻辑大概是:先在主线程的 Activity 里开一个子线程,用回调接口把结果传回来,再runOnUiThread更新适配器。中间还要考虑界面销毁后回调是否还有效。这一套写下来,十行代码打不住,错误处理还容易漏。
用协程写,最朴素也最清晰的版本长这样:
class NewsViewModel(private val repo: NewsRepository) : ViewModel() { private val _newsList = MutableStateFlow<List<NewsItem>>(emptyList()) val newsList: StateFlow<List<NewsItem>> = _newsList.asStateFlow() fun refresh() { viewModelScope.launch { runCatching { repo.getNews() } .onSuccess { list -> _newsList.value = list } .onFailure { error -> _uiMessage.value = error.message } } } }在 Fragment 侧的写法也简单:
viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.newsList.collect { list -> adapter.submitList(list) } } }不要被repeatOnLifecycle劝退,它的意思是“页面处于 STARTED 以上状态时,持续收集数据流;页面退到后台就自动停止收集”。这样既安全又干净,不会再有“回调在页面销毁后才回来”的诡异情况。
4. 把 Kotlin 真正写进 Android 项目:组件的实战写法
4.1 Activity 和 Fragment 的基础姿势已经变了
如果你还在写findViewById,第二篇看下来之后真的可以考虑改了。Kotlin 时代,配合 Jetpack 的 ViewBinding,代码会简洁一大截。
先启用 ViewBinding,在模块的build.gradle.kts里加:
android { buildFeatures { viewBinding = true } }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.buttonSubmit.setOnClickListener { // ... } } }Activity 的布局文件如果是activity_main.xml,生成的绑定类就是ActivityMainBinding。它帮你省掉所有findViewById和类型转换,还有一个额外的好处:如果布局里某个 id 被删掉了,编译期就会报错,而不是等你运行到那行才崩溃。
Fragment 里要注意绑定生命周期问题,不能直接在onDestroyView之后还持有 binding。标准写法是:
class HomeFragment : Fragment() { private var _binding: FragmentHomeBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentHomeBinding.inflate(inflater, container, false) return binding.root } override fun onDestroyView() { super.onDestroyView() _binding = null } }第一次看会觉得样板有点多,其实这就是为了不让 binding 越过视图生命周期继续引用旧视图。习惯了以后,可以抽一个基类来封装。
4.2 用泛型封装一个带 ViewBinding 的基类模板
教程里肯定会让你封装基类,这也是很多项目的通行做法。我分享一个最常用的模板,能同时减少 Activity 和 Fragment 两个地方的样板代码。
先写 BaseActivity:
abstract class BaseActivity<VB : ViewBinding>( private val inflate: (LayoutInflater) -> VB ) : AppCompatActivity() { private var _binding: VB? = null protected val binding: VB get() = _binding!! protected val mContext: Context get() = this override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) _binding = inflate(layoutInflater) setContentView(binding.root) } override fun onDestroy() { super.onDestroy() _binding = null } }子类使用:
class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::inflate) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.buttonSubmit.setOnClickListener { ... } } }BaseFragment 稍微麻烦一点,因为要管理_binding和onDestroyView:
abstract class BaseFragment<VB : ViewBinding>( private val inflate: (LayoutInflater, ViewGroup?, Boolean) -> VB ) : Fragment() { private var _binding: VB? = null protected val binding: VB get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = inflate(inflater, container, false) return binding.root } override fun onDestroyView() { super.onDestroyView() _binding = null } }有了这个基类,后续再写页面,注意力只需要放在业务逻辑上,不用每次重复那段“绑定和解绑”的仪式。这类封装的价值会随着项目页面数量增多而放大。
4.3 状态管理选 LiveData 还是 Flow
Kotlin 加协程普及以后,很多人开始纠结:ViewModel 里到底用 LiveData 还是 Flow?
我的保守建议是:如果你在学第二篇阶段,项目规模和团队熟练度都一般,优先用 StateFlow。理由很简单:Flow 本身就是协程世界的产物,处理异步变换、组合数据流的姿势更统一;而 LiveData 是 Java 时代为“遵守生命周期安全”设计的,它和协程一起用总觉得有一层别扭。
但这不是说 LiveData 完全不能碰。老项目改造时,LiveData 可能已经用得很深,把整个数据层推倒重来不现实。还有一种场景,比如只想做一个简单的“显示/隐藏加载中”状态的开关,LiveData 的postValue和observe足够直观,不是不能用。
StateFlow 的核心特点是它有一个初始值,而且总是保存着最新状态。页面重新重建后,收集时第一时间拿到的是当前最新值。一个典型写法:
class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow<UiState>(UiState.Loading) val uiState: StateFlow<UiState> = _uiState.asStateFlow() fun load() { viewModelScope.launch { _uiState.value = UiState.Loading _uiState.value = try { UiState.Success(repository.fetchData()) } catch (e: Exception) { UiState.Error(e.localizedMessage ?: "出错了") } } } }Fragment 里收集:
class HomeFragment : BaseFragment<FragmentHomeBinding>() { private val viewModel by viewModels<MainViewModel>() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { state -> when (state) { is UiState.Loading -> showLoading() is UiState.Success -> showData(state.item) is UiState.Error -> showError(state.message) } } } } }这套组合拳下来,你会发现数据和 UI 之间不再是一堆回调,而是一条有方向的数据流:ViewModel 负责生产状态,Fragment 负责观察和渲染。这正是后面理解 MVVM、理解 Repository 模式的起点。
5. 我在学这套内容和动手实践时踩过的一些坑
5.1 反序列化空值,运行时的隐藏炸弹
我在第一次按教程练习时,写了这样一个模型:
data class Article( val title: String, val description: String, val likeCount: Int )接口某个返回结果里description是null,而我的模型没给默认值。结果就是列表页加载的时候,要么直接崩溃,要么显示诡异。后面我学到一个经验:凡是来自网络、数据库、Intent 的数据,跨边界时默认走可空,再用默认值洗成业务需要的非空值。
data class Article( val title: String = "", val description: String? = null, val likeCount: Int = 0 )这样写虽然不完美,但至少不会因为一个字段缺失毁掉整个页面。这里是说第二篇里可能没刻意讲反序列化,但真实项目里这个问题非常高频,提前养成习惯,后面会少调很多 bug。
5.2 协程作用域没理清,界面销毁后回调还在跑
我早期在 Fragment 里图省事,直接在回调里更新 UI。后来做了个实验:进详情页立刻返回,观察日志,发现网络回调居然还能触发列表更新。这是因为协程作用域选成了GlobalScope或者当前 Activity 的lifecycleScope,页面销毁了,协程还在继续执行。
排查思路其实很简单:把日志打印放到协程入口和回调处,日志里带上Thread和是否处于销毁状态的判断。更根治的办法是养成一句话习惯:ViewModel 里用viewModelScope,Fragment 里拿上下文时用viewLifecycleOwner而不是this。很多人写 Fragment 的lifecycleScope会顺手写成:
lifecycleScope.launch { ... }在 Fragment 里,这其实拿到的是 Fragment 对象的生命周期,而不是 Fragment 视图的生命周期。视图销毁不等于 Fragment 销毁,但 UI 更新操作往往依赖视图。安全做法是:
viewLifecycleOwner.lifecycleScope.launch { ... }这两者差别非常细,但踩过一次坑就记住了。
5.3 Java 混编项目的几个低级错误
如果项目里还有 Java 代码,Kotlin 这边很容易遇到一些“奇怪的编译错误”。最常见的几个:
- Java 的静态方法在 Kotlin 里调用时,如果是
public static,可以直接类名.方法()调用;但如果它定义在工具类里,被 Kotlin 调用时名字和签名对不上,会提示类型不匹配。这时候可以用@JvmStatic注解,或者把工具类换成顶层函数。 - Java Bean 的
getXxx()/setXxx()会被 Kotlin 当成属性使用,但字段是null时,Kotlin 侧比你声明成非空类型更容易翻车。转 Kotlin 时一定检查字段有没有默认值。 - 如果一个 Java 方法返回
List<String>,Kotlin 侧推断成List<String!>的平台类型,集合内容仍可能是 null。遍历时别偷懒,该判空就判。
我遇到的第一个 Java 混编编译错误,就是Integer类型被 Kotlin 当成Int?,我非要赋值给Int,当时不明所以,后来才明白这是可空装箱类型的问题。这类问题和语言本身关系不大,更多是经验问题,踩过一次,下次就会主动看 API 签名了。
6. 怎么练手效果最好:一个三周小项目建议
6.1 第一章周:把空安全和数据模型练扎实
我不建议你急着写完整项目,而是直接做一个“最小闭环”:一个只有一个页面的 App,从网络加载一个列表,展示到 RecyclerView。列表数据来自一个简单接口,不用管什么架构,重点是把data class模型、sealed class状态、ViewModel和 ViewBinding 串起来跑通。
第一周你做这些事就好:
- 定义好列表条目模型,字段有几个就填几个,处理空值。
- 用
sealed class定义 Loading、Success、Error 三个状态。 - 用 ViewBinding 展示列表,点击条目后用
copy更新局部数据。 - 每写一步都确认自己理解,不是复制粘贴。
你会发现这个最小闭环已经把 Kotlin 的很多特性全用了:数据类、密封类、作用域函数、泛型、协程。这套东西跑通后,你再去看后面的架构内容,会有“原来如此的顿悟感”。
6.2 第二三周:加协程、加状态、加封装
第二周,把网络请求换成“模拟耗时操作”,比如delay(1000),再切到Dispatchers.IO去跑,然后把加载完成后的结果通过StateFlow发出来。第三周,尝试加一个详情页,两个 Fragment 之间用 ViewModel 共享状态,或者用简单的回调传参也行。这两周的核心目标不是学更多功能,而是把第二篇里那些写法用到“自己设计流程”的高度。
等到你能不看参考实现,独立写一个列表页、一个详情页,并且能顺畅地解释“为什么要用viewModelScope而不是GlobalScope”“为什么密封类适合做状态”的时候,就可以放心进入 Jetpack 和架构章节了。后面那些内容虽然更多,但底层思维已经通了。
我自己学这部分时还有一个心得:跟着教程敲代码的时候,尽量别只敲一遍。敲完一遍,再合上书重新实现一遍;实现不出来就翻回去看,再敲,直到能凭空写出来为止。这个过程看似浪费时间,实际上比任何“计划表”都管用。Kotlin 的语法特性很多,只有反复写进肌肉记忆,写项目时才不会被表达方式卡住。