LoginSignup
21
5

More than 1 year has passed since last update.

Flutterでステージング環境やテスト環境へのデプロイを自動化 & 本番環境と共存させる【GitHub Actions x Firebase App Distribution】

Last updated at Posted at 2021-12-09

はじめに

こんにちは。今日が誕生日のishihayaと申します。自分、誕生日おめでとうございます。

今回はFlutterでテスト環境へのデプロイ、およびテスターへアプリを配布するフローを自動化したいと思います。
コードだけ見たいという人は右の目次っぽいとこから、サンプルコードまで飛んでください。

自分はネイティブアプリの経験は浅く1、2ヶ月というところでまだ試行錯誤中ですが、
基本的にデプロイフローは(テスト環境や本番に限らず)自動化もしくはフロー化しておいた方が良いと思っています。

今回実装した理由としては、
例えば自分だけがデプロイフローを知っていて手元でデプロイしていた場合、急にいなくなったらできる人がおらず苦労することが見えているからです。
ワークフローとしておくことで、pushなどのトリガーや自分で実行したい時にボタン1つでリリースすることができると、基本的に引き継ぎすることなくデプロイをし続けられます。
他にもリモートからpushで自動でデプロイすることで同じフローを踏むのでヒューマンエラーがなくなる等あります。

それらを踏まえて、以下のサービスを使用して今回はテスターへの配布またはステージング環境などへのデプロイを自動化したいと思います。

GitHub Actions

CIサービスです。
GitHub Actionsを採用した理由としては単純に自分が慣れているという理由と、GitHubでCIも管理して使うサービス自体を減らす(新しいサービスを使用して管理しない)意図があります。

(注意点)
GitHub Actionsは個人だと2000分/月無料(Orgだと3000分?)なので自分の場合は使い切ったことはありません。
ただし、macOSをランタイムに指定すると10倍の時間消費されるので、この記事では自動化と謳っているものの、
実際に自分が案件等でmacOSを使用するワークフローのときは必要な時だけボタンを押してリリースする形、すなわちworkflow_dispatchのみ指定する形を取っています。
今はボタンを押すだけの形でも困ってはいないのでこれでも問題ないのですが、実際自動化するなら料金によっては別サービス(FlutterならBitriseやCodeMagic等)の方がいい場合もあります。
とはいえその2つも時間数制限あるので使いすぎると普通に課金になります。
また、pushが少ないブランチで定期的に使用するとかだと普通に自動化になるので、個人的にはGitHub Actionsでも良いかなとは思いますが、その辺りは運用方法と技術選定をチームで話し合うのが良さそうです(良いソリューションがあれば教えてください)

Firebase App Distribution

テスト環境へのアプリのリリースを容易にするFirebaseのサービスです。
バックエンドでGCPのプロジェクトの使用、また認証でFirebaseを使用したりするので今回はFirebase App Distributionを使用するとスムーズにリリースできるかなと思い採用しました。

今回の実装は本番環境ではなくテスト環境へのデプロイのみを行いますが、ワークフローの環境変数と、リリース先をFirebase App Distribution経由ではなくアプリのストアの方に直接するように変えれば適宜本番環境へも対応可能かと思われます(まだやってはいないです。。)
すなわち、最後のデプロイ先以外は本番でも真似できると思われますので、ぜひみて行ってください。

次に、iOSのサンプルコードを示します。解説はその後に載せてますので、読むかどうかはコードを先に見てから判断してください。

iOSのサンプルコード

deploy-dev-ios.yml
name: Deploy dev ios

on:
  # mainブランチへのマージでデプロイしたい場合 publicリポジトリだと無料なのでこれでいいと思う
  push:
    branches:
     - main
  # macOSはGitHubActionsの時間数を食うのでプライベートリポジトリの場合は自分はしたい時だけ手動でdev環境にデプロイしてます(できればmainブランチpushでトリガーしたい)
  workflow_dispatch:
