! This post is also available in the following language. 英語

LINEアプリに設定検索機能を追加

大規模なプラットフォームとなったLINEでは、様々な機能が追加されてきました。それに伴い、設定メニューにも新しいメニューや項目が追加されてきました。個人情報、テーマ、権限、通知などに関連する47個の設定メニューと185の項目があり、その中からユーザーが変更したい設定を見つけるのは難しくなっていました。設定がどこにあるのかを正確に把握していなければ、最終的に探しているものを見つけるまでに、いくつものページや階層を移動しなければなりませんでした。 
私たちはこの問題を解決し、ユーザーによりシンプルな使い勝手を提供するために、設定メニューに検索バーを追加することにしました。

新しい検索バーでは、ユーザーはキーワードを入力して、目的の設定を見つけることができます。その設定メニューの階層がどんなに深くても問題ありません。検索結果のいずれかをタップすると、該当するメニューが開いて目的の設定までスクロールし、点滅するアニメーションによって目的の設定が強調表示されるようになりました。設定によっては、検索結果から設定の詳細ページを直接開くこともできます。 

新しい検索画面を構築するにあたり、最初に考えたことは、「検索結果を表示するためのデータをどうやって取得するか」ということでした。この問いに答えるために、私たちは以下の2つの方法を考えました。 

1.現在の設定メニューと同じ項目を持つ検索データセットを別途構築する 

この手法は、現在のコードベースに手を加える必要がないというメリットがあります。しかし、項目が多いため、設定データに変更があった時に検索データを更新するのを忘れたり、間違えたりしやすいため、設定やメンテナンスに多大な労力を要します。また、表示される設定項目と該当するメニュー構成が一致しなくなる可能性があり、それを修正するのは容易ではありません。 

2. 設定メニューと検索データを同期させる 

この手法は、1つ目の手法の問題をすべて解決できるうえに、設定データの更新が常に検索データに反映されます。そのため、検索システムが正しく機能し、メンテナンスコストもほとんどかかりません。 

数多くのコード行に手を加える必要があったものの、他の開発者が将来、メンテナンスにコストを掛けないで済むように、2番目の手法を採用しました。 

実際の変更作業に取り掛かる前に、同期メカニズムを適用する最適な方法を見つけるため、現在のメニュー構造を詳しく調査しました。残念なことに、設定データの構造はバラバラで、データ構造が明確なものもあれば、定義が不明確でさまざまなソースからデータが収集されているものもありました。設定メニューと検索結果の同期について検討する前に、各設定メニューを理解し、必要に応じてデータ構造を統一するための改修には時間がかかります。さらに、コードベースの中にはかなり前に書かれたものもあり、将来的にプロダクトの規模を拡張したり、デザインを更新しようとした時に、メンテナンスが大変になります。

そのため、メンテナンスコストを削減し、拡張性を高め、検索データをより効率よく同期できるように、すべての設定メニューをリファクタリングして構造を統一するという大きな決断をしました。 

リファクタリング前後のメニュー構造

実装 

データソースの検索 

まず、各設定メニューには数多くの項目が含まれていて、各項目は、下位の設定メニューを指すものもあれば、アクション項目や情報項目となっているものもあります。 

検索データを構築するには、下位の設定メニューを持つ項目から下位の項目を取得できるように、設定メニューのすべての階層から項目データを取得する必要があります。この場合、項目のデータ構造をツリーで表すのが適しています。設定項目をノードとし、設定システム全体をツリーとすると、各ノードは、その下に枝(子ノード)を持つか、それ自体が葉となるという仮説を立てました。 

そこで、検索項目の情報(SearchItem)とデータソース(SearchDataSource)を持つ検索ノードモデル(SearchNode)を定義しました。ノードに枝が存在する場合、SearchDataSourceがその枝を表します。 

SearchNode 

public final class SearchNode {
    public let item: SearchItem
    public let dataSource: SearchDataSource?
}

 

SearchItem 

public final class SearchItem {
    public enum Action {
        case select
        case highlight
    }
 
    public let id: String
    public let title: String
    public let value: NSAttributedString?
    public let category: String?
    public let keywords: [String]
    public let icon: UIImage?
    public let action: () -> Action
}

 

各設定メニューでは、以下の要素を持つSearchDataSourceを定義しました。 

  • searchTree: そのメニューに含まれる項目に対応する検索ノード 
  • ownedViewControllerFactory: 設定メニューの View Controller を作成するFactory 
public protocol SearchDataSource {
    var ownedViewControllerFactory: ((_ inStackViewControllers: [UIViewController]) -> UIViewController)? { get }
    var searchTree: [SearchNode] { get }
}

 

以下の図から、検索データソースがどのように表されるかが分かります。 

同期 

メニューから取得した設定データを統一したので、次に、各項目の情報を持った項目モデルとそれをまとめたセクションからなる新しいモデルを定義しました。 

項目モデルには以下のものが格納されます。 

  • 対応する項目セルをTableViewに設定するための項目データ 
  • 検索ノードを設定するための追加情報を提供する検索データ 

