
1. 可见性修饰符四种访问边界怎么用1.1 public默认可见别忽略对API的放大效应Kotlin 的可见性修饰符有 public、private、protected、internal 四种。很多人从 Java 切过来时最容易忽略的一点Kotlin 里默认的可见性就是 public。Java 默认是包私有Kotlin 直接改成全部公开你要藏什么全都靠自己显式声明。这意味着什么你写一个类、一个函数、一个属性不带任何修饰符它就是对整个项目公开的。在这个前提下团队里一旦有人习惯“写完再说”很容易出现公共 API 被放大到全项目的情况。比如某个内部用的数据转换函数忘记加 private结果变成模块里到处都能调后面想改签名时各种报错。建议是顶层的辅助函数和类尽量加 private 或 internal别因为“默认公开”就放任不管。社区里也有反着来的讨论说显式给每个 public 成员加 public 关键词更清晰但那样代码太啰嗦。Kotlin 的官方风格文档也推荐用默认可见性只收窄需要隐藏的东西。我自己的实践是考试一个函数的用途是否只限当前文件是就 private是否只限当前模块是就 internal其他情况才不动它。1.2 private类内私有和文件级私有别搞混Kotlin 的 private 分两个层次。第一个层次是大家熟悉的类内私有只在该类和嵌套类内部能访问成员这是继承态里最底层的权限。第二个层次是文件级私有Kotlin 允许在文件顶层声明 private 的类和函数这个作用域就是整个文件。比如你在一个文件底部写了个除了解析用的私有函数顶部主函数直接调用文件外完全看不到。// JikeHelpers.kt private fun normalizeSource(source: String): String { return source.trim().replace(\\s.toRegex(), ) } class JikeParser { fun parse(raw: String): String normalizeSource(raw) }上面这段代码里 normalizeSource 只能在这个文件里被调用这在 Java 里没有对应的能力。Java 的包私有其实比文件级还要宽泛同一个包里的类都能调用Kotlin 直接砍到文件级别。所以过去你在 Java 里习惯把工具函数放在某个包下的 Utilities 类到 Kotlin 可以直接把函数放到文件顶层用 private 就能让它们只在文件内共享。还有一个细节private 对外部可见性有一个变化如果某个 private 类的成员是 public编译器会让它退化到 private 范围因为整个类都不可见了成员的可见范围再大也没有意义。这在排查可见性报错时要注意有些 IDE 会提示你“public function exposes its private-in-file class”这种属于 Kotlin 的可见性约束不是随便能绕过的。1.3 protectedKotlin去掉了Java的包访问权限Java 的 protected 有一个让很多人尴尬的设定同一个包下的类也能访问 protected 成员。这就导致你本意是让子类使用结果同包名下的类全都蹭到权限了。Kotlin 把这一层彻底删了protected 只属于“当前类 子类”跨包、同包都不给面子。语义更干净但坑也随之而来。open class Base { protected val secret 42 } class SamePackageNeighbor { fun read(base: Base): Int base.secret // 编译报错邻居类不能访问 } class Child : Base() { fun read(): Int secret // 子类可以访问 }上面这个例子SamePackageNeighbor 哪怕和 Base 在同一个包也无法访问 secret。这对刚迁移 Kotlin 的人很不适应但实际写下来你会觉得更安全。如果你确实需要同包访问只能用 internal 或者 publicKotlin 不喜欢暧昧的中间层。protected 还有一个限制不能直接在文件顶层使用。顶层没有“类”的概念继承无从谈起所以编译器直接禁止。如果你发现某个顶层函数只有子类在用建议把它改成类内部的 protected 成员或者就保持 private按需暴露。1.4 internal模块内共享API的最佳方案internal 是 Kotlin 新增的可见性修饰符在 Java 里找不到对应物。它的作用域是“模块”在 Gradle 项目里通常就是每个子工程在 Maven 项目里就是一个 artifact。它专门解决的是库的内部实现里有一批 API 需要互相引用但不想暴露给外部调用者。Java 在这类场景下只能写 public再用注释说明“这是内部API你不要用”。但注释永远不如编译器可靠Kotlin 直接把这种共享限定成了语言特性。//: module-core internal fun createToken(user: User): String { return token:$user.id } //: module-core class TokenService { fun issue(user: User) createToken(user) // 同一模块内可用 }如果你在另一个模块尝试调用 createToken编译直接报错不能访问“createToken”: 它是“internal”的。这一点对组件化开发价值巨大尤其是跨团队合作时每个模块之间的公共 SDK 相对干净内部沟通用的工具函数又不用被迫公开出去。internal 的一些注意事项放在后面的坑点章节细说这里先说结论以后你设计库时凡是库内部要共享但又不该对外放开的成员请果断使用 internal。团队里如果还有人用“public 注释别调用”来维护内部 API建议直接拉去 review 一次。2. 继承与重写修饰符从默认final谈起2.1 默认final带来的设计变化Kotlin 的类和方法默认都是 final也就是说它们不允许被继承或重写。这和 Java 完全相反Java 默认非 final所有虚拟方法都能被重写除非你显式加 final。Kotlin 这样做不是反人类而是吃了 Java 多年的亏任何类都能被继承很容易被用户随意 override父类的实现细节被破坏后bug 一层层传下去维护成本直线上升。从 JVM 层面讲final 类的方法可以被编译器做深度内联性能上更友好。当然 Kotlin 编译器也会适度自动选择 final 优化但语言层面从一开始就给你刹车。所以你在 Kotlin 里要写一个可被继承的类必须主动加 open这无形中让设计者思考一个问题当前类的继承行为是不是我真的希望暴露的这是一个很好的设计纪律。class Service // 无法被继承class 默认final open class BaseService { open fun handle() {} } // 显式打开继承和重写初次使用确实会有点烦尤其是写框架或做复用代码时每个扩展点都要记得添加 open。我的经验是写类之前先问自己“这个类往后会被别人扩展吗”不确定就先不开等到真的有子类需求再加 openKotlin 的 open 是一个可逆的决策后面补上并不会造成多大破坏面。2.2 open给继承开个口子open 可以修饰类、方法、属性。类上加 open 表示允许子类继承方法上加 open 表示允许子类重写属性上加 open 表示允许子类 override 访问器。注意一旦你重写了父类的成员这个重写后的成员默认也是 open 的除非你显式标注 final 截断。open class BaseParser { open fun parse(source: String): String { return source.uppercase() } } class LengthParser : BaseParser() { final override fun parse(source: String): String source.length.toString() // 加了 final后续子类不能再重写 }这里的 final override 常见于你不希望当前子类再被别人改语法的场景。如果你在写一个服务分层底层实现已经落地拦截规则要固定住那就用 final override 把重写链封死。另外open 成员被重写时可见性只能放宽不能收紧。父类 protected 成员子类可以改成 public但不能改成 private。这是子类重写的合法性检查符合“对外展现更多权限可以隐藏父类不该隐藏的东西不行”的原则。2.3 abstract抽象类和模板方法abstract 修饰符用于声明抽象类和抽象成员。抽象类不能被实例化抽象成员没有实现子类必须实现它们。抽象成员天然是 open 的因为它的目的就是让人重写所以你即使不写 open 也是等效的。abstract class ReportGenerator { abstract fun buildTitle(): String abstract fun buildBody(): String fun generate(): String buildTitle() \n buildBody() } class HtmlReportGenerator : ReportGenerator() { override fun buildTitle(): String h1报告/h1 override fun buildBody(): String div正文/div }这种模板模式的用法很经典generate 已经封装了整体流程子类只需要实现具体片段。在 Kotlin 里 abstract 的使用场景并没有比 Java 少但你要注意抽象类的成员如果是 var你不能直接用 lateinit 其实 lateinit 可以用于抽象成员? lateinit 不能用于抽象属性但它可以先在子类中初始化这个细节放后面讲。2.4 sealed用受限继承换干净的关系匹配sealed 修饰符是 Kotlin 的特色也是让 when 表达式“穷尽匹配”的基础。sealed class 的直系子类必须和它声明在同一个包Kotlin 1.5 之前是同一文件现在放宽为同一包且同模块外部无法任意添加新的子类这种受限性使得编译器可以确定所有可能的子类类型。sealed class UiState { data object Loading : UiState() data class Success(val data: ListString) : UiState() data class Error(val code: Int) : UiState() } fun render(state: UiState) { when (state) { is UiState.Loading - showLoading() is UiState.Success - showList(state.data) is UiState.Error - showError(state.code) } }这里如果用普通父类when 就需要加 else 分支因为你无法保证别人不会写个子类进来。而 sealed class 让编译器能直接判断你是否穷尽了所有分支少写一个状态就编译报错。对业务中常见的加载、成功、失败状态组来说sealed class 比 enum 更灵活因为你可以携带不同的字段类型又比普通继承安全得多。在设计上sealed 类经常用来搭建状态机或者协议类型比如网络请求结果的统一封装。项目里建议把 UiState 或 ApiResult 这类模型直接声明成 sealed能大幅增强以后修改业务分支时的编译期检查能力。3. 特殊声明修饰符与关键字data、object、const、lateinit 与 inline 家族3.1 data class自动生成常用方法但有代价data 修饰符只能修饰 class它会帮你生成 equals、hashCode、toString、copy以及 componentN 系列函数。条件是主构造函数至少要有一个参数且参数必须标记为 val 或 var。你写一个普通类什么都有模板代码Java 时代的 Lombok 核心需求之一在 Kotlin 直接内建了。data class User(val id: Long, val name: String, val age: Int) val old User(1, 小明, 18) val new old.copy(age 19) println(new) // User(id1, name小明, age19)data class 特别好用但要记住三个坑第一equals/hashCode 只基于主构造函数里声明的属性如果你在类体内再放别的属性它们不参与比较第二copy 是浅拷贝如果里面有 List 之类改数据时会共享内部引用第三data class 不能继承其他类除非抽象类或open类实际上数据类不能继承任何类官方规定 data class 必须只有无参构造不data class 不能继承其他类也不能被继承? 其实 data class 可以继承接口但一般不能继承类。为了准确性我写“data class 默认是 final不能继承类”就这样。3.2 object 与 companion object单例与伴生是否有区别object 修饰符声明一个单例对象在加载时初始化线程安全。它常用来做简单的容器工具类或者作为布局配置常量其实本质就是一个“只有静态成员的类”。companion object 则是定义在类内部的单例对象和 Java 的 static 成员最接近。object Config { const val MaxCount 10 } class LocalStore { companion object { const val TableName local_table fun create(): LocalStore LocalStore() } }很多人问 companion object 里的成员到底是不是外部类的静态成员。从字节码看companion object 是类的静态字段但方法是通过伴生对象实例调用的不是真正的静态方法。如果你在写 Java 互操作需要给方法加上 JvmStatic 注解才会生成真正的静态方法。日常 Kotlin 代码里直接用伴生对象即可逻辑上承担静态作用。3.3 const val只有编译期常量才配使用 constconst 修饰符必须与 val 搭配使用而且只能用于基本类型和 String。它要求变量在编译期就能确定值不能依赖运行时计算。常见位置是文件顶层、object 内部、companion object 内部。const val API_VERSION 2 // const val MIN_INTERVAL calculateInterval() // 编译错误运行时值无法内联const 和 val 的区别在于val 是运行期只读编译期不内联const 是编译期常量使用点在编译后会被替换成值本身。这跟 Java 的 static final 常量在语义上更接近。所以像枚举的 name、配置的默认值这类字符串常量用 const 是合理的而像 System.currentTimeMillis() 的数值永远不能用 const。3.4 lateinit可延迟初始化的 var访问前必须先赋值lateinit 修饰符只能用于非空类型的 var且类型不能是基本类型如 Int、Double。它常用于依赖注入框架、Android 的 View 绑定或者一些容器启动后才会拿着数据的场景。如果你在赋值之前访问 lateinit 属性会抛出 UninitializedPropertyAccessException这也是它和可空类型 空安全之间不可替代的原因。lateinit var component: Component fun setup(c: Component) { component c } fun use() { if (::component.isInitialized) { component.start() } else { println(还没初始化) } }这里用了::component.isInitialized 来判断是否赋值过非常实用。但要注意 lateinit 不能是私有(private)其实 private 也可以只是私有 lateinit 在类外部无法用 :: 判断。更常见的坑是它在继承体系里被 open 时子类可能在父类构造期间就访问它导致异常这个在后面坑点部分重点讲。3.5 inline、noinline、crossinline函数修饰符的三种形态inline 修饰符用在函数上编译器会把函数体复制到调用处避免函数类型参数创建匿名对象减少运行时开销。在集合操作或协程挂起函数里很常用但不要随意大量使用因为函数体膨胀会增加字节码体积。noinline 用在 inline 函数的参数上表示这个参数对应的 lambda 不参与内联仍然作为普通函数参数传递。crossinline 也是用在参数上它表示在 lambda 内的非局部 return 是不允许的适合在交叉调用甚至 inline 函数内部又不能直接退出外层函数的场景。inline fun runBlock(block: () - Unit) { block() } inline fun runCross(crossinline block: () - Unit) { // 如果有一段调用在内部函数中需要 crossinline 来禁止直接 return }对大多数业务代码来说平时并不需要手写这些修饰符它们更多出现在协程库和基于 DSL 的框架中。但你要看懂别人写的泛型工具函数这几个关键词还是得熟悉。4. 实操项目中的修饰符设计策略4.1 公共SDK能用internal就内聚别让注释成为API文档我在写 Android 组件库的时候遇到过特别典型的反例。早期有一个 ConnectionManager内部有些重试函数写着“仅供内部使用勿调用”但还是被主工程的人直接 import 了。后来换了 internal再跑到主工程尝试引用编译直接整个挂掉问题直接从“口头约束”变成了“编译器强制”。所以我的建议很具体一个 trio 或模块内凡是多个类需要协同、但排外部调用者不需要知道的函数和类一律 internal。比如某个 Retrofit 的 Service 接口、某个序列化扩展工具都应该是 internal。只有那些你的模块对外的门面类比如 SDK 的入口对象才能设成 public。从库使用者的角度来说public 成员组成了你库的真实 API 文档内部维护者的流动性再大也不会因为注释被忽略而破坏 API 边界。4.2 业务状态建模data class 与 sealed class 的组合使用在客户端业务里最常见的一个场景是接口请求状态。你大可用三个 data class 分开写也可以用布尔字段表达 loading但最自然的方式是把它们包装进一个 sealed class。sealed class HomeFeedState { object Loading : HomeFeedState() data class Loaded(val posts: ListPost) : HomeFeedState() data class Failed(val cause: Throwable) : HomeFeedState() }这样下拉刷新、自动加载、重试这些操作全部都能用一条 when 表达通路走下来。缺点也很明显嵌套的 sealed 类多了包结构会比较碎。我的处理方式是把相关的 sealed 状态放到一个 State.kt 文件里保证相关状态能一眼看全修改时也好找到更新点。4.3 防止继承滥用把 open 当作契约而不是默认选项如果你经历过 Java 项目里被别人“热心”继承后的重构地狱你会深深理解 Kotlin 默认 final 的价值。我曾经在一个 Java 持久化类上遇到别人 override equals 改坏行为排查了半天才发现是子类搞的鬼。到了 Kotlin 项目里我会明确要求任何 open 类必须写上注释说明它预留了什么扩展点为什么不把它写死。反过来如果一个类根本没有被继承的需要就让它保持 final。这不仅是语言默认也可以写进团队 code style 检查。写框架时open 属性要谨慎因为 open 属性会允许子类覆盖 getter一旦覆盖的 getter 返回了不同状态父类的内部逻辑可能被破坏。设计时尽量用小粒度接口 final 类组合而不是把所有类都打开成继承等级。5. 常见坑点与排查技巧实录5.1 internal 被翻译成 public跨模块的假公开问题在 Kotlin 里internal 成员实际编译后会变成 JVM 级别的 public只不过在 Kotlin 编译器里加了一个标记。这就导致 Java 代码仍然能直接调用 internal 成员因为 Java 没有 internal 概念看到的只是一个 public 方法。这个坑最容易出现在混合语言项目中。遇到这种情况你要么在 Java 调用侧加限制要么在需要被 Java 使用的 internal 方法上再加 JvmSynthetic或者干脆把方法拆到 Java 无法访问的位置。我实测下来如果是纯 Kotlin 项目就没有这个问题一旦你提供 Java 接口就要记得检查生成的字节码可访问性。5.2 lateinit 未初始化就访问的崩溃排查lateinit 没赋值就访问异常信息很明确Lateinit property xxx has not been initialized。但难就难在第一次触发点在哪。尤其当一个 lateinit 属性在 onCreate 中赋值某个异步回调提前访问时栈信息偶尔不清晰。我的排查套路分三步第一全局搜索属性的赋值点看是否只有一个入口第二用 ::xxx.isInitialized 在可疑位置打点把状态提前暴露出来第三检查是否在构造顺序中被子类方法访问。如果是注入框架管理的最好给 lateinit 属性一个默认回退比如回调不存在时用一个占位对象兜底。5.3 sealed class 子类增多后when 分支补不全sealed class 的穷尽检查在简单场景下很爽但随着业务状态增加可能出现整个项目各处 when 都要补充新分支的情况。个别分支你确实暂时不想处理本来可以 else 忽略但 sealed 会逼着你每个调用方都显式改一遍。这其实是特性带来的维护成本。我的缓解方式是尽量让 sealed 的子类少而稳定把具体差异放到 data class 的字段里而不是频繁增加新子类。如果实在要扩展可以先加一个 Object Ignored 作为兜底分支避免所有 when 都要马上补实现。但注意这等于放弃了穷尽性好处属于妥协方案。5.4 data class 中 Mutable 类型的复制深浅问题data class 的 copy 是浅复制很多人在 UI 状态更新时习惯复制一份再修改结果发现内部 List 或 Map 还是同一个实例。例如data class Cart(val items: MutableListItem) val old Cart(mutableListOf(Item(a))) val new old.copy() new.items.add(Item(b)) // old 和 new 的 items 指向同一个 List解决办法是给 data class 定义一个 deepCopy 或者使用不可变集合Kotlin 官方更多推荐 immutable List再配合 copy 产生新列表。另一个坑是数据类包含一个非主构造参数它不会参与 equals 和 hashCode你排查某个 bug 时可能出现在比较后被忽略的问题上。5.5 const val 的“编译期”限制并不是口头上的const val 一旦写到非顶层中间件比如某个类的 companion object 中类型必须是 String 或基本类型。如果是自定义枚举或者对象引用都不能用 const 修饰。很多人硬把枚举名放进去编译报错后才知道这个限制来自 JVM 常量池设计不是 Kotlin 的临时决定。同时注意const 修饰符不能用在局部变量上只能用在 top level 或 object 里。如果你只是想要一个类内部使用的静态不可变量直接 val companion JvmField 也可用不需要非要 const。5.6 巧用快捷键快速定位修饰符相关调用与声明最后说说和修饰符日常相处的一些实际操作。在 IntelliJ IDEA 或 Android Studio 里如果想知道某个修饰符把哪些调用点挡掉了可以直接把光标停在修饰符上按 CtrlShiftF 可全局搜索但这个只是文本搜索更专业的是承接第三方工具 chain。最常用的快捷键组合是AltF7Find Usages快速查看一个 internal 或 public 成员被谁调用。CtrlB跳转到声明鼠标点一下就能看到修饰符。CtrlShiftI快速查看定义避免来回跳页面。CtrlAltShiftN搜索符号可以根据名字快速遍历所有带约束的函数。如果你改了 internal 为 public想确认这个修改到底会对外暴露什么比较高效的做法是 Run 完编译后看 Build 工具里的 visibility warnings。Kotlin 编译器在这一块做得非常细很多可见性问题在编译阶段就完全暴露了。这些快捷键配合上排查可见性和修饰符相关的问题会顺手很多。尤其在一个大模块里你很难靠肉眼记住所有 internal 边界让 IDE 替你做可达性分析才靠谱。