yn2011's blog

技術メモ

Expo の Config Plugin とは何か

ざっくり要約

ExpoJavaScript (React Native) で iOS / Android / Web で動作するアプリケーションを開発できるフレームワーク。また、Expo で開発したアプリケーションをビルド・配布・アップデート可能な SaaS でもある。

フレームワークとしての Expo は、完全に JavaScript のみで開発が完結する Managed Workflow と、iOS / Android アプリの実装言語(Swift, Kotlin, etc)を使ってネイティブ領域の実装を変更可能にする Bare Workflow の 2つのモードがある。

一般的に、Firebase 等の外部サービスを利用するために SDK を組み込む際には、起動時処理の変更のためネイティブ領域のコードを変更する必要がある場合があるが Managed Workflow ではそれができなかった。

Config Plugin を使うことで Managed Workflow のまま JavaScript を使ってネイティブ領域のコードをメタプログラミング可能になるので Bare Workflow にする必要がなくなり、Web のフロントエンドエンジニアにとってはよりネイティブアプリの開発の敷居が下がる。

というのがざっくりとした Config Plugin に対する自分の理解だが、以下で更に掘り下げていきたい。

Conifig Plugin とは

公式ドキュメントから引用すると

Config plugins are a system for extending the Expo config and customizing the prebuild phase of managed builds.

https://docs.expo.dev/guides/config-plugins/

(訳)Config Plugin は Expo Config を拡張し、managed ビルドの prebuild フェーズをカスタマイズする仕組みです。

managed ビルドというのは Managed Workflow を利用したビルドのことだが、prebuild フェーズをカスタマイズするとはどういう意味なのか?

prebuild フェーズって何?

公式ドキュメントから引用すると

In Expo, prebuilding  is the process of generating the native runtime code for your React project. Prebuild can be used to automatically link and configure complex native modules that have implemented CocoaPods, autolinking, and config plugins.

https://github.com/expo/fyi/blob/main/prebuilding.md

(訳)prebuild とは、ネイティブランタイムコードを生成するプロセスのことです。PreBuild は Native Modules を自動的に結合、設定するために利用されます。

prebuild フェーズにおいて、Managed Workflow の場合は、/ios/android ディレクトリを生成し、Config Plugin として実装されたコードを元にディレクトリ内のネイティブランタイムコードを書き換える。

これは、仮に Config Plugin がなかったとしても、Managed Workflow のプロジェクトをアプリとしてビルドする場合には必ず通過するフェーズ。

最終的には Managed Workflow で開発していても普遍的なネイティブアプリとしてのコードベースに変換する必要がある(当然といえば当然)

Config Plugin は SDK 毎に開発

多くの場合、Config Plugin は外部の SDK(Native Modules) 毎に作成する。その SDK に必要なカスタマイズを SDK 毎に作成した Config Plugin に記述する。

なので、起動時処理などは SDK 間で同じファイルの同じ箇所を変更する競合が発生する可能性があるので注意をする必要がある。

Config Plugin が必要かどうかの判定方法

外部の SDK を利用したい場合に、Config Plugin が必要なのかどうかはどうやって判断すればいいのか?

ちなみに純粋な iOS / Android アプリ向けの SDK しか存在しない場合は React Native 向けの SDK を開発する必要があるので、Config Plugin 以前に最初はそこから始めるということになる。

既に公開されている Config Plugin がないかを探す

有名なサービスであれば既に公開されている場合もあるので、もし公開されているなら必要だと分かるし単純にそれを使えば OK

github.com

もし上記のリポジトリにもなく、サービスから公式に提供されているものがないのであれば、自分で必要性の有無を判断する必要がある。

SDK のドキュメントがある場合

ドキュメントを読み、ネイティブコードを書き換える必要があるかどうかを確認する。

例えば Repro なら SDK 導入手順がドキュメント化されていて、AppDelegate.m の書き換えが必要なことがわかる。

https://docs.repro.io/ja/dev/sdk/getstarted/react-native.html#id5

これは、Managed Workflow の場合は直接編集不可能なので Config Plugin を開発し、以下のような書き換え処理を TypeScript (JavaScript)で実装する。

