P0 native decode/encode benchmark passes on Xiaomi (12MP decode+encode ~210-270ms, off-main-thread)

This commit is contained in:
2026-09-09 15:10:30 +07:00
parent a8932f52ed
commit dfcf0f3a28
5 changed files with 157 additions and 40 deletions
@@ -2,6 +2,7 @@ package com.locphamtran.recipescamera.export
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -10,9 +11,12 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
private const val TAG = "RecipesCamExport"
class RecipescamExportModule : Module() {
override fun definition() = ModuleDefinition {
Name("RecipesCamExport")
Log.i(TAG, "module defined RecipesCamExport")
// P0 spike: decode (BitmapFactory) + JPEG encode + file write, all off the
// JS/main threads. Returns per-stage ms so JS can prove the JS thread is
@@ -20,12 +24,39 @@ class RecipescamExportModule : Module() {
AsyncFunction("decodeEncodeAsync") Coroutine { srcPath: String, dstPath: String, quality: Int ->
decodeEncodeAsync(srcPath, dstPath, quality)
}
// P0 probe helpers — removed before ship.
AsyncFunction("ping") { ->
Log.i(TAG, "ping called")
"pong"
}
// P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the
// 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the
// real file path back to JS, which then runs decodeEncodeAsync on it. This
// exercises the exact file-based path P1 will use.
AsyncFunction("materializeBenchAsset") Coroutine { assetName: String ->
val ctx = appContext.reactContext ?: error("react context lost")
val resId = ctx.resources.getIdentifier(assetName, "drawable", ctx.packageName)
check(resId != 0) { "drawable $assetName not bundled" }
val srcFile = File(ctx.cacheDir, "$assetName.png")
withContext(Dispatchers.IO) {
if (!srcFile.exists()) {
ctx.resources.openRawResource(resId).use { input ->
FileOutputStream(srcFile).use { output -> input.copyTo(output) }
}
}
Log.i(TAG, "materialized $assetName -> ${srcFile.absolutePath} bytes=${srcFile.length()}")
}
srcFile.absolutePath
}
}
}
private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map<String, Int> {
val timings = mutableMapOf<String, Int>()
val t0 = System.nanoTime()
Log.i(TAG, "start src=$srcPath q=$quality thread=${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
val src = File(srcPath)
check(src.exists()) { "source file missing: $srcPath" }
@@ -40,8 +71,10 @@ private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality:
check(bmp.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" }
}
timings["encodeWriteMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt()
Log.i(TAG, "run done decodeMs=${timings["decodeMs"]} encodeWriteMs=${timings["encodeWriteMs"]} size=${bmp.width}x${bmp.height} thread=${Thread.currentThread().name}")
bmp.recycle()
}
timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt()
Log.i(TAG, "totalMs=${timings["totalMs"]}")
return timings
}
+3
View File
@@ -9,6 +9,9 @@ export interface DecodeEncodeTimings {
export interface RecipescamExportModule {
/** Decode JPEG/PNG at srcPath (plain absolute path), re-encode JPEG q and write dstPath. All native, background. Returns stage timings in ms. */
decodeEncodeAsync(srcPath: string, dstPath: string, quality: number): Promise<DecodeEncodeTimings>;
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;
}
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;