肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

Trump Realm With SwiftUI

SwiftUI

You may have already known how to using Realm with Swift. In case you were not, we would like to have a quick review.

Realm With Swift

Model

import Foundation
import RealmSwift

class Item:Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var title = ""
    @Persisted(originProperty: "items") var group: LinkingObjects<Group>
}

class Group:Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var items = RealmSwift.List<Item>()
}

Get Results

let realm = try! Realm()
let groups = realm.objects(Group.self)

Mutating Data

try! realm.write {
    let group = Group()
    realm.add(group, update: .all)
    group.items.append(Item())
}

Realm With SwiftUI

Model in Realm with SwiftUI is the same. However, how to getting results and mutating data are much different.

Get Results

Results are got implicitly.

import SwiftUI
import RealmSwift

struct ContentView: View {
    @ObservedResults(Group.self) var groups
    
    var body: some View {
        if let group = groups.last {
            MainSwiftUIView(group: group)
        } else {
            ProgressView().onAppear(perform: {
                $groups.append(Group())
            })
        }
    }
}

You can think @ObservedResults as some kind of Binding struct, its wrappedValue is the original Realm results.

public var wrappedValue: RealmSwift.Results<ResultType> { get }

When you call @ObservedResults(Group.self) var groups, Realm automatically created implicitly.

public init(_ type: ResultType.Type, configuration: RealmSwift.Realm.Configuration? = nil, filter: NSPredicate? = nil, keyPaths: [String]? = nil, sortDescriptor: RealmSwift.SortDescriptor? = nil)

Mutating Data

@ObservedResults(Group.self) var groups

The variable groups and $groups are different. groups is a frozen object of Group. While $groups is a ObservedResults object, it is unfrozen and is used to do mutation.

Frozen object mean the state of the object is unchanged, the normal Realm object is live and unfrozen.

private func add() {
    $group.items.append(Item())
}

@ObservedRealmObject

@ObservedRealmObject is similar like @ObservedResults, but it is not results, but for single realm object.

You should take care of the spelling. As there are a lot similar spellings. Like @ObservableObject, @ObservedObject, they all look like @ObservedRealmObject, if you misspell them, the compiler won't notify and you app won't work.
Also, there are two common naming space issues. RealmSwift.List<T> and SwiftUI.App.

References

Pop View and Void Stacked NavigationBars in SwiftUI

SwiftUI

We could use SwiftUI to pop a View to another, like this

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink(
                destination: PopView(),
                label: {
                    Text("Pop View")
                })
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct PopView:View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        Button(
            "Back",
            action: { self.presentationMode.wrappedValue.dismiss() }
        )
    }
}

This will create a button, when you press the button, a new pop up, like below.

Simulator Screen Shot - iPhone 12 Pro - 2021-08-12 at 15.49.44,

Stacked NavigationBars

import SwiftUI

struct PopSwiftUIView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        NavigationView {
            Button(
                "Back",
                action: { self.presentationMode.wrappedValue.dismiss() }
            ).toolbar(content: {
                Button(action: { }, label: {
                    Text("Done")
                })
            })
        }
    }
}

struct PopSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        PopSwiftUIView()
    }
}

Simulator Screen Shot - iPhone 12 Pro - 2021-08-12 at 16.06.17

Above code will run like this, you can see the Done button is stacked to a lower level.

That is because you should only use NavigationView one time. You don't need to use it again if you previously used it.

However, in this case, you need to add NavigationView to preview yourself.

import SwiftUI

struct PopSwiftUIView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        Button(
            "Back",
            action: { self.presentationMode.wrappedValue.dismiss() }
        ).toolbar(content: {
            Button(action: { }, label: {
                Text("Done")
            })
        })
    }
}

struct PopSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            PopSwiftUIView()
        }
    }
}

Hide Back Button

Use .navigationBarBackButtonHidden(true) to hide the back button.

Simulator Screen Shot - iPhone 12 Pro - 2021-08-12 at 16.12.52

References

Data State And Binding in SwiftUI

SwiftUI

