Skip to content

Commit

Permalink
perf: implement multiple optimizations to limit expensive computations
Browse files Browse the repository at this point in the history
The imageBoxDimensions are now computed when required. In addition,
requirements are also recomputed when necessary, preventing expensive
operations from happening too often. Also, because we need to flatten
the style prop to infer requirements, this flatten value is now cached
and re-evaluated when appropriate. Finally, styles have been moved to a
stylesheet when that is possible, to avoid commiting updates to the native
side. Note that these performance optimizations are made possible by the
high coverage rate of the HTMLImage component.
  • Loading branch information
jsamr committed Jul 24, 2020
1 parent b056873 commit 4905fe9
Show file tree
Hide file tree
Showing 2 changed files with 160 additions and 84 deletions.
242 changes: 159 additions & 83 deletions src/HTMLImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import { Image, View, Text, StyleSheet } from "react-native";
import PropTypes from "prop-types";

const defaultImageStyle = { resizeMode: "cover" };
const emptyObject = {};

const styles = StyleSheet.create({
image: { resizeMode: "cover" },
errorBox: {
width: 50,
height: 50,
borderWidth: 1,
borderColor: "lightgray",
overflow: "hidden",
justifyContent: "center",
},
errorText: { textAlign: "center", fontStyle: "italic" }
});

