肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

SwiftData初探

SwiftData

没想到我还是入了SwiftData的坑。没想到坑还挺深。没想到苹果连最基本的Xcode的模版应用都没有做好。没想到连苹果的文档说明都不全。

最近我开了一个新坑,是一个背单词的小应用。因为是新应用,而且需要转换词库文件从json到数据库,于是选择了SwiftData作为中介。问题由此而来。

按照惯例,我本以为新应用使用SwiftData会很简单。只需要在应用创建时,选择SwiftData,然后选中使用iCloud同步,最后选择创建应用就可以了。

结果是不行。

这么创建的其实只包括本地的部分以及使用iCloud的部分配置。效果是并没能真正开启iCloud同步。

苹果做了什么

苹果创建了模型,但是模型不符合CloudKit的规范

众所周知,苹果程序模版创建的模型是一个叫Item的类。在Core Data时,它创建的与iCloud同步的模版应用是可用的。但是在SwiftData时,模版创建的应用实际上iCloud的同步不可用。

https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices

如同上面的链接所说,iCloud因为不定期同步的关系。它必须要求所有属性要么有默认值,要么是Optional。(这一点与Core Data一致)

但是苹果模版程序创建的模版Item的属性却是Date,而非Date?

修改了这个问题之后,我们再根据Xcode的提示修改相关的几处代码,直到Xcode可以编译通过。

苹果创建了部分CloudKit同步的设置,但是设置不全

这里的问题就更大了。因为文档说得也不够全面。根据上面链接的文档,SwiftData同步需要3步:

  1. 获取iCloud权限,激活CloudKit,创建一个或选择一个已有的Container。
  2. 设置后台运行模式,激活远程通知选项。
  3. 修改ModelConfiguration()设置。

下面我来依次说说这三点里哪些苹果没做到,哪些苹果太啰嗦,哪些又是苹果所遗漏的。

苹果没做到

第一条:获取iCloud权限,激活CloudKit,创建一个或选择一个已有的Container。
苹果做了前两项,然后没做第三项。我们需要自己创建或者选择一个已有的容器。

苹果太啰嗦

第三条:修改ModelConfiguration()设置。
苹果针对这条的讲解没有错。但是太过啰嗦。苹果的示例是手工添加一个私有的数据库用于同步。但是其实如果你只使用一个数据库,那么就没必要这么做。因为SwiftData默认就是使用你选择的那个唯一的数据库。

苹果所遗漏的

做完了以上的那些。你会发先你的数据库内容仍旧没有同步。这是为什么呢?这就是苹果所遗漏的。同步数据库我们需要什么?需要上网啊。因此,你必须还要打开网络功能的Client权限。所谓Client权限就是你的应用访问外部的权限。如果你的应用还提供服务供其他设备访问,你还需要打开Server权限。

好了。经过以上的补救措施。苹果的模版程序算是完整了。可以正常的运行并同步了。

使用CloudKit时,SwiftData会有哪些改变?

还是上面的那个链接。那个链接的内容非常重要,一定要看。

当开启了iCloud同步之后,SwiftData会失去以下功能。

  • @Attribute,失去unique的属性。苹果说这是因为CloudKit的同步原理本身造成了它没法保证属性unique不重复。
    • 因此,我们在开发时,需要保证数据的unique性。即不能依赖unique性来添加数据,而应该先查询,没有再添加。
  • @Relationship,这里有3点
    • Optional。上面提到了
    • SwiftData automatically sets the inverse of a relationship if it can reliably infer that inverse from your schema.这里之所以把苹果的原文放上来,是因为这句话具有迷惑性。初看这句话的时候,我认为苹果说的意思是,只要两个属性彼此拥有对方,SwiftData就会自动隐藏添加inverse的关系。比如像这样
@Model
final class Owner {
  var name: String?
  var pets: [Pet]? = []

  init(name: String) {
    self.name = name
  }
}

@Model
final class Pet {
  var name: String?
  var species: String?
  var owner: Owner?

  init(name: String, species: String) {
    self.name = name
    self.species = species
  }
}

但是在之前的使用中,我发现这样的代码,pet添加了owner属性之后,owner里仍然是看不到pet的。

我让AI根据出错信息,修改代码,AI给出的如下:

@Model
final class Owner {
  var name: String?
  @Relationship(deleteRule: .cascade, inverse:\Pet.owner) var pets: [Pet]? = []

  init(name: String) {
    self.name = name
  }
}

@Model
final class Pet {
  var name: String?
  var species: String?
  @Relationship(deleteRule: .cascade, inverse: \Owner.pets) var owner: Owner?

  init(name: String, species: String) {
    self.name = name
    self.species = species
  }
}

