一张图片的轮播不难做。麻烦的是用户刚滑到第三张,外部“下一张”按钮却仍以第二张为准;屏幕变窄以后图片被裁成一条窄缝;图库中某个资源名拼错,页面只剩一块毫无解释的空白。对于在 HarmonyOS 上制作作品展示页的开发者来说,决定体验的往往不是轮播动效有多炫,而是哪一个状态具有最终解释权。本篇使用一个与库存台账、厨房计时和票夹完全无关的独立工程 FrameGallery,实现可重复切换的本地三图作品集。

它不涉及网络下载与系统相册授权,重点只有三件事:Swiper 当前索引同应用工具栏双向同步;图片按原比例适配可用展示区;资源缺失时提供确定的退化方案。核心组件可以直接在 Stage 模型的 ArkTS 页面中使用,示例资源是项目自己的三张风景素材,不会申请访问用户的私人照片。

FrameGallery 本地作品集与索引状态的技术封面

一、先规定“谁有权改变当前页”

想象相册包含“山湖晨光”“雪峰云海”“曲线建筑”三张作品。用户用手势从第一张滑到第二张时,Swiper 内部页码已经变化;上方计数器如果依靠一个自己递增的变量,就很容易与实际页面分离。反过来,用户点击“下一张”,按钮如果只修改标签而不控制 Swiper,同样会出现“标题写第三张,屏幕还停在第二张”的错位。

因此唯一真实页码定义为 activeIndex,可用范围固定为 0 到 photos.length - 1。滑动事件 onChange 更新它,左右按钮也只更新它。Swiper.index 以它作为外部控制索引。这样的写法让按钮、图片、文字和计数器始终引用一个状态,而不是分别维护“当前项目”“已滑次数”“标题页号”。另外,业务序号不等于数组地址:photo.id 是资源稳定身份,activeIndex 只是当前有序集合的位置。将来作品集增删图片,应基于 ID 重算索引,而不能强行保留一个可能越界的旧整数。

项目结构可以保持很小:entry/src/main/ets/pages/GalleryPage.ets 放页面及模型定义,entry/src/main/resources/base/media/ 放 gallery_lake.png、gallery_snow.png、gallery_arch.png 三张可再分发的示例资源,另备一张 gallery_fallback.png 作为确实存在的加载占位资源。三张图的内容仅为山湖、雪峰和建筑场景,不包含用户私人信息。新建 Stage 工程时需要把页面注册到应用路由配置,并确保三个资源文件确实存在;资源名称大小写错误是本文重点要避免的故障之一。实际图片可以是按自己版权许可准备的原创照片,保持一定宽高比,避免把极小缩略图当作全屏素材。

二、用一个小模型表达素材,不让显示层猜资源名

// entry/src/main/ets/pages/GalleryPage.ets
class GalleryPhoto {
  id: string;
  title: string;
  description: string;
  image: Resource;
  constructor(id: string, title: string, description: string,
              image: Resource) {
    this.id = id;
    this.title = title;
    this.description = description;
    this.image = image;
  }
}

@Entry
@Component
struct GalleryPage {
  @State private activeIndex: number = 0;
  @State private errorText: string = '';
  @State private failedIds: string[] = [];
  private photos: GalleryPhoto[] = [
    new GalleryPhoto('lake', '山湖晨光', '湖面与树林',
      $r('app.media.gallery_lake')),
    new GalleryPhoto('snow', '雪峰云海', '雪山与云层',
      $r('app.media.gallery_snow')),
    new GalleryPhoto('arch', '曲线建筑', '建筑与线条',
      $r('app.media.gallery_arch'))
  ];

  private jump(delta: number): void {
    const next: number = this.activeIndex + delta;
    if (next >= 0 && next < this.photos.length) {
      this.activeIndex = next;
    }
  }
}

