Listify 的iCloud云同步功能开发笔记

April 21, 2019
SoftwareiOS

Listify 在2018年7月登陆了 AppStore,受到了很多人的喜爱,在接下来的半年时间中收获了两次App Store的推荐,收费之后也一直停留在
Lifestyle 付费榜前十的样子。随着 Listify
用户变多,很多的用户给我发邮件请求iCloud的同步功能,这个功能我拖了很久很久,因为我很少使用Apple
Cloudkit,第一次实现这样的功能就要放在一个这么多人的项目上,我也是压力山大。在复活节假期的时候终于还是鼓起勇气开始做这个功能,在网上搜了搜相关的文章,真的少之又少,所有的文章都是讲的很基础的东西,比如如何在云上保存一个项目之类的,用在Listify上根本不现实,查了很多官方的文档,结合着开发时候累积的经验,写下这篇笔记。

因为国内的厂商用根本不会去使用 Cloudkit 这样的 BaaS,因为这些公司都会有自己的后端服务器来处理同步,毕竟 Android 端是不能通过
iCloud
同步的,而且Cloudkit中用户上传的数据开发者根本看不见,国内的厂商肯定不允许这样的事情发生。所以Cloudkit在国内是一个很小众的解决方案,Listify
暂时没有 Android 版本的开发计划,再加上 iCloud 同步不需要设计登录,用的就是用户在 iOS 设备上的账号,能省下很多的开发周期。

下图就是Listify的界面,开发云同步的目的就是同步所有的列表和所有的todo到云上,这样我在我的10个iPhone上和20个 iPad
上就都能看到Listify的内容了,是不是很方便。

这篇笔记几乎所有的内容都来自 Apple Cloudkit 的官方文档和 WWDC 期间的讲座 WWDC CloudKit Best
Practices
,有能力的读者可直接去官网上看。还有一些CloudKit的使用基础这里也不多写了,网上有很多。

Cloudkit 介绍

那么要想使用 iCloud,Apple已经提供了相应的 API,名字叫做 CloudKit。开发者可以在 CloudKit
Dashboard上进行数据库的管理。接下来需要讲解一些CloudKit的设计结构。

CloudKit Container

第一个需要知道的概念就是 Container,在iOS 11以后有三种Container,分别是公共的,私有的和共享的。他们的用途也是顾名思义,Public
Container里面存的数据是所有用户都可以访问的,比如我需要开发一个新闻的App,那么新闻的列表和内容一定是存在Public
Container上的。像Listify这样的需要储存用户自己数据的应用,每个用户的数据应该存放在Private
Container里面,这样就类似于每个人在云上都有一个属于自己的数据库,和本地相同。在Private
Container中还可以建立很多Zone,在每一个Zone底下存放的规定的 Record,在Listify 中有两种 Record 类型,分别是 Todo
和 List,对应todo和列表。

基本工作流

下面是基本的同步工作流,之后还会讲一些优化之类的

  1. 创建Zone
  2. 订阅 数据库变化
  3. 在 App 启动的时候获取记录然后显示给用户
  4. 推送用户本地的变化
  5. 周期性执行第三步和第四步 (数据推拉)
  6. 用户最小化应用时(App进入后台)推拉一次
  7. 用户启动App的时候(App进入前台)时推拉一次

订阅(Subscription)

订阅是 iOS CloudKit 同步中非常重要的设计。通过订阅,当 Cloudkit云中的数据库发生改变的时候,Apple
服务器会像其他的设备推送一条远程通知。通过这种实现,其他的设备就不用频繁的向服务器来请求取得新的数据了,当别的设备在云上增添或者删除了条目的时候其他设备自然会收到一条推送的,收到推送后客户端可以发起一次推拉来进行数据更新。通知推送由APNS
(Apple Push Notification service) 进行处理。

那么为什么之前要写到要创建一个新的 Zone 来保存数据呢?因为 Cloudkit Private Container 中自带的 DefaultZone
是不支持订阅功能的。

一下是订阅的部分代码

  1. fileprivate let database = CKContainer.default().privateCloudDatabase
  2. //注册一个静默通知
  3. let predicate = NSPredicate(value: true)
  4. let notification = CKNotificationInfo()
  5. notification.shouldSendContentAvailable = true
  6. let todoSubscription = CKQuerySubscription(recordType: "Todo",
  7. predicate: predicate,
  8. subscriptionID: "todo-subscription",
  9. options: [.firesOnRecordUpdate,
  10. .firesOnRecordCreation,
  11. .firesOnRecordDeletion])
  12. self.database.save(todoSubscription)

