プロジェクトの作業を開始し、2つの目標を設定しました。
- スケーラブルなアーキテクチャ :以前に記述されたコードを犠牲にすることなく、新しいタイプのメッセージを簡単に追加できる機能が必要でした。
- 優れたパフォーマンス :メッセージのスムーズな読み込みとスクロールを保証したかった。
この記事では、目標をどのように達成したか、どの方法が使用され、最終的に何が行われたかについて詳しく説明します。 GitHubのページには、アプリケーションアーキテクチャのかなり詳細な説明があります。
どちらが良いですか:UICollectionViewまたはUITableView?
以前のチャットではUITableViewを使用していました。 これは非常に優れていますが、UICollectionViewは、設定( アニメーション 、 UIDynamicsなど)および最適化(UICollectionViewLayoutおよびUICollectionViewLayoutInvalidationContext)のオプションを備えたより豊富なAPIを提供します。
さらに、複数の既存のチャットアプリケーションを調査した結果、すべてが正確にUICollectionViewを使用していることがわかりました。 したがって、UICollectionViewの選択を支持する決定は当たり前のことでした。
テキストメッセージ
テキストクラウドなしではチャットはできません。 実際、パフォーマンスの観点から、このタイプのメッセージは、テキストのレンダリングとスケーリングが遅いため、実装が最も困難です。 iMessageのように、チャットがリンクを自動的に検出し、定期的なアクティビティを実行することを望んでいました。
UITextViewは当初これらすべての要件をサポートしているため、リンクを処理するために1行のコードを記述する必要はありません。 そのため、このクラスを選択しましたが、このソリューションは問題の原因になりました。 次に、理由を説明します。
自動レイアウトとセルのサイズ変更
レイアウトとサイズの計算は常に困難を引き起こします。複製コードを書くのは非常に簡単で、維持するのがより難しく、バグの出現につながるので、これを避けようとしました。 当初からiOS 8のサポートを提供していたため、自動レイアウトとセルのサイズ変更を試してみることにしました。 このアプローチの実装の一般的な説明を含むブランチを次に示します。 試したところ、2つの大きな問題が発生しました。
- スクロール中にジャンプします。 通常、チャットでは下から上にスクロールが行われるため、最初は下のセルのサイズがカウントされ、スクロール中は上から表示されるセルのサイズがカウントされます。 同時に、上部にあるセルの正確なサイズは事前にわからないため、UICollectionViewは指定されたtimatedItemSizeを使用してcontentSizeと下位セルの位置を計算します。 正確なセルサイズを取得するために、UICollectionViewFlowLayoutはUICollectionViewCellでpreferredLayoutAttributesFittingAttributes(_ :)メソッドを呼び出します。 次に、このサイズは指定されたtimatedItemSizeに対応していないため、以前に作成されたセルの位置が調整され、その結果、セルが下に移動します。 UICollectionViewとUICollectionViewCellsを180度回転させることでこのバグを回避できました(実際には下部のセルが上部のセルになります)が、別の問題、つまり...
- スクロール性能が低い 。 セルサイズを正確に計算しても、1秒あたり60フレームの速度でスクロールすることはできませんでした。 ボトルネックは、自動レイアウトとUITextViewのサイズ変更でした。 AppleがiMessageのセル内で自動レイアウトを使用しないことを知っていたので、私たちはそれほど驚いていませんでした。 このため、自動レイアウトを使用すべきではありません。 実際、Badooでは非常に広く使用されています。 ただし、パフォーマンスの問題があり、通常はUICollectionViewとUITableViewに影響します
手動レイアウト
そのため、自動レイアウトではなくレイアウトのために、従来のアプローチを使用することにしました。 空白セルを使用してサイズを計算し、レイアウトとサイズをカウントするために、できるだけ多くの一般的なコードを使用する古典的な方法に決めました。 このアプローチははるかに高速に動作しましたが、iPhone 4sにはまだ十分ではありませんでした。 プロファイリングにより、layoutSubviewsメソッド内の作業が多すぎることが判明しました。
実際、同じジョブを2回実行しました。最初は空白のサイズをカウントしてから、layoutSubviews内の実際のセルで再度実行しました。 この問題を解決するために、UITextViewのsizeThatFits(_ :)値をキャッシュできますが、これは計算が非常に高価ですが、さらに進んで、すべてのサブビューのセルサイズとフレームが計算されてキャッシュされるレイアウトモデルを作成しました。 その結果、スクロール速度を大幅に向上させるだけでなく、sizeThatFits(_ :)とlayoutSubviewsの呼び出し間でコードを最大限の効率で再利用することができました。
さらに、updateViewsメソッドが注目を集めました。 小さいサイズでは、これは、指定されたスタイルと表示されるデータのタイプに従ってセルを更新する主要なメソッドの1つであることが判明しました。 UIを更新するための1つの主な方法の存在により、将来のコードのロジックとメンテナンスが簡素化されましたが、同時に、セルのプロパティを変更するほとんどすべてのアクションに対して呼び出されました。 この問題に対処するために、最適化する2つの方法を考え出しました。
- 2つの異なるコンテキスト :.Normalと.Sizing。 空白セルに.Sizingコンテキストを使用して、いくつかの冗長なupdateViews呼び出しをスキップしました(たとえば、クラウドイメージの更新やUITextViewでのリンク検出の無効化)。
- バッチ更新 :セルにperformBatchUpdates(_:animated:completion)関数を実装しました。 これにより、必要な回数だけセルのプロパティを更新できましたが、同時にupdateViewsを呼び出すのは一度だけです。
さらに高速
すでに十分なスクロール速度を達成していますが、より多くのメッセージ(50単位のパッケージ)を読み込むと、メインスレッドが長時間ブロックされ、その結果、スクロールが一瞬停止しました。 もちろん、UITextView.sizeThatFits(_ :)関数もボトルネックでした。 リンクを検出して空白セルのテキストを選択する機能を無効にし、非連続レイアウトを有効にすることで、処理を大幅に高速化することができました。
textView.layoutManager.allowsNonContiguousLayout = true textView.dataDetectorTypes = .None textView.selectable = false
その後、50個の新しいメッセージを同時に表示することは問題ではなくなりました。ただし、それ以前にはあまりメッセージがなかったことが前提です。 しかし、さらに先へ進むことができると判断しました。
サイズと位置を計算するタスクを実行するためにレイアウトモデルをキャッシュして再利用することによって達成された抽象化のレベルを考慮すると、バックグラウンドスレッドで計算を実行するために必要なものがすべて揃いました。 しかし... ... UIKitはカウントしません。
ご存知のように、UIKitはスレッドセーフではなく、元の戦略(単にこの事実を無視することでした)により、予想される多くのUITextViewクラッシュが発生しました。 NSString.boundingRectWithSize(_:options:attributes:context)メソッドをバックグラウンドで使用できることはわかっていましたが、返されるサイズはUITextView.sizeThatFits(_ :)から取得したサイズと一致しませんでした。 私たちは多くの時間を費やしましたが、それでも解決策を見つけることができました。
textView.textContainerInset = UIEdgeInsetsZero textView.textContainer.lineFragmentPadding = 0
NSString.boundingRectWithSize(_:オプション:属性:コンテキスト)から取得したサイズの丸めを使用して、ピクセルをスクリーンします
extension CGSize { func bma_round() -> CGSize { return CGSize(width: ceil(self.width * scale) * (1.0 / scale), height: ceil(self.height * scale) * (1.0 / scale) ) } }
したがって、キャッシュをバックグラウンドスレッドで準備し、レイアウトが5000メッセージを処理する必要がない限り、メインスレッドのすべてのサイズを非常に迅速に取得できます。
この場合、UICollectionViewLayout.prepareLayout()メソッドが呼び出されると、iPhone 4sの速度が低下し始めました。 主なボトルネックは、UICollectionViewLayoutAttributesの作成とNSCacheからの5,000メッセージのサイズ設定でした。 この問題をどのように解決しましたか? セルと同じことを行いました。UICollectionViewLayoutAttributesの作成に関与するUICollectionViewLayoutのモデルを作成し、同様にその作成をバックグラウンドストリームに転送しました。 ここで、メインスレッドで、古いモデルを新しいモデルに単純に置き換えました。 そして、すべてが驚くほど速く動作し始めましたが、...
回転と分割ビュー
デバイスの回転または分割ビューのサイズ変更中に、メッセージを表示するために使用可能な幅が変更されたため、メッセージのすべてのサイズと位置を再度読み取る必要がありました。 このアプリケーションは回転をサポートしていないため、これは特に問題になりませんでしたが、すでにChattoをオープンソースでリリースし、回転と分割ビューの適切なサポートがこれらの目的に大きなプラスになると判断しました。 そのときまでに、バックグラウンドスレッドでサイズの計算を既に実装しており、スムーズなスクロールと新しいメッセージの読み込みを行っていましたが、アプリケーションが10,000メッセージを処理しなければならない場合はあまり役に立ちませんでした。 バックグラウンドでのこのような多数のメッセージのサイズを計算するために、iPhone 4sは10〜20秒かかりました。もちろん、ユーザーをそれほど待たせることはできませんでした。 問題を解決する2つの方法を見ました。
- 寸法を2回計算します。1回目は現在の幅で、2回目はデバイス上のメッセージが90度回転した後に受け入れる幅です。
- 10,000件のメッセージを処理する必要はありません。
最初のオプションは、ソリューション自体よりもハックです-スプリットビューモードでは実際には役に立たず、スケールしません。 したがって、2番目の方法を選択しました。
スライディングデータソース
iPhone 4sでのいくつかのテストの後、高速ローテーションのサポートは500メッセージ以下の処理を意味するという結論に達しました。そこで、カスタマイズ可能な数のメッセージを同時に表示する移動データソースを実装しました。 したがって、チャットを開くと、最初に50のメッセージをロードする必要があり、その後、ユーザーがチャットをスクロールして以前のレコードを表示すると、50のメッセージの次の部分がロードされます。 ユーザーが十分な数のメッセージをスクロールバックすると、最初のメッセージがメモリから削除されました。 したがって、ページネーションは両方の方法で機能しました。 このメソッドの実装は非常に簡単なタスクでしたが、別のケースで問題が発生しました。データソースが既にいっぱいで、新しいメッセージが到着したときです。
すでに500件のメッセージを受信して新しいメッセージを受信した場合、一番上のメッセージを削除し、他のすべてのメッセージを1つ上に移動して、到着したチャットに挿入する必要がありました。 これを解決するのに困難はありませんでしたが、UICollectionView.performBatchUpdates(_:completion :)メソッドはこのアプローチを好みませんでした。 主に2つの問題がありました( ここで再現できます )。
- 多数の新しいメッセージを受信したときの遅いスクロールとジャンプ。
- contentOffsetの変更により、投稿を追加するときにアニメーションが壊れます。
これらの問題に対処するために、許可されるメッセージの最大数の制限を緩和することにしました。 これで、アプリケーションが新しいメッセージを挿入できるようになり、設定された制限に違反し、UICollectionViewのスムーズな更新が保証されました。 挿入を完了した後、更新キューに未処理の変更がない場合、データソースに到着するメッセージが多すぎるという警告を送信しました。 その後、performBatchUpdatesではなくreloadDataを使用して必要な調整を行いました。 これが起こる瞬間を実際に制御することはできず、ユーザーがチャットを任意の位置にスクロールできることを考慮して、現在表示されているメッセージを削除しないように、ユーザーがチャットをスクロールした場所をデータソースに伝える必要がありました:
public protocol ChatDataSourceProtocol: class { ... func adjustNumberOfMessages(preferredMaxCount preferredMaxCount: Int?, focusPosition: Double, completion:(didAdjust: Bool) -> Void) }
カーキUITextView
そのため、これまで、自動レイアウトのパフォーマンスとサイズ計算の問題だけでなく、NSString.boundingRectWithSize(_:options:attributes:context)を使用してバックグラウンドスレッドでサイズを計算する問題を解決するための障害のみを考慮してきました。
リンク検出機能と他の利用可能なアクションを利用するには、UITextView.selectableプロパティをアクティブにする必要がありました。 これにより、雲に望ましくない副作用が発生しました(たとえば、テキストを選択する機会や虫眼鏡の外観)。 さらに、これらの機能をサポートするために、UITextViewはジェスチャ認識システムを使用して、テキストクラウドの選択やその内部での長いクリックの処理などのアクションを妨害します。 ハックの助けを借りてこれらの問題をどのように回避したかについては詳しく説明しませんが、 ChatMessageTextViewとBaseMessagePresenterのリンクをたどることで詳細を知ることができます。
インタラクティブキーボード
前述の問題に加えて、UITextViewの操作もキーボードに影響しました。 理論的には、最近では対話型キーボードの非表示の実装は非常に簡単な作業になります。 ここに示すように、使用するコントローラーのinputAccessoryViewとcanBecomeFirstResponderをオーバーライドするだけで十分です 。 ただし、ユーザーが任意のリンクをクリックしているときにUITextViewからUIActionSheetを表示すると、このメソッドは非効率的に機能しました。
問題の本質は、メニューがキーボードの下に表示され、まったく表示されないことでした。 この問題を自分で操作できる別のスレッドを次に示します ( rdar:// 23753306 )。
入力フィールドをView Controller階層の一部にし、キーボードからの通知を追跡し、UICollectionViewのcontentInsetsを手動で変更しようとしました。 ただし、ユーザーがキーボードを操作したとき、通知は受信されず、入力フィールドは画面の中央に表示され、ユーザーがキーボードを下に引いたときにユーザーとキーボードの間に隙間が残りました。 この問題は、ダミーのinputAccessoryView(入力フィールドの下にある)を使用し、KVOを使用してそれを観察するという特別なハックを使用して解決されます。 詳細については、 こちらをご覧ください 。
まとめ
- 自動レイアウトを使用しようとしましたが、パフォーマンスが不十分なため、手動レイアウトに切り替える必要がありました。
- 独自のポジショニングモデルに到達しました。これにより、layoutSubViewsとsizeThatFits(_ :)のコードを再利用でき、バックグラウンドでレイアウトの計算も実装できました。 私たちが見つけた解決策は、 AsyncDisplayKitプロジェクトのいくつかのアイデアと何らかの形で一致したことがわかりました。
- ビューの更新回数を最小限に抑えるために、performBatchUpdates(_:animated:completion)メソッドと2つの個別のセルコンテキストを実装しました。
- コード内のメッセージの数を制限した移動データソースを実装しました。これにより、デバイスが回転し、Split Viewモードが切り替えられたときに高速スケーリングを実現します。
- UITextViewは非常に使いにくいことが判明しましたが、リンク検出機能により、古いデバイス(iPhone 4s)でのスクロール中にパフォーマンスを低下させるボトルネックのままです。 それでも、リンクを操作するときに定期的なアクションを実行する機能が必要だったため、引き続き使用しました。
- UITextViewのため、KVOでダミーのinputAccessoryViewを観察することで、手動で対話型キーボードの非表示を実装する必要がありました。
Badoo iOS開発チーム