Skip to content

Commit

Permalink
Merge pull request #177 from OleksandrKucherenko/android-performance-…
Browse files Browse the repository at this point in the history
…improvements

Android performance improvements
  • Loading branch information
gre authored Oct 25, 2018
2 parents f32408d + b7e9d6e commit 2457a00
Show file tree
Hide file tree
Showing 15 changed files with 739 additions and 225 deletions.
17 changes: 12 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ npm-debug.log

# android
#
android/build/
android/.gradle/
android/.idea/
android/*.iml
android/gradle/
.vscode/
.settings/
android/bin
android/gradle/wrapper
android/gradlew
android/gradlew.bat
android/local.properties
*.iml
.gradle
/local.properties
.idea/
captures/
.externalNativeBuild
.project

75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,81 @@ Model tested: iPhone 6 (iOS), Nexus 5 (Android).
3. Component itself lacks platform support.
4. But you can just use the react-native-maps snapshot function: https://github.com/airbnb/react-native-maps#take-snapshot-of-map

## Performance Optimization

During profiling captured several things that influence on performance:
1) (de-)allocation of memory for bitmap
2) (de-)allocation of memory for Base64 output buffer
3) compression of bitmap to different image formats: PNG, JPG

To solve that in code introduced several new approaches:
- reusable images, that reduce load on GC;
- reusable arrays/buffers that also reduce load on GC;
- RAW image format for avoiding expensive compression;
- ZIP deflate compression for RAW data, that works faster in compare to `Bitmap.compress`

more details and code snippet are below.

### RAW Images

Introduced a new image format RAW. it correspond a ARGB array of pixels.

Advantages:
- no compression, so its supper quick. Screenshot taking is less than 16ms;

RAW format supported for `zip-base64`, `base64` and `tmpfile` result types.

RAW file on disk saved in format: `${width}:${height}|${base64}` string.

### zip-base64

In compare to BASE64 result string this format fast try to apply zip/deflate compression on screenshot results
and only after that convert results to base64 string. In combination zip-base64 + raw we got a super fast
approach for capturing screen views and deliver them to the react side.

### How to work with zip-base64 and RAW format?

```js
const fs = require('fs')
const zlib = require('zlib')
const PNG = require('pngjs').PNG
const Buffer = require('buffer').Buffer

const format = Platform.OS === 'android' ? 'raw' : 'png'
const result = Platform.OS === 'android' ? 'zip-base64' : 'base64'

captureRef(this.ref, { result, format }).then(data => {
// expected pattern 'width:height|', example: '1080:1731|'
const resolution = /^(\d+):(\d+)\|/g.exec(data)
const width = (resolution || ['', 0, 0])[1]
const height = (resolution || ['', 0, 0])[2]
const base64 = data.substr((resolution || [''])[0].length || 0)

// convert from base64 to Buffer
const buffer = Buffer.from(base64, 'base64')
// un-compress data
const inflated = zlib.inflateSync(buffer)
// compose PNG
const png = new PNG({ width, height })
png.data = inflated
const pngData = PNG.sync.write(png)
// save composed PNG
fs.writeFileSync(output, pngData)
})
```

Keep in mind that packaging PNG data is a CPU consuming operation as a `zlib.inflate`.

Hint: use `process.fork()` approach for converting raw data into PNGs.

> Note: code is tested in large commercial project.
> Note #2: Don't forget to add packages into your project:
> ```js
> yarn add pngjs
> yarn add zlib
> ```
## Troubleshooting / FAQ
### Saving to a file?
Expand Down
43 changes: 25 additions & 18 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,41 +1,48 @@
buildscript {
repositories {
jcenter()
}

dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
/* In case of submodule usage, do not try to apply own repositories and plugins,
root project is responsible for that. */
if (rootProject.buildDir == project.buildDir) {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
}
}
}

apply plugin: 'com.android.library'

