MVVM+SwiftUI+Clean Code實(shí)踐

- Coordinator的職責(zé)
- 負(fù)責(zé)構(gòu)建具體的頁面模塊 makeViewController
- 負(fù)責(zé)頁面之前的跳轉(zhuǎn) navigator
- 負(fù)責(zé)頁面與頁面之間的交互傳值
- 依賴注入 dependency
- Controller
- 持有View
- 需要有Controller的處理的邏輯時,與ViewModel進(jìn)行雙向綁定
- View
- 負(fù)責(zé)UI控件的創(chuàng)建,與ViewModel進(jìn)行雙向綁定
- viewModel的輸出傳遞給View,View的UI響應(yīng)提交給ViewModel進(jìn)行邏輯處理
- ViewModel
- ViewModel負(fù)責(zé)數(shù)據(jù)邏輯處理,頁面狀態(tài)管理
- 和UseCase打交道,比如獲取數(shù)據(jù)時,直接調(diào)用usecase的數(shù)據(jù)獲取方法
- 頁面狀態(tài)管理主要是通過Combine把數(shù)據(jù)發(fā)送出去
- UseCase
- 負(fù)責(zé)處理數(shù)據(jù)repository的數(shù)據(jù)返回映射邏輯,比如錯誤映射處理(錯誤A映射為錯誤B或者映射為一個默認(rèn)值),接口整合(比如兩個網(wǎng)絡(luò)請求的合并,當(dāng)上一個網(wǎng)絡(luò)請求返回?cái)?shù)據(jù)之后,需要馬上調(diào)用下一個接口)
- repository
- 負(fù)責(zé)封裝單個數(shù)據(jù)處理邏輯(比如一個網(wǎng)絡(luò)請求對應(yīng)方法)
- 完成服務(wù)端的JSON數(shù)據(jù)與ViewModel或者View需要的數(shù)據(jù)的轉(zhuǎn)換,如果轉(zhuǎn)化邏輯較多,可以抽取出一個DataMapper專門來處理JSON轉(zhuǎn)Model的邏輯
- Service
- 負(fù)責(zé)外部基礎(chǔ)工具類,比如apiClinent,SessionStorage相關(guān)的基礎(chǔ)工具類。被Repository的具體實(shí)現(xiàn)類調(diào)用。通依賴注入的方式寫入Repository。
目錄結(jié)構(gòu)劃分
- Features
- Auth
- Coordinator
- Data
- Mapper
- Repository
- Domain
- Entity
- UseCase
- Repository
- Service
- UI
- Login
- View
- Controller
- ViewModel
- Registration
- Login
- Auth
以實(shí)現(xiàn)一個簡單的登錄為例,實(shí)踐SwiftUI + Clean架構(gòu)

實(shí)現(xiàn)結(jié)果
-
整體文件夾布局
image.png
構(gòu)建Service(基礎(chǔ)工具層)
- 我們這里的只有LoginService,它的作用就是調(diào)用原生的網(wǎng)絡(luò)框架的方法進(jìn)行接口請求。LoginService是一個接口,由具體的LoginServiceImpl實(shí)現(xiàn)
- Service注入到Respository中,實(shí)現(xiàn)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()
}
}
構(gòu)建Repository
- 我們首先聲明一個LoginRepository接口
protocol LoginRepository {
func login(account: String, password: String) -> AnyPublisher<LoginEntity, AppError>
}
- 這個接口可以被不同的實(shí)例實(shí)現(xiàn),只要實(shí)現(xiàn)了接口,我們就認(rèn)為它具有LoginRepository的功能,這樣我們就能在Coordinator層中注入不同場景下的LoginRepository。比如在正式的運(yùn)行環(huán)境中,我們使用的StandardLoginRepository去真實(shí)的調(diào)用網(wǎng)絡(luò)接口,實(shí)現(xiàn)與服務(wù)端的校驗(yàn);又比如我們在單元測試環(huán)節(jié)時,我們可以實(shí)現(xiàn)一個MockLoginRepository在本地模擬登錄交互環(huán)節(jié)等等。根據(jù)不同的場景使用同一個接口,不同的實(shí)現(xiàn),這種設(shè)計(jì)模式叫做面向接口編程。
- 這里我們使用LoginRepositoryImpl來實(shí)現(xiàn)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)
}
}
構(gòu)建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的主要作用是處理和協(xié)調(diào)Repository的數(shù)據(jù)邏輯,處理一些中間過程,把最終的結(jié)果返回給ViewModel
- 我們Demo中的UseCase,提供了一個Login方法給外部調(diào)用,比如在login這個流程要經(jīng)歷先獲取rsaKey對密碼加密,然后再將加密的數(shù)據(jù)發(fā)送給服務(wù)端,那么在Repository中提供兩個單一的方法(獲取rsakey, 發(fā)送加密數(shù)據(jù)),在UseCase中提供一個方法(login),在這個login方法中依次依序調(diào)用Respository中的單一方法,同時處理異常邏輯,并把最終的結(jié)果回調(diào)給外部
- 我們這里的數(shù)據(jù)交互方式使用的是Combine,當(dāng)然也可以使用閉包。(大家不用過分糾結(jié)數(shù)據(jù)回調(diào)方式是使用combine還是閉包,最重要的是程序的本質(zhì)和思想)
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)
}
}
構(gòu)建ViewModel
- ViewModel的作用主要是處理交互邏輯,比如輸入的字符長短的限制,按鈕點(diǎn)擊相應(yīng),頁面狀態(tài)管理等等
- Repository是通過依賴注入的方式,在創(chuàng)建VIewModel時,注入到usecase中,同時在ViewModel內(nèi)部創(chuàng)建usercase,usecase不對外暴露。
- 數(shù)據(jù)交互邏輯,直接調(diào)用usecase中提供的login方法即可
- account與第一個TextField綁定,用于接收username的輸入, password與第二TextField綁定,用于接收password的輸入,
- loginStatus與View中的ActivityIndicator綁定,主要用于模擬網(wǎng)絡(luò)加載過程;
- loginBtnEnable與LoginBtn的狀態(tài)綁定,控制按鈕enable的時機(jī),只有滿足一定的輸入條件按鈕才能被點(diǎn)擊
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構(gòu)建View
- 在純粹SwiftUI中,沒有Controller,所以View直接與ViewModel綁定,在企業(yè)級項(xiàng)目中可以使用SwiftUI作為UI,UIHonstingViewController來承載SwiftUI。
- 在登錄的demo中,UI比較簡單,兩個TextField來接收用戶文本輸入,一個按鈕接收用戶的點(diǎn)擊等等。
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))
}
}
}
構(gòu)建Coordinator
- Coordinator的作用是創(chuàng)建對用的Controller或者View
- 獲取當(dāng)前的依賴項(xiàng)(外部參數(shù))
- 通過Coordinator實(shí)現(xiàn)頁面跳轉(zhuǎn)
- 可以認(rèn)為Coordinator是一個UINavigationController,創(chuàng)建的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() {
}
}
