KkevinK / Me

0 stars 0 forks source link

学习六 新的列表思路和巩固 #8

Open KkevinK opened 3 years ago

KkevinK commented 3 years ago

这次做了一个获取宠物小精灵列表的demo,当然目前还没有完善,后续会通过进一步的学习来把它做完。封面必须给主角皮卡丘。 image

先说说思路:

  1. 数据源结构与方法枚举化
    
    import Foundation

struct AppState { var settings = Settings() var pokemonList = PokemonList() }

extension AppState { struct Settings { enum Sorting: CaseIterable { case id, name, color, favorite } var showEnglishName = true var sorting = Sorting.id var showFavoriteOnly = false

    enum AccountBehavior: CaseIterable {
        case register, login
    }
    var accountBehavior = AccountBehavior.login
    var email = ""
    var password = ""
    var verifyPassword = ""

    var loginUser: User?
    var loginRequesting = false
    var loginError: AppError?
}

}

extension AppState { struct PokemonList { var pokemons: [Int: PokemonViewModel]? var loadingPokemons = false var allPokemonsByID: [PokemonViewModel] { guard let pokemons = pokemons?.values else { return [] } return pokemons.sorted { $0.id < $1.id } } } }

struct User: Codable { var email: String var favoritePokemonIDs: Set func isFavoritePokemon(id: Int) -> Bool { favoritePokemonIDs.contains(id) } }

这里是数据源的结构,看到它使用了extension来进行结构的扩展,看上去清晰优雅,最终呈现的两个数据结构一个是宠物小精灵的列表一个是设置的数据,然后具体的结构通过另一个结构写清楚。
CaseIterable被用于合成简单枚举类型的 allCases 静态属性,在settingView中有具体的具现方法:

extension AppState.Settings.AccountBehavior { var text: String { switch self { case .register: return "注册" case .login: return "登录" } } }

class Store: ObservableObject { @Published var appState = AppState()

func dispatch(_ action: AppAction) {
    #if DEBUG
    print("[ACTION]: \(action)")
    #endif
    let result = Store.reduce(state: appState, action: action)
    appState = result.0
    if let command = result.1 {
        #if DEBUG
        print("[COMMAND]: \(command)")
        #endif
        command.execute(in: self)
    }
}

static func reduce(state: AppState, action: AppAction) -> (AppState, AppCommand?)
{
    var appState = state
    var appCommand: AppCommand?
    switch action {
    case .login(let email, let password):
        guard !appState.settings.loginRequesting else {
            break
        }
        appState.settings.loginRequesting = true
        appCommand = LoginAppCommand(email: email, password: password)

    case .accountBehaviorDone(let result):
        appState.settings.loginRequesting = false
        switch result {
        case .success(let user):
            appState.settings.loginUser = user
        case .failure(let error):
            appState.settings.loginError = error
        }

    case .loadPokemons:
        if appState.pokemonList.loadingPokemons {
            break
        }
        appState.pokemonList.loadingPokemons = true
        appCommand = LoadPokemonsCommand()

    case .loadPokemonsDone(let result):
        switch result {
        case .success(let models):
            appState.pokemonList.pokemons = Dictionary(
                uniqueKeysWithValues: models.map { ($0.id, $0) }
            )
        case .failure(let error):
            print(error)
        }
    }
    return (appState, appCommand)
}

}

这里是推送数据的地方,最终推送的数据只是一个AppState类型的appState,但是下面的方法很有意思,首先定义了一个外部入口dispatch,但是实现中使用了下面的静态方法reduce,且根据方法名进行执行。而在appAction中定义了这些方法,有点类似接口:

enum AppAction { case login(email: String, password: String) case accountBehaviorDone(result: Result<User, AppError>)

case loadPokemons
case loadPokemonsDone(result: Result<[PokemonViewModel], AppError>)

}