const addImport = (contents: string): string => {
  if (contents.includes("@import <Repro/Repro.h>;")) {
    return contents;
  } else {
    return contents.replace(
      /#import "AppDelegate.h"/g,
      `#import "AppDelegate.h"
#import <Repro/Repro.h>
`,
    );
  }
};

SDK のドキュメントがない場合

とりあえず Config Plugin なしでビルドが通るか、きちんと動作するかを試してみるしかない。

Config Plugin を開発したい

自分が使いたい SDK に対して、どうやら公開されている Config Plugin もなさそうだしドキュメントを読むとネイティブコードを書き換えないといけないらしいという場合は自分で開発するしかない。ではどうやって開発するのか?

これは既存の実装を真似るしかない。。

自分は上記の Config Plugin を集めたリポジトリを参考にして何となく開発できたので、あまり尻込みせずに挑戦してみても良いと思う。

git のブランチ命名とコミットメッセージのルール

所属企業で代々引き継がれている(と思う) git のブランチ命名とコミットメッセージのルールを汎用化してみた。

ブランチ

形式:<type>-<scope>/<task-number>-<name>

具体例

scope を使用する場合

  • feat-ui/ABC-123-add-nice-feature

scope を使用しない場合

  • fix/ABC-123-fix-bad-bug
  • refactor/ABC-123-remove-chaos

type

type 説明
feat 新しい機能追加
fix バグの修正等
refactor リファクタリング

scope

モノレポやチームによっては何か定義してもいいかも。不要な場合もある。

task-number

JIRA だと ABC-123 とか。チケットの運用方法(チケットがどこからも参照されない、本文の情報量が少ない等)によっては不要な場合もある。

name

何をするブランチなのかが分かる文言を書く。add-nice-feature とか。

コミットメッセージ

形式:<type>(<scope>): <task-number> message

具体例

scope を使用する場合

  • feat(ui): ABC-123 X の機能を実装

scope を使用しない場合

  • docs: ABC-123 README の記述が古くなっていたので変更

type

type 説明
feat 新しい機能追加
fix バグの修正等
refactor リファクタリング
docs ドキュメント
package npm パッケージ関連
test テストコード関連

バグ修正と同時にリファクタにもなってるとか、機能実装と同時にテストコードを書いている場合はどうするんだとか色々例外ケースはありそうだが、まあ雑にどれかを選んでおけば十分じゃないかなと思う。

scope, task-numer

ブランチと同様

message

何をしたコミットなのかが分かる文言を書く。多分(日本語話者のチームでは)日本語で書く人が多い。

そんなにコミットメッセージって大事?

自分は大事な場合”も”あると考えている。自分の経験でいうと実際に、リリースから 5~10 年ぐらい経過しているプロダクトの運用保守をしたときには blame で行単位のコミットメッセージを読むと調査の役に立つことが多かった。

if 文の分岐 1行だけでも、長期間運用されていると様々な理由で変更される。最初のリリースからの不具合の修正、仕様変更、別の機能追加に伴う改修など...

なぜそのコミットが行われているのか、関連するチケットや PR は何なのか、改修した際にどういう検証をしたのか等が分かるのは本当に助かった。

どちらかというとコミットメッセージ自体というかチケットや PR に情報が集約されている前提で、そこにコミットメッセージからアクセスできると、(長期間の運用保守が想定されるプロダクトなら)嬉しいね、ということかもしれない。

なので、 1 年続くか不明なプロダクトならコミットメッセージやチケット上の情報整理にあまり時間を費やさない方が賢い選択かもとは思う。

React Native (Expo) で開発した iOS / Android アプリの起動時間を計測する

前提

  • Expo SDK 45
  • react-native-startup-time v2.0.0

結論

react-native-startup-time を利用した。

import { getTimeSinceStartup } from 'react-native-startup-time';

// 省略...

export const Screen: React.FC = () => {
  const [launchTime, setLaunchTime] = React.useState(0);

  useEffect(() => {
    getTimeSinceStartup().then((time) => {
      setLaunchTime(time);
    });

  }, []);

  return (
        <Body>アプリ起動からコンポーネント表示までの時間:{launchTime / 1000}</Body>
  );
};

起動時間の定義(計算式)

