Introduce an image sizes cache to improve feed layouts (close #213) (#335)

* Introduce an image sizes cache to improve feed layouts (close #213)

* Clear out resolved promises from the image cache
zio/stable
Paul Frazee 2023-03-21 12:59:10 -05:00 committed by GitHub
parent c1d454b7cf
commit 858d4c8c88
7 changed files with 92 additions and 30 deletions

View File

@ -5,6 +5,11 @@ import RNFS from 'react-native-fs'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
export interface Dim {
width: number
height: number
}
export interface DownloadAndResizeOpts { export interface DownloadAndResizeOpts {
uri: string uri: string
width: number width: number
@ -119,10 +124,6 @@ export async function compressIfNeeded(
return finalImg return finalImg
} }
export interface Dim {
width: number
height: number
}
export function scaleDownDimensions(dim: Dim, max: Dim): Dim { export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
if (dim.width < max.width && dim.height < max.height) { if (dim.width < max.width && dim.height < max.height) {
return dim return dim

View File

@ -0,0 +1,37 @@
import {Image} from 'react-native'
import {Dim} from 'lib/media/manip'
export class ImageSizesCache {
sizes: Map<string, Dim> = new Map()
private activeRequests: Map<string, Promise<Dim>> = new Map()
constructor() {}
get(uri: string): Dim | undefined {
return this.sizes.get(uri)
}
async fetch(uri: string): Promise<Dim> {
const dim = this.sizes.get(uri)
if (dim) {
return dim
}
const prom =
this.activeRequests.get(uri) ||
new Promise<Dim>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
})
this.activeRequests.set(uri, prom)
const res = await prom
this.activeRequests.delete(uri)
this.sizes.set(uri, res)
return res
}
}

View File

@ -1,10 +1,10 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {LRUMap} from 'lru_map' import {LRUMap} from 'lru_map'
import {RootStoreModel} from './root-store' import {RootStoreModel} from '../root-store'
import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta' import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
type CacheValue = Promise<LinkMeta> | LinkMeta type CacheValue = Promise<LinkMeta> | LinkMeta
export class LinkMetasViewModel { export class LinkMetasCache {
cache: LRUMap<string, CacheValue> = new LRUMap(100) cache: LRUMap<string, CacheValue> = new LRUMap(100)
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {

View File

@ -1,6 +1,6 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
const CACHE_TTL = 1000 * 60 * 60 // hourly const CACHE_TTL = 1000 * 60 * 60 // hourly
@ -16,7 +16,7 @@ type Profile =
* follows. It should be periodically refreshed and updated any time * follows. It should be periodically refreshed and updated any time
* the user makes a change to their follows. * the user makes a change to their follows.
*/ */
export class MyFollowsModel { export class MyFollowsCache {
// data // data
followDidToRecordMap: Record<string, string> = {} followDidToRecordMap: Record<string, string> = {}
lastSync = 0 lastSync = 0

View File

@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {FeedModel} from './feed-view' import {FeedModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view' import {NotificationsViewModel} from './notifications-view'
import {MyFollowsModel} from './my-follows' import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
export class MeModel { export class MeModel {
@ -15,7 +15,7 @@ export class MeModel {
followersCount: number | undefined followersCount: number | undefined
mainFeed: FeedModel mainFeed: FeedModel
notifications: NotificationsViewModel notifications: NotificationsViewModel
follows: MyFollowsModel follows: MyFollowsCache
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@ -27,7 +27,7 @@ export class MeModel {
algorithm: 'reverse-chronological', algorithm: 'reverse-chronological',
}) })
this.notifications = new NotificationsViewModel(this.rootStore, {}) this.notifications = new NotificationsViewModel(this.rootStore, {})
this.follows = new MyFollowsModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore)
} }
clear() { clear() {

View File

@ -13,10 +13,11 @@ import {LogModel} from './log'
import {SessionModel} from './session' import {SessionModel} from './session'
import {ShellUiModel} from './ui/shell' import {ShellUiModel} from './ui/shell'
import {ProfilesViewModel} from './profiles-view' import {ProfilesViewModel} from './profiles-view'
import {LinkMetasViewModel} from './link-metas-view' import {LinkMetasCache} from './cache/link-metas'
import {NotificationsViewItemModel} from './notifications-view' import {NotificationsViewItemModel} from './notifications-view'
import {MeModel} from './me' import {MeModel} from './me'
import {resetToTab} from '../../Navigation' import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes'
export const appInfo = z.object({ export const appInfo = z.object({
build: z.string(), build: z.string(),
@ -34,7 +35,8 @@ export class RootStoreModel {
shell = new ShellUiModel(this) shell = new ShellUiModel(this)
me = new MeModel(this) me = new MeModel(this)
profiles = new ProfilesViewModel(this) profiles = new ProfilesViewModel(this)
linkMetas = new LinkMetasViewModel(this) linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache()
// HACK // HACK
// this flag is to track the lexicon breaking refactor // this flag is to track the lexicon breaking refactor

View File

@ -1,7 +1,15 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' import {
import Image, {OnLoadEvent} from 'view/com/util/images/Image' Image,
StyleProp,
StyleSheet,
TouchableOpacity,
ViewStyle,
} from 'react-native'
// import Image from 'view/com/util/images/Image'
import {clamp} from 'lib/numbers' import {clamp} from 'lib/numbers'
import {useStores} from 'state/index'
import {Dim} from 'lib/media/manip'
export const DELAY_PRESS_IN = 500 export const DELAY_PRESS_IN = 500
const MIN_ASPECT_RATIO = 0.33 // 1/3 const MIN_ASPECT_RATIO = 0.33 // 1/3
@ -22,16 +30,27 @@ export function AutoSizedImage({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const [aspectRatio, setAspectRatio] = React.useState<number>(1) const store = useStores()
const onLoad = (e: OnLoadEvent) => { const [dim, setDim] = React.useState<Dim | undefined>(
setAspectRatio( store.imageSizes.get(uri),
clamp( )
e.nativeEvent.width / e.nativeEvent.height, const [aspectRatio, setAspectRatio] = React.useState<number>(
MIN_ASPECT_RATIO, dim ? calc(dim) : 1,
MAX_ASPECT_RATIO, )
), React.useEffect(() => {
) let aborted = false
} if (dim) {
return
}
store.imageSizes.fetch(uri).then(newDim => {
if (aborted) {
return
}
setDim(newDim)
setAspectRatio(calc(newDim))
})
}, [dim, setDim, setAspectRatio, store, uri])
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={onPress} onPress={onPress}
@ -39,16 +58,19 @@ export function AutoSizedImage({
onPressIn={onPressIn} onPressIn={onPressIn}
delayPressIn={DELAY_PRESS_IN} delayPressIn={DELAY_PRESS_IN}
style={[styles.container, style]}> style={[styles.container, style]}>
<Image <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
style={[styles.image, {aspectRatio}]}
source={{uri}}
onLoad={onLoad}
/>
{children} {children}
</TouchableOpacity> </TouchableOpacity>
) )
} }
function calc(dim: Dim) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
overflow: 'hidden', overflow: 'hidden',