一文精通-OC 和 Swift 开发中的 UI 数据绑定:MVC 与 MVVM 模式详解

233 阅读10分钟

OC 和 Swift 开发中的 UI 数据绑定:MVC 与 MVVM 模式详解

1. 基本概念介绍

1.1 UIKit 与 SwiftUI 对比

UIKit (Objective-C/Swift):

  • 命令式 UI 框架
  • 基于视图控制器 (UIViewController) 的中心架构
  • 需要手动管理视图层次结构和状态
  • 使用委托模式和数据源模式
  • 成熟的生态系统,支持 iOS 8+

SwiftUI (仅 Swift):

  • 声明式 UI 框架
  • 基于值类型的视图结构
  • 自动状态管理和视图更新
  • 内置数据绑定支持
  • 需要 iOS 13+,功能仍在不断丰富中

1.2 MVC 与 MVVM 模式

MVC (Model-View-Controller):

  • 传统苹果推荐模式
  • Controller 作为 Model 和 View 的中介
  • 容易导致 Massive View Controller 问题
  • 数据流向: View → Controller → Model → Controller → View

MVVM (Model-View-ViewModel):

  • 更现代的架构模式
  • ViewModel 封装展示逻辑和状态
  • View 通过绑定自动更新
  • 更好的可测试性和关注点分离
  • 数据流向: View ↔ ViewModel ↔ Model

2. UIKit 中的数据绑定 (Objective-C)

2.1 MVC 模式实现

网络请求与数据处理

objectivec

// UserModel.h
@interface UserModel : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@property (nonatomic, copy) NSString *avatarUrl;

- (instancetype)initWithDictionary:(NSDictionary *)dict;
@end

// UserModel.m
@implementation UserModel
- (instancetype)initWithDictionary:(NSDictionary *)dict {
    self = [super init];
    if (self) {
        _name = dict[@"name"];
        _age = [dict[@"age"] integerValue];
        _avatarUrl = dict[@"avatar"];
    }
    return self;
}
@end
控制器实现

objectivec

// UserViewController.h
#import <UIKit/UIKit.h>

@interface UserViewController : UIViewController
@property (weak, nonatomic) IBOutlet UILabel *nameLabel;
@property (weak, nonatomic) IBOutlet UILabel *ageLabel;
@property (weak, nonatomic) IBOutlet UIImageView *avatarImageView;
@end

// UserViewController.m
#import "UserViewController.h"
#import "UserModel.h"

@interface UserViewController ()
@property (nonatomic, strong) UserModel *user;
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@end

@implementation UserViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self fetchUserData];
}

- (void)fetchUserData {
    NSURL *url = [NSURL URLWithString:@"https://api.example.com/user/123"];
    self.dataTask = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        if (data) {
            NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
            self.user = [[UserModel alloc] initWithDictionary:json];
            
            dispatch_async(dispatch_get_main_queue(), ^{
                [self updateUI];
            });
        }
    }];
    [self.dataTask resume];
}

- (void)updateUI {
    self.nameLabel.text = self.user.name;
    self.ageLabel.text = [NSString stringWithFormat:@"%ld", (long)self.user.age];
    
    if (self.user.avatarUrl) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            NSData *imageData = [NSData dataWithContentsOfURL:[NSURL URLWithString:self.user.avatarUrl]];
            UIImage *image = [UIImage imageWithData:imageData];
            dispatch_async(dispatch_get_main_queue(), ^{
                self.avatarImageView.image = image;
            });
        });
    }
}

- (void)dealloc {
    [self.dataTask cancel];
}

@end

2.2 MVVM 模式实现 (使用 ReactiveObjC)

ViewModel 实现

objectivec

// UserViewModel.h
#import <ReactiveObjC/ReactiveObjC.h>

@class UserModel;

@interface UserViewModel : NSObject
@property (nonatomic, strong, readonly) UserModel *user;
@property (nonatomic, strong, readonly) RACCommand *fetchUserCommand;
@property (nonatomic, strong, readonly) RACSignal *nameSignal;
@property (nonatomic, strong, readonly) RACSignal *ageSignal;
@property (nonatomic, strong, readonly) RACSignal *avatarSignal;
@end

// UserViewModel.m
#import "UserViewModel.h"
#import "UserModel.h"

@interface UserViewModel ()
@property (nonatomic, strong) UserModel *user;
@end

@implementation UserViewModel

- (instancetype)init {
    self = [super init];
    if (self) {
        [self setup];
    }
    return self;
}

