引子:500 行代码塞一个文件,迟早要出事
星空运势应用的所有代码都写在一个Index.ets文件里,超过 500 行。功能跑起来没问题,但每次改代码都要在文件里上下翻找——改转盘的坐标要翻到 200 行,改塔罗的洗牌要翻到 400 行,改导航栏要翻到 100 行。
一开始图省事全塞一个文件,开发速度快,但维护成本越来越高。更麻烦的是,三个页面的状态都耦合在一起——改转盘的逻辑可能影响星图的渲染,改塔罗的状态可能影响导航栏的高亮。
完整效果
单文件架构的问题
优点
先把优点说清楚——单文件不是一无是处:
- 开发快:所有代码在一个文件里,跳转方便,不用切换文件
- 理解快:读代码时不需要在多个文件之间跳转
- 部署简单:只有一个文件需要编译和打包
对于小项目(<300 行),单文件完全够用。但超过 500 行,问题就开始显现。
缺点
1. 状态耦合
@Statepage:number=0;// 导航状态@StatewheelAngle:number=0;// 转盘状态@Statespinning:boolean=false;// 转盘状态@StateselectedSign:number=-1;// 星图状态@Statedealt:boolean=false;// 塔罗状态@StatetarotIdx:number[]=[];// 塔罗状态@StatetarotOpen:boolean[]=[];// 塔罗状态7 个@State变量全在同一个组件里。改任何一个都可能触发整个组件的重新渲染,导致不必要的性能开销。
2. 代码查找困难
500+ 行代码在一个文件里,找某个功能的实现需要上下翻找。虽然 IDE 有搜索功能,但远不如文件级别的模块清晰。
3. 复用困难
转盘的旋转逻辑、塔罗的洗牌算法、星图的坐标计算——这些逻辑在当前应用中只能用一次。如果要做另一个应用(比如"每日塔罗"),需要复制粘贴代码。
4. 测试困难
单文件架构无法对单个功能进行单元测试。要测洗牌算法,必须加载整个应用。
@Builder vs @Component:什么时候用哪个
ArkUI 提供了两种代码复用方式:@Builder和@Component。理解它们的区别是模块化的基础。
@Builder:轻量级 UI 片段
@BuilderStarDetail(){Stack(){// 星图详情的 UI}}特点:
- 不能有
@State状态 - 不能接收
@Prop或@Link参数 - 本质上是"模板函数",把一段 UI 代码封装起来
- 调用方式和普通函数一样:
this.StarDetail()
适用场景:
- UI 片段的封装(比如卡片样式、按钮样式)
- 不需要独立状态的简单组件
- 同一页面内的代码组织
@Component:独立组件
@Componentstruct StarPage{@PropselectedSign:number=-1;build(){Column(){// 星图页面的完整 UI}}}特点:
- 可以有
@State状态 - 可以接收
@Prop、@Link、@Provide/@Consume参数 - 是独立的组件,有完整的生命周期
- 调用方式是标签语法:
<StarPage />
适用场景:
- 需要独立状态的复杂组件
- 跨页面复用的组件
- 需要单元测试的组件
对比表
| 特性 | @Builder | @Component |
|---|---|---|
| 状态 | 无 | 有(@State) |
| 参数 | 普通参数 | @Prop/@Link |
| 复用性 | 页面内复用 | 跨页面复用 |
| 性能 | 轻量 | 略重 |
| 测试 | 困难 | 容易 |
| 适用场景 | UI 片段 | 独立组件 |
当前应用的选择
星空运势应用全部用@Builder,没有用@Component。这是因为三个页面都在同一个组件里,不需要跨组件通信。但如果要拆分,就需要把@Builder升级为@Component。
组件拆分策略:怎么拆
拆分的时机
不是代码多就要拆,而是:
- 功能独立:一个功能可以独立运行(比如转盘的旋转逻辑)
- 状态独立:一个功能有自己的状态(比如塔罗的洗牌状态)
- 复用需求:一个功能要在多处使用(比如卡片样式)
- 维护困难:找代码要花很长时间
拆分的粒度
拆分不是越细越好。粒度太细会导致组件太多,管理困难。建议按"功能模块"拆分:
当前结构(单文件): Index.ets ├── TopNav() ├── WheelPage() ├── StarPage() ├── TarotPage() ├── StackedDeck() ├── DealtCards() ├── Readings() └── 数据定义 拆分后(按功能模块): Index.ets # 主页面,只负责路由和状态管理 ├── components/ │ ├── TopNav.ets # 导航栏组件 │ ├── WheelPage.ets # 转盘页面组件 │ ├── StarPage.ets # 星图页面组件 │ └── TarotPage.ets # 塔罗页面组件 ├── data/ │ ├── Signs.ts # 星座数据 │ ├── Tarot.ts # 塔罗数据 │ └── Colors.ts # 颜色配置 └── utils/ ├── shuffle.ts # 洗牌算法 └── math.ts # 数学工具函数拆分的步骤
第一步:提取数据
数据是最容易拆分的,因为数据没有依赖:
// data/Signs.tsexportconstSIGNS:SignData[]=[newSignData('白羊座','♈','3.21-4.19','火','热情勇敢·行动力强',[newStarPt(100,30,3),// ...]),// ...];第二步:提取工具函数
洗牌算法、日期种子、坐标计算——这些纯函数也可以轻松提取:
// utils/shuffle.tsexportfunctionfisherYatesShuffle(arr:number[],seed:number):number[]{constresult=[...arr];lets=seed;for(leti=result.length-1;i>0;i--){s=(s*1103515245+12345)%2147483648;constj=s%(i+1);[result[i],result[j]]=[result[j],result[i]];}returnresult;}第三步:提取页面组件
把三个页面拆分成独立的@Component:
// components/WheelPage.ets@Componentstruct WheelPage{@Propspinning:boolean=false;@PropwheelAngle:number=0;@PropresultIdx:number=-1;onSpin:()=>void=()=>{};onReveal:()=>void=()=>{};build(){Column(){// 转盘的 UI}}}第四步:提取公共组件
导航栏、卡片样式、按钮样式——这些可以跨页面复用的组件:
// components/TopNav.ets@Componentstruct TopNav{@Proppage:number=0;onNavigate:(idx:number)=>void=()=>{};build(){Row(){// 导航栏的 UI}}}拆分后的主页面
// Index.ets@Entry@Componentstruct Index{@Statepage:number=0;@StatewheelAngle:number=0;@Statespinning:boolean=false;@StateselectedSign:number=-1;@Statedealt:boolean=false;@StatetarotIdx:number[]=[0,1,2];@StatetarotOpen:boolean[]=[false,false,false];build(){Column(){TopNav(page:this.page,onNavigate:(idx:number)=>{this.page=idx;})Swiper(){WheelPage(spinning:this.spinning,wheelAngle:this.wheelAngle,...)StarPage(selectedSign:this.selectedSign,...)TarotPage(dealt:this.dealt,tarotIdx:this.tarotIdx,...)}.index(this.page).onChange((idx:number)=>{this.page=idx;})}}}主页面只负责状态管理和路由,UI 逻辑全部委托给子组件。
数据与 UI 的分离原则
拆分代码时,一个重要的原则是数据与 UI 分离:
- 数据层:定义数据结构、常量、工具函数
- UI 层:使用数据渲染界面
当前应用的问题
当前应用把数据和 UI 混在一起:
constSIGNS:SignData[]=[...];// 数据@Componentstruct Index{// UI 和状态}虽然数据定义在组件外面,但SignData、StarPt等类定义也在同一个文件里。如果要复用这些数据,必须把整个文件复制过去。
改进方案
把数据和类定义移到独立文件:
// data/types.tsexportclassPos2D{...}exportclassStarPt{...}exportclassSignData{...}exportclassTarotInfo{...}// data/Signs.tsimport{SignData,StarPt}from'./types';exportconstSIGNS:SignData[]=[...];// data/Tarot.tsimport{TarotInfo}from'./types';exportconstTAROT:TarotInfo[]=[...];这样每个文件职责单一,修改数据不会影响 UI,修改 UI 不会影响数据。
状态管理的优化
拆分组件后,状态管理也需要优化。
问题:状态提升
当前所有状态都在主组件里,子组件通过@Prop接收。这意味着每次状态变化,主组件都会重新渲染,然后所有子组件都会重新渲染。
解决方案:状态局部化
把状态移到需要它的组件里:
// components/WheelPage.ets@Componentstruct WheelPage{@StatewheelAngle:number=0;// 转盘角度只在转盘组件里@Statespinning:boolean=false;// 旋转状态只在转盘组件里@StateresultIdx:number=-1;// 结果只在转盘组件里build(){...}}// components/TarotPage.ets@Componentstruct TarotPage{@Statedealt:boolean=false;// 抽牌状态只在塔罗组件里@StatetarotIdx:number[]=[0,1,2];// 牌组只在塔罗组件里@StatetarotOpen:boolean[]=[false,false,false];// 翻牌只在塔罗组件里build(){...}}这样每个组件管理自己的状态,修改转盘状态不会触发塔罗组件的重新渲染。
但有些状态需要共享
page状态需要在导航栏和 Swiper 之间共享,这种状态应该留在主组件里,通过@Prop或@Link传递给子组件。
踩坑记录
坑 1:@Builder 不能有 @State
@Builder不能声明@State状态。如果需要状态,必须用@Component。这是很多新手踩的坑——以为@Builder和@Component一样,结果发现无法添加状态。
坑 2:组件间的通信
拆分后,组件间需要通信。比如点击转盘的"重新占卜"按钮,需要通知塔罗组件重置状态。ArkUI 提供了@Provide/@Consume、事件回调等方式,但增加了复杂度。
坑 3:文件路径的引用
拆分后需要 import 其他文件。ArkTS 的 import 路径相对复杂,需要配置tsconfig.json的paths字段。新手容易在这里卡住。
坑 4:组件的生命周期
@Component有完整的生命周期(aboutToAppear、aboutToDisappear 等),@Builder没有。如果需要在组件加载时执行逻辑,必须用@Component。
代码改进建议
1. 渐进式拆分
不需要一次性拆完所有代码。可以按优先级逐步拆分:
- 先提取数据文件(最简单)
- 再提取工具函数(纯函数,无依赖)
- 然后提取页面组件(需要处理状态传递)
- 最后提取公共组件(需要考虑复用性)
2. 建立目录规范
entry/src/main/ets/ ├── pages/ # 页面组件 ├── components/ # 公共组件 ├── data/ # 数据定义 ├── utils/ # 工具函数 ├── styles/ # 样式配置 └── constants/ # 常量定义3. 使用 TypeScript 的模块系统
ArkTS 基于 TypeScript,可以充分利用 TypeScript 的模块系统:
export/import导出导入interface定义数据结构(比class更轻量)type定义类型别名enum定义枚举(替代常量数组)
4. 考虑状态管理库
对于复杂应用,可以考虑引入状态管理库(比如 ArkUI 的@ohos.stateManagement),但小项目用@State就够了。
总结
星空运势应用的 500+ 行代码全部写在一个文件里,短期内可以跑,但长期维护会越来越痛苦。拆分代码的核心原则是"单一职责"——每个文件只做一件事,每个组件只管一个功能。
适用边界:这个部分适合用作 ArkTS 代码架构和模块化的入门指南,涵盖了单文件的问题、@Builder vs @Component、组件拆分策略、数据与 UI 分离、状态管理优化等核心知识点。但如果要上架应用商店,还需要补充状态管理库、路由管理、错误处理、日志系统等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。