くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

システム刷新という話

「システム刷新」という言葉を、
皆さんはどんな場面で聞いたことがあるでしょうか?

はじめまして。
私は、みんなのマーケット株式会社(以下、minma)で、エンジニアリングマネージャーをしています。
ちょうど一年前、この場所にやってきました。

minmaでは、約15年にわたって「くらしのマーケット」というサービスを運営しています。
約400を超えるサービスカテゴリ。
それぞれの暮らしのすぐそばで、今日も静かに使われています。

長く続くサービスには、必ず時間が積もります。
ユーザーが増え、店舗が増え、
その一つひとつに応えるように、システムも姿を変えてきました。

開発のやり方が変わり、
運用の考え方が変わり、
使う技術も、時代とともに移り変わっていく。

その変化に、minmaのエンジニアたちは、
壊さないように、止めないように、
静かに、誠実に向き合い続けてきました。

一方で、社会もまた、15年の間に大きく変わりました。
気候、価値観、働き方。
それに合わせて、会社も、人も、少しずつ形を変えてきたのだと思います。

けれど、変化が長く続くと、
ほんの小さな遅れや、わずかな判断のズレが、
気づかないうちに積み重なっていきます。

システムは、「今」に追いつけないまま、
その場ごとの判断で、なんとか応え続けてきました。

良かれと思って重ねた対応は、
いつしかシステムの中心に、大きく重たい"核"をつくります。

小さな変更でも、中心が揺れる。
大きな変更には、長い時間が必要になる。
気づけば、動く速さは、少しずつ落ちていました。

もちろん、立ち止まっていたわけではありません。
これまでに、何度もリニューアルを行ってきました。
小さなものも、大きなものも。

そのたびに、手応えはありました。
「良くなった」
「これで大丈夫だ」

それでも、時間が経つと、
また同じような問題が、似た場所から顔を出す。

この感覚に、私は既視感を覚えました。

それは、ダイエットに似ています。

一時的に運動量を増やせば、体は変わります。
数値も、見た目も、確かに結果は出る。

けれど、生活そのものが変わらなければ、
時間とともに、元の状態へと戻っていきます。

そしてまた、
同じ決意をして、
同じことを繰り返す。

終わりのない宿題のようです。

システムも、同じなのだと思います。
バージョンアップや技術刷新は、確かに必要です。
けれど、それだけでは体質は変わらない。

ダイエットが続くのは、
運動や食事だけでなく、
暮らしそのものが変わったときです。

無理をしなくても続くこと。 止めなくていいこと。

システム刷新も、
一度きりのイベントではなく、
続いていく前提であるべきだと、私は考えています。

健全な文化があり、
その中で自然に選ばれる設計があり、
少しずつ、しかし確実に、前へ進んでいく。

このブログでは、
minmaのシステム刷新について、
成功だけでなく、迷いや違和感も含めて、
エッセイのように綴っていこうと思います。

エンジニアだけでなく、
デザイナー、プランナー、ディレクター
ものづくりに関わるすべての人に、
どこか引っかかる一文が残れば、それで十分です。

もし、
よければ、一緒に歩いてください。
システム刷新という、長く静かな旅を。

次は、minmaのシステムが抱えている課題を、
もう少し物語として紹介してみようと思います。

Redash の出力結果を Chrome 拡張機能で Markdown にコピーできるようにした話

こんにちは、エンジニアの yuma です。

突然ですが、皆さんは Redash を使っていますか?弊社では本番データを参照する際に Redash を使っており、エンジニアはもちろん、エンジニアでない方もデータ分析に活用しています。

redash.io

Redash では豊富なビジュアライゼーションが提供されていますが、データ分析には有用な一方でエンジニア間でデータを共有するには少々不便でした。

そこで、Redash の出力結果を Markdown のテーブルでコピーできるようにする Chrome 拡張機能を作成しました。拡張機能自体を公開することはできませんが、 Chrome 拡張機能の開発や実装時のポイントを共有します。

なお、スクリーンショットを含む本記事で掲載しているデータは、全てデモ用のダミーデータです。

モチベーション

弊社では、原則エンジニアを含めて本番データの参照には Redash を利用しています。本番データの変更は特定のエンジニアのみが実行権限を持ち、どのような変更であっても GitLab 上で変更内容を起票、適切な承認フローを通した上で変更を依頼します。

