你能想象吗,UIKit已经有11年历史了! 自从2008年iOS SDK发布以来,我们就一直在使用它开发应用。在这段时间里,开发人员一直在为他们的应用程序寻找最佳的架构。从MVC开始,后来我们看到了MVP、MVVM、VIPER、RIBs、VIP的崛起。

但是最近发生了一些事情。 这个“东西”是如此重要,以至于大多数用于iOS的架构模式将很快成为历史。

我说的是SwiftUI。它哪儿也去不了。不管你喜不喜欢,这就是iOS开发的未来。它改变了我们在设计架构时所面临的挑战。

1. What are the conceptual changes?

UIKit是一个命令式的、事件驱动的框架。我们可以引用层次结构中的每个视图,当视图被加载时更新它的外观,或者作为对事件的反应(按钮的触摸或新的数据在UITableView中显示)。 我们使用**回调、委托、目标操作(callbacks, delegates, target-actions)**来处理这些事件。

现在,一切都过去了。SwiftUI是一个声明式的、状态驱动的框架。我们不能引用层次结构中的任何视图,也不能直接改变视图作为对事件的反应。相反,我们改变绑定到视图的状态。委托、目标-动作、响应链、KVO——所有的回调技术都被闭包和绑定所取代。

SwiftUI中的每个视图都是一个Struct,其创建速度比类似的UIView后代要快很多倍。该结构保持对它为呈现UI而提供给body函数的状态的引用。

SwiftUI中的视图只是一个编程函数。您为它提供输入(状态)——它绘制输出。而改变输出的唯一方法就是改变输入:我们不能通过添加或删除子视图来触及算法(body函数) - UI显示的所有可能的改变都必须在body中声明,不能在运行时更改。

在SwiftUI方面,我们没有添加或删除子视图,而是在预定义的流程图算法中启用或禁用UI的不同部分。

2. MVVM is the new standard architecture

SwiftUI天生契合MVVM。

在最简单的情况下,View不依赖于任何外部状态,它的本地@State变量充当ViewModel的角色,提供了订阅机制(Binding),以便在状态更改时刷新UI。

对于更复杂的场景,视图可以引用一个外部的ObservableObject,在这种情况下,它可以是一个不同的ViewModel

无论如何,SwiftUI视图处理状态的方式非常类似于经典的MVVM(除非我们引入更复杂的编程实体图)。

让我们考虑一下这个针对SwiftUI应用程序的MVVM模块的快速示例。

  • Model: a data container
struct Country {
    let name: String
}
  • View: a SwiftUI view
struct CountriesList: View {
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        List(viewModel.countries) { country in
            Text(country.name)
        }
        .onAppear {
            self.viewModel.loadCountries()
        }
    }
}
  • ViewModel: an ObservableObject that encapsulates the business logic and allows the View to observe changes of the state
extension CountriesList {
    class ViewModel: ObservableObject {
        @Published private(set) var countries: [Country] = []
        
        private let service: WebService
        
        func loadCountries() {
            service.getCountries { [weak self] result in
                self?.countries = result.value ?? []
            }
        }
    }
}

在这个简化的例子中,当视图出现在屏幕上时,onAppear回调调用ViewModel上的loadCountries(),触发网络调用来加载WebService中的数据。ViewModel接收回调中的数据,并通过视图观察的@Published变量countries推送更新

avatar

虽然这篇文章是关于Clean Architecture的,但是我收到了很多关于在SwiftUI中应用MVVM的问题,所以我把最初的样例项目移植到MVVM的一个单独的分支中。你可以比较两者,选择最适合你需要的。项目的主要特点:

  • 香草SwiftUI + Combine实现
  • 解耦表示层、业务逻辑和数据访问层
  • 完整的测试覆盖范围,包括UI(多亏了ViewInspector)
  • 类似于redux的集中式AppState作为单一的真相来源
  • 程序化导航(支持深度链接)
  • 基于泛型的简单而灵活的网络层
  • 处理系统事件(当应用程序处于非活动状态时模糊视图层次)

3. Under the hood, SwiftUI is based on ELM

我在ELM语言的网站上查看了ELM Architecture的描述,没有发现什么新的东西。SwiftUI基于与ELM相同的本质:

  • 模型-应用程序的状态
  • 一种将你的状态转换成HTML的方法
  • 更新-一种基于消息更新你的状态的方法

我们在哪儿见过,不是吗?

avatar

