Androidアプリのリソースを整理して開発効率を改善した話

技術部モバイル基盤グループの児山です。

モバイル基盤グループではモバイルアプリの開発だけでなく、開発環境の整備や開発効率の向上も重要な目的の一つとしています。 今回はその取組の中で、特にAndroidアプリの開発効率向上に関する取り組みを紹介したいと思います。

開発効率を下げる要因

経験上、どのようなアプリでも開発を続けていくうちに細かい技術的な負債がたまり、開発効率は下がっていくものです。 クックパッドアプリでは朝Lintの実施やDokumiによるレビューによってソースコードに技術的負債を溜め込まないよう心がけてきましたが、画像やレイアウトなどのリソースファイルは無法地帯になりつつありました。 以下にクックパッドアプリがかかえていた、リソース関連の問題をいくつか挙げてみます。

themeが整備されていない

Androidアプリでは、themeを定義し適用することで画面全体のUIを一括で設定することができます。 しかし、これまでのクックパッドアプリでは規則なく命名されたtheme定義が複数のファイルに散らばっており、どのthemeがどのようなUIを提供しているのか、代替リソースによって何が違うのか確認しづらい状態になっていました。 このため、開発効率の低下だけでなく、新しい画面を追加する際に既存のthemeとほぼ同じthemeを定義してしまうなど新たな負債を生む原因にもなっていました。

styleによるデザインの再利用ができていない

AndroidアプリではいくつかのUI属性をまとめてstyleとして定義し、そのstyleを複数のViewに適用することで、それぞれのViewの見た目を簡単に統一することができます。 しかし、styleの命名規則が定まっていなかったり文字サイズや文字色に決まったルールがないといった問題でstyleの再利用がうまくいかず、結局同じエンジニアが作成した2,3個のlayout間でしか再利用されていないということがよくありました。

文字の色、サイズ、書体などが整理されていない

アプリのデザインにおいて、全体の統一感を高めるためにも文字の色やサイズ、書体は表示箇所によって正しく使い分ける必要があります。 Androidアプリでは先述のstyleを利用することで使い分けが簡単にできるようになっていますが、クックパッドアプリではアプリ全体に関する標準的な文字サイズという定義がなかったため、各画面バラバラに定義されている状態でした。 その結果、文字サイズの直接指定箇所はレイアウトファイル内だけで400箇所以上、文字サイズのdimen定義は50個以上存在し、ほとんど再利用されないまま放置されていました。

エンジニアとデザイナの間に共通言語がない

styleの命名規則が統一されていなかったため、デザイナとエンジニアのやりとりはこの部分のテキストサイズが何sp、Boldで…と属性ごとの指定で行っていました。 標準となる文字サイズも決まっていなかったため1sp刻みで文字サイズを調整することもあり、「なぜこのサイズなのか」という疑問に明確な答えがだせずにもやもやしながら修正したこともあります。 クックパッドアプリでも、このエンジニア・デザイナ間のやりとりが一番開発効率を下げてしまっているようでした。

開発効率を上げるための工夫

ここまで書いてきたように、デザイン面の開発効率が下がって辛い感じになっていたクックパッドアプリですが、一番の問題は命名規則の混乱とそこから来る再利用性の低さにあることがわかりました。 これを解消するため、クックパッドアプリでは以下のような対応を段階的に進めていきました。

未使用のリソースを削除する

まず最初に、すでに使われなくなっているリソースを探して削除しました。 gradle plugin 1.4からはshrinkResourcesというオプションでビルド時に未使用リソースを削除することができるようになりましたが、開発中のファイル検索などでは普通に出てくるため残しておくと開発時に目にするリソース名が増えてしまい開発効率が下がります。

不要リソースの一覧は先ほどのshrinkResourcesの結果でも表示されますが、Android StudioでAnalyze -> Run Inspection by name -> Unused resourcesを実行することでも表示できます。

これらの方法ではdrawablelayoutのようなファイル単位ではなく、stringdimenstyleなどからも探すことができて非常に便利です。 実際にクックパッドアプリでは未使用リソースの整理だけで30個以上の未使用ファイルと1300行程度のリソース定義を削減することができました。

themeの定義

次に、themeの定義内容の整理を行いました。 先述の通りクックパッドアプリには元々いくつかのthemeが定義されていましたが、命名規則がばらばらであったり定義されているファイルがまちまちになっているという問題がありました。 また、タブレットやTVの対応を行ったりminSdkVersionを引き上げた際に代替リソースの整理をしておらず、すでに参照されなくなっている代替リソースが手付かずで残ったりもしていました。 これを修正するために、以下のような手順でリファクタリングを進めました。

  1. themeの定義をtheme.xmlに集約する(代替リソースの定義もtheme.xmlというファイルに集約する)
  2. themeの名前を規則性のある名前(AppTheme.*など)に揃える
  3. 直接指定するthemeと代替リソースを定義するthemeを分ける

1,2は通常のリファクタリングで行う作業と同じなので割愛し、3について説明します。

themeでは新しく追加された属性を使用したり、タブレットの場合は画面をダイアログのように表示したい、という場合にAPIレベルや画面サイズに応じて異なる記述が必要になることがあります。 これは代替リソースという仕組みを通じて切り替えることが可能ですが、代替リソースは差分のみうまく適用してくれるような仕組みなっていないため、同じthemeの代替リソースを定義すると本来必要な差分だけでなく共通部分もそれぞれ書く必要があります。

values/theme.xml

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="android:textColor">@android:color/holo_red_dark</item>
</style>

values-*/theme.xml

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <!-- 本来必要な差分 -->
    ....

    <!-- この行の定義も必要 -->
    <item name="android:textColor">@android:color/holo_red_dark</item>
</style>

これでは同じ内容の記述が分散してしまうので、一旦 代替リソースの実装はBase.AppThemeのような名前で行うようにします。 その上でAppThemeという代替リソースを持たないthemeを新しく定義し、Base.AppThemeparentに指定して継承させます。 最後にすべての代替リソースで共通な部分をAppThemeに集約することで、代替リソースで定義すべき差分のみをvalues-*配下のtheme.xmlに集約することができました。

values/theme.xml

<style name="AppTheme" parent="AppTheme">
    <item name="android:textColor">@android:color/holo_red_dark</item>
