RemoteConfigを使ってABテスト振り分けしていくのをやめた話

f:id:hoshitocat:20210709173512p:plain

こんにちは。mikanでサーバーサイドエンジニアをしております。星(@hoshitocat)です。 最近ハマっているアニメは「不滅のあなたへ」です。先週はこれを観てうるっときてしまいました... OP曲の宇多田ヒカルさんの曲もよくて、おすすめです!

さて、mikanではGrowthのための施策やプロダクトの方向性を決める際にABテストを行っています。 これまでABテストの振り分けにはRemoteConfigを使っておりましたが、 最近振り分けロジックを変更したので、それについてご紹介したいと思います。 なぜRemoteConfigをやめたのか?みたいな話から、これから使おうと思っている方やすでに使っていて、課題感ある方に少しでも参考になればと思っております。

RemoteConfigを使った振り分け

RemoteConfigは、Firebaseのコンソールなどからあらかじめパラメータを設定しておいて、アプリ起動時などにその設定値を取得し、 それに応じてアプリの挙動などを制御することができるようなサービスになります。(ざっくり理解、詳細は公式ページをご参照ください。)

この設定値は、RemoteConfigで振り分けて返すことができ、以下のように50%のユーザには true を返したいけど、残り50%は false にしたいなどの分類が可能です。 これまでmikanでは、この機能を使ってABテストの振り分けを行っていました。

f:id:hoshitocat:20210709173518p:plainf:id:hoshitocat:20210709173516p:plain
remoteconfigのconditionsとparameters

ABテストを実施する際もそのまま使うだけという導入コストの低さゆえに、mikanではRemoteConfigを昔から使っていました。 また、分析にはログをAthenaで分析1し、ABテストの結果などを判断しているのですが、そのログに ab_flags という、RemoteConfigから取得された設定値を入れることで、 あるユーザがABテストのどのABフラグを持っているかで判断しています。

次にRemoteConfigを使ってよかったメリットとデメリットを示します。

メリット

  • 管理が楽
    • リッチなコンソール
    • GUIで設定項目を簡単に編集できる
  • 非常に導入が簡単
    • SDKを入れるだけ

デメリット

  • ヒューマンエラーを防ぐためには専用の仕組みを作る必要がある
    • コンソールから設定しておりましたが、 レビューの体制を作りたいと思い、 管理用のリポジトリを用意し、コードで管理していました。
    • 自動化にはRemoteConfigのAPIを叩いて実装していました。
  • 通信環境が悪いユーザのバイアス
    • 通信環境が悪いとRemoteConfigから値を取得できないため、 その場合はABの振り分けがデフォルト値にアサインされてしまい、 ABテストの結果に影響してしまうことになります。
  • サーバーサイドのAB振り分けロジックには利用できない
    • RemoteConfigはあくまでクライアントから利用することを想定されているため、 クライアントSDKを使った振り分けは可能だが、 サーバーサイドのAPIなどで、ユーザのAB振り分けを行うことはできません。

上記にあげた以外に、RemoteConfigにはキャッシュがあり (デフォルトだと12時間詳細はこちら) 一度値を取得し振り分けられたら、キャッシュが消えるまではそのままの値が保持されます。 これは便利なのですが、値を変更しても最大で12時間たたないとその変更後の値にはならないため注意が必要です。 (もちろん取得時間などは調整可能です)

RemoteConfigを使わずに実装した

デメリットであげたもので、特に以下2つ解消するためにABの振り分けロジックを考えました。

  • 通信環境が悪いユーザのバイアス
  • サーバーサイドでも使える振り分けロジック

前提として、ABテストの結果の分析にはAthenaを使っているので、Athenaで分析可能な振り分けを行う必要がありました。

結果として以下のようなアルゴリズムで実装しました。

  1. Firebase AuthのUIDとABテスト固有の文字列を連結
  2. ①の文字列をハッシュ化
  3. ②から先頭4バイト取得
  4. 10進数に変換
  5. ④の絶対値を取得し、100で割った余りを使いABテストの振り分けを行う

サーバーサイドとクライアントと分析基盤で同じ値が取得できることを確認するためにそれぞれで実装を書いてみました。

サーバーサイドで利用するGo言語で実装

package main

import (
    "bytes"
    "encoding/binary"
    "fmt"

    "crypto/sha1"
)

func main() {
    salt := "cdf3fa31-4cb5-4663-91a8-9274f722e232" // ABフラグ
    uid := "00wOT95NyMZvnprfjN4uU0AJ67C2"          // Firebase Auth UID(開発環境のもの)

    // saltとuidをくっつけた文字列をバイト配列に変換し、SHA1でハッシュ化
    value := sha1.Sum([]byte(salt + uid))

    // ハッシュ化されたバイト配列から先頭4バイト取得(32bit符号付き整数へ変換するため)
    // 先頭4バイトからint32へ変換
    var intValue int32
    buf := bytes.NewReader(value[:4])
    binary.Read(buf, binary.BigEndian, &intValue)

    fmt.Println(intValue) // ABテストの振り分けに利用する10進数
}

iOSアプリで利用するSwiftの実装

import UIKit
import CommonCrypto

let salt = "cdf3fa31-4cb5-4663-91a8-9274f722e232" // ABフラグ
let uid = "00wOT95NyMZvnprfjN4uU0AJ67C2"          // Firebase Auth UID(開発環境のもの)
let str = salt + uid
var hash: [UInt8] = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
let strLen = CC_LONG(str.lengthOfBytes(using: String.Encoding.utf8))
CC_SHA1(str, strLen, &hash)
let raw = hash[0...3].reduce(0) { $0 << 8 + Int32($1) }
print(raw)

Androidで利用するKotlinの実装

package org.kotlinlang.play

import java.nio.ByteBuffer
import java.security.MessageDigest

fun main() {
    val salt = "cdf3fa31-4cb5-4663-91a8-9274f722e232" // ABフラグ
    val uid = "00wOT95NyMZvnprfjN4uU0AJ67C2"          // Firebase Auth UID(開発環境のもの)

    val target = salt + uid
    val targetBytes = target.toByteArray()
    val sha1 = MessageDigest.getInstance("SHA-1")
    val sha1Result = sha1.digest(targetBytes)

    // 分析基盤の処理上、4byteまでしか受けつけれないため、揃えるために先頭4byteを取得するようにしてます
    val sliceResult = sha1Result.slice(0..3).toByteArray()
    val result = ByteBuffer.wrap(sliceResult).int
    println(result)
}

