エブリーエンジニアブログ エブリーエンジニアブログ

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で快適に開発できる可能性も見えました。

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

『DELISH KITCHEN』のA/Bテスト基盤を構築しました

f:id:nanakookada:20211008171022p:plain

はじめに

こんにちは。株式会社エブリーでデータサイエンティストをしている伊藤です。

『DELISH KITCHEN』では、サービスをより良くするため、新機能の開発や既存機能・デザインの改善など様々な施策が行われています。 これらの施策は、一部のユーザのみを対象とする「A/Bテスト」によってオンライン評価され、その効果が認められてからユーザ全体にリリースされます。

直近、A/Bテストの信頼性・アジリティをより高めるため、データチームが主導となり新しくA/Bテスト基盤を構築・導入しました。 本記事では、新しく導入したA/Bテスト基盤の概観を紹介させていただきます。

今回紹介するA/Bテスト基盤の活用については、少し前の記事でも紹介していただいているので、そちらも是非合わせてご覧ください。

tech.every.tv

これまでの課題

これまで、A/Bテストは各運営チームが主導となって実施されてきましたが、改めて運用体制を見直すといくつかの課題点がありました。

ここでは特に

  • ユーザグループの選び方
  • 評価指標設定と結果の解釈プロセス

の2つに関して詳しく解説します。

ユーザグループの選び方

A/Bテストは Randomized Controlled Trial (RCT) とも呼ばれ、何らかの変化(介入)がユーザにもたらす因果効果を評価するための分析手法です。 具体的には、ランダムなユーザの割り当てによって2つの同質なグループを用意し、その片方のグループ(テスト群)にのみ介入を加え変化を計測します。

2つのグループが介入の有無を除き同質ならば、計測された変化をそのまま介入による効果として扱えますが、注目する介入以外の影響が混入している場合計測された変化にはバイアスがかかり、正確な因果効果が評価できません。 従って、A/Bテストではいかにランダム性の高いユーザ選定を行うかが重要になります。

これまで『DELISH KITCHEN』で実施されていたA/Bテストでは、アプリユーザにランダムに付与されるユーザIDを利用し、その末尾の数字を元に割り当てグループが決められていました。 この方式は、ランダムなユーザIDを元にグループを分けており、また「末尾1のユーザはテスト群にする」など集計時にSQLで表現しやすいというメリットもあるため、一見すると有効な方法だと思えます。

しかしながら、この方式は割り当ての粒度が粗く、運用する上で

  1. 同時に実施される別の施策と割り当ての重複が発生しやすい
  2. 割り当て対象は施策の担当者が決定する場合が多く、選ばれる番号に偏りが生じやすい

といった状況が発生します。

1つ目の状況では、関係のない施策の効果が本来計測したい介入効果のバイアスとなるため、正確な評価が難しくなります。 また、これを回避するために関係者間での調整が発生するため、アジリティの低下に繋がる可能性があります。

2つ目の状況では、過去に実施した施策の影響(キャリーオーバー効果)が特に問題となります。 特定のユーザグループに何度も介入を続けると、そのグループは複数の介入効果を含んだ性質を持つため、一度も介入を受けていないグループとの差分が単一の施策によるものであるという保証が難しくなります。

以上から、よりランダム性の高いユーザ選定を実施するためには、ユーザID末尾を用いない方式が必要だと考えられます。

評価指標設定と結果の解釈

A/Bテストでは、CVRや機能利用回数といったビジネス目標・ユーザ体験を表す評価指標を複数設定し、コントロール群・テスト群の差分から結果を評価します。

そのため、適切な意思決定を行うためには

  • 評価指標が正しく定義され、かつ継続的に正しさが担保されている
  • 結果を解釈・評価するプロセスが整備されている

の2点が重要となります。

『DELISH KITCHEN』ではデータ分析のためのBIツールにRedash1を採用しており、誰でもSQLを書いてログの分析やダッシュボード作成が可能な環境が整備されています。

A/Bテストについても、担当者それぞれがRedashを使って評価指標設定と結果の解釈を行っていましたが、それ故に

  • 評価指標集計のために書かれたクエリが散逸しており、統一的に管理されていない
  • 結果の解釈の仕方が担当者それぞれの方法に依存してしまっている

といった課題も生じていました。

従って、より信頼性の高いA/Bテストの運用体制を実現するためには、これらのプロセス整備も必要だと考えられます。

A/Bテスト基盤方針

要件整理

