fix(mobile): stop loading every image of an entry at once

Entries in the Pictures view can carry dozens of images (cosplaytele
posts have 25 to 110), and MediaCarousel rendered every page of its
ScrollView, so each mounted row loaded all of them. Until onLayout the
carousel was 0 px tall, so FlashList measured rows at footer height,
mounted every row of the page and kept them in its recycle pool. The
lightbox likewise mounted the original of every page.

Load images only for the visible page and its neighbors in both, and
size the carousel by its aspect ratio before its width is measured.

On an Android emulator with 20 cosplaytele entries (1142 images),
opening the Pictures view took the native heap from 96 MB to 2.1 GB
before this change and to about 170 MB after it.

Refs #5108

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-16 11:19:29 +08:00
parent 4e489f4e5e
commit a044040bad
2 changed files with 36 additions and 20 deletions
@@ -3,7 +3,7 @@ import type { MediaModel } from "@follow/database/schemas/types"
import { useRecyclingState } from "@shopify/flash-list"
import type { ImageSource } from "expo-image"
import type { Ref } from "react"
import { useEffect, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { ScrollView, View } from "react-native"
import Animated, {
interpolateColor,
@@ -41,6 +41,15 @@ export const MediaCarousel = ({
const [containerWidth, setContainerWidth] = useState(0)
const containerHeight = Math.floor(containerWidth / aspectRatio)
const hasMany = media.length > 1
// Media without an image get no page, so a page's position can differ from its media index
const pages = useMemo(
() =>
media.flatMap((m, index) => {
const imageUrl = m.type === "video" ? m.preview_image_url : m.url
return imageUrl ? [{ media: m, index, imageUrl }] : []
}),
[media],
)
// The list recycles this row for other entries, which start at their first image
const scrollViewRef = useRef<ScrollView>(null)
@@ -54,7 +63,9 @@ export const MediaCarousel = ({
setContainerWidth(e.nativeEvent.layout.width)
}}
>
<View ref={ref} className="relative overflow-hidden rounded-md">
{/* Sized by the aspect ratio before the width is measured: a row laid out without its image
is so short that the list mounts far more rows than fit on screen */}
<View ref={ref} className="relative overflow-hidden rounded-md" style={{ aspectRatio }}>
<ScrollView
ref={scrollViewRef}
onMomentumScrollEnd={(e) => {
@@ -69,10 +80,12 @@ export const MediaCarousel = ({
// See https://github.com/Shopify/flash-list/issues/797
style={{ height: containerHeight }}
>
{media.map((m, index) => {
const imageUrl = m.type === "video" ? m.preview_image_url : m.url
if (!imageUrl) {
return null
{pages.map(({ media: m, index, imageUrl }, position) => {
// An entry can carry dozens of images, so only the visible page and its neighbors load
if (Math.abs(position - activeIndex) > 1) {
return (
<View key={imageUrl} style={{ width: containerWidth, height: containerHeight }} />
)
}
const proxy = {
height: 400,
@@ -320,20 +320,23 @@ function ImageView({
>
{images.map((imageSrc, i) => (
<View key={imageSrc.uri}>
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
isScaled={isScaled}
isFlyingAway={isFlyingAway}
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/>
{/* An entry can carry dozens of images, so only the visible page and its neighbors load */}
{Math.abs(i - imageIndex) <= 1 && (
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
isScaled={isScaled}
isFlyingAway={isFlyingAway}
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/>
)}
</View>
))}
</PagerView>