コネヒト開発者ブログ

コネヒト開発者ブログ

Canvas を使って画像をリサイズする

はじめに

こんにちは! フロントエンドエンジニアの もりや です。

今回はママリのアプリ内で使われている WebView で、画像をリサイズする処理を Canvas で実装した事例を紹介します。

画像のリサイズが必要な理由

昨今のスマホのカメラで撮った画像は数MB程度と大きく、アップロードに時間がかかったり、そもそもサーバー側で何MBまでの画像を許容するかなど課題もあります。 また iOS/Android のママリアプリでも、おそらく同様の理由からリサイズをしてアップロードするようになっていました。 そのため、WebView でもアップロード前に画像をリサイズする処理を入れ、快適かつ安全にアップロードできるようにしました。

ライブラリなどもあると思いますが、今回のようにシンプルなリサイズ用途であれば Canvas のみで十分可能と判断し実装してみました。

Canvas とは

Canvas API は JavaScript と HTML の <canvas> 要素によってグラフィックを描く方法を提供します。他にも、アニメーション、ゲームのグラフィック、データの可視化、写真加工、リアルタイム動画処理などに使用することができます。 https://developer.mozilla.org/ja/docs/Web/API/Canvas_API

つまり、グラフィックに関する様々なことができる Web API です。

サポートされているブラウザも96%以上とかなり多く、ほとんどの環境で使えると思います。

Can I Use Canvas https://caniuse.com/canvas

Canvas を使ったリサイズの実装

今回実装したリサイズ処理を、実装例を使いながら解説します。 (コード全体を見たい場合は「コード例」の章まで飛ばしてください)

なお、今回はコードをシンプルにするため幅 (width) だけを指定してリサイズするような処理にしています。

1. Context の取得

Canvas に描画するために必要な CanvasRenderingContext2D を取得します。

const context = document.createElement('canvas').getContext('2d')

ちなみに 2d の他に webgl, webgl2, bitmaprenderer といった値も指定できるようです。 (私は使用したことがないので、説明は省略します)

2. 画像サイズの取得

リサイズ後のサイズを計算するために、Image を使用して変換対象の画像のサイズを取得します。

const image: HTMLImageElement = await new Promise((resolve, reject) => {
  const image = new Image()
  image.addEventListener('load', () => resolve(image))
  image.addEventListener('error', reject)
  image.src = URL.createObjectURL(imageData)
})
const { naturalHeight: beforeHeight, naturalWidth: beforeWidth } = image
console.log("H%ixW%i", beforeHeight, beforeWidth) // => H800xW600

画像のロード後でしかサイズが取得できないので、コールバックを使いつつ Promise でラップするような感じにしています。

ちなみに new Image() で引数を指定しない場合は、naturalHeight, naturalWidth でも height, width でも同じ値になるようです。

CSS pixels are reflected through the properties HTMLImageElement.naturalWidth and HTMLImageElement.naturalHeight. If no size is specified in the constructor both pairs of properties have the same values.

https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image#usage_note

3. 変換後のサイズを計算

今回は幅 (width) のみを指定する方法にしているので、比率を保ちつつリサイズできる高さを計算して出します。

const afterWidth: number = width
const afterHeight: number = Math.floor(beforeHeight * (afterWidth / beforeWidth))

4. Canvas にリサイズ後のサイズで画像を描画

まず Canvas のサイズをリサイズ後の大きさにします。

context.canvas.width = afterWidth
context.canvas.height = afterHeight

そして、画像をキャンバス上に描画します。

context.drawImage(image, 0, 0, beforeWidth, beforeHeight, 0, 0, afterWidth, afterHeight)

引数を9個指定した場合は、以下のような内容になります。

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage

  • 元画像のデータ (image)
  • 元画像データの描画開始座標 (sx, sy)
  • 元画像のサイズ(sWidth, sHeight)
  • キャンバスへの描画開始座標 (dx, dy)
  • キャンバスへの描画サイズ (dWidth, dHeight)

という感じになります。 元画像全体を、キャンバスのサイズピッタリに描画するというような意味合いになります。 これが実質リサイズ処理になります。

5. Canvas の内容を JPEG で出力

最後に Canvas の内容をJPEGとして出力します。

const jpegData = await new Promise((resolve) => {
  context.canvas.toBlob(resolve, `image/jpeg`, 0.9)
})

こちらもコールバックしか使えないので、Promise でラップするような感じにしています。

ちなみに image/jpeg 以外にも image/pngimage/webp なども使えるようです。

コード例

これらのコードをまとめた関数の実装例を紹介します。 (ママリで実際に使っているコードと全く同じではないので悪しからず)