以上挙げた課題をふまえて、まずA/Bテスト基盤の要件を整理したいと思います。

2017年にMicrosoftから発表された論文では、A/Bテスト環境の成長過程を表現したExperimentation Maturity Modelsが提案されています2

このモデルでは、1つ1つのA/Bテストが単発的に実施される段階から、数多くのA/Bテストが絶え間なく実施される段階までを、Crawl、Walk、Run、Flyの4段階に分類しています。

A/Bテスト基盤での技術的な観点に注目すると、A/Bテストをより効果的かつ効率的に実施するためには、以下のような仕組みが必要であるとわかります。

  • 適切な割り当てグループの作成(検定力分析、A/Aテスト、キャリーオーバー効果の制御)
  • ユーザ体験への悪影響の最小化(アラートシステム、A/Bテストの自動停止、イテレーションの効率化、介入の相互作用の制御と検知)
  • A/Bテストの実施履歴の保存

全体像

Experimentation Maturity Modelsで示されていた技術要件全てをいきなり実現するのは難しいですが、これまでの課題と照らし合わせ、最初の取り組みとして新しいA/Bテスト基盤では次の内容に取り組みました。

  • 評価指標を管理するための評価指標ライブラリの作成
  • 他の施策の影響が含まれないランダムなコントロール群・テスト群の割り当て方式の整備
  • 統計的手法に基づく考察と意思決定が可能なダッシュボードの作成

f:id:sbrf:20211006162342p:plain
A/Bテスト基盤全体像

以下では、これら3つのより詳細な内容について紹介したいと思います。

A/Bテスト基盤内容

評価指標を管理するための評価指標ライブラリの作成

評価指標ライブラリでは、A/Bテストに使われる様々な評価指標の登録と管理を行います。

ひとくちに評価指標といっても、その集計プロセスは

  • イベントデータの組み合わせ方といった大枠の集計フロー
  • 集計軸や期間・アプリバージョンなどの集計条件

の2つの要素に分解できます。

例として「アクティブユーザ数」を挙げてみます。 大枠の集計フローは、評価指標自体の性質から「アクセステーブルに記録されたユーザ数」となります。 集計軸や集計条件はユースケースにより変化し、

  • ユーザ全体について日別で集計する場合 (DAU) は、集計軸を日付に設定
  • あるA/Bテスト実施期間内のアクティブユーザ数を集計する場合は、集計軸に割り当てられるユーザグループ、集計条件にA/Bテスト対象のユーザや実施期間、対象となるOSなどを設定

というような流れになります。

従って、評価指標を扱う上では、評価指標それぞれに対して大枠のフローを定義し、それに追加条件を付与する形で集計クエリを操作できると、運用上使いやすくなると考えられます。

エブリーのデータ基盤はLakehouseプラットフォーム3を採用しており、イベントデータのETL処理は一通りSparkSQLで記述してDatabricks4上で実行可能です。 このプラットフォームを活用し、SparkSQL形式で評価指標を登録でき、用途に応じてDatabricksから集計軸などのパラメータを与えてクエリを呼び出せるような、評価指標ライブラリを構築しました。

f:id:sbrf:20211006162526p:plain
評価指標集計の流れ

評価指標ライブラリは

  1. PythonによるSparkSQLクエリの発行
  2. 集計される統計量に応じた評価指標の分類
  3. 分類ごとに使用される統計手法(サンプルサイズ計算・仮説検定など)と可視化方式の管理

の3つの機能を持ちます。

1つ目の「PythonによるSparkSQLクエリの発行」は上で述べたような流れを実現する機能で、登録されている評価指標に必要なパラメータを渡すとSparkSQLクエリが文字列として発行されます。

2つ目の「集計される統計量に応じた評価指標の分類」、 3つ目の「分類ごとに使用される統計手法(サンプルサイズ計算・仮説検定など)と可視化方式の管理」は、登録される評価指標をユースケース別に管理するために用意した機能です。 例えば「アクティブユーザ数」は単純にユーザ数をカウントする評価指標ですが、「1人あたりの検索回数」の場合は検索が実行された回数をユーザごとに集計した値の平均値が統計量となります。 集計される統計量が変わると統計処理や可視化の方式も変わるため、これらを対応づけた状態で整理しておくと、他のチームメンバーも統一した形で評価指標を発行・利用できます。

以上のような評価指標ライブラリを活用し、A/Bテスト基盤では評価指標を集約的に管理された状態で運用しています。

