UI 复用的关键不是抽基类,而是先想清楚哪一层是不变的。
当一个 App 迭代到第三年,你会发现最贵的根本不是写新页面,而是"改一个风格要动几十个地方"。弹窗圆角改了,确认框还是方的;状态栏颜色换了,设置页顶栏还是旧的;扫码库要换成厂商能力,结果登录页、设置页、主页的扫码代码散成三份,谁都改不动。这些不是 bug,是"复用粒度"没选对留下的债。
更反直觉的是:很多人一上来就抽一个超级基类,把能想到的通用方法全塞进去,结果基类变成谁都不敢动的"上帝类"------动一行,全 App 抖三抖。复用当然是好的,但复用什么、在哪一层复用,比"要不要复用"重要得多。把变化点埋进基类深处,等于把未来的雷也埋了进去。
这篇把五篇内部笔记合并成一条从"单个控件"到"整页调用"的阶梯:骨架与插槽负责复用结构,泛型基类负责复用能力,策略门面负责复用接口,类型契约负责复用调用姿势。粒度从细到粗,复用的成本从"写一个新类"一路降到"写一行"。读到最后你会发现,这四件事讲的其实是同一个判断------先圈出不变的那层,把变化点留成插槽或接口,其余全部下沉。
一、先说复用的四个粒度
复用不是只有"抽基类"一种姿势。同样是"少写代码",发生在不同的层,收益和代价完全不同。我们先把四个粒度摆清楚,后面的章节再逐一展开。
最底层是结构复用:一份布局骨架,子类只填"中间那块"。典型代表是 Dialog 的插槽模式和设置页的行控件家族------它们复用的不是逻辑,是"外观的一致性"。
往上一层是能力复用:把横竖屏、Loading、防双击这些每个页面都要的系统能力,逐层下沉进泛型基类。子类只声明一个泛型参数,通用能力白拿。
再往上是接口复用:换一个扫码库,业务代码一行不改。靠的是抽象出"扫码这件事"的稳定接口,把变化的维度(用哪家库)抽出去。
最粗的一层是调用复用 :把"发 Intent + 解析结果"封成类型安全的一行调用。ActivityResultContract 干的就是这个,连 requestCode 分发都省了。
四层递进,粒度越来越粗,复用的成本越来越低,但反过来,越粗的复用越难在局部推翻------你不会为了一个弹窗去改契约框架。所以选型时要想清楚:这个变化点,值得放在哪一层被复用?
下面这张图把四层、每层的代表方案、以及"复用的是谁"串成一条阶梯。注意箭头方向表达的是"复用粒度由细到粗、复用成本由高到低",不是调用关系。
把四个粒度放进一张对比表,一眼能看出各自的适用边界。这张表是后面所有章节的索引,建议先记住前两列。
| 复用粒度 | 代表方案 | 复用对象 | 复用成本(新场景) | 变化点落点 |
|---|---|---|---|---|
| 结构复用 | Dialog 插槽 / 设置行家族 | 布局骨架与外观样式 | 低:一个新布局 + 一个继承类 | 内容插槽 / 右侧控件插槽 |
| 能力复用 | ViewBinding 三层基类 | 横竖屏、Loading、防双击等系统能力 | 极低:声明一个泛型参数 | 抽象方法(initViewBinding 等) |
| 接口复用 | 扫码策略门面 | 多供应商能力的稳定契约 | 中:新写一实现类 + 改一行初始化 | 具体实现类 |
| 调用复用 | ActivityResultContract | 页面跳转的构建与解析 | 极低:一行 launch | 契约类的 create/parse |
一个容易踩的误区是"上来就抽基类"。基类的代价是:它一旦被几十个页面依赖,就再也不能随便改签名。所以能用插槽解决的(外观一致),别用基类;能用接口隔离的(供应商差异),别下沉进基类;能用契约收口的(跳转样板),别让每个页面各写一套。后面每一章都会回到这个判断。
二、骨架与插槽(Dialog / 设置行)
先讲最细的一层:结构复用。它的核心就一句话------把"不变的骨架"和"变的内容"拆开。这一章合并了 Dialog 插槽模式和设置页行控件家族两篇,它们用的是同一个套路,只是插槽的位置不同:Dialog 把"标题栏 + 操作栏"固定、留"内容区"当插槽;设置行把"左侧标题 + 行容器"固定、留"右侧控件"当插槽。
2.1 弹窗的痛点:每个弹窗各写各的
最原始的做法是每个弹窗一个独立的布局文件,自己管标题、自己管按钮、自己设背景圆角:
xml
<!-- 弹窗 A 的布局 -->
<LinearLayout ...>
<TextView android:text="提示" .../>
<TextView android:text="确认删除?" .../>
<Button android:text="确定" .../>
</LinearLayout>
弹窗 A、B、C 各自这样写,标题样式、按钮样式、圆角完全靠每个开发者"记得保持统一"。一旦有人改了主题色,所有弹窗要挨个改。插槽模式要解决的就是这个"重复 + 不统一"------而它的根因,是变与不变混在同一个布局里。
这里有个反例值得展开:有人试图用"复制一个弹窗模板 XML 再改文字"来统一,结果三个月后模板更新了圆角,十几个复制出去的副本一个都没跟上。模板靠人复制,就一定会漂移;只有把模板变成"被引用的一份骨架",漂移才不会发生。
2.2 弹窗骨架:一份统一布局 + 一个内容插槽
定义一份"通用弹窗骨架"布局,结构固定:
标题栏(可隐藏)
────────────
内容区(插槽)
────────────
操作栏(可隐藏:确定/取消按钮)
在代码里,弹窗基类暴露两个"槽位"方法:一个提供内容布局,一个提供标题栏布局(可选)。子类只需重写这些方法,往槽位里放自己的东西:
kotlin
abstract class BaseBottomDialog : BottomSheetDialogFragment() {
// 子类重写:返回自己的内容布局
protected abstract fun contentView(parent: ViewGroup): View
// 子类可选重写:返回标题栏布局(默认不显示)
protected open fun titleView(parent: ViewGroup): View? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
// 加载统一骨架
val root = inflater.inflate(R.layout.dialog_skeleton, container, false)
// 往插槽里填内容
val contentSlot = root.findViewById<FrameLayout>(R.id.content_slot)
contentSlot.addView(contentView(contentSlot))
// 可选标题
val titleSlot = root.findViewById<FrameLayout>(R.id.title_slot)
titleView(contentSlot)?.let { titleSlot.addView(it) }
return root
}
}
骨架布局里,标题栏和操作栏默认 gone(隐藏),子类需要时才显示。所有弹窗共用这份骨架,所以圆角、背景、标题样式只有一份,改一次全生效。这一步是"结构复用"成立的前提:不变的东西必须只有一份、被所有人引用,而不是被复制。
一个边界情况:如果某个弹窗需要两个按钮之外再加一个"中间操作",直接在骨架里加第三个槽位会破坏所有子类。正确做法是把"操作栏"也设计成可插拔的容器(和 Dialog 的内容插槽同构),而不是写死两三个按钮。骨架本身也要为"未来的变化"留插槽。
2.3 内容插槽:子类只写"中间那块"
具体弹窗子类变得非常轻,只需重写 contentView 提供中间内容:
kotlin
class ConfirmDeleteDialog(
private val onConfirm: () -> Unit
) : BaseBottomDialog() {
override fun contentView(parent: ViewGroup): View {
return inflater(parent).inflate(R.layout.content_confirm, parent, false)
}
override fun onConfirmClicked() {
onConfirm()
dismiss()
}
}
要做一个新弹窗,只需要:新建一个内容布局 + 继承骨架 + 重写一个方法。不需要关心标题、按钮、圆角长什么样,这些全在骨架里定好了。
2.4 操作栏:确定/取消的复用
确定/取消按钮也在骨架里,通过回调让子类挂接自己的逻辑:
kotlin
// 骨架里提供统一的确定/取消回调
interface ActionListener {
fun onConfirm()
fun onCancel() {}
}
子类实现 onConfirm/onCancel,骨架负责绑定按钮点击和默认样式。于是所有弹窗的按钮都是同一个样式、同一个间距,点按反馈也统一。
操作栏用统一回调挂接,按钮样式、点击反馈全局一致。这张类关系图把"骨架---子类---回调"三者的关系画清楚:骨架持有插槽和回调,子类只填插槽、实现回调,谁都不碰样式。
2.5 设置行家族:把"标题 + 右侧控件"也做成插槽
设置页是结构复用的另一个典型。一个典型的设置行长这样:
css
[ 标题文字 ] [ 开关 / 输入 / 箭头 ... ]
最常见的坏写法是每种行一个独立布局文件,比如"开关行"一个 XML、"输入行"一个 XML、"箭头行"一个 XML。它们 90% 是重复的(标题样式、左边距、右边距、分割线、点击态),只有右侧那一小块不一样。结果就是几十个 XML,改一次标题字体要改几十处。
设计一个"基础设置行"控件,左侧标题是固定的,右侧用一个容器承接不同类型的控件。先定行类型,右侧放什么由类型决定:
kotlin
enum class RowType { ARROW, SWITCH, INPUT, TEXT, CUSTOM, ... }
class SettingItemBar @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
private val titleView: TextView
private val contentContainer: ViewGroup
// 根据类型装配右侧控件
fun configure(type: RowType, title: String, ...) {
titleView.text = title
when (type) {
RowType.ARROW -> showArrow()
RowType.SWITCH -> showSwitch(onChanged = ...)
RowType.INPUT -> showInput(...)
RowType.CUSTOM -> /* 子类/调用方塞自己的 view */
}
}
}
右侧控件(开关、输入框、箭头)都从基类里生成,所以它们的样式、间距、对齐全部一致。设置页需要新行时,只是"换一种装配方式",不用新写布局。
每种右侧控件封装成一个装配方法,细节(大小、颜色、默认值)都在基类里定好:
kotlin
private fun showSwitch(title: String, checked: Boolean, onChanged: (Boolean) -> Unit) {
val sw = Switch(context).apply {
isChecked = checked
setOnCheckedChangeListener { _, isOn -> onChanged(isOn) }
}
contentContainer.addView(sw)
}
private fun showArrow() {
val arrow = ImageView(context).apply {
setImageResource(R.drawable.ic_arrow_right)
}
contentContainer.addView(arrow)
}
private fun showInput(title: String, value: String, onDone: (String) -> Unit) {
val et = EditText(context).apply {
setText(value)
// 软键盘完成键触发
setOnEditorActionListener { _, _, _ ->
onDone(text.toString()); true
}
}
contentContainer.addView(et)
}
无论预置多少种行,总有"其他"的情况。所以基类必须留一个 CUSTOM 出口:让调用方直接塞任意 View 进右侧容器:
kotlin
fun setCustomContent(view: View) {
contentContainer.removeAllViews()
contentContainer.addView(view)
}
这样家族内的行统一,家族外的极端情况也有兜底,不会把每个特殊行都做成独立布局、破坏统一性。CUSTOM 插槽是这套设计的"安全阀"------它承认"我不可能预置所有情况",从而避免了"为了一个特例破坏整体统一"的尴尬。
最终,一个设置页的一行,可以收敛成一次配置调用:
kotlin
// 开关行
settingsLayout.addBar(
SettingItemBar(context).apply {
configure(RowType.SWITCH, "自动同步") { isOn -> saveSetting(isOn) }
}
)
// 跳转行
settingsLayout.addBar(
SettingItemBar(context).apply {
configure(RowType.ARROW, "关于我们") { navigateToAbout() }
}
)
设置页的代码变成"一组配置的列表",每个设置项的添加、修改、回调都集中在一处,可读性和可维护性都很好。
2.6 结构复用的坑:插槽也会泄漏变化
结构复用看着简单,但有两个坑反复出现。把它们和后面章节的坑放在一起对照,更容易记住"变化点一定要关在插槽里"。
| 现象 | 根因 | 解法 |
|---|---|---|
| 改了主题色,十几个弹窗样式没跟上 | 弹窗布局是复制出去的副本,不是引用同一份骨架 | 骨架只定义一份,子类只填内容插槽 |
| 某个弹窗要第三个按钮,被迫改骨架破坏所有子类 | 操作栏写死两三个按钮,没留可插拔容器 | 操作栏也做成容器,和内容的插槽同构 |
| 特殊行被做成独立布局,设置页又分裂了 | 预置类型不够用,又走了"各写各的"老路 | 必须留 CUSTOM 插槽兜底极端情况 |
| 骨架升级圆角,老弹窗崩溃 | 老子类依赖了骨架内部私有 id | 骨架只暴露槽位方法,内部 id 不对外承诺 |
这一章的结论可以收紧成一句:结构复用的本质是把变化的(内容/右侧控件)和不变的(骨架/标题)拆开,且不变的那份必须只有一份、被引用而非被复制。能靠插槽解决的,就别上基类。
三、泛型基类与 ViewBinding 三层体系
结构复用解决"长得像",但解决不了"每个页面都要干的系统活"------横竖屏、状态栏、Loading、防双击、收键盘、统一 Toast。这一章讲能力复用:把这些都下沉进泛型基类,业务 Activity 只剩十几行。
3.1 三层结构:通用能力逐层下沉
三层基类,职责从"通用"到"具体"逐层递进:
- 第一层(最通用):封装所有页面都需要的系统能力------横竖屏控制、状态栏/导航栏颜色、Loading、Toast、防双击、收起键盘。
- 第二层(绑定视图) :引入 ViewBinding,用泛型
VB : ViewBinding接管"创建绑定实例、设置根视图",业务只需给一个"怎么创建 binding"的方法。 - 第三层(业务):具体业务 Activity,继承第二层,实现那一两个抽象方法,页面逻辑就齐了。
kotlin
// 第一层:通用能力基类
open class BaseActivity : AppCompatActivity() {
// 横竖屏控制
var orientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
// Loading、Toast、防双击、键盘收起等,都定义在这里
}
// 第二层:ViewBinding 泛型基类
abstract class BaseBindingActivity<VB : ViewBinding> : BaseActivity() {
protected lateinit var binding: VB
abstract fun initViewBinding(): VB
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = initViewBinding() // 业务只提供这一句
setContentView(binding.root)
initViews()
}
abstract fun initViews()
}
// 第三层:业务 Activity
class SettingsActivity : BaseBindingActivity<ActivitySettingsBinding>() {
override fun initViewBinding() = ActivitySettingsBinding.inflate(layoutInflater)
override fun initViews() {
// 这里直接 binding.xxx 访问控件
}
}
业务 Activity 的最小骨架只有十几行:一个绑定方法 + 一个初始化方法。其余全是框架给的。
这里有个设计取舍要点:为什么是三层而不是两层? 有人想直接把 ViewBinding 逻辑写进 BaseActivity,省掉中间那层。问题是那样 BaseActivity 会同时依赖 ViewBinding 泛型参数,导致"不需要 ViewBinding 的页面"(比如纯 Fragment 容器页)也被迫带一个 VB 泛型。把"通用能力"和"视图绑定"拆成两层,是让 ViewBinding 成为"可选的能力"而非"强制的负担"。这一层拆分,正是"变化点(是否用 ViewBinding)留在中间层"的体现。
下面的类图把三层继承关系画出来,泛型参数 VB 用 classDiagram 的参数语法表示。
3.2 横竖屏控制:通过 Intent 透传
很多行业 App 强制某些页面横屏。做法是:基类在 startActivity 时,把当前的横竖屏设置塞进 Intent 的 extra,新的 Activity 在 onCreate 里读出来应用。这样从一个横屏页面跳出去的页面,自动也是横屏:
kotlin
override fun startActivity(intent: Intent) {
// 把当前的屏幕方向透传给目标页
if (!intent.hasExtra(KEY_ORIENTATION)) {
intent.putExtra(KEY_ORIENTATION, orientation)
}
super.startActivity(intent)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
orientation = intent.getIntExtra(KEY_ORIENTATION, orientation)
requestedOrientation = orientation
}
这个设计的边界要讲清楚:它只透传"方向设置"这一个值,不负责"旋转后重建"。横屏页跳到一个没声明方向的页,目标页读不到就退回自己的默认值------这是有意的,因为不是所有页都该继承上游方向。如果某个页明确要竖屏,它应该显式设置 orientation 并写进 extra,覆盖上游透传值。透传是"默认继承",不是"强制统一"。
3.3 Loading 超时自动关闭
通用 Loading 不该只弹不关。基类封装一个带定时器的 Loading:弹出后如果超过 N 秒还没被主动关闭,就自动关闭,防止网络异常时界面卡死在 Loading 上。定时器由独立组件持有,避免 Activity 旋转后计时器丢失。
kotlin
fun showLoading(msg: String = "加载中") {
loadingDialog = LoadingDialog(this)
loadingDialog?.show()
startLoadingTimeout() // 定时器,超时自动 dismiss
}
fun dismissLoading() {
loadingDialog?.dismiss()
loadingDialog = null
stopLoadingTimeout()
}
超时自动关闭这个细节,看着是"防御性编程",实则是稳定性兜底:网络回调丢失、异常分支忘记 dismiss,都会导致界面永远卡在 Loading。把"一定会关"这件事交给基类,业务层就少了一个出错面。而"定时器由独立组件持有"则是为了应对旋屏------Activity 重建时匿名 Handler/Runnable 容易泄漏或失效,独立持有者能在重建后正确续上或取消。
3.4 防双击与统一 Toast
- 防双击:基类提供"最近是否发生过点击"的判定。两次点击间隔小于阈值(比如 600ms)就忽略第二次,用于"提交订单""删除"这种危险操作,防止用户连点触发两次请求:
kotlin
private var lastClickTime = 0L
fun isFastDoubleClick(): Boolean {
val now = SystemClock.uptimeMillis()
if (now - lastClickTime < 600) return true
lastClickTime = now
return false
}
- 统一 Toast :所有页面共用同一个 Toast 实例。因为
Toast.makeText(...).show()每调一次就创建一个新实例,连点会叠成一串队列慢慢消失。用一个单例字段复用同一个 Toast,每次setText再show,连点也只显示最后一条:
kotlin
private var toast: Toast? = null
fun showToast(msg: String) {
toast?.cancel()
toast = Toast.makeText(this, msg, Toast.LENGTH_SHORT).also { it.show() }
}
防双击的 600ms 阈值不是拍脑袋。它要小于"用户故意连点两下的合理间隔",又要大于"单次点击的事件抖动",600ms 是大量 App 验证过的折中值。低于它可能误杀正常操作,高于它则防不住真正的连点。统一 Toast 的 cancel() 再 show() 而非新建,是为了避免"连点十次、Toast 排队十秒"的经典体验事故------这种事故用户说不清哪里卡,但就是觉得 App 反应慢。
3.5 能力复用的坑:基类变成上帝类
能力复用下沉得爽,但下沉的边界最容易失控。见下表。
| 现象 | 根因 | 解法 |
|---|---|---|
| 基类改一个方法签名,全 App 编译不过 | 几十个页面直接依赖基类,基类承担了过多职责 | 能力按层拆分(通用/绑定),保持基类只放真正通用的 |
| 某个页面不需要 Loading,却被强制带 VB 泛型 | ViewBinding 和通用能力没分层,绑定成了强制项 | 抽中间层,让 ViewBinding 成为可选能力 |
| 用户连点提交,请求发了两次 | 没有统一的防双击入口,各页面自己写或不写 | 防双击下沉进基类,危险操作统一调用 |
| Toast 连点排起长队 | 每次 show 都新建 Toast 实例 | 基类复用单例 Toast,cancel 后重 show |
这一章收一句:能力复用的关键不是"能下沉多少",而是"只下沉真正通用的",把可选的变化点(如是否用 ViewBinding)留在可插拔的中间层。上帝类不是复用,是负债。
四、策略与门面(扫码)
能力复用解决"每个页面都要干的系统活",但还有一种重复更隐蔽:同一个能力(扫码、支付、推送)有多家供应商,业务层被供应商的 API 差异绑架。这一章讲接口复用------换库 = 换一个实现类,业务代码一行不改。
4.1 问题:扫码逻辑散落各处
假设 App 里好几个页面都要扫码:登录页扫设备二维码、设置页扫配置码。每个页面各写一套:
kotlin
// 页面 A
camera.open()
decoder.setMode(...)
// 页面 B 又抄一遍
camera.open()
decoder.setMode(...)
一旦换扫码库,所有页面都要改;更糟的是,不同扫码库的 API 千差万别:有的要手动管理相机,有的自带界面;返回的二维码可能是字符串,也可能是带格式的对象。业务层被这些差异绑架,根本没法抽身。
这里的变化维度很清晰:"扫什么、怎么展示结果"是稳定的,"用哪家的库去扫"是变化的。很多人错把变化维度写死在业务层,结果换库就是重写。接口复用要做的,就是把"变化维度"从业务里抽出去。
4.2 抽象:定义一个"扫码者"接口
先想清楚一件事:业务层到底需要扫码干什么? 无非是------开始扫、扫相册、设置选项(震动/闪光灯/音效)、把内容生成二维码。把这几个动作抽成接口,就是稳定契约:
kotlin
interface Scanner {
val requestCode: Int // 启动扫码的请求码
fun startScan() // 打开摄像头扫码
fun openAlbumScan() // 打开相册识别二维码
fun setScanOptions(options: Options) // 配置震动、闪光灯、音效等
fun generateQrCode(text: String, size: Int, logo: Bitmap?): Bitmap
}
业务层只认识这个接口。它不需要知道底层用的是哪家的扫码库,只需要"有个东西能帮我扫码、能给我一个二维码图片"。接口里的 requestCode 之所以放在接口里,是因为不同扫码库可能要求不同的启动方式,把它作为契约的一部分,调用方拿到的就是"已经配好的请求码",不必自己再记一个魔数------这点和第五章的契约思想一脉相承。
4.3 门面:一个总入口,按需选择实现
再包一层门面,作为唯一入口。它内部维护当前选中的实现,业务层向门面要一个"扫码者"即可:
kotlin
object ScannerManager {
private var current: Scanner? = null
// 应用启动时设置一次用哪家实现
fun init(scanner: Scanner) {
current = scanner
}
fun get(): Scanner =
current ?: DefaultScanner() // 兜底默认实现
fun generateQrCode(text: String, size: Int, logo: Bitmap?): Bitmap =
get().generateQrCode(text, size, logo)
}
门面的价值在于:业务层连"当前用哪家"都不需要知道,只管 ScannerManager.get().startScan()。供应商的切换收口在 init() 一处。DefaultScanner 兜底很重要------万一 init 忘了调用,业务层也不至于空指针崩溃,而是退化到一个可用实现。这是"失败也要有默认行为"的防御设计。
4.4 多个实现:策略类的自由切换
每种扫码库写一个实现类,都实现同一接口。
用开源库的实现:
kotlin
class OpenSourceScanner : Scanner {
override fun startScan() {
// 启动开源库的扫码界面
openCaptureActivity()
}
override fun openAlbumScan() {
// 调起系统相册选图识别
}
override fun setScanOptions(options: Options) {
// 配置震动、闪光灯开关
}
override fun generateQrCode(text: String, size: Int, logo: Bitmap?) = decodeQr(text, size, logo)
}
用厂商扫码能力的实现:
kotlin
class VendorScanner : Scanner {
override fun startScan() {
// 调厂商的扫码能力
vendor.startScanAndListen(onResult = ::onDecoded)
}
override fun openAlbumScan() {
vendor.openGallery()
}
// ...
}
两个实现内部差异巨大,但对 ScannerManager 来说都是同一个接口。切换只需在 init() 时换一个实现:
kotlin
// 想在 A 机型用厂商能力、其他用开源库
ScannerManager.init(
if (isVendorDevice) VendorScanner() else OpenSourceScanner()
)
策略模式在这里的精髓不是"多用几个类",而是把变化的维度(供应商)抽出来,让稳定的一侧(业务接口)永远不变 。注意 isVendorDevice 这种"运行时选择"也放在了初始化处,而不是散落在业务页------选择逻辑集中,业务页才真正无感。
一个边界:如果两家库的"扫码结果类型"不一样(一家返回 String,一家返回带格式的 ScanResult),这个差异必须在实现类内部归一化成接口约定的返回类型,绝不能让差异透传到接口签名上。接口一旦为了某家库改签名,就又回到了"被供应商绑架"的老路。接口是稳定契约,归一化是实现的职责。
4.5 接口复用的坑
| 现象 | 根因 | 解法 |
|---|---|---|
| 换扫码库,业务页全要改 | 供应商 API 差异直接写在业务层 | 抽象 Scanner 接口,业务只依赖接口 |
| 忘了 init,调用扫码空指针 | 门面没有兜底默认实现 | ScannerManager 提供 DefaultScanner 兜底 |
| 某家库结果类型特殊,被迫改接口 | 差异透传到了接口签名 | 在实现类内部归一化,接口保持稳定 |
| 业务页还知道"当前用哪家" | 选择逻辑散落在各页面 | 选择收口在 init() 一处,业务无感 |
这一章收一句:接口复用的关键不是多写几个策略类,而是把"变化的维度"从业务里抽出去,让稳定一侧永远不变------支付、地图、推送等"今天 A 明天 B"的能力都该这么抽象。
五、类型安全的调用契约(ActivityResultContract)
前面三层在"页面内部"做复用,最后一层的复用发生在"页面之间"------把一次跳转封成类型安全的一行调用。这一章讲调用复用。
5.1 老写法:三处样板 + 魔数满天飞
老派的页面跳转拿结果,是这样写的:
- 调用方:拼 Intent、
putExtra塞参数、startActivityForResult、然后在onActivityResult里再手写一个switch(requestCode)分发。 - 被调页:
onCreate里getIntent()取参数、设置结果时setResult+ 拼数据塞进 Intent。
调用方发起请求:
kotlin
val intent = Intent(this, FilePickerActivity::class.java)
intent.putExtra("mode", 1) // 魔数:1 代表单选
intent.putExtra("extensions", arrayOf("csv", "txt"))
startActivityForResult(intent, 100) // 魔数:100 是请求码
回调里再分发:
kotlin
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 100 && resultCode == RESULT_OK) {
val path = data?.getStringExtra("path") // 又是一堆魔数 key
// ...
}
}
被调页取参数:
kotlin
val mode = intent.getIntExtra("mode", 1)
val exts = intent.getStringArrayExtra("extensions") ?: emptyArray()
痛点一眼可见:"mode"、"path"、100 全是散落的魔数,改一个 key 要同时改三处,编译期完全查不出来。数据类型靠 getIntExtra / getStringArrayExtra 这种强转式 API 保证,容易错。
这里还有个老写法的隐性雷:onActivityResult 里一旦忘了写 super.onActivityResult(...),下游的 Fragment 回调就会收不到结果,而且这种 bug 只在特定跳转组合下才暴露,极难定位。契约方案从根上消灭了手写分发,自然也就消灭了"漏 super"这类人为失误。
5.2 契约类:把"发 Intent + 解析结果"封装起来
ActivityResultContract<Input, Output> 的核心思想是:一个页面跳转 = 一份契约,契约定义了两件事------如何根据输入参数构建 Intent、如何从返回的 Intent 解析出结果。这两个逻辑只写一次,调用方和被调页都用契约的字段,类型是强约束的。
kotlin
// 输入参数:用 data class 封装,字段是强类型
data class FilePickerInput(
val mode: Int,
val extensions: List<String> = emptyList()
)
// 输出结果:同样强类型
data class FilePickerResult(
val path: String,
val name: String
)
// 契约:负责 Intent 构建与结果解析
class FilePickerContract :
ActivityResultContract<FilePickerInput, FilePickerResult?>() {
override fun createIntent(context: Context, input: FilePickerInput): Intent {
return Intent(context, FilePickerActivity::class.java).apply {
putExtra("input", input) // 输入序列化进 Intent
}
}
override fun parseResult(resultCode: Int, intent: Intent?): FilePickerResult? {
if (resultCode != Activity.RESULT_OK) return null
val input = intent?.getSerializableExtra("output") as? FilePickerResult
return input
}
}
注意这里输入输出都是 data class,直接整个对象塞进 Bundle,靠编译期类型保证不会传错,不需要手拆成 getIntExtra 这种基础类型。契约内部用 "input" / "output" 这两个 key,但它们只在契约类内部出现一次------这是调用复用的精髓:字符串 key 这种"易错但必须存在"的东西,被关进了唯一一处,业务层再也碰不到。
5.3 调用方:注册一个"启动器",一行启动
调用方不再自己拼 Intent、不再重写 onActivityResult。而是在组件创建时注册一个启动器,它把"如何发请求"和"结果回调"绑在一起:
kotlin
// 注册:参数就是契约类
private val pickLauncher = registerForActivityResult(FilePickerContract()) { result ->
// 结果回调,result 已经是解析好的强类型对象
result?.let {
loadFile(it.path) // 直接用,无需再 getStringExtra
}
}
// 发起:传强类型输入,一行搞定
fun openFilePicker() {
pickLauncher.launch(FilePickerInput(mode = 1, extensions = listOf("csv", "txt")))
}
一个页面要跳多个地方?多注册几个 launcher 就行,互不干扰,再也不用手写 requestCode 去区分。registerForActivityResult 的注册时机也有讲究:必须在 Activity/Fragment 的 onCreate 之前(确切说是 onStart 之前、通常通过 by lazy 或属性初始化)完成注册,否则框架会抛异常。契约把"注册时机"也变成了框架约束的一部分,比手写 requestCode 安全得多。
5.4 被调页:从契约的输入类里取参数、按结果类返回
被调页的职责也简化了:读取输入、返回结果时都构造强类型对象。
kotlin
class FilePickerActivity : AppCompatActivity() {
private val input: FilePickerInput by lazy {
intent.getSerializableExtra("input") as FilePickerInput
}
fun onPickDone(file: File) {
// 用契约的输出类型构造结果
setResult(RESULT_OK, Intent().apply {
putExtra("output", FilePickerResult(file.absolutePath, file.name))
})
finish()
}
}
这里有个小约定:契约里用什么 key 存输入、用什么 key 存输出,只在契约类内部出现一次 。业务代码只和 FilePickerInput、FilePickerResult 这两个强类型打交道,key 字符串全部收口在契约里,改了也只改一处。
5.5 契约调用的时序
把"调用方注册 → 启动 → 被调页返回 → 解析 → 回调"这条链路画成时序图,能看清契约把"构建"和"解析"两端都收口在了 Contract 类里,调用方和被调页只和强类型对象对话。
5.6 调用复用的坑
| 现象 | 根因 | 解法 |
|---|---|---|
| 改一个参数 key,编译通过运行时错 | 参数用字符串魔数散落三处 | 用 data class 做 Input/Output,编译期强约束 |
| onActivityResult 里漏写 super | 手写分发,人为易漏 | 用 launcher 回调,框架自动分发 |
| 多个跳转 requestCode 撞号 | 手写请求码靠人记 | 一个跳转一个 launcher,无需 requestCode |
| 被调页取不到参数崩溃 | getExtra 的 key 和调用方不一致 | key 只在契约内部出现一次,全局唯一 |
这一章收一句:调用复用的关键是把"构建 Intent"和"解析结果"两个样板收口进契约类,业务只和强类型对象打交道------这是新架构体系里页面跳转的标准做法。
六、小结
- UI 复用的关键不是抽基类,而是先想清楚"哪一层是不变的"------把变化点留成插槽或接口,其余全部下沉。
- 结构复用靠骨架 + 插槽(Dialog / 设置行):不变的样式只定义一份、被引用而非复制,变化的内容/右侧控件填进插槽。
- 能力复用靠泛型基类三层体系:通用系统能力下沉,ViewBinding 拆成可选中间层,业务 Activity 只剩十几行。
- 接口复用靠策略 + 门面(扫码):把"供应商"这个变化维度抽出去,换库 = 换实现类 + 改一行初始化,业务零改动。
- 调用复用靠 ActivityResultContract:把"发 Intent + 解析结果"封成类型安全的一行调用,字符串 key 只出现一次,告别 requestCode 分发。
- 四层递进(结构 → 能力 → 接口 → 调用)粒度越来越粗、复用成本越来越低,但越粗的复用越难局部推翻,选型时要先判断变化点该落在哪一层。
系列导航:Android 基础库系列第 3 篇(共 9 篇)。上一篇《Android 自定义 View 三关:触摸分发、绘制与坐标错位》,下一篇《Android 状态保持:进程被杀、旋屏与后台任务的存活术》。
你现在的项目里,弹窗和设置页是走"一份骨架 + 插槽"的路子,还是每个页面各写各的?哪一层的变化点最难收口?