肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

Remove the video part from a live photo

Photos

Someone may think the process is as easy as getting the video from a live photo, removing the video and saving the other parts back. It is wrong.

We can get the video from a live photo using PHAssetResource's class func assetResources(for livePhoto: PHLivePhoto) -> [PHAssetResource]. But the PHAssetResource it gets contains empty assetLocalIdentifier. So you can't get its asset directly. And you can't remove it separately.

The correct way is to get the photo part, save it and remove the live photo.

Get the photo and save it

We could get the photo in three ways. Two from PHImageManager and one from PHAssetResourceManager. However, only one method is right way.

requestImage(for:targetSize:contentMode:options:resultHandler:)

OS, Mac Catalyst, tvOS
func requestImage(for asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?, resultHandler: @escaping (UIImage?, [AnyHashable : Any]?) -> Void) -> PHImageRequestID
macOS
func requestImage(for asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?, resultHandler: @escaping (NSImage?, [AnyHashable : Any]?) -> Void) -> PHImageRequestID

We should not use this method as it returns UIImage/NSImage, according to Apple, those two classes lacks metadata.

A UIImage object does not contain all metadata associated with the image file it was originally loaded from (for example, Exif tags such as geographic location, camera model, and exposure parameters). To ensure such metadata is saved in the Photos library, instead use the creationRequestForAssetFromImage(atFileURL:) method or the PHAssetCreationRequest class. To copy metadata from one file to another, see Image I/O.
creationRequestForAsset(from:)

requestData(for:options:dataReceivedHandler:completionHandler:)

We cannot use requestData(for:options:dataReceivedHandler:completionHandler:) of PHAssetResourceManager either. It does return Data instead of UIImage/NSImage. But the data it returns cannot save to PHPhotoLibrary correctly.

I think it is because of the data it returns contains the same localIdentifier as the live photo, which is allowed to save separately.

requestImageDataAndOrientation(for:options:resultHandler:)

This is the only way we should use to save the photo part from a live photo.

Remove the live photo

This is an easy job. Just use PHAssetChangeRequest.deleteAssets(_ assets: NSFastEnumeration).

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")
        }
    }
}

Get Critical Alert Permission from User instead of Apple

通用

When a user uses "Do not disturb" mode, your notifications no longer get sound and banner, unless your app can post a Critical Alert.

However, to get the entitlement of critical alert, you have to ask Apple to give your permission. Someone complains that their requests to Apple even took months without a response.

Critical Alerts entitlement

I found a new way to get the permission directly from the user, instead of Apple.

Steps

UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert, .criticalAlert]) { granted, error in
    guard error == nil else {
        DispatchQueue.main.async {
            NSSound.beep()
            let alert = NSAlert(error: error!)
            alert.runModal()
        }
        
        return
    }
}

Above code will get an error as you don't have the entitlement of critical alert. But we could use code below:

UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert]) { granted, error in
    guard error == nil else {
        DispatchQueue.main.async {
            NSSound.beep()
            let alert = NSAlert(error: error!)
            alert.runModal()
        }
        
        return
    }
    
    if granted {
        UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert, .criticalAlert]) { granted, error in
            /* You will get an error here as you don't have the critical alert entitlement.
                But since you have already got the permission for normal notifications, you now get a disabled critical alert permission.
                You are just one step to the critical alert. Ask your user to manually enable the permisssion.
                The error should be igonred here.
                */

            UNUserNotificationCenter.current().getNotificationSettings { settings in
                if settings.criticalAlertSetting != .enabled {
                    DispatchQueue.main.async {
                        NSSound.beep()
                        showCriticalAlert = true
                    }
                }
            }
        }
    }
}

We request the permission twice. First, we request the common notification permission. When a user allow that, we request the critical alert permission. Since we have already got the common notification permission the second permission request won't pop up. Also as we don't have critical alert entitlement from Apple, we will get an error. But the error can be ignored. Then when you open the app's notification settings, there is a critical alert permission, with disabled state. So what you only need to do is to show an alert and ask the user to enable the critical alert if the user wants to get notified in "Do not disturb" mode.

Sample App

I have already got an sample app on sale in App Store. You can try it for free.

Stand Reminder

The Login Item in macOS Ventura

macOS

Apps Crash on Ventura

Before Ventura, if I wanted some app to launch when a user logged in, I used this code:

private func setAutoStart() {
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
        fatalError()
    }
}

However, in macOS Ventura, SMLoginItemSetEnabled(_:_:) global function is deprecated, and what's more, it takes no effects so it returns false and fatalError() is executed. So every app of mine that used auto launch when logged in crashed on macOS Ventura.

How to Solve

To solve the issue is easy, we must update the code and use the new API in macOS Ventura and later.

private func setAutoStart() {
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService().register()
        } else {
            try? SMAppService().unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
}

Other Related Issues

In Ventura, you may find that many versions of the same app that created login items all launched when you logged in. Once I had 3 "Stand Reminder" and 2 "Poster 2" in different versions auto launched when I logged in.

I found that the extra apps were debug versions that I had tested with Xcode. So there must be some relations with the new API.

Workaround

You can simply manually remove the debug version apps to solve the issue. But this method takes to much time as you always have new debug versions.

Or you can stop this feature on debug versions after you fully tested it.

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService().register()
        } else {
            try? SMAppService().unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
    #endif
}

An Interesting Issue of @State Variable Not Set after Set

SwiftUI

In my recent project, a @State variable was not set after set.

        self.project?.currentTransUnit?.isVerified = true
        self.project?.update() // debug point. 
        // po "self.project?.currentTransUnit?.isVerified" prints false.

So I created another project to narrow the issue.

//
//  Model_SampleApp.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import SwiftUI

