組長問我幾天能開發一個人見人愛的百萬量級Android相機,我是這樣回答的...

騰訊雲加社群發表於2018-09-19

歡迎大家前往騰訊雲+社群,獲取更多騰訊海量技術實踐乾貨哦~

本文由QQ空間開發團隊發表於雲+社群專欄

最近我負責開發了一個跟Android相機有關的需求,新功能允許使用者使用手機攝像頭,快速拍攝特定尺寸(1:1或3:4)的照片,並支援在拍攝出的照片上做貼紙相關的操作。由於之前沒有接觸過Android相機開發,所以在整個開發過程中踩了不少坑,費了不少時間和精力。這篇文章總結了Android相機開發的相關知識、流程,以及容易遇到的坑,希望能幫助今後可能會接觸Android相機開發的朋友快速上手,節省時間,少走彎路。

一.Android中開發相機應用的兩種方式

Android系統提供了兩種使用手機相機資源實現拍攝功能的方法,一種是直接通過Intent呼叫系統相機元件,這種方法快速方便,適用於直接獲得照片的場景,如上傳相簿,微博、朋友圈發照片等。另一種是使用相機API來定製自定義相機,這種方法適用於需要定製相機介面或者開發特殊相機功能的場景,如需要對照片做裁剪、濾鏡處理,新增貼紙,表情,地點標籤等。這篇文章主要是從如何使用相機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介面的拍攝預覽類。此類能夠顯示相機的實時預覽影象。
  • 建立預覽佈局 有了拍攝預覽類,即可建立一個佈局檔案,將預覽畫面與設計好的使用者介面控制元件融合在一起。
  • 設定拍照監聽器 給使用者介面控制元件繫結監聽器,使其能響應使用者操作(如按下按鈕), 開始拍照過程。
  • 拍照並儲存檔案 將拍攝獲得的影象轉換成點陣圖檔案,最終輸出儲存成各種常用格式的圖片。
  • 釋放相機資源 相機是一個共享資源,必須對其生命週期進行細心的管理。當相機使用完畢後,應用程式必須正確地將其釋放,以免其它程式訪問使用時,發生衝突。

img

圖1 定製自定義相機的過程

對應到程式碼編寫上可以分成三個步驟:

第一步:在AndroidManifest.xml中新增Camera相關功能使用的許可權,具體宣告有以下這些:

img

第二步:編寫相機操作功能類CameraOperationHelper。採用單例模式來統一管理相機資源,封裝相機API的直接呼叫,並提供用於跟自定義相機Activity做UI互動的回撥介面,其功能函式如下,主要有建立\釋放相機,連線\開始\關閉預覽介面,拍照,自動對焦,切換前後攝像頭,切換閃光燈模式等,具體實現可以參考官方API文件。

img

img

第三步:編寫自定義相機Activity,主要是定製相機介面,實現UI互動邏輯,如按鈕點選事件處理,icon資源切換,鏡頭尺寸切換動畫等。這裡需要宣告一個SurfaceView物件來實時顯示相機預覽畫面。通過SurfaceHolder及其Callback介面來一同管理螢幕surface和相機資源的連線,相機預覽影象的顯示/關閉。

img

四. 開發過程遇到的一些坑

下面再講講我在開發自定義相機時踩過的一些坑:

1. Activity設為豎屏時,SurfaceView預覽影象顛倒90度。

說明這個問題之前,先介紹下Android手機上幾個方向的概念:

螢幕方向:在Android系統中,螢幕的左上角是座標系統的原點(0,0)座標。原點向右延伸是X軸正方向,原點向下延伸是Y軸正方向。

相機感測器方向:手機相機的影象資料都是來自於攝像頭硬體的影象感測器,這個感測器在被固定到手機上後有一個預設的取景方向,如下圖2所示,座標原點位於手機橫放時的左上角,即與橫屏應用的螢幕X方向一致。換句話說,與豎屏應用的螢幕X方向呈90度角。

img

圖2 相機感測器方向示意圖

相機的預覽方向:由於手機螢幕可以360度旋轉,為了保證使用者無論怎麼旋轉手機都能看到“正確”的預覽畫面(這個“正確”是指顯示在UI預覽介面的畫面與人眼看到的眼前的畫面是一致的),Android系統底層根據當前手機螢幕的方向對影象感測器採集到的資料進行了旋轉處理,然後才送給顯示系統,因此可以保證預覽畫面始終“正確”。在相機API中可以通過setDisplayOrientation()設定相機預覽方向。在預設情況下,這個值為0,與影象感測器一致。因此對於橫屏應用來說,由於螢幕方向和預覽方向一致,預覽影象不會顛倒90度。但是對於豎屏應用,螢幕方向和預覽方向垂直,所以會出現顛倒90度現象。為了得到正確的預覽畫面,必須通過API將相機的預覽方向旋轉90,保持與螢幕方向一致,如圖3所示。

img

圖3 相機預覽方向示意圖

(紅色箭頭為預覽方向,藍色方向為螢幕方向)

相機的拍照方向:當點選拍照按鈕,拍攝的照片是由影象感測器採集到的資料直接儲存到SDCard上產生的,因此,相機的拍照方向與感測器方向是一致的。

2. SurfaceView預覽影象、拍攝照片拉伸變形

說明這個問題之前,同樣先說一下幾個跟相機有關的尺寸。