</style>


<style name="Base.AppTheme" parent="Theme.AppCompat.Light.DarkActionBar" />

values-*/theme.xml

<style name="Base.AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <!-- 本来必要な差分 -->
    ....
</style>

このようにthemeの定義を効率化することで、冗長な記述の削減しつつ参照すべき場所を限定することができ、開発効率を上げることができます。 (appcompatライブラリで提供されているTheme.AppCompat系のthemeも代替リソースをいくつも継承した構成になっており、効率化されていることがわかります)

styleの整理

styleについては記述箇所の整理と名前の整理を行いました。

styleはとても大雑把に分けるとthemeの属性に適用されるstyleと、layoutに適用するためのstyleに分類できます。 この2つが同じstyles.xmlに含まれていると、滅多に触らないthemeをいじるためにtheme.xmlstyles.xml両方を見る必要が出てきてしまい面倒です。 themeに適用するためのstyleはtheme.xmlに含めてしまうほうが良いでしょう。 稀にthemeとlayout両方で使われるstyleもありますが、その場合はthemeの属性に適用するstyleとしてtheme.xmlに含めたほうが良いと思います。

また、layoutに適用するstyleでは名前による継承をうまく利用することも大事です。 名前による継承とは、style名LargeTextというものを定義されている時、LargeText.Boldというstyleは自動的にLargeTextで定義された属性を引き継ぐことができるという機能になります。 例えば以下のように定義した場合、LargeText.Boldは自動的にテキストサイズ20spとなります。

<style name="LargeText">
    <item name="android:textSize">20sp</item>
</style>

<style name="LargeText.Bold">
    <item name="android:textStyle">bold</item>
</style>

残念ながらこれらの作業を簡単にやる方法はないので、定義済みのstyleに対して継承をうまく使いながら属性を減らす作業で地道に整理していくことになります。

その他のリソース整理

theme/style以外のリソースとして、color、dimenを整理しました。

colorの定義でも名前の付け方には注意する必要があり、画面名や機能名で付けてしまうと後々再利用しても良いかどうかわからなくなり多重定義されてしまう可能性が高くなります。 クックパッドアプリでは特によく使う色に関してはgreenorangeなどのわかりやすい名前で定義し、それがレシピ名のような特別な意味を保つ場合は<color name="recipe">@color/green</color>のように再定義しています。 この方法は一見複雑に見えますが、エンジニアからはレシピ名の色を探しやすく、デザイナーからは定義済みの色の種類をみつけやすくするための工夫になっています。

dimenに関しては、View要素のサイズ指定のどこまでをdimenとして定義するか悩ましく、クックパッドアプリでもまだまだ整理の途中となっています。 しかし、テキストサイズに関しては確実にdimenに集約しておくべきだと言えます。 クックパッドアプリではdimenの整理にあたって既存の文字サイズの定義を一旦捨てて、文字サイズをExtraLarge/Large/Default/Small/ExtraSmallの5段階に分けることにしました。 これまでのdimenを新しい5段階にあわせて分類する作業は大変でしたが、このルールを決めてからテキストサイズの指定時に何sp/何dpという混乱が起きなくなりました。

また、colorとdimenの整理にあわせてTextAppearance(TextAppearanceを継承したstyle)を定義することも非常に役立ちました。 TextAppearanceとは、Androidが提供している文字色や文字サイズ、フォントスタイルをまとめた小さなstyleのことで、文字表示に関する設定を一括で適用することができます。 非常に便利なので、クックパッドアプリでもこれを真似して独自のTextAppearanceを定義し、提供するようにしました。 クックパッドアプリでは文字に使える色は7色あり、そこに文字サイズが5段階と文字の太さが標準/太字の2種類、全部で70種類の組み合わせがあります。 この70種類の組み合わせについて、CookpadFont.Base.ExtraSmall.Whiteのようにそれぞれの属性がわかるような名前で定義し、実際に利用するもののみCookpadFont.Base.*を継承したCookpadFont.*、といった形で定義しています。 わざわざ継承させているのは理由があり、CookpadFont.Base.*階層で名前による継承を利用しやすくなるほか、同じCookpadFont.Base.*を利用する複数のCookpadFont.*をわかりやすくする目的があります。

text_appearance_base.xml

<style name="CookpadFont.Base" parent="TextAppearance.AppCompat" />

<style name="CookpadFont.Base.ExtraSmall">
    <item name="android:textSize">@dimen/extraSmallTextSize</item>
</style>

<style name="CookpadFont.Base.ExtraSmall.White">
    <item name="android:textColor">@color/white</item>
</style>

text_appearance.xml

<style name="CookpadFont.CaptionWhite" parent="CookpadFont.Base.ExtraSmall.White" />

どう変わったか

上記のような様々な改修を加えた結果、クックパッドアプリではリソースのxml行数を2500行以上減らすことができました。 もともとそれだけ管理できずに肥大化していたということですが、改修後は新しいstyleやdimenを定義せずに既存のものを再利用するようになったおかげで新しい定義が増えることもほとんどなくなりました。 リソースの管理が適切にできるようになったと言えそうです。 今回の改修の中でもTextAppearanceの導入によって文字色・文字サイズの組み合わせに名前が与えられ、エンジニアやデザイナの共通言語として扱えるようになったのが特に大きな成果で、開発効率の低下を防ぐだけでなく向上させることができました。 リソースの整理は地道で根気のいる作業ですが、デザイン関連の開発効率の低下が気になっている方はぜひ検討してみては如何でしょうか。

TextAppearanceによるデザイン指示行われている様子 f:id:nein37:20160517114325p:plain

色や文字サイズにわかりやすい名前がついたのでTextAppearance自体の仕様変更も簡単です f:id:nein37:20160517114334p:plain

おわりに

本稿ではAndroidアプリの開発効率改善に関する最近の取り組みについてご紹介しました。 モバイル基盤グループでは引き続き最短距離でユーザーさんに価値を届けるための仕組みを考え、実施していきます。

弊社採用ページ(iOS/Android アプリエンジニア)

日本語形態素解析の裏側を覗く!MeCab はどのように形態素解析しているか

