くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

DockerでVue.jsの環境構築を行う

今回はDockerでVue.jsの環境構築を行います。

前提として以前記事にしたRailsプロジェクトをDockerで作成する手順を確認した上で続きとして読み進めてください

kumaskun.hatenablog.com

今回は、バックエンドをRailsでフロントエンドをVue.jsで合わせて1つのプロジェクトとして作成していきます。 下記イメージです。

root
├── compose.yml
├── backend (Rails プロジェクト)
├── client (Vue.js プロジェクト)
     └── Dockerfile

また、今回はViteを使用してVue.jsプロジェクトを作成します。

Viteは今までのビルドツール(Vue CLI等)に比べて、高速で動作するビルドツールといわれています。

vitejs.dev

プロジェクト作成の準備

まずはcompose.ymlの修正を行います。 下記を追加します。

// 下記を追加
client:
    build:
      context: ./client
      dockerfile: Dockerfile
      args:
        WORKDIR: $WORKDIR
    volumes:
      - ./client:/$WORKDIR:cached
    ports:
      - "5173:5173"
    depends_on:
      - backend

volumesでは、ボリュームのマウントを行います。 ボリュームとはデータを永続化できる場所のことで、外部HDDに近いイメージです。 コンテナは破棄すると消えてしまうため、データを永続化したいときは、コンテナの外にデータを置く必要があります。 そしてマウントとは、ホスト側(コンテナの外)にあるディレクトリやファイルを、コンテナの中から利用できるようにすることです。 volumesの指定は、コロンの左にホストのパスを、右にコンテナのパスを指定します。

そして、Vue.jsのプロジェクト内でDockerfileを作成します。

FROM node:18.15.0-bullseye

ARG WORKDIR

ENV HOME=/${WORKDIR}

WORKDIR ${HOME}

// apt updateコマンドを実行してパッケージリストを更新し、yarn installコマンドを実行してNode.jsアプリケーションに必要なパッケージをインストールする
RUN apt update \
    && yarn install

CMD ["yarn", "dev", "--host"]

ここまで作成した状態で、下記コマンドを実行します。

$ docker-compose build

エラーが発生しなければ先に進みます。

次にプロジェクトの新規作成を行います。下記コマンドを実行します。

$ docker-compose run --rm client npm init vite@latest

コマンドを実行するとProject nameとSelect a frameworkとSelect a variantの設定を求められます。

今回は、「Project name」はvite-project、「Select a framework」はVue、「Select a variant」はJavaScriptとします。(適宜置き換えてプロジェクトを作成してください)

成功するとclient配下にvite-projectというフォルダが作成されていると思います。

ここからはvite-projectの中身を移動して、空になったvite-projectを削除します。 下記ターミナルで実行します。(フォルダ名は適時置き換えて実行してください)

$ ls -A client/vite-project 
.gitignore     .vscode        README.md      index.html     package.json   public         src            vite.config.js

