Core Data with CloudKit(四)—— 调试、测试、迁移及其他

本文聊一下在开发Core Data with CloudKit项目中常见的一些问题,让大家少走弯路、避免踩坑。

健康笔记是我开发的一个iOS app,主要服务于有长期健康管理需求的人士。健康笔记提供了强大的自定义数据类型功能,可以满足记录生活中绝大多数的健康项目数据的需要。你可以为每个家庭成员创建各自的健康数据记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推广

控制台日志信息

log

一个支持Core Data with CloudKit的项目,控制台输出将常态化地成为上图状态。

每个项目面对的情况不同且信息中的废话较多,因此我仅就可能的信息种类做一下归纳。

正常情况的信息

  • 初始化信息

代码启动后,通常首先出现在控制台的便是NSPersistentCloudKitContainer展示的初始化信息。包括:成功在指定url创建了容器,成功启用了NSCloudKitMirroringDelegate同步响应等。如果是首次运行项目,还会有成功在iCloud上创建了Schema之类的提示。

  • 数据模型迁移信息

如果本地和服务器端的数据模型不一致,会出现迁移提醒。有时即使本地的Core Data模型和iCloud上的模型一致,也会看到类似Skipping migration for 'ANSCKDATABASEMETADATA' because it already has a column named 'ZLASTFETCHDATE'之类的信息,表示无需迁移。

  • 数据同步信息

会详细描述导入、导出的具体的内容,信息比较好理解。应用程序端或服务器端任何数据发生变动都会出现对应的信息。

  • 持久化历史跟踪信息

NSPersistentCloudKitContainer使用持久化历史跟踪来管理导入导出事务,在数据同步信息的左右经常会伴随包含NSPersistentHistoryToken之类的提示。另外类似Ignoring remote change notification because the exporter has already caught up to this transaction: 11 / 11 - <NSSQLCore: 0x7ff73e4053b0>的信息也是持久化历史跟踪产生的,容易让人误以为总有事务没有处理。关于Persistent History Tracking可以阅读我另一篇文章 在 CoreData 中使用持久化历史跟踪

可能的不正常情况的信息

  • 初始化错误

比较常见的有,无法创建或读取sqlite文件产生的本地url错误以及CKContainerID权限问题。如果url指向appGroupContainer,一定要确认appGroupID正确,且app已获得group权限。CKContainerID权限问题通常使用 之前文章 中提到的重置Certificates,Identifiers&Profiles中的配置来解决。

  • 模型迁移错误

正常情况下,Xcode不会让你生成同CloudKitSchema不兼容的ManagedObjectModel,所以多数情况下,都是由于在开发环境下,本地的数据模型和服务器端的数据模型不匹配导致的问题(比如更改了某个属性名称、或者使用了较老的开发版本等)。在确认代码版本正确的情况下,可采取删除本地app,重置CloudKit端开发环境的方法来解决。但如果你的应用程序已经上线,应尽量避免此类问题的发生可能。请考虑后文中的更新数据模型提供的模型迁移策略。

  • 合并冲突

请检查是否设置了正确的合并冲突策略NSMergeByPropertyObjectTrumpMergePolicy?是否从CloudKit控制台对数据做出了错误的修改?如仍处于开发阶段,可采用和上面一样的方式解决。

  • iCloud 账号或网络错误

iCloud没登录,iCloud服务器没响应,iCloud 账号受限等。以上问题多数都是开发人员这端无法解决的。NSPersistentCloudKitContainer会在iCloud账户登录后自动恢复同步。在代码中进行账号状态检查,并提醒用户登录账号。

关闭日志输出

在确认同步功能代码已正常工作的情况下,如无法忍受控制台的信息轰炸,可尝试关闭Core Data with CloudKit的日志输出。调试任何使用Core Data的项目,我都推荐大家为项目添加如下的默认参数:

image-20210810152755744
  • -com.apple.CoreData.ConcurrencyDebug

及时发现由托管对象或上下文线程错误而导致的问题。执行任何可能导致错误的代码时,应用程序会立刻崩溃,帮助在开发阶段清除隐患。启用后,控制台会显示CoreData: annotation: Core Data multi-threading assertions enabled.

  • -com.apple.CoreData.CloudKitDebug

CloudKit调试信息输出级别,从 1 开始,数字越大信息愈详尽

  • -com.apple.CoreData.SQLDebug

CoreData发送到SQLite的实际SQL语句,1——4,数值越大越详细。输出提供的信息在调试性能问题时很有用——特别是它可以告诉你什么时候 Core Data 正在执行大量的小提取(例如当单独填充fault时)。

  • -com.apple.CoreData.MigrationDebug

迁移调试启动参数将使您在控制台中了解迁移数据时的异常情况。

  • -com.apple.CoreData.Logging.stderr

信息输出开关

设置-com.apple.CoreData.Logging.stderr 0,所有的同数据库有关日志信息都将不再输出。

关闭网络同步

在程序开发阶段,我们有时候并不想被数据同步所打扰。增加网络同步控制参数方便提高专注力。

