Skip to content

Commit 50bcfb1

Browse files
wuyu8512Kanscape
andauthored
perf(android): cut cover placeholder cost and bound the infinite list window (#166)
* perf(android): halve cover placeholder cost on grid screens Android cover tiles did two rounds of the same work. `BookCoverBlurHash` stacked an expo-image BlurHash layer *and* a Jetpack Compose `Host` that decoded the identical BlurHash again, so every tile paid for a ComposeView, its own composition and an AndroidComposeView owner. A Perfetto capture of a scripted Discover -> ranking scroll showed 68 `Compose:initializeView`, 155 `Compose:recompose` (161ms) and 97 per-tile ComposeView draws in a single 8s scroll, plus 416ms of GC (worst pause 113ms) from the churn. - Replace the Compose `ExpoUIView` with a plain `ExpoView` that draws the bitmap itself, and memoise decoded placeholders in a 256-entry LRU (~2MB) so re-mounted covers cost a cache lookup. Adds `android.os.Trace` sections for future captures. - Drop the redundant expo-image BlurHash layer. - Rename the native size props to `decodeWidth`/`decodeHeight`: `width` and `height` collide with React Native layout style names, so Yoga sized the placeholder to 32x48dp (112x168px) instead of filling the tile. The removed expo-image layer had been masking that bug. - Unmount the placeholder once the cover is displayed; it was invisible but still drawn every frame. - Replace the per-cover RN `Animated` fade with expo-image's native `transition`, removing one view and one Animated node per tile. - Memoise `BookCoverGridItem` and change `onPress` to receive the book so every grid can hoist a stable handler; activating one cover no longer re-renders the whole list. All seven call sites migrated. - Clip offscreen rows on the ranking list (`removeClippedSubviews`). Measured on a OnePlus PLC110 (Android 16, 144Hz), release builds, three alternating runs per side, medians: ranking scroll p90 25.2ms -> 19.1ms, p99 38.7ms -> 29.7ms, over-budget frames 148/202 -> 67/176, dropped 21 -> 11 discover scroll p50 7.99ms -> 7.56ms cold scroll p90 15.4ms -> 12.3ms, draw slices 1972ms -> 1023ms GC during scroll 416ms -> 0ms, JS thread CPU 873ms -> 463ms Compose init 68 -> 3 per run Remaining cost is outside the view layer: 64 cover decodes at 7.5ms each (expo-image already downsamples to the view box on Android) and 492ms of `dequeueBuffer` waits from buffer stuffing. * perf(mobile): bound the all-novels list window Scrolling the infinite novel grid retained RN's default ~21 screens of rows and their cover bitmaps: peak RSS measured 1.28GB across 30s of scrolling on a PLC110, which is a large LMK risk on smaller devices. windowSize=11 cuts that to 1.02GB (3 runs, all below every baseline run) and drops 154 -> 116 (fling) and 128 -> 110 (relentless) dropped frames, with per-second throughput unchanged at 101-102fps median. removeClippedSubviews, maxToRenderPerBatch and updateCellsBatchingPeriod were measured on the same scenario and rejected: they tripled draw work (6.4s -> 16.4s of draw slices) and added missed vsyncs. * refactor(mobile): trim review noise and gate list clipping to Android - removeClippedSubviews on the ranking grid was unconditional, unlike the four other lists in this app; on iOS it is known to blank out cells in multi-column lists, so it now matches the existing Android-only gate. - The same "module scope keeps the handler identity stable" paragraph was copy-pasted above five hoisted navigation helpers; the reason belongs once, on BookCoverGridItem's memo, and that comment already says it. - Dropped comments that narrate the previous implementation (Compose host, the doubled expo-image placeholder layer, the Animated fade) since the code no longer contains any of it, and deduplicated the decodeWidth / decodeHeight warning down to the prop registration that needs it. - Removed the zero-duration Trace marker on the BlurHash cache-hit path: it existed only for an A/B measurement and ran on every placeholder render. - shelf-screen's press handler shadowed the outer `book`; renamed the parameter. * fix(ios): register secondary screen scroll owners --------- Co-authored-by: Kanscape <Kanscape@celia.sh>
1 parent 395859a commit 50bcfb1

15 files changed

Lines changed: 346 additions & 297 deletions
Lines changed: 108 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,117 @@
11
package sh.celia.novella.modules.novellaui
22

3+
import android.content.Context
34
import android.graphics.Bitmap
4-
import androidx.compose.foundation.Image
5-
import androidx.compose.runtime.Composable
6-
import androidx.compose.runtime.remember
7-
import androidx.compose.ui.Modifier
8-
import androidx.compose.ui.graphics.asImageBitmap
9-
import androidx.compose.ui.layout.ContentScale
10-
import expo.modules.kotlin.views.ComposeProps
11-
import expo.modules.kotlin.views.FunctionalComposableScope
12-
import expo.modules.kotlin.views.OptimizedComposeProps
13-
import expo.modules.ui.ModifierList
14-
import expo.modules.ui.ModifierRegistry
5+
import android.graphics.Canvas
6+
import android.graphics.Paint
7+
import android.graphics.Rect
8+
import android.os.Trace
9+
import android.util.LruCache
10+
import expo.modules.kotlin.AppContext
11+
import expo.modules.kotlin.views.ExpoView
1512

16-
@OptimizedComposeProps
17-
data class BlurHashProps(
18-
val blurHash: String = "",
19-
val width: Int = 32,
20-
val height: Int = 48,
21-
val modifiers: ModifierList = emptyList()
22-
) : ComposeProps
13+
/**
14+
* Decoded placeholder cache. Cover placeholders are 32x48 (~6KB each), so 256
15+
* entries cost well under 2MB while removing every repeated decode: grids
16+
* re-mount the same covers constantly while scrolling and recycling.
17+
*
18+
* Bitmaps are immutable and only ever read, so handing the same instance to
19+
* several ImageViews is safe; eviction merely drops our reference because the
20+
* views still hold theirs.
21+
*/
22+
private object BlurHashBitmaps {
23+
private const val MAX_ENTRIES = 256
24+
private val cache = LruCache<String, Bitmap>(MAX_ENTRIES)
2325

24-
@Composable
25-
fun FunctionalComposableScope.BlurHashContent(props: BlurHashProps) {
26-
val width = props.width.coerceIn(1, 128)
27-
val height = props.height.coerceIn(1, 128)
28-
val bitmap = remember(props.blurHash, width, height) {
29-
// Expo Image's Android cosine cache is keyed only by dimension * component
30-
// count. Different dimension/component pairs can collide and produce black
31-
// bands. Keep Expo's native decoder but bypass that unsafe global cache for
32-
// these tiny placeholders.
33-
decodeWithExpoImage(props.blurHash, width, height)
26+
fun get(blurHash: String, width: Int, height: Int): Bitmap? {
27+
if (blurHash.isEmpty()) return null
28+
val key = "$blurHash|$width|$height"
29+
cache.get(key)?.let { return it }
30+
Trace.beginSection("NovellaBlurHash.decode")
31+
val bitmap = try {
32+
decodeWithExpoImage(blurHash, width, height)
33+
} finally {
34+
Trace.endSection()
35+
}
36+
if (bitmap != null) cache.put(key, bitmap)
37+
return bitmap
3438
}
35-
if (bitmap != null) {
36-
Image(
37-
bitmap = bitmap.asImageBitmap(),
38-
contentDescription = null,
39-
contentScale = ContentScale.Crop,
40-
modifier = ModifierRegistry.applyModifiers(
41-
props.modifiers,
42-
appContext,
43-
composableScope,
44-
globalEventDispatcher
39+
40+
private fun decodeWithExpoImage(blurHash: String, width: Int, height: Int): Bitmap? =
41+
runCatching {
42+
// expo-image is autolinked into Expo's aggregated Android module and is not
43+
// available as a separate Gradle project dependency in SDK 57. Resolve its
44+
// bundled decoder at runtime so this adapter can select useCache=false
45+
// without copying the BlurHash algorithm or moving pixels through JS.
46+
//
47+
// Expo Image's own cosine cache is keyed only by dimension * component
48+
// count, so different dimension/component pairs collide and produce black
49+
// bands. We keep Expo's decoder but bypass that unsafe global cache and
50+
// memoise the finished bitmaps here instead.
51+
val decoderClass = Class.forName("expo.modules.image.blurhash.BlurhashDecoder")
52+
val decoder = decoderClass.getField("INSTANCE").get(null)
53+
val decode = decoderClass.getMethod(
54+
"decode",
55+
String::class.java,
56+
Int::class.javaPrimitiveType,
57+
Int::class.javaPrimitiveType,
58+
Float::class.javaPrimitiveType,
59+
Boolean::class.javaPrimitiveType
4560
)
46-
)
47-
}
61+
decode.invoke(decoder, blurHash, width, height, 1f, false) as? Bitmap
62+
}.getOrNull()
4863
}
4964

50-
private fun decodeWithExpoImage(blurHash: String, width: Int, height: Int): Bitmap? =
51-
runCatching {
52-
// expo-image is autolinked into Expo's aggregated Android module and is not
53-
// available as a separate Gradle project dependency in SDK 57. Resolve its
54-
// bundled decoder at runtime so this adapter can select useCache=false
55-
// without copying the BlurHash algorithm or moving pixels through JS.
56-
val decoderClass = Class.forName("expo.modules.image.blurhash.BlurhashDecoder")
57-
val decoder = decoderClass.getField("INSTANCE").get(null)
58-
val decode = decoderClass.getMethod(
59-
"decode",
60-
String::class.java,
61-
Int::class.javaPrimitiveType,
62-
Int::class.javaPrimitiveType,
63-
Float::class.javaPrimitiveType,
64-
Boolean::class.javaPrimitiveType
65-
)
66-
decode.invoke(decoder, blurHash, width, height, 1f, false) as? Bitmap
67-
}.getOrNull()
65+
/**
66+
* Plain View-backed BlurHash placeholder.
67+
*
68+
* The bitmap is drawn by this view itself rather than by a child ImageView:
69+
* React Native sizes the exported view but never measures its native children,
70+
* which left a child collapsed in the corner instead of filling the tile.
71+
*/
72+
class BlurHashView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
73+
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
74+
private val destination = Rect()
75+
76+
private var bitmap: Bitmap? = null
77+
private var blurHash: String = ""
78+
private var decodeWidth: Int = 32
79+
private var decodeHeight: Int = 48
80+
81+
init {
82+
setWillNotDraw(false)
83+
}
84+
85+
fun setBlurHash(value: String) {
86+
if (value == blurHash) return
87+
blurHash = value
88+
render()
89+
}
90+
91+
fun setDecodeWidth(value: Int) {
92+
val next = value.coerceIn(1, 128)
93+
if (next == decodeWidth) return
94+
decodeWidth = next
95+
render()
96+
}
97+
98+
fun setDecodeHeight(value: Int) {
99+
val next = value.coerceIn(1, 128)
100+
if (next == decodeHeight) return
101+
decodeHeight = next
102+
render()
103+
}
104+
105+
override fun onDraw(canvas: Canvas) {
106+
val current = bitmap ?: return
107+
// Placeholder and tile share the cover aspect ratio, so filling the bounds
108+
// matches the previous ContentScale.Crop without any cropping maths.
109+
destination.set(0, 0, width, height)
110+
canvas.drawBitmap(current, null, destination, paint)
111+
}
112+
113+
private fun render() {
114+
bitmap = BlurHashBitmaps.get(blurHash, decodeWidth, decodeHeight)
115+
invalidate()
116+
}
117+
}

apps/mobile/modules/novella-ui/android/src/main/java/sh/celia/novella/modules/novellaui/NovellaUiModule.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@ class NovellaUiModule : Module() {
88
override fun definition() = ModuleDefinition {
99
Name("NovellaUi")
1010

11-
ExpoUIView<BlurHashProps>("BlurHash") {
12-
Content { props -> BlurHashContent(props) }
11+
View(BlurHashView::class) {
12+
Name("BlurHash")
13+
14+
// NOT "width"/"height": those collide with React Native's layout style
15+
// props and Yoga would size the view to the decode dimensions.
16+
Prop("blurHash") { view: BlurHashView, value: String -> view.setBlurHash(value) }
17+
Prop("decodeWidth") { view: BlurHashView, value: Int -> view.setDecodeWidth(value) }
18+
Prop("decodeHeight") { view: BlurHashView, value: Int -> view.setDecodeHeight(value) }
1319
}
1420

1521
ExpoUIView<BottomSheetProps>("BottomSheet") {
Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
1-
import type { PrimitiveBaseProps } from '@expo/ui/jetpack-compose';
2-
import { createViewModifierEventListener } from '@expo/ui/jetpack-compose/modifiers';
31
import { requireNativeView } from 'expo';
2+
import type { StyleProp, ViewStyle } from 'react-native';
43

5-
export interface NativeBlurHashProps extends PrimitiveBaseProps {
4+
export interface NativeBlurHashProps {
65
blurHash: string;
7-
height: number;
8-
width: number;
6+
/** Decode resolution in pixels, not layout size. */
7+
decodeHeight: number;
8+
decodeWidth: number;
9+
style?: StyleProp<ViewStyle>;
910
}
1011

11-
const NativeView = requireNativeView<NativeBlurHashProps>('NovellaUi', 'BlurHash');
12-
13-
export function NativeBlurHash({ modifiers, ...props }: NativeBlurHashProps) {
14-
return (
15-
<NativeView
16-
{...props}
17-
{...(modifiers ? { modifiers } : {})}
18-
{...(modifiers ? createViewModifierEventListener(modifiers) : {})}
19-
/>
20-
);
21-
}
12+
export const NativeBlurHash = requireNativeView<NativeBlurHashProps>('NovellaUi', 'BlurHash');
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import type { PrimitiveBaseProps } from '@expo/ui/jetpack-compose';
1+
import type { StyleProp, ViewStyle } from 'react-native';
22

3-
export interface NativeBlurHashProps extends PrimitiveBaseProps {
3+
export interface NativeBlurHashProps {
44
blurHash: string;
5-
height: number;
6-
width: number;
5+
decodeHeight: number;
6+
decodeWidth: number;
7+
style?: StyleProp<ViewStyle>;
78
}
89

10+
/** iOS renders BlurHash placeholders through expo-image; nothing native here. */
911
export function NativeBlurHash(_props: NativeBlurHashProps): null {
1012
return null;
1113
}
Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { Host } from '@expo/ui';
2-
import { fillMaxSize } from '@expo/ui/jetpack-compose/modifiers';
3-
import { Image } from 'expo-image';
41
import { StyleSheet } from 'react-native';
52

63
import { NativeBlurHash } from '../../modules/novella-ui';
@@ -13,21 +10,11 @@ export function BookCoverBlurHash({
1310
placeholder: ExpoBlurHashPlaceholder;
1411
}) {
1512
return (
16-
<>
17-
<Image
18-
accessibilityElementsHidden
19-
contentFit="cover"
20-
source={placeholder}
21-
style={StyleSheet.absoluteFill}
22-
/>
23-
<Host style={StyleSheet.absoluteFill} useViewportSizeMeasurement>
24-
<NativeBlurHash
25-
blurHash={placeholder.blurhash}
26-
height={placeholder.height}
27-
modifiers={[fillMaxSize()]}
28-
width={placeholder.width}
29-
/>
30-
</Host>
31-
</>
13+
<NativeBlurHash
14+
blurHash={placeholder.blurhash}
15+
decodeHeight={placeholder.height}
16+
decodeWidth={placeholder.width}
17+
style={StyleSheet.absoluteFill}
18+
/>
3219
);
3320
}

apps/mobile/src/components/book-cover-grid-item.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IconCheck, IconGripVertical } from '@tabler/icons-react-native';
2+
import { memo } from 'react';
23
import { useTranslation } from 'react-i18next';
34
import {
45
Pressable,
@@ -34,14 +35,19 @@ interface BookCoverGridItemProps {
3435
networkImageEnabled?: boolean;
3536
onAccessibilityAction?: (event: AccessibilityActionEvent) => void;
3637
onLongPress?: (event: GestureResponderEvent) => void;
37-
onPress?: () => void;
38+
/** Receives the rendered book so callers can hoist a stable handler. */
39+
onPress?: (book: BookListItem) => void;
3840
onPressOut?: () => void;
3941
/** Leaderboard position; renders a gold/silver/bronze badge for ranks 1-3. */
4042
rank?: number;
4143
tileWidth: number;
4244
}
4345

44-
export function BookCoverGridItem({
46+
/**
47+
* Memoised: grids re-render on every cover-activation change, and without this
48+
* every tile in the list re-rendered for one newly activated cover.
49+
*/
50+
export const BookCoverGridItem = memo(function BookCoverGridItem({
4551
accessibilityActions,
4652
animateCachedImage,
4753
book,
@@ -72,7 +78,7 @@ export function BookCoverGridItem({
7278
delayLongPress={180}
7379
onAccessibilityAction={onAccessibilityAction}
7480
onLongPress={onLongPress}
75-
onPress={onPress}
81+
onPress={onPress ? () => onPress(book) : undefined}
7682
onPressOut={onPressOut}
7783
style={[styles.item, { width: tileWidth }]}
7884
>
@@ -127,7 +133,7 @@ export function BookCoverGridItem({
127133
</View>
128134
</Pressable>
129135
);
130-
}
136+
});
131137

132138
function RankBadge({ rank }: { rank: number }) {
133139
const styles = useBookCoverGridItemStyles();

0 commit comments

Comments
 (0)