Skip to content

Commit ef7de21

Browse files
committed
Optimize performance of GradientMaskView by using ColorMatrix for maskOpacity adjustments instead of bitmap recreation. Update App.tsx to include stress test for performance validation.
1 parent e17e667 commit ef7de21

2 files changed

Lines changed: 138 additions & 30 deletions

File tree

android/src/main/java/expo/modules/gradientmask/GradientMaskView.kt

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import expo.modules.kotlin.views.ExpoView
1919
* maskOpacity 控制漸層 mask 效果的顯示程度:
2020
* - maskOpacity = 0 → 無漸層效果,內容完全可見(全部 alpha=255)
2121
* - maskOpacity = 1 → 完整漸層效果(使用原始 alpha)
22+
*
23+
* 效能優化:
24+
* - 基礎漸層 bitmap (baseMaskBitmap) 只在 colors/locations/direction/size 變化時重建
25+
* - maskOpacity 變化時只使用 ColorMatrix 調整 alpha,不重建 bitmap
2226
*/
2327
class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
2428

@@ -30,12 +34,19 @@ class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(cont
3034
// maskOpacity: 0 = 無漸層效果, 1 = 完整漸層效果
3135
private var maskOpacity: Float = 1f
3236

33-
// Mask bitmap 和相關的 Paint
34-
private var maskBitmap: Bitmap? = null
35-
private var maskBitmapInvalidated = true
37+
// 基礎漸層 bitmap(完整漸層效果,maskOpacity=1 時使用的原始漸層)
38+
private var baseMaskBitmap: Bitmap? = null
39+
// 是否需要重建基礎 bitmap(只有 colors/locations/direction/size 變化時才需要)
40+
private var baseBitmapInvalidated = true
41+
42+
// 繪製用的 Paint
3643
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
3744
private val porterDuffXferMode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
3845

46+
// 用於調整 mask alpha 的 ColorMatrix
47+
private val colorMatrix = ColorMatrix()
48+
private val colorMatrixFilter = ColorMatrixColorFilter(colorMatrix)
49+
3950
init {
4051
// 確保背景是透明的
4152
setBackgroundColor(Color.TRANSPARENT)
@@ -49,42 +60,46 @@ class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(cont
4960

5061
fun setColors(colorArray: List<Int>?) {
5162
colors = colorArray?.toIntArray()
52-
maskBitmapInvalidated = true
63+
baseBitmapInvalidated = true
5364
invalidate()
5465
}
5566

5667
fun setLocations(locationArray: List<Double>?) {
5768
locations = locationArray?.map { it.toFloat() }?.toFloatArray()
58-
maskBitmapInvalidated = true
69+
baseBitmapInvalidated = true
5970
invalidate()
6071
}
6172

6273
fun setDirection(dir: String) {
6374
direction = dir
64-
maskBitmapInvalidated = true
75+
baseBitmapInvalidated = true
6576
invalidate()
6677
}
6778

6879
fun setMaskOpacity(opacity: Double) {
69-
maskOpacity = opacity.toFloat().coerceIn(0f, 1f)
70-
maskBitmapInvalidated = true
71-
invalidate()
80+
val newOpacity = opacity.toFloat().coerceIn(0f, 1f)
81+
if (newOpacity != maskOpacity) {
82+
maskOpacity = newOpacity
83+
// 只需要 invalidate,不需要重建 bitmap
84+
// dispatchDraw 時會使用 ColorMatrix 來調整 alpha
85+
invalidate()
86+
}
7287
}
7388

7489
// MARK: - Layout
7590

7691
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
7792
super.onSizeChanged(w, h, oldw, oldh)
7893
if (w > 0 && h > 0) {
79-
updateMaskBitmap()
80-
maskBitmapInvalidated = false
94+
updateBaseMaskBitmap()
95+
baseBitmapInvalidated = false
8196
}
8297
}
8398

8499
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
85100
super.onLayout(changed, l, t, r, b)
86101
if (changed) {
87-
maskBitmapInvalidated = true
102+
baseBitmapInvalidated = true
88103
}
89104
}
90105

@@ -97,13 +112,13 @@ class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(cont
97112
return
98113
}
99114

100-
// 如果 mask bitmap 需要更新,重新創建
101-
if (maskBitmapInvalidated) {
102-
updateMaskBitmap()
103-
maskBitmapInvalidated = false
115+
// 如果基礎 bitmap 需要更新,重新創建
116+
if (baseBitmapInvalidated) {
117+
updateBaseMaskBitmap()
118+
baseBitmapInvalidated = false
104119
}
105120

106-
val bitmap = maskBitmap
121+
val bitmap = baseMaskBitmap
107122
// 如果沒有 mask bitmap 或 maskOpacity=0,直接繪製子元件(無 mask 效果)
108123
if (bitmap == null || maskOpacity <= 0f) {
109124
super.dispatchDraw(canvas)
@@ -122,19 +137,48 @@ class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(cont
122137
super.dispatchDraw(canvas)
123138

124139
// 應用 mask(使用 DST_IN 模式)
140+
// 使用 ColorMatrix 來調整 alpha,實現 maskOpacity 效果
141+
// 這樣就不需要每次 maskOpacity 變化都重建 bitmap
125142
paint.xfermode = porterDuffXferMode
143+
paint.colorFilter = if (maskOpacity < 1f) {
144+
// 使用 ColorMatrix 來混合原始 alpha 和完全不透明
145+
// maskOpacity = 0 → 所有像素的 alpha 變為 255(完全可見)
146+
// maskOpacity = 1 → 使用原始 alpha
147+
//
148+
// ColorMatrix 的 alpha 行:[0, 0, 0, scale, translate]
149+
// 結果 alpha = originalAlpha * scale + translate
150+
//
151+
// 我們想要:resultAlpha = 255 + (originalAlpha - 255) * maskOpacity
152+
// = 255 * (1 - maskOpacity) + originalAlpha * maskOpacity
153+
// 所以:scale = maskOpacity, translate = 255 * (1 - maskOpacity)
154+
colorMatrix.set(floatArrayOf(
155+
1f, 0f, 0f, 0f, 0f, // R
156+
0f, 1f, 0f, 0f, 0f, // G
157+
0f, 0f, 1f, 0f, 0f, // B
158+
0f, 0f, 0f, maskOpacity, 255f * (1f - maskOpacity) // A
159+
))
160+
ColorMatrixColorFilter(colorMatrix)
161+
} else {
162+
null
163+
}
126164
canvas.drawBitmap(bitmap, 0f, 0f, paint)
127165
paint.xfermode = null
166+
paint.colorFilter = null
128167
} finally {
129168
canvas.restoreToCount(saveCount)
130169
}
131170
}
132171

133-
private fun updateMaskBitmap() {
172+
/**
173+
* 更新基礎漸層 bitmap
174+
* 這個 bitmap 包含原始漸層效果(maskOpacity = 1 時的效果)
175+
* maskOpacity 的調整在 dispatchDraw 時透過 ColorMatrix 實現
176+
*/
177+
private fun updateBaseMaskBitmap() {
134178
if (width <= 0 || height <= 0) return
135179

136180
// 回收舊的 bitmap
137-
maskBitmap?.recycle()
181+
baseMaskBitmap?.recycle()
138182

139183
// 創建新的 mask bitmap
140184
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
@@ -148,25 +192,22 @@ class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(cont
148192
currentColors.size != currentLocations.size ||
149193
currentColors.isEmpty()) {
150194
bitmapCanvas.drawColor(Color.WHITE)
151-
maskBitmap = bitmap
195+
baseMaskBitmap = bitmap
152196
return
153197
}
154198

155-
// 計算 effective colors(根據 maskOpacity 混合
156-
val effectiveColors = IntArray(currentColors.size) { i ->
199+
// 轉換顏色為白色 + 原始 alpha(mask 只需要 alpha 通道
200+
val maskColors = IntArray(currentColors.size) { i ->
157201
val originalColor = currentColors[i]
158202
val originalAlpha = Color.alpha(originalColor)
159-
// 當 maskOpacity = 0,alpha = 255(完全不透明,內容完全可見)
160-
// 當 maskOpacity = 1,alpha = originalAlpha
161-
val blendedAlpha = (255 + (originalAlpha - 255) * maskOpacity).toInt()
162-
Color.argb(blendedAlpha, 255, 255, 255)
203+
Color.argb(originalAlpha, 255, 255, 255)
163204
}
164205

165206
// 建立 gradient shader
166207
val (startX, startY, endX, endY) = getGradientCoordinates()
167208
val shader = LinearGradient(
168209
startX, startY, endX, endY,
169-
effectiveColors,
210+
maskColors,
170211
currentLocations,
171212
Shader.TileMode.CLAMP
172213
)
@@ -177,13 +218,13 @@ class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(cont
177218
}
178219
bitmapCanvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gradientPaint)
179220

180-
maskBitmap = bitmap
221+
baseMaskBitmap = bitmap
181222
}
182223

183224
override fun onDetachedFromWindow() {
184225
super.onDetachedFromWindow()
185-
maskBitmap?.recycle()
186-
maskBitmap = null
226+
baseMaskBitmap?.recycle()
227+
baseMaskBitmap = null
187228
}
188229

189230
private fun getGradientCoordinates(): List<Float> {

example/App.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
* Two modes:
88
* - Half height mode (50%): Gradient starts from middle
99
* - Full height mode (95%): Gradient covers almost entire screen
10+
*
11+
* Performance optimization test:
12+
* - Android now uses ColorMatrix to adjust alpha instead of rebuilding bitmap
13+
* - This significantly improves maskOpacity animation performance
1014
*/
1115

1216
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -394,6 +398,7 @@ export default function CharacterChatScreenV3DTest() {
394398
const [autoAddMessages, setAutoAddMessages] = useState(false); // Auto add messages
395399
const [messages, setMessages] = useState<ChatMessage[]>(MOCK_MESSAGES);
396400
const messageCountRef = useRef(100); // Track message count
401+
const [isStressTest, setIsStressTest] = useState(false); // Stress test mode
397402

398403
// Mask opacity animation control (using Reanimated SharedValue)
399404
const maskOpacity = useSharedValue(0);
@@ -441,6 +446,30 @@ export default function CharacterChatScreenV3DTest() {
441446
return () => clearInterval(interval);
442447
}, [autoAddMessages]);
443448

449+
// Stress test: rapidly toggle maskOpacity to test ColorMatrix optimization
450+
// This tests the performance improvement where Android no longer rebuilds bitmap on opacity change
451+
useEffect(() => {
452+
if (!isStressTest) return;
453+
454+
const interval = setInterval(() => {
455+
// Toggle between 0 and 1 rapidly (every 100ms = 10 times per second)
456+
maskOpacity.value = withTiming(maskOpacity.value > 0.5 ? 0 : 1, {
457+
duration: 100,
458+
easing: Easing.linear,
459+
});
460+
}, 150);
461+
462+
return () => {
463+
clearInterval(interval);
464+
// Reset to 0 when stopping stress test
465+
maskOpacity.value = withTiming(0, { duration: 300 });
466+
};
467+
}, [isStressTest, maskOpacity]);
468+
469+
const toggleStressTest = useCallback(() => {
470+
setIsStressTest((prev) => !prev);
471+
}, []);
472+
444473
const toggleAutoAddMessages = useCallback(() => {
445474
setAutoAddMessages((prev) => !prev);
446475
}, []);
@@ -612,6 +641,22 @@ export default function CharacterChatScreenV3DTest() {
612641
<Text style={styles.messageCountText}>Messages: {messages.length}</Text>
613642
</View>
614643
</View>
644+
{/* Stress test button - tests ColorMatrix optimization on Android */}
645+
<View style={styles.toggleRow}>
646+
<Pressable
647+
style={[styles.stressTestButton, isStressTest && styles.stressTestButtonActive]}
648+
onPress={toggleStressTest}
649+
>
650+
<FontAwesome6
651+
name="bolt"
652+
size={12}
653+
color={isStressTest ? Colors.white : 'rgba(255,255,255,0.5)'}
654+
/>
655+
<Text style={[styles.stressTestText, isStressTest && styles.stressTestTextActive]}>
656+
{isStressTest ? 'Stop Stress Test' : 'Opacity Stress Test'}
657+
</Text>
658+
</Pressable>
659+
</View>
615660
</View>
616661

617662
{/* Info Banner */}
@@ -775,6 +820,28 @@ const styles = StyleSheet.create({
775820
autoAddTextActive: {
776821
color: Colors.white,
777822
},
823+
stressTestButton: {
824+
flexDirection: 'row',
825+
alignItems: 'center',
826+
justifyContent: 'center',
827+
backgroundColor: 'rgba(255, 255, 255, 0.1)',
828+
paddingVertical: 8,
829+
paddingHorizontal: 12,
830+
borderRadius: 8,
831+
gap: 6,
832+
marginTop: 8,
833+
},
834+
stressTestButtonActive: {
835+
backgroundColor: 'rgba(239, 68, 68, 0.7)',
836+
},
837+
stressTestText: {
838+
fontSize: 12,
839+
fontWeight: '600',
840+
color: 'rgba(255, 255, 255, 0.5)',
841+
},
842+
stressTestTextActive: {
843+
color: Colors.white,
844+
},
778845
messageCountBadge: {
779846
backgroundColor: 'rgba(0, 0, 0, 0.3)',
780847
paddingVertical: 8,

0 commit comments

Comments
 (0)