UIKit だけでタブブラウザを作ってオープンソースにした話

2020年末、こよなく愛用していた Smooz という iOS 向けのタブブラウザが突然使えなくなってしまいました。で、代わりのブラウザをいろいろ試してみたものの、Smooz みたいに手に馴染む物が見つからず... ならばと思い、試しに作ってみたところ、意外にも標準的な UI の組み合わせで作れてしまったので、一通り実装して[App Store でリリース](https://apps.apple.com/jp/app/relief-simplest-tab-browser/id1553379094)してみました。

また、日頃から Qiita やインターネットでいろいろな方の記事を参考にさせてもらっているので、何か還元できないかと思い、[オープンソースにしてみました](https://github.com/ogaoga/UITabBrowser)。独学なのであまり自身はないですが、参考にしていただければ幸いです。

ということで、このアプリをどう作ったかを解説します。

# コンセプト

## 安心して使えるブラウザ

Smooz では個人情報の扱いが問題となったので、一切情報を収集、送信しないブラウザを目指しました[^1]。

* 3rd パーティーライブラリ不使用
* 外部 API 不使用
* アナリティクス不使用
* 広告不使用
* WKWebKit 経由でコンテンツを操作しない

その結果、ケアすることも少なくなり、素早く実装できた気がします。

[^1]: もちろん、WKWebView 内で表示している Web コンテンツが、広告を表示したりアクセス解析することを防ぐことはできないです。あ、[Content Blocker](https://developer.apple.com/documentation/safariservices/creating_a_content_blocker)を実装すれば良いのか？

## OS が提供するライブラリだけで作る

3rd パーティーのライブラリを使わないので、UI は [UIKit](https://developer.apple.com/jp/documentation/uikit/) だけで実装。データベースは [Core Data](https://developer.apple.com/documentation/coredata) を使い、データバインディングに [Combine](https://developer.apple.com/documentation/combine) を使用しました。なので、もちろん CocoaPods / Carthage / SPM などは不要で、ビルドもめちゃくちゃ速い！

## オープンソース

今回、オープンソースにしてみました。「安心して使えるブラウザ」にするためには、コードの中身も見てもらったほうがいい[^2] かなというのが元々のアイデアでしたが、自分のコードを参考にしてもらったり、逆に指摘してもらって自分ももっと学べることにメリットがあると思いました。なので、プルリク Welcome です。

[^2]: 公開しているソースコードがそのままビルドされて App Store でリリースされている、ということは証明できないので、厳密には安心を提供できているわけではないです。もちろん、ビルド時に変なコードを入れたりはしてないですが。

# 画面構成と UIKit の使い方

## メイン画面

![メイン画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/d4ffa6dc-f2d7-1786-973a-3df5fcb16f01.png)
`UINavigationController -> UIViewController` の構成にして、`UIViewController` の View に `UIPageViewController` と `UICollectionView` を配置しています。

### コンテンツ部分（`UIPageViewController`）

`UIPageViewController` は、ビューコントローラをページとして、複数のページを管理できるコントローラです。

Web コンテンツを表示する場合には `WKWebView` を表示し、検索ビュー（虫眼鏡を押した時に表示されるビュー）では、検索履歴リストを表示するために `UICollectionView` を表示しています。
![webcontentsandsearchview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/0bb8442c-e09c-4bb3-acd7-161798b2f638.png)
Smooz では、左右にスワイプしてタブを切り替えることができ、これがとても便利でした。`UIPageViewController` は標準でスワイプでのページ切り替えがサポートされていますが、今回は諦めました。というのも、Web ページがカルーセルなどのスワイプさせるコンテンツを含む場合に、操作が競合してしまうためです。これ解決するの相当大変そう。

### タブ部分（`UICollectionView`）

このエリアには、水平方向にタブが並びスクロールができるようになっています。ここには `UICollectionView` を利用しています。ちなみにこのアプリでは、iOS13 から導入されたモダンな `UICollectionView` を使っています。

`NSDiffableDataSourceSectionSnapshot` を使うことで、ダイナミックなタブの追加／挿入／削除をうまく処理してくれたのが便利でした。 [コードはこの辺り](https://github.com/ogaoga/UITabBrowser/blob/4bc8c29ddc5159c020f6743343ecab0e5ae7664e/UITabBrowser/Tabs/TabsViewController.swift#L36)。

#### コンテクストメニュー

タブを長押しすると、コンテクストメニューが表示されて、タブに対する操作ができます。
![contextmenu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/0ba32282-728c-8c2d-375a-bc05d17a9e64.png)
これは、`UICollectionViewDelegate` の `collectionView(_:contextMenuConfigurationForItemAt:point:)` で簡単に設定できます。[コードはこの辺り](https://github.com/ogaoga/UITabBrowser/blob/4bc8c29ddc5159c020f6743343ecab0e5ae7664e/UITabBrowser/Tabs/TabsViewController.swift#L192)。

### 検索フィールド部分（`UISearchBar`）

`navigationItem.titleView` に `UISearchBar` を埋めて、検索フィールドを実現しています。[コードはここ](https://github.com/ogaoga/UITabBrowser/blob/4bc8c29ddc5159c020f6743343ecab0e5ae7664e/UITabBrowser/Main/MainViewController.swift#L56)。

### 操作ボタン部分（`UIToolbar`）

Storyboard で View Controller を選択すると、Attributes inspector で `Bottom Bar` というプルダウンメニューがあり、`~ Toolbar` という項目を選択すると、Toolbar が表示されます。そこに `UIBarButtonItem` を配置しています。最近のアプリではあまり使われていない気がしますが、Storyboard でお手軽にボタンを配置できます。

#### メニュー（`UIMenu`）

iOS14 から、ボタンに対してメニューを追加できるようになりました。今回「閉じる」ボタンにこれを使って、長押しして「すべてを閉じる」を実行できるようにしています。[コードはこの辺り](https://github.com/ogaoga/UITabBrowser/blob/4bc8c29ddc5159c020f6743343ecab0e5ae7664e/UITabBrowser/Main/MainViewController.swift#L138)。
![pulldownmenu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/2f159325-fb40-c67c-41d0-0cf87a301172.png)

前述のコンテクストメニューに似ていますが、こちらは `UIBarButtonItem` の `menu` プロパティに `UIMenu` を渡して設定します（[Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/controls/pull-down-menus/)でも、別のものとして定義されています）。

## ブックマーク画面

ブックマークボタンを押すと、ブックマーク画面がモーダルビューで表示されます。その内側の構成は下記のようになってます。

![bookmark.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/eb65ee58-a647-5a96-b374-0e7a04249b8e.png)

### 一覧部分（UICollectionView）

ブックマーク／閲覧履歴／検索履歴一覧には、モダン `UICollectionView` を使っていて、検索ビューと共通化をしています。[コードはこちら](https://github.com/ogaoga/UITabBrowser/blob/1.0/UITabBrowser/SearchResults/SearchResultsController.swift)。

### 検索フィールド部分（`UISearchController`）

ナビゲーションバーに、項目を絞り込む検索フィールドと、一覧の種類を切り替える Segmented Controls が表示されていますが、これは `navigationItem.searchController` に `UISearchController` を設定しています。これはずいぶん前から提供されている仕組みで、簡単に一覧を絞り込む UI を構築できます。[コードはこの辺り](https://github.com/ogaoga/UITabBrowser/blob/4bc8c29ddc5159c020f6743343ecab0e5ae7664e/UITabBrowser/Bookmark/BookmarkViewController.swift#L108)。

ちなみに、前述のメイン画面の検索フィールドとは違う実装をしています（こちらの方がむしろ標準的な実装）。また、`UISearchController` をインスタンスする際に、検索結果を表示するビューコントローラーを `searchResultsController` に指定できますが、今回は使用していません。

### 操作ボタン部分（`UIToolbar`）

こちらは、メイン画面と同様の、標準的な `UIToolbar` の実装となっています。

## 設定画面

検索フィールドの右側のボタンを押すと、設定画面がモーダルビューで表示されます。こちらは極力 Storyboard で実装しています。
![settings.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/0b1622ff-7628-5686-afcf-dba2444ab626.png)

### 設定一覧（`UITableView`）

設定一覧は、`UITableView` の Static Cells で実装しています。選択された検索エンジン名を表示したり、Safari ビューでコンテンツを表示したり、バージョン番号を動的に取得して表示する部分は、コードで実装しています。

### 検索エンジン選択（`UITableViewController`）

いくつかの項目から１つ選択する一覧画面ですが、モダン `UICollectionView` だと多少 too much な感じなので、`UITableViewController` で実装しています。

### Safari ビュー（`SFSafariViewController`）

フィードバックフォーム（Goole フォーム）や、プライバシーポリシー（Web ページ）は Safari View を使ってモーダル表示しています。Safari View は Storyboard では実現できないので、[コードで表示しています](https://github.com/ogaoga/UITabBrowser/blob/1.0/UITabBrowser/Common/SafariView.swift)。

## オンボーディング画面（`UIPageViewController`）

初回起動時の[オンボーディング](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/onboarding/)画面は、ページをめくる構成なので、`UIPageViewController` を利用しています。また、コンテンツ部分は Storyboard で作りました。

![スクリーンショット 2021-02-28 21.22.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/e32b9422-d982-dea9-678a-dc07514624fb.png)
各ページは `UIViewController`（下段の５つ）となっていて、Storyboard ID をキーにして下記のようにコードに読み込んでいます。

```
    pages = (1...5).map {
        storyboard.instantiateViewController(withIdentifier: "View\($0)")
    }
```

## その他

### アイコン

各所でアイコンを使っていますが、これらはすべて [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/) を使っています。コードでは下記のように指定します。

```swift
    UIImage(systemName: "magnifyingglass"),
```

`systemName` に指定する文字列は [SF Symbols 2](https://developer.apple.com/sf-symbols/) という macOS アプリで調べると便利です。

### アプリアイコン

唯一、ビジュアルデザインが必要なのがアプリのアイコンです。シンプルを売りにするアプリなので、アイコンもシンプルにしました（というかこの程度しかデザインできない）。ちなみに、[Google Slide を使って作成しました](https://docs.google.com/presentation/d/1IOyVDwjCT8LVe_4dUMZyV1sJPHWZHfkuEQ1m0xN7Yj0/edit#slide=id.gbd289b3f3c_1_0)。

# データハンドリングまわり

## Combine

アプリ全体の状態管理は `Browsers` という[シングルトンのクラス](https://github.com/ogaoga/UITabBrowser/blob/1.0/UITabBrowser/Model/Browsers.swift)で行い、各 View Model がそれを Subscribe してビューに必要な情報に変換して、ビューがそれを Subscribe して表示する、という構成になってます。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4617/6159f13b-fcfe-7e6e-a38a-944649f2a35f.png)

この状態の監視、データの変換に [Combine](https://developer.apple.com/documentation/combine) を利用しています。

Combine は SwiftUI と同時期にリリースされた Apple 提供のライブラリで、[以前作った SwiftUI ベースのアプリ](https://qiita.com/ogaoga/items/d38caaad0cda50068a1b)で習得したのですが、UIKit ベースのアプリでも問題なく使えました。

実際の使い方は、説明できるほどちゃんと理解できていないので、[ソースコード](https://github.com/ogaoga/UITabBrowser)を見ていただければと思います。なお、アーキテクチャは MVVM っぽい感じになっていると思いますが、ちゃんと学んだことがないのでこれで良いのか自信がないです。

## Core Data

タブの状態やブックマーク、履歴などの保存に Core Data を使いました。今のところ問題はなさそうですが、Core Data もあまり自信を持って使っているわけではないので、コードを整理したり、最適化できる余地があるかと思います。

ちなみに、途中で Core Data を使うことにしたので、こちらの記事を参考にさせていただきました。

[[Xcode9] 既存プロジェクトにCoreDataを追加する方法 - Qiita](https://qiita.com/sensuikan1973/items/8ae1eabcac7fe60e0c4d)

# 課題

## iPad 対応が適当。

iPad はただ iPhone を拡大しただけになっているので、使いやすさを考慮して UI を最適化したり、`UISplitViewController` を使ってブックマーク等へのアクセス性を向上できればと思っています。

参考：[UISplitViewController 公式ドキュメントの和訳（iOS14 対応）](https://qiita.com/ogaoga/items/c01009ee85efa36b99d5)

## スクロールした時に上下のバーを隠す処理が適当。

使用感がいまいちなので、もう少しスムーズかつ適切な動きにしたいと思っています。

## タブを削除した時に、タブの挙動がおかしくなる。

おそらく `UICollectionView` の扱い方がおかしいのだと思うけど、タブ（`UICollectionViewCell`）が１つずれたり、横スクロールがおかしくなってしまう。うーむ。

## Basic 認証など、いくつかのブラウザ機能やエラーハンドリングがちゃんと実装できていない。

ブラウザとして必要な機能を実装するのに、この記事を参考にさせていただきました。ただ、まだ Basic 認証の入力ダイアログが実装できていなかったり、エラー処理も適当なので、今後ちゃんと実装していきたいと思います。

[WKWebViewで必要十分な機能を持ったアプリ内ブラウザを作る - Qiita](https://qiita.com/t__nabe/items/4cc9650b1131f6d4d114)

## Combine のイベントが余計に発行されている気がする。

UI がちらついたり、閲覧履歴が何度も更新されているっぽいので、一度 Combine の流れを見直して、不要なイベントを捨てる必要がありそう。

## 非同期処理を使いこなせていなさそう。

基本 UI スレッドで動かしているので、Core Data の処理とかを別スレッドで動かせる気がしますが、この辺りも詳しくないので勉強が必要。

## メモリ管理もちゃんと検討・実装できていない。

大量にタブを開いた時などの考慮ができていないので、表示するまで読み込まないなどの対応が必要。また、メモリリークもちゃんと検討できていないので、もっと勉強して対応できればと思っています。

# まとめ

iOS も 14 となり、既存の UI のアーキテクチャが使いやすく進化していたり、新しい UI が追加されていたり、Combine のような新しいフレームワークも出てきていて、初期の頃に比べたら、より安定したモダンな実装が可能になってきている気がします。逆にいうと、常に学んでいく必要があって追いつくのが大変ではありますが...

それでも、多くのエンジニアの方が Qiita などで新しい技術を素早くわかりやすく共有してくれているので、こうやってアプリを開発することができています。この場を借りて感謝申し上げます。お返しになるかわかりませんが、今回[オープンソースにした](https://github.com/ogaoga/UITabBrowser)ので、実際のアプリでどう新しい技術が使われているかの参考になればいいなと思っています。ぜひ、フィードバックもお待ちしております！