要注意的一点是,订阅操作只需要执行一次,一旦创建了订阅之后,用户所有的设备都会被订阅,不需要每台设备都执行这个操作,也不需要每次启动都进行订阅,在第一次启动同步功能的时候执行订阅就行了。我的做法是同步启动更新的时候先查找一下用户的所有订阅,当发现用户没有订阅的时候再新建订阅。

开发的时候需要在Xcode中 启用 Background Mode 中的 Remote notifications ,并且用户的设备上需要允许你的
App 启用通知,否自你的 App 是收不到订阅推送的,即使你的通知是一个不带显示Banner的 Silent Push也不行。并且推 送是不可靠的
,当用户网络状况不佳的时候或者数据库改变但是用户没有联网的时候,推送是不能保证可以到达用户 iPhone 的,所以我还是会定期进行一次推拉。

本地数据库的设计

当然,Listify 一开始是一款纯单机 App,那么背后有一个数据库是必然的,我没有使用CoreData而是使用了我更熟悉的
SQLite。CloudKit的数据规范上也写到,需要有一个本地缓存(Local
Cache)来确保用户离线的时候也能够使用,并且每次同步的时候把本地的所有文件全部推上去很不高效。那么怎么样才能知道用户在上次同步之后又做了哪些修改呢?

为了解决这个问题,我在数据表中添加了一个modify_date 的字段,是一个时间戳,用于表示某个条目的修改时间。并且App中会持久化维护
一个lastSyncTime的值,用来记录上次同步成功的时间。每当有新的todo产生,或者更改的时候,我的数据库模块将会同时更新他们的modify_data为修改时的时间戳,之后当需要推送本地变化的时候,拿lastSyncTime和modify_date进行比对来获得自从上次更新后本地数据库中发生的变化。

从服务器获取改变的数据

Listify
的同步逻辑是先拉取数据再推送数据的,所以先来讲如何从服务器获取改变的数据。一开始我也是遵循着类似的思路,CloudKit上每个Record也有一个Modify_date字段用于表示上次修改的时间,我也可以通过比对这个字段来分析出来云数据库中哪些条目变化了,但是CloudKit其实提供了更加方便的设计。那就是
CKServerChangeToken类
CKServerChangeToken文档

每次从服务器拉取数据之后,服务器会返回一个CKServerChangeToken对象,来表示一个节点,我们需要通过持久化这个对象,在下一次拉取服务器数据的时候将这个Token传给服务器,服务器就会返回相应的需要更新的条目。下图是WWDC讲座中对于Token的讲解,演示了两台设备进行同步的过程。黄圈中的字幕代表一个token。

以下是获取服务器改变数据的代码

  1. //1
  2. let options = CKFetchRecordZoneChangesOptions()
  3. options.previousServerChangeToken = UserDefaults.standard.serverChangeToken
  4. //2
  5. let changesOperation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zone.zoneID],
  6. optionsByRecordZoneID: [zone.zoneID:options])
  7. //3
  8. changesOperation.recordChangedBlock
  9. changesOperation.recordWithIDWasDeletedBlock
  10. changesOperation.recordZoneFetchCompletionBlock
  11. //4
  12. database.add(changesOperation)
  1. 初始化一个CKFetchRecordZoneChangesOptions实例并且将保存的serverChangeToken赋值到previousServerChangeToken,如果是第一次同步则传入nil
  2. 使用CKFetchRecordZoneChangesOperation类来获得更改的数据
  3. 实现三个闭包来回调来得到删除的Record,更改的Record和抓取完完成的回调,在recordZoneFetchCompletionBlock中会得到这次的ServerChangeToken,将这个token持久化。
  4. 添加任务到任务队列中

在CloudKit中有很多类似于CKFetchRecordZoneChangesOperation类的CKOperations,熟悉NSOperation的同学会更容易理解这样的设计,因为CloudKit中的CKOperations就是根据NSOperation来设计的。通过把每一个任务抽象成一个Operations类,在database中维护者一个CKOperations任务队列,通过add方法将Operation加入队列进行执行。对于每一个CKOperations实例,还可以调整他的优先级等等。

大概的拉取逻辑就是以上讲的,但是这里面会产生一个逻辑上的漏洞,如果拉下来的数据没有我本地的数据新怎么办,因为我设计的同步逻辑是先拉再推,那么必定会遇到这个问题,所以当从服务器拉下来数据之后要先和本地数据进行modify_date字段的比对再更新数据库。

推送本地变化的数据

