一套基于Swift+MVVM为基础的iOS App 开发框架

基于 MVVM 模式、Swift 语言、可直接落地生产 的 iOS APP 开发框架,核心需求是:框架需覆盖项目工程结构、基础组件、业务分层、通用能力封装、开发规范,实现「高内聚、低耦合、易扩展、易维护」,让团队开发时只需关注业务逻辑,无需重复造轮子 ------ 这套框架会贴合 iOS 现代开发范式(Swift 5.9+、iOS 15+),融合 Combine 响应式编程、模块化设计、协议化抽象,同时沉淀 MVVM 最佳实践,适配中小到中大型 APP 的开发场景。

以下是一套完整、可落地的 MVVM 开发框架设计方案,从「核心设计理念→工程结构→基础层封装→业务层规范→通用能力→工程配置→团队协作规范」逐步拆解,所有代码均为生产级,可直接复制到项目中复用。

一、框架核心设计理念

MVVM 的核心是「数据驱动、层间解耦」,这套框架的设计会围绕以下 5 个核心原则,避免 MVVM 落地时常见的「ViewModel 臃肿、数据流向混乱、层间依赖失控」问题:

表格

设计原则 具体落地方式
单向数据流 View → ViewModel → Model → ViewModel → View,所有数据变更仅通过@Published/PassthroughSubject传递,禁止 View 直接修改 Model
层间严格隔离 View 只依赖 ViewModel,ViewModel 只依赖 Repository/Service,Repository 只依赖 Model / 网络 / 存储,禁止跨层调用
协议化抽象 核心能力(网络、存储、定位)通过协议定义,实现类和接口分离,便于单元测试和替换实现
组合优于继承 基础能力通过「协议扩展 + 组合」实现,避免深层继承链;ViewModel 通过BaseViewModel做轻量封装,而非强继承
响应式优先 基于 Combine 实现数据绑定,View 层仅订阅 ViewModel 的发布属性,无手动刷新 UI 的代码

二、标准化工程结构

工程结构是框架的「骨架」,需兼顾「模块化、业务化、清晰性」,以下是经过验证的标准化结构(适配 Xcode 15+,支持 Swift Package Manager/ CocoaPods):

plaintext

复制代码
YourApp/
├── App/                          # 应用入口(生命周期、全局配置)
│   ├── AppDelegate.swift         # UIKit入口(或SwiftUI的App结构体)
│   ├── SceneDelegate.swift       # 多场景配置(UIKit)
│   └── AppConfiguration.swift    # 全局配置(环境、主题、功能开关)
├── Core/                         # 核心层(框架基础,无业务逻辑)
│   ├── Base/                     # 基础抽象(协议、基类)
│   │   ├── BaseViewModel.swift   # ViewModel基类(封装Combine、加载状态)
│   │   ├── BaseViewController.swift # View基类(UIKit,封装通用UI逻辑)
│   │   ├── BaseView.swift        # 自定义View基类(UIKit/SwiftUI)
│   │   └── BaseRepository.swift  # 数据仓库基类(封装网络/存储通用逻辑)
│   ├── Common/                   # 通用工具(全项目复用)
│   │   ├── Extensions/           # 系统类扩展(UIKit/Swift/Combine)
│   │   ├── Constants/            # 常量(枚举、静态常量)
│   │   ├── Utils/                # 工具类(加密、验证、格式化)
│   │   └── Logger/               # 日志工具(分级打印、埋点)
│   ├── Services/                 # 核心服务(协议+实现,通用能力)
│   │   ├── Network/              # 网络服务(复用之前的Moya/GraphQL封装)
│   │   ├── Storage/              # 存储服务(Keychain/ UserDefaults/ CoreData)
│   │   ├── Location/             # 定位服务(CoreLocation封装)
│   │   ├── Analytics/            # 埋点服务(友盟/GA封装)
│   │   └── Theme/                # 主题服务(暗黑模式、换肤)
│   └── Models/                   # 全局通用模型(如BaseResponse、UserModel)
├── Modules/                      # 业务模块(按功能拆分,高内聚)
│   ├── User/                     # 用户模块(登录、个人中心)
│   │   ├── View/                 # 视图层(VC/View/UI组件)
│   │   ├── ViewModel/            # 视图模型层(业务逻辑)
│   │   ├── Repository/           # 数据仓库层(网络/存储数据聚合)
│   │   ├── Model/                # 模块内私有模型
│   │   └── Router/               # 模块路由(页面跳转)
│   ├── Home/                     # 首页模块
│   ├── Order/                    # 订单模块
│   └── Product/                  # 商品模块
├── Resources/                    # 资源文件(非代码)
│   ├── Assets.xcassets           # 图片/颜色/字体资源
│   ├── Localizable.strings       # 多语言
│   ├── Configs/                  # 配置文件(plist/json)
│   └── Fonts/                    # 自定义字体
└── Tests/                        # 单元测试/UI测试
    ├── UnitTests/                # 单元测试(ViewModel/Repository)
    └── UITests/                  # UI测试(View层)

