肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

接力(Handoff)在SwiftUI下的实现

SwiftUI

传统上,接力在UIKit和AppKit上的实现有两种。一种是基于UIDocument/NSDocument的,一种是基于NSResponder/UIResponder的。苹果有文档分别说明了两种情况下,要如何实现接力功能。

Handoff Programming Guide
Implementing Handoff in Your App

但是,针对SwiftUI,苹果并没有专门进行说明要如何实现接力。苹果网站上的确有一篇文档,同时用到SwiftUI和NSUserActivity,但是那篇文档偏重的是如何恢复应用的状态,并不是如何使用接力。

Increasing App Usage with Suggestions Based on User Activities

本文的结果,是我通过试验摸索出来的。适用于苹果的iOS 16和macOS 13。鉴于目前版本的苹果实现过于简陋。大概率苹果今后会做出较大的修改。这点希望阅读者注意。

接力的传统实现方法

类似接力这种预制的功能,最复杂的地方是,苹果本身设定了它预想的功能方式,这就像是填空题,而不是简答题,你需要在留给你的空位填入自己的内容,而不能天马行空,自己设计一套。

使得接力更为复杂的,是苹果还设置了几种完全不同的方式。

步骤1,设置Info.plist。

如果你的应用是文档应用,即基于UIDocumentNSDocument或者DocumentGroup,那么应该把接力的类型写在文档类型里。

其它方式,则应该把接力的类型写在顶层。

需要注意两点:

  1. 注册在Info.plist的目的是标记应用支持“接收”何种类型。即,发送本身是不需要注册的。
  2. 在Xcode 14中,默认是不存在Info.plist这个文件的,这个由Xcode生成项目时根据项目目标的Info选项卡自动生成。并且,在Info选项卡添加某些内容的时候会自动消失部分内容。
    不过,在添加文件类型的时候我们会发现,系统会自动添加一个“项目目标-Info.plist”的补充文件。这就是我们可以用来编辑的Info.plist文件,在Info选项卡中会自动消失的属性,我们都可以添加到这里。

步骤2,创建NSUserActivity。

这一步比较简单,只需要创建NSUserActivity的实例,设置属性,然后设置为becomeCurrent(),然后将实例赋值给相应的NSResponder

传递的数据只能是文档中指定的那些类型。

步骤3,修改NSUserActivity

复杂的来了,根据不同的场景,苹果共设置了三处,可以后续修改NSUserActivity的方法。

NSUserActivityDelegate

我们可以NSUserActivityDelegateuserActivityWillSave(NSUserActivity)方法来修改NSUserActivity的内容。

NSResponder

我们也可以通过NSResponderupdateUserActivityState(_:)方法来修改。

NSApplicationDelegate

最后,我们可以通过NSApplicationDelegateapplication(_:didUpdate:)方法完成对于NSUserActivity的最终修改。

感兴趣的读者可以同时使用三者,以了解者三者的调用顺序。

步骤4,接收NSUserActivity

接收是在NSApplicationDelegateapplication(_:continue:restorationHandler:)方法。此外,NSApplicationDelegate还有其它的方法用于快速响应接力已经处理出错的问题。

另外,NSUserActivityDelegate还包含一个userActivity(_:didReceive:outputStream:)方法,可以处理数据在设备之间的双向传送。

接力在SwiftUI下如何实现

根据苹果的文档,接力在SwiftUI创建应用使用userActivity(_:element:_:)userActivity(_:isActive:_:)来创建,然后使用onContinueUserActivity(_:perform:)来接收。

遇到的困境

不过在实际使用中,经常遇到SwiftUI创建NSUserActivity不及时,以及接收不到的问题。

在经过我的反复试验之后,我得出了如下结论。

创建NSUserActivity

要创建NSUserActivity,必须满足以下条件。

  1. NSUserActivity实例必须持续存在。因此,不能使用临时变量创建,必须将其赋值给视图的属性。
  2. 必须有对应的类型。
  3. 设置需要的内容。
  4. 必须使用becomeCurrent()

你也可以尝试SwiftUI原生提供的方式,我遇到的问题是应用启动后能成功,但是后续更新容易出现问题。
如果使用原生方式,那么就不需要becomeCurrent()。因为系统会自动根据预设条件自动判断是否becomeCurrent()