jobs:
  build_ios:
    runs-on: macos-latest
    env:
      BUILD_CERTIFICATE_BASE64: ${{ secrets.IOS_P12_BASE64 }}
      P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
      BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
      KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
      APPLICATION_NAME: exampleApp # bundleIDの最後の要素のアプリ名
      TEAM_NAME: exampleTeam # プロジェクトのチームや会社名
    steps:
      - uses: actions/checkout@v2
      - name: cache flutter dependencies
        uses: actions/cache@v2
        with:
          path: /Users/runner/hostedtoolcache/flutter
          key: ${{ runner.os }}-flutter
      - name: install flutter
        uses: subosito/flutter-action@v1
        with:
          flutter-version: '2.5.0'
      - name: set up environment
        run: |
          cat ios/envs/dev/Release.xcconfig >> ios/Flutter/Release.xcconfig
          cp -f ios/envs/dev/ExportOptions.plist ios/
          cp -f ios/envs/dev/GoogleService-Info.plist ios/
      - name: set up provisioning profile
        # https://docs.github.com/ja/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/$TEAM_NAME.p12
          PP_PATH=$RUNNER_TEMP/$APPLICATION_NAME.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/$TEAM_NAME.keychain-db
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode --output $PP_PATH
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
      - name: install dependencies
        run: |
          flutter pub get
          find . -name "Podfile" -execdir pod install \; 
      - name: build ios app
        run: |
          flutter build ipa \
          --release \
          --export-options-plist=ios/ExportOptions.plist \
      - name: upload artifact
        uses: actions/upload-artifact@v2
        with:
          name: ios-build
          path: build/ios/ipa/example_app.ipa
      - name: clean up keychain and provisioning profile
        if: ${{ always() }}
        run: |
          security delete-keychain $RUNNER_TEMP/$TEAM_NAME.keychain-db
          rm ~/Library/MobileDevice/Provisioning\ Profiles/$APPLICATION_NAME.mobileprovision
  deploy_ios:
    runs-on: macos-latest
    needs: build_ios
    env:
      GCP_PROJECT_ID: example-app
      GCP_SA_KEY: ${{ secrets.GCP_SA_KEY_BASE64 }}
      APP_ID: example-app-id # Firebaseのやつ
      RELEASE_BINARY_PATH: ios/example_app.ipa
      RELEASE_NOTES_PATH: ios/release-notes.txt
      TESTER_GROUPS: example-app-group
    steps:
      - uses: actions/checkout@v2
      - name: download artifact
        uses: actions/download-artifact@master
        with:
          name: ios-build
          path: ios
      - name: set up cloud sdk
        uses: google-github-actions/setup-gcloud@master
        with:
          project_id: ${{ env.GCP_PROJECT_ID }}
          service_account_key: ${{ env.GCP_SA_KEY }}
          export_default_credentials: true
      - name: install firebase
        run: curl -sL https://firebase.tools | bash
      - name: deploy
        run: firebase appdistribution:distribute $RELEASE_BINARY_PATH --app $APP_ID --release-notes-file "$RELEASE_NOTES_PATH" --groups "$TESTER_GROUPS"

iOSの実装手順

それでは、上記のコードの解説に入ります。

以下、テスト環境をデプロイするまでのフローを記述します
また、テスト環境のことをdev環境と呼びますが、stg(ステージング)でもtestでも自分のチームで使用する環境名でOKです。なお、本番環境のテストリリースなどでもFirebase App Distributionが使えたはずなので、その場合はprdに置き換えて読んでもらって大丈夫です。

早速ワークフローを書いていきたいところなんですが、
ワークフローを書く前にいくつか準備をする必要があります
自分はデプロイから逆算してエラーを出していったのでここに書いていくフローは基本的に省略できない最短距離だと思っています(他に良い書き方があれば教えてください)

