React Native for OpenHarmony 三方库集成实战:响应式图片审阅

受测宿主:RN能力库 0.3.1

一、应用背景

图片审阅页通常同时包含缩略图列表、占位动画和大图手势。图片加载前如果没有确定的展示矩形,骨架屏、真实图片和缩放容器会反复改变布局;用户切换资源时,如果继续沿用上一张图的缩放矩阵,新的图片会以错误的偏移量显示。

本文用三个 JavaScript 三方库完成一个固定流程:先根据应用窗口计算预览矩形,再用 shimmer 占位等待图片就绪,最后把已加载图片交给 ImageZoom 处理缩放和拖动。加载失败、窗口尺寸变化和快速切换资源都由业务层处理,组件库只负责自己的展示职责。

在这里插入图片描述

图 1:预览图加载完成后进入缩放审阅状态。

二、应用目标

  • 使用当前应用窗口而非物理屏幕计算预览宽度,并限制最大值;
  • 在图片加载完成前保持占位区尺寸稳定;
  • 资源切换时清除旧图片的缩放和位移状态;
  • 将加载、失败、重试和手势冲突建模为明确状态;
  • 真机上检查窄屏、窗口变化和图片审阅结果。

三、三方库与版本

三方库锁定版本适配 TAG职责
react-native-responsive-screen1.4.21.4.2-ohos-1.0.0按窗口比例计算逻辑尺寸
react-native-shimmer-placeholder2.0.92.0.9-ohos-1.0.0加载阶段占位
react-native-image-pan-zoom2.1.122.1.12-ohos-1.0.0图片缩放和拖动

react-native-responsive-screen 和 react-native-shimmer-placeholder 使用 MIT 许可证,react-native-image-pan-zoom 使用 ISC 许可证。三个包均为纯 JavaScript 依赖,源、版本和 TAG 截止 2026-09-26 核对;本场景不新增 HAR 和系统权限。

npm install
npm run bundle:harmony

四、应用方式

先把尺寸计算从图片组件中抽出来。88% 为窄屏保留边距,340 是预览最大宽度,宽高比由产品设计决定:

import {useMemo} from 'react';
import {useWindowDimensions} from 'react-native';
import {widthPercentageToDP} from 'react-native-responsive-screen';

export function usePreviewRect(aspectRatio = 0.58) {
  const {width, height} = useWindowDimensions();
  return useMemo(() => {
    const horizontalPadding = width < 360 ? 24 : 32;
    const previewWidth = Math.max(
      1,
      Math.min(widthPercentageToDP('88%'), width - horizontalPadding, 340),
    );
    return {
      width: previewWidth,
      height: Math.max(1, Math.round(previewWidth * aspectRatio)),
      windowWidth: width,
      windowHeight: height,
    };
  }, [aspectRatio, height, width]);
}

然后按 idle -> loading -> ready/error 渲染同一个外层矩形。占位和实际图片都使用 previewWidth、previewHeight,避免状态切换造成列表跳动:

const {width: previewWidth, height: previewHeight} = usePreviewRect();
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('loading');

<View style={{width: previewWidth, height: previewHeight, overflow: 'hidden'}}>
  <ImageZoom
    cropWidth={previewWidth}
    cropHeight={previewHeight}
    imageWidth={previewWidth}
    imageHeight={previewHeight}>
    <Image
      source={{uri: asset.uri}}
      style={{width: previewWidth, height: previewHeight}}
      resizeMode="contain"
      onLoad={() => setStatus('ready')}
      onError={() => setStatus('error')}
    />
  </ImageZoom>
  {status === 'loading' && (
    <View
      style={{
        position: 'absolute',
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
        justifyContent: 'center',
        alignItems: 'center',
      }}
      pointerEvents="none">
      <ShimmerPlaceholder
        visible={false}
        width={previewWidth - 36}
        height={18}
        shimmerColors={['#E4E8EB', '#F7F8F9', '#E4E8EB']}
      />
    </View>
  )}
  {status === 'error' && (
    <Pressable onPress={() => setStatus('loading')}>
      <Text>重新加载</Text>
    </Pressable>
  )}
</View>

实际项目应让图片先加载,再挂载缩放容器;如果资源来自本地 require,则可以在渲染时直接进入 ready。远程图片切换时要用资源 ID 而不是 URL 片段作为状态键。

五、工程实现

资源身份必须进入状态。

快速翻页时,上一张图片的 onLoad 可能晚于下一张图片的请求。如果回调不校验资源身份,页面会把旧图片标记为新图片已完成。下面的 Hook 将 assetKey 和状态一起保存:

type ImageStatus = 'idle' | 'loading' | 'ready' | 'error';

