如何为 Crashlytics 添加自定义日志和用户标识?

解读

国内面试中,Crashlytics 通常与 Firebase 强绑定,面试官想确认两点:

  1. 你是否真的在灰度/线上环境用过 Crashlytics,而不是“只集成过 SDK”;
  2. 你对“可观测性”是否有体系化思维——日志、用户、上下文、性能、隐私合规能否一把抓。
    因此,回答要给出“代码 + 时机 + 场景 + 合规”四维闭环,而不是背文档。

知识点

  1. Firebase Crashlytics 自定义日志接口:
    • FirebaseCrashlytics.getInstance().log(String)
    • recordException(Throwable) / recordException(Throwable, Attributes)
  2. 用户标识接口:
    • setUserId(String)
    • setCustomKey(String, String|int|long|float|double|boolean)
  3. 日志上限与采样策略:64 KB/会话,循环覆盖;高频写入需自行降采样。
  4. 合规要求:
    • 国内《个人信息保护法》要求“用户可撤回”,故需封装“退出登录即清除”逻辑。
    • 若用户未同意《隐私政策》,禁止调用 setUserId 与含 PII 的 setCustomKey
  5. 性能与稳定性:
    • 日志写入走 native 层环形缓冲区,主线程安全;但仍建议放后台线程,避免极端场景锁竞争。
    • 应用多进程时,Crashlytics 默认只在主进程初始化,需手动在 :push:download 等子进程调用 FirebaseApp.initializeApp,否则日志会丢失。
  6. 国内厂商兼容:
    • 华为、荣耀无 GMS,需降级为“自采集 + 后端转发”方案,或切换为腾讯 Bugly、阿里 SLS 终端 SDK,但接口设计可保持一致,方便抽象层切换。

答案

步骤一:在 Application.onCreate() 中初始化,并做合规判断

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (PrivacyManager.userAcceptedPrivacyPolicy()) {
            FirebaseApp.initializeApp(this)   // 多进程场景必调
            val crashlytics = FirebaseCrashlytics.getInstance()
            crashlytics.setCrashlyticsCollectionEnabled(true)
        }
    }
}

步骤二:封装统一日志门面,支持“延迟写入”与“隐私开关”

object CrashlyticsLogger {
    private const val MAX_LOG_LEN = 800   // 预留中文双字节安全余量
    private val buffer = StringBuilder()

    @AnyThread
    fun log(message: String) {
        if (!PrivacyManager.userAcceptedPrivacyPolicy()) return
        val ts = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.CHINA).format(Date())
        val line = "$ts ${message.takeLast(MAX_LOG_LEN)}\n"
        synchronized(buffer) {
            buffer.append(line)
            if (buffer.length > 56 * 1024) {          // 提前触发裁剪,留 8 KB 余量
                buffer.delete(0, buffer.length / 4)
            }
        }
        // 异步落盘,避免 ANR
        GlobalScope.launch(Dispatchers.IO) {
            FirebaseCrashlytics.getInstance().log(line)
        }
    }

    fun setUserId(userId: String) {
        if (!PrivacyManager.userAcceptedPrivacyPolicy()) return
        FirebaseCrashlytics.getInstance().setUserId(userId.md5()) // 脱敏
    }

    inline fun setCustomKeys(block: FirebaseCrashlytics.() -> Unit) {
        if (!PrivacyManager.userAcceptedPrivacyPolicy()) return
        FirebaseCrashlytics.getInstance().apply(block)
    }
}

步骤三:在关键业务节点埋点

// 登录成功
CrashlyticsLogger.setUserId(userId)
CrashlyticsLogger.setCustomKeys {
    setCustomKey("channel", BuildConfig.CHANNEL)
    setCustomKey("api_level", Build.VERSION.SDK_INT)
}

// 支付异常
try {
    payCore.charge(order)
} catch (e: PayException) {
    CrashlyticsLogger.log("Pay failed, order=$order, code=${e.code}")
    FirebaseCrashlytics.getInstance().recordException(e)
}

步骤四:退出登录或撤回隐私授权时立即清理

fun logout() {
    PrivacyManager.revokeConsent()
    FirebaseCrashlytics.getInstance().setUserId("")
    FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false)
}

通过以上四步,既满足 Crashlytics 官方最佳实践,也符合国内隐私合规要求,同时把日志量、线程安全、多进程、用户撤回等细节全部收口。

拓展思考

  1. 日志分级与采样:
    线上高并发接口可能每秒上万次调用,全量写入会冲掉关键日志。可引入“令牌桶”限流,仅 1% 采样,但异常 case 强制全量。
  2. 与APM融合:
    把 Crashlytics 日志同时写入字节跳动 RangersAPM 或腾讯 Matrix,做“崩溃+性能”联动,比如检测到卡顿后 3s 内发生崩溃,自动把最近 100 条日志上传到自建 SLS,方便后台还原用户路径。
  3. 端到端加密:
    若日志含订单号、手机号,可在 Java 层做 AES-GCM 加密,密钥放在 TEE,通过 HAL 接口获取;Crashlytics 仅存储密文,后台解密,防止 Google 服务器或第三方 CDN 泄露数据。
  4. 灰度回捞:
    国内厂商 ROM 会杀后台,导致 Crashlytics 上传失败。可在下次冷启时通过 WorkManager 触发“回捞任务”,把 .gz 压缩包上传至国内 CDN,再转发到 Firebase,提升 15% 上报率。