$ mv client/vite-project/* client 

$ ls -A client/vite-project        
.gitignore .vscode

$ mv client/vite-project/.gitignore client/ && mv client/vite-project/.vscode client 

$ ls -A client/vite-project
// 空なので何も表示されなければOK!

$ rmdir client/vite-project 

これでvite-projectにあったファイルはVue.jsプロジェクトのルートに配置されたと思います。

ローカル起動できるようにする

ここからはlocalhostで起動できるようにします。

まずは、yarnを使用するためインストールを行います。 npmのままがいい方は、yarn installのところをnpm installに置き換えて実行してください。

両方とも、Javascriptのパッケージマネージャーです。 yarnはnpmと互換性があり、npmで使用していたプロジェクト設定ファイル(package.json)がそのまま使えます。

今回下記の理由でyarnを選択しています。

  • npmと比べてインストールが速い、セキュリティが高い。インストール時にパッケージが不正に変更されていないかなどをチェックサムを用いて検証することができ、安全なパッケージのインストールが可能
  • オフラインキャッシュの仕組みを利用している。パッケージを初めてインストールすると、Yarnはそのパッケージを~/.yarn-cacheの下にあるキャッシュフォルダに追加する。これによりnpmと比較してYarnのパフォーマンスを大幅に向上させている
$ docker-compose run --rm client yarn install 
yarn install v1.22.19
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.

yarnを使用できるようになったらDockerfileを修正します。

FROM node:18.15.0-bullseye

ARG WORKDIR

ENV HOME=/${WORKDIR}

WORKDIR ${HOME}

RUN apt update \
    && yarn install

// ここを追加
COPY . ./
COPY package.json /client/package.json
COPY yarn.lock /client/yarn.lock

CMD ["yarn", "dev", "--host"]

COPY package.json /client/package.jsonCOPY yarn.lock /client/yarn.lockを行う理由は起動の時間を短縮するためです。 dockerはビルド開始時にdocker daemonにDockerfileのディレクトリにあるファイルを全部tarして(アーカイブを作成/展開)送ります。 コンテナ内にパッケージの一覧とバージョンの依存関係が書かれたファイルだけコピーして、その後コンテナ内でインストールする方が時間を短縮できます。

そして、compose.ymlを修正します。

// volumesを追加
volumes:
  node_modules:
services:
  db:
  backend:
  client:
    build:
      context: ./client
      dockerfile: Dockerfile
      args:
        WORKDIR: $WORKDIR
    volumes:
      - ./client:/$WORKDIR:cached
      // ここを追加
      - node_modules:/$WORKDIR/node_modules
    ports:
      - "5173:5173"
    depends_on:
      - backend

ここで、追加した内容を少し補足します。

volumesでバインドマウントを設定しています。 カレントディレクトリをまるごとマウントしてしまうと node_modules もホストとコンテナで同期されてしまうことになります。(ホスト側の情報がそのまま反映される) これだとホスト側に node_modules が作成されて、意図しない挙動を招きかねません。(node_modules をいじったりしてしまうなど)

また、バインドマウントはマウント時にホスト側の情報がそのまま反映され、そのあとコンテナ内で変更があればホスト側に反映されます。 するとコンテナ内のnode_modulesがなくなるパターンもあります。 これを避けるためには以下のように名前付きボリュームを用意する(volumes:のこと)ことで node_modules が名前付きボリュームに格納され、ホストマシンに同期されることがなくなります。 そのための設定をここでは追加しています。

この状態で、docker-compose buildを行ってから、docker-compose upを実行します。

$ docker-compose build --no-cache

$ docker-compose up

docker-compose up実行後にエラーが発生していなければ、http://localhost:5173/ にアクセスしてください。 下記画面が表示されれば疎通できています。

参照

qiita.com

qiita.com

kyoruni.hatenablog.com

qiita.com

hub.docker.com

choice-site.com

jdlm.info

Androidでの色について

今回はAndroidでの色についてです。

Androidではカラーコードは色の値(16 進数の色コード)を使用します。
そして、RGB値とアルファ値で指定します。 カラーリソースやコード上で表現します。

値は常に#で始め、その後に次のいずれかの形式で Alpha-Red-Green-Blue 情報を続けます。

  • #RGB
  • #ARGB
  • #RRGGBB
  • #AARRGGBB

#RGBはR(赤)・G(緑)・B(青)の3つの色の値を16進数で表現します。 たとえば、#FF0000は、赤色の最大値である255をを示し、他の色はゼロです。

#ARGBは、A(アルファ)・R(赤)・G(緑)・B(青)の4つの色の値を16進数で表現します。 例えば、#FF0000は、アルファ値が不透明(255)、赤の色の強度が最大(255)、緑と青が0であることを表します。 このカラーコードは、背景色やテキストカラーを指定する場合など、アルファ値を指定する必要がない場合に使用されます。

#RRGGBBは、R(赤)・G(緑)・B(青)の3つの色の値を16進数で表現します。 たとえば、#00FF00は緑色を表します。

#AARRGGBBは、A(アルファ)、R(赤)、G(緑)、およびB(青)の4つの色の値を16進数で表現します。 #AARRGGBBは、#ARGBと異なり、アルファ値を指定する必要がある場合に使用されます。 例えば、#80FF0000は、アルファ値が128(半透明)、赤の色の強度が最大(255)、緑と青が0であることを表します。

透明度を追加する

まず、透明度は0から255の範囲で16進数で表現します。

例えば、アルファ値が半透明の場合、つまり透明度が50%の場合は、アルファ値を128(255の半分)に設定します。 この場合、カラーコードは#80RRGGBBとなります。

透明度を追加するには次のようにalphaを指定するというパターンがまず1つあります。

<TextView
        android:textColor="#000000"
        android:alpha="0.3"

こうしなくても次のようにアルファ値まで含めたカラーコードを指定することができます。

// アルファ値が30%の黒色
<TextView
        android:textColor="#4D000000"

また、次のようにコードで表現することもできます。

// 引数は 「alpha 透過率」 「red 赤」 「green 緑」 「blue 青」の順
val color = Color.argb(30,30,30,30)

透明度(アルファ)を16進数のカラーコードに置き換える

自分が確認するときのために記載していますが、参考になれば

100% — FF
 99% — FC
 98% — FA
 97% — F7
 96% — F5
 95% — F2
 94% — F0
 93% — ED
 92% — EB
 91% — E8
 90% — E6
 89% — E3
 88% — E0
 87% — DE
 86% — DB
 85% — D9
 84% — D6
 83% — D4
 82% — D1
 81% — CF
 80% — CC
 79% — C9
 78% — C7
 77% — C4
 76% — C2
 75% — BF
 74% — BD
 73% — BA
 72% — B8
 71% — B5
 70% — B3
 69% — B0
 68% — AD
 67% — AB
 66% — A8
 65% — A6
 64% — A3
 63% — A1
 62% — 9E
 61% — 9C
 60% — 99
 59% — 96
 58% — 94
 57% — 91
 56% — 8F
 55% — 8C
 54% — 8A
 53% — 87
 52% — 85
 51% — 82
 50% — 80
 49% — 7D
 48% — 7A
 47% — 78
 46% — 75
 45% — 73
 44% — 70
 43% — 6E
 42% — 6B
 41% — 69
 40% — 66
 39% — 63
 38% — 61
 37% — 5E
 36% — 5C
 35% — 59
 34% — 57
 33% — 54
 32% — 52
 31% — 4F
 30% — 4D
 29% — 4A
 28% — 47
 27% — 45
 26% — 42
 25% — 40
 24% — 3D
 23% — 3B
 22% — 38
 21% — 36
 20% — 33
 19% — 30
 18% — 2E
 17% — 2B
 16% — 29
 15% — 26
 14% — 24
 13% — 21
 12% — 1F
 11% — 1C
 10% — 1A
  9% — 17
  8% — 14
  7% — 12
  6% — 0F
  5% — 0D
  4% — 0A
  3% — 08
  2% — 05
  1% — 03
  0% — 00

参照

developer.android.com

qiita.com

www.colordic.org

Xcodeのテンプレートファイルを設定する

今回はXcodeで自分で設定したテンプレートファイルを選択できるようにします。

例えば、ViewControllerを作成するときに毎回同じ内容をコピペしてクラス名を変えるということをしていませんか?

自分は似たようなことをしていたのでテンプレート化の方法を探しました。 結果的にテンプレート化できるので方法をまとめようと思います。

テンプレート定義の場所

既存のテンプレートの場所は下記にあります。(Xcode.appは場合によっては異なる場合があるので適宜置き換えてください)

/Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/

開いてみるとTemplatesディレクトリにはFile TemplatesProject Templatesがあります。 今回はファイルのテンプレートを作りたいので、File Templatesを開きます。 File Templates配下にあるMultiPlatformフォルダにファイル新規作成したときに表示される項目があります。 そして、テンプレートは.xctemplateというネームスペースのファイルで構成されています。 今回は最も基本のSwift File.xctemplate配下にあるというファイルをコピーします。

コピー先は以下のカスタムテンプレートを置くパスにします。 ただし、Templatesフォルダは存在しないので作成する必要があります。

~/Library/Developer/Xcode/Templates

先程のSwift File.xctemplateフォルダ配下のファイルをコピーします。 今回はコピーした後は以下のようなフォルダ構成です。

この状態でXcodeでファイルを新規で追加しようとすると以下のようにカスタムのテンプレートが候補に上がってくるはずです。

File Templatesがセクション名でTest File.xctemplateのTest Fileの部分が項目名となっています。

テンプレートファイルのカスタマイズ

ファイルを新規作成する際に特定のコードが入った状態で作成されたい場合には先程コピーしたテンプレートを編集します。

先程コピーしてペーストしたファイル___FILEBASENAME___.swiftを編集します。(~/Library/Developer/Xcode/Templatesにあるファイルです)

ちなみに、___FILEBASENAME___は作成したファイル名が入ります。

例えば、テンプレートを以下にすると作成したときのファイル名で作成されるようになります。

import RxSwift
import RxCocoa

protocol ___FILEBASENAME___Inputs: AnyObject {

}

protocol ___FILEBASENAME___Outputs: AnyObject {

}

protocol ___FILEBASENAME___Type: AnyObject {
    var inputs: ___FILEBASENAME___Inputs { get }
    var outputs: ___FILEBASENAME___Outputs { get }
}

class ___FILEBASENAME___: ___FILEBASENAME___Type, ___FILEBASENAME___Inputs, ___FILEBASENAME___Outputs {

    var inputs: ___FILEBASENAME___Inputs { return self }
    var outputs: ___FILEBASENAME___Outputs { return self }
}

参照

yamatooo.blog

Firebaseの主要サービスの設定まとめ

今回はiOSAndroidでのFirebase関連の設定についてです。 Firebaseのサービスを使ってアプリを開発することは多いと思いますので、この機会にまとめようと思います。

プッシュ通知

プッシュ通知はFirebase Cloud Messagingを使うと思います。 プッシュ通知に関しては、次の記事でまとめていますので確認してください。

kumaskun.hatenablog.com

kumaskun.hatenablog.com

クラッシュログ調査

次にFirebase Crashlyticsについてです。

Firebase Crashlyticsはリリースされているアプリがクラッシュした際のログを飛ばしFirebaseのコンソールでクラッシュ内容を確認できる機能です。
ここからは今回はiOSAndroidで設定方法が異なるのでそれぞれ見ていきます。

※これ以降はプッシュ通知で行ったFirebaseの設定は行なっているという前提で進めますのでご了承ください🙇‍♂️(FirebaseApp.configure()のコード記述やGoogle-service.jsonをプロジェクトに配置しているなど)
確認したい場合にはプッシュ通知の箇所で貼ったリンクを見てもらえると初期設定は記載しています。

iOS

まずはiOSでの設定方法についてです。

まずはライブラリをインストールしましょう。

pod 'Firebase/Crashlytics'

Crashlyticsを初期化するために、実行スクリプトをプロジェクトのBuild Phaseに追加します。 実行スクリプトを使用すると、アプリがクラッシュするたびにXcodeが自動的にプロジェクトのdSYMファイルをアップロードしてくれるため、Crashlyticsは自動的にクラッシュレポートを生成できます。 Xcodeのプロジェクトの[Build Phases]タブを選択し、+ > [New Run Script Phase]を選択し、Run Scriptに以下を記入します。

${PODS_ROOT}/FirebaseCrashlytics/run

次に、アプリのdSYMファイルとInfo.plistの場所を指定するために以下の2つをinput Filesに記入します。 アプリのdSYMの場所を、大規模なアプリに対するCrashlyticsの自動dSYM生成を高速化する入力ファイルとして追加します。

${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}
$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)

スクリプトの追加のイメージは以下です。

ここでdSYMファイルとは、簡単にいうとデバッグ情報ファイルのことです。

dSYMが有効にするためには次の手順を確認してください

  1. XcodeProject Navigatorで、プロジェクトを選択します。
  2. ターゲットリストで、アプリケーションを構築するターゲットを選択します。
  3. Build Settingsタブを選択します。
  4. Build Optionsセクションで、Debugging Information FormatDWARF with dSYM Fileに設定されていることを確認します。

dSYMファイルが欠落してクラッシュ情報を取得できない場合にはFirebaseのコンソールにアップロードを行ってください。

アップロードする場合にはビルドプロセスに次のスクリプトを追加するとビルド時に実行してくれます。

find dSYM_directory -name "*.dSYM" | xargs -I \{\} $PODS_ROOT/FirebaseCrashlytics/upload-symbols -gsp /path/to/GoogleService-Info.plist -p platform \{\}

dSYMファイルをアップロードが正常終了しているかは次のようなビルドのログを見るとわかるので気になったら確認してください。 よくあるのはスクリプトに不要な空白が入っていてアップロードできていないパターンがあるので確認してみるといいかもしれません。

Android

次にAndroidでの設定についてです。 iOSほど複雑ではないので簡単に説明します。

Crashlyticsのライブラリを追加するだけです。 dSYMファイルなど必要ありません。

まずはFirebase Cloud Messagingを利用できるようにライブラリの追加を行います。ルートレベルのbuild.gradleに下記設定を追加します。

buildscript {
    dependencies {
        classpath 'com.google.gms:google-services:4.3.10'
    }
}

そしてモジュールレベルのbuild.gradleに下記を追加します(Firebaseのバージョンをbomで管理しています)

dependencies {
    implementation platform('com.google.firebase:firebase-bom:28.3.1')
    implementation 'com.google.firebase:firebase-crashlytics'
}

アプリ解析

最後にFirebase Analyticsについてです。

Firebase Analyticsは、アプリの利用者に関するデータ分析やレポート集計を行います。 また、Firebase Analyticsはイベントベースで計測を行います。 ユーザーが特定のアクションを起こす度にデータに反映されるため、詳細に行動分析が行えます。 Firebaseのコンソールでイベントやユーザー情報など数値化したデータを確認できます。

iOS

まずはライブラリをインストールしましょう。

pod 'Firebase/Analytics'

また、シュミレーターから送信したイベントがみれるので、Xcodeに設定します。 Xcodeを開いて上部タブバーの[Product] > [Scheme] > [Edit scheme] > [Run]を開きます。 開いたらArguments Passed On Launchに-FIRAnalyticsDebugEnabledを追加します。

ここまでの設定とFirebaseの設定まで行った段階でデフォルトの情報はコンソールに表示されるようになります。 ここからはカスタムで情報を入れたい場合の対応になります。 次のようにAnalytics.logEventにイベント名と1対のkeyとValueになったパラーメータを設定することで送信できます。

Analytics.logEvent("イベント名", parameters: [
  // パラメーターを設定
  "パラメーターkey1": "パラメーターvalue1",
  "パラメーターkey2": "パラメーターvalue2",
  "パラメーターkey3": "パラメーターvalue3"
])

下記はデフォルトでで用意されているものを使用した例です。

Analytics.logEvent(AnalyticsEventSelectContent, parameters: [
  AnalyticsParameterItemID: "id",
  AnalyticsParameterItemName: "Eventname",
  AnalyticsParameterContentType: "cont"
 ])

これらの設定を例えば、ボタンを押したタイミングや画面を表示したタイミングなどログを表示させたい場面に実装します。

ログを確認したい場合には、Firebaseコンソールの左メニュー [分析] > [DebugView]を開くとイベントが表示されます。

Android

Androidも同様にライブラリをインストールしてカスタムイベントが必要な場合には設定を行うだけです。

dependencies {
    implementation platform('com.google.firebase:firebase-bom:28.3.1')
    implementation 'com.google.firebase:firebase-analytics
}
 // 発生させたいタイミングでイベントを送る(thisはcontext)
val firebaseAnalytics = FirebaseAnalytics.getInstance(this)
val bundle = Bundle()
bundle.putString("param1", "value")
firebaseAnalytics.logEvent("test_event", bundle)

参照

qiita.com

qiita.com

firebase.google.com

firebase.google.com

qiita.com

firebase.google.com

Githubでいきなりプッシュできなくなったら

Githubでいきなりプッシュできなくなったのでメモに残します。

ここ最近発生している場合には同じ事象の可能性はあるので参考にしてみてください。

エラー内容

プッシュしようとしたら以下のエラーメッセージが表示されました。 @がたくさん表示されるとビビります!

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the RSA key sent by the remote host is
SHA256:xxxxx
Please contact your system administrator.
Add correct host key in /Users/xxx/.ssh/known_hosts to get rid of this message.
Offending RSA key in /Users/xxx/.ssh/known_hosts:1
Host key for github.com has changed and you have requested strict checking.
Host key verification failed.
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.

SSHでは初回接続時に接続先ホストの公開鍵を保存しておき、次回接続時にホスト鍵を比較して前回と同じホストに接続したかを確認するような仕組みになっています。 そのため、IPアドレスの振り直しやOS再インストールなどでホスト鍵が変わってしまった場合、エラーが発生してSSH接続が失敗します。

しかし、特にホスト鍵が変わるようなことをした覚えが全くなく、何か変えるようなことしたかなあと思っていたら、Githubにお知らせがありました。

github.blog

対応内容

エラーへの対処としては$HOME/.ssh/known_hostsの該当行を削除すれば同じエラーは出なくなります。 エディタで削除してもいいのですが、下記のようにssh-keygenコマンドの-Rオプションで消すこともできます。

$ ssh-keygen -R github.com
# Host github.com found: line 133 type RSA
/Users/xxxxx/.ssh/known_hosts updated.
Original contents retained as /Users/xxxxx/.ssh/known_hosts.old

困ったら、Githubのお知らせを見ると対処法が記載されているので確認するのが一番よさそうです。

DockerでRailsアプリケーションを動かす

今回はRails環境をDockerで構築する方法についてです。

Docker Compose V1が非推奨となり、Docker Compose V2が推奨となることも踏まえてV2で行おうと思います。

Docker Desktopはインストール済み、アプリケーションを格納するフォルダは作成済みの状態で進めます。

Dockerfileの作成

まずはDockerfileを作成します。
DockerfileはRailsの実行環境用のイメージを作成するためのファイルです。 次のコマンドでDockerfileを作成します。(今回はsamplev2というフォルダを作成したとします)

samplev2 % touch Dockerfile

そして、Dockerfileに以下の記述を追加します。 今回はRuby3.1.0を使用するため、冒頭にFROM ruby:3.1.0を記載します。

FROM ruby:3.1.0
RUN apt-get update -qq && apt-get install -y postgresql-client
WORKDIR /samplev2
COPY Gemfile/samplev2/Gemfile
COPY Gemfile.lock/samplev2/Gemfile.lock
RUN bundle install

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]

少し記述について補足します。

RUN apt-get update -qq && apt-get install -y nodejs postgresql-clientRailsアプリケーションに必要なNode.jsとPostgreSQLのインストールを行うように指定します。(PostgreSQLは必須ではありません)

WORKDIR /samplev2は作業ディレクトリを指定します。ここに誤りがあるとうまくプロジェクトが作成されないので、注意が必要です。

COPY Gemfile/samplev2/GemfileはローカルのGemfileをsamplev2配下にコピーします。 COPY Gemfile.lock /samplev2/Gemfile.lockはローカルのGemfile.lockをsamplev2配下にコピーします。

RUN bundle install先程コピーしたGemfileに記載されているGemをbundle installするように指定します。

COPY entrypoint.sh /usr/bin/はローカルのentrypoint.shを/usr/bin配下にコピーします。

RUN chmod +x /usr/bin/entrypoint.shでコピーしたentrypoint.shに権限を付与します。

ENTRYPOINT ["entrypoint.sh"]

EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"]でポート番号を指定して、Railsを実行します。

exec形式([""]の形式)は指定したコマンドと引数がそのまま実行されるので、ENTRYPOINTやCMDはexec形式で記載しましょう。

Gemfileの作成

次にGemfileを作成します。
今回はRails7.0.4.2のアプリケーションを使用するため、作成したGemfileには次のように、gem 'rails', '7.0.4.2'を記載します。

source 'https://rubygems.org'
gem "rails", '7.0.4.2'

そして、Dockerfileをビルドするため、Gemfile.lockも作成します。 Gemfile.lockは空の状態で作成します。

entrypoint.shの作成

次にentrypoint.shを作成します。 既にserver.pidファイルが存在してしまうことで、サーバーを再起動できない問題を解消するために追加します。 (server.pidはサーバー起動時に生成されます。生成されている場合、サーバーは起動状態にあると認識されます。 サーバー終了後、server.pidが残存する恐れがあり、サーバーを再起動する場合に「サーバーは起動しています」というエラーが発生する場合があります。 よって、明示的に残存したserver.pidを削除する事で問題なくサーバーを起動する事ができるようになります)

samplev2 % touch entrypoint.shで空のファイルを作成して、次のように設定します。

#!/bin/bash
set -e

# すでにserver.pidがある場合には削除
rm -f /samplev2/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

exec "$@"でDockerfileのCMDで渡されたコマンド(→Railsのサーバー起動)を実行します。

compose.ymlの作成

次にアプリケーションを構成するサービス一覧を記載し、それらを一括で実行してくれるcompose.ymlを作成します。 V3からはcompose.ymlという名前が使えます。 以前使用していたdocker-compose.yml後方互換のためにしばらくサポートしているだけみたいなので、compose.ymlの方を使った方がいいと思います。

今回は、作成したcompose.ymlにdbとwebの2つを記載します。 dbはPostgreSQLを指定します。 samplev2 % touch compose.ymlで空のファイルを作成して、次のように設定します。

services:
  db:
    image: postgres
    volumes:
      - ./tmp/db:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: password
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/samplev2
    ports:
      - "3000:3000"
    depends_on:
      - db

services:はservices配下にコンテナを定義します(今回はwebとdbのみ)。

image:は利用するイメージの指定します(今回はPostgreSQL)。

volumes:はデータを永続的に保存する場所を指定します(手元のデータをマウントしているなら、コンテナを立ち上げる度にデータが消えない)。 今回の場合は、手元でプロジェクトルートにファイルを作ると、コンテナ内の/sample2にも、ファイルが作られます。

environment:PostgreSQLに関する環境変数の設定(今回はパスワードを設定)

build: .はDockerfileがあるディレクトリを指定しています。

depends_on:はコンテナの実行順序の指定します(今回はDB起動からweb起動する設定にしています)

また、compose.yml内でversion: '3'のようにバージョンを記述する項目が非推奨になったので設定を入れていません。

Railsアプリケーションの作成

ここまでファイルを作成したら、Railsの新規アプリケーションを作成します。

samplev2 % docker compose run --no-deps web(ここはcompose.ymlで定義したサービス名) rails new . --force --database=postgresql

--forceを設定することで実行時にGemfileの上書きを行います。

--no-depsを設定することでリンクされたコンテナを起動させないようにします。

--skip-bundleを設定することでbundle installをスキップします(今回はgemを追加していないので)

ビルド

rails newで作成した新規アプリケーションのGemfileを更新するため、ビルドを実行します。

samplev2 % docker compose build

DBの作成

ビルドまで成功していれば、デフォルトのフォルダやファイルが作成されていると思います。 ここでDBを作成します。

まずはdatabase.ymlの編集を行います。 デフォルトではRailslocalhost上でDBが動作してしまうため、先ほど記述したDBコンテナを動作先に指定しましょう。 config/database.ymlのdefault部分を以下のように編集します。

// 編集前

default: &default
  adapter: postgresql
  encoding: unicode
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

↓
↓
↓

// 編集後

default: &default
  adapter: postgresql
  encoding: unicode
  host: db
  username: postgres
  password: password
  pool: 5

編集後は以下のコマンドでDBを起動します。

samplev2 % docker compose run web rails db:create

成功すると次のようにDBが作られると思います。

Created database 'samplev2_development'
Created database 'samplev2_test'

アプリケーションの起動

ここまででDockerの設定を一通り終了しました。 最後にアプリケーションを立ち上げます。 Dockerを起動した状態で、次のコマンドを実行します。

samplev2 % docker compose up

http://localhost:3000/にアクセスしてみて次のような画面が表示されれば成功です。

参照

www.docker.com

docs.docker.com

qiita.com

matsuand.github.io

github.com

github.com

qiita.com

qiita.com

zenn.dev

Google Mapを使ってみよう

今回はGoogle Mapをモバイル開発で使う流れをまとめようと思います。 AndroidiOS両方説明しようと思います。
AndroidiOSでできることにはほとんど差がないのでコードの書き方が少し違うだけだと思います。

iOSGoogle Mapの対応を行う

最初にマップを表示させるところから始めましょう!

事前準備

まずは、マップを使えるようにするために次の手順を行う必要があります。(順不同)

  1. APIキーを取得してプロジェクトに導入
  2. ライブラリを使えるようにする
  3. 位置情報の権限追加

まずはAPIキーを取得します。 どのアプリケーションからGoogle Mapを使うかを判定、リクエストを行うためにAPIキーを取得してプロジェクトで設定する必要があります。

APIキーを取得は次の公式ドキュメントを確認してください。 対象のアプリでGoogle Mapの使用するかでONを設定すると認証情報の欄でAPI Keyが確認できます。

developers.google.com

そして、AppDelegateで下記のようにAPIキーを設定します。

GMSServices.provideAPIKey("取得したAPIキーを設定")

次にライブラリを使えるようにpodファイルにGoogle Mapのライブラリを追加してpod installを行います。

pod 'GoogleMaps'

そして、位置情報の権限追加はinfo.plistに位置情報の権限を追加します。 細かい記載は今回割愛します。

マップを表示

事前準備を行ったら実際にマップを表示させます。

class MapViewController: UIViewController {
    private var locationManager: CLLocationManager?
    private var mapView: GMSMapView?

    deinit {
        if let manager = locationManager {
            manager.stopUpdatingLocation()
            locationManager = nil
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        setupLocationManager()
    }

    private func setupLocationManager() {
        let locationManager = CLLocationManager()
        locationManager.delegate = self
        // 位置情報の取得精度を指定
        locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters

        // 位置情報の権限確認
        let status = CLLocationManager.authorizationStatus()

        switch status {
        case .notDetermined:
            locationManager.requestWhenInUseAuthorization()
        case .restricted, .denied:
            setupGoogleMaps()
        case .authorizedAlways, .authorizedWhenInUse:
            locationManager.startUpdatingLocation()
        @unknown default:
            setupGoogleMaps()
        }

        self.locationManager = locationManager
    }
}

extension MapViewController {
    private func setupGoogleMaps() {
        if mapView != nil {
            return
        }

        var location: CLLocation!

        if let lastKnownLocation = locationManager?.location {
            location = lastKnownLocation
        } else {
            location = CLLocation(latitude: 0, longitude: 0)
        }

        // 現在地とマップがどれくらいズームするかを設定する
        let camera = GMSCameraPosition.camera(
            withLatitude: location.coordinate.latitude,
            longitude: location.coordinate.longitude,
            zoom: 12.5
        )

        // GMSMapViewを初期化する
        let mapView = GMSMapView.map(
            withFrame: CGRect(x: 0, y: 0, width: view.frame.width,height: view.frame.height),
            camera: camera
        )

        // 現在値を中心にするボタンを有効にする設定
        mapView.settings.myLocationButton = true

        // AutoLayoutで定義したUIViewにaddSubViewする
        mapBaseView.addSubview(mapView)
        mapBaseView.sendSubviewToBack(mapView)

        self.mapView = mapView
    }
}

// MARK: - CLLocationManagerDelegate

extension MapViewController: CLLocationManagerDelegate {
    // 権限が変更されたときの処理
    func locationManager(_ manager: CLLocationManager, 
                           didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .notDetermined:
            manager.requestWhenInUseAuthorization()
        case .restricted, .denied:
            setupGoogleMaps()
        case .authorizedAlways, .authorizedWhenInUse:
            manager.startUpdatingLocation()
        @unknown default:
            setupGoogleMaps()
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        manager.stopUpdatingLocation()
        setupGoogleMaps()
    }
}

上記は表示させるためのコードです。

setupLocationManager()で位置情報を許諾しているかの確認を行い、まだ1度も設定していない場合には確認のダイアログを表示させます。 許可している場合にはstartUpdatingLocation()で現在地を取得してマップに適用させます。
移動する場合にもCLLocationManagerDelegatefunc locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation])で現在地を定期的に更新できます。

また、マップにはGMSMapViewクラスを使います。 GMSMapViewは、地図に関するすべてのメソッドの入り口で、表示するMapのクラスです。
setupGoogleMaps()でGoogleMapの表示設定を行いました。 大まかには初期表示の位置やどれくらいズームするかを設定したGMSMapViewを初期化して、AutoLayoutで定義したUIViewにaddSubViewすると表示できます。
それが上記のコードになります。

ピンを表示

次に特定の位置にピン(アイコン)を表示させる方法です。
ピンにはGMSMarkerクラスを使います。 先ほどの例のsetupGoogleMaps()の後にcreateMarker()メソッドを追加します。

class MapViewController: UIViewController {
    private var locationManager: CLLocationManager?
    private var mapView: GMSMapView?
}

extension MapViewController {
    private func setupGoogleMaps() {
        // 先ほどの例の部分は割愛

        createMarker()
    }

    private func createMarker() {
        let marker = GMSMarker()

        //ピンの色変更したい場合に設定
        marker.icon = GMSMarker.markerImage(with: .black) 
        marker.position = CLLocationCoordinate2D(latitude: 34.077875549971,
                                                 longitude: 134.56156512254)
        marker.title = "タイトル"
        //ピンの画像を変更したい場合に設定
        marker.icon = UIImage(named: "camera_1")!
        marker.zIndex = 2
        marker.map = mapView

        mapView.delegate = self
    }
}

extension MapViewController: GMSMapViewDelegate {
    // ピンをタップした時の処理
    func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool {
        return true
    }

    // マップをタップした時の処理
    func mapView(_ mapView: GMSMapView, didTapAt coordinate: CLLocationCoordinate2D) {
    }

    func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) {
    }
}

今回はposition(表示させる緯度・経度の情報)と titleを含むGMSMarkerオブジェクトを作成し、GMSMapViewに設定します。 titleはピンをタップすると情報ウィンドウに設定した文字列が表示されます。 iconはアイコンの情報を設定することができ、色や画像の変更を行えます。
GMSMapViewDelegateを設定しているのはアイコンやマップをタップしたときに処理を行う場合には必要です。
また、zIndexを指定していますが、これはピンが重なった時にzIndexの値が大きい方が前に表示されます。
意外と簡単だと思った方が多いと思いますが、ここまでで最低限のマップアプリは作れると思います。

AndroidGoogle Mapの対応を行う

AndroidiOSと同様に進めます。
iOSと大きな違いがあるわけではないのでそこまで難しいことはないと思います。

マップを表示

まずはiOSと同様にマップを表示させるところから始めましょう!

最初にAPI Keyを設定します。
AndroidManifest.xmlAPI Keyの設定を追加します。

<meta-data
        android:name="com.google.android.geo.API_KEY"
        android:value="取得したAPI Keyを設定" />

そして、ライブラリをbuild.gradleに追加します。

implementation 'com.google.gms:google-services:4.3.14'
implementation 'com.google.android.gms:play-services-location:21.0.1'
implementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1'

Mapを表示させるにはGoogleMapクラスを使用します。 LocationServices.getFusedLocationProviderClientで位置情報を取得します。 Mapを表示させるための一例は次のコードです。

class MapFragment : Fragment(), OnMapReadyCallback {
    private var mapView: GoogleMap? = null
    private var fusedLocationClient: FusedLocationProviderClient? = null

    private var _binding: FragmentMapBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentMapBinding.inflate(inflater, container, false)

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        setupData()
    }

    override fun onResume() {
        super.onResume()
    }

    private fun setupData() {
        checkPermission()
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireContext())

        val mapFragment =
            childFragmentManager.findFragmentById(R.id.mapView) as SupportMapFragment
        mapFragment.getMapAsync(this)     
    }

    override fun onMapReady(p0: GoogleMap) {
        this.mapView = p0
        fetchLocation()
    }

    private fun fetchLocation() {
        mapView?.uiSettings?.isMyLocationButtonEnabled = false
        if (ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION)
            != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
                requireContext(),
                Manifest.permission.ACCESS_COARSE_LOCATION
            )
            != PackageManager.PERMISSION_GRANTED
        ) {
            return
        } else {
            mapView?.isMyLocationEnabled = true
            val task = fusedLocationClient?.lastLocation
            task?.addOnSuccessListener { location ->
                if (location != null) {
                    // Mapの中心の緯度・経度を設定して表示させる
                    mapView?.moveCamera(
                        CameraUpdateFactory.newLatLngZoom(
                            LatLng(location.latitude, location.longitude),
                            10.0f
                        )
                    )
                } else {
                    val request = LocationRequest.create()
                        .setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
                        .setInterval(500)
                        .setFastestInterval(300)

                    fusedLocationClient?.requestLocationUpdates(
                        request,
                        object : LocationCallback() {
                            override fun onLocationResult(result: LocationResult) {
                                mapView?.moveCamera(
                                    CameraUpdateFactory.newLatLngZoom(
                                        LatLng(
                                            result.lastLocation!!.latitude,
                                            result.lastLocation!!.longitude
                                        ),
                                        12.5f
                                    )
                                )
                                fusedLocationClient?.removeLocationUpdates(this)
                            }
                        }, Looper.getMainLooper()
                    )
                }
            }
        }
    }       

今回はFragmentでGoogle Mapを表示させていますが、Activityで表示させる場合にも基本的には同じです。 FragmentManager.findFragmentById() を呼び出して、地図フラグメントに対するハンドルを取得します。
次に、getMapAsync()を使用して、GoogleMapインスタンス生成完了時に呼ばれる地図コールバックを登録します。 OnMapReadyCallbackインターフェースを実装し、GoogleMap オブジェクトが使用可能な場合に地図を設定するようonMapReady()をオーバーライドします。

ピンを表示

次に特定の位置にピン(アイコン)を表示させる方法です。
iOSではピンにはGMSMarkerクラスを使いましたが、AndroidではMarkerクラスを使用します。 Mapが表示した後に下記の処理を行います。(onMapReady()や初期設定後)

private fun createOption(latLng: LatLng): MarkerOptions {
    val options = MarkerOptions().position(latLng)
    // アイコンを指定
    options.icon(BitmapDescriptorFactory.fromResource(R.drawable.pin))

    options.zIndex(2.0F)

    // mapViewはGoogle Mapクラスのインスタンス
    mapView?.addMarker(options)
}

上記の処理を行うと指定した緯度・経度にマーカーが表示されます。 削除する場合にはremove()を使用します。

ダークモード対応

作成したコードをダークモードで実行してもライトモードのままだと思います。 ダークモード対応は追加で別で実装が必要となるので、最後に補足します。

iOS

次のメソッドをマップの初期化や画面復帰した際の処理に追加してください。

private func setMapStyle(_ mapView: GMSMapView?) {
    guard let mapView = mapView else {
        return
    }

    if UITraitCollection.current.userInterfaceStyle == .dark {
        // ダークモードのjsonを読み込ませる
        if let styleURL = Bundle.main.url(forResource: "map_dark", withExtension: "json") {
            do {
                mapView.mapStyle = try GMSMapStyle(contentsOfFileURL: styleURL)
            } catch {
                return
            }
        }
    } else {
        // ライトモードのjsonを読み込ませる
        if let styleURL = Bundle.main.url(forResource: "map_light", withExtension: "json") {
            do {
                mapView.mapStyle = try GMSMapStyle(contentsOfFileURL: styleURL)
            } catch {
                return
            }
      }
}

何をやっているかというと、GMSMapViewのmapStylejsonで定義した色などの情報を適用させています。 背景色やラベルの色、道路の色など設定できます。
下記に色を設定してjsonファイルを作成してくれるサイトがあるので、作成は楽だと思います。

https://mapstyle.withgoogle.com

Android

AndroidiOSと同様にjsonで定義した色などの情報を適用させます。 他にも方法はあるかもしれませんが、iOSと実装方法を共通化できる点でjsonを使うのがおすすめです。

private fun setMapStyle(mapView: GoogleMap?) {
        mapView?.let { view ->
            val nightModeFlags = requireContext().resources.configuration.uiMode and
                Configuration.UI_MODE_NIGHT_MASK

            if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) {
                view.setMapStyle(
                    MapStyleOptions.loadRawResourceStyle(
                        requireContext(), R.raw.map_dark
                    )
                )
            } else {
                view.setMapStyle(
                    MapStyleOptions.loadRawResourceStyle(
                        requireContext(), R.raw.map
                    )
                )
            }
        }
    }

最後に

両OSともにシンプルなコードでGoogle Mapを表示できたと思います。 もう少し突っ込んだことを実装したい場合には公式ドキュメントが細かく記載されているため確認してみるといいと思います。

参照

developers.google.com

qiita.com

developers.google.com

developers.google.com

akira-watson.com

qiita.com

qiita.com

KotlinのCoroutineについて

今回はCoroutineについてまとめようと思います

Coroutineは並行実行のデザインパターンです。 そして、特定のスレッドに束縛されない中断可能な計算インスタンスです。
非同期処理で用いられますが、Threadよりも軽量で、実行途中で処理を中断・再開することができます。
今まではRxJavaを使っていましたが、Googleが公式に提供しているのでCoroutineが非同期処理の王道になっています。

Coroutineを使うことによって、作成したクラスが残ってしまうことによるメモリリークを考慮する必要がありません。 ThreadやAsyncTaskを使用する場合、作成したクラスからライフサイクルが終了したActivityやViewModel内のメソッドを呼び出されメモリリークを引き起こすリスクがあります。

Coroutine Scope

CoroutineScopeは、Coroutineが実行される範囲です。
Coroutineは通常、CoroutineScopeで起動されます。 これにより、管理されずに失われるCoroutineがなくなり、リソースが無駄になりません。

CoroutineScopeはライフサイクルに関連付けられ、スコープ内のコルーチンの存続期間を設定します。
Scopeがキャンセルされると、そのJobはキャンセルされ、キャンセルが子Jobに伝播します。
Scope内の子ジョブが例外で失敗すると、他の子Jobがキャンセルされ、親Jobがキャンセルされて、例外は呼び出し元に再度スローされます。

CoroutineScopeは、launchまたはasyncを使用して作成したCoroutineをすべて追跡します(launchやasyncは後述します)。 Android では、独自のCoroutineScopeを提供しています。 たとえば、ViewModelには viewModelScope、Lifecycle(Activityなど)には lifecycleScopeがあります。 ただし、ディスパッチャとは異なり、CoroutineScopeでCoroutineは実行されません。

Coroutineは実行するためには次で説明するBuilderが必要です。

Builder

BuilderとはCoroutineScopeを起点にスレッドを起動して、引数で指定されたタスクブロックを実行します。 簡単にいうと、Builderの役割はコルーチンを開始することです。 Builderは3種類あるのでそれぞれみていきましょう!

launch

launch関数でCoroutineを開始でき、コードブロック内に並列に実行する処理を記述できます。 launchは新規コルーチンを開始し、呼び出し元に結果を返しません。

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch {
        println("World!")
    }
    GlobalScope.launch {
        println("start")
    }
    println("end")
}

上記の処理だと"World!"と"start"は並列なのでどちらが先に出力されるかは明確には決まっておらず、順番が変わる可能性はあります。

また、上記の例の GlobalScopeは先ほど軽く触れたCoroutineScopeと呼ばれています。

※GlobalScopeは取扱注意のCoroutineScopeとして定義されています。 基本的には使わないという認識でいた方がいいと思われます。
GlobalScopeはどのJobにもバインドされません。 GlobalScopeはアプリケーションの寿命を通じて動作する最上位のコルーチンを起動するには使用され、途中でキャンセルされません。
GlobalScope で起動されたアクティブなCoroutineは、プロセスをaliveに維持するものではなく、 デーモンスレッドのように機能します。
GlobalScope は、使用すると誤ってメモリリークを発生させてしまう可能性があるため、取り扱いに注意な API です。

launch() 関数でコルーチンを起動すると、Jobインスタンスが返されます。 Jobは、コルーチンへの参照を保持するため、そのライフサイクルを管理できます。
Jobを使用すると、Jobのステータス(アクティブ、キャンセル、完了)を確認できます。
Coroutineと、そのCoroutineが起動したCoroutineがすべての処理を完了していれば、Jobは完了です。
なお、Coroutineは別の理由(キャンセルや例外による失敗など)で完了することもありますが、その時点でJobは完了したとみなされます。

また、メインの処理とは別スレッドになるので、launchしたスレッドが終わるまで待つということはありません。 スレッドの状態を確認するプロパティがあるので、うまく使って待ってあげる必要があります。

async

asyncはlaunchとの違い、戻り値の型に特に制限が無いため、任意の値を返せます。(Deferredを返却) また、launchの場合、処理が終わったかどうかをプロパティで判断していました。 asyncはDeferredオブジェクトを返します。
これは特定の型のインスタンスを後で返すオブジェクトです。
また、キャンセルも行えます。
Deferred に対して、 await を呼び出すことで最終的なデータを受け取ることができます。

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val n = 2
    val add = async {
        delay(1000L)
        n + 1
    }
    val multi = async {
        delay(1000L)
        n * 3
    }

    val amount = add.await() + multi.await()
    println(amount) // (2+1と2 *3の合計の9が出力される)
}

runBlocking

runBlockingは自身を実行したスレッドをブロックするBuilderです。

fun main() {
    println("start")
    runBlocking {
        println("coroutine!")
    }
    println("end") // start、coroutine!、endの順で表示される
}

上記の例をみるとrunBlockingの処理が終わったら後続の処理が行われていることがわかると思います。

また、runBlockingは実行したCoroutineの戻り値を取得することもできます。 先程の例を次のように変更します。

fun main() {
    println("start")
    val text = runBlocking {
        "coroutine!"
    }
    println(text)
    println("end")
}

この場合も先程と同じく、"start", "coroutine!", "end" の順に出力されます。

runBlockingはCoroutineScopeの拡張関数ではないので、起点のCoroutineScopeを必要としません。 GlobalScopeをrunBlockingの定義内に持っているからです。 runBlockingはGlobalScopeを参照します。

最後にrunBlockingは自身を実行したスレッドをブロックするので、非同期という考えから外れてしまうので使うのをオススメしません。

suspend関数

次にsuspend関数についてです。 suspendは中断という意味があるので、suspend関数は中断可能な処理を表します。 中断のイメージは以下の画像です。

suspend関数は主に以下の特徴があります。

  • 暗黙に別のスレッドというのがあり、別のスレッドで行われる(コードには現れない)処理がある
  • 呼び出し自体はすぐ返ってくるが実際の処理はこの時点では行われていない
  • 実際の処理の終了は、呼び出した関数が終わったあとに、システムからコールバックで返ってくる
  • suspendというキーワードのついた関数のbody部にかかれている事は、全部この主スレッドで実行されます。 例えば、launchとかの中で呼び出せばUIスレッドであることを表す
  • suspend関数のbody部に書く関数は必ずsuspend関数になる
  • Coroutineを開始するには最低1つのsuspend関数がなければなりません

一般的な非同期処理では、呼び出した結果はすぐ戻ってきてしまいます。 呼び出した関数、例えばonCreateの実行はそのまま進んでしまい、サーバーに実際にリクエストがpostされるよりも前に最後まで実行を終えてreturnしてしまいます。 そして、別スレッドの処理が終わると、主スレッドであとからコールバックという関数が呼ばれます。

susupend関数でもほぼメカニズムは同じです。
suspend関数を呼んだ時にはすぐに返ってきて、あとから処理が終わったという通知が主スレッドに何かしらの形でやってきます。
ただ、普通のコールバックとは違ってsuspend関数の結果の呼び出しは直接はコードの上では見えないのが難しく感じる部分かもしれません。 主スレッドで呼び出されたら何か短い処理が走ってすぐ返ってきます。そして、別スレッドに通知がいって、コードには書いてない何かの処理が裏で行われる・ 別スレッドの処理が終わると、謎の通知メカニズムで主スレッドにあとから通知が来ます。
これがsuspend関数の実行されるスレッドを考える時の基本になります。

suspend関数のbody部に書く関数は必ずsuspend関数になるに関しては次のようなイメージです

suspend fun getContent(id: Int): String { /* ... */ }