react-native-startup-time の iOS / Android それぞれの実装を確認すると、以下の計算で起動時間を算出していることが分かった。

getTimeSinceStartup実行時の時刻 - アプリ起動直後の初期化における時刻

実装に興味のある方はこちらを参照

なぜ react-native-startup-time を使ったか

React Native で開発したアプリの起動時間を計測する方法は大きく分けると、①ライブラリを利用する方法と、②XCode / Android Studio を利用する方法がある。

今回は Expo の Managed Workflow を使用しており、②の方法で計測するには毎回 eject しなくてはいけなかったので①のライブラリを利用する方法を選択した。Expo の development Client を使用して開発している場合は、ライブラリで起動時間を計測できるようにしておけば JavaScript の変更の度に再ビルドする必要がないのも嬉しい。

逆にライブラリを使うデメリットとしてはプロファイルが取得できない点が挙げられる。これは②のツールを使用する必要があるので注意が必要。

ライブラリを使う場合、react-native-startup-time の他に react-native-perf-logger でも起動時間が計測できそうだったが、ライブラリを利用すると EAS Build で Android のビルドに失敗してしまったので選択できなかった。react-native-startup-time でやりたいことはできているので、まあこっちでいいかーという感じで最終的に react-native-startup-time を使うことにした。

エンジニアが業務用 mac の ディスク空き容量を 100GB 増やすためにやったこと

気づいたら業務用 mac のディスク容量の空きが 10GB ぐらいしかなくて困ったので色々やって 100GB 空けたという話。「mac ディスク容量」みたいにググってもゴミ箱を空にするとか全然参考にならないことしか出てこなかったのでこの記事を書いている。

業務用 mac でやってること

業務では Web のフロントエンドと、React Native を利用したアプリ(iOS / Android 両方)開発をやっている。

特に以下を使って開発している人には参考になるかもしれない。

現状を把握する

mac OS 標準のディスク管理ツール的なものでも大まかには把握できるが、キャッシュ系はすべて「その他」と「書類」に分類されるのであんまり約に立たない。 アプリケーションやダウンロード、ゴミ箱などを整理したのに全然容量が空かず、「その他」が100GB使用しているのは何??となりがち。更に噂によると「書類」と「その他」が重複カウントされている場合もあるらしく、これではまったく意味が分からない。

というわけでフリーのディスク分析ツールを使う。自分は、OmniDiskSweeper というやつを使ってみた。

www.omnigroup.com

正直この手のツールはマルウェアっぽいのも多い気がしていて、あんまりインストールしたくはなかった。root から地味に du して探索する手もあり、実際最初はやってみてたが、途中でしんどくて止めた。普通に visualize 系のツールを使ったほうが楽だと思う。

以下、OmniDiskSweeper でサイズが大きなディレクトリを見つけて手当たり次第に対応していった作業ログ。

Docker

Docker for Mac が Docker.raw という 30GB ぐらいの謎ファイルを持っていた。

以下のサイトで紹介されている通り、Docker for Mac の設定でディスク使用量を最小に変更する。

apple.stackexchange.com

それから $ docker system prune -a で不要なイメージ等を全部消して 1/3 以下ぐらいに減らせた。

XCode

無駄に仮想端末を作っていたので削除した。

$ xcrun simctl list devices で一覧から削除したい端末の UDID を特定して $ xcrun simctl delete 26B***

それとなんかよく分からないキャッシュがあったのでそれも全部 rm で消した。

~/Library/Developer/CoreSimulator/Caches/dyld/20G95
❯ ls
com.apple.CoreSimulator.SimRuntime.iOS-15-0.19A339
com.apple.CoreSimulator.SimRuntime.tvOS-15-0.19J344
com.apple.CoreSimulator.SimRuntime.watchOS-8-0.19R345

Android Studio

こちらも使ってない仮想端末と SDK を削除した。 仮想端末は普通に Android Studio から削除して、SDK はよく分からなかったので rm した。 ~/Library/Android/sdk/system-images 配下にあるはず。

yarn

.yarn-cache が巨大だったので yarn cache clean で全部消した。 yarn はそれなりに使うので別に消さなくても良かった可能性はある。

