Android Camera2 API 錄影開發歷程 (二)

重點整理

  • 實作 CameraCore class
  • 完成 video size , preview size , sensor orientaion 的讀取
  • 成功打開相機

第一步

New一個新專案出來,在activity_main.xml上先拉個畫面出來吧!很簡單,我們就一個 TextureView、一個 Button就行,完成以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="#000000"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<TextureView
android:id="@+id/surface"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<Button
android:id="@+id/btnRecord"
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_marginBottom="15dp"
android:layout_marginEnd="18dp"
android:layout_marginStart="18dp"
android:text="錄影"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />

</android.support.constraint.ConstraintLayout>

接著到 styles.xml 裡的AppTheme增加以下兩行程式碼:

1
2
3
<!-- 取消系統預設的 tool bar -->
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>

因為我們要用全螢幕來顯示相機預覽,所以任何toolbar通通要消失不見,連v7 toolbar我們也沒有建立。完成後應該在preview的版面會看到像這樣:

然後打開你的app層的build.gradle,加入RxJava library,我接下來的範例中就會開始有Rx的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:27.1.1'
implementation 'com.android.support.constraint:constraint-layout:1.1.2'

//Rx library
implementation "io.reactivex.rxjava2:rxjava:2.2.1"

testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

然後請產生一個 Class RootApplication,我們要建立 Application 進入點:

1
2
3
4
5
6
7
8
9
10
11
12
class RootApplication : Application() {

companion object {
lateinit var cameraManager: CameraManager
}

override fun onCreate() {
super.onCreate()

cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
}
}

照著範例程式,一樣的去繼承Application實作onCreate(),這是Android開啟App後的第一個進入點,我們要在上面先把CameraManager建立好。

CameraManager用 static 的方式去保存,因為 Application 只有在onCreate這一次的機會進行其他設定或把相關App要用的資源準備、初始化,等等之類的工作都會在這裡執行。

而在之後,若想要取得某些App全體共用的資源時,開發者是拿不到 Application的實例,這時只好將建立好的資源存在這個Class的static欄位上,以便日後存取。(Application對於App的生命週期,在此不多加詳述)

之所以可以這麼做,其實是一個很簡單的道理,Application在App啟動之後被建立而且是一次,在此之後除非App被系統殺掉,那麼這個Application都會存在一份。

這就類似單例模式,所以我通常會被全體共享用的資源放在上面,但注意不要太依賴,資料也盡量簡單就好,把握這個原則,最小化你想要存取的東西就好。

完成後,記得最後在AndroidManifest.xml裡application tag中新增android:name=”” 指定這個class,讓App加載:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="tool.interview.mayohr.camera2apidemo">

<application
<!-- 這裡 -->
android:name=".RootApplication"

android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

大致上到這裡 build 應該都沒什麼問題,進入下一步驟。

第二步

這一階段,我們要開始封裝 Camera2的一切東西,為了讓其他物件使用順暢,根據需求建構一些簡單易用的funtion直接在我們的app取用,說明白一點就是把Camera2再封裝成一層變成更高一級的API,讓我們自己使用。

新建一個 kt class,命名為: CameraCore,然後先加入以下兩個method:

1
2
3
4
5
6
7
8
 //純預覽,什麼動作都不做
fun startPreview(surface: List<Surface>): Observable<Boolean> {
return Observable.just(false)
}

fun close() {

}

preivew的主要方法回傳了 Observable,是為了要先讓編譯器通過,沒什麼特別的原因。

回顧一下架構圖,我們若想要讓Camera的資料流入到UI上,必須提供 surface ,而這裡預覽的主要function就是

  • startPreview(surface: List)

對於CameraCaptureSession來說,他並不用知道Surface到底來自哪裡,但是我們的App是需要知道Surface的用途,因此針對需求,我們要自行取找到哪些 Surface 並設定給 Session。

close(),既然有開就有關閉,所以也要先寫出來。

接下來,補上兩個重點方法,一個是 kt的init 和 openCamera:

1
2
3
4
5
6
7
init {

}

fun openCamera(): Observable<Boolean> {
return Observable.just(false)
}

openCamera 一樣回傳 return Observable.just(false),先通過編譯器檢查。

kt的init函式,這段程式放在這個物件的哪個位置,當物件產生之後會依循著編譯順序,等到該是init要進行的時候才會進行,這個方法跟建構子是有區別性的哦!

所以我的習慣我會讓init放在所有宣告的建構子和屬性成員之下,然後開始做初始化。那麼就開始先宣告一些這個CameraCore會用到的全域變數,請把這些變數全部放於init上方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//open 的時候拿到
var cameraDevice: CameraDevice? = null

//create session 的時候拿到
var captureSession: CameraCaptureSession? = null

//create request的時候拿到
var requestBuilder: CaptureRequest.Builder? = null

//可錄製的影片大小,CameraCharacteristics可取得
var videoSize: Size = Size(0, 0)

