はじめに
本日も、Androidアプリ開発の学習を続けましょう!今回は、アプリ開発において非常に重要な「HTTP通信」について学びます。外部サービスと連携するために欠かせない知識を、実践的なコード例とともに解説します。
1. なぜAPI通信が必要なのか?
現代のアプリの多くは、ローカルデータだけではなく、インターネット経由で外部サービスと連携しています。例えば:
- 天気予報アプリ: リアルタイムの気象データを取得
- SNSアプリ: ユーザーの投稿やタイムラインを同期
- ECアプリ: 商品情報や在庫状況を更新
- ニュースアプリ: 最新記事の配信
このような外部データとの連携を可能にするのが、**API(Application Programming Interface)**です。
API通信の基本概念
- リクエスト(Request): アプリがサーバーにデータを要求すること
- レスポンス(Response): サーバーがリクエストに応答してデータを返すこと
- HTTPプロトコル: この通信で使用される標準的なルール
2. HTTP通信の基本構成要素
HTTP通信は以下の4つの要素で構成されます:
1. URL(エンドポイント)
データの取得先となるWebアドレス
例: https://api.github.com/users/octocat
2. HTTPメソッド
実行したい操作の種類を指定
| メソッド | 用途 | 例 |
|---|---|---|
| GET | データの取得 | ユーザー情報の表示 |
| POST | 新規データの作成 | 新規投稿の送信 |
| PUT | データの完全更新 | プロフィール全体の更新 |
| PATCH | データの部分更新 | ステータスのみ変更 |
| DELETE | データの削除 | 投稿の削除 |
3. ヘッダー
リクエストのメタデータ(認証情報、データ形式など)
Content-Type: application/json
Authorization: Bearer your-token-here
4. ボディ
POST/PUT/PATCHで送信するデータ本体
{
"name": "山田太郎",
"email": "yamada@example.com"
}
3. Retrofit2とOkHttpの役割
AndroidでHTTP通信を行う標準的な組み合わせです:
OkHttp
- 役割: HTTP通信の実行エンジン
-
機能:
- 効率的なネットワーク通信
- 自動的な接続プーリング
- 透明的なGZIP圧縮
- レスポンスキャッシュ
- 自動リトライ機能
Retrofit2
- 役割: RESTful APIクライアントライブラリ
-
機能:
- インターフェース定義によるAPI宣言
- 自動的なJSONシリアライゼーション
- Coroutinesとの完全統合
- カスタムコンバーターサポート
比喩: OkHttpが「実際の配達員」だとすると、Retrofit2は「注文を受け付けて配達を手配するカスタマーサービス」のような存在です。
4. 実装:GitHubユーザー情報取得アプリ
ステップ1: 依存関係の追加
build.gradle.kts (Module: app):
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp(ログ出力用)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// ViewModel & LiveData
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
// 画像読み込み(オプション)
implementation("com.github.bumptech.glide:glide:4.16.0")
}
ステップ2: ネットワーク権限の追加
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
ステップ3: データモデルの定義
import com.google.gson.annotations.SerializedName
data class GitHubUser(
val login: String,
val id: Int,
@SerializedName("avatar_url")
val avatarUrl: String,
val name: String?,
val company: String?,
val location: String?,
val email: String?,
@SerializedName("public_repos")
val publicRepos: Int,
val followers: Int,
val following: Int,
@SerializedName("created_at")
val createdAt: String,
val bio: String?
)
// エラーレスポンス用
data class ApiError(
val message: String,
val documentation_url: String?
)
// API結果を表すsealed class
sealed class ApiResult<out T> {
data class Success<out T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
data class Loading(val isLoading: Boolean = true) : ApiResult<Nothing>()
}
ステップ4: APIサービスインターフェースの定義
import retrofit2.Response
import retrofit2.http.*
interface GitHubService {
@GET("users/{username}")
suspend fun getUser(@Path("username") username: String): Response<GitHubUser>
@GET("users/{username}/repos")
suspend fun getUserRepositories(
@Path("username") username: String,
@Query("sort") sort: String = "updated",
@Query("per_page") perPage: Int = 10
): Response<List<GitHubRepository>>
@GET("search/users")
suspend fun searchUsers(
@Query("q") query: String,
@Query("page") page: Int = 1,
@Query("per_page") perPage: Int = 30
): Response<GitHubSearchResult>
}
data class GitHubRepository(
val name: String,
val description: String?,
val language: String?,
@SerializedName("stargazers_count")
val stars: Int,
@SerializedName("html_url")
val htmlUrl: String
)
data class GitHubSearchResult(
@SerializedName("total_count")
val totalCount: Int,
val items: List<GitHubUser>
)
ステップ5: Retrofit設定とネットワーククライアント
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object NetworkClient {
private const val BASE_URL = "https://api.github.com/"
private val httpClient: OkHttpClient by lazy {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Accept", "application/vnd.github.v3+json")
.addHeader("User-Agent", "Android-GitHub-Client")
.build()
chain.proceed(request)
}
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val gitHubService: GitHubService by lazy {
retrofit.create(GitHubService::class.java)
}
}
ステップ6: Repositoryパターンの実装
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
class GitHubRepository {
private val apiService = NetworkClient.gitHubService
suspend fun getUser(username: String): ApiResult<GitHubUser> = withContext(Dispatchers.IO) {
try {
val response = apiService.getUser(username)
handleResponse(response)
} catch (e: Exception) {
ApiResult.Error("ネットワークエラー: ${e.localizedMessage}")
}
}
suspend fun searchUsers(query: String): ApiResult<List<GitHubUser>> = withContext(Dispatchers.IO) {
try {
val response = apiService.searchUsers(query)
when {
response.isSuccessful -> {
response.body()?.let { searchResult ->
ApiResult.Success(searchResult.items)
} ?: ApiResult.Error("レスポンスが空です")
}
else -> ApiResult.Error("検索に失敗しました", response.code())
}
} catch (e: Exception) {
ApiResult.Error("ネットワークエラー: ${e.localizedMessage}")
}
}
private fun <T> handleResponse(response: Response<T>): ApiResult<T> {
return when {
response.isSuccessful -> {
response.body()?.let { body ->
ApiResult.Success(body)
} ?: ApiResult.Error("レスポンスが空です")
}
response.code() == 404 -> {
ApiResult.Error("ユーザーが見つかりません", 404)
}
response.code() == 403 -> {
ApiResult.Error("APIリクエスト制限に達しました", 403)
}
else -> {
ApiResult.Error("エラー: ${response.message()}", response.code())
}
}
}
}
ステップ7: ViewModelでの活用
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class GitHubUserViewModel(private val repository: GitHubRepository) : ViewModel() {
private val _uiState = MutableStateFlow<ApiResult<GitHubUser>>(ApiResult.Loading(false))
val uiState: StateFlow<ApiResult<GitHubUser>> = _uiState.asStateFlow()
fun fetchUser(username: String) {
viewModelScope.launch {
_uiState.value = ApiResult.Loading(true)
_uiState.value = repository.getUser(username)
}
}
fun clearState() {
_uiState.value = ApiResult.Loading(false)
}
}
class GitHubUserViewModelFactory(
private val repository: GitHubRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(GitHubUserViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return GitHubUserViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
ステップ8: Activityでの使用例
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private val repository by lazy { GitHubRepository() }
private val viewModel: GitHubUserViewModel by viewModels {
GitHubUserViewModelFactory(repository)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupUI()
observeViewModel()
// 初期データ取得
viewModel.fetchUser("octocat")
}
private fun setupUI() {
findViewById<Button>(R.id.button_search).setOnClickListener {
val username = findViewById<EditText>(R.id.edit_username).text.toString()
if (username.isNotBlank()) {
viewModel.fetchUser(username)
}
}
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.uiState.collect { result ->
when (result) {
is ApiResult.Loading -> {
showLoading(result.isLoading)
}
is ApiResult.Success -> {
displayUser(result.data)
}
is ApiResult.Error -> {
showError(result.message)
}
}
}
}
}
private fun displayUser(user: GitHubUser) {
findViewById<TextView>(R.id.text_username).text = user.login
findViewById<TextView>(R.id.text_name).text = user.name ?: "名前未設定"
findViewById<TextView>(R.id.text_followers).text = "フォロワー: ${user.followers}"
findViewById<TextView>(R.id.text_following).text = "フォロー中: ${user.following}"
findViewById<TextView>(R.id.text_repos).text = "リポジトリ: ${user.publicRepos}"
// アバター画像の読み込み(Glide使用)
Glide.with(this)
.load(user.avatarUrl)
.into(findViewById<ImageView>(R.id.image_avatar))
}
private fun showLoading(isLoading: Boolean) {
findViewById<ProgressBar>(R.id.progress_bar).visibility =
if (isLoading) View.VISIBLE else View.GONE
}
private fun showError(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
}
5. セキュリティとベストプラクティス
APIキーの安全な管理
// local.properties(Gitで管理しない)
github_api_token=your_personal_access_token_here
// build.gradle.kts
android {
buildTypes {
debug {
buildConfigField("String", "GITHUB_TOKEN", "\"${project.findProperty("github_api_token")}\"")
}
}
}
// 使用例
private fun addAuthInterceptor(): Interceptor {
return Interceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Authorization", "token ${BuildConfig.GITHUB_TOKEN}")
.build()
chain.proceed(request)
}
}
エラーハンドリングのベストプラクティス
sealed class NetworkError : Exception() {
object NetworkUnavailable : NetworkError()
object Timeout : NetworkError()
data class HttpError(val code: Int, val message: String) : NetworkError()
data class UnknownError(override val message: String) : NetworkError()
}
suspend fun <T> safeApiCall(apiCall: suspend () -> Response<T>): ApiResult<T> {
return try {
val response = apiCall()
if (response.isSuccessful) {
response.body()?.let { body ->
ApiResult.Success(body)
} ?: ApiResult.Error("レスポンスが空です")
} else {
ApiResult.Error("HTTP ${response.code()}: ${response.message()}", response.code())
}
} catch (e: IOException) {
ApiResult.Error("ネットワーク接続を確認してください")
} catch (e: HttpException) {
ApiResult.Error("サーバーエラー: ${e.message()}", e.code())
} catch (e: Exception) {
ApiResult.Error("予期しないエラー: ${e.localizedMessage}")
}
}
6. テストの実装
// テスト用のMockWebServer
@Test
fun `ユーザー取得APIが正常に動作する`() = runTest {
val mockWebServer = MockWebServer()
val mockResponse = MockResponse()
.setResponseCode(200)
.setBody("""
{
"login": "testuser",
"id": 123,
"avatar_url": "https://example.com/avatar.jpg",
"name": "Test User",
"followers": 10,
"following": 5,
"public_repos": 3
}
""".trimIndent())
mockWebServer.enqueue(mockResponse)
mockWebServer.start()
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(GitHubService::class.java)
val response = service.getUser("testuser")
assertTrue(response.isSuccessful)
assertEquals("testuser", response.body()?.login)
assertEquals(123, response.body()?.id)
mockWebServer.shutdown()
}
まとめ
今回学んだ重要なポイント:
技術的な学習内容
- Retrofit2 + OkHttpによる効率的なHTTP通信実装
- Coroutinesを活用した非同期処理
- Repository Patternによるデータ層の抽象化
- sealed classを使った型安全なエラーハンドリング
実践的なベストプラクティス
- 適切なタイムアウト設定とリトライ処理
- ログ出力の環境別切り替え
- APIキーの安全な管理
- ユーザビリティを考慮したローディング表示
次回予告
次回は、今回学んだHTTP通信を応用して、JSON解析の詳細と複雑なAPIレスポンスの処理方法を学習します。また、オフライン対応やデータキャッシュの実装についても詳しく解説予定です!
これでAPI連携の基礎をしっかりマスターし、実際のプロダクション環境でも使える実装スキルを身につけることができました。