获取NSUserActivity

要获取NSUserActivity,则需要满足以下条件:

  1. 在Info.plist中,注册要接收的NSUserActivity的类型。
  2. 这里,最麻烦的点来了。根据我的测试,如果想要接收NSUserActivity,在不同的系统中,机制居然是不一样的。
    1. 如果是macOS,那么请使用NSApplicationDelegate来接收NSUserActivity,SwiftUI自带的onContinueUserActivity(_:perform:)在macOS中没有正常调用。
    2. 如果是iOS,刚好反过来。必须使用SwiftUI自带的onContinueUserActivity(_:perform:)来接收NSUserActivityUIApplicationDelegate对应的方法反而会毫无响应。

结合以上知识点,一个能够同时支持macOS和iOS的接力的SwiftUI应用是这样的:

import SwiftUI

#if os(macOS)
class AppDelegate:NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        if userActivity.activityType == "com.parussoft.Handoff-Test.editing" {
            NotificationCenter.default.post(name: .userActivityReceived, object: nil, userInfo: userActivity.userInfo)
            return true
        }
        
        return false
    }
}
#endif

@main
struct Handoff_TestApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
    #endif
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension Notification.Name {
    static let userActivityReceived = Notification.Name("userActivityReceived")
}

struct ContentView: View {
    @State private var counter = 0
    @State private var userActivity:NSUserActivity?
    
    private let userActivityReceivedPublisher = NotificationCenter.default.publisher(for: .userActivityReceived)
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world! \(counter)")
            
            Button("Add") {
                counter += 1
            }
        }
        .padding()
        .onContinueUserActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            if let userInfo = userActivity.userInfo as? [String:Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
        .onChange(of: counter) { newValue in
            let userActivity = NSUserActivity(activityType: "com.parussoft.Handoff-Test.editing")
            userActivity.title = NSLocalizedString("Editing", comment: "")
            userActivity.addUserInfoEntries(from: ["counter" : counter])
            userActivity.becomeCurrent()

            self.userActivity = userActivity
        }
        .onReceive(userActivityReceivedPublisher) { notification in
            if let userInfo = notification.userInfo as? [String : Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
    }
}

或者

import SwiftUI

#if os(macOS)
class AppDelegate:NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        if userActivity.activityType == "com.parussoft.Handoff-Test.editing" {
            NotificationCenter.default.post(name: .userActivityReceived, object: nil, userInfo: userActivity.userInfo)
            return true
        }
        
        return false
    }
}
#endif

@main
struct Handoff_TestApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
    #endif
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension Notification.Name {
    static let userActivityReceived = Notification.Name("userActivityReceived")
}

struct ContentView: View {
    @State private var counter = 0
    
    private let userActivityReceivedPublisher = NotificationCenter.default.publisher(for: .userActivityReceived)
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world! \(counter)")
            
            Button("Add") {
                counter += 1
            }
        }
        .padding()
        .userActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            userActivity.title = NSLocalizedString("Editing", comment: "")
            userActivity.addUserInfoEntries(from: ["counter" : counter])
        }
        .onContinueUserActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            if let userInfo = userActivity.userInfo as? [String:Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
        .onReceive(userActivityReceivedPublisher) { notification in
            if let userInfo = notification.userInfo as? [String : Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
    }
}

我更推荐第一种方式。

最后,附上Info.plist。Xcode 14中,它的文件名为“Handoff-Test-Info.plist”。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDocumentTypes</key>
	<array/>
	<key>NSUserActivityTypes</key>
	<array>
		<string>com.parussoft.Handoff-Test.editing</string>
	</array>
</dict>
</plist>

补充说明

如果还是没有接力的提示,在项目中,打开iCloud的key-value权限。

OptionSet与NSPredicate

Swift

在Swift中,我们习惯了使用contains来比较OptionSet,这个方法使用起来十分简单,就不赘述了。某些情况下,我们必须使用NSPredicate来进行比较OptionSet,由于Objective-C不支持contains,所以比较的方法有所不同。特别的,我们有时需要考虑组合后的特性,因为不是数学简单的相等关系,有时候理解起来存在一定的困难,容易出错。

照片库搜索遇到的问题

我打算搜索照片库,需要排除掉实况照片和截图,我的代码一开始是这么写的:

let notLivePhotoPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

这段代码运行之后,结果为空。我不是很理解,但是我还是想办法更改了代码:

let livePhotoPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let notLivePhotoPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: livePhotoPredicate)
let notScreenshotPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: screenshotPredicate)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