他の施策の影響を取り除くランダムなコントロール群・テスト群の割り当て方式の整備

前述のように、A/Bテストではランダムなグループ選定によって同質なコントロール群・テスト群を用意する必要があります。 また、介入による変化で思わぬ悪影響が発生した場合にその影響を最小限に留めるため、割り当てグループは必要十分なユーザ規模であることが望ましいです。

これらを実現するため、新しいABテスト基盤では

  1. サンプルサイズ計算
  2. ランダムサンプリング
  3. A/Aテスト

の3段階による割り当てプロセスを構築しました。

本記事では、特に2つ目の「ランダムサンプリング」について紹介します。

ランダムサンプリング

ランダムサンプリングでは、計算されたサンプルサイズを元にユーザをランダムに選定し、コントロール群とテスト群を用意します。

ここで注意すべきは、「同時期に実施されているA/Bテスト」と「過去に実施されたA/Bテスト」の両方の影響を受けないようなグループでなければならない、という点です。

これを満たすような割り当て方式として、今年の3月にSpotifyのテックブログで紹介されていたBucket Reuse方式が目に留まり、エブリーでも実現可能だと判断できたため採用してみました。 ここでは大まかな方針を紹介に留めるため、詳細はSpotifyのテックブログをご覧ください。

engineering.atspotify.com

Bucket Reuseでは、何人かのユーザがランダムに所属するバケットというグループ単位を定義します。 ユーザの所属バケットは、アプリのユーザIDをハッシュ化し10進数に変換したものを、バケットの総数Nで割った余りによって算出され、サンプリングもバケットを最小単位として行います。 例えば、バケットあたりのユーザ数が7人だった場合、サンプルサイズ40人のA/Bテストでは6バケット(42人)をサンプリングします。

ここまではユーザID末尾をより拡張したグルーピングと考えられますが、Bucket Reuseの肝はA/BテストをNonexclusive experiments(非排他実験)とExclusive Experiments(排他実験)の2種類に分類し、それぞれに応じたサンプリング方式を選択する点にあります。

非排他実験は、割り当てられたユーザ(バケット)が別のA/Bテストにも同時に割り当て可能なA/Bテストで、サンプリングは他の実験を意識せずバケット全体から実施されます。 つまり、非排他実験に割り当てられたユーザは、並行して実施されている他の実験にも同時に属する可能性があります。

f:id:sbrf:20211006162606p:plain
非排他実験サンプリング例

例えば、「バナーのデザインを変更するA/Bテスト」の実施中に、「ボタンのアイコンを変更するA/Bテスト」を非排他実験として開始したいとすると、割り当て対象のユーザは全体からランダムに選ばれるため、すでに実施中の「バナーA/Bテスト」に割り当てられているユーザの一部は、これから開始する「ボタンA/Bテスト」にも割り当てられる、という状況になります。

一見すると、複数の実験に割り当てられたユーザが存在すると、測定される介入効果にバイアスが含まれるように感じます。 しかし、バケットは全体からランダムにサンプリングされているため、コントロール群・テスト群それぞれに他の実験のバケットが混ざる確率は同等になり、結果としてそれぞれの平均の差を測定する上では、他の実験によるバイアスが抑制されるとみなせます(より厳密には、このサンプリング方式の元で、測定された平均の差による推定量はATEの不偏推定量になるとBucket Reuse記事内の論文で証明されています)。

一方排他実験は、互いに割り当てユーザ(バケット)の重複が発生しないようなA/Bテストを指し、同時期に実施されている他の排他実験を除いた上でサンプリングを行います。 つまり、排他実験と非排他実験のバケットは重複する可能性がありますが、排他実験同士は重複がないような割り当てとなります。

f:id:sbrf:20211006162637p:plain
排他実験サンプリング例

排他実験は、検索画面などの介入の影響が大きい配信面でのA/Bテストで特に重要となります。 例えば、「検索画面のレシピの表示方法を変更するA/Bテスト」の実施中に、「検索結果のソートアルゴリズムを変更するA/Bテスト」を開始したいとします。 これらは検索体験に強く関わる介入であるため、同時に両方の介入を受けたユーザは検索体験が大きく変化してしまうおそれがあります。 従って、この2つのA/Bテストは排他実験に分類し、ユーザは多くともいずれか一方にしか割り当てられないように設定するのが望ましい状態となります。