こんにちは、買物情報事業部の荒引 (@a_bicky) です。 前回、「検索結果の疑問を解消するための検索の基礎」で単語単位でインデキシングする前提で説明しましたが、今回は文などを単語単位で分割するために使う技術である形態素解析について触れます。 形態素解析器には色々ありますが、中でもメジャーと思われる MeCab の仕組みについて説明します。

MeCab の解析精度を上げるために辞書に単語を追加したことのある方もいると思いますが、動作原理を理解することで単語を追加する際に適切な生起コストを設定できるようになったり、学習の際に適切なパラメータを設定できるようになったりするはずです。

なお、MeCab は汎用テキスト変換ツールとしても使用できます が、簡単のため MeCab + IPA 辞書のデフォルト設定前提で説明します。

アジェンダ

  • 形態素解析とは
  • MeCab における最適な解析結果の推定
    • ラティスの構築と最適パスの選択
    • 未知語処理
    • 共通接頭辞検索 (common prefix search)
  • MeCab におけるコストの算出
    • CRF によるモデル化
    • 素性関数
    • モデルから生起コストと連接コストへの変換
  • 最後に

「MeCab における最適な解析結果の推定」では、形態素解析する際に MeCab が内部でどのようなことを行っているかについて説明します。「MeCab におけるコストの算出」は辞書を独自で追加したりモデルを再学習させたりする人向けの内容で、ある程度機械学習の知識を持っていることを前提として MeCab がどのようにコストを決定しているかについて説明します。

形態素解析とは

日本語の形態素解析では一般的に次の 2 つのことを行います。

  • 単語分割*1
  • 品詞付与

次の結果は「東京都に住む」を MeCab で形態素解析した結果です。入力文が適切に分割され、適切な品詞が割り当てられています。

% echo 東京都に住む | mecab
東京    名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー
都      名詞,接尾,地域,*,*,*,都,ト,ト
に      助詞,格助詞,一般,*,*,*,に,ニ,ニ
住む    動詞,自立,*,*,五段・マ行,基本形,住む,スム,スム
EOS

MeCab における最適な解析結果の推定

本節では MeCab を使って形態素解析する際に内部でどのようなことが行われているかを説明します。 単純化すると、最適な解析結果を求めるには次の 2 つのことを行います。

  1. ラティスを構築する
  2. ラティスから最適なパスを選択する

ラティスとは、考えられる全ての解を表現したデータ構造で、例えば次のようなデータです。

f:id:a_bicky:20160511155333p:plain

BOS は beginning of sentence で文頭、EOS は end of sentence で文末を意味しています。 各ラティスのノードには単語の生起コスト、エッジには品詞の連接コスト*2が割り当てられています。 最適なパスを選択する際には累積コストを最小にするパスを選択します。

f:id:a_bicky:20160511155409p:plain

累積コストを最小にするパスは動的計画法により効率的に求めることができます(ビタビアルゴリズム)。

ラティスの構築と最適パスの選択

ラティスを構築してから最適なパスを選択すると前述しましたが、実際にはラティスの構築と累積コストの計算は同時に行われ、ラティスの構築が完了した時点で最適パスが求まる形になっています。 具体的には次の手順によってラティスの構築と最適パスの選択を行います。

  1. n = 1 とする
  2. 入力文の n 文字目において分割候補になり得る単語を辞書から全て取得する (共通接頭辞検索)
    • カタカナ等同じ文字の種類の連続をひとまとめにした単語を未知語として候補に追加することもある
  3. 取得した全ての単語に関して、累積コストが最小になるエッジとその累積コストを求める
  4. n を +1 して入力文の末尾に到達するまで 2, 3 を繰り返す
  5. 末尾から先頭に向かって累積コストを最小にするパスをたどる(これが最適パス)

なお、単語の生起コストは sys.dic 等の辞書に、品詞の連接コストは matrix.bin (matrix.def) に保存されています。

上記の処理は次のスライドを見てもらうとイメージが付きやすいと思います。太いエッジは、右側のノード(単語)にとって累積コストが最小になるパスを意味しています。 左文脈 ID、右文脈 ID は特殊な使い方をしない限りは品詞 ID のようなものと理解しておけば大丈夫です。前件文脈 ID は連接する左側の単語の右文脈 ID、後件文脈 ID は右側の単語の左文脈 ID です。

実際に生起コストや連接コストを出してみるとスライドのとおりになっていることがわかります。-F オプションで出力形式を指定することで、デフォルトの内容に加えて生起コスト、連接コスト、累積コストも表示しています。

% # 表層形\t素性\t生起コスト,連接コスト,累積コスト
% echo 東京都に住む | mecab -F '%m\t%H\t%pw,%pC,%pc\n' -E 'EOS\t%pw,%pC,%pc\n'
東京    名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー  3003,-310,2693
都      名詞,接尾,地域,*,*,*,都,ト,ト   9428,-9617,2504
に      助詞,格助詞,一般,*,*,*,に,ニ,ニ 4304,-3573,3235
住む    動詞,自立,*,*,五段・マ行,基本形,住む,スム,スム  7048,-3547,6736
EOS     0,-409,6327

未知語処理

ラティスを構築する際、辞書に登録されている単語しか考慮しない場合、例えば次のように任意の数字を処理するには考えうる全ての値を辞書に登録しておかなければ正しく解析できません。

% echo 1234個 | mecab
1234    名詞,数,*,*,*,*,*
個      名詞,接尾,助数詞,*,*,*,個,コ,コ
EOS

辞書に存在しない単語(未知語)にも対応するため、MeCab では同じ文字の種類 (e.g. KATAKANA) でまとめて 1 つの単語とみなすようにしています。これによって、ラティスを構築する際に未知語のノードを追加し、未知語を含んだ解を選択することも可能になります。 未知語の生起コストに関しては文字の種類に応じて unk.dic (unk.def) に定義されています。

ラティスに未知語を追加するかどうかは char.def で制御されています。以下は char.def の該当部分です。