参考:https://stackoverflow.com/questions/39991508/how-to-clear-cache-in-yarn

npm

yarn と同様に $ npm cache clean --force

$ npm cache verify はキャッシュを最適化してくれるらしいが、あんまり変わらなかったので全消しした。

参考:https://stackoverflow.com/questions/55157862/npm-cache-clean-v-s-npm-cache-verify

Cypress

Cypress もなぜか巨大なキャッシュを Cypress のバージョンごとに持っていた。 $ cypress cache prune で削除できるらしいが、自分はディレクトリ毎 rm した気がする。

参考:https://docs.cypress.io/guides/guides/command-line#cypress-cache-command

gradle

.gradle/caches を rm

クローンしたリポジトリ

何気に1リポジトリで 1GB ぐらい消費しているものがあったりした。昔のプロジェクトで時々参照はするから削除はしたくないけどローカルで起動することは少ないなーみたいなやつは node_moduels だけでも削除しておくとディスク容量を大きく削減できる。

まとめ

大体以上の作業で 100GB 程度のディスク空き容量を確保できた。思ったよりキャッシュや不要な仮想端末等が多かった。そもそも業務用 mac のストレージ上限が 250GB なのは心許ないが、その制約がディスクの使い方を見直すきっかけになり、この記事を書くこともできたので良かったと思う(ポジティブ)

img 要素に width と height を指定してもレイアウトシフトする原因は属性値が auto だからかも

環境

tldr;

img 要素に width と height を指定するとレイアウトシフトしないと聞いて試してみたが、レイアウトシフトが改善しないケースがあった。img 要素の width と height に auto を指定していて画像を読み込むまで幅を決定できない場合にレイアウトシフトが発生する。

width と height でレイアウトシフトを防ぐ

以下の HTML をブラウザで描画するとレイアウトシフトが発生する

<img src="https://via.placeholder.com/1200x600" />
<p>text</p>

画像が後から読み込まれ、text が下に移動する。

以下の場合はレイアウトシフトしない。ブラウザがアスペクト比を理解するからである。この例では実際の画像の幅と属性値が一致するが、一致していない場合でも width と height の属性値で描画を決定できるのでレイアウトシフトは回避できる。

<img src="https://via.placeholder.com/1200x600" width="1200" height="600" />
<p>text</p>

詳細な説明は以下を参照

width:auto と height:auto を組み合わせるとレイアウトシフトする

属性値でアスペクト比を伝えている場合でも、以下のスタイルを当てるとレイアウトシフトが発生する。

.image {
  width: auto;
  height: auto;
}
<img src="https://via.placeholder.com/1200x600" width="1200" height="600" class="image" />
<p>text</p>

これは、width と height 属性を HTML で指定していたとしても、CSS で値を auto にすると画像が読み込まれるまで幅と高さを解決できないからだと考えられる。

例えば、以下のように width と height を 200 に指定したとしても、auto のスタイルを当てている場合には無視されて実際の画像の大きさ(1200*600) で描画される。

 <img src="https://via.placeholder.com/1200x600" width="200" height="200" class="image" />

なのでブラウザは画像が読み込まれた後にレイアウトシフトを発生させるしかない。

片方だけ auto の場合はレイアウトシフトしない

width と height で片方が auto の場合は、属性値とアスペクト比から片方を事前に計算可能なのでレイアウトシフトを回避できる。ただし、画像の実際のアスペクト比と、width と height から計算するアスペクト比が一致していることは前提である。

これはOK

.image {
  width: auto;
}

これもOK

.image {
  height: auto;
}

これも大丈夫

.image {
  width: 100%;
  height: auto;
}

img 要素に指定した width と height から計算されるアスペクト比と、実際の画像のアスペクト比が一致しない場合

img 要素に指定した width と height から計算されるアスペクト比と、実際の画像のアスペクト比が一致しない場合でも片方が auto であれば画像が持つ正しいアスペクト比で幅を算出してくれる。

例えば、これはアスペクト比が 1 / 1 だと認識されそうだが

 <img src="https://via.placeholder.com/1200x600" width="200" height="200" class="image" />

height を auto にしている場合は、画像を読み込んだ後に本来のアスペクト比を利用して height = width / 2 で 100 としてブラウザは表示してくれる。

