Android UI 复用体系:泛型基类、骨架与插槽

UI 复用的关键不是抽基类,而是先想清楚哪一层是不变的。

当一个 App 迭代到第三年,你会发现最贵的根本不是写新页面,而是"改一个风格要动几十个地方"。弹窗圆角改了,确认框还是方的;状态栏颜色换了,设置页顶栏还是旧的;扫码库要换成厂商能力,结果登录页、设置页、主页的扫码代码散成三份,谁都改不动。这些不是 bug,是"复用粒度"没选对留下的债。

更反直觉的是:很多人一上来就抽一个超级基类,把能想到的通用方法全塞进去,结果基类变成谁都不敢动的"上帝类"------动一行,全 App 抖三抖。复用当然是好的,但复用什么、在哪一层复用,比"要不要复用"重要得多。把变化点埋进基类深处,等于把未来的雷也埋了进去。

这篇把五篇内部笔记合并成一条从"单个控件"到"整页调用"的阶梯:骨架与插槽负责复用结构,泛型基类负责复用能力,策略门面负责复用接口,类型契约负责复用调用姿势。粒度从细到粗,复用的成本从"写一个新类"一路降到"写一行"。读到最后你会发现,这四件事讲的其实是同一个判断------先圈出不变的那层,把变化点留成插槽或接口,其余全部下沉。

一、先说复用的四个粒度

复用不是只有"抽基类"一种姿势。同样是"少写代码",发生在不同的层,收益和代价完全不同。我们先把四个粒度摆清楚,后面的章节再逐一展开。

最底层是结构复用:一份布局骨架,子类只填"中间那块"。典型代表是 Dialog 的插槽模式和设置页的行控件家族------它们复用的不是逻辑,是"外观的一致性"。

往上一层是能力复用:把横竖屏、Loading、防双击这些每个页面都要的系统能力,逐层下沉进泛型基类。子类只声明一个泛型参数,通用能力白拿。

再往上是接口复用:换一个扫码库,业务代码一行不改。靠的是抽象出"扫码这件事"的稳定接口,把变化的维度(用哪家库)抽出去。

最粗的一层是调用复用 :把"发 Intent + 解析结果"封成类型安全的一行调用。ActivityResultContract 干的就是这个,连 requestCode 分发都省了。

四层递进,粒度越来越粗,复用的成本越来越低,但反过来,越粗的复用越难在局部推翻------你不会为了一个弹窗去改契约框架。所以选型时要想清楚:这个变化点,值得放在哪一层被复用?

下面这张图把四层、每层的代表方案、以及"复用的是谁"串成一条阶梯。注意箭头方向表达的是"复用粒度由细到粗、复用成本由高到低",不是调用关系。

flowchart TD A["结构复用 骨架加插槽"] --> B["能力复用 泛型基类"] B --> C["接口复用 策略门面"] C --> D["调用复用 类型契约"] A1["Dialog 插槽 统一外观"] --> A A2["设置行家族 标题加右侧控件"] --> A B1["ViewBinding 三层 下沉通用能力"] --> B C1["Scanner 接口 换库不改业务"] --> C D1["ActivityResultContract 一行调用"] --> D D -->|"粒度由细到粗 成本由高到低"| A

把四个粒度放进一张对比表,一眼能看出各自的适用边界。这张表是后面所有章节的索引,建议先记住前两列。

复用粒度 代表方案 复用对象 复用成本(新场景) 变化点落点
结构复用 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,骨架负责绑定按钮点击和默认样式。于是所有弹窗的按钮都是同一个样式、同一个间距,点按反馈也统一。

操作栏用统一回调挂接,按钮样式、点击反馈全局一致。这张类关系图把"骨架---子类---回调"三者的关系画清楚:骨架持有插槽和回调,子类只填插槽、实现回调,谁都不碰样式。

classDiagram class BottomSheetDialogFragment class BaseBottomDialog { +contentView(parent) View +titleView(parent) View +onConfirm() +onCancel() } class ConfirmDeleteDialog { +onConfirmClicked() } class ActionListener { +onConfirm() +onCancel() } BottomSheetDialogFragment <|-- BaseBottomDialog BaseBottomDialog <|-- ConfirmDeleteDialog BaseBottomDialog ..|> ActionListener

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 三层结构:通用能力逐层下沉

三层基类,职责从"通用"到"具体"逐层递进:

  1. 第一层(最通用):封装所有页面都需要的系统能力------横竖屏控制、状态栏/导航栏颜色、Loading、Toast、防双击、收起键盘。
  2. 第二层(绑定视图) :引入 ViewBinding,用泛型 VB : ViewBinding 接管"创建绑定实例、设置根视图",业务只需给一个"怎么创建 binding"的方法。
  3. 第三层(业务):具体业务 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 的参数语法表示。

classDiagram class AppCompatActivity class BaseActivity { +orientation +showLoading() +dismissLoading() +isFastDoubleClick() +showToast() } class BaseBindingActivity_VB { +binding: VB +initViewBinding() VB +initViews() } class SettingsActivity AppCompatActivity <|-- BaseActivity BaseActivity <|-- BaseBindingActivity_VB BaseBindingActivity_VB <|-- SettingsActivity

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 类里,调用方和被调页只和强类型对象对话。

sequenceDiagram participant Caller as 调用方Activity participant Launcher as 启动器Launcher participant Target as 被调页FilePickerActivity participant Contract as FilePickerContract Caller->>Launcher: launch(FilePickerInput) Launcher->>Contract: createIntent(input) Contract->>Target: 启动(Intent含input) Target->>Target: 读取FilePickerInput Target-->>Contract: setResult(parseResult) Contract-->>Launcher: parseResult(resultCode,intent) Launcher-->>Caller: 回调 FilePickerResult?

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 状态保持:进程被杀、旋屏与后台任务的存活术》。

你现在的项目里,弹窗和设置页是走"一份骨架 + 插槽"的路子,还是每个页面各写各的?哪一层的变化点最难收口?

相关推荐
Cici_ovo2 小时前
手机数据删除原理、运行内存、闪存、机械硬盘完整原理详解
android·智能手机
hi_LeTian2 小时前
【Linphone】Ubuntu20.04 编译 Linphone5.2 Android SDK 适配RK3326硬解码完整教程(避坑版)
android
SoStraw2 小时前
跨平台P2P通信SDK,Windows/Android/iOS/Linux/macOS一套API全兼容
android·linux·windows·跨平台·p2p·webrpc
李游Leo2 小时前
《HarmonyOS 7 ArkGraphics 3D 空间设计开发实战》07:复杂3D场景的帧率、内存与资源性能优化【鸿蒙心迹】
android·3d·性能优化·harmonyos
天神哥哥啊2 小时前
unity联调注意事项-安卓
android·unity·游戏引擎
机核研创社2 小时前
polo 长袖短裤套装自动化产线:八台机器排队接力
android·java·自动化
JosieBook2 小时前
【数据库】MySQL 实战精通系列 · 第2篇:数据建模与建表实战
android·数据库·mysql
Martin -Tang3 小时前
uniapp app嵌套webview 弹窗方式
android·ios·uni-app
蒸鱼Yuzheng12 小时前
设备端性能工件可靠导出:断点续传、哈希、manifest 与失败恢复
android·自动化测试·python·adb·数据完整性