关键结构说明:

  1. Core 层:「纯框架代码」,无任何业务逻辑,可抽成独立的 Swift Package/ Cocoapod 库,供多项目复用;
  2. Modules 层:按「业务域」拆分(而非按 UI 层拆分),每个模块是「高内聚」的独立单元,模块间通过「Router / 协议」通信,禁止直接依赖;
  3. Repository 层:MVVM 的「数据层」,负责聚合网络、存储、第三方 SDK 的数据,ViewModel 只依赖 Repository,不直接依赖网络 / 存储,便于单元测试;
  4. Router 层:模块内 / 跨模块页面跳转的统一入口,避免 View/ViewModel 直接创建 VC,解耦页面依赖。

三、核心层封装(Core)

Core 层是框架的「基础能力库」,以下是核心文件的生产级封装代码,所有代码均遵循「协议化、响应式、可测试」原则。

3.1 基础层(Core/Base)

3.1.1 BaseViewModel.swift(ViewModel 基类)

封装 Combine 订阅管理、加载状态、错误处理,避免每个 ViewModel 重复写冗余代码:

swift

复制代码
import Foundation
import Combine

/// MVVM核心:ViewModel基类(所有ViewModel的父类)
open class BaseViewModel: ObservableObject {
    // MARK: - 核心属性
    /// 管理Combine订阅,防止内存泄漏(自动跟随ViewModel销毁)
    internal var cancellables = Set<AnyCancellable>()
    /// 加载状态(View层绑定,控制加载动画)
    @Published public var loadingState: LoadingState = .idle
    /// 全局错误(View层绑定,展示错误提示)
    @Published public var error: AppError?
    
    // MARK: - 生命周期
    public init() {}
    
    deinit {
        cancellables.removeAll()
        #if DEBUG
        print("[MVVM] \(self) deinit ✅")
        #endif
    }
    
    // MARK: - 通用方法
    /// 标记请求开始(设置加载状态为loading)
    public func startLoading() {
        loadingState = .loading
    }
    
    /// 标记请求结束(恢复加载状态为idle)
    public func stopLoading() {
        loadingState = .idle
    }
    
    /// 处理错误(统一设置error属性,可扩展全局错误处理)
    public func handleError(_ error: AppError) {
        self.error = error
        stopLoading()
        // 可选:全局错误上报/弹窗
        ErrorHandler.shared.handle(error)
    }
}

// MARK: - 加载状态枚举(覆盖常见场景)
public enum LoadingState: Equatable {
    case idle       // 初始状态
    case loading    // 加载中
    case empty      // 加载完成但无数据
    case error      // 加载失败
}

// MARK: - 全局错误枚举(覆盖APP所有错误场景)
public enum AppError: LocalizedError, Equatable {
    case network(NetworkError)      // 网络错误(复用之前的NetworkError)
    case storage(String)            // 存储错误
    case business(code: Int, msg: String) // 业务错误
    case system(String)             // 系统错误(如权限、解析)
    case custom(String)             // 自定义错误
    
    public var errorDescription: String? {
        switch self {
        case .network(let error):
            return error.localizedDescription
        case .storage(let msg):
            return "存储错误:\(msg)"
        case .business(_, let msg):
            return msg
        case .system(let msg):
            return "系统错误:\(msg)"
        case .custom(let msg):
            return msg
        }
    }
    
    // Equatable实现(便于比较)
    public static func == (lhs: AppError, rhs: AppError) -> Bool {
        switch (lhs, rhs) {
        case (.network(let l), .network(let r)): return l.localizedDescription == r.localizedDescription
        case (.storage(let l), .storage(let r)): return l == r
        case (.business(let lCode, let lMsg), .business(let rCode, let rMsg)): return lCode == rCode && lMsg == rMsg
        case (.system(let l), .system(let r)): return l == r
        case (.custom(let l), .custom(let r)): return l == r
        default: return false
        }
    }
}

// MARK: - 全局错误处理器(可扩展)
public final class ErrorHandler {
    public static let shared = ErrorHandler()
    private init() {}
    
    /// 统一处理错误(可扩展:弹窗、埋点、日志)
    public func handle(_ error: AppError) {
        #if DEBUG
        print("[Error] \(error.errorDescription ?? "未知错误")")
        #endif
        // 生产环境:根据错误类型展示吐司/弹窗
        switch error {
        case .network(.noNetwork):
            ToastManager.shared.show(text: "网络连接失败,请检查网络")
        case .network(.tokenExpired):
            NotificationCenter.default.post(name: .tokenExpired, object: nil)
        default:
            ToastManager.shared.show(text: error.errorDescription!)
        }
    }
}

// MARK: - 通知扩展(全局通知)
extension Notification.Name {
    public static let tokenExpired = Notification.Name("kTokenExpired")
}
3.1.2 BaseViewController.swift(UIKit View 基类)

封装通用 UI 逻辑(导航栏、生命周期、数据绑定),避免 ViewController 冗余:

swift

复制代码
import UIKit
import Combine