@main
struct Model_SampleApp: App {
    @State private var foo:Foo? = Foo(bar: Bar(item: Item(name: "Johnny")))
    
    var body: some Scene {
        WindowGroup {
            ContentView(foo: $foo)
        }
    }
}
//
//  ContentView.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import SwiftUI

struct ContentView: View {
    @Binding var foo:Foo?
    
    var body: some View {
        VStack {
            Text(foo?.bar.item.name ?? "nil")
            
            HStack {
                TextField("name", text: Binding(get: {
                    foo?.bar.item.name ?? "nil"
                }, set: { newValue in
                    foo?.bar.item.name = newValue
                }))
            }
        }
        .padding()
        .onChange(of: foo) { newValue in
            print(newValue)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(foo: .constant(nil))
    }
}
//
//  Model.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import Foundation

struct Foo:Equatable {
    var bar:Bar
}

struct Bar:Equatable {
    var item:Item
}

struct Item:Equatable {
    var name:String
    let id = UUID()
}

All things went fine. I noticed that onChange required Equatable. So I did a test.

func onChange<V>(of value: V, perform action: @escaping (V) -> Void) -> some View where V : Equatable
struct Item:Equatable {
    var name:String
    let id = UUID()
    
    static func == (lhs: Item, rhs: Item) -> Bool {
        return lhs.id == rhs.id
    }
}

Now the issue happened again. So I guess for optimize performance. When set to a @State variable, SwiftUI compared the previous and current value by Equatable. If they were the same, SwiftUI thought they were identical and didn't replace that current with the previous value. So if we implemented the Equatable's method with part implementation on purpose, the issue appeared on the values which were not mentioned in Equatable's method.

Understand TabView in SwiftUI and Adopt a TabView with indicators into Window’s Toolbar

SwiftUI

A Simplest TabView Sample

ContentView

struct ContentView: View {
    var body: some View {
        TabView {
            ForEach(1..<4) { id in
                TabItemView(id: id)
            }
        }
    }
}

TabItemView

struct TabItemView: View {
    @State var id:Int
    
    var body: some View {
        Text("Tab \(id)")
            .tabItem {
                Text(String(id))
            }
    }
}

first_tabview_sample

Life Cycle of Tab Item View

TabItemView

struct TabItemView: View {
    @State var id:Int
    
    var body: some View {
        Text("Tab \(id)")
            .tabItem {
                Text(String(id))
            }
            .onAppear(perform: {
                print("I am tab \(id)")
            })
            .onDisappear {
                print("Tab \(id) am quit!")
            }
    }
}

Runs.

I am tab 1
Tab 1 am quit!
I am tab 2
Tab 2 am quit!
I am tab 3
Tab 3 am quit!
I am tab 1

Is the tab item view just hidden or actually quit when you change a tab?

TabItemView

struct TabItemView: View {
    @State var id:Int
    @State private var title = ""
    
    var body: some View {
        HStack {
            Text("Tab \(id)")
                .onAppear(perform: {
                    print("I am tab \(id)")
                })
                .onDisappear {
                    print("Tab \(id) am quit!")
            }
            
            TextField("Title", text: $title)
        }
        .tabItem {
            Text(String(id))
        }
        .padding()
    }
}

Input "Test" in tab 1, and change to other tabs, then change back to tab 1. You will see the "Test" is still there. That means the tab item view was just hidden, not quit.

third_tabview_sample

Change Tab Name

TabItemView

struct TabItemView: View {
    @State var id:Int
    @State private var title = ""
    
    var body: some View {
        HStack {
            Text("Tab \(id)")
                .onAppear(perform: {
                    print("I am tab \(id)")
                })
                .onDisappear {
                    print("Tab \(id) am quit!")
            }
            
            TextField("Title", text: $title)
        }
        .tabItem {
            Text(getTabName())
        }
        .padding()
    }
    
    private func getTabName() -> String {
        if title.isEmpty {
            return String(id)
        }
        
        return title
    }
}

change_tab_name

Put Indicators in Window's Title Bar

ContentView

@main
struct TabView_SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .windowToolbarStyle(.unifiedCompact(showsTitle: false))
    }
}

tab_in_window_title

The indicators worked. But the tab title no longer worked.

Fix Tab Title

TabItemView

struct TabItemView: View {
    @Binding var tabContent:TabContent
    
    var body: some View {
        HStack {
            Text("Tab \(tabContent.id)")
            
            TextField("Title", text: $tabContent.title)
                .frame(minWidth: 300)
        }
        .tabItem {
            Text(getTabName())
        }
        .padding()
    }
    
    private func getTabName() -> String {
        if tabContent.title.isEmpty {
            return String(tabContent.id)
        }
        
        return tabContent.title
    }
}

struct TabContent:Identifiable, Equatable, Hashable {
    var id = 0
    var title = ""
}

ContentView

struct ContentView: View {
    @State private var tabContents:[TabContent] = {
        (1..<4).map { TabContent(id: $0) }
    }()
    
    @State private var currentTabContent = TabContent(id: 1)
    
    var body: some View {
        ForEach($tabContents) { $tabContent in
            if currentTabContent.id == tabContent.id {
                TabItemView(tabContent: $tabContent)
            }
        }
        .toolbar {
            HStack {
                Picker(String(currentTabContent.title), selection: $currentTabContent) {
                    ForEach(tabContents) { tabContent in
                        Text(tabContent.title.isEmpty ? String(tabContent.id) : tabContent.title).tag(tabContent)
                    }
                }
                .pickerStyle(.segmented)
                .onChange(of: currentTabContent) { newValue in
                    if let index = tabContents.map({$0.id}).firstIndex(of: newValue.id) {
                        tabContents[index] = newValue
                    }
                }
                
                Spacer()
            }
        }
        .frame(width: 400, height: 50)
    }
}

worked_tab_in_window_title