@AppStorage研究

前言

在苹果生态的应用中,开发者或多或少都会使用到UserDefaults。我个人习惯将可被用户自定义的配置信息(精度、单位、色彩等)保存在UserDefaults中。随着配置信息的增加,在SwiftUI视图中使用的@AppStorage越来越多。

【健康笔记3】中,我计划开放更多的自定义选项给用户,简单的算下来要有40-50项,在配置视图中更会将所有用到的UserDefaults内容都注入进代码。

本文探讨的是如何优雅、高效、安全地在SwiftUI中使用@AppStorage,在不借助第三方库的情况下,解决当前@AppStorage使用中出现的痛点:

  • 支持的数据类型少
  • 声明繁琐
  • 声明容易出现拼写错误
  • 大量@AppStorage无法统一注入

@AppStorage基础指南

@AppStorage是SwiftUI框架提供的一个属性包装器,设计初衷是创建一种在视图中保存和读取UserDefaults变量的快捷方法。@AppStorage在视图中的行为同@State很类似,其值变化时将导致与其依赖的视图无效并进行重新绘制。

@AppStorage声明时需要指定在UserDefaults中保存的键名称(Key)以及默认值。

@AppStorage("username") var name = "fatbobman"

userName为键名称,fatbobman是为username设定的默认值,如果UserDefaults中的username已经有值,则使用保存值。

如果不设置默认值,则变量的为可选值类型

@AppStorage("username") var name:String?

默认情况下使用的是UserDefaults.standard,也可以指定其他的UserDefaults。

public extension UserDefaults {
    static let shared = UserDefaults(suiteName: "group.com.fatbobman.examples")!
}

@AppStorage("userName",store:UserDefaults.shared) var name = "fat"

对UserDefaults操作将直接影响对应的@AppStorage

UserDefaults.standard.set("bob",forKey:"username")

上述代码将更新所有依赖@AppStorage("username")的视图。

UserDefaults是一种高效且轻量的持久化方案,它有以下不足:

  • 数据不安全

    它的数据相对容易提取,所以不要保存和隐私有关的重要数据

  • 持久化时机不确定

    为了效率的考量,UserDefaults中的数据在发生变化时并不会立即持久化,系统会在认为合适的时机才将数据保存在硬盘中。因此,可能发生数据不能完全同步的情况,严重时有数据彻底丢失的可能。尽量不要在其中保存会影响App执行完整性的关键数据,在出现数据丢失的状况下,App仍可根据默认值正常运行

尽管@AppStorage是作为UserDefaults的属性包装器存在的,但@AppStorage并没有支持全部的property list数据类型,目前仅支持:Bool、Int、Double、String、URL、Data(UserDefaults支持更多的类型)。

增加@AppStorage支持的数据类型

除了上述的类型外,@AppStorage还支持符合RawRepresentable协议且RawValueIntString的数据类型。通过增加RawRepresentable协议的支持,我们可以在@AppStorage中读取存储原本并不支持的数据类型。

下面的代码添加了对Date类型的支持:

extension Date:RawRepresentable{
    public typealias RawValue = String
    public init?(rawValue: RawValue) {
        guard let data = rawValue.data(using: .utf8),
              let date = try? JSONDecoder().decode(Date.self, from: data) else {
            return nil
        }
        self = date
    }

    public var rawValue: RawValue{
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data:data,encoding: .utf8) else {
            return ""
        }
       return result
    }
}

使用起来和直接支持的类型完全一致:

@AppStorage("date") var date = Date()

下面的代码添加了对Array的支持:

extension Array: RawRepresentable where Element: Codable {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let result = try? JSONDecoder().decode([Element].self, from: data)
        else { return nil }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data: data, encoding: .utf8)
        else {
            return "[]"
        }
        return result
    }
}
@AppStorage("selections") var selections = [3,4,5]

对于RawValueIntString的枚举类型,可以直接使用,比如:

enum Options:Int{
    case a,b,c,d
}

@AppStorage("option") var option = Options.a

安全和便捷的声明(一)

@AppStorage的声明方式有两个令人不悦的地方:

  • 每次都要设定Key(字符串)
  • 每次都要设定默认值

而且开发者很难享受到代码自动补全和编译时检查带来的快捷、安全的体验。

较好的解决方案是将@AppStorage集中声明,并在每个视图中通过引用注入。鉴于SwiftUI的刷新机制,我们必须要在集中声明、单独注入后仍需保留@AppStorage的DynamicProperty特征——当UserDefaults的值发生变动时刷新视图。

