wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

react-native-view-shotを使用してiOSで8200pxを超える画像をキャプチャした時に画像が真っ白になる不具合の対応

概要

タイトルの通りだが、react-native-view-shotに不具合があり、iOSで8200pxを超える画像をキャプチャする真っ白の画像になってしまっていた。

github.com

native側の修正が必要なので、Expoでは修正不可能だなーとなっていたのを力技で解決したので、それについて記事にした。

PR

■ アプリ github.com

■ 画像を結合するAPI

github.com

実装

事象は概要の通りで、このままだとiOSで長いキャプチャを作成する事ができないので対応。 native実装側の不具合なので、Expoで修正することは難しいので初めは以下の方法を考えた

  • ①. react-native-view-shotを分割して画像を作成
  • ②.アプリ側で画像を結合して共有する

しかし、調査した結果Expoを使用した上タウでアプリ側で画像を結合する手段は無さそうだったので、最終的に以下の実装方法に変更

  • ①.react-native-view-shotを分割して画像を作成
  • ②.分割した画像を圧縮してFirebase Storageにアップロード
  • ③.圧縮した画像を結合してバイナリを返すAPIにRequestして結合した画像を取得
  • ④.③でダウンロードした画像を共有する

まず、画像を結合してバイナリを返すAPIをCloud Functionsで作成。
画像結合はsharpを使用して実装。

www.npmjs.com

APIのコードは以下の通り

MargeImages/index.js

const sharp = require("sharp");
const axios = require("axios");
const imageHost = process.env.IMAGE_HOST;
const imageParam = process.env.IMAGE_PARAM;

const getImageData = async (url) => {
  const response = await axios.get(url, {
    responseType: "arraybuffer",
  });
  const buffer = Buffer.from(response.data, "utf-8");

  const image = await sharp(buffer);
  const r = await image.metadata();
  const buf = await image.toBuffer();

  return {
    width: r.width,
    height: r.height,
    buf,
  };
};

const margeImage = async (urlList) => {
  let images = [];

  for (let i = 0; i < urlList.length; i++) {
    const image1 = await getImageData(`${imageHost}${urlList[i]}${imageParam}`);
    images.push(image1);
  }

  let totalTop = 0;

  const height = images.map((v) => v.height).reduce((a, x) => (a += x), 0);

  const buf = await sharp({
    create: {
      width: images[0].width,
      height: height,
      channels: 4,
      background: { r: 255, g: 255, b: 255, alpha: 0 },
    },
  })
    .composite(
      images.map((v, i) => {
        if (i > 0) {
          totalTop += images[i - 1].height;
        }

        return {
          input: v.buf,
          gravity: "northwest",
          left: 0,
          top: totalTop,
        };
      })
    )
    .toFormat("png")
    .toBuffer();

  return buf;
};

exports.margeImage = async (req, res) => {
  const param = req.query.images;
  const urlList = param.split(",");

  const buf = await margeImage(urlList);

  res.status(200).send(buf);
};

sharpのcompositeを使用することで画像の結合が行える。
compositeの戻り値のinputに画像のbuffer、topに結合する高さ(結合前の画像のheight)を指定すればOK。

  const buf = await sharp({
    create: {
      width: images[0].width,
      height: height,
      channels: 4,
      background: { r: 255, g: 255, b: 255, alpha: 0 },
    },
  })
    .composite(
      images.map((v, i) => {
        if (i > 0) {
          totalTop += images[i - 1].height;
        }

        return {
          input: v.buf,
          gravity: "northwest",
          left: 0,
          top: totalTop,
        };
      })
    )
    .toFormat("png")
    .toBuffer();

これで画像を結合するAPIは完成。
次に react-native-view-shotのキャプチャ分割を実装
useRefを動的に増やすのは出来なそうなだったので愚直に5分割してキャプチャするように修正(useRef毎に20件分の表示データを取り扱うようにしている)

src/components/organisms/Memoir/ScreenShot.ios.tsx

const ScreenShot: React.FC<Props> = (props) => {
  const { env } = useConfig();

  const viewShot1 = useRef<ViewShot>(null);
  const viewShot2 = useRef<ViewShot>(null);
  const viewShot3 = useRef<ViewShot>(null);
  const viewShot4 = useRef<ViewShot>(null);
  const viewShot5 = useRef<ViewShot>(null);


...略)

  const sliceItemCount = Math.floor(getData().length / 20) + 1;

  const items: RenderedItem[][] = [];

  for (let i = 0; i < sliceItemCount; i++) {
    const target = i * 20;
    items.push(getData(onLoadEnd).slice(target, target + 20));
  }

  const getViewShotRef = (key: number) => {
    switch (key) {
      case 0:
        return viewShot1;
      case 1:
        return viewShot2;
      case 2:
        return viewShot3;
      case 3:
        return viewShot4;
      case 4:
        return viewShot5;
      default:
        return viewShot1;
    }
  };

  return (
    <>
      <ScrollView style={styles.root}>
        {items.map((item, key) => {
          const ref = getViewShotRef(key);

          return (
            <ViewShot ref={ref} options={{ format: 'jpg' }} key={key}>
              <RNView style={styles.inner}>
                {key === 0 && (
                  <Header
                    startDate={props.startDate}
                    endDate={props.endDate}
                    isTitle
                  />
                )}
                {item.map((v, index) => {
                  return <RenderItem {...v} key={`${key}_${index}`} />;
                })}
              </RNView>
            </ViewShot>
          );
        })}
      </ScrollView>
      {!loading && <Loading text="作成中" />}
    </>
  );
};

以下でのキャプチャした画像をFirebase StorageにUpload、画像のパスを画像結合APIにリクエストし、画像をダウンロードして共有する

src/components/organisms/Memoir/ScreenShot.ios.tsx

  const onCapture = useCallback(async () => {
    const urlList: string[] = [];
    const deleteImageURL: string[] = [];

    for (let i = 0; i < sliceItemCount; i++) {
      const ref = getViewShotRef(i);
      const url = await ref.current?.capture?.();
      const uri = await resizeImage(url || '');
      const uploadURL = await uploadImageAsync(uri, `public/${uuidv4()}`);
      const u = uploadURL
        .replace(process.env.STORAGE_URL || '', '')
        .split('?')[0];
      urlList.push(u);
      deleteImageURL.push(uploadURL);
    }

    const param = urlList.join(',');
    const fileName = `${dayjs(props.startDate).format('YYYYMMDD')}_${dayjs(
      props.endDate
    ).format('YYYYMMDD')}_memoir`;

    const res = await FileSystem.downloadAsync(
      `${process.env.IMAGE_MERGE_API}?images=${param}`,
      `${FileSystem.documentDirectory}${fileName}.png`
    );

    for (let i = 0; i < deleteImageURL.length; i++) {
      await deleteImageAsync(deleteImageURL[i]);
    }

    if (res.uri) onShare(res.uri);
  }, [onShare, env, sliceItemCount, props.startDate, props.endDate]);

ここまで実装して以下のように正常な動作が確認できた。

www.youtube.com

まとめ

  • iOSのシミュレータだと8200px超えても正常に動作するので発見するのが遅れた
  • かなりの力技の修正になったが、一応動作して良かった。