その際、正しい変更が行われたかどうかを確認するために、変更前のデータと変更後の想定データを記入し、レビューを受ける必要があります。エンジニアは Redash でクエリを実行して変更前のデータを取得しますが、 Redash の出力結果はコピペがしづらく、GitLab へのデータの貼り付けが手間になっていました。

ワークアラウンド的な方法もあるようですが、頻度の高い作業であるため、せっかくならワンボタンでコピペできるようにしたい!と思い、Chrome 拡張機能を作成することにしました。

あまりコピペしやすい形式とはいえない...

構成

簡易的な図ですが、以下のような構成で開発しました。

表示しているページに任意のスクリプトを挿入できる Content scripts でコピーボタンの挿入やボタン押下時のテーブル取得を行い、バックグラウンドで実行される Service worker にメッセージを送信します。

Content scripts では、 Redash のテーブル表示を CSV ライクな文字列配列に変換し、 Service worker へメッセージを送ります。 Markdown 形式への変換は Service worker で実行し、 Content scripts は返却された Markdown 文字列をクリップボードにコピーする、という流れです。

より詳しくみていきましょう。

Content scripts

Content scripts は、表示しているページに任意のスクリプトを挿入できる機能です。挿入されたページの DOM にアクセスできるため、ページ上の要素を取得・操作が可能です。

developer.chrome.com

今回は、 Redash のクエリ実行画面(/queries)にコピー用のボタンを挿入します。コピー用のボタンは React コンポーネントとして実装し、ReactDOM で root.render() することで Redash の画面上に描画しています。以下の「Copy as Markdown」ボタンが、 Chrome 拡張機能で描画されたコンポーネントです。

ただし、 Redash は SPA で構築されており、ページ遷移時にリロードが発生しないため Content scripts が再実行されません。また遷移後のクエリ実行画面でも、ボタン部分を含め頻繁に表示が変化するため、単純にボタンを挿入するコードだけでは DOM の再構築によってボタンが消えてしまいます。

このような DOM の変更を監視・コンポーネントを再描画するために、 MutationObserver を利用しました。

MutationObserver

MutationObserver は、 DOM の変更を監視することができる WebAPI です。特定の DOM の変更をその子孫も含めて監視することができ、その変更をコールバック関数に通知します。

developer.mozilla.org

変更の内容は MutationRecord というクラスインスタンスで通知され、この辺りに少々癖がある印象でしたが、フレームワークなしでも DOM の変更を監視して扱えるのは非常に便利でした。

今回は、クエリ実行画面の下部バー DOM が追加されたタイミングで、コピー用ボタンのコンポーネントを描画しました。クエリ実行画面の遷移や、クエリを実行した際の再描画でもボタンが表示されるようになりました。

  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      // MutationRecord.addedNodes で、追加されたノードを取得できる
      mutation.addedNodes.forEach((node) => {
        if (node.hoge() === "fuga")) {
          // ... 中略 ...
          // div を挿入して React コンポーネントを描画
          const rootDom = document.createElement("div");
          bottomController.insertBefore(
            rootDom,
            bottomController.lastElementChild
          );
          const root = createRoot(rootDom);
          root.render(createElement(CopyAsMarkdown));
        }
      });
    }
  });

Service workers

Service workers は、ページ内で動作するスクリプトとは別にバックグラウンドで実行されるスクリプトです。

各ページから発火されるイベントのハンドラを定義することで様々なイベントを監視できるほか、ページに挿入した Content scripts からのメッセージを受けることもできます。今回はテーブルのデータをペイロードとして受け取り、 Markdown 形式に変換するメッセージハンドラを実装しました。

chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
  if (message.type === "RRC_MARKDOWN_COPY") {
    const csvAsArray = (message as Messages["CopyMarkdown"]).data;
    sendResponse(markdownTable(csvAsArray));
  }
  return;
});

メッセージの送信も以下のようにシンプルな API で実現できます。

const execCopyMessage = (data: Csv, callback: (response: string) => void) => {
  const message = {
    type: "RRC_MARKDOWN_COPY",
    data,
  };
  return chrome.runtime.sendMessage(message, callback);
};