suspend fun getPostContent(id: Int): String {
    val content = getContent(id)
    return content
}

「Coroutineを開始するには最低 1 つのsuspend関数がなければなりません」について、軽く補足です。

launchの定義は次のようになっています。

public fun launch(
    context: CoroutineContext = DefaultDispatcher,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

つまり、先程からlaunchなどでラムダを使用してCoroutineを作成していましたが、実はこれらは無名関数のsuspend関数だったのです。 このように明記されていなけどsuspend関数があるパターンもあります。

Channel

Channelについて説明していきます。

ChannelはメインからもCoroutineからでも使える入れ物のようなものです。
ただ、単なる入れ物ではなく、取り出す(受信する、コンシューマー)スレッドに待ってもらったり、Channelにデータを入れる(送信する、プロデューサー)スレッドにはデータが入っているので送信を待ってもらうなどの交通整理を行います。

そして、Channelは受け取ってもらえるまで送信側の処理が動き続けるので場合によってはメモリリークを引き起こす可能性があるため、注意が必要です。

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<Int>()
    launch {
        for (x in 1..5) channel.send(x * x)
    }

    // receive()は一つずつsendされた情報を受け取る
    // なお、ここで中断されるため、sendされない限り次の処理に進まない。非同期でないところに注意
    repeat(5) { println(channel.receive()) }
    println("Done!")
}

