第 5 章:建立你的第一個 Camera2 專案
歡迎來到 Camera2 教學系列的實作部分。在前面的章節中,你學習了手機相機硬體以及 Camera2 API 的理論基礎。現在該捲起袖子寫真正的程式碼了。到本章結束時,你將擁有一個可運作的 Android 專案,能夠成功初始化 Camera2 API 並存取 CameraManager 服務——這是你在列舉相機、開啟裝置或顯示預覽之前必須完成的關鍵第一步。
如果你想看到本系列中將建置的所有內容的正式範例,可以在 GitHub 和 Google Play 上查看 Android Camera Parameters 應用程式。它展示了進階 Camera2 用法,包括完整的 CameraCharacteristics 列舉、手動擷取控制以及多相機支援。
為什麼要從專案建置開始?
在你撰寫任何一行 Camera2 程式碼之前,應用程式必須正確設定。Camera2 是一個低階層、對效能敏感的 API,在搭建階段偷工減料會導致莫名的崩潰、ANR(Application Not Responding,應用程式無回應)或永遠無法到達的影格。一個正確的 Camera2 專案建置有三大支柱:
- 權限 — Android 框架在安裝時(資訊清單)和執行時(使用者同意)兩個層面限制相機存取。
- 執行緒架構 — Camera2 回呼絕不能阻塞主執行緒;我們需要一個專用的背景執行緒。
- View 設定 — 如果你打算使用 TextureView 進行預覽(推薦做法),必須啟用硬體加速。
讓我們系統地逐一處理。
第 1 步:建立新的 Android Studio 專案
啟動 Android Studio 並建立一個新專案。對於本教學系列,我們推薦:
- 範本:Empty Activity(最簡單的起點)
- 語言:Kotlin(Android 開發的現代標準;本系列所有範例都使用 Kotlin)
- 最低 SDK:API 21(Lollipop)——這是原生支援 Camera2 的第一個 SDK 層級。如果你需要透過 OTG 支援外部 USB 相機,請目標 API 23 或更高。如果你需要為照片儲存提供 Scoped Storage 支援(第 9 章),則需要 API 29+,但我們會在那裡處理回溯相容性。
- 建置設定語言:Kotlin DSL 或 Groovy——兩者都可以;我們的範例與建置系統無關。
專案生成後,開啟模組層級的 build.gradle(或 build.gradle.kts)檔案。預設的 Empty Activity 範本包含了你所需的大部分相依項目,但請確保至少有以下內容:
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// Camera2 是 Android 框架的一部分,因此基礎 API 不需要任何額外相依項目。
// androidx.camera.camera2 僅用於 CameraX 互通。
}
你不需要加入任何外部 Camera2 相依項目。整個 android.hardware.camera2 套件都是 Android 框架的一部分。Jetpack CameraX 程式庫是建置在 Camera2 之上的獨立高階抽象;本教學中我們直接使用原生 Camera2 API。
第 2 步:在 AndroidManifest.xml 中宣告權限
每個相機應用程式都必須在 AndroidManifest.xml 中宣告 CAMERA 權限。這會告訴 Google Play 商店你的應用程式使用相機硬體,並啟用 Android 6.0(API 23)及以上的執行時權限對話框。
開啟 app/src/main/AndroidManifest.xml,將以下元素加入為根 <manifest> 標籤的子元素(不要放在 <application> 內部):
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- ✅ 相機權限宣告 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 可選的功能宣告(用於 Google Play 過濾) -->
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<application
android:allowBackup="true"
...>
<activity
android:name=".MainActivity"
android:exported="true"
android:hardwareAccelerated="true">
...
</activity>
</application>
</manifest>
讓我們拆解其中重要的部分:
<uses-permission android:name="android.permission.CAMERA" />
這是核心權限。沒有它,任何對相機服務的呼叫都會拋出 SecurityException。在 API 22 及以下,使用者在安裝時授予此權限;在 API 23+,你還必須在執行時請求(稍後講解)。
<uses-feature android:name="android.hardware.camera" android:required="true" />
此宣告告訴 Google Play 將你的應用程式過濾到至少有一個相機的裝置上。如果你的應用程式可以在沒有相機的情況下運作(例如,一個可選拍攝功能的相簿應用程式),請將 android:required="false"。如果你完全不宣告此項,Google Play 會假設相機不是必需的,這可能會把你的應用程式安裝到沒有相機的裝置上。
<activity> 上的 android:hardwareAccelerated="true"
這對 TextureView 預覽渲染至關重要。TextureView 使用 GPU 合成管線來高效顯示相機影格。如果在 Activity 或 Application 層級未啟用硬體加速,TextureView 會靜默無法渲染或顯示黑屏。現代 Android 中整個應用程式的預設值是 true,但在任何承載 TextureView 的 Activity 上明確宣告是一個良好的實踐。
第 3 步:執行時權限請求
在 Android 6.0(Marshmallow,API 23)及更高版本中,在資訊清單中宣告權限只是事情的一半。你還必須使用 Activity Compat 程式庫在執行時明確向使用者請求權限。標準模式是:
- 用
ContextCompat.checkSelfPermission檢查權限是否已授予。 - 如果已授予,繼續相機初始化。
- 如果未授予,呼叫
ActivityCompat.requestPermissions顯示系統對話框。 - 在
onRequestPermissionsResult中處理結果。
下面是 MainActivity.kt 中完整的權限流程:
package com.example.camera2tutorial
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (allPermissionsGranted()) {
initializeCamera()
} else {
ActivityCompat.requestPermissions(
this,
REQUIRED_PERMISSIONS,
REQUEST_CODE_PERMISSIONS
)
}
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
initializeCamera()
} else {
Toast.makeText(
this,
"需要相機權限才能使用此應用程式。",
Toast.LENGTH_LONG
).show()
finish()
}
}
}
private fun initializeCamera() {
// TODO: 我們將在下面的章節中實作此方法。
// CameraManager 的設定將在這裡進行。
// 目前,僅記錄成功。
android.util.Log.d(TAG, "Permissions granted. Ready to initialize camera.")
}
companion object {
private const val TAG = "Camera2Tutorial"
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
}
}
為什麼 allPermissionsGranted() 使用陣列模式
雖然我們目前只需要 CAMERA 權限,但定義一個 REQUIRED_PERMISSIONS 陣列使得日後加入額外權限變得輕而易舉(例如用於傳統照片儲存的 WRITE_EXTERNAL_STORAGE,或用於影片的 RECORD_AUDIO)。all { ... } 函式會檢查陣列中的每一個權限是否都已授予,然後才繼續。
第 4 步:背景執行緒(HandlerThread)
這是初學者 Camera2 程式碼中最常被遺漏的細節,它會導致隨機、難以重現的 bug。讓我們先理解為什麼 Camera2 需要背景執行緒,然後再正確實作它。
為什麼 Camera2 絕不能在主執行緒上執行
Android 主(UI)執行緒負責:
- 以 60-120 FPS 繪製 UI
- 處理使用者觸控事件
- 分發生命週期回呼
- 預設執行所有 Activity/Fragment 程式碼
Camera2 API 同步地傳遞幾個關鍵回呼:
CameraDevice.StateCallback—— 當相機開啟、斷開或出錯時CameraCaptureSession.StateCallback—— 當擷取工作階段設定完成時CameraCaptureSession.CaptureCallback—— 每一影格(高達每秒 60+ 次!)
如果這些回呼在主執行緒上執行,會發生兩種災難性的事情:
- 卡頓和掉影格:如果處理一個回呼甚至需要 10ms,就會跳過一個 60FPS 的影格,使用者會看到卡頓。
- 死結和 ANR:某些 Camera2 方法(如
close())是同步的並等待回呼。如果回呼必須在呼叫close()的同一執行緒上執行,就會產生死結。
解決方案是一個專用的背景執行緒,擁有自己的 Looper,透過 HandlerThread 實作。
正確實作 HandlerThread
背景執行緒的生命週期必須與相機操作的生命週期匹配。當 Activity 啟動/恢復時我們啟動執行緒,當 Activity 停止/暫停時我們退出執行緒。
package com.example.camera2tutorial
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraManager
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
// --- 背景執行緒元件 ---
private lateinit var backgroundThread: HandlerThread
private lateinit var backgroundHandler: Handler
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (allPermissionsGranted()) {
initializeCamera()
} else {
ActivityCompat.requestPermissions(
this,
REQUIRED_PERMISSIONS,
REQUEST_CODE_PERMISSIONS
)
}
}
override fun onResume() {
super.onResume()
startBackgroundThread()
// 如果在應用程式處於背景時授予了權限,則重新初始化
if (allPermissionsGranted() && this::cameraManager.isInitialized) {
// (cameraManager 在下面宣告)
}
}
override fun onPause() {
stopBackgroundThread()
super.onPause()
}
private fun startBackgroundThread() {
backgroundThread = HandlerThread("Camera2Background").apply {
start()
}
backgroundHandler = Handler(backgroundThread.looper)
Log.d(TAG, "Background thread started: ${backgroundThread.name}")
}
private fun stopBackgroundThread() {
backgroundThread.quitSafely()
try {
backgroundThread.join(1000) // 最多等待 1 秒進行清理
Log.d(TAG, "Background thread stopped cleanly")
} catch (e: InterruptedException) {
Log.e(TAG, "Interrupted while joining background thread", e)
}
}
// --- CameraManager 初始化 ---
private lateinit var cameraManager: CameraManager
private fun initializeCamera() {
cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraIdList = cameraManager.cameraIdList
Log.d(TAG, "Successfully accessed CameraManager. Found ${cameraIdList.size} camera(s).")
cameraIdList.forEachIndexed { index, cameraId ->
Log.d(TAG, "Camera $index: ID = $cameraId")
}
Toast.makeText(
this,
"CameraManager initialized! Found ${cameraIdList.size} camera(s).",
Toast.LENGTH_LONG
).show()
}
// --- 權限處理(同上) ---
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
initializeCamera()
} else {
Toast.makeText(
this,
"需要相機權限才能使用此應用程式。",
Toast.LENGTH_LONG
).show()
finish()
}
}
}
companion object {
private const val TAG = "Camera2Tutorial"
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
}
}
關鍵執行緒模式解析
-
onResume()中的startBackgroundThread():每次 Activity 進入前景時,我們都會建立一個全新的HandlerThread,啟動它,並建立一個綁定到該執行緒Looper的Handler。此 Handler 將傳遞給所有接受 Handler 的 Camera2 回呼方法(openCamera、createCaptureSession等)。 -
onPause()中的stopBackgroundThread():在 Activity 進入背景之前,我們在執行緒上呼叫quitSafely()。這告訴 Looper 在當前訊息處理完後停止處理新訊息(與quit()不同,後者會丟棄待處理的訊息)。然後我們呼叫join(1000)最多阻塞主執行緒一秒鐘,等待背景執行緒完成清理。這可以防止資源洩漏。 -
為什麼用
HandlerThread而不是CoroutineDispatcher? Camera2 比 Kotlin Coroutines 早出現了好幾年,其回呼系統從根本上來說是基於 Handler/Looper 的。雖然你可以在高階程式碼中使用Dispatchers.Default.asExecutor()或將回呼包裝在suspendCoroutine中,但底層的 Camera2 API 仍然需要一個 Looper 執行緒來接收回呼。直接使用HandlerThread是官方 Android 範例中規範的、有文件記錄的方法。
第 5 步:完整的初始化流程(綜合)
現在讓我們看看應用程式啟動時必須發生的事件完整序列。順序至關重要:權限 → 執行緒 → CameraManager。如果你顛倒任何步驟,程式碼會崩潰或行為不一致。
上面的流程圖說明了為什麼每個步驟都存在:
- 權限閘道:整個相機子系統都受到保護;在使用者授予同意之前我們無法繼續。
- 執行緒先於 CameraManager:雖然
getSystemService()本身是執行緒安全的,但我們希望在執行任何回呼驅動的 Camera2 操作(下一章開始)之前,背景執行緒已經執行。 - CameraManager → cameraIdList:呼叫
cameraIdList是驗證 CameraManager 是否運作的最廉價方式。如果此呼叫成功且不拋出例外,那麼你的資訊清單宣告、執行時權限和服務繫結都是正確的。
綜合起來:執行並驗證
至此,你已經有了一個完整、可運作的 Camera2 專案,它:
- 建立一個具有正確 SDK 目標的 Android 專案。
- 在資訊清單中宣告 CAMERA 權限。
- 在執行時請求權限,處理接受和拒絕兩種路徑。
- 在
onResume中啟動專用的 HandlerThread,並在onPause中乾淨地停止它。 - 檢索
CAMERA_SERVICE系統服務並將其轉換為CameraManager。 - 呼叫
cameraIdList並記錄相機數量及其 ID。
執行時你應該看到的內容
- 首次啟動時,Android 顯示權限對話框:"允許 Camera2Tutorial 拍攝照片和錄製影片?"
- 點擊允許。
- 出現一個 Toast:"CameraManager initialized! Found X camera(s)."
- 在 Logcat 中(按
Camera2Tutorial過濾),你應該看到類似以下條目:D/Camera2Tutorial: Background thread started: Camera2BackgroundD/Camera2Tutorial: Successfully accessed CameraManager. Found 4 camera(s).D/Camera2Tutorial: Camera 0: ID = 0D/Camera2Tutorial: Camera 1: ID = 1D/Camera2Tutorial: Camera 2: ID = 2D/Camera2Tutorial: Camera 3: ID = 3 - 當你按下 Home 鍵或導航離開時,Logcat 顯示:
D/Camera2Tutorial: Background thread stopped cleanly
如果你看到這些日誌,恭喜你!你已經成功建置了 Camera2 應用程式的基礎。目前還沒有相機預覽——那是在第 8 章——但管線是正確的。如果你收到 SecurityException,請仔細檢查你是否接受了權限對話框。如果 cameraIdList 傳回空陣列,裝置可能沒有相機(在手機上不太可能)或權限被拒絕。
常見建置問題排查
SecurityException: Lacking privileges to access camera service
這意味著執行時權限未被授予。檢查以下內容:
- 你在資訊清單中加入了
<uses-permission android:name="android.permission.CAMERA" />。 - 你使用正確的請求碼呼叫了
ActivityCompat.requestPermissions。 - 使用者在對話框上點擊了允許。
- 如果你在實體裝置上測試,請前往設定 → 應用程式 → 你的應用程式 → 權限,並確保相機已啟用。
backgroundHandler 上的 NullPointerException
這發生在你嘗試在 startBackgroundThread() 執行之前使用 backgroundHandler 時。確保所有接受 Handler 的 Camera2 操作只在 onResume 被呼叫且執行緒執行後才執行。在我們的程式碼中,initializeCamera() 從 onCreate 呼叫,但它只同步使用 CameraManager;需要 backgroundHandler 的回呼將在後續章節中加入,並正確地閘控在 onResume 上。
TextureView 在後續章節中顯示黑屏
如果你跳到前面並立即加入 TextureView,請確保在資訊清單中的 Activity 上設定了 android:hardwareAccelerated="true"。還要確保 TextureView 已附加到視圖階層並在你的版面配置 XML 中可見。
摘要
在本章中,你建置了 Android Camera2 應用程式的完整鷹架。你學習了:
- 專案結構:如何使用 Empty Activity 範本建立新的 Android Studio 專案,目標 API 21+,使用 Kotlin,並驗證不需要外部 Camera2 相依項目。
- 資訊清單設定:
CAMERA權限宣告、用於 Google Play 過濾的uses-feature標籤,以及用於 TextureView 渲染的 Activity 上的hardwareAccelerated="true"。 - 執行時權限:使用
ContextCompat.checkSelfPermission和ActivityCompat.requestPermissions的完整檢查 → 請求 → 結果循環,並處理接受和拒絕兩種路徑。 - 背景執行緒:為什麼 Camera2 回呼不能在主執行緒上執行,以及如何實作一個具有正確生命週期管理的
HandlerThread+Handler對,在onResume中呼叫startBackgroundThread(),在onPause中呼叫stopBackgroundThread()配合quitSafely()+join()。 - CameraManager 初始化:檢索
CAMERA_SERVICE系統服務,轉換為CameraManager,呼叫cameraIdList驗證服務是否運作,並記錄發現的相機 ID。
本章中的程式碼是後續所有內容的基礎。Android Camera Parameters 應用程式(GitHub、Google Play)正是使用了這些模式——針對不同的工作負載使用多個 HandlerThread、仔細的權限檢查以及穩健的生命週期管理。
接下來
既然 CameraManager 已成功初始化並且我們有了相機 ID 清單,下一步就是查詢每個相機的能力。在第 6 章:探索相機中,你將:
- 學習相機 ID 字串代表什麼(以及為什麼你絕不應該對它們進行硬編碼假設)。
- 使用
LENS_FACING區分前置、後置和外部(USB OTG)相機。 - 查詢每個相機的硬體層級(
INFO_SUPPORTED_HARDWARE_LEVEL)以確定它是 LEGACY、LIMITED、FULL 還是 LEVEL_3。 - 使用
CameraCharacteristics遍歷裝置上的每個相機並記錄其屬性。
到第 6 章結束時,你將擁有一個可運作的相機列舉工具,可以從裝置中提取真實的 Camera2 中繼資料——你已經可以用它來比較不同手機的相機硬體了!