In SwiftUI, the flows between data model and views are bidirectional automatically. That means, if a variable changes, the corresponding view changes too. The same is true on the reverse side.

@State

This mechanism is called binding. For variables within the same view, you can use @State property wrapper.

import SwiftUI

struct ContentView: View {
    @State var isEditing = true
    
    var body: some View {
        VStack {
            if isEditing {
                Text("Editing")
            } else {
                Text("Done")
            }
        }.onTapGesture {
            isEditing.toggle()
        }.frame(width: 200, height: 200, alignment: .center)
        .font(.title)
        .padding()
    }
}

When the code runs, the app shows a text with "Editing". If you click the text, the isEditing is toggled and app shows a text with "Done".

state_sample

@Binding

@State is used only its own view. If you need to pass @State to another view, the destination view should not use @State, but @Binding to get the @State variable.

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var isEditing = true
    
    var body: some View {
        ContentView(isEditing: $isEditing)
    }
}


import SwiftUI

struct ContentView: View {
    @Binding var isEditing:Bool
    
    var body: some View {
        VStack {
            if isEditing {
                Text("Editing")
            } else {
                Text("Done")
            }
        }.onTapGesture {
            isEditing.toggle()
        }.frame(width: 200, height: 200, alignment: .center)
        .font(.title)
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(isEditing: .constant(true))
    }
}

For @Binding variable, you must use .constant(true) as a parameter. Also there is a $ prefix for @State as a @Binding variable.

@Binding in Closure

Many times, we may need binding in closure. For example, doing things when List is selected. However, you will find that @Binding doesn't work in parameters in closure.

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var bools:[Bool] = Array(repeating: true, count: 10)
    
    var body: some View {
        List(bools, id: \.self) { isEditing in
            ContentView(isEditing: $isEditing) // Cannot find '$isEditing' in scope
        }
    }
}

That is because the parameter in closure cannot be used as @Binding.

New Trick

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var bools:[Bool] = Array(repeating: true, count: 10)
    
    var body: some View {
        List(bools.indices, id: \.self) { idx in
            ContentView(isEditing: $bools[idx])
        }
    }
}

We use Array.indices and idx to get the element from the struct property, this works with @Binding.

App Crash Issue When Delete Data With Nested Bindings

@Binding is useful. But you also need to take good card of it. Some times, it leads crashes. For example,

import SwiftUI

struct ContentView: View {
    static let deleteNum = Notification.Name("deleteNum")
    @Binding var num:Int
    
    var body: some View {
        HStack {
            Text("\(num)")
            Spacer()
            Button("Delete", action:delete)
        }
    }
    
    private func delete() {
        NotificationCenter.default.post(name: ContentView.deleteNum, object: self, userInfo: ["number":num])
    }
}

import SwiftUI

struct AnotherSwiftUIView: View {
    let pub = NotificationCenter.default.publisher(for: ContentView.deleteNum)
    @State var array = [1,2,3,4,5]
    
    var body: some View {
        List(array.indices, id:\.self) { idx in
            ContentView(num: $array[idx])
        }.onReceive(pub, perform: { noti in
            if let info = noti.userInfo,
               let num = info["number"] as? Int,
               let index = array.firstIndex(of: num) {
                array.remove(at: index)
            }
        })
    }
}

struct AnotherSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        AnotherSwiftUIView()
    }
}

nested_bindings

The app will crash when you click the "Delete" button. The issue happens because the array is changed but the array.indices are not changed accordingly in the app.
We need to double check the index.

import SwiftUI

struct AnotherSwiftUIView: View {
    let pub = NotificationCenter.default.publisher(for: ContentView.deleteNum)
    @State var array = [1,2,3,4,5]
    @State var indexSet = IndexSet()
    
    var body: some View {
        List(array.indices, id:\.self) { idx in
            Safe($array, index: idx) { binding in
                ContentView(num: binding)
            }
        }.onReceive(pub, perform: { noti in
            if let info = noti.userInfo,
               let num = info["number"] as? Int,
               let index = array.firstIndex(of: num) {
                array.remove(at: index)
            }
        })
    }
}