export const resizeImage = async (imageData: Blob, width: number): Promise<Blob | null> => {
  try {
    const context = document.createElement('canvas').getContext('2d')
    if (context == null) {
      return null
    }

    // 画像のサイズを取得
    const image: HTMLImageElement = await new Promise((resolve, reject) => {
      const image = new Image()
      image.addEventListener('load', () => resolve(image))
      image.addEventListener('error', reject)
      image.src = URL.createObjectURL(imageData)
    })
    const { naturalHeight: beforeHeight, naturalWidth: beforeWidth } = image

    // 変換後の高さと幅を算出
    const afterWidth: number = width
    const afterHeight: number = Math.floor(beforeHeight * (afterWidth / beforeWidth))

    // Canvas 上に描画
    context.canvas.width = afterWidth
    context.canvas.height = afterHeight
    context.drawImage(image, 0, 0, beforeWidth, beforeHeight, 0, 0, afterWidth, afterHeight)

    // JPEGデータにして返す
    return await new Promise((resolve) => {
      context.canvas.toBlob(resolve, `image/jpeg`, 0.9)
    })
  } catch (err) {
    console.error(err)
    return null
  }
}

サンプルページ

上記のコードを使って、簡単に試せるページを用意してみましたので、興味がある方はお試しください。

https://mryhryki.com/experiment/resize-on-canvas.html

preview

(猫画像はこちらのフリー素材を使用しました) https://pixabay.com/ja/photos/%e7%8c%ab-%e8%8a%b1-%e5%ad%90%e7%8c%ab-%e7%9f%b3-%e3%83%9a%e3%83%83%e3%83%88-2536662/

おわりに

ブラウザの機能だけをつかって、シンプルに画像のリサイズ処理を実装することができました。 実は、個人的に Skitch の代替として使っている Web App を作った経験が生きた感じで、割とすんなりと実装ができました。 なんでも色々やって見るものですね。

PR

コネヒトではエンジニアを募集しています!

hrmos.co

AWS OpenSearchでの技術検証をスムーズにしたTIPS

こんにちは。インフラエンジニアの永井(shnagai)です

今回は3ヶ月ほど行っていたAWS OpenSearchの技術検証をしている中で、技術検証のスピードアップに貢献してくれたTIPSを2つご紹介出来ればと思います。

内容はざっくり下記2項目です。

  • ユーザ辞書やシノニムOpenSearchのカスタムパッケージを使って管理する
  • Reindex APIを使ったIndex変更内容の反映高速化

ユーザ辞書やシノニムOpenSearchのカスタムパッケージを使って管理する

カスタム辞書と日本語全文検索

今回、日本語の全文検索を行うための技術検証でOpenSearchを触りました。

全文検索を行うにあたっては、ユーザ辞書やシノニム、ストップワードの設定が肝になります。

例えば、OpenSearchで使える日本語の形態素解析エンジンであるkuromojiを使って文章を分割するケースを考えてみます。

ユーザ辞書を使わないプレーンなkuromojiの解析結果が下記です。

抱っこ/紐/と/朝/ごはん/を/食べる/こと

抱っこ紐が「抱っこ」と「紐」に分割されているので、このままだと「抱っこ紐」と全文検索した時にこの文章のスコアは高くなりません。(厳密に言うとsearch時にどう単語を分割して当てにいくかの話だがここでは単純なパターンを想定して話します)

「抱っこ紐」と検索したら、「抱っこ紐」の情報が一番に出てきてほしいので、ユーザ辞書で「抱っこ紐」を定義したインデックスを作ります。

tst_custom_dicというカスタム辞書を使ったインデックスのサンプル

PUT tst_custom_dic
{

    "settings": {
      "index": {
      "analysis": {
          "tokenizer": {
            "kuromoji_user_dict": {
              "type": "kuromoji_tokenizer",
              "user_dictionary_rules": [
              "抱っこ紐,抱っこ紐,ダッコヒモ,カスタム名詞"
              ]
            }
          },
          "analyzer": {
            "tst_analyzer": {
              "type": "custom",
              "tokenizer": "kuromoji_user_dict"
            }
          }
        }
      }
  }
}

user_dictionary_rules 部分でカスタム辞書を定義し、インデックスを再作成することで下記のように「抱っこ紐」を一単語として分割することが可能になります。

抱っこ紐/と/朝/ごはん/を/食べる/こと

カスタムパッケージを利用してインデックスを定義

先程説明した形は、インデックスのtokenizerの定義で辞書を管理する方法なのですが、カスタム辞書は何百、何千というワードを登録するためインデックスの定義で辞書を管理すると本質的ではない部分で定義が冗長になり可読性や保守性が著しく下がってしまいます。

このような課題を解決するために、OpenSearchではs3に保存したカスタム辞書ファイルを使える機能がカスタムパッケージという名前で提供されています。 シノニムとストップワードも同じ方法で外部ファイル化が可能です。

詳細な設定方法は、下記の公式ブログで詳しく解説されているのでこちらをご覧ください。

https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/custom-packages.html

カスタムパッケージを使う際のインデックスの定義は下記のようになります。

PUT tst_custom_dic
{

    "settings": {
      "index": {
      "analysis": {
          "tokenizer": {
            "kuromoji_user_dict": {
              "type": "kuromoji_tokenizer",
              "user_dictionary": "analyzers/F72444002"
            }
          },
          "analyzer": {
            "tst_analyzer": {
              "type": "custom",
              "tokenizer": "kuromoji_user_dict"
            }
          }
        }
      }
  }
}