这段代码运行的结果倒是符合预期。这是为什么呢?难道这两段代码不应该是等价的吗?

于是我重新查看苹果的文档,结果我发现,PHAssetMediaSubtype不是enum,而是OptionSet。在NSPredicate中,比较OptionSet不能用相等,而应该使用:

let livePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoScreenshot.rawValue)

上面的代码运行同样符合预期。那如果将代码改成如下呢?

let notLivePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

又不工作了。这是?

这是因为类型是OptionSet,比较不是简单的是否相等的算术关系。比如不工作的最后那段代码,如果是Set,那么结果永远是空集。

最终我的代码:

let combinedTypes:PHAssetMediaSubtype = [.photoLive, .photoScreenshot]
let combinedTypesPredicate =  NSPredicate(format: "(mediaSubtypes & %d) != 0", combinedTypes.rawValue)
let notCombinedTypesPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: combinedTypesPredicate)

参考

NSPredicate syntax for PHFetchOption keys

完美解决Xcode在预览iOS应用SwiftUI View时,占用内存过多的问题

SwiftUI

我的Mac配置是M1,16GB内存,512GB固态硬盘。原以为足够使用了,顶多是偶尔内存不足,用磁盘虚拟一些就足够了。可没成想,这两天调试iOS应用,动不动内存压力就黄了。

虽然我没有感知到系统变慢,但是看着内存压力变黄就是不爽。于是想追究一下具体的原因。结果让我大吃一惊!

为什么Xcode在iOS应用的预览时,会占用额外的内存?

所谓占用额外的内存,是相比较同样的代码,macOS版本应用预览时占用的内存。

macOS应用在预览时,因为Xcode本身就是在macOS运行的,所以无需模拟系统,只需要运行程序就可以了。所以相比于模拟iOS应用,少了模拟了iOS系统,节省了大量的内存。

根据上面的原因,我首先想到的是可以使用模拟较低版本的iOS系统。因为众所周知,版本越新的系统,可能消耗的资源就越大。目前Xcode自带的模拟器是iOS 16.4,由于我的新应用最低支持到iOS 16,所以我选择额外下载iOS 16.0的模拟器。

经过测试我发现,使用iOS 16.0模拟器,相对于iOS 16.4的模拟器,可以节省约5%的内存。16GB*5%=0.8GB。

此外,我之前默认都是使用iPhone SE 3进行模拟,因为这个也是我目前在使用的手机。不过iPhone SE 3本身是4GB内存,所以模拟也需要4GB内存,而iOS 16支持的手机中,内存最小的是iPhone 8,于是我将iPhone SE 3换成iPhone 8,结果发现内存的占用又降低了一些。

将模拟器从iPhone SE 3换成iPhone 8之后,可以再多节省约2%的内存。两次节省合计16GB*(2%+5%)=1.12GB,节省内存超过1GB。

大功告成了吗?

原来只需要换掉模拟器,就可以节省超过1GB的内存。事情真的只有这么简单吗?并没有!

平常我们在调试应用时,都是用Xcode写好界面和功能,在预览里看着差不多了,然后上真机上跑的。

xcode_preview

如图,我是在图中2所示的那样,通过制定模拟器型号来进行模拟的。但是我发现,最终占用的内存大小,要比我预期的大。

进一步分析,我发现,原来图中1部分的内容,会对于内存占用产生巨大的影响。

Xcode在预览时存在bug,导致模拟器内存占用翻倍

我们先做一番测试,然后你就知道问题出在哪里了。

  1. 首先我们维持2不变,然后将1的内容也改成iPhone 8。然后退出Xcode,重新开。(退出Xcode并重新开的目的是恢复真机模拟所占用的缓存)
  2. 点击预览,当预览呈现时,我们查看内存占用。此时内存的占用符合我们的预期。
  3. 将1的内容改成真机iPhone SE 3。因为我们模拟是指定的模拟器,此时3的内容应该不变。
    1. 但实际上,你会发现3位置的标签在转圈,这意味着模拟器在刷新。而这本来是不应该发生的。
    2. 转圈结束后,我们再次查看内存,发现内存占用大大增加。

