肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

将协议默认实现作为`selector`进行调用

Swift

这个是在swift-users邮件列表遇到的。原标题是Calling default implementation of protocol methods as selectors。原本是个很简单的问题,但是经过几轮讨论,得到的收获还是蛮大的。

先来看问题

有如下代码

protocol Foo: class {
    func bar()
}

extension Foo {
    func bar() {
        print("bar")
    }
}

class Baz: Foo {
    init() {
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(bar))  // error: argument of '#selector' refers to instance method 'bar()' that is not exposed to Objective-C
    }
}

编辑器提示无法调用这个#selector,因为它bar()并没有暴露给Objctive-C。而protocol不能插入@objc

如何解决

方法1

不使用协议扩展,而使用基类。

protocol Foo: class {
    func bar()
}

class Base:Foo {
    @objc func bar() {
        print("bar")
    }
}

class Baz: Base {
    override init() {
        super.init()
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(bar))
    }
}

方法2

使用一个代理函数。

protocol Foo: class {
    func bar()
}

extension Foo {
    func bar() {
        print("bar")
    }
}

class Baz: Foo {
    init() {
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(delegate))
    }
    
    @objc func delegate() {
        bar()
    }
}

这两个办法中,1比2好。因为1方便修改。而如果2需要扩展的话,就需要每个都写一遍。太麻烦。

方法3

我也尝试了convenience init(target: Any, closure: @escaping () -> ())的方法,但是我放弃了。因为我发现,我没法调用closure

后续的邮件中,有人提出了可以内嵌一个类,来实现调用closure。这个是他的实现。

public class BlockTapGestureRecognizer: UITapGestureRecognizer {
    private class Target: NSObject {
        private let closure: (UITapGestureRecognizer) -> ()

        init(closure: @escaping (UITapGestureRecognizer) -> ()) {
            self.closure = closure
            super.init()
        }

        @objc func performClosure(_ sender: Any?) {
            guard let recognizer = sender as? UITapGestureRecognizer else {
                print("Unexpected sender (expected UITapGestureRecognizer)")
                return
            }

            self.closure(recognizer)
        }
    }

    private let target: Target

    public init(closure: @escaping (UITapGestureRecognizer) -> ()) {
        self.target = Target(closure: closure)
        super.init(target: self.target, action: #selector(Target.performClosure(_:)))
    }
}

深入探讨NotificationCenter的addObserver(forName:object:queue:using:)中的queque

iOSwatchOSmacOS

先看看下面的代码,这两段notiObserver有区别吗?

override func viewDidLoad() {
    super.viewDidLoad()
    
    let noti = Notification(name: MyNotificationName)
    var v = 0
    let center = NotificationCenter.default
    var notiObserver:NSObjectProtocol! = nil
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: .main) {_ in 
        if v == 10 { center.removeObserver(notiObserver) }
        else {
            v += 1
            center.post(noti)
        }
        print(v)
    }
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: nil) { _ in
        DispatchQueue.main.async {
            if v == 10 { center.removeObserver(notiObserver) }
            else {
                v += 1
                center.post(noti)
            }
            print(v)
        }
    }
    
    center.post(noti)
}

无论你觉得有区别,还是没区别,你可以分别注释掉其中一个然后运行。运行结果是这样的。

上面的那段会输出

10
10
10
10
10
10
10
10
10
10
10

下面那段会输出

1
2
3
4
5
6
7
8
9
10
10

这是为什么呢?我们来看文档。苹果的文档在提到queue是这么说的。

queue
The operation queue to which block should be added.
If you pass nil, the block is run synchronously on the posting thread.

也就是说,它默认是在队列中同步执行。即便我们换成了.main,它也不是我们以为的是异步执行的。实际上,我们可以测试出来,这段代码,在运行时,是堆栈式的。新到的Notification,会进行压栈,优先执行。然后才是旧的Notification没执行完的代码。

如图所示,从左到右是时间轴,依次运行的3段代码,绿色的center.post(noti)导致黄色被执行,黄色的center.post(noti)导致蓝色被执行……,一直到v==10,最后的一段代码print(v),才会从上到下执行下来。

stack
结论:如果我们想要代码符合我们的预期的顺序,应该使用第二种方式,通过GCD,将代码加入到顺序执行的queue中去。

iOS SDK出错,导致返回值为空指针问题的处理

Swift

