肇鑫的技术博客

业精于勤,荒于嬉

UserDefaults的正确用法(修正版)

通常使用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

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

结论

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

"Closure cannot implicitly capture a mutating self parameter"问题的处理

struct中,如果我们在closure中使用self,就会得到Closure cannot implicitly capture a mutating self parameter的错误提示。比如:

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let closure = {
            self.bar = 50 // Closure cannot implicitly capture a mutating self parameter
        }
        
        closure()
    }
}

并且由于Foo的类型是struct,我们也没发在closure里添加截获列表。那么是不是就必须使用class了?答案是否定的。有两种方式可以解决这个问题。

方案一:为closure增加一个inout类型的参数

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let closure = { (s:inout Foo) -> () in
            s.bar = 50
        }
        
        closure(&self)
    }
}

根据inout类型的说明,我们知道,实际上这相当于增加了一个隐藏的临时变量,self被复制,然后在closure中使用,完成后,再复制回self。也就是说,这个方法有额外的内存开销。如果是struct较大的情形,这么做并不划算。

方案二:使用UnsafeMutablePointer<Pointee>

这次采用直接指针的方式对于struct来进行操作,采用指针的好处是self不会被多次复制,性能较高。缺点是你需要自行确定你的代码的安全。

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let selfPointer = UnsafeMutablePointer(&self)
        
        let closure = {
            selfPointer.pointee.bar = 50
        }
        
        closure()
    }
}

结论

Closure cannot implicitly capture a mutating self parameter错误的原因是在进出closure之后,self的一致性没办法得到保证,所以编译器默认不允许在structclosure中使用self。如果我们确定这么做是安全的,就可以通过上面的两种方式解决这个问题。其中,方法二的性能更好一些。

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let closure = {
            self.bar = 50 // Closure cannot implicitly capture a mutating self parameter
        }
        
        closure()
    }
}

注意
这里可以记一下指针和swift变量之间的关系:
UnsafePointer对应let
UnsafeMutablePointer对应var
AutoreleasingUnsafeMutablePointer对应unowned UnsafeMutablePointer,用于inout的参数类型
UnsafeRawPointer对应let Any,raw系列都是对应相应的Any类型
UnsafeBufferPointernon-owning的类型(unowned),用于collectionelements, buffer系列均如此