#  CHARACTER CATEGORY DEFINITION
#
#  CATEGORY_NAME INVOKE GROUP LENGTH
#
#   - CATEGORY_NAME: Name of category. you have to define DEFAULT class.
#   - INVOKE: 1/0:   always invoke unknown word processing, evan when the word can be found in the lexicon
#   - GROUP:  1/0:   make a new word by grouping the same chracter category
#   - LENGTH: n:     1 to n length new words are added
#
DEFAULT        0 1 0  # DEFAULT is a mandatory category!
SPACE          0 1 0  
KANJI          0 0 2
SYMBOL         1 1 0
NUMERIC        1 1 0
ALPHA          1 1 0
HIRAGANA       0 1 2 
KATAKANA       1 1 2
KANJINUMERIC   1 1 0
GREEK          1 1 0
CYRILLIC       1 1 0

2, 3, 4 列目が未知語処理に関する設定で、それぞれ次のような意味を持っています。

列数 名前 意味
2 INVOKE 1 であれば常に未知語を追加する。0 であれば、候補となる単語が見つからなかった場合にのみ未知語を追加する
3 GROUP 1 であれば同じ種類の文字を最大 max-grouping-size 文字まとめて 1 つの未知語として追加する
4 LENGTH 現在の位置から 1 〜 LENGTH 文字の部分文字列全てを未知語として追加する

GROUP と LENGTH の設定は互いに独立です。例えば、「ホゲホゲ」の 1 文字目の位置で単語を追加する場合、KATAKANA は GROUP が 1 なので「ホゲホゲ」という未知語を追加します。また、LENGTH が 2 なので、「ホ」と「ホゲ」という未知語も追加します。2 文字目の位置で未知語を追加する場合も同様に「ゲホゲ」、「ゲ」、「ゲホ」という未知語を追加します。

% echo ホゲホゲ | mecab
ホゲホゲ        名詞,固有名詞,組織,*,*,*,*
EOS

漢字は INVOKE が 0 で、単語が辞書にある場合は未知語を追加しないので、漢字で構成される固有名詞の解析は上手くいかないことが多いです。

% echo 荒引 | mecab
荒      名詞,固有名詞,人名,姓,*,*,荒,アラ,アラ
引      名詞,固有名詞,組織,*,*,*,*
EOS

共通接頭辞検索 (common prefix search)

ラティスを構築する上で、該当位置において候補となり得る全ての単語を辞書から取得する必要があります。

common_prefix_search("東京都に住む")  # => ["東", "東京"]
common_prefix_search("京都に住む")    # => ["京", "京都"]
common_prefix_search("都に住む")      # => ["都(名詞)", "都(接尾辞)"]

このような用途の検索は共通接頭辞検索 (common prefix search) と呼ばれています。共通接頭辞検索を高速に行うためには TRIE というデータ構造を利用するのが一般的です。 MeCab では TRIE の中でもダブル配列という実装を採用しています*3。ダブル配列については非常にわかりやすいエントリーがあるので興味のある方は次のエントリーを参照してみてください。

情報系修士にもわかるダブル配列 - アスペ日記

MeCab におけるコストの算出

形態素解析時には生起コストや連接コストは与えられている状態ですが、本節ではそれらのコストを事前にどのように決定しているかについて説明します。 コストの算出には大きく 2 つの手順があります。

  1. CRF によるモデル化
  2. モデルからコストの算出

MeCab には新しい単語を追加する際に自動で生起コストを推定する機能がありますが、モデルファイルが必要なのは、1 が終わった状態で 2 を行うためです。

余談ですが、あるドメインに頻出する単語を新しく辞書に追加する際、生起コストを自動推定しても実際よりも高くなることがあるはずです。 モデルはどの品詞が出現しやすいか、どの文字種が出現しやすいか、どの原形が出現しやすいか等の情報を保持していますが、新しく追加する単語の原形などの出現しやすさについての情報を持っていないからです。 個人的には、自動推定するよりも、同じぐらいの頻度で出現すると思われる単語の生起コストを新しく追加する単語に割り当てる方が確実だと思います。

それでは、モデル化からコストの算出まで説明していきます。

CRF によるモデル化

MeCab では生起コストと連接コストを決定する上で Conditional Random Fields (CRF) を使ってモデル化しています。CRF は系列ラベリングの一手法で、系列データが与えられると対応する系列ラベルを出力します。 MeCab の場合、入力の系列データは単語分割済みの文字列の配列、出力は品詞情報などを保持したオブジェクトの配列です。

f:id:a_bicky:20160511155013p:plain

CRF のモデルは次の式で表すことができます。φは素性関数で詳細は後述します。Z は確率の総和を 1 にするための正規化項です。

f:id:a_bicky:20160511155019p:plain

MeCab では、人手等で形態素解析した x, y の組み合わせに対し、正則化項も追加して次の目的関数を最小とするパラメータαを求め、αの値を基にコストを決定しています。

f:id:a_bicky:20160511155025p:plain

なお、MeCab ではモデルを再学習させることもできますが、その場合の目的関数は次のようになっています。

f:id:a_bicky:20160511155031p:plain

これは、元のモデルのパラメータから変化量が大きいと損失が大きくなるので、元のモデルをできる限り変化させないように最適化することを意味しています。

これらの目的関数は凸関数であり、MeCab では L-BFGS(準ニュートン法の一種)で解を求めています。

CRF については「言語処理のための機械学習入門」の 5 章「系列ラベリング」に非常にわかりやすくまとまっているので、詳細を知りたい方はそちらを参照してください。

素性関数

素性関数は、引数がその素性関数の条件を満たす場合に 1、そうでない場合に 0 を返す関数です。 MeCab で採用している CRF は次のグラフィカルモデルのように、t 番目の y は 1 つ前の y と現在の x にしか依存しないことを仮定しています。

f:id:a_bicky:20160511155037p:plain

よって、

f:id:a_bicky:20160511155043p:plain

と表すことができます。

素性関数の内容は feature.def を基に決定されます。それ故、feature.def の内容は素性テンプレートと呼ばれます。 feature.def には unigram feature(現在の単語にしか依存しない素性)と bigram feature(現在と 1 つ前の単語に依存する素性)が定義されています。

ここで、例として素性テンプレートが次の場合を考えます。

UNIGRAM W0:%F[6]
BIGRAM B00:%L[0]/%R[0]

%F[6] は処理対象の単語の7番目の素性、%L[0] は処理対象の単語の前の単語(左側の単語)の1番目の素性、%R[0] は処理対象の単語(右側の単語)の1番目の素性を意味しています。