分析に使うAthenaでの実装

SELECT 
from_big_endian_32(
  SUBSTR(
    SHA1(
      CAST(
        'cdf3fa31-4cb5-4663-91a8-9274f722e232'      -- ABフラグ
        ||
        '00wOT95NyMZvnprfjN4uU0AJ67C2' AS VARBINARY -- Firebase Auth UID(開発環境のもの)
      )
    ),
    1,
    4
  )
)

メリット

  • オフラインでもクライアント側に振り分けロジックを記載すれば振り分けを正確に行うことが可能
  • サーバーサイドでも同じようなロジックでABテスト可能

デメリット

  • 振り分け時にABテスト時に割合を徐々に拡大する必要がある場合に対応できない
    • ABテストの比率50%:50%でテストしたいものだったので、 今回はクライアント側で振り分けロジックの実装をしました。 しかし、5%からはじまり徐々に拡大していく必要のあるABテストの場合はサーバー側で比率を調整し、 それをクライアントから取得するといったように実装しないと、アプリのアップデートありきの割合調整 になるため、比率調整が難しくなってきます。
    • 比率だけRemoteConfigから取得することもできましたが、 その場合どうしても、オンラインで通信環境が良いユーザのバイアスが含まれてしまいます。
  • 集計が複雑になる
    • UIDからハッシュ化などでユーザごとに振り分けがどちらなのか計算する必要があるため、集計が少々複雑になります。 (分析用ログに振り分けに利用したABフラグを付与することで回避できます。)

まとめ

今回紹介した方法では、RemoteConfigを使ったABテストのバイアスがかかってしまう問題を解消できました。 また、共通のロジックを使いサーバーサイドでもABテストができるようになりました。

しかし、管理側で比率をいつでも調整できないことは、今後の課題になります。 RemoteConfigだと通信環境が悪いユーザをABテストしづらいです。 特にオフラインのユーザはサーバーから比率を取得したり、ABフラグを取得することができないためどうしようもないのですが、 通信環境が悪い程度なら、通信コストをもっと抑えた実装をすることで、ABテストを実施できるかもしれません。 ここは調査していきたいです。

最後になりますが、mikanでは現在積極採用中です!

ABテストの分析やその機構の実装に興味がある方、英語が好き、日本の英語教育を変えたいと思っている方は、ぜひ一緒に働きましょう!!

mikan.link


  1. 弊社で分析してくれている石塚がこれまでの分析基盤の歴史を書いてくれています。また、これらのためのインフラ構成図もあります。

100%在宅勤務に集中デキる!!こだわりのデスク環境紹介

f:id:uk_oasis:20210702134806p:plain

はじめに

こんにちは、株式会社mikanでiOSエンジニアをしているukoasisです。

twitter.com

この緊急事態宣言の中で、だいぶ自宅作業にも慣れてきた方が増えてきたのではないでしょうか?

私はほぼリモートワークのmikanで働くことで様々な問題に悩まされました。そんな中で私が試行錯誤を重ねた自宅作業環境を公開します。同じ悩みを持たれる方の参考になれば幸いです。

マイク

まずは、リモートワークでミーティングをする上で欠かせないマイクからです。

自分の声は低く通りにくいので意識して声を張らなないと、「え?なになに?」となってミーティングを中断させてしまいます。

また、子供がいる身としては子供たちの全力全開な歌声がミーティングの妨げになるのではないか?と気になってしまいます(それによってミーティングの雰囲気が和らぐので、良い時もありますが・・・)。

これらを考慮して、最初は不要な音が入りにくいヘッドセットを使っていました。

www.logicool.co.jp

有線にしていたのは、以前使ってたヘッドセットがbluetooth経由のワイヤレスだったため、音があまり良くないこと、電池切れ、接続不良によってミーティングの時間に遅れてしまうことがあったためです。

これで満足していたのですが、そこで新たな問題が・・・・

最近は、mikanに入社していただいた方・副業で関わってくださる方とのウェルカムランチの機会がありがたいことにだんだん増えてきて、ランチの時の咀嚼音が気になりはじめました・・・。他のメンバーからは、あまり気になりませんでしたよ!と言っていただけたものの、うーん、この音大丈夫か?を気にするのに疲れてしまいました。

さらに、mikanでは朝から元気にラジオ体操をするのですが、ここでワイヤードだとどうなるか?首が持っていかれるか、線が切れるかの2択です(幸いにも線が切れたことはなかったですが、首はグキッとなったことが何回かありました)。

そこでやっと辿り着いたのがこの構成です。

マイク: BETA 58A

www.shure.com

以下の観点でマイクを選びました。

  • 周りの環境音をできるだけ拾って欲しくない
  • 音質はミーティング時の会話がメインなので求めない
  • ズボラなので手軽に利用できるのが良い

音質を求めず手軽に使えるということでダイナミックマイク。周りの音を拾いにくいということで単一指向性のマイク。と絞っていった結果、BETA 58Aにたどり着きました。

あれこれ買って比較していないので、もっと良いものがあるかもですが、今のところかなり満足しています!

オーディオインターフェース: EVO4

www.allaccess.co.jp

マイクは比較的スッと決まったのですが、問題はUSB出力がないため、Macとの接続にオーディオインターフェースが必要な点です。

マイクだけ繋げれば良いのでコンパクト、かつ、線をあまり繋げたくないと思って、探していて見つけたのがEVO4でした。

各所で紹介されているのでご存知の方も多いと思いますが、USBバスパワーなので電源接続が不要で設置が楽なのが好みの点です! また、自分はM1 Macを利用していますが、特に問題なく使えている点も大きいです。

マイクアーム: RODE PSA1

ja.rode.com

ここまできたらマイクアームもちゃんとしていきたいですよね。

Blue compassと迷ったのですが、以下の二点でRODE PSA1を選びました。

www.bluemic.com

  • 自分が購入するときに、値段がちょっと安かった
  • マイクを低位置で固定できる(内角を90度以下にできる)

