上个月整理项目里那块历史遗留代码,我发现光是适配器包装就套了三层。那一刻我脑子里冒出来的画面特别清晰:一支NBA球队签了个欧洲联赛的MVP,教练布置战术,球员听不懂;球队找个翻译,翻译先跟助教说、助教再跟球员说,每一层转述都丢一点意思,最后场上传出来的配合彻底变味。代码里的“翻译链”和这个几乎一模一样。这篇文章我打算用NBA来把适配器模式彻底讲透,再给上Kotlin里真正能落地、能避坑的写法。如果你正在啃设计模式,或者已经在项目里写过各种Adapter却总觉得哪里不对劲,这篇正好适合你。
先说明一件事:我不会只是把“插座转换头”换成一个NBA段子。插座那个例子只能解释“形状不匹配”,没法解释为什么系统里会存在两套完全不同的接口,更没法回答“到底什么时候该加适配器、什么时候不该加”。NBA场景的好处在于,它可以同时把“接口的所有权”“两个体系各自的惯性”“中间翻译能干什么不能干什么”这几层问题一次讲清楚,对应到代码里,你以后判断“要不要引入适配器”会从容很多。
1. NBA的“语言不通”与代码里的“接口不匹配”:适配器到底适配了什么
1.1 一个外援球员,三个系统难题
先建立一个场景。某支NBA球队签下了一名来自欧洲联赛的明星外援,这名外援在自己原来的体系里是绝对的进攻核心,拿了多个冠军,个人数据也非常漂亮。但到了新球队,教练组很快就发现三个问题:
第一,防守习惯对不上。外援过去常年打的是区域联防站位,讲究的是守住一块区域、互相补位;而NBA球队这套战术手册里大量要求盯人换防、无限换防,防守端跑位逻辑完全不同。第二,进攻风格有冲突。他在老体系里习惯依靠队友的连续掩护做无球切入,到了新体系,教练要求更多的是持球单打和有球挡拆,球权分配模式差异非常大。第三,战术指令听不懂。这里不只是英语和母语的语言问题,更麻烦的是战术手册里每个术语都带着特定的跑位、时机和信号,翻译只翻字面意思根本不够。
如果把这个故事往代码上搬,你会发现每一处都能对应上:老系统内部有自己约定成俗的接口标准,比如支付模块期待一个payment.execute(totalAmount)的调用;而现在一个新的外部SDK或者另一团队维护的服务只能提供pay(amount, currency, merchantId)这样的方法,而且这个外部类是被别人封死的,我们改不了。更麻烦的是,如果强行把新SDK的方法名改成老系统认识的格式,还会破坏它内部的状态校验、参数校验,整个模块都得跟着回归测试一遍。
“改不动”和“改不起”是最关键的客观条件。适配器模式不是让外援马上忘掉欧洲打法,也不是让教练把整个战术体系推翻,而是安排一个双方都能接受的中间翻译,把教练的战术意图转换成外援能执行的动令,让球队先运转起来。
1.2 三个角色:目标接口、被适配者、适配器
适配器模式的参与角色永远只有三个,甭管代码写得多花哨,本质上不会超出这个范围。
第一个是目标接口(Target),也就是主队体系对外公布的约定。拿篮球场景来说,NBA球队对“能上场的球员”有一个标准定义,比如上场的球员必须能执行进攻、执行防守、理解教练的战术指令。代码里对应的就是客户端所依赖的那个接口:
interface NbaPlayable { fun runOffense(): String fun runDefense(): String fun followPlaybook(order: String): String }第二个是被适配者(Adaptee),也就是外援自带的那套能力。他有一套已经练了十几年的技术动作,但这些动作并不是按照NBA接口来组织的。代码里对应的是外部SDK提供的类,比如一个播放器SDK提供了startPlayback()、pausePlayback(),而不是业务层认识的play()、pause()。
第三个是适配器(Adapter),也就是中间那层翻译官。翻译官自己要符合NBA的约定,让教练可以当作普通球员一样使用;同时它熟悉外援的那套能力,能把每个指令拆解后转发给真正干活的球员。在代码里,适配器实现目标接口,内部持有被适配者,把方法调用做“翻译转发”。
你只要抓住这一个结构,后面所有实现方式的差异都很好理解:类适配器、对象适配器、Kotlin扩展函数适配,它们改的只是“适配器怎么实现”的细节,并没有改变这三个角色的协作关系。
1.3 为什么不能直接改外援,而要加适配器
每次讲到适配器,总有人问我同一个问题:既然接口对不上,直接把新SDK的方法改成旧接口的样子不就行了?这个问题的答案要先分清“接口所有权在谁手里”。
如果接口是我们自己团队维护的,那直接改当然可行,甚至应该改。但适配器模式面对的是“接口所有权不在自己手里”的场景——外部SDK来自第三方厂商,每次升级都可能在变;旧系统是十年前的老前辈写的,连维护的人都找不齐,谁敢去动它的核心接口;还有一种更常见的情况是对面是并行开发的另一个团队,他们给了API,后续版本还不见得稳定。这时候,强改对方代码不现实,等对方改又遥遥无期,适配器就成了唯一能让两边先接通的办法。
适配器模式的本质是“隔离影响面”:它不影响老系统的调用方,也不影响外部SDK的实现,只在中间多了一个可替换、可删除的适配层。等以后双方接口趋于稳定,或者老系统准备重构,这个适配器可以直接拆掉,代价很小。这一点和NBA签外援的思路完全一致——先让翻译官跟上球队,球能打起来再说;至于以后外援学不学得会英语,那是后话。
2. 两种适配路线:继承式“类适配器”与组合式“对象适配器”怎么选
2.1 类适配器的Kotlin实现:看似直接,其实很容易被Kotlin卡住
类适配器的思路是同时利用继承和目标接口,让适配器类既继承被适配者,又实现目标接口,这样目标接口里的方法可以直接复用父类的实现。
在Java时代,只要被适配类没有被标记成final,这个思路非常直接。Kotlin里就不太一样了,Kotlin的类默认都是final的,没有显式声明open不允许被继承。这正好体现了Kotlin“组合优于继承”的设计倾向。假设我们拿到一个外部SDK的类:
// 这是外部SDK里的真实类,注意它没有被设计成open class ExternalPlayerSdk { fun startPlayback(url: String) = "SDK开始播放" fun pausePlayback() = "SDK暂停播放" fun seekToPosition(seconds: Int) = "SDK快进到${seconds}秒" }你可以试一下,直接写一个子类去继承它,编译器立刻就会报错,因为外部类默认是final。除非你在外部类上动手脚,或者使用接口实现版本,否则类适配器在Kotlin里天然要碰壁。即便我们拿到的外部类是open的,类适配器还有另一个问题:它暴露了父类里一大堆我们根本没打算适配的方法,调用方如果绕过目标接口、直接拿着适配器当外部类用,就等于把适配层给击穿了。
2.2 对象适配器:组合优先,Kotlin里最自然的选择
对象适配器不继承被适配者,而是持有被适配者的引用,把目标接口里的方法转发给被适配者。这是Kotlin里最推荐的写法。我直接给一个完整的例子,刚好呼应NBA翻译场景:
// 目标接口:NBA球员要会做的事 interface NbaPlayable { fun runOffense(): String fun runDefense(): String fun followPlaybook(order: String): String } // 被适配者:外援自带的能力接口 interface OverseasPlayer { fun euroStep(): String fun zoneDefense(): String fun nativeLanguageOrder(order: String): String } // 外援的具体实现 class EuropeanGuard : OverseasPlayer { override fun euroStep(): String = "欧洲步突破上篮" override fun zoneDefense(): String = "区域联防站位防守" override fun nativeLanguageOrder(order: String): String = "用母语理解的指令:$order" } // 适配器:翻译官 class PlayerTranslator( private val overseasPlayer: OverseasPlayer ) : NbaPlayable { override fun runOffense(): String = overseasPlayer.euroStep() override fun runDefense(): String = overseasPlayer.zoneDefense() override fun followPlaybook(order: String): String = overseasPlayer.nativeLanguageOrder(order) }调用方只认识NbaPlayable,使用起来和任何别的球员没有区别:
fun main() { val foreignStar: OverseasPlayer = EuropeanGuard() val player: NbaPlayable = PlayerTranslator(foreignStar) println(player.runOffense()) // 欧洲步突破上篮 println(player.runDefense()) // 区域联防站位防守 println(player.followPlaybook("第四节单打战术")) // 翻译后的指令 }对象适配器最大的好处是灵活:它可以适配任何外部类,不管这个类是不是final;它不会把外部类的无关方法泄漏给调用方;更重要的是,同一个外援可以被不同适配器翻译成不同体系,就好比同一个球员既能打NBA的战术,也能回原来的欧洲体系打球,翻译官随时换,球员本身不用改。
2.3 用Kotlin接口委托by简化转发逻辑
对象适配器还有一个语法层面的优化手段:Kotlin的接口委托by。如果我们能把被适配者也设计成接口,那么适配器可以通过by把接口实现委托出去,减少手写转发的代码量:
class DelegatingTranslator( private val overseasPlayer: OverseasPlayer ) : NbaPlayable, OverseasPlayer by overseasPlayer { override fun runOffense(): String = euroStep() override fun runDefense(): String = zoneDefense() override fun followPlaybook(order: String): String = nativeLanguageOrder(order) }这段代码的意思很直白:OverseasPlayer里的方法全部由overseasPlayer代为实现,适配器只需要定义额外属于自己的那部分逻辑。但这里有一个前提:被适配者必须是一个接口。如果外部SDK给你的只是具体类,你仍然需要老老实实手写转发。所以我个人的习惯是把接口委托当作一种优化手段,而不是默认方案,先保证对象适配器能跑通,再用委托去消除明显的样板代码。
为了更直观地对比两条路线,我把要点整理成一张表:
| 对比维度 | 类适配器(继承) | 对象适配器(组合) |
|---|---|---|
| 对Kotlin的友好度 | 低,外部类默认final | 高,不关心被适配者是否可继承 |
| 接口污染程度 | 会继承父类无关方法 | 只暴露目标接口约定的方法 |
| 灵活性 | 一个适配器只能绑定一个父类 | 可以持有多个对象,组合灵活 |
| 可复用性 | 父类行为直接复用,但绑定死 | 通过构造函数注入,解耦更彻底 |
| 实际使用频率 | 低 | 极高 |
从实际项目经验看,对象适配器几乎覆盖了90%以上的适配场景。类适配器的价值更多体现在教科书和面试题里,真实业务中你很难碰到一个恰好是open且结构稳定的被适配类。
3. Kotlin实战:适配器模式的三种落地姿势
3.1 对象适配器封装外部SDK:最频繁的真实场景
我随便举一个真实项目里几乎每个人都会遇到的例子。假设产品要接入一个第三方视频播放SDK,SDK的全套API是它自己的风格,而你的App内部播放器管理层早就定义好了统一接口,其他所有页面只认这套接口。你现在不能改SDK,也不能让几十个页面跟着改调用方式,唯一合理的做法是给SDK写一个适配器。
// App内部统一的播放器接口 interface MediaPlayer { fun play(url: String) fun pause() fun seekToSecond(seconds: Int) } // 第三方SDK对外暴露的接口 interface ThirdPartyPlayerApi { fun startPlayback(uri: String) fun pausePlayback() fun seekTo(progress: Int) } // 第三方SDK的实现 class ThirdPartyPlayer : ThirdPartyPlayerApi { override fun startPlayback(uri: String) = "启动播放 $uri" override fun pausePlayback() = "暂停播放" override fun seekTo(progress: Int) = "跳转到 $progress 秒" } // 适配器:把第三方风格翻译成统一风格 class ThirdPartyPlayerAdapter( private val thirdPartyPlayer: ThirdPartyPlayerApi ) : MediaPlayer { override fun play(url: String) = thirdPartyPlayer.startPlayback(url) override fun pause() = thirdPartyPlayer.pausePlayback() override fun seekToSecond(seconds: Int) = thirdPartyPlayer.seekTo(seconds) }这里有几个细节值得注意:第一,适配器内部的方法映射不是简单的名字替换,参数单位、语义都要对齐。比如SDK里如果seekTo接收的是毫秒,而App管理层的seekToSecond接收的是秒,适配器里就必须做seconds * 1000的换算。翻译官如果只把“跳转到第3节”翻成“跳到3分钟”,那也是失职。
第二,适配器不承担业务逻辑,它只做接口转换。不要把鉴权、数据缓存、埋点统计全塞进适配器里,否则它就不是翻译官,而是保姆了,后面讲到“上帝适配器”时会展开说。
第三,适配器可以组合使用。如果视频SDK里还有一套独立的广告播放器,它的接口和MediaPlayer差异更大,你完全可以另写一个AdAdapter,不要硬塞进一个播放器适配器里,保持每个适配器只负责办一件事。
3.2 用扩展函数做“轻适配”:Kotlin独有的一种取舍
Kotlin的扩展函数可以在不改动原有类的情况下,为它增加新的方法。如果你只是临时需要一个适配关系,不需要维护一个完整适配器类,扩展函数是很轻的选择。还是沿用外援的例子:
fun OverseasPlayer.asNbaPlayable(): NbaPlayable = object : NbaPlayable { override fun runOffense(): String = euroStep() override fun runDefense(): String = zoneDefense() override fun followPlaybook(order: String): String = nativeLanguageOrder(order) }用起来是这样:
val guard: OverseasPlayer = EuropeanGuard() val nbaPlayer: NbaPlayable = guard.asNbaPlayable()你可能已经发现了,这本质上就是对象适配器的另一种写法,只不过把适配器藏在了扩展函数里。它的优势是调用非常自然,不需要额外定义一个类,读者一眼就能看出“把外援转成NBA球员”这个意图;劣势是如果多处需要这种转换,扩展函数里的object : NbaPlayable会在每次调用时都创建一个新的匿名对象,造成不必要的分配。
所以我的建议是:如果适配逻辑只在局部使用一两次,用扩展函数完全没问题;如果适配器要被频繁调用、被多个模块共享,还是应该抽成一个独立的适配器类,或缓存适配后的对象。在NBA场景里就是,临时需要翻译时找个随行翻译没问题,整个赛季都靠随行翻译不合适,得配备长期跟队的战术翻译官。
3.3 适配函数签名:当“翻译”的粒度为回调级别
有时候需要适配的并非整个类,而只是一个函数签名。这种场景最常见的表现是:A SDK的回调接口和B业务层的回调风格完全不一致。
比如某个旧的网络请求库习惯用(Boolean, String)表示成功与否和错误信息,而新的SDK用的是(Int)状态码。两边语义不一样,你不能直接传引用,需要做个轻量的翻译:
// 业务层通用的回调类型 fun interface StateCallback { fun onStateChanged(success: Boolean, message: String) } // 新SDK的状态回调参数是 Int,1表示成功,其余表示错误码 fun adaptNewSdkStatus( sdkStatus: Int ): Pair<Boolean, String> = if (sdkStatus == 1) { true to "请求成功" } else { false to "错误码:$sdkStatus" } // 使用适配器函数 fun handleCallback(callback: StateCallback) { val status = 1 callback.onStateChanged( adaptNewSdkStatus(status).first, adaptNewSdkStatus(status).second ) }这个例子表面上很简单,但它体现了一个重要的思想:适配器既可以包装类,也可以包装函数。函数级适配在Kotlin里非常常见,而且经常藏在业务层代码里,散落各处。当函数签名经常需要转换时,把这些函数适配器放在同一个文件里管理,能减少很多无谓的重复翻译。
另外,Kotlin的fun interface可以配合lambda使用,这让函数级适配器调用起来更短:
val adapter: (Int) -> Pair<Boolean, String> = ::adaptNewSdkStatus本质上,你把这个状态码翻译成业务层能理解的信息,这就是翻译官在“手势”和“指令”之间做转换,和NBA助教在场边把教练的手势战术实时转成球员母语指令,是同一个逻辑。
4. NBA现场最容易混淆的三种模式:适配器 vs 外观、装饰者、代理
4.1 适配器 vs 外观:同样都穿中间层,服务对象完全不同
适配器模式经常被拿来跟外观模式(Facade)比较,因为两者都存在一个中间层,都做“封装”,容易混淆。
我还是用NBA来说明。适配器解决的是“接口不匹配”,它把教练的战术语言翻译成外援能听懂的语言,目标是兼容,最终两边的接口都保持原样,只是中间多了一个翻译。外观模式解决的是“接口太多太复杂”,它把球队背后的一整套运作事务,包括战术会议、体能管理、媒体采访、商业活动,统一收束到一个“球队事务经理”面前。你只需要跟事务经理一个人打交道,事务经理再帮你协调后面一堆子系统。
代码上的区别更直接:适配器的目标接口是事先存在的,适配器把新的被适配者映射到目标接口上;外观模式则会自己定义一个更简化的接口,它的意义在于降低使用复杂度而不是解决不兼容。
你可以记一句话:适配器是硬接两边的接口,外观是给复杂子系统开一个服务总台。两者结构相似,目的完全不同,别混着用。
4.2 适配器 vs 装饰者:是让球员听懂战术,还是给球员加Buff
装饰者模式(Decorator)和适配器模式也有点像,因为它们都用了“包装”这个动作,但守护的核心完全不同。
适配器不改变被适配对象的行为能力,它只改变接口口径。翻译官不会教欧洲球员变得更能防守,也不会让他的投篮命中率提高,翻译官只负责让教练的意图传达到位,球员该怎么做还是怎么做。
装饰者模式则恰恰相反,它不改变接口,但会动态增加行为。你可以把它想象成一个技术教练,在保留球员原有打法的基础上,给他加一套挡拆后的分球能力,或加强背身单打的脚步。球员对外还是同一个球员接口,但实际执行时多了额外增强。
代码层面的例子是Kotlin里的BufferedReader:它包装一个Reader,接口不变,但增加了缓冲能力。对比而言,适配器是把三方SDK的接口包装成业务接口,接口变了,行为没变。
所以判断标准特别简单:接口变了、行为没变,是适配器;接口没变、行为增强了,是装饰者;如果接口变了、行为又增强了,你要先反思是不是把一个适配器和一个装饰者叠在一起用了。
4.3 适配器 vs 代理:翻译官和替身球员差在哪儿
代理模式(Proxy)和适配器模式的代码结构经常长得一模一样,区别只在意图上。
代理模式是为了控制访问,它的接口通常和原始对象保持一致。比如在NBA场景里,某核心球员的商业代言事务可以由经纪人代理,商业合作方找的还是同一个球员的“对外形象”,接口没变,但权限和流程都被代理层控制。翻译官则不同,翻译官不是球员本人,也不会替球员上场打球,它只是改变了交流接口。
具体到代码里,代理常常用在懒加载、访问控制、远程调用这些场景,典型的比如Lazy代理、权限代理;适配器则出现在“接口版本不一样、需要做兼容”的场景,比如老接口转新接口、第三方SDK转内部接口。
还有一个容易看走眼的细节:代理模式往往要求代理类和被代理类实现相同的接口,这样客户端无感知;适配器则强制要求适配器类实现目标接口,但被适配者可以完全不认识这个目标接口。所以,从接口是否一致这个角度,可以快速区分它们。
5. 实战中适配器模式被用废的三种毒瘤:三层翻译链、上帝适配器、过度适配
5.1 三层翻译链:“翻译的翻译”让信息越转越歪
前面开头提到的那段三层适配器代码,我这次展开说一下教训。当时项目里接一家物流平台的接口,由于历史原因,先是SDK需要适配到数据层接口,数据层又适配到服务层接口,服务层再适配给上层业务。从业务层到底层SDK,一共经过了三层对象适配器。每次新增一个字段要改三个地方,排查问题时整个调用链像追连续剧一样,断点根本不知道停在哪层。
用NBA来打比方就是:教练用英语向助教布置战术,助教翻译成西班牙语给外援,但外援母语是塞尔维亚语,于是还要配第二个翻译把西班牙语翻成塞尔维亚语。中间任何一层理解偏了,球员跑出来的战术就偏到观众席去了。翻译层不是不能嵌套,但每一层都在增加延迟、增加失真、增加维护成本。
实际问题出在最初那位同事的需求判断上:他以为每一层都需要一个适配器,实际上完全可以在最底层把SDK一步适配到业务层需要的接口。后来我做的重构是砍掉中间两层,保留最外层一个适配器,让目标接口直接对接SDK,逻辑反而更清晰。这里我总结一条经验:写适配器之前先看目标接口在哪一层,能一步到位就绝不要中转。
5.2 上帝适配器:翻译官干成了保姆,适配器变成百宝箱
另一种很常见的设计退化是“上帝适配器”。表现为适配器类越写越长,除了接口转换,还顺带负责缓存、埋点、重试、权限判断,甚至把一些编解码逻辑也塞进来。表面上看这些功能都是“顺手加的”,实际上适配器已经承担了不该承担的职责,变得很难测试、很难替换。
NBA的反面对应很清晰:助教的本职是把教练战术翻译给外援,结果这位助教还要帮外援谈合同、安排理疗、处理媒体采访,到最后他对战术翻译的精力自然被分散了,而且整个球队越来越离不开这个“全能王”,一旦他请假,所有和外援相关的事情全部停摆。
适配器的职责边界其实很清楚:只处理接口形状的转换,不处理业务策略。重试是重试策略的职责,缓存是缓存层的职责,适配器应该保持纯粹。如果你发现适配器代码里出现了超过一类的“顺手逻辑”,尽快把它们拆成独立对象,通过构造函数传入适配器,而不是直接长在适配器里。
5.3 什么时候不要用适配器,直接改设计更合适
适配器模式不是万金油。有几种情况,我认为完全不应该引入适配器:
第一种,目标接口是你自己团队维护的,而且接口的演化还处于早期。与其花精力写一个适配器,不如直接调整目标接口的定义。适配器本质上是一次性的缓冲垫,如果缓冲垫本身要铺很久,接口本身的演变可能更值得做。
第二种,适配器里开始出现大量“碰巧同名但行为不同”的方法映射。比如一个send方法在两边一个代表发送网络请求,一个代表发送邮件,这种语义不一致不是适配器能解决的,背后是领域模型出了问题,应该去统一模型而不是硬翻译。
第三种,为了适配而适配,追求“完美设计”。比如只在单元测试里需要伪装接口,只为一个临时分支写专用适配器。这种适配器生命周期极短,反而增加了代码复杂度。宁可先让接口直接对接,等稳定的差异出现之后再引入适配器也不晚。
Kotlin这个语言本身已经帮你铺了一条更好的路:如果一个类的接口实在不满意,优先思考能不能用扩展函数、接口委托、默认参数来优雅调整,只有当这些语法糖覆盖不了的时候,再上完整的适配器类。适配器模式的价值在于“隔离”,四两拨千斤,一旦你把它用成每个模块的标配,那隔离就不再是保护,而是累赘。
我这里给一个比较实用的判断清单:当你觉得某两个接口需要适配时,先问四件事——这个接口的所有权在谁手里?是否能通过调整现有接口直接兼容?适配器是否只承担接口转换这一件事?是否只有最外层一个适配器?如果四个问题的答案都偏否定,请停下来重新考虑设计,而不是继续往上叠翻译层。