MVVM+SwiftUI+Clean架構(gòu)實(shí)踐

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

以實(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() {
        
    }
}

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容