SEO 信息

  • SEO 标题:动图魔方技术拆解 14:ArkUI 大型单页的 Tab 路由、状态拆分与空状态设计
  • SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,本文拆解一个工具类 App 很容易越写越乱的页面层问题:当首页、编辑器、作品、发现、我的五个主页面都集中在 Index.ets 时,如何用最少的路由状态、编辑器子 Tab 状态和空状态组件,把“单页承载多工作台”的交互稳定下来。文章结合 BottomNav()EditorToolTab()HomePage()WorksPage()DiscoverPage()ProfilePage() 的真实代码,以及真实页面截图和验收清单,适合正在做 HarmonyOS 工具类 App、ArkUI 大型单页、状态拆分和空状态设计的开发者参考。
  • 关键词:HarmonyOS, ArkUI, ArkTS, 动图魔方, 单页应用, Tab 路由, 状态拆分, 空状态设计, Index.ets
  • 文章封面doc/csdn-series/covers/cover-14-arkui-tab-state-empty.jpg
  • 投稿方向:普通技术拆解 / ArkUI 页面架构与交互状态治理
  • 项目环境:HarmonyOS SDK 6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube

前一篇我们把作品、草稿和主题偏好的本地持久化闭环补上了,但一个真正能持续扩展的工具类 App,不只是“能存下来”,还要“切得清楚”。当首页、编辑器、作品、发现、我的五个页面都共存于一个入口组件时,如果没有一层明确的页面路由、编辑器局部 Tab 和统一空状态策略,后续每加一个功能都会把 UI 写成状态泥团。本文就围绕 Index.ets 这一层,把动图魔方当前的大型单页结构拆开。

一、真实工程问题背景

“动图魔方”不是内容浏览型应用,而是一个把素材导入、参数调整、预览、导出、作品回看、草稿恢复和偏好设置都塞进同一条使用链路里的工具 App。对这种产品来说,页面层会同时面对 3 类问题:

  1. 顶层工作台切换要快:用户在首页选能力、进入编辑器、导出后跳到作品页、再去发现页看模板,整个过程不能被复杂路由打断。
  2. 编辑器内部状态要稳:用户进入编辑器后,还要在播放、画布、效果、帧、3D、导出六个工具分区之间切换,且不能把整个页面跳来跳去。
  3. 空状态不能随便写:真实素材、草稿、作品、3D 能力并不总是存在,如果空状态各写各的,页面很快就会出现文案不一致、动作不一致、跳转不一致的问题。

这就是为什么当前版本把主页面路由、编辑器局部 Tab 和空状态组件都收敛进 Index.ets:不是为了省文件,而是为了先把“单页工具台”的状态边界捋顺。

二、目标与边界

这篇文章重点回答 5 个问题:

  1. 顶层五页签为什么只用一个 page 状态,而不是先拆成多个 Ability 或复杂路由。
  2. 编辑器为什么还要再维护一个 editorToolTab,而不是让每个工具分区都做成独立页面。
  3. 空状态为什么要抽出 EmptyState()ToolEmptyPanel() 两套 Builder。
  4. 导出、草稿恢复、主题切换后,页面如何在同一个单页结构里保持状态连续。
  5. 当项目继续扩展时,什么状态适合留在 Index.ets,什么状态应该下沉到服务层。

本文不展开的部分:

  1. 导出链路、取消与异常恢复,已经在第 12 篇展开。
  2. Preferences 持久化与作品草稿模型,已经在第 13 篇展开。
  3. 深浅色 token 与视觉细节,会在第 15 篇继续拆。

三、先把顶层页面路由压成一个状态

当前入口页最重要的一行状态其实很朴素:

@State page: string = 'home';
@State editorType: string = 'video';
@State editorToolTab: string = 'play';