なお、今回は環境が複数ある前提で作業していますが、個人開発だから本番しか使わない、など単一環境の場合は環境を分けるSTEPに関しては適宜読み飛ばしてもらっても問題ありません。

STEP1 App Store Connectでアプリを作成する

dev環境のアプリを作成します
アプリのbundleIDがわかっていてコンソール上に表示されていればOKです

(詳細省略)

STEP2 Firebaseでアプリ作成

FirebaseのAppIDがわかっていてコンソール上に表示されていればOKです
アプリ登録時にGoogleService-Info.plistが入手できると思いますが次のステップで使用するのでダウンロードだけしておいてください
Firebase App Distribution有効化は、確か最初に1度だけやっておく必要があったはずです
また、Firebaseをimportするためにswiftに数行コードを書く必要があったはずで、そちらもFirebaseのステップの中で出てくるはずなので、それも対応必要です。

(詳細省略)

STEP3 GoogleService-Info.plistをアプリに入れる

このSTEP3からSTEP5までのフローで実現したいことを先に書きます。

deploy-dev-ios.yml
...
steps:
  # 省略
  - name: set up environment
    run: |
      cat ios/envs/dev/Release.xcconfig >> ios/Flutter/Release.xcconfig
      cp -f ios/envs/dev/ExportOptions.plist ios/
      cp -f ios/envs/dev/GoogleService-Info.plist ios/

ここでやりたいことは、環境ごとに設定ファイルを分けてワークフローで一気に読み込むことをしたいです。
上記を踏まえ、今から、ios/envs/環境/配下(今回だとios/envs/dev/)にそれぞれの環境ごとの設定ファイルを用意していきます。

それでは、GoogleService-Info.plistをアプリに入れます。

まず、ios/envs/dev/GoogleService-Info.plistに置きます。

次に、ios/GoogleService-Info.plistにも同じファイルを置きます。
理由はbuildがこけるからです。どの環境のファイルでも良いです。
同じ名前のファイルがあればcp -f ...でワークフローの際に上書きされます。

注意点)
XCodeでios直下でGoogleService-Info.plistを認識させないとビルドエラーになります。
自分はネイティブアプリ開発経験が浅いのでこの辺りの知識に乏しいのですが、いい感じでコードでできないかな・・・と思ってます(project.pbxprojをいじればなんとかなる?)

STEP4 Build時の設定ファイルExportOptions.plistを用意する

build時に設定ファイルとして読み込むためのファイルです。
XCodeでアーカイブしてダウンロードすると中にExportOptions.plistが入っています。
なお、アーカイブしてダウンロードしなくても、以下コピってきて各種設定すれば使いまわせますので、(個人的には)そちらをお勧めします。
なお、Provisioning Profileやアプリ名やチームIDは仮のものを記述しています。
次以降のSTEPでは、以下のBundleIDやファイル名で進めていきます。

配置先: ios/envs/dev/ExportOptions.plist

ExportOptions.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>compileBitcode</key>
	<true/>
	<key>destination</key>
	<string>export</string>
	<key>method</key>
	<string>ad-hoc</string> // 配布形式 本番ならapp-storeとかになる
	<key>provisioningProfiles</key>
	<dict>
		<key>com.exampleTeam.exampleAppDev</key> // Apple Bundle ID
		<string>exampleAppDev</string> // Provisioning Profile ファイル名
	</dict>
	<key>signingCertificate</key>
	<string>Apple Distribution</string>
	<key>signingStyle</key>
	<string>manual</string>
	<key>stripSwiftSymbols</key>
	<true/>
	<key>teamID</key>
	<string>ADZK6RM6CL</string> // チームID
	<key>thinning</key>
	<string>&lt;none&gt;</string>
</dict>
</plist>

STEP5 Build時に使用する設定ファイルRelease.xcconfigを用意する

こちらは環境変数などを定義していくファイルになります
適宜この辺りは変えてもらってOKです