正常来讲,苹果的SDK是不会在Swift的非Optional的类型返回空指针的。如果我们遇到了这个情况,就应该立刻向苹果提交错误报告,让苹果修复这个API。文中提到的这个API,我已经向苹果提交了错误报告,期待苹果能尽快修复。下面来谈谈遇到这种情况要如何处理。

今天发现的坑是这个,func calendarItem(withIdentifier identifier: String) -> EKCalendarItem,它是EKEventStore的实例的方法,可以用来同时查询EKEventEKReminder匹配对应id的值。这个id指的是EKCalendarItemcalendarItemIdentifier

实际上如果只是想获得EKEvent,我们应该使用func event(withIdentifier identifier: String) -> EKEvent?,可以看到,这个方法的返回值是Optional<EKEvent>类型的,因此不会有空指针的问题。

代码示例:
在获得了访问日历和提醒事项的权限后,我们先创建一个日历和一个事件,保存这个日历和事件,然后删除事件。最后通过事件id来查询这个事件。由于此时事件已经被删除了,打印这个事件会导致程序崩溃。这个例子的意义在于,由于现在我们的日历、提醒事项都是同步的,很有可能的当前设备的事件,在其它设备被删掉了,而此时如果你还用原来的id查询,程序就有可能崩溃,因此需要额外的处理。

import UIKit
import EventKit

class ViewController: UIViewController {
    var store:EKEventStore!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        store = (UIApplication.shared.delegate as! AppDelegate).store
        
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [unowned self] (timer) in
            let status = EKEventStore.authorizationStatus(for: .event)
            if status == .authorized {
                timer.invalidate()
                // add a new event calendar
                let calendar = EKCalendar(for: .event, eventStore: self.store)
                calendar.title = "issue test"
                calendar.source = self.store.sources.filter({ $0.sourceType == .local || $0.sourceType == .calDAV}).first!
                try! self.store.saveCalendar(calendar, commit: false)
                
                // add a new event to above calendar
                let event = EKEvent(eventStore: self.store)
                event.calendar = calendar
                event.title = "Have a rest"
                event.startDate = Date()
                event.endDate = Date().addingTimeInterval(30 * 60)
                try! self.store.save(event, span: .thisEvent)
                
                // commit
                try! self.store.commit()
                
                // store the id of the event 
                let id = event.calendarItemIdentifier
                
                // remove the event
                try! self.store.remove(event, span: .thisEvent, commit: true)
                
                // get the event
                let item = self.store.calendarItem(withIdentifier: id)
                print(item)
            }
        }
        
    }
}

由于我们添加了事件之后,又对事件进行了删除。此时再用事件的id获取项目时,项目实际应该返回nil,但是由于苹果设计这个API出现了问题,返回的不是Optional,而是确定的类型,这导致该值为一个空指针。此时,你就算想使用try catch,也是不行的,因为它并没有throw。此时你对它做的大部分操作,都会导致程序崩溃。难道就没有路可以走了吗?其实也不是。

虽然它不是Optional类型,但是它是确定类型的空指针,这个是苹果API的错误,是从Objective-c转换到Swift时出现的。我们自己写的Swift不会允许这样的情况出现。

某种角度来说,它其实也是个nil,因此,我们可以使用if let as?来进行匹配

 // get the event
 let item = self.store.calendarItem(withIdentifier: id)
 
 if let event = item as? EKEvent {
     print(event)
 }
 else if let reminder = item as? EKReminder {
     print(reminder)
 }
 else {
     print("item is a null pointer")
 }

这样就可以临时处理掉这个空指针的问题了。

UserDefaults的正确用法(修正版)

Swift

通常使用UserDefaults的方法有两种。

func applicationDidFinishLaunching(_ aNotification: Notification) {
    let dic = ["test":true]
    let id = Bundle.main.bundleIdentifier!
    // 1
    UserDefaults.standard.setPersistentDomain(dic, forName: id)
    // 2
    UserDefaults.standard.register(defaults: dic)
}

我们知道,PersistentDomain是永久的,会写入到磁盘。而register是临时的,每次程序启动都需要重新加载。因此,上面的代码有一些问题,需要改为

func applicationDidFinishLaunching(_ aNotification: Notification) {
   let dic = ["test":true]
   let id = Bundle.main.bundleIdentifier!
   if let _ = UserDefaults.standard.persistentDomain(forName: id) {
       
   }
   else {
       UserDefaults.standard.setPersistentDomain(dic, forName: id)
   }
   
   UserDefaults.standard.register(defaults: dic)
}

