用 SwiftUI 编写真正的 app

原文:Making real-world app with SwiftUI

05 Jun 2019

在 WWDC 大会开始一个星期以来,大家纷纷为今年的 SwiftUI 、昏暗模式、可更新的 CoreML 模型兴奋不已。在接下来的一周,我将会逐一介绍这些新玩意。首先从 SwiftUI 开始吧。SwiftUI 是苹果大家族中用来构造 APP 的全新方法。

SwiftUI 是一个声明式的基于组件的框架。你需要完全忘记曾经的 MVC 概念——在这种模型中,每个视图和模型之间都必须有一个控制器。在 SwiftUI 中只有状态的概念,同时视图继承于状态。当状态发生改变,SwiftUI 会根据改变后的状态重构 UI。苹果团队做了一件了不起的工作——它们提供了一个 SwiftUI 的完美教程。在教程中涉及到许多东西,比如布局、UIKit 的接口等等。

我会演示一个完全用 SwiftUI 编写的真实 APP 的案例。我们将编写一个能够查找 Github repos 的 APP。它有一个界面,会拥有一个文本框,用于输入查询关键;以及一个列表,用于展现搜索结果。我会假设你已经阅读过 SwiftUI 官方文档,因此我不再介绍最近本的概念,我主要会描述文档中所没有的内容。

首先编写一个 GithubService 类,用于创建查询请求以及 Repo 结构——用于描述一个 Github repository。

struct Repo: Decodable, Identifiable {
    var id: Int
    let name: String
    let description: String
}

struct SearchResponse: Decodable {
    let items: [Repo]
}

class GithubService {
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
        self.session = session
        self.decoder = decoder
    }

    func search(matching query: String, handler: @escaping (Result<[Repo], Error>) -> Void) {
        guard
            var urlComponents = URLComponents(string: "https://api.github.com/search/repositories")
            else { preconditionFailure("Can't create url components...") }

        urlComponents.queryItems = [
            URLQueryItem(name: "q", value: query)
        ]

        guard
            let url = urlComponents.url
            else { preconditionFailure("Can't create url from url components...") }

        session.dataTask(with: url) { [weak self] data, _, error in
            if let error = error {
                handler(.failure(error))
            } else {
                do {
                    let data = data ?? Data()
                    let response = try self?.decoder.decode(SearchResponse.self, from: data)
                    handler(.success(response?.items ?? []))
                } catch {
                    handler(.failure(error))
                }
            }
        }.resume()
    }
}

我们的 Repo 结构字段不多,但对于本例来说足够了。如果想在用 SwiftUI 创建的视图中使用这个 Repo 结构,你必须实现 Identifiable 协议。这个协议只需要你定义一个 id 属性,这个属性必须是一个 Hashable 类型。

接下来实现视图,用于表示 repos 列表中的一行。我们将在垂直分布的 stack view 中包含两个 label。

struct RepoRow: View {
    let repo: Repo

    var body: some View {
        VStack(alignment: .leading) {
            Text(repo.name)
                .font(.headline)
            Text(repo.description)
                .font(.subheadline)
        }
    }
}

然后是 SearchView,用于描述整个屏幕。

struct SearchView : View {
    @State private var query: String = "Swift"
    @EnvironmentObject var repoStore: ReposStore

    var body: some View {
        NavigationView {
            List {
                TextField($query, placeholder: Text("type something..."), onCommit: fetch)
                ForEach(repoStore.repos) { repo in
                    RepoRow(repo: repo)
                }
            }.navigationBarTitle(Text("Search"))
        }.onAppear(perform: fetch)
    }

    private func fetch() {
        repoStore.fetch(matching: query)
    }
}

query 字段用 @State 修饰。这表示这个视图继承了这个状态,当状态发生改变,SwiftUI 会重构该视图。SwiftUI 使用差异算法来判断发生了什么变化,同时只会更新对应的视图。SwiftUI 会将所有用 @State 标记的字段保存到单独的内存,只有对应的视图能够访问和修改这片内存。@State 是 Swift 新特性中的属性包装器,具体请参考这里。令人激动的地方是 $query 的使用,它表示获取一个属性包装器的引用,而不是值的引用。我们用它来双向绑定 TextField 和 query 变量。

另一个有意思的地方是 @EnvironmentObject。它属于 Environment 的一部分。你可以在 Environment 中注入想要的服务类,然后就可以在属于这个 Environment 的所有视图中使用它们。Environment 就是 SwiftUI 中的依赖注入。

import SwiftUI
import Combine

class ReposStore: BindableObject {
    var repos: [Repo] = [] {
        didSet {
            didChange.send(self)
        }
    }

    var didChange = PassthroughSubject()

    let service: GithubService
    init(service: GithubService) {
        self.service = service
    }

    func fetch(matching query: String) {
        service.search(matching: query) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let repos): self?.repos = repos
                case .failure: self?.repos = []
                }
            }
        }
    }
}

RepoStore 类应该遵循 BindableObject 协议,该协议需要一个 didChange 属性。这样就可以在 Environment 中使用它并在它发生改变时重建视图。didChange 属性应当是一个 Publisher——它属于苹果的新响应式框架 Combine 中的内容。Publisher 的主要功能是在发生改变时通知所有订阅者。因此我们的 repos 数组才能在 didSet 方法中通知订阅者数据发生改变。当有新的值出现时,SwiftUI 会重构 ReposView。

@State 和 @EnvironmentObject 的最大不同是, @State 是只能被某个特定视图所访问,而 @EnvironmentObject 对 Environment 中的所有视图有效。但两者都可被 SwiftUI 用来在变化发生时跟踪变化并重建视图。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let window = UIWindow(frame: UIScreen.main.bounds)
        let store = ReposStore(service: .init())
        window.rootViewController = UIHostingController(
            rootView: SearchView().environmentObject(store)
        )
        self.window = window
        window.makeKeyAndVisible()
    }
}

将指定的 Environment 用于启动 SwiftUI APP。

结论

本周,我们介绍了一种全新的 iOS 开发方法。接下来几周我会介绍更多 WWDC 主题。请关注我的 Twitter,对本文有任何问题请问我。感谢您的阅读。

你可能感兴趣的:(iPhone开发)