我们已经有了模型,视图从模型中自动生成,我们唯一能调整的是更新交付的方式。们可以采用REDUX的方式,使用Command模式来改变状态,而不是让SwiftUI的视图和其他模块直接写入状态。
虽然我更喜欢在我之前的UIKit项目中使用REDUX (ReSwift❤),但对于swifti应用程序是否需要REDUX还是有疑问的——数据流已经在控制之下,而且很容易追踪。

4. Coordinator is SwiftUI

Coordinator(也就是Router)是VIPER、RIBs和MVVM-R体系结构的重要组成部分。为屏幕导航分配一个单独的模块在UIKit应用程序中是很合理的——从一个ViewController到另一个ViewController的直接路由导致了它们的紧密耦合,更不用说在ViewController层次结构中深入链接到屏幕的代码地狱了。

在UIKit中添加一个协调器是相当容易的,因为UIView(和UIViewController)是环境独立的实例,你可以随时从层次结构中添加/删除它们。

对于SwiftUI来说,这样的动态在设计上是不可能的:层次结构是静态的,所有可能的导航都在编译时定义和固定。没有办法在运行时调整层次结构:相反,导航完全由通过Bindings改变的状态控制:看看你的NavigationView, TabView或.sheet(),每次你都会看到一个init,它接受路由的Binding参数。

"视图是状态的函数"还记得吗? 这里的关键词是function,一种将状态数据转换为渲染图像的算法。

这就解释了为什么从SwiftUI视图中提取路由是一个相当大的挑战:路由(routing)是绘图算法的一个组成部分

Coordinator的目标是解决两个问题:

  1. 将ViewControllers彼此解耦
  2. 程序化的导航

SwiftUI有一个内置的机制,可以通过前面提到的Bindings进行程序化导航。我有一篇专门的文章。

至于SwiftUI中视图的脱钩,这很容易实现。如果你不希望视图A直接引用视图B,你可以简单地将B转换为A的泛型参数,然后就结束了。

你也可以使用相同的方法来抽象视图A可以打开B的事实方式(使用TabView, NavigationView等),尽管我不认为在你的视图中陈述这一点有什么问题。如果需要,您可以轻松地就地更改路由模型,而无需接触视图B。

不要忘记@ViewBuilder和AnyView -另外两种方法使类型B对A来说是隐藏的。

鉴于上述情况,我认为SwiftUI让Coordinator变得不必要了:我们可以使用泛型参数或@ViewBuilder隔离视图,并使用标准工具实现编程导航。

quickbirdstudios有一个在SwiftUI中使用coordinator的实际例子,然而,在我看来,这有点过头了。另外,这种方法有几个缺点,比如授予Coordinators对所有ViewModels的完全访问权,但是您应该检查一下并自己决定。

5. Are VIPER, RIBs and VIP applicable for SwiftUI?

我们可以从这些架构中借鉴很多伟大的想法和概念,但最终任何一种架构的规范实现对SwiftUI应用都没有意义。

  • 首先,正如我刚才所阐述的,实际上并不需要有一个Coordinator
  • 其次,SwiftUI中数据流的全新设计,加上对视图状态绑定的本地支持,将所需的设置代码压缩到Presenter变成了一个愚蠢的实体的程度,不做任何有用的事情。
  • 随着模式中模块数量的减少,我们发现我们也不需要Builder。 所以基本上,整个模式就分崩离析了,因为它旨在解决的问题不再存在了。
  • SwiftUI在系统设计中引入了自己的一套挑战,所以我们必须从头开始重新设计UIKit的模式。

无论如何,都有人试图坚持心爱的架构,但请不要这样做。

6. Clean Architecture

让我们参考Uncle Bob的Clean Architecture,即VIP的前身。

通过将软件分层,并遵循依赖规则,您将创建一个本质上可测试的系统,并具有它所暗示的所有好处。

Clean Architecture对于我们应该引入的层的数量非常自由,因为这取决于应用程序。

但对于手机应用来说,最常见的情况是,我们需要有三个层:

  • Presentation layer (呈现层)
  • Business Logic layer(业务层)
  • Data Access layer (数据层)

因此,如果我们通过SwiftUI的特性来提炼Clean架构的需求,我们会得出如下结论:

avatar

6.1. AppState

AppState是模式中唯一需要是对象(特别是ObservableObject)的实体。 或者,它可以是一个封装在来自Combine的CurrentValueSubject中的结构。

就像Redux一样,AppState作为唯一的真相来源,并保存整个应用的状态,包括用户数据、认证令牌、屏幕导航状态(选中的选项卡、呈现的表单)和系统状态(是活动的/是后台的,等等)。