manifest.json

manifest.json は Chrome 拡張機能本体の設定ファイルです。配布時に重要になるような、拡張機能の名称や説明・アイコンなどはあまり気にせず、実装に必要な部分だけ設定しました。

ポイントとなる部分だけを抜粋して解説します。

content_scripts / background

実行したい Content scripts , Service workers のスクリプトを指定します。

Content scripts には、実行したいページの URL を正規表現や独自のパターン表現で設定できます。

  "content_scripts": [
    {
      "js": [
        "content.js"
      ],
      "matches": [
        "https://<Redash サービスのホスト>/queries/*"
      ]
    }
  ],
  "background": {
    "service_worker": "worker.js",
  },

permissions

Chrome 拡張機能向け API や一部のスクリプトのためには、ユーザーからの権限許可が必要です。 permissions に必要な権限を記述することで、 Chrome 拡張機能からユーザーに権限の許可を求めることができます。

今回はクリップボードへの書き込み権限が必要なため、 clipboardWrite を必須の権限に追加しました。

  "permissions": [
    "clipboardWrite"
  ]

補足: CRXJS Vite Plugin について

弊社では TypeScript を開発言語に採用しています。Chrome 拡張機能の開発でも TypeScript を使うことにしました。

「Chrome 拡張 TypeScript」などで検索すると、 CRXJS という Vite プラグインがヒットすると思いますが、今回はこちらの採用は見送りました。*1

crxjs.dev

CRXJS が対応しているのは Vite 2、ベータバージョンでも Vite 3 までのため、最新のバージョンとは大きく離れてしまっています。今回の開発内容で得られる恩恵は manifest.json の型付け程度で重要度も低いことから、CRXJS を使わなくても特段問題にはなりませんでした。

まとめ

今回は、 Chrome 拡張機能を開発する中でのポイントをいくつかご紹介しました。

ストアでの公開・配布を考慮すると、安定度やメンテナンス性などをさらに考える必要がありそうですが、個人用や社内ツールとして簡単に使う上ではそこまでハードルは高くないように思います。皆さんも、身近なツールでかゆいところに手が届かないようなときには、ぜひ自分用の Chrome 拡張機能を作ってみてください!

最後に

みんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!

詳しくは、こちらをご覧ください。

*1:正確にはプロトタイプの段階では CRXJS を採用していましたが、 React コンポーネント化に際して Vite のバージョンが問題となってしまったため、CRXJS を採用しない方針に変更しました。

エンジニアが出店者が集まるイベントに参加してみた

バックエンドエンジニアのあべです。

エンジニアが、サミットと呼ばれる出店者イベントに参加した感想をまとめてみました。

動画も含めて紹介していますので、ぜひご覧いただければと思います。

サミットとは

くらしのマーケットに出店している方々(以下、出店者)が、参加する出店者コミュニティのイベントです。

東京・大阪・名古屋・福岡など全国各地で開催されており、毎回多くの出店者が参加しています。

基本的には、出店者が企画・運営をしており、くらしのマーケットで活躍する出店者の発表やグループワークを通じて、サービスページの作り方や集客方法などお互いに情報共有して教え合う場となっています。

サミットの動画↓

www.youtube.com

サミットに参加した理由

「くらしのマーケットアワード」に参加したことがきっかけです。

「くらしのマーケットアワード」とは、年に一度開催されるくらしのマーケットでお客様からの支持が特に高かった店舗を表彰する式典です。

私は、2021年の「くらしのマーケットアワード」に初めて参加して、アワードの盛り上がりに衝撃を受けました。

くらしのマーケットで活躍する出店者の方々が受賞・表彰されて、涙を流しながら喜ぶ姿、またそれを称え合う姿に、素晴らしいプロダクトを開発できていることに感動しました。

翌年に、サミットが開催されるということで、実際に出店者の方々とお話をしてみたいと思ったのが参加の理由です。

2023年のくらしのマーケットアワードの動画↓

www.youtube.com

くらしのマーケットアワード2023の特集記事↓

https://curama.jp/featured/curama-awards/list/

エンジニアが実際に参加してみて

開発した内容についての率直なフィードバックを受けることができる

