From a7072201548262830d67a455bdfaf3f733f7283c Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Fri, 7 Aug 2026 14:14:06 +0800 Subject: [PATCH 1/6] Stop packaging 38MB of duplicate and doc-only engine assets assets/engine was 67MB, of which 16.3MB was byte-identical to files already shipped in jniLibs: lib/maxima/5.49.0/binary-ecl/maxima matches libmaxima.so, and lib/libecl.so matches libecl.so (md5 584ba60b / same size). Both cost space twice - once in the APK, once extracted into filesDir - and neither can ever run, since Android 10+ forbids execve from filesDir. That is why the binary is shipped via jniLibs at all. The stale copies persisted because package-engine.sh only ever did incremental cp and never cleaned its target, so files dropped from the packaging step (libecl.so, and additions/qepcad from the legacy MoA script) stayed in the working tree forever and shipped anyway. Clean the target first, and prune share-tree documentation (PDF manuals alone were 9.3MB) plus ECL link-time archives. Also move init.lisp.template generation into package-engine.sh - only package-moa-engine.sh produced it, so cleaning the target would otherwise leave the engine without its init template. EngineInstaller drops the assets fallbacks for the executable and for libecl.so; on a real device those paths cannot work, and keeping them turned a packaging error into an opaque execve errno later. Missing binaries now fail immediately with the expected path. Extraction also rebuilds the directory instead of overwriting it, so upgrading installs shed content that is no longer packaged, and copies stream instead of buffering whole files. assets/engine: 67MB -> 29MB. Co-Authored-By: Claude Opus 5 --- .../maxmath/engine/EngineInstallerTest.kt | 77 +++++++++++++---- .../paruh/maxmath/engine/EngineInstaller.kt | 82 ++++++------------- native/README.md | 10 +++ native/package-engine.sh | 42 ++++++++++ 4 files changed, 141 insertions(+), 70 deletions(-) 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/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt b/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt index cded477..819a948 100644 --- a/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt +++ b/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt @@ -18,7 +18,7 @@ object EngineInstaller { val maximaPath: String, val workDir: String, val initLispPath: String, - /** libecl.so 所在目录(优先 nativeLibraryDir,回退 assets 解压目录)。 */ + /** libecl.so 所在目录,恒为 nativeLibraryDir。 */ val libDir: String, /** ECL 运行文件目录(.fas/encodings),传给子进程设置 ECLDIR。 */ val eclDataDir: String, @@ -29,52 +29,41 @@ object EngineInstaller { fun install(context: Context): InstallResult { val dir = File(context.filesDir, "engine").apply { mkdirs() } - val hasEngine = File(dir, "share/maxima").isDirectory || File(dir, MAXIMA_DATA_DIR).isDirectory + val hasEngine = File(dir, "share/maxima").isDirectory val marker = File(dir, ENGINE_MARKER) val currentVersion = if (marker.exists()) marker.readText() else "" if (!hasEngine || currentVersion != ENGINE_VERSION || !File(dir, INIT_TEMPLATE).exists()) { + // 整目录重建而不是覆盖解压:解压只会新增/改写文件,旧版本里 + // 已不再打包的内容(MoA 的 maxima.pie/additions、与 jniLibs 重复 + // 的 binary-ecl 与 libecl.so)会永久留在 filesDir 里白占空间。 + dir.deleteRecursively() + dir.mkdirs() extractAssets(context, dir) - // 升级自旧版 MoA 引擎时清理 32 位二进制与旧 share 树,避免回退选择。 - File(dir, "maxima.pie").delete() - File(dir, "maxima.x86.pie").delete() - File(dir, MAXIMA_DATA_DIR).deleteRecursively() marker.writeText(ENGINE_VERSION) } File(dir, "tmp").mkdirs() File(dir, "user").mkdirs() - val abi = Build.SUPPORTED_ABIS.firstOrNull { it in SUPPORTED_ABIS } + Build.SUPPORTED_ABIS.firstOrNull { it in SUPPORTED_ABIS } ?: return InstallResult.Failure("不支持的 ABI:${Build.SUPPORTED_ABIS.joinToString()}") - val isArm = abi.startsWith("arm") - // Android 10+ 禁止 execve filesDir 下的文件(W^X 策略),因此 - // 可执行文件必须放在 APK 的 nativeLibraryDir(jniLibs,安装时解压, - // 带可执行 SELinux 标签)。assets 里的旧布局仅作回退。 + // Android 10+ 禁止 execve filesDir 下的文件(W^X 策略),因此可执行 + // 文件只能来自 APK 的 nativeLibraryDir(jniLibs,安装时解压,带可执行 + // SELinux 标签)。assets 里再放一份既执行不了,又要多占一倍空间, + // 所以这里不再保留 assets 回退路径——找不到就是打包错误。 val nativeLibDir = context.applicationInfo.nativeLibraryDir ?.takeIf { it.isNotBlank() } ?.let { File(it) } - ?: File(dir, "lib") - val nativeMaxima = File(nativeLibDir, "libmaxima.so").takeIf { it.exists() } + ?: return InstallResult.Failure("nativeLibraryDir 不可用,引擎二进制无处可执行") + val maxima = File(nativeLibDir, "libmaxima.so").takeIf { it.exists() } ?: File(nativeLibDir, "maxima").takeIf { it.exists() } - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - nativeMaxima != null && - !nativeMaxima.canExecute() - ) { - return InstallResult.Failure("引擎二进制无执行权限:${nativeMaxima.absolutePath}") - } - val maxima = nativeMaxima - ?: findMaximaBinary(dir) - ?: findMoABinary(dir, isArm) - ?: File(dir, "bin/maxima") - if (!maxima.exists()) { - return InstallResult.Failure("Maxima 引擎二进制未打包(${maxima.absolutePath})") + ?: return InstallResult.Failure( + "Maxima 引擎二进制未打包(${File(nativeLibDir, "libmaxima.so").absolutePath})", + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !maxima.canExecute()) { + return InstallResult.Failure("引擎二进制无执行权限:${maxima.absolutePath}") } maxima.setExecutable(true, false) - val libDir = if (File(nativeLibDir, "libecl.so").exists()) { - nativeLibDir.absolutePath - } else { - File(dir, "lib").absolutePath - } + val libDir = nativeLibDir.absolutePath if (!File(libDir, "libecl.so").exists()) { return InstallResult.Failure("libecl.so 未找到($libDir)") } @@ -103,13 +92,8 @@ object EngineInstaller { internal fun writeInitLisp(dir: File): File { val template = File(dir, INIT_TEMPLATE) - // 标准 prefix 布局:share/maxima/ 位于引擎根目录下; - // 旧 MoA 布局则指向版本目录。 - val maximaDir = if (File(dir, "share/maxima").isDirectory) { - dir.absolutePath - } else { - File(dir, MAXIMA_DATA_DIR).absolutePath - } + // 标准 autoconf prefix 布局:share/maxima/ 就在引擎根目录下。 + val maximaDir = dir.absolutePath val content = if (template.exists()) { template.readText() .replace("__MAXIMA_DIR__", maximaDir) @@ -161,29 +145,15 @@ object EngineInstaller { private fun copyAssetFile(context: Context, path: String, target: File) { target.parentFile?.mkdirs() + // 流式拷贝:readBytes() 会把整个文件读进内存,share 树里有若干 MB + // 级条目,逐个全量装载会在首次安装时造成明显的内存尖峰。 context.assets.open(path).use { input -> - target.writeBytes(input.readBytes()) + target.outputStream().use { output -> input.copyTo(output) } } } - private fun findMaximaBinary(dir: File): File? { - val images = File(dir, "lib/maxima") - if (!images.isDirectory) return null - images.listFiles()?.forEach { versionDir -> - val candidate = File(versionDir, "binary-ecl/maxima") - if (candidate.exists()) return candidate - } - return null - } - - private fun findMoABinary(dir: File, isArm: Boolean): File? { - val name = if (isArm) "maxima.pie" else "maxima.x86.pie" - return File(dir, name).takeIf { it.exists() } - } - - private const val MAXIMA_DATA_DIR = "maxima-5.41.0" private const val ENGINE_MARKER = "engine_version" - private const val ENGINE_VERSION = "maxima-5.49.0-arm64-jnilib-autoconf-matplotlib-assetfix" + private const val ENGINE_VERSION = "maxima-5.49.0-arm64-jnilib-autoconf-matplotlib-slim" private const val INIT_TEMPLATE = "init.lisp.template" private val SUPPORTED_ABIS = setOf("arm64-v8a", "armeabi-v7a", "armeabi", "x86_64", "x86") } diff --git a/native/README.md b/native/README.md index e07322e..0a05b87 100644 --- a/native/README.md +++ b/native/README.md @@ -81,6 +81,13 @@ Android 10 及更高版本禁止从应用私有数据目录执行文件。Maxima libmaxima.so 名称放入 jniLibs,使安装器将它解压到带可执行权限的 nativeLibraryDir。 其余 share 数据和 ECL 模块仍作为 assets 解压到应用私有目录。 +可执行文件只放 jniLibs 一份。assets 里不再保留 `lib/maxima//binary-ecl/maxima` +与 `lib/libecl.so`:它们与 jniLibs 逐字节相同(合计约 16MB),既进 APK 又解压到 +filesDir,而 Android 10+ 根本不允许从那里执行。打包脚本同时会删掉 share 树里的 +文档(PDF/texi/info/html/dem/usg/tex)和 ECL 的链接期静态库(`*.a`、help.doc、 +TAGS、ecl_min)。打包前脚本会先 `rm -rf` 整个 assets/engine,否则历史遗留文件 +会一直留在工作区并被打进 APK。 + ## 验证 至少检查以下内容: @@ -90,6 +97,9 @@ test -f ../app/src/main/jniLibs/arm64-v8a/libmaxima.so test -f ../app/src/main/jniLibs/arm64-v8a/libecl.so test -d ../app/src/main/assets/engine/share/maxima/5.49.0/share test -f ../app/src/main/assets/engine/lib/ecl-26.3.27/sb-bsd-sockets.fas +# 重复文件不应回归(各约 12MB / 4MB) +test ! -e ../app/src/main/assets/engine/lib/maxima/5.49.0/binary-ecl +test ! -e ../app/src/main/assets/engine/lib/libecl.so ~~~ 随后在 arm64-v8a 设备上安装 Debug APK,分别验证轻量计算、方程组、积分、2D 绘图 diff --git a/native/package-engine.sh b/native/package-engine.sh index ceed3a6..8a454ab 100755 --- a/native/package-engine.sh +++ b/native/package-engine.sh @@ -18,10 +18,17 @@ ECL_ANDROID="$DIST/ecl-android/$ABI" [ -d "$MAXIMA_DIR" ] || { echo "缺少 Maxima 产物($ABI)"; exit 1; } [ -d "$ECL_ANDROID" ] || { echo "缺少 ECL 产物($ABI)"; exit 1; } +# 先清空再打包:脚本只做增量 cp,历史遗留文件(旧版 libecl.so、 +# package-moa-engine.sh 解压的 additions/qepcad)会一直留在工作区并被 +# 打进 APK。必须整目录重建,assets 才等于本次产物。 +rm -rf "$TARGET" mkdir -p "$TARGET/bin" "$TARGET/lib" "$TARGET/share" # 可执行文件只进 jniLibs(Android 10+ 禁止从 filesDir execve),assets 里 # 不再放 maxima 二进制,避免误导后续维护者。 cp -a "$MAXIMA_DIR/lib/maxima/." "$TARGET/lib/maxima/" +# binary-ecl/maxima 与 jniLibs/libmaxima.so 逐字节相同(约 12MB):assets +# 副本既进 APK 又解压到 filesDir,而 Android 10+ 根本不允许从那里 execve。 +rm -rf "$TARGET/lib/maxima/$MAXIMA_VERSION/binary-ecl" # 标准 autoconf 布局:Maxima 的 file_search 指向 # /share/maxima//share/**(缺少这一层会导致共享包 # 找不到文件);lisp-utils 是部分 share 包在 ECL 下的运行时依赖。 @@ -35,6 +42,28 @@ if [ -d "$ROOT/.build/src/maxima-$MAXIMA_VERSION/lisp-utils" ]; then "$TARGET/share/maxima/$MAXIMA_VERSION/share/" fi +# init.lisp 模板:占位符由 EngineInstaller.writeInitLisp 在运行时替换。 +# 过去只有 package-moa-engine.sh 生成它,本脚本清空 assets 后必须自己产出, +# 否则 init.lisp 为空、Maxima 找不到 share 树。 +cat > "$TARGET/init.lisp.template" <<'EOF' +;;; MaxMath engine init (paths filled at runtime) +(setq *maxima-dir* "__MAXIMA_DIR__") +(defun maxima-getenv (a) + (cond ((string-equal a "MAXIMA_PREFIX") *maxima-dir*) + ((string-equal a "MAXIMA_TEMPDIR") "__TEMP_DIR__") + ((string-equal a "MAXIMA_USERDIR") "__USER_DIR__") + (t nil))) +(setq *maxima-default-layout-autotools* "true") + +;;; 常驻进程的完成信号。计算结果本身由 with_stdout 写进文件,stdout 只用来 +;;; 传这一行;finish-output 是阻塞刷新,解决“块缓冲下哨兵永远到不了原生层” +;;; ——正是当初只能靠“进程退出”表示完成的原因。 +(defun $maxmath_done () + (format *standard-output* "~&<<>>~%") + (finish-output *standard-output*) + '$done) +EOF + # Android 10+ 禁止从 filesDir execve(W^X 策略);可执行文件与 libecl.so # 必须放在 jniLibs,随 APK 解压到 nativeLibraryDir 才能运行。 mkdir -p "$JNI_TARGET" @@ -43,5 +72,18 @@ cp -a "$ECL_ANDROID/lib/libecl.so" "$JNI_TARGET/libecl.so" # 其余 ECL 运行文件(module .fas、encodings、licenses)仍是数据,留在 assets。 find "$ECL_ANDROID/lib" -maxdepth 1 -mindepth 1 -not -name 'libecl.so' -exec cp -a {} "$TARGET/lib/" \; +# ECL 的 .a 是链接期静态库,设备端永远用不到;help.doc/TAGS/ecl_min 是 +# 开发期产物。sb-bsd-sockets.fas 等 .fas 与 encodings/ 必须保留 +# (EngineInstaller.findEclDataDir 以 sb-bsd-sockets.fas 定位 ECLDIR)。 +find "$TARGET/lib" -maxdepth 2 -name '*.a' -delete +rm -f "$TARGET/lib"/ecl-*/help.doc "$TARGET/lib"/ecl-*/TAGS "$TARGET/lib"/ecl-*/ecl_min + +# share 树里的文档对离线 App 没有价值:13 个 PDF 手册就占 9.3MB。 +# 只删文档,不动 .mac/.lisp,raw 模式仍可 load 任意共享包。 +find "$TARGET/share" \ + \( -name '*.pdf' -o -name '*.texi' -o -name '*.info' -o -name '*.html' \ + -o -name '*.dem' -o -name '*.usg' -o -name '*.tex' -o -name '*.TEX' \) \ + -delete + echo "引擎已打包到 $TARGET(ABI: $ABI)" du -sh "$TARGET" From ba066268d5c5e043f70793a9ac20359af340081d Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Fri, 7 Aug 2026 14:14:26 +0800 Subject: [PATCH 2/6] Remove per-frame allocation from the plot paths Plot2DPainter.draw runs from a Compose Canvas on every gesture frame during live 2D pan/zoom. Each frame it allocated a Paint per curve, a Path per segment, an AndroidPaint in drawTickLabels plus another per annotation label, a List>> of boxed samples, and a String.format per tick label. Paints, paths, the sample buffer and the formatted tick labels are now reused; ticks recompute only when the range actually changes. CurveSampler grows an array-based entry point (interleaved coordinates plus segment-boundary indices) so sampling no longer boxes. The List API stays as a thin adapter over it, keeping CurveSamplerTest as a check on the new logic rather than a parallel implementation. PlotGlMesh allocated a boxed four-element list per grid cell to test for finite corners - 14161 of them in buildSurface and 141610 in buildContourLines, where the check sits inside the level loop - plus a boxed Int list for indices, a boxed Float list for contour vertices, an IntArray per cell in accumulateNormal, and a fresh FloatArray per grid point from viridis. All replaced with preallocated arrays and direct comparisons. PlotGlRenderer queried glGetUniformLocation/glGetAttribLocation for the axes program on every frame while the main program cached its locations at link time; cache them the same way and hoist the constant colours and light vector. PlotScreen decoded the plot PNG with BitmapFactory.decodeFile inside remember, on the composition thread. Moved to produceState on Dispatchers.IO. No subsampling: matplotlib emits 720x480, and the view allows up to 8x zoom. Co-Authored-By: Claude Opus 5 --- .../com/paruh/maxmath/ui/plot/CurveSampler.kt | 88 ++++++- .../paruh/maxmath/ui/plot/Plot2DPainter.kt | 230 +++++++++++------- .../paruh/maxmath/ui/plot/gl/PlotGlMesh.kt | 173 +++++++------ .../maxmath/ui/plot/gl/PlotGlRenderer.kt | 49 ++-- .../paruh/maxmath/ui/screens/PlotScreen.kt | 16 +- 5 files changed, 357 insertions(+), 199 deletions(-) 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() From e149147010cd89a0d4bb871eac1db4a4eba91f67 Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Fri, 7 Aug 2026 14:14:42 +0800 Subject: [PATCH 3/6] Keep one Maxima process alive instead of forking per request Every request forked a fresh maxima --batch and reloaded a 12MB ECL image, typically 1-3s on a phone. The comment in maxmath_engine.cpp explained why: completion was signalled by process exit because a stdout sentinel "may never arrive on Android due to unflushed buffering". That is fixable directly. init.lisp is ours, so define a Maxima-callable $maxmath_done that prints a sentinel and calls finish-output - a blocking flush, which is exactly the guarantee that was missing. Results already came from the with_stdout file rather than stdout (see ScriptRunner), so only the completion signal needed solving. The native layer now keeps a warm child with stdin as a pipe, writes kill(all) + batch(script) + maxmath_done() per request, and reads stdout until the sentinel. kill(all) matters because a persistent process would otherwise leak state between requests - one "x: 5" in raw mode would follow the user around. Reuse invariant: a child returns to the warm pool only if its sentinel arrived cleanly. Timeout, cancel, EOF and unexpected exit all kill and respawn lazily. This is what makes an interactive Maxima prompt (asksign, askinteger) safe - it would otherwise swallow the next script as its answer - and it degrades to the previous timeout behaviour instead. Crash diagnostics (exit code, signal) are still reported on EOF, reaped before the SIGKILL that would mask them. Persistence needs the process to outlive one request, so EngineService gets a startService keep-alive with a 60s idle timeout, which also stops re-paying Python/Matplotlib startup per plot. MaximaEngine carries its own idle reaper as well: light operations run in the UI process, which has no service to bound them. MaximaEngine.cancel now checks whether a run is actually in flight. CalcViewModel and PlotViewModel both call cancel unconditionally before each computation; without the check, the idle warm child would be killed every time and persistence would be defeated. The 2D annotation probes are merged into one script per function. Zeros and extrema both start from a solve over the same expression, so they were two round trips where one does; each side keeps its own errcatch so an unsolvable zero probe no longer costs the extrema. The default two-function plot goes from 4 engine round trips to 2. Co-Authored-By: Claude Opus 5 --- docs/SPEC.md | 9 +- engine/src/main/cpp/maxmath_engine.cpp | 376 +++++++++++------- .../com/paruh/maxmath/engine/EngineService.kt | 40 +- .../com/paruh/maxmath/engine/MaximaEngine.kt | 105 +++-- .../maxmath/engine/MaximaScriptBuilder.kt | 52 ++- .../com/paruh/maxmath/engine/PlotRenderer.kt | 25 +- .../maxmath/engine/MaximaScriptBuilderTest.kt | 19 +- 7 files changed, 412 insertions(+), 214 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 8cbcb39..7c75d17 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -34,8 +34,13 @@ parser 模块提供纯 Kotlin 的词法分析、递归下降解析和 AST。自 - 轻量操作在 UI 进程执行:化简、展开、代值、单变量求导,以及部分不超过四阶的 矩阵操作。 -- 重量操作通过 Android Messenger 转发到 :engine 独立进程。每个请求启动单独的 - Maxima 批处理子进程;取消或超时会终止子进程。 +- 重量操作通过 Android Messenger 转发到 :engine 独立进程。该进程维护一个常驻的 + Maxima 子进程:完成信号是 init.lisp 中 `maxmath_done()` 打印并 `finish-output` + 刷新的哨兵,请求之间用 `kill(all)` 复位用户绑定,从而免去每个请求重新载入 + ECL 映像的开销。取消或超时会终止该子进程,下一次请求重新拉起——只有哨兵 + 完整到达的子进程才会被复用,避免 Maxima 停在交互提问上时污染后续请求。 +- :engine 进程在最后一次请求后保留 60 秒空闲期再退出,否则每次请求都要重付 + 进程创建与 Python/Matplotlib 启动的代价。 - 2D 绘图使用同一 AST 生成 NumPy 表达式,由 Maxima 探测零点/极值,再由 Chaquopy 内嵌的 Matplotlib Agg 后端输出 PNG。 - 3D 曲面和等高线不经过 Maxima/Matplotlib:应用在本地求值 AST、构建网格并使用 diff --git a/engine/src/main/cpp/maxmath_engine.cpp b/engine/src/main/cpp/maxmath_engine.cpp index e92f841..0293fdf 100644 --- a/engine/src/main/cpp/maxmath_engine.cpp +++ b/engine/src/main/cpp/maxmath_engine.cpp @@ -18,12 +18,24 @@ extern char **environ; namespace { constexpr const char *LOG_TAG = "MaximaEngine"; +// 与 init.lisp.template 里的 $maxmath_done 一致。用尖括号包裹是为了不和 +// batch() 回显的脚本正文或计算结果撞车。 +constexpr const char *SENTINEL = "<<>>"; pid_t g_child = -1; +int g_stdin = -1; int g_stdout = -1; int g_stderr = -1; volatile sig_atomic_t g_cancelled = 0; +// nativeStart 保存的启动参数:子进程被取消/超时/意外退出后按需重启。 +std::string g_path; +std::string g_work_dir; +std::string g_init_lisp; +std::string g_lib_dir; +std::string g_ecl_data_dir; +bool g_configured = false; + std::string jstring_to_string(JNIEnv *env, jstring js) { if (js == nullptr) return ""; const char *chars = env->GetStringUTFChars(js, nullptr); @@ -38,61 +50,62 @@ long long now_ms() { return static_cast(tv.tv_sec) * 1000 + tv.tv_usec / 1000; } +void close_fd(int &fd) { + if (fd >= 0) { + close(fd); + fd = -1; + } +} + void kill_child() { if (g_child > 0) { kill(g_child, SIGKILL); waitpid(g_child, nullptr, 0); g_child = -1; } - if (g_stdout >= 0) { - close(g_stdout); - g_stdout = -1; - } - if (g_stderr >= 0) { - close(g_stderr); - g_stderr = -1; - } + close_fd(g_stdin); + close_fd(g_stdout); + close_fd(g_stderr); } -// 每个请求独立启动一个 maxima -batch 进程,完成后自然退出: -// 完成信号 = 进程退出,而不是依赖 stdout 上的哨兵(Android 上可能因 -// 缓冲不刷新而永远等不到)。超时则杀进程并返回超时标记。 -std::string run_maxima(const std::string &path, const std::string &work_dir, - const std::string &init_lisp, const std::string &script_path, - const std::string &lib_dir, const std::string &ecl_data_dir, - long timeout_ms) { - g_cancelled = 0; - if (g_child > 0) return "[engine] 已有计算在进行中"; - - __android_log_print(ANDROID_LOG_INFO, LOG_TAG, - "run: path=%s workdir=%s libdir=%s ecldir=%s", - path.c_str(), work_dir.c_str(), lib_dir.c_str(), ecl_data_dir.c_str()); +// 构造子进程环境:bin/ 与原生库目录加入 PATH,原生库目录与引擎 lib/ +// 加入 LD_LIBRARY_PATH(maxima 依赖 libecl.so),ECLDIR 指向 ECL +// 运行文件(sb-bsd-sockets.fas 等;末尾斜杠是 ECL 的要求),并让 +// Maxima 找到打包后的 share 目录。 +std::vector build_env_overrides() { + return { + "PATH=" + g_lib_dir + ":" + g_work_dir + "/bin:" + + (getenv("PATH") ? getenv("PATH") : ""), + "LD_LIBRARY_PATH=" + g_lib_dir + ":" + g_work_dir + "/lib:" + g_work_dir + "/bin", + "MAXIMA_PREFIX=" + g_work_dir, + "MAXIMA_USERDIR=" + g_work_dir + "/user", + "MAXIMA_TEMPDIR=" + g_work_dir + "/tmp", + "ECLDIR=" + g_ecl_data_dir + "/", + // 引擎数据使用标准 autoconf 布局(share/maxima//share), + // 必须让 Maxima 按 autoconf 规则计算 file_search 路径。 + "MAXIMA_LAYOUT_AUTOTOOLS=true", + }; +} - int out_pipe[2], err_pipe[2]; - if (pipe(out_pipe) != 0 || pipe(err_pipe) != 0) { +// 启动常驻的 maxima REPL(stdin 是管道,不再是 --batch 后自然退出)。 +// 返回空串表示成功,否则是错误信息。 +std::string spawn_child() { + int in_pipe[2], out_pipe[2], err_pipe[2]; + if (pipe(in_pipe) != 0) return "[engine] 创建管道失败"; + if (pipe(out_pipe) != 0) { + close(in_pipe[0]); + close(in_pipe[1]); + return "[engine] 创建管道失败"; + } + if (pipe(err_pipe) != 0) { + close(in_pipe[0]); + close(in_pipe[1]); + close(out_pipe[0]); + close(out_pipe[1]); return "[engine] 创建管道失败"; } - // 构造子进程环境:bin/ 与原生库目录加入 PATH,原生库目录与引擎 lib/ - // 加入 LD_LIBRARY_PATH(maxima 依赖 libecl.so),ECLDIR 指向 ECL - // 运行文件(sb-bsd-sockets.fas 等;末尾斜杠是 ECL 的要求),并让 - // Maxima 找到打包后的 share 目录。 - std::string path_var = "PATH=" + lib_dir + ":" + work_dir + "/bin:" + - (getenv("PATH") ? getenv("PATH") : ""); - std::string ld_var = "LD_LIBRARY_PATH=" + lib_dir + ":" + work_dir + "/lib:" + work_dir + "/bin"; - std::string md_var = "MAXIMA_PREFIX=" + work_dir; - std::string mu_var = "MAXIMA_USERDIR=" + work_dir + "/user"; - std::string mt_var = "MAXIMA_TEMPDIR=" + work_dir + "/tmp"; - std::string ecl_dir = "ECLDIR=" + ecl_data_dir + "/"; - // 引擎数据使用标准 autoconf 布局(share/maxima//share), - // 必须让 Maxima 按 autoconf 规则计算 file_search 路径。 - std::string layout_var = "MAXIMA_LAYOUT_AUTOTOOLS=true"; - - // Android app 进程的环境里可能已存在 LD_LIBRARY_PATH/PATH(Zygote 继承)。 - // execve 环境数组出现同名键时,读取方行为取决于实现,可能取第一个旧值。 - // 因此这里必须“替换”而不是“追加”,保证子进程一定拿到引擎自己的路径。 - std::vector overrides = { - path_var, ld_var, md_var, mu_var, mt_var, ecl_dir, layout_var}; + std::vector overrides = build_env_overrides(); const char *override_prefixes[] = { "PATH=", "LD_LIBRARY_PATH=", "MAXIMA_PREFIX=", "MAXIMA_USERDIR=", "MAXIMA_TEMPDIR=", "ECLDIR=", @@ -100,81 +113,169 @@ std::string run_maxima(const std::string &path, const std::string &work_dir, }; const size_t prefix_count = sizeof(override_prefixes) / sizeof(override_prefixes[0]); + // Android app 进程的环境里可能已存在 LD_LIBRARY_PATH/PATH(Zygote 继承)。 + // execve 环境数组出现同名键时,读取方行为取决于实现,可能取第一个旧值。 + // 因此这里必须“替换”而不是“追加”,保证子进程一定拿到引擎自己的路径。 std::vector envp; for (size_t i = 0; environ[i] != nullptr; i++) { bool replaced = false; for (size_t p = 0; p < prefix_count; p++) { - size_t len = strlen(override_prefixes[p]); - if (strncmp(environ[i], override_prefixes[p], len) == 0) { + if (strncmp(environ[i], override_prefixes[p], strlen(override_prefixes[p])) == 0) { replaced = true; break; } } - if (!replaced) { - envp.push_back(environ[i]); - } - } - for (auto &e : overrides) { - envp.push_back(const_cast(e.c_str())); + if (!replaced) envp.push_back(environ[i]); } + for (auto &e : overrides) envp.push_back(const_cast(e.c_str())); envp.push_back(nullptr); - std::string init_arg = "--init-lisp=" + init_lisp; - std::string batch_arg = "--batch=" + script_path; - char *const argv[] = {const_cast(path.c_str()), + // --very-quiet 抑制横幅与 (%i) 提示符,避免污染 stdout 上的哨兵判定。 + std::string init_arg = "--init-lisp=" + g_init_lisp; + std::string quiet_arg = "--very-quiet"; + char *const argv[] = {const_cast(g_path.c_str()), const_cast(init_arg.c_str()), - const_cast(batch_arg.c_str()), + const_cast(quiet_arg.c_str()), nullptr}; pid_t pid = fork(); if (pid == 0) { - int devnull = open("/dev/null", O_RDONLY); - if (devnull >= 0) { - dup2(devnull, STDIN_FILENO); - close(devnull); - } + dup2(in_pipe[0], STDIN_FILENO); dup2(out_pipe[1], STDOUT_FILENO); dup2(err_pipe[1], STDERR_FILENO); + close(in_pipe[0]); + close(in_pipe[1]); close(out_pipe[0]); close(out_pipe[1]); close(err_pipe[0]); close(err_pipe[1]); - chdir(work_dir.c_str()); - execve(path.c_str(), argv, envp.data()); - char msg[256]; - snprintf(msg, sizeof(msg), - "[engine] execve 失败: errno=%d path=%s", errno, path.c_str()); - write(err_pipe[1], msg, strlen(msg)); + chdir(g_work_dir.c_str()); + execve(g_path.c_str(), argv, envp.data()); _exit(127); } + close(in_pipe[0]); close(out_pipe[1]); close(err_pipe[1]); if (pid < 0) { + close(in_pipe[1]); close(out_pipe[0]); close(err_pipe[0]); return "[engine] fork 失败"; } g_child = pid; + g_stdin = in_pipe[1]; g_stdout = out_pipe[0]; g_stderr = err_pipe[0]; + __android_log_print(ANDROID_LOG_INFO, LOG_TAG, "maxima started: pid=%d", pid); + return ""; +} + +/** + * 回收子进程并给出退出原因。崩溃诊断(退出码 / 信号)是排查 execve 失败与 + * 段错误的主要线索,进程常驻后仍要在异常退出时保留。 + */ +std::string reap_status_note() { + if (g_child <= 0) return "\n[engine] 引擎进程已退出"; + int status = 0; + char note[160]; + pid_t r = waitpid(g_child, &status, 0); + g_child = -1; + if (r <= 0) { + snprintf(note, sizeof(note), "\n[engine] 引擎进程已退出"); + } else if (WIFEXITED(status)) { + snprintf(note, sizeof(note), "\n[exit] 退出码=%d", WEXITSTATUS(status)); + if (WEXITSTATUS(status) == 127) { + __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, + "child exited 127 (likely execve failure): %s", g_path.c_str()); + } + } else if (WIFSIGNALED(status)) { + snprintf(note, sizeof(note), "\n[exit] 信号=%d", WTERMSIG(status)); + } else { + snprintf(note, sizeof(note), "\n[exit] 状态=0x%x", status); + } + __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "maxima child exited:%s", note); + return note; +} + +/** 子进程已死则重启。返回空串表示可用。 */ +std::string ensure_child() { + if (g_child > 0) { + // 非阻塞回收:子进程可能已自行退出(崩溃/被系统杀),此时必须重启, + // 否则后面的 write 会打在已关闭的管道上。 + int status; + pid_t r = waitpid(g_child, &status, WNOHANG); + if (r == g_child) { + __android_log_print(ANDROID_LOG_WARN, LOG_TAG, "maxima exited unexpectedly"); + g_child = -1; + close_fd(g_stdin); + close_fd(g_stdout); + close_fd(g_stderr); + } else { + return ""; + } + } + return spawn_child(); +} + +bool write_all(int fd, const std::string &data) { + size_t written = 0; + while (written < data.size()) { + ssize_t n = write(fd, data.data() + written, data.size() - written); + if (n > 0) { + written += static_cast(n); + continue; + } + if (n < 0 && errno == EINTR) continue; + return false; + } + return true; +} + +/** 把管道里此刻可读的数据丢掉,保证下一次请求从干净的 stdout 开始。 */ +void drain(int fd) { + if (fd < 0) return; + char buf[4096]; + while (true) { + pollfd p = {fd, POLLIN, 0}; + if (poll(&p, 1, 0) <= 0) return; + if (!(p.revents & (POLLIN | POLLHUP))) return; + ssize_t n = read(fd, buf, sizeof(buf)); + if (n <= 0) return; + } +} + +/** + * 在常驻进程里跑一个脚本:写 stdin,读 stdout 直到哨兵。 + * + * 复用不变量:只有哨兵完整到达才保留子进程。超时、取消、EOF、进程意外 + * 退出一律 kill 并在下次请求重启——Maxima 可能停在 asksign 这类交互提问上 + * 等输入,若此时复用,下一份脚本会被当成那道提问的答案,状态就彻底乱了。 + */ +std::string run_script(const std::string &script_path, long timeout_ms) { + g_cancelled = 0; + std::string start_error = ensure_child(); + if (!start_error.empty()) return start_error; + + drain(g_stdout); + drain(g_stderr); + + // 常驻进程必须在每个请求前清干净:否则 raw 模式里一句 x: 5 会一直影响 + // 后续计算。省下的是 ECL 映像载入与 Maxima 启动(每次请求 1~3 秒), + // 脚本里的 load("eigen") 仍会照常重跑——kill(all) 也会清掉它。 + std::string command = "kill(all)$\nbatch(\"" + script_path + "\")$\nmaxmath_done()$\n"; + if (!write_all(g_stdin, command)) { + kill_child(); + return "[engine] 无法写入引擎进程"; + } std::string output; std::string err_output; - pollfd fds[2] = { - {g_stdout, POLLIN, 0}, - {g_stderr, POLLIN, 0}, - }; const long long deadline = now_ms() + timeout_ms; - bool out_open = true; - bool err_open = true; - while (out_open || err_open) { - // 取消时 kill_child 会关闭管道:bionic 上 poll 可能反复返回 - // POLLNVAL(而不是 EBADF),必须显式检查取消标志立即退出, - // 否则会空转到原 deadline,后续计算长期卡住。 + while (true) { if (g_cancelled) { kill_child(); return "[engine] 计算已取消"; @@ -186,95 +287,92 @@ std::string run_maxima(const std::string &path, const std::string &work_dir, snprintf(msg, sizeof(msg), "[engine] 计算超时(%ld 秒)", timeout_ms / 1000); return msg; } - int poll_wait = static_cast(remaining < 5000 ? remaining : 5000); + + pollfd fds[2] = { + {g_stdout, POLLIN, 0}, + {g_stderr, POLLIN, 0}, + }; + int poll_wait = static_cast(remaining < 500 ? remaining : 500); int rc = poll(fds, 2, poll_wait); if (rc < 0) { if (errno == EINTR) continue; - break; + kill_child(); + return "[engine] 引擎管道错误"; + } + + // 取消时 kill_child 会在另一个线程关闭管道;bionic 上 poll 可能反复 + // 返回 POLLNVAL 而不是错误,不显式处理就会空转到 deadline。 + if (fds[0].revents & (POLLNVAL | POLLERR)) { + kill_child(); + return g_cancelled ? "[engine] 计算已取消" : "[engine] 引擎管道已失效"; } - if (out_open && (fds[0].revents & (POLLIN | POLLHUP))) { + + if (fds[0].revents & (POLLIN | POLLHUP)) { char buf[4096]; ssize_t n = read(g_stdout, buf, sizeof(buf)); if (n > 0) { output.append(buf, static_cast(n)); + size_t at = output.find(SENTINEL); + if (at != std::string::npos) { + // 哨兵之后的残留(换行等)要清掉,否则会串进下一次请求。 + drain(g_stdout); + output.resize(at); + if (!err_output.empty()) output += "\n[stderr]\n" + err_output; + return output; + } } else { - close(g_stdout); - g_stdout = -1; - out_open = false; + // stdout EOF:子进程没了,本次结果不可信。先取回退出状态再 + // 收尾,否则 kill_child 的 SIGKILL 会盖掉真实的崩溃信号。 + std::string note = reap_status_note(); + kill_child(); + output += note; + if (!err_output.empty()) output += "\n[stderr]\n" + err_output; + return output; } } - if (err_open && (fds[1].revents & (POLLIN | POLLHUP))) { + + // stderr 只做尽力而为的收集:常驻进程不会关闭它,绝不能等它 EOF。 + if (fds[1].revents & (POLLIN | POLLHUP)) { char buf[4096]; ssize_t n = read(g_stderr, buf, sizeof(buf)); if (n > 0) { err_output.append(buf, static_cast(n)); } else { - close(g_stderr); - g_stderr = -1; - err_open = false; + // g_stderr 置 -1 后,下一轮 poll 会直接忽略这一项。 + close_fd(g_stderr); } } } - - // 管道 EOF 后回收子进程;若还没退出(极少数情况)最多等 1 秒再杀。 - int child_status = -1; - bool child_reaped = false; - if (g_child > 0) { - int status; - for (int i = 0; i < 10; i++) { - pid_t r = waitpid(g_child, &status, WNOHANG); - if (r == g_child) { - child_status = status; - child_reaped = true; - g_child = -1; - break; - } - usleep(100000); - } - if (g_child > 0) kill_child(); - } - - if (child_reaped) { - char note[160]; - if (WIFEXITED(child_status)) { - snprintf(note, sizeof(note), "\n[exit] 退出码=%d", WEXITSTATUS(child_status)); - if (WEXITSTATUS(child_status) == 127) { - __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, - "child exited 127 (likely execve failure): %s", path.c_str()); - } - } else if (WIFSIGNALED(child_status)) { - snprintf(note, sizeof(note), "\n[exit] 信号=%d", WTERMSIG(child_status)); - } else { - snprintf(note, sizeof(note), "\n[exit] 状态=0x%x", child_status); - } - output += note; - __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, - "maxima child exited: %s", note + 1); - } - - if (g_cancelled) { - return "[engine] 计算已取消"; - } - - if (!err_output.empty()) { - output += "\n[stderr]\n" + err_output; - } - return output; } } // namespace +extern "C" JNIEXPORT void JNICALL +Java_com_paruh_maxmath_engine_MaximaEngine_nativeStart( + JNIEnv *env, jobject, jstring jpath, jstring jworkdir, jstring jinit, + jstring jlibdir, jstring jeclDataDir) { + g_path = jstring_to_string(env, jpath); + g_work_dir = jstring_to_string(env, jworkdir); + g_init_lisp = jstring_to_string(env, jinit); + g_lib_dir = jstring_to_string(env, jlibdir); + g_ecl_data_dir = jstring_to_string(env, jeclDataDir); + g_configured = true; + // 写已关闭的管道会收到 SIGPIPE,默认动作是终止整个 :engine 进程。 + signal(SIGPIPE, SIG_IGN); + __android_log_print(ANDROID_LOG_INFO, LOG_TAG, + "configured: path=%s workdir=%s libdir=%s ecldir=%s", + g_path.c_str(), g_work_dir.c_str(), g_lib_dir.c_str(), + g_ecl_data_dir.c_str()); +} + extern "C" JNIEXPORT jstring JNICALL Java_com_paruh_maxmath_engine_MaximaEngine_nativeRun( - JNIEnv *env, jobject, jstring jpath, jstring jworkdir, jstring jinit, - jstring jscript, jstring jlibdir, jstring jeclDataDir, jlong timeout_ms) { - std::string path = jstring_to_string(env, jpath); - std::string workdir = jstring_to_string(env, jworkdir); - std::string init = jstring_to_string(env, jinit); + JNIEnv *env, jobject, jstring jscript, jlong timeout_ms) { + if (!g_configured) { + return env->NewStringUTF("[engine] 引擎尚未初始化"); + } std::string script = jstring_to_string(env, jscript); - std::string libdir = jstring_to_string(env, jlibdir); - std::string ecldata = jstring_to_string(env, jeclDataDir); - std::string result = run_maxima(path, workdir, init, script, libdir, ecldata, timeout_ms); + std::string result = run_script(script, timeout_ms); return env->NewStringUTF(result.c_str()); } diff --git a/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt b/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt index 99e38b1..ce99923 100644 --- a/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt +++ b/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt @@ -21,11 +21,29 @@ class EngineService : Service() { private lateinit var handler: Handler private var messenger: Messenger? = null + /** + * 空闲计时器。客户端每次请求后都会 unbind,若无其他绑定,服务连同 + * :engine 进程会立刻销毁——常驻 Maxima 进程也就无从常驻,每个请求还要 + * 重付进程创建、System.loadLibrary 和 Python/Matplotlib 启动的代价。 + * 因此这里在最后一次请求后多留 [IDLE_TIMEOUT_MS],然后主动收摊。 + */ + private val idleStop = Runnable { + MaximaEngine.stop() + stopSelf() + } + override fun onCreate() { super.onCreate() thread = HandlerThread("maxima-engine").apply { start() } handler = Handler(thread.looper) { msg -> handleMessage(msg) } messenger = Messenger(handler) + // startService 让服务不随最后一次 unbind 立即销毁;真正的结束由 + // 空闲计时器或取消触发。后台启动受限时会抛异常,此时退化成原来的 + // “每次请求重建进程”,不能让引擎进程直接崩掉。 + try { + startService(Intent(this, EngineService::class.java)) + } catch (_: Exception) { + } } override fun onBind(intent: Intent?): IBinder? = messenger?.binder @@ -36,26 +54,39 @@ class EngineService : Service() { Process.killProcess(Process.myPid()) return START_NOT_STICKY } + // 不需要系统在被杀后重建:保活靠 startService + stopSelf 这一对。 return START_NOT_STICKY } + /** 允许重新绑定,否则复用期间的第二次 bind 拿不到 onBind 的 binder。 */ + override fun onUnbind(intent: Intent?): Boolean = true + override fun onDestroy() { + handler.removeCallbacks(idleStop) MaximaEngine.stop() thread.quitSafely() super.onDestroy() } + private fun restartIdleTimer() { + handler.removeCallbacks(idleStop) + handler.postDelayed(idleStop, IDLE_TIMEOUT_MS) + } + private fun handleMessage(msg: Message): Boolean { val bundle = msg.data val reply = msg.replyTo when (bundle.getString("action")) { "eval" -> { + // 请求期间不计空闲;回复后重新计时。 + handler.removeCallbacks(idleStop) val requestJson = bundle.getString("json") ?: return true val request = CalcRequest.fromJson(requestJson) - // 首次解压 68MB 引擎资产较慢,放在工作线程执行,避免服务主线程 ANR。 + // 首次解压引擎资产较慢,放在工作线程执行,避免服务主线程 ANR。 val initError = MaximaEngine.init(applicationContext) if (initError != null) { sendReply(reply, CalcResponse(id = request.id, ok = false, error = initError).toJson()) + restartIdleTimer() return true } val response = try { @@ -64,6 +95,7 @@ class EngineService : Service() { CalcResponse(id = request.id, ok = false, error = e.message ?: "引擎内部错误") } sendReply(reply, response.toJson()) + restartIdleTimer() } "cancel" -> { MaximaEngine.cancel() @@ -89,5 +121,11 @@ class EngineService : Service() { companion object { const val ACTION_CANCEL = "com.paruh.maxmath.engine.CANCEL" + + /** + * 空闲多久后释放常驻 Maxima 与 :engine 进程。取值要盖住“看完结果再 + * 算下一题”的间隔,又不至于让一个几十 MB 的进程长期挂着。 + */ + private const val IDLE_TIMEOUT_MS = 60_000L } } diff --git a/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt b/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt index 09670a4..bc0134d 100644 --- a/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt +++ b/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt @@ -3,6 +3,9 @@ package com.paruh.maxmath.engine import android.content.Context import android.util.Log import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit /** * Maxima 引擎门面:暴露 init/run/cancel/stop 四个入口。 @@ -14,70 +17,104 @@ import java.io.File object MaximaEngine { private const val EVAL_TIMEOUT_MS = 120_000L - internal const val TIMEOUT_MARKER = "[engine] 计算超时" + + /** + * 常驻 Maxima 的空闲上限。轻量操作在 UI 进程内执行,没有 EngineService + * 的空闲计时器兜底,若不在这一层回收,一个几十 MB 的原生子进程会跟着 + * UI 进程一直挂到应用退出。 + */ + private const val IDLE_STOP_MS = 60_000L private val loadLock = Any() private val runLock = Any() - private const val LOG_TAG = "MaximaEngine" + private val idleReaper = Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "maxima-idle").apply { isDaemon = true } + } @Volatile - private var loaded = false + private var idleTask: ScheduledFuture<*>? = null @Volatile - private var installed = false + private var lastActivity = 0L + /** 是否有 nativeRun 正在进行;cancel 只在这种情况下才需要动子进程。 */ @Volatile - private var engineDir: File? = null + private var running = false - @Volatile - private var maximaPath: String? = null + private const val LOG_TAG = "MaximaEngine" @Volatile - private var initLispPath: String? = null + private var loaded = false @Volatile - private var libDir: String? = null + private var installed = false @Volatile - private var eclDataDir: String? = null + private var engineDir: File? = null @Volatile private var appContext: Context? = null - private external fun nativeRun( + /** 保存启动参数;原生层按需惰性拉起常驻子进程。 */ + private external fun nativeStart( maximaPath: String, workDir: String, initLisp: String, - scriptPath: String, libDir: String, eclDataDir: String, - timeoutMs: Long, - ): String + ) + private external fun nativeRun(scriptPath: String, timeoutMs: Long): String private external fun nativeStop(): Boolean private external fun nativeCancel(): Boolean /** - * 供 ScriptRunner 执行脚本:每个请求独立启动一次 maxima -batch, - * 以进程退出作为完成信号,不依赖 stdout 哨兵。 + * 供 ScriptRunner 执行脚本。原生层维护一个常驻的 maxima REPL:完成信号 + * 是 init.lisp 里 maxmath_done() 打出并 finish-output 刷新的哨兵,而不再是 + * 进程退出,因此不用每个请求重新载入 12MB 的 ECL 映像并重启 Maxima。 + * 每个请求前原生层会先 kill(all) 清掉上一次的用户绑定。 + * + * 只有哨兵完整到达时子进程才会被复用;超时、取消或异常退出都会杀掉进程, + * 下一次请求重新拉起。 */ internal fun eval(script: String): String = synchronized(runLock) { val dir = engineDir ?: return@synchronized "[engine] 引擎尚未初始化" - val binary = maximaPath ?: return@synchronized "[engine] 引擎尚未初始化" - val initLisp = initLispPath ?: return@synchronized "[engine] 引擎尚未初始化" - val lib = libDir ?: return@synchronized "[engine] 引擎尚未初始化" - val eclDir = eclDataDir ?: return@synchronized "[engine] 引擎尚未初始化" val scriptFile = File(dir, "tmp/script_${System.nanoTime()}.mac") scriptFile.parentFile?.mkdirs() scriptFile.writeText(script) - val result = nativeRun(binary, dir.absolutePath, initLisp, scriptFile.absolutePath, lib, eclDir, EVAL_TIMEOUT_MS) - scriptFile.delete() - if (result.startsWith(TIMEOUT_MARKER)) { - // 超时后原生层已杀死子进程;init 状态本身仍有效,下次直接重跑。 + running = true + val result = try { + nativeRun(scriptFile.absolutePath, EVAL_TIMEOUT_MS) + } finally { + running = false + scriptFile.delete() + scheduleIdleStop() } result } + /** + * 空闲一段时间后放掉常驻子进程。回收动作同样要拿 runLock,因此绝不会 + * 打断进行中的计算:真轮到它时若 [lastActivity] 已被新的请求刷新, + * 说明期间又算过,直接跳过。 + */ + private fun scheduleIdleStop() { + val stamp = System.nanoTime() + lastActivity = stamp + idleTask?.cancel(false) + idleTask = idleReaper.schedule( + { + synchronized(runLock) { + if (lastActivity == stamp && installed) { + nativeStop() + } + } + }, + IDLE_STOP_MS, + TimeUnit.MILLISECONDS, + ) + } + /** * 初始化:解压 assets/engine 到私有目录并启动 maxima 进程。 * 返回 null 表示成功,否则返回可展示的错误信息。 @@ -99,11 +136,14 @@ object MaximaEngine { } is EngineInstaller.InstallResult.Success -> { engineDir = File(install.workDir) - maximaPath = install.maximaPath - initLispPath = install.initLispPath - libDir = install.libDir - eclDataDir = install.eclDataDir appContext = context.applicationContext + nativeStart( + install.maximaPath, + install.workDir, + install.initLispPath, + install.libDir, + install.eclDataDir, + ) Log.i( LOG_TAG, "engine ready: binary=${install.maximaPath} libDir=${install.libDir} " + @@ -130,9 +170,16 @@ object MaximaEngine { } } + /** + * 中止进行中的计算。 + * + * 必须判断 [running]:CalcViewModel/PlotViewModel 在每次发起计算前都会 + * 无条件调一次 cancel,若不加判断,空闲的常驻子进程会在每次计算前被杀掉, + * 常驻也就名存实亡。 + */ fun cancel() { // 不持有 runLock:nativeRun 可能在等待子进程,取消必须立即可达。 - if (installed) { + if (installed && running) { nativeCancel() } } diff --git a/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt b/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt index c1b8844..ed46372 100644 --- a/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt +++ b/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt @@ -27,34 +27,30 @@ object MaximaScriptBuilder { """.trimMargin() } - /** 零点探测脚本:输出带 MAXMATH_RESULT 标记的数值列表。 */ - fun buildZeroProbe(expression: String, variable: String, outFile: String): String = probeScript( - outFile = outFile, - resultVar = "zs", - body = """ - | zs: map(rhs, solve((${expr(expression)})=0,$variable)), - | zs: sublist(zs, lambda([z], is(imagpart(float(z))=0))), - """.trimMargin(), - ) - - /** 极值点探测脚本:输出 [[x1,y1],[x2,y2],...] 形式的数值列表。 */ - fun buildExtremaProbe(expression: String, variable: String, outFile: String): String = probeScript( - outFile = outFile, - resultVar = "pts", - body = """ - | crits: map(rhs, solve(diff((${expr(expression)}),$variable)=0,$variable)), - | crits: sublist(crits, lambda([z], is(imagpart(float(z))=0))), - | pts: map(lambda([z], [float(realpart(z)), float(ev((${expr(expression)}),$variable=z))]), crits), - """.trimMargin(), - ) - - private fun probeScript(outFile: String, resultVar: String, body: String): String = """ - |with_stdout("${escapeForMaximaString(outFile)}", - | errcatch(block([$resultVar], - |$body - | print("MAXMATH_RESULT"), - | print(string($resultVar)))))$ - """.trimMargin() + /** + * 零点 + 极值联合探测:两者都要先解一次 solve,且共用同一个表达式, + * 拆成两份脚本就是两次引擎往返。零点写在 MAXMATH_RESULT 之后, + * 极值点写在 MAXMATH_TEX 之后,复用 ScriptRunner 已有的双标记解析。 + */ + fun buildAnnotationProbe(expression: String, variable: String, outFile: String): String { + val e = expr(expression) + return """ + |with_stdout("${escapeForMaximaString(outFile)}", + | errcatch(block([zs,crits,pts], + | zs: errcatch(sublist(map(rhs, solve(($e)=0,$variable)), + | lambda([z], is(imagpart(float(z))=0)))), + | zs: if zs = [] then [] else first(zs), + | crits: errcatch(sublist(map(rhs, solve(diff(($e),$variable)=0,$variable)), + | lambda([z], is(imagpart(float(z))=0)))), + | crits: if crits = [] then [] else first(crits), + | pts: errcatch(map(lambda([z], [float(realpart(z)), float(ev(($e),$variable=z))]), crits)), + | pts: if pts = [] then [] else first(pts), + | print("MAXMATH_RESULT"), + | print(string(zs)), + | print("MAXMATH_TEX"), + | print(string(pts)))))$ + """.trimMargin() + } private fun commandFor(task: MathTask): String = when (task) { is SimplifyTask -> "ratsimp((${expr(task.expression, task.raw)}))" diff --git a/engine/src/main/java/com/paruh/maxmath/engine/PlotRenderer.kt b/engine/src/main/java/com/paruh/maxmath/engine/PlotRenderer.kt index 9f73d9a..ee54cff 100644 --- a/engine/src/main/java/com/paruh/maxmath/engine/PlotRenderer.kt +++ b/engine/src/main/java/com/paruh/maxmath/engine/PlotRenderer.kt @@ -79,22 +79,21 @@ internal object PlotRenderer { val outDir = File(dir, "out").apply { mkdirs() } val zeros = mutableListOf() val extrema = mutableListOf>() + // 每个函数一次引擎往返:零点与极值原先是两份脚本,而两者都要先解 + // 一次 solve,合并后默认的双函数图从 4 次往返降到 2 次。 functions.forEach { f -> - val zeroFile = File(outDir, "zero_${System.nanoTime()}.txt") - val zeroOutcome = ScriptRunner.runScript( - MaximaScriptBuilder.buildZeroProbe(f, variable, zeroFile.absolutePath), - zeroFile, + val probeFile = File(outDir, "probe_${System.nanoTime()}.txt") + val outcome = ScriptRunner.runScript( + MaximaScriptBuilder.buildAnnotationProbe(f, variable, probeFile.absolutePath), + probeFile, ) - zeroOutcome.plain?.let { zeros += parseNumberList(it) } - - val extremaFile = File(outDir, "extrema_${System.nanoTime()}.txt") - val extremaOutcome = ScriptRunner.runScript( - MaximaScriptBuilder.buildExtremaProbe(f, variable, extremaFile.absolutePath), - extremaFile, - ) - extremaOutcome.plain?.let { line -> + outcome.plain?.let { zeros += parseNumberList(it) } + outcome.tex?.let { line -> val nums = parseNumberList(line) - nums.chunked(2).forEach { (x, y) -> extrema += x to y } + // 成对取用:奇数个数字说明输出被截断,丢掉尾巴而不是错位。 + for (i in 0 until nums.size - 1 step 2) { + extrema += nums[i] to nums[i + 1] + } } } PlotAnnotations(zeros.distinct(), extrema) diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt index 31dfc2f..c6abf8a 100644 --- a/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt +++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt @@ -96,11 +96,26 @@ class MaximaScriptBuilderTest { } @Test - fun `zero probe parses natural expression`() { - val script = MaximaScriptBuilder.buildZeroProbe("2x-1", "x", "/tmp/z.txt") + fun `annotation probe parses natural expression and emits both markers`() { + val script = MaximaScriptBuilder.buildAnnotationProbe("2x-1", "x", "/tmp/z.txt") assertTrue(script.contains("solve(")) assertTrue(script.contains("(2*x)")) + // 零点走 MAXMATH_RESULT,极值走 MAXMATH_TEX,一次往返取回两组标注。 assertTrue(script.contains("MAXMATH_RESULT")) + assertTrue(script.contains("MAXMATH_TEX")) + assertTrue(script.contains("diff(")) + } + + /** + * 回归:solve 解不出闭式时(如 x-cos(x)),单侧失败不能连累另一侧—— + * 合并成一份脚本后,两组探测各自套一层 errcatch。 + */ + @Test + fun `annotation probe isolates each solve with errcatch`() { + val script = MaximaScriptBuilder.buildAnnotationProbe("x-cos(x)", "x", "/tmp/z.txt") + assertTrue(script.contains("zs: errcatch(")) + assertTrue(script.contains("crits: errcatch(")) + assertTrue(script.contains("pts: errcatch(")) } @Test From 33b3acff8c7b28c42c134941f2d6ad9f2ccac4eb Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Fri, 7 Aug 2026 14:14:53 +0800 Subject: [PATCH 4/6] Add build caching and a compile check to CI Gradle build cache and parallel execution: the three modules have no serial dependency between them. Configuration cache is left commented out - Chaquopy 17 compatibility is unverified here, and a broken build is worse than a slower one. CI ran unit tests only, which compile test sources but never the Compose and GL code, so a UI-side compile error could reach main. Add assembleDebug as a compile check, and lint as a non-blocking step - lint has never run on this repo, so make its findings visible without gating on an unknown backlog. No APK size step: engine runtime assets are gitignored, so CI cannot build a representative APK. The size regression guard lives where it can actually run - assertions in EngineInstallerTest that the duplicate binaries and PDFs are absent, plus the checks in native/README.md. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ gradle.properties | 9 +++++++++ 2 files changed, 30 insertions(+) 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/gradle.properties b/gradle.properties index a311f80..ff7cd96 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,12 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official + +# 构建性能::parser/:engine/:app 之间没有串行依赖,并行 + 构建缓存能显著 +# 缩短重复构建。 +org.gradle.caching=true +org.gradle.parallel=true + +# 配置缓存还能跳过重复的配置阶段,但 Chaquopy 17 是否完全兼容尚未在本仓库 +# 验证过;确认一次构建通过后再打开这一行。 +#org.gradle.configuration-cache=true From 9dddcdbb9a22a35771469045664bc94667d03291 Mon Sep 17 00:00:00 2001 From: MaxMath Date: Fri, 7 Aug 2026 20:29:51 +0800 Subject: [PATCH 5/6] release: 1.0.1 Bump to 1.0.1 (versionCode 15) and document the optimization pass (packaging slim-down, persistent Maxima, plot hot-path de-boxing, build/CI improvements). --- RELEASE_NOTES.md | 16 +++ app/build.gradle.kts | 4 +- docs/OPTIMIZATION.md | 323 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 docs/OPTIMIZATION.md 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/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("