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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 224,815评论 6 522
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 96,251评论 3 402
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 171,999评论 0 366
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 60,996评论 1 300
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 69,993评论 6 400
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 53,477评论 1 314
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 41,848评论 3 428
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 40,823评论 0 279
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 47,361评论 1 324
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 39,401评论 3 346
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 41,518评论 1 354
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 37,119评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,850评论 3 338
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 33,292评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 34,424评论 1 275
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 50,072评论 3 381
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 46,588评论 2 365

推荐阅读更多精彩内容