「この機能が便利になった」「この機能に助けられています」など意見をもらえました。

自分が開発したサービスが、利用者に価値を与えていることを実感できることはとてもやりがいになります。フィードバックの中には、自分が直接開発に関わっていない機能もあるので、それは社内に持ち帰り、担当エンジニアに伝えたりしています。

また、くらしのマーケット一本で仕事をしている出店者からの期待の声を聞くと、責任感も湧きました。

お話をさせていただいている中で、「こういうことに困っている」「こういう悩みがある」など、出店者が抱える根本的な問題を考えるきっかけにもなっています。

話を聞いて視座を高めることによって、開発するときに利用者の立場を想像することにも役立つと考えており、貴重なコミュニケーションです。

出店者のサービスページの作り込みや研究がスゴイ

出店者が、集客のためにサービスページの研究に日々取り組んでいることに驚きました。

活躍している出店者の方々は、写真や動画の工夫をはじめとして、文章の細部までこだわって作成しています。

普段、開発しているエンジニアでも気づかないようなポイントにフォーカスしていて、こちらも勉強することがたくさんあります。

また、出店者の方は、普段自分たちが利用している店舗管理画面の変化だけではなく、ユーザー(お客さん)が利用する画面での細かな変化に気づいて対策する出店者の方もいて、研究がスゴイと感じています。

出店者の方々のポジティブな変化を知ることができる

去年初めてサミットに参加して、まだくらしのマーケットに登録したばかりだった出店者の方が、1年後にはサミット運営をして口コミ数や売上が何倍にもなっていて、成功を感じることができました。

くらしのマーケットを利用して、努力を続けて成功している出店者の方を見ることができるのもサミットならではだと思います。

サミットで自己紹介をするあべ(緊張気味)

出店者のグループワークに参加しちゃうあべ

最後に

エンジニアは、どうしても開発や技術にフォーカスして、プロダクトから興味が離れてしまいがちです。(すみません、偏見かもしれないです。)

ただ、エンジニアにとって、開発したプロダクトを利用しているユーザーと直接コミュニケーションをとるのは、プロダクトが利用者に与える価値を身近に感じることができます。

手を挙げれば、エンジニアでもくらしのマーケットアワードやサミットに参加できるチャンスがあるのは、良い環境だと思います。 たまには、そういうことに参加してみちゃうエンジニアがいても、良いのではないでしょうか。

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。

ts-pattern という OSS のコードリーディングしてみた

はじめに

こんにちは!バックエンドエンジニアのハラノです。 最近開発をしていて、try catchを使ったエラーハンドリングに少し不満を持つようになりました。
型だけを見ても発生しうる Error がわからないため、エラーハンドリングを行うときに、実際の処理を追う必要がある上、エラーハンドリングが網羅されていなくてもコンパイルが失敗しないからです。

そのため、より適切にエラーハンドリングをする方法がないか探していたところ、Result 型を提供するneverthrowとパターンマッチングを行うts-patternというライブラリがあることを知りました。

組み合わせることで、Result 型で発生するエラーがわかる上に、ts-pattern のパターンマッチングを使うことによって、発生するエラーが網羅されていない場合コンパイルエラーにすることができます。

もともと OSS のコードを読んでみてして新しい学びを得たかったのと、サイズも大きすぎずコードリーディングするのに適していると感じたので、今回はts-patternの読んで、内部構造を調べてみました!

どのようにコードリーディングしたのか、今回得られた知見について、今回共有いたします。

どのようにコードリーディングしたのか

cursorというエディタが前々から気になっており、コードを clone し、コードの処理を追いかけつつ、cursor の chat で質問し、わかったことについて Notion でメモしていました。
AI の回答は正しいとは限らないため、生成された回答について正しそうか?というのを確認しつつ進めました!
精度が高く、また同じようなことをするなら絶対使おうと思えるレベルでした!

以下、一部の回答例です。

cursorの回答_1

cursorの回答_2

今回のサンプルコードについて

以下のコードが内部でどのようにハンドリングされ、hello world が出力されるのかを調べていきます。

このコードはどのような値でも マッチ するP.any を使っており、結果 マッチ してhello world が出力されます。

import { match, P } from "ts-pattern";