/// UIKit基础ViewController(所有VC的父类)
open class BaseViewController<VM: BaseViewModel>: UIViewController {
    // MARK: - 核心属性
    /// 关联的ViewModel(子类需初始化)
    public var viewModel: VM!
    /// 管理Combine订阅(View层)
    private var viewCancellables = Set<AnyCancellable>()
    /// 加载动画View(全局复用)
    private lazy var loadingView: LoadingIndicatorView = {
        let view = LoadingIndicatorView(frame: self.view.bounds)
        view.isHidden = true
        return view
    }()
    
    // MARK: - 生命周期
    public override func viewDidLoad() {
        super.viewDidLoad()
        setupBaseUI()
        setupBindings() // 数据绑定(子类重写)
        setupViewModel() // 初始化ViewModel(子类重写)
        setupNavigation() // 导航栏配置(子类重写)
        setupGesture() // 手势配置(如点击空白处收起键盘)
    }
    
    public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
    }
    
    deinit {
        viewCancellables.removeAll()
        #if DEBUG
        print("[VC] \(self) deinit ✅")
        #endif
    }
    
    // MARK: - 基础UI配置(子类可重写)
    open func setupBaseUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(loadingView)
        // 绑定ViewModel的加载状态
        viewModel.$loadingState
            .sink { [weak self] state in
                guard let self = self else { return }
                switch state {
                case .loading:
                    self.loadingView.isHidden = false
                    self.loadingView.startAnimating()
                default:
                    self.loadingView.isHidden = true
                    self.loadingView.stopAnimating()
                }
            }
            .store(in: &viewCancellables)
        
        // 绑定ViewModel的错误
        viewModel.$error
            .compactMap { $0 }
            .sink { [weak self] error in
                self?.handleViewModelError(error)
            }
            .store(in: &viewCancellables)
    }
    
    // MARK: - 子类需重写的方法(核心)
    /// 初始化ViewModel(必须重写)
    open func setupViewModel() {
        fatalError("子类必须实现setupViewModel()")
    }
    
    /// 数据绑定(ViewModel → View)
    open func setupBindings() {}
    
    /// 导航栏配置
    open func setupNavigation() {}
    
    /// 手势配置
    open func setupGesture() {
        let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        view.addGestureRecognizer(tap)
    }
    
    // MARK: - 通用方法
    /// 处理ViewModel的错误(子类可重写)
    open func handleViewModelError(_ error: AppError) {}
    
    /// 收起键盘
    @objc open func dismissKeyboard() {
        view.endEditing(true)
    }
    
    /// 是否隐藏导航栏(子类可重写)
    open var navigationBarHidden: Bool { false }
    
    /// 跳转页面(封装导航跳转,统一管理)
    open func push(_ vc: UIViewController, animated: Bool = true) {
        navigationController?.pushViewController(vc, animated: animated)
    }
    
    /// 弹出页面
    open func pop(animated: Bool = true) {
        navigationController?.popViewController(animated: animated)
    }
    
    /// 弹出到根页面
    open func popToRoot(animated: Bool = true) {
        navigationController?.popToRootViewController(animated: animated)
    }
}

// MARK: - 通用加载动画View(可自定义)
final class LoadingIndicatorView: UIView {
    private let activityIndicator = UIActivityIndicatorView(style: .large)
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }
    
    private func setupUI() {
        backgroundColor = UIColor.black.withAlphaComponent(0.3)
        activityIndicator.center = center
        activityIndicator.color = .white
        addSubview(activityIndicator)
        activityIndicator.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
    }
    
    func startAnimating() {
        activityIndicator.startAnimating()
    }
    
    func stopAnimating() {
        activityIndicator.stopAnimating()
    }
}
3.1.3 BaseRepository.swift(数据仓库基类)

Repository 是「数据聚合层」,负责整合网络、存储、第三方 SDK 的数据,ViewModel 只通过 Repository 获取数据,不直接依赖底层服务 ------ 这是 MVVM 解耦的关键(便于单元测试时替换 Repository 的实现):

swift

复制代码
import Foundation
import Combine

/// 数据仓库基类(所有Repository的父类)
open class BaseRepository {
    // MARK: - 核心属性
    /// 管理Combine订阅
    internal var cancellables = Set<AnyCancellable>()
    /// 网络服务(通过协议抽象,便于替换)
    internal let networkService: NetworkServiceProtocol
    /// 存储服务(通过协议抽象)
    internal let storageService: StorageServiceProtocol
    
    // MARK: - 初始化
    public init(
        networkService: NetworkServiceProtocol = NetworkService.shared,
        storageService: StorageServiceProtocol = StorageService.shared
    ) {
        self.networkService = networkService
        self.storageService = storageService
    }
    
    deinit {
        cancellables.removeAll()
        #if DEBUG
        print("[Repository] \(self) deinit ✅")
        #endif
    }
    
    // MARK: - 通用方法
    /// 将NetworkError转换为AppError
    internal func mapNetworkError(_ error: NetworkError) -> AppError {
        return .network(error)
    }
    
    /// 将存储错误转换为AppError
    internal func mapStorageError(_ msg: String) -> AppError {
        return .storage(msg)
    }
    