这里至少做对了两件事:

  1. 主页面和工具页不是同一个维度page 只表示当前处于首页、编辑器、作品、发现、我的哪一个主工作台;editorToolTab 只表示编辑器内部当前展开哪组工具。
  2. 编辑器类型独立于页面路由editorType 决定“当前是视频转 GIF、图片拼 GIF、GIF 再编辑还是 3D 动图”,而不是靠不同页面名来隐式区分。

这让顶层切换逻辑非常可控:

@Builder
NavItem(label: string, target: string) {
  Text(label)
    .layoutWeight(1)
    .height(48)
    .textAlign(TextAlign.Center)
    .fontSize(13)
    .fontWeight(this.page === target ? FontWeight.Bold : FontWeight.Medium)
    .fontColor(this.page === target ? '#FFFFFF' : this.bodyColor())
    .borderRadius(18)
    .backgroundColor(this.page === target ? '#6A4DFF' : (this.darkPreview ? '#10242335' : '#18FFFFFF'))
    .onClick(() => this.page = target)
}

底部导航不做复杂跳转,而是直接写回 page。对于工具类 App,这种写法的价值是:

  1. 页面切换开销小,不需要跨路由恢复太多上下文。
  2. 用户刚导出完作品后,直接把 page 改到 works 即可,反馈很直接。
  3. 页面之间共享同一批 @State,例如 worksdraftsthemeMode,避免为跨页传值写一层额外胶水代码。

对应的底部导航结构也保持克制:

@Builder
BottomNav() {
  Row() {
    this.NavItem('首页', 'home')
    this.NavItem('作品', 'works')
    this.CreateNavItem()
    this.NavItem('发现', 'discover')
    this.NavItem('我的', 'profile')
  }
  .height(76)
  .width('90%')
  .padding({ left: 9, right: 9, top: 9, bottom: 9 })
  .borderRadius(28)
  .backgroundBlurStyle(BlurStyle.COMPONENT_THICK)
}

这里的 + 号不是单独路由,而是一个强动作入口,统一把用户带回创作流。对于单页工具台,这种“中间主按钮 + 两侧工作区”的导航结构,比机械地堆五个等权 Tab 更贴近产品动线。

四、编辑器内部再拆一层局部 Tab,而不是继续堆页面

进入编辑器后,页面又会切成六块工具区:

Row({ space: 0 }) {
  this.EditorToolTab('播放', 'play')
  this.EditorToolTab('画布', 'canvas')
  this.EditorToolTab('效果', 'effect')
  this.EditorToolTab('帧', 'frames')
  this.EditorToolTab('3D', 'device')
  this.EditorToolTab('导出', 'export')
}

对应的局部 Tab Builder 也非常明确:

@Builder
EditorToolTab(label: string, value: string) {
  Column() {
    Text(label)
      .fontSize(15)
      .fontWeight(this.editorToolTab === value ? FontWeight.Bold : FontWeight.Medium)
      .fontColor(this.editorToolTab === value ? '#6A4DFF' : this.bodyColor())
    Row()
      .height(3)
      .width(this.editorToolTab === value ? 24 : 0)
      .borderRadius(2)
      .backgroundColor('#6A4DFF')
  }
  .width(56)
  .height(46)
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.editorToolTab = value;
  })
}

为什么这一层不继续拆路由?

  1. 这些分区本质上都是“同一个素材编辑上下文”的不同面板。拆成独立页面反而会增加预览区、素材区、导出区之间的状态同步成本。
  2. 预览区需要常驻。当前编辑器结构里,媒体预览在上,工具分区在下,切换工具时预览不消失,用户更容易保持当前操作语境。
  3. 参数联动频繁。比如速度、倒放、裁剪、滤镜、字幕、导出规格都会触发预览刷新,用局部 Tab 切换更适合承载这种“同上下文内切面切换”的操作。

当前页面实际上就是这种结构:

首页与底部工作台

首页负责承接能力入口,底部导航始终常驻。对工具类产品来说,用户不会先想“我要去哪一页”,而是先想“我要开始创作还是看作品”,这就是单页工作台适合它的原因。