这说明,Xcode出了bug,在我们指定模拟器的情况下,1的内容是否改变,应该不影响2、3的模拟器的,但实际上,虽然3表面上还是2的模拟器的最终结果,但是Xcode还额外开了1的模拟器,这导致模拟器打开的数量翻倍,内存占用大大增加。

验证上面的提到的bug

我们可以通过如下的步骤验证上面提到的bug:

  1. 将2中指定模拟器的代码注释掉。这样3中的模拟器,就会伴随1的变化而变化。
  2. 关闭Xcode,并重开,然后选择预览。
  3. 将1中的iPhone SE 3,换成iPhone 8,然后重新回到步骤2。

我们发现,无论是从1到2,还是从3到2,最终占用的内存都大大低于上面的情况。换句话说,也就是当2不设置时,因为1与3是相同的,每次都会只打开1个模拟器,因此内存占用的区别仅仅是iOS版本和iPhone版本区别,而不涉及到重复打开模拟器的问题。

不完美的解决方案

那么,我们为了保证同时还要调节真机的需要,是不是就只能妥协。不指定模拟器的型号,而选择与真机一致这种次优的解决方案了呢?

完美解决方案

并不是!我最终找到了完美的解决方案。

我们知道,Xcode之所以能调用多个模拟器,是因为系统中存在多个模拟器。那么如果我们通过手段,删除掉多余的模拟器,那是不是Xcode就不会调用了呢?抱着试试看的想法,我删除了所有模拟器,仅保留iPhone 8 iOS 16.0这一个。然后再次进行测试。

结果符合预期!因为系统中不再包含iPhone SE 3的模拟器,所以即便1切换为iPhone SE 3,3中的标签也不会有任何变化。因为iPhone 8是唯一的模拟器,所以任何时候,都只会用iPhone 8进行模拟。2中的指定模拟器也是没有必要的了。

至此,Xcode在模拟iOS应用的SwiftUI View预览时,占用内存过高的问题完美解决了。

实际使用中,我的内存剩余,从不足40%,恢复到接近70%。压力也不再变黄了。
测试时使用内存压力的手段是在终端中输入memory_pressure命令,查看最后一行的剩余内存。

其它节省内存的技巧

我发现Xcode有一个问题,正常退出Xcode之后,有时SourceKitService并没有伴随退出,而是继续留驻在内存中。它会占据1GB左右的空间。解决办法是在任务管理中,手动终止这个进程。

为了解决这个问题,我手动写了一个工具。会在在Xcode关闭之后,查看是否有SourceKitService遗留,有则自动结束,减少内存占用。如果你也需要,可以尝试使用。

SwiftUI view recreated may cause bugs what are hard to debug

SwiftUI

Today, I encountered an issue that VideoPlayer turned to blank when iPhone screen was rotated.

The VideoPlayer was in a modal view that created with fullScreenCover. The sample code was like this:

import SwiftUI
import AVKit

struct TopView: View {
    private let player = AVQueuePlayer(playerItem: nil)
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Using Publisher for UIDevice.orientationDidChangeNotification

At first, I thought I should recreate the player when the screen was rotated.

import SwiftUI
import AVKit

struct TopView: View {
    @State private var playerItem:AVPlayerItem?
    
    private let player = AVQueuePlayer(playerItem: nil)
    private let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
    
    var body: some View {
        if playerItem != nil {
            VideoPlayer(player: player)
                .onReceive(publisher, perform: { _ in
                    self.player.pause()
                    self.playerItem = nil
                })
        } else {
            ProgressView()
                .onAppear(perform: setPlayer)
        }
    }
    
    private func setPlayer() {
        if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
            self.playerItem = AVPlayerItem(url: videoURL)
            player.replaceCurrentItem(with: playerItem)
            
            Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                if player.status == .readyToPlay {
                    player.play()
                    
                    if player.timeControlStatus == .playing {
                        timer.invalidate()
                    }
                }
            }
        }
    }
}

