肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

原来Swift的Sheet里还不能这么做

SwiftUI

今天在调试一个应用崩溃的问题的时候,发现有个sheet弹出后,被反复执行了多次。于是询问AI怎么回事。

原始的代码是这样的

.sheet(isPresented: $showAITranslationGuideView) {
  let xtTransUnits: [XTTransUnit] = {
    let xtTransUnits = Xliff_Tool.getXTTransUnits(from: trees)
    return xtTransUnits.filter { $0.target == nil || $0.target?.isEmpty == true }
  }()

  AITranslationView(xtTransUnits: xtTransUnits)
}

询问AI啥原因,AI是这么回答的。

.sheet(isPresented: $showAITranslationGuideView)为什么会多次执行?

在 .sheet(isPresented: $showAITranslationGuideView) 代码块中,每次 showAITranslationGuideView 状态变化时都会重新执行闭包内的代码。这里的问题是 xtTransUnits 的计算被放在了视图构建的闭包中,而不是在实际需要时才执行。

建议将这些逻辑移到 AITranslationView 的初始化或 onAppear 中。以下是修改建议:

.sheet(isPresented: $showAITranslationGuideView) {
    AITranslationView(trees: trees)
}

然后在 AITranslationView 中处理数据:

struct AITranslationView: View {
    let trees: [Tree<IDTree>]
    @State private var xtTransUnits: [XTTransUnit] = []
    
    var body: some View {
        // 你的视图内容
        .onAppear {
            xtTransUnits = getXTTransUnits(from: trees)
                .filter { $0.target == nil || $0.target?.isEmpty == true }
        }
    }
}

这样可以确保计算只在实际需要时执行一次。

需要注意的是,AI的解释是对的,但是不是真正的原因。但是解法又同样是对的。只要移出这个初始化的代码之后,的确就不会重复计算了。

我认为真正原因是这样的。像.sheet(isPresented: $showAITranslationGuideView)的sheet,是在View初始化时就会被生成的。它是不变的。这就是为什么还有一个同名的但是使用Optional的sheet,那个才是动态的。但是我这里面的代码,在sheet使用前重新进行了计算。这导致原本应该只能是静态的sheet,变成了动态的,从而导致它从新被加载,而这种情形,其实是为定义的,因为这里不应该使用动态的。这个才是出错的真正原因。

苹果的老六行为

Foundation

苹果有时候还是很老六的。比如在系统声音设置中,有一个声音叫“Heroine”,也就是女英雄。

lao6

那么如果你想在自己的应用中使用它,可以通过NSSound调用

class Foo {
    private var sound: NSSound?
    
    func playSound() {
        sound = NSSound(named: "Heroine")
        sound?.play()
    }
}

但是这个声音应用运行时并不会发出。这是因为传统上,这段声音的名字叫“Hero”,大概是出于平权的考虑,苹果将它改名为“Heroine”了。但是程序代码里的,没改,还是叫“Hero”。所以,想在自己的应用里使用它,必须使用

 sound = NSSound(named: "Hero")

这可是连文档里都没有的。只能自己搜索加猜测才能摸索到。

苹果还真是个老六。如果你真想平权,还想保证兼容性。那你让"Hero"和"Heroine"可以同时使用就可以了啊。这么做很难吗?

TabView自动跳转Tab问题的解决

SwiftUI

今天我在开发应用的时候遇到了一个奇怪的问题。当TabView的最后一个tab被拖动图片之后,应用会自动跳到第一个tab去。我很奇怪为什么有这个行为,于是拿来问AI。没想到很快就解决了。

提示词:阅读代码,尝试解决问题。当最后一个tab被拖动图片后,它会自动跳到第一个tab,我不明白这是什么原因造成的。

AI回复

问题分析

我已经查看了您的代码,找到了为什么当最后一个watchOSTabView被拖动图片后会自动跳回第一个标签页的问题。

这是由于在TabView中没有设置选中的标签页状态,所以当watchOSTabView中的State变量更新时,视图会重新绘制,导致TabView回到默认的第一个标签页。

解决方案

我们需要在MainView中添加一个State变量来跟踪当前选中的标签页,并将其绑定到TabView。这样,即使视图重新绘制,也会保持在当前选中的标签页上。

//
//  MainView.swift
//  My Icon Gear
//
//  Created by zhaoxin on 2025/3/5.
//

import SwiftUI

struct MainView: View {
  // 添加一个状态变量来跟踪选中的标签页
  @State private var selectedTab = 0
  private let platforms = ["iOS", "macOS", "watchOS"]

  var body: some View {
    TabView(selection: $selectedTab) {
      macOSTabView()
        .tag(0)
      iOSTabView()
        .tag(1)
      watchOSTabView()
        .tag(2)
    }
    .toolbar(content: {
      Button {

      } label: {
        Image(systemName: "square.and.arrow.up")
        Text("Export to Folder")
      }
      .bold()
    })
    .padding()
  }
}

#Preview {
  MainView()
}

解决方案解释

我对MainView.swift文件进行了以下修改:

  1. 添加了一个@State private var selectedTab = 0状态变量来跟踪当前选中的标签页。

  2. 修改了TabView,使用TabView(selection: $selectedTab)将选中状态绑定到TabView。

  3. 为每个TabView的子视图添加了.tag()修饰符,分别标记为0、1、2,这样系统就能知道哪个标签页被选中。

这样修改后,当您在watchOSTabView(最后一个标签页)上拖放图片时,即使视图状态发生变化并重新绘制,TabView也会保持在当前选中的标签页(即tag为2的watchOSTabView),而不会跳回第一个标签页。

这是因为现在TabView的选择状态由selectedTab变量控制,而不是由系统自动决定。无论是哪个子视图更新状态并触发重绘,TabView都会保持在用户上次选择的标签页。

Runloop的各种模式的区别

Swift

薅到AI的羊毛了。👀今天AI在编写Timer相关的代码时,手动添加了loop,让我看不懂,于是我询问AI,它的解释让我学到了。

能否解释一下“确保计时器在RunLoop中运行”的必要性这断代码的必要性。

关于RunLoop中运行Timer的必要性

在Swift中,关于这段代码的必要性:

if let timer = autoCheckTimer {
  RunLoop.main.add(timer, forMode: .common)
}

这段代码确实非常重要,让我解释一下为什么:

为什么需要将Timer添加到RunLoop中

当你使用Timer.scheduledTimer创建计时器时,它会自动添加到当前线程的RunLoop中,但仅使用默认模式(.default)。这在大多数情况下已经足够,但在某些特定情况下可能会导致计时器无法按预期工作:

  1. 滚动期间计时器暂停:当用户在UI上执行滚动操作时,RunLoop会切换到.tracking模式,这时默认模式下的计时器会暂时停止触发。

  2. 模态操作期间计时器暂停:某些模态操作(如拖拽)也会导致RunLoop切换模式,影响计时器的正常运行。

  3. 确保可靠性:显式添加计时器到RunLoop可以确保在不同的应用状态下计时器都能正常工作。

通过将计时器添加到.common模式,我们确保它在多种常见的RunLoop模式下都能正常触发,提高了计时器的可靠性和稳定性。

最佳实践

尽管scheduledTimer方法会自动将计时器添加到当前RunLoop,但明确地将计时器添加到RunLoop的.common模式是一种最佳实践,可以避免在复杂应用场景下出现计时器不按预期工作的问题。

特别是对于像自动检查更新这样需要在后台可靠运行的功能,确保计时器在各种应用状态下都能正常触发是非常重要的。

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。