function attemptParseFloat(value) {
const result = parseFloat(value);
Expand Down Expand Up @@ -41,7 +55,12 @@ function normalizeSize(
return null;
}

function extractHorizontalSpace({ marginHorizontal, leftMargin, rightMargin, margin } = {}) {
function extractHorizontalSpace({
marginHorizontal,
leftMargin,
rightMargin,
margin,
} = {}) {
const realLeftMargin = leftMargin || marginHorizontal || margin || 0;
const realRightMargin = rightMargin || marginHorizontal || margin || 0;
return realLeftMargin + realRightMargin;
Expand All @@ -50,18 +69,17 @@ function extractHorizontalSpace({ marginHorizontal, leftMargin, rightMargin, mar
function deriveRequiredDimensionsFromProps({
width,
height,
style,
enableExperimentalPercentWidth,
enablePercentWidth,
contentWidth,
flatStyle,
}) {
const normalizeOptionsWidth = {
enablePercentWidth: enableExperimentalPercentWidth,
enablePercentWidth,
containerDimension: contentWidth,
};
const normalizeOptionsHeight = {
enablePercentWidth: false,
};
const flatStyle = StyleSheet.flatten(style || {});
const styleWidth = normalizeSize(flatStyle.width, normalizeOptionsWidth);
const styleHeight = normalizeSize(flatStyle.height, normalizeOptionsHeight);
const widthProp = normalizeSize(width, normalizeOptionsWidth);
Expand Down Expand Up @@ -107,7 +125,7 @@ function scaleDown(maxDimensions, desiredDimensions) {
}

function scale({ minBox, maxBox }, originalBox) {
return scaleDown(maxBox, scaleUp(minBox, originalBox))
return scaleDown(maxBox, scaleUp(minBox, originalBox));
}

function sourcesAreEqual(source1, source2) {
Expand All @@ -120,15 +138,81 @@ function identity(arg) {
return arg;
}

function computeImageBoxDimensions(params) {
const {
computeImagesMaxWidth,
contentWidth,
flattenStyles,
imagePhysicalWidth,
imagePhysicalHeight,
requiredWidth,
requiredHeight,
} = params;
const horizontalSpace = extractHorizontalSpace(flattenStyles);
const {
maxWidth = Infinity,
maxHeight = Infinity,
minWidth = 0,
minHeight = 0,
} = flattenStyles;
const imagesMaxWidth =
typeof contentWidth === "number"
? computeImagesMaxWidth(contentWidth)
: Infinity;
const minBox = {
width: minWidth,
height: minHeight,
};
const maxBox = {
width:
Math.min(
imagesMaxWidth,
maxWidth,
typeof requiredWidth === "number" ? requiredWidth : Infinity
) - horizontalSpace,
height: Math.min(
typeof requiredHeight === "number" ? requiredHeight : Infinity,
maxHeight
),
};
if (typeof requiredWidth === "number" && typeof requiredHeight === "number") {
return scale(
{ minBox, maxBox },
{
width: requiredWidth,
height: requiredHeight,
}
);
}
if (imagePhysicalWidth != null && imagePhysicalHeight != null) {
return scale(
{ minBox, maxBox },
{
width: imagePhysicalWidth,
height: imagePhysicalHeight,
}
);
}
return null;
}

export default class HTMLImage extends PureComponent {
__cachedFlattenStyles = null;
__cachedRequirements = null;

constructor(props) {
super(props);
const requirements = deriveRequiredDimensionsFromProps(props);
this.state = {
this.invalidateRequirements(props);
const state = {
imagePhysicalWidth: null,
imagePhysicalHeight: null,
requiredWidth: requirements.width,
requiredHeight: requirements.height,
requiredWidth: this.__cachedRequirements.width,
requiredHeight: this.__cachedRequirements.height,
imageBoxDimensions: null,
};
this.state = {
...state,
imageBoxDimensions: this.computeImageBoxDimensions(props, state),
};
}

Expand Down Expand Up @@ -156,6 +240,45 @@ export default class HTMLImage extends PureComponent {
},
};

invalidateRequirements(props) {
const {
width,
height,
contentWidth,
enableExperimentalPercentWidth,
style
} = props;
this.__cachedFlattenStyles =
StyleSheet.flatten(style) || emptyObject;
this.__cachedRequirements = deriveRequiredDimensionsFromProps({
width,
height,
contentWidth,
enablePercentWidth: enableExperimentalPercentWidth,
flatStyle: this.__cachedFlattenStyles,
});
}

computeImageBoxDimensions(props, state) {
const { computeImagesMaxWidth, contentWidth } = props;
const {
imagePhysicalWidth,
imagePhysicalHeight,
requiredWidth,
requiredHeight,
} = state;
const imageBoxDimensions = computeImageBoxDimensions({
flattenStyles: this.__cachedFlattenStyles,
computeImagesMaxWidth,
contentWidth,
imagePhysicalWidth,
imagePhysicalHeight,
requiredWidth,
requiredHeight,
});
return imageBoxDimensions;
}

componentDidMount() {
this.mounted = true;
if (this.state.requiredWidth == null || this.state.requiredHeight == null) {
Expand All @@ -167,8 +290,7 @@ export default class HTMLImage extends PureComponent {
this.mounted = false;
}

componentDidUpdate(prevProps) {
let requirements = null;
componentDidUpdate(prevProps, prevState) {
const sourceHasChanged = !sourcesAreEqual(
prevProps.source,
this.props.source
Expand All @@ -177,77 +299,38 @@ export default class HTMLImage extends PureComponent {
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height ||
prevProps.style !== this.props.style;
const shouldRecomputeImageBox =
requirementsHaveChanged ||
this.state.imagePhysicalWidth !== prevState.imagePhysicalWidth ||
this.state.imagePhysicalHeight !== prevState.imagePhysicalHeight ||
this.props.contentWidth !== prevProps.contentWidth ||
this.props.computeImagesMaxWidth !== prevProps.computeImagesMaxWidth;

if (requirementsHaveChanged) {
requirements = deriveRequiredDimensionsFromProps(this.props);
this.invalidateRequirements(this.props);
this.setState({
requiredWidth: requirements.width,
requiredHeight: requirements.height
requiredWidth: this.__cachedRequirements.width,
requiredHeight: this.__cachedRequirements.height,
});
}
if (sourceHasChanged) {
if (!requirements) {
requirements = deriveRequiredDimensionsFromProps(this.props);
}
if (
requirements.width === null ||
requirements.height === null
this.__cachedRequirements.width === null ||
this.__cachedRequirements.height === null
) {
this.fetchPhysicalImageDimensions();
}
}
}

getImageBoxDimensions() {
const { computeImagesMaxWidth, contentWidth, style = {} } = this.props;
const flattenStyles = StyleSheet.flatten(style);
const horizontalSpace = extractHorizontalSpace(flattenStyles);
const { maxWidth = Infinity, maxHeight = Infinity, minWidth = 0, minHeight = 0 } = flattenStyles;
const imagesMaxWidth =
typeof contentWidth === "number"
? computeImagesMaxWidth(contentWidth)
: Infinity;
const {
imagePhysicalWidth,
imagePhysicalHeight,
requiredWidth,
requiredHeight,
} = this.state;
const minBox = {
width: minWidth,
height: minHeight
}
const maxBox = {
width: Math.min(
imagesMaxWidth,
maxWidth,
typeof requiredWidth === "number" ? requiredWidth : Infinity
) - horizontalSpace,
height: Math.min(
typeof requiredHeight === "number" ? requiredHeight : Infinity,
maxHeight
),
};
if (
typeof requiredWidth === "number" &&
typeof requiredHeight === "number"
) {
return scale({ minBox, maxBox }, {
width: requiredWidth,
height: requiredHeight,
});
}
if (imagePhysicalWidth != null && imagePhysicalHeight != null) {
return scale({ minBox, maxBox }, {
width: imagePhysicalWidth,
height: imagePhysicalHeight,
});
if (shouldRecomputeImageBox) {
this.setState((state, props) => ({
imageBoxDimensions: this.computeImageBoxDimensions(props, state),
}));
}
return null;
}

fetchPhysicalImageDimensions(props = this.props) {
const { source } = props;
Image.getSize(
source && source.uri && Image.getSize(
source.uri,
(imagePhysicalWidth, imagePhysicalHeight) => {
this.mounted &&
Expand All @@ -263,12 +346,12 @@ export default class HTMLImage extends PureComponent {
);
}

renderImage(imageBox) {
renderImage(imageBoxDimensions) {
const { source, style } = this.props;
return (
<Image
source={source}
style={[defaultImageStyle, style, imageBox]}
style={[defaultImageStyle, style, imageBoxDimensions]}
testID="image-layout"
/>
);
Expand All @@ -277,18 +360,11 @@ export default class HTMLImage extends PureComponent {
renderAlt() {
return (
<View
style={{
width: 50,
height: 50,
borderWidth: 1,
borderColor: "lightgray",
overflow: "hidden",
justifyContent: "center",
}}
style={styles.errorBox}
testID="image-error"
>
{this.props.alt ? (
<Text style={{ textAlign: "center", fontStyle: "italic" }}>
<Text style={styles.errorText}>
{this.props.alt}
</Text>
) : (
Expand All @@ -308,13 +384,13 @@ export default class HTMLImage extends PureComponent {
}

render() {
const imageBox = this.getImageBoxDimensions();
if (this.state.error) {
const { error, imageBoxDimensions } = this.state;
if (error) {
return this.renderAlt();
}
if (imageBox === null) {
if (imageBoxDimensions === null) {
return this.renderPlaceholder();
}
return this.renderImage(imageBox);
return this.renderImage(imageBoxDimensions);
}
}
2 changes: 1 addition & 1 deletion src/__tests__/regression.141.custom-blur-image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe("HTMLImage component should pass regression test #141", () => {
const imageLayout = queryByTestId("image-layout")
expect(imageLayout).toBeFalsy();
await expect(
findByTestId("image-layout", { timeout: 10 })
findByTestId("image-layout", { timeout: 100 })
).resolves.toBeTruthy();
});
});

0 comments on commit 4905fe9

Please sign in to comment.