Blue compassはケーブルを通す溝があって、ケーブルが露出しないのが素敵だなと思っているので、どこかのタイミングで試したいなと思っています。

これで完璧だ!と思っていたら大きな落とし穴が・・・

あれ?マイクアームが上に上がってしまうぞ・・・・ マイクを口元に持ってきてもすぃーっと上がってしまう・・・

よくよく調べてみると適合荷重というものがあり、RODE PSA1の適合荷重は0.7 〜 1.1kgとのこと。BETA 58Aのようなダイナミックマイクはシンプルな構造なので結構軽く、マイク単体の重さは278g。なので、最低でもあと400gは必要でした。

この問題を解決するために、カメラの重さを利用することにしました!詳細は次のセクションで!

カメラ

リモートワークではカメラも必須ですよね!

webカメラが品切れだったタイミングでRaspberry Pi Zeroをwebカメラにして満足していましたが、早々と壊れてしまい、新しいものを買うか?と思ってた時に前職の同僚の記事を思い出しました。

note.com

ちょうど一台利用していないiPhoneがあったため、この記事で紹介されていたCamoを試してみました。

reincubate.com

Camoを導入したことで、以下の点が改善しました。

  • 安定して動作する
  • 色・明るさ・ズームなどがMacアプリで簡単に調整できる

M1 Macにも対応しているため、一番の懸念であるハードウェアの問題も解決。あとはどうやって設置するか。最初はよくある自撮り棒 兼 三脚を利用して置いていたのですが以下が課題でした。

  • 利用しない時の置き場所
  • 移動させるときにケーブルが引っかかる

上記の記事を読んでて、マイクアームに追加のアームを取り付けて、それにiPhoneつければ良いのでは?と考えました。さらに、マイクアームの適合荷重問題も解決できるかも。調べてみるとカメラを扱う方々向けに便利なアームが何個か見つかったので、その中で使えそうなものをピックアップして設置してみたところうまく行きました!

f:id:uk_oasis:20210628122950j:plain

f:id:uk_oasis:20210628123208j:plain

適合荷重は以下のようになりました。0.7 〜 1.1kgなのでピッタリ!

マイク: 278g + 追加のアーム: 400g + iPhone 6s: 143g = 821g

さらに、iPhoneMacをつなぐケーブルもマイクアーム経由で取り扱うことでスッキリさせることができました!

結果オーライですが、これでマイクからカメラまで、思い描いていた通りに利用できるようになりました!

アプリ

Meeter

リモートワークになってミーティングはほぼオンラインになりました。 朝会に始まり、振り返り、夕会などmikanのオンラインミーティングでは主にZoomを利用しています。 ここで厄介なのが、URLがどれか?Slackで共有されてたあれかー!次のミーティングは別の部屋でやるから移動かぁ。あれ?次のミーティングのURLはどれだっけ?これをやっているとミーティング前に疲弊してしまいますよね・・・

そんな時に出会ったのがMeeterでした!

カレンダーと同期させて、オンラインミーティングのURLを自動で収集し、メニューバーに表示、ワンクリックで参加できます!

また、ミーティングの時間になったら通知してくれて、通知をタップすると該当のミーティングに参加できます。

一つ手間なのが、カレンダーにZoomのURLを設定する必要がありますが、mikanでは愛用者が多いのでZoomのURLを設定してくれる文化が出来ています!とても良い会社ですよね!

今やこれなしではリモートワークできないほど必須アプリになりました!

trymeeter.com

Krisp

mikanでは全員採用をテーマにしていて、自分もオンラインで面談・面接に参加する機会が増えました。ふと、メモをとっている時に気づきます。キーボードの打鍵音が相手に聞こえていないか???

一旦気にし始めると気になってしまいます・・・ そこで、弊社のみぞりんが教えてくれたのがKrispでした!

とりあえず試してみるかとインストール。インストール中に効果を試す画面があるのですが、キーボードを普段の感覚でタイプしながら、話すということを試してみました。結果を確認してみると打鍵音が気にならない!

個人差はあるので完全に無くなかった!というレベルではないですが、気になって面接に集中できない!という課題は解消することができました!

mikanではKrispをチームで契約しているので、利用したい社員は自由に利用することができます!とても良い会社ですよね!

krisp.ai

おわりに

まだまだ改善の余地があるものの、リモートで作業するのに困らない環境ができました。人それぞれこだわりの環境ががあると思うので、これも良いよ!オススメはこれ!などありましたら、ぜひ教えてください!

今回は自分の環境についてでしたが、mikanのメンバーには自分以上にこだわりを持つメンバーがおり、社内のSlackのtimesチャンネルで呟くと、これいいよ。あれいいよ。と情報が集まるので大変便利ですw

f:id:uk_oasis:20210701195421p:plain

この記事を読んで、こんな話題が好きな人がいるチームなんだな。なんか面白そう!と思ってもらえたら嬉しいです!

この流れで定番ですが、mikanでは一緒に働く仲間を募集中です。少しでも興味をお持ちいただいた方はぜひご連絡ください!

一緒に英語学習をより良いものにしていきましょう!

mikan.link

mikanで色々改善したった!!!

f:id:qurangumio:20210624160627p:plain

はじめに

皆さん、こんにちは!mikanでAndroidエンジニアをしているgumioです。

twitter.com

今回は僕がmikanに入社する前から、今までにメイン業務とは別でしてきたことを紹介していきたいと思います!

個人振り返りみたいなものですが、誰かの参考になれば幸いです。

MTG改善・集中day

1つめにすぐ思い浮かぶのはMTG改善と集中dayじゃないでしょうか!

僕が入社した当時のmikanは1時間のMTGが細切れで設定されていて、作業に集中できるような状態ではなかったです。

きっかけは単純で1on1の時に、「フルタイムになったのに副業の時より開発時間が減ってるように感じる!」という相談をしました。

なぜ、開発時間が減ったように感じたか?

  1. MTGの曜日がバラバラかつ、1日の中でも細切れになっている

  2. 1時間もかからないMTGだけど、多めに取ってしまっているが故に、早めに終わったときに上の細切れを誘発している

これに対して、提案したのは以下のようになっています。

  1. 月・金にMTGを寄せてしまう

  2. 上記の曜日以外でも必要なMTGを設定する時は連続的になるように調整

  3. 1時間以上のMTGは合宿とし、極力1時間未満で済むようにする