ios/envs/dev/Release.xcconfig
DISPLAY_APPLICATION_NAME=exampleAppDev # 表示するアプリ名
BUNDLE_ID_SUFFIX=exampleAppDev # BundleIDの最後のやつ
PROVISIONING_PROFILE_NAME=exampleAppDev # Provisioning Profile ファイル名

上記ファイルをワークフローの時に
ios/Flutter/Release.xcconfig
に追記されるようにします

また、デバッグ時用に
ios/Flutter/Debug.xcconfig
に以下を追記してください。テスト環境のもので良いです。
(PROVISIONING_PROFILE_NAMEは自分はdebugの時は設定していません)

Debug.xcconfig
DISPLAY_APPLICATION_NAME=exampleAppDev
BUNDLE_ID_SUFFIX=exampleAppDev

STEP6 STEP5で設定した環境変数を適用できるようにする

STEP5でOSごとのディレクトリで使用する環境変数を定義したので、ソースコード上でいくつか適用したい箇所があります。

ios/Runner.xcodeproj/project.pbxproj
のなかで
PRODUCT_BUNDLE_IDENTIFIER
とコード検索をかけるとBundle IDを登録しているっぽい記述が3つほど(debug, profile, releaseの設定をしているらしきコード)見つかるので、BundleIDを環境ごとで更新してください。

PRODUCT_BUNDLE_IDENTIFIER = com.exampleTeam.$(BUNDLE_ID_SUFFIX);

exampleTeamはチームまたは会社名が入ると思います。
環境変数を使うと、上記のようになると思います。
また、Provisioning Profileもここで読み込んでおきます。
アルファベット順ぽいので2行下くらいに、

PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_NAME)";

としてください

大体こんな感じなります


XXXXXXXXXXXXXXX / * Profile * / = {
...
	PRODUCT_BUNDLE_IDENTIFIER = "com.exampleTeam.$(BUNDLE_ID_SUFFIX)";
	PRODUCT_NAME = xxxxxxxxxxx;
	PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_NAME)";
...
}

XXXXXXXXXXXXXXX / * Debug * / = {
...
	PRODUCT_BUNDLE_IDENTIFIER = "com.exampleTeam.$(BUNDLE_ID_SUFFIX)";
	PRODUCT_NAME = xxxxxxxxxxx;
	PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_NAME)";
...
}

XXXXXXXXXXXXXXX / * Release * / = {
...
	PRODUCT_BUNDLE_IDENTIFIER = "com.exampleTeam.$(BUNDLE_ID_SUFFIX)";
	PRODUCT_NAME = xxxxxxxxxxx;
	PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_NAME)";
...
}

ios/Runner/Info.plist

Info.plist
<dict>
<!-- 省略 -->
	<key>CFBundleName</key>
	<string>$(DISPLAY_APPLICATION_NAME)</string>
	<key>CFBundleDisplayName</key>
	<string>$(DISPLAY_APPLICATION_NAME)</string>
<!-- 省略 -->
</dict>

上記のKeyがあれば更新、なければ新規で定義しましょう。

STEP7 証明書設定

いくつかファイルの準備が必要です。

  • p12証明書
  • provisioning profile

上記の用意をしましょう。

証明書

Provisioning Profile

もろに上記の2つのサイトを参考にしました。

ファイルはBASE64エンコードしてGitHub Secretsに入れましょう。
また、p12作成時にパスワードの作成を求められますのでそちらもメモしつつ、同様にGitHub Secretsに入れましょう。

なお、上記の証明書等をワークフロー上で適用する方法ですが、

最終的に以下のサイトのコピペで適宜変更で落ち着きました。
ソースコード自体はサンプルコードでも参照できますが、ほぼそのまま使ってます。使えてます。
actionsの外部ライブラリを使用することも検討しましたが、サードパーティ製のライブラリにp12やprovisioning profileなどの情報を渡したくなかったり、個人開発で作られたライブラリだとメンテナンスがされなかったりするので、自分は公式のやつとかスター数がかなり多いもの以外のライブラリの使用は見送っています。

