Gazelle
2020年04月05日更新 3017 Views

【React Native】AsyncStorageとメモリキャッシュでUXを快適にする

Yahoo,楽天,Amazonから価格比較を簡単に行えるシンプル最安値検索というアプリを以前にリリースした。毎年Apple様に支払う13500円程度のデベロッパー更新料を回収できずに、赤字を垂れ流し続けているアプリであるが、少しずつ快適な使い心地にしていきたいものである。

使い勝手の肝となる一つが商品検索へのネットワークアクセス数を減らす事である。商品検索APIを叩くときが最も時間が掛かる。

また、APIの使用回数制限に引っかからないことも重要な要素となる。多くのAPIはアクセス数に応じてRate Limitを掛けており、全体や同一IPからのアクセスが一定値を超えるとエラーを返す可能性がある。

よってAsyncStorageとメモリキャッシュを活用し既存アプリの高速化と安定化を図りたい。

本アプリの特徴を事前に述べておくと次のとおりである。

  • React Nativeを使用してアプリを制作
  • 環境としてExpoを用いており、手軽にアプリを制作できる反面Expoが提供しているAPI以外が使えない
  • Yahoo/楽天/Amazonの商品検索APIを使用して、必要なデータを取得、並び替えしてユーザに提示している

それでは始めていこう。

【要件定義】何をどこに、いつまで保存させるか?

商品検索結果を再取得しなくて良いように、AsyncStorageかメモリとして保存させたい。

AsyncStorageは保存したい値をHDDやSSDなどのストレージに保存するため、アプリケーションを終了させても再び取り出すことができる。一方でメモリはアプリケーションを終了させると保存内容が消去されるがアクセスは速い

よってメモリにキャッシュがあればキャッシュを用い、無ければStorageにアクセスするという仕組みを実現したい。

また、商品検索で得られる価格情報等は通常1日は変更されることはないだろう。そこで下記を要件とした。

  • 商品検索時はメモリ=>AsyncStorage=>サーバAPIの優先順位に基づき情報を取得
  • サーバ取得時はAsyncStorageとメモリの両方に検索結果を保存
  • AsyncStorageに保存されたデータは1日間有効

AsyncStorageをWrapしたreact-native-storageが便利

AsyncStorageは仕様を見ると分かるが、key value storeをベースとしたシンプルなAPIを提供してくれている。

しかしStringでしか保存できないために毎回JSON.stringfy(obj)をしてObjectを変換しなければならない。またストレージへの保存期間が指定できないなど定義した要件を簡単には満たすことができなそう。

自分でWrapperを書くのも手だが、幸いReact NativeのライブラリとしてAsyncStorageをいい感じにWrapしてくれたreact-native-storageが提供されているため、これを使用してよりシンプルで可読性のあるコードを目指したい。

react-native-storageをインストール

そんなわけで実装を進めていく。

$ npm install react-native-storage
# 下記コマンドは叩かないでください。
$ npm install @react-native-community/async-storage
$ react-native link @react-native-community/async-storage

とreact-native-storageの公式サイトには書かれているが、Expoはlinkコマンドを使う事ができない。カスタマイズして使うことはできるが、単純化されたbuildワークフローなどExpoの恩恵も受けられなくなる。

そこでreact-native-storageのみをインストールし、async-storageとしてはreact-nativeパッケージ標準のAsyncStorageを使用する。Deprecatedになっており推奨はされないが使用は可能である。

import Storage from 'react-native-storage';
import { AsyncStorage } from 'react-native';

Expo側もどこかで対応するはずなので、今のところDeprecatedのまま使用しておき、何かしらの対応が入れば乗り換えればよいだろう。

実装を進める

実装した内容を抜粋して記す。メモリキャッシュを有効にする。Storageのデータ有効期限は1日など

import Storage from 'react-native-storage';
import { AsyncStorage } from 'react-native';

const storage = new Storage({
  // maximum capacity, default 1000
  size: 1000,

  // Use AsyncStorage for RN apps, or window.localStorage for web apps.
  // If storageBackend is not set, data will be lost after reload.
  storageBackend: AsyncStorage, // for web: window.localStorage

  // expire time, default: 1 day (1000 * 3600 * 24 milliseconds).
  // can be null, which means never expire.
  defaultExpires: 1000 * 3600 * 24, // 1days

  // cache data in the memory. default is true.
  // メモリキャッシュの有効化
  enableCache: true,

  // if data was not found in storage or expired data was found,
  // the corresponding sync method will be invoked returning
  // the latest data.
  sync: {
    // we'll talk about the details later.
  }
});

const _setStorage = (services, params, result) => {
  storage.save({
    key: 'searchResult',
    id: services.join('') + JSON.stringify(params),
    data: result
  }).catch(() => {
    // やや乱暴だが、データの不整合やAsyncStorageの不足が起こった時は全てのストレージデータを削除
    storage.clearMap();
  });
};

export const getProductList = (services, params, options = {}) => {
  // storageに検索結果が入っていればその値を取得
  return storage.load({
    key: 'searchResult',
    id: services.join('') + JSON.stringify(params),
    autoSync: false,
  }).catch(() => {
    // 入っていなければサーバAPIを投げる
    const promiseObj = {};
    promiseObj.yahoo = search('yahoo', params);
    if (services.includes('rakuten')) {
      promiseObj.rakuten = search('rakuten', params);
    }
    if (services.includes('amazon')) {
      promiseObj.amazon = search('amazon', params);
    }
    return Promise.hash(promiseObj).then((results) => {
       const listData = productsManager.createListData(results.yahoo, results.rakuten, results.amazon);
       // リスト表示に必要なデータのみを保存
      _setStorage(services, params, listData);
      return listData;
    });
  })
}

これで一通りの要件は満たすことができたと思われる。

AndroidのAsyncStorageはわずか6MB

ハマった点として、AndroidのAsyncStorageをデフォルトで使用できる容量は6MBということである。一回のHTTPリクエストで200KB程度のデータが返却されるので、Amazon/rakuten/yahoo合わせて1回で600KB、すなわち10回分の検索しかキャッシュができないということになる。

これを超過すると

Unhandled promise rejection: Error: database or disk is full (code 13 SQLITE_FULL)

というエラーがログとして表示され、ストレージにこれ以上保存することができなくなる。

このエラーの対応としてstorage.clearMap()をストレージが溢れた時に行うという対応と、必要なデータのみをストレージに保存する対応を行った。

具体的にはHTTPリクエストで返されるjsonレスポンスをそのまま保存していては容量がすぐにパンクするため、jsonレスポンスから必要なデータのみを取り出して保存することにした。この結果50回分程度検索結果を保持できるようになり、十分とは言えないかもしれないが許容範囲であろう。

最大データ保持件数を指定して、AsyncStorageの容量超過を防ぐ

size:1000が何のサイズを意味しているのか分からなかったが、実験してみたところidに紐づくデータの保存件数のことだと分かった。

検索50回分まで保存できるため50と指定してやれば良いが、念のため余裕をもって30回分の検索データをストレージに保存できるように変更した。

  // maximum capacity, default 1000
+  size: 30,
-  size: 1000,

内部的には保存件数が30を超えると、一番最初のデータを新規のデータで上書きしているようである。ライブラリのコードとしてはこの辺を参考。これによりストレージがパンクすることを防げそうである。

まとめ

メモリのキャッシュ機構を独自で持たなければならないと当初考えていたが、react-native-storageを用いることでシンプルに書けることが分かった。

また、AsyncStorage容量も保存件数を指定することで解決することができ、結果コードを汚さずにシンプルに機能を実装する事が可能となった。

外部APIを使用する時は制約が厳しい場合も多く、また速さはUXの大きな要因を占めるため、同様のことを考えている人に本記事が参考になれば幸いである。

関連記事

ReactNativeでアプリを作成してiOS/Androidへリリースするまで説明。アプリ制作はJavascriptを理解していれば簡単であるが、リリースは煩雑で面倒である。
2020年06月01日
React NativeでのFlexレイアウトは描画領域とflexDirectionを常に意識することがポイント
2020年03月29日
ホームへ戻る