tokenizer内の user_dictionaryanalyzers/パッケージID を指定することで利用可能になります。

f:id:nagais:20220318091812p:plain

カスタムパッケージを使うことで、辞書更新が下記のようなフローで可能になります。

  1. カスタム辞書を手元のエディタで追加
  2. s3にファイルをアップロード
  3. OpenSearchのカスタムパッケージ機能で2のファイルをインポート
    1. 新しいversionが付与される
  4. 辞書更新をしたいOpenSearchクラスタで「更新を適用」するとパッケージが最新になる(5分くらいかかるがその間は前のverを使える)
    1. 同じカスタムパッケージを利用していても、クラスタ単位で適用タイミングをずらせるので、stgで先にカスタム辞書を更新して動作確認してから、本番にも適用するというようなフローを組むことも出来ます。
  5. インデックスを更新(Reindexもしくは作り直し)することで新たなカスタム辞書でトークン分割されたインデックスを利用可能に
    1. Reindexについては、この後紹介します
  6. エイリアス更新するかインデックス名を変えて参照元からの参照先を変えるか
    1. この辺はまだ設計途上

今考えているデプロイパターンだと、

2のs3アップロードをGitHubマージトリガで動かして、3以降はs3のputトリガでStepFunctionsを動かすも良し、CIツールでAWS SDK使ったフロー組むのも良しという形で自由度高く組めると考えています。

まだ、その部分の検証はしていないので手を動かしながら最適解を探していきたいと思います。

最後に、もっとこうなるとうれしいと思った部分を一つだけ

  • パッケージの更新時にバリデーション走ると尚うれしい
    • 現状だと辞書の内容の間違いに気づくのは、openSearchのインデックスを更新するタイミングでのエラーで気づく
    • 只、自動適用考える時にCI側でバリデーションするのがフローとしては健全と思っている(この部分はまだ未検証)

Reindex APIを使ったIndex変更内容の反映高速化

OpenSearch(Elasticsearchでも一緒)では辞書の更新はもちろん、インデックス自体の定義を更新するには、インデックス自体を新規で作り直す必要があります。

これはインデックスの作成時に、定義に基づいてトークン化が行われるからです。

検証中は特に、インデックスの定義を更新して、データがどう変わるかを見るというオペレーションが頻発します。

ストレートにこのインデックス更新を行うと、下記手順を繰り返します。

  • 定義の更新
  • 元のインデックスの削除 DELETE Index
  • インデックスの新規作成 PUT Index ~BodyにJSONの定義
  • 新規のインデックスにデータを投入

あまりにも非効率だなと思い、色々と調べるとelastic社の公式ドキュメントにReindexというAPIが紹介されていることを発見しました。

https://www.elastic.co/guide/en/elasticsearch/reference/7.6/docs-reindex.html

下記のような簡単なAPIを叩くことで、インデックスのコピーが出来ることを発見しました。

量により処理時間はマチマチですがbulkでデータ投入するよりは圧倒的に早いです。

POST _reindex
{
  "source": {
    "index": "元のインデックス名"
  },
  "dest": {
    "index": "新しいインデックス名"
  }
}

このReindexを使うようになったことで技術検証のスピードは圧倒的に早くなりました。(始めから気づけよという話ではあるのですが。。)

Reindexを使った本格的な運用について、二三歩先に進んだ事例も下記のブログで紹介されています。 https://tech.legalforce.co.jp/entry/2021/12/21/190129

今回はOpenSearch(Elasticsearch)の運用に役立ちそうなTIPSを2つ紹介しました。

次回はもう少し踏み込んだ内容の日本語の形態素解析でハマったことを中心に書こうと思います。

コネヒトではサービスの信頼性向上をミッションに幅広い領域をカバーしながらエンジニアリングの力でサービスをよりよくしていけるエンジニアを募集しています。 少しでも興味もたれた方は、是非気軽にお話出来るとうれしいです。

サービスファーストな思考ができるインフラエンジニア募集! | コネヒト株式会社

iOS/Android と WebView でデータを連携する仕組みを作りました

はじめに

こんにちは! フロントエンドエンジニアの もりや です。

今回は、ママリアプリ内で iOS/Android と WebView 間でデータを連携する仕組みを作った事例を紹介します。 2021年6月頃に実装してリリースし、現在(2022年3月)も問題なく使えています。

データの連携を使いたい場面

ママリの場合、例えば以下のような場面で使っています。

  • 【WebView → iOS/Android の例】
    • WebView で作った入力画面で編集中の時に、閉じるボタンを押した場合は iOS/Android 側で確認ダイアログを出す
  • 【iOS/Android → WebView の例】
    • iOS/Android 側で処理を行った後で、WebView 側で何らかのアクションを行いたい場合

それまではその場その場で対応していましたが、これらを共通で便利に扱うための仕組みをそろそろ作りたいね、という話がでてきたので実装をしました。

JavaScript (TypeScript) での実装方法

WebView → iOS/Android の連携