非排他実験とは異なり、排他実験のサンプリング方式の元で算出された介入効果(平均の差)は、過去に実施された排他実験によって均質にサンプリングされず、バイアスが生じます。 しかしながら、前回の割り当てから十分な空白期間(必要な期間の計算方法も論文中で記載されています)があれば、そのバイアスは緩和可能であると示されており、運用上の工夫で対処可能であると考えられます。

以上紹介したようなBucket Reuse方式による割り当てによって、「同時期に実施されているA/Bテスト」と「過去に実施されたA/Bテスト」による影響の少ない効果測定が可能になりました。

統計的手法に基づく考察と意思決定が可能なダッシュボードの作成

A/Bテストでは、ユーザの確率的な行動によりコントロール群とテスト群との間に多かれ少なかれ差分が出るため、施策の影響が全く無かったとしても差分がゼロになるのは極めて稀です。 このような結果の解釈では、適切な意思決定へ繋げるために「得られている差分が本当に意味のある差分なのか」を慎重に吟味する必要があります。

新しいA/Bテスト基盤では、適切な解釈プロセスを統一化された形で実現するため、統計的仮説検定などの検証手法を組み込んだダッシュボードをRedashで提供し、結果のレポーティングを実施しています。

ダッシュボードでは

  • コントロール群・テスト群それぞれのサンプルサイズと結果
  • 差分の統計的仮説検定と有意な結果のハイライト
  • 差分の信頼区間

を表形式で可視化しています。

f:id:sbrf:20211006162659p:plain
ダッシュボード例

可視化処理は、直接Redash上で記述すると管理が難しくなるため、社内向けのPythonライブラリにまとめてRedashサーバにデプロイしておき、Pythonデータソースからimportして使用できるようにしました。

実際の運用では、このダッシュボードを使いながら、運営チームとA/Bテストの結果を確認しつつ「事前に設定していた見込みの効果量と比較して意味ある差分が得られているか」「なぜこのような結果になったのか」といった部分を中心に議論を行っています。

現状と今後の展望

本記事では、新しく導入したA/Bテスト基盤の概観を紹介させていただきました。

導入にあたっては、所属するデータチームのメンバーをはじめ、バックエンド開発チームやクライアント開発チーム、プロダクトマネジャーの方々に多々ご協力をいただきました。

はじめに述べたように、これまでは

  • ユーザグループの選び方
  • 評価指標設定と結果の解釈プロセス

といった課題がありましたが、今回の取り組みを通して

  • Bucket Reuse方式による他の施策の影響が抑制されるユーザ選定
  • 評価指標ライブラリによる評価指標の管理と統計的手法を組み込んだダッシュボードを用いた結果の考察

が実現でき、以上の課題は一定以上解消できたと思います。

もちろん、A/Bテスト基盤としてはこれで完成ではなく、より信頼性・安全性の高いA/Bテストが絶え間なく実行可能なRun, Flyフェーズに向けて継続的に改善を続けていきたいと考えています。

お読みいただき、ありがとうございました。

参考文献

最後に、A/Bテスト基盤を作成する上で参考になった書籍・記事をいくつか紹介したいと思います。

  • Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing5
    • A/Bテストを通じたサービス改善の文化から具体的な方法までが一通りまとめられている書籍です。 Experimentation Maturity Modelsなども紹介されており、A/Bテスト基盤の構想はこの本を足掛かりとして進めました。 最近は邦訳版も出版されています。
  • Spotify's New Experimentation Platform (Part 1, Part 2)
    • SpotifyがA/Bテストをどのように運用しているかが紹介されています。評価指標ライブラリの設計やダッシュボードの設計で参考になりました。
  • Spotify's New Experimentation Coordination Strategy
    • 同じくSpotifyの記事で、ユーザ割り当てのBucket Reuseが紹介されています。
  • Reimagining Experimentation Analysis at Netflix
    • NetflixのA/Bテスト分析基盤について紹介されており、評価指標ライブラリの設計で参考になりました。
  • サンプルサイズの決め方6
    • サンプルサイズの計算方法について、基礎的な部分から体系的にまとめられており、勉強のための良い参考書でした。
  • 効果検証入門7
    • A/Bテスト (RCT) とは何か、A/Bテストが実施できない状況ではどのような分析があるのか、などが具体例を通じて紹介されており、実際に施策を進めていく中で活用しやすい書籍だと思います。

  1. https://redash.io/

  2. Aleksander Fabijan, et al. 2017. The Evolution of Continuous Experimentation in Software Product Development. In ICSE.

  3. Michael Armbrust, et al. 2021. Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics. In CIDR.

  4. https://databricks.com/jp/

  5. Ron Kohavi et al. 2020. Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing. Cambridge University Press.

  6. 永田 靖. 2003. サンプルサイズの決め方. 朝倉書店.

  7. 安井 翔太,株式会社ホクソエム. 2020. 効果検証入門〜正しい比較のための因果推論/計量経済学の基礎. 技術評論社.