- (void)setup {
    @weakify(self);
    self.fetchUserCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
        @strongify(self);
        return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {            NSURL *url = [NSURL URLWithString:@"https://api.example.com/user/123"];
            NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
                if (data) {
                    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
                    self.user = [[UserModel alloc] initWithDictionary:json];
                    [subscriber sendNext:self.user];
                    [subscriber sendCompleted];
                } else {
                    [subscriber sendError:error];
                }
            }];
            [task resume];
            
            return [RACDisposable disposableWithBlock:^{                [task cancel];
            }];
        }];
    }];
    
    self.nameSignal = RACObserve(self, user.name);
    self.ageSignal = [RACObserve(self, user.age) map:^id(NSNumber *age) {        return [NSString stringWithFormat:@"%ld", (long)[age integerValue]];
    }];
    self.avatarSignal = [RACObserve(self, user.avatarUrl) flattenMap:^RACSignal *(NSString *urlString) {        if (!urlString) return [RACSignal return:nil];
        
        return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {            NSURL *url = [NSURL URLWithString:urlString];
            NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
                if (data) {
                    UIImage *image = [UIImage imageWithData:data];
                    [subscriber sendNext:image];
                    [subscriber sendCompleted];
                } else {
                    [subscriber sendError:error];
                }
            }];
            [task resume];
            
            return [RACDisposable disposableWithBlock:^{                [task cancel];
            }];
        }];
    }];
}

@end
控制器实现

objectivec

// UserViewController.m (MVVM版本)
#import "UserViewController.h"
#import "UserViewModel.h"
#import <ReactiveObjC/ReactiveObjC.h>

@interface UserViewController ()
@property (nonatomic, strong) UserViewModel *viewModel;
@end

@implementation UserViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.viewModel = [[UserViewModel alloc] init];
    
    // 绑定数据
    [self bindViewModel];
    
    // 执行获取数据的命令
    [self.viewModel.fetchUserCommand execute:nil];
}

- (void)bindViewModel {
    @weakify(self);
    
    // 绑定名字
    [self.viewModel.nameSignal subscribeNext:^(NSString *name) {        @strongify(self);        self.nameLabel.text = name;    }];
    
    // 绑定年龄
    [self.viewModel.ageSignal subscribeNext:^(NSString *age) {        @strongify(self);        self.ageLabel.text = age;    }];
    
    // 绑定头像
    [self.viewModel.avatarSignal subscribeNext:^(UIImage *image) {        @strongify(self);        self.avatarImageView.image = image;    }];
    
    // 处理错误
    [self.viewModel.fetchUserCommand.errors subscribeNext:^(NSError *error) {        NSLog(@"Error fetching user: %@", error.localizedDescription);    }];
}

@end

3. Swift + UIKit 中的数据绑定

3.1 MVC 模式实现

swift

// UserModel.swift
struct User {
    let name: String
    let age: Int
    let avatarUrl: String
}

// UserViewController.swift
import UIKit

class UserViewController: UIViewController {
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var ageLabel: UILabel!
    @IBOutlet weak var avatarImageView: UIImageView!
    
    private var user: User?
    private var dataTask: URLSessionDataTask?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        fetchUserData()
    }
    
    private func fetchUserData() {
        let url = URL(string: "https://api.example.com/user/123")!
        dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let self = self else { return }
            
            if let data = data {
                do {
                    let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
                    if let json = json {
                        self.user = User(
                            name: json["name"] as? String ?? "",
                            age: json["age"] as? Int ?? 0,
                            avatarUrl: json["avatar"] as? String ?? ""
                        )
                        
                        DispatchQueue.main.async {
                            self.updateUI()
                        }
                    }
                } catch {
                    print("JSON error: (error.localizedDescription)")
                }
            }
        }
        dataTask?.resume()
    }
    
    private func updateUI() {
        guard let user = user else { return }
        
        nameLabel.text = user.name
        ageLabel.text = "(user.age)"
        
        if !user.avatarUrl.isEmpty {
            DispatchQueue.global().async { [weak self] in
                if let url = URL(string: user.avatarUrl),
                   let data = try? Data(contentsOf: url),
                   let image = UIImage(data: data) {
                    DispatchQueue.main.async {
                        self?.avatarImageView.image = image
                    }
                }
            }
        }
    }
    
    deinit {
        dataTask?.cancel()
    }
}

3.2 MVVM 模式实现 (使用 Combine)

ViewModel 实现

swift

// UserViewModel.swift
import Combine