五、空状态不能散着写,要分成“页面级”和“工具级”两类

工具类 App 最大的问题之一,是很多页面天然会遇到“还没有东西”的阶段。当前项目把空状态拆成两种 Builder:

1. 页面级空状态:EmptyState()

@Builder
EmptyState(title: string, desc: string, action: string, type: string) {
  Column() {
    Image($r('app.media.app_icon')).width(58).height(58).borderRadius(18).opacity(0.78)
    Text(title).fontSize(16).fontWeight(FontWeight.Bold).fontColor(this.titleColor()).margin({ top: 12 })
    Text(desc).fontSize(12).fontColor(this.bodyColor()).textAlign(TextAlign.Center).margin({ top: 6 })
    Button(action).height(38).borderRadius(16).backgroundColor('#6A4DFF').margin({ top: 14 }).onClick(() => this.openEditor(type))
  }.width('100%').padding(20).borderRadius(20).backgroundColor(this.cardBg()).border({ width: 1, color: this.cardBorder() })
}

它用于首页“最近作品为空”或作品页“作品列表为空”的场景,特点是:

  1. 组件体积更大,承担页面主视觉空白。
  2. 动作按钮直接把用户带到创作入口。
  3. 文案更偏页面导向,比如“开始创作”“选择素材”。

对应的作品页空状态如下:

作品页与空列表方向

2. 工具级空状态:ToolEmptyPanel()

@Builder
ToolEmptyPanel(title: string, desc: string, actionLabel: string, action: () => void) {
  Column() {
    Text(title).fontSize(16).fontWeight(FontWeight.Bold).fontColor(this.titleColor())
    Text(desc).fontSize(12).fontColor(this.bodyColor()).lineHeight(18).textAlign(TextAlign.Center).margin({ top: 8 })
    Button(actionLabel)
      .height(38)
      .borderRadius(14)
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .backgroundColor('#6A4DFF')
      .margin({ top: 14 })
      .onClick(action)
  }
  .width('100%')
  .padding(18)
  .borderRadius(20)
  .backgroundColor(this.cardBg())
  .backgroundBlurStyle(BlurStyle.BACKGROUND_THIN)
  .border({ width: 1, color: this.cardBorder() })
}

这一类专门服务编辑器内部。例如在 play 分区里,如果用户还没选素材,就不会硬渲染一堆失效控件,而是先给出操作提示:

if (this.editorToolTab === 'play') {
  if (this.sourceUris.length === 0) {
    this.ToolEmptyPanel(
      '等待素材',
      '选择视频、图片或 GIF 后,可在这里调整播放速度、倒放和裁剪范围。',
      '选择素材',
      () => this.pickSource()
    )
  } else {
    // 正常渲染播放工具区
  }
}

这样做的价值在于:

  1. 页面级空状态和工具级空状态不会混用,视觉层级更稳定。
  2. 只要素材不存在,局部工具就退化成引导面板,减少禁用态堆积。
  3. 每个工具分区都能就地解释“为什么你现在不能操作”,而不是把错误甩给导出按钮或页面顶部提示。

六、五个主页面为什么能共存而不乱:关键在 Builder 职责切分

虽然当前主入口还是一个大文件,但它不是把所有布局平铺在 build() 里,而是先切成职责清晰的 Builder:

@Builder HomePage() { ... }
@Builder EditorPage() { ... }
@Builder WorksPage() { ... }
@Builder DiscoverPage() { ... }
@Builder ProfilePage() { ... }
@Builder BottomNav() { ... }
@Builder PreviewOverlay() { ... }

最终只在 build() 阶段做一次汇总:

build() {
  Stack({ alignContent: Alignment.Bottom }) {
    Column() {
      if (this.page === 'editor') {
        this.EditorPage()
      } else if (this.page === 'works') {
        this.WorksPage()
      } else if (this.page === 'discover') {
        this.DiscoverPage()
      } else if (this.page === 'profile') {
        this.ProfilePage()
      } else {
        this.HomePage()
      }
    }.width(this.contentWidth()).height('100%')
    this.BottomNav()
    if (this.previewUri.length > 0) {
      this.PreviewOverlay()
    }
  }.width('100%').height('100%').backgroundColor(this.pageBg())
}

这说明当前设计的核心不是“彻底拆文件”,而是先保证三件事:

  1. 主页面切换只受 page 控制
  2. 底部导航和预览浮层作为跨页面公共层统一收口
  3. 每个主页面都以 Builder 为最小职责单元,避免在 build() 里混杂业务判断。

对大型单页来说,这是一个很现实的中间态:先把状态切干净,再决定是否继续把 Builder 下沉成独立组件文件。

七、发现页和我的页不是“附属页”,而是状态闭环的一部分

很多工具类 App 会把“发现”和“我的”写成顺手加的静态页,但当前项目里这两页其实分别承担了不同的状态职责。

1. 发现页:承接模板和能力边界

this.Header('发现', '模板、教程和能力边界')
this.DiscoverTemplateCard('聊天表情包', '多张表情图按节奏拼接,适合 Image2 生成角色表情后合成 GIF。', 'image', '#F15BA6', '表情')
this.DiscoverTemplateCard('商品旋转展示', '导入 3D 模型或序列帧,生成 1:1 旋转动图。', 'threeD', '#5C8DFF', '3D')
this.DiscoverTemplateCard('教程步骤动图', '录屏或视频截取关键片段,导出清晰小体积 GIF。', 'video', '#705BFF', '教程')

它不是内容页,而是把“下一步做什么”重新导回编辑器。也就是说,发现页的动作出口依然是创作流,而不是跳去一堆不相干的详情页。

发现页模板与能力地图

2. 我的页:承接本地偏好和能力检测

this.ThemeChoice('跟随系统', 'system')
this.ThemeChoice('浅色', 'light')
this.ThemeChoice('深色', 'dark')

Button('检测 3D 能力')
  .onClick(() => {
    const result = CapabilityService.detect3D();
    this.support3DPreview = result.support3DPreview;
    this.support3DRecon = result.support3DRecon;
    this.statusText = result.message;
  })

这里的“我的”页并不是账号中心,而是工具工作台的“本地环境控制面板”。主题偏好、设备能力、隐私模式说明都在这里汇总,这也解释了为什么 themeModesupport3DPreviewsupport3DRecon 这些状态会留在 Index.ets 顶层。

我的页偏好与能力入口

八、状态连续性的关键,不是页面多,而是动作出口统一

单页结构是否稳定,不看页面数,而看动作之后用户会落到哪里。

当前 Index.ets 里几个关键动作的出口非常统一:

  1. 开始创作 / 选择素材 / 模板卡片:统一走 openEditor(type)
  2. 导出成功:更新 works 后直接把 page 切到 works
  3. 恢复草稿:恢复编辑参数后把 page 切到 editor
  4. 主题切换:停留在当前工作台,只刷新视觉状态。

例如草稿恢复逻辑:

private restoreDraft(draft: DraftEntry): void {
  this.editorType = draft.editorType;
  this.selectedRatio = draft.ratio;
  this.selectedFps = draft.fps;
  this.selectedQuality = draft.quality;
  this.selectedSpeed = draft.speed;
  this.reversed = draft.reversed;
  this.selectedFilter = draft.filter;
  this.subtitleText = draft.subtitle;
  this.subtitleSize = draft.subtitleSize;
  this.subtitleColor = draft.subtitleColor;
  this.subtitlePosition = draft.subtitlePosition;
  this.sourceUris = draft.sourceUris.slice();
  this.page = 'editor';
  this.schedulePreviewRefresh();
}

这里没有重新走一遍“先跳首页,再跳编辑器,再重新选类型”的冗余路径,而是直接恢复到目标工作台。这个细节决定了用户会不会觉得“草稿恢复是真的恢复”,还是只是把你重新送回一个还要手动点半天的页面。