NSPersistentCloudKitContainer载入没有配置cloudKitContainerOptionsNSPersistentStoreDescription时,它的行为同NSPersistentContainer是一致的。通过使用类似下面的代码,可在调试中控制是否启用数据网络同步功能。

let allowCloudKitSync: Bool = {
        let arguments = ProcessInfo.processInfo.arguments
        guard let index = arguments.firstIndex(where: {$0 == "-allowCloudKitSync"}),
              index + 1 < arguments.count - 1 else {return true}
        return arguments[index + 1] == "1"
    }()

if allowCloudKitSync {
            cloudDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.fatobman.blog.SyncDemo")
        } else {
            cloudDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
            cloudDesc.setOption(true as NSNumber,
                                forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        }

因为NSPersistentCloudKitContiner会自动启用持久化历史跟踪的,如没有设置NSPersistentCloudKitContainerOptions,必须在代码中显式启用Persistent History Tracking,否则数据库会变成只读。

image-20210810155946312

设置为0将关闭网络同步。

本地数据库的更改在恢复同步功能后,仍将会同步到服务器端。

同步不正常

当网络同步不正常时,请先尝试做以下检查:

  • 网络连接是否正常
  • 设备是否已登录iCloud账户
  • 同步私有数据库的设备是否登录的是同一个iCloud账号
  • 检查日志,是否有错误提示,尤其是服务器端的
  • 模拟器不支持后台静默推送,将模拟器中的app切换至后台再切换回来,看看是否有数据

如果还是找不到原因的话,请泡壶茶、听听歌、看看远方,过一会可能就好了。

苹果服务器抽风的频率并不低,推送延迟不必惊讶。

检查用户账户状态

NSPersistentCloudKitContainer会在iCloud账号可用时自动恢复网络同步。通过代码检查用户的iCloud账户登录情况,在应用程序中提醒用户进行账户登录。

调用CKContainer.default().accountStatus检查用户iCloud账号状态,订阅CKAccountChanged,在登录成功后取消提醒。譬如

    func checkAccountStatus() {
        CKContainer.default().accountStatus { status, error in
          DispatchQueue.main.async {
            switch status {
            case .available:
               accountAvailable = true
            default:
               accountAvailable = false
            }
            if let error = error {
                print(error)
            }
          }
        }
    }

检查网络同步状态

CloudKit没有提供详尽的网络同步状态API,开发者无法获得例如有多少数据需要同步、同步进度等信息。

NSPersistentCloudKitContainer提供了一个eventChangedNotification通知,该通知将在importexportsetup三种状态切换时提醒我们。严格意义上,我们很难仅通过切换通知来判断当前同步的实际状态。

在实际的使用中,对用户感知影响最大的是数据导入状态。当用户在新设备上安装了应用程序,并且已经在网络上保存有较多数据时,面对完全没有数据的应用程序用户会感到很茫然。

数据会在应用程序启动后 20-30 秒开始导入,如果数据量较大,用户很可能会在 1-2 分钟后才会在 UI 上看到数据(批量导入通常会在整批数据都导入后才会merge到上下文中)。因此为用户提供足够的提示尤为重要。

在实际使用中,当导入状态结束后,会切换到其他的状态。利用类似如下的代码,尝试给用户提供一点提示。

@State private var importing = false
@State private var publisher = NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)

var body:some View{
  VStack{
     if importing {
        ProgressView()
     }
  }
  .onReceive(publisher){ notification in
     if let userInfo = notification.userInfo {
        if let event = userInfo["event"] as? NSPersistentCloudKitContainer.Event {
            if event.type == .import {
              importing = true
            }
            else {
              importing = false
            }
         }
      }
   }  
}

当应用程序被切到后台时,同步任务仅能继续执行 30 秒左右,在切换回前台后数据会继续进行同步。因此当数据较多时,需做好用户的提示工作(比如保持在前台,或让用户继续等待)。

创建默认数据集

有的应用程序会为用户提供一些默认的数据,比如说起始数据集,或者演示数据集。如果提供的数据集是放置在可同步的数据库中时需要谨慎处理。比如,已经在一台设备上创建了默认数据集并进行了修改,当在新设备上再次安装并运行应用程序时,处理不当可能导致数据被异常覆盖,或者重复。

  • 确认数据集是否一定需要被同步

如无需同步可以考虑采用 同步本地数据库到 iCloud 私有数据库 一文中,有选择的同步数据解决方案。

  • 如数据集必须要同步
  1. 最好引导用户手动点击创建默认数据按钮,让用户自行判断是否需要再度创建。
  1. 也可在应用程序首次运行时,利用CKQuerySubscription通过查询特定记录判断网络数据库中是否已有数据(此方法是在前几天和一个网友交流时他采用的方法,不过该网友对返回响应并不满意,用户感知不太好)。
    1. 或许可考虑通过使用NSUbiquitousKeyValueStore进行判断。

2、3 两种方式都需要保证网络及账号状态正常的情况下才能检查,让用户自行判断或许最为简单。

