最近我負責開發了一個跟Android相機有關的需求,新功能允許使用者使用手機攝像頭,快速拍攝特定尺寸(1:1或3:4)的照片,并支援在拍攝出的照片上做貼紙相關的操作。由于之前沒有接觸過Android相機開發,是以在整個開發過程中踩了不少坑,費了不少時間和精力。這篇文章總結了Android相機開發的相關知識、流程,以及容易遇到的坑,希望能幫助今後可能會接觸Android相機開發的朋友快速上手,節省時間,少走彎路。
Android系統提供了兩種使用手機相機資源實作拍攝功能的方法,一種是直接通過Intent調用系統相機元件,這種方法快速友善,适用于直接獲得照片的場景,如上傳相冊,微網誌、朋友圈發照片等。另一種是使用相機API來定制自定義相機,這種方法适用于需要定制相機界面或者開發特殊相機功能的場景,如需要對照片做裁剪、濾鏡處理,添加貼紙,表情,地點标簽等。這篇文章主要是從如何使用相機API來定制自定義相機這個方向展開的。
通過相機API實作拍攝功能涉及以下幾個關鍵類和接口:
Camera:最主要的類,用于管理和操作camera資源。它提供了完整的相機底層接口,支援相機資源切換,設定預覽/拍攝尺寸,設定光圈、曝光、聚焦等相關參數,擷取預覽/拍攝幀資料等功能,主要方法有以下這些:
open():擷取camera執行個體。
setPreviewDisplay(SurfaceHolder):綁定繪制預覽圖像的surface。surface是指向螢幕視窗原始圖像緩沖區(raw buffer)的一個句柄,通過它可以獲得這塊螢幕上對應的canvas,進而完成在螢幕上繪制View的工作。通過surfaceHolder可以将Camera和surface連接配接起來,當camera和surface連接配接後,camera獲得的預覽幀資料就可以通過surface顯示在螢幕上了。
setPrameters設定相機參數,包括前後攝像頭,閃光燈模式、聚焦模式、預覽和拍照尺寸等。
startPreview():開始預覽,将camera底層硬體傳來的預覽幀資料顯示在綁定的surface上。
stopPreview():停止預覽,關閉camra底層的幀資料傳遞以及surface上的繪制。
release():釋放Camera執行個體
takePicture(Camera.ShutterCallback shutter, Camera.PictureCallback raw, Camera.PictureCallback jpeg):這個是實作相機拍照的主要方法,包含了三個回調參數。shutter是快門按下時的回調,raw是擷取拍照原始資料的回調,jpeg是擷取經過壓縮成jpg格式的圖像資料的回調。
SurfaceView:用于繪制相機預覽圖像的類,提供給使用者實時的預覽圖像。普通的view以及派生類都是共享同一個surface的,所有的繪制都必須在UI線程中進行。而surfaceview是一種比較特殊的view,它并不與其他普通view共享surface,而是在内部持有了一個獨立的surface,surfaceview負責管理這個surface的格式、尺寸以及顯示位置。由于UI線程還要同時處理其他互動邏輯,是以對view的更新速度和幀率無法保證,而surfaceview由于持有一個獨立的surface,因而可以在獨立的線程中進行繪制,是以可以提供更高的幀率。自定義相機的預覽圖像由于對更新速度和幀率要求比較高,是以比較适合用surfaceview來顯示。
SurfaceHolder:surfaceholder是控制surface的一個抽象接口,它能夠控制surface的尺寸和格式,修改surface的像素,監視surface的變化等等,surfaceholder的典型應用就是用于surfaceview中。surfaceview通過getHolder()方法獲得surfaceholder 執行個體,通過後者管理監聽surface 的狀态。
SurfaceHolder.Callback接口:負責監聽surface狀态變化的接口,有三個方法:
surfaceCreated(SurfaceHolder holder):在surface建立後立即被調用。在開發自定義相機時,可以通過重載這個函數調用camera.open()、camera.setPreviewDisplay(),來實作擷取相機資源、連接配接camera和surface等操作。
surfaceChanged(SurfaceHolder holder, int format, int width, int height):在surface發生format或size變化時調用。在開發自定義相機時,可以通過重載這個函數調用camera.startPreview來開啟相機預覽,使得camera預覽幀資料可以傳遞給surface,進而實時顯示相機預覽圖像。
surfaceDestroyed(SurfaceHolder holder):在surface銷毀之前被調用。在開發自定義相機時,可以通過重載這個函數調用camera.stopPreview(),camera.release()來實作停止相機預覽及釋放相機資源等操作。
定制一個自定義相機應用,通常需要完成以下步驟,其流程圖如圖1所示:
檢測并通路相機資源 檢查手機是否存在相機資源,如果存在,請求通路相機資源。
建立預覽類 建立繼承自SurfaceView并實作SurfaceHolder接口的拍攝預覽類。此類能夠顯示相機的實時預覽圖像。
建立預覽布局 有了拍攝預覽類,即可建立一個布局檔案,将預覽畫面與設計好的使用者界面控件融合在一起。
設定拍照監聽器 給使用者界面控件綁定監聽器,使其能響應使用者操作(如按下按鈕), 開始拍照過程。
拍照并儲存檔案 将拍攝獲得的圖像轉換成位圖檔案,最終輸出儲存成各種常用格式的圖檔。
釋放相機資源 相機是一個共享資源,必須對其生命周期進行細心的管理。當相機使用完畢後,應用程式必須正确地将其釋放,以免其它程式通路使用時,發生沖突。