export function useAssetState(assetKey: string) {
  const [state, setState] = useState({
    key: assetKey,
    status: 'idle' as ImageStatus,
    attempts: 0,
  });

  useEffect(() => {
    setState({key: assetKey, status: 'loading', attempts: 0});
  }, [assetKey]);

  const onLoad = (key: string) => {
    setState(current => current.key === key
      ? {...current, status: 'ready'}
      : current);
  };

  const onError = (key: string) => {
    setState(current => current.key === key
      ? {...current, status: 'error'}
      : current);
  };

  const retry = () => {
    setState(current => ({
      ...current,
      status: 'loading',
      attempts: current.attempts + 1,
    }));
  };

  return {state, onLoad, onError, retry};
}

错误状态应保存资源 ID、错误类别和重试次数,不要把带签名参数的完整 URL 写入日志。展示文案可以说“图片加载失败”,日志则记录稳定的资源版本和错误码。

缩放、滚动和坐标保存。

ImageZoom 消费双指缩放和拖动手势,外层 ScrollView 或分页容器也可能需要横向滑动。未放大时可以让外层滚动消费手势,放大后再由图片容器处理拖动;这个仲裁需要在真机上验证,不能只看组件类型定义。

切换资源时使用 key={asset.id} 重新创建缩放容器,确保上一张图片的变换矩阵不会泄漏。只在进入审阅状态时挂载 ImageZoom,列表缩略图使用普通 Image,可减少不必要的手势注册。

如果需要保存框选位置,应将屏幕坐标换算成原图坐标。以下函数适用于未旋转、contain 裁剪和等比缩放;接入平移矩阵后,必须把当前缩放和偏移作为参数传入:

type Rect = {x: number; y: number; width: number; height: number};

export function screenToImage(
  point: {x: number; y: number},
  viewport: Rect,
  image: {width: number; height: number},
) {
  const scale = Math.min(
    viewport.width / image.width,
    viewport.height / image.height,
  );
  const rendered = {
    width: image.width * scale,
    height: image.height * scale,
  };
  const offsetX = viewport.x + (viewport.width - rendered.width) / 2;
  const offsetY = viewport.y + (viewport.height - rendered.height) / 2;
  return {
    x: (point.x - offsetX) / scale,
    y: (point.y - offsetY) / scale,
  };
}

大图和失败路径。

大图不应一次性把原始分辨率全部读入内存。列表先使用缩略图,进入详情后再请求适合窗口的尺寸;离开详情时释放不再使用的状态。缓存键应包含资源 ID、尺寸和版本,不能把带鉴权信息的 URL 作为长期缓存键。

重试按钮只重置当前资源状态,不能把整个列表清空。网络错误、资源不存在和格式不支持应映射到不同错误码;如果产品支持替代图,替代图也要使用与真实内容相同的外层矩形。

重试还需要限制次数和间隔,避免网络断开时每次点击都立即发起请求。状态对象可以同时保存 attempts 和 nextRetryAt,进入后台后暂停倒计时,回到前台再根据资源是否仍然可见决定是否继续。图片 URL 如果包含临时签名,重试时应由资源服务重新生成 URL,而不是在客户端无限延长旧签名。

async function loadWithRetry(
  load: () => Promise<void>,
  maxAttempts = 3,
) {
  let attempt = 0;
  let lastError: unknown;
  while (attempt < maxAttempts) {
    attempt += 1;
    try {
      await load();
      return;
    } catch (error) {
      lastError = error;
      if (attempt === maxAttempts) break;
      await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 250));
    }
  }
  throw lastError;
}

loadWithRetry 只适合可重试的网络错误,资源不存在和格式错误应直接停止。真正的 Image 组件还要在每次重试前更新资源 key,否则某些缓存实现不会重新触发 onError。

让列表和详情使用不同的资源策略。 列表只加载缩略图,详情页才加载大图;FlatList 的 keyExtractor 使用资源 ID,固定外层矩形可以减少滚动测量。详情页离开后清除临时大图引用,但可以保留缩略图缓存,避免列表一次性创建大量 ImageZoom。

const renderThumbnail = ({item}: {item: Asset}) => (
  <Pressable onPress={() => openReview(item.id)}>
    <Image
      source={{uri: item.thumbnailUri}}
      style={{width: 88, height: 88}}
      resizeMode="cover"
      accessibilityLabel={`打开图片 ${item.id}`}
    />
  </Pressable>
);

<FlatList
  data={assets}
  keyExtractor={item => item.id}
  renderItem={renderThumbnail}
  removeClippedSubviews
  initialNumToRender={6}
/>;

