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("