class UserViewModel {
    @Published var name: String = ""
    @Published var age: String = ""
    @Published var avatarImage: UIImage?
    @Published var isLoading: Bool = false
    @Published var error: Error?
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchUser() {
        isLoading = true
        
        let url = URL(string: "https://api.example.com/user/123")!
        URLSession.shared.dataTaskPublisher(for: url)
            .map(.data)
            .decode(type: UserResponse.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.error = error
                }
            } receiveValue: { [weak self] userResponse in
                self?.updateUser(userResponse)
            }
            .store(in: &cancellables)
    }
    
    private func updateUser(_ user: UserResponse) {
        name = user.name
        age = "(user.age)"
        
        guard let url = URL(string: user.avatarUrl) else { return }
        
        URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .replaceError(with: nil)
            .receive(on: DispatchQueue.main)
            .assign(to: .avatarImage, on: self)
            .store(in: &cancellables)
    }
}

struct UserResponse: Codable {
    let name: String
    let age: Int
    let avatarUrl: String
}
控制器实现

swift

// UserViewController.swift (MVVM with Combine)
import UIKit
import Combine

class UserViewController: UIViewController {
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var ageLabel: UILabel!
    @IBOutlet weak var avatarImageView: UIImageView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    
    private var viewModel = UserViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
        viewModel.fetchUser()
    }
    
    private func bindViewModel() {
        // 绑定名字
        viewModel.$name
            .receive(on: DispatchQueue.main)
            .assign(to: .text, on: nameLabel)
            .store(in: &cancellables)
        
        // 绑定年龄
        viewModel.$age
            .receive(on: DispatchQueue.main)
            .assign(to: .text, on: ageLabel)
            .store(in: &cancellables)
        
        // 绑定头像
        viewModel.$avatarImage
            .receive(on: DispatchQueue.main)
            .sink { [weak self] image in
                self?.avatarImageView.image = image
            }
            .store(in: &cancellables)
        
        // 绑定加载状态
        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                if isLoading {
                    self?.activityIndicator.startAnimating()
                } else {
                    self?.activityIndicator.stopAnimating()
                }
            }
            .store(in: &cancellables)
        
        // 处理错误
        viewModel.$error
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] error in
                let alert = UIAlertController(
                    title: "Error",
                    message: error.localizedDescription,
                    preferredStyle: .alert
                )
                alert.addAction(UIAlertAction(title: "OK", style: .default))
                self?.present(alert, animated: true)
            }
            .store(in: &cancellables)
    }
}

4. SwiftUI 中的数据绑定

4.1 原生 SwiftUI 实现 (类似 MVVM)

swift

// UserViewModel.swift
import Combine

class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var error: Error?
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchUser() {
        isLoading = true
        
        let url = URL(string: "https://api.example.com/user/123")!
        URLSession.shared.dataTaskPublisher(for: url)
            .map(.data)
            .decode(type: User.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.error = error
                }
            } receiveValue: { [weak self] user in
                self?.user = user
            }
            .store(in: &cancellables)
    }
}

struct User: Codable {
    let name: String
    let age: Int
    let avatarUrl: String
}

// UserView.swift
import SwiftUI

struct UserView: View {
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView()
            } else if let user = viewModel.user {
                AsyncImage(url: URL(string: user.avatarUrl)) { image in
                    image.resizable()
                } placeholder: {
                    Color.gray
                }
                .frame(width: 100, height: 100)
                .clipShape(Circle())
                
                Text(user.name)
                    .font(.title)
                
                Text("Age: (user.age)")
                    .font(.subheadline)
            } else if let error = viewModel.error {
                Text("Error: (error.localizedDescription)")
                    .foregroundColor(.red)
            }
        }
        .onAppear {
            viewModel.fetchUser()
        }
    }
}

4.2 使用第三方响应式框架 (RxSwift)

ViewModel 实现

swift

// UserViewModel.swift
import RxSwift
import RxCocoa

class UserViewModel {
    // 输出
    let name: Driver<String>
    let age: Driver<String>
    let avatarImage: Driver<UIImage?>
    let isLoading: Driver<Bool>
    let error: Driver<Error?>
    
    // 输入
    let fetchTrigger = PublishSubject<Void>()
    
    private let disposeBag = DisposeBag()
    
    init() {
        let activityIndicator = ActivityIndicator()
        let errorTracker = ErrorTracker()
        
        let user = fetchTrigger
            .flatMapLatest {
                URLSession.shared.rx.data(request: URLRequest(url: URL(string: "https://api.example.com/user/123")!))
                    .map { try JSONDecoder().decode(User.self, from: $0) }
                    .trackActivity(activityIndicator)
                    .trackError(errorTracker)
                    .catchAndReturn(User(name: "", age: 0, avatarUrl: ""))
            }
            .asDriver(onErrorJustReturn: User(name: "", age: 0, avatarUrl: ""))
        
        name = user.map { $0.name }.asDriver()
        age = user.map { "($0.age)" }.asDriver()
        
        avatarImage = user.flatMapLatest { user in
            guard !user.avatarUrl.isEmpty, let url = URL(string: user.avatarUrl) else {
                return Driver.just(nil)
            }
            
            return URLSession.shared.rx.data(request: URLRequest(url: url))
                .map { UIImage(data: $0) }
                .trackActivity(activityIndicator)
                .trackError(errorTracker)
                .asDriver(onErrorJustReturn: nil)
        }
        
        isLoading = activityIndicator.asDriver()
        error = errorTracker.asDriver()
    }
}
SwiftUI View 实现

