第3.8篇:图片上传服务设计(存根模式)

难度:⭐⭐ 进阶
前置知识:3.1 HTTP 网络请求
涉及源文件products/default/src/main/ets/services/ImageUploadService.ets


在这里插入图片描述

概述

在"画伴梦工厂"的 AI 处理流程中,用户拍摄或从相册选择的图片需要经过多个环节——压缩、识别、生成动画。在某些场景下,图片需要被上传到远程服务器进行存储或后续处理。然而,在项目开发的早期阶段,后端上传接口可能尚未就绪,或者我们希望在离线状态下也能验证前端流程的完整性。

存根模式(Stub Pattern) 正是为了解决这个问题而生的。它是一种测试替身(Test Double)技术,用一个轻量级的"替身"对象替换真实的依赖服务,让调用方在无需真实后端的情况下完成开发和调试。本文将通过项目中的 ImageUploadService,深入讲解存根模式的设计思想、实现方式以及它在鸿蒙应用开发中的实际价值。


一、为什么需要存根模式?

在典型的 Client-Server 架构中,前端开发往往依赖后端的接口就绪。传统的开发流程是这样的:

  1. 后端设计 API → 编写接口文档
  2. 前端等待后端完成开发 → 联调测试
  3. 发现问题 → 后端修改 → 重新部署 → 再次联调

这种方式存在几个明显的痛点:

  • 串行阻塞:前端开发被后端进度"卡脖子"
  • 环境依赖:需要网络可达的后端服务器,离线环境下无法开发
  • 调试困难:难以模拟各种异常场景(超时、错误码、网络断开)
  • 反馈周期长:每次改动都要等待后端部署

存根模式彻底改变了这一局面。前端开发者可以先定义好服务接口契约,然后用一个极简的"存根"实现来模拟后端行为。这个存根返回伪造但结构正确的数据,让前端流程可以完整跑通。等到后端接口就绪后,再替换为真实的网络实现——整个过程不需要修改调用方的任何代码。


二、UploadedImage 接口定义

服务的起点是一个清晰的数据模型。ImageUploadService.ets 中首先定义了上传结果的接口:

export interface UploadedImage {
  localUri: string;
  remoteUrl: string;
}

这个接口包含两个字段:

字段 类型 含义
localUri string 图片的本地文件 URI(如相册路径或沙箱路径)
remoteUrl string 上传成功后服务器返回的远程访问地址

接口设计的精妙之处在于它只暴露了调用方真正关心的信息——“这张图片的本地来源是什么"和"它在远程怎么访问”。至于上传过程是 HTTP 还是 FTP、用了什么认证方式、服务器地址是什么,对调用方完全透明。

这种面向接口编程的思想是存根模式能够成立的前提:只要调用方依赖的是接口(而非具体实现),我们就可以随时替换背后的实现逻辑。


三、存根实现:把本地路径当作远程地址

有了接口定义,来看看项目中实际的存根实现:

export class ImageUploadService {
  static async uploadImage(localUri: string): Promise<UploadedImage> {
    return {
      localUri: localUri,
      remoteUrl: localUri
    };
  }
}

这个实现只有 6 行代码,它的行为非常直接:

  1. 接收一个 localUri 字符串参数
  2. 返回一个 UploadedImage 对象
  3. localUri 同时赋值给 localUriremoteUrl 两个字段

关键就在 remoteUrl: localUri 这一行。存根模式的本质逻辑是:“既然还没有真正的远程服务器,那就把本地文件路径当作远程地址来用。” 对于调用方而言,它拿到 UploadedImage 后,无论是想展示图片、还是将 URL 传递给下一个服务,都可以直接使用 remoteUrl 字段——只不过在存根模式下,这个"远程地址"实际上指向的是本地文件。

这种做法带来的好处是显而易见的:

  • 调用方无需区分"真实"和"存根":代码路径完全一致
  • 图片可以正常显示:本地 URI 当然可以在 Image 组件中渲染
  • 后续服务可以正常处理:如果下游服务需要读取图片内容,本地路径同样有效
  • 切换真实实现时零改动:在调用方眼中,接口契约没有变化

四、如何实现真实上传(扩展思路)

存根的真正价值在于——它给出了一个"最小可用实现",而在此基础上扩展为真实服务的路径是清晰的。下面我们探讨一下 ImageUploadService 可能的发展方向。

4.1 真实 HTTP 上传实现

当后端接口就绪后,只需在同一个类中新增一个真实上传方法,或者直接替换 uploadImage 的实现:

// 真实上传的伪代码示意
import { http } from '@kit.NetworkKit';

export class ImageUploadService {
  private static readonly UPLOAD_URL = 'https://api.example.com/upload';