mikanのいい所はこういった施策がすぐに試せる環境、だめだったらそれをやめればいいという小回りの効くところだと思います。

当日すぐに定例などのMTGは全体で断捨離、統合、短縮などがされ、月・金に寄せられるようになりました。 その翌日から朝会があるのですが、全体周知で1時間以上のMTGは合宿とします。というアナウンスがされるようになりました。

実施した効果は絶大で生産性が向上したとの声がたくさんあり、今でも継続されてます。

メリット

この取り組み職種問わずに集中して作業する時間が確保されること、MTG -> 作業 -> MTGと言った、スイッチングコストがかからないことで組織全体の生産性を向上させることができます。

デメリット

ないです!ハッピーハッピー!

振り返ってみて

今はまだ1日の枠に収まりきってますが、人が増えると共に開催されるMTGは増えていきます。そうすると何曜日はMTG dayという風に徐々に汚染されていきます。それを防ぐために極力、共有などの非同期で済むようなMTGはしないように、日頃からドキュメントを貯めていく文化づくりを徹底するともっとよくなるんじゃないかなって思いました。ドキュメント文化は絶賛取り組み中です!

Discord導入

2つめはDiscord導入です!

mikanはほぼフルリモートになっており、当時の社内でのコミュニケーション方法はSlackかZoomの二択でした。

そこにDiscordを導入する意味はあるんか?と思う方もいらっしゃるとは思いますが、zoomのみだと、「今話せますか?」などのMTGするまでもないけど、軽く相談したい時にSlackやり取りが発生してから、部屋を立てて、繋ぐことになるので、少しシームレスなコミュニケーションとは言えない気がしました。

会社に通勤してる時は、隣や対面に同僚がいて、今いいですか?だけでやり取りができるのはオフラインのメリットですよね。それを極力オンラインでも実現したいと思い、Discord導入を行いました。

Discord導入の際にやったことは以下のようになっています。

  1. Discordの運用方針ドキュメント作成

  2. Discordのサーバー準備

  3. 1ヶ月の仮運用を全体に周知

  4. 仮運用終了後に満足度・継続導入できるかのヒアリング

運用方針はこんな感じのものを作成しました。これは前職でも導入されていた手法で僕は好きだったので、少し改良して導入してます。

f:id:qurangumio:20210624155220p:plain
Discord運用方針ドキュメント一部抜粋

運用方法はざっくりと以下のようになっています。

  1. 仮想的な自席を用意

  2. 自席roomにマイクだけミュートにして、常時参加にする(スピーカーはON)

  3. 話しかけたい場合はその人のroomに訪問することでコミュニケーションが成立

  4. 自席を離れる場合はroomを出るだけでよく、周りもそれで分かる

  5. 話しかけられたくない、集中したい時は1人しか入ることができない集中部屋に入る

  6. Discordは組織アカウントなどのプランがあるわけではなく、招待リンクさえあれば、誰でも入れます。故にセキュリティ的にあまりよろしくないので、業務関係の画面共有・text投稿は行わない

普段の光景はこんな感じです。一部QAなどは音声のみでよいので、Discordで行われていたりします。

f:id:qurangumio:20210624155226p:plain
いつもの光景

f:id:qurangumio:20210624155222p:plain
集中したいとき

メリット

Discord導入する前に比べて、気軽なコミュニケーションが取れるようになったという声がたくさん増えました!

余談ですが、Discordは音質がめちゃめちゃいいです。

デメリット

毎朝Discordに入るのが忘れがちになります。 Discordはゲーミングツールなので、ゲームをしない人は日常的に触れる機会はあまりありません。なので、詳しい人が1人いないと推進するのが少し難しいかもしれません。

振り返ってみて

コロナになり、オンラインツールが流行り始めた時に、「各社でDiscordを導入やってみたけど、うまく浸透しなかった。」などの声もたまに見かけますが、なぜmikanでは浸透したのか考えてみた。

意識して、やってみたことは以下です。

  1. ハードルを感じさせないために使い方などをなるべく、画像やgifなどで丁寧に分かりやすく書く

  2. 1ヶ月の仮運用からスタートさせ、じわじわと浸透させる

  3. 仮運用期間時になるべく便利だ!と思わせるような体験をさせる

  4. 使用は強制ではなく、あくまで任意。家庭環境なども考慮しました

  5. 浸透するまではSlackリマインダーとか使わずに自分で部屋入りましょう〜!って声掛け

  6. カスタムbotで「Discord入った」って発言されたら、「世界一えらい!!!!」ってめっちゃ褒められるようにした

また、mikanではファーストペンギンに続いてくれるセカンドペンギンが多い組織なのも浸透要因の1つなのではないかと思ってます。

これが実際に効いた!とまでは言わないですが、少なくともDiscord便利じゃん!という体験を仮運用期間にさせてあげるのが1番大きかったのかもしれません。

もちろんうまくいかなかったこともあり、週1の固定時間に皆でDiscordに集まって、仕事を休憩して、雑談する「ゆるゆるmikanタイム」というイベントを設けてたのですが、仕事に追われて参加できる、できないの差が激しく、最近辞めることにしました。この辺りはtryしてみたけど、うまくいかなったから、サクッと辞めたという一例ですね。どうすればうまくいくかを次の施策に活かしてみたいと思います!

mikan LT

3つめは社内LTの実施です!

こちらも前職でやっていた文化が好きだったので、mikanに導入してみました! 導入しようと思ったきっかけは、コミュニケーション活性化のためです。 mikanには社内Notionに1人1人の詳細な自己紹介ページがあるのですが、そこで語られるパーソナルな部分には限りがあると思っていて、それ以上の深い部分は見えてこないと思います。

そこをmikanLTで埋め合わせるために、フリーテーマでその人のパーソナルな部分を好きに語ってもらうことで、社内全体のコミュニケーション活性を図ろうとしたのがきっかけです。

こちらの導入の流れはDiscordとほぼ同様ですが、以下のようになっています。

  1. mikanLTの目的などが書かれたドキュメント作成

  2. 毎週金曜日にある、社内全体の定例でやりたい旨を伝える

  3. 月1で実施

  4. 参加は副業の方問わず、任意参加

