Codable和@Observable/ObservableObject同时使用时的问题
我们在通过struct使用Codable的时候,是有默认实现的。但是如果class,就会麻烦一些。需要创建init函数。如果还要使用@Observable/ObservableObject,那就会更麻烦了。因为@Observable/ObservableObject会自动生成带_(下划线)的内部属性。这在解码的时候会报错。
此时就只能完全手写Codable才可以了。
我们在通过struct使用Codable的时候,是有默认实现的。但是如果class,就会麻烦一些。需要创建init函数。如果还要使用@Observable/ObservableObject,那就会更麻烦了。因为@Observable/ObservableObject会自动生成带_(下划线)的内部属性。这在解码的时候会报错。
此时就只能完全手写Codable才可以了。
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。看你的界面的需要。
之前我的苹果表s5无法和Xcode 15配对。用了很多办法也没解决。后来我发现,这个问题在Xcode 15上几乎是普遍存在的。于是死心了。等苹果修。结果等来的却是WWDC2024的watchOS 11不再支持我的s5的消息。索性,趁着618的促销,把s5卖掉,换成s9。
出乎我意料的,s9与Xcode 15的配对也不是一帆风顺。最初遇到的难题就是,手表没法打开开发者模式。我使用方式是:
我这么做完之后,我的s9并没有成功显示开发者选项。这时,我看到了手机提示手表系统可以升级,点开一看,原来手表的系统是watchOS 10.4,而最新的是watchOS 10.5。于是将s9连上充电器,开始升级系统。
升级系统完成后,直接就发现了开发者选项。可见,第一步应该是升级系统到最新的10.5,然后是上面的那几步。
结果和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的使用。比如在使用iCloud同步Core Data的时候,会有多个NSPersistentStoreRemoteChangeNotificationPostOptionKey通知。这时如果我们如果要更新界面,就会导致界面会重复计算多次。
这时我们就可以使用Combine的debounce方法。在SwiftUI中,只需要这样调用就可以了。
private let updateUIPublisher = NotificationCenter.default.publisher(for: .updateUI)
.debounce(for: 0.2, scheduler: RunLoop.main)
稍微记录一下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)
}
}
}
}
原本我以为二者是等价的。但是实际上,二者有一个重要区别。如果不掌握,你就没法使用fileImporter。
今天我就遇到了这个问题。当我使用fileImporter打开桌面的截图后,NSImage居然崩溃了。
func generataScreenshots(urls: [URL]) {
var screenshots = [NSImage]()
for url in urls {
let image = NSImage(contentsOf: url)! // nil crash
screenshots.append(createScreenshot(image: image))
}
self.screenshots = screenshots
self.showScreenshotsPreviewView = true
}
我很疑惑。因为用户选中的文件的权限是默认开启的。难不成Xcode把这个也给搞坏了?我将用户选择文件的权限由默认的只读改成读、写。结果还是崩溃。但是当我添加了Download文件夹为读、写之后。重新选中一个Download文件夹下的图片,这时系统弹出是否允许应用访问Download文件夹,我选择允许之后,这次居然成功运行了。
看起来的确还是和权限有关系。我将NSImage的代码前面加了一行Data的代码。采用Data 读取这个图片。结果这次,Xcode的输出给出了正确的提示,说没有权限打开URL。
查看fileImporter的文档说明,注释中写到:
This dialog provides security-scoped URLs. Call the startAccessingSecurityScopedResource method to access or bookmark the URLs, and the stopAccessingSecurityScopedResource method to release the access.
这段对话提供了安全范围的URL。调用startAccessingSecurityScopedResource方法来访问或将URL添加到书签,以及调用stopAccessingSecurityScopedResource方法来释放访问权限。
修改代码
func generataScreenshots(urls: [URL]) {
var screenshots = [NSImage]()
for url in urls {
if url.startAccessingSecurityScopedResource() {
let image = NSImage(contentsOf: url)!
screenshots.append(createScreenshot(image: image))
url.stopAccessingSecurityScopedResource()
}
}
self.screenshots = screenshots
self.showScreenshotsPreviewView = true
}
这回就可以了。
fileImporter获取的URL必须使用startAccessingSecurityScopedResource()进行处理,不然是不能直接读取的。除非你已经plist中明确了该文件夹的权限,类似Download文件夹这种Xcode可以添加的才行。
而NSOpenPanel,打开同样的图片,直接使用就可以正常运行。
func openPanel() {
let panel = NSOpenPanel()
panel.allowedContentTypes = allowedContentTypes
panel.allowsMultipleSelection = true
let response = panel.runModal()
var screenshots = [GeneralImage]()
if response == .OK {
for url in panel.urls {
let image = NSImage(contentsOf: url)!
screenshots.append(createScreenshot(image: image))
}
}
self.screenshots = screenshots
self.showScreenshotsPreviewView = true
}
在使用Xcode开发SwiftUI项目时,经常会遇到Xcode持续高CPU占用的问题。以往我没有重视这个问题,经常是很久之后才发现。此时,原本冰冷的Mac mini摸起来已经温热了。为此,我特意开发了一个小工具,提醒我关于这个问题。
最初,我发现这个问题出现的几率,和我打开的SwiftUI的文件的数量相关。打开的SwiftUI的文件越多,越有可能遇到这个问题。
就在我以为这就是真正的原因,并发文之后,我在仅打开2-3个SwiftUI文件的时候又遇到了这个问题。这次,我终于找到了是哪个文件导致的这个问题了。是MainView。应该是文件功能太多导致的。我的MainView,是一个接近2000行的文件。它包含多项功能:
我尝试将代码分离按照功能为多个小文件。但是问题仍旧存在。最后发现,解决的办法就是注释掉预览的代码。
长久以来,SwiftUI下的其中的一个坑就是NavigationView,它在使用@ObservedObject时,会出现界面回退的问题。
SwiftUI NavigationView pops back when updating observableObject
我原本以为随着NavigationView被反对,替代为NavigationSplitView和NavigationStack之后,这个问题也就消失了。但是没相当,这个问题在NavigationSplitView中仍旧存在。
实在没办法,我想到是否可以使用@Observable来替代ObservableObject。采用这个办法的主要缺点是前者只有最新一代的系统iOS 17、macOS 14才支持。但是好在我这个涉及到的应用是伴随Xcode发布的。因此,使用我的应用的用户应该主要是使用最新的系统。
从ObservableObject迁移到@Observable还是比较简单的。只需要跟随官方的文档一步一步操作即可。
Migrating from the Observable Object protocol to the Observable macro
不过,这里我还要额外再说两点:
我们已经很习惯使用第三方的库来调用UserDefaults了。但是有时我们也需要只使用苹果官方的框架来实现相同的功能。
extension UserDefaults {
static let text = "text"
}
class AppDelegate: NSObject, NSApplicationDelegate {
@AppStorage(UserDefaults.text) private var text: String = "Hello, Zhao!"
func applicationDidFinishLaunching(_ notification: Notification) {
registerKeyValueSyncing()
}
}
extension AppDelegate {
private func registerKeyValueSyncing() {
NotificationCenter.default.addObserver(forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default, queue: nil) { [self] notification in
guard let userInfo = notification.userInfo else { return }
guard let reasonForChange = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else { return }
guard let keys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { return }
guard keys.contains(UserDefaults.text) else { return }
if reasonForChange == NSUbiquitousKeyValueStoreAccountChange {
text = "Hello, Zhao!"
return
}
if let newText = NSUbiquitousKeyValueStore.default.string(forKey: UserDefaults.text),
newText != text {
text = newText
print("update text to \(text)")
}
}
UserDefaults.standard.addObserver(self, forKeyPath: UserDefaults.text, options: [.new, .initial], context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard let keyPath, keyPath == UserDefaults.text else { return }
if let change {
print("[debug] change is \(change)")
if let newText = change[.newKey] as? String {
NSUbiquitousKeyValueStore.default.set(newText, forKey: keyPath)
}
}
}
}
@main
struct KeyValueSyncingApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
#else
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
#endif
var body: some Scene {
WindowGroup {
ContentView()
}
}
}