    /// 业务错误转换
    internal func mapBusinessError(code: Int, msg: String) -> AppError {
        return .business(code: code, msg: msg)
    }
}

// MARK: - 网络服务协议(抽象接口,与实现分离)
public protocol NetworkServiceProtocol {
    /// 发起RESTful请求(复用之前的Moya封装)
    func request<T: Codable>(_ api: BaseAPI, config: RESTfulRequestConfig) -> AnyPublisher<T, NetworkError>
    /// 发起无数据返回的请求
    func requestWithoutData(_ api: BaseAPI, config: RESTfulRequestConfig) -> AnyPublisher<Bool, NetworkError>
}

// MARK: - 存储服务协议
public protocol StorageServiceProtocol {
    /// 存储数据到UserDefaults
    func set<T: Codable>(_ value: T, forKey key: String) throws
    /// 从UserDefaults读取数据
    func get<T: Codable>(forKey key: String) -> T?
    /// 存储数据到Keychain
    func setToKeychain(_ value: String, forKey key: String) throws
    /// 从Keychain读取数据
    func getFromKeychain(forKey key: String) -> String?
    /// 删除数据
    func remove(forKey key: String)
}

// MARK: - 网络服务实现(适配协议)
final class NetworkService: NetworkServiceProtocol {
    public static let shared = NetworkService()
    private init() {}
    
    func request<T: Codable>(_ api: BaseAPI, config: RESTfulRequestConfig) -> AnyPublisher<T, NetworkError> {
        return RESTfulRequestTool.request(api, config: config)
    }
    
    func requestWithoutData(_ api: BaseAPI, config: RESTfulRequestConfig) -> AnyPublisher<Bool, NetworkError> {
        return RESTfulRequestTool.requestWithoutData(api, config: config)
    }
}

// MARK: - 存储服务实现(适配协议)
final class StorageService: StorageServiceProtocol {
    public static let shared = StorageService()
    private let userDefaults = UserDefaults.standard
    private let keychain = KeychainSwift() // 需引入pod 'KeychainSwift'
    
    private init() {}
    
    func set<T: Codable>(_ value: T, forKey key: String) throws {
        let data = try JSONEncoder().encode(value)
        userDefaults.set(data, forKey: key)
    }
    
    func get<T: Codable>(forKey key: String) -> T? {
        guard let data = userDefaults.data(forKey: key) else { return nil }
        return try? JSONDecoder().decode(T.self, from: data)
    }
    
    func setToKeychain(_ value: String, forKey key: String) throws {
        guard keychain.set(value, forKey: key) else {
            throw NSError(domain: "StorageService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Keychain存储失败"])
        }
    }
    
    func getFromKeychain(forKey key: String) -> String? {
        return keychain.get(key)
    }
    
    func remove(forKey key: String) {
        userDefaults.removeObject(forKey: key)
        keychain.delete(key)
    }
}

3.2 通用工具层(Core/Common)

3.2.1 系统类扩展(Core/Common/Extensions)

UIView+Extension.swift(简化 UI 布局):

swift

复制代码
import UIKit

extension UIView {
    /// 添加子视图(批量)
    func addSubviews(_ views: UIView...) {
        views.forEach { addSubview($0) }
    }
    
    /// 取消所有约束
    func removeAllConstraints() {
        translatesAutoresizingMaskIntoConstraints = false
        removeConstraints(constraints)
        superview?.removeConstraints(superview?.constraints.filter {
            $0.firstItem as? UIView == self || $0.secondItem as? UIView == self
        } ?? [])
    }
    
    /// 快速布局(边缘贴合父视图)
    func pinToSuperview(insets: UIEdgeInsets = .zero) {
        guard let superview = superview else { return }
        translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: insets.left),
            trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: -insets.right),
            topAnchor.constraint(equalTo: superview.topAnchor, constant: insets.top),
            bottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: -insets.bottom)
        ])
    }
}

Combine+Extension.swift(简化订阅):

swift

复制代码
import Combine

extension AnyPublisher {
    /// 简化订阅(适配AppError)
    func sinkToResult(
        _ cancellables: inout Set<AnyCancellable>,
        onSuccess: @escaping (Output) -> Void,
        onFailure: @escaping (AppError) -> Void
    ) where Failure == AppError {
        self.sink(
            receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    onFailure(error)
                }
            },
            receiveValue: { value in
                onSuccess(value)
            }
        )
        .store(in: &cancellables)
    }
    
    /// 简化订阅(适配NetworkError,转换为AppError)
    func sinkToResult(
        _ cancellables: inout Set<AnyCancellable>,
        onSuccess: @escaping (Output) -> Void,
        onFailure: @escaping (AppError) -> Void
    ) where Failure == NetworkError {
        self.mapError { AppError.network($0) }
            .sinkToResult(&cancellables, onSuccess: onSuccess, onFailure: onFailure)
    }
}
3.2.2 常量定义(Core/Common/Constants)

AppConstants.swift

swift

复制代码
import Foundation