However, the new code didn't work. I added more debug point and finally found that TopView was recreated when the screen was rotated. Since the view was recreated, the player in onReceive was newly created, it couldn't stop the playing of the previously played item.

The issue was because, unlike other structs and objects, which could automatically released when container view was released. AVPlayer hold its owned reference when playing. This behavior caused the SwiftUI view was not released properly.

Use Binding from parent view

The solution was easy. Since I wanted the player to be constant, I should set it up in the parent view.

import SwiftUI
import AVKit

struct TopView: View {
    @Binding var player:AVQueuePlayer
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Now everything worked fine.

Final Thoughts

Some objects hold their own references as strong. Those objects may keep view from release and cause bugs. We should using Binding to create those objects in a higher view which is not recreated. Then the bugs are fixed.

Gain Insight of Serial and Concurrent Operations, Closure and async/await, Dispatch Semaphore and Dispatch Group

Swift

I understood those concepts deeper with a recent project.

Serial and Concurrent Operations

Serial

func serial() {
    for i in 0..<5 {
        let seconds:UInt32 = (1...3).randomElement()!
        sleep(seconds)
        print("\tWait \(seconds)s.")
        print(i)
    }
}
serial()

	Wait 3s.
0
	Wait 2s.
1
	Wait 1s.
2
	Wait 2s.
3
	Wait 1s.
4

Concurrent

func concurent() {
    let queue = DispatchQueue.global()
    
    for i in 0..<5 {
        queue.async {
            let seconds:UInt32 = (1...3).randomElement()!
            sleep(seconds)
            print("\tWait \(seconds)s.")
            print(i)
        }
    }
}
concurent()

	Wait 2s.
2
	Wait 3s.
0
	Wait 3s.
	Wait 3s.
	Wait 3s.
4
3
1

We can see that with serial operations, the codes are running one by one. And with concurrent operations, the codes are running in parallels.

Closure and async/await

Closure

func closure() {
    let url = URL(string: "https://zhaoxin.pro")!

    let task = URLSession.shared.dataTask(with: url) { data, urlResponse, error in
        if let error {
            print(error)
            return
        }
        
        if let httpResponse = urlResponse as? HTTPURLResponse,
           httpResponse.statusCode == 200,
           let data, let output = String(data: data, encoding: .utf8) {
            print(output)
        }
    }
    
    task.resume()
}
closure()

<!DOCTYPE html>
<!--[if IEMobile 7 ]><html class="no-js iem7"><![endif]-->
<!--[if lt IE 9]><html class="no-js lte-ie8"><![endif]-->
<!--[if (gt IE 8)|(gt IEMobile 7)|!(IEMobile)|!(IE)]><!--><html class="no-js"><!--<![endif]-->
<head>
  <meta charset="utf-8">
  <title>
  
  肇鑫的技术博客
  

  </title>
  <meta name="author" content="">
  <meta name="description" content="业精于勤,荒于嬉">
  ...

async/await

func async_await() async throws {
    let url = URL(string: "https://zhaoxin.pro")!
    let (data, urlResponse) = try await URLSession.shared.data(from: url)
    if let httpResponse = urlResponse as? HTTPURLResponse,
       httpResponse.statusCode == 200,
       let output = String(data: data, encoding: .utf8) {
        print(output)
    }
}
do {
    try await async_await()
} catch let error {
    print(error)
}

As you can see, most of the closures can convert to async/await, which makes them easy to understand. However, not all closures could be converted as async/await. For example, there are many API in Photos could not be converted to async/await. As those closures are called more than one time.

Dispatch Semaphore and Dispatch Group

For concurrent operations, if we want to do something after all operations are finished. We can use a timer, a Dispatch Semaphore, or a Dispatch Group.

Timer

func concurrentWithTimer() {
    let queue = DispatchQueue.global()
    let total = 5
    var finished = 0
    
    let lock = NSRecursiveLock()
    
    for i in 0..<5 {
        queue.async {
            let seconds:UInt32 = (1...3).randomElement()!
            sleep(seconds)
            print("\tWait \(seconds)s.")
            print(i)

            lock.lock()
            finished += 1
            lock.unlock()
        }
    }
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { timer in
        if finished == total {
            timer.invalidate()
            print("All Finished")
        }
    })
}
concurrentWithTimer()

	Wait 1s.
	Wait 1s.
