目录

  1. 引言:为什么在仓颉中讨论MVVM?

  2. MVVM 核心三要素解析

  3. 仓颉的“天然”MVVM亲和性

  4. 实战一:轻量级实现 (View即ViewModel)

  5. 实战二:经典MVVM分离 (View + ViewModel)

  6. 深度探讨:ViewModel的职责与生命周期

  7. Model层的设计:服务与数据

  8. MVVM的真正优势:可测试性

  9. 总结:MVVM是手段,不是目的


一、引言:为什么在仓颉中讨论MVVM?

随着鸿蒙应用的复杂度不断提升,我们不可避免地会遇到一个问题:逻辑往哪里放?

在仓颉的声明式UI范式中,我们很容易将所有状态(@State)和业务逻辑(方法)都堆砌在 @Component 结构体中。对于简单的页面,这无可厚非。但当页面逻辑变得复杂时(例如:网络请求、数据处理、多状态校验),@Component 就会迅速膨胀,变得难以维护和测试。这就是所谓的“重量级视图”(Massive View)问题。

MVVM(Model-View-ViewModel)架构模式,正是解决这一问题的利器。它倡导职责分离(Separation of Concerns),将UI展示、状态与逻辑、数据模型彻底分开。

仓颉的响应式状态管理系统(@State, @Observable, @Computed 等)与MVVM的设计哲学天然契合。本文将深入探讨如何在仓颉项目中,从简单的“视图即模型”演进到经典的MVVM架构,构建可维护、可测试、可扩展的鸿蒙应用。

二、MVVM 核心三要素解析

MVVM模式将应用分为三个核心部分:

  • View (视图)

    • 职责:UI的声明式描述。在仓颉中,这就是你的 @Component 结构体,主要负责 build() 函数的实现。

    • 原则:它应该是“哑巴”(Dumb)。它只负责展示数据(来自ViewModel)和转发事件(给ViewModel)。它不包含任何业务逻辑。

  • ViewModel (视图模型)

    • 职责:View的“大脑”。它持有View所需的所有状态(State),以及响应View事件的业务逻辑(Logic/Commands)。

    • 原则:它不知道 View 的存在。ViewModel 不会引用任何UI组件。它只通过其可观察的状态(@Observable)来“通知”View更新。

  • Model (模型)

    • 职责:应用的数据层。包括数据结构(如 struct User)和数据服务(如 class ApiService,负责网络请求、数据库读写等)。

    • 原则:它完全独立于UI,是可复用的业务核心。

[Image of MVVM Diagram: View <-> ViewModel <-> Model]
(示意图:View 观察 ViewModel 的状态;View 调用 ViewModel 的方法;ViewModel 与 Model 交互)

三、仓颉的“天然”MVVM亲和性

仓颉之所以适合MVVM,关键在于其强大的数据绑定响应式系统:

  1. View 订阅 ViewModel

    • 仓颉的 @Observable@State 装饰器使得ViewModel的 class 能够被View观察。

    • 当ViewModel中的状态(如 @State var username)发生变化时,仓颉的运行时会自动通知订阅了该状态的View进行重渲染。这就是MVVM中 VM -> V 的单向数据流。

  2. View 调用 ViewModel

    • View通过事件处理器(如 onClick)调用ViewModel暴露的公共方法(如 viewModel.login())。这就是 V -> VM 的事件流。

  3. 派生状态

    • @Computed 装饰器允许我们在ViewModel中根据基础状态计算出派生状态(如 isLoginButtonEnabled),View可以直接绑定这个派生状态,而无需自己计算。

四、实战一:轻量级实现 (View即ViewModel)

在进入经典MVVM之前,我们先看看最常见的实现:@Component 自身充当了View和ViewModel的角色。