目的、尺や配分、テーマについては以下ドキュメントより 補足: 登壇者は毎回挙手か、依頼制で2人選出しています。

f:id:qurangumio:20210624155230p:plain
mikanLTドキュメント一部抜粋

メリット

オンラインで職種問わずに顔合わせる機会が増えたこと。mikanLTは任意だけど、参加率が非常にいいです。毎回楽しみにしているというありがたい声も頂きました。

デメリット

Qの終わり頃とか月末に近いときに登壇者に選ばれていると追い込まれてると感じてしまう。無理をさせてしまう(ここは口頭カバーで、資料は時間かけずに簡単なペライチだけでもいいという旨はしっかり伝えてある)。

主催者が1人だと最初の導入までの負担がでかい。

振り返ってみて

デメリットにあげているが、自分でこういうのを開催したい!ってなった際にルール決めとか、実施までの負担が意外とでかいことを予測できなかったので、協力者を1人求めればよかったなって思いました。

最初は総合尺を40分とかにしてたのですが、全然時間が足りなくて、結局1時間でいい感じに収まったのですが、1回・2回を通じてブラッシュアップしていけたのはよかったかなと思ってます。

f:id:qurangumio:20210624204511p:plain
mikan LTをしてる様子

オンボーディング改善プチ協力

4つめはオンボーディング改善プチ協力です! 皆さん、新しいメンバーが来た際にしっかりと迎える準備はできていますか..??

はい、オンボーディングってやつです。

オンボーディングが整ってる、整ってないでは新しく来た人の初動スピード、活躍できるまでの時間に差が生まれると思っています。

充実したオンボーディングを提供することで、その人が会社で活躍できるまでの時間が、グッと縮まるのであれば、リソース割いてでも充実したオンボーディングを作った方がよいと思いませんか??

そもそものきっかけは、Podcastnoteなどでもしつこく言っていますが、僕が入社した時のmikanは今のオンボーディングと比較すると整っていなかった状態でした。必要ツールの招待や定例などは都度自分から拾いに行くスタイルでした。

なので、オンボーディングが、、、あばばばばばばbryという感じで機会があるごとに言ってました。(ここですぐにオンボーディング改善したろ!!!ってならなかったのは反省ですね)

そういえば、なんでプチ協力かというと僕はきっかけに過ぎず、実際にアクションを起こしてくれたのはmikanの取締役の溝さんで、僕はきっかけづくりとレビュー対応をしたくらいです。

そこで溝さんが次の入社の人に同じ思いをさせまい!と立ち上がったわけですね。 実際にはオンボーディングの叩きを作ってくださって、僕はそれにこういうのあったら、よかったな〜という視点でレビューをしたという感じです。

僕の屍を乗り越えて、オンボーディング資料が完成したというわけですね。(めでたしめでたし)

新たに作成されたオンボーディング資料について、6, 7人目の正社員のUKさん, まきさんがPodcastで赤裸々に語ってくれているので、よかったらご視聴くださいませ。

anchor.fm

メリット

一度時間かけて汎用的に作成すれば、それ以降は部分アップデートだったり、これからずっと使えるものになります。 新しく来た人が早く活躍できます!

デメリット

ないです!すぐやりましょう!

振り返ってみて

途中でも述べてるのですが、オンボーディングあばばってなった時に僕から、こんなオンボーディングだったら、めっちゃよかったという提案をして作ればよかったなとは思いました。

今ではmikanのオンボーディングは汎用的で、副業の方にも同じように使えます。最近僕はAndroidの副業の方にオンボーディングをしたんですよね。

そしたら、「今から自分は入社するんじゃないかと錯覚しました」と言われました。

まあびっくり。僕の過去の屍も喜んでいます。

人には常に丁寧に寄り添いましょうという学びですね!!!

おわりに

いかがだったでしょうか?エンジニアだからといって、コード周り、チーム内改善のみに留まらずに会社自体を良くしようと動いてみました!

上記で紹介したものは一部のみとなっていますが、インパクトが大きめなものをチョイスして紹介してみました。(今それってあなたの感想ですよね?とか思いました...?)

前職のような大きい企業だと、先人達が整えてきてくださった上で働けるので、あまりこういった改善箇所を見つけるのは難しかったり、そもそも企業の文化によっては行動起こすのが難しいとかはあるかもですね。

スタートアップとかだと整っていなくて荒地なとこは多々ありますが、そこを一人一人が良くしていこうという意識を持てるのと、こういう文化づくりをしたい!というチャレンジがたくさんできるのはいいことだなって思います。

こういうチャレンジは駄目だったら、すぐ辞めればよくて、よかったらもっとよくするためにブラッシュアップしていけばいいので、それもスタートアップならではの動きやすさですね。

今回、少しでも他の企業の方の参考になれば、いいなと思い書いてみました!引き続きmikanで組織がもっとよくなるための行動をおこしてみようと思います!

最後になりますが、mikanでは現在職種問わずに積極採用中です!!!!!!

ぜひ、このような環境で働いてみたいと少しでも思った方、英語好き、教育を変えていきたい方は一緒に働きましょう!!!

採用ページが充実しておりますので、ご覧くださいませ。

mikan.link

Kotlin flowsをmikanに導入しました

f:id:iiizukkeyiii:20210617174808p:plain

はじめに

こんにちは。 mikanでAndroidエンジニアをしております、zukkeyといいます。

今回は、mikan AndroidにKotlin flowsを導入したので、Flowのざっくりとしたまとめとmikanでの実装例について紹介させていただきます。

まず初めに、Kotlin flowsに関しての事前知識部分についてざっくりと紹介いたします。

Kotlin flows

簡単にまとめると次のとおりです。

  • Flowは、コルーチンの一種で 複数の値を順次出力 することが可能
  • 一連の値を生成することができるIteratorに似ているが、 非同期に値を生成することができる
  • リアクティブプログラミングスタイルで利用可能。Rxと同様の機能。

詳細については、公式ドキュメント 1とコードラボ2に詳しく載っています。

StateFlow / SharedFlow

