MVVM+SwiftUI+Clean Code实践
- Coordinator的职责
- 负责构建具体的页面模块 makeViewController
- 负责页面之前的跳转 navigator
- 负责页面与页面之间的交互传值
- 依赖注入 dependency
- Controller
- 持有View
- 需要有Controller的处理的逻辑时,与ViewModel进行双向绑定
- View
- 负责UI控件的创建,与ViewModel进行双向绑定
- viewModel的输出传递给View,View的UI响应提交给ViewModel进行逻辑处理
- ViewModel
- ViewModel负责数据逻辑处理,页面状态管理
- 和UseCase打交道,比如获取数据时,直接调用usecase的数据获取方法
- 页面状态管理主要是通过Combine把数据发送出去
- UseCase
- 负责处理数据repository的数据返回映射逻辑,比如错误映射处理(错误A映射为错误B或者映射为一个默认值),接口整合(比如两个网络请求的合并,当上一个网络请求返回数据之后,需要马上调用下一个接口)
- repository
- 负责封装单个数据处理逻辑(比如一个网络请求对应方法)
- 完成服务端的JSON数据与ViewModel或者View需要的数据的转换,如果转化逻辑较多,可以抽取出一个DataMapper专门来处理JSON转Model的逻辑
- Service
- 负责外部基础工具类,比如apiClinent,SessionStorage相关的基础工具类。被Repository的具体实现类调用。通依赖注入的方式写入Repository。
目录结构划分
- Features
- Auth
- Coordinator
- Data
- Mapper
- Repository
- Domain
- Entity
- UseCase
- Repository
- Service
- UI
- Login
- View
- Controller
- ViewModel
- Registration
- Login
- Auth
以实现一个简单的登录为例,实践SwiftUI + Clean架构
实现结果
-
整体文件夹布局
image.png
构建Service(基础工具层)
- 我们这里的只有LoginService,它的作用就是调用原生的网络框架的方法进行接口请求。LoginService是一个接口,由具体的LoginServiceImpl实现
- Service注入到Respository中,实现Respository的接口功能
class LoginServiceImpl: LoginService { }
protocol LoginService {
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError>
}
extension LoginService {
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError> {
Future<LoginEntity, AppError> { promise in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if password == "error" {
promise(.failure(.serverError))
return
}
promise(.success(LoginEntity(account: account, password: password)))
}
}.eraseToAnyPublisher()
}
}
构建Repository
- 我们首先声明一个LoginRepository接口
protocol LoginRepository {
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError>
}
- 这个接口可以被不同的实例实现,只要实现了接口,我们就认为它具有LoginRepository的功能,这样我们就能在Coordinator层中注入不同场景下的LoginRepository。比如在正式的运行环境中,我们使用的StandardLoginRepository去真实的调用网络接口,实现与服务端的校验;又比如我们在单元测试环节时,我们可以实现一个MockLoginRepository在本地模拟登录交互环节等等。根据不同的场景使用同一个接口,不同的实现,这种设计模式叫做面向接口编程。
- 这里我们使用LoginRepositoryImpl来实现LoginRepository的接口
class LoginRepositoryImpl: LoginRepository {
let service: LoginService
init(service: LoginService) {
self.service = service
}
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError> {
return service.login(account: account, password: password)
}
}
构建UseCase
class LoginUseCase {
private var loginRepo: LoginRepository
init(loginRepo: LoginRepository) {
self.loginRepo = loginRepo
}
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError> {
loginRepo.login(account: account, password: password)
}
}
- UseCase的主要作用是处理和协调Repository的数据逻辑,处理一些中间过程,把最终的结果返回给ViewModel
- 我们Demo中的UseCase,提供了一个Login方法给外部调用,比如在login这个流程要经历先获取rsaKey对密码加密,然后再将加密的数据发送给服务端,那么在Repository中提供两个单一的方法(获取rsakey, 发送加密数据),在UseCase中提供一个方法(login),在这个login方法中依次依序调用Respository中的单一方法,同时处理异常逻辑,并把最终的结果回调给外部
- 我们这里的数据交互方式使用的是Combine,当然也可以使用闭包。(大家不用过分纠结数据回调方式是使用combine还是闭包,最重要的是程序的本质和思想)
class LoginUseCase {
private var loginRepo: LoginRepository
init(loginRepo: LoginRepository) {
self.loginRepo = loginRepo
}
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError> {
loginRepo.login(account: account, password: password)
}
}
构建ViewModel
- ViewModel的作用主要是处理交互逻辑,比如输入的字符长短的限制,按钮点击相应,页面状态管理等等
- Repository是通过依赖注入的方式,在创建VIewModel时,注入到usecase中,同时在ViewModel内部创建usercase,usecase不对外暴露。
- 数据交互逻辑,直接调用usecase中提供的login方法即可
- account与第一个TextField绑定,用于接收username的输入, password与第二TextField绑定,用于接收password的输入,
- loginStatus与View中的ActivityIndicator绑定,主要用于模拟网络加载过程;
- loginBtnEnable与LoginBtn的状态绑定,控制按钮enable的时机,只有满足一定的输入条件按钮才能被点击
class LoginViewModel: ObservableObject {
@Published var account: String = ""
@Published var password: String = ""
// output
@Published var loginStatus: LoginStatus = .none
@Published var loginBtnEnable: Bool = false
@Published var accountValid: Bool = true
var responseResult: LoginStatus? {
get {
switch loginStatus {
case .success:
return loginStatus
case .failure:
return loginStatus
case .laoding:
break
case .none:
break
}
return nil
}
set { }
}
private weak var navigator: AuthNagation?
private let loginUseCase: LoginUseCase
var bag: Set<AnyCancellable> = .init()
var isAccountValid: AnyPublisher<Bool, Never> {
let remoteVerify =
$account.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.flatMap {
// assume remote validate
return Just($0.count >= 8).eraseToAnyPublisher()
}
let localVerify = $account.map { $0.count <= 20}
return Publishers.CombineLatest(remoteVerify, localVerify)
.map { $0 && $1 }
.eraseToAnyPublisher()
}
var isPasswordValid: AnyPublisher<Bool, Never> {
$password.map { $0.count >= 6}
.eraseToAnyPublisher()
}
var isLoginBtnEnable: AnyPublisher<Bool, Never> {
Publishers.CombineLatest(isAccountValid, isPasswordValid)
.map { $0 && $1 }
.eraseToAnyPublisher()
}
init(reposity: LoginRepository, navigator: AuthNagation) {
self.loginUseCase = LoginUseCase(loginRepo: reposity)
self.navigator = navigator
configBinding()
}
// click
func login() {
self.loginStatus = .laoding
loginUseCase
.login(account: account, password: password)
.sink { complete in
switch complete {
case .failure(_):
self.loginStatus = .failure(.passwordNotMatch)
case .finished:
break
}
}
receiveValue: { entity in
self.loginStatus = .success
}
.store(in: &bag)
}
private func configBinding() {
isLoginBtnEnable
.sink { isValid in
self.loginBtnEnable = isValid
}
.store(in: &bag)
isAccountValid
.sink { isValid in
self.accountValid = isValid
}
.store(in: &bag)
}
}
使用SwiftUI构建View
- 在纯粹SwiftUI中,没有Controller,所以View直接与ViewModel绑定,在企业级项目中可以使用SwiftUI作为UI,UIHonstingViewController来承载SwiftUI。
- 在登录的demo中,UI比较简单,两个TextField来接收用户文本输入,一个按钮接收用户的点击等等。
struct LoginView: View {
@ObservedObject
var viewModel: LoginViewModel
var body: some View {
VStack {
VStack(alignment: .center, spacing: 40) {
VStack {
TextField("", text: $viewModel.account)
.placeholder(when: viewModel.account.isEmpty, placeholder: {
Text("电子邮箱").foregroundColor(Color(hex: 0x717478))
})
.font(Font.system(size: 14))
.foregroundColor(.white)
.frame(height: 40)
.padding(EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50))
Divider()
.background(Color(hex: 0x717478))
.frame(height: 1)
.padding(EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50))
}
VStack {
SecureField("", text: $viewModel.password)
.placeholder(when: viewModel.password.isEmpty, placeholder: {
Text("密码").foregroundColor(Color(hex: 0x717478))
})
.font(Font.system(size: 14))
.foregroundColor(.white)
.frame(height: 40)
.padding(EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50))
.frame(height: 40)
Divider()
.background(Color(hex: 0x717478))
.frame(height: 1)
.padding(EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50))
}
ZStack {
Button(action: {
viewModel.login()
}, label: {
Text(viewModel.loginStatus.isLoding ? "" : "登录")
.font(Font.system(size: 16))
.frame(width: UIScreen.main.bounds.width - 50 * 2, height: 40)
.foregroundColor(viewModel.loginBtnEnable ? Color.white : Color(hex: 0x717478))
.background(viewModel.loginBtnEnable ? Color(hex: 0x2772C7) : Color(hex: 0x333333))
.cornerRadius(27)
})
.disabled(!viewModel.loginBtnEnable)
ActivityIndicator()
.opacity(viewModel.loginStatus.isLoding ? 1 : 0)
}
}
}
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.alert(item: $viewModel.responseResult) { status in
Alert(title: Text(status.id))
}
}
}
构建Coordinator
- Coordinator的作用是创建对用的Controller或者View
- 获取当前的依赖项(外部参数)
- 通过Coordinator实现页面跳转
- 可以认为Coordinator是一个UINavigationController,创建的Controller作为UINavigationController的子控制器。
class AuthCoordinator {
var dependency: AuthDependency // reposity, service
init(dependency: AuthDependency) {
self.dependency = dependency
}
func makeView() -> LoginView {
let viewModel = LoginViewModel(reposity: dependency.loginRepository, navigator: self)
let view = LoginView(viewModel: viewModel)
return view
}
}
extension AuthCoordinator: AuthNagation {
func navigateToLogin() {
}
func navigateToRegister() {
}
}