//可預覽的大小,CameraCharacteristics可取得
var previewSize: Size = Size(0, 0)

//相機物理角度
var sensorOrientation: Int = 0

init {

}

這裏要先講解,videoSize, previewSize , sensorOrientation的用途,

  • videoSize:該手機對於相機錄影可以提供的size,從CameraCharacteristics取得。這裡我會去選擇適合於螢幕尺寸比例的video size,官方範例中是直接找到 4:3 比例的最小 video size,應該是為了縮減影片大小,不過我在錄影的設定上,會降低一些品質,所以也先不要煩惱影片會太大
  • previewSize:相機可以輸出預覽的size,從CameraCharacteristics取得。這個size每一台手機都不太一樣,甚至他並不會跟可支援的video size列表一模一樣,完全是手機硬體底層控制,我們也無法去更改。
  • sensorOrientation:相機物理的旋轉角度,基本上是固定的,不是90就是270

Preview Size的問題

特別說明一下,為什麼一定要規定previewSize? 因為我是用TextureView,而其實我也是從錯誤訊息得知,如果你設定的BufferSize寬高不符合這個比例,就會噴錯,是個從經驗中學習,原理為何,我還沒深究過。

總而言之,遵循著API的方式做的話,假如我在TexturSurface設定BufferSize => 1920 x 1080,結果我的手機相機卻不支援 1920 x 1080 的 preview,這時候就一定會噴錯。

但對於 SurfaceView設定FixedSize,其實沒錯誤發生,但影響的是畫面會被拉伸而已,並不是很好的解決方式。

也因此 video size 跟 preview size 還有 screen size 是有相關聯性的,假如我今天的需求是全螢幕預覽加錄影,我得到以下資訊:

  1. Screen : 1080x1920
  2. Video : 1280x720 , 960x720 , 640x480
  3. Preview : 1440x1088 , 1280x720 , 960x720 , 640x480

我使用了 1080x1920 對 Texture BufferSize設定,然後開始輸出,就得到錯誤說無法支援的preivew size,可是這時候若選擇了 1440x1088 又會發生什麼事情? 你的畫面會被嚴重拉伸,比例很不正常。

要取得最佳大小,是先用 Screen 的 短/長 = Ratio 得到的是正常寬高比,所以我們要用 Screen.width / Screen.height。

以這個假設例子來看,得到的是 9:16 , 顛倒則是 16:9 之比例,所以我們要找的正常比值應是 16:9 的輸出品質,Video就會是第一個:1280x720;Preview則是第二個:1280x720。

正常手機的preview應該會有 16:9 的 1080p 顯示,不過我這裡有一支手機前鏡頭可能不太好,測試起來確實只有到1440x1088,研判Screen之所以會有 1080x1920 的輸出,應該是其解析度(dpi)較高的關係。

那麼渲染1280x720的圖像到1920x1080像素上時,是一定會有模糊感的差距,但如果為了解決掉full screen不拉伸,必須要捨棄掉一些東西,所以這個做法是比較恰當的。

當然你也可以去制定設定值,比如我要App可以支援 1080p、720p、480p的錄製,那麼在預覽到手機上的時候就要照著你拿到的Preview比例去對TextureView做畫面寬高設定。

假如我要以1440x1088輸出1080p高解析度的時候,為了符合比例值,通常會將螢幕的寬縮短、高縮短,兩邊對稱1440x1088的比例,那麼就會正常。

只是可能你的UI下方 Layout版面會空出一塊出來。

第三步

PreviewSize說的那麼多,其實是為了接下來的選擇方式,請先在RootApplication的 companion object block中加入以下兩個static 變數:

1
2
3
4
5
6
companion object {
lateinit var cameraManager: CameraManager

lateinit var screenSize: Size
private set
}

然後在 onCreate裡實作取得 display size的方法:

1
2
3
4
5
6
7
8
9
10
11
override fun onCreate() {
super.onCreate()

cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager

val display = (getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
val metrics = DisplayMetrics()
display.getRealMetrics(metrics)

screenSize = Size(metrics.widthPixels, metrics.heightPixels)
}

以上使用了 private set,讓這個欄位對外界而言只能是唯讀,對內部則可以設定,保護這個欄位不受外界干擾。

回到CameraCore,在 init block 中寫下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
init {
val manager = RootApplication.cameraManager

//前鏡頭是1
val camera = manager.cameraIdList[1] ?: ""

val characteristics = manager.getCameraCharacteristics(camera)
?: throw RuntimeException("No Camera")

val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
?: throw RuntimeException("No Configuration")

//根據可支援的錄影大小和預覽大小,選取跟螢幕一樣比例的尺寸,有可能比螢幕尺寸還小
videoSize = chooseSizeFitWithScreen(map.getOutputSizes(MediaRecorder::class.java))
previewSize = chooseSizeFitWithScreen(map.getOutputSizes(SurfaceTexture::class.java))
sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)
}