  static async uploadImage(localUri: string): Promise<UploadedImage> {
    const httpRequest = http.createHttp();
    try {
      // 构造 multipart/form-data 请求
      const response = await httpRequest.request(
        ImageUploadService.UPLOAD_URL,
        {
          method: http.RequestMethod.POST,
          extraData: {
            file: { uri: localUri, name: 'image.jpg', type: 'image/jpeg' }
          },
          header: {
            'Content-Type': 'multipart/form-data'
          },
          connectTimeout: 30000,
          readTimeout: 60000
        }
      );
      // 解析响应,提取 remoteUrl
      const result = JSON.parse(response.result as string);
      return {
        localUri: localUri,
        remoteUrl: result.data.url
      };
    } finally {
      httpRequest.destroy();
    }
  }
}

注意看:返回的类型依然是 Promise<UploadedImage>,调用方不需要做任何修改。这就是面向接口编程的魅力。

4.2 上传进度回调

真实上传场景中,用户往往需要看到上传进度。可以在接口层面增加进度回调的支持:

export interface UploadProgress {
  bytesWritten: number;
  totalBytes: number;
  percentage: number;
}

export class ImageUploadService {
  static async uploadImage(
    localUri: string,
    onProgress?: (progress: UploadProgress) => void
  ): Promise<UploadedImage> {
    // 在 http 请求的 on('progress') 中回传进度
    // onProgress({ bytesWritten, totalBytes, percentage: bytesWritten / totalBytes * 100 });
    // ...
  }
}

存根模式下,onProgress 参数可以直接忽略或立即回传 100% 完成——无论哪种方式,都不会影响调用方的逻辑。

4.3 批量上传队列

在"画伴梦工厂"中,用户可能会一次性选择多张图片进行批量处理。此时可以扩展一个批量上传队列:

export class ImageUploadService {
  static async uploadMultiple(
    localUris: string[],
    onProgress?: (index: number, total: number, uri: string) => void
  ): Promise<UploadedImage[]> {
    const results: UploadedImage[] = [];
    for (let i = 0; i < localUris.length; i++) {
      const result = await this.uploadImage(localUris[i]);
      results.push(result);
      onProgress?.(i + 1, localUris.length, localUris[i]);
    }
    return results;
  }
}

存根模式下,这个批量上传就是循环调用存根方法——瞬时返回,完全不需要等待。


五、本地优先策略(Local-First)

存根模式带出了另一个重要的设计思想:本地优先(Local-First)策略

在"画伴梦工厂"的架构中,"图片上传"本质上是一个渐进增强的能力:

离线/开发模式(存根)
    ↓
网络可达但服务未部署(存根)
    ↓
服务上线(替换为真实实现)
    ↓
网络中断(降级为存根或本地缓存)

这种设计意味着应用的核心功能——将用户图画转换为动画——不依赖于网络连接。即使用户在飞机上、在地下室、在没有蜂窝网络的平板设备上,只要图片已经在本地,流程就可以继续。

本地优先策略的实现通常包含以下几个层次:

层次 说明 项目中对应
本地存储 图片保存到应用沙箱 fileIo + 沙箱路径
本地索引 以本地 URI 作为唯一标识 localUri 字段
存根服务 模拟远程服务的行为 ImageUploadService 存根
远程同步 网络可用时上传到服务器 真实的 uploadImage 实现
冲突处理 本地与远程数据的一致性维护 项目暂未涉及

存根模式正是"本地优先"策略在服务层的具体体现——在无法或不必要访问远程服务时,用本地能力替代。


六、可替换服务架构设计

ImageUploadService 采用的静态类 + 统一接口模式,本质上是一种轻量级的服务定位器(Service Locator) 模式。下面是这种架构的整体设计:

┌─────────────────────────────────────────────┐
│               调用方(调用者)                   │
│  ImageUploadService.uploadImage(localUri)    │
└────────────────────┬────────────────────────┘
                     │ 依赖接口(契约),不依赖实现
                     ▼
┌─────────────────────────────────────────────┐
│           ImageUploadService(服务门面)        │
│  static async uploadImage(): UploadedImage   │
└────────────────────┬────────────────────────┘
                     │ 可替换的实现策略
                     ▼
           ┌─────────────────────┐
           │ 存根实现(开发/测试)   │
           │ StubUploadStrategy  │
           └─────────────────────┘
           ┌─────────────────────┐
           │ 真实实现(生产环境)    │
           │ HttpUploadStrategy  │
           └─────────────────────┘
           ┌─────────────────────┐
           │ 缓存实现(离线降级)    │
           │ CacheUploadStrategy │
           └─────────────────────┘

在更复杂的场景中,我们可以将上传策略抽象为接口,通过依赖注入的方式在运行时切换:

export interface UploadStrategy {
  upload(localUri: string): Promise<UploadedImage>;
}

export class ImageUploadService {
  private static strategy: UploadStrategy = new StubUploadStrategy();

  static setStrategy(strategy: UploadStrategy) {
    this.strategy = strategy;
  }

