前阵子接手一个活儿,要把已经跑了三年的内部办公 App 接进浙政钉,实现扫码登录、免登跳转和消息推送这三件事。听上去就是"装个 SDK"的活儿,真上手才发现光安装这一环就够折腾:渠道包不通用、签名校验卡死、混淆之后回调莫名其妙失灵、多进程重复初始化……前后踩了差不多两周。这篇就把浙政钉 SDK 安装这条链路从头到尾捋一遍,包括环境准备、依赖引入、初始化和鉴权时序,以及我实际遇到的十来个报错和对应解法。如果你手上正好有个 App 要往这个平台接,或者团队里没人干过这事被你赶鸭子上架,那这篇东西应该能帮你省掉大半的排查时间。
1. 先拆清楚浙政钉 SDK 到底装的是什么
1.1 SDK 的能力边界与交付形态
很多人第一反应是"去官网下个安装包双击装一下",这个思路从根上就偏了。浙政钉 SDK 不是一个独立可运行的程序,而是一组按能力切分的客户端组件集合,外加一套服务端 HTTP 接口规范。它要装进的是你自己的 App 工程,而不是某个系统目录。这一点想不明白,后面所有的配置都会找不到方向。
客户端这边的交付形态通常有三种:Android 侧以 aar 包为主,偶尔配一个单独的 so 库包;iOS 侧是 framework 或 xcframework,通过 CocoaPods 或者手动拖入工程;H5 微应用侧则是一份 JS 文件,由容器在页面加载时自动注入,你只需要调用dd.xxx这类挂载在全局对象上的方法。服务端侧没有"包"的概念,你能拿到的只有接口文档和一对 appKey/appSecret 凭证。
能力上大致可以分成五类。第一类是身份类,免登授权和扫码登录都在这里,核心是拿免登码去服务端换用户身份。第二类是消息类,工作通知推送、会话消息发送。第三类是原生能力类,扫一扫、拍照、定位、通讯录选人这些,本质是让宿主 App 的容器能力暴露给你。第四类是事件回调类,组织架构变更、应用可见范围调整这类通知。第五类是数据类,通过服务端接口读组织和人员信息。
这里有个新手最容易误解的点:客户端 SDK 基本只负责"唤起"和"回调",真正拿到用户是谁、属于哪个部门,是靠服务端拿免登码去换的。所以安装阶段就要把客户端和服务端当成一个整体来规划,只做客户端或者只做服务端,最后一定跑不通。
1.2 三种接入形态的取舍
选哪种接入形态,决定了你后面安装步骤的复杂度和长期维护成本。这张表是我自己项目里总结的对比,供参考。
| 接入形态 | 适用场景 | 开发成本 | 可用原生能力 | 发版节奏 | 调试难度 |
|---|---|---|---|---|---|
| 客户端原生集成 | 已有独立 App,要打通身份和推送 | 高 | 全部 | 跟随 App 发版 | 高,需真机+签名 |
| H5 微应用 | 内部轻量工具、表单审批类 | 低 | 仅 JSAPI 暴露的 | 随时更新 | 低,浏览器+容器 |
| 纯服务端对接 | 后台数据同步、组织架构同步 | 中 | 无 | 独立部署 | 中,日志即可 |
为什么这么分?因为浙政钉的客户端本身就是一个"超级容器",它把系统能力做了封装再对外开放。你走原生集成,相当于把自己塞进这个容器里,能力和宿主一样全,但代价是必须跟着它的版本走,它升级大版本你就得回归测试。你走 H5,相当于租了容器的几个窗口,开发快、改起来也快,但一旦需要蓝牙、后台定位这种深度能力就没辙。
我的建议是:核心业务走原生,运营活动和临时工具走 H5,两条腿并行。千万别为了省事把所有东西都塞 H5,等业务方提一个"要读本地相册原图"的需求时你会很尴尬。反过来,也别什么都原生,一个活动页要发版审核两周,业务方会疯。
2. 安装前的环境准备与依赖梳理
2.1 版本矩阵必须一次性对齐
SDK 安装失败十次有七次栽在版本不对齐上。Android 侧至少要确认这几个:JDK 版本、Gradle 版本、Android Gradle Plugin 版本、compileSdk、minSdk、targetSdk,如果 SDK 里带 so 库还要确认 ABI 过滤配置。iOS 侧则是 Xcode 版本、CocoaPods 版本、最低支持的 iOS 版本、以及是否需要开启 Bitcode。
为什么这么强调对齐?因为 aar 包里通常带着它自己编译时的依赖声明。如果你的工程还在用老的支持库,而 SDK 依赖的是 AndroidX,构建时资源合并会直接报Duplicate class或者Program type already present。我遇到过一次最典型的:工程里android.useAndroidX=false,结果引入 SDK 之后 build 直接崩在 mergeDebugResources 阶段,日志刷了三千行。
判断方法很简单,打开 aar 里的AndroidManifest.xml和classes.jar看一眼它引了什么。命令行一条就够:
unzip -o your-sdk.aar -d sdk_extract cat sdk_extract/AndroidManifest.xml unzip -l sdk_extract/classes.jar | head -50iOS 那边同理,otool -L YourSDK.framework/YourSDK看一下它链接了哪些系统库和第三方库,避免和工程里已有的库版本打架。
提示:版本对齐这件事不要靠猜。把 SDK 文档里给的版本矩阵抄进项目的 README,每次升级 SDK 都对照一遍,比事后查崩因快十倍。
2.2 凭证申请与签名指纹
这是很多人会忽略的一步:SDK 在初始化阶段就会做调用方校验,校验依据就是包名加签名指纹。所以你在写第一行集成代码之前,就得把这两样东西准备好,否则初始化会静默失败,日志里只有一行含糊的"参数错误"。
Android 侧需要的是应用包名和签名证书的 MD5 指纹。取指纹的命令:
keytool -list -v -keystore your_release.jks -alias your_alias -storepass 你的密码输出里找 MD5 那一行,去掉冒号,转成小写,就是平台要的格式。这里有个坑:debug 包和 release 包的签名指纹不一样,测试环境和生产环境要分别申请,或者干脆用同一套配置。我见过团队只申请了 release 的,结果开发同学本地一跑就报授权失败,查了半天。
iOS 侧要的是 Bundle ID,以及如果用到跳转回 App 的能力,还要配 Universal Links 的域名和 apple-app-site-association 文件。这个文件必须放在域名的根路径,Content-Type是application/json,且不能有任何重定向,否则 iOS 直接不认。
服务端凭证是 appKey 和 appSecret,这个只在服务端保存,绝对不要塞进客户端代码里。我之前 review 过一个项目,appSecret 硬编码在 Android 的 BuildConfig 里,反编译一下就能看到,等于把后台接口的钥匙挂在门口。
2.3 依赖仓库与构建环境
如果 SDK 只给了本地 aar,那你需要决定是自己搭个私服还是直接放 libs 目录。放 libs 目录最快,但传递依赖会丢,后面要手动补一堆implementation。用私服的话,把 aar 用maven-publish推上去,依赖关系就能自动解析。
内网环境还要注意仓库地址和代理配置。Gradle 的repositories里如果只写了公司私服,而 SDK 依赖的某个开源库不在私服里,构建会直接超时。稳妥做法是私服放前面,后面兜底加公共仓库。
repositories { maven { url 'https://your-nexus/repository/maven-releases/' } mavenCentral() google() }3. Android 端安装与集成实操
3.1 依赖引入的两种方式及其代价
本地 aar 引入的写法:
android { repositories { flatDir { dirs 'libs' } } } dependencies { implementation(name: 'zjz-sdk-1.0.0', ext: 'aar') // flatDir 不解析 pom,传递依赖需要手动补 implementation 'com.squareup.okhttp3:okhttp:4.9.3' implementation 'com.google.code.gson:gson:2.8.9' }flatDir最大的问题是它不读 pom 文件,也就是说 SDK 里声明依赖的第三方库全部不会自动拉下来,你得自己一个个补。漏一个的后果通常是运行时NoClassDefFoundError,编译期完全看不出问题。所以我更推荐第二条路:把 aar 转成 maven 坐标推到私服。
# 用 maven-publish 插件发布本地 aar 到私服 ./gradlew publishReleasePublicationToMavenRepository发布之后依赖就变成一行干净的坐标,传递依赖自动解析,版本冲突也能用./gradlew app:dependencies查出来。
3.2 ABI 过滤、权限与混淆规则
如果 SDK 带 so 库,先看一下它提供了哪些架构。启动 x86 模拟器调试时如果 SDK 没有 x86 版本,会在System.loadLibrary那里崩掉。解决办法是给模拟器装 ARM 翻译或者直接用真机。
android { defaultConfig { ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' } } }权限方面,SDK 的 Manifest 会自动合并进来一部分,但相机、存储、定位这类危险权限通常要你在业务侧自己申请。合并之后建议打开app/build/intermediates/merged_manifests/看一眼最终结果,确认没有意外的权限被引入。上线前如果被审核问到"为什么申请通讯录权限",你得答得上来。
混淆规则是另一个重灾区。SDK 内部大量使用反射和 JS bridge,类名被裁掉之后回调直接失灵,而且不报错,就是没反应。所以必须保留:
-keep class com.zjz.** { *; } -keepclassmembers class com.zjz.** { *; } -dontwarn com.zjz.** -keep class * extends com.zjz.callback.BaseCallback { *; }注意:不要图省事写
-keep class com.** { *; },那等于没混淆,包体积和安全性都受影响。要精确到 SDK 的包名前缀。
3.3 初始化时序与多进程陷阱
初始化必须放在Application.onCreate里,而且要早于任何一次 SDK 调用。但这里有个隐蔽的坑:如果你的 App 有推送进程、常驻进程,Application.onCreate会被调用多次,SDK 就被重复初始化。有些 SDK 会抛异常,有些会静默覆盖状态,导致主进程的回调收不到消息。
标准做法是先判断进程名:
public class MyApp extends Application { @Override public void onCreate() { super.onCreate(); if (isMainProcess()) { ZjzSdk.init(this, appKey, new InitCallback() { @Override public void onSuccess() { // 初始化完成后才能调免登 } @Override public void onFailure(int code, String msg) { Log.e("ZjzSdk", "init failed: " + code + " " + msg); } }); } } private boolean isMainProcess() { String processName = getProcessName(); return processName != null && processName.equals(getPackageName()); } }getProcessName在不同 Android 版本上实现不一样,Android 9 以上可以用Application.getProcessName(),低版本要读/proc/self/cmdline。这段代码几乎每个项目都要写一遍,建议直接抽成工具类。
初始化回调里有个容易忽略的点:onSuccess之后才能调免登和扫码。如果你在onCreate里初始化完就立刻调免登,大概率拿到的是"SDK 未就绪"。稳妥做法是把初始化完成的信号用一个标志位或者CountDownLatch存起来,业务侧调用前先等这个信号。
4. iOS 端安装与配置实操
4.1 CocoaPods 集成与静态库选择
Podfile 里引入 SDK 通常有两种形式,一种是官方源,一种是本地 podspec 或直接拖 framework。用 Pod 的好处是版本管理和依赖解析都省心。
platform :ios, '12.0' use_frameworks! :linkage => :static target 'YourApp' do pod 'ZjzSDK', '~> 1.0.0' enduse_frameworks!后面的:linkage参数很关键。如果 SDK 是静态库而你用动态链接,会出现符号重复;反过来如果 SDK 内部依赖了动态库而你强制静态,链接会报错。判断方法还是那句otool -L,或者直接问 SDK 提供方要一份标准 Podfile 示例。
国内网络环境下pod install卡在CDN: trunk Repo update是常态,可以临时改成用国内镜像源或者--verbose看卡在哪一步。这一步没有技术含量但很耗时间,建议第一次装的时候就配好。
4.2 白名单、URL Scheme 与关联域名
iOS 的沙箱机制决定了"跳出去再跳回来"这件事必须提前声明。如果要唤起宿主 App 的扫码页,需要在Info.plist里配置LSApplicationQueriesSchemes,把要查询的 scheme 加进去,否则canOpenURL永远返回 false。
<key>LSApplicationQueriesSchemes</key> <array> <string>zjz</string> <string>dingtalk</string> </array>如果要让宿主 App 跳回你的 App,需要在CFBundleURLTypes里注册自己的 scheme,同时如果要走 Universal Links,还得配com.apple.developer.associated-domains权限并在服务端放好关联文件。这两套机制建议都配上,scheme 作为兜底,Universal Links 作为主路径。
回跳的接收在AppDelegate里处理:
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { return ZjzSDK.handleOpenURL(url) }如果是 SceneDelegate 架构,对应的方法要挪到scene(_:openURLContexts:)里。这个迁移坑了很多从老项目升级上来的团队,表现就是回跳没反应,日志也没有。
4.3 初始化与鉴权调用时序
iOS 的初始化和 Android 类似,放在didFinishLaunchingWithOptions里,拿 launchOptions 里的信息做前置处理。时序上的严格程度甚至更高,因为 iOS 的 UIApplication 生命周期更强调顺序。
一个常见问题是初始化返回成功但免登拿不到码返回值。八成是因为你调用的 URL 没有在关联域名里,或者associated-domains权限没开。检查顺序建议是:先看 Xcode 里的 capability 有没有勾上,再看服务端的关联文件能不能直接 curl 到,最后再看签名证书和 Bundle ID 是否和申请时一致。
5. H5 微应用与服务端配套要点
5.1 JSAPI 注入与鉴权签名
H5 微应用不需要"安装"包,容器会在页面加载时注入 JS 桥。你只需要在合适时机调用,但调用前必须先做鉴权签名,这一步是服务端完成的。
服务端的流程是:先用 appKey 和 appSecret 换 access_token,再用 access_token 换 jsapi_ticket,然后把jsapi_ticket、nonceStr、timestamp、当前页面 URL(去掉 # 后面的部分)按字段名排序拼接成字符串,做 SHA1 得到签名。前端拿到签名后调用配置接口,之后才能用其他 JSAPI。
// 前端拿到服务端返回的签名配置后 dd.config({ agentId: 'xxx', corpId: 'xxx', timeStamp: 'xxx', nonceStr: 'xxx', signature: 'xxx', jsApiList: ['runtime.permission.requestAuthCode', 'biz.util.scan'] }); dd.ready(function () { dd.runtime.permission.requestAuthCode({ corpId: 'xxx', onSuccess: function (res) { // 把 res.code 传给自己的服务端换用户身份 } }); });签名失败最常见的原因是 URL 拼接不对。浏览器地址栏里的 URL 和容器实际传给 SDK 的 URL 经常不一致,尤其是带了路由参数或者被 Nginx 重写过的情况。排查方法是在签名接口里把参与签名的原始 URL 打日志,和dd.error回调里返回的 URL 对比,一个字符一个字符地对。
5.2 服务端 token 缓存策略
access_token 有有效期,一般是两小时,且同一个应用多次获取会有频率限制。所以必须缓存,不能每次请求都去换。缓存方案很简单:Redis 存一个 key,值就是 token,过期时间设成比实际有效期少五分钟,避免临界点失效。
import time import redis r = redis.Redis() def get_access_token(app_key, app_secret): cached = r.get(f"zjz:token:{app_key}") if cached: return cached.decode() resp = requests.get(TOKEN_URL, params={ "appkey": app_key, "appsecret": app_secret }).json() token = resp["access_token"] expires = resp.get("expires_in", 7200) r.setex(f"zjz:token:{app_key}", expires - 300, token) return token def get_jsapi_ticket(token): cached = r.get(f"zjz:ticket:{token[:8]}") if cached: return cached.decode() resp = requests.get(TICKET_URL, params={"access_token": token}).json() ticket = resp["ticket"] expires = resp.get("expires_in", 7200) r.setex(f"zjz:ticket:{token[:8]}", expires - 300, ticket) return ticket这里有个细节:换 token 和换 ticket 这两个接口如果并发调用,会互相顶掉对方的旧值,导致后拿到的那个失效。稳妥做法是加一把分布式锁,或者干脆用一个定时任务每 90 分钟刷一次,业务侧只读缓存。
6. 踩过的坑与排查速查表
6.1 构建期常见报错
这张表是我自己记录过的,基本覆盖了构建阶段 90% 的问题。
| 报错信息关键词 | 根因 | 处理方式 |
|---|---|---|
| Duplicate class / Program type already present | 支持库与 AndroidX 混用 | 打开android.useAndroidX=true,全量迁移 |
| Could not find :zjz-sdk: | 仓库地址没配或 aar 没上传 | 检查repositories顺序,确认私服里有这个坐标 |
| No such property for ABI | so 库架构不匹配 | 用abiFilters过滤,或换真机调试 |
| Undefined symbols for architecture arm64 | iOS 库架构缺失 | 确认 xcframework 包含 arm64 切片 |
| Linker command failed with exit code 1 | 静态库与动态库链接方式冲突 | 调整 Podfile 的:linkage参数 |
| Module not found | Pod 没 install 或 Header Search Path 不对 | 重新pod install,检查配置 |
6.2 运行期常见故障
运行期的坑更隐蔽,因为很多失败是静默的。免登回调不触发、扫码返回空、推送收不到,这三类占了我遇到问题的大头。
免登回调不触发,先查初始化是否真的完成。加日志打一下初始化回调,如果压根没回调,说明校验就失败了,多数是签名指纹不对或者包名不匹配。扫码返回空,通常是目标 App 没装或者白名单没配。推送收不到,分两层看:客户端看是否成功注册了推送通道,服务端看推送接口返回码是不是"用户不在可见范围内"。
有个特别坑的现象:debug 包一切正常,release 包一打就全崩。原因几乎肯定是混淆。花十分钟把-keep规则补全,比事后拿着一份线上崩溃日志猜半天强得多。
提示:建议在 SDK 初始化回调里把返回码和描述完整打到日志里,并在测试包里加一个"打印 SDK 版本和当前配置"的调试入口。真出问题时,这两个信息能帮你少走很多弯路。
6.3 排查工具与日志位置
Android 侧,adb logcat过滤 SDK 的 TAG 是最直接的手段。如果 SDK 日志被关掉了,可以用adb shell setprop log.tag.ZjzSdk VERBOSE打开。iOS 侧,Xcode 的 Console 加上设备日志(Xcode 菜单里的 Devices and Simulators)配合看,一些容器层面的日志只在设备日志里有。
网络层建议挂个抓包工具,把免登换码、token 换取这几个请求的入参出参都看一眼。很多时候服务端返回的 JSON 里明明有错误码,只是前端没打出来,白白排查半天。
7. 上线前自检与几句实在话
上线前我会过一遍这张清单,你也可以照着走。包名和签名指纹是否和申请时一致,release 和 debug 是否都验证过;混淆规则是否覆盖 SDK 全部包名前缀,并且测试过 release 包;初始化是否只在主进程执行,多进程场景是否验证;免登、扫码、推送三条主链路是否在真机上完整跑通;服务端 token 缓存是否生效,有没有做并发保护;iOS 的白名单、URL Scheme、关联域名是否三条都配齐。
还有一点值得单独说:SDK 版本升级不要跟得太紧。新版本刚发布时,社区反馈的坑还没出来,你贸然升上去,踩雷的概率远大于收益。我的习惯是等一个小版本,比如从 1.0.0 到 1.0.1 之后再看,同时在自己的测试环境把新旧版本的回调行为对比一遍。
最后说个我觉得最有价值的经验。这套集成的难点其实不在"装",而在"断"。客户端和服务端之间的边界、主进程和子进程之间的边界、宿主和容器之间的边界,三条边界只要有一条没理清,问题就会以极其反直觉的形式冒出来。我在项目里做的一件事是画了一张时序图贴在工位上,把"用户点击扫码"到"服务端拿到用户身份"中间经过的每一个环节和每一次跨进程、跨应用跳转都标出来。后来组里新人接手,靠着这张图两天就定位了一个困扰我们三天的问题。工具和文档会变,这套把链路拆到最细的思路不会变。