2.2 初始化时机:lateinit与lazy


2.2 初始化时机:lateinit 与 lazy

本节摘要:安卓开发里"值暂时不存在"是常态——视图要等布局填充、依赖要等注入、重对象要等首次使用。用可空类型加判空处理这些场景会让代码被 ?. 淹没。Kotlin 给了两个更精准的工具:lateinit 表达"稍后初始化、之前访问是编程错误",by lazy 表达"首次使用时初始化、之后缓存"。本节讲两者的分界、各自的崩溃形态与工程纪律。

一个两难开局

第 1 章的骨架里出现过这样一行:

private lateinit var binding: ActivityProfileBinding

为什么不写成 private val binding: ActivityProfileBinding? = null,然后每次用的时候 binding?.tvName?技术上可行,但代价是:这个引用在类里的每一次使用都要带上问号——而按业务逻辑,onCreate 之后它必然非空。用可空类型表达"必然非空只是晚点赋值",等于把类型系统当成形式主义:满屏 ?. 提供的不是安全,是噪音。

反过来,Java 时代这类字段的最常见写法是:

private TextView tvName; // 声明时不初始化,默认 null // onCreate 里 findViewById 赋值

这恰恰是 NPE 的温床:字段是 null 的窗口期完全靠人肉纪律管理,谁在初始化前访问了它,运行时见。问题本质是:Java 只有一种"暂时没有值"的表达(null),而安卓需要区分两种语义——"业务上可能没有"(真可空,该用 ?)与"只是现在还没有"(时序问题,不是业务问题)。Kotlin 用 lateinit 和 lazy 分别接住后者。

lateinit:时序约定的显式化

lateinit 修饰 var 属性,向编译器承诺"我会在使用前初始化它":