この場合は window.mamariq.state という名前空間を用意して、iOS/Android からそこを参照してもらう形にしました。

window.mamariq = {
  state: {
    PAGE: {       // ページごとに名前空間を作る
      KEY: VALUE, // 参照してもらいたい値を入れる
    }
  }
}

状態が変わったら、その値を更新していきます。 React の場合は、以下のように useEffect で更新するようにする実装が多いです。

useEffect(() => {
  window.mamariq.PAGE.KEY = value
}, [value])

あとは iOS/Android から必要な時にセットされている値を見にいけばOKです。

(iOS/Android での実装方法は「iOS/Android からの呼び出し方」の章を参照してください)

補足1: WebView からプッシュしたい場合

上記の方法は、状態を参照して処理するタイミングが iOS/Android 側で決めるものなので、すぐに iOS/Android へ状態を伝えたい場合には使えません。 そういった用途は(ページ遷移を伴わない)専用のディープリンクを作って対応しています。

補足2: iOS の一般的なやり方

ちなみに iOS でプッシュする場合あれば、以下のやり方が一般的だそうです(yanamura からお聞きしました)

【Swift】WKWebViewでJavaScriptのコールバックを受けつける(WKUserContentControllerの使い方)

ただ、このやり方だと iOS と Android で方式を変えないといけないので、共通で使えるやり方を考えました。

iOS/Android → WebView の連携

この場合は window.mamariq.action という関数を用意しておき、iOS/Android から呼び出してもらう形にしました。 また、UI側では必要なシーンでリスナーを登録しておき、イベントの内容に応じて処理を登録する、という形にしています。

ざっくりと以下のような構造になっています。

+-----------------------+
|     iOS/Android       |
+-----------------------+
           |
           | イベント発行
           v
+-----------------------+
| window.mamariq.action |
+-----------------------+
           |
           | イベント発行
           v
+-----------------------+
|       listeners       |
+-----------------------+
          ^  |
 登録/削除 |  | イベント発行
          |  v
+-----------------------+
|        UI (React)     |
+-----------------------+

リスナーの管理・登録・削除

リスナーは以下のような型定義になっています。 type というイベントを識別するキーと、必要な場合は(iOS/Android 側で)payload に情報を詰めて呼び出してくれます。

// リスナーに渡されるイベントの型定義
export interface MamariqBridgeEvent {
  type: string
  payload: ObjectType
}

// リスナーの型定義
type MamariqBridgeEventListener = (event: MamariqBridgeEvent) => void

リスナーを管理する配列と、それに追加・削除をするための関数を用意しておきます。

// リスナーを管理する配列
let listeners: MamariqBridgeEventListener[] = []

// リスナーを削除する関数
export const removeMamariqEventListener = (listener: MamariqBridgeEventListener): void => {
  listeners = listeners.filter((_listene) => _listener !== listener)
}

// リスナーを追加する関数
export const addMamariqEventListener = (listener: MamariqBridgeEventListener): void => {
  removeMamariqEventListener(listener) // 重複登録を避けるため、念の為一度削除する(通常は何も起こらない)
  listeners.push(listener)
}

window.mamariq.action

iOS/Android からイベントを受け取るための関数を定義しておきます。

window.mamariq = {
  action: (event: unknown): void => {
    const type = `${checkObject(event).type ?? '(unknown)'}`
    const payload = checkObject(checkObject(event).payload) ?? {}
    listeners.forEach((listener) => listener({ type, payload }))
  }
}

checkObject はオブジェクト型であるかを確認して、違う型であっても必ずオブジェクト型で返してくれる関数です。

type ObjectType = { [key: string]: unknown }
const checkObject = (target: unknown): ObjectType => {
  if (typeof target === 'object' && target != null && !Array.isArray(target)) {
    return target as ObjectType
  }
  return {}
}

React からのリスナーの登録・削除

実際に使用する側では、useEffect を使ってこういう感じで実装してます。

useEffect(() => {
  const receiveMamariqBridgeEvent = (event: MamariqBridgeEvent): void => {
    switch (event.type) {
      case 'EVENT_TYPE':
        // do something
        break
    }
  }
  addMamariqEventListener(receiveMamariqBridgeEvent)
  return () => removeMamariqEventListener(receiveMamariqBridgeEvent)
}, [])

そのコンテキストで必要なイベントのみハンドリングするようにしておくことで、リスナーを複数登録したり新しいイベントを追加した場合でも問題が起きにくくしています。

この実装のメリット

iOS/Android で同じ形でできることが一つのメリットかな、と思います。

また DevTools のコンソールを使って、実機を繋がなくてもブラウザ単体で動作確認ができるのも一つのメリットだと思います。 (他の方法はあまり知りませんので、推測です)

// 現状のステートを確認できる
console.log(window.mamariq.state.PAGE.KEY)

// iOS/Android からイベントが来た場合の動作を確認できる
window.mamariq.action({ event: "EVENT_TYPE"})

ハマったところ

iOS で boolean が数値扱いされる