簡単にまとめると次のとおりです。

  • StateFlow
    • 状態保持用の監視可能な Flowで同じ値は流れない
    • 「ホット」なFlow
    • LiveDataとの違い
      • StateFlowは、初期状態をコンストラクタに渡す必要がある
      • StateFlowは、StateFlow やその他の Flow からの収集ではコンシューマー側の登録解除がされない
      • UIでデータを収集する際に、Jobを作成して明示的に手動で停止しないとリークする(repeatOnLifecycleAPIでも可能だが、2021/6現在alpha版のみ提供)
  • SharedFlow
    • StateFlowを一般化して、詳細な設定ができる
    • 定期的にすべてのコンテンツをまとめて更新するということができる

詳細については、公式ドキュメント3に詳しく載っています。

ざっくりと概要がわかったところで、実際にどのようにしてmikanでは利用しているのか説明していきます。

mikanプロダクトへ導入した部分の紹介

mikan Androidは、間もなく5年近くになるアプリで、この先mikanを高速かつ、複数人で進化させていくためには厳しい仕組みになっています。今後は、大規模なリファクタリング(リアーキテクチャ)を行い、結果的にチーム全体のパフォーマンス向上を狙っていきたいとチームで取り決めています。

そのため、今まで支えてくれたコードに感謝をしつつ、現在新しくアーキテクチャを刷新していくという状況です。

新しいアーキテクチャは、公式推奨の形に則り下記の図のように定めました。(※今後必要に応じて変わっていく可能性があります。)

f:id:iiizukkeyiii:20210617173433p:plain

現在のmikanでは、すでにCoroutinesを導入しているという背景もあり、Rx部分とLiveData部分をKotlin flowsに置き換えられると判断して、次のように利用していくことといたしました。

f:id:iiizukkeyiii:20210617173450p:plain

非同期に取得できるデータは、Flowで値を流すようにして、View側で収集を行い非同期で受け取った値を元にUIに反映していくというような流れになっています。

具体的には、mikanのアプリで適用した箇所がマイページ画面になります。

f:id:iiizukkeyiii:20210617173508p:plain

マイページでは、高校が設定されている場合には、高校の情報を取得するAPIを初回に叩いており、高校名をニックネーム(添付画像の「ひとかわ むけお」)の部分の下に表示されるようになっています。

実際にどのようなフローで利用しているのか、コードベースで紹介していきます。

まずはじめに、Viewが生成されたタイミングで、ViewModelの関数をコールします。...は省略を意味し、以降同様です。 先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173526p:plain

実際のコードは次のとおりです。

@FlowPreview
@AndroidEntryPoint
class MyPageFragment : Fragment(), BottomNavigationView.OnNavigationItemReselectedListener {
    ...
    private val myPageViewModel: MyPageViewModel by viewModels()
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setUp()
    }
    ...

    private fun setUp() {
        ...
        myPageViewModel.initialize()
    }
    ...
}

下タブをマイページに切り替えた際に、onViewCreatedが呼ばれるので、その中でViewModelのinitialize関数をコールします。

ViewModelのinitialize関数の中身をみていきましょう。

MyPageViewModelの中は次のとおりです。

@FlowPreview
class MyPageViewModel @ViewModelInject constructor(
    @ApplicationContext private val context: Context,
    private val myPageRepository: MyPageRepository
) : ViewModel() {
    // UiDataはUIの表示に必要な情報としています
    private var uiData = MyPageUiData(
        ...
    )
    ...

    fun initialize() {
        if (!uiData.shouldInitialize) return
        viewModelScope.launch {
            myPageRepository.getNickName(context)
                .flatMapConcat {
                    uiData = uiData.copy(nickName = it)
                    // 今回の例における高校の情報を取得する部分がこちら
                    myPageRepository.getSchoolName(context)
                }
                ...
        }
    }
    ...
}

initialize関数の中では、API呼び出し以外にもSharedPreferenceから情報を読み取り、マイページに表示したい情報を取得するようになっています。flatMapConcatでRxのように繋げることが可能です。

高校名の取得部分は、MyPageRepositoryにgetSchoolNameという関数を定義して取得するようになっています。 先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173549p:plain

次に、MyPageRepositoryからAPIコールする部分をみていきましょう。 先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173606p:plain

実際のコードは次のとおりです。

class MyPageRepositoryImpl : MyPageRepository {
...
    override suspend fun getSchoolName(context: Context): Flow<String> {
        ...
        return flow {
            try {
                // ServiceはAPIコール部分を担い、APIコールを行い返ってきた値を出力します
                emit(service.getSchoolDetails(schoolId)?.name ?: defaultSchoolName)
            } catch (e: Exception) {
                Logger.e(e)
                emit(defaultSchoolName)
            }
        }
    }
  ...
}

MyPageRepositoryのgetSchoolNameでは、APIコールを行うべくサービスの実体を生成し、Flowビルダー関数で新しいFlowを作成し、APIコールを行い返って来た値を emit します。Flowでは、emitすることで、データストリームに値を出力することができます。高校の表示部分は、デフォルトの文字列が決まっているので、例外でもdefaultSchoolNameをemitするようにしています。

Flowのデータストリームに値を生成して出力をしたら、次は出力した値の収集を行います。

マイページ画面では、ViewModelで収集を行っています。

先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173625p:plain

実際のコードは次のとおりです。

@FlowPreviewに関しては、こちらに詳しく載っており、flatMapConcatを利用しているため付与しています。

また、mikanではDagger Hiltを利用しており、一部Dagger Hiltのコードが含まれています。

@FlowPreview
class MyPageViewModel @ViewModelInject constructor(
    @ApplicationContext private val context: Context,
    private val myPageRepository: MyPageRepository
) : ViewModel() {
    ...
    fun initialize() {
        if (!uiData.shouldInitialize) return
        viewModelScope.launch {
            myPageRepository.getNickName(context)
                .flatMapConcat {
                    uiData = uiData.copy(nickName = it)
                    myPageRepository.getSchoolName(context)
                }
                .flatMapConcat {
                    ...
                }
                .catch { 
                  // Error時に流れる。UiState.Errorを放出する。
                  _uiState.value = MyPageUiState.Error
                }
                .collect {
                  // データの収集を行う。返って来た値をViewModel側で保持しているところで更新し、UiStateを流す。
                    uiData = uiData.copy(
                        isPro = it,
                        ...
                        shouldInitialize = false
                    )
                    _uiState.value = MyPageUiState.Success(uiData)
                }
        }
    }
    ...
}

