こんにちは。Android アプリエンジニアの geckour です。
今回は、スタディサプリ 中学/高校/大学受験講座 にて先日リリースした問題タイプ "ディクテーション" の Android アプリにおける実装についてお話します。
ディクテーションって何?
"聞き取った英語を文字に書き起こしていく" という英語のトレーニングの一つで、主にリスニングスキルの向上に役立つとされています。
モバイルアプリにおいてこのトレーニングを実現するには
- 音声を再生する
- 聞き取った英語を入力する
という機能が最低限必要となります。
聞き取った英語を入力する
こちらの機能、文字面だけを見ると簡単に実現できそうな気がしますが、実際にはいくつかの難関がありました。
以下でそれらについて説明します。
実際の画面 | 実際の画面 2 |
---|---|
大変だった/工夫したこと
複雑なロジック
ディクテーション機能は、複雑な状態管理の上に実現されています。
- 問題文の状態
- 回答時に伏せておく文字か否か
- 入力対象か否か
- 回答が入力済みか否か
- 現在入力が反映される対象か否か
- 誤答が入力されているか否か
- etc.
- 問題そのものの状態
- 回答を提出済みか否か
- 回答を受け付ける状態か否か
- etc.
これらの状態に応じてできる操作・見せるべきビューが変わり、かつ状態はユーザの入力によってインタラクティブに変化します。
// コード例 // 解答が Hello world. だとすると… // ここの var なところが全てユーザの入力に応じて変化します data class QuestionLetter( val letter: String, var isAnswerTarget: Boolean, var isResult: Boolean = false, var hasUserInput: Boolean = false, var isFocused: Boolean = false, var isFailed: Boolean = false ) val questionSentence: List<QuestionLetter> = listOf( QuestionLetter("H", true), QuestionLetter("e", true), QuestionLetter("l", true), QuestionLetter("l", true), QuestionLetter("o", true), QuestionLetter(" ", false), QuestionLetter("w", true), QuestionLetter("o", true), QuestionLetter("r", true), QuestionLetter("l", true), QuestionLetter("d", true), QuestionLetter(".", false), )
この複雑な仕様から予想される複雑なコードをできる限りシンプルに保つために
- 状態表現
- 状態管理部分
をそれぞれ単一のクラスに閉じ込めて、かつ状態管理クラスへの入出力インターフェイスをそれぞれ 1 つに絞りました (下記のコード例の onInput()
が入力、 changedQuestionSentenceData
が出力)。
// コード例 class Dictation(private val questionSentence: List<QuestionLetter>) { private var inputEnabled = true // この LiveData が全てのユーザ入力結果の出力を担います private val _changedQuestionSentenceData = MutableLiveData<List<Pair<Int, QuestionLetter>>>() val changedQuestionSentenceData: LiveData<List<Pair<Int, QuestionLetter>>> get() = _changedQuestionSentenceData private val focusIndex get() = questionSentence.indexOfFirst { it.isFocused } init { _changedQuestionSentenceData.value = questionSentence.apply { firstOrNull { it.isAnswerTarget }?.isFocused = true } .indexedList() } // このメソッドが全てのユーザ入力の受付を担います fun onInput(value: String) { if (inputEnabled.not()) return questionSentence.getOrNull(focusIndex)?.apply { if (letter == value) { onCorrectInput() } else { onWrongInput(this) } } } private fun onCorrectInput() { if (inputEnabled.not()) return _changedQuestionSentenceData.value = ... // ユーザ入力の結果変更があった全ての QuestionLetter を流す if (questionSentence.filter { it.isAnswerTarget }.all { it.hasUserInput }) { onComplete() } } private fun onWrongInput(letterData: LetterData) { _changedQuestionSentenceData.value = ... // ユーザ入力の結果変更があった全ての QuestionLetter を流す } private fun onComplete() { ... } }
その結果、コードの見通しが改善したことはもちろん、ユニットテストをする際に入力データを変えるだけでテストケースが表現できるため非常に簡単に書けるようになるという嬉しい副産物もありました。
ユニットテストを過不足なく書いておくことで、チームメンバーへの説明がとても楽になるということもわかりました。
複雑なレイアウト
キーボード部分のレイアウトを組む際に、どうすれば画面幅にピッタリ収まるものができるか色々と試行錯誤をしました。
最終的には、ConstraintLayout を利用して、 layout_constraintWidth_percent
や layout_constraintDimensionRatio
を使うことでなんとかいい感じにできました。
キーをタップするとそのキーのビューの大きさが変わるのですが、これはコード上で動的に Constraint を付け直すことで実現しています。
// コード例 ConstraintSet().apply { val targetId = ... clone(binding.dictationContainer) setDimensionRatio( targetId, getString(R.string.dictation_height_key_ratio) ) applyTo(binding.dictationContainer) }
また、どうしても View の数が多くなるのですが、まとめられるものを徹底的に Style にまとめることでレイアウト XML の見通しを確保しています。
上記の通り複雑に変化する状態に対しても、状態に基づいた View の背景を DataBinding によって宛てることで比較的シンプルに対応することができました。
おわりに
僕も開発を通して実際にディクテーション機能を使っているのですが、なかなか楽しいトレーニングだと感じています。
楽しくスキルアップにも繋がる、そんな機能をチーム一丸となって無事リリースできたことが嬉しいです。
ユニットテストのやりやすさということを考える時に、やはりコンパクトにまとめて依存を排除する、ということがとても重要なのだなと再確認もできました。
こちらの記事を読まれて Quipper に興味をお持ちいただいた皆様、ぜひ Quipper に遊びに来ませんか? カジュアル面談を含め、オンライン開催中です! 以下 Wantedly ページよりお気軽にご連絡ください! https://www.wantedly.com/companies/quipper/projects