移动本地数据库

已经在AppStore上架的应用程序,在某些情况下有移动本地数据库到其他URL的需求。比如,为了让Widget也可以访问数据库,我将 健康笔记 的数据库移动到了appGroupContainerURL

如果使用NSPersistentContainer,可以直接调用coordinator.migratePersistentStore即可安全完成数据库文件的位置转移。但如果对NSPersistentCloudKitContainer加载的store调用此方法,则必须强制退出应用程序后再次进入方可正常使用(虽然数据库文件被转移,但迁移后会告知加载CloudKit container错误,无法进行同步。需重启应用程序才能正常同步)。

因此正确的移动方案是,在创建container之前,采用FileManager将数据库文件移动到新位置。需同时移动sqlitesqlite-walsqlite-shm三个文件。

类似如下代码:

func migrateStore() {
        let fm = FileManager.default
        guard !FileManager.default.fileExists(atPath: groupStoreURL.path) else {
            return
        }

        guard FileManager.default.fileExists(atPath: originalStoreURL.path) else {
            return
        }

        let walFileURL = originalStoreURL.deletingPathExtension().appendingPathExtension("sqlite-wal")
        let shmFileURL = originalStoreURL.deletingPathExtension().appendingPathExtension("sqlite-shm")
        let originalFileURLs = [originalStoreURL, walFileURL, shmFileURL]
        let targetFileURLs = originalFileURLs.map {
            groupStoreURL
                .deletingLastPathComponent()
                .appendingPathComponent($0.lastPathComponent)
        }

        // 将原始文件移动到新的位置。
        zip(originalFileURLs, targetFileURLs).forEach { originalURL, targetURL in
            do {
                try fm.moveItem(at: originalURL, to: targetURL)
            } catch error {
                print(error)
            }
        }
}

更新数据模型

CloudKit 仪表台 一文,我们已经探讨过CloudKit的两种环境设置。一旦将Schema部署到生产环境,开发者便无法对记录类型和字段进行重命名或者删除。必须仔细规划你的应用程序,保证其在对数据模型进行更新时仍做到向前兼容

不可以随心所欲地修改数据模型,对实体、属性尽量做到:只加、不减、不改。

可以考虑以下的模型更新策略:

增量更新

以增量的方式添加记录类型或向现有记录类型添加新字段。

采用这种方式,旧版本的应用程序仍可以访问用户创建的记录,但不是每个字段。

请确保新增的属性或实体都只服务于新版本的新功能,且即使没有这些数据,新版本程序仍可可正常运行(如此时用户仍使用旧版本更新数据,新添加的实体和属性都不会有内容)。

增加 version 属性

这个策略是上一个策略的加强版。通过一开始在实体上添加version属性,对实体进行版本控制,通过谓词仅提取与应用程序当前版本兼容的记录。旧版本程序将不会提取新版本创建的数据。

例如,实体Post具备version属性

// 当前的数据版本。
let maxCompatibleVersion = 3

context.performAndWait {
    let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Post")
    
    // 提取不大于当前版本的数据
    fetchRequest.predicate = NSPredicate(
        format: "version <= %d",
        argumentArray: [maxCompatibleVersion]
    )
    
    let results = context.fetch(fetchRequest)
}

锁定数据,提示升级

利用version属性,应用程序可以很容易知道当前的版本已经不满足数据模型的需要。它可以禁止用户修改数据,并提示用户更新应用程序版本。

创建新 CKContainer 及新的本地存储

如果你的数据模型发生了巨大的变化,采用上述方式已经很难处理,或者上述方式会造成巨大的数据浪费时,可以为应用程序添加一个新的关联容器,并通过代码将原始数据转移到新容器上。

大概的流程为:

  • 在应用程序中添加新的xcdatamodeld(此时应该有两个模型,旧模型对应旧容器,新模型对应新容器)
  • 为应用程序添加新的关联容器(同时使用两个容器)
  • 判断是否已经迁移,如果没有迁移则让应用程序通过旧模型和容器正常运行
  • 让用户选择迁移数据(提醒用户须确保旧数据都已经同步到本地再执行迁移)
  • 通过代码将旧数据转移到新容器和本地存储中,标记迁移完成(使用两个NSPersistentCloudKitContainer
  • 切换数据源

无论采用上述哪种策略,都应该不计一切代价避免数据丢失、混乱。

总结

本文中的问题,是我在开发过程中碰到并已尝试解决的。其他的开发者还会碰到更多的未知情况,只要能掌握其规律,总是可以找到解决之法。

在下一篇文章中,我们聊一下同步公共数据库

本博客所有文章除特别声明外,均采用CC 4.0许可协议。转载请注明出处和作者。

关注微信公共号肘子的Swift记事本或在推特上关注@fatbobman,永远不会错过新内容! 您的支持和鼓励将为我的博客写作增添更多的动力! 如果您或身边的朋友有健康数据管理的需求,请使用我开发的app【健康笔记】,正是因为它我才创建了这个博客。

关注