GalleryPhoto 是普通 ArkTS 数据类,不假装有网络请求、数据库事务或神奇的图片自动加载能力。Resource 是应用资源引用,只有当三个文件已经放在约定媒体资源目录时,资源映射才成立。errorText 留给页面说明可预期的错误与替代行为;它并不是对编译阶段“找不到资源”的万能补救——如果 $r('app.media.gallery_snow') 指向一个根本不存在的静态名称,项目可能在构建时就报错,应该先在 DevEco 的资源目录中修正,不能等到运行时再靠 try/catch 处理。

继续在 GalleryPage 中加入页面 build()。这里主动关闭无限循环,因为“上一张/下一张”按钮在边界处应该变为不可用。让 Swiper 自带循环而工具栏不循环,最终会形成两个冲突的交互模型。

build() {
  Column({ space: 16 }) {
    Text('作品相册').fontSize(28).fontWeight(FontWeight.Bold)
      .width('100%')
    Text(`${this.activeIndex + 1} / ${this.photos.length}`)
      .fontSize(17).fontColor('#475569')
    Swiper() {
      ForEach(this.photos, (photo: GalleryPhoto) => {
        Column({ space: 8 }) {
          if (this.failedIds.includes(photo.id)) {
            Column({ space: 10 }) {
              Text('这张作品暂时无法显示').fontSize(18)
              Text('可切换到其他作品').fontSize(15)
            }.height(300).justifyContent(FlexAlign.Center)
          } else {
            Image(photo.image)
              .alt($r('app.media.gallery_fallback'))
              .objectFit(ImageFit.Contain)
              .width('100%').height(300)
              .onError(() => {
                this.failedIds = [...this.failedIds, photo.id];
                this.errorText = '部分作品未能加载,请查看其他作品';
              })
          }
          Text(photo.title).fontSize(20).fontWeight(FontWeight.Medium)
          Text(photo.description).fontSize(15).fontColor('#64748B')
        }.width('100%')
      }, (photo: GalleryPhoto) => photo.id)
    }
    .index(this.activeIndex).loop(false).indicator(false)
    .onChange((index: number) => { this.activeIndex = index; })
    .height(370).width('100%')
    Row({ space: 18 }) {
      Button('上一张').enabled(this.activeIndex > 0)
        .onClick(() => this.jump(-1))
      Button('下一张').enabled(this.activeIndex < this.photos.length - 1)
        .onClick(() => this.jump(1))
    }.justifyContent(FlexAlign.Center).width('100%')
    if (this.errorText.length > 0) {
      Text(this.errorText).fontColor('#9A3412')
    }
  }
  .padding(20).width('100%').height('100%')
  .backgroundColor('#F6F8FC')
}

这两段代码属于同一 GalleryPage,实际粘贴时需要把上面的 build() 和 jump() 放在同一个 struct 内,不能在第一个 } 后面另起一个自由函数。页面采用 @State V1;没有混用 V2 @Local 等模型。ForEach 用作品 ID 做 key,是为了将来更换图片标题时仍能识别原来的组件身份。Text 的序号显示给用户的是一基序号,而 Swiper 接收的是零基索引,两者只在显示层作加一转换。

三、图片怎么装进容器,跟资源大小是两回事

图片的天然比例不同:横向湖景很适合宽容器,竖构图建筑照片却可能在窄屏上被截断。若直接用 ImageFit.Cover 强制铺满,画面四周会被裁切,技术博客里的“显示完整作品”就不再成立。这里选择 ImageFit.Contain,优先展示整张作品,允许出现留白。若产品确实要做沉浸式背景,可以单独设计封面卡使用 Cover,但要区分“装饰”与“需要完整辨认的作品”。

height(300) 定义了一个方便复现的示例展示窗,并不是所有设备的最佳高度。折叠屏展开后可能希望更宽的作品区,小屏横屏则可能需要减少图片高度;上线时应根据容器尺寸或多断点布局计算可用空间,而不是宣称三个设备都必须 300vp。页面标题放在图片之外,也是在为窄屏留空间:标题不叠在照片上,就不会因为风景局部过亮或过暗而失去对比度。

FrameGallery DevEco Studio 白色主题:Swiper 当前索引与页面预览

