第4章 双端共用核心模块设计(数据模型、常量定义、工具类)

双端 SDK 共用核心模块,说白了就是让 Android 和 iOS 两端「说同一种方言」。我的经验是,很多项目一开始各写各的,等联调时才发现字段名对不上、状态码含义不同,改起来比重新写还痛苦。

这一章我带你梳理哪些东西该抽成共用模块,怎么设计才能让双端维护成本最低。

4.1 数据模型——统一协议的第一道坎

数据模型是网络通信的骨架。两端解析同一个 JSON 时,字段名、类型、嵌套结构必须完全一致。

举个实际例子:用户信息模型,在 Android 端用 Kotlin data class,iOS 端用 Swift struct,但 JSON 结构必须长一样。

// Android (Kotlin)
data class UserProfile(
    val userId: String,
    val nickname: String,
    val avatarUrl: String,
    val level: Int,
    val vipExpireTime: Long?
)

// iOS (Swift)
struct UserProfile: Codable {
    let userId: String
    let nickname: String
    let avatarUrl: String
    let level: Int
    let vipExpireTime: Int64? // 注意类型对应
}

我在项目中踩过一个坑:数字类型的精度丢失。服务端返回的 `userId` 是 Int64 类型,Android 端用 `Long` 没问题,但 iOS 端默认用 `Int`(32位设备上最大只有 2^31-1),直接解析就溢出了。后来我们约定:所有可能超过 2^31 的整数统一用 String 传输,两端各自转。

避坑指南:我曾经忽略过空值处理。服务端某个字段可能返回 null,Android 用 `?` 可空类型,iOS 用 `Optional` 没问题,但如果你用 `Int?` 和 `Int` 不对应,两端解析结果会不同。建议共用字段原则:服务端返回的字段全部视为可空,模型中统一带 Optional。

4.1.1 序列化/反序列化层

两端虽然语言不同,但序列化框架可以选配对方案。我个人习惯这么用:

能力Android 端iOS 端注意事项
JSON 解析Moshi / GsonCodable(原生)字段命名策略要一致(默认蛇形 vs 驼峰)
日期格式@JsonAdapter(DateAdapter)自定义 DateFormatter统一使用 ISO8601 毫秒时间戳
枚举处理@Json(name = "xxx")CodingKeys保持枚举值字符串一致

你想想看,如果服务端字段是 `create_time`,Android 用 `createTime` 映射,iOS 也用了 `createTime`,但 Moshi 默认不转换蛇形,你得手动加注解。所以我们内部协议直接约定:所有字段名使用驼峰,JSON key 也强制驼峰,这样两端都能省掉映射配置。

4.2 常量定义——把「魔法数字」关进笼子

常量最大的用处是减少硬编码。双端共用常量通常包括:错误码、事件类型、键名(SharedPreferences / UserDefaults 的 key)、通知名称、API 版本号。

我的建议:准备一个共用常量文档(或者直接用代码仓库里的 Markdown 文件),每新增一个常量都通知对方。我在团队里用了一个简单的脚本,从 Kotlin 文件里提取常量生成 Swift 代码模板,避免手写不一致。

4.2.1 枚举 vs 静态常量

通常情况下,有穷集合用枚举(比如错误码、状态),字符串/数字用静态常量(比如 SharedPreferences key)。

// Android
object AppConstants {
    const val PREFS_KEY_USER_ = "user_"
    const val PREFS_KEY_USER_ID = "user_id"
}

enum class ErrorCode(val code: Int, val message: String) {
    NETWORK_ERROR(1001, "网络异常"),
    AUTH_EXPIRED(1002, "登录过期"),
    UNKNOWN(9999, "未知错误")
}

// iOS
struct AppConstants {
    static let prefsKeyUserToken = "user_"
    static let prefsKeyUserId = "user_id"
}

enum ErrorCode: String {
    case networkError = "NETWORK_ERROR"
    case authExpired = "AUTH_EXPIRED"
    case unknown = "UNKNOWN"
}

这里有个细节:枚举值映射。Android 的枚举可以带 Int 和 String,iOS 的枚举底层是 Int 或 String。如果你想让两端能直接通过枚举值通信,我建议统一用 String 枚举,JSON 里直接传字符串”NETWORK_ERROR”,两端都能解析。

4.2.2 平台差异常量处理

有些常量在两端值不同,但语义相同。比如:页面跳转的 intent action / URL scheme。Android 用 intent 传递,iOS 用 URL scheme,但路径名可以统一。

场景Android 端常量iOS 端常量
跳转个人中心"app://user/profile""myapp://user/profile"
分享图片路径Environment.getExternalStorageDirectory()NSTemporaryDirectory()