type Input = {
  text: string;
};

const input: Input = {
  text: "hello world",
};

const result = match(input)
  .with(P.any, (i) => i.text)
  .exhaustive();

console.log(result);
// hello world

それぞれの関数を簡単に説明すると以下のようになります。

  • match(input) でパターンマッチングのビルダーを作成
  • .with() でパターンを追加、パターンマッチした場合に実行する関数を追加
  • .exhaustive() でパターンの結果を返す

それぞれの処理について具体的に見ていきます!

match()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L31

export function match<const input, output = symbols.unset>(
  value: input
): Match<input, output> {
  return new MatchExpression(value, unmatched) as any;
}

こちらを見てみると match は単純に MatchExpression を new して返しているだけのようです。

MatchExpression の実装は以下になっており、with() の実装についても MatchExpression にあります。

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L46

以下の記述があり、MatchExpression は入力とマッチングの状態を持ち、入力に対してパターンマッチする処理の責務を持つクラスになっています。

/**
 * This class represents a match expression. It follows the
 * builder pattern, we chain methods to add features to the expression
 * until we call `.exhaustive`, `.otherwise` or the unsafe `.run`
 * method to execute it.
 *
 * The types of this class aren't public, the public type definition
 * can be found in src/types/Match.ts.
 */
class MatchExpression<input, output> {
  constructor(private input: input, private state: MatchState<output>) {}

  // 以下省略
}

with()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L49

  with(...args: any[]): MatchExpression<input, output> {
    if (this.state.matched) return this;

    const handler: (selection: unknown, value: input) => output =
      args[args.length - 1];

    const patterns: Pattern<input>[] = [args[0]];
    let predicate: ((value: input) => unknown) | undefined = undefined;

    if (args.length === 3 && typeof args[1] === 'function') {
      // case with guard as second argument
      predicate = args[1];
    } else if (args.length > 2) {
      // case with several patterns
      patterns.push(...args.slice(1, args.length - 1));
    }

    let hasSelections = false;
    let selected: Record<string, unknown> = {};
    const select = (key: string, value: unknown) => {
      hasSelections = true;
      selected[key] = value;
    };

    const matched =
      patterns.some((pattern) => matchPattern(pattern, this.input, select)) &&
      (predicate ? Boolean(predicate(this.input)) : true);

    const selections = hasSelections
      ? symbols.anonymousSelectKey in selected
        ? selected[symbols.anonymousSelectKey]
        : selected
      : this.input;

    const state = matched
      ? {
          matched: true as const,
          value: handler(selections, this.input),
        }
      : unmatched;

    return new MatchExpression(this.input, state);
  }

今回は第 1 引数にP.any ,第 2 引数に マッチ したときに実行される関数を渡した時の流れを説明します。 コードとして表すと以下になります。

match(input).with(P.any, (i) => i.text);
  1. handler に パターンがマッチしたときに実行する関数を格納する
  2. patterns に P.any が代入される
  3. それぞれの pattern について matchPattern()関数が実行され、パターンがマッチ しているかの boolean が格納される
    1. matchPattern 内では Matcher(P.any)の場合 P.any[Symbol.for("@ts-pattern/matcher")]().match(value) で パターンがマッチ しているかを比較しています
  4. マッチしている場は、{ matched: true, value: handler() } として新しい MatchExpression を作成して return する

P.any

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L660

export const any: AnyPattern = chainable(when(isUnknown));

以下の関数を使用しているようです。

  • isUnknown()
  • when()
  • chainable()

isUnknown()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L612

function isUnknown(x: unknown): x is unknown {
  return true;
}

unknown かどうかを判定する関数で、常に true を返す関数のようです。

when()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L526

export function when<input, predicate extends (value: input) => unknown>(
  predicate: predicate
): GuardP<
  input,
  predicate extends (value: any) => value is infer narrowed ? narrowed : never
>;
export function when<input, narrowed extends input, excluded>(
  predicate: (input: input) => input is narrowed
): GuardExcludeP<input, narrowed, excluded>;
export function when<input, predicate extends (value: input) => unknown>(
  predicate: predicate
): GuardP<
  input,
  predicate extends (value: any) => value is infer narrowed ? narrowed : never
