Skip to content

Commit

Permalink
feat(image): enabling basic cache control for android (#47182)
Browse files Browse the repository at this point in the history
Summary:
Fixes #12606

Previously, `Image` cache control options were not functional on Android, even though they were being passed to the native component via the `source` prop. This PR addresses that by implementing logic to manage cache behaviour on Android.

When the `reload` option is explicitly set, the image is now evicted from both memory and disk caches before a new request is made. This ensures the image is always fetched from the source, aligning the caching behaviour between Android and iOS for the `default` and `reload` options.

## Changelog:

[ANDROID][ADDED] - Enabling basic `Image` cache control for Android

Pull Request resolved: #47182

Test Plan:
Added a new example to the `rn-tester`, where we can notice that the image on the right is reloaded if rendered or re-rendered as the cache policy is set to `reload`. The image on the left has the cache policy set to `default` and won't be re-rendered as the image is already in the cache.  See the video below:

https://github.com/user-attachments/assets/88bc1d2d-0239-4deb-bcde-fe0ce521ff4d

Also tested on both old and new architecture.

Reviewed By: NickGerleman

Differential Revision: D64915440

Pulled By: Abbondanzo

fbshipit-source-id: 32e1c55dd20bf96ab0f69ef900d821c3c2552ef7
  • Loading branch information
mateoguzmana authored and facebook-github-bot committed Oct 31, 2024
1 parent a12036f commit e5dd7d6
Show file tree
Hide file tree
Showing 9 changed files with 144 additions and 20 deletions.
2 changes: 1 addition & 1 deletion packages/react-native/Libraries/Image/ImageSource.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface ImageURISource {
* to a URL load request, no attempt is made to load the data from the originating source,
* and the load is considered to have failed.
*
* @platform ios
* @platform ios (for `force-cache` and `only-if-cached`)
*/
cache?: 'default' | 'reload' | 'force-cache' | 'only-if-cached' | undefined;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/Libraries/Image/ImageSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export interface ImageURISource {
* to a URL load request, no attempt is made to load the data from the originating source,
* and the load is considered to have failed.
*
* @platform ios
* @platform ios (for `force-cache` and `only-if-cached`)
*/
+cache?: ?('default' | 'reload' | 'force-cache' | 'only-if-cached');

Expand Down
17 changes: 15 additions & 2 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -3377,14 +3377,25 @@ public final class com/facebook/react/modules/fresco/FrescoModule$Companion {
public final fun hasBeenInitialized ()Z
}

public final class com/facebook/react/modules/fresco/ImageCacheControl : java/lang/Enum {
public static final field DEFAULT Lcom/facebook/react/modules/fresco/ImageCacheControl;
public static final field RELOAD Lcom/facebook/react/modules/fresco/ImageCacheControl;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lcom/facebook/react/modules/fresco/ImageCacheControl;
public static fun values ()[Lcom/facebook/react/modules/fresco/ImageCacheControl;
}

public final class com/facebook/react/modules/fresco/ReactNetworkImageRequest : com/facebook/imagepipeline/request/ImageRequest {
public static final field Companion Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest$Companion;
public synthetic fun <init> (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public static final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest;
public static final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest;
}

public final class com/facebook/react/modules/fresco/ReactNetworkImageRequest$Companion {
public final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest;
public final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest;
public static synthetic fun fromBuilderWithHeaders$default (Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest$Companion;Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;ILjava/lang/Object;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest;
}

public final class com/facebook/react/modules/fresco/SystraceRequestListener : com/facebook/imagepipeline/listener/BaseRequestListener {
Expand Down Expand Up @@ -6707,8 +6718,10 @@ public class com/facebook/react/views/imagehelper/ImageSource {
public fun <init> (Landroid/content/Context;Ljava/lang/String;)V
public fun <init> (Landroid/content/Context;Ljava/lang/String;D)V
public fun <init> (Landroid/content/Context;Ljava/lang/String;DD)V
public synthetic fun <init> (Landroid/content/Context;Ljava/lang/String;DDILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Landroid/content/Context;Ljava/lang/String;DDLcom/facebook/react/modules/fresco/ImageCacheControl;)V
public synthetic fun <init> (Landroid/content/Context;Ljava/lang/String;DDLcom/facebook/react/modules/fresco/ImageCacheControl;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun equals (Ljava/lang/Object;)Z
public final fun getCacheControl ()Lcom/facebook/react/modules/fresco/ImageCacheControl;
public final fun getSize ()D
public final fun getSource ()Ljava/lang/String;
public static final fun getTransparentBitmapImageSource (Landroid/content/Context;)Lcom/facebook/react/views/imagehelper/ImageSource;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.modules.fresco

public enum class ImageCacheControl {
/** Uses OkHttp's default cache control policy with no store. */
DEFAULT,
/**
* The data for the URL will be loaded from the originating source. No existing cache data should
* be used to satisfy a URL load request.
*/
RELOAD,
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ public class ReactNetworkImageRequest
private constructor(
builder: ImageRequestBuilder,
/** Headers for the request */
internal val headers: ReadableMap?
internal val headers: ReadableMap?,
internal val cacheControl: ImageCacheControl,
) : ImageRequest(builder) {

public companion object {
@JvmStatic
@JvmOverloads
public fun fromBuilderWithHeaders(
builder: ImageRequestBuilder,
headers: ReadableMap?
headers: ReadableMap?,
cacheControl: ImageCacheControl = ImageCacheControl.DEFAULT,
): ReactNetworkImageRequest {
return ReactNetworkImageRequest(builder, headers)
return ReactNetworkImageRequest(builder, headers, cacheControl)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,27 @@ internal class ReactOkHttpNetworkFetcher(private val okHttpClient: OkHttpClient)
fetchState.submitTime = SystemClock.elapsedRealtime()
val uri = fetchState.uri
var requestHeaders: Map<String, String>? = null
val cacheControlBuilder = CacheControl.Builder().noStore()
if (fetchState.context.imageRequest is ReactNetworkImageRequest) {
val networkImageRequest = fetchState.context.imageRequest as ReactNetworkImageRequest
requestHeaders = getHeaders(networkImageRequest.headers)
when (networkImageRequest.cacheControl) {
ImageCacheControl.RELOAD -> {
cacheControlBuilder.noCache()
}
ImageCacheControl.DEFAULT -> {
// No-op
}
}
}
val headers = OkHttpCompat.getHeadersFromMap(requestHeaders)
val request =
Request.Builder()
.cacheControl(CacheControl.Builder().noStore().build())
.cacheControl(cacheControlBuilder.build())
.url(uri.toString())
.headers(headers)
.get()
.build()
fetchWithRequest(fetchState, callback, request)
}

private companion object {
private const val TAG = "ReactOkHttpNetworkFetcher"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import com.facebook.common.references.CloseableReference
import com.facebook.common.util.UriUtil
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.drawee.controller.AbstractDraweeControllerBuilder
import com.facebook.drawee.controller.ControllerListener
import com.facebook.drawee.controller.ForwardingControllerListener
Expand All @@ -49,6 +50,7 @@ import com.facebook.react.common.annotations.UnstableReactNativeAPI
import com.facebook.react.common.annotations.VisibleForTesting
import com.facebook.react.common.build.ReactBuildConfig
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
import com.facebook.react.modules.fresco.ImageCacheControl
import com.facebook.react.modules.fresco.ReactNetworkImageRequest
import com.facebook.react.uimanager.BackgroundStyleApplicator
import com.facebook.react.uimanager.LengthPercentage
Expand Down Expand Up @@ -268,7 +270,8 @@ public class ReactImageView(
} else if (sources.size() == 1) {
// Optimize for the case where we have just one uri, case in which we don't need the sizes
val source = sources.getMap(0)
var imageSource = ImageSource(context, source.getString("uri"))
val cacheControl = computeCacheControl(source.getString("cache"))
var imageSource = ImageSource(context, source.getString("uri"), cacheControl = cacheControl)
if (Uri.EMPTY == imageSource.uri) {
warnImageSource(source.getString("uri"))
imageSource = getTransparentBitmapImageSource(context)
Expand All @@ -277,12 +280,14 @@ public class ReactImageView(
} else {
for (idx in 0 until sources.size()) {
val source = sources.getMap(idx)
val cacheControl = computeCacheControl(source.getString("cache"))
var imageSource =
ImageSource(
context,
source.getString("uri"),
source.getDouble("width"),
source.getDouble("height"))
source.getDouble("height"),
cacheControl)
if (Uri.EMPTY == imageSource.uri) {
warnImageSource(source.getString("uri"))
imageSource = getTransparentBitmapImageSource(context)
Expand All @@ -301,6 +306,15 @@ public class ReactImageView(
isDirty = true
}

private fun computeCacheControl(cacheControl: String?): ImageCacheControl {
return when (cacheControl) {
null,
"default" -> ImageCacheControl.DEFAULT
"reload" -> ImageCacheControl.RELOAD
else -> ImageCacheControl.DEFAULT
}
}

public fun setDefaultSource(name: String?) {
val newDefaultDrawable = instance.getResourceDrawable(context, name)
if (defaultImageDrawable != newDefaultDrawable) {
Expand Down Expand Up @@ -409,7 +423,9 @@ public class ReactImageView(
}

private fun maybeUpdateViewFromRequest(doResize: Boolean) {
val uri = this.imageSource?.uri ?: return
val imageSource = this.imageSource ?: return
val uri = imageSource.uri
val cacheControl = imageSource.cacheControl

val postprocessorList = mutableListOf<Postprocessor>()
iterativeBoxBlurPostProcessor?.let { postprocessorList.add(it) }
Expand All @@ -418,6 +434,11 @@ public class ReactImageView(

val resizeOptions = if (doResize) resizeOptions else null

if (cacheControl == ImageCacheControl.RELOAD) {
val imagePipeline = Fresco.getImagePipeline()
imagePipeline.evictFromCache(uri)
}

val imageRequestBuilder =
ImageRequestBuilder.newBuilderWithSource(uri)
.setPostprocessor(postprocessor)
Expand All @@ -430,7 +451,7 @@ public class ReactImageView(
}

val imageRequest: ImageRequest =
ReactNetworkImageRequest.fromBuilderWithHeaders(imageRequestBuilder, headers)
ReactNetworkImageRequest.fromBuilderWithHeaders(imageRequestBuilder, headers, cacheControl)

globalImageLoadListener?.onLoadAttempt(uri)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.facebook.react.views.imagehelper

import android.content.Context
import android.net.Uri
import com.facebook.react.modules.fresco.ImageCacheControl
import java.util.Objects

/** Class describing an image source (network URI or resource) and size. */
Expand All @@ -19,7 +20,8 @@ constructor(
/** Get the source of this image, as it was passed to the constructor. */
public val source: String?,
width: Double = 0.0,
height: Double = 0.0
height: Double = 0.0,
public val cacheControl: ImageCacheControl = ImageCacheControl.DEFAULT,
) {

/** Get the URI for this image - can be either a parsed network URI or a resource URI. */
Expand All @@ -45,10 +47,11 @@ constructor(
return java.lang.Double.compare(that.size, size) == 0 &&
isResource == that.isResource &&
uri == that.uri &&
source == that.source
source == that.source &&
cacheControl == that.cacheControl
}

override fun hashCode(): Int = Objects.hash(uri, source, size, isResource)
override fun hashCode(): Int = Objects.hash(uri, source, size, isResource, cacheControl)

private fun computeUri(context: Context): Uri =
try {
Expand All @@ -70,6 +73,6 @@ constructor(

@JvmStatic
public fun getTransparentBitmapImageSource(context: Context): ImageSource =
ImageSource(context, TRANSPARENT_BITMAP_URI)
ImageSource(context, TRANSPARENT_BITMAP_URI, cacheControl = ImageCacheControl.DEFAULT)
}
}
61 changes: 61 additions & 0 deletions packages/rn-tester/js/examples/Image/ImageExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import type {ImageProps} from 'react-native/Libraries/Image/ImageProps';
import type {LayoutEvent} from 'react-native/Libraries/Types/CoreEventTypes';

import RNTesterButton from '../../components/RNTesterButton';
import RNTesterText from '../../components/RNTesterText';
import ImageCapInsetsExample from './ImageCapInsetsExample';
import React from 'react';
Expand Down Expand Up @@ -626,6 +627,51 @@ class VectorDrawableExample extends React.Component<
}
}

function CacheControlAndroidExample(): React.Node {
const [reload, setReload] = React.useState(0);

const onReload = () => {
setReload(prevReload => prevReload + 1);
};

return (
<>
<View style={styles.horizontal}>
<View>
<RNTesterText style={styles.resizeModeText}>Default</RNTesterText>
<Image
source={{
uri: fullImage.uri + '?cacheBust=default',
cache: 'default',
}}
style={styles.base}
key={reload}
/>
</View>
<View style={styles.leftMargin}>
<RNTesterText style={styles.resizeModeText}>Reload</RNTesterText>
<Image
source={{
uri: fullImage.uri + '?cacheBust=reload',
cache: 'reload',
}}
style={styles.base}
key={reload}
/>
</View>
</View>

<View style={styles.horizontal}>
<View style={styles.cachePolicyAndroidButtonContainer}>
<RNTesterButton onPress={onReload}>
Re-render image components
</RNTesterButton>
</View>
</View>
</>
);
}

const fullImage: ImageSource = {
uri: IMAGE2,
};
Expand Down Expand Up @@ -863,6 +909,11 @@ const styles = StyleSheet.create({
height: 100,
width: '500%',
},
cachePolicyAndroidButtonContainer: {
flex: 1,
alignItems: 'center',
marginTop: 10,
},
});

exports.displayName = (undefined: ?string);
Expand Down Expand Up @@ -1038,6 +1089,16 @@ exports.examples = [
},
platform: 'ios',
},
{
title: 'Cache Policy',
description: ('First image will be loaded and will be cached. ' +
'Second image is the same but will be reloaded if re-rendered ' +
'as the cache policy is set to reload.': string),
render: function (): React.Node {
return <CacheControlAndroidExample />;
},
platform: 'android',
},
{
title: 'Borders',
name: 'borders',
Expand Down

0 comments on commit e5dd7d6

Please sign in to comment.