// 出力
// 1
// 4
// 9
// 16
// 25
// Done!

あまり使うイメージがわかない方もいると思いますが、次のようなメリットがあります

  • Channelを使用すると、コルーチンが待機することなく、キューに格納されたデータを受信することができます
  • Channelは、指定したサイズのバッファを持つことができます。これにより、キューに格納されたデータが多すぎても、コルーチンが待機することなく、プロデューサーが新しいデータを送信することができます
  • クローズすることができます。これにより、プロデューサーが新しいデータを送信できなくなったことを、コンシューマーに通知することができます
  • プロデューサーとコンシューマーの間で明確な制御ができます。プロデューサーが送信したデータをコンシューマーが受信するまで待機することができ、逆に、コンシューマーが受信するまで、プロデューサーが新しいデータを送信しないように制御することもできます
  • 複数のコルーチンで同じChannelにアクセスすることができます。これにより、プロデューサーとコンシューマーが別々のコルーチンで実行される場合でも、データの送受信が可能になります

Flow

Flowは、非同期ストリーム処理を扱うための機能で、Kotlinのコルーチンライブラリに含まれています。 suspend関数は非同期で一つの値を返しますが、Flowは非同期で複数の計算した値を返します。 APIからストリーミングされるリアルタイムデータやデータベースの変更通知など、非同期で取得される複数の値を処理する場合に使用することができます。