> {
  return {
    [matcher]: () => ({
      match: <UnknownInput>(value: UnknownInput | input) => ({
        matched: Boolean(predicate(value as input)),
      }),
    }),
  };
}

Symbol.for("@ts-pattern/matcher") の key に与えられた関数に input を渡してマッチしているかの関数を提供する object を返す関数のようです。

文章だけだと分かりづらいので、それぞれを console に出力して確認します。

このようにP.anyなどの Pattern は、マッチ するかの関数を提供しています。

const matcherSymbol = Symbol.for("@ts-pattern/matcher");
const matcher = (P.any as any)[matcherSymbol]();

console.log(P.any);
/*
{
  [Symbol(@ts-pattern/matcher)]: [Function],
  optional: [Function: optional],
  and: [Function: and],
  or: [Function: or],
  select: [Function: select],
}
*/

console.log(matcher);
/*
{
  match: [Function: match],
}
*/

console.log(matcher.match());
/*
{
  matched: true,
}
*/

chainable()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L118

function chainable<pattern extends Matcher<any, any, any, any, any>>(
  pattern: pattern
): Chainable<pattern> {
  return Object.assign(pattern, {
    optional: () => optional(pattern),
    and: (p2: any) => intersection(pattern, p2),
    or: (p2: any) => union(pattern, p2),
    select: (key: any) =>
      key === undefined ? select(pattern) : select(key, pattern),
  }) as Chainable<pattern>;
}

P.any.select()などができるように、select などを元の object に追加しているようです。

exhaustive()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L114

  exhaustive(): output {
    if (this.state.matched) return this.state.value;

    let displayedValue;
    try {
      displayedValue = JSON.stringify(this.input);
    } catch (e) {
      displayedValue = this.input;
    }

    throw new Error(
      `Pattern matching error: no pattern matches value ${displayedValue}`
    );
  }

マッチ している場合は、value を返し、マッチ するパターンがなければ Error を実装するようになっています。

実際は型でのガードによってケースが網羅されていない場合は型エラーになるため、実行時に Error になることはそこまで多くなさそうです。

まとめ

改めて最初のコードを再掲します。

import { match, P } from "ts-pattern";

type Input = {
  text: string;
};

const input: Input = {
  text: "hello world",
};

const result = match(input) // MatchExpressionのインスタンスを生成する
  .with(P.any, (i) => i.text) // 何にでもMatchするP.anyを渡し、matchした場合は(i) => i.textを実行、内部のステートを更新する
  .exhaustive(); // matchしていない場合、Errorをthrowする

console.log(result);
// マッチしているため、 hello world が出力される

内部で何が行われているのかイメージがついたのではないでしょうか?

今回調べてみて、{ text: P.string }P.string.select()をパターンとして渡した場合などについても、学びを深めていきたいと感じました!

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。

Twilio AudioSwitchを使ってAndroid Bluetooth通話を爆速で開発する方法

こんにちは、モバイルアプリエンジニアのリュウです。

最近、ワイヤレスイヤホンなどのBluetoothデバイスを使って通話する対応を行いました。 そこで、Twilio AudioSwitchというライブラリを知って利用しました。

本記事では、AudioSwitchの使い方とその開発で得た知見をご紹介します。

開発の背景

我々「くらしのマーケット」が提供しているユーザー向けアプリと店舗向けアプリには、 インターネットを介したIPネットワークを通じて音声通話をする機能(VoIP)があります。

ユーザーがアプリ内での通話をより便利に利用できるために、以下のようなさらなる進化を遂げたいと考えました。 - Bluetoothデバイスとのシームレスな連携 - Bluetoothデバイスに接続したら、Bluetoothで通話を続ける - Bluetoothデバイスに接続中に、端末のスピーカーなど他のデバイスに切り替えられる

AudioSwitchとは

AudioSwitchは、オーディオデバイスの管理を簡素化するAndroid向けのツールです。

AudioSwitchの特徴

  • オーディオデバイスの管理

    スピーカーフォン、イヤホン、またはヘッドセット間でオーディオ入出力を簡単に切り替えあれる。

  • オーディオデバイスの可用性の変更の検出

    オーディオデバイスの入出力の可用性が変更された際に、この変更を検出し、適切に対応できる。

  • エラーやタイムアウトの処理

    オーディオデバイスの選択中にエラーが発生した場合や、タイムアウトが発生した場合にも、適切に処理できる。

  • Bluetoothデバイスの選択

    Bluetoothデバイスを安全的にハンドリングできる。

