SwiftUI极简教程31:Combine异步编程框架和MVVM开发模式的使用(下)

1,549 阅读3分钟

承接上一章的内容,这一章,我们实现一下Combine异步编程框架和MVVM开发模式。

我们来看下登录页面有哪些元素:用户名、密码、再次输入密码。

1.png

接下来,每一个元素的校验规则我们定一下:

用户名:至少需要2个字符;

密码:至少需要6位数,而且需要有一位是大写;

再次输入密码:需要和密码相同;

数据模型创建

我们创建一个新的swift文件,命名为ModelView.swift,用来作为ModelView文件。

class ViewModel: ObservableObject {

    // 输入
    @Published var username = ""
    @Published var password = ""
    @Published var passwordConfirm = ""

    // 输出
    @Published var isUsernameLengthValid = false
    @Published var isPasswordLengthValid = false
    @Published var isPasswordCapitalLetter = false
    @Published var isPasswordConfirmValid = false
}

2.png

我们创建了一个ModelView类,它符合ObservableObject协议,然后使用了@Published注释username用户名、password密码和passwordConfirm二次密码,当我们的值发生变化的时候,系统会通知订阅者执行相应的校验。

校验规则-订阅

好了,数据模型建立好了,我们继续完成数据校验规则的部分。

首先,我们试试完成用户名的校验,当username用户名发生改变的时候,我们将结果告诉isUsernameLengthValid

然后,在这里我们使用到的就是Combine异步编程框架,首先需要引入import Combine,然后在init()方法中完成代码。

Combine框架提供了两个内置订户:接收和分配。接收器创建一个通用订阅者来接收值;分配器创建特定属性,用来更新数据对象。例如,它将验证结果(true/false)直接赋值给isUsernameLengthValid

init() {

        //用户名校验
        $username
            .receive(on: RunLoop.main)
            .map { username in
                return username.count >= 2
            }
            .assign(to: \.isUsernameLengthValid, on: self)
    }

在上面的代码中,$username是我们需要操作的监听的数据源,我们调用receive(on:xxxx)函数来确保订阅者在主线程RunLoop上接收到它的值。

map函数是Combine中的操作符,它接受输入、处理输入并将输入转换为订阅者所期望的内容,也就是判断username用户名至少2个字符。

最后,我们将验证结果作为布尔值(true/false)返回给订阅者。

同理,我们完成密码、密码二次确认的代码。

init() {

         //用户名校验
        $username
            .receive(on: RunLoop.main)
            .map { username in
                return username.count >= 2
            }
            .assign(to: \.isUsernameLengthValid, on: self)

         //密码校验
        $password
            .receive(on: RunLoop.main)
            .map { password in
                return password.count >= 6
            }
            .assign(to: \.isPasswordLengthValid, on: self)

        //密码大写校验
        $password
            .receive(on: RunLoop.main)
            .map { password in
            
        let pattern = "[A-Z]"
        
        if let _ = password.range(of: pattern, options: .regularExpression) {
                    return true
                } else {
                    return false
                }
            }
            .assign(to: \.isPasswordCapitalLetter, on: self)
    }

第一个订阅者订阅密码长度的验证结果,我们分配给isPasswordLengthValid属性。

第二个用于处理大写字母的验证,我们使用range方法来测试密码是否至少包含一个大写字母,然后分配给isPasswordCapitalLetter属性。

对于密码和密码二次确认,由于passwordpasswordConfirm都是发布者,我们需要验证两个发布者是否具有相同的值,我们使用Publisher.combingLatest来接收和组合来自发布者的最新值,然后验证这两个值是否相同。

//两次密码是否相同
        Publishers.CombineLatest($password, $passwordConfirm).receive(on: RunLoop.main)
            .map { password, passwordConfirm in
                !passwordConfirm.isEmpty && (passwordConfirm == password)
            }
            .assign(to: \.isPasswordConfirmValid, on: self)

校验规则-取消

完成了基于Combine异步编程框架订阅后,我们还需要完成取消订阅的操作,以便于我们在ModelView类初始化的时候更新UI。

我们需要定义一个取消订阅的数组,把可以被取消的引用全部包裹在里面。

private var cancellableSet: Set<AnyCancellable> = []

然后在每一个校验代码后面都加上.store修饰。

.store(in: &cancellableSet)

store函数允许我们将可取消引用保存到一个集合中,以便以后进行清理。如果不存储引用,可能会出现内存泄漏问题。

3.png

校验规则-引用

接下来,我们可以在ContentView主视图中引用校验规则。

由于我们在ModelView中定义好了我们需要的属性,username用户名、password密码和passwordConfirm二次密码。那么我们就可以直接引用ModelView,然后删掉之前用@State定义的参数。

@ObservedObject private var viewModel = ViewModel()

然后校验规则的绑定上,我们将原有的$绑定关系,修订为$viewModel.XXXX绑定关系。

以及我们可以根据订阅者接收返回的值,示例isUsernameLengthValid,判读是否显示错误提醒。