swift

// UserView.swift (with RxSwift)
import SwiftUI
import RxSwift
import RxCocoa

struct RxUserView: View {
    @ObservedObject private var viewModel = RxUserViewModel()
    private let disposeBag = DisposeBag()
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView()
            } else {
                if let image = viewModel.avatarImage {
                    Image(uiImage: image)
                        .resizable()
                        .frame(width: 100, height: 100)
                        .clipShape(Circle())
                } else {
                    Color.gray
                        .frame(width: 100, height: 100)
                        .clipShape(Circle())
                }
                
                Text(viewModel.name)
                    .font(.title)
                
                Text("Age: (viewModel.age)")
                    .font(.subheadline)
                
                if let error = viewModel.error {
                    Text("Error: (error.localizedDescription)")
                        .foregroundColor(.red)
                }
            }
        }
        .onAppear {
            viewModel.fetchUser()
        }
    }
}

// 适配器将RxSwift的Driver转换为SwiftUI可用的@Published属性
class RxUserViewModel: ObservableObject {
    private let rxViewModel = UserViewModel()
    private let disposeBag = DisposeBag()
    
    @Published var name: String = ""
    @Published var age: String = ""
    @Published var avatarImage: UIImage?
    @Published var isLoading: Bool = false
    @Published var error: Error?
    
    func fetchUser() {
        rxViewModel.fetchTrigger.onNext(())
        
        rxViewModel.name
            .drive(onNext: { [weak self] in self?.name = $0 })
            .disposed(by: disposeBag)
        
        rxViewModel.age
            .drive(onNext: { [weak self] in self?.age = $0 })
            .disposed(by: disposeBag)
        
        rxViewModel.avatarImage
            .drive(onNext: { [weak self] in self?.avatarImage = $0 })
            .disposed(by: disposeBag)
        
        rxViewModel.isLoading
            .drive(onNext: { [weak self] in self?.isLoading = $0 })
            .disposed(by: disposeBag)
        
        rxViewModel.error
            .drive(onNext: { [weak self] in self?.error = $0 })
            .disposed(by: disposeBag)
    }
}

5. 实时数据更新实现

5.1 使用 WebSocket 实时更新 (Combine + SwiftUI)

swift

// RealTimeUserViewModel.swift
import Combine
import Foundation

class RealTimeUserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var error: Error?
    
    private var webSocketTask: URLSessionWebSocketTask?
    private var cancellables = Set<AnyCancellable>()
    
    func connect() {
        isLoading = true
        
        let url = URL(string: "wss://api.example.com/realtime/user/123")!
        webSocketTask = URLSession.shared.webSocketTask(with: url)
        webSocketTask?.resume()
        
        listenForMessages()
    }
    
    private func listenForMessages() {
        webSocketTask?.receive { [weak self] result in
            guard let self = self else { return }
            
            switch result {
            case .failure(let error):
                DispatchQueue.main.async {
                    self.error = error
                    self.isLoading = false
                }
            case .success(let message):
                switch message {
                case .string(let text):
                    if let data = text.data(using: .utf8),
                       let user = try? JSONDecoder().decode(User.self, from: data) {
                        DispatchQueue.main.async {
                            self.user = user
                            self.isLoading = false
                        }
                    }
                case .data(let data):
                    if let user = try? JSONDecoder().decode(User.self, from: data) {
                        DispatchQueue.main.async {
                            self.user = user
                            self.isLoading = false
                        }
                    }
                @unknown default:
                    break
                }
                
                // 继续监听下一条消息
                self.listenForMessages()
            }
        }
    }
    
    func disconnect() {
        webSocketTask?.cancel(with: .normalClosure, reason: nil)
        isLoading = false
    }
    
    deinit {
        disconnect()
    }
}

// RealTimeUserView.swift
import SwiftUI