AudioSwitchを採用した理由

  • 開発コストの削減

    Android標準のフレームワーク(例えばAudioManager)を使用すると、数百行もの複雑なコードが必要になるが、AudioSwitchはこれらの機能をカプセル化したため、数十行で完結できる。

  • ライブラリの一貫性

    「くらしのマーケットのアプリ」ではTwilio Voice SDKを使用して音声通話機能を開発したため、同じくTwilioが提供しているライブラリを導入することでライブラリの一貫性を保つことができる。

実際に取り組んだこと

さて、サンプルコードとともに解説していきます。

導入

まずは、GradleにAudioSwitchの設定を追加します。

dependencies {
  implementation 'com.twilio:audioswitch:1.1.8'
}

インスタンス化

AudioSwitchを使い始める前に、アプリケーション コンテキスト参照を使用してインスタンス化します。

    // 必要に応じてログの有効化・無効化を行う
    val audioSwitch = AudioSwitch(context, loggingEnabled = true)

アプリのライフサイクル全体で必要なのは、1つのAudioSwitchインスタンスだけです。
依存性の注入(DI)フレームワークを使用してコンポーネントに依存関係を注入するのも良いでしょう。

デバイスの変化をリッスンする

オーディオデバイスの可用性の変更をリアルタイムで検出できるよう、リッスンを開始します。

また、不要なタイミングに停止させます。

fun start() {
    // リッスンの開始
    audioSwitch.start { devices, selectedDevice ->
        // 有効なデバイスや選択したデバイスが変わるたびに実行される
        handleAudioDeviceChanges(devices)
    }
}

fun stop() {
    // リッスンの停止
    audioSwitch.stop()
}

デバイスの変化により選択したデバイスを自動的に切り替える

Bluetooth機器の接続状態・使用状態をもとに、接続や切断などの処理を行います。

// 有効なデバイスのキャッシュ
val cachedAvailableDevices = mutableListOf<AudioDevice>()

// 指定したデバイスを使用する
fun useAudioDevice(device: AudioDevice?) {
    if (audioSwitch.selectedAudioDevice == device) {
        return
    }
    audioSwitch.selectDevice(device)
    // デバイスを選択した後は、そのデバイスのアクティブ化が必須
    audioSwitch.activate()
}

fun findBluetooth(devices: List<AudioDevice>) =
    devices.find { it is AudioDevice.BluetoothHeadset }

fun handleAudioDeviceChanges(devices: List<AudioDevice>) {
    val oldBluetooth = findBluetooth(cachedAvailableDevices)
    val newBluetooth = findBluetooth(devices)

    // 変化したデバイスはBluetoothかどうかを確認
    if (oldBluetooth != newBluetooth) {
        when (newBluetooth) {
            // 接続 -> 切断
            null -> when {
                // 選択中のデバイスをリセットする(端末に戻る)
                audioSwitch.selectedAudioDevice is AudioDevice.BluetoothHeadset -> useAudioDevice(null)
            }

            // 切断 -> 接続
            else -> useAudioDevice(newBluetooth)
        }
    }

    // Bluetoothは変化なし
    cachedAvailableDevices.clear()
    cachedAvailableDevices.addAll(devices)
}

次回の課題

現在、Bluetooth機器のボタンで電話に応答・切電する機能に対応していません。
このため、TwilioとAndroid ConnectionServiceを統合して、この機能を実現したいと考えています。

ConnectionServiceは、Androidの通話管理システムを拡張し、通話の発信、着信、通話中の動作制御などを簡単に実装できるフレームワークです。
Twilioの通話機能と組み合わせることで、より安定した通話体験や追加機能の実装が可能となります。

まとめ

この記事では、Bluetooth通話機能の開発においてTwilio AudioSwitchがどのように活用されるかを解説しました。
今後のプロジェクトでBluetooth通話機能を実装する際は、ぜひ、検討してみてくださいね。


最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!
詳しくは、こちらをご覧ください。