  static async uploadImage(localUri: string): Promise<UploadedImage> {
    return this.strategy.upload(localUri);
  }
}

这种设计让策略切换成为运行时的一行代码调用。在 aboutToAppear 中根据网络状态选择策略:

aboutToAppear() {
  if (canIUse('SystemCapability.Communication.Network')) {
    ImageUploadService.setStrategy(new HttpUploadStrategy());
  } else {
    ImageUploadService.setStrategy(new StubUploadStrategy());
  }
}

七、在 AI 处理管线中的位置

"画伴梦工厂"的 AI 处理流程是一条完整的管线(Pipeline),ImageUploadService 位于流程中承上启下的位置:

用户拍照/选图
    │
    ▼
图片压缩(3.4 图片压缩与 Base64 编解码)
    │
    ▼
图片上传 ←── ImageUploadService(本文)
    │
    ├── 存根模式 → 直接使用 localUri 继续
    │
    └── 真实上传 → 拿到 remoteUrl 后继续
          │
          ▼
GPT-4o-mini 图像识别(3.5)
    │
    ▼
Seedream 文生图(3.2)
    │
    ▼
图生视频(3.3)
    │
    ▼
用户查看结果

在这个管线中,图片上传服务的关键作用是:

  1. 统一图片访问方式:不论图片来自相机、相册还是网络,都统一为 UploadedImage 结构
  2. 解耦前后处理环节:上游(拍照/选图)不需要知道下游如何处理图片;下游(识别/生成)不需要关心图片从哪来
  3. 提供切换点:在存根和真实实现之间无缝切换,不影响上下游的任何逻辑

存根模式确保了整条管线可以在完全没有网络连接的情况下完整走通——这对于开发阶段的调试、自动化测试、以及离线演示场景至关重要。


八、存根模式在鸿蒙开发中的实践意义

结合鸿蒙生态和"画伴梦工厂"项目的实际经验,存根模式带来了以下几个层面的收益:

8.1 开发效率提升

在 HarmonyOS 应用开发中,真机调试的资源往往比较稀缺(需要注册开发者、申请设备、配置签名)。存根模式让开发者可以在预览器(Previewer) 中就跑通包含网络请求的完整流程,无需真机、无需后端。

8.2 并行开发解耦

团队中,前端 UI 开发者、AI 服务集成者、后端 API 开发者可以并行工作。前端工程师只需要知道 ImageUploadService.uploadImage(localUri) 返回 Promise<UploadedImage> 这个契约,就可以独立完成 UI 开发和联调。

8.3 自动化测试友好

存根服务的确定性输出让单元测试变得简单可靠:

// 测试用例示例
const result = await ImageUploadService.uploadImage('file://test.jpg');
expect(result.localUri).toBe('file://test.jpg');
expect(result.remoteUrl).toBe('file://test.jpg'); // 存根模式下的行为

测试不依赖网络环境,不需要 mock 框架,也不需要测试服务器。

8.4 渐进式增强

鸿蒙生态覆盖了从手机、平板到智慧屏、车机的多种设备,不同设备的网络能力差异巨大。存根模式天然支持能力降级:有网络时使用真实上传,无网络时透明降级为本地存根,用户体验不受影响。


九、项目中的其他存根实践

存根模式的思路在"画伴梦工厂"中并非孤例。实际上,项目的多个服务都采用了类似的设计哲学:

服务 存根行为 真实行为 切换触发
ImageUploadService 返回 localUri 作为 remoteUrl HTTP 上传到服务器 后端就绪后替换
AIGenerationService 可配置为返回固定示例结果 调用火山引擎 API 配置开关/网络状态
VideoExportService 直接返回本地路径 拷贝到用户指定目录 环境区分

这种一致的架构风格降低了团队成员的认知负担——"每个服务都有一个轻量级的替身实现"成为一种约定俗成的模式。


总结

本文通过"画伴梦工厂"中仅 13 行代码的 ImageUploadService,深入探讨了存根模式的设计思想与应用实践。

知识点 说明
存根模式(Stub Pattern) 用轻量级替身代替真实服务,让开发不依赖后端就绪
面向接口编程 通过 UploadedImage 接口定义契约,调用方不依赖具体实现
本地优先策略 将 localUri 作为 remoteUrl,离线状态下流程依然完整
可替换服务架构 静态类方法封装,运行时可以无缝切换实现策略
AI 管线集成 上传服务在拍照→识别→生成的流程中承上启下
渐进式增强 从存根到真实的演进路径清晰,不破坏调用方代码

下一篇: 第 3.9 篇将整合本篇和前面所有 AI 服务,呈现"画伴梦工厂"从拍照到动画的完整 AI 编排流程——看看多个服务如何无缝协同工作。


参考源码

本文所有代码均来自项目文件:

  • products/default/src/main/ets/services/ImageUploadService.ets — 图片上传服务的接口定义与存根实现
Logo

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

更多推荐