九、什么时候该继续拆,什么时候继续留在单页

当前 Index.ets 的体量已经不小,但并不意味着它现在就一定要粗暴拆成十几个文件。更合理的判断标准是:

适合继续留在单页顶层的状态

  1. 会跨多个主页面共享的状态,例如 worksdraftsthemeMode
  2. 决定主页面切换的状态,例如 page
  3. 决定编辑器主工作模式的状态,例如 editorTypeeditorToolTab

适合逐步下沉的部分

  1. 单个页面内部反复复用的卡片组件,例如模板卡、能力卡、资料卡。
  2. 与业务无关的视觉 token 和颜色判断函数。
  3. 更复杂的编辑器工具分区,如果后续某一块逻辑继续膨胀,可以优先从 Builder 下沉成独立组件。

换句话说,先拆状态边界,再拆文件边界,比一开始只按文件数做“形式化组件化”更重要。

十、工程验收与证据

这篇文章涉及的结论,对应的工程证据主要来自以下三类:

  1. 真实源码对象D:\HuaweiDevelopFormalStudy\a_myHarmonyOSApplications\GIFRubiksCube\entry\src\main\ets\products\main\Index.ets 中的 BottomNav()EditorToolTab()EmptyState()ToolEmptyPanel()HomePage()WorksPage()DiscoverPage()ProfilePage()
  2. 真实页面截图:首页、作品页、发现页、我的页截图,证明单页工作台、空状态和模板入口都已经落到界面上。
  3. 前后文一致性:第 12 篇的导出闭环和第 13 篇的持久化闭环,在这一篇里通过 pageworksdraftsthemeMode 继续被串到页面层。

十一、工程验收清单

验收项 结果 证据
五个主工作台共用一套底部导航 通过 BottomNav() 统一渲染首页、作品、创建、发现、我的
主页面切换状态独立于编辑器工具状态 通过 pageeditorToolTab 为两套独立 @State
编辑器工具区采用局部 Tab 切换 通过 EditorToolTab() + if (this.editorToolTab === ...) 分区渲染
页面级空状态有统一组件 通过 EmptyState() 被首页和作品页复用
工具级空状态有统一组件 通过 ToolEmptyPanel() 在编辑器无素材时承接引导
发现页模板动作能够回到创作流 通过 DiscoverTemplateCard(...).onClick(() => this.openEditor(type))
我的页承担偏好和能力检测入口 通过 ThemeChoice()CapabilityService.detect3D() 实际接入
单页结构能承接导出后跳转作品页 通过 导出成功后页面状态切到 works,与第 12、13 篇逻辑保持一致

十二、小结

“动图魔方”当前的页面结构说明了一件事:工具类 App 不一定要一开始就拆成很多页面和很多路由,真正需要先控制住的是状态维度。

这一版 Index.ets 至少做对了三点:

  1. page 管主工作台,用 editorToolTab 管编辑器分区,避免状态混层。
  2. EmptyState()ToolEmptyPanel() 分开承接页面级与工具级空状态,避免 UI 语义漂移。
  3. 让发现页、作品页、我的页都服务于创作闭环,而不是各自成为孤立页面。

这也是为什么当前单页结构虽然“大”,但还没有“乱”。对工程来说,边界清楚比文件数量好看更重要。

十三、下一篇衔接

第 15 篇继续拆 《动图魔方技术拆解 15:ArkTS 深浅色与跟随系统的应用级 ColorMode 实战》,重点会落在:

  1. themeMode 为什么要保留 system / light / dark 三态。
  2. pageBg()cardBg()cardBorder()bodyColor() 这类视觉状态函数如何为整套工作台服务。
  3. 深浅色切换时,底部导航、毛玻璃、卡片与预览区怎样保持统一而不刺眼。
Logo

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

更多推荐