/// 全局常量
enum AppConstants {
    /// 网络相关
    enum Network {
        static let timeout: TimeInterval = 20
        static let baseURLDev = "https://dev-api.xxx.com/v1"
        static let baseURLTest = "https://test-api.xxx.com/v1"
        static let baseURLProd = "https://api.xxx.com/v1"
    }
    
    /// 存储相关
    enum Storage {
        static let userTokenKey = "kUserToken"
        static let userInfoKey = "kUserInfo"
        static let themeKey = "kAppTheme"
    }
    
    /// UI相关
    enum UI {
        static let screenWidth = UIScreen.main.bounds.width
        static let screenHeight = UIScreen.main.bounds.height
        static let navBarHeight: CGFloat = {
            let window = UIApplication.shared.windows.first
            let statusBarHeight = window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 20
            return statusBarHeight + 44
        }()
    }
}
3.2.3 通用工具(Core/Common/Utils)

ToastManager.swift(全局吐司):

swift

复制代码
import UIKit

final class ToastManager {
    public static let shared = ToastManager()
    private let toastView = UILabel()
    private var toastTimer: Timer?
    
    private init() {
        setupToastView()
    }
    
    private func setupToastView() {
        toastView.backgroundColor = UIColor.black.withAlphaComponent(0.7)
        toastView.textColor = .white
        toastView.font = UIFont.systemFont(ofSize: 14)
        toastView.textAlignment = .center
        toastView.layer.cornerRadius = 8
        toastView.clipsToBounds = true
        toastView.numberOfLines = 0
        toastView.isHidden = true
    }
    
    public func show(text: String, duration: TimeInterval = 2) {
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }
            // 停止之前的定时器
            self.toastTimer?.invalidate()
            // 更新文本和尺寸
            self.toastView.text = text
            self.toastView.sizeToFit()
            let width = min(self.toastView.bounds.width + 32, AppConstants.UI.screenWidth - 64)
            let height = self.toastView.bounds.height + 16
            self.toastView.frame = CGRect(
                x: (AppConstants.UI.screenWidth - width) / 2,
                y: AppConstants.UI.screenHeight - 100,
                width: width,
                height: height
            )
            // 添加到窗口
            if let window = UIApplication.shared.windows.first {
                window.addSubview(self.toastView)
            }
            // 显示并定时隐藏
            self.toastView.isHidden = false
            self.toastTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in
                UIView.animate(withDuration: 0.3) {
                    self.toastView.alpha = 0
                } completion: { _ in
                    self.toastView.isHidden = true
                    self.toastView.alpha = 1
                    self.toastView.removeFromSuperview()
                }
            }
        }
    }
}

三、业务层规范(Modules)

以「用户模块(User)」为例,展示 MVVM 各层的落地规范,其他模块(Home/Order)完全复用此模式。

3.1 模块内结构

plaintext

复制代码
Modules/
└── User/
    ├── View/
    │   ├── LoginViewController.swift   # 登录页面
    │   ├── PersonalViewController.swift # 个人中心页面
    │   └── UI/                         # 模块内UI组件(如LoginInputView)
    ├── ViewModel/
    │   ├── LoginViewModel.swift        # 登录ViewModel
    │   └── PersonalViewModel.swift     # 个人中心ViewModel
    ├── Repository/
    │   └── UserRepository.swift        # 用户数据仓库
    ├── Model/
    │   ├── UserModel.swift             # 用户模型
    │   └── LoginModel.swift            # 登录模型
    └── Router/
        └── UserRouter.swift            # 用户模块路由

3.2 数据仓库层(UserRepository.swift)

负责用户相关的数据获取(网络请求、本地存储),ViewModel 只调用 Repository 的方法,不直接接触网络 / 存储:

swift

复制代码
import Foundation
import Combine

/// 用户数据仓库(聚合网络/存储数据)
final class UserRepository: BaseRepository {
    /// 登录(网络请求)
    func login(account: String, password: String) -> AnyPublisher<LoginModel, AppError> {
        let api = UserAPI.login(account: account, password: password)
        let config = RESTfulRequestConfig(retryCount: 1)
        return networkService.request(api, config: config)
            .mapError { self.mapNetworkError($0) }
            .eraseToAnyPublisher()
    }
    
    /// 获取用户信息(先读缓存,再请求网络更新)
    func fetchUserInfo() -> AnyPublisher<UserModel, AppError> {
        // 1. 先读取本地缓存
        if let cachedUser = storageService.get(UserModel.self, forKey: AppConstants.Storage.userInfoKey) {
            // 2. 缓存存在则先返回缓存,再请求网络更新
            return networkService.request(UserAPI.fetchUser(id: cachedUser.id), config: .init())
                .handleEvents(receiveOutput: { [weak self] user in
                    // 3. 网络请求成功后更新缓存
                    try? self?.storageService.set(user, forKey: AppConstants.Storage.userInfoKey)
                })
                .mapError { self.mapNetworkError($0) }
                .prepend(cachedUser) // 先发送缓存数据
                .eraseToAnyPublisher()
        } else {
            // 无缓存则直接请求网络
            return Fail(error: AppError.custom("请先登录"))
                .eraseToAnyPublisher()
        }
    }
    
