はじめに
業務でKotlin・API・RecycleView・Adapterを扱っているが、業務中では改修作業が多いため、ゼロから設計~実装まで通しで経験するために、学習用として「書籍管理アプリ」を作成した。無料のGoogle Books APIと連携し、検索した本をローカルDB(Room)に保存して一覧管理するシンプルなアプリである。
本記事はシリーズ1本目(アプリの基盤部分の構築)で、まずはアプリを動かすところまでを扱う。最後に触れる通り、実はこの構成には一つ注意点があり、それは次回の記事で掘り下げる。
記事内のコードは要点を抜粋したものです。
プロジェクト全体のソースコードは以下のGitHubで公開しています
(本記事の内容に対応するタグ: v1-direct-api)。
開発
1. 要件定義
やること
- 本の管理表
- 追加処理
- 削除処理
- API連携
やらないこと
- 編集機能
- 絞り込み機能
- 手入力での追加
2. 全体アーキテクチャ
レイヤー構成を採用した理由
- 小~中規模のアプリで、学習しやすい
- 「データの形(Entity)」「操作方法(Dao)」「入口(Database)」のように役割ごとに分離すると、変更の影響範囲を限定できる
- API層とDB層を分けることで、どちらかの実装を変更しても他方に影響しない設計にできる
3. ディレクトリ構成
com.example.myapplication/
├── data/
│ ├── local/ ← Room関連(端末内DB)
│ │ ├── BookEntity.kt
│ │ ├── BookDao.kt
│ │ └── AppDatabase.kt
│ │
│ ├── remote/ ← API通信関連
│ │ ├── BookApiModel.kt (BookResponse, BookItem, VolumeInfo, ImageLinks)
│ │ ├── ApiService.kt
│ │ └── RetrofitInstance.kt
│ │
│ ├── mapper/ ← API⇔DB間の変換処理
│ │ └── BookMapper.kt
│ │
│ └── repository/ ← Room・APIをまとめる窓口
│ └── BookRepository.kt
│
├── ui/
│ ├── list/ ← 管理表(一覧)画面
│ │ ├── MainActivity.kt
│ │ └── BookListAdapter.kt
│ │
│ └── search/ ← 検索・追加画面
│ ├── SearchActivity.kt
│ └── SearchResultAdapter.kt
│
└── AndroidManifest.xml
res/layout/
├── activity_main.xml ← ui/list (管理表画面)
├── header.xml ← ui/list (管理表の列見出し、includeで読み込む)
├── item_book_row.xml ← ui/list (管理表の行)
├── activity_search.xml ← ui/search (検索画面)
└── item_search_result.xml ← ui/search (検索結果の行)
res/layoutはサブフォルダを作れないため、activity_xxx.xml(画面全体)、item_xxx.xml(リストの行)として整理した。ヘッダーのような「画面の一部品」はheader.xmlのように用途がわかる単独の名前にし、<include>で読み込む形にした。
4. データ層の実装
4-1. Room(ローカルDB)
import androidx.room3.Entity
import androidx.room3.PrimaryKey
@Entity(tableName = "books")
data class BookEntity (
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val title: String,
val author: String,
val publisher: String,
val publishedDate: String,
val memo: String = ""
)
@Dao
interface BookDao {
@Query("SELECT * FROM books ORDER BY id DESC")
fun getAllBooks(): Flow<List<BookEntity>>
@Insert
suspend fun insert(book: BookEntity)
@Update
suspend fun update(book: BookEntity)
@Delete
suspend fun delete(book: BookEntity)
}
@Database(entities = [BookEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun bookDao(): BookDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"book_database"
).build().also { INSTANCE = it }
}
}
}
ポイント
-
BookDaoはインターフェースのみ定義し、実際のSQL処理はRoomがビルド時に自動生成する -
AppDatabaseはシングルトンパターンで実装し、アプリ内でDBインスタンスが複数作られることによるデータ不整合を防止 - 一覧取得は
LiveDataではなくFlowに統一(Googleの現在の推奨に合わせた) - Room本体は
androidx.room3パッケージ(Room 2.7以降のKotlin Multiplatform対応版)を使用している
4-2. API通信(Retrofit)
data class BookResponse(
val items: List<BookItem>?
)
data class BookItem(
val id: String,
val volumeInfo: VolumeInfo
)
data class VolumeInfo(
val title: String,
val authors: List<String>?,
val publisher: String?,
val publishedDate: String?,
val imageLinks: ImageLinks?
)
// ImageLinksクラスの定義は省略
interface ApiService {
@GET("volumes")
suspend fun searchBooks(
@Query("q") query: String,
@Query("key") apiKey: String
): BookResponse
}
object RetrofitInstance {
private const val BASE_URL = "https://www.googleapis.com/books/v1/"
val api: ApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
ポイント
-
BookApiModelの各プロパティ名は、Google Books APIが返すJSONのキー名と完全に一致させる必要がある(Gsonが名前を頼りに変換するため) -
ApiServiceはapiKeyをパラメータとして受け取り、@Query("key")でURLに付与する形にした。APIキー自体はBuildConfig経由で渡す - Retrofitのインスタンスはコストがかかるため
object(シングルトン) +by lazy(遅延初期化)で1つだけ生成
4-3. API⇔DB変換(Mapper)
fun BookItem.toBookEntity(): BookEntity {
return BookEntity(
title = volumeInfo.title,
author = volumeInfo.authors?.joinToString(", ") ?: "著者不明",
publisher = volumeInfo.publisher ?: "",
publishedDate = volumeInfo.publishedDate ?: ""
)
}
なぜ独立ファイルにしたのか
BookEntity(Room)にもBookItem(API)にも変換処理を書かず、あえて別ファイルにしている。DB層がAPI層を知っている(API層がDB層を知っている)状態になると依存関係が生じるため、変換専用のMapperを用意し、どちらかの型が変わっても修正箇所をこのファイルに閉じ込めるようにした。
4-4. Repository
class BookRepository (
private val bookDao: BookDao,
private val apiService: ApiService
) {
fun getAllBooks(): Flow<List<BookEntity>> = bookDao.getAllBooks()
suspend fun searchBooks(query: String, apiKey: String): List<BookItem> {
return apiService.searchBooks(
query, apiKey
).items ?: emptyList()
}
suspend fun deleteBook(book: BookEntity) {
bookDao.delete(book)
}
suspend fun addBook(bookItem: BookItem) {
bookDao.insert(bookItem.toBookEntity())
}
}
ui層がRoomとAPIそれぞれを直接呼ばず、このRepositoryだけを見れば済むようにすることで、将来保存先やAPIを変更しても画面側のコードに影響が出ないようにしている。searchBooksはApiServiceと同じくapiKeyを引数で受け取り、そのまま渡す薄い窓口になっている。
5. UI層の実装
5-1. 管理表画面(MainActivity)
class BookListAdapter (
private var books: List<BookEntity>,
private val onDeleteClick: (BookEntity) -> Unit
) : RecyclerView.Adapter<BookListAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.textTitle)
val author: TextView = view.findViewById(R.id.textAuthor)
val publisher: TextView = view.findViewById(R.id.textPublisher)
val publishedDate: TextView = view.findViewById(R.id.textPublishedDate)
val deleteButton: Button = view.findViewById(R.id.buttonDelete)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_book_row, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val book = books[position]
holder.title.text = book.title
holder.author.text = book.author
holder.publisher.text = book.publisher
holder.publishedDate.text = book.publishedDate
holder.deleteButton.setOnClickListener {
onDeleteClick(book)
}
}
override fun getItemCount(): Int = books.size
fun updateList(newBooks: List<BookEntity>) {
books = newBooks
notifyDataSetChanged()
}
}
class MainActivity : AppCompatActivity() {
private lateinit var adapter: BookListAdapter
private lateinit var repository: BookRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val db = AppDatabase.getInstance(applicationContext)
repository = BookRepository(db.bookDao(), RetrofitInstance.api)
adapter = BookListAdapter(emptyList()) { book ->
lifecycleScope.launch {
repository.deleteBook(book)
}
}
findViewById<RecyclerView>(R.id.recyclerBookList).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
}
findViewById<Button>(R.id.buttonGoToSearch).setOnClickListener {
startActivity(Intent(this, SearchActivity::class.java))
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.getAllBooks().collect { books ->
adapter.updateList(books)
}
}
}
}
}
ポイント
- Adapterに「削除処理そのもの」を持たせず、コンストラクタで
(BookEntity) -> Unitの関数を受け取る設計にすることで、「表示」と「削除の実処理」の役割を分離した -
repository.getAllBooks().collect { ... }でFlowを監視しているため、追加・削除どちらの操作でもRoom側の変化が自動で画面に反映される(明示的な再描画コードが不要) -
repeatOnLifecycle(Lifecycle.State.STARTED)で、画面が表示されていないときは監視を止める安全な書き方にしている
5-1-1. ヘッダー(header.xml)の追加
ヘッダーをheader.xmlとして別ファイルに切り出し、activity_main.xmlに<include>で読み込む構成にした。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<TableRow
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/headerTitle"
android:layout_width="0dp"
android:layout_weight="4"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
android:text="タイトル" />
<TextView
android:id="@+id/headerAuthor"
android:layout_width="0dp"
android:layout_weight="2"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
android:text="著者" />
<TextView
android:id="@+id/headerPublisher"
android:layout_width="0dp"
android:layout_weight="2"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
android:text="出版社" />
<TextView
android:id="@+id/headerPublishedDate"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
android:text="出版日" />
<TextView
android:id="@+id/headerButton"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end" />
</TableRow>
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/buttonGoToSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="10dp"
android:text="本を検索して追加" />
<include
layout="@layout/header" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerBookList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
ポイント
-
<include layout="@layout/header" />で、レイアウトの一部品を別ファイルとして再利用できる。activity_main.xml自体を肥大化させずに済む -
header.xml側の各TextViewのlayout_weightは、item_book_row.xml側の各列のlayout_weightと同じ比率に揃えている。これにより、見出し文字と実際のデータの列位置がずれずに揃う -
headerButtonは削除ボタン列の見出し(空欄)に対応する要素で、item_book_row.xmlのbuttonDeleteと横位置を合わせるために用意している
5-2. 検索画面(SearchActivity)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal">
<EditText
android:id="@+id/editSearchQuery"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:hint="タイトルや著者名で検索" />
<Button
android:id="@+id/buttonSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="検索" />
<Button
android:id="@+id/buttonBack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="メイン画面" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerSearchResults"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
class SearchResultAdapter(
private var books: List<BookItem>,
private val onAddClick: (BookItem) -> Unit
) : RecyclerView.Adapter<SearchResultAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.textResultTitle)
val author: TextView = view.findViewById(R.id.textResultAuthor)
val addButton: Button = view.findViewById(R.id.buttonAdd)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_search_result, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val book = books[position]
holder.title.text = book.volumeInfo.title
holder.author.text = book.volumeInfo.authors?.joinToString(", ") ?: "著者不明"
holder.addButton.setOnClickListener {
onAddClick(book)
}
}
override fun getItemCount(): Int = books.size
fun updateList(newBooks: List<BookItem>) {
books = newBooks
notifyDataSetChanged()
}
}
import com.example.myapplication.BuildConfig.GOOGLE_BOOKS_API_KEY
class SearchActivity : AppCompatActivity() {
private lateinit var repository: BookRepository
private lateinit var adapter: SearchResultAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
val db = AppDatabase.getInstance(applicationContext)
repository = BookRepository(db.bookDao(), RetrofitInstance.api)
val editQuery = findViewById<EditText>(R.id.editSearchQuery)
val buttonSearch = findViewById<Button>(R.id.buttonSearch)
val progressBar = findViewById<ProgressBar>(R.id.progressBar)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerSearchResults)
adapter = SearchResultAdapter(emptyList()) { bookItem ->
lifecycleScope.launch {
repository.addBook(bookItem)
Toast.makeText(this@SearchActivity, "「${bookItem.volumeInfo.title}」を追加しました", Toast.LENGTH_SHORT).show()
}
}
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
buttonSearch.setOnClickListener {
val query = editQuery.text.toString()
if (query.isBlank()) {
Toast.makeText(this, "検索キーワードを入力してください", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
progressBar.visibility = View.VISIBLE
lifecycleScope.launch {
try {
val results = repository.searchBooks(query, GOOGLE_BOOKS_API_KEY)
adapter.updateList(results)
} catch (e: Exception) {
Toast.makeText(this@SearchActivity, "検索に失敗しました", Toast.LENGTH_SHORT).show()
} finally {
progressBar.visibility = View.GONE
}
}
}
findViewById<Button>(R.id.buttonBack).setOnClickListener {
finish()
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/textResultTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textResultAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp" />
</LinearLayout>
<Button
android:id="@+id/buttonAdd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="追加" />
</LinearLayout>
ポイント
-
import com.example.myapplication.BuildConfig.GOOGLE_BOOKS_API_KEYで、ビルド設定から読み込んだAPIキーを直接インポートし、repository.searchBooks(query, GOOGLE_BOOKS_API_KEY)のように渡している -
buttonBack(戻るボタン)の処理としてfinish()を呼び、検索画面を閉じて管理表画面に戻れるようにしている
6. AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
<activity
android:name=".ui.list.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.search.SearchActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
</application>
-
INTERNET権限が無いとAPI通信ができない -
<intent-filter>(MAIN/LAUNCHER)は「アプリ起動時に最初に開く画面」を示す -
android:exportedは「他アプリから起動できるか」の指定。Android 12以降、<intent-filter>を持つActivityでは指定が必須。アプリの入口であるMainActivityはtrue、内部からしか呼ばれないSearchActivityはfalseにした
7. つまずいた点
7-1. AndroidManifestのLAUNCHER設定ミス
SearchActivityに<intent-filter>(MAIN/LAUNCHER)を付けてしまい、アプリ起動時に検索画面が最初に表示される不具合が発生した。MainActivity側に移動して解決。「起動時に開く画面」と「メイン機能の画面」を混同しないよう、設計時点で入口を1つに決めておくことが大事だと感じた。
7-2. RecyclerViewとAdapterの紐付け忘れ
削除機能追加時のリファクタリングで、以下の「RecyclerViewにAdapterとLayoutManagerをセットする処理」を誤って削除してしまい、リストが表示されなくなった。
findViewById<RecyclerView>(R.id.recyclerBookList).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
}
8. 動作確認
実際に動かした様子を紹介する。
管理表画面
タイトル・著者・出版社・出版日の4列で管理表が表示される。
検索画面
「kotlin」で検索した結果、Google Books APIから返ってきた本が一覧表示される。
各行の「追加」ボタンをタップすると、その本がRoomに保存され、管理表画面に反映される。
管理表画面(追加後)
検索画面で追加ボタンを押した結果、先ほどの管理表画面の一番上に追加した本の情報が追加されている。
9. APIキーの注意点
ここまでで、検索・追加・削除を行うが出来るアプリが完成した。しかし、この構成には一つ触れておくべき点がある。
このアプリでは、Google Books APIキーをlocal.propertiesに書き、build.gradle.ktsのbuildConfigField経由でBuildConfig.GOOGLE_BOOKS_API_KEYとして読み込んでいる。local.propertiesはGitの管理対象外(.gitignoreに含まれる)なので、一見するとキーが安全に守られているように思える。
ところが、実際にビルドされたAPKを、無料の逆コンパイルツールjadx-guiで開いてみると、次のように見える。
※APIキーは一時的にSampleAPIKey としている。実際に動かすためにはGoogle Books APIから取得したAPIキーをlocal.propertiesに書く必要がある。
GOOGLE_BOOKS_API_KEYが、文字列としてそのまま埋め込まれているのが分かる(上記はサンプル値に置き換えているが、実際のキーも同じ形でそのまま見える)。
つまり、「Gitに含めていないから安全」というのは誤解で、local.propertiesが守っているのはソースコード管理上の秘匿性だけであり、ビルド後のバイナリの中身までは守ってくれない。APKファイルさえ入手できれば、Android開発の専門知識がなくても、jadx-guiのようなGUIツールで数分あれば読み取れてしまう。
本記事では、まずアプリが動作するところまでを目的とし、この対策については次回扱う。次回は、AWS LambdaとAPI Gatewayを間に挟むことで、この状態をどう変えられるか(そしてどこまでは変えられないか)を実測ベースで検証する。
10. 今後の展望
次回は、このアプリのGoogle Books API連携部分を、AWS LambdaとAPI Gateway経由に置き換える。「APIキーをどこに、どう隠すか」を実測データとともに扱う予定。
まとめ
- Room(ローカルDB) / Retrofit(API通信) / RecyclerView.Adapter(表示)という3つの技術要素を、Repositoryパターンで疎結合につなぐ構成で実装した
- レイヤー構成のディレクトリ分割により、各技術要素の役割を明確化できた
- 動作するところまでは完成したが、APIキーの持ち方には注意点が残っている。この点は次回の記事で扱う。