class ProfileActivity : AppCompatActivity() { private lateinit var binding: ActivityProfileBinding private lateinit var analytics: AnalyticsService override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityProfileBinding.inflate(layoutInflater) setContentView(binding.root) analytics = (application as App).analytics binding.tvName.text = "阿禾" // 此后随便用,非空类型 } }

它在三个意义上优于 Java 的裸字段。第一,类型保持非空——binding.tvName 不需要问号,IDE 补全完整。第二,违约有明确崩溃:初始化前访问抛出的是 UninitializedPropertyAccessException,堆栈直指具体属性,比 NPE 的"object reference"含糊报错可定位得多。第三,lateinit 这个词本身写在代码里——晚初始化是一个显式决策,review 时看得见

它有三条使用边界。只能修饰 var(要重新赋值);不能修饰原生类型(Int、Long 等无 null 值可占位,编译直接拒绝);目标类型不能是可空类型(语义冲突)。另外从 Kotlin 1.2 起可以用 ::binding.isInitialized 检查是否已初始化,主要服务于测试与防御性分支,日常代码里频繁出现这个检查通常说明设计有问题。

最常见的翻车场景是生命周期错位:

class OrderFragment : Fragment() { private lateinit var adapter: OrderAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { adapter = OrderAdapter() // 在 onViewCreated 里初始化 } override fun onResume() { super.onResume() refresh() // Fragment 视图重建后,此路径安全 // 但若某路径在 onViewCreated 之前被系统回调触发,则抛未初始化异常 } }

Fragment 的视图生命周期比实例生命周期短——实例还在、视图已销毁重建,是安卓最阴险的时序问题。lateinit 在这里的态度值得玩味:它不消除这类事故(时序错误是逻辑错误),但把事故从"NPE:null object reference"变成"UninitializedPropertyAccessException: adapter",报错即文档

by lazy:首次使用时初始化

by lazy 委托处理另一种时机:值可以现算,但算起来贵,且未必用得上:

class ImageRepo(context: Context) { private val diskCache by lazy { // 磁盘缓存初始化要读配置、建目录,几百毫秒 DiskCache.open(context.cacheDir, 32 * 1024 * 1024) } fun load(key: String): Bitmap? { return diskCache.get(key) // 第一次走到这里才初始化 } }

语义三件套:首次访问时执行初始化块、结果缓存、默认线程安全(lazy 默认加同步锁,多线程首次访问只初始化一次)。而且它修饰的是 val——初始化后引用不再变,这与第 4 章的不可变纪律天然契合。

LazyThreadSafetyMode 有三档,安卓开发需要认识:

模式 线程安全 适用场景
SYNCHRONIZED(默认) 是,双重检查锁 默认选择,主线程初始化的 UI 相关对象
PUBLICATION 允许并发初始化,取先完成者 初始化无副作用且可重复执行时
NONE 否,无锁开销 确认只在单线程访问时(如主线程私有对象)

默认锁的开销在一次性的初始化场景里几乎可以忽略,不必为了性能主动降级;真要降级,注释写清线程假设。

两者的分界线

把决策规则压缩成一句话:值来自外部注入用 lateinit,值可以自己算出来用 by lazy,业务上真的可能没有用可空类型。一张表对齐:

维度 lateinit by lazy 可空类型
语义 稍后会有人给我 我需要时自己算 可能永远没有
修饰 只能 var 只能 val val 或 var
谁触发初始化 外部(框架、注入器) 首次访问 无初始化动作
违约形态 未初始化访问异常 几乎不会违约 null,需消解
典型安卓场景 视图绑定、注入的依赖 磁盘缓存、格式化器、重计算 接口返回、用户可清除的数据

一个综合示例展示三者在同一个类里各司其职:

class PayViewModel(private val repo: OrderRepo) : ViewModel() { // 可空:业务上可能没有进行中的订单 var currentOrder: Order? = null private set // lazy:二维码生成器构造昂贵,且用户未必走到付款页 private val qrPainter by lazy(LazyThreadSafetyMode.NONE) { QrPainter(size = 512, margin = 16) } fun pay(orderId: String) { viewModelScope.launch { currentOrder = repo.fetch(orderId) currentOrder?.let { order -> val bitmap = qrPainter.draw(order.code) _qrState.value = QrState.Ready(bitmap) } } } }

三种"缺失语义"在同一个类里互不越界:订单真可能没有(问号)、二维码生成器只是懒得提前建(lazy)。

初始化时机全景时间轴

下图把一个典型 Activity 生命周期里各类属性的初始化时机排在一条时间轴上,标注每种工具的窗口期与违约崩溃点。

初始化时机全景时间轴

与 lazy 相邻的另一种延迟:惰性集合

顺带纠正一个常见误解:by lazy 作用于属性,而第 4 章会讲到的 sequence 提供元素级的惰性。两者解决的都是"现在算不如用时算",但粒度不同——属性级用 lazy,集合元素级用序列。初学者常把大集合的构造包在 by lazy 里,以为占了惰性的便宜,实际上首次访问时整个集合还是一次性建完;要逐元素惰性,得用序列。这个区分放在这里提一句,细节到第 4 章展开。

工程纪律与常见坑

坑一:lateinit 当万能延迟用。如果一个属性真的可能"一直没人初始化"(比如依赖注入失败的降级路径),lateinit 会把这种业务状态藏成崩溃。判断标准问一句:"这个值最终一定会就位吗?"会——lateinit;不一定——可空。把业务上的"可能没有"误建成 lateinit,是把业务问题伪装成时序问题,比原来的 NPE 更隐蔽。

坑二:lazy 块里捕获可变状态by lazy 的初始化块只执行一次,捕获的若是 var,后续 var 的变化不会再反映进来。初始化块里只该放"结果确定"的计算,依赖可变配置的应该改成函数或委托属性。

坑三:主线程 lazy 做重 IOby lazy 的首次访问发生在哪个线程,初始化就在哪个线程。UI 属性的 lazy 块里藏磁盘 IO,首次访问就在主线程上卡 ANR。重初始化应该放进协程或注入时完成,lazy 块只做纯计算。

坑四:Fragment 视图字段跨视图生命周期存活。viewBinding 的习惯做法是在 onDestroyView 里把 binding 置空(Kotlin 里用可空属性配合手动清理,或改用属性委托库)。lateinit 修饰的视图绑定在视图销毁后仍持有旧引用,是泄漏与"更新了不存在的视图"双重隐患。这个场景是 lateinit 少数不该出场的地方,值得单独记住。

再往前一步:属性委托的预告

by lazy 里的 by 不是普通语法糖,它是 Kotlin 属性委托机制的入口:属性的 getter 与 getter 背后的逻辑,可以委托给一个约定了 getValue(与 setValue)的对象。lazy 是标准库提供的委托之一,读写 SharedPreferences 的PreferenceStore、把findViewById 自动化的视图绑定委托、Jetpack 的 viewModels 与 SavedStateHandle 的委托读写,都是同一机制的应用:

val vm: OrderHistoryVM by viewModels() // Jetpack 委托 val draft: String by savedState delegation // 概念示意 实际用键存取

本册不展开自定义委托的写法(它属于进阶话题),但要立住认知:当你看到 by,读作"这个属性的值由这个对象代管"。代管逻辑可以是延迟(lazy)、缓存(map 委托)、观测(observable 委托)或生命周期绑定(viewModels)。第 8 章 UI 模板里 by viewModels() 已经出场过,那时你会带着这个认知再遇到它。委托与 lateinit 解决的是同一族问题——初始化的时机与责任——只是委托把"怎么初始化"也抽象成了可复用的策略。

常见问题

问:lateinit 的属性在单元测试里怎么处理?
测试里未初始化访问同样会抛异常,这是特性不是麻烦——它让测试的初始化顺序问题早早暴露。需要预填值时直接赋值即可(lateinit 是 var)。要测"未初始化路径"的行为,用 ::prop.isInitialized 分支或干脆在测试里不赋值并断言异常类型。反过来,测试代码里的 lateinit 应当克制:测试的初始化通常可以确定,用普通 val 加构造参数更清晰,lateinit 留给"被测类的真实时序"场景。

问:by lazy 的默认线程安全会不会成为性能问题?
单次初始化场景下,同步锁的开销只在"首次访问的竞争窗口"存在,之后的读取走已初始化的快速路径,无锁开销。真正要留意的是两点:一,主线程属性挂了重 IO 的 lazy 块(本节的坑三),那是线程归属问题不是锁问题;二,大量属性共用默认 SYNCHRONIZED 模式且在高频并发首访时,可评估 PUBLICATION 模式——但这种情况在安卓上极少见,别过早优化。默认档位是经过工程检验的保守选择。

本节要点回顾

  • 两种"暂时没有":业务可能没有(可空类型)与时序上还没到(lateinit 或 lazy),安卓高频事故来自混用两者;
  • lateinit:非空 var 加稍后初始化承诺,违约抛出可定位的未初始化属性访问异常,适用于注入与视图绑定;
  • by lazy:val 首次访问初始化并缓存,默认线程安全,适合昂贵的现算对象;注意初始化块的线程与捕获;
  • 分界口诀:注入用 lateinit、现算用 lazy、真可能没有用问号;
  • Fragment 视图生命周期是 lateinit 的雷区,视图绑定应随 onDestroyView 清理;
  • 报错即文档:lateinit 的崩溃自带属性名,把时序错误的排查成本降到一行堆栈。

下一节从"值的缺失"转向"操作的失败":异常在 Kotlin 里的新位置,以及用值风格收容失败的 runCatching。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U