struct AnotherSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        AnotherSwiftUIView()
    }
}

struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
   
   typealias BoundElement = Binding<T.Element>
   private let binding: BoundElement
   private let content: (BoundElement) -> C

   init(_ binding: Binding<T>, index: T.Index, @ViewBuilder content: @escaping (BoundElement) -> C) {
      self.content = content
      self.binding = .init(get: { binding.wrappedValue[index] },
                           set: { binding.wrappedValue[index] = $0 })
   }
   
   var body: some View {
      content(binding)
   }
}

References

Sidebar, Hybrid Programming SwiftUI with AppKit

macOSSwiftUISwift

You could get a NavigationView easily in SwiftUI. However, in macOS, there is an issue. If you collapse the sidebar and release your mouse, you cannot expand it again. What even worse, the state is also be remembered by the system and you could not get the sidebar even if you restart the app.

Here is a sample.

import SwiftUI

struct SidebarSwiftUIView: View {
    @State private var shouldShowPurple = false
    
    var body: some View {
        NavigationView {
            List {
                NavigationLink(
                    destination: DestinationPageView(color: .purple),
                    isActive: $shouldShowPurple
                ) {
                    Text("Purple Page")
                }
                NavigationLink(
                    destination: DestinationPageView(color: .pink)
                ) {
                    Text("Pink Page")
                }
                NavigationLink(
                    destination: DestinationPageView(color: .orange)
                ) {
                    Text("Orange Page")
                }
            }
            .frame(minWidth: 180, idealWidth: 200, maxWidth: 250, alignment: .leading)
            .navigationTitle("Colors")
            Text("Select a color page from the links.")
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) {
                shouldShowPurple = true
            }
        }
    }
}

struct SidebarSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SidebarSwiftUIView()
    }
}

struct DestinationPageView: View {
    var color: Color
    var body: some View {
        Text("Destination Page")
            .font(.title)
            .foregroundColor(color)
            .frame(minWidth: 300, idealWidth: 450, maxWidth:.infinity)
    }
}

It runs like this.

macOS_sidebar
The issue:

macOS_collapse_sidebar

Hybrid Programming With AppKit

Hybrid SwiftUI with AppKit is easy. It takes 2 steps.

NSHostingViewController

  1. Create a NSHostingViewController in Storyboard.
  2. Create HostingViewController.swift and init it with the SwiftUIView.
import Cocoa
import SwiftUI

class HostingController: NSHostingController<SidebarSwiftUIView> {
    @objc required dynamic init?(coder: NSCoder) {
        super.init(coder: coder, rootView: SidebarSwiftUIView())
    }
}

Sidebar

Unless in iOS/iPadOS, there is no sidebar for navigation bar in macOS. So you have to do it yourself. Just simply add a toolbar in WindowController.

add_toolbar_in_window_controller

Make sure to set the tool item as Navigational in Position.

leading_the_sidebar

Toggle the Sidebar

SwiftUI will create a NSSplitViewController automatically. So we use respond chain to collapse/expand the sidebar.

import Cocoa

@main
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Insert code here to initialize your application
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }

    @IBAction private func showSideBar(sender:Any?) {
        NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
    }
}

Bind the action to first responder.

action_binding

Final

Now the app will look like this.

final_sidebar_window

References

Collapse sidebar in SwiftUI (Xcode 12)

closure导致的循环引用

Swift

最近在开发macOS版的咕唧2,遇到了视图控制器没有释放的问题。花了些时间进行调试。结果总结如下:

我们知道,如果存在循环引用,就会导致类无法释放。因此,我们通常会使用weak var delegate的方式来避免循环引用。

这里不谈一般的循环引用,谈谈closure的循环引用。

closure的循环引用

view controllers

如图,点击上面视图控制器的Next按钮,会自动弹出下面的视图控制器。代码如下:

import Cocoa

class ViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }
}
import Cocoa

class V2ViewController: NSViewController {
    private let a = 100
    private var foo:Foo!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        foo = Foo(doStaff)
        foo.run()
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    func doStaff() {
        print(self.a)
    }
}