```cang见的实现:@Component 自身充当了View和ViewModel的角色。

@Component
struct SimpleCounter {
    // 状态 (State)
    @State var count: Int32 = 0
    @State var lastUpdated: Date? = None
    
    // 业务逻辑 (Logic)
    func increment() {
        this.count += 1
        this.lastUpdated = Date.now()
        Logger.info("Count increased")
    }
    
    func decrement() {
        if (this.count > 0) {
            this.count -= 1
            this.lastUpdated = Date.now()
        }
    }
    
    // 视图 (View)
    func build() -> View {
        Column(spacing: 16.0) {
            Text("Count: ${this.count}")
                .fontSize(32.0)
            
            if (let time = this.lastUpdated) {
                Text("Last updated: ${time.toLocalString()}")
            }
            
            Row(spacing: 12.0) {
                Button("Increment") {
                    this.increment() // 逻辑和View耦合
                }
                
                Button("Decrement") {
                    this.decrement() // 逻辑和View耦合
                }
            }
        }
    }
}

分析
* * 优点:代码集中,对于简单场景非常直观、高效。

  • 缺点:职责不清。incrementdecrement 是业务逻辑,但它们被硬编码在 SimpleCounter 这个UI组件中。如果 increment 需要调用API,这个View就会变得很“重”。

五、实战二:经典MVVM分离 (View + ViewModel)

现在,让我们用一个更复杂的“登录”场景来重构为经典的MVVM模式。

5.1 Model (模型)

首先定义数据结构和服务。

// Model: 数据结构
struct User {
    let id: String
    let username: String
    let token: String
}

// Model: 数据服务 (模拟)
class AuthService {
    // 模拟API调用
    func login(username: String, password: String) -> Promise<User> {
        return Promise<User> { (resolve, reject) =>
            Timer.schedule(1500) { // 模拟1.5秒网络延迟
                if (username == "Cangjie" && password == "123456") {
                    resolve(User(
                        id: "user-001",
                        username: "Cangjie",
                        token: "fake-token-string"
                    ))
                } else {
                    reject(Error("用户名或密码错误"))
                }
            }
        }
    }
}

5.2 ViewModel (视图模型)

这是MVVM的核心。我们创建一个 @Observableclass 来承载所有状态和逻辑。

// ViewModel: 视图模型
@Observable
class LoginViewModel {
    // 1. 状态 (State)
    @State var username: String = ""
    @State var password: String = ""
    @State var isLoading: Bool = false
    @State var errorMessage: String? = None
    @State var loginSuccess: Bool = false
    
    // 2. 依赖 (Model)
    private var authService: AuthService = AuthService()
    
    // 3. 派生状态 (Computed State)
    @Computed var isLoginButtonEnabled: Bool {
        get {
            // 登录按钮的可用性
            return !this.username.isEmpty &&
                   !this.password.isEmpty &&
                   !this.isLoading
        }
    }
    
    // 4. 业务逻辑 (Logic / Command)
    func login() {
        if (!this.isLoginButtonEnabled) { return }
        
        this.isLoading = true
        this.errorMessage = None
        
        // 异步任务
        Task {
            try {
                let user = await this.authService.login(
                    this.username,
                    this.password
                )
                
                // 登录成功
                Logger.info("Login success for user: ${user.username}")
                this.loginSuccess = true
                
                // 可以在这里存储Token, 更新全局用户状态等
                
            } catch (e: Error) {
                // 登录失败
                this.errorMessage = e.message
            } finally {
                // 结束加载
                this.isLoading = false
            }
        }
    }
    
    func reset() {
        this.username = ""
        this.password = ""
        this.errorMessage = None
        this.isLoading = false
        this.loginSuccess = false
    }
}

5.3 View (视图)

View变得非常“薄”和“哑”。它只负责绑定和委托。

// View: 视图
@Component
struct LoginPage {
    // 1. 持有 ViewModel 实例
    // 使用 @State 确保ViewModel的生命周期与View绑定
    @State var viewModel: LoginViewModel = LoginViewModel()
    
    func build() -> View {
        Column(spacing: 16.0) {
            
            Text("欢迎登录")
                .fontSize(28.0)
                .fontWeight(FontWeight.Bold)
            
            // 2. 双向绑定 (V <-> VM)
            TextField(
                text: $this.viewModel.username, // 使用 $ 实现双向绑定
                placeholder: "用户名"
            )
            .disabled(this.viewModel.isLoading)
            
            TextField(
                text: $this.viewModel.password,
                placeholder: "密码",
                type: InputType.Password
            )
            .disabled(this.viewModel.isLoading)
            
            // 3. 错误信息展示 (VM -> V)
            if (let error = this.viewModel.errorMessage) {
                Text(error)
                    .color(Color.Red)
                    .fontSize(14.0)
            }
            
            // 4. 事件委托 (V -> VM)
            Button("登录") {
                this.viewModel.login()
            }
            // 5. 派生状态绑定 (VM -> V)
            .disabled(!this.viewModel.isLoginButtonEnabled)
            .loading(this.viewModel.isLoading)
            .width("100%")
            
            // 6. 响应成功状态
            if (this.viewModel.loginSuccess) {
                Text("登录成功!正在跳转...")
                    .color(Color.Green)
                // 可以在这里触发导航
                // this.navigateToHome()
            }
        }
        .padding(24.0)
    }
}

分析

  • **职责**:LoginPage (View) 完全不关心“如何”登录,它只知道“何时”调用 viewModel.login()。所有的加载状态、错误处理、API调用都在 LoginViewModel (VM) 中。

  • 响应式:当VM中的 isLoading 变为 true 时,View自动重渲染,显示加载中并禁用按钮,我们无需手动操作UI。

六、深度探讨:ViewModel的职责与生命周期

6.1 ViewModel的职责边界

  • 应该做:持有和管理UI状态、执行业务逻辑(表单验证、API调用)、格式化数据(如将 Date 格式化为 String)。

  • 不应该做

    • 引用View:绝对禁止。

    • 执行导航:严格来说,VM不应控制导航。它应该只设置一个状态(如 loginSuccess = true),由View(或更高层的协调器)来响应这个状态并执行导航。
      -----操作UI组件*:VM不应该知道有 ButtonTextField 的存在。

6.2 ViewModel的生命周期与实例化

在上述例子中,我们使用 @State var viewModel = LoginViewModel()。这意味着:

  • 生命周期:ViewModel的生命周期与 LoginPage 绑定。当 LoginPage 创建时,LoginViewModel 被创建;当 LoginPage 销毁时,VM也被销毁。这适用于大多数页面级ViewModel。

  • 共享ViewModel:如果多个View需要共享同一个ViewModel(例如:购物车、用户信息),则不应在View内部用 @State 创建。而应在它们的共同祖先处创建,并通过 @Provide / @Consume 或其他依赖注入方式将其注入到子View中。

七、Model层的设计:服务与数据

Model层是应用的地基。在仓颉中,我们通常这样设计:

  • 数据实体(Structs):使用 struct 定义不可变或值类型的数据模型。它们是纯粹的数据容器。

  • 服务(Classes):使用 class 定义服务,如 ApiService, DatabaseService, LocationService。这些服务封装了数据获取和持久化的具体实现。

  • 仓库(Repository)(可选):引入Repository模式,作为ViewModel和数据服务之间的中介。ViewModel只与Repository通信,Repository负责决定是从网络还是本地缓存获取数据。

// Repository 模式示例
class UserRepository {
    private var api: AuthService = AuthService()
    private var cache: LocalCache = LocalCache()
    
    func login(u: String, p: String) -> Promise<User> {
        // ViewModel 只管调用 login
        // Repository 负责复杂的逻辑
        return this.api.login(u, p).then { user =>
            this.cache.saveUser(user) // 登录成功后缓存
            return user
        }
    }
}

// ViewModel 中
class LoginViewModel {
    private var userRepository: UserRepository = UserRepository()
    // ...
    func login() {
        // ...
        await this.userRepository.login(u, p)
        // ...
    }
}

八、MVVM的真正优势:可测试性

分离职责的最大好处之一是可测试性

我们可以独立地对 LoginViewModel 进行单元测试,而无需启动任何UI界面。

// 单元测试(示例)
@Test
func testLoginViewModel_LoginFails() {
    // 1. 准备
    let viewModel = LoginViewModel()
    viewModel.username = "wrong"
    viewModel.password = "user"
    
    // 2. 执行
    viewModel.login()
    
    // 3. 断言
    // (等待异步任务完成)
    // ...
    Assert.isNotNull(viewModel.errorMessage)
    Assert.isFalse(viewModel.loginSuccess)
    Assert.isFalse(viewModel.isLoading)
}

@Test
func testLoginViewModel_ButtonDisabled() {
    // 1. 准备
    let viewModel = LoginViewModel()
    
    // 2. 断言 (初始状态)
    Assert.isFalse(viewModel.isLoginButtonEnabled)
    
    // 3. 执行
    viewModel.username = "Cangjie"
    
    // 4. 断言 (状态变化)
    Assert.isFalse(viewModel.isLoginButtonEnabled)
    
    // 5. 执行
    viewModel.password = "123456"
    
    // 6. 断言 (派生状态更新)
    Assert.isTrue(viewModel.isLoginButtonEnabled)
}

这种测试的运行速度极快,且非常稳定,是保证应用质量的核心手段。

九、总结:MVVM是手段,不是目的

MVVM不是银弹,它是一种用于管理复杂度的工具。

  • 对于简单组件@Component 内部管理状态(实战一)完全足够,强行使用MVVM是过度设计。

  • **对于复杂页面:当状态、异步逻辑和业务规则开始混杂时(实战二),MVVM是保持代码清晰、可维护、可测试的最佳实践。

仓颉的响应式系统为MVVM提供了强大的底层支持。作为开发者,我们的“成长”体现在能准确判断何时从轻量级实现演进到经典MVVM架构,以应对不断增长的应用复杂度。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