学習データは MeCab の出力と同じ形式のものを用意します。左の列が表層形で右の列が単語の素性です。 素性の内容は左から順に、品詞、品詞細分類1、品詞細分類2、品詞細分類3、活用型、活用形、原形、読み、発音になっています。

東京   名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー
都 名詞,接尾,地域,*,*,*,都,ト,ト
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
住む  動詞,自立,*,*,五段・マ行,基本形,住む,スム,スム
EOS

処理対象が「住む」の場合、素性テンプレートとテンプレートを基に生成される素性は次のとおりです。

素性テンプレート 素性
W0:%F[6] W0:住む
B00:%L[0]/%R[0] B00:助詞/動詞

素性テンプレートから生成された素性から、素性関数は次のように定義できます。

f:id:a_bicky:20160514005304p:plain

モデルから生起コストと連接コストへの変換

前述のとおり、feature.def には unigram feature と bigram feature が定義されています。 生起コストは unigram feature に属する素性関数を使って次のように算出されます。x は y の表層形、cost factor は dicrc に定義されている cost-factor です。

f:id:a_bicky:20160511154956p:plain

連接コストは bigram feature に属する素性関数を使って次のように算出されます。x は yjの表層形です。

f:id:a_bicky:20170423100840p:plain

mecab-ipadic の feature.def の bigram feature は品詞の情報しか使っていないため、MeCab の連接コストは品詞の情報しか考慮されていないことになります。よって、本エントリーでは連接コストのことを品詞の連接コストと呼んでいました。

以上の方法で算出した生起コストと連接コストがそれぞれ sys.dic と matrix.bin に保存されています。

最後に

以上、MeCab がどのように形態素解析しているかについて説明しました。 本エントリーを通じて、自然言語処理を応用した各種サービスの精度向上に少しでも貢献できれば幸いです。


追記:興味のある方はこちらもどうぞ

*1:「形態素」とは意味の最小単位であり、本来は単語よりも小さな単位ですが、ほとんどの場合「単語」と解釈しても差し支えないでしょう

*2:本エントリーでは連接コストを品詞の連接コストと呼んでいますが、feature.def の定義によっては単語の連接コストにすることも可能です

*3:cf. http://chasen.org/~taku/software/darts/

iOSアプリケーションの国際化と地域化

 海外事業向けのiOSアプリケーション開発を担当している西山(@yuseinishiyama)です。クックパッドは現在、海外複数カ国に向けてサービスを展開しています。

 海外事業向けのiOSアプリケーションは、英語、スペイン語、インドネシア語、タイ語、ベトナム語、アラビア語をサポートしています。今後、サポートする言語は更に増えていく予定です。

 これまで、複数の言語に対応するための国際化(internationalization)と地域化(localization)を行ってきました。ここでは、その中で得た知見を以下の4つのパートに分けて共有したいと思います。

  • コンテンツとUIの言語の決定
  • RTL対応
  • 翻訳フロー
  • 翻訳に関するTips

 ちなみに、当該プロジェクトがサポートしているiOSバージョンはiOS8以上です。そのため、iOS9以降でしかサポートされない機能については触れません。

 また、我々のサービスの性質上、精度の高いローカライゼーションを実現する必要があり、標準的な方法に背いた強引な方法を取っている箇所がいくつかあります。必ずしも全てのサービスで同様の対応が必要とは思いません。あくまで1つの事例として参考にしてください。

 以下、「Cookpadアプリ」は海外事業向けのアプリケーションのことを指します。

コンテンツとUIの言語の決定

 本節では、どのような思想に基いて、CookpadアプリがコンテンツとUIの言語を決定しているかについて説明します。

Cookpadアプリおける「言語」の意味

 Cookpadアプリにおいて、「言語」は以下の2つの意味を持ちます。

  • コンテンツの言語
  • UIの言語

 UIの言語は基本的には翻訳に関わる問題です。一方で、コンテンツの言語はそうではありません。それぞれのレシピは、特定の言語圏のユーザーがその言語で投稿したレシピであるため、言語によってコンテンツそのものが異なります。スペイン語圏のユーザーがインドネシア語のコンテンツを見たとしても、ほとんど意味を成しません。理解できないだけでなく、地域特有の材料などが手に入らないため、内容も役に立たないからです。次の画像は、あるインドネシア語のレシピをアプリで表示している様子です。

f:id:yuseinishiyama:20160510150316p:plain

 本節では、Cookpadアプリがどのようにコンテンツの言語とUIの言語を決定しているかについて説明します。

コンテンツの言語を決定する

 現在のCookpadアプリでは1つのアカウントは1つのコンテンツの言語に結びつく仕様となっています*1。これは、前述の理由から、1人のユーザーが複数の言語を跨いでコンテンツを閲覧する可能性は低いと考えているためです。そのため、適切なコンテンツの言語が最初に決定される必要があります。

 本項では、いかにしてコンテンツの言語を決定するかについて説明します。

デバイスの言語やリージョンの設定は信頼できない

 適切なコンテンツの言語を選択する上で、まず参考になりそうなのが言語やリージョンの設定です。言語やリージョンの設定とは、設定のGeneral > Language & Regionからアクセスできる項目のことを指します。

f:id:yuseinishiyama:20160510150138p:plain

 アプリで使用される言語は以下の手順で決定されます。

  1. Preferred Languagesの先頭の言語にアプリが対応しているか調べる。対応していれば、その言語を使用する。
  2. 対応していなければ、Preferred Languages内の次の言語にアプリが対応しているか調べる。対応していれば、その言語を利用する。これを、対応している言語が見つかるまで繰り返す。
  3. Preferred Languages内にアプリがサポートしている言語が見つからなければ、アプリのデフォルトの言語が使用される。

 これらの値は、コード上からも取得可能です、次のコードではPreferred Languagesの先頭の言語を取得しています。ちなみに、NSLocale.preferredLanguages()が返す値は、iOS9から変更されました。iOS8までは、["en"]のような値を返していましたが、iOS9からは、["en-GB"]というように言語とリージョンの組み合わせを返却するようになりました。