即,必须先确定没有这个PersistentDomain,然后才能注册。当重置设置为默认时,二者的代码也有一些差异。

let dic = ["test":true]
let id = Bundle.main.bundleIdentifier!
// 1
UserDefaults.standard.removePersistentDomain(forName: id)
UserDefaults.standard.setPersistentDomain(dic, forName: id)
// 2
UserDefaults.standard.resetStandardUserDefaults()

上面代码1是大家经常在网上看到的,但是它实际存在问题,是不正确的。这段代码表面看起来似乎没什么问题,第一步是删除所有设置,第二部是将设置设为默认。但是,如果你考虑到通知的,就会明白了。假设我们有一个注册到UserDefaults.didChangeNotification的通知。那么第一步就会发出通知,第二步还会再发一次。但是,由于第一步实际上是删除了所有的设置,此时程序有极大的可能会出错。

如果要解决这个问题,上面的代码可能要改为先解除注册的通知,然后删除所有设置,然后再注册通知,再将设置改为默认。但是且慢,我们真的需要这么做吗?为什么一定要清空才能再设置呢?难道不是直接设置就可以了吗?是的,其实直接设置就可以了,完全没必要清空。这样代码也就变成了下面这样:

let dic = ["test":true]
let id = Bundle.main.bundleIdentifier!
// 1
UserDefaults.standard.setPersistentDomain(dic, forName: id)
// 2
UserDefaults.standard.resetStandardUserDefaults()

那么方法2是怎么回事?可不可以也采用直接设置的方式?比如UserDefaults.standard.register(defaults: dic)。实际上是不可以的。这涉及到UserDefaults的原理。在实际使用中,系统是将registration domain和其它domain联合使用的。苹果的文档这样写到。

The registration domain defines the set of default values to use if a given preference is not set explicitly in one of the other domains.
如果其它domain没有设置某个设置,就使用registration domain定义的设置,即它实际是fail safe的默认值。

实际上registration domain,是临时的设置,你在每次启动程序时,都需要设置它。但是,如果用户将registration domain中含有的项的值改变了,系统就会自动将改变的内容写入到一个以你的程序命名的plist中。这里的值的优先级别,要比registration domain里的值的级别高,程序会以这里的为准。当然,你也可以通过直接写入PersistentDomain方法来修改这里的值。但是我们一般不会这么做。而是使用UserDefaults的一系列set方法,当使用set方法时,系统会自动将设置写入到plist中。

使用registration domain的好处

  1. 用户仅能见到非默认的设置,如果有些设置你不打算让用户知道或修改,这样更安全。
  2. 注册和删除的方式更加优雅。
  3. 添加新设置时更为方便,无需考虑程序版本。

结论

正确的使用UserDefaults的方法是:

  1. 在程序每次启动时调用registration domain来写如程序的默认值
  2. 当默认值被被用户修改时,调用UserDefaults的系列set方法来调用
  3. 除非要写入到其它的plist,我们一般不必使用PersistentDomain

WKWatchConnectivityRefreshBackgroundTask is never triggered in background, but WKSnapshotRefreshBackgroundTask

watchOS

Conclusion first

Currently WKWatchConnectivityRefreshBackgroundTask is only called for sure on a watchOS Simulator when the watchOS extension's WCSession is in notActivated state and the extension is not running in foreground (in background or terminated).

In real devices, it won't be called in my tests. But Apple docs says it may. So you shouldn't rely on it won't be called until Apple changes its docs.

WCSession Cores

WCSession Core

For WCSession, when it is activated, you can transfer userInfo, and when the counterpart is active, it can get the userInfo. The counterpart won't need to be in foreground to be activated, it can be in a high priority background.

Testing Results

Here are my testing results.

In Simulators and in devices

How to make WCSession notActivated?

  1. Using Xcode terminate your watchOS Extension. Xcode will send a kill signal to your WKExtension.
  2. Or don't run WCSession.activate() in your code on watchOS Extension side. As WCSession is notActivated by default.

WatchConnectivity的一些坑

watchOS

func updateApplicationContext([String : Any])的传输

这个方法的传输会对数据进行优化,连续发送相同的数据,后发的不会被传输。

正如这个函数的名称那样,这个函数用于发送程序状态的信息,如果有多个状态,那么连续发送相同的,只有第一个会被发送。