struct RealTimeUserView: View {
    @StateObject private var viewModel = RealTimeUserViewModel()
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView()
            } else if let user = viewModel.user {
                AsyncImage(url: URL(string: user.avatarUrl)) { image in
                    image.resizable()
                } placeholder: {
                    Color.gray
                }
                .frame(width: 100, height: 100)
                .clipShape(Circle())
                
                Text(user.name)
                    .font(.title)
                
                Text("Age: (user.age)")
                    .font(.subheadline)
            } else if let error = viewModel.error {
                Text("Error: (error.localizedDescription)")
                    .foregroundColor(.red)
            }
        }
        .onAppear {
            viewModel.connect()
        }
        .onDisappear {
            viewModel.disconnect()
        }
    }
}

5.2 使用 Firebase 实时数据库 (MVVM)

swift

// FirebaseUserViewModel.swift
import Combine
import FirebaseDatabase

class FirebaseUserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var error: Error?
    
    private var ref: DatabaseReference!
    private var handle: DatabaseHandle?
    
    init() {
        ref = Database.database().reference()
    }
    
    func startListening() {
        isLoading = true
        handle = ref.child("users").child("123").observe(.value) { [weak self] snapshot in
            guard let self = self else { return }
            
            self.isLoading = false
            guard let value = snapshot.value as? [String: Any] else {
                self.error = NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid data format"])
                return
            }
            
            do {
                let data = try JSONSerialization.data(withJSONObject: value)
                self.user = try JSONDecoder().decode(User.self, from: data)
                self.error = nil
            } catch {
                self.error = error
            }
        } withCancel: { [weak self] error in
            self?.error = error
            self?.isLoading = false
        }
    }
    
    func stopListening() {
        if let handle = handle {
            ref.child("users").child("123").removeObserver(withHandle: handle)
        }
        isLoading = false
    }
    
    deinit {
        stopListening()
    }
}

// FirebaseUserView.swift
import SwiftUI

struct FirebaseUserView: View {
    @StateObject private var viewModel = FirebaseUserViewModel()
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView()
            } else if let user = viewModel.user {
                AsyncImage(url: URL(string: user.avatarUrl)) { image in
                    image.resizable()
                } placeholder: {
                    Color.gray
                }
                .frame(width: 100, height: 100)
                .clipShape(Circle())
                
                Text(user.name)
                    .font(.title)
                
                Text("Age: (user.age)")
                    .font(.subheadline)
                
                Button("Update Age") {
                    if let currentAge = viewModel.user?.age {
                        viewModel.ref.child("users").child("123").updateChildValues(["age": currentAge + 1])
                    }
                }
                .padding()
            } else if let error = viewModel.error {
                Text("Error: (error.localizedDescription)")
                    .foregroundColor(.red)
            }
        }
        .onAppear {
            viewModel.startListening()
        }
        .onDisappear {
            viewModel.stopListening()
        }
    }
}

6. 总结与最佳实践

6.1 模式选择建议

  1. 对于新项目

    • 优先考虑 SwiftUI + Combine + MVVM
    • 如果需要支持 iOS 13 以下,使用 UIKit + Combine/MVVM
  2. 对于现有项目

    • 逐步迁移到 MVVM 模式
    • 引入 Combine 或 RxSwift 处理数据流
    • 新功能使用 SwiftUI 实现
  3. 实时数据需求

    • WebSocket 适合自定义实时协议
    • Firebase 提供完整的实时数据库解决方案
    • Combine 的 PassthroughSubject 或 RxSwift 的 PublishSubject 适合应用内实时事件

6.2 性能考虑

  1. UIKit

    • 避免频繁的主线程更新
    • 使用 diffable data sources 处理表格/集合视图更新
  2. SwiftUI

    • 使用 @StateObject 替代 @ObservedObject 避免不必要的重建
    • 对于复杂视图,考虑使用 EquatableView 或自定义视图的 equatable() 实现
  3. 网络请求

    • 使用 Combine 的 share() 或 RxSwift 的 share(replay:scope:) 避免重复请求
    • 实现适当的缓存策略

6.3 测试策略

  1. ViewModel 测试

    • 测试业务逻辑和状态转换
    • 模拟网络请求和依赖项
  2. UI 测试

    • 对于 SwiftUI,使用 XCTest 检查视图状态
    • 对于 UIKit,测试视图控制器的输出和行为
  3. 集成测试

    • 测试整个功能流程
    • 包括网络层和持久层的交互

通过以上模式和技术的合理应用,可以构建出响应迅速、易于维护且可测试性高的 iOS 应用程序。

Swift + UIKit 中使用 RxSwift 实现数据绑定与 UI 实时驱动

1. RxSwift 基础概念