let mostPreferredLanguage = NSLocale.preferredLanguages().first?.componentsSeparatedByString("-").first

 ところで、言語やリージョンの設定の値は信用できるものなのでしょうか。全てのユーザーが自分にとって適切な値に設定しているのでしょうか。

 各地のメンバーと調査した結果、言語やリージョンの設定があまりあてにならないということが分かりました。例えば、インドネシアでは多くのユーザーが言語設定を英語のまま使っていることが判明しました*2。端末の流通経路によっては、デフォルトの言語やリージョンの設定が適切とは限らず、また、それらの変更方法を知らないユーザーも多くいるためでしょう。

 そのため、コンテンツの言語の選択にデバイスの言語やリージョンの設定をそのまま適用することはせず、ユーザーがアプリ内でコンテンツの言語を明示的に選択する仕様となりました。

 次のgifアニメはアプリ内で言語を選択している様子です。選択した言語にあわせて、UIの言語やレイアウトも動的に変更されています。例えば、アラビア語選択時は戻るボタンの位置が右側になっています。これらの挙動の実現方法については後述します。

f:id:yuseinishiyama:20160510150319g:plain

コンテンツの言語を予測する

 ユーザーが任意で言語を選択できるとはいえ、誤った言語が選択されないように配慮するべきです。そこで、適切なコンテンツの言語を予測し、それがデフォルトで選択されるようにしました。

 先述の通り、デバイスの言語設定はあまり信頼できるものではありません。一方で、SIMから取得できる国情報はより信頼できるものであるはずです。そこで、コンテンツの言語の予測には、MCC(Mobile Country Code)から得られる国コードを、言語設定や地域設定より優先して使用するようにしました。

 次の例では、SIMから国コードを取得しています。取得した値は、ISO 3166-1で表現された文字列です。

import Foundation
import CoreTelephony

struct NetworkInfoUtils {
    static func isoCountryCode() -> String? {
        let networkInfo = CTTelephonyNetworkInfo()
        let provider = networkInfo.subscriberCellularProvider
        return provider?.isoCountryCode // ISO 3166-1
    }
}

 この値が取得できなかった時に、初めて言語設定や地域設定を参照します。次のCountryCodePredictor型のinferredISOCountryCode()メソッドは、予測された国コードを返却しますが、まずSIMから得た国コードを参照し、次に地域設定から取得できる国コードを参照しています。

import Foundation

struct CountryCodePredictor {
    static func inferredISOCountryCode() -> String? {
        return countryCodeFromNetwork() ?? countryCodeFromLocale() ?? nil
    }

    private static func countryCodeFromNetwork() -> String? {
        return NetworkInfoUtils.isoCountryCode()
    }

    private static func countryCodeFromLocale() -> String? {
        let locale = NSLocale.currentLocale()
        return locale.objectForKey(NSLocaleCountryCode) as? String
    }
}

地域に応じてコンテンツを最適化する

 ちなみに、取得した国コードはヘッダを通じてAPIにも送っています。これは地域によって、検索結果などを最適化するためです。

 例えば、同じスペイン語圏であっても、スペインとメキシコではもちろん料理が異なります。アクセス先の地域の料理が優先的に表示されるようにしています。次の画像のうち、1つ目はスペインからの、2つ目はメキシコからの検索結果の例です。

f:id:yuseinishiyama:20160510150507p:plain f:id:yuseinishiyama:20160510150508p:plain

UIの言語を決定する

 既に述べたとおり、コンテンツの言語とUIの言語は別の概念です。そのため、コンテンツとUIで別の言語を使用することも可能です。しかし、Cookpadアプリではユーザーが選んだコンテンツの言語にUIの言語も合わせるという方針を取ることにしました。

 本項では、そのような方針に至るまでの経緯と、その実現方法について説明します。

UIの言語をコンテンツの言語に一致させる

 UIの言語を決定する上で、Appleの思想に則った最も適切な方針はデバイスの言語設定を尊重することでしょう。先ほどの、言語とリージョンの設定画面にも次のような説明文があります。

Apps and website will use the first language in this list that they support.

f:id:yuseinishiyama:20160510150317p:plain

 しかし、既に述べた通り、デバイスの言語設定は適切とは限りません。もちろん、母国語でない言語を理解できるユーザーや、言語が理解できなくとも操作方法を推察することができるユーザーも多数存在するでしょう。しかし、「世界中の人々に向けて毎日の料理を楽しみにするサービスを提供していく」という我々のミッションの性質上、このようなユーザー以外の人々の課題も解決しなければいけません。UIの言語もユーザーが「確実に」理解できるものであるべきです。

 ここまでで説明したように、コンテンツの言語はユーザーが明示的に選択しているため、ユーザーが理解可能な言語であることはほぼ間違いありません。そこで、UIの言語をコンテンツの言語と一致させることにしました。

UIの言語を動的に切り替える

 ローカライズされた文字列を取得するには、NSLocalizedString(_:comment:)関数を使用します。第1引数に指定したキーに対応する、ローカライズされた文字列を取得することができます。

NSLocalizedString("key", comment: "comment")

しかし、NSLocalizedString(_:comment:)関数はデバイスの言語設定を参照して、どの言語のリソースファイルを使用するかを決定するため、アプリ内で言語スイッチを持つ場合に、これをそのまま使用することはできません。

 あり得る手段の1つとしては、実行時にアプリの言語を強制的に上書きするというものです。

NSUserDefaults.standardUserDefaults().setObject(["es"], forKey: "AppleLanguages")
NSUserDefaults.standardUserDefaults().synchronize()

 しかし、この方法にはアプリの再起動が必要であるという致命的な欠点があります。WWDC14のセッション"Advanced Topics in Internationalization"でも次のように説明されています。

Changing the language preference requires restarting apps

 利用開始後、早々にアプリから再起動を促されるという体験はユーザーに与える印象をかなり悪くすると思われます。そこで、NSLocalizedString(_:comment:)関数をラップした、任意のリソースファイルから文字列を取得する関数を実装しました。ここで、FoundationNSLocalizedString(_:comment:)関数と重複するにも関わらず、アプリ本体の名前空間に同名の関数を定義している理由は後述します。