与WKWatchConnectivityRefreshBackgroundTask的关系


  1. When transferring data between iOS and watchOS, there must be an activated WCSession in the sending app. That is the first condition to check before sending data.
  2. For sendMessage(_:replyHandler:errorHandler:) and sendMessageData(_:replyHandler:errorHandler:), it is needed to check isReachable to be true as well.
  3. For a watchOS app sending data to iOS, you need to check iOSDeviceNeedsUnlockAfterRebootForReachability for if the iOS device needs to unlock after rebooting.
  4. For an iOS app sending data to watchOS, you may also check isPaired and isWatchAppInstalled before sending any data.
// iOS app
class AppDelegate: UIResponder, UIApplicationDelegate, WCSessionDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        if WCSession.isSupported() {
            let session = WCSession.default()
            session.delegate = self
            session.activate()
        }
        
        return true
    }
    
    extension AppDelegate:WCSessionDelegate {
    public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        guard error == nil else { fatalError(error!.localizedDescription) } 
    }
}
// view controller
class ViewController: UIViewController {
    @IBAction func sendButtonClicked(_ sender: Any) {
        if WCSession.isSupported() {
            let session = WCSession.default()
            if session.activationState == .activated {
                if session.isWatchAppInstalled && session.isPaired {
                    session.transferUserInfo(["send test":nil])
                }
            }
        }
    }
}

// watchOS app
class ExtensionDelegate: NSObject, WKExtensionDelegate {

    func applicationDidFinishLaunching() {
        // Perform any final initialization of your application.
        let session = WCSession.default()
        session.delegate = self
        session.activate()
    }

    func applicationDidBecomeActive() {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
    }

    func applicationWillResignActive() {
        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
        // Use this method to pause ongoing tasks, disable timers, etc.
    }
    
    var total = 0

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        // Sent when the system needs to launch the application in the background to process tasks. Tasks arrive in a set, so loop through and process each one.
        for task in backgroundTasks {
            // Use a switch statement to check the task type
            switch task {
            case let backgroundTask as WKApplicationRefreshBackgroundTask:
                // Be sure to complete the background task once you’re done.
                backgroundTask.setTaskCompleted()
            case let snapshotTask as WKSnapshotRefreshBackgroundTask:
                // Snapshot tasks have a unique completion call, make sure to set your expiration date
                snapshotTask.setTaskCompleted(restoredDefaultState: true, estimatedSnapshotExpiration: Date.distantFuture, userInfo: nil)
            case let connectivityTask as WKWatchConnectivityRefreshBackgroundTask:
                // Be sure to complete the connectivity task once you’re done.
                total += 1
                DispatchQueue.main.async {
                    if let viewController = WKExtension.shared().rootInterfaceController as? InterfaceController {
                        viewController.activeBurnedEnergyLabel.setText(String(self.total))
                    }
                }
                
                connectivityTask.setTaskCompleted()
            case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
                // Be sure to complete the URL session task once you’re done.
                urlSessionTask.setTaskCompleted()
            default:
                // make sure to complete unhandled task types
                task.setTaskCompleted()
            }
        }
    }

}

extension ExtensionDelegate: WCSessionDelegate {
    public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        guard error == nil else { fatalError(error!.localizedDescription) }
    }
    
    /** -------------------------- Background Transfers ------------------------- */
    
    public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        total += 4
        DispatchQueue.main.async {
            if let viewController = WKExtension.shared().rootInterfaceController as? InterfaceController {
                viewController.activeBurnedEnergyLabel.setText(String(self.total))
            }
        }
    }
}

Swift程序内部对于锁的处理

Swift

我们在同步时,经常会遇到不同来源的数据同时到达的问题,这个时候,我们就需要先锁定资源,然后再对数据依次进行处理。

这里,我们通过一个开关来简单的模拟这个锁定的过程,当开关打开时,点击按钮的信息会被延后;在开关关闭时,延后的消息会执行。

s

方案一:忙等待

忙等待是最简单的处理方式。程序反复检测开关是否关闭,如果不是,则继续检测;如果是,则执行任务。

@IBAction func printHello(_ sender: Any) {
    // 忙等待
    DispatchQueue(label: "default").async {
        while true {
            if self.shouldWaitSwitch.isOn { continue }
            
            print("你好!")
            break
        }
    }
}

@IBOutlet weak var shouldWaitSwitch: UISwitch!

忙等待的优点是实现简单。缺点是忙等待会一直占用CPU。如果预期等待的时间会很长,则应避免使用忙等待。

方案二:延时处理

