文章目录前言为什么鸿蒙项目需要 MVVMViewModel 的职责边界State 与 ViewModel 的协作实战新闻列表页的 MVVM 拆分这个结构的好处一点建议前言鸿蒙项目写多了你会发现组件越来越大越来越难维护。一个新闻列表页数据请求、分页、筛选、错误处理、加载状态全塞在Component里几百行代码糊在一起改个筛选逻辑能把列表刷新搞崩。是时候把 ViewModel 层拆出来了。今天聊聊在鸿蒙项目里怎么落地 MVVM以及 ViewModel 到底该管什么不该管什么。为什么鸿蒙项目需要 MVVMArkUI 本身就是声明式的天然适合 MVVM。View 层组件只负责描述UI 长什么样ViewModel 负责数据和业务逻辑Model 负责原始数据和网络请求。没有 ViewModel 的时候你的组件大概长这样请求数据的代码和渲染 UI 的代码搅在一起一个build()方法里又有if判断加载状态又有网络请求又有错误处理又长又乱。拆出 ViewModel 之后组件只管拿到 ViewModel 暴露的状态去渲染业务逻辑全在 ViewModel 里测试和维护。ViewModel 的职责边界这个很重要搞不清楚边界拆了也白拆。ViewModel 该管的业务逻辑筛选、排序、分页、搜索数据请求和状态管理loading、error、success数据格式转换把后端返回的数据转成 View 需要的结构ViewModel 不该管的UI 长什么样颜色、间距、字体大小用户交互事件的具体响应方式手势、动画导航跳转这是 View 层的事ViewModel 不应该持有 Router简单说ViewModel 只管数据是什么状态不管数据怎么展示。State 与 ViewModel 的协作鸿蒙的State配合Observed类天然就能当 ViewModel 用。ViewModel 类加Observed里面的状态用Track标记组件用State或ObjectLink持有它。实战新闻列表页的 MVVM 拆分先定义数据模型// model/NewsModel.etsexportclassNewsItem{id:numbertitle:stringsummary:stringcategory:stringpublishedAt:stringimageUrl:stringconstructor(id:number,title:string,summary:string,category:string,publishedAt:string,imageUrl:string){this.ididthis.titletitlethis.summarysummarythis.categorycategorythis.publishedAtpublishedAtthis.imageUrlimageUrl}}// 模拟网络请求exportasyncfunctionfetchNewsList(page:number,category:string):PromiseNewsItem[]{// 模拟网络延迟awaitnewPromisevoid((resolve)setTimeout(resolve,800))// 模拟空数据if(page3){return[]}constcategories[科技,财经,体育,娱乐]constcatcategory||categories[Math.floor(Math.random()*categories.length)]returnArray.from({length:10},(_,i){constidx(page-1)*10ireturnnewNewsItem(idx1,${cat}新闻标题${idx1},这是第${idx1}条${cat}新闻的摘要内容简要介绍了这条新闻的核心信息。,cat,2026-06-23,)})}然后搞 ViewModel这是核心// viewmodel/NewsViewModel.etsimport{NewsItem,fetchNewsList}from../model/NewsModelexportenumLoadState{IDLE,LOADING,SUCCESS,ERROR,NO_MORE}ObservedexportclassNewsViewModel{TracknewsList:NewsItem[][]TrackloadState:LoadStateLoadState.IDLETrackerrorMessage:stringTrackcurrentCategory:stringTrackcurrentPage:number1TrackisRefreshing:booleanfalseprivatecategories:string[][全部,科技,财经,体育,娱乐]getCategories():string[]{returnthis.categories}// 首次加载asyncloadInitial(){if(this.loadStateLoadState.LOADING)returnthis.loadStateLoadState.LOADINGthis.currentPage1this.errorMessagetry{constitemsawaitfetchNewsList(1,this.currentCategory)this.newsListitemsthis.loadStateitems.length0?LoadState.NO_MORE:LoadState.SUCCESS}catch(e){this.loadStateLoadState.ERRORthis.errorMessage加载失败请检查网络后重试}}// 加载更多分页asyncloadMore(){if(this.loadStateLoadState.LOADING||this.loadStateLoadState.NO_MORE){return}this.loadStateLoadState.LOADINGtry{constnextPagethis.currentPage1constitemsawaitfetchNewsList(nextPage,this.currentCategory)if(items.length0){this.loadStateLoadState.NO_MORE}else{this.newsList[...this.newsList,...items]this.currentPagenextPagethis.loadStateLoadState.SUCCESS}}catch(e){// 加载更多失败不影响已有数据this.loadStateLoadState.SUCCESSthis.errorMessage加载更多失败}}// 下拉刷新asyncrefresh(){this.isRefreshingtruethis.currentPage1try{constitemsawaitfetchNewsList(1,this.currentCategory)this.newsListitemsthis.loadStateitems.length0?LoadState.NO_MORE:LoadState.SUCCESS}catch(e){this.errorMessage刷新失败}finally{this.isRefreshingfalse}}// 切换分类asyncswitchCategory(category:string){if(categorythis.currentCategory)returnthis.currentCategorycategory全部?:categoryawaitthis.loadInitial()}}ViewModel 把所有状态和业务逻辑都包了对外暴露的方法很清晰loadInitial、loadMore、refresh、switchCategory。最后是 View 层只管渲染// view/NewsListPage.etsimport{NewsViewModel,LoadState}from../viewmodel/NewsViewModelimport{NewsItem}from../model/NewsModelComponentstruct CategoryTab{Proplabel:stringPropisActive:booleanfalseonTabClick:()void(){}build(){Text(this.label).fontSize(14).fontColor(this.isActive?#3498db:#666).fontWeight(this.isActive?FontWeight.Bold:FontWeight.Normal).padding({left:16,right:16,top:8,bottom:8}).backgroundColor(this.isActive?#ebf5fb:transparent).borderRadius(20).onClick((){this.onTabClick()})}}Componentstruct NewsCard{ObjectLinkitem:NewsItembuild(){Row(){Column(){Text(this.item.title).fontSize(16).fontWeight(FontWeight.Medium).maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis})Text(this.item.summary).fontSize(13).fontColor(#999).maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis}).margin({top:6})Row(){Text(this.item.category).fontSize(11).fontColor(#3498db).backgroundColor(#ebf5fb).padding({left:6,right:6,top:2,bottom:2}).borderRadius(4)Blank()Text(this.item.publishedAt).fontSize(11).fontColor(#ccc)}.margin({top:8}).width(100%)}.layoutWeight(1)// 占位图区域Column().width(80).height(80).backgroundColor(#f0f0f0).borderRadius(8).margin({left:12})}.padding(16).backgroundColor(Color.White).borderRadius(12).shadow({radius:4,color:rgba(0,0,0,0.05),offsetX:0,offsetY:2}).margin({left:16,right:16,bottom:12})}}EntryComponentstruct NewsListPage{Statevm:NewsViewModelnewNewsViewModel()aboutToAppear(){this.vm.loadInitial()}build(){Column(){// 标题Text(新闻).fontSize(22).fontWeight(FontWeight.Bold).padding({left:16,top:16,bottom:8})// 分类 TabScroll(){Row({space:8}){ForEach(this.vm.getCategories(),(cat:string){CategoryTab({label:cat,isActive:(cat全部this.vm.currentCategory)||catthis.vm.currentCategory,onTabClick:(){this.vm.switchCategory(cat)}})})}.padding({left:16,right:16})}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)// 列表区域if(this.vm.loadStateLoadState.LOADINGthis.vm.newsList.length0){// 首次加载Column(){LoadingProgress().width(48).height(48)Text(加载中...).fontSize(14).fontColor(#999).margin({top:8})}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}elseif(this.vm.loadStateLoadState.ERRORthis.vm.newsList.length0){// 首次加载失败Column({space:12}){Text(加载失败).fontSize(16).fontColor(#999)Text(this.vm.errorMessage).fontSize(13).fontColor(#ccc)Button(重试).onClick((){this.vm.loadInitial()})}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}else{// 正常列表Refresh({refreshing:this.vm.isRefreshing}){List(){ForEach(this.vm.newsList,(item:NewsItem){ListItem(){NewsCard({item:item})}},(item:NewsItem)item.id.toString())// 底部状态ListItem(){Row(){if(this.vm.loadStateLoadState.LOADING){LoadingProgress().width(20).height(20)Text( 加载中...).fontSize(12).fontColor(#999)}elseif(this.vm.loadStateLoadState.NO_MORE){Text(没有更多了).fontSize(12).fontColor(#ccc)}}.width(100%).justifyContent(FlexAlign.Center).padding(16)}}.onReachEnd((){this.vm.loadMore()})}.onRefreshing((){this.vm.refresh()}).layoutWeight(1)}}.width(100%).backgroundColor(#f5f5f5)}}看看 View 层有多干净——没有任何网络请求代码没有任何业务逻辑全是根据状态渲染对应的 UI。分类切换、加载分页、下拉刷新全是调 ViewModel 的方法。这个结构的好处职责分明。ViewModel 管数据和逻辑View 管渲染。改筛选逻辑不用碰 UI 代码改 UI 样式不用碰业务逻辑。方便测试。ViewModel 是纯 TypeScript 类不依赖任何 UI 组件。你可以直接写单元测试// 测试示例伪代码constvmnewNewsViewModel()awaitvm.loadInitial()assert(vm.newsList.length0)assert(vm.loadStateLoadState.SUCCESS)awaitvm.switchCategory(科技)assert(vm.currentCategory科技)状态可控。所有状态变更都在 ViewModel 里不会出现这个状态到底谁改了的困惑。一点建议不是所有页面都需要 MVVM。如果一个页面就展示个静态内容或者交互很简单硬套 MVVM 反而多此一举。我的判断标准是一个组件超过 150 行或者涉及网络请求 多种状态就该拆 ViewModel 了。简单页面直接State搞定就好别为了架构而架构。另外 ViewModel 的命名也有讲究。我习惯用页面名 ViewModel比如NewsViewModel、ProfileViewModel。别搞个MainViewModel这种含糊的名字项目大了根本找不到对应的页面。