    /// 保存用户Token(Keychain)
    func saveToken(_ token: String) -> AnyPublisher<Bool, AppError> {
        return Future<Bool, AppError> { promise in
            do {
                try self.storageService.setToKeychain(token, forKey: AppConstants.Storage.userTokenKey)
                // 更新全局网络请求头
                NetworkConfig.shared.updateGlobalHeaders(["Token": token])
                NetworkManager.shared.reloadProvider()
                promise(.success(true))
            } catch {
                promise(.failure(self.mapStorageError(error.localizedDescription)))
            }
        }.eraseToAnyPublisher()
    }
    
    /// 退出登录(清除缓存+Token)
    func logout() -> AnyPublisher<Bool, AppError> {
        return networkService.requestWithoutData(UserAPI.logout)
            .handleEvents(receiveOutput: { [weak self] _ in
                // 清除本地缓存
                self?.storageService.remove(forKey: AppConstants.Storage.userTokenKey)
                self?.storageService.remove(forKey: AppConstants.Storage.userInfoKey)
                // 清空全局Token
                NetworkConfig.shared.clearGlobalHeaders()
                NetworkManager.shared.reloadProvider()
            })
            .mapError { self.mapNetworkError($0) }
            .eraseToAnyPublisher()
    }
}

3.3 ViewModel 层(LoginViewModel.swift)

负责业务逻辑(参数校验、调用 Repository、数据转换),不包含任何 UI 代码,所有数据通过@Published发布:

swift

复制代码
import Foundation
import Combine

/// 登录ViewModel(纯业务逻辑,无UI依赖)
final class LoginViewModel: BaseViewModel {
    // MARK: - 发布属性(View层绑定)
    /// 账号输入
    @Published var account: String = ""
    /// 密码输入
    @Published var password: String = ""
    /// 登录按钮是否可用
    @Published var loginButtonEnabled: Bool = false
    /// 登录成功后的用户Token
    @Published var loginToken: String?
    
    // MARK: - 依赖注入(便于单元测试)
    private let userRepository: UserRepository
    
    // MARK: - 初始化
    init(userRepository: UserRepository = UserRepository()) {
        self.userRepository = userRepository
        super.init()
        setupValidations() // 初始化参数校验
    }
    
    // MARK: - 核心业务逻辑
    /// 登录操作(View层调用)
    func login() {
        // 1. 参数校验
        guard !account.isEmpty, !password.isEmpty else {
            handleError(AppError.custom("账号或密码不能为空"))
            return
        }
        // 2. 标记加载状态
        startLoading()
        // 3. 调用Repository获取数据
        userRepository.login(account: account, password: password)
            .flatMap { [weak self] loginModel -> AnyPublisher<Bool, AppError> in
                // 4. 登录成功后保存Token
                guard let self = self else { return Fail(error: AppError.custom("ViewModel已释放")).eraseToAnyPublisher() }
                return self.userRepository.saveToken(loginModel.token)
                    .map { _ in loginModel.token }
                    .mapError { $0 }
                    .eraseToAnyPublisher()
            }
            .sinkToResult(&cancellables) { [weak self] token in
                // 5. 登录成功
                self?.stopLoading()
                self?.loginToken = token
            } onFailure: { [weak self] error in
                // 6. 处理错误
                self?.handleError(error)
            }
    }
    
    // MARK: - 私有方法
    /// 初始化参数校验(账号/密码非空时按钮可用)
    private func setupValidations() {
        Publishers.CombineLatest($account, $password)
            .map { account, password in
                !account.isEmpty && !password.isEmpty && password.count >= 6
            }
            .assign(to: &$loginButtonEnabled)
    }
}

3.4 View 层(LoginViewController.swift)

负责 UI 展示和用户交互,仅订阅 ViewModel 的发布属性,无任何业务逻辑:

swift

复制代码
import UIKit
import Combine

/// 登录页面(纯UI,无业务逻辑)
final class LoginViewController: BaseViewController<LoginViewModel> {
    // MARK: - UI组件
    private let accountTF = UITextField()
    private let pwdTF = UITextField()
    private let loginBtn = UIButton(type: .system)
    
    // MARK: - 初始化ViewModel(必须实现)
    override func setupViewModel() {
        viewModel = LoginViewModel()
    }
    
    // MARK: - 数据绑定(核心)
    override func setupBindings() {
        super.setupBindings()
        
        // 1. View → ViewModel(用户输入传递给ViewModel)
        accountTF.textPublisher
            .assign(to: &viewModel.$account)
        pwdTF.textPublisher
            .assign(to: &viewModel.$password)
        
        // 2. ViewModel → View(按钮状态绑定)
        viewModel.$loginButtonEnabled
            .sink { [weak self] enabled in
                self?.loginBtn.isEnabled = enabled
                self?.loginBtn.backgroundColor = enabled ? .systemBlue : .lightGray
            }
            .store(in: &viewCancellables)
        
        // 3. ViewModel → View(登录成功跳转)
        viewModel.$loginToken
            .compactMap { $0 }
            .sink { [weak self] _ in
                ToastManager.shared.show(text: "登录成功")
                // 通过路由跳转首页
                UserRouter.shared.gotoHome()
            }
            .store(in: &viewCancellables)
    }
    