在开始具体实现前,先了解几个 RxSwift 核心概念:

  • Observable: 可观察序列,表示数据流
  • Observer: 观察者,订阅 Observable 并响应事件
  • Subject: 既是 Observable 又是 Observer
  • Operator: 操作符,用于转换、过滤、组合序列
  • Disposable: 管理订阅的生命周期
  • Scheduler: 决定在哪个线程执行操作

2. 项目设置

首先需要在项目中集成 RxSwift 和 RxCocoa:

  1. 使用 CocoaPods 添加依赖:

ruby

pod 'RxSwift'
pod 'RxCocoa'
  1. 在需要使用的文件中导入:

swift

import RxSwift
import RxCocoa

3. 基础数据绑定示例

3.1 简单的文本绑定

swift

class SimpleBindingViewController: UIViewController {
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var label: UILabel!
    
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 将 textField 的文本变化绑定到 label
        textField.rx.text
            .orEmpty // 将 String? 转换为 String
            .bind(to: label.rx.text)
            .disposed(by: disposeBag)
    }
}

3.2 按钮点击事件

swift

class ButtonClickViewController: UIViewController {
    @IBOutlet weak var button: UIButton!
    @IBOutlet weak var counterLabel: UILabel!
    
    private let disposeBag = DisposeBag()
    private let counter = BehaviorSubject(value: 0)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 按钮点击时增加计数器
        button.rx.tap
            .subscribe(onNext: { [weak self] in
                guard let self = self else { return }
                let currentValue = (try? self.counter.value()) ?? 0
                self.counter.onNext(currentValue + 1)
            })
            .disposed(by: disposeBag)
        
        // 将计数器绑定到 label
        counter
            .map { "Clicked ($0) times" }
            .bind(to: counterLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

4. 完整 MVVM 示例:用户信息展示

4.1 Model 层

swift

struct User {
    let id: Int
    let name: String
    let email: String
    let avatarUrl: String
}

extension User: Decodable {
    enum CodingKeys: String, CodingKey {
        case id, name, email
        case avatarUrl = "avatar_url"
    }
}

4.2 ViewModel 层

swift

class UserViewModel {
    // 输入
    let fetchTrigger = PublishSubject<Void>()
    
    // 输出
    let name: Driver<String>
    let email: Driver<String>
    let avatarImage: Driver<UIImage?>
    let isLoading: Driver<Bool>
    let error: Driver<Error?>
    
    private let disposeBag = DisposeBag()
    
    init() {
        // ActivityIndicator 用于跟踪加载状态
        let activityIndicator = ActivityIndicator()
        let errorTracker = ErrorTracker()
        
        // 网络请求获取用户数据
        let user = fetchTrigger
            .flatMapLatest {
                URLSession.shared.rx.data(request: URLRequest(url: URL(string: "https://api.example.com/users/1")!))
                    .map { try JSONDecoder().decode(User.self, from: $0) }
                    .trackActivity(activityIndicator)
                    .trackError(errorTracker)
                    .catchAndReturn(User(id: 0, name: "Error", email: "", avatarUrl: ""))
            }
            .asDriver(onErrorJustReturn: User(id: 0, name: "Error", email: "", avatarUrl: ""))
        
        // 处理输出
        name = user.map { $0.name }.asDriver()
        email = user.map { $0.email }.asDriver()
        
        avatarImage = user.flatMapLatest { user in
            guard !user.avatarUrl.isEmpty, let url = URL(string: user.avatarUrl) else {
                return Driver.just(nil)
            }
            
            return URLSession.shared.rx.data(request: URLRequest(url: url))
                .map { UIImage(data: $0) }
                .trackActivity(activityIndicator)
                .trackError(errorTracker)
                .asDriver(onErrorJustReturn: nil)
        }
        
        isLoading = activityIndicator.asDriver()
        error = errorTracker.asDriver()
    }
}

4.3 View 层 (ViewController)

swift

class UserViewController: UIViewController {
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var emailLabel: UILabel!
    @IBOutlet weak var avatarImageView: UIImageView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var refreshButton: UIButton!
    
    private let viewModel = UserViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }
    
    private func setupBindings() {
        // 输入绑定
        refreshButton.rx.tap
            .bind(to: viewModel.fetchTrigger)
            .disposed(by: disposeBag)
        
        // 输出绑定
        viewModel.name
            .drive(nameLabel.rx.text)
            .disposed(by: disposeBag)
        
        viewModel.email
            .drive(emailLabel.rx.text)
            .disposed(by: disposeBag)
        
        viewModel.avatarImage
            .drive(avatarImageView.rx.image)
            .disposed(by: disposeBag)
        
        viewModel.isLoading
            .drive(onNext: { [weak self] isLoading in
                self?.activityIndicator.isHidden = !isLoading
                isLoading ? self?.activityIndicator.startAnimating() : self?.activityIndicator.stopAnimating()
            })
            .disposed(by: disposeBag)
        
        viewModel.error
            .drive(onNext: { [weak self] error in
                guard let error = error else { return }
                self?.showErrorAlert(error: error)
            })
            .disposed(by: disposeBag)
        
        // 初始加载
        viewModel.fetchTrigger.onNext(())
    }
    
    private func showErrorAlert(error: Error) {
        let alert = UIAlertController(
            title: "Error",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
}

5. 表格视图 (UITableView) 数据绑定

5.1 简单列表绑定

swift

class SimpleListViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    
    private let disposeBag = DisposeBag()
    private let items = Observable.just(["Item 1", "Item 2", "Item 3", "Item 4"])
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 注册 cell
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        
        // 绑定数据到表格
        items
            .bind(to: tableView.rx.items(cellIdentifier: "Cell", cellType: UITableViewCell.self)) { (row, element, cell) in
                cell.textLabel?.text = element
            }
            .disposed(by: disposeBag)
        
        // 处理 cell 选择事件
        tableView.rx.modelSelected(String.self)
            .subscribe(onNext: { item in
                print("Selected: (item)")
            })
            .disposed(by: disposeBag)
    }
}

5.2 复杂列表绑定 (MVVM)

ViewModel

swift

struct Product {
    let id: Int
    let name: String
    let price: Double
}

class ProductListViewModel {
    let products: Driver<[Product]>
    let isLoading: Driver<Bool>
    let error: Driver<Error?>
    