なお、GitHub Secretsに設定するKEYCHAIN_PASSWORDに関しては、任意に設定してOKです。

STEP8 アプリアイコンの設定

自分はdev環境の場合アイコンを別に分けたかったので、

を使用しました。

環境ごとにymlファイルを作成して、ワークフローで呼び分けます。

以下はflutter pub getの後に挟む処理です

      - name: import app icon
        run: flutter pub run flutter_launcher_icons:main -f flutter_icons-dev.yml

flutter_icons-環境.ymlをプロジェクトルートに環境数分配置します。

flutter_icons-dev.yml
# https://pub.dev/packages/flutter_launcher_icons
# flutter pub run flutter_launcher_icons:main -f flutter_icons-dev.yml
flutter_icons:
  android: true
  ios: true
  image_path: "assets/launcher_icon/icon-dev.png"

assets/launcheer_icon/icon-dev.png
にdev環境のアイコンを配置します。

STEP9 ビルド

こんな感じです。dart defineを使用している場合は以下で環境変数を定義します。

jobs:
  build_ios:
    # jobs配下のenvならrunnerの環境変数として$~~で読み込み可能
    env:
      EXAMPLE_ENV: example-env-value
...
      - name: build ios app
        run: |
          flutter build ipa \
          --release \
          --export-options-plist=ios/ExportOptions.plist \
          --dart-define=EXAMPLE_ENV=$EXAMPLE_ENV

STEP10 ビルドされたバイナリをアップロードしておく

      - name: upload artifact
        uses: actions/upload-artifact@v2
        with:
          name: ios-build
          path: build/ios/ipa/exampleApp.ipa # DISPLAY_APPLICATION_NAME.ipa

上記のようにしておくことで、.ipaファイルを入手できます。
自分が触ったところだとまずipaファイルを初回だけアップロードしないといけなかった気がするので、その際にも使えそうです。
また、こちらはdeploy時にも使用します。

STEP11 バイナリダウンロード

buildとdeployのjobを分けているので、build jobで作成したバイナリをダウンロードします。

      - name: download artifact
        uses: actions/download-artifact@master
        with:
          name: ios-build
          path: ios

STEP12 GCP認証

FIREBASE_TOKENでの認証でも可能ですが、ユーザー依存になるので、
自分達のチームでは、GCPプロジェクトでサービスアカウントを作成して認証するようにしています。(GCPをそもそも使っているのもある)
それを気にしなければ、Firebase login:ciでトークンもらってそれでCI回してもOKです

Firebase cli tokenの場合
https://firebase.google.com/docs/cli?hl=ja#cli-ci-systems

この辺り参考にしつつ、TOKENをGitHub Secretsに入れたらあとはSTEP14で--tokenを指定すればできるはずです

GCPの場合

      - name: set up cloud sdk
        uses: google-github-actions/setup-gcloud@master
        with:
          project_id: ${{ env.GCP_PROJECT_ID }}
          service_account_key: ${{ env.GCP_SA_KEY }}
          export_default_credentials: true

STEP13 Firebase App Distribution使ってデプロイ

上記を参考にしました。

CLIの方では書いてないけどコンソールでのデプロイで書いていることもちょくちょくあるので、両方見つつデプロイしてみるのがいいと思います。


      APP_ID: xxxxxxxxxxxxx # Firebaseでアプリ作成したときののApp ID
      RELEASE_BINARY_PATH: ios/exampleApp.ipa # DISPLAY_APPLICATION_NAME.ipa
      RELEASE_NOTES_PATH: ios/release-notes.txt # このファイル作っておいたらrelease notes変更できる
      TESTER_GROUPS: example-team # testerは firebase コンソール上から作成可能。ない場合エラー出る。