iOS の場合、boolean値 (true, false) が何故か 0, 1 の数値として取得できてしまうとのことでした。 その原因はわかりません・・・。 (知っている方いましたらコメントいただけると嬉しいです)

今回は、それぞれ文字列 ("true", "false") とすることで対応しました。

iOS/Android からの呼び出し方

yanamuratommykw にご協力頂き、それぞれの OS での実装箇所を抜粋しました。

iOS での呼び出し方

状態の読み取り

window.mamariq.xxx のデータを以下のように取得します。

webView
    .evaluateJavaScript(
        "window.mamariq.xxx"
    ) { [weak self] result, _ in
        if let result = result as? String, result == "true" {
            // do something
        } else {
            // do something
        }
    }

イベントの発行

window.mamariq.action() で以下のようにイベントを発行します。

let params: [String: Any] = [
    "type": "xxx",
    "payload": [
        "yyy": "zzz"
    ],
]
do {
    let data = try JSONSerialization.data(withJSONObject: params, options: [])
    guard let stringValue = String(data: data, encoding: .utf8) else {
        assertionFailure()
        return
    }
    contentViewController.webView.evaluateJavaScript(
        "window.mamariq.action(\(stringValue))"
    ) { _, _ in }
} catch {
    // do something
}

Android での呼び出し方

状態の読み取り

window.mamariq.xxx のデータを以下のように取得します。

// データバインディングを利用
binding.webView.evaluateJavascript("window.mamariq.xxx") { result ->
    if (result == "true") {
        // do something
    } else {
        // do something
    }
}

イベントの発行

window.mamariq.action() で以下のようにイベントを発行します。

// データバインディングを利用
binding.webView.evaluateJavascript("window.mamariq.action({type:'xxx'})") {}

おわりに

既に実装して8ヶ月以上が経ち、本番環境でも使っていますが、現状では特に問題なく使えています。 やっていることがシンプルなので、あまり不具合が起きにくいというのもあるかもしれません。

この仕組みを作っておいたおかげで、iOS/Android と WebView でデータを連携する時に実装方法に迷うことがなくなり、実装に集中できるようになりました。 最初が少し面倒ですが、早めにやっておくとコスト的にペイできたな〜、と思っています。

PR

コネヒトではエンジニアを募集しています!

hrmos.co

1年間プロダクトゴールを運用していく中で行った3つの工夫

こんにちは。バックエンドエンジニアのTOCです。

弊社ではミッションごとにいくつかチームに分かれており、それぞれのチームでスクラム開発を行なっています。 (弊社開発体制についてはこちらの記事に詳しく記載があります) 今回は僕が所属しているチームで去年から運用しているプロダクトゴールについて、運用するにあたって、どういった工夫をしているのかを書きたいと思います。

※当エントリーでは「プロダクトゴールとは何か」という点については言及しませんが、同じチームの方が共有してくれたブログにプロダクトゴールの例が記されているので、もしよければ参考にしていただければと思います。

Product Goal & Sprint Goals – A Simple Example


目次


プロダクトゴールを運用するようになった背景

僕が所属するチームでは、スクラムガイドが改訂になったタイミングでスクラムガイドの理解を深める会を行いました。この会ではスクラムガイド2020解説ビデオをみんなで鑑賞した後に、それぞれが感想や疑問に思ったことを出し合い議論することを行いました。

(一人で黙々と読んでも理解が深まりづらい僕にとって、こういった場があるのは非常にありがたいです🙌 )

この会の中で、プロダクトゴールってなんなの?という疑問が議論の中心になり、チームで深ぼってみる価値がありそうだ、ということで継続してプロダクトゴールについて考えようということになりました。

何度かプロダクトゴールについて話し合い、チームとしてプロダクトゴールを実際に作って試してみよう!ということで自分たちなりに解釈をし、作成したプロダクトゴールを運用してみることに決定しました。

プロダクトゴールを運用するために行なってる3つの工夫

運用することが決まってから、半期ごとにプロダクトゴールを設定し、日々開発を行なっているのですが、運用する上で行なっている工夫について3つご紹介したいと思います。

1. 日常的に振り返る

プロダクトゴールを設定したはいいものの、ただ設定しただけでは自分達って何を目指しているんだっけ?というような状態になり、目の前のことに追われてしまいかねません。 そこで、自分達が達成したいゴールを意識しながら開発ができるように日常的にプロダクトゴールを目にする機会を作るようにしています。

具体的にはデイリースクラムやレトロスペクティブなど、日々行なっているスクラムイベントのタイムラインにプロダクトゴールを確認する時間を設けるようにしています。

例えばデイリースクラム時は先日リリースした機能がプロダクトゴールにどう寄与しているのかをPdMから共有してもらったりしています。 また、デイリースクラムの進行表にはプロダクトゴールを一番上に書いてるので、日々目に入ってプロダクトゴールがチームの合言葉みたいな感じになりました💪

ちょっとした工夫で盛り上がりながらチームで運用できているので、継続して振り返ることができています!

2. 定期的に振り返る