然后Xcode提示,这么写也是错的。因为双方都使用inverse,会导致循环引用。

那么怎么做才是对的呢?经过摸索,我发现是这样。

@Model
final class Owner {
  var name: String?
  @Relationship(deleteRule: .cascade) var pets: [Pet]? = []

  init(name: String) {
    self.name = name
  }
}

@Model
final class Pet {
  var name: String?
  var species: String?
  @Relationship(deleteRule: .cascade, inverse: \Owner.pets) var owner: Owner?

  init(name: String, species: String) {
    self.name = name
    self.species = species
  }
}

为什么呢?因为“SwiftData automatically sets the inverse of a relationship if it can reliably infer that inverse from your schema.” 这句话,直译就是“SwiftData会自动设置关系的逆数,如果它可以从您的模式中可靠地推断出该逆数。”看明白了吗?“SwiftData会自动设置”的前提是“它可以从你的模式中可靠地推断出来该逆数”。因此,一个inverse不写,SwiftData就不会尝试推断。两个都写,就会导致循环引用。所以,唯一正确的方式就是只写一边。这对于从Core Data过来的我十分不适应。因为Core Data是自动两边的。如果你没做到,它虽然允许,但是会用黄色的感叹号提示你。

这里充分证明了,学不好英语,理解不了字里行间的意思,那么写代码就会遇到瓶颈。

  • 差点忘记了。还有第三条呢。Relationship的第三条是说,同样是因为受到同步的限制,CloudKit不支持Schema.Relationship.DeleteRule.deny的关系。
    • 这一条也就是说,我们在删除之前,需要自己检测,而不能依赖这一条轻易删除。

JSON转Model的一些技巧

Swift

当JSON文件存在字典时,需要为字典的value创建单独的容器。比如:

{
  "pets": {
    "dog": {
      "name": "Bark",
      "color": "Black"
    },
    "cat": {
      "name": "Miao",
      "color": "White",
      "habit": "play papers"
    }
  }
}

对应的Model应该是

struct Model: Codable {
  var pets: [String: PetContainer]
}

struct PetContainer: Codable {
  var name: String
  var color: String
  var habit: String?
}

增加一层

{
  "animals": {
    "pets": {
      "dog": {
        "name": "Bark",
        "color": "Black"
      },
      "cat": {
        "name": "Miao",
        "color": "White",
        "habit": "play papers"
      }
    }
  }
}

对应model:

struct Animal: Codable {
  var animals: [String: [String: PetContainer]]
}

再增加一层

{
  "object": {
    "animals": {
      "pets": {
        "dog": {
          "name": "Bark",
          "color": "Black"
        },
        "cat": {
          "name": "Miao",
          "color": "White",
          "habit": "play papers"
        }
      }
    }
  }
}

对应model:

struct MyObject: Codable {
  var object: [String: PetsContainer]
}

struct PetsContainer: Codable {
  var pets: [String: PetContainer]
}

SwiftUI下,TextField诡异失去Focus下样式的问题

Core Data

今天出现一个诡异的问题。解决的办法还挺出人意料的,记录一下。

一开始我发现,当sheet弹出后,TextField虽然可以正常输入,但是却没有文本输入的提示符,并且也没有选中的状态。

i9c9y-yxsu9

我尝试了很多办法。一开始我以为是第三方框架导致的。但是取消了第三方框架的引用之后,也还是有这个问题。后来我以为是sheet的模态导致的,但上后来我发现单独的主视图的TextField的也还是有这个问题。最后,我以为是Xcode的问题。但是新建的项目完全没有这个问题。

经过仔细思考,我突然想起来前两天在设计界面多主题切换的时候,我不小心删除过AccentColor,然后我就新建了一个AccentColor。目前的AccentColor是这个样子的。

new_accent_color

但是创建项目生成的AccentColor是这个样子的。

default_accent_color

解决

新建了一个项目,然后将Xcode默认生成的AccentColor拖动过来。问题解决。

0hgsw-9di0t

后续

我将这个问题作为bug报告给了苹果。反馈ID是FB15042261。

Codable和@Observable/ObservableObject同时使用时的问题

Swift

我们在通过struct使用Codable的时候,是有默认实现的。但是如果class,就会麻烦一些。需要创建init函数。如果还要使用@Observable/ObservableObject,那就会更麻烦了。因为@Observable/ObservableObject会自动生成带_(下划线)的内部属性。这在解码的时候会报错。

此时就只能完全手写Codable才可以了。

List更改选中状态的背景色正确办法

SwiftUI

macOS中,默认List选中之后的背景是深蓝色。这会导致我的按钮变得不明显,我希望修改这个背景色。

