every Tech Blog

株式会社エブリーのTech Blogです。

2022年 エブリーの開発組織の抱負

f:id:nanakookada:20220124122335p:plain

少し遅くなりましたが、あけましておめでとうございます。エブリーのCTO今井です。
早速ですが、2021年の振り返りと今年の抱負についてお話しできればと思います。

2021年の振り返り

2021年は開発本部に属する全開発部の部長が入れ替わり、 僕自身もDELISH KITCHEN開発部長となり、そして10月にはCTOに就任することとなりました。
会社としてもPMV(パーパス・ミッション・バリュー)を定めるなど、次のステップに向けて足場を整え、
深くしゃがんだ1年だったように思います。
個人としては、未知の領域がさらに増えパンクしそうになりながらも、
チームの仲間のサポートもあり、過去一番成長した1年だったと思います。

今後の抱負

DataUtilization

全社をあげてDataUtilization(データ活用)を推進しています。
これはデータを活用できる人材を増やすことと、活用可能なデータを増やすことの両面が必要だと考えます。
開発本部ではこの両面を支援すべく尽力していきたいと思います。

具体的には非エンジニアのSQLの習得、より利用しやすいデータプラットフォームの構築、
AI・機械学習の推進などはその第一歩になると考えています。

事業にこだわる開発組織へ

僕が大切にしている価値観の1つに エンジニアが事業を引っ張る というのがあります。
当たり前ですが、事業が求める成果を最短・最速で実現することが、もちろんエンジニアにも求められます。
これに愚直にこだわれる組織にしていきたいと思います。

開発をしていると目の前の機能実装に追われ、この機能が事業を伸ばす上で本当に必要なのか、
もっと良い手段がないのか、それを考えるのをおろそかにしてしまうこともあるかもしれません。
時には最新の技術よりも既に涸れてる技術を選択することもあるし、
コストを払ってスピードを優先することもコスト削減のために地道な開発をすることもあると思います。
その時々で最適な選択は事業に一番貢献することだということに立ち返って、
開発を進められるようになって欲しいと思います。

技術的な挑戦の推進

事業にこだわる一方で、新しい技術やよりチャレンジングな挑戦をしていかないと衰退していくのがエンジニアだと思います。
僕個人も打算的である程度想定できる範囲で開発をすることよりも、
今まで使ったことない、未知の技術に触れながら開発する方がワクワクします。
それはエンジニアの働きがいなどにつながることもあれば、
長い目で見て生産性の向上や、採用にもつながることも大きいと考えています。

目の前の事業貢献と天秤にかけた時に、どちらを取るのかは大変難しい判断になることもありますが、
日々事業貢献を考えているエンジニアが必要と判断した場合に、積極的に挑戦できるよう、
周囲へ理解してもらうための説明やそのための予算取りなどで推進していけたらと思います。

まとめ

上記では足りないことも多々あるとは思いますし、
自分自身、CTOになったばかりでこの考えが数ヶ月後には変わっていることがあるかもしれませんが、
それも成長だと思いますし、日々変化の多いこの業界だからこそだとも思います。

エブリーでは常に仲間を募集しています。
少しでも共感する部分があった方、または自分ならもっとこう推進できるのにと思った方、
まずはカジュアルに面談からでもお待ちしております!

コーポレートサイト リクルートページ

エブリー公式オウンドメディア

Datadog APM を試してみた話

f:id:nanakookada:20211213104750p:plain

はじめに

はじめまして。DELISH KITCHEN 開発部でバックエンド開発を担当している池と申します。2021 年 9 月にエブリーに転職してバックエンドエンジニアとして働いています。入社して 3 ヶ月ですがサーバーサイド、フロントエンド、クラウド、CI/CD など多岐に渡る技術領域を触ることができ、とても有意義な毎日を送っています。

今回はこれまでに触ってきた技術の中から Datadog APM を試した際の内容についてご紹介したいと思います。

Datadog APM とは

ご存知の方も多いとは思いますが、Datadog は SaaS 型運用監視サービスです。様々なプラットフォームにおけるホストの監視、アプリケーション監視、ログ蓄積などシステム監視全般を Datadog 一つで行うことができます。その中で APM(Application Performance Management)は、名前の通りアプリケーションのパフォーマンスを監視する機能になります。

詳細は後述しますが、Datadog APM は分散トレーシングという監視の手法を用いており、マイクロサービスのような分散したアーキテクチャのフロントエンドからデータベースまで、エンドツーエンドのアプリケーション監視を行うことができます。

下記は具体的に計測できるメトリクスの一例です。

  • 時系列でのリクエスト数・レイテンシー、エラー数
  • エンドポイント毎のリクエスト数・レイテンシー、エラー率
  • 各リクエストの処理時間・ボトルネック

分散トレーシングについて

分散トレーシングは分散されたアーキテクチャのアプリケーションを監視するための手法です。 マイクロサービスのような複数システムから構成されるアーキテクチャでは、複数のサービスをまたいで処理が動くため、全体の振る舞いを把握することが難しいという課題があります。 また、障害発生時に原因を特定することも困難です。 分散トレーシングの手法を用いることで、サービス間をまたいだ処理の計測や可視化が可能になり、それら課題の解決に繋がります。

簡単に用語を説明します。分散トレーシングの主要な用語としてトレース(Trace)とスパン(Span)があります。

※ 公式のAPM 用語集から抜粋

用語 意味
トレース(Trace) トレースは、アプリケーションがリクエストを処理するのに費やした時間と、このリクエストのステータスを追跡するために使用されます。各トレースは、一つまたは複数のスパンで構成されます。
スパン(Span) スパンは、特定の期間における分散システムの論理的な作業単位を表します。複数のスパンでトレースが構成されます。

例えば、API サーバが一つのリクエストを受け取ってからレスポンスを返却する一連の処理を計測するとします。よくある API では、クライアントからリクエストが送られてきた後、DB や外部サーバなど複数のサービスと連携してデータを処理し、ビジネスロジックを経由して最終的にレスポンスが返却されると思います。

それらをトレースとスパンを用いて表すと次の図のようになります。各サービスにおける処理の単位がスパンで表され、複数のスパンで構成される一連の全体処理がトレースで表されます。

f:id:yutaike:20211210162937p:plain

実装方法

前提

今回は下記の前提のもと、 Datadog APM の導入および、一つの API リクエストを受け取ってからレスポンスを返却するまでのトレース計測の実装を行います。

  • Go で実装されている API サーバでトレース処理を実装する
  • Web フレームワークには echo を利用
  • API サーバは AWS ECS で管理

次の図は今回取り扱う全体のイメージ図です。Server へ Datadog Agent を導入し、API アプリケーション本体へトレース計測の処理を実装します。

f:id:yutaike:20211210163049p:plain

datadog-agent の導入

datadog-agent は計測対象の API が動作しているサーバーで起動される必要があります。公式の datadog-agent コンテナを AWS ECS のタスク定義から起動することで、簡単に導入することができます。

次のようにコンテナの定義をタスク定義に設定します。詳細なセットアップ方法は公式ページを参照ください。

f:id:yutaike:20211210163116p:plain f:id:yutaike:20211210163128p:plain:w400

トレースの実装

Datadog APM の trace ライブラリを導入します。

go get gopkg.in/DataDog/dd-trace-go.v1/...

トレースエージェントにホスト設定を伝えます。

import(
    "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" // traceライブラリ追加
    ...

)

func main() {
    ...

    resp, err := http.Get("http://169.254.169.254/latest/meta-data/local-ipv4")
    bodyBytes, err := ioutil.ReadAll(resp.Body)
    host := string(bodyBytes)
    if err == nil {
        //set the output of the curl command to the DD_Agent_host env
        os.Setenv("DD_AGENT_HOST", host)

        // tell the trace agent the host setting
        tracer.Start(tracer.WithAgentAddr(host))
        defer tracer.Stop()
    }

次にスパンを実装します。

スパンの実装には Datadog が公式でサポートしている専用の統合ライブラリを利用します。この専用の統合ライブラリは、一般的に広く用いられている Go の Web フレームワークや、データストア、ライブラリを Datadog APM に統合するために作られているライブラリで、それら Web フレームワークやライブラリと互換性を持っています。(一覧はこちらを参照ください。)

今回の例では、echo と olivere/elastic ライブラリを専用の統合ライブラリに置き換えます。

echo を専用の統合ライブラリに置き換える

import(
    ddEcho "gopkg.in/DataDog/dd-trace-go.v1/contrib/labstack/echo" //追加
    "github.com/labstack/echo"
    ...
)

func main() {
    ...

    e := echo.New()
    e.Use(ddEcho.Middleware(ddEcho.WithServiceName("echo-service-name-test")))

専用の統合ライブラリを import し、datadog echo で準備されている Middleware をecho.Useすることで統合することができます。

統合することで、datadog echo によって API リクエスト処理のスパンが計測されて datadog-agent に送られ、Datadog の UI 上で可視化されるようになります。

f:id:yutaike:20211210163900p:plain
datadog echoで計測したスパンのFlameGraph

olivere/elastic を専用の統合ライブラリに置き換える

次に olivere/elastic を専用の統合ライブラリに置き換えます。

import()
    elastictrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/olivere/elastic"
    ...
)

func NewElasticSample() *ElasticSampleImpl {
    tc := elastictrace.NewHTTPClient(elastictrace.WithServiceName("my-es-service-test"))
    cli, _ := elastic.NewSimpleClient(
        ...
        elastic.SetHttpClient(tc),
    )

echo と olivere/elastic を同じトレースとして認識させるためには、同じ Context を利用する必要があります。

今回は簡易的に特定の elastic の呼び出し時に echo の Context を渡すように実装して動作を確かめました。

// SampleMethod
func (s *ElasticSampleImpl) SampleMethod(c echo.Context) {
    svc := s.client.Search().Index("Sample")
    ctx = c.Request().Context()
    result, err := svc.Do(ctx)
    ...

以上の実装により、各サービス(echo、elasticsearch)の処理時間が計測されるようになりました。

f:id:yutaike:20211210163451p:plain
サービス毎の処理時間

また、ここに示すのは一部ですが、様々なパフォーマンスデータを UI 上で確認することができ、それらのデータに対してアラート設定や、リアルタイム検索の機能なども実行可能です。

  • エンドポイント毎のリクエスト数
  • 時系列毎のリクエスト数・レイテンシー
  • レイテンシー分布

f:id:yutaike:20211210163512p:plain
エンドポイント毎の計測結果

f:id:yutaike:20211210172722p:plain
時系列でのリクエスト数・レイテンシー

f:id:yutaike:20211210172737p:plain
レイテンシー分布

Continuous Profiler の導入

次に Datadog Continuous Profiler という機能を試します。この機能はアプリケーションの性能をプロファイリングする機能です。

APM と Continuous Profiler の双方を有効化することで、Code Hotspots という機能によりコードベースでパフォーマンスのボトルネックを特定することができると記載があったため、試してみました。

APM 分散型トレーシングと Continuous Profiler の双方が有効化されたアプリケーションプロセスは自動的にリンクされるため、Code Hotspots タブでスパン情報からプロファイリングデータを直接開き、パフォーマンスの問題に関連する特定のコード行を見つけることができます。

※ 引用元:https://docs.datadoghq.com/ja/tracing/profiler/connect_traces_and_profiles/

Go では下記のプロファイルタイプがサポートされています。(詳細はこちらを参照ください。)

  • CPU Time
  • Allocations
  • Allocated Memory
  • Heap Live Objects
  • Heap Live Size
  • Mutex
  • Block
  • Goroutines

プロファイリングの有効化は、次のように数行で実装できます。

import(
    "gopkg.in/DataDog/dd-trace-go.v1/profiler"
    ...
)

func main() {
    ...

    if err := profiler.Start(
        profiler.WithService("profiler-service-name"),
        profiler.WithEnv("profiler-env-test"),
        profiler.WithProfileTypes(
            profiler.CPUProfile,
            profiler.HeapProfile,

            // The profiles below are disabled by
            // default to keep overhead low, but
            // can be enabled as needed.

            // profiler.BlockProfile,
            // profiler.MutexProfile,
            // profiler.GoroutineProfile,
        ),
    ); err != nil {
        log.Fatal(err)
    }
    defer profiler.Stop()

上記の実装を行うだけで、プロファイリング結果が Datadog の UI 上で可視化されます。

f:id:yutaike:20211210172802p:plain
時系列での CPU Time 上位 10 件

f:id:yutaike:20211210172814p:plain
CPU Time の FrameGraph

Code Hotspots

残念ながら Code Hotspots 機能はまだ Go に対応していませんでした。

f:id:yutaike:20211210172836p:plain
Code Hotspots 未対応(2021/10時点)

dd-trace-go の github を見ると Code Hotspots に関する PR が出ているので開発中であることがわかります。 アップデートに期待します。

料金 / サンプリング

最後に料金とサンプリングについてご紹介します。 (※2021/10 時点での料金体系です。)

料金

公式の料金ページから最小料金と加算費用を抜粋しました。

年払い
最小料金(1 ホスト、1 か月あたり) $31(オンデマンド払いは$36)
プラス料金:スパンの取り込み APM ホストあたり 150GB (すべての APM ホスト平均)無料、その後 $0.10 /GB
プラス料金:スパンの保存 APM ホストあたり Indexed Span 100 万件 (すべての APM ホスト平均)、その後
・保存期間 7 日、$1.27 / 100 万スパン / 月 (年払いまたはオンデマンド払いで $1.91)
・保存期間 15 日、$1.70 / 100 万スパン / 月 (年払いまたはオンデマンド払いで $2.55)
・保存期間 30 日、$2.50 / 100 万スパン / 月 (年払いまたはオンデマンド払いで $3.75)
プラス料金:アドオン AWS Fargate $2 / タスク

簡単にですが、以下の仮定をもとにざっくりと料金を試算します。

仮定

  • 月の総リクエスト数:1 億リクエスト(1 日あたり約 333 万リクエスト)
  • ホスト数:10 ホスト
  • 1 リクエスト:3 スパン
  • スパンの保存期間:7 日
  • 年払い
  • スパンの取り込みは試算が難しいため除く
  • AWS Fargate アドオンのプラス料金は掛からない
  • $1 = 113 円換算

料金試算

  • 最小料金:$31 × 10 ホスト = $310
  • スパン数:1 億リクエスト × 3 スパン = 3 億スパン
  • スパンの保存料金:(3 億スパン - 100 万スパン) ÷ 100 万スパン × $1.27 ≒ $380
  • 総料金:$310 + $380 = $690 ≒ 77,970 円

本来はここに、スパンの取り込みに応じたプラス料金と、もし利用があれば AWS Fargate アドオンのプラス料金が加算されます。 この試算のように全トレースデータを取り込んで保存すると大きな費用がかかるため、実際には一部のデータを取り込むようにサンプリングすることで費用を抑えつつ運用する形が現実的だと考えられます。

サンプリング

サンプリングは次の図の「Your instrumented applications」と「Intelligent retention & custum filters」の 2 箇所で設定することができます。前者はサーバで設定する Datadog に送るトレースのサンプリング設定です。後者は送られてきたトレースに対して Datadog の UI 上で設定するトレース保存のフィルター設定になります。

f:id:yutaike:20211210174109p:plain
Datadog にトレースが保存されるまでのフロー
※引用元:https://docs.datadoghq.com/ja/tracing/trace_retention_and_ingestion

本記事ではサーバ側のサンプリング設定についてご紹介します。

サーバ側のサンプリングは、デフォルト設定で 50 トレース / 1 秒まで 100%のトレースを取り込まれる設定になっています。超えた分は Datadog Agent によって自動的に選択/削除されて Datadog へ送られます。

このデフォルトのサンプリング率は、下記の環境変数を設定することで変更できます。

DD_TRACE_SAMPLE_RATE = 1.0

基本的にはDD_TRACE_SAMPLE_RATEに基づいて Datadog Agent が自動的にサンプリングしてくれるのですが、Span にMANUAL_KEEPMANUAL_DROP タグを追加することで、優先的に 100%保持・削除するように設定できます。

// 100%削除
span.SetTag(ext.ManualDrop, true)

// 100%保持
span.SetTag(ext.ManualKeep, true)

ここまでがサーバ側で設定できるサンプリング設定になります。

最終的には Datadog の UI 上で設定するフィルターを通して保存されるトレースが決定されるので、そちらの詳細はこちらを参照ください。

まとめ

今回 Datadog APM を触ってみて、公式のドキュメントが豊富だったため、大きく迷わずに導入を進めることができました。一方、既に運用されている複数のサービスにおいて導入・整備を進める作業にコストがかかることもわかりました。特に、サービス間のスパンを紐付けるために同一の Context を用いる設計にする必要があることや、専用の統合ライブラリに未対応のライブラリも多くあることから、既存システムへの導入の難しさを感じました。

しかし、本記事では紹介できていない機能もとても数多くあり、導入して環境整備さえできてしまえば、Datadog 一つにシステム監視ツールを統一でき、相応のメリットがあると思います。

また、CodeHotspots が Go に対応されれば、コードベースのパフォーマンス分析が可能になってメリットも増えるため、アップデート情報を待ちたいと思います。

最後まで閲覧いただきありがとうございました。少しでも参考になれば幸いです。

Jetpack Composeのチュートリアルをやってみた

f:id:nanakookada:20211126110305p:plain

はじめに

こんにちは。MAMADAYSでAndroidアプリの開発を担当している高野です。

UIはXMLで作成したほうが楽なのではないかと思い、まだ Jetpack Composeを触ったことがなかったのでチュートリアルに沿って進めながら体験してみたいと思います。

※Jetpack Compose は Android の UI を構築するための新しいツールキットです。2021年7月にバージョン 1.0 をリリースし、チュートリアルのページも用意されました。

チュートリアルでやること

チュートリアルでは4つのレッスンを通して次のことを学ぶことができるようです。

  1. 要素の追加
  2. プレビュー方法
  3. 複数要素のレイアウト
  4. デザインテーマの適用
  5. リストの実装
  6. アニメーションの実装

環境の準備

現在使っているAndroid Studioをそのまま使用します。

  • Android Studio Arctic Fox 2020.3.1 Patch 3

プロジェクトは新規で作成し、テンプレートは Empty Activity を使用します。

f:id:itsmynote:20211124185126p:plain:w380

Empty Activity のプロジェクトでJetpack Composeを使用できるようにセットアップの例に合わせてgradleファイルを修正します。

build.gradle

@@ -6,7 +6,7 @@ buildscript {
     }
     dependencies {
         classpath "com.android.tools.build:gradle:7.0.3"
-        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31"
+        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.21"

         // NOTE: Do not place your application dependencies here; they belong
         // in the individual module build.gradle files

app/build.gradle

@@ -15,7 +15,9 @@ android {

         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
     }
-
+    buildFeatures {
+        compose true
+    }
     buildTypes {
         release {
             minifyEnabled false
@@ -28,6 +30,11 @@ android {
     }
     kotlinOptions {
         jvmTarget = '1.8'
+        useIR = true
+    }
+    composeOptions {
+        kotlinCompilerVersion '1.4.21'
+        kotlinCompilerExtensionVersion '1.0.0-alpha10'
     }
 }

@@ -37,7 +44,17 @@ dependencies {
     implementation 'androidx.appcompat:appcompat:1.3.1'
     implementation 'com.google.android.material:material:1.4.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
+
+    implementation 'androidx.compose.ui:ui:1.0.0-alpha10'
+    implementation 'androidx.compose.ui:ui-tooling:1.0.0-alpha10'
+    implementation 'androidx.compose.foundation:foundation:1.0.0-alpha10'
+    implementation 'androidx.compose.material:material:1.0.0-alpha10'
+    implementation 'androidx.compose.material:material-icons-core:1.0.0-alpha10'
+    implementation 'androidx.compose.material:material-icons-extended:1.0.0-alpha10'
+    implementation 'androidx.compose.runtime:runtime-livedata:1.0.0-alpha10'
+
     testImplementation 'junit:junit:4.+'
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+    androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.0.0-alpha10'
 }

この後の流れはチュートリアルの内容をなぞるだけなので実装については割愛します。

チュートリアルを終えて

チュートリアルはとてもよくできていて、つまずくポイントはほとんどありませんでした。強いて言えば、Lesson3のマテリアルデザインを使用するためのComposeTutorialThemeって何だろう…?という部分ぐらいです。一通り終えるため、今回はThemeと入力した時にサジェストされた MaterialThemeという記述で代用しました。

Lesson1からLesson4までを通して次のような成果物ができました。

f:id:itsmynote:20211124185131g:plain

少しカスタマイズしてみる

チュートリアルのサンプルはチャット画面のようなレイアウトになっています。サンプルのままだと一人でチャットしているようで寂しいので、登場人物を二人にしてみたいと思います。

イメージとしてはこんな感じです。

f:id:itsmynote:20211124185205p:plain:w380

Message の修正

データクラスMessageをsealed classにしてMe/Youを追加します。

sealed class Message {
    abstract val author: String
    abstract val body: String

    data class Me(override val author: String, override val body: String) : Message()
    data class You(override val author: String, override val body: String) : Message()
}

サンプルデータ

サンプルデータは好みで修正します。

object SampleData {
    // Sample conversation data
    val conversationSample = listOf(
        Message.You(
            "Colleague",
            "Test...Test...Test..."
        ),
        Message.Me(
            "Me",
            "List of Android versions:\n" +
...

幅一杯に広げる

ModifierクラスにfillMaxWidthという関数があるので追加します。

@Composable
fun MessageCard(msg: Message) {
    Row(
        modifier = Modifier
            .padding(all = 8.dp)
            .fillMaxWidth()

位置の調整

MessageCardを含むRowの定義を見てみます。

@Composable
inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
) {
        ...

horizontalArrangementという引数があります。この引数にArrangementの値を渡すことで位置の調整ができるようです。horizontalArrangementを指定する際に引数msgの型を判定して位置を調整します。

@Composable
fun MessageCard(msg: Message) {
    Row(
        modifier = Modifier
            .padding(all = 8.dp)
            .fillMaxWidth(),
        horizontalArrangement = when (msg) {
            is Message.Me -> Arrangement.End
            is Message.You -> Arrangement.Start
        }

合わせて、アイコンや名前・メッセージも修正を加えます。名前とメッセージはColumnの中なので、Columnの定義をみてみます。

inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {
        ...

Columnの場合はhorizontalAlignmentAlignmentを指定するようですね。ついでにsurfaceColorも修正しています。

if (msg is Message.You) {
    Image(
        painter = painterResource(R.drawable.ic_launcher_foreground),
        modifier = Modifier
            .size(40.dp)
            .clip(CircleShape)
            .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
    )

    Spacer(modifier = Modifier.width(8.dp))
}

...
val surfaceColor: Color by animateAsState(
    if (isExpanded) MaterialTheme.colors.primary else when (msg) {
        is Message.Me -> MaterialTheme.colors.surface
        is Message.You -> MaterialTheme.colors.secondary
    },
)

Column(
    modifier = Modifier.clickable { isExpanded = !isExpanded },
    horizontalAlignment = when (msg) {
        is Message.Me -> Alignment.End
        is Message.You -> Alignment.Start
    }
) {
        ...
}

if (msg is Message.Me) {
    Spacer(modifier = Modifier.width(8.dp))

    Image(
        painter = painterResource(R.drawable.ic_launcher_foreground),
        modifier = Modifier
            .size(40.dp)
            .clip(CircleShape)
            .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
    )
}

エミュレータで確認

チャット画面らしくなりましたね。

f:id:itsmynote:20211124185207p:plain:w380

最後に

Composeでの実装は想像していたよりずっと簡単でした。特に、リストがたった5行で実装できることに驚きました。RecyclerViewと比べるととても簡単ですよね。

まだComposeに慣れていないためもどかしさは感じましたが、簡単なアプリをいくつか作っていくうちに感覚的につかめそうだなといった印象です。

@angular/flex-layoutをtailwindcssで置き換える

f:id:nanakookada:20211116163840p:plain

はじめに

こんにちは、DELISH KITCHEN開発部でバックエンド開発を担当している高木です。 DELISH KITCHENのリテールソリューションズ事業部(以下、RS事業部)が、小売向けに展開している店頭サイネージの管理画面等の開発をしています。

DELISH KITCHEN RS事業部で提供している管理画面は、AngularというWebフレームワークを用いて開発されています1。 画面のレイアウト(Flexbox, Grid CSS + mediaQuery)には、Angular公式である@angular/flex-layoutというライブラリを使用していたのですが、 開発が活発ではないのと、ずっとベータの状態というのもあり、他の方法を模索していました。 そんな時に最近、tailwindcssというCSSフレームワークが話題になっていたのと、@angular/flex-layoutと同じようにHTML上でレイアウトが記述できることから、試しに置き換えた話をします。

tailwindcssとは

flex、pt-4、text-center、rotate-90などのCSSクラスが集まったutility-first CSSフレームワークです。下記のように、用意されているutilityクラスをHTML上で組み合わせることによってデザインしていくことが出来ます。

<div class="flex flex-row gap-2 p-4">
  <div class="text-blue-600 text-xl">Hello,</div>
  <div class="text-green-600 text-2xl">World</div>
</div>

導入

Angular CLI v11.2からtailwindcssが公式サポートされたので、導入は簡単です。 まずはtailwindcssをプロジェクトに追加します。

npm install tailwindcss

インストールが終わったら、設定ファイルを生成します。

npx tailwindcss init

初期設定のままでは不都合が多いので、下記の項目を設定しています。

  • purge
    • tailwindcssで用意されている全クラスが含まれてCSSのサイズが肥大化してしまので、使用しているクラス以外は除外する設定
  • prefix
    • クラス名が他のCSSフレームワークやアプリ固有のクラスと被らないための設定
  • screens
    • レスポンシブ対応のための設定で、ここではモバイルとそれ以外で分けている
  • important
    • tailwindcssのクラスを常に優先させるための設定
module.exports = {
  prefix: 'tw-',
  purge: {
    content: ['./apps/**/*.{html,ts,css,scss}', './libs/**/*.{html,ts,css,scss}']
  },
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
    screens: {
      xs: { max: '599px' },
      'gt-xs': { min: '600px' }
    }
  },
  variants: {
    extend: {}
  },
  plugins: [],
  important: true
};

最後にstyles.scssに下記を追加します。

@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

公式ドキュメントではtailwindcss/baseも追加していますが、これは標準タグのスタイルに変更が入ってしまうので、抜いています。

以上で導入は完了したので、次は実際に@angular/flex-layoutの部分をtailwindcssに置き換えていきます。

変換表

@angular/flex-layoutは、tailwindcssと同じくHTML上でレイアウトを記述するので移行は簡単でした。 プロジェクトで使用していたAPIのみ、下記に変換表を載せておきます。

@angular/flex-layout tailwindcss
<div fxLayout="column"> <div class="tw-flex tw-flex-col">
<div fxLayout="row"> <div class="tw-flex tw-flex-row">
<div fxLayout.xs="column" fxLayout.gt-xs="row"> <div class="tw-flex xs:tw-flex-col gt-xs:tw-flex-row">
<div fxLayout="row wrap"> <div class="tw-flex tw-flex-wrap">
<div ... fxLayoutGap="16px"> <div class="... tw-gap-4">
<div ... fxLayoutAlign="start center"> <div class="... tw-items-center">
<div fxFlexFill> <div class="tw-w-full">

まとめ

@angular/flex-layoutからtailwindcssにレイアウト部分の記述を移行しました。Angularが公式にtailwindcssをサポートしているのと、 どちらも似たような書き方で記述できることから、移行は比較的に簡単に出来ました。

今回tailwindcssを使用してみて、用意されているクラス名を組み合わせていくことで、クラス名を考える必要がなくなり、 CSSサイズの削減にも繋がるメリットを感じられたので、レイアウト以外のスタイルもtailwindcssに乗り換えていくことを検討中です。

SwiftUIをiPadのSwift Playgroundsで試してみた

f:id:nanakookada:20211028165115p:plain

はじめに

こんにちは、DELISH KITCHENのiOSアプリ開発をしている山口です。

今年のWWDC21でiPadのSwift Playgroundsを使ってアプリ製作ができるようになるというアナウンスがありました。本当はそれを試そうと思ったのですが、執筆時点だとまだPlaygroundsが対応していないようなので、今回は前段として、iPadのPlaygroundsでSwiftUIを使って簡単な動くものを作ろうと思います。

WWDC 2021

そもそも今までXcodeでの開発はやっているもののMac・iPadどちらのPlaygroundsもまともに使ったことがなく、またSwiftUIもちゃんと使ったことがない状態からのスタートになります。

使用端末は、iPadPro 11インチ(2018)です。

実作に作ってみる

あまりデザインなどは考えずに、カップラーメンタイマーを作ろうと思います。
カップラーメンの種類によっても時間が違ってくるので、3分、5分のようにデフォルトでいくつか設定できるのと、すこし硬めに麺を作りたい時もあると思うので、自分で時間を設定できるようにしようと思います。

1. プロジェクトをつくる

左上の新規作成マークを押すと新しいプロジェクトファイルができました。

f:id:eta0:20211026163255j:plain
プロジェクト新規作成後

そもそもSwiftという言語を学ぶためのアプリということもあり、Page(キャプチャーのようなものを作って)ステップごとに学んでいけるようになっているようです。
Source Code部分は走らせた時に自動実行されるMainと、モジュールという構成になっています。 今回は既存のプロジェクトを参考に使っていきます。

2. Mainを書く

SwiftUIのViewをUIHostingControllerに渡してそれをPlaygroundのLiveViewに渡せば表示はできるようになるみたいです。

UIHostingController(rootView: xxx)
PlaygroundPage.current.liveView = yyy

3. SwiftUIで画面をつくる

とりあえず、こんな感じに書きました。
ちなみにモジュール内で定義しているのでMainで読むためにPublicにさせられます。

public var body: some View {
    VStack {
        Spacer()

        HStack(spacing: 24) {
            ForEach(model.preset, id: \.self) { time in 
                Button(action: {
                    setTime(time: time)
                }) {
                    Text("\(time) m")
                        .foregroundColor(selectedTime == time ? Color.black : Color.white)
                        .font(.largeTitle)
                }
                .padding(.init(top: 8, leading: 8, bottom: 8, trailing: 8))
                .background(selectedTime == time ? Color.yellow : Color.gray)
                .cornerRadius(8.0)
            }
            Button(action: {
                model.alertRelay.send(("Input new time", true))
            }) {
                Text("+")
                    .foregroundColor(Color.white)
                    .font(.largeTitle)
            }
            .padding(.init(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(Color.gray)
            .cornerRadius(8.0)
        }

        Spacer()

        Text("\(seconds) s")
            .foregroundColor(Color.white)
            .font(.system(size: 64, weight: .bold))
            .fontWeight(.bold)

        Spacer()

        Button(action: {
            isPlay ? stopTimer() : startTimer()
        }) {
            Text(isPlay ? "Stop" : "Start")
                .foregroundColor(Color.black)
                .font(.largeTitle)
                .fontWeight(.bold)
        }
        .padding(.init(top: 8, leading: 32, bottom: 8, trailing: 32))
        .background(isPlay ? Color.yellow : Color.white)
        .cornerRadius(8.0)
        Spacer()
    }
}

ただ単にVStackとHStackを組み合わせて要素を羅列しただけなのですが、Spacerが良い感じに間を取ってくれていて、それっぽいデザインになりました。

f:id:eta0:20211026163408j:plain
SwiftUIで実装した画面

ボタンを押した時の色の変更などは、SwiftUIの@State@ObservedObjectを使用すると、値の変更を自動検知して再描画してくれます。

public struct ContentView: View {
    
    @ObservedObject var model: TimerModel
    
    @State private var seconds: Int = 0
    @State private var isPlay: Bool = false
    @State private var timer: Timer? = nil
    @State private var selectedTime: Int = 0

さて、Viewが組み立てられたので、あとはStartをタップした時に再生するようにして、Stopした時に一時停止するようにすればタイマーの完成です。

3. ロジックを書く

タップした時に、Timer.scheduledTimer()を使って1秒間隔で処理を実行させれば設定した時間分カウントダウンしてくれます。

timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in
    guard seconds > 0 else {
        model.alertRelay.send(("Done", false))
        setTime(time: selectedTime)
        stopTimer()
        return
    }
    seconds -= 1
})

あとは、適当にModelを定義してプリセットの時間をいくつか持てるようにしてあげます。

final public class TimerModel: ObservableObject {
    
    @Published var preset: [Int] = [1, 3, 5]
...
}

ただカウントダウンするだけだと終わった時に気づかない可能性があるため、ダイアログを出すようにします。
今回は、Combineを使ってSwiftUI側からMain側に知らせるようにしました。

ついでに、ユーザがプリセットの時間を作る時もAlertのダイアログで指定できるようにしました。

let cancellable = model.alertRelay.sink{ [unowned vc] (title, isInputEnable) in
    guard !title.isEmpty else { return }
    
    let alertVC = UIAlertController(title: title, message: "", preferredStyle: .alert)
    if isInputEnable {
        alertVC.addTextField()
    }
    alertVC.addAction(UIAlertAction(title: "OK", style: .default) { _ in
        guard isInputEnable else { return }
        
        guard let value = Int(alertVC.textFields?[0].text ?? "") else { 
            model.alertRelay.send(("Invalid time input", false))
            return
        }
        
        model.addPresetTime(time: value)
    })
    vc.present(alertVC, animated: true)
}

これで全て動くようになりました!

カウントダウン 新しい時間をセット
f:id:eta0:20211026163521g:plain f:id:eta0:20211026174218g:plain

注意点

これは何かの相性なのかデフォルトの挙動なのかわからないのですが、プレビュー画面の左下にあるメニューの「Enable Results」が有効の状態で、実行するとSwiftUIの再描画がされない現象がありました。

最後に

普段UIKitを使ったUIしか作っていないので、今更ながらかなり新鮮でした。アイテムが規則的に並んでいる画面であれば、UIKitよりもかなり簡単に作れるので良さそうです。

iPadのPlaygrounds自体は、やはりSwiftを学ぶために最適化されていて、現時点だとアプリを作るのは大変そうです、ただMacを買わなくても身近な不満を手軽にアプリを作って解決できるようになるのは良い仕組みだなと感じました。

今回iPadのPlaygroundsのみで作ってみたのですが、最低外部キーボードがあれば、画面は多少小さいもののコーディングできそうでした。また、デスクだけでなくベッドの上などでもコーディングできるか試してみたのですが、意外とできたので将来的にiPadで快適に開発できる可能性も見えました。

以上、ありがとうございました!