func NSLocalizedString(key: String,
                       tableName: String? = nil,
                       bundle: NSBundle = NSBundle.mainBundle(),
                       value: String = "",
                       comment: String) -> String {
    var bundleToUse = bundle
    if
        let languageCode = (ユーザーが選択した言語の言語コード)
        let path = bundle.pathForResource(
            languageCode,
            ofType: "lproj"),
        let bundle = NSBundle(path: path) {
            bundleToUse = bundle
    }

    return Foundation.NSLocalizedString(key,
                                        tableName: tableName,
                                        bundle: bundleToUse,
                                        value: value,
                                        comment: comment)
}

 ところで、このようにリソースファイルへのパスをアプリ内で組み立てることをAppleは推奨していません。WWDC15のセッション"What's New in Internationalization"では次のように説明されています。

Don’t access the directories yourself

 同様のことを行う場合は、非推奨の方法であるということに留意しましょう。

RTL対応

 本節では、CookpadアプリのRTL対応について説明します。

RTLとは

 アラビア語やヘブライ語のような言語が採用している、右から左へ書くシステムのことをRTL(Right to Left)といいます。ちなみに、英語のような、左から右へ書くシステムはLTR(Left to Right)です。

 RTLの言語を母語とするユーザーは5億人ほどいると言われています。そのためアプリケーションをRTLに対応させることは、ユーザーの獲得という観点から見て非常に重要です。Cookpadアプリはアラビア語に対応しているので、RTLの対応は必須でした。

AutoLayoutによるRTL対応

 AutoLayoutを利用していれば、RTLに対応することは難しくありません。制約を設定する際のNSLayoutAttribute型の値として、.Left.Rightではなく、.Leading.Trailingを指定するだけで大部分がRTLに対応済みとなります。LTR環境において、.Leadingは左を、.Trailingは右を指しますが、RTL環境では、.Leadingが右を、.Trailingが左を指すようになります。

 GUI上で、.Leading.Trailingを指定する場合は、Respect language directionという項目にチェックをいれます。

f:id:yuseinishiyama:20160510150416p:plain

手動でRTLに対応する

 AutoLayoutはデバイスの言語設定を参照してレイアウトの方向を決定します。つまり、AutoLayoutによるRTL対応は、デバイスの言語がRTLでなければ機能しません。

 先述の通り、Cookpadアプリは言語スイッチをアプリ内に持っているため、デバイスの言語はRTLではないが、ユーザーが選択した言語はRTLであるという可能性があります。結果として、AutoLayoutに頼らない手動のRTLを実装することになりました。

デバイスの言語
RTL LTR
選択された言語 RTL RTL(AutoLayout) RTL(手動)
LTR RTL(AutoLayout) LTR

 デバイスの言語を明示的にRTLにしているユーザーが、LTRの言語を選択するのはレアケースと考えています。そのため、今のところ、RTLを強制的にLTRにすることはしていません。

要素を反転させる

 手動でRTL対応を行うため、UIViewクラスのtransformプロパティを使用して、ビューを反転させることにしました。以下のようにすると、ビューは左右反転します。UIViewControllerクラスのviewプロパティの値に対して同様の操作を行えば、画面上の要素の左右の順序が入れ替わります。

transform = CGAffineTransformMakeScale(-1, 1)

 次の画像のうち、1つ目はオリジナルのもの、2つ目はルートビューを反転させたものです。レイアウトの方向がRTLになっていることが分かります。

f:id:yuseinishiyama:20160510150311p:plain

f:id:yuseinishiyama:20160510150314p:plain

 しかし、画像を見ても明らかな通り、テキストや画像も左右に反転してしまいます。これらの要素は、個別に、もう一度反転させることで適切な状態になります。

f:id:yuseinishiyama:20160510150315p:plain

翻訳フロー

 機能追加毎に6つの言語への翻訳を行うコストは決して少なくはありません。適切な翻訳フローを設けなければ、翻訳のためのタスクがブロックとなり、スムーズな開発を妨げる可能性があります。

 本節では、Cookpadアプリの翻訳がどのようなフローに則って行われているかについて説明します。

ブランチ戦略

 翻訳は各地のメンバーが行っています。PR毎に全ての翻訳を完了させるということも可能ですが、時差の都合上、翻訳待ちの状態のPRが多くなりがちです。そこで、各トピックブランチでは英語の文言を設定するだけにしました。developブランチには英語の文言だけが存在する変更がマージされ、リリースブランチ上で残りの言語の翻訳を行います。

 このようなフローを取るためにブランチモデルは、git-flowを採用しました。

リリースブランチでの翻訳作業

 本項では、リリースブランチでの翻訳作業について説明します。

エクスポート

 まず、ベースとなる言語(ここでは英語)のXLIFF(XML Localisation Interchange File Format)ファイルをエクスポートします。XLIFFはローカライゼーションのための標準規格で、Xcode 6からサポートされるようになりました。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
    <file original="Global/SupportingFiles/en.lproj/Localizable.strings" source-language="en" datatype="plaintext">
    <header>
      <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="7.3" build-num="7D175"/>
    </header>
    <body>
      <trans-unit id="application_config.new_version_available.button_title">
        <source>Open in App Store</source>
        <note>No comment provided by engineer.</note>
      </trans-unit>
      <trans-unit id="application_config.new_version_available.message">
        <source>A new version is available in the App Store. Please update before continuing to use the app.</source>
        <note>No comment provided by engineer.</note>
            <note>No comment provided by engineer.</note>
      </trans-unit>
      (省略)
    </body>
  </file>
</xliff>

 このファイルはGUI上からもエクスポートすることができますが、xcodebuildを利用すれば、コマンドラインからもエクスポート可能です。自動化のタスクに組み込むなどすると良いでしょう。

xcodebuild -exportLocalizations -localizationPath <エクスポート先> -project <プロジェクト名>

 ちなみに、エクスポートする際に、Xcodeはソースコードを解析して、存在するNSLocalizedString(_:comment:)のキーを確認します。この解析時に、XcodeはNSLocalizedStringという文字列を探して、その後に続く文字列リテラルからキーを取得しているようです。先ほど、NSLocalizedString(_:comment:)のラッパーの関数名に、そのままNSLocalizedStringを使っていたのはこのためです。もし、次のように別名の関数を用意していた場合、エクスポートは上手く機能しません。

