本节摘要:扩展函数在不修改类、不继承类的前提下,给既有类型添上新方法——调用方向从
Utils.hideKeyboard(activity)翻转为activity.hideKeyboard(),逻辑因此可发现、可补全、可归位。本节讲声明语法、静态解析的本质(它不修改类,编译为静态函数)、扩展属性、与继承及组合的选型分界、core-ktx 的扩展生态,以及防止扩展退化为新式工具类的纪律。
打开一个五年历史的安卓工程,全局搜索 Utils,大概率收获一打文件:StringUtils 里有七个 static 方法,DateUtils 与 TimeUtils 功能重叠(谁也说不清该用哪个),KeyboardUtils 负责显隐软键盘,DensityUtils 负责 dp 与像素互转,SpUtils 包装 SharedPreferences,ViewUtils 里塞着"设置显隐""防抖点击""圆角背景"……这是 Java 的组织极限:逻辑既不属于调用者,也无法挂到别人的类上(类是 SDK 的,改不了),只好流放到静态方法集中营。
工具类的真实代价不在丑,在三件事。发现性:写 date 的时候,IDE 不会告诉你有个 DateUtils.relative(date) 能用——补全列表里没有它,新人只能靠口口相传或全文搜索;归属感:手机号脱敏逻辑到底该在 StringUtils 还是 PhoneUtils?同一逻辑在两个 Utils 里各存一份的故事每个团队都演过;可测性:静态方法与全局状态纠缠后,测试要么起 Instrumented 要么跳过。三类问题的共同根源:逻辑与它的宿主类型失联了。
扩展函数的声明只比普通函数多一段"接收者类型":
fun View.hideKeyboard() { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(windowToken, 0) } fun String.maskPhone(): String = if (length == 11) "${substring(0, 3)}****${substring(7)}" else this
fun 与函数名之间的 View.、String. 声明了宿主类型;函数体内可以直接调宿主的公开成员(context、windowToken、length),像一个你拥有却从未写过的成员方法。调用方的世界因此翻转:
// Java 时代 KeyboardUtils.hideKeyboard(getActivity()); String masked = StringUtils.maskPhone(user.getPhone()); // Kotlin 扩展 requireActivity().hideKeyboard() binding.tvPhone.text = user.phone.maskPhone()
关键变化发生在 IDE 里:输入 user.phone. 的瞬间,maskPhone 出现在补全列表里——逻辑被类型系统索引了。新人不再需要知道"我们有个 StringUtils";他需要处理手机号,补全列表告诉他有什么可用。可发现性问题的解法不是文档,是把逻辑挂到调用现场。
扩展不修改类。它编译成的字节码,与手写的静态函数几乎一致:
fun String.maskPhone(): String = ... // 编译产物等价于 fun maskPhone(receiver: String): String = ...
由此推出扩展的几条硬边界,每条都值得写进团队认知:
静态分派。扩展函数按声明类型的编译期决议,不按运行时实际类型:
open class Animal class Dog : Animal() fun Animal.sound() = "某种声音" fun Dog.sound() = "汪" val a: Animal = Dog() println(a.sound()) // 某种声音 静态看声明类型 不是汪
如果扩展参与多态,这个例子会变成埋雷。扩展不能碰私有成员:函数体里只有宿主的公开与 internal 成员可见——它毕竟是"外挂",不是成员。成员永远赢:如果类自己有同名成员,扩展永远不被调用(连警告都未必有),别给 String 写个 length 扩展企图覆盖。可空接收者合法但要显式声明:fun String?.orDefault() 的接收者类型带问号,函数内 this 可空。
这些边界的哲学是自洽的:扩展是语法层面的归位,不是语义层面的改造。它把"逻辑写在哪里、从哪里被发现"的问题解决了,但明确不碰继承体系与封装边界。