Flowは、以下のような特徴があります。

  • 再利用可能なコンポーネントとして設計されています。つまり、同じコードを複数の場所で使用することができます。Flowの定義を変更することで、複数の場所で異なる出力を生成することもできます。
  • コルーチンがキャンセルされたときに自動的にキャンセルされます。そのため、コードが複雑になることなく、非同期処理をキャンセルすることができます。
  • 多様なオペレーターを提供しています。これにより、簡単にフィルタリング、変換、結合、集約などの処理を行うことができます。
  • FlowはChannelと違い、受信する処理(collectメソッド)が実行されないと、送信処理(emitメソッド)を行いません。(ColdなObservable/ Stream)そのため、メモリーリークは発生しません。
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    launch {
        for (k in 1..3) {
            delay(500)
        }
    }
    simpleFlow().collect { value -> print(value) }
}

fun simpleFlow(): Flow<Int> = flow {
    for (i in 1..3) {
        // Thread.sleep(500)
        delay(500)   
        emit(i)
    }
}

// 出力
// 1
// 2
// 3

emit関数を使用して値がemitされ、collect関数を使用して値を取得・集計します。

Coroutine使用例

APIをViewModelで呼び出す

個人的にもっとも使うパターンがViewModelでAPI実行などの非同期処理を行うパターンです。 ViewModelでAPIの処理を非同期で行い、結果をActivityやFragmentに伝えるという流れです。