今回、対応するセルタイプのデータを保存するための項目モデルタイプをいくつかサポートしました。例えば以下のようなものです。 

  • プロフィール画像モデルには、プロフィール画像セル(カバー画像とユーザープロフィール画像)の情報が格納される。 
  • プレーンモデルには、プレーンセル(タイトル、サブタイトル、アクセサリビューを持つセル)の情報が格納される。 
  • スイッチモデルには、スイッチセル(トグル機能を実装するセル)の情報が格納される。

また、項目タイプから検索ノードを抽出するためのSearchNodeSupportableプロトコルを定義しました。 

public protocol SearchNodeSupportable {
    func getSearchNode() -> SearchNode?
}

 

各モデルタイプは、このプロトコルに従って検索ノードを出力します。一方、特殊な項目を検索対象から除外することもできます(この場合、項目タイプを返さなくてもよい)。 

以下の図は、リファクタリングされた設定メニューに新しいデータ構造を適用したものです。 

検索データソースを構築した後、与えられた検索キーワードに一致する項目を照会するための一次ソースとして、このデータソースを検索画面で使うことを考えました。ユーザーが検索結果の項目を選択すると、目的の設定メニューへのナビゲーションスタックが作成されます(例えば、設定項目が3番目の階層にある場合、1番目と2番目の階層にあるメニューがスタックにプッシュされてから、最後のメニューが表示されます)。そして、SearchAction列挙型で以下のように定義されたアクションタイプに基づいて、選択した項目に対応するアクションが実行されます。 

  • highlight: 目的の項目までスクロールして強調表示アニメーションを実行する 
  • select: 目的の項目までスクロールして強調表示アニメーションを実行し、選択アクションを実行する 

強調表示アクションと選択アクションをサポートするため、CellHighlightSupportableプロトコルとCellSelectingSupportableプロトコルを定義し、与えられた設定項目IDに基づいて強調表示アクションと選択アクションを実行するようにしました。 ViewController の設定では、これらのプロトコルに従ってアクションを実行する必要があります。 

デフォルトでは、これらのプロトコルのエクステンションとして、アクションのアニメーション効果が実行されます。ビューコントローラが別のアニメーションを実行するような特殊なケースでは、関数を実装し直すことで簡単にカスタマイズできます 

public protocol CellHighlightSupportable {
    func highlightCell(withID id: String, completion: (() -> Void)?)
}
 
public protocol CellSelectingSupportable {
    func selectCell(withID id: String)
}

 

拡張性 

これまで、設定メニューでは3~4種類のセルですべてのケースをカバーできるように対応してきましたが、今後の仕様変更やデザイン変更などでセルが追加された場合に備えて、修正をできるだけ少なく済むようにする必要があります。そのため、 ViewModel と対応するセルを照合してそれらを設定するためのTableViewCellConfigurableプロトコルを導入しました。

public protocol TableViewCellConfigurable: TableViewCellConfigurationWrappable {
    associatedtype TableViewCell
    associatedtype ViewModel
 
    func registerCellType(for tableView: UITableView)
    func config(
        cell: TableViewCell,
        withViewModel viewModel: ViewModel,
        indexPath: IndexPath,
        for tableView: UITableView
    )
    func height(with viewModel: ViewModel, indexPath: IndexPath, for tableView: UITableView) -> CGFloat
    func estimateHeight(with viewModel: ViewModel, indexPath: IndexPath, for tableView: UITableView) -> CGFloat
}

 

新しいセルタイプを追加したい場合は、TableViewCellConfigurableプロトコルに準拠したコンフィギュレーターを新たに定義するだけで、セルコンフィギュレーターを別途設定します。そうすることで、コードの他の部分に手を加えることなく個別に設定することができます。このコンフィギュレーターは、ビューコントローラーの初期化時にプールに登録され、このプールを通じて対応するセルに項目モデルが自動的に転送されます。 

副作用 

かなり多くの設定メニューをリファクタリングしたため、予期せぬ問題が発生し、現在の動作に合わせて構造を変更することになりました。 

そのうちの一つが、変更された行の能動的な再読み込みをサポートすることです。古い構造では、そのような行を手動で呼び出していました。新しい構造でもそれは可能ですが、データソースが動的に変化する場合は効率的ではありません。そのこで、DifferenceKitと呼ばれる差分検出アルゴリズムを導入しました。このアルゴリズムは時間計算量がO(n)であり、パフォーマンスに優れています。設定モデルが変更されると、過去の状態に基づくチェンジセットが計算され、それに応じた一括更新アクションを出力します。これにより、テーブルビューを手動で更新する際の苦痛を軽減し、更新アクションとデータソースとの不一致により生じるクラッシュを減らすことができました。 

まとめ 

私たちは、血と汗と涙の末に、LINEバージョン11.7.0で設定検索機能の最初のバージョンをリリースすることができました。リリース後1週間のユーザーの利用状況は、非常に印象的でした。 

  • 10万4千人のユーザーが設定検索機能を使用した 
  • 設定のホームメニューを開いたユーザーの47%が検索機能を利用した 
  • ユーザーの37%が目的の設定項目を見つけることができた 

以上のこの分析結果から、この機能が今のところはユーザーの役に立っていることが確認できます。しかし、目的の設定項目を見つけることができたユーザーの割合は、予想よりも期待値を下回っています。私たちは、検索をより効率的にするための改善に取り組んでいきます。次のバージョンでは使い勝手をさらに向上できることを願っています。