SurfaceView尺寸:即自定義相機應用中用於顯示相機預覽影象的View的尺寸,當它鋪滿全屏時就是螢幕的大小。這裡surfaceview顯示的預覽影象暫且稱作手機預覽影象。

Previewsize:相機硬體提供的預覽幀資料尺寸。預覽幀資料傳遞給SurfaceView,實現預覽影象的顯示。這裡預覽幀資料對應的預覽影象暫且稱作相機預覽影象。

Picturesize:相機硬體提供的拍攝幀資料尺寸。拍攝幀資料可以生成點陣圖檔案,最終儲存成.jpg或者.png等格式的圖片。這裡拍攝幀資料對應的影象稱作相機拍攝影象。圖4說明了以上幾種影象及照片之間的關係。手機預覽影象是直接提供給使用者看的影象,它由相機預覽影象生成,拍攝照片的資料則來自於相機拍攝影象。

img

圖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尺寸不能太奇葩,最好也設定成這樣的長寬比。

3. 各種crash

img

img

img

前兩個Crash的原因是:相機硬體在聚焦和拍照前必須要保證已經連線到surface,並且開啟相機預覽,surface有收到預覽資料。如果在還沒有執行camera. setPreviewDisplay或者未呼叫camera. startPreview之前,就呼叫camera.autofocus或camera.takepicture,就會出現這個執行時異常。對應到自定義相機的程式碼中,要注意在拍照按鈕事件響應中執行camera.autofocus或camera.takepicture前,一定要檢驗camera有沒有設定預覽Surfaceview並開啟了相機預覽。這裡有個方法可以判斷預覽狀態:Camera.setPreviewCallback是預覽幀資料的回撥函式,它會在SurfaceView收到相機的預覽幀資料時被呼叫,因此在裡面可以設定是否允許對焦和拍照的標誌位。

img

還有一點要注意,camera.takePicture()在執行過程中會執行camera.stopPreview來獲取拍攝幀資料,表現為預覽畫面卡住,而如果此時使用者點選了按鈕的話,也就是呼叫camera.takepicture,也會出現上面的crash,因此在開發時,可能還需要遮蔽拍照按鈕的連續點選。

第三個crash則涉及影象的裁剪,由於要支援1:1或者4:3尺寸鏡頭,所以會需要對預覽檢視進行裁剪,由於是豎屏應用,所以裁剪區域的座標系跟相機感測器方向是成90度角的,表現在裁剪裡就是,螢幕上的x方向,對應在拍攝影象上是高度方向,而螢幕上的y方向,對應到拍攝影象上則是寬度方向。因此在計算時要一定注意座標系的轉換以及越界保護。

img

4. 前置攝像頭的映象效果

Android相機硬體有個特殊設定,就是對於前置攝像頭,在展示預覽檢視時採用類似鏡面的效果,顯示的是攝像頭成像的映象。而拍攝出的照片則仍採用攝像頭成像。看到這裡,大家可能會有些懷疑,不妨現在就試試自己Android手機上的前置攝像頭,對比下預覽影象和拍攝出照片的區別。這是由於底層相機在傳遞前置攝像頭預覽資料時做了水平翻轉變換,即將x方向映象翻轉180度。這個變化對之前豎屏預覽的方向也會造成影響,本來對於後置攝像頭旋轉90度即可使預覽檢視正確,而對前置攝像頭,如果也旋轉90度的話,看到的預覽影象則是上下顛倒的(因為x方向翻轉了180度),因此必須再旋轉180度,才能顯示正確,如圖5所示,大家可以結合之前相機預覽方向的示意圖一起理解。

img

img

圖5 前置攝像頭的預覽方向示意圖

此外,由於拍攝影象並沒有做水平翻轉,所以對於前置攝像頭拍出來的照片,使用者會發現跟預覽時所見的是左右翻轉的。這個在一定程度上會影響使用者體驗。為了解決這個問題,可以對前置攝像頭拍攝的影象在生成點陣圖檔案時增加一個水平翻轉矩陣變換。

5. 鎖屏下相機資源的釋放問題

為了節省手機電量,不浪費相機資源,在開發的自定義相機裡,如果預覽影象已不需要顯示,如按Home鍵盤切換後臺或者鎖屏後,此時就應該關閉預覽並把相機資源釋放掉。參考官方API文件,當surfaceView變成可見時,會建立surface並觸發surfaceHolder.callback介面中surfaceCreated回撥函式。而surfaceview變成不可見時,則會銷燬surface,並觸發surfacedestroyed回撥函式。我們可以在對應的回撥函式裡,處理相機的相關操作,如連線surface、開啟/關閉預覽。 至於相機資源釋放,則可以放在Acticity的onpause裡執行。相應的,要重新恢復預覽影象時,可以把相機資源申請和初始化放在Acticity的onResume裡執行,然後通過建立surfaceview,將camera和surface相連並開啟預覽。

img

但是在開發過程中發現,對於按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的可見性的方法,並不會對使用者的體驗造成影響。

img

問答

Android - 如何修復許可權異常?

相關閱讀

深入理解Autorelease Pool

ComponentKit框架解析之一—初識CK

Android 記憶體洩漏分析心得

【每日課程推薦】機器學習實戰!快速入門線上廣告業務及CTR相應知識

此文已由作者授權騰訊雲+社群釋出,更多原文請點選

搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!

海量技術實踐經驗,盡在雲加社群

相關文章