    private let fetchTrigger = PublishSubject<Void>()
    
    init() {
        let activityIndicator = ActivityIndicator()
        let errorTracker = ErrorTracker()
        
        products = fetchTrigger
            .flatMapLatest {
                // 模拟网络请求
                Observable<[Product]>.create { observer in
                    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                        let products = [                            Product(id: 1, name: "iPhone", price: 999),                            Product(id: 2, name: "MacBook", price: 1999),                            Product(id: 3, name: "iPad", price: 799)                        ]
                        observer.onNext(products)
                        observer.onCompleted()
                    }
                    return Disposables.create()
                }
                .trackActivity(activityIndicator)
                .trackError(errorTracker)
                .asDriver(onErrorJustReturn: [])
            }
            .asDriver(onErrorJustReturn: [])
        
        isLoading = activityIndicator.asDriver()
        error = errorTracker.asDriver()
        
        // 初始加载
        fetchTrigger.onNext(())
    }
    
    func refresh() {
        fetchTrigger.onNext(())
    }
}
ViewController

swift

class ProductListViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var refreshButton: UIButton!
    
    private let viewModel = ProductListViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        setupBindings()
    }
    
    private func setupTableView() {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }
    
    private func setupBindings() {
        // 绑定产品列表到表格
        viewModel.products
            .drive(tableView.rx.items(cellIdentifier: "Cell", cellType: UITableViewCell.self)) { (row, product, cell) in
                cell.textLabel?.text = product.name
                cell.detailTextLabel?.text = "$(product.price)"
            }
            .disposed(by: disposeBag)
        
        // 绑定加载状态
        viewModel.isLoading
            .drive(onNext: { [weak self] isLoading in
                self?.activityIndicator.isHidden = !isLoading
                isLoading ? self?.activityIndicator.startAnimating() : self?.activityIndicator.stopAnimating()
            })
            .disposed(by: disposeBag)
        
        // 绑定错误
        viewModel.error
            .drive(onNext: { [weak self] error in
                guard let error = error else { return }
                self?.showErrorAlert(error: error)
            })
            .disposed(by: disposeBag)
        
        // 刷新按钮
        refreshButton.rx.tap
            .subscribe(onNext: { [weak self] in
                self?.viewModel.refresh()
            })
            .disposed(by: disposeBag)
        
        // 处理选择事件
        tableView.rx.modelSelected(Product.self)
            .subscribe(onNext: { product in
                print("Selected product: (product.name)")
            })
            .disposed(by: disposeBag)
    }
    
    private func showErrorAlert(error: Error) {
        let alert = UIAlertController(
            title: "Error",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
}

6. 表单验证示例

swift

class FormValidationViewController: UIViewController {
    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var loginButton: UIButton!
    @IBOutlet weak var usernameErrorLabel: UILabel!
    @IBOutlet weak var passwordErrorLabel: UILabel!
    
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupValidation()
    }
    
    private func setupValidation() {
        // 用户名验证 (至少3个字符)
        let usernameValid = usernameTextField.rx.text.orEmpty
            .map { $0.count >= 3 }
            .share(replay: 1)
        
        // 密码验证 (至少6个字符)
        let passwordValid = passwordTextField.rx.text.orEmpty
            .map { $0.count >= 6 }
            .share(replay: 1)
        
        // 用户名错误提示
        usernameValid
            .skip(1) // 跳过初始空值
            .map { $0 ? "" : "Username must be at least 3 characters" }
            .bind(to: usernameErrorLabel.rx.text)
            .disposed(by: disposeBag)
        
        // 密码错误提示
        passwordValid
            .skip(1)
            .map { $0 ? "" : "Password must be at least 6 characters" }
            .bind(to: passwordErrorLabel.rx.text)
            .disposed(by: disposeBag)
        
        // 按钮启用状态
        Observable.combineLatest(usernameValid, passwordValid) { $0 && $1 }
            .bind(to: loginButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        // 按钮点击事件
        loginButton.rx.tap
            .subscribe(onNext: { [weak self] in
                self?.login()
            })
            .disposed(by: disposeBag)
    }
    
    private func login() {
        guard let username = usernameTextField.text,
              let password = passwordTextField.text else { return }
        
        print("Attempting to login with username: (username), password: (password)")
        // 实际登录逻辑...
    }
}

7. 实时搜索示例

swift

class SearchViewController: UIViewController {
    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    
    private let disposeBag = DisposeBag()
    private let viewModel = SearchViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        setupBindings()
    }
    
    private func setupTableView() {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }
    
    private func setupBindings() {
        // 将搜索框文本变化绑定到 ViewModel
        searchBar.rx.text.orEmpty
            .bind(to: viewModel.searchQuery)
            .disposed(by: disposeBag)
        
        // 绑定搜索结果到表格
        viewModel.searchResults
            .drive(tableView.rx.items(cellIdentifier: "Cell", cellType: UITableViewCell.self)) { (row, result, cell) in
                cell.textLabel?.text = result
            }
            .disposed(by: disposeBag)
        
        // 绑定加载状态
        viewModel.isLoading
            .drive(activityIndicator.rx.isAnimating)
            .disposed(by: disposeBag)
        
        // 处理选择事件
        tableView.rx.modelSelected(String.self)
            .subscribe(onNext: { [weak self] item in
                self?.showDetail(for: item)
            })
            .disposed(by: disposeBag)
    }
    
    private func showDetail(for item: String) {
        let alert = UIAlertController(title: "Selected", message: item, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
}

class SearchViewModel {
    let searchQuery = BehaviorSubject(value: "")
    let searchResults: Driver<[String]>
    let isLoading: Driver<Bool>
    
    private let disposeBag = DisposeBag()
    
    init() {
        let activityIndicator = ActivityIndicator()
        
        // 模拟搜索 API 调用
        searchResults = searchQuery
            .debounce(.milliseconds(300), scheduler: MainScheduler.instance) // 防抖
            .distinctUntilChanged() // 忽略相同值
            .flatMapLatest { query -> Observable<[String]> in
                if query.isEmpty {
                    return .just([])
                }
                
                // 模拟网络请求
                return Observable<[String]>.create { observer in
                    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                        let results = (1...5).map { "(query) result ($0)" }
                        observer.onNext(results)
                        observer.onCompleted()
                    }
                    return Disposables.create()
                }
                .trackActivity(activityIndicator)
            }
            .asDriver(onErrorJustReturn: [])
        
        isLoading = activityIndicator.asDriver()
    }
}

8. 最佳实践与注意事项

  1. 内存管理:

    • 始终使用 disposed(by: disposeBag) 管理订阅
    • 在适当的时候使用 [weak self] 避免循环引用
    • 对于长时间运行的订阅考虑使用 takeUntil 操作符
  2. 线程管理:

    • 使用 observeOn 和 subscribeOn 控制线程
    • UI 更新必须在主线程执行
  3. 性能优化:

    • 对于频繁变化的事件使用 debounce 或 throttle
    • 使用 share(replay:scope:) 避免重复计算
    • 对于表格/集合视图使用 distinctUntilChanged 减少不必要的刷新
  4. 错误处理:

    • 使用 materialize() 将错误转换为事件
    • 使用 retry 操作符处理可重试的错误
    • 通过 ErrorTracker 集中处理错误
  5. 测试:

    • 使用 RxTest 和 RxBlocking 进行单元测试
    • 测试 Observable 的输入输出关系
    • 模拟时间相关的操作

通过 RxSwift,我们可以以声明式的方式构建响应式 UI,实现数据与界面的自动同步,大大简化了 UIKit 应用的状态管理和数据绑定工作。