collect でFlowのデータストリームに流れているデータ収集をすることができます。 マイページでは、今回はStateFlowと組み合わせているのでcollectに来た場合はSuccessとしてUiStateをsetするようにしています。

また、catch 中間演算子で、データ生成時に例外が発生する可能性があるので、ラムダの中で予期しない例外処理を行います。catchに流れて来た場合は、collectラムダは呼び出されないため、今回はError状態としてUiState.Errorをsetするようにしています。

最後に、ViewModelからViewへデータを流す部分について説明していきます。

LiveDataで実装することもあるかと思いますが、今回はStateFlowで実装を行いました。

先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173645p:plain

実際のコードは次のとおりです。

@FlowPreview
@AndroidEntryPoint
class MyPageFragment : Fragment(), BottomNavigationView.OnNavigationItemReselectedListener {
    @Inject
    lateinit var myPageAdapter: MyPageAdapter
    private val myPageViewModel: MyPageViewModel by viewModels()
    ...
    private var job: Job? = null
    ...
    override fun onStart() {
        super.onStart()
        job = lifecycleScope.launch {
            myPageViewModel.uiState.collect { uiState ->
                when (uiState) {
                    is MyPageUiState.Success -> {
                        val uiData = uiState.uiDate
                        myPageAdapter.reload(
                            uiData = uiData,
                            ...
                        )
                        ...
                    }
                    is MyPageUiState.Loading -> {
                        ...
                    }
                    is MyPageUiState.Error -> {
                        // 一部省略してます
                        Toast.makeText(...).show()
                    }
                    is MyPageUiState.None -> Unit
                }.safe  // safeは、コンパイル段階で気づけるようにしている拡張関数です
            }
        }.apply { start() }
    }
    ...
    override fun onStop() {
        super.onStop()
        job?.cancel()
        ...
        job = null
    }
  ...
}

Flowと同様に、StateFlowでも collect で収集を行います。ViewModelでsetしたUiStateのデータがcollectラムダに流れてくるようになり、UiStateの各状態に応じて処理を行います。

一点注意が必要なのが、StateFlowは、UIで収集を行うとViewが表示されていなくてもイベントを処理するため、不具合の原因になりえます。そのため、UiStateのJobを定義してライフサイクルに合わせてstartとstopを行うようにします。 repeatOnLifecycleAPIで解決する手段もありますが、alpha版のため今回はこちらの手法で対応しています。

以上がマイページでのFlow導入例についての紹介でした。 まとめると、AndroidにFlowに導入する時に行うことは次のようになります。

  1. FlowビルダーAPIを利用して flow { } でFlowの生成を行う
  2. emit 関数でデータの生成をし、Flowにデータを出力する
  3. collect で作成したFlowデータストリームのデータ収集を行う
  4. UIでデータ収集する場合は、Jobを定義してライフサイクルに合わせて収集を行う

おわりに

Flowを導入してみて、Rxと同様かつStateFlowでもLiveDataを置き換えることができて特に問題なく運用していけそうなことがわかりました。

Flowに置き換えるメリットとしては、公式が出しているので、そのまま公式に統一できるのが一番でかい。公式なので、という一言で共通認識が取りやすいという部分とCoroutinesを入れている場合は特にサードパーティライブラリに頼らないで置き換えていけそうなので、その点だけでも技術採用しても良いと思いました。

まだ、導入したばかりですので、引き続き運用してみてまたわかったことがあれば記事にしていこうかと思います。

さいごに、mikanでは、一緒に英語学習をより良いものにしていくべくサービスを作っていく仲間を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください!

herp.careers


  1. Kotlin flows on Android では、Flowの概要について詳しく説明されている。3つのエンティティの概念だけではなく、具体例も書かれているので必見です。

  2. CodeLab:8. Introducing Flowでは、サンプルアプリを用いてFlowについて解説がある。Roomを用いた例も示されており、初めの導入として一通り目を通すと学びがあります。

  3. StateFlow and SharedFlowでは、StateFlow と SharedFlowの概念と、実際にAndroidアプリで使うときにどのような想定で使うかについて書かれています。今回プロダクトではalpha版を利用していないため、使っていませんがrepeatOnLifecycle APIの実装例も記載されています。

開発チームのテンションがアガる "Debug icon kit" を公開しました

f:id:mizonit:20210406081601p:plain


こんにちは、株式会社mikanでデザインを担当している @3izorin です。

デザインチーム(といっても一人なので絶賛募集中です)のブログは初めてですね。

今回のブログは短いんですが、 debug版のアイコンを作った話を書いていきます。

  • きっかけは毎週のQAから
  • できたもの
  • こうかはばつぐんだ!
  • OSSとして公開しました
  • おわりに
続きを読む

mikanのデータ分析基盤の歴史

こんにちは、株式会社mikanでデータ分析を担当している @ij_spitz です。

データ分析チームのブログも2本目になりますが、今回は前回の記事で少しだけ頭出しをしていたデータ分析基盤の歴史について書いていきます。

↓前回の記事はこちら mikan-tech.hatenablog.jp

英単語アプリmikanは2014年10月にリリースされたプロダクトでデータ分析基盤も年月を経て変わってきました。

その頃と比べると現在はAWSGCPで分析系のサービスが充実しており、知見も豊富になってきているので、現行の基盤以外は正直参考になりません。 また自分たちでもそんなことをしてたのかと疑いたくなるような運用をしていたので、ツッコミを入れつつ、温かい目で見ていただけると幸いです。笑

第1世代: Redshift(2014年末 ~ 2019年始め)

mikanの初期の分析基盤はRedshiftをメインに置いたものでした。

この頃僕は特にmikanに関わっていたわけではないのですが、2014年末くらいからRedshiftを運用していたという話を聞き、他の企業に比べても早くデータ分析基盤を用意していていたんだなと感じました。

調べたところ、Redshiftが正式にリリースされたのが2013年2月、東京リージョンでリリースされたのが2013年6月だったようです。

www.atmarkit.co.jp

www.atmarkit.co.jp