询问AI,AI给我的反馈是通过background修改。但是通过background修改的背景色,会比系统自带的小一圈,十分丑陋。我再次询问AI,如果要修改系统的默认选中背景,要如何做。AI回答说,要修改那个很复杂。然后给了我一个错误的解法。

我只要换个AI来问。第二个AI给出的也是通过background修改。不过它在说思路的时候,应该是通过listRowBackground修改。于是我自己尝试用listRowBackground来修改。这次成功了。

.listRowBackground(selectedLanguage == locale ? Color.gray.opacity(0.15) : Color.clear)

当使用listRowBackground,List的选中就不要绑定selectedLanguage了。因为那样会导致.listRowBackground和高亮色被同时使用。

高亮色就是List选中的背景色的正式说法。

因为上面说的原因,我们需要手动指令selectedLanguage,可以通过点击或者onHover。看你的界面的需要。

watchOS 10无法在Xcode 15下配对问题的解决

Xcode

之前我的苹果表s5无法和Xcode 15配对。用了很多办法也没解决。后来我发现,这个问题在Xcode 15上几乎是普遍存在的。于是死心了。等苹果修。结果等来的却是WWDC2024的watchOS 11不再支持我的s5的消息。索性,趁着618的促销,把s5卖掉,换成s9。

打开开发者选项

出乎我意料的,s9与Xcode 15的配对也不是一帆风顺。最初遇到的难题就是,手表没法打开开发者模式。我使用方式是:

  1. 将s9与iPhone进行配对。设置为全新手表,并且不安装任何软件。这样的目的是保证系统的纯正,以及尽快完成设置。
  2. 手表要显示开发者模式选项,必须现在手机端开启开发者模式。
    1. 但是由于我的手机是在和手表配对之前就已经开启了开发者模式。因此需要先关闭手机的开发者模式,然后重新开启,重启手机。
    2. 手机成功开启开发者模式后。手表端需要关机,然后重启。这样应该就能看到开发者模式的选项了。

我这么做完之后,我的s9并没有成功显示开发者选项。这时,我看到了手机提示手表系统可以升级,点开一看,原来手表的系统是watchOS 10.4,而最新的是watchOS 10.5。于是将s9连上充电器,开始升级系统。

升级系统完成后,直接就发现了开发者选项。可见,第一步应该是升级系统到最新的10.5,然后是上面的那几步。

与Xcode配对

结果和Xcode 15的配置还是不成功。于是我打开Xcode 16 beta配对。结果提示了这个错误。

Transport error
Domain: com.apple.CoreDevice.ControlChannelConnectionError
Code: 0
User Info: {
    DVTErrorCreationDateKey = "2024-06-16 08:32:19 +0000";
    "com.apple.dt.DVTCoreDevice.operationName" = connect;
}
--
Control channel connection timed out while in state preparing
Domain: com.apple.dt.RemotePairingError
Code: 4
--


System Information

macOS Version 14.5 (Build 23F79)
Xcode 16.0 (23037.4) (Build 16A5171c)
Timestamp: 2024-06-16T16:32:19+08:00

我怀疑和VPN的设置有关。我使用的是Clash Pro。于是重启到macOS 15 beta系统。那个系统中Clash Pro没有设置为开机启动。果然,进入到macOS 15 beta之后,和Xcode 16 beta的配对成功了。

然后我又重启回macOS 14.5,这回提示为另外一个错误了。

Previous preparation error: A networking error occurred.. Control channel connection was invalidated while creating tunnel connection

我将Clash Pro的策略从“规则”,修改为“全局”。然后重开Xcode 15,这次可以成功配对了。

最后,我创建了一个手表应用测试了一下,可以成功调试了。

SwiftUI与Combine

Combine

SwiftUI大大简化了Combine的使用。比如在使用iCloud同步Core Data的时候,会有多个NSPersistentStoreRemoteChangeNotificationPostOptionKey通知。这时如果我们如果要更新界面,就会导致界面会重复计算多次。

这时我们就可以使用Combine的debounce方法。在SwiftUI中,只需要这样调用就可以了。

private let updateUIPublisher = NotificationCenter.default.publisher(for: .updateUI)
    .debounce(for: 0.2, scheduler: RunLoop.main)

Core Data的准备阶段

稍微记录一下Core Data端的准备代码。如果不设置,是不会获得通知的。

if let description = container.persistentStoreDescriptions.first {
      description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
      reigsteriCloudCoreDataSyncing()
}

extension PersistenceController {
  func reigsteriCloudCoreDataSyncing() {
    NotificationCenter.default.addObserver(forName: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator, queue: nil) { _ in
      DispatchQueue.main.async {
        NotificationCenter.default.post(name: .updateUI, object: container.persistentStoreCoordinator)
      }
    }
  }
}