14、联系人同步:SyncAdapter架构、Account与Authenticator、同步周期设置、处理同步冲突

说到联系人同步,很多开发者的第一反应就是「调个接口上传下载不就完了?」。嗯,如果你只是做个Demo,确实可以这么干。但一旦进入生产环境,你会发现事情远没那么简单——后台被系统杀掉怎么办?多账户怎么隔离?用户改了本地数据又改了服务器数据,到底听谁的?

Android官方其实早就为我们准备了一套完整的同步框架:SyncAdapter。这套东西说实话有点重,但一旦你理解了它的设计思路,就会发现它把同步这件事的「脏活累活」全包了。我个人习惯把它比作一个「同步管家」——你只需要告诉它什么时候该干活、怎么干活,剩下的调度、重试、电量优化,系统帮你搞定。

核心要点:SyncAdapter 不是让你直接写网络请求的,而是让你实现一个 ContentProvider 的同步扩展。它运行在独立的进程中,不会阻塞 UI,并且系统会根据网络状态、电量、用户设置来智能调度。

14.1 SyncAdapter 架构:谁在管谁?

先看一张整体架构图,我尽量画得直观一些。你不需要记住所有细节,但要知道每个模块的职责。

SyncAdapter 架构总览 系统 SyncManager 调度、重试、电量管理 触发 你的 SyncAdapter onPerformSync() 实现 读写 ContentProvider 联系人数据库 AccountManager 管理账户 & 令牌 绑定 Authenticator 身份验证 & 授权 同步周期设置方式 ① 自动同步:addPeriodicSync() ② 手动触发:requestSync() ③ 自适应:SyncResult 系统会合并多个周期请求,最小间隔通常为15分钟

这张图里,SyncManager 是系统级的调度中心。它不关心你的业务逻辑,只负责在合适的时间调用你的 onPerformSync()。你的 SyncAdapter 则通过 ContentProvider 读写本地数据,并通过网络与服务器交互。AccountManager 和 Authenticator 负责账户的创建与认证——说白了,就是告诉系统「这个用户是谁,他有没有权限同步」。

我的经验:刚开始接触 SyncAdapter 时,我犯过一个低级错误——直接在 onPerformSync() 里做了网络请求,但没有检查网络状态。结果用户在飞行模式下,同步任务被反复触发,导致大量失败日志。后来我养成了习惯:在同步方法第一行就检查 ConnectivityManager,没有网络就直接返回,并设置 SyncResult 的重试标志。

14.2 Account 与 Authenticator:没有账户,同步个啥?

Android 的同步机制是「账户驱动」的。你想想看,如果系统不知道数据属于哪个用户,它怎么知道该同步到哪个服务器?所以,第一步是创建一个 Account 类型。

我通常会在 authenticator.xml 里定义账户类型,比如 com.example.contacts。然后实现一个 AbstractAccountAuthenticator 的子类。这里有个坑:Authenticator 的 addAccount() 方法 并不是真的去注册一个新用户,而是返回一个 Bundle,告诉系统「我已经处理了,你调用我的 Activity 去让用户输入账号密码吧」。

// 示例:Authenticator 的核心骨架
public class ContactAuthenticator extends AbstractAccountAuthenticator {

    public ContactAuthenticator(Context context) {
        super(context);
    }

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response,
                             String accountType, String authTokenType,
                             String[] requiredFeatures, Bundle options) {
        // 这里启动一个 Activity 让用户输入账号密码
        Intent intent = new Intent(context, LoginActivity.class);
        intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
        Bundle bundle = new Bundle();
        bundle.putParcelable(AccountManager.KEY_INTENT, intent);
        return bundle;
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response,
                               Account account, String authTokenType,
                               Bundle options) {
        // 从 AccountManager 获取缓存的 token,如果没有则重新登录
        // 我曾经在这里踩过坑:没有处理 token 过期的情况
        // 导致用户改了密码后,旧 token 一直用不了
        return null; // 实际需要完整实现
    }
    // 其他方法:hasFeatures()、editProperties() 等
}

账户注册完成后,系统会在 Settings -> Accounts 里看到你的账户类型。用户点击「同步」按钮时,系统就会调用你的 SyncAdapter。

注意:Android 8.0 之后,系统对后台同步的限制越来越严。如果你的应用没有在前台运行,系统可能会延迟或合并同步请求。我曾经在适配 Android 12 时发现,addPeriodicSync() 的最小间隔被强制限制为 15 分钟,即使你设置成 5 分钟也没用。所以,别指望用同步来做实时推送。

14.3 同步周期设置:别让用户骂你耗电