class Foo {
    private(set) var bar:() -> ()
    
    init(_ bar:@escaping () -> ()) {
        self.bar = bar
    }
    
    deinit {
        print("Foo deinit.")
    }
    
    func run() {
        bar()
    }
}

运行并关闭弹出的视图控制器,控制台没有输出deinit的打印。存在内存泄漏。

分析

这是因为Foo中的bar,引用了V2ViewControllerdoStaff。而doStaff引用了self

解决

对于closure,我们不能使用weak,只能使用其它的方式。

方法1

调用完成后手动释放。

func run() {
    bar()
    bar = {}
}

方法2

还是手动释放,不过将bar定义为(()->())?,即Opitional

class Foo {
    private(set) var bar:(() -> ())?
    
    init(_ bar:@escaping () -> ()) {
        self.bar = bar
    }
    
    deinit {
        print("Foo deinit.")
    }
    
    func run() {
        bar?()
        bar = nil
    }
}

方法3

更改调用的方式,不直接分配函数,而是增加一层closure

override func viewDidLoad() {
    super.viewDidLoad()
    
    foo = Foo({ [unowned self] in
        self.doStaff()
    })
    
    foo.run()
}

通常我们认为直接分配和增加一层是等价的。但是这里我们看到,实际上,二者并不完全等价。

结论

综合起来,我认为方法3的解决方案,修改代码的成本是最低的。因为它是直接从源头解决了问题。

iOS项目CocoaPods的安装与使用

SwiftRealmSwiftXcode

安装

网上看了好几篇文章,综合起来才成功,这个是在macOS Sierra 10.12.4 (16E195)下正确安装CocoaPods的步骤:

1. 替换源为镜像

gem sources -l

查看当前的gem源的地址,由于墙的缘故,如果源是https://rubygems.org/,我们需要将它删除。

gem sources --remove https://rubygems.org/

然后替换为ruby china的镜像。

gem sources --add https://gems.ruby-china.org/

确认源替换是否成功

gem sources -l

2. 升级gem

系统自带的gem版本较低,使用时会出现莫名其妙的问题,因此要把它升级到最新版

sudo gem update --system

如果上面代码提示没有权限,改成

sudo gem update -n /usr/local/bin --system

3. 安装cocoapods

在macOS 10.11和10.12中安装时,安装到/usb/bin会提示错误,因此需要安装到/usb/local/bin里

sudo gem install -n /usr/local/bin cocoapods

4. 替换源

默认的源更新起来很慢,需要替换才能变快。如果你想使用默认的,可以跳过这一步。

查看当前的源:

pod repo 

删除master:

pod repo remove master

添加清华的源:

git clone https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git ~/.cocoapods/repos/master

更新源:

pod repo update

5. 更新pod

如果你做过上一步,可以跳过这一步。

pod setup

这步时间较长,需要耐心等待。

使用

如果你使用了第4步的替换源,那么需要在你的每个Podfile的最前面,添加一行。

source 'https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git'
  1. 新建一个iOS的项目。
  2. 在终端,进入到该项目的文件夹。
  3. 执行pod init
  4. 打开自动生成的Podfile文件
  5. 在其中添加你要使用的框架的名称
  6. 保存好Podfile文件
  7. 执行pod install
  8. 在Xcode中,关闭你的项目
  9. 在文件夹,找到pod生成的.xcworkspace文件,打开它。
  10. 像平常一样使用框架就可以了。

比如我新建的项目叫PodTest,添加了RealmSwift,最终的Podfile如下:

这其中大部分的内容都是pod自动生成的,我需要修改/添加的只有第三行,iOS的版本。第四行,去除警告。以及pod 'RealmSwift'的那一行。

最后的那段,是RealmSwift网站要求的。你需要什么框架,就到那个框架的官网,按照提示复制粘贴上去就可以了。

最后记得,每次修改完Podfile之后,都要记得运行一遍pod install

# Uncomment the next line to define a global platform for your project
platform :ios, '10.3'
inhibit_all_warnings!