属性形态的扩展同样存在,长于"派生值"的表达:
val View.visibleHeight: Int get() = height - paddingTop - paddingBottom val Context.screenWidth: Int get() = resources.displayMetrics.widthPixels // 用 dp 扩展消灭魔数(core-ktx 已提供) val badgePadding = 8.dp
注意扩展属性不能有幕后字段(backing field)——它没有存储,只能由 getter 现算或委托给别的存储。8.dp 这类字面量扩展是安卓代码可读性的主力军,core-ktx 已经提供了完整的 dp、sp 家族。
扩展的可见性由它所在的文件与修饰符决定,这比工具类细腻得多。同一个文件里声明的扩展只在该文件内可用(IDE 里常见"局部 helper 扩展");internal 修饰的扩展模块内可用——把"全局能力"从默认改为按需开放,是控制扩展膨胀的第一道闸。第 5 章 Flow 的操作符能在标准库外被第三方库大量扩展(如官方的 kotlinx-coroutines-playground、社区的流扩展库),靠的正是这套机制:不改类、不继承,挂上就用,作用域可控。
骨架工程引入的 androidx.core-ktx 本身就是一场扩展函数的集中示范,挑几个高频的看:
// 视图显隐的可读化 binding.tvEmpty.isVisible = true // 替代 visibility = View.VISIBLE // SharedPreferences 的协程化 prefs.edit { putString("token", t) } // 替代三行模板 apply // 组件化的 Intent context.startActivity(intentFor<OrderActivity> { putExtra("id", orderId) }) // 集合的安卓特化 val avg = list.average() // 标准库 list.indexOfFirst { it.selected } // 标准库
读 core-ktx 的源码是学扩展的最佳途径之一:每个扩展短小、单一职责、挂在最自然的宿主上。仿照它给项目写扩展(比如给自己的 View 加统一的防抖点击),比再造一个 ViewUtils 健康得多。
扩展不是唯一选项,四种工具的分工用一张表定界:
| 方案 | 适用条件 | 代价 |
|---|---|---|
| 扩展函数 | 逻辑只依赖宿主公开成员、无状态 | 静态分派、不能覆盖成员 |
| 继承 | 需要改写行为、需要多态 | 脆弱基类问题,Kotlin 默认 final 已是提醒 |
| 组合 | 需要新增状态或复杂协作 | 样板略多,但最稳 |
| 顶层函数 | 逻辑不绑定任何类型 | 无补全加持,退回工具类形态 |
经验法则:先问"这逻辑属于哪个类型";答得出类型且只用公开成员,写扩展;答不出类型,顶层函数;要动内部状态,继承或组合。安卓工程里绝大多数 Utils 方法倒在第一条上——它们本来就该是扩展。
扩展也会泛滥:见人就给 String 加扩展、扩展互相重叠、一个文件里堆五十个不相关扩展——这是工具类的借尸还魂。三条纪律:按宿主类型组织文件(所有 View 扩展在 ViewExt 一处,String 扩展在 StringExt),命名统一后缀或统一目录,让"找个扩展"有固定入口;优先 internal,确有跨模块需要再放开;扩展必须有单一职责与测试,和成员函数同等对待。第 8 章的 Detekt 配置里可以限制扩展文件的体积与导出范围,把纪律交给机器。
扩展写多之后,组织方式决定它是资产还是新的负债。推荐的文件组织:按宿主类型分文件,命名统一(ViewExt、StringExt、IntentExt 这类后缀),放在各自模块的 ext 或 extension 包里;跨模块共享的扩展下沉到公共模块并标注 internal 或 public 的明确理由。一个文件超过两百行就该拆——它说明你对"按宿主组织"的粒度失控了(一个宿主塞进了不相干的能力域)。扩展的命名评审标准与成员函数一致:动词开头、含义单一、不缩写。
扩展的测试与普通函数无差别——它本来就是静态函数:
class StringExtTest { @Test fun `十一位手机号脱敏后中间四位为星号`() { assertEquals("138****5678", "13812345678".maskPhone()) } @Test fun `非十一位输入原样返回`() { assertEquals("12345", "12345".maskPhone()) } }
纯扩展函数(只依赖输入与公开成员)的测试是全工程最好写的一类——没有上下文、没有协程、没有安卓依赖。反过来,"这个扩展不好测"几乎总意味着它依赖了隐式环境(单例、Context 缓存),那是设计味道,先改设计再写测试。
问:扩展函数能被子类"继承"吗?比如给基类写的扩展,子类实例能调吗?
能调用、不能继承。给 Animal 写的扩展 sound,Dog 实例也能调(Dog 是 Animal),但执行的是 Animal 版本的逻辑——静态分派按声明类型决议(本节的硬边界一)。这带来一个实践纪律:给基类写扩展前先问"子类需要不同的行为吗"——需要的话扩展是错的工具(多态场景用 open 成员或接口);所有子类行为一致才轮到扩展。试图用"给每个子类各写一个同名扩展"模拟重载,调用结果取决于声明类型,是埋雷不是特性。
问:扩展函数与顶层函数都能放"不属于任何类的逻辑",怎么选?
看发现的路径。逻辑与某个类型的实例强相关(操作它的数据、返回它的变换)用扩展——补全列表直达;逻辑是流程性的(组装参数、编排步骤)用顶层函数。一个可操作的判断:如果函数的第一个参数 dominate 了整个签名(其他参数都是配置),它就该是那个参数的类型的扩展。Utils 方法绝大多数倒在"第一个参数决定一切"上,翻个方向就顺了。
Utils.f(x) 变 x.f(),逻辑被类型系统索引,补全直达、归属唯一;扩展机制立好了,下一节看它的最成功应用:标准库的六个作用域函数——它们是所有人都会用、但多数人用错的管道工具。