ログを収集する部分はFlyDataというサービスを使っていました。この頃はKinesis Data Firehoseのようなマネージドのログ収集サービスがなかったので、各社自前でFluentdを立てたり、試行錯誤していた時代だと思います。

FlyDataは導入のサポートが手厚く、mikanが知見のない中で素早くデータ分析基盤を作ることができたのはFlyDataの功績が大きいです(その節はありがとうございました🙏)。

Redshiftをメインに置いていても、ビジュアライゼーションはどうするかなどの詳細の部分は時期によって変わっているので、時系列に沿って順に紹介してきます。

HTMLでのビジュアライズ(2014年末 ~ 2015年末)

Redshift → Python CGI → HTML

Redshiftを導入した初期はHTMLでデータを描画していました。弊社の代表が過去の画像を探し出してきてくれましたが、CSSは無く、HTMLっぽさは一切ありません...

f:id:ishitsukajun:20210224003104p:plain
HTMLでのビジュアライズ

ただしこの方法だと

  • URLにアクセスがある度にRedshiftを叩いてしまうので表示に時間が掛かる
  • 指標を追加するのにPythonとHTMLをいじる必要がある

などのデメリットがあり、今考えるととても良い方法には思えません。笑

独自でビューを作っているのでカスタマイズ性はありますが、そこまで複雑な指標を追っていたり、特別な使い方をしたいというわけではなかったので無駄にコストだけ掛かっていました。

Spreadsheetでのビジュアライズ(2015年末 ~ 2017年始め)

Redshift → Python Batch → Spreadsheet

Redshiftを運用し始めてから少し時が経ってからはHTMLを辞めて、Spreadsheetに書き込む運用になりました。HTML時代よりはだいぶマシになりましたが依然として

  • 指標を追加するにはPythonスクリプトをいじる必要がある
  • Spreadsheetが無限に長くなっていき、メンテナンス性に欠ける

などのデメリットがありました。

Redashでのビジュアライズ(2017年始め ~ 2019年始め)

Redshift → Redash

mikanでは2017年の始めくらいから現在もデータのビジュアライゼーションにRedashを使っています。僕が副業としてmikanに関わり始めたのもこの頃で、界隈でもRedashなどのBIツールが使われ出した時期だと思います。

Redashを導入することによって、プログラムを書く必要がなくなり、圧倒的にデータへのアクセスが容易になりました。

またこの頃にRDB上のデータとログをJOINして分析したいなどのニーズも出てきたので、Data Pipelineを使用してPostgresql上のデータをRedshiftに持っていくなどの仕組みも構築していきました。

第2世代: Athena(2019年始め ~ )

Redashを使うようになってから、ある程度データへのアクセスは容易になりましたが、まだまだ課題はありました。以下がその一例です。

  • クエリが遅い
    • Redshiftのスキーマが列志向ではなく、RDBのようにJOINを前提とする形になっている(1つのユーザーのアクションログがactionsテーブルとparametersテーブルに分かれて保存されており、パラメータを取り出すには2つのテーブルをJOINする必要がある...)
  • Redshiftの運用コストが掛かる
    • 容量を空けるためにログをローテートする必要がある
  • 価格が高い

そんな中で2018年末に、ログの収集に使用していたFlyDataのサービス終了が決まり、Athenaをメインに据えた現状の構成に移行することを決めました。

Athenaをメインに置いた現行のデータ分析基盤(2019年始め ~ )

以下が現行の構成になります。AWSを使った基盤では一般的なもので特に変わった点はあまりないのですが、mikanでは少人数で開発を行っているということもあり、余計な開発コストや運用コストを掛けたくなかったため、できる限りマネージドサービスに乗っかる、構成をシンプルに保つということを意識しています。

f:id:ishitsukajun:20210222181922p:plain
現行のデータ分析基盤

現時点では現在の構成で大きな不満点はないので、大幅な基盤の構成の変更などは考えていません。しかしながら現状で大満足というわけでもなく、より一層データ分析をしやすくするために直近では以下のようなテーマに取り組んでいきたいと考えています。

  • 普段よく見るKPIを集計して保存しておくデータマートをRDBで用意
  • 集計をしやすくするためにAthena上に中間テーブルを構築
  • 速報値などのリアルタイムなデータ集計
  • FirestoreなどGCPサービスとのデータ連携

おわりに

長くなりましたが、以上がmikanのデータ分析基盤の歴史となります。公開するのをためらわれるような事例もありましたが(笑)、なんとかプロダクトをより良くしようと試行錯誤している様子が伝われば幸いです!

最後に弊社では一緒に働く仲間を募集中です。

この記事でご覧いただいたように、まだまだmikanはプロダクトを始めとして、開発環境、分析環境も改善していく余地がたくさんあるフェーズなので、少しでも興味をお持ちいただいた方はぜひご連絡ください!

mikan.link

mikanにおける不具合との向き合い方

f:id:mizonit:20210208232555p:plain

こんにちは。 株式会社mikanでiOSエンジニアなどをしている、飯田(@aviciida)といいます。 この記事は「モバイルアプリマーケティングアドベントカレンダー2020」の6日目の投稿です!

前回のTech Blogでは、「mikanがどんなテーマ・やり方で開発を進めているのか」をざっくり触れました。

テーマとしては大きく
ゴリゴリ改善回していく系(色んな改善を回して継続率・LTVをあげよう!)

開発基盤整える系(クライアントサイドのDBをFirestoreに統一しよう!)
があり、多くの開発リソースをそこに割いています。

しかし多くのユーザーさんに毎日使っていただいているmikanには、日々多くの問い合わせが寄せられ、不具合も報告されており、不具合修正の開発も必須になってきます。

この記事では、mikanでは不具合に関してどのように向き合っているか、また報告される不具合をどうCSチームと開発チームが連携して管理しているのか、という話を書こうと思います。


目次

  • 目次
  • 不具合との向き合い方
    • 1. 不具合の優先順位
    • 2. 常にユーザーの声に触れる
    • 3.「プロダクトチーム」として一丸となって向き合う
  • 不具合の管理方法を刷新した話
    • 1. 当時の課題
    • 2. 何をしたか
    • 3.「でも運用が大変なんでしょ?」
  • 今の課題: そもそも不具合を出さない体制に
  • 採用情報
  • 付録: mikanでのNotion活用術


続きを読む