日常的にプロダクトゴールの振り返りを行なっていますが、時には深い議論をしたり、「本当に達成できるのか」「達成するためにできることはないのか」といったちょっとした時間ではできない議論をしたくなったりします。 なので月に一回、「プロダクトゴールの現状確認と振り返り」という会を1時間半かけて行なっています。 この会はモデレーターをチームで交代制にしていて、それぞれが考えた流れで進行していきます。モデレーターが毎回変わるので、今までにいろんな会が行われました!

  • プロダクトゴールを眺めてKPTをやってみる
  • プロダクトゴールの達成ができそうかをグラフ化してみる
  • プロダクトゴール達成に向けて勢いづけるためにウィンセッションしてみる
  • チームメンバーが考えたワークを行なって、日々の仕事とプロダクトゴールの関係性を考えてみる

f:id:toc8:20220311112746p:plain
達成できそうかの表明や、ウィンセッションの様子

メンバーの個性が出るので、今回はどんな感じなんだろうと毎月ワクワクしています!

実際に運用してみると、計画通りにいかないこともあるので、一回立ち止まって考える機会を設けると日々の変化に対応した運用ができるかなと思っています。

3. 達成を諦めないアイデア会

2.でも記載したように、プロダクトゴール達成のための計画を遂行していく中で、やってみて初めてわかることだったり、思うようにいかなかったりすることはあるかと思います。結果を見た上で、もっとこういうことできるんじゃない?というような具体的な施策案を思いついたとしても、あまりチームで案出しをして話し合える機会って意外とない、という意見があったので、プロダクトゴールを達成するためのアイデア出し会を最近行いました。 やり方としてはアイデアをブレストしてからインパクトと工数の2軸でマッピングをして、それを元にPdMの方がブラッシュアップした施策案リストをNotionに作成してくれます。

f:id:toc8:20220310233927p:plain
出てきたアイデアをマッピング
f:id:toc8:20220311094341p:plain
施策案リスト

実際に行ってみると、プロダクトゴール設定時には出てこなかったアイデアだったり、最近リリースした機能を応用して少ない工数でできそうなアイデアが出てきて、非常に有用な会になりました。

チーム内でも、こういった機会は定期的にあっても良さそう、という話になり、隔週くらいのペースで時間をとってみることをトライしています。 この取り組みは最近始めたばかりなので、まだ試行錯誤中ですが、ブラッシュアップしていきながら、より有用なものにしていきたいと思っています!

おわりに

以上がプロダクトゴールを運用するにあたってチーム内で行なっている工夫になります。 日々運用する中で、チーム内で「これってこのままでいいんだっけ?」「もっと良くできるんじゃない?」といったアイデアが出てくるのが素敵だなぁと思いながら、運用方法をブラッシュアップしています! 今回紹介した内容が、何かしら日々の開発に役立つようなことがあれば幸いです。

コネヒトでは、各チームで様々な工夫を行い日々スクラム開発を進めています。 今回のような活動に少しでも興味をもたれた方は、ぜひ一度お話させてもらえるとうれしいです。

hrmos.co

Android版ママリアプリのリファクタ事情 ~パッケージ構成編~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

前回のエントリーから、髪の毛はアップデートされておりません。そろそろ髪の毛切りに行ってさっぱりと4月を迎えたいと思っています!

さて今回は、パッケージ構成のリファクタリングについて紹介いたします。

初めに

コネヒト社で開発しているママリ Android 版は、開発が始まってから 5 年以上経過しました。

開発当初からの歴史の中で、さまざまなコードを継ぎ足してきたママリ Android 版は、いくつもの改善ポイントを抱えています。この記事では、ようやくメスを入れられた 「目的のコードにたどり着くまでに時間がかかる」 という問題への取り組みを紹介します。

結論

まず、問題へ取り組んだ結果を紹介します。

対応としては、パッケージの構成を変更しました。 以下の画像は、ママリ Android 版の今までのパッケージ構成です。

f:id:katsutomu0124:20220311112336p:plain

そして、改善後のパッケージ構成がこちらです。

f:id:katsutomu0124:20220311112405p:plain

なぜ問題が発生してたか

「目的のコードにたどり着くまでに時間がかかる」 という問題が起こっていた理由を考察したところ、「ある機能を実現しているコードがまとまっていないから」という話になりました。

普段のプロダクト開発では、新規機能の開発もありますが、すでに実装している機能に対しての改修も頻繁に行われています。

その機能を実現している既存のコードが、複数のパッケージに分散している、同じロジックなのに複数箇所に分散している、などまとまっていない状態になっていた場合、まず分散している箇所の把握から始める必要があり、その知識があった上で改修を行う必要があります。

日々開発している我々が「この機能のコードがどこにあるのか」というのを常に覚えられているか、というと、人によってはなかなか難しいことと感じることもあります。思い出す時間や探す時間が少しばかり必要になることもあり、そこにパワーを割けなくてもよいならそれに越したことはないですね。

改修内容

コードをまとめる、にしてもいろいろな観点がありますが、まずパッケージを見直すことを最初の一手としてメスを入れました。

今までのパッケージ構成は以下の通りでした。

