Open Libraryのレスポンスが1.2秒を越えるたびに、自分の集中がざらついた。失敗。著者検索に切り替えたら別のURLを叩き直さないといけない。焦った。50歳の目には、HTTP通信の例外メッセージがただの赤いノイズに見える瞬間がある。
この文章は、Open Library APIをWPF+WebView2で抱きしめたBookSearchAppをWindows 10上で量産ビルドし、検索・詳細・お気に入り・Web表示を一続きに仕上げるまでの制作エッセイです。甘さを削るまでの記録です。
対象読者
- WPFでAPI検索UIを作り込みたいデスクトップ開発者
- WebView2とMVVMの連携で悩んでいる.NETエンジニア(WebView2のナビゲーションイベントとViewModelの連携方法)
- Open Library APIの現実的なレイテンシーとフォールバックを知りたい人
記事に書いてあること
- タイトル/著者/ISBNの3種類の検索を分岐し、平均1.0〜1.2秒のレイテンシーでもUIのテンポを崩さない構成
- BookSearchViewModelのページングと
HasNextPage計算をUIにどう露出したか
- FavoriteServiceで
%LocalAppData%\BookSearchApp\favorites.jsonを更新する理由と段取り
- WebView2のナビゲーションイベントで読み込み状態を操るテクニック
- ビルドスクリプトでx64/x86/arm64を153MB前後にそろえた手順
前提知識
- WPF (XAML) と MVVM の基本バインディング構造
- Open Library API(
search.json / api/books)のクエリ構造
- WebView2 runtime/自己完結型の発行 (単一ファイル + 実行時コンパイルの一部をビルド時に済ませるオプション) の概念
今回仕上げたもの
タイトル/著者/ISBNを切り替えて検索し、20件ずつの結果から詳細タブで表紙・出版社・ページ数を眺め、気に入った本を星ボタンでfavorites.jsonに落とし込み、さらにWebView2でOpen Libraryの元ページを埋め込むデスクトップアプリです。UIはタブ4枚(検索・詳細・お気に入り・Web表示)に分け、ローディングとエラー表示を常に手元に置きました。BookSearchApp-x64.exeは実行時コンパイルの一部をビルド時に済ませるオプションを有効にした自己完結型の単一EXE配布です。
👉 BookSearchApp-x64.exe をダウンロードして実行する(Edgeが警告を出しても詳細→実行で継続できます)
汗だくの検索ループ
タイトル検索のhttps://openlibrary.org/search.json?title=harry+potter&limit=20は計測上1223ミリ秒。著者asimovは1144ミリ秒、ISBN 9784041052062は1038ミリ秒でした(全部PowerShell + Stopwatchで実測)。コーヒーを一口飲む隙もない。BookSearchViewModelは検索コマンドとISBN検索コマンドを明確に分け、ISBNは1件だけを選択書籍に突っ込んで詳細タブへ飛ばします。Title/Authorはデータ変更を自動で通知するコレクションをクリア→追加して総結果数とページ情報を更新、次ページ有無の計算に備えます。これなら遅いAPIの日でもUIを崩さない。
検索処理は、タイトルや著者名を受け取ってOpen Library APIを呼び出し、結果をパースしてUIに反映します。ISBN検索は別のエンドポイントを使い、単一の書籍情報を取得します。
// dotNet学習/dotnetProjectForWindows_7/Services/OpenLibraryApiService.cs(抜粋 39-56行目)
public async Task<BookSearchResult> SearchByTitleAsync(string title, int limit = 20, int offset = 0)
{
if (string.IsNullOrWhiteSpace(title)) return new BookSearchResult();
var encodedTitle = Uri.EscapeDataString(title);
var url = $"/search.json?title={encodedTitle}&limit={limit}&offset={offset}";
var response = await _httpClient.GetStringAsync(url);
return ParseSearchResponse(response);
}
public async Task<BookSearchResult> SearchByAuthorAsync(string author, int limit = 20, int offset = 0)
{
if (string.IsNullOrWhiteSpace(author)) return new BookSearchResult();
var encodedAuthor = Uri.EscapeDataString(author);
var url = $"/search.json?author={encodedAuthor}&limit={limit}&offset={offset}";
var response = await _httpClient.GetStringAsync(url);
return ParseSearchResponse(response);
}
ExecuteSearchAsync()ではIsLoading=true→ErrorMessage=""→CurrentPage=0→_openLibraryApiService呼び出し→コレクションクリア→TotalResults更新→finallyでIsLoading=false。ここで例外が出たらSearchResults.Clear()とMessageBox.Show()でユーザーに知らせる。これはmidori_new007: 気象コックピットで使ったロギング癖の延長です。地味だけど効いた。意外だ。
目視できるページング
HasNextPage => CurrentPage * 20 + SearchResults.Count < TotalResultsと条件をはっきり書いたので、UI側はこの計算結果だけで「次へ」ボタンの活性を決められます。PageInfoは"ページ {CurrentPage + 1} / {((TotalResults - 1) / 20) + 1}"の式で常に人間が読める形に整形。LoadPageAsyncはlimit/offsetを指定しなおして同じサービスメソッドを叩き直すシンプル構成です。
ページングの計算は、現在のページ番号と表示中の件数から次ページの有無を判定します。UIではこの計算結果を使って「次へ」「前へ」ボタンの有効/無効を切り替えます。
// dotNet学習/dotnetProjectForWindows_7/ViewModels/BookSearchViewModel.cs(抜粋 251-275行目)
public bool HasSearchResults => TotalResults > 0;
public bool HasNextPage => CurrentPage * 20 + SearchResults.Count < TotalResults;
public bool HasPreviousPage => CurrentPage > 0;
public string ResultsSummary => TotalResults > 0
? $"{TotalResults}件の検索結果({SearchResults.Count}件表示中)"
: string.Empty;
public string PageInfo => HasSearchResults
? $"ページ {CurrentPage + 1} / {((TotalResults - 1) / 20) + 1}"
: string.Empty;
最初はHasNextPageの計算式を間違えて、最後のページでも「次へ」が有効になってしまった。CurrentPage * 20を忘れていた。修正してからは完璧に動いた。これだ。
LocalAppDataに置いたノート
お気に入りはFavoriteServiceで%LocalAppData%\BookSearchApp\favorites.jsonへ保存します。フォルダがなければmkdir、JSONはNewtonsoft.JsonでSerialize。AddFavoriteはBook.Key単位で重複を弾き、DateTime.NowをAddedAtに刻む。GetFavorites()は降順で返すだけなので、ViewModelはRefreshFavoritesCommand→LoadFavorites()でUIを最新化できます。Toggle系の発想はFontViewerAppの記事 (midori_new008)で学んだやり方を踏襲しました。
お気に入り保存処理は、書籍情報をJSON形式でローカルに保存します。%LocalAppData%配下にアプリ専用のフォルダを作成し、そこにfavorites.jsonを配置します。
// dotNet学習/dotnetProjectForWindows_7/Services/FavoriteService.cs(抜粋 21-74行目)
public FavoriteService()
{
var appDataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"BookSearchApp"
);
if (!Directory.Exists(appDataPath))
{
Directory.CreateDirectory(appDataPath);
}
_favoritesFilePath = Path.Combine(appDataPath, "favorites.json");
_favorites = LoadFavorites();
}
public bool AddFavorite(Book book, string? notes = null)
{
if (book == null || string.IsNullOrWhiteSpace(book.Key)) return false;
if (_favorites.Any(f => f.Book.Key == book.Key)) return false;
var favorite = new FavoriteBook { Book = book, AddedAt = DateTime.Now, Notes = notes };
_favorites.Add(favorite);
SaveFavorites();
return true;
}
最初は保存先を%AppData%にしていたが、ユーザーごとのデータを分離したかったので%LocalAppData%に変更した。これで複数ユーザーが同じPCを使ってもデータが混ざらない。
WebView2の呼吸を可視化
Open Libraryの詳細ページをWebView2で開くときはWebページを開くコマンド→Webタブへの遷移要求→MainWindowでWebページURLを拾ってNavigateします。ナビゲーション開始とコンテンツ読み込み中で読み込み状態をtrueに、ナビゲーション完了でfalse。ローディングの可視化を怠ると、Externalブラウザと何も変わらないUIになってしまう。これはマズい。
WebView2のナビゲーションイベントを監視し、読み込み状態をViewModelに反映します。これにより、UIでローディングインジケーターを表示できます。
// dotNet学習/dotnetProjectForWindows_7/Views/MainWindow.xaml.cs(抜粋 44-125行目)
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
await WebBrowser.EnsureCoreWebView2Async();
if (WebBrowser.CoreWebView2 != null)
{
WebBrowser.CoreWebView2.NavigationStarting += WebBrowser_NavigationStarting;
WebBrowser.CoreWebView2.ContentLoading += WebBrowser_ContentLoading;
WebBrowser.CoreWebView2.NavigationCompleted += WebBrowser_NavigationCompleted;
}
}
private void WebBrowser_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
_viewModel.IsWebViewLoading = true;
}
private void WebBrowser_NavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
_viewModel.IsWebViewLoading = false;
}
private async void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(BookSearchViewModel.WebPageUrl) && !string.IsNullOrWhiteSpace(_viewModel.WebPageUrl))
{
if (WebBrowser.CoreWebView2 == null)
{
await WebBrowser.EnsureCoreWebView2Async();
// イベント登録を再セット
}
if (WebBrowser.CoreWebView2 != null)
{
_viewModel.IsWebViewLoading = true;
WebBrowser.CoreWebView2.Navigate(_viewModel.WebPageUrl);
}
}
}
最初はナビゲーション完了だけを監視していたが、ページ遷移の開始時にもローディング状態を更新したほうが自然だった。ナビゲーション開始とコンテンツ読み込み中の両方で読み込み状態をtrueにすることで、ユーザーが迷わなくなった。
失敗を隠さないエラー調停
タイトル検索を失敗させるのは簡単。クエリを空にするか、ネットワークを遮断するだけ。問題はその時にSearchResultsが中途半端に残ること。BookSearchViewModelはcatch (Exception ex)でErrorMessage = ex.Message→SearchResults.Clear()→TotalResults = 0→MessageBox.Show()という順番を守ります。短いが覚悟の順番です。これでHasErrorとBooleanToVisibilityConverterが正しく効き、UIは素直に赤枠を表示します。
エラー処理では、例外をキャッチしてエラーメッセージを設定し、検索結果をクリアします。UIではエラーメッセージが設定されている場合にエラー表示を出します。
// dotNet学習/dotnetProjectForWindows_7/ViewModels/BookSearchViewModel.cs(抜粋 360-407行目)
private async Task ExecuteSearchAsync()
{
if (string.IsNullOrWhiteSpace(SearchText)) return;
IsLoading = true;
ErrorMessage = string.Empty;
CurrentPage = 0;
try
{
BookSearchResult result = SelectedSearchType == SearchType.Author
? await _openLibraryApiService.SearchByAuthorAsync(SearchText)
: await _openLibraryApiService.SearchByTitleAsync(SearchText);
SearchResults.Clear();
foreach (var book in result.Books) { SearchResults.Add(book); }
TotalResults = result.NumFound;
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
SearchResults.Clear();
TotalResults = 0;
MessageBox.Show($"検索中にエラーが発生しました。\n\n{ex.Message}", "エラー",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
IsLoading = false;
}
}
最初はErrorMessageだけを設定してSearchResultsをクリアし忘れていた。結果、エラーが発生しても前回の検索結果が残り続けて混乱を招いた。SearchResults.Clear()を必ず呼ぶようにしてから、エラー時の挙動が安定した。
3アーキのビルド結果
build-package.ps1はdotnet publishを3回叩いてBookSearchApp-x64.exe (151.16MB)、BookSearchApp-x86.exe (142.39MB)、BookSearchApp-arm64.exe (166.88MB) を生成します。すべてPublishSingleFile=true+IncludeNativeLibrariesForSelfExtract=true+PublishReadyToRun=true。それでも最初の起動でReadyToRunの恩恵が出なければ意味がないので、x64は平均1秒短縮されるまで粘りました。
ビルドスクリプトは、x64、x86、arm64の3つのアーキテクチャ向けに単一EXEファイルを生成します。各アーキテクチャで最適化されたバイナリを作成し、自己完結型の配布パッケージにします。
# dotNet学習/dotnetProjectForWindows_7/build-package.ps1(抜粋 12-34行目)
dotnet publish -c Release -r win-x64 --self-contained true `
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true `
-p:PublishReadyToRun=true -p:DebugType=None -p:DebugSymbols=false `
-o "$distDir\win-x64"
dotnet publish -c Release -r win-x86 --self-contained true ...
dotnet publish -c Release -r win-arm64 --self-contained true ...
Copy-Item "$distDir\win-x64\BookSearchApp.exe" -Destination "$distDir\BookSearchApp-x64.exe" -Force
Copy-Item "$distDir\win-x86\BookSearchApp.exe" -Destination "$distDir\BookSearchApp-x86.exe" -Force
Copy-Item "$distDir\win-arm64\BookSearchApp.exe" -Destination "$distDir\BookSearchApp-arm64.exe" -Force
最初は、実行時コンパイルの一部をビルド時に済ませるオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2秒から約1秒に短縮されました。ファイルサイズは約5MB増えましたが、起動速度の向上は明らかでした。
全体の導線を握り直す
検索→詳細→お気に入り→Web表示という流れを1枚で把握できるよう、流れ図を作っておきました。RequestNavigateToDetailsTabやRequestNavigateToWebTabでDispatcherを跨いだ時の気持ち悪さをここで可視化しておくと、将来の改修が怖くなくなります。これだ。
アプリ全体のフローを可視化することで、各機能間の連携を理解しやすくします。ViewModelからViewへのイベント通知、タブ間の遷移、データの流れを一覧できるようにしました。
使ってみて
実際に触って流れを確認してみてください。
1. タイトルか著者を入力してSearchCommandを動かす
2. 気になる書籍で「📄 詳細を見る」を押してWebタブへ遷移
3. 星ボタンでfavorites.jsonに記録し、⭐ お気に入りタブで確認する
HasNextPageがfalseになるまでページングしてから、最後にWebタブでOpen Libraryの記事を表示すると体験が一周します。ヨシ。
ポイントは以下の3つ:
- 検索タイプごとにAPIエンドポイントを分岐し、レイテンシーを許容できるUI設計にした
- ページング計算を明示的にすることで、UI状態とデータ状態を常に同期できるようにした
- WebView2のローディング状態を可視化し、ユーザーが迷わないようにした
実際に使ってみると、1.2秒のレイテンシーでもUIのテンポが崩れないことが分かります。同じようなAPI検索アプリを作っている方の参考になれば嬉しいです。
まとめ
- Open Libraryの3種類のAPIを分岐し、平均1.0〜1.2秒のレイテンシーでもUIのテンポを崩さない構成にした
HasNextPageとPageInfoを明示したことで、ページングの挙動とUI状態を常に同期できるようにした
- FavoriteServiceで
%LocalAppData%\BookSearchApp\favorites.jsonを守り、星ボタンの応答を即時化した
- WebView2のナビゲーションイベントで
IsWebViewLoadingを制御し、ユーザーが迷わないようにした
- ビルドスクリプトでx64/x86/arm64の単一EXEを約150MBでそろえ、実行時コンパイルの一部をビルド時に済ませるオプションで初回起動を短縮した
Open Library APIとの格闘を通じて、遅いAPIでも快適なUIを作れることを学びました。同じような検索アプリを作っている方の参考になれば嬉しいです。
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。焦りも喜びも含めて、このアプリが誰かの検索時間を少しでも短くできたら嬉しいです。