target 'PodTest' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!
  pod 'RealmSwift'

  # Pods for PodTest

  target 'PodTestTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'PodTestUITests' do
    inherit! :search_paths
    # Pods for testing
  end

end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['SWIFT_VERSION'] = '3.1'
    end
  end
end

小技巧

  1. 第四行的inhibit_all_warnings!,可以使得Xcode不对框架中不当的帮助说明生成警告。因为框架是第三方的,我们即便看到了,也不会修改它。
  2. 有时会由于网络问题导致下载的框架不全,此时可以使用这个命令重新下载。
pod deintegrate && pod install

参考资料

循环结构中异步代码实现灵活退出(下)

Swift

循环结构中异步代码实现灵活退出(上)

上一篇中,我们实现了基本结构。但是如果每次都这么做,会比较麻烦。这一篇中,我们尝试将代码封装起来,这样以后我们再做时,只需调用一次就可以了。

首先,我们将之前的代码变成closure。

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        concurrentQueue.async {
            self.run()
        }
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i, withClosure: { (result) in
                DispatchQueue.main.async {
                    self.textView.string += "\(i)\n"
                }
                
                if i == 5 {
                    result = true
                }
            }) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int, withClosure closure: @escaping (inout Bool) -> ()) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)
        
        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            closure(&result)
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }

    @IBOutlet var textView: NSTextView!
}

接下来,我们新建一个文件RangeEnumerator.swift,扩展Sequence

import Foundation

extension Sequence {
    public func breakableForEach(closureWithCondition: @escaping (Element, inout Bool)->()) {
        let semaphore = DispatchSemaphore(value: 0)
        
        DispatchQueue.global().async {
            for e in self {
                if self.shouldBreak(semaphore, e, withClosure: closureWithCondition) {
                    break
                }
            }
        }
    }
    
    private func shouldBreak(_ semaphore:DispatchSemaphore, _ e:Element, withClosure closure: @escaping (Element, inout Bool) -> ()) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)
        
        DispatchQueue.global().asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            closure(e, &result)
            semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
}

之后,最初的代码就可以简化为。

class ViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        (1...10).breakableForEach { (i, result) in
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }

            if i == 5 {
                result = true
            }
        }
    }
    
    @IBOutlet var textView: NSTextView!
}

另外,由于扩展中不能创建存储变量,所以,之前的信号量和队列,就必须放在函数里了。

循环结构中异步代码实现灵活退出(上)

Swift

在同步代码时,循环要提前退出十分简单。

for i in 1...10 {
    print(i)
    
    if i == 5 {
        break
    }
}

但是在异步代码中,要灵活退出就不那么容易了。比如,在调用RestAPI时,如果一切正常,就执行下一次循环,如果出错,则进行提示用户,进行重试或者退出。这个就属于异步操作。

import Cocoa

class ViewController: NSViewController {
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        run()
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            if i == 5 {
                result = true
            }
        }
        
        return result
    }
}

由于采用了异步,shouldBreak(_ i:Int) -> Boolresult是先于DispatchQueue.main.async中的代码执行的。因此输出始终是1-10

为保证执行的顺序,需要使用信号量。

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        run()
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            if i == 5 {
                result = true
            }
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
}

代码执行正确。下面我们加入改变UI的部分。添加一个NSTextView,让它显示每次的i

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        run()
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }
            
            if i == 5 {
                result = true
            }
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
    
    @IBOutlet var textView: NSTextView!
}

代码执行后,我们会发现,textView中的i,不是一行一行显示的,而是一开始不显示,然后一下子都显示出来。这和我们期望的不符。

这是什么原因造成的呢?其实,这是因为视图控制器中的代码,默认运行在图形线程,因此semaphore.wait()其实每次都阻塞了图形线程。这导致textView一直没法刷新。直到循环跳出后,界面才成功刷新。

知道了原因,解决办法就有了。将代码从默认的图形线程中移除即可。最终代码:

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        concurrentQueue.async {
            self.run()
        }
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }
            
            if i == 5 {
                result = true
            }
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
    
    @IBOutlet var textView: NSTextView!
}

循环结构中异步代码实现灵活退出(下)