这种情况下,我们会在共用文档里写清楚:双端各自实现自己的常量文件,但外部暴露的协议/路径字符串要一致。比如跳转 URL 统一用 `"scheme://user/profile"`,具体 scheme 在各自配置里。

记一个经验:我曾经把 Android 的 `SharedPreferences` key 设计成 `"pref_user_"`,iOS 端写成 `"userToken"`,结果升级时因为 key 不统一导致用户登录态丢失。后来我们定下规则:所有持久化 key 必须两端同名,且加上模块前缀,比如 `"user."`。

4.3 工具类——让公共能力「一次编写,双端复用」

工具类是「底层能力」,比如日志、网络判断、加密、文件操作。双端虽然不能代码复用,但接口和功能行为必须对齐。

4.3.1 日志工具类

日志打印是调试的命脉。我见过最痛的是:Android 用 Logcat 的 tag 格式 `[TAG] message`,iOS 用 `NSLog` 输出 `[时间] [进程] message`,两边日志格式不统一,排查时根本对不上。所以我们自己封装了一个统一日志工具:

// 两端约定输出格式: [LEVEL] [MODULE] message
// Android 内部调用 android.util.Log
class UnifiedLogger {
    companion object {
        fun d(module: String, message: String) {
            Log.d("SDK_$module", message)
        }
    }
}

// iOS 内部调用 os_log
struct UnifiedLogger {
    static func debug(module: String, message: String) {
        os_log("[DEBUG] [%@] %@", module, message)
    }
}

这样不管用哪个平台,日志过滤时搜索 `[DEBUG]` 就能同时看到两端输出。

4.3.2 网络状态判断

SDK 经常需要检测网络状态。两端的 API 完全不同:Android 用 `ConnectivityManager`,iOS 用 `NWPathMonitor`。但给外部使用的接口可以统一:

  • isNetworkAvailable() -> Boolean
  • isWifi() -> Boolean
  • getNetworkType() -> String (返回 "WIFI", "4G", "5G", "NONE")

内部实现各自调用平台 API,上层业务代码不用感知。我在项目中就是用这种「抽象接口 + 平台适配器」模式,切换网络监测时只改工具类内部,不影响业务代码。

4.3.3 加密与哈希

SDK 通常需要对 或关键数据进行加密。双端算法的参数必须一致:算法(如 AES-GCM)、密钥长度、IV 生成规则、编码方式(Base64)。

能力Android 端iOS 端
AES-GCM 加密Cipher.getInstance("AES/GCM/NoPadding")CryptoKit.AES.GCM
SHA256 哈希MessageDigest.getInstance("SHA-256")SHA256.hash(data:)
Base64 编解码Base64.getEncoder()Data.base64EncodedString()
记一个坑:Android 的 Cipher 默认使用 PKCS5Padding,但 GCM 模式只能用 NoPadding,如果你用错了会抛异常。iOS 端 CryptoKit 的 GCM 默认是 NoPadding,两端一致。但如果你在 Android 端写成了 "AES/GCM/PKCS5Padding",编译不报错,运行时抛异常。所以测试时一定要跑一遍双端加密/解密互通信例。

4.3.4 线程切换工具

SDK 内部很多异步操作需要在主线程回调结果。两端的线程模型不同:

  • Android:Handler(Looper.getMainLooper()) 或 runOnUiThread
  • iOS:DispatchQueue.main.async

我们统一暴露接口:runOnMainThread(Runnable),各自实现。注意 iOS 端如果当前已经在主线程,再 `async` 也不会死锁,但 Android 端如果在主线程调用 `Handler.post` 会等到当前消息队列处理完才执行,导致回调延迟。我的做法:在 Android 端先判断当前线程,如果是主线程直接同步调用,否则 post。

// Android 示例
fun runOnMainThread(action: () -> Unit) {
    if (Looper.myLooper() == Looper.getMainLooper()) {
        action()
    } else {
        mainHandler.post { action() }
    }
}

4.4 知识体系图

下面这张图概括了本章的核心内容:共用核心模块的分层结构,以及各模块之间的依赖关系。

双端共用核心模块知识体系 共用核心模块 Android / iOS 共用接口 数据模型 常量定义 工具类 JSON 结构对齐 序列化/反序列化 可空类型 & 精度 枚举 / 静态常量 平台差异 & 命名规范 持久化 Key 一致性 统一日志 网络状态判断 加密 & 哈希 线程切换

图中把「共用核心模块」放在中心位置,左边是数据模型,右边是工具类,下方是常量定义。每个模块再展开原子能力。你搭建 SDK 时,建议先画类似的结构图,再逐项填充代码。