圖1 定制自定義相機的過程
對應到代碼編寫上可以分成三個步驟:
第一步:在AndroidManifest.xml中添加Camera相關功能使用的權限,具體聲明有以下這些:
第二步:編寫相機操作功能類CameraOperationHelper。采用單例模式來統一管理相機資源,封裝相機API的直接調用,并提供用于跟自定義相機Activity做UI互動的回調接口,其功能函數如下,主要有建立\釋放相機,連接配接\開始\關閉預覽界面,拍照,自動對焦,切換前後攝像頭,切換閃光燈模式等,具體實作可以參考官方API文檔。
第三步:編寫自定義相機Activity,主要是定制相機界面,實作UI互動邏輯,如按鈕點選事件處理,icon資源切換,鏡頭尺寸切換動畫等。這裡需要聲明一個SurfaceView對象來實時顯示相機預覽畫面。通過SurfaceHolder及其Callback接口來一同管理螢幕surface和相機資源的連接配接,相機預覽圖像的顯示/關閉。
下面再講講我在開發自定義相機時踩過的一些坑:
說明這個問題之前,先介紹下Android手機上幾個方向的概念:
螢幕方向:在Android系統中,螢幕的左上角是坐标系統的原點(0,0)坐标。原點向右延伸是X軸正方向,原點向下延伸是Y軸正方向。
相機傳感器方向:手機相機的圖像資料都是來自于攝像頭硬體的圖像傳感器,這個傳感器在被固定到手機上後有一個預設的取景方向,如下圖2所示,坐标原點位于手機橫放時的左上角,即與橫屏應用的螢幕X方向一緻。換句話說,與豎屏應用的螢幕X方向呈90度角。
圖2 相機傳感器方向示意圖
相機的預覽方向:由于手機螢幕可以360度旋轉,為了保證使用者無論怎麼旋轉手機都能看到“正确”的預覽畫面(這個“正确”是指顯示在UI預覽界面的畫面與人眼看到的眼前的畫面是一緻的),Android系統底層根據目前手機螢幕的方向對圖像傳感器采集到的資料進行了旋轉處理,然後才送給顯示系統,是以可以保證預覽畫面始終“正确”。在相機API中可以通過setDisplayOrientation()設定相機預覽方向。在預設情況下,這個值為0,與圖像傳感器一緻。是以對于橫屏應用來說,由于螢幕方向和預覽方向一緻,預覽圖像不會颠倒90度。但是對于豎屏應用,螢幕方向和預覽方向垂直,是以會出現颠倒90度現象。為了得到正确的預覽畫面,必須通過API将相機的預覽方向旋轉90,保持與螢幕方向一緻,如圖3所示。
圖3 相機預覽方向示意圖
(紅色箭頭為預覽方向,藍色方向為螢幕方向)
相機的拍照方向:當點選拍照按鈕,拍攝的照片是由圖像傳感器采集到的資料直接存儲到SDCard上産生的,是以,相機的拍照方向與傳感器方向是一緻的。
說明這個問題之前,同樣先說一下幾個跟相機有關的尺寸。
SurfaceView尺寸:即自定義相機應用中用于顯示相機預覽圖像的View的尺寸,當它鋪滿全屏時就是螢幕的大小。這裡surfaceview顯示的預覽圖像暫且稱作手機預覽圖像。
Previewsize:相機硬體提供的預覽幀資料尺寸。預覽幀資料傳遞給SurfaceView,實作預覽圖像的顯示。這裡預覽幀資料對應的預覽圖像暫且稱作相機預覽圖像。
Picturesize:相機硬體提供的拍攝幀資料尺寸。拍攝幀資料可以生成位圖檔案,最終儲存成.jpg或者.png等格式的圖檔。這裡拍攝幀資料對應的圖像稱作相機拍攝圖像。圖4說明了以上幾種圖像及照片之間的關系。手機預覽圖像是直接提供給使用者看的圖像,它由相機預覽圖像生成,拍攝照片的資料則來自于相機拍攝圖像。
圖4 幾種圖像之間的關系
下面說下我在開發過程中遇到的三種拉伸變形現象:
1、手機預覽畫面中物體被拉伸變形。
2、拍攝照片中物體被拉伸變形。
3、點選拍照瞬間,手機預覽畫面會停頓下,此時的圖像是拉伸變形的,然後預覽畫面恢複後圖像又正常了。
現象1的原因是SurfaceView和Previewsize的長寬比率不一緻。因為手機預覽視圖的圖像是由相機預覽圖像根據SurfaceView大小縮放得來的,當長寬比不一緻時必然會導緻圖像變形。後兩個現象的原因則是Previewsize和Picturesize的長寬比率不一緻所緻,查了相關的資料,發現其具體原因跟某些手機相機硬體的底層實作有關。總之為了避免以上幾種變形現象的發生,在開發時最好将SurfaceView、PreviewSize、PictureSize三個尺寸保證長寬比例一緻。具體實作可以先通過camera.getSupportedPreviewSizes()和camera.getSupportedPictureSizes()獲得相機硬體支援的所有預覽和拍攝尺寸,然後在裡面篩選出和SurfaceView的長寬比一緻并且大小合适的尺寸,通過camera.setPrameters來更新設定。注意:市場上手機相機硬體支援的尺寸一般都是主流的4:3或者16:9,是以SurfaceView尺寸不能太奇葩,最好也設定成這樣的長寬比。
前兩個Crash的原因是:相機硬體在聚焦和拍照前必須要保證已經連接配接到surface,并且開啟相機預覽,surface有收到預覽資料。如果在還沒有執行camera. setPreviewDisplay或者未調用camera. startPreview之前,就調用camera.autofocus或camera.takepicture,就會出現這個運作時異常。對應到自定義相機的代碼中,要注意在拍照按鈕事件響應中執行camera.autofocus或camera.takepicture前,一定要檢驗camera有沒有設定預覽Surfaceview并開啟了相機預覽。這裡有個方法可以判斷預覽狀态:Camera.setPreviewCallback是預覽幀資料的回調函數,它會在SurfaceView收到相機的預覽幀資料時被調用,是以在裡面可以設定是否允許對焦和拍照的标志位。
還有一點要注意,camera.takePicture()在執行過程中會執行camera.stopPreview來擷取拍攝幀資料,表現為預覽畫面卡住,而如果此時使用者點選了按鈕的話,也就是調用camera.takepicture,也會出現上面的crash,是以在開發時,可能還需要屏蔽拍照按鈕的連續點選。
第三個crash則涉及圖像的裁剪,由于要支援1:1或者4:3尺寸鏡頭,是以會需要對預覽視圖進行裁剪,由于是豎屏應用,是以裁剪區域的坐标系跟相機傳感器方向是成90度角的,表現在裁剪裡就是,螢幕上的x方向,對應在拍攝圖像上是高度方向,而螢幕上的y方向,對應到拍攝圖像上則是寬度方向。是以在計算時要一定注意坐标系的轉換以及越界保護。
Android相機硬體有個特殊設定,就是對于前置攝像頭,在展示預覽視圖時采用類似鏡面的效果,顯示的是攝像頭成像的鏡像。而拍攝出的照片則仍采用攝像頭成像。看到這裡,大家可能會有些懷疑,不妨現在就試試自己Android手機上的前置攝像頭,對比下預覽圖像和拍攝出照片的差別。這是由于底層相機在傳遞前置攝像頭預覽資料時做了水準翻轉變換,即将x方向鏡像翻轉180度。這個變化對之前豎屏預覽的方向也會造成影響,本來對于後置攝像頭旋轉90度即可使預覽視圖正确,而對前置攝像頭,如果也旋轉90度的話,看到的預覽圖像則是上下颠倒的(因為x方向翻轉了180度),是以必須再旋轉180度,才能顯示正确,如圖5所示,大家可以結合之前相機預覽方向的示意圖一起了解。
圖5 前置攝像頭的預覽方向示意圖
此外,由于拍攝圖像并沒有做水準翻轉,是以對于前置攝像頭拍出來的照片,使用者會發現跟預覽時所見的是左右翻轉的。這個在一定程度上會影響使用者體驗。為了解決這個問題,可以對前置攝像頭拍攝的圖像在生成位圖檔案時增加一個水準翻轉矩陣變換。
為了節省手機電量,不浪費相機資源,在開發的自定義相機裡,如果預覽圖像已不需要顯示,如按Home鍵盤切換背景或者鎖屏後,此時就應該關閉預覽并把相機資源釋放掉。參考官方API文檔,當surfaceView變成可見時,會建立surface并觸發surfaceHolder.callback接口中surfaceCreated回調函數。而surfaceview變成不可見時,則會銷毀surface,并觸發surfacedestroyed回調函數。我們可以在對應的回調函數裡,處理相機的相關操作,如連接配接surface、開啟/關閉預覽。 至于相機資源釋放,則可以放在Acticity的onpause裡執行。相應的,要重新恢複預覽圖像時,可以把相機資源申請和初始化放在Acticity的onResume裡執行,然後通過建立surfaceview,将camera和surface相連并開啟預覽。
但是在開發過程中發現,對于按HOME鍵切背景場景,程式可以正常運作。對于鎖屏場景,則在重新申請相機資源時會發生crash,說相機資源通路失敗。那麼原因是什麼呢?我在代碼裡增加了調試log, 檢查了代碼的執行順序,結果如下:
在自定義相機頁面按HOME鍵時的執行流程:
程式運作->按HOME鍵
Activity調用的順序是onPause->onStop
SurfaceView調用了surfaceDestroyed方法
然後再切回程式
Activity調用的順序是onRestart->onStart->onResume
SurfaceView調用了surfaceCreated->surfaceChanged方法
而對于鎖屏,其執行流程則是:
Activity隻調用onPause方法
解鎖後Activity調用onResume方法
SurfaceView中surfaceholder.callback的所有方法都沒有執行
問題找到了,由于鎖屏時,callback的回調方法沒有執行,導緻相機和預覽的連接配接還沒有斷開,相機資源就被釋放了,是以導緻在重新申請相機資源時,系統報crash。根據上面的文檔,推測是鎖屏下系統并沒有改變surfaceview的可見性,于是我嘗試在onPause和onResume時通過手動設定surfaceview的visibile屬性,結果發現可以正常觸發回調函數了。由于在切背景或者鎖屏時,使用者本來就應該看不到surfaceview,是以這種手動更改surfaceview的可見性的方法,并不會對使用者的體驗造成影響。
文章來自公衆号:QQ空間終端開發團隊(qzonemobiledev)