そこで、APIの処理をcoroutineで行うパターンです。取得した結果はLiveDataを使って伝搬します。

class TestViewModel(val app: Application): AndroidViewModel(app) {
    private val _sample: MutableLiveData<CustomResponse> = MutableLiveData()
    val sample: MutableLiveData<CustomResponse> = _sample

    fun get() {
        viewModelScope.launch {
            try {
                val request = TestRequest()
                val response = request.get()
                _sample.postValue(response)
            } catch {
            }
        }
    }
}

class TestRequest: CustomResponse {
    // ここで本来はAPIを実行、今回はあくまでもサンプル
    suspend fun get() {
        return CustomResponse
    }
}

class TestActivity: AppCompatActivity() {
    override fun onCrerate(saveInstanceState: Bundle?) {
        viewModel = ViewModelProvider(this)[TestViewModel::class.java]

        viewModel.sample.observe(this) { sample ->
            // ViewModelからの結果を取得
        }
    }
}

CoroutineScopeをviewModelScopeで起動します。 APIの実行部分はsuspend関数にしないとエラーが発生します。

また、LiveDataはStateFlowに置き換えることも可能です。

class TestViewModel(val app: Application): AndroidViewModel(app) {
    private val _sample = MutableStateFlow(CustomResponse())
    val sample: StateFlow<CustomResponse> = _sample

