diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09ab858..3a9e9d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,24 @@ jobs: :parser:test \ :engine:testDebugUnitTest \ :app:testDebugUnitTest + + # 单测只编译测试源集,Compose 界面与 GL 代码的编译错误不会被发现。 + # 引擎运行时资产不入库,这里产出的 APK 不具代表性,只作编译检查。 + - name: Compile app sources + shell: bash + run: ./gradlew --no-daemon -Pmaxmath.buildNative=false :app:assembleDebug + + # 暂不阻断:lint 从未在本仓库跑过,先让历史问题可见,基线清理干净后 + # 去掉 continue-on-error 变成硬门禁。 + - name: Android Lint + continue-on-error: true + shell: bash + run: ./gradlew --no-daemon -Pmaxmath.buildNative=false :app:lintDebug + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html + if-no-files-found: ignore diff --git a/README.md b/README.md index 609f8ba..9e0c874 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ 简体中文

-[Download MaxMath 1.0.0 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.0.0/MaxMath-v1.0.0-arm64-v8a.apk) -· [Release notes](https://github.com/yueye6811/MaxMath/releases/tag/v1.0.0) +[Download MaxMath 1.0.1 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.0.1/MaxMath-v1.0.1-arm64-v8a.apk) +· [Release notes](https://github.com/yueye6811/MaxMath/releases/tag/v1.0.1) MaxMath is an offline Android app for higher-algebra computation and interactive plotting, powered by GNU Maxima. Its interface is built with Kotlin and Jetpack Compose, mathematical input is handled by a pure Kotlin parser, and complex symbolic computations run in a separate engine process. -> Current release: 1.0.0. Minimum supported version: Android 8.0 (API 26). +> Current release: 1.0.1. Minimum supported version: Android 8.0 (API 26). > The native computation engine currently provides an `arm64-v8a` build > workflow only. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ff825f..3273cda 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,14 +9,14 @@ 简体中文

-[下载 MaxMath 1.0.0 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.0.0/MaxMath-v1.0.0-arm64-v8a.apk) -· [发布说明](https://github.com/yueye6811/MaxMath/releases/tag/v1.0.0) +[下载 MaxMath 1.0.1 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.0.1/MaxMath-v1.0.1-arm64-v8a.apk) +· [发布说明](https://github.com/yueye6811/MaxMath/releases/tag/v1.0.1) 基于 GNU Maxima 的离线 Android 高等代数计算与交互式绘图应用。界面使用 Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂符号计算在独立 引擎进程中执行。 -> 当前版本 1.0.0;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供 +> 当前版本 1.0.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供 > `arm64-v8a` 构建流程。 ## 功能 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1254b52..52d46f6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,19 @@ +# MaxMath 1.0.1 + +性能与体积优化版(2026-08-07),详见 `docs/OPTIMIZATION.md`。 + +## 本版改进 + +- 引擎资产精简 38.3 MB:去除与 jniLibs 重复的可执行文件、share 文档与 + ECL 链接期产物;安装器整目录重建,升级后不再残留旧文件。 +- Maxima 子进程常驻复用:完成信号改为 `maxmath_done()` 刷新哨兵, + 请求间 `kill(all)` 复位,省去每次请求重新载入 ECL 映像的开销; + `:engine` 进程保留 60 秒空闲期,取消/超时/异常退出才重建子进程。 +- 2D/3D 绘图热路径去装箱:曲线采样、刻度、画笔/路径、网格与等值线 + 全部改用复用缓冲与预分配数组;GL 位置查询与常量提升到链接期。 +- 零点/极值探测合并为一次引擎往返;图像解码移到 IO 线程。 +- CI 增加 `assembleDebug` 编译检查与非阻断 lint。 + # MaxMath 1.0.0 首个正式发布版本(2026-08-06)。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 676b20f..f5efa54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "com.paruh.maxmath" minSdk = 26 targetSdk = 36 - versionCode = 14 - versionName = "1.0.0" + versionCode = 15 + versionName = "1.0.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/CurveSampler.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/CurveSampler.kt index d5048f3..148d027 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/CurveSampler.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/CurveSampler.kt @@ -5,39 +5,101 @@ import kotlin.math.abs /** * 2D 曲线采样:在 [x0, x1] 上取 n 个点,遇到非有限值或数值跳变 * (超过 [maxJump],用于 tan 等渐近线)时断成多个线段。 + * + * 拖动/缩放时每帧都要重新采样,因此主入口 [sampleInto] 写进调用方复用的 + * [Samples] 缓冲:按 Pair 返回会让每帧产生上千个装箱对象。 + * [segments] 是等价的装箱版本,只用于测试与非热路径。 */ object CurveSampler { - fun segments( + /** 可复用的采样结果。坐标交错存放,段边界用下标表示,全程零装箱。 */ + class Samples { + /** 交错的 x,y;有效范围是前 [pointCount] * 2 个元素。 */ + var xy: DoubleArray = DoubleArray(0) + private set + + /** 已写入的点数(所有段之和)。 */ + var pointCount: Int = 0 + internal set + + /** 第 i 段的结束点下标(不含);起点是前一段的结束下标,首段为 0。 */ + var segmentEnds: IntArray = IntArray(0) + private set + + /** 有效段数。 */ + var segmentCount: Int = 0 + internal set + + /** 第 [index] 段的起点下标(含)。 */ + fun segmentStart(index: Int): Int = if (index == 0) 0 else segmentEnds[index - 1] + + internal fun ensureCapacity(points: Int) { + if (xy.size < points * 2) xy = DoubleArray(points * 2) + // 每段至少两个点,段数不会超过 points / 2;+1 容纳收尾段。 + if (segmentEnds.size < points / 2 + 1) segmentEnds = IntArray(points / 2 + 1) + } + } + + /** 采样进复用缓冲;[dest] 的既有内容会被覆盖。 */ + fun sampleInto( + dest: Samples, f: (Double) -> Double, x0: Double, x1: Double, n: Int, maxJump: Double = Double.POSITIVE_INFINITY, - ): List>> { - val points = ArrayList>(n) - val result = ArrayList>>() + ) { + dest.ensureCapacity(n) + val xy = dest.xy + val ends = dest.segmentEnds + val checkJump = maxJump.isFinite() + var write = 0 + var segStart = 0 + var segCount = 0 var lastY = Double.NaN + for (i in 0 until n) { val x = if (n == 1) x0 else x0 + (x1 - x0) * i / (n - 1) val y = f(x) + // write > segStart 等价于“当前段已有点”:段刚断开时不比较跳变, + // 否则会拿上一段的 lastY 去判定新段的第一个点。 val broken = !y.isFinite() || - (maxJump.isFinite() && points.isNotEmpty() && abs(y - lastY) > maxJump) + (checkJump && write > segStart && abs(y - lastY) > maxJump) if (broken) { - flush(points, result) + if (write - segStart >= 2) ends[segCount++] = write else write = segStart + segStart = write } else { - points += x to y + xy[write * 2] = x + xy[write * 2 + 1] = y + write++ lastY = y } } - flush(points, result) - return result + if (write - segStart >= 2) ends[segCount++] = write else write = segStart + + dest.pointCount = write + dest.segmentCount = segCount } - private fun flush(points: MutableList>, result: MutableList>>) { - if (points.size >= 2) { - result += points.toList() + fun segments( + f: (Double) -> Double, + x0: Double, + x1: Double, + n: Int, + maxJump: Double = Double.POSITIVE_INFINITY, + ): List>> { + val samples = Samples() + sampleInto(samples, f, x0, x1, n, maxJump) + val result = ArrayList>>(samples.segmentCount) + for (seg in 0 until samples.segmentCount) { + val start = samples.segmentStart(seg) + val end = samples.segmentEnds[seg] + val points = ArrayList>(end - start) + for (p in start until end) { + points += samples.xy[p * 2] to samples.xy[p * 2 + 1] + } + result += points } - points.clear() + return result } } diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt index 5f3a623..1d63a31 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt @@ -8,12 +8,12 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.PaintingStyle import androidx.compose.ui.graphics.nativeCanvas import com.paruh.maxmath.engine.PlotAnnotations import com.paruh.maxmath.parser.Evaluator import com.paruh.maxmath.parser.Expr -import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor import kotlin.math.log10 @@ -22,9 +22,16 @@ import kotlin.math.pow /** * 2D 实时绘图画布:网格、坐标轴、刻度、曲线、零点/极值标注。 * 同一套绘制逻辑既画 Compose Canvas,也离屏渲染高清 PNG(保存用)。 + * + * 拖动/缩放期间 [draw] 每帧都会被调用,因此画笔、路径、采样缓冲与刻度 + * 标签全部复用:这些对象原先都是每帧新建,是手势掉帧的主要来源。 + * + * 复用状态非线程安全——[draw] 与 [renderToBitmap] 都只在主线程调用。 */ object Plot2DPainter { + private const val SAMPLE_COUNT = 800 + private val CURVE_COLORS = listOf( Color(0xFF1F77B4), Color(0xFFFF7F0E), @@ -38,6 +45,30 @@ object Plot2DPainter { Color(0xFF17BECF), ) + private val ZERO_COLOR = Color(0xFFD62728) + private val EXTREMA_COLOR = Color(0xFF2CA02C) + + private val bgPaint = Paint().apply { color = Color.White } + private val gridPaint = Paint().apply { color = Color(0xFFE0E0E0) } + private val axisPaint = Paint().apply { color = Color(0xFF666666) } + private val curvePaint = Paint().apply { + strokeWidth = 2.5f + style = PaintingStyle.Stroke + } + private val borderPaint = Paint().apply { + color = Color(0xFF999999) + style = PaintingStyle.Stroke + strokeWidth = 1f + } + private val markerPaint = Paint() + private val textPaint = AndroidPaint(AndroidPaint.ANTI_ALIAS_FLAG) + + private val curvePath = Path() + private val diamondPath = Path() + private val samples = CurveSampler.Samples() + private val xTicks = TickCache() + private val yTicks = TickCache() + fun draw( canvas: Canvas, size: Size, @@ -54,17 +85,17 @@ object Plot2DPainter { fun sx(x: Double): Float = ((x - range.xMin) / w * size.width).toFloat() fun sy(y: Double): Float = ((range.yMax - y) / h * size.height).toFloat() - canvas.drawRect(Rect(0f, 0f, size.width, size.height), androidx.compose.ui.graphics.Paint().apply { color = Color.White }) + canvas.drawRect(Rect(0f, 0f, size.width, size.height), bgPaint) - val xTicks = ticks(range.xMin, range.xMax) - val yTicks = ticks(range.yMin, range.yMax) - val gridPaint = androidx.compose.ui.graphics.Paint().apply { color = Color(0xFFE0E0E0) } - val axisPaint = androidx.compose.ui.graphics.Paint().apply { color = Color(0xFF666666) } - for (x in xTicks) { - canvas.drawLine(Offset(sx(x), 0f), Offset(sx(x), size.height), gridPaint) + xTicks.update(range.xMin, range.xMax) + yTicks.update(range.yMin, range.yMax) + for (i in 0 until xTicks.count) { + val px = sx(xTicks.values[i]) + canvas.drawLine(Offset(px, 0f), Offset(px, size.height), gridPaint) } - for (y in yTicks) { - canvas.drawLine(Offset(0f, sy(y)), Offset(size.width, sy(y)), gridPaint) + for (i in 0 until yTicks.count) { + val py = sy(yTicks.values[i]) + canvas.drawLine(Offset(0f, py), Offset(size.width, py), gridPaint) } if (range.xMin < 0.0 && range.xMax > 0.0) { @@ -75,68 +106,67 @@ object Plot2DPainter { } val maxJump = h * 50.0 + val vars = HashMap() expressions.forEachIndexed { index, expr -> - val color = CURVE_COLORS[index % CURVE_COLORS.size] - val paint = androidx.compose.ui.graphics.Paint().apply { - this.color = color - strokeWidth = 2.5f - style = PaintingStyle.Stroke - } - val vars = HashMap() - val segments = CurveSampler.segments( + curvePaint.color = CURVE_COLORS[index % CURVE_COLORS.size] + CurveSampler.sampleInto( + samples, { x -> vars["x"] = x Evaluator.eval(expr, vars) }, range.xMin, range.xMax, - 800, + SAMPLE_COUNT, maxJump = maxJump, ) - for (segment in segments) { - val path = Path() - segment.forEachIndexed { i, (x, y) -> - if (i == 0) path.moveTo(sx(x), sy(y)) else path.lineTo(sx(x), sy(y)) + for (seg in 0 until samples.segmentCount) { + val start = samples.segmentStart(seg) + val end = samples.segmentEnds[seg] + curvePath.reset() + for (p in start until end) { + val px = sx(samples.xy[p * 2]) + val py = sy(samples.xy[p * 2 + 1]) + if (p == start) curvePath.moveTo(px, py) else curvePath.lineTo(px, py) } - canvas.drawPath(path, paint) + canvas.drawPath(curvePath, curvePaint) } } annotations?.let { ann -> - val zeroPaint = androidx.compose.ui.graphics.Paint().apply { color = Color(0xFFD62728) } - val extremaPaint = androidx.compose.ui.graphics.Paint().apply { color = Color(0xFF2CA02C) } + markerPaint.color = ZERO_COLOR ann.zeros.forEach { x -> if (x in range.xMin..range.xMax) { - canvas.drawCircle(Offset(sx(x), sy(0.0)), 4f, zeroPaint) - drawLabel(canvas, "(${fmt(x)}, 0)", sx(x), sy(0.0) - 6f, textSizePx, Color(0xFFD62728)) + canvas.drawCircle(Offset(sx(x), sy(0.0)), 4f, markerPaint) + drawLabel(canvas, "(${fmt(x)}, 0)", sx(x), sy(0.0) - 6f, textSizePx, ZERO_COLOR) } } + markerPaint.color = EXTREMA_COLOR ann.extrema.forEach { (x, y) -> if (x in range.xMin..range.xMax && y in range.yMin..range.yMax) { val cx = sx(x) val cy = sy(y) - val diamond = Path().apply { - moveTo(cx, cy - 6f) - lineTo(cx + 6f, cy) - lineTo(cx, cy + 6f) - lineTo(cx - 6f, cy) - close() - } - canvas.drawPath(diamond, extremaPaint) - drawLabel(canvas, "(${fmt(x)}, ${fmt(y)})", cx, cy - 10f, textSizePx, Color(0xFF2CA02C)) + diamondPath.reset() + diamondPath.moveTo(cx, cy - 6f) + diamondPath.lineTo(cx + 6f, cy) + diamondPath.lineTo(cx, cy + 6f) + diamondPath.lineTo(cx - 6f, cy) + diamondPath.close() + canvas.drawPath(diamondPath, markerPaint) + drawLabel( + canvas, + "(${fmt(x)}, ${fmt(y)})", + cx, + cy - 10f, + textSizePx, + EXTREMA_COLOR, + ) } } } - drawTickLabels(canvas, size, range, xTicks, yTicks, textSizePx) - canvas.drawRect( - Rect(0f, 0f, size.width, size.height), - androidx.compose.ui.graphics.Paint().apply { - color = Color(0xFF999999) - style = PaintingStyle.Stroke - strokeWidth = 1f - }, - ) + drawTickLabels(canvas, size, range, textSizePx) + canvas.drawRect(Rect(0f, 0f, size.width, size.height), borderPaint) } fun renderToBitmap( @@ -153,49 +183,70 @@ object Plot2DPainter { return bitmap } - private fun ticks(min: Double, max: Double): List { - val span = max - min - if (span <= 0.0) return emptyList() - val raw = span / 8.0 - val exp = floor(log10(raw)) - val fraction = raw / 10.0.pow(exp) - val step = when { - fraction <= 1.0 -> 1.0 - fraction <= 2.0 -> 2.0 - fraction <= 5.0 -> 5.0 - else -> 10.0 - } * 10.0.pow(exp) - val result = mutableListOf() - var v = ceil(min / step) * step - while (v <= max + step * 1e-9) { - result += v - v += step + /** + * 刻度值与其格式化标签。两者只随坐标范围变化,纯平移/缩放的中间帧 + * 可以直接复用,避免每帧重算刻度并对每个标签做一次 String.format。 + */ + private class TickCache { + var values: DoubleArray = DoubleArray(0) + private set + var labels: Array = emptyArray() + private set + var count: Int = 0 + private set + + private var cachedMin = Double.NaN + private var cachedMax = Double.NaN + + fun update(min: Double, max: Double) { + if (min == cachedMin && max == cachedMax) return + cachedMin = min + cachedMax = max + count = 0 + val span = max - min + if (span <= 0.0) return + val raw = span / 8.0 + val exp = floor(log10(raw)) + val fraction = raw / 10.0.pow(exp) + val step = when { + fraction <= 1.0 -> 1.0 + fraction <= 2.0 -> 2.0 + fraction <= 5.0 -> 5.0 + else -> 10.0 + } * 10.0.pow(exp) + // 上界按 step 估算容量,避免边界浮点误差导致越界。 + val capacity = ((span / step).toInt() + 3).coerceAtLeast(1) + if (values.size < capacity) { + values = DoubleArray(capacity) + labels = Array(capacity) { "" } + } + var v = ceil(min / step) * step + while (v <= max + step * 1e-9 && count < capacity) { + values[count] = v + labels[count] = fmt(v) + count++ + v += step + } } - return result } private fun drawTickLabels( canvas: Canvas, size: Size, range: PlotRange, - xTicks: List, - yTicks: List, textSizePx: Float, ) { - val paint = AndroidPaint(AndroidPaint.ANTI_ALIAS_FLAG).apply { - color = android.graphics.Color.rgb(90, 90, 90) - textSize = textSizePx - } + textPaint.color = android.graphics.Color.rgb(90, 90, 90) + textPaint.textSize = textSizePx val native = canvas.nativeCanvas - for (x in xTicks) { - val px = ((x - range.xMin) / range.width * size.width).toFloat() - val label = fmt(x) - native.drawText(label, px - paint.measureText(label) / 2f, size.height - 4f, paint) + for (i in 0 until xTicks.count) { + val px = ((xTicks.values[i] - range.xMin) / range.width * size.width).toFloat() + val label = xTicks.labels[i] + native.drawText(label, px - textPaint.measureText(label) / 2f, size.height - 4f, textPaint) } - for (y in yTicks) { - val py = ((range.yMax - y) / range.height * size.height).toFloat() - val label = fmt(y) - native.drawText(label, 4f, py - 4f, paint) + for (i in 0 until yTicks.count) { + val py = ((range.yMax - yTicks.values[i]) / range.height * size.height).toFloat() + native.drawText(yTicks.labels[i], 4f, py - 4f, textPaint) } } @@ -207,17 +258,16 @@ object Plot2DPainter { textSizePx: Float, color: Color, ) { - val paint = AndroidPaint(AndroidPaint.ANTI_ALIAS_FLAG).apply { - this.color = android.graphics.Color.argb( - 255, - (color.red * 255).toInt(), - (color.green * 255).toInt(), - (color.blue * 255).toInt(), - ) - textSize = textSizePx - } - canvas.nativeCanvas.drawText(text, x - paint.measureText(text) / 2f, y, paint) + textPaint.color = android.graphics.Color.argb( + 255, + (color.red * 255).toInt(), + (color.green * 255).toInt(), + (color.blue * 255).toInt(), + ) + textPaint.textSize = textSizePx + canvas.nativeCanvas.drawText(text, x - textPaint.measureText(text) / 2f, y, textPaint) } - - private fun fmt(v: Double): String = "%.4g".format(v) } + +/** 顶层私有:嵌套的 TickCache 也要用,放在 object 内会引入作用域歧义。 */ +private fun fmt(v: Double): String = "%.4g".format(v) diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt index cffbde5..03eefbb 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt @@ -10,6 +10,9 @@ import kotlin.math.sqrt * - [PlotGlMesh.buildSurface]:3D 曲面,带逐顶点法线用于光照。 * - [PlotGlMesh.buildContour]:等高线热力图(顶视图),z 编码为颜色, * 另生成等值线段 [GlMesh.contourLines]。 + * + * 默认 120×120 网格有 14161 个单元,内层循环里任何一次装箱都会被放大成 + * 十万级分配,因此索引、等值线与配色全部写进预分配数组。 */ data class GlMesh( val positions: FloatArray, @@ -49,28 +52,27 @@ object PlotGlMesh { positions[idx * 3 + 1] = ys[j].toFloat() positions[idx * 3 + 2] = zs[idx] val t = if (zs[idx].isFinite()) (zs[idx] - zMin) / zSpan else 0f - val rgb = viridis(t) - colors[idx * 4] = rgb[0] - colors[idx * 4 + 1] = rgb[1] - colors[idx * 4 + 2] = rgb[2] + viridisInto(t, colors, idx * 4) colors[idx * 4 + 3] = 1f } } - val indexList = mutableListOf() + val indices = IntArray((n - 1) * (n - 1) * 6) + var written = 0 for (i in 0 until n - 1) { for (j in 0 until n - 1) { val a = i * n + j val b = a + 1 val c = a + n + 1 val d = a + n - if (listOf(a, b, c, d).all { zs[it].isFinite() }) { - indexList += a - indexList += b - indexList += c - indexList += a - indexList += c - indexList += d + if (allFinite(zs, a, b, c, d)) { + indices[written] = a + indices[written + 1] = b + indices[written + 2] = c + indices[written + 3] = a + indices[written + 4] = c + indices[written + 5] = d + written += 6 accumulateNormal(positions, normals, a, b, c, d) } } @@ -94,7 +96,7 @@ object PlotGlMesh { positions = positions, normals = normals, colors = colors, - indices = indexList.toIntArray(), + indices = if (written == indices.size) indices else indices.copyOf(written), contourLines = FloatArray(0), zMin = zMin, zMax = zMax, @@ -127,28 +129,27 @@ object PlotGlMesh { positions[idx * 3 + 2] = 0f normals[idx * 3 + 2] = 1f val t = if (zs[idx].isFinite()) (zs[idx] - zMin) / zSpan else 0f - val rgb = viridis(t) - colors[idx * 4] = rgb[0] - colors[idx * 4 + 1] = rgb[1] - colors[idx * 4 + 2] = rgb[2] + viridisInto(t, colors, idx * 4) colors[idx * 4 + 3] = if (zs[idx].isFinite()) 1f else 0f } } - val indexList = mutableListOf() + val indices = IntArray((n - 1) * (n - 1) * 6) + var written = 0 for (i in 0 until n - 1) { for (j in 0 until n - 1) { val a = i * n + j val b = a + 1 val c = a + n + 1 val d = a + n - if (listOf(a, b, c, d).all { zs[it].isFinite() }) { - indexList += a - indexList += b - indexList += c - indexList += a - indexList += c - indexList += d + if (allFinite(zs, a, b, c, d)) { + indices[written] = a + indices[written + 1] = b + indices[written + 2] = c + indices[written + 3] = a + indices[written + 4] = c + indices[written + 5] = d + written += 6 } } } @@ -157,13 +158,16 @@ object PlotGlMesh { positions = positions, normals = normals, colors = colors, - indices = indexList.toIntArray(), + indices = if (written == indices.size) indices else indices.copyOf(written), contourLines = buildContourLines(xs, ys, zs, n, zMin, zMax, levels), zMin = zMin, zMax = zMax, ) } + private fun allFinite(zs: FloatArray, a: Int, b: Int, c: Int, d: Int): Boolean = + zs[a].isFinite() && zs[b].isFinite() && zs[c].isFinite() && zs[d].isFinite() + private fun evaluateGrid( expr: Expr, xMin: Double, @@ -222,9 +226,6 @@ object PlotGlMesh { val bx = positions[b * 3] val by = positions[b * 3 + 1] val bz = positions[b * 3 + 2] - val cx = positions[c * 3] - val cy = positions[c * 3 + 1] - val cz = positions[c * 3 + 2] val dx = positions[d * 3] val dy = positions[d * 3 + 1] val dz = positions[d * 3 + 2] @@ -243,14 +244,20 @@ object PlotGlMesh { nx /= len ny /= len nz /= len - for (v in intArrayOf(a, b, c, d)) { - normals[v * 3] += nx - normals[v * 3 + 1] += ny - normals[v * 3 + 2] += nz - } + // 手动展开:intArrayOf(a,b,c,d) 会在每个单元多分配一个数组。 + addNormal(normals, a, nx, ny, nz) + addNormal(normals, b, nx, ny, nz) + addNormal(normals, c, nx, ny, nz) + addNormal(normals, d, nx, ny, nz) } } + private fun addNormal(normals: FloatArray, v: Int, nx: Float, ny: Float, nz: Float) { + normals[v * 3] += nx + normals[v * 3 + 1] += ny + normals[v * 3 + 2] += nz + } + private fun buildContourLines( xs: DoubleArray, ys: DoubleArray, @@ -261,7 +268,10 @@ object PlotGlMesh { levelCount: Int, ): FloatArray { if (zMax <= zMin || levelCount <= 0) return FloatArray(0) - val lines = mutableListOf() + var lines = FloatArray(1024) + var count = 0 + // 一个单元最多 4 个交点,每个交点存 (x, y)。 + val hits = DoubleArray(8) for (k in 0 until levelCount) { val level = zMin + (zMax - zMin) * (k + 1) / (levelCount + 1) for (i in 0 until n - 1) { @@ -270,31 +280,34 @@ object PlotGlMesh { val b = a + 1 val c = a + n + 1 val d = a + n - if (!listOf(a, b, c, d).all { zs[it].isFinite() }) continue - val hits = mutableListOf() - edgeHit(zs[a], zs[b], xs[i], ys[j], xs[i + 1], ys[j], level)?.let { hits += it } - edgeHit(zs[b], zs[c], xs[i + 1], ys[j], xs[i + 1], ys[j + 1], level)?.let { hits += it } - edgeHit(zs[c], zs[d], xs[i + 1], ys[j + 1], xs[i], ys[j + 1], level)?.let { hits += it } - edgeHit(zs[d], zs[a], xs[i], ys[j + 1], xs[i], ys[j], level)?.let { hits += it } + if (!allFinite(zs, a, b, c, d)) continue + var hitCount = 0 + hitCount = addEdgeHit(hits, hitCount, zs[a], zs[b], xs[i], ys[j], xs[i + 1], ys[j], level) + hitCount = addEdgeHit(hits, hitCount, zs[b], zs[c], xs[i + 1], ys[j], xs[i + 1], ys[j + 1], level) + hitCount = addEdgeHit(hits, hitCount, zs[c], zs[d], xs[i + 1], ys[j + 1], xs[i], ys[j + 1], level) + hitCount = addEdgeHit(hits, hitCount, zs[d], zs[a], xs[i], ys[j + 1], xs[i], ys[j], level) var p = 0 - while (p + 1 < hits.size) { - val p0 = hits[p] - val p1 = hits[p + 1] - lines += p0[0].toFloat() - lines += p0[1].toFloat() - lines += 0f - lines += p1[0].toFloat() - lines += p1[1].toFloat() - lines += 0f + while (p + 1 < hitCount) { + if (count + 6 > lines.size) lines = lines.copyOf(lines.size * 2) + lines[count] = hits[p * 2].toFloat() + lines[count + 1] = hits[p * 2 + 1].toFloat() + lines[count + 2] = 0f + lines[count + 3] = hits[(p + 1) * 2].toFloat() + lines[count + 4] = hits[(p + 1) * 2 + 1].toFloat() + lines[count + 5] = 0f + count += 6 p += 2 } } } } - return lines.toFloatArray() + return if (count == lines.size) lines else lines.copyOf(count) } - private fun edgeHit( + /** 命中则把交点写入 [hits] 并返回新的交点数,否则原样返回。 */ + private fun addEdgeHit( + hits: DoubleArray, + hitCount: Int, z0: Float, z1: Float, x0: Double, @@ -302,40 +315,42 @@ object PlotGlMesh { x1: Double, y1: Double, level: Float, - ): DoubleArray? { + ): Int { val d0 = z0 - level val d1 = z1 - level - if (d0 * d1 > 0f) return null - if (d0 == d1) return null + if (d0 * d1 > 0f) return hitCount + if (d0 == d1) return hitCount val t = (d0 / (d0 - d1)).toDouble() - return doubleArrayOf(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + hits[hitCount * 2] = x0 + (x1 - x0) * t + hits[hitCount * 2 + 1] = y0 + (y1 - y0) * t + return hitCount + 1 } - /** 近似 viridis 色表,线性插值。 */ - private fun viridis(t: Float): FloatArray { - val x = (t.coerceIn(0f, 1f)) * (VIRIDIS_STOPS.size - 1) - val i = x.toInt().coerceIn(0, VIRIDIS_STOPS.size - 2) + /** 近似 viridis 色表,线性插值;直接写入目标数组,避免逐点分配。 */ + private fun viridisInto(t: Float, dest: FloatArray, offset: Int) { + val stops = VIRIDIS_STOPS.size / 3 + val x = (t.coerceIn(0f, 1f)) * (stops - 1) + val i = x.toInt().coerceIn(0, stops - 2) val f = x - i - val a = VIRIDIS_STOPS[i] - val b = VIRIDIS_STOPS[i + 1] - return floatArrayOf( - a[0] + (b[0] - a[0]) * f, - a[1] + (b[1] - a[1]) * f, - a[2] + (b[2] - a[2]) * f, - ) + val a = i * 3 + val b = a + 3 + dest[offset] = VIRIDIS_STOPS[a] + (VIRIDIS_STOPS[b] - VIRIDIS_STOPS[a]) * f + dest[offset + 1] = VIRIDIS_STOPS[a + 1] + (VIRIDIS_STOPS[b + 1] - VIRIDIS_STOPS[a + 1]) * f + dest[offset + 2] = VIRIDIS_STOPS[a + 2] + (VIRIDIS_STOPS[b + 2] - VIRIDIS_STOPS[a + 2]) * f } - private val VIRIDIS_STOPS = listOf( - floatArrayOf(0.267f, 0.005f, 0.329f), - floatArrayOf(0.283f, 0.141f, 0.458f), - floatArrayOf(0.254f, 0.265f, 0.530f), - floatArrayOf(0.207f, 0.372f, 0.553f), - floatArrayOf(0.164f, 0.471f, 0.558f), - floatArrayOf(0.128f, 0.567f, 0.551f), - floatArrayOf(0.135f, 0.659f, 0.518f), - floatArrayOf(0.267f, 0.749f, 0.441f), - floatArrayOf(0.478f, 0.821f, 0.318f), - floatArrayOf(0.741f, 0.873f, 0.150f), - floatArrayOf(0.993f, 0.906f, 0.144f), + /** 11 个 RGB 停靠点,扁平存放。 */ + private val VIRIDIS_STOPS = floatArrayOf( + 0.267f, 0.005f, 0.329f, + 0.283f, 0.141f, 0.458f, + 0.254f, 0.265f, 0.530f, + 0.207f, 0.372f, 0.553f, + 0.164f, 0.471f, 0.558f, + 0.128f, 0.567f, 0.551f, + 0.135f, 0.659f, 0.518f, + 0.267f, 0.749f, 0.441f, + 0.478f, 0.821f, 0.318f, + 0.741f, 0.873f, 0.150f, + 0.993f, 0.906f, 0.144f, ) } diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt index 15c8941..9387f90 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt @@ -59,6 +59,10 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { private val lightUniform = intArrayOf(0) private val shadedUniform = intArrayOf(0) private val colorUniform = intArrayOf(0) + // axesProgram 的位置同样在链接后缓存:原先每帧都对它做一次 + // glGetUniformLocation/glGetAttribLocation,那是同步的驱动查询。 + private val axesMvpUniform = intArrayOf(0) + private val axesPosAttr = intArrayOf(0) override fun onSurfaceCreated( unused: javax.microedition.khronos.opengles.GL10?, @@ -81,6 +85,8 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { } if (axesProgram != 0) { colorUniform[0] = GLES20.glGetUniformLocation(axesProgram, "uColor") + axesMvpUniform[0] = GLES20.glGetUniformLocation(axesProgram, "uMvp") + axesPosAttr[0] = GLES20.glGetAttribLocation(axesProgram, "aPos") } axisXBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, AXIS_X) axisYBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, AXIS_Y) @@ -176,11 +182,10 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { val rot = Mat4.identity().multiply(rotY).multiply(rotX) Mat4.normalMatrix(rot, ex / 2f, ey / 2f, ez / 2f) } else { - floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f) + IDENTITY_MAT3 } GLES20.glUniformMatrix3fv(normalUniform[0], 1, false, normalMat, 0) - val light = normalize(floatArrayOf(0.4f, 0.7f, 0.8f)) - GLES20.glUniform3fv(lightUniform[0], 1, light, 0) + GLES20.glUniform3fv(lightUniform[0], 1, LIGHT_DIR, 0) GLES20.glUniform1i(shadedUniform[0], if (kind == GlPlotKind.SURFACE) 1 else 0) GLES20.glEnableVertexAttribArray(positionAttr[0]) @@ -228,8 +233,8 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { ) } GLES20.glUseProgram(axesProgram) - val mvpLoc = GLES20.glGetUniformLocation(axesProgram, "uMvp") - val posLoc = GLES20.glGetAttribLocation(axesProgram, "aPos") + val mvpLoc = axesMvpUniform[0] + val posLoc = axesPosAttr[0] GLES20.glEnableVertexAttribArray(posLoc) if (kind == GlPlotKind.SURFACE) { @@ -242,9 +247,9 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { mvpLoc, 1, false, Mat4.identity().set(proj).multiply(view).multiply(model).m, 0, ) - drawLines(posLoc, colorUniform[0], axisXBuf, 2, floatArrayOf(0.75f, 0.25f, 0.25f, 1f)) - drawLines(posLoc, colorUniform[0], axisYBuf, 2, floatArrayOf(0.25f, 0.65f, 0.25f, 1f)) - drawLines(posLoc, colorUniform[0], axisZBuf, 2, floatArrayOf(0.25f, 0.35f, 0.85f, 1f)) + drawLines(posLoc, colorUniform[0], axisXBuf, 2, AXIS_X_COLOR) + drawLines(posLoc, colorUniform[0], axisYBuf, 2, AXIS_Y_COLOR) + drawLines(posLoc, colorUniform[0], axisZBuf, 2, AXIS_Z_COLOR) } else { val currentRange = range ?: return // 热力图与等值线/边框都在 z=0:开启深度测试时后画的线会被 @@ -256,14 +261,14 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { mvpLoc, 1, false, Mat4.identity().set(proj).multiply(view).multiply(contourFrameModel(state)).m, 0, ) - drawLines(posLoc, colorUniform[0], frameBuf, 8, floatArrayOf(0.35f, 0.35f, 0.35f, 1f)) + drawLines(posLoc, colorUniform[0], frameBuf, 8, FRAME_COLOR) if (contourBuf != 0 && contourSize > 0) { GLES20.glUniformMatrix4fv( mvpLoc, 1, false, Mat4.identity().set(proj).multiply(view) .multiply(contourLineModel(state, currentRange)).m, 0, ) - drawLines(posLoc, colorUniform[0], contourBuf, contourSize, floatArrayOf(0.12f, 0.12f, 0.12f, 1f)) + drawLines(posLoc, colorUniform[0], contourBuf, contourSize, CONTOUR_COLOR) } GLES20.glEnable(GLES20.GL_DEPTH_TEST) } @@ -373,12 +378,26 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { return shader } - private fun normalize(v: FloatArray): FloatArray { - val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) - return if (len == 0f) floatArrayOf(0f, 0f, 1f) else floatArrayOf(v[0] / len, v[1] / len, v[2] / len) - } - companion object { + + /** 光照方向与各种线条颜色都是常量,不必每帧重新构造数组。 */ + private val LIGHT_DIR = normalize(floatArrayOf(0.4f, 0.7f, 0.8f)) + private val IDENTITY_MAT3 = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f) + private val AXIS_X_COLOR = floatArrayOf(0.75f, 0.25f, 0.25f, 1f) + private val AXIS_Y_COLOR = floatArrayOf(0.25f, 0.65f, 0.25f, 1f) + private val AXIS_Z_COLOR = floatArrayOf(0.25f, 0.35f, 0.85f, 1f) + private val FRAME_COLOR = floatArrayOf(0.35f, 0.35f, 0.35f, 1f) + private val CONTOUR_COLOR = floatArrayOf(0.12f, 0.12f, 0.12f, 1f) + + private fun normalize(v: FloatArray): FloatArray { + val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) + return if (len == 0f) { + floatArrayOf(0f, 0f, 1f) + } else { + floatArrayOf(v[0] / len, v[1] / len, v[2] / len) + } + } + /** * 曲面网格归一化:数据坐标 → [-1,1]^3,中心平移到原点。 * 必须先平移再缩放(S*T),否则中心不会落在原点。 diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt index 1a4079b..bb767fd 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable @@ -76,6 +77,8 @@ import com.paruh.maxmath.ui.plot.gl.GlPlotKind import com.paruh.maxmath.ui.plot.gl.GlViewState import com.paruh.maxmath.ui.plot.gl.PlotGlController import com.paruh.maxmath.ui.plot.gl.PlotGlSurface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File @Composable @@ -274,9 +277,18 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) { onCopyPlain = {}, ) - val bitmap = remember(imagePath) { - imagePath?.let { BitmapFactory.decodeFile(it) } + // 解码放到 IO 线程:原先在 remember 里直接 decodeFile,等于在组合 + // 线程上做一次文件读取加解码。Matplotlib 输出是 720x480,不做降采样, + // 否则双指放大(最高 8 倍)会糊。 + val bitmapState = produceState(initialValue = null, key1 = imagePath) { + val path = imagePath + value = if (path == null) { + null + } else { + withContext(Dispatchers.IO) { BitmapFactory.decodeFile(path) } + } } + val bitmap = bitmapState.value Box( modifier = Modifier .fillMaxWidth() diff --git a/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt b/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt index 4013a85..ec45384 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt @@ -21,15 +21,28 @@ import java.io.FileNotFoundException * P0 回归:首次安装必须完整解压 engine 数据资产并定位可执行引擎。 * * 可执行文件在真机走 nativeLibraryDir(jniLibs,Android 10+ 唯一允许 - * execve 的位置);数据(share/lib/init 模板)走 assets 解压。此测试 - * 覆盖 Robolectric 下 assets 回退路径,确保解压不因空目录或零字节文件 - * 中途抛异常(每次计算秒出错误的根因之一)。 + * execve 的位置);数据(share/lib/init 模板)走 assets 解压。Robolectric + * 不会填充 nativeLibraryDir,因此测试自行铺一份,模拟真机契约——assets + * 里不再有可执行文件回退,那条路径在设备上本来也走不通。 */ @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(sdk = [34]) class EngineInstallerTest { + /** 铺一份假的 nativeLibraryDir:jniLibs 在真机由系统解压,测试里没有。 */ + private fun stageNativeLibDir(ctx: Context): File = + File(ctx.cacheDir, "native-libs").apply { + deleteRecursively() + mkdirs() + File(this, "libmaxima.so").apply { + writeText("maxima") + setExecutable(true, false) + } + File(this, "libecl.so").writeText("ecl") + ctx.applicationInfo.nativeLibraryDir = absolutePath + } + @Test fun freshInstallExtractsEngineAssetsAndLocatesMaxima() { val ctx = ApplicationProvider.getApplicationContext() @@ -40,6 +53,7 @@ class EngineInstallerTest { "源码检出未包含本地生成的 Maxima/ECL 运行时,跳过打包资产集成测试", packagedEnginePresent, ) + stageNativeLibDir(ctx) val result = EngineInstaller.install(ctx) assertTrue( @@ -74,9 +88,25 @@ class EngineInstallerTest { "版本标记应写入,避免每次计算重复解压", File(workDir, "engine_version").readText().isNotBlank(), ) + // 可执行文件只应来自 nativeLibraryDir。assets 里的 binary-ecl/maxima + // 与 lib/libecl.so 同 jniLibs 逐字节相同(合计约 16MB),既进 APK 又 + // 解压到 filesDir,且在 Android 10+ 上根本无法 execve。 + assertTrue( + "引擎二进制应来自 nativeLibraryDir", + result.maximaPath.startsWith(ctx.applicationInfo.nativeLibraryDir), + ) + assertTrue( + "binary-ecl/maxima 副本不应再打包", + !File(workDir, "lib/maxima/5.49.0/binary-ecl/maxima").exists(), + ) + assertTrue( + "libecl.so 副本不应再打包进 assets", + !File(workDir, "lib/libecl.so").exists(), + ) assertTrue( - "binary-ecl/maxima 应存在", - File(workDir, "lib/maxima/5.49.0/binary-ecl/maxima").exists(), + "PDF 手册不应再打包", + File(workDir, "share/maxima/5.49.0/share").walkTopDown() + .none { it.extension == "pdf" }, ) } @@ -89,6 +119,7 @@ class EngineInstallerTest { @Test fun installSucceedsWhenListThrowsForFilePathsLikeRealAndroid() { val app = ApplicationProvider.getApplicationContext() + stageNativeLibDir(app) val assets = mockAospAssetManager() val context = object : ContextWrapper(app) { override fun getAssets(): AssetManager = assets @@ -101,7 +132,28 @@ class EngineInstallerTest { assertTrue("init.lisp.template 文件应被解压", File(workDir, "init.lisp.template").exists()) assertTrue("ECL .fas 文件应被解压", File(workDir, "lib/ecl-26.3.27/sb-bsd-sockets.fas").exists()) assertTrue("share 文件应被解压", File(workDir, "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp").exists()) - assertTrue("additions 文件应被解压", File(workDir, "additions/cpuarch.sh").exists()) + } + + /** + * 回归:assets 里缺可执行文件时不再静默回退,必须给出明确的打包错误, + * 否则失败会推迟到 execve 才暴露成一句无从下手的 errno。 + */ + @Test + fun installFailsClearlyWhenNativeBinaryMissing() { + val ctx = ApplicationProvider.getApplicationContext() + val emptyNativeDir = File(ctx.cacheDir, "empty-native-libs").apply { + deleteRecursively() + mkdirs() + } + ctx.applicationInfo.nativeLibraryDir = emptyNativeDir.absolutePath + + val result = EngineInstaller.install(ctx) + + assertTrue("应失败:$result", result is EngineInstaller.InstallResult.Failure) + assertTrue( + "错误信息应指向缺失的引擎二进制:$result", + (result as EngineInstaller.InstallResult.Failure).message.contains("libmaxima.so"), + ) } } @@ -110,15 +162,15 @@ class EngineInstallerTest { * 对目录返回子项。这是与 Robolectric 行为(文件返回空数组)的关键差异。 */ private fun mockAospAssetManager(): AssetManager { + // 与 package-engine.sh 的产物保持一致:assets 只放数据,可执行文件 + // (maxima、libecl.so)走 jniLibs,不再有 binary-ecl / additions。 val dirs = mapOf( - "engine" to listOf("additions", "init.lisp.template", "lib", "share"), - "engine/additions" to listOf("cpuarch.sh"), - "engine/lib" to listOf("ecl-26.3.27", "maxima", "libecl.so"), + "engine" to listOf("init.lisp.template", "lib", "share"), + "engine/lib" to listOf("ecl-26.3.27", "maxima"), "engine/lib/ecl-26.3.27" to listOf("encodings", "sb-bsd-sockets.fas"), "engine/lib/ecl-26.3.27/encodings" to emptyList(), "engine/lib/maxima" to listOf("5.49.0"), - "engine/lib/maxima/5.49.0" to listOf("binary-ecl"), - "engine/lib/maxima/5.49.0/binary-ecl" to listOf("maxima"), + "engine/lib/maxima/5.49.0" to emptyList(), "engine/share" to listOf("maxima"), "engine/share/maxima" to listOf("5.49.0"), "engine/share/maxima/5.49.0" to listOf("share"), @@ -127,10 +179,7 @@ private fun mockAospAssetManager(): AssetManager { ) val files = mapOf( "engine/init.lisp.template" to "template".toByteArray(), - "engine/additions/cpuarch.sh" to "script".toByteArray(), - "engine/lib/libecl.so" to "ecl".toByteArray(), "engine/lib/ecl-26.3.27/sb-bsd-sockets.fas" to "fas".toByteArray(), - "engine/lib/maxima/5.49.0/binary-ecl/maxima" to "binary".toByteArray(), "engine/share/maxima/5.49.0/share/lisp-utils/defsystem.lisp" to "lisp".toByteArray(), ) diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md new file mode 100644 index 0000000..1c914bc --- /dev/null +++ b/docs/OPTIMIZATION.md @@ -0,0 +1,323 @@ +# Optimization Pass — `perf/optimization-pass` + +> **Verification status: not compiled.** The machine this work was done on has +> no JDK and no Android SDK, so `./gradlew` could not run. Every change below +> was self-reviewed (JNI signature cross-checks, brace balance, conservative +> API choices) but none of it has been through a compiler. CI is the first real +> gate; §2 additionally requires an on-device pass. See +> [Verification](#verification). + +Four commits on top of `dd25f11`: + +| Commit | Section | +| --- | --- | +| `a707220` | §1 Packaging | +| `ba06626` | §3 Plot allocations | +| `e149147` | §2 Persistent Maxima | +| `33b3acf` | §4 Build & CI | + +--- + +## Results + +| Metric | Before | After | Verified | +| --- | --- | --- | --- | +| `assets/engine` on disk | 67 MB | **29 MB** | yes — `du` | +| Files extracted on first launch | 2,015 | 1,768 | yes — `find` | +| Maxima process starts per request | 1 (cold, ~12 MB ECL image) | 0 when warm | no — needs device | +| Engine round trips, default 2D plot | 4 probes + render | 2 probes + render | no — needs device | +| Allocations per contour mesh build | ~141,610 throwaway lists | 0 | no — needs compile | + +--- + +## §1 Packaging — 38.3 MB removed + +### What was in there + +| Item | Size | Why it was dead | +| --- | --- | --- | +| `lib/maxima/5.49.0/binary-ecl/maxima` | 11.98 MB | md5 `584ba60b…` — **identical** to `jniLibs/arm64-v8a/libmaxima.so` | +| `lib/libecl.so` | 4.29 MB | **identical** to `jniLibs/arm64-v8a/libecl.so` | +| `share/**` documentation | 13.0 MB (209 files) | PDF manuals alone were 9.3 MB | +| `additions/qepcad` | 4.3 MB | Referenced by no Kotlin source | +| ECL `*.a`, `help.doc`, `TAGS`, `ecl_min` | 4.9 MB (15 files) | Link-time and dev-only artifacts | + +The two duplicated binaries cost space **twice** — once in the APK, once +extracted into `filesDir` — and neither could ever execute, because Android 10+ +forbids `execve` from `filesDir`. That restriction is the entire reason the +binary ships via `jniLibs` in the first place. + +### The actual root cause + +The duplicates were not a packaging decision; they were sediment. +[`native/package-engine.sh`](../native/package-engine.sh) only ever did +incremental `cp` and **never cleaned its target**. Line 44 already excluded +`libecl.so` from the copy — but the file from an older run was still sitting in +the working tree, so it shipped anyway. Same story for `additions/qepcad`, left +behind by the legacy `package-moa-engine.sh`. + +So the fix is `rm -rf "$TARGET"` first, then prune docs and archives after +copying. Anything dropped from packaging in future now actually disappears. + +That change had a consequence worth calling out: **`init.lisp.template` was +only ever generated by `package-moa-engine.sh`**, the legacy script. Cleaning +the target would have left the engine with no init template and no working +`share` lookup. Template generation now lives in `package-engine.sh`, where it +belongs. + +### Installer + +[`EngineInstaller.kt`](../engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt) +drops the assets fallbacks for both the executable and `libecl.so`. On a real +device those paths cannot work, and keeping them converted a packaging mistake +into an opaque `execve` errno much later. A missing binary now fails +immediately, naming the path it expected. + +Two more changes there: + +- Extraction **rebuilds** the directory instead of overwriting it, so upgrading + installs shed content that is no longer packaged. Without this, existing + users would keep carrying the old 67 MB in `filesDir` forever. +- `copyAssetFile` streams instead of `readBytes()` into memory, removing the + spike on multi-MB entries. + +`ENGINE_VERSION` is bumped so existing installs re-extract. + +--- + +## §2 Persistent Maxima process + +### The problem the old design was solving + +[`maxmath_engine.cpp:57`](../engine/src/main/cpp/maxmath_engine.cpp) explained +why every request forked a fresh `maxima --batch`: completion was signalled by +**process exit**, because a stdout sentinel "may never arrive on Android due to +unflushed buffering". The cost was a full ECL image load — typically 1–3 s on a +phone — on every single request. + +### Why it was fixable + +Two observations: + +1. Results never came from stdout anyway. `ScriptRunner` reads them from the + file written by `with_stdout`; stdout is only an error fallback. So **only + the completion signal** needed solving. +2. `init.lisp` is ours. So define a Maxima-callable Lisp function in it: + +```lisp +(defun $maxmath_done () + (format *standard-output* "~&<<>>~%") + (finish-output *standard-output*) + '$done) +``` + +`finish-output` is a blocking standard-CL flush — precisely the guarantee the +original comment was missing. + +### The design + +The native layer keeps a warm child with stdin as a pipe, and per request +writes: + +``` +kill(all)$ +batch("