十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android第三方SDK封装实战:接口设计、回调处理与多厂商切换

Android第三方SDK封装实战:接口设计、回调处理与多厂商切换 1. 为什么每个安卓项目都该给第三方SDK套一层壳先讲个真实场景。之前我接手过一个已经上线两年的App代码里到处都是某个推送SDK的调用。登录成功要调一次MainActivity的onCreate要调一次设置页要调一次消息通知栏的点击事件里还要处理它回调的数据结构。这个SDK的初始化参数散落在三个不同的类里有的地方传了alias有的地方没传还有一个已经废弃的接口居然还在线上跑着。最头疼的是有一天这个SDK的厂商发了公告说旧版本要停服必须升级到新版本。我打开代码一看升级意味着要改十几个文件因为新版本的API签名变了连回调的数据结构都重构了。这就是典型的业务代码被第三方SDK绑架。所谓的封装本质上就是在你的业务代码和第三方SDK之间插入一个中间层。这个中间层是一个完全由你定义的接口业务层只依赖这个接口永远不直接触碰厂商的代码。所有跟厂商SDK的交互都收敛到这个中间层的实现类里。你可能会说就一个推送SDK有必要搞这么复杂吗还真有必要。封装的价值在项目早期看不出来但一旦到了后期它的回报是非常惊人的替换成本极低。今天用的是A厂商的推送明天想换B厂商只需要新写一个适配B的封装类业务代码一行不用动。风险隔离。第三方SDK经常会有崩溃、内存泄漏、回调不稳定的问题。封装层可以把这些脏东西挡在业务之外甚至可以做到SDK崩溃不影响主流程。团队协作更顺。业务开发不需要去翻厂商的文档只需要看你封装层暴露的接口。接口写得清楚整个团队的理解成本就低很多。代码风格统一。不同厂商的SDK风格差异很大有的用builder有的用静态方法有的要传listener有的用广播。封装层可以统一成一种风格让你的团队只维护一套代码习惯。我在团队里常打一个比方第三方SDK就像租来的房子封装层就是你跟房东之间的租赁合同。合同条款是你定的房子里水管坏了你找房东修但你的生活方式、你的家具摆放不需要为房东改变。业务代码就是你的家具封装层就是那份合同。有了合同你想换房东的时候搬走就行家具一件都不用扔。2. 封装前必须想清楚的四个决策点动手写封装代码之前有四个问题不解决后面大概率要返工。2.1 接口的边界划在哪这是整个封装里最重要的问题。接口边界的意思是你的封装层暴露给业务层的方法粒度要多大。很多新手容易走到两个极端。一个是接口暴露得特别细几乎是把厂商SDK的方法原样翻译了一遍比如init、resume、pause、stop、setDebug、setAlias、deleteAlias、addTags、deleteTags、clearAllNotifications……另一个极端是暴露得太粗就一个init方法和一个setListener方法业务层想干点什么都得自己再去拿SDK的原生实例。我的建议是以业务场景来定接口而不是以厂商SDK的方法来定。比如推送SDK业务层真正关心的就这几件事初始化SDK传入Context和业务必要的配置设置别名或标签用于定向推送接收推送消息和通知栏点击事件清空角标或通知获取推送id用于上报给服务端你不需要暴露pause、resume这种偏底层的生命周期控制方法除非你的业务确实需要。接口越少维护成本越低业务层的认知负担也越小。封装层要做的是翻译和简化不是复制。2.2 依赖方向谁该依赖谁模块化工程里这个问题的答案是明确的接口是抽象必须下沉到最底层的基础模块里厂商SDK实现之上的依赖品要往上放。举个例子。你的工程结构可能是这样的base module最底层存放抽象接口和纯数据类 -- PushService.kt接口 -- PushMessage.kt接口用的数据模型 business module业务层只依赖base module -- 只调用PushService的方法 push-sdk-impl module实现层依赖base module和厂商SDK -- PushServiceImpl_JG.kt极光SDK的适配实现这样依赖关系就是单向的业务层 → base接口 → 厂商SDK实现在单独的module里。业务层永远不知道厂商SDK长什么样。将来换SDK只是把impl模块换掉业务层和base模块完全不受影响。如果项目还没有做模块化在一个application模块里也建议把这个抽象接口单独放到一个package里从物理上隔离。2.3 版本策略接口的兼容性怎么保证第三方SDK有自己的版本迭代节奏你的封装层也会跟着变。为了不让业务代码跟着遭殃接口设计必须考虑向后兼容。我习惯的做法是接口版本只增不减废弃方法保留一个Deprecated标记而不是直接删除。比如原来有一个register(alias: String)方法后来业务需要支持标签了不要改这个方法的签名而是新加一个register(alias: String, tags: ListString)。旧方法内部调用新方法传一个空列表。这样老代码不用改新代码用新方法封装层的实现类也能平滑演进。另外回调数据的数据模型也要注意。厂商SDK的回调数据结构可能会变你的封装层数据模型一旦定义了字段不要随便改。如果厂商新加了一个字段你的模型里也加一个但不要改已有字段的类型或含义。这跟网络API的兼容性原则是完全一样的。2.4 线程模型与回调线程第三方SDK的回调线程是你最容易忽视的问题。很多SDK的回调是在Binder线程或者自己的线程池里触发的不是你主线程。如果你直接把回调里的数据丢给业务层业务层再直接去更新UI轻则闪烁重则崩溃。封装层必须在设计之初就定下一个约定所有回调统一转到主线程回调给业务层。实现方式很简单用一个主线程Handler包装一下就行。但是要注意两点第一确定好回调线程的转换统一在封装层里做不要让业务层自己判断要不要切线程第二回调接口的设计上要明确标注回调发生的线程比如在接口注释里写清楚以下回调均在主线程。3. 把一个推送SDK从头封装一遍的完整过程下面我用一个具体的例子来说明整套流程。以推送SDK为例假设我们采用的是厂商SDK的假装叫JPush的SDK我们要封装出一个属于自己的推送到接口。3.1 第一步定义自己的数据模型先定义封装层的数据模型这个模型不能依赖厂商SDK的任何类。比如data class PushMessage( val title: String?, val content: String?, val extra: MapString, String?, val notificationId: Int? ) data class PushConfig( val appKey: String, val channel: String, val debug: Boolean false, val autoInit: Boolean true )这一步非常关键。数据模型是业务层和封装层的共同语言它一旦带上厂商SDK的类型比如直接用了cn.jpush.android.api.JPushMessage那业务层还是会被厂商SDK的类型污染抽象就白做了。还有一点厂商SDK的extra字段往往是String到String的Map但有些SDK回调里extra的value是JSON字符串。这种情况在数据模型层面你可以直接暴露原始字符串由业务层自己去解析也可以增强封装层的解析能力把JSON解析成Map。我建议暴露原始值封装层不做多余的业务解析保持简单。3.2 第二步定义抽象接口接口是封装的灵魂它决定了业务层能看到什么、能做什么。以一个典型的推送SDK为例我最终沉淀出来的接口大致是interface PushService { fun init(context: Context, config: PushConfig) fun registerAlias(alias: String) fun unregisterAlias() fun setTags(tags: SetString) fun getPushId(): String? fun setListener(listener: PushListener?) fun clearAllNotifications() fun stop() } interface PushListener { fun onMessageReceived(message: PushMessage) fun onNotificationClicked(message: PushMessage) fun onAliasChanged(result: Boolean, code: Int) }看到没有这个接口完全是围绕业务需要设计的没有出现任何厂商SDK的影子。业务层想做什么看这个接口就能懂不需要去翻厂商文档。有一点需要提醒。接口方法不宜太多如果发现接口里的方法数量超过了15个要停下来想一想是不是把不该暴露的都暴露了。接口越小实现和替换的成本就越低。3.3 第三步实现厂商适配类接口定义好了接下来写一个具体的实现类去适配厂商SDK的API。这个类在代码里就是脏活累活集中地class JPushServiceImpl : PushService { private var listener: PushListener? null override fun init(context: Context, config: PushConfig) { JPushInterface.setDebug(config.debug) JPushInterface.init(context) // 渠道、appKey等可以通过manifest占位符或代码设置 } override fun registerAlias(alias: String) { JPushInterface.setAlias(context, alias, object : TagAliasCallback { override fun gotResult(code: Int, alias: String?, tags: MutableSetString?) { // code为0表示成功 mainThreadHandler.post { listener?.onAliasChanged(code 0, code) } } }) } // ... 其他方法 }在这个适配类里你要做的核心工作就是翻译把厂商SDK的调用翻译成自己的接口把厂商SDK的回调翻译成自己的回调把厂商SDK的数据类型翻译成自己的数据模型。这个类是整个封装层里最脏的类但它同时也是一道防火墙把所有的厂商依赖都挡在这里。这里有个细节值得注意。适配类的回调里state code的处理逻辑要谨慎。厂商SDK的状态码含义千奇百怪有的0是成功有的0是失败。封装层应该把状态码翻译成语义化的结果比如用Boolean result表达成功或失败而不是直接抛code给业务层。真的需要code来做详细排查时可以作为第二个参数提供但业务层的主要判断逻辑最好还是基于语义化的返回值。3.4 第四步提供统一入口接口和实现都有了业务层怎么拿到这个实现呢我推荐用单例门面Manager模式。object PushManager : PushService by PushDelegateHolder.delegate { fun init(context: Context, config: PushConfig) { // 确保初始化只执行一次 if (!initialized) { delegate.init(context.applicationContext, config) initialized true } } }把PushManager做成一个单例它内部持有一个PushService引用所有业务层调用都走这个Manager。这样做的额外好处是你可以在Manager里做统一的初始化保护、日志打印、异常捕获。比如在方法调用前后打印日志方便线上排查问题捕获SDK回调里的异常不让它在业务层炸掉。3.5 第五步注册和生命周期管理要放在哪初始化动作的调用时机也得考虑。常见做法是在自定义Application的onCreate里初始化但有个坑Application的onCreate里不能保证主线程而且不能做太多耗时操作。所以最好把初始化动作拆成两步第一步在Application里拿到Context保存到一个全局位置。第二步在业务层真正需要使用之前首次调用时再延迟初始化。延迟初始化可以用懒加载单例来保证。但要注意这样如果调用时机比较晚可能收不到早期已经到达的消息。所以如果业务对消息的完整性要求高还是在Application里尽早初始化更稳妥。这个取舍要根据你业务的实际情况来定我的建议是如果SDK初始化本身不耗时少于50ms就在Application里初始化如果初始化较重拉配置、建长连接就用延迟初始化保证启动速度。4. 回调解耦与内存泄漏防治是封装层最容易翻车的地方第三方SDK的生命周期往往比Activity长。尤其推送SDK它通常运行在独立进程里生命周期几乎等于App的整个存活时间。如果你的封装层把Activity或者View的引用传进去了那内存泄漏几乎是必然的。4.1 回调转发的两种典型姿势第一种直接把listener传给适配类。这种方式最简单但如果listener里持有了Activity的引用而SDK的回调存活时间很长就会造成泄漏。第二种在封装层内部做一个观察者模式。也就是封装层内部持有一个listener集合业务层通过register/unregister方式动态增删监听者。这样业务层在Activity的onDestroy里可以主动解绑。推送SDK的监听尤其适合这种方式因为一个App可能会有多个页面关心推送事件比如主页面要弹通知消息页面要更新角标。推荐第二种。但注意要处理好线程同步因为监听者的注册和回调可能发生在不同线程。4.2 用弱引用解决SDK持有Activity的问题有些SDK的回调接口里会传Context如果你的业务代码把Context当Activity用SDK内部可能会长期持有这个引用。所以在封装层就要把关凡是需要传Context给SDK的统一传applicationContext不传Activity。这是封装层的一条铁律。如果某些场景确实需要Activity比如要弹一个通知栏点击后跳转的Intent那应该通过PendingIntent或者applicationContext.startActivity()加FLAG_ACTIVITY_NEW_TASK来解决而不是把Activity引用传递下来。4.3 如何排查为什么没回调封装层做得越复杂回调链路就越长。业务层没收到回调你排查起来也更头疼。所以封装层里的日志系统从一开始就要打好地基。我习惯在每个封装方法入口和每个回调出口都打日志统一加一个前缀比如[PushService]线上开debug模式可以开关排查问题时一打开就能看到完整的链路。这算是用日志换取排障幸福感。5. 封装之后躲不开的三座山混淆、多Module、多进程5.1 混淆规则哪些要keep哪些不需要第三方SDK的代码通常都有自己附带的混淆规则一般都在consumer-rules.pro里。但封装层会导致一个问题你的适配类实现了厂商SDK的接口名字被混淆后SDK的反射机制可能找不到它们。举个例子JPush内部底层是用反射来生成通知栏的如果你的类没有处理好keep规则可能会出现通知栏无法点击这种莫名其妙的问题。正确的做法是# 自有封装接口不要混淆因为要提供给业务层使用 -keep class com.yourpackage.push.api.** { *; } # 适配类不要混淆因为厂商SDK的反射可能会调用 -keep class com.yourpackage.push.impl.** { *; } # 厂商SDK自己的keep规则直接引用官方建议 -keep class cn.jpush.android.** { *; }注意不只是厂商SDK要keep你自己的适配实现类也要keep。这是一个很多人忽略的坑。更稳妥的做法是在实现类上统一加Keep注解同时配合proguard规则双重保险。5.2 多Module场景下的依赖关系要怎么理顺如果你的项目是模块化工程封装层的代码放在哪个module里会影响整个工程的编译时间、耦合度、可维护性。我的建议是接口定义和数据模型放在base或common模块最底层无任何第三方依赖。厂商适配实现放在独立的push-impl模块依赖base和厂商SDK。业务模块只依赖base模块的接口运行时通过依赖注入或ServiceLoader动态拿到实现。如果暂时不做动态注入用Gradle的implementation依赖方向也能实现。核心是不要让业务模块在编译期直接依赖厂商SDK的库。否则一旦厂商SDK升级改动会波及所有业务模块。5.3 多进程问题Application会被多次调用很多第三方SDK自己会启动进程比如推送SDK经常有一个独立的推送进程。这会导致什么你的Application的onCreate会被调用两次甚至更多次——主进程一次推送进程一次。如果不加判断在Application里初始化封装层就会在每个进程里都初始化一遍。有些初始化操作比如创建数据库连接、拉取远端配置在推送进程里做完全没必要还可能引起崩溃。所以封装层的init方法内部要做好进程判断val processName getProcessName(context) if (processName ! context.packageName) { // 非主进程不执行主进程才需要的初始化 return }这段逻辑不用做得太复杂但必须有。这是很多封装方案里最容易漏掉的细节。6. 进阶玩法一套接口下实现多厂商切换封装做得好不好终极考验就是同一套接口到底能不能支撑多个厂商的实现切换。这里分享两个实用的玩法。6.1 Debug/Release环境切换开发的时候你可能不想接真厂商SDK因为真SDK调试起来慢、推送频率受限、还有沙箱环境等问题。这时候可以做一个假实现包装一下接口在Debug环境下使用本地模拟的SDK实现Release环境切换为真实现。具体做法是在Gradle的buildConfigField里传一个开关buildTypes { debug { buildConfigField String, PUSH_IMPL, com.yourpackage.push.impl.MockPushServiceImpl } release { buildConfigField String, PUSH_IMPL, com.yourpackage.push.impl.JPushServiceImpl } }然后在Manager里通过反射或者显式判断来加载对应的实现类。这个能力在团队开发里非常好用。测试人员和前端同学不用依赖真实的推送环境就能在本地验证推送消息的展示逻辑。而且因为实现了同一套接口开发环境暴露的问题基本就是封装层的问题不会把厂商SDK的坑和业务代码的坑混在一起。6.2 为未来替换厂商预留的平滑迁移方案假如有一天真的要决定从JPush换成Firebase或个推迁移流程应该是这样的第一步写一个新的适配实现类实现PushService接口。比如PNServiceFBM_Impl。写完后在测试环境跑全套回归重点验证消息接收、通知栏点击跳转参数、alias/tag设置、推送id获取与上报。第二步灰度切换。通过远端配置开关控制Manager内加载哪个实现先让5%的用户流量切到新实现对比崩溃率和推送到达率。第三步全部切换。确保旧实现无流量后移除旧SDK及适配类清理混淆规则和依赖。整个过程中业务代码一个字符都不需要动。这就是封装的终极意义你的架构给了你换引擎的权利而不会把整辆车都换掉。我在实际项目中做过一次类似迁移从厂商A换到厂商B业务代码零改动只在适配层新增了一个类灰度了一周服务端推送链路完全正常。那一刻你会觉得当初在一个简单推送SDK上花几天时间做封装层真的是性价比最高的技术决策。7. 封装实战里的最后几个拾遗封装工作走到这里大的框架和细节都已经聊完了。最后分享几个零散但很实用的经验。不要过度封装。有人会为了封装而封装把接口设计得异常抽象什么泛型、工厂模式、桥接模式全上。但封装的核心目的是隔离和简化不是炫技。接口方法不多于15个类不超过5个每个方法都能说清楚它对应业务的什么诉求就比较健康。厂商SDK升级之前先做兼容性确认。升级厂商SDK前先看这个版本的API有没有break change。有的话先改适配代码在自己的模块里通过编译再让业务层无感升级。不要直接把新的厂商SDK版本丢给业务层容易出事。封装层也要有测试。可以针对接口的骨架逻辑写几个单元测试比如Manager的初始化和回调线程切换确保你封装层的核心逻辑在替换厂商SDK前后都稳定。没空做完整封装时退而求其次。如果你的项目已经很老了也没办法立刻做大规模重构那至少做到集中管理把散落在各处的厂商SDK调用都集中挪到一个文件或一个类里。虽然业务层还是会直接调用这个类但至少将来替换时你只需要改一个文件而不是十几个文件。安卓第三方SDK的封装这件事说难不难说简单也不简单。核心不在于写出漂亮的代码而在于想清楚边界在哪里、依赖往哪个方向走、回调怎么转、生命周期怎么管理。把这些想清楚代码自然就顺了。希望这篇文章能给你一点实战参考少踩几个我已经替你踩过的坑。
返回列表