    fun get() {
        viewModelScope.launch {
            try {
                val request = TestRequest()
                val response = request.get()
                _sample.value = response
            } catch {
            }
        }
    }
}

class TestRequest: CustomResponse {
    suspend fun get() {
        return CustomResponse
    }
}

class TestActivity: AppCompatActivity() {
    override fun onCrerate(saveInstanceState: Bundle?) {
        viewModel = ViewModelProvider(this)[TestViewModel::class.java]

        lifecycleScope.launch {
            viewModel.sample.collect {
            }
        }
    }
}

変更通知を受け取るには collect を使います。 collect は suspend 関数になってるので、 lifecycleScopeで起動します。

そして、MutableStateFlow の場合は必ずコンストラクタに初期値にパラメータが必要になります。 そのため collect したタイミングで初期値が必ず通知されることになります。 これはコンストラクタにnullを設定した場合でも通知されるのがLiveDataと異なるので意識しましょう!

MutableStateFlowはメインスレッド・バックグラウンドスレッド関係なくvalueを設定することで更新します。 StateFlowの場合はCoroutineContextによって値を受け取るときのスレッドを制御することができます。 lifecycleScope はデフォルトではメインスレッドで動くようになっています。 これは制御可能で、次のようにすることで collect の処理を IO Workerで動かすことが可能になります。そのため、UI変更の処理をしてるともちろんクラッシュします。