AppState不知道任何其他层,也不包含任何业务逻辑。

国家演示项目中的AppState示例:

class AppState: ObservableObject, Equatable {
    @Published var userData = UserData()
    @Published var routing = ViewRouting()
    @Published var system = System()
}

6.2. View

这是SwiftUI的常识。它可以是无状态的,也可以有本地的@State变量。

没有其他层知道View层的存在,所以没有必要将它隐藏在协议后面。

当视图被初始化时,它通过SwiftUI的标准依赖注入来接收AppState和Interactor,注入的变量是@Environment, @EnvironmentObject或@ observetobject

副作用是由用户的操作(如点击按钮)或视图生命周期事件onAppear触发的,并被转发到Interactor

struct CountriesList: View {
    
    @EnvironmentObject var appState: AppState
    @Environment(\.interactors) var interactors: InteractorsContainer
    
    var body: some View {
        ...
        .onAppear {
            self.interactors.countriesInteractor.loadCountries()
        }
    }
}

6.3. Interactor

Interactor(交互器)封装特定视图或一组视图的业务逻辑。 与AppState一起形成业务逻辑层,它完全独立于表示层和外部资源。

它是完全无状态的,作为构造函数参数注入的,只引用AppState对象。

Interactor应该用协议“伪装”,以便视图可以在测试中与模拟交互者对话。

Interactor接收执行工作的请求,例如从外部源获取数据或进行计算,但它们从不直接返回数据,例如在闭包中。

相反,它们将结果转发给AppState或视图提供的Binding

当工作结果(数据)在本地属于一个视图而不属于中心的AppState时,Binding被使用,也就是说,它不需要被持久化或与应用程序的其他屏幕共享。

演示项目中的CountriesInteractor:

protocol CountriesInteractor {
    func loadCountries()
    func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country)
}

// MARK: - Implemetation

struct RealCountriesInteractor: CountriesInteractor {
    
    let webRepository: CountriesWebRepository
    let appState: AppState
    
    init(webRepository: CountriesWebRepository, appState: AppState) {
        self.webRepository = webRepository
        self.appState = appState
    }

    func loadCountries() {
        appState.userData.countries = .isLoading(last: appState.userData.countries.value)
        weak var weakAppState = appState
        _ = webRepository.loadCountries()
            .sinkToLoadable { weakAppState?.userData.countries = $0 }
    }

    func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country) {
        countryDetails.wrappedValue = .isLoading(last: countryDetails.wrappedValue.value)
        _ = webRepository.loadCountryDetails(country: country)
            .sinkToLoadable { countryDetails.wrappedValue = $0 }
    }
}

6.4. Repository

Repository(存储库)是一个用于读写数据的抽象网关。提供对单个数据服务的访问,无论是web服务器还是本地数据库。

我有一篇专门的文章解释为什么提取存储库是必要的。

例如,如果应用程序使用它的后端,Google Maps APIs并写入一些到本地数据库,将有三个存储库:两个用于不同的web API提供商,一个用于数据库IO操作。

存储库也是无状态的,没有对AppState的写访问权,只包含与处理数据相关的逻辑。它对View或Interactor一无所知

实际的存储库应该隐藏在协议后面,以便Interactor可以在测试中与模拟的存储库对话。

来自演示项目的国家webrepository:

protocol CountriesWebRepository: WebRepository {
    func loadCountries() -> AnyPublisher<[Country], Error>
    func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details.Intermediate, Error>
}

// MARK: - Implemetation

struct RealCountriesWebRepository: CountriesWebRepository {
    
    let session: URLSession
    let baseURL: String
    let bgQueue = DispatchQueue(label: "bg_parse_queue")
    
    init(session: URLSession, baseURL: String) {
        self.session = session
        self.baseURL = baseURL
    }
    
    func loadCountries() -> AnyPublisher<[Country], Error> {
        return call(endpoint: API.allCountries)
    }

    func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details, Error> {
        return call(endpoint: API.countryDetails(country))
    }
}

// MARK: - API

extension RealCountriesWebRepository {
    enum API: APICall {
        case allCountries
        case countryDetails(Country)
        
        var path: String { ... }
        var httpMethod: String { ... }
        var headers: [String: String]? { ... }
    }
}

因为WebRepositoryURLSession作为构造函数参数,所以很容易通过使用定制的URLProtocol模拟网络调用来测试它