MVVM+SwiftUI+Clean架构实践

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

以实现一个简单的登录为例,实践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() {
        
    }
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容