这里是一个登录和登录完成的事件,还有一个读取数据和读取完成的事件。为什么要使用action呢,因为这样符合一种类似于 Redux,但是针对 SwiftUI 的特点进行了一些改变的数据管理方式,如下图所示:
![image](https://user-images.githubusercontent.com/29086170/113375817-d7d55700-93a2-11eb-9f9b-4ef2aa0bcb0c.png)
传统 Redux 有两点比较大的限制,在 SwiftUI 中会显得有些水土不服,可能需要一些改进。
首先,“只能通过发送 Action 的方式,间接改变存储在 Store 中的 State” 这个要求太过严格。SwiftUI 有着方便和现成的 Binding 行为,来完成状态和界面的双向绑定。
使用这个特性可以大幅简化程序的编写,同时保持数据流的清晰稳定。因此,我们希望为状态改变设置一个例外:除了通过 Action 外,也可以通过 Binding 来改变状态。
其次,我们希望 Reducer 具有纯函数特性,但是在实际开发中,我们会遇到非常多 带有副作用 (side effect) 的情况:比如在改变状态的同时,需要向磁盘写入文件,或者需要进行网络请求。在上图中,我们没有阐释这类副作用应该如何处理。有一些架 构实现选择不区分状态和副作用,让它们混在一起进行,有一些架构选择在 Reducer 前添加一层中间件 (middleware),来把 Action 进行预处理。我们在 PokeMaster app 的架构中,选择在 Reducer 处理当前 State 和 Action 后,除了返回新的 State 以外,再额外返回一个 Command 值,并让 Command 来执行所需的副作用。

2. Command 和异步操作
对于一个异步操作,一般来说我们比较关注两个时间点。首先是异步操作开始的时 候,我们可能希望在此时显示像是 “正在加载” 的界面,让用户知道正在进行一项耗 时操作。另一个时间点是操作完成时,这时候我们可以使用异步操作的结果 (比如网 络请求返回的数据) 来更新界面。因此,一个异步操作一般会对应两个 State:一个代表操作开始,app 进入等待状态;另一个代表操作结束,可以按照需要更新 UI。 Reducer 负责返回新的 State,对于像网络请求这种耗时的异步操作,我们不可能阻 塞线程去等待请求完成再返回新状态,因此,我们需要一种另外的方式来处理异步操作,让它在一次请求中拥有两次更新状态的机会。
Reducer 的唯一职责应该是计算新的 State,而发送请求和接收响应,显然和返回新的 State 没什么关系,它们属于设置状态这一操作的 “副作用”。在我们的架构中我们使用 Command 来代表 “在设置状态的同时需要触发 一些其他操作” 这个语境。Reducer 在返回新的 State 的同时,还返回一个代表需要进行何种副作用的 Command 值 (对应上一段中的第一个时间点)。Store 在接收到这 个 Command 后,开始进行额外操作,并在操作完成后发送一个新的 Action。这个 Action 中带有异步操作所获取到的数据。它将再次触发 Reducer 并返回新的 State, 继而完成异步操作结束时的 UI 更新 (对应上一段中的第二个时间点)。

import Foundation import Combine

struct LoginRequest { let email: String let password: String var publisher: AnyPublisher<User, AppError> { Future { promise in DispatchQueue.global() .asyncAfter(deadline: .now() + 1.5) { if self.password == "password" { let user = User( email: self.email, favoritePokemonIDs: [] ) promise(.success(user)) } else { promise(.failure(.passwordWrong)) } } } .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } }

首先我们定义一个类型,来模拟用户登录的网络请求。这个类型使用 Combine 框架 中的 Future 模拟网络延迟。
使用一个 Publisher 来发布登录操作的在未来的可能结果。当登录成功时,可 以得到一个 User 值,否则给出 AppError。我们马上会看到 AppError 的定义。
把新建的 Future Publisher 发送到后台队列,并延时 1.5 秒执行。这用来模拟 网络请求的延时状况。
因为我们会想用这个 Publisher 的值更新 UI,所以我们指定后续的接收者应 该在主队列接收事件。
我们不关心变形后的 Publisher 的具体类型,所以将它的类型抹掉。

然后定义command:

protocol AppCommand { func execute(in store: Store) }

struct LoginAppCommand: AppCommand { let email: String let password: String func execute(in store: Store) { let token = SubscriptionToken()

    LoginRequest(email: email, password: password).publisher
        .sink(
            receiveCompletion: { complete in
                if case .failure(let error) = complete {
                    store.dispatch(
                        .accountBehaviorDone(result: .failure(error))
                    )
                }
                token.unseal()
            },
            receiveValue: { user in
                store.dispatch(
                    .accountBehaviorDone(result: .success(user))
                )
            }
        )
        .seal(in: token)
}

}

Q:为什么要定一个外部的AppCommand,再定义LoginAppCommand继承它?
A:因为后续还有LoadPokemonsCommand。定义一个唯一的方法 execute(in:),它是开始执行副作用的入口。参数 Store 则提供了一个执行后续操作的上下文,让我们可以在副作用执行完毕时,继续发送新的 Action 来更改 app 状态。而在store中,dispatch通过reduce返回的AppCommand类型,来判断执行哪种command,且只需要一句话command.execute(in: self)
Q:publisher,sink是什么?
A:publisher是Combine框架中的发布者,而sink是订阅。当错误发生时,receiveCompletion 将会被调用,闭包的传入参数是一个描述 错误的 .failure 成员。使用 if case 我们可以在过滤出这个 case 的同时,将关联的 error 值提取出来。在这里,我们需要通过向 store 发送 Action 来显示错误。登录成功时我们可以收到 Publisher 发出的 User 值。类似上面,我们也需要通过 Action 来改变 State。
Q:token是干什么的?这里不是模拟网络情况吗?
A:在 execute(in:) 里,我们并没有使用到通过 sink 订阅的 LoginRequest publisher 所返回的值。这个返回值是一个 AnyCancellable,在它被释放时,cancel() 会被自动调用,导致订阅取消。如果我们不想要这个异步操作在完成之前就被取消掉,就需要想办 法持有 sink 的返回值,直到异步操作完成。为了达到这一点,可以添加一个 SubscriptionToken 来持有 AnyCancellable:

class SubscriptionToken { var cancellable: AnyCancellable? func unseal() { cancellable = nil } } extension AnyCancellable { func seal(in token: SubscriptionToken) { token.cancellable = self } }


AnyCancellable extension 上的 seal 会把当前的 AnyCancellable 值 “封印” 到 SubscriptionToken 中去。在 sink 订阅后,把返回的 AnyCancellable 存放到 token 里。调用 token 的 unseal 方法将 AnyCancellable 释放。在这里,unseal 中里将 cancellable 置为 nil 的操作其实并不是必须的,因为一旦 token 离开作用域被释放后,它其中的 cancellable 也会被释放,从而导致订阅资源的释放。这 里的关键是利用闭包,让 token 本身存活到事件结束,以保证订阅本身不被取消。
对于内部含有异步操作,并使用 Combine 来处理的 AppCommand,我们都可以通 过类似的方式来维持订阅,这可以让我们以最小限度使用资源的同时,保持一个相对干净的写法。Combine 框架内部为 AnyCancellable 准备了 store(in:) 方法,用来 将自身存储到一个 Array 或者 Set 中,而这里的 seal(in:) 正是借鉴了 Combine 的处理方式。

3. 异步 Command 结果和更新VIew
订阅的receiveCompletion中complete闭包给出了完成时执行的事件accountBehaviorDone,并在Store的reduce中通过accountBehaviorDone将正在登录的状态置为false,并且判断成功与失败,成功则显示正在登录的user,失败则显示失败信息。