同步周期的设置,说白了就是「多久拉一次数据」。但这里有个平衡:拉得太频繁,用户骂你耗电;拉得太慢,数据不一致。我个人习惯的做法是:

  • 首次安装:立即触发一次全量同步,用 ContentResolver.requestSync()
  • 日常同步:使用 ContentResolver.addPeriodicSync(),间隔设为 1 小时。
  • 用户手动下拉刷新:再次调用 requestSync(),并传入一个 SyncRequest.Builder 设置 syncExtrastrue,表示这是用户主动触发的。
// 设置周期性同步
Account account = new Account("user@example.com", "com.example.contacts");
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1);
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true);
ContentResolver.addPeriodicSync(
    account,
    ContactsContract.AUTHORITY,
    Bundle.EMPTY,
    3600L // 单位:秒,即1小时
);

这里有个细节:setSyncAutomatically() 必须设为 true,否则系统不会自动执行周期性同步。我曾经在一个项目里忘了这行代码,结果用户反馈「联系人从来不更新」,排查了半天才发现是这里的问题。

避坑指南:如果你需要支持「仅WiFi下同步」,可以在 SyncAdapter 的 onPerformSync() 里判断 ConnectivityManager 的网络类型。不要依赖系统提供的 setSyncable() 参数,因为不同厂商的定制系统对这个参数的处理不一致。我遇到过某款国产手机,即使设置了 SYNC_EXTRAS_REQUIRE_CHARGING,它依然在电量 20% 时执行同步。

14.4 处理同步冲突:到底听谁的?

同步冲突是联系人系统里最头疼的问题。用户可能在手机上修改了张三的电话号码,同时服务器上也有人修改了同一个联系人的邮箱。两边都变了,同步时该以谁为准?

常见的策略有三种:

策略 说明 适用场景
最后写入者胜出 比较时间戳,谁晚听谁的 简单场景,但依赖时钟同步
服务器优先 始终以服务器数据为准 企业级应用,数据由管理员维护
客户端优先 始终以本地修改为准 个人通讯录,用户本地操作频繁
合并+标记 保留双方修改,标记为「待确认」 需要用户手动裁决的场景

我个人比较推荐「最后写入者胜出 + 版本号校验」的组合。具体做法是:

  1. 每条联系人记录在本地和服务器都维护一个 version 字段(整数)。
  2. 同步时,客户端把本地的 version 发给服务器。
  3. 服务器比较 version:如果客户端的 version 小于服务器当前 version,说明服务器有更新,以服务器为准;反之则以客户端为准。
  4. 如果 version 相同但数据不同,说明两边同时修改了同一条记录——这时候我习惯用「时间戳」做二次判断,但时间戳不可靠时,就标记为冲突,让用户下次打开应用时手动选择。
// 冲突检测伪代码
public SyncResult resolveConflict(Contact local, Contact remote) {
    if (local.version > remote.version) {
        // 本地更新,上传本地数据
        uploadContact(local);
        return SyncResult.SYNC_UPLOAD;
    } else if (local.version < remote.version) {
        // 服务器更新,下载并覆盖本地
        updateLocalContact(remote);
        return SyncResult.SYNC_DOWNLOAD;
    } else {
        // version 相同,但数据不同 → 冲突
        if (local.lastModified > remote.lastModified) {
            uploadContact(local);
            return SyncResult.SYNC_UPLOAD;
        } else {
            updateLocalContact(remote);
            return SyncResult.SYNC_DOWNLOAD;
        }
    }
}

我的教训:早期我天真地只用时间戳来判断冲突,结果用户手机时间设置错误(比如跨时区没调),导致同步数据来回覆盖。后来我强制要求服务器返回一个单调递增的 version,客户端每次同步后更新本地 version。时间戳只作为辅助参考,不再作为唯一依据。这个改动之后,冲突率下降了 90%。

14.5 总结一下

SyncAdapter 这套框架,学习曲线确实有点陡。但一旦你掌握了它的套路,后续的维护成本会非常低。你不需要自己写 Service、不需要管理线程池、不需要担心系统杀掉进程——这些 SyncManager 都帮你做了。

最后提醒一句:别忘了在 AndroidManifest.xml 里声明你的 SyncAdapter 和 Authenticator,并且加上 android:exported="false"。我曾经见过有人忘了声明 <sync-adapter> 标签,结果同步服务死活不启动,查了两天才发现是清单文件的问题。

好了,这一章的内容就到这里。如果你在实际开发中遇到什么奇怪的同步问题,欢迎来公众号找我聊聊。

公众号:蓝海资料掘金营,微信 deep3321