下面的代码能满足以上的要求:

enum Configuration{
    static let name = AppStorage(wrappedValue: "fatbobman", "name")
    static let age = AppStorage(wrappedValue: 12, "age")
}

在视图中使用方法如下:

let name = Configuration.name
var body:some View{
     Text(name.wrappedValue)
     TextField("name",text:name.projectedValue)
}

name和直接在代码中通过@AppStorage声明的效果类似。不过付出的代价就是需要将wrappedValueprojectedValue明确标注出来。

是否有不标注wrappedValueprojectedValue又能达到上述结果的实现方案呢?在安全和便捷的声明(二)中我们将尝试使用另一种解决途径。

集中注入

在介绍另一种便捷声明方式之前,我们先聊一下集中注入的问题。

【健康笔记3】目前面临着前言中所描述的情况,配置信息内容很多,如果单独注入会很麻烦。我需要找到一种可以集中声明、一并注入的方式。

安全和便捷的声明(一)中使用的方法对于单独注入的情况是满足的,但如果我们想统一注入的话就需要其他的手段了。

我并不打算将配置数据汇总到一个结构体中并通过支持RawRepresentable协议统一保存。除了数据转换导致的性能损失外,另一个重要问题是,如果出现数据丢失的情况,逐条保存的方式还是可以保护绝大多数的用户设定的。

基础指南中,我们提到@AppStorage在视图中的表现同@State非常类似;不仅如此,@AppStorage还有一个官方文档从没提到的神奇特质,在ObservableObject中具有同@Published一样的特性——其值发生变化时会触发objectWillChange。这个特性只发生在@AppStorage身上,@State、@SceneStorage都不具备这个能力。

目前我无法从文档或暴露的代码中找到这一特性原因,因此以下的代码并不能获得官方的长期保证

class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
}

视图代码:

@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name",text:defaults.$name)

不仅代码整洁了许多,而且由于只需要在Defaults中声明一次,极大的降低了由于字符串拼写错误而出现的不易排查的Bug。

Defaults中使用的是@AppStorage的声明方式,而Configuration中使用的是AppStorage的原始构造形式。变化的目的是为了能够保证视图更新机制的正常运作。

安全和便捷的声明(二)

集中注入中提供的方法已经基本解决了我在当前使用@AppStorage中碰到的不便,不过我们还可以尝试另一种优雅、有趣的逐条声明注入的方式。

首先修改一下Defaults的代码

public class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
    public static let shared = Defaults()
}

创建一个新的属性包装器Default

@propertyWrapper
public struct Default<T>: DynamicProperty {
    @ObservedObject private var defaults: Defaults
    private let keyPath: ReferenceWritableKeyPath<Defaults, T>
    public init(_ keyPath: ReferenceWritableKeyPath<Defaults, T>, defaults: Defaults = .shared) {
        self.keyPath = keyPath
        self.defaults = defaults
    }

    public var wrappedValue: T {
        get { defaults[keyPath: keyPath] }
        nonmutating set { defaults[keyPath: keyPath] = newValue }
    }

    public var projectedValue: Binding<T> {
        Binding(
            get: { defaults[keyPath: keyPath] },
            set: { value in
                defaults[keyPath: keyPath] = value
            }
        )
    }
}

现在我们可以在视图中采用如下代码来逐个声明注入了:

@Default(\.name) var name
Text(name)
TextField("name",text:$name)

逐个注入且无需标注wrappedValueprojectedValue。由于使用keyPath,避免了可能出现的字符串拼写错误问题。

鱼和熊掌不可兼得,上述的方法还是不十分完美——会出现过度依赖的情况。即使你只在视图中注入了一个UserDefaults键值(比如name),但当Defaults中其他未注入的键值内容发生变动时(age发生变化),依赖name的视图也同样会被刷新。

不过由于通常情况下配置数据的变化频率很低,所以并不会对App造成什么性能负担。

总结

本文提出了几个在不采用第三方库的情况下,解决@AppStorage痛点的方案。为了保证视图的刷新机制,分别采用的不同的实现方式。

SwiftUI中即使一个不起眼的环节也有不少乐趣值的我们探索。

如果想实现完美的逐条注入方式(自动补全、编译器检查、不过度依赖)可以通过创建自己的UserDefaults响应代码来实现,这已超出了本文对于@AppStorage的探讨范围。

本文原载于我的个人博客肘子的Swift记事本

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容