f:id:katsutomu0124:20220311112336p:plain

この構成で発生していた問題は、例えばある機能を構築するクラス FooActivity FooFragment FooAdapter FooViewModel があった場合、パッケージレベルで分散しているので、これらのクラスを探すことに無駄なパワーを割く必要がありました。

- .activity
  - FooActivity
- .adapter
  - FooAdapter

改修後のパッケージは以下の通りです。

f:id:katsutomu0124:20220311112405p:plain f:id:katsutomu0124:20220311112501p:plain

この構成にのっとると以下のような配置になります。 こうすることで、ある画面への改修をするときに、この .foo パッケージさえ知っていれば探す手間も省けます。またパッケージの命名は、普段の業務中の会話で使われている言葉を流用することで、より直感的なコードに近くなるのも良いです。

- .foo
    - FooActivity
    - FooAdapter

おわりに

今回は、なかなか手をつけられていなかったパッケージ構成の改善について紹介させていただきました。今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!

今回の改修を主導してくれた、もっさん*1に感謝します!!

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co

*1:業務委託で参画してくれている水元さんです

Google オプティマイズのリダイレクトテストを使ってABテストを実施する

こんにちは。 @otukutunです。 今回とあるキャンペーンサイトでGoogle オプティマイズのリダイレクトテストとCakePHPを連携して、すばやくABテストできる仕組みを構築したのでその方法について説明します。

Google オプティマイズとは

Google オプティマイズはGoogleが提供しているABテストツールで、無料で提供されています(2022/03/11時点)。Google製ということもあり、Googleアナリティクス連携がスムーズにできる使い勝手のよいツールです。Google オプティマイズには様々なテスト方法が提供されていて、A/B テストやサーバーサイドテスト、リダイレクトテストなど様々な方法(エクスペリエンス)が提供されています。

各方法の具体的な説明はこちらの公式ページに見ていただくといいですが、ざっくりと説明すると

  • A/B テストはHTMLや画像などのクライアントサイドの要素をテストできる、各種variantの振り分けはGoogleオプティマイズに任せることができる
  • サーバーサイドテストはサーバーサイド側の要素もテストできる、ただし各種variantは自前でする必要がある(サーバーサイドテストの詳細はこちら)
  • リダイレクトテストはランディングページ(以下LP)のテストができる、各種variantの振り分けはGoogleオプティマイズで行われ、ランディング後にリダイレクトされる

になっています。

サーバーサイドの変更をテストする場合は、サーバーサイドテストとリダイレクトテストが候補として上がりましたが、今回はリダイレクトテストを選択して行いました。理由としてはいくつかあるんですが

  • 今回の対象がキャンペーンサイトでLPは1つになるので、リダイレクトテストでも要件を満たせた(サーバーサイドテストである必要がなかった)
  • リダイレクトテストではVariantの比率などをwebページから操作できるので柔軟に設定できる(後から比率を変更することもできます)。サーバーサイドテストではサーバー側で実装する必要がある
  • 結果が分かった段階で、一旦成果がでたvariantに寄せることができる
  • Google Optimizeの設定が実装依存が少ないのでシンプルになる 

などの利点があり、リダイレクトテストにしました。

リダイレクトテストを実施する

リダイレクトテストと連携するために

  • LPでvariantを表すクエリストリングストリングからvariant設定する
  • variantを取得する機能を提供(切り替えのため)
  • (任意) 完了ページでURLでクエリストリングを付与する

を実装して連携できるようにしました。実装例はCakePHPですが、他の言語やフレームワークでも同じような実装でできるかと思います。

オプティマイズの設定

f:id:azuki_mihomiho:20220308192044p:plain
a

パターンでLPのvariantごとのURLを設定し、ページターゲティングを設定するだけです(目標数値などの設定は今回のテーマではないので省略します)。

CakePHPの実装

実装はシンプルでLPでクエリパラメータをみてvariantを設定して、それによって挙動を変えてあげるだけです。endページではクエリパラメータをつけることでどのvariantかをGAだけでなく広告のコンバージョンタグでも判別が容易になるようにしています。

CampaignsController.php

<?php

class CampaignsController extends AppController
{
    public function lp()
    {
        // ABテスト開始
        $this->startABTest();
        // variant毎に処理を変える
        if ($this->getABTestVariant() === 'a') {
            // オリジナルの場合
        } else if ($this->getABTestVariant() === 'b') {
            // variant Bの場合
        }
        // variantをクエリストリングに付与する
        $this->redirect('controller' => 'Campaings', 'action' => 'end', '_method' => 'GET', ['?' => ['variant' => $this->request->getSession()->read('ab_test_session')]]);
    }

    public function end()
    {
         // クエリストリングにvariantが付与される
    }

    /**
     * ABテストのタイプを設定
     */
    private function startABTest()
    {
        // 設定済みの値
        $current = $this->getRequest()->getSession()->read('ab_test_session');
        $variant = $this->request->getQuery('variant');

        if (!in_array($variant, ['a', 'b'])) {
            // クエリパラメーターなしの場合は既に設定されている値、それもない場合はaとする
            $userType = $current ?? 'a';
        }
        $this->getRequest()->getSession()->write(
            'ab_test_session',
            $userType
        );
    }
}