//用户名
VStack {
    RegistrationView(isTextField: true, fieldName: "用户名", fieldValue: $viewModel.username)

    if viewModel.isUsernameLengthValid {
        InputErrorView(iconName: "exclamationmark.circle.fill", text:"用户不存在")
        }
    }

4.png

恭喜你,完成了本章的所有练习~

章节中可能有存在校验规则的一些小错误,这里也懒得改了,就当作留个小作业给到童鞋们吧!

完整代码

//ViewModel.swift

import Combine
import Foundation

class ViewModel: ObservableObject {

    // 输入
    @Published var username = ""
    @Published var password = ""
    @Published var passwordConfirm = “"

    // 输出
    @Published var isUsernameLengthValid = false
    @Published var isPasswordLengthValid = false
    @Published var isPasswordCapitalLetter = false
    @Published var isPasswordConfirmValid = false

    //取消订阅
    private var cancellableSet: Set<AnyCancellable> = []

    init() {

        //用户名校验
        $username
            .receive(on: RunLoop.main)
            .map { username in

                username.count >= 2

            }
            .assign(to: \.isUsernameLengthValid, on: self)
            .store(in: &cancellableSet)

        //密码校验
        $password
            .receive(on: RunLoop.main)
            .map { password in
                password.count >= 6
            }
            .assign(to: \.isPasswordLengthValid, on: self)
            .store(in: &cancellableSet)

        //密码大写校验
        $password
            .receive(on: RunLoop.main)
            .map { password in

                let pattern = "[A-Z]"

                if let _ = password.range(of: pattern, options: .regularExpression) {
                    return true
                } else {
                    return false
                }
            }
            .assign(to: \.isPasswordCapitalLetter, on: self)
            .store(in: &cancellableSet)

        //两次密码是否相同
        Publishers.CombineLatest($password, $passwordConfirm).receive(on: RunLoop.main)
            .map { password, passwordConfirm in
                !passwordConfirm.isEmpty && (passwordConfirm == password)
            }
            .assign(to: \.isPasswordConfirmValid, on: self)
            .store(in: &cancellableSet)
    }
}
//ContentView.swift

import SwiftUI

struct ContentView: View {

    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        VStack (alignment: .leading, spacing: 40) {

            //用户名
            VStack {
                RegistrationView(isTextField: true, fieldName: "用户名", fieldValue: $viewModel.username)

                if !viewModel.isUsernameLengthValid {
                    InputErrorView(iconName: "exclamationmark.circle.fill", text:"用户不存在")
                    }
            }

            //密码
            VStack{
                RegistrationView(isTextField: false, fieldName: "密码", fieldValue: $viewModel.password)

                if !viewModel.isPasswordLengthValid && !viewModel.isPasswordCapitalLetter {
                    InputErrorView(iconName: "exclamationmark.circle.fill", text: viewModel.isPasswordCapitalLetter ? "密码不正确" : "密码需要有一位大写")
                }
            }

            //再次输入密码
            VStack {
                RegistrationView(isTextField: false, fieldName: "再次输入密码", fieldValue: $viewModel.passwordConfirm)

                if !viewModel.isPasswordConfirmValid {
                    InputErrorView(iconName: "exclamationmark.circle.fill", text: "两次密码需要相同")
                }
            }

            //注册按钮
            Button(action: {

            }) {
                Text("注册")
                    .font(.system(.body, design: .rounded))
                    .foregroundColor(.white)
                    .bold()
                    .padding()
                    .frame(minWidth: 0, maxWidth: .infinity)
                    .background(Color(red: 51 / 255, green: 51 / 255, blue: 51 / 255))
                    .cornerRadius(10)
                    .padding(.horizontal)
            }
        }.padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

//注册视图
struct RegistrationView:View {

    var isTextField = false
    var fieldName = ""
    @Binding var fieldValue: String

    var body: some View {
        VStack {
            //判断是不是输入框
            if isTextField {

                //输入框
                TextField(fieldName, text: $fieldValue)
                    .font(.system(size: 20, weight: .semibold))
                    .padding(.horizontal)

            } else {

                //密码输入框
                SecureField(fieldName, text: $fieldValue)
                    .font(.system(size: 20, weight: .semibold))
                    .padding(.horizontal)
            }

            //分割线
            Divider()
                .frame(height: 1)
                .background(Color(red: 240/255, green: 240/255, blue: 240/255))
                .padding(.horizontal)
        }
    }
}

//错误判断
struct InputErrorView:View {

    var iconName = ""
    var text = ""

    var body: some View {
        HStack {
            Image(systemName: iconName)
                .foregroundColor(Color(red: 251/255, green: 128/255, blue: 128/255))
            Text(text)
                .font(.system(.body, design: .rounded))
                .foregroundColor(Color(red: 251/255, green: 128/255, blue: 128/255))
            Spacer()
            
        }.padding(.leading,10)
    }
}

快来动手试试吧!

如果本专栏对你有帮助,不妨点赞、评论、关注~