...
      - name: install firebase
        run: curl -sL https://firebase.tools | bash
      - name: deploy
        run: firebase appdistribution:distribute $RELEASE_BINARY_PATH --app $APP_ID --release-notes-file "$RELEASE_NOTES_PATH" --groups "$TESTER_GROUPS"

firebase tokenを使用している場合、デプロイ時に以下の引数が使用可能です。(ドキュメントにも記載されている)
--token=${{ secrets.FIREBASE_TOKEN }}

STEP14 ワークフロー記述

解説が必要そうなステップは全て完了したので、上記を踏まえてワークフローを書きましょう。

GitHub Actionsの基本的な構文については以下を参照してください。
https://docs.github.com/ja/actions/learn-github-actions/workflow-syntax-for-github-actions

解説が必要そうな箇所は上のSTEPで触れていますので、あとはサンプルコードを参考に実装してみてもらえると!!!(投げやり)
随時更新しますので不明点やご指摘あればお願いいたします。

Androidのサンプルコード

deploy-dev-android.yml
name: Deploy dev android

on:
  # androidはmainブランチにマージされた時点でdev環境にデプロイされる
  push:
    branches:
      - main
  workflow_dispatch:
jobs:
  build_android:
    runs-on: ubuntu-latest
    env:
      # NOTE: android/app/build.gradleで下記STORE_FILE_PATHを直接指定している
      STORE_FILE_PATH: android/release.keystore
      KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
      KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
      KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
      APPLICATION_NAME: example_app # applicationIdの最後の要素のアプリ名
    steps:
      - uses: actions/checkout@v2
      - name: Cache Flutter dependencies
        uses: actions/cache@v2
        with:
          path: /opt/hostedtoolcache/flutter
          key: ${{ runner.os }}-flutter
      - name: install flutter
        uses: subosito/flutter-action@v1
        with:
          flutter-version: '2.5.0'
      - name: set up config
        run: cp android/envs/dev/google-services.json android/app/
      - name: create store file for sign
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > $STORE_FILE_PATH
      - name: install dependencies
        run: flutter pub get
      - name: build android app
        run: |
          flutter build appbundle \
          --release \
      - name: upload artifact
        uses: actions/upload-artifact@v2
        with:
          name: android-build
          path: build/app/outputs/bundle/release/app-release.aab

  deploy_android:
    runs-on: ubuntu-latest
    needs: build_android
    env:
      GCP_PROJECT_ID: example-app
      GCP_SA_KEY: ${{ secrets.GCP_SA_KEY_BASE64 }}
      APP_ID: example-app-id # Firebaseのやつ
      RELEASE_BINARY_PATH: android/app-release.aab
      RELEASE_NOTES_PATH: android/release-notes.txt
      TESTER_GROUPS: example-app-group
    steps:
      - uses: actions/checkout@v2
      - name: download artifact
        uses: actions/download-artifact@master
        with:
          name: android-build
          path: android
      - name: set up cloud sdk
        uses: google-github-actions/setup-gcloud@master
        with:
          project_id: ${{ env.GCP_PROJECT_ID }}
          service_account_key: ${{ env.GCP_SA_KEY }}
          export_default_credentials: true
      - name: install firebase
        run: curl -sL https://firebase.tools | bash
      - name: deploy
        run: firebase appdistribution:distribute $RELEASE_BINARY_PATH --app $APP_ID --release-notes-file "$RELEASE_NOTES_PATH" --groups "$TESTER_GROUPS"

Androidの実装手順

すみません、
まだ書けてません・・・(iOS書けたからいいかという甘えが出ました)
随時更新すると思います。

納期は絶対に守りますので、ぜひ仕事は任せてください!!!本当にお願いします!!!

おわりに

以上になります。

「はじめに」があるので、「おわりに」を作ってみましたが、特に書くことはありませんでした。

ありがとうございました。

全力で参考にさせていただいた記事

21
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
5