0
2
	Wait 2s.
3
	Wait 3s.
	Wait 3s.
4
1
All Finished

Using Timer is easy to understand, but it costs more as it runs many times.

Dispatch Semaphore

func dispatch_semaphore() {
    let queue = DispatchQueue.global()
    let semaphore = DispatchSemaphore(value: 0)
    let total = 5
    var finished = 0
    
    let lock = NSRecursiveLock()
    
    for i in 0..<5 {
        queue.async {
            let seconds:UInt32 = (1...3).randomElement()!
            sleep(seconds)
            print("\tWait \(seconds)s.")
            print(i)

            lock.lock()
            
            finished += 1
            
            if finished == total {
                semaphore.signal()
            }
            
            lock.unlock()
        }
    }
    
    semaphore.wait()
    
    print("All Finished")
}

Semaphore only runs one time. But you need to deal with extra local variables, even more, you have to use lock to void data racing.

Dispatch Group

func dispatch_group() {
    let queue = DispatchQueue.global()
    let group = DispatchGroup()
    
    for i in 0..<5 {
        group.enter()
        
        queue.async {
            let seconds:UInt32 = (1...3).randomElement()!
            sleep(seconds)
            print("\tWait \(seconds)s.")
            print(i)

            group.leave()
        }
    }
    
    group.wait()
    
    print("All Finished")
}

Dispatch Group is the most elegant way to do the same job. There is no extra variables and no more extra operations. Just enter and leave then wait. Everything works like a charm.

VideoPlayer Turns Black Screen When Replace a Player Item

SwiftUI

I was working on a project that shows videos from the Photo Library. However, each time I replaced the current video, the VideoPlayer turned black screen for a while.

I had no idea why this happened so I looked for what Photos app did for videos. It turned out that Photos showed a preview of the video first and then automatically played the video when the video data was loaded.

I did the same. First I fetched the preview image. When the preview was downloaded, I fetched the video.

The issue was the same. After some investigation, I found that the method of fetching video was called twice, which was not expected. Then I found that was because fetching preview may be called multiple times. Photo Library may provide a low quality image first then provide the full quality image. This issue was solved by read the info dictionary of the fetching preview result. But the black screen issue was still there.

Maybe the AVPlayer or PlayerItem was not ready?

if let video {
    player.replaceCurrentItem(with: video)
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
        if player.status == .readyToPlay {
            playerItem = video
            timer.invalidate()
        }
    }
}

The result was the same.

Maybe there was still some time between the player was ready and the player was really playing.

if let video {
    player.replaceCurrentItem(with: video)
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
        if player.status == .readyToPlay {
            player.play()
            
            if player.timeControlStatus == .playing {
                playerItem = video
                timer.invalidate()
            }
        }
    }
}

The issue solved.

Conclusion

  1. Do not create another AVPlayer each time. Just create an empty player and replace the replaceCurrentItem.
  2. There was time gap between player ready and player real playing. So you need to play the player first when player was ready and then updated the UI after the player was really playing.
  3. I was using a timer instead of a notification as the former was more simple to use.

SwiftUI with Preview

SwiftUI

Some times, only the preview of SwiftUI view crashes. Both the simulator and the real device don't crash. So we need to do extra works or don't run some methods if the target is a preview.

We can't identify preview from debug, so we add a isPreview variable under debug, and that won't leave the variable in release version.

struct SomeView : View {
    #if DEBUG
    var isPreview = false
    #endif
    var body: some View {
        Text("Hello World!")
            .onAppear {
                #if DEBUG
                if isPreview {
                    return
                }
                #endif
                
                foo()
            }
    }
    
    private func foo() {
        ...
    }
}

struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            SomeView(isPreview: true)
                .previewDevice(PreviewDevice(rawValue: "iPhone SE 2"))
                .previewDisplayName("iPhone SE 2")
            
            SomeView(isPreview: true)
                .previewDevice(PreviewDevice(rawValue: "iPhone 14 Pro"))
                .previewDisplayName("iPhone 14 Pro")
        }
    }
}