しかし、本来の画像のアスペクト比は画像をブラウザが読み込むまでは解決できないはずであるため、読み込み後にレイアウトシフトは発生する。この場合のレイアウトシフトは先に height=200 で確保した縦幅が 最終的に 100 になるので下から上へと要素が移動することになる。

逆に、上記の例で width を auto にした場合は(少なくとも上下の)レイアウトシフトは発生しない。画像の本来のアスペクト比が何であれ、高さが 200 であるということは変わらないからだ。左右に要素が存在する場合には横幅が画像を読み込んだ後に決定されるのでレイアウトシフトするはずだ。

疑問

CSS の width と height は初期値が auto なのに、CSS で明示する場合としない場合で挙動が変わるのはなぜ?

参考

(Android) Expo Bare workflow に Firebase Crashlytics を導入する

Firebase Crashlytics を使うとアプリがクラッシュした際にエラーレポートを送信することができる。

iOS に導入する手順は(iOS) Expo Bare workflow に Firebase Crashlytics を導入する に以前書いた。今回は Android アプリに Firebase Crashlytics を導入する手順について書く。

環境

  • Expo SDK 44
  • custom development client 利用
  • Firebase コンソールからアプリを登録済み
  • Android Studio (Emulator) でアプリを起動

パッケージインストール

公式ドキュメントの手順通りにパッケージをインストールする。Firebase のサービス自体を初めて利用する場合は、@react-native-firebase/app のインストールが必要。

// 未インストールの場合
yarn add @react-native-firebase/app

yarn add @react-native-firebase/crashlytics

Android 向けのセットアップ

iOS では不要だったが、Android のビルド向けに設定が必要。手順は、Crashlytics - Android Setup の 1~4 を参照。

firebase.json を作成する

省略(iOS 導入の記事参照)

テストクラッシュを起こす実装をする

省略(iOS 導入の記事参照)

アプリをビルドし起動する

EAS Build 等を利用してアプリをビルドし Android Emulator で起動する。

正常に Crashlytics がレポート送信を行っていることを確認するため、Android Studio でログを表示する。ログの表示方法は、logcat を使用してログを書き込み、表示するを参照。

クラッシュを発生させる

自分の環境だと、crashlytics().crash() ではレポートを送信することができなかった。クラッシュを実行すると以下の画面が表示される。

f:id:pokuwagata:20220220150633p:plain

ログは以下になる。

2022-02-08 18:31:04.712 14816-15028/appId E/DevLauncher: DevLauncher tries to handle uncaught exception.
    java.lang.RuntimeException: Crash Test
        at io.invertase.firebase.crashlytics.ReactNativeFirebaseCrashlyticsModule$1.run(ReactNativeFirebaseCrashlyticsModule.java:83)
        at android.os.Handler.handleCallback(Handler.java:938)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loop(Looper.java:223)
        at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:226)
        at java.lang.Thread.run(Thread.java:923)

どうやら先にエラーをキャッチされてしまっているようである。

一方で、JS エラー(throw new Error()) を起こした場合は以下のログが表示されアプリはクラッシュした。

2022-02-08 18:52:44.727 16610-16947/appId D/RNFBCrashlyticsInit: isCrashlyticsCollectionEnabled final value: true
2022-02-08 18:52:44.738 16610-16947/appId E/Crashlytics: Crash logged. Terminating app.

アプリを再起動して少し待つと、Firebase コンソールからは以下のようなレポートを閲覧できる。(なお、JS エラーをレポート送信するかどうかは firebase.json で設定可能)

f:id:pokuwagata:20220220151138p:plain

まとめ

Expo Bare workflow を利用した Android アプリに Firebase Crashlytics を導入する手順を書いた。

development client を利用していると、ネイティブコードのエラーを正常にレポート送信することはできなかった。stand alone にビルドしていればクラッシュすると思うので正常にレポート送信は行われるのではないかと予想している。この点については今後検証したい。

(iOS) Expo Bare workflow に Firebase Crashlytics を導入する

Firebase Crashlytics を使うとアプリがクラッシュした際にエラーレポートを送信することができる。Expo Bare workflow のプロジェクトに Firebase Crashlytics を導入する手順について書く。

