1.前言
從零開始的車載Android HMI是一個系列性的文章,目的在于展示一些在Android手機應用開中不常用,但是在車載應用開發中較為常用的一系列Android HMI 組件,希望能夠幫助初入車載應用開發的同學了解車載應用開發過程中常用的各種UI 組件。
RE: 從零開始的車載Android HMI(一) - Lottie
RE: 從零開始的車載Android HMI(二) - Widget
本文參考資料:
《Android自定義控件開發入門與實戰》 - 啟艦
Understanding Canvas and Surface concepts
Surface | Android Developers
2.SurfaceView 簡介
相信每一個Android初學者在自學Android編程時都使用過VideoView
來播放視頻,當打開VideoView
的源碼時,會發現VideoView
并不是直接繼承自我們常用的ViewGroup或是View,它實際上繼承自一種更特殊的View - SurfaceView
2.1.SurfaceView 是什么
簡單來說,SurfaceView就是一個嵌入了Surface的特殊View,Surface中有一個獨立的畫布Canvas用于繪制內容,SurfaceView本質上是這個Surface的容器,用于控制Surface的格式、尺寸等基礎信息。
SurfaceView顯示內容時,會在Window上挖一個洞,SurfaceView繪制的內容顯示在這個洞里,其他的View繼續顯示在Window上。
2.2.SurfaceView 應用場景
SurfaceView
的出現并不是為了取代View,當界面繪制需要頻繁刷新,或刷新時需要處理的數據量較大時,就應該考慮使用SurfaceView
,例如:視頻播放、展示攝像頭數據。在車載應用開發中,我們通常使用SurfaceView
展示Camera的數據,例如 泊車雷達 等應用。如果需要對來自Camera的數據進行二次處理后再展示,應該使用TextureView
。
3.SurfaceView 基礎用法
接下來我們寫一個簡單繪的圖板,來看一下SurfaceView的基礎用法是怎樣的。
1.創建一個繼承自SurfaceView的自定義View,并初始化Paint、Path
init {
paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Color.BLUE
paint.style = Paint.Style.STROKE
paint.strokeWidth = 5f
path = Path()
}
2.獲取SurfaceHolder,并監聽Surface生命周期
init {
...
// 為了代碼的可讀性,這里并沒有使用Kotlin的簡寫
surfaceHolder = getHolder()
surfaceHolder?.addCallback(this)
}
override fun surfaceCreated(holder: SurfaceHolder) {
flag = true
drawCanvas()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
flag = false
}
SurfaceHolder
從名字上就能看出,他是Surface的持有對象,必須通過它才能獲取到繪圖所必須的畫布。下一節會詳細介紹。
3.監聽手勢
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
path.moveTo(event.x, event.y)
return true
}
MotionEvent.ACTION_MOVE -> {
path.lineTo(event.x, event.y)
}
}
return super.onTouchEvent(event)
}
通過onTouchEvent監聽到屏幕上的手勢移動,并將軌跡保存在Path
中。
4.在子線程中將軌跡繪制到畫布上
private fun drawCanvas() {
Thread {
while (flag) {
val canvas = surfaceHolder.lockCanvas()
canvas.drawPath(path, paint)
surfaceHolder.unlockCanvasAndPost(canvas)
}
} .start()
}
在這一步中,通過SurfaceHolder獲取到SurfaceView自帶的緩沖畫布,并對這個畫布加鎖surfaceHolder.lockCanvas()
。
在繪制完成后,將緩沖畫布釋放,并將畫布的內容更新到主線程的畫布上surfaceHolder.unlockCanvasAndPost(canvas)
,這樣緩沖畫布的內容就顯示到屏幕上了。
完整的源碼如下所示:
class CustomSurfaceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : SurfaceView(context, attrs), SurfaceHolder.Callback {
private val paint: Paint
private val path: Path
private val surfaceHolder: SurfaceHolder
private var flag: Boolean = false
init {
paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Color.BLUE
paint.style = Paint.Style.STROKE
paint.strokeWidth = 5f
path = Path()
// 為了代碼的可讀性,這里并沒有使用Kotlin的簡寫
surfaceHolder = getHolder()
surfaceHolder?.addCallback(this)
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
path.moveTo(event.x, event.y)
return true
}
MotionEvent.ACTION_MOVE -> {
path.lineTo(event.x, event.y)
}
}
return super.onTouchEvent(event)
}
private fun drawCanvas() {
Thread {
while (flag) {
val canvas = surfaceHolder.lockCanvas()
canvas.drawPath(path, paint)
surfaceHolder.unlockCanvasAndPost(canvas)
}
} .start()
}
override fun surfaceCreated(holder: SurfaceHolder) {
flag = true
drawCanvas()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
flag = false
}
}
讀到這里或許會有幾個疑問
-
SurfaceView繪制操作必須在在子線程中嗎?
不是必須的,上述的例子,我們改造一下也可以放置在主線程中繪制。
override fun onTouchEvent(event: MotionEvent?): Boolean {
...
// 每次觸摸屏幕,都主動執行一次繪制
drawCanvas()
return super.onTouchEvent(event)
}
private fun drawCanvas() {
// Thread {
// while (flag) {
val canvas = surfaceHolder.lockCanvas()
canvas.drawPath(path, paint)
surfaceHolder.unlockCanvasAndPost(canvas)
// }
// }.start()
}
onTouchEvent
是在主線程中觸發的,我們把創建線程的代碼注釋掉,這樣繪制的這一步就會主線程中調用。在主線程中繪制,就必須把while(flag)
也注釋掉,否則主線程會持續停留在while循環中,導致UI發生ANR。
但是,這樣的操作是不必要的,如果可以繪制可以在主線程中執行,實際上就不應該使用SurfaceView
。
-
SurfaceView的繪制能否放在View.onDraw(canvas)中?
不行。SurfaceView在初始化時調用了setWillNotDraw(true)
表示該控件沒有需要繪制的內容,所以在屏幕刷新時,SurfaceView的onDraw()
方法默認是不會被調用。
當然我們也可以,再把它設定false來觸發onDraw()
,不過一般沒有這樣必要,因為SurfaceView自帶了緩沖畫布,并不需要onDraw()
中畫布。
init {
// 為了代碼的可讀性,這里并沒有使用Kotlin的簡寫
surfaceHolder = getHolder()
surfaceHolder?.addCallback(this)
setWillNotDraw( false )
}
setWillNotDraw()
是系統提供的一種優化策略,它可以系統跳過那些不需要繪制的控件,比如LinearLayout
在不需要繪制DividerDrawable
時就會聲明該屬性。
-
SurfaceView的畫布獲取時為何要加鎖?
這個很好理解了,因為SurfaceView是可以在子線程中執行繪制的,如果不對畫布加鎖,那么多個子線程同時更新畫布就會產生無法預期的情況,所以需要加鎖。
其實,對畫布加鎖也引入了新的問題。當一個線程對調用surfaceHolder.lockCanvas()
請求畫布時,另一個線程也在調用surfaceHolder.lockCanvas()
就會發生異常。如下所示
public Canvas lockCanvas(Rect inOutDirty)
throws Surface.OutOfResourcesException, IllegalArgumentException {
synchronized (mLock) {
checkNotReleasedLocked();
if (mLockedObject != 0) {
// 理想情況下,nativeLockCanvas()會在這種情況下引發并防止雙重鎖定,但如果mNativeObject被更新,則不會發生這種情況。
// 我們不能放棄舊的mLockedObject,因為它可能仍在使用中,所以我們只是拒絕重新鎖定Surface。
throw new IllegalArgumentException("Surface was already locked");
}
mLockedObject = nativeLockCanvas(mNativeObject, mCanvas, inOutDirty);
return mCanvas;
}
}
所以調用surfaceHolder.lockCanvas()
時要進行必要的非空判斷以及加入重試機制,繪制完成后,要及時釋放畫布。
4.Surface & SurfaceView & SurfaceHolder
上面我們介紹了SurfaceView的簡單用法,與SurfaceView相關的有三個概念分別是Surface、SurfaceView、SurfaceHolder。
4.1.Surface
Surface
是一個包含需要渲染到屏幕上的像素對象。屏幕上的每一個窗口都有自己的Surface
,而SurfaceFlinger
會按照正確的Z軸順序,將它們合成在屏幕上。
一個Surface
會有多個緩沖區來進行雙緩沖渲染
,顯示在屏幕上稱為前端緩沖區
,還沒有顯示在屏幕上的稱為后端緩沖區
,這樣應用程序可以先在后端緩沖區
繪制下一幀的內容,每隔一段時間交換兩塊緩沖區,這樣就不需要等待所有內容都繪制完畢,屏幕上就可以顯示出內容。
相信肯定有人會有這樣的疑問,Surface、Window、View之間是什么關系?
Window
基本上就是我們常見應用程序的窗口,WindowManger
會為每個Window
創建一個Surface
,并將其提供給應用程序進行繪制。對于WindowManager
來說,Surface
只是一個不透明的矩形而已。
View
是顯示在Window
內的可交互的UI元素。View
依附于Window
,并且運用Window
提供的Surface
進行UI繪制。
4.2.SurfaceView
SurfaceView是一種特殊View,上面我們提到過View用來繪制的Surface是Window提供的,但是SurfaceView不同。SurfaceView持有一個獨立的Surface,專門用于一些特殊且耗時的繪制。
SurfaceView 的常用方法
getHolder()
返回SurfaceHolder,提供對該SurfaceView底層Surface的訪問和控制。setSecure(isSecure: Boolean)
設定是否應將SurfaceView的內容視為安全內容,防止其出現在屏幕截圖中或在不安全的顯示器上查看。setZOrderMediaOverlay(isMediaOverlay: Boolean)
設定SurfaceView的Surface是否放置在窗口中另一個常規Surface的頂部(但仍位于窗口本身的后面)。setZOrderOnTop(onTop: Boolean)
設定SurfaceView的Surface是否放置在其窗口的頂部。
4.3.SurfaceHolder
從字面意義上來理解,它就是Surface的持有者,我們在使用SurfaceView時并不能直接操作Surface,否則可能會產生一些不可預期的操作,所以Android為我們提供SurfaceHolder來間接操作Surface。
讀到這里應該就能明白了SurfaceView屬于典型的MVC構型,Surface中保存著屏幕的繪制信息屬于Model
,在SurfaceView(View
)中借助Surface進行繪制時,需要通過SurfaceHolder(Controller
)來操作Surface。
SurfaceView的MVC構型非常具有參考意義,我們自己在編寫較為復雜的自定義View時,應該參考這種設計思路。
SurfaceHolder 的常用方法
-
addCallback ( SurfaceHolder.Callback callback)
監聽Surface的生命周期。
surfaceHolder?.addCallback(object : SurfaceHolder.Callback{
override fun surfaceCreated(holder: SurfaceHolder) {
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int
) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
}
})
surfaceCreated:當Surface被創建后,就會被立即調用。
surfaceChanged:當Surface發生任何結構性變化時,就會被立即調用。
surfaceDestroyed:當Surface被銷毀時,就會被立即調用。
removeCallback ( SurfaceHolder.Callback callback)
移除回調。Canvas lockCanvas ()
獲取Surface中CanvasCanvas lockCanvas ( Rect dirty)
獲取指定區域的Canvas。一般在需要局部更新時會用到。
有關“局部更新”下一小節會介紹。
Canvas lockHardwareCanvas **()******
獲取硬件加速的Canvas。unlockCanvasAndPost ( Canvas canvas)
釋放Canvas。使用Canvas繪制完成后,必須釋放Canvas才會顯示在屏幕上Surface getSurface ()
獲取Surface對象。Rect getSurfaceFrame ()
獲取當前Surface的大小boolean isCreating ()
Surface是否正在創建中void setFixedSize (int width, int height)
將Surface設定為固定大小void setFormat (int format)
設定Surface的像素格式void setSizeFromLayout ()
允許Surface根據容器的布局大小,調整Surface的大小。默認就會被調用。
5.SurfaceView 雙緩沖機制
5.1.概述
前面我們提到過Surface會基于雙緩沖機制
進行渲染,簡單來說就是,顯示在屏幕上的是前端緩沖區
,通過surfaceHolder.lockCanvas()
獲取到的是后端緩沖區
,調用surfaceHolder.unlockCanvasAndPost(canvas)
后會交換前后端緩沖區,此時用戶繪制在后端緩沖區上的內容就能顯示在屏幕上,如此循環。
這里的“緩沖區”就是我們用于繪制的“畫布”。
雙緩沖機制大大提升了圖形的渲染效率,但也造成一些問題,兩塊相互交替的畫布上面的內容肯定是不一樣的,在多線程的情況下尤其如此。
private fun drawCanvas() {
Thread {
for (i in 0..9) {
val canvas = surfaceHolder.lockCanvas()
Log.e("TAG", "drawCanvas: $i" )
canvas?.drawText("$i", i * 30f, 50f, paint)
surfaceHolder.unlockCanvasAndPost(canvas)
}
} .start()
}
例如,上面的代碼,每次循環時獲取畫布繪制完一個數字后,將前后端緩沖區交換,循環10次。運行代碼,我們看到的如下的情景。
屏幕為什么只顯示了4個數字?
這是因為其他的數字繪制在別的畫布上,所以沒有顯示出來。這也證明了,前端緩沖區
經過交換后,轉換為后端緩沖區
時,并沒有把正顯示在屏幕上的緩沖區內容復制下來。為什么是0 3 6 9?兩個緩沖區的不應該是1 3 5 7 9嗎?
原因很簡單,雖然名義上叫雙緩沖區
但其實并不止兩個緩沖區,在這個例子中,實際上有三個緩沖區。
根據Goodle的官方文檔解釋,Surface中緩沖區個數是根據需求動態分配的,如果用戶獲取畫布的頻率較慢,那么將會分配兩個緩沖區,否則,將分配3的倍數個緩沖區。總得來說,Surface分配的緩沖畫布數量會大于等于2,具體多少需要視情況而定。
private fun drawCanvas() {
Thread {
for (i in 0..9) {
val canvas = surfaceHolder.lockCanvas()
Log.e("TAG", "drawCanvas: $i")
canvas?.drawText("$i", i * 30f, 50f, paint)
surfaceHolder.unlockCanvasAndPost(canvas)
Thread.sleep(500)
}
} .start()
}
當我們將畫布的獲取頻率降低時,就可以看出每次緩沖區交換時繪制在屏幕上的內容。這里就可以很明顯的看出,當畫布的獲取頻率較慢時,系統只分配了兩個畫布。如下所示:
原理是搞清楚了,那么我們應該怎么把數字完整地繪制到畫布上呢?其實很簡單,把每次繪制存在起來,下一次繪制時,把上一次的數字也繪制出來就可以了。
private val nums: MutableList<Int> = mutableListOf()
private fun drawCanvas() {
Thread {
for (i in 0..9) {
val canvas = surfaceHolder.lockCanvas()
nums.add(i)
for (num in nums) {
canvas?.drawText("$num", num * 30f, 50f, paint)
}
surfaceHolder.unlockCanvasAndPost(canvas)
Thread.sleep(500)
}
} .start()
}
5.2.雙緩沖局部更新原理
在前面的SurfaceHolder常用方法中,我們提到lockCanvas``(rect)
一般會在需要SurfaceView局部更新時用到,那么它和lockCanvas()
有什么區別呢?
lockCanvas(): 獲取整屏畫布。獲取到的畫布,不是當前屏幕正在顯示的畫布。獲取到畫布中繪制的內容,并不一定是當前顯示在屏幕上的內容。具體原因,上面已經介紹過了。
lockCanvas (Rect dirty): 獲取指定的區域的畫布。 畫布以外的區域保持與屏幕內容一致,畫布內的區域依然保持原畫布內容。
lockCanvas(Rect dirty)
在使用時容易產生一些誤解,我們來看一個例子:
private fun drawCanvas() {
Thread {
// 第一次繪制
var canvas = surfaceHolder.lockCanvas(Rect(0, 0, 768, 768))
canvas.drawColor(Color.RED)
Log.e("TAG", "drawCanvas1:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
//第二次繪制
canvas = surfaceHolder.lockCanvas(Rect(100, 100, 600, 600))
canvas.drawColor(Color.BLUE)
Log.e("TAG", "drawCanvas2:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
//第三次繪制
canvas = surfaceHolder.lockCanvas(Rect(150, 150, 500, 500))
canvas.drawText("3.WU", 200f, 200f, paint)
Log.e("TAG", "drawCanvas3:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
//第四次繪制
canvas = surfaceHolder.lockCanvas(Rect(200, 200, 400, 400))
canvas.drawText("4.JIA", 200f, 300f, paint)
Log.e("TAG", "drawCanvas4:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
} .start()
}
第一次繪制:獲取區域畫布,繪制上藍色。
第二次繪制:獲取區域畫布,繪制成紅色。
第三次繪制:獲取區域畫布,繪制文字“3.WU”
第四次繪制:獲取區域畫布,繪制文字“4.JIA”
運行代碼后,我們會看到下面的情況
不知道繪制出的界面,是否與你期望的現象一樣呢?相信你看到這里或許會產生這樣幾個疑問
第三次繪制并沒有繪制背景,為什么背景是黑色的?
因為這里有三個緩沖畫布,第三次繪制時,使用的是一塊空白的畫布,空白畫布的默認背景就是黑色。-
第一次繪制應該是指定區域繪制成紅色,為什么看到的卻是全屏都是紅色?
打開運行日志,我們會發現:第一獲取指定區域的畫布大小,并不是我們指定的大小,實際上獲取到的是全屏畫布。
這是因為當我們第一次獲取畫布時,這塊畫布還沒有被畫過,屬于臟畫布,系統認為它都應該被畫上,所以返回了全屏的畫布。
由于這樣的機制存在,實際上我們在獲取區域畫布時需要判斷,是否是我們指定的區域畫布,否則就需要先把畫布清理一遍,才能獲取到我們期望的區域畫布。清屏代碼如下:
while (true) {
val canvas = surfaceHolder.lockCanvas(Rect(0, 0, 1, 1))
val rectCanvas = canvas.clipBounds
if (rectCanvas.height() == height && rectCanvas.width() == width) {
canvas.drawColor(Color.WHITE)
surfaceHolder.unlockCanvasAndPost(canvas)
} else {
surfaceHolder.unlockCanvasAndPost(canvas)
break
}
}
加入清屏代碼后,再來看看繪制的效果是怎樣的
完整的代碼如下:
private fun drawCanvas() {
Thread {
while (true) {
val canvas = surfaceHolder.lockCanvas(Rect(0, 0, 1, 1))
val rectCanvas = canvas.clipBounds
Log.e("TAG", "drawCanvas0:${rectCanvas} ")
if (rectCanvas.height() == height && rectCanvas.width() == width) {
canvas.drawColor(Color.WHITE)
surfaceHolder.unlockCanvasAndPost(canvas)
} else {
surfaceHolder.unlockCanvasAndPost(canvas)
break
}
}
// 第一次繪制
var canvas = surfaceHolder.lockCanvas(Rect(0, 0, 768, 768))
canvas.drawColor(Color.RED)
Log.e("TAG", "drawCanvas1:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
Thread.sleep(500)
//第二次繪制
canvas = surfaceHolder.lockCanvas(Rect(100, 100, 600, 600))
canvas.drawColor(Color.BLUE)
Log.e("TAG", "drawCanvas2:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
Thread.sleep(500)
//第三次繪制
canvas = surfaceHolder.lockCanvas(Rect(150, 150, 500, 500))
canvas.drawText("3.WU", 200f, 200f, paint)
Log.e("TAG", "drawCanvas3:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
Thread.sleep(500)
//第四次繪制
canvas = surfaceHolder.lockCanvas(Rect(200, 200, 400, 400))
canvas.drawText("4.JIA", 200f, 300f, paint)
Log.e("TAG", "drawCanvas4:${canvas.clipBounds} ")
surfaceHolder.unlockCanvasAndPost(canvas)
Thread.sleep(500)
} .start()
}
6.SurfaceView 使用注意事項
6.1.SurfaceView的背景
默認情況下SurfaceView渲染時會顯示黑色的背景,如果當我們需要顯示透明的背景可以使用如下的代碼。弊端是SurfaceView會顯示在Window的頂層,遮住其他的View。
surfaceHolder.setFormat(PixelFormat.TRANSPARENT)
setZOrderOnTop(true)
如果不希望遮住上層的View,那么折中的辦法是,在SurfaceView的畫布上把底層背景繪制出來。當容器的背景很復雜時,事情就會變得麻煩了,這種情況或許TextureView更合適。
private fun drawCanvas() {
Thread {
while (flag) {
val canvas = surfaceHolder.lockCanvas()
canvas?.drawColor(context.resources.getColor(R.color.purple_200))
canvas?.drawPath(path, paint)
surfaceHolder.unlockCanvasAndPost(canvas)
}
} .start()
}
6.2.畫布內容不一致
由于雙緩沖
機制的影響,我們獲取到的畫布上的內容,與我們實際期望的可能是不一樣的。解決方案有兩種
保存每次繪制的內容
這個解決方案就是第一個繪制軌跡的例子采用的方案。利用Path保存每次手指的軌跡,在獲取到畫布時,將Path整個繪制上去。內容不交叉時,增量繪制
當每次繪制的內容不會產生交叉時,也可以使用lockCanvas(Rect dirty)
,采用增量繪制的方式,只把怎么每次新增的內容繪制上去,使用lockCanvas(Rect dirty)
時要注意先清屏!