    // MARK: - 导航栏配置
    override func setupNavigation() {
        title = "登录"
        navigationItem.hidesBackButton = true
    }
    
    // MARK: - UI布局
    override func setupBaseUI() {
        super.setupBaseUI()
        setupSubviews()
        setupConstraints()
        setupUIAppearance()
    }
    
    private func setupSubviews() {
        accountTF.placeholder = "请输入账号"
        pwdTF.placeholder = "请输入密码"
        pwdTF.isSecureTextEntry = true
        loginBtn.setTitle("登录", for: .normal)
        loginBtn.addTarget(self, action: #selector(loginBtnClick), for: .touchUpInside)
        
        view.addSubviews(accountTF, pwdTF, loginBtn)
    }
    
    private func setupConstraints() {
        let margin: CGFloat = 32
        let itemHeight: CGFloat = 50
        
        accountTF.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            accountTF.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 64),
            accountTF.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
            accountTF.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
            accountTF.heightAnchor.constraint(equalToConstant: itemHeight)
        ])
        
        pwdTF.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            pwdTF.topAnchor.constraint(equalTo: accountTF.bottomAnchor, constant: 16),
            pwdTF.leadingAnchor.constraint(equalTo: accountTF.leadingAnchor),
            pwdTF.trailingAnchor.constraint(equalTo: accountTF.trailingAnchor),
            pwdTF.heightAnchor.constraint(equalToConstant: itemHeight)
        ])
        
        loginBtn.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            loginBtn.topAnchor.constraint(equalTo: pwdTF.bottomAnchor, constant: 32),
            loginBtn.leadingAnchor.constraint(equalTo: accountTF.leadingAnchor),
            loginBtn.trailingAnchor.constraint(equalTo: accountTF.trailingAnchor),
            loginBtn.heightAnchor.constraint(equalToConstant: itemHeight)
        ])
    }
    
    private func setupUIAppearance() {
        accountTF.borderStyle = .roundedRect
        pwdTF.borderStyle = .roundedRect
        loginBtn.layer.cornerRadius = 8
        loginBtn.setTitleColor(.white, for: .normal)
        loginBtn.isEnabled = false
    }
    
    // MARK: - 事件处理
    @objc private func loginBtnClick() {
        // 调用ViewModel的登录方法,无任何业务逻辑
        viewModel.login()
    }
}

// MARK: - UITextField扩展(获取文本变化)
extension UITextField {
    var textPublisher: AnyPublisher<String, Never> {
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: self)
            .compactMap { $0.object as? UITextField }
            .map { $0.text ?? "" }
            .eraseToAnyPublisher()
    }
}

3.5 路由层(UserRouter.swift)

负责模块内 / 跨模块页面跳转,避免 View/ViewModel 直接创建 VC,解耦页面依赖:

swift

复制代码
import UIKit

/// 用户模块路由(统一管理页面跳转)
final class UserRouter {
    public static let shared = UserRouter()
    private init() {}
    
    /// 跳转到登录页
    func gotoLogin() {
        let loginVC = LoginViewController()
        let nav = UINavigationController(rootViewController: loginVC)
        if let window = UIApplication.shared.windows.first {
            window.rootViewController = nav
        }
    }
    
    /// 跳转到个人中心
    func gotoPersonal() {
        let personalVC = PersonalViewController()
        if let nav = UIApplication.shared.windows.first?.rootViewController as? UINavigationController {
            nav.pushViewController(personalVC, animated: true)
        }
    }
    
    /// 跳转到首页(跨模块)
    func gotoHome() {
        // 此处可通过协议/通知/第三方路由库(如URLNavigator)实现跨模块跳转
        let homeVC = HomeViewController()
        let nav = UINavigationController(rootViewController: homeVC)
        if let window = UIApplication.shared.windows.first {
            window.rootViewController = nav
        }
    }
}

四、工程配置与团队协作规范

4.1 工程配置(Xcode)

  1. Build Configuration:配置 Debug/Test/Release 三种环境,对应不同的基础 URL / 功能开关;

  2. Swift Compiler - Custom Flags :在 Debug 环境添加-D DEBUG,用于区分调试 / 生产逻辑;

  3. 资源管理 :使用 Asset Catalog 管理图片 / 颜色,通过UIColor(named:)/UIImage(named:)获取,避免硬编码;

  4. 依赖管理 :优先使用 Swift Package Manager,其次 CocoaPods,核心依赖如下:

    ruby

    复制代码
    # Podfile示例
    platform :ios, '15.0'
    use_frameworks!
    
    target 'YourApp' do
        # 网络
        pod 'Moya', '~> 15.0'
        pod 'Moya/Combine'
        # 存储
        pod 'KeychainSwift', '~> 2.0'
        # 图片加载
        pod 'Kingfisher', '~> 7.0'
        # 布局
        pod 'SnapKit', '~> 5.0' # 可选,替代纯AutoLayout
        # 测试
        pod 'Quick', '~> 6.0'
        pod 'Nimble', '~> 12.0'
    end