图中的工程目录与实际代码保持一致:GalleryPage.ets 位于 pages 下,资源名是 gallery_lake、gallery_snow、gallery_arch,预览画面处于“2 / 3 · 雪峰云海”状态。它说明的不是“项目已经编译成功”,而是开发时应该观察的状态对应关系:编辑器里的 activeIndex 同预览页码和标题一起变化。若页面只变计数、图片没有切换,优先核查 .index(this.activeIndex) 是否存在;若图片动了而标题没变,核查 .onChange 是否更新同一状态。

四、不要把所有图片故障都归结为网络失败

这里是本地媒体资源。能遇到的第一类故障发生在编译阶段:app.media 名称错误、媒体文件未加入工程或文件格式不被工具链识别。这类问题应在资源管理器与构建输出中修正;运行时代码无法从一个不存在的静态资源引用中“恢复”出图像。第二类发生在运行阶段:图片数据损坏、解码失败、实际像素尺寸太大导致加载压力上升。它们与网络断开没有直接关系,不能给用户弹出“请检查网络”的错误提示。

上面的代码并不是只有一个报错文案,而是提供了确切的退化分支:为每张图维护 failedIds;Image.onError 将失败 ID 写入新数组,页面从 Image 切到“这张作品暂时无法显示”的文字卡,errorText 则提示用户可以继续浏览。.alt 使用同工程中存在的 gallery_fallback.png 提供加载期间的占位,真正失败后的交互结果仍由显式分支负责。这个错误回调从 API 9 开始受支持;更高版本新增的回调字段不属于本例的必要依赖,因而没有猜测它们的结构。对于保守的静态图库,实现层还可以先检查素材清单,发布前通过构建脚本核实媒体文件完整性,将故障阻断在打包阶段,而不是在运行时试图挽救资源缺失。

在 UI 设计上,故障提示应当可读、可操作:显示“这张作品暂时无法显示”,提供“查看其他作品”的按钮;避免用纯红色叉号代表全部含义。对于频繁滑动时可能被迫多次解码的资源,可以按实际素材尺寸压缩源图,并只让当前页附近的内容参与加载。官方 Swiper 资料讨论了预加载与缓存机制;具体参数应随目标 API 版本和图像体量压测,不能直接宣称缓存越多越流畅,更不该捏造帧率数据。

五、用故障用例反过来检查交互约束

FrameGallery 页码同步、边界和资源失败处置示意

验收从三个路径开始。路径一是连续手势:从第一页滑到第三页,再按“上一张”,预期页码从 3 / 3 返回 2 / 3,标题随图同步更新。路径二是工具栏:从第一张直接点击“上一张”,按钮应该禁用;从第三张点击“下一张”亦如此,且没有任何看似成功但实际没动的操作。路径三是布局变化:停在 2 / 3 时调整窗口宽度,页面重排以后仍显示雪峰作品,而不是重置成第一张。

再增加两种非正常情况。构建前故意把 gallery_arch.png 改名,应观察资源引用校验是否提示缺项,随后恢复名称;这一项属于编译期检查。运行期的解码失败只能用真实损坏样本或支持的加载错误路径验证,不应把“空白背景”误当作有回退机制的证据。随着列表增长到十几张,还要检查作品顺序修改后 activeIndex 是否越界,必要时按稳定 id 找回当前作品,找不到时退回第一张并给出文字提示。

这个独立案例的工程价值并不是轮播几张图片,而是一个可迁移的设计规则:页面只有一个当前选择状态、越界情况明确处理、资源依赖在构建前显式核对、加载错误按来源分层诊断。在内容展示、商品轮播、教学步骤卡中都可以复用这种思路,但必须尊重它们各自的隐私和性能约束。本文不申请相册读取权限,不上传用户照片,也不暗示能读取系统图库。

参考华为开发者文档:使用 Swiper 创建轮播、ArkTS ForEach 渲染控制、Image 图片组件。本文代码针对 Stage 模型中常见的 ArkUI 声明式组件进行说明;具体资源回调、图片格式及缓存行为均需在目标版本 DevEco 与设备上结合真实素材再验。

Logo

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

更多推荐