android {
compileSdkVersion 26
buildToolsVersion "26.0.1"
compileSdkVersion 27
buildToolsVersion "28.0.3"

defaultConfig {
minSdkVersion 16
targetSdkVersion 26
targetSdkVersion 27

versionCode 1
versionName "1.0"
}

lintOptions {
abortOnError false
}
}

allprojects {
repositories {
mavenLocal()
jcenter()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url "$rootDir/../node_modules/react-native/android"
}
repositories {
google()
jcenter()
mavenLocal()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url "$rootDir/../node_modules/react-native/android"
}
}

dependencies {
compile 'com.facebook.react:react-native:+'
implementation 'com.android.support:support-v4:27.+'

This comment has been minimized.

Copy link
@rikur

rikur Nov 29, 2018

Uh was this needed? v4 is an old version and conflicts with the one used in my app.


api 'com.facebook.react:react-native:+'
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,36 @@

package fr.greweb.reactnativeviewshot;

import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.support.annotation.NonNull;
import android.util.DisplayMetrics;
import android.view.View;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import android.util.Log;

import com.facebook.react.bridge.GuardedAsyncTask;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.uimanager.UIBlock;
import com.facebook.react.uimanager.UIManagerModule;

import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import fr.greweb.reactnativeviewshot.ViewShot.Formats;
import fr.greweb.reactnativeviewshot.ViewShot.Results;

public class RNViewShotModule extends ReactContextBaseJavaModule {

public static final String RNVIEW_SHOT = "RNViewShot";

private final ReactApplicationContext reactContext;

public RNViewShotModule(ReactApplicationContext reactContext) {
Expand All @@ -39,7 +40,7 @@ public RNViewShotModule(ReactApplicationContext reactContext) {

@Override
public String getName() {
return "RNViewShot";
return RNVIEW_SHOT;
}

@Override
Expand Down Expand Up @@ -67,30 +68,40 @@ public void releaseCapture(String uri) {

@ReactMethod
public void captureRef(int tag, ReadableMap options, Promise promise) {
ReactApplicationContext context = getReactApplicationContext();
String format = options.getString("format");
Bitmap.CompressFormat compressFormat =
format.equals("jpg")
? Bitmap.CompressFormat.JPEG
: format.equals("webm")
? Bitmap.CompressFormat.WEBP
: Bitmap.CompressFormat.PNG;
double quality = options.getDouble("quality");
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
Integer width = options.hasKey("width") ? (int)(displayMetrics.density * options.getDouble("width")) : null;
Integer height = options.hasKey("height") ? (int)(displayMetrics.density * options.getDouble("height")) : null;
String result = options.getString("result");
Boolean snapshotContentContainer = options.getBoolean("snapshotContentContainer");
final ReactApplicationContext context = getReactApplicationContext();
final DisplayMetrics dm = context.getResources().getDisplayMetrics();

final String extension = options.getString("format");
final int imageFormat = "jpg".equals(extension)
? Formats.JPEG
: "webm".equals(extension)
? Formats.WEBP
: "raw".equals(extension)
? Formats.RAW
: Formats.PNG;

final double quality = options.getDouble("quality");
final Integer scaleWidth = options.hasKey("width") ? (int) (dm.density * options.getDouble("width")) : null;
final Integer scaleHeight = options.hasKey("height") ? (int) (dm.density * options.getDouble("height")) : null;
final String resultStreamFormat = options.getString("result");
final Boolean snapshotContentContainer = options.getBoolean("snapshotContentContainer");

try {
File file = null;
if ("tmpfile".equals(result)) {
file = createTempFile(getReactApplicationContext(), format);
File outputFile = null;
if (Results.TEMP_FILE.equals(resultStreamFormat)) {
outputFile = createTempFile(getReactApplicationContext(), extension);
}
UIManagerModule uiManager = this.reactContext.getNativeModule(UIManagerModule.class);
uiManager.addUIBlock(new ViewShot(tag, format, compressFormat, quality, width, height, file, result, snapshotContentContainer,reactContext, getCurrentActivity(), promise));
}
catch (Exception e) {
promise.reject(ViewShot.ERROR_UNABLE_TO_SNAPSHOT, "Failed to snapshot view tag "+tag);

final Activity activity = getCurrentActivity();
final UIManagerModule uiManager = this.reactContext.getNativeModule(UIManagerModule.class);

uiManager.addUIBlock(new ViewShot(
tag, extension, imageFormat, quality,
scaleWidth, scaleHeight, outputFile, resultStreamFormat,
snapshotContentContainer, reactContext, activity, promise)
);
} catch (final Throwable ignored) {
promise.reject(ViewShot.ERROR_UNABLE_TO_SNAPSHOT, "Failed to snapshot view tag " + tag);
}
}

Expand All @@ -106,34 +117,41 @@ public void captureScreen(ReadableMap options, Promise promise) {
* image files. This is run when the catalyst instance is being destroyed (i.e. app is shutting
* down) and when the module is instantiated, to handle the case where the app crashed.
*/
private static class CleanTask extends GuardedAsyncTask<Void, Void> {
private final Context mContext;
private static class CleanTask extends GuardedAsyncTask<Void, Void> implements FilenameFilter {
private final File cacheDir;
private final File externalCacheDir;

private CleanTask(ReactContext context) {
super(context);
mContext = context;

cacheDir = context.getCacheDir();
externalCacheDir = context.getExternalCacheDir();
}

@Override
protected void doInBackgroundGuarded(Void... params) {
cleanDirectory(mContext.getCacheDir());
File externalCacheDir = mContext.getExternalCacheDir();
if (null != cacheDir) {
cleanDirectory(cacheDir);
}

if (externalCacheDir != null) {
cleanDirectory(externalCacheDir);
}
}

private void cleanDirectory(File directory) {
File[] toDelete = directory.listFiles(
new FilenameFilter() {
@Override
public boolean accept(File dir, String filename) {
return filename.startsWith(TEMP_FILE_PREFIX);
}
});
@Override
public final boolean accept(File dir, String filename) {
return filename.startsWith(TEMP_FILE_PREFIX);
}

private void cleanDirectory(@NonNull final File directory) {
final File[] toDelete = directory.listFiles(this);

if (toDelete != null) {
for (File file: toDelete) {
file.delete();
for (File file : toDelete) {
if (file.delete()) {
Log.d(RNVIEW_SHOT, "deleted file: " + file.getAbsolutePath());
}
}
}
}
Expand All @@ -143,26 +161,26 @@ public boolean accept(File dir, String filename) {
* Create a temporary file in the cache directory on either internal or external storage,
* whichever is available and has more free space.
*/
private File createTempFile(Context context, String ext)
throws IOException {
File externalCacheDir = context.getExternalCacheDir();
File internalCacheDir = context.getCacheDir();
File cacheDir;
@NonNull
private File createTempFile(@NonNull final Context context, @NonNull final String ext) throws IOException {
final File externalCacheDir = context.getExternalCacheDir();
final File internalCacheDir = context.getCacheDir();
final File cacheDir;

if (externalCacheDir == null && internalCacheDir == null) {
throw new IOException("No cache directory available");
}

if (externalCacheDir == null) {
cacheDir = internalCacheDir;
}
else if (internalCacheDir == null) {
} else if (internalCacheDir == null) {
cacheDir = externalCacheDir;
} else {
cacheDir = externalCacheDir.getFreeSpace() > internalCacheDir.getFreeSpace() ?
externalCacheDir : internalCacheDir;
}
String suffix = "." + ext;
File tmpFile = File.createTempFile(TEMP_FILE_PREFIX, suffix, cacheDir);
return tmpFile;
}

final String suffix = "." + ext;
return File.createTempFile(TEMP_FILE_PREFIX, suffix, cacheDir);
}
}
Loading

0 comments on commit 2457a00

Please sign in to comment.