環境

  • Expo SDK 44
  • Firebase コンソールからアプリを登録済み
  • iOS シミュレータビルドで検証

パッケージインストール

公式ドキュメントの手順通りにパッケージをインストールする。Firebase のサービス自体を初めて利用する場合は、@react-native-firebase/app のインストールが必要。

// 未インストールの場合
yarn add @react-native-firebase/app

yarn add @react-native-firebase/crashlytics

cd ios/ && pod install

最終的に EAS build するとしても Podfile.lock の更新のため pod install は必要だと思う(多分)

firebase.json を作成する

プロジェクトディレクトリの直下に、firebase.json を作成する。今回は疎通確認のため以下の設定値を有効にする。

ネイティブコードではなく、React Native で実行した JavaScript のエラーもレポート送信の対象にしている。

{
  "react-native": {
    "crashlytics_debug_enabled": true,
    "crashlytics_auto_collection_enabled": true,
    "crashlytics_is_error_generation_on_js_crash_enabled": true,
    "crashlytics_javascript_exception_handler_chaining_enabled": true
  }
}

設定値のリファレンスは Firebase JSON Config を参照。

テストクラッシュを起こす実装をする

crashlytics().crash() を実行するとネイティブ領域でエラーを発生させアプリをクラッシュさせることができる。

<Button title="Test Crash" onPress={() => crashlytics().crash()} />

以下のような JS Error でも良い。

<Button title="Test Crash" onPress={() => throw new Error("test")} />

アプリをビルドしシミュレータで起動する

アプリをビルドし iOS シミュレータにインストールする。

デバッグ用に iOS シミュレータのログを確認できるようにしておく。

$ xcrun simctl spawn booted log stream --level debug --style compact | grep -i crash

crash で grep し、Crashlytics 関連のログのみが表示されるようにする。

以下のようにfirebase.json の設定値がログに出力されていればOK

2022-02-03 17:08:16.938 Df appName[63895:42a438] +[RNFBSharedUtils getConfigBooleanValue:key:defaultValue:] [Line 160] RNFBCrashlyticsInit crashlytics_auto_collection_enabled final value: 1

テストクラッシュ用に実装したボタンを押下するとアプリがクラッシュする。

再度アプリを起動すると以下のようなログが出力される。

2022-02-03 17:14:22.300 Df appName[64062:42b7c7] +[RNFBCrashlyticsInitProvider configureWithApp:] [Line 101] RNFBCrashlyticsInit initialization successful
2022-02-03 17:14:23.192 Df appName[64062:42b7d5] [com.apple.network:connection] [C6 4F05B069-6BF2-442B-9434-CF5F0223CBF1 crashlyticsreports-pa.googleapis.com:443 tcp, url hash: 668ea868, tls, definite, attribution: developer, context: com.apple.CFNetwork.NSURLSession.{4F3F6DE6-21E5-4E1C-8B27-6043E5450330}{(null)}{Y}{2} (private), proc: EA95ADB9-AD35-39EC-BB62-880027AC07B2] start
2022-02-03 17:14:23.192 I  appName[64062:42b7d5] [com.apple.network:connection] nw_endpoint_handler_start [C6 crashlyticsreports-pa.googleapis.com:443 initial path ((null))]

Crashlytics の初期化成功後に、ネットワーク通信が始まりレポート送信が行われる。アプリがクラッシュしたタイミングではなく、クラッシュ後にアプリを起動しないとレポートは送信されないことに注意。

Firebase コンソールでレポートを確認する

f:id:pokuwagata:20220212120955p:plain

送信したレポートを閲覧できていれば Firebase Crashlytics の導入に成功している。

crashlytics().setUserId(id) によってユーザの識別子を付与してレポートを送信することも可能。

まとめ

Expo Bare workflow を利用した iOS アプリに Firebase Crashlytics を導入する手順を書いた。

Bare workflow の Android の場合はパッケージインストール以外にも追加で build.gradle を編集する必要があるが、iOS の場合は比較的ラクに導入できると思う。

手元では Android でも既に導入に成功しているので別の記事で手順やハマりポイントを書きたい。