Viewで挙動を変えたい場合はこんな感じで、切り分ければいけます。

lp.ctp

<?php
// variantを取得
$abTestVariant = $this->request->getSession()->read('ab_test_session')
?>


<?php if ($abTestVariant === 'a'): ?>
<p>Aだよ~</p>
<?php elseif ($abTestVariant === 'b'): ?>
<p>Bだよ~</p>
<?php endif; ?>

おわりに

Google オプティマイズのリダイレクトテストと簡単な仕組みでvariantの振り分けは任せつつvariant毎の実装に集中できるようになりました。今はComponentとHelper化してさらに使いやすくなり、素早くABテストを行えるようになっています。何かの参考になれば幸いです。ではまた!

HimotokiからSwift.Decodableに徐々に移行させる

こんにちは!コネヒトでiOSエンジニアをやっていますyanamuraです。

コネヒトではAPIで取得したJSONをDecodeするのにHimotokiを使ってきましたが、Swift4でSwift.Decodableが追加されてからは新しいコードはSwift.Decodableを使っています。共存状態はのぞましくなく全てSwift.Decodableにしたいところですが、かなりの量があるので一気に変更するのが大変で影響範囲も大きいです。そのため、徐々に移行していく方針を取りました。

ステップ1

徐々に移行するためにまずAPI通信部分のユニットテストを用意しました。古いAPI周りはテストが書かれていなかったのでちょっと大変でしたが、これをしたことで変更に凡ミスが入らないという安心感がえられ、また、置き換え作業自体もコンパイルとテストが通っていれば実機で動かして確認する必要がなく効率的に行うことができました。

ステップ2

ここからHimotoki.DecodableをSwift.Decodableに置き換えていきます。

置き換える上で問題となってくるのが、以下の例のようにDecodableなstruct間で依存が発生しているパターンです。

struct Question: Himotoki.Decodable {
    let user: User  // UserもHimotoki.Decodable
    let answers: [Answer]  // AnswerもHimotoki.Decodable
}

struct Video: Himotoki.Decodable {
    let user: User
    let videoId: Int
}

QuestionをSwift.Decodableにしようとすると、その子のUser, AnswerもSwift.Decodableにしなければならず、さらにUserがSwift.Decodableに変えると、それを使っているVideoもSwift.Decodableに変える・・というように芋づる式に変更が必要となり大変なことになります。

そこでこのような場合は、次のようなパターンで対応していきます。

case1: 親がない場合

Swift.Decodableに変更するだけでOK

case2: 親が一つしかない場合

子:Swift.Decodableに変更

親:

before

extension DailyMessageResponse: Himotoki.Decodable {
    static func decode(_ e: Extractor) throws -> DailyMessageResponse {
                return try DailyMessageContent(
            dailyMessage: e <| "daily_message",
            child: e <|  "child"
        )
    }
}

after

// daily_messageだけSwift.Decodableにする
extension DailyMessageResponse: Himotoki.Decodable {
    static func decode(_ e: Extractor) throws -> DailyMessageResponse {
                let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        if let json = e.rawValue as? [String: Any], let dailyMessageJSON = json["daily_message"] {
            let dailyMessage = try decoder.decode(DailyMessage.self, from: try JSONSerialization.data(withJSONObject: dailyMessageJSON, options: []))
            return try DailyMessageContent(
                dailyMessage: dailyMessage,
                child: e <|  "child"
            )
        } else {
            throw Himotoki.DecodeError.missingKeyPath("daily_message")
        }
    }
}

case3: 親が複数ある場合

子:Himotoki.Decodable, Swift.Decodable両方に準拠する

extension User: Himotoki.Decodable {
    static func decode(_ e: Extractor) throws -> User {
        return try User(
            id: e <| "id",
            name: e <| "name"
        )
    }
}

extension User: Swift.Decodable {}

親:

親はすぐに変更しなくても問題ない。

親を変更する場合は、親が1つのパターンを使って一つずつ変更する。

まとめ

これを地道に続けてると移行が完了します。

コネヒトで開発しているママリiOSアプリではようやく移行が完了し、トータルで20~30個くらいのPullRequestになりました。移行完了までかなり時間を要しましたが、コネヒトでは2週間に一日くらいの頻度で、丸一日は普段開発しているプロダクトの目標とは関係のない負債解消などの技術的なことを行うようにしていまして、これを活用してやりきることができました。

大規模な変更となりましたが、問題なく移行完了しました。1つだけリグレッションテストで不具合が見つかりヒヤリとしましたが、その不具合は唯一ユニットテストの書き漏れがあった箇所のコードで発生しておりユニットテストの重要さを改めて感じました。

コネヒトではこういった負債解消だけでなくSwiftUIの導入など技術的なチャレンジも盛り込みながら開発しています。もしご興味ありましたら一度話を聞きに来てください!

hrmos.co