那么接下来要推送本地变化的数据到服务器上,前面的章节已经讲过如何获取本地变化的数据,那么如何将这个数据推送到云端呢。和拉取数据一样,也会用到一个CKOperation。
CKModifyRecordsOperation
类可以解决这个需求。使用套路和CKFetchRecordZoneChangesOperation一样。

CKFetchRecordZoneChangesOperation文档有着很详细的解释,这里不多赘述

  1. //1
  2. let operation = CKModifyRecordsOperation(recordsToSave: recordsToBeUpload, recordIDsToDelete: recordIDsToDelete)
  3. operation.savePolicy = .allKeys
  4. //2
  5. operation.perRecordCompletionBlock = { record, error in}
  6. //3
  7. operation.modifyRecordsCompletionBlock = {savedRecords, deletedRecordIDs, error in}
  8. //4
  9. database.add(operation)
  1. 初始化CKModifyRecordsOperation,传入需要更改的CKRecord和需要删除的CKRecord
  2. perRecordCompletionBlock闭包是每个Record传输完成的回调,假设你需要推送40个Record到服务器上,顺利的话这个闭包会调用40次
  3. modifyRecordsCompletionBlock是Operation结束后的闭包回调

  4. 添加Operation到任务队列

相比很多文章所讲述的database.save(),
CKModifyRecordsOperation在大量Record更改面前明显是个更好的方案。save()函数一次请求只能保存一个CKRecord,并且服务器上
CKRecord 有重复的时候会直接报错。CKModifyRecordsOperation 每次请求最多可以更改或删除最多400个CKRecord。

后台运行

Listify这样的小型应用,推拉同步几乎是一瞬间的事情,在一秒之内就能结束整个同步流程。但是对于需要大量数据同步的应用程序,比如iOS的图库,App能在后台进行同步就成了一个重要的任务。

在App进入后台之后,会触发applicationDidEnterBackground函数,并且App在此时可以访问backgroundTimeRemaining值来知晓
iOS
操作系统Suspended应用程序的时间,对于Listify,这个时间一般是180秒,在这段时间内,App可以进行后台任务,比如进行推拉同步,180秒之后操作系统将会挂起(Suspended)你的应用程序,挂起之后将不能执行任何操作,直到下次App进入前台或者操作系统唤醒你的程序。
详细的请看Apple的官方文档 About the Background Execution
Sequence

通过 UIApplication.shared.beginBackgroundTask(withName: "Finish Network Tasks") {}闭包可以在超时的时候处理一些事务,比如在180秒过后你的App没有完成同步操作,这时就需要执行一些收尾操作。具体可以查看Apple的文档Extending
Your App’s Background Execution
Time

上一段还说道Suspended之后操作系统会有机会唤醒你的App,这个会发生在收到远程推送的时候,或者应用程序开启了 Background App
Refresh
,这使得操作系统会周期性的唤醒App,但是这个唤醒时间是不固定的。
你可以通过setMinimumBackgroundFetchInterval(_:) 函数来设置一个最小的唤醒周期,在appDelegate中实现
application(_:performFetchWithCompletionHandler:)来执行任务,在handler中可以执行一些数据下载操作。

但是需要注意的是,你不可以在performFetchWithCompletionHandler中进行长时间的操作,iOS
操作系统会根据这个handler的执行时间来分配以后你的App的唤醒次数,如果在handler中你花费了大量的时间处理任务,那么之后你的App的唤醒机会将会减少以达到省电的目的。具体可以查看Apple的文档
Updating Your App with Background App
Refresh

异常处理

网络操作的可靠性肯定是比本机操作低很多的,太多的不可靠要素使得异常处理变得异常重要,不过这个还是要看开发者的设计能力了。

效能考量

Listify
在诞生之日起就定义为一个极简的待办事项应用,简约的设计和快速的启动一直是我追求的,在开启了云同步之后,我依旧希望App能够尽可能少的唤醒后台,并且Listify这样的应用也不需要,所以Listify会在启动的时候或进入后台的时候进行一次推拉同步,App
在前台期间每2分钟会进行一次同步。这样做的还有一个顾虑就是,CloudKit是有请求限制的,免费的话每秒钟的请求数有40次,对于我这样的同步设计和我的用户基数,绰绰有余。

尾巴

以上就是 Listify
的iCloud同步设计中的一些经验,不一定都对,以后随着更新可能还会有一些更改。对于真正需要实现iCloud的读者我还是推荐查阅Apple的文档,写的很详细,这篇文章只是为了记录和抛砖引玉。

Comments

July 21, 2018 at 10:52 am

There are no comments

keyboard_arrow_up