func MyLocalizedString(key: String, comment: String) -> String {
    return NSLocalizedString(key, comment: comment)
}

MyLocalizedString("key", comment: "comment")

OneSkyを用いた翻訳

 このプロジェクトでは、全プラットフォームで翻訳作業にOneSkyというサービスを利用しています。OneSkyはXLIFFフォーマットをサポートしているため、OneSkyに上記の手順で生成したベースとなる言語ののXLIFFファイルをアップロードすることができます。アップロード作業も公式のクライアントを用いれば、コマンドライン上から行うことができます。

 アップロードすると、文言が翻訳可能な状態となるので、後は各地のメンバーに翻訳作業を依頼します。OneSky内で翻訳について翻訳者と議論したり、別のプラットフォームで使用している文言を参照したり、翻訳の進捗状況を視覚的に確認することもできます。

f:id:yuseinishiyama:20160510150415p:plain

 OneSky内で翻訳を外注することもできますが、我々はあくまでツールとして利用し、翻訳自体は各地のメンバーが行っています。単なる翻訳ではなく、サービスの思想を汲んだ上で適切な意訳を行うことができるということが、翻訳を外注しないことの最大のメリットでしょう。

インポート

 翻訳が完了したら、OneSkyから全ての言語用のXLIFFファイルをダウンロードしてプロジェクトにインポートします。Cookpadアプリの場合は、6つの言語をサポートしているので、6つのXLIFFファイルをインポートすることになります。

 エクスポートと同じく、インポートもGUI、コマンドラインの両方から行えます。

xcodebuild -importLocalizations -localizationPath <インポート対象> -project <プロジェクト名>

翻訳に関するTips

 本節では翻訳に関するTipsについて説明します。

適切なNSLocalizedStringのキーを設定する

 NSLocalizedString(_:comment:)のキーとして設定する値は非常に重要です。適切なキーを設定しなければ、あっという間に管理不能な状態になります。

 次のような、キーと値の組み合わせは良くありません。"latest"のような似通った表現が出てきた場合に、どこでどちらが使われているのか分からなくなる可能性があります。また、英語において、全て"new"と表現することができる箇所が、他の言語でも1つの用語で表現できるとは限りません。

"new" = "new";

 キーは常にコンテキストが分かるような値にしましょう。また、同じ単語であっても、コンテキストが異なれば、別のものとして定義するべきです。

"home.feed.new" = "new";

変数の順序に気をつける

 書式に変数を埋め込む形の翻訳対象の文字列があったとします。

"Copy %@’s %@" = "Copying %@’s %@";

 このように複数の変数がある場合、必ずしも他の言語でも上手く機能するとは限りません。例えば、これをそのままドイツ語にすると次のようになりますが、これは誤りです。ドイツ語では1番目の変数と2番目の変数の順序が逆である必要があります。

"Copy %@’s %@" = "%@ von %@ kopieren";

 この問題を回避するために、書式中の変数に対して、順序を指定することができます。

"Copy %@’s %@" = "%$2@ von %$1@ kopieren";

デバッグ

 本項ではデバッグに関するTipsを紹介します。

Double Length Pseudolanguage

 言語が増えれば増えるほど、文字列の長さを予測することは難しくなります。ある言語では1行で収まるような文言でも、他の言語では複数行となるといったようなことは頻繁に起こります。常に想定より長い文字列を表示できるようなレイアウトを組むべきです。

 「Double Length Pseudolanguage」というデバッグオプションを使用すれば、全てのローカライズされた文字列が2回繰り返される状態を再現できます。

f:id:yuseinishiyama:20160510150313p:plain

これによって、想定より長い文字列が入った場合にレイアウトが崩れるケースを検出できるかもしれません。次の例では、"bookmark"、"Create Recipe"などの翻訳対象の文字列が全て2回繰り返されており、ここから想定より長い文字列を表示すると一部のレイアウトが崩れることが検出できます。

f:id:yuseinishiyama:20160510150312p:plain

Right to Left Pseudolanguage

 「Right to Left Pseudolanguage」というデバッグオプションを使用すれば、擬似的なRTLの状態を再現することが可能です。

f:id:yuseinishiyama:20160510150505p:plain

 まだRTLの言語をサポートしていないアプリでも、RTLになった際にどのようなレイアウトになるか確認することができます。次の例では、言語は英語ですが、レイアウトはRTLになっています。

f:id:yuseinishiyama:20160510150419p:plain

NSShowNonLocalizedStrings

 NSShowNonLocalizedStringsを起動時のオプションに指定すれば、未翻訳の文字列のキーが大文字で表示されます。

f:id:yuseinishiyama:20160510150414p:plain

 例えば、次の例では、検索バー内のプレースホルダーが未翻訳のため、そのキーが大文字で表示されています。この結果、デバッグ時に翻訳忘れに気付きやすくなります。

f:id:yuseinishiyama:20160510150412p:plain

おわりに

 本記事では、CookpadアプリにおけるiOSアプリケーションの国際化と地域化について説明しました。

 言語毎にコンテンツやアカウントが異なるサービスというのは珍しく無いはずです。これをきっかけに、iOSアプリにおける言語の扱いに関する議論が盛んになれば、と思います。

 また、普遍的に役立つトピックもあれば、Cookpadアプリ固有のトピックもあったかと思います。最初に補足したように、いくつかの点で標準的な方法から逸脱した手段を取っています。我々のサービスでは、要求を満たすためにこうした手段を取りましたが、その代償として多少のメンテナンスコストの増加は避けられませんでした。もし、同様のことを行うのであれば、それが本当に必要かどうか、そしてそのコストがどれくらいかを検討するのにこの記事が参考になれば幸いです。

 このようにサービスの海外展開では、海外の文化、ユーザーのモバイル利用環境など様々な事柄を考慮しなければなりません。私たちは、こうした困難に積極的に立ち向かい、海外でクックパッドのサービスを展開することに協力してくれるモバイルエンジニアを積極募集中です!

弊社採用ページ(海外グループ iOS/Android アプリエンジニア)

*1:ローンチ後、Google Analyticsのデータからインドネシアからの多くのアクセスが、言語設定が英語のデバイスからであることが明らかになった。

*2:2016年5月10日時点の仕様。今後、変更される可能性がある。