lifecycleScope.launch(Dispatchers.IO) {
    viewModel.stateFlow.collect {
        // メインスレッドで実行してないので、UIを変更しようとするとクラッシュする
        textView.text = "$it"
    }
}

EventBus

coroutinesを使ってEventBusのように使うことができます。

EventBusはEventが発火されたら、そのEventの発火を待ち受けていた別の箇所で処理をする仕組みです。 イベントを発行するオブジェクト(Publisher)と、そのイベントに対する処理を実行するオブジェクト(Subscriber)をつなぐ仕組みを提供します。 EventBusは、PublisherとSubscriberを疎結合にすることで、コードの柔軟性と保守性を向上させることができます。 つまり、PublisherとSubscriberは、EventBusを介して明示的に接続される必要はありません。 また、PublisherとSubscriberが同じスレッド上で実行されている必要もありません。

EventBusをRxJavaで作っていた方が今までは多いと思います。 Courtineで作成する場合には、Channelを使って可能ですが、演算のオペレータがなかったり、closeを忘れるとメモリリークを引き起こす可能性があります。 そこでFlowを使ってEvent Busを実現させます。

まずはイベントを管理するクラスを作成します

class EventFlow {
    private val flow = MutableSharedFlow<String>()

    suspend fun sendEvent(event: String) = flow.emit(event)

    fun subscribe(scope: CoroutineScope, onConsume: (String) -> Unit) {
        flow.onEach {
            onConsume(it)
        }.launchIn(scope)
    }
}

今回はホットストリームなFlowとして扱えるSharedFlowを使います。 これにより複数箇所から呼び出してもFlow側の処理数が増えることはありません。 そして、subscribe()の引数で指定したscopeの中で、Flowのイベントを待ち受けます。

class TestActivity : AppCompatActivity() {

    private val eventFlow = EventFlow()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //EventFlowをsubscribeしてイベントを待ち受ける
        eventFlow.subscribe(lifecycleScope) {
            when (it) {
                "BUTTON_PUSHED" -> {
                    // イベントを受けとったら処理を行う
                }
            }
        }

        findViewById<Button>(R.id.button).setOnClickListener {
            runBlocking {
                //Event発火
                eventFlow.sendEvent("BUTTON_PUSHED")
            }
        }
    }
}

上記のようにsendEventでイベントを発火したらsubscribeを定義している箇所で処理を行います。 今回はイベント発火とイベントを受け取るのを同一クラスで行いましたが、別々のクラスでも可能です。

最後に

Coroutineについて紹介しましたが、まだまだ理解が浅い箇所や知らない機能もあります。 しかし、ドキュメントが充実しているので1度確認するのをオススメします!

参照

developer.android.com

kotlinlang.org

kotlinlang.org

zenn.dev

github.com

developer.android.com

developer.android.com

android.benigumo.com