然後我們要實作一下 chooseSizeFitWithScreen private 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private fun chooseSizeFitWithScreen(choices: Array<Size>): Size {

//短邊/長邊
val screenRatio = RootApplication.screenSize.width.toFloat() / RootApplication.screenSize.height.toFloat()

for (size in choices) {
val sizeRatio = size.height.toFloat() / size.width.toFloat()
if (screenRatio == sizeRatio) {
return size
}
}

//如果一個都找不到
for (size in choices) {
if (size.width >= RootApplication.screenSize.height && size.height >= RootApplication.screenSize.width) {
return size
}
}

//最後的機會
return choices[choices.size - 1]
}

程式碼說明:

先用短邊/長邊取得螢幕比例值,再用這個比例找尋相同的最大比例,請注意,是相同的最大比例。for loop在這裡迴圈所有的size可以找到最大比例是因為從CameraCharacteristics取得到可支援size,是遞減排序,由大到小。

那如果想要針對video size找尋比較適合的尺寸來減少影片大小,就要多寫一個方法,針對video的部分去判斷,但這裡為了快速帶範例,就不多做此步驟。

第四步

開啟相機

第二章的最後是打開相機,也是整個Camera2的第一步,如果根本打不開,就什麼都別談了。

現在把注意力放到之前我們寫的 openCamera 方法,開始實作吧!

首先,我們要先建立一個Obserable,在這裡我使用了 Obserable.create 方法建立,最後回傳帶Boolean值的輸出,ture就是成功開啟,false就是失敗,只要在open接到失敗的結果,我們就不用再繼續往下做。

請先把 return Observable.just(false),這行刪除,改回傳以下:

1
2
3
return Observable.create {

}

因為是泛型的關係,所以Observable.create會自動帶入泛型值,我們不用特別宣告。接著在create block中,使用一開始 Application 建立好的 manager 來 open:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return Observable.create {
val manager = RootApplication.cameraManager

manager.openCamera(manager.cameraIdList[1]?: "", object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice?) {

}

override fun onDisconnected(device: CameraDevice?) {
}

override fun onError(device: CameraDevice?, code: Int) {
}

}, null)
}

openCamera帶三個參數,第一個是 cameraId string value、第二個是 callback 介面直接匿名類別實作、第三個是 handelr,這裡我使用 null,使用當前 Thread的Handler。

特別說明 handler 在這裡的意義,如果你不特別啟用另一條HandlerThread的話,這裡就會使用 main thread的Handler,如果你用了 thread (跟 handler thread不同)去open,然後這個參數傳進去null值,那麼就會出現錯誤警告你沒有設定 Handler,如一定要用 其他執行緒來跑,就要使用 HandlerThread 加上 Handler,讓他有loop,才能正常執行,想必應該是內部做了執行緒安全和UI Render 不衝突的機制。

開啟成功之後,在onOpened call back裡將device保存起來,並且 fire true,然後完成這個Observable:

1
2
3
4
5
6
7
8
9
override fun onOpened(device: CameraDevice?) {
this@CameraCore.cameraDevice = device

//fire true
it.onNext(true)

//宣告完成
it.onComplete()
}

在其他 onDisconnected , onError call back,分別寫下:

1
2
3
4
5
6
7
8
9
override fun onDisconnected(device: CameraDevice?) {
it.onNext(false)
it.onComplete()
}

override fun onError(device: CameraDevice?, code: Int) {
it.onNext(false)
it.onComplete()
}

最後在我們開啟相機之前,要先在 AndroidManifest.xml 加上權限,並且在 Activity 中動態請求使用者授權:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 請求權限 -->
<uses-permission android:name="android.permission.CAMERA"/>

<application
android:name=".RootApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

在Activity中請求:

  • 先宣告一下請求碼和 CameraCore class,方便之後用:

使用lateinit,代表之後一定要初始化,這樣就不用加上問號做optional的判斷

1
2
3
4
5
6
7
class MainActivity : AppCompatActivity() {
companion object {
const val CAMERA_REQUEST_CODE = 255
}

lateinit var cameraCore: CameraCore
}
  • 實作 onCreate,初始化 cameraCore
1
2
3
4
5
6
7
8
9
10
11
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

cameraCore = CameraCore()

//請求一次
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), CAMERA_REQUEST_CODE)
}
}
  • 實作 onResume,沒授權不開啟
1
2
3
4
5
6
7
8
9
10
override fun onResume() {
super.onResume()

if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
//有授權就打開
cameraCore.openCamera().subscribe {

}
}
}
  • 在 openCamera 訂閱後的 onNext 結果中,實作以下:
1
2
3
4
5
cameraCore.openCamera().subscribe {
if (it) {
Log.d("onResume", "Camera Opened")
}
}

如果你有看到這行輸出,代表相機開啟成功!