4.2 团队协作规范

4.2.1 代码规范
  1. 命名规范
    • 类 / 结构体 / 枚举:大驼峰(如LoginViewControllerUserModel);
    • 属性 / 方法:小驼峰(如accountTFfetchUserInfo);
    • 常量:大驼峰 + 枚举(如AppConstants.Network.timeout);
    • 协议:后缀加Protocol(如NetworkServiceProtocol)。
  2. 注释规范
    • 公共类 / 方法:添加文档注释(///),说明功能、参数、返回值;
    • 复杂逻辑:添加行注释(//),说明设计思路;
    • 待优化代码:添加// TODO:标记。
  3. 代码风格
    • 缩进:4 个空格;
    • 空行:不同逻辑块之间空一行;
    • 闭包:优先使用尾随闭包,参数使用[weak self]避免循环引用。
4.2.2 分支管理(Git)
  • main:生产分支,仅合并 release 分支;
  • develop:开发分支,团队日常开发;
  • feature/xxx:功能分支,基于 develop 创建,完成后合并回 develop;
  • bugfix/xxx:修复分支,基于 develop/main 创建,修复后合并回对应分支;
  • release/x.x.x:发布分支,基于 develop 创建,测试通过后合并到 main/develop。
4.2.3 单元测试规范
  1. 核心测试目标:ViewModel/Repository(无 UI 依赖,易测试);
  2. 测试方法:使用 Quick/Nimble,通过「模拟 Repository 实现」测试 ViewModel 逻辑;
  3. 测试覆盖率:核心业务逻辑覆盖率≥80%。

五、框架核心优势与落地建议

5.1 核心优势

  1. 彻底解耦:View/ViewModel/Repository 严格隔离,ViewModel 无 UI 依赖,便于单元测试和复用;
  2. 响应式数据绑定:基于 Combine 实现单向数据流,无手动刷新 UI 的代码,减少 bug;
  3. 模块化设计:业务按模块拆分,模块内高内聚,模块间低耦合,便于多人协作;
  4. 可扩展性强:核心能力通过协议抽象,替换实现(如换网络库、换存储方案)不影响业务层;
  5. 团队友好:标准化的工程结构 + 代码规范,新人上手快,代码风格统一。

5.2 落地建议

  1. 小步快跑:先在一个小模块(如登录)落地这套框架,验证可行性后再推广到全项目;
  2. 沉淀通用组件:项目初期集中开发 Core 层,沉淀通用工具(Toast、Loading、网络、存储),避免重复造轮子;
  3. 单元测试先行:核心 ViewModel/Repository 必须写单元测试,减少回归 bug;
  4. 定期重构:项目迭代过程中,定期重构臃肿的 ViewModel/Repository,保持代码整洁;
  5. 避免过度设计:中小项目可简化部分抽象(如去掉 Router 层,直接跳转),优先保证开发效率。

总结

这套 MVVM 开发框架的核心是「标准化、解耦、响应式」:

  1. 标准化:统一的工程结构、层间职责、代码规范,让团队有章可循;
  2. 解耦:通过 Repository 抽象数据层,通过协议抽象核心服务,避免层间强依赖;
  3. 响应式:基于 Combine 实现单向数据流,View 层仅订阅数据,无需手动管理 UI 刷新。

框架既适配中小 APP 的快速开发,也能支撑中大型 APP 的模块化扩展,核心代码可直接落地,团队只需关注业务逻辑,无需重复开发基础能力 ------ 这也是 MVVM 模式的核心价值:让代码更易维护、更易扩展、更易测试

相关推荐
符哥20087 小时前
Swift 开发 iOS App 过程中写自定义控件的归纳总结
ios·cocoa·swift
锐意无限1 天前
Swift 扩展归纳--- UIView
开发语言·ios·swift
文件夹__iOS1 天前
AsyncStream 进阶实战:SwiftUI 全局消息流极简实现
ios·swiftui·swift
fendoudexiaoniao_ios4 天前
iOS 列表拖拽cell排序
ios·swift
大熊猫侯佩5 天前
Swift 6 驱魔实录:揭开 Combine 与 @Sendable 的“血色契约”
swift·block·combine·preconcurrency·sendable·mainactor·isolation
初级代码游戏5 天前
iOS开发 SwiftUI 15:手势 拖动 缩放 旋转
ios·swiftui·swift
ujainu5 天前
Flutter + OpenHarmony 游戏开发进阶:虚拟摄像机系统——平滑跟随与坐标偏移
开发语言·flutter·游戏·swift·openharmony
初级代码游戏8 天前
iOS开发 SwiftUI 14:ScrollView 滚动视图
ios·swiftui·swift
初级代码游戏8 天前
iOS开发 SwitftUI 13:提示、弹窗、上下文菜单
ios·swiftui·swift·弹窗·消息框