为避免忙等待一直占用CPU的问题,我们可以使用延迟处理的方式,即创建一个计时器,每间隔一定的时间,就检查一次状态,看可不可以执行。如果可执行,则执行,并终止计时器;如果不能执行,则等待下一次的查看。

@IBAction func printHello(_ sender: Any) {
    // 延迟
    if shouldWaitSwitch.isOn {
        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [unowned self] (timer) in
            if self.shouldWaitSwitch.isOn { return }
            
            print("你好!")
            timer.invalidate()
        })
    }
    else {
        print("你好!")
    }
}

@IBOutlet weak var shouldWaitSwitch: UISwitch!

延迟处理的好处是大大降低了等待时的CPU的占用。缺点是,当开关变为关闭时,不会立即执行,而是可能会有一段间隔。如果时间间隔设置不当,或者开关切换得很频繁,则可能产生饥饿现象。

方案三:消息绑定

消息绑定在开关状态改变时,立即执行相应操作。

@IBAction func printHello(_ sender: Any) {
    // 消息绑定
    if shouldWaitSwitch.isOn {
        if let _ = shouldWaitSwitch.actions(forTarget: self, forControlEvent: .valueChanged) { // already set
            return
        }
        
        shouldWaitSwitch.addTarget(self, action: #selector(printHello(_:)), for: .valueChanged)
    }
    else {
        print("你好!")
    
        if let _ = shouldWaitSwitch.actions(forTarget: self, forControlEvent: .valueChanged) { // if set
            shouldWaitSwitch.removeTarget(self, action: #selector(printHello(_:)), for: .valueChanged)
        }
    }
}

@IBOutlet weak var shouldWaitSwitch: UISwitch!

消息绑定的优点是CPU占用低、响应迅速。缺点是实现起来比较复杂。特别是,一旦绑定了某个操作,这个操作在完成后,要尽量取消绑定,或者使其在开关状态发生改变时,刚好成为不满足运行条件的状态。


队列

上面解决问题的方案,都是只能执行一次打印功能。如果我们需要记录每一次的按钮点击,上面的方案就不适用了。这时我们就需要额外维护一个队列,来记录每一次的点击。这里通过扩展消息绑定的代码来实现这个队列功能。

当开关开启时,如果有任务来,我们就向队列插入一个任务。当开关关闭时,我们执行这个任务。在执行任务之前,我们对于队列进行锁定,此时不能插入新任务,由于我们确认执行时间会很短,所以插入这个新任务的等待这里采用了忙等待。当任务执行完毕后,队列锁定被打开。

@IBAction func printHello(_ sender: Any) {
    // 队列,以消息绑定为例
    if shouldWaitSwitch.isOn {
        while true {
            let isSuccess = WaitingTaskQueue.append(task: "你好!")
            if isSuccess { break } else { continue }
        }
        
        if let _ = shouldWaitSwitch.actions(forTarget: WaitingTaskQueue.self, forControlEvent: .valueChanged) { // already set
            return
        }

        shouldWaitSwitch.addTarget(WaitingTaskQueue.self, action: #selector(WaitingTaskQueue.dealingTasks), for: .valueChanged)
    }
    else {
        print("你好!")
        
        if let _ = shouldWaitSwitch.actions(forTarget: WaitingTaskQueue.self, forControlEvent: .valueChanged) { // if set
            shouldWaitSwitch.removeTarget(WaitingTaskQueue.self, action: #selector(WaitingTaskQueue.dealingTasks), for: .valueChanged)
        }
    }
}

@IBOutlet weak var shouldWaitSwitch: UISwitch!

class WaitingTaskQueue {
    private static var firstItemPosition = 0
    private static var queue:[String] = []
    private static var isLocked = false
    
    @objc static func dealingTasks() {
        guard !isLocked else {
            return
        }
        
        isLocked = true
        
        var relativePosition = 0
        while relativePosition < queue.count {
            print("\(queue[relativePosition]), \(firstItemPosition + relativePosition)")
            relativePosition += 1
        }
        queue.removeAll(keepingCapacity: true)
        firstItemPosition += relativePosition
        
        isLocked = false
    }
    
    static func append(task:String) -> Bool { // is success
        guard !isLocked else {
            return false
        }
        
        queue.append(task)
        
        return true
    }
}

结论

采用消息通知,结合忙等待是最好的方式。延迟技术,存在较大的局限性,例如容易造成饥饿或者任务的执行顺序无序等问题,应该仅在特定的条件下使用。