审阅结果中的框选坐标应保存原图坐标和原图版本,不能只保存当前窗口坐标。窗口尺寸、旋转或缩放策略变化后,重新投影原图坐标即可恢复标记。

当图片已经被 ImageZoom 平移或放大时,屏幕坐标换算还要减去当前位移并除以缩放值。变换参数由手势容器维护,业务层只保存原图坐标:

type Transform = {scale: number; translateX: number; translateY: number};

export function viewportToImage(
  point: {x: number; y: number},
  transform: Transform,
  viewport: Rect,
) {
  return {
    x: (point.x - viewport.x - transform.translateX) / transform.scale,
    y: (point.y - viewport.y - transform.translateY) / transform.scale,
  };
}

export function imageToViewport(
  point: {x: number; y: number},
  transform: Transform,
  viewport: Rect,
) {
  return {
    x: viewport.x + point.x * transform.scale + transform.translateX,
    y: viewport.y + point.y * transform.scale + transform.translateY,
  };
}

这两个函数应使用同一组逻辑像素坐标。不要把 pageX/pageY、物理像素和图片原始像素混在一个对象里;服务端保存的是原图坐标,渲染时再根据当前窗口矩形投影回来。边界测试至少覆盖缩放值为 1、最小缩放值、最大平移和窗口旋转后的新矩形。

预览尺寸还应加入可测量的断言,而不是只在截图上判断“看起来合适”:

it.each([
  [240, 208],
  [320, 281],
  [768, 340],
])('keeps the preview inside the window (%s)', (windowWidth, expectedMax) => {
  const width = Math.min(windowWidth * 0.88, windowWidth < 360 ? windowWidth - 24 : windowWidth - 32, 340);
  expect(width).toBeLessThanOrEqual(expectedMax);
  expect(width).toBeGreaterThan(0);
});

对大图还要记录加载耗时和解码失败次数。性能日志使用资源 ID、尺寸和耗时,不记录带鉴权参数的 URI;如果同一资源在某个系统版本上反复解码失败,应优先检查图片格式、尺寸和 OpenHarmony 图片管线,而不是盲目增加重试次数。

为切换资源建立一次性清理点。 资源 ID 变化时先停止旧资源的重试计时器,清空错误状态,再重置手势变换。不能在 useEffect 中只更新 URI 而继续复用旧的 ImageZoom 实例:

useEffect(() => {
  cancelPendingRetry();
  resetTransform();
  setStatus('loading');
}, [asset.id]);

return (
  <ImageZoom key={asset.id} cropWidth={width} cropHeight={height}>
    <Image source={{uri: asset.uri}} style={{width, height}} />
  </ImageZoom>
);

如果图片详情处于导航栈中但失焦,暂停自动重试和预加载;重新获得焦点后先检查资源版本,再决定是否恢复。这样可以把网络请求、手势状态和页面可见性保持在同一个生命周期内。

测试尺寸和竞态。

尺寸测试覆盖 240、320、768 和 1280 逻辑像素,断言宽度不超过 340 且高度与宽高比一致。状态测试覆盖旧资源回调晚到、连续重试和切换后重新挂载缩放容器:

it('ignores a late callback from the previous asset', () => {
  const state = {key: 'new', status: 'loading' as const};
  const onLoad = (key: string) =>
    key === state.key ? {...state, status: 'ready' as const} : state;

  expect(onLoad('old')).toEqual({key: 'new', status: 'loading'});
  expect(onLoad('new')).toEqual({key: 'new', status: 'ready'});
});

六、真机验证

验证项实测结果结论
HAP 安装与启动RN能力库 0.3.1 可启动通过
响应式尺寸预览区按窗口宽度计算并保持稳定通过
占位与真实图片加载完成后从 shimmer 切换到图片通过
缩放审阅双指缩放和拖动结果可见通过
资源切换切换后没有沿用上一张图片的变换状态通过

受测设备为 6UMBB26319007180,系统 OpenHarmony-7.0.0.105,HAP SHA-256:a0f079982142ef5eb836f439af5e71c8f31f33c4988e80ab2a7575bee64b1f03。图 1 与 HAP 来自同一次构建。

在这里插入图片描述

图 2:react-native-image-pan-zoom 库级真机验证,截图来自 2026-09-14 的独立包验证。

在这里插入图片描述

图 3:独立包验证中的滚动与动画场景。

在这里插入图片描述

图 4:独立包验证中的输入与占位场景。

本次证据覆盖静态场景和已加载图片的审阅,未覆盖超大原图、弱网重试、外层分页与缩放手势冲突、分屏窗口和无障碍焦点顺序。接入真实图片服务后,应重新记录资源版本、网络条件和 HAP 哈希。

七、参考链接

欢迎加入 RN for OpenHarmony 社区。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