定期購読の難しいところ

f:id:nanakookada:20210922211918p:plain

定期購読の難しいところ

システム開発部部長の内原です。

今回はバックエンドエンジニア観点で、定期購読(サブスクリプション)を扱う際に問題となるであろう様々なことについてお話しします。

私は現在システム開発部という部署を担当していますが、以前はDELISH KITCHENのプレミアムサービスチームでバックエンドエンジニアとして働いていたので、その経験を元にして実装や運用で難しさを感じたことについて語ります。

はじめに

DELISH KITCHEN においてもプレミアムサービスという定期購読サービスを提供しています。 内容は以下のようなものです。

DELISH KITCHEN プレミアムサービスとは

  • 機能面

    • お気に入り数無制限
    • 人気順検索
    • プレミアムレシピ(ダイエット、ヘルスケア、美容・健康、作りおき)
    • おまかせ献立(1週間ぶんまるごとで献立を提供)
    • プレミアム検索(糖質オフ、塩分控えめ、などの検索条件を利用可能)
  • 価格面

    • 月480円、半年2,400円、年4,500円
    • 初回登録時は1ヶ月無料(キャンペーン時期により2ヶ月無料、3ヶ月無料)

決済システムについて一般的なお話

DELISH KITCHENに限らず、自社アプリに決済機能を設ける場合は、なんらかの外部サービスが提供する決済システムを利用するケースが殆どではないでしょうか。

自前の決済システムを構築するとなると、技術的難易度が上がることもさることながら、それ以上にセキュリティや法律の観点での考慮が重要になりますので、決済システムそのものがコア技術となるようなサービスを開発するのでない限り、費用対効果を考慮して外部が提供している決済システムを利用することが多いと予想します。

DELISH KITCHENでは、決済システムとして以下を利用しています。 そもそも、iOS/Androidについてはアプリ内でデジタルコンテンツを販売する場合はIAP/IABを利用する必要があるため、事実上の標準となっています。

  • iOS
    • Apple In App Purchase (IAP)
  • Android
    • Google Play Billing Library (In App Billing / IAB)
  • 携帯キャリア (DoCoMo/au/Softbank)決済。DELISH KITCHEN WEBで利用
    • 外部決済システム

※クレジットカードや銀行口座、コンビニ払いといった支払い方法には現状対応しておりません

そもそも決済システムは難しい

そもそも、定期購読に限らず消費型(買い切り型)の商品を扱う場合でも、決済システムの構築にはいくつか考慮しなければならない問題があります。

決済プラットフォーム別の調査&実装が必要である

決済プラットフォーム間でいろいろと仕様が異なる部分が存在します。プラットフォーム間での相違から、必要となるデータが得られないというケースもあり、そうすると差分を埋めるために独自に実装が必要になることも多いです。例えば、IAPでは講読更新ごとに講読更新日時が取得できるが、IABだと初回の講読日時しか取得できないといったケースです。

決済処理は処理のフローが不安定なケースがある

ユーザ側の操作として、(初回は決済方法の指定が必要、など)いったん別画面に遷移したりすることが多いので、途中でユーザが脱落したり、通信エラーが発生したりする頻度も自然と高くなります。さらに、決済部分はアプリ外の挙動であり、課金ボタンはタップしたがその後脱落というケースが多くても、その原因は分からないということが多いです。プラットフォーム側で決済は完了したが、正しく通達できていないというケースのことです。

決済状態と内部システムの同期が必要である

上記問題に対応するため、購入情報の同期を行う機能が必要になります(アプリ内では購入情報の復元と表現されます)。

不具合対応やお客様対応の難易度が高くなりやすい

そもそも不具合が発生すること自体が問題ではあるのですが、金銭的損害が絡まない場合に比べて問題が複雑化&深刻化する傾向が高いです。また、お客様のお問い合わせ対応において返金作業が別途必要になるなど、他の問題と比較してお問い合わせクローズまでの時間が長くなりやすいです。

購読の状態が複雑

定期購読は、未購読、購読中、定期購読中止(未解約)、解約済、といった状態が時系列で存在しているため、いったん確定した購読状態が時間経過によって変化することになります。 よってこれらの状態変化を正しく認識する必要があります。

以下に購読ライフサイクルの例を挙げます。 特にユーザが操作をしていなくても、時間経過によって購読状態が変化する場合があるのが分かります。

購読、解約

- 登録直後 0.5ヶ月後 1ヶ月後 1.5ヶ月後 2ヶ月後 現在
購読中 無料期間 - 更新1回目 - 更新2回目 購読中
購読後解約A 無料期間 - 更新1回目 定期購読中止 解約 解約済
購読後解約B 無料期間 定期購読中止 解約 - - 解約済

解約後、再購読

※過去に購読→解約を経験しているため、無料期間が存在しない

登録直後 1ヶ月後 1.5ヶ月後 2ヶ月後 6ヶ月後 現在
無料期間 更新1回目 定期購読中止 解約済 更新通算2回目 購読中

購読後、商品切替(1ヶ月→半年)

※購読している商品の購読期間を変更することが可能

登録直後 1ヶ月後 1.5ヶ月後 7ヶ月後 現在
無料期間(1ヶ月) 更新1回目(1ヶ月) 購読切替(半年) 更新2回目(半年) 購読中

分析要件の難しさ

「どのような理由で購読/解約したのか?」、「どのような施策が売上に効いている/効いていないのか?」、「キャンペーン内容は適切であるか否か?」といった様々な観点での分析が必要となります。

前述の通り、購読状態は時間とともに複雑に変化するため、時系列での評価を行わなければならなくなります。

なにを見たいか

  • 課金ページ閲覧数、課金数
  • 新規購読UU
  • 全体購読UU
  • 課金転換率
    • 無料期間によって対象期間は変わる
  • 解約率
    • 購読開始してから何日めか、が重要

なんの軸で集計するか

  • 購読期間別
    • 1ヶ月、6ヶ月、1年
  • 無料期間別
    • 1ヶ月、2ヶ月、3ヶ月、6ヶ月
  • 登録日別
    • キャンペーンやプロモーションの影響を分析する
  • 訴求内容別
    • 献立、ダイエット、ヘルスケア、など
  • 上記の組み合わせ

過去の状態を判定

分析観点において、特定ユーザのN日前の購読状態を知りたいというニーズがあったとしても、状態遷移としては購読開始、購読更新×N、定期購読中止(未解約)、解約済、という起点があるだけです。単にN日前のデータを見ただけでは判別ができないということになります。 よって、時系列で購読状態を解釈する必要性があります。

プラットフォーム差異

前述したように、プラットフォームごとに提供される機能やデータ種類に差分がありますが、DELISH KITCHENシステムとしてはなるべくプラットフォーム差異を意識しないで済むようにしたいので、これらの差分を吸収する実装を行う必要が出てきます。 とは言え、そもそもの仕様が異なる関係で、完全に吸収することは難しいものも存在します。

例えば以下のように、IAP/IABでは仕様が異なる部分があり、いずれかに合わせる、または代替となるデータを自前で算出する機構を実装するなどの対応が必要になります。

- 価格 無料期間 購読履歴 定期購読中止日 購読商品切替時
IAP 価格帯から選択 期間帯から選択 取得可能 取得不可 現購読完了後に切替
IAB 1円単位で指定 1日単位で指定 取得不可 取得化 即時切替

本番での課金テストの辛さ

検証環境でのテストを十分に行っていたとしても、本番環境での検証を一切しないままだと不安が残ります。 よって、検証環境ほどの作業項目ではないにしても、最低限の動作確認は本番環境でも行っておきたいところです。 ただそれについても困難が伴います。

初回無料に関するテスト

初回無料が有効になるのは、ストアアカウントごとに1回ずつというのがIAP/IABにおける仕様です。 つまり、初回Nカ月無料が本当に正しくシステムで捕捉できているかといったようなテストを行う際は、都度ストアのアカウントを作り直さないといけないということになります。

解約済に関するテスト

いったん購読開始した後、定期購読を中止して解約済になった状態でテストする必要が出てきたとします。

しかし、実際に提供している商品の購読期間は1カ月や6カ月といった単位なので、定期購読中止してもその期間内はまだ購読済みのままです。 解約済み状態にするには最低でも1カ月前には準備しておかなければならないということを意味します。

終わりに

定期購読(サブスクリプション)に関する実装、運用の観点からの難しさについて記述しました。 今後の参考になりましたら幸いです。