📱

React Native + Expoでアプリを作ってApp StoreとGoogle Playで公開した

2021/04/21に公開4

先日、VioletというTumblrクライアントアプリをReact Native + Expoで作りました。iOSとAndroidに対応していて、先日無事に審査が通り、現在App StoreとGoogle Playで公開しています。

https://apps.apple.com/jp/app/violet-tumblr-client/id1560068929
https://play.google.com/store/apps/details?id=jp.brdr.app.violet

※有料アプリですが、もし良かったら買って使ってみてください!

2021年にまさかTumblrアプリ!というのはさておき、開発の上でのもろもろを書いておきます。

経緯

もともとTumblrが好きで、スマホでよくTumblrを見ています。しかし、特にAndroidには純正アプリを含めて、「ここがもう少しこうだったらいいのにな」というアプリが多く、ならば自分が一番使いやすいTumblrクライアントアプリを作ろう、と思ったのがきっかけです。

開発当初はAndroidをメインで使っていましたが、iPhoneもよく使っていました。そこで、どうせならiOS/Androidの両方に対応したアプリを作ろうと思い立ち、クロスプラットフォームなフレームワークで、慣れ親しんだJavaScript (TypeScript)で書けるReact Nativeで作ることにしました。さらに調べるうちに、Expoという便利な開発ツールがあることを知り、React Native + Expoで作ろう、と決めました。

ナビゲーション

React Navigationのv5を使いました。公式ドキュメントのAuthentication flowsを参考にしました。

状態管理、永続化

状態管理はunstated-nextを使いました。シンプルで使いやすかったです。
認証情報や設定の永続化は、react-native-storageを使っています。

認証

これが一番つらかった…。

OAuth 2.0の認証は、AppAuthを使うと結構簡単です。しかし、Tumblrの認証はOAuth 1.0です。

参考: Tumblr APIでwebサービスを作りたい全ての人に向けて書きました

僕が調べた限りではOAuth 1.0に対応していて、Tumblrの認証方法も用意されていて、かつReact Nativeで動く認証ライブラリは、react-native-simple-authだけでした。

しかし、react-native-simple-authはExpoには対応していません。仕方がないので、react-native-simple-authの実装をコピペ参考にして、Expoでも動くようにTypeScriptで書き換えました。
※全く使ったことのないRamda.jsが使われてて、コードを理解するのにかなり苦労した

LinkingではなくWebBrowserを使う

最初はexpo-linkingLinking.openURL()を使ってブラウザを開いて認証をしようと思っていましたが、特定環境でブラウザからアプリに戻ってきた時に認証情報が取得できないことがありました。
散々悩んだのですが、expo-web-browserWebBrowser.openAuthSessionAsync()を使うとうまくいきました。

しかもiOSだと、SafariではなくハーフモーダルでIn-App Browserが開くので、ユーザーがアプリを離脱せず、体験としてもいい感じです。

import * as WebBrowser from 'expo-web-browser'

export const auth = async (authUrl, redirectUrl) => {
  const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUrl)
  
  if (result.type === 'cancel') {
    throw new Error('cancel')
  } else if (result.type === 'success') {
    if (!result.url || result.url.indexOf('fail') > -1) {
      throw new Error('failed')
    }

    // success
    return result.url
  }
}

WebBrowserを使う箇所だけを抜粋するとこんな感じです。
認証が成功するとOauth Token Secretが含まれたresult.urlが返ってくるので、それを使ってAPIクライアントを作成します。

環境によって変わるURLスキームにハマる

認証時にブラウザからアプリを開くためのURLスキームはLinking.createURL(path: string)で作ることができますが、環境でそのURLが変わります。

  • Published app in Expo Go: exp://exp.host/@community/with-webbrowser-redirect
  • Published app in standalone: myapp://
  • Development: exp://localhost:19000

参考: Linking - Expo Documentation

ドキュメントにはこう書いてあるのですが、なぜかPublished app in standalone、つまりビルドしたアプリだけ、myapp:///...とスラッシュが3つになってしまい、ブラウザからアプリに戻ってきても、画面を開けずにエラー、という不具合がありました。
仕方がないので、スラッシュが3つだったら2つに置換する、というゴリ押しの処理を挟むことで、どの環境でも無事にリダイレクト先の画面を表示することができるようになりました。
※未だに原因が何か分かってない

import * as Linking from 'expo-linking'

const redirectUrl = Linking.createURL('SignIn').replace(/\/\/\//g, '//')

FlatListのパフォーマンスチューニング

ダッシュボード画面では、ImageやTextが含まれたScrollViewがひとつの投稿だとして、数百個の投稿をFlatListで表示することになります。
その巨大なFlatListのメモリ消費量をいかに少なくするか、というのが結構大変でした。

設定を見直す

  • maxToRenderPerBatch
  • updateCellsBatchingPeriod
  • initialNumToRender
  • windowSize

などのPropsの値を、そのリストで本当に必要な最小限のものにしました。

getItemLayoutを設定する

すべてのリストアイテムの高さ(水平スクロールの場合は幅)が同じでよい場合、getItemLayoutを設定すると、ある程度計算量の削減になります。

// const itemWidth = Dimensions.get('screen').width

function getItemLayout(
  data: TumblrPost[] | null | undefined,
  index: number
) {
  return {
    length: itemWidth,
    offset: itemWidth * index,
    index
  }
}

return <FlatList getItemLayout={getItemLayout} />

ビューポート外のrenderItemをアンマウントする

一番効果があったのがこの対策で、スクロールして見えなくなっているrenderItem(投稿)は、そもそもマウントしない、というものでした。

const renderItem: ListRenderItem<TumblrPost> = ({ post, index }) => (
  <>
    {index === currentIndex || index === currentIndex + 1 || index === currentIndex - 1 ? (
      <Post post={post} />
    ) : (
      <View style={{ width: itemWidth, flex: 1 }} />
    )}
  </>
)

こんな感じで、currentIndex (現在表示している投稿)の前後1つずつ、つまり計3つの投稿以外は、空のViewを返すようにしたところ、明らかにメモリ使用量が下がり、メモリリークもしなくなりました。めでたい。

参考

UI

デザイン

Figmaでモックアップを作りました。
Tumblrのダッシュボードを左右スワイプで移動でき、下部にフローティングしているUIから、Reblog/Like/Shareが可能です。

ちなみにFigmaでパスを引くのはかなり辛いので、AppアイコンはIllustratorで作りました。

文字まわり

iOSとAndroidでは、UIに使うフォントや文字サイズが違います。可能な限りそれぞれのOSのルールを踏襲したいものです。

https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/
https://material.io/design/typography/the-type-system.html#type-scale

react-native-typographyというライブラリを使うと、各OSのルールに則った文字スタイルのオブジェクトが返ってくるので、それをReact NativeのPlatform.select()で分岐させています。

文字以外にも、余白、リストの高さ、アイコンの大きさなども、OSによって出し分けています。

テーマ切り替え

テーマを3つから任意の1つに切り替えられるようにしたかったのですが、React Native Extended StyleSheetをReact Navigationと一緒に使うことで実現できました。
以下を参考にしました。

ハーフモーダル

Twitterのアプリのようなハーフモーダルを実装したかったのですが、react-native-modalを使うことで実現できました。微妙に理想の挙動ではないですが、おおむね満足のいくUIになりました。

多言語対応

日本語と英語の両方に対応したかったので、ExpoのLocalizationi18n-jsを使いました。

参考: Localization | React Navigation

ライセンス画面の自動生成

アプリを公開するなら、ライセンス画面で使用しているライブラリとそのライセンスの一覧が見れる画面があった方がいいなと思ったのですが、自分でリストアップするのは大変なので、自動で生成する方法を探しました。
今回の用途ではlicense-lsというライブラリが一番良さそうでした。

package.json
{
  "scripts": {
    "generate-license": "npx license-ls --depth=0 --prod --format=json > './src/assets/license.json'",
    "postinstall": "yarn generate-license",
  }
}

package.jsonにこんな感じのタスクを書くことで、yarn installyarn addを実行すると自動でlicense.jsonというファイルが出力されます。
license-lsコマンドのオプションに--depth=0 --prodとすることで、package.jsonのdependenciesに書かれているものだけを出力することができます。

license.json
[
  {
    "id": 0,
    "name": "@expo/vector-icons",
    "version": "10.2.1",
    "license": "MIT License (MIT)",
    "repository": "git+https://github.com/expo/vector-icons.git",
    "author": "Brent Vatne",
    "homepage": "https://expo.github.io/vector-icons",
    "dependencyLevel": "production"
  },
  ...
]

JSONはこんな感じで、あとはライセンス画面でこのJSONをimportして、リストで表示します。

いい感じでライセンス画面ができました。

バージョン管理とRelease Channel

ExpoにはRelease Channelという機能があります。

この機能をうまく使うことで、ストアを介さずにアプリをアップデートできたり、開発ビルドと本番ビルドを分ける、というようなことができます。
開発当初は今いちピンとこず、全然使っていませんでしたが、開発終盤にちゃんとチャンネルを運用した方がいいなと思い、設定することにしました。

https://zenn.dev/terrierscript/articles/2020-08-27-expo-versioning-rules

ほぼ全部、この記事を参考にさせていただきました。感謝🙏

package.json
{
  "scripts": {
    "release-channel": "echo v$(semver-extract --pjson --minor -x)",
    "increment-build": "react-native-version -b --skip-tag",
    "build:ios:dev": "expo build:ios -t simulator --no-wait --release-channel dev-$(npm run release-channel --silent)",
    "build:ios:staging": "yarn increment-build && expo build:ios -t archive --no-wait --release-channel staging-$(npm run release-channel --silent)",
    "postversion": "react-native-version"
  }
}

package.jsonにこういう感じのタスクを足します。そして、例えばyarn build:ios:devを実行すると、dev-v1.5.xのようなリリースチャンネルが設定されます。本番ビルドだとprod-v1.5.xになります。

こうしておくことで、例えばv1.6.0を開発中のときにbuildやpublishをしたとしても、公開中のv1.5.0のリリースチャネルはprod-v1.5.xなので、v1.5.0にアップデートが降ってくる、というようなトラブルはなくなります。
expo buildをすると一緒にpublish (OTA)もされてしまう

また、これも参考記事のそのままですが、react-native-versionのおかげで、iOSのbuildNumberとAndroidのversionCodeを自動でインクリメントできるようになり、めちゃくちゃ便利でした。

申請

いよいよ申請!

ストアに掲載する画像を作成

これもFigmaで作成しました。

モックアップを作ったときは、自分が実装するときに色や数値などが分かればいい、というくらいの雑さで適当にデザインしましたが、ストアに掲載するとなると、ちゃんとしたスクリーンショットを作らなければなりません。
もちろん、そのスクリーンショットに載せる情報の著作権にも気を遣う必要があります。自分で撮った写真、自分で描いたイラスト、自分で書いた文章をそれっぽく配置してスクリーンショットを用意しました。
この作業が一番面倒だったかも…。

App Store ConnectとGoogle Play Consoleに申請

https://qiita.com/mildsummer/items/e98b1b8e4ea7f72b9899
https://qiita.com/mildsummer/items/f6a64db64b17fc240914

これらを参考に、App StoreとGoogle Playに申請を出します。頼むぞ…!

iOSでリジェクトを食らう

iOSの方が、以下のような理由でリジェクトされてしまいました。

Guideline 2.1 - Information Needed

We’re looking forward to reviewing your app, but we were unable to sign in with the demo account credentials you provided.

要は、ログインできないからレビューができないよ、ということ。
ログインができないような重大なバグはないはずだけど…と思いながら、送られてきたスクリーンショットを見ると、明らかに操作を間違えてログインに失敗している様子でした。

仕方がないので、PDFでめちゃくちゃ分かりやすい手順書を作り、それを送りました。

AppleのレビュワーはiPadでレビューするようだったので、わざわざiPadでスクショを撮ったりしました。これもストア掲載画像の作成と同じくらい面倒でした…。
しかしそのおかげか、再提出すると問題なく審査が通りました!

ちなみにAndroidの方は一発ですんなり審査が通りました。

公開

iOSもAndroidも無事に審査が通ったので、ストアに公開!
アプリを作ろうと思い立ってから、なんだかんだで半年くらいかかってしまいましたが、無事に公開まで辿り着きました。

※もう一度アプリへのリンクを貼っておきます。
https://apps.apple.com/jp/app/violet-tumblr-client/id1560068929
https://play.google.com/store/apps/details?id=jp.brdr.app.violet

最後に

React Native + Expoは、公式ドキュメントがめちゃくちゃ充実しているので開発しやすかったです。
何よりJavaScript (TypeScript)で書けるので、WebでSPAを作るのに近い感じでネイティブアプリが作れてよかったです。
あとは、React + TypeScript + VSCodeの補完がバリバリ効く開発体験が良すぎて最高でした。

とはいえ、iOS 14からのウィジェットを使ったアプリを作ってみたいという気持ちもあるので、次こそは、もう何度もやろうと思っては挫折しているSwift (SwiftUI)にチャレンジしようと思います。

Discussion

minnaminna

勉強になりました。ありがとうございました!

SoireeSoiree

購入して使用させていただきました。フリック操作が気持ち良く満足しています!

一点、Linking 部分で「特定環境でブラウザからアプリに戻ってきた時に認証情報が取得できないこと」とありましたが、具体的にどのような状況で起こったか、ザックリで良いのでお伺いしたいです。
ReactNative で deepLink を使う想定なのですが、WebBrowser の検討もあるのかと考えさせられていまして。

Ryo NakaeRyo Nakae

すみません、コメントに気づかず返信が遅くなってしまいました🙇‍♂️ 購入いただきありがとうございます!😭

特定環境でブラウザからアプリに戻ってきた時に認証情報が取得できないこと

ちょっとうろ覚えで申し訳ないのですが、手元のAndroid(HUAWEI P30 Pro)で、Tumblrの認証→アプリにリダイレクト→認証情報(tokenなど)が取得できずにログインに失敗する、という挙動だったかと思います…。

大まかに言うと、Linking.openURL(authUrl)をしたあとに、Linking.addEventListener('url', callback)で、コールバック関数(tokenが帰ってきていたらログイン済みとしてダッシュボードに遷移)を実行するような実装にしていたんですが、これがうまく動いていなかったようです。(少なくとも僕の実装スキルでは)

このときはまだWebBrowserの存在を知りませんでしたw
その後、WebBrowserを知って、ドキュメントを読むと、やりたかったことはWebBrowserで出来るな、となったので、WebBrowserを使っての実装に書き換えました。

参考にならずすみません!🙇‍♂️