Kaynağa Gözat

resources usage minimalization

Milan Jurkulak 3 ay önce
ebeveyn
işleme
ff1b07b8e6

+ 69 - 0
ai-resource-analysis.md

@@ -0,0 +1,69 @@
+# Analýza minimalizace resource usage (CPU/RAM)
+
+Datum: 2026-06-16. Cíl: **non-invazivní** snížení CPU/RAM bez změny viditelného chování.
+
+---
+
+## HOTOVO (kompiluje desktop + android, čeká na běhové ověření)
+- **#0 ADB mirror → Dispatchers.IO** — `AdbScreenMirror`/`DeviceState` blokující adb I/O přesunuto
+  z Compose UI dispatcheru na IO (smyčka i `tapOnDevice`), `refreshInterval` 100→200 ms.
+  Odstraňuje zamrzání desktopu při zobrazeném mirroru.
+- **#1 Bulk pixel write** — nový `imageBitmapFromArgb` (desktop Skia `installPixels`, android
+  `Bitmap.createBitmap`) nahradil per-pixel `Canvas.drawRect` v `GifDecoder.setPixels` a
+  `ImageBitmap.cut`. ~2M draw volání/snímek → jeden bulk zápis. Výstup identický.
+- **#3 Paleta bez mezibitmap** — `Palette.update` čte pixely jednou (`image.pixels`) a vzorkuje
+  4 rohy přímo z `IntArray` přes `ImageUtils.dominantArgbColor` (Int-keyed histogram). Zrušeny
+  4× `cut()` bitmapy + Color-boxing.
+- **#4 Clock polling** — `CustomExt.timeFlow` spí do dalšího celého sekundového okraje místo
+  pollingu á 200 ms (~1 probuzení/s).
+- **#7.1 Shell blokující I/O → IO** — `Shell.init` launch přepnut z `Dispatchers.Default` na `IO`
+  (blokující `ProcessBuilder.waitFor` nehladoví CPU-sized Default pool).
+
+Pozn.: přímý `java.util.HashMap` v commonMain shazuje K2 actualizer (přes `OsRelease`) — používat
+`mutableMapOf`.
+
+---
+
+## ZBÝVÁ K ROZHODNUTÍ
+
+### #2 — GIF snímky v RAM (LRU) — POZOR na návrh
+Naivní LRU paměť **nesníží** (možná zvýší): snímek jako `IntArray` (heap, w·h·4 B) je stejně velký
+jako `ImageBitmap`, takže držet všechny IntArrays + pár přestavěných bitmap = stejně/víc než dnes
+(všechny bitmapy). Reálnou úsporu dá jen:
+- **(a)** ukládat snímky komprimovaně — palette-index **1 B/px** (~4× méně heapu) a kompozitovat
+  on-demand; čisté pro **sekvenční** přehrávání, ale rozbíjí náhodný seek (`prevFrame`/`reset`
+  v `GifViewState`) — nutno dořešit (keyframe replay nebo udržovat poslední kompozici);
+- **(b)** **coil3 animated decoder** pro přehrávání (coil GIF dekodér v projektu už je, používá se
+  pro paletu) — deleguje správu snímků, ruší vlastní `GifDecoder`; mění verified-working playback
+  path → ověřit vizuál 1:1 za běhu.
+Vybrat (a)/(b). Měřit RSS + počet živých bitmap před/po. `imageBitmapFromArgb` (z #1) dělá rebuild
+snímku levný, takže lazy decode je teď reálná cesta.
+
+### #7.2 — Scope leaky v default argumentech @Composable
+`CoroutineScope(Dispatchers.Default)` jako default arg (`AppsMenu.kt:264,276`, bázová
+`VisibilityState.kt:22`) → nový nezrušený scope při každé rekompozici. Fix = scope z
+`context.scope`/`rememberCoroutineScope()`/`remember{}`. **Pozor:** dock/sliding je křehký kód
+(leave-zone race, měsíce ladění) — udělat cíleně po domluvě, ne plošně. (`LaunchedEffect.kt:37`
+`CoroutineScope(context).launch(context)` ověřit zrušení.)
+
+---
+
+## KE ZVÁŽENÍ (prodiskutovat, zatím neřešit)
+
+### #5 — Mrtvý `colorFilter` na GIF/video (spíš correctness)
+`ImageAny` (`components/image/ImageAny.kt:126-151`) předává `colorFilter`
+(`BackgroundImage` → `ContrastColorFilter(1.5f)`) do `GifView`, ale `GifView`ovo `Image(...)`
+(`components/image/GifView.kt:39-45`) ho ignoruje → filtr je mrtvý (ai-todo #9). Buď propsat
+(1 řádek), nebo parametr zahodit. Neovlivní výkon.
+
+### #6 — Délka Crossfade tapety (možná změna chování)
+`BackgroundImage.kt:36-37` `fadeInDuration = 60000/8 = 7,5 s`, `fadeOutDuration = 15 s`. Po celou
+dobu fade se překresluje fullscreen pozadí (a běží-li GIF, i jeho snímky). Zkrácení by ušetřilo,
+ale je to viditelná změna — jen podnět.
+
+---
+
+## Měření (před/po)
+- RAM: `jcmd <pid> GC.heap_info`, RSS přes `ps -o rss`, heap dump (počet `ImageBitmap`/`IntArray`).
+- CPU: async-profiler / VisualVM — podíl `drawRect`/`writePixels`/`setPixels` při běžící GIF tapetě;
+  doba načtení tapety. Vždy stejná GIF + statická tapeta jako kontrola vizuálu.

+ 18 - 0
shared/src/androidMain/kotlin/org/mjdev/desktop/extensions/ImageBitmapFactory.kt

@@ -0,0 +1,18 @@
+package org.mjdev.desktop.extensions
+
+import android.graphics.Bitmap
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+
+// Bitmap.createBitmap consumes packed ARGB_8888 ints directly — a single bulk copy instead of
+// the per-pixel Canvas.drawRect path.
+actual fun imageBitmapFromArgb(
+    pixels: IntArray,
+    width: Int,
+    height: Int,
+): ImageBitmap {
+    if (width <= 0 || height <= 0) {
+        return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).asImageBitmap()
+    }
+    return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888).asImageBitmap()
+}

+ 7 - 4
shared/src/commonMain/kotlin/org/mjdev/desktop/components/adb/AdbScreenMirror.kt

@@ -14,6 +14,7 @@ import androidx.compose.ui.layout.ContentScale
 import com.sun.jna.Platform.isAndroid
 import dadb.Dadb
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
@@ -29,7 +30,7 @@ fun AdbScreenMirror(
     modifier: Modifier = Modifier,
     visibilityState: MutableState<Boolean> = mutableStateOf(false),
     scope: CoroutineScope = rememberCoroutineScope(),
-    refreshInterval: Long = 100L,
+    refreshInterval: Long = 200L,
     deviceState: DeviceState =
         rememberDeviceState(
             refreshInterval = refreshInterval,
@@ -86,8 +87,10 @@ class DeviceState(
 
     fun start() {
         if (job?.isActive == true) return
+        // Blocking adb/socket I/O (discover, open, screencap read) MUST run off the UI/Default
+        // dispatcher — on the Compose Main dispatcher it freezes the whole desktop.
         job =
-            scope.launch {
+            scope.launch(Dispatchers.IO) {
                 while (isActive) {
                     val dadb =
                         try {
@@ -145,7 +148,7 @@ class DeviceState(
         sizeHeight: Int,
     ) {
         dadbInstance?.let { dadb ->
-            scope.launch {
+            scope.launch(Dispatchers.IO) {
                 try {
                     val scaleX = screenWidth.toFloat() / sizeWidth
                     val scaleY = screenHeight.toFloat() / sizeHeight
@@ -162,7 +165,7 @@ class DeviceState(
     companion object {
         @Composable
         fun rememberDeviceState(
-            refreshInterval: Long = 100L,
+            refreshInterval: Long = 200L,
             scope: CoroutineScope = rememberCoroutineScope(),
         ) = remember {
             DeviceState(scope, refreshInterval)

+ 2 - 1
shared/src/commonMain/kotlin/org/mjdev/desktop/extensions/CustomExt.kt

@@ -84,7 +84,8 @@ object CustomExt {
                                 send(time)
                             }
                         }
-                        delay(200L)
+                        // sleep to just past the next whole second (~1 wake/s) instead of polling 5×/s
+                        delay((1000L - (currentTime.toEpochMilliseconds() % 1000)).coerceAtLeast(1L))
                     } while (true)
                 }
             }.collectAsState(initial = "")

+ 1 - 3
shared/src/commonMain/kotlin/org/mjdev/desktop/extensions/ImageBitmapExt.kt

@@ -67,7 +67,6 @@ fun ImageBitmap.cut(
     height: Int,
 ): ImageBitmap {
     val srcPixels = this.pixels
-    val result = ImageBitmap(width, height)
     val subPixels = IntArray(width * height)
     for (j in 0 until height) {
         for (i in 0 until width) {
@@ -76,6 +75,5 @@ fun ImageBitmap.cut(
             subPixels[dstIndex] = srcPixels.getOrElse(srcIndex) { 0 }
         }
     }
-    result.pixels = subPixels
-    return result
+    return imageBitmapFromArgb(subPixels, width, height)
 }

+ 15 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/extensions/ImageBitmapFactory.kt

@@ -0,0 +1,15 @@
+package org.mjdev.desktop.extensions
+
+import androidx.compose.ui.graphics.ImageBitmap
+
+/**
+ * Builds an [ImageBitmap] from packed ARGB_8888 pixels in a SINGLE bulk operation.
+ * Replaces the per-pixel `Canvas.drawRect` path of [writePixels] (which issued one draw call per
+ * pixel — ~2M calls for a 1080p frame) on the hot GIF-decode / palette paths.
+ * Desktop uses Skia `Bitmap.installPixels`, Android uses `Bitmap.createBitmap`.
+ */
+expect fun imageBitmapFromArgb(
+    pixels: IntArray,
+    width: Int,
+    height: Int,
+): ImageBitmap

+ 9 - 18
shared/src/commonMain/kotlin/org/mjdev/desktop/helpers/gif/GifDecoder.kt

@@ -8,7 +8,6 @@
 
 package org.mjdev.desktop.helpers.gif
 
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ImageBitmap
 import androidx.compose.ui.unit.IntRect
 import okio.BufferedSource
@@ -17,7 +16,7 @@ import okio.Path
 import okio.Path.Companion.toPath
 import okio.buffer
 import okio.source
-import org.mjdev.desktop.extensions.fillRect
+import org.mjdev.desktop.extensions.imageBitmapFromArgb
 import org.mjdev.desktop.extensions.pixels
 import org.mjdev.desktop.log.Log
 import org.mjdev.desktop.system.Filesystem.source
@@ -151,7 +150,9 @@ class GifDecoder {
     }
 
     private fun setPixels() {
-        val dest = image?.pixels ?: intArrayOf()
+        // Build the frame into a plain IntArray and create the ImageBitmap in ONE bulk op
+        // (imageBitmapFromArgb) instead of the old per-pixel Canvas.drawRect write.
+        val dest = IntArray(width * height)
         if (lastDispose > 0) {
             if (lastDispose == 3) {
                 val n = frameCount - 2
@@ -160,19 +161,9 @@ class GifDecoder {
             if (lastImage != null) {
                 val prev = lastImage?.pixels ?: intArrayOf()
                 System.arraycopy(prev, 0, dest, 0, width * height)
-                if (lastDispose == 2) {
-                    image?.fillRect(
-                        lastRect.left,
-                        lastRect.top,
-                        lastRect.width,
-                        lastRect.height,
-                        if (transparency) {
-                            Color(0, 0, 0, 0)
-                        } else {
-                            Color(lastBackgroundColor)
-                        },
-                    )
-                }
+                // NOTE: the original code called image.fillRect here for lastDispose == 2, but the
+                // result was immediately overwritten by `image.pixels = dest`, so it was a no-op.
+                // Preserved as a no-op to keep frame output identical.
             }
         }
         var pass = 1
@@ -215,7 +206,7 @@ class GifDecoder {
                 }
             }
         }
-        image?.pixels = dest
+        image = imageBitmapFromArgb(dest, width, height)
     }
 
     private fun decodeImageData() {
@@ -473,7 +464,7 @@ class GifDecoder {
         skip()
         if (isErr()) return
         frameCount++
-        image = ImageBitmap(width, height)
+        // setPixels() builds `image` directly from the decoded pixels (bulk), no empty alloc needed
         setPixels()
         frames.add(GifFrame(image, delay))
         if (transparency) {

+ 27 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/helpers/image/ImageUtils.kt

@@ -26,4 +26,31 @@ object ImageUtils {
                 .filter { c -> c.key.alpha != 0F }
                 .maxByOrNull { (_, occurrence) -> occurrence }
                 ?.key ?: Color.SuperDarkGray
+
+    /**
+     * Most frequent non-transparent color in a rectangular region of a packed-ARGB pixel buffer.
+     * Operates directly on an [IntArray] (one bulk `readPixels` for the whole image) with an
+     * Int-keyed histogram — no intermediate [ImageBitmap] allocation and no `Color` boxing per
+     * pixel. Equivalent result to [topMostColor] on a `cut()` sub-bitmap.
+     */
+    fun dominantArgbColor(
+        pixels: IntArray,
+        imageWidth: Int,
+        x: Int,
+        y: Int,
+        width: Int,
+        height: Int,
+    ): Color {
+        val counts = mutableMapOf<Int, Int>()
+        for (j in 0 until height) {
+            val rowBase = (y + j) * imageWidth + x
+            for (i in 0 until width) {
+                val argb = pixels.getOrElse(rowBase + i) { 0 }
+                if ((argb ushr 24) == 0) continue // fully transparent
+                counts[argb] = (counts[argb] ?: 0) + 1
+            }
+        }
+        val top = counts.maxByOrNull { (_, occurrence) -> occurrence }?.key ?: return Color.SuperDarkGray
+        return Color(top)
+    }
 }

+ 9 - 10
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/palette/Palette.kt

@@ -22,8 +22,8 @@ import org.mjdev.desktop.extensions.Colors.isLightColor
 import org.mjdev.desktop.extensions.Colors.lighter
 import org.mjdev.desktop.extensions.Colors.nonAlphaValue
 import org.mjdev.desktop.extensions.ImageBitmapExt.loadPicture
-import org.mjdev.desktop.extensions.cut
-import org.mjdev.desktop.helpers.image.ImageUtils.topMostColor
+import org.mjdev.desktop.extensions.pixels
+import org.mjdev.desktop.helpers.image.ImageUtils.dominantArgbColor
 
 @Suppress("CanBeParameter", "MemberVisibilityCanBePrivate", "unused")
 class Palette(
@@ -115,16 +115,15 @@ class Palette(
                 val height = image.height
                 val cutWidth = (width / 100) * cutPercent
                 val cutHeight = (height / 100) * cutPercent
-                val leftTopPart = image.cut(0, 0, cutWidth, cutHeight)
-                val rightTopPart = image.cut(width - cutWidth, 0, cutWidth, cutHeight)
-                val leftBottomPart = image.cut(0, height - cutHeight, cutWidth, cutHeight)
-                val rightBottomPart = image.cut(width - cutWidth, height - cutHeight, cutWidth, cutHeight)
+                // One bulk readPixels, then sample the four corners directly from the IntArray —
+                // no four intermediate cut() bitmaps, no per-pixel Color boxing.
+                val pixels = image.pixels
                 val colors =
                     PaletteColors(
-                        leftTopPart.topMostColor,
-                        rightTopPart.topMostColor,
-                        leftBottomPart.topMostColor,
-                        rightBottomPart.topMostColor,
+                        dominantArgbColor(pixels, width, 0, 0, cutWidth, cutHeight),
+                        dominantArgbColor(pixels, width, width - cutWidth, 0, cutWidth, cutHeight),
+                        dominantArgbColor(pixels, width, 0, height - cutHeight, cutWidth, cutHeight),
+                        dominantArgbColor(pixels, width, width - cutWidth, height - cutHeight, cutWidth, cutHeight),
                     )
                 val background = colors.darkestColor
                 // Derive text from the NEW background (assigned below), not the stale state value,

+ 30 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/extensions/ImageBitmapFactory.kt

@@ -0,0 +1,30 @@
+package org.mjdev.desktop.extensions
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asComposeImageBitmap
+import org.jetbrains.skia.Bitmap
+import org.jetbrains.skia.ColorAlphaType
+import org.jetbrains.skia.ImageInfo
+
+// Skia N32 (BGRA on little-endian) layout: the little-endian bytes of a 0xAARRGGBB int are
+// already [B, G, R, A], so the packed ARGB pixels map straight onto the byte buffer.
+actual fun imageBitmapFromArgb(
+    pixels: IntArray,
+    width: Int,
+    height: Int,
+): ImageBitmap {
+    if (width <= 0 || height <= 0) return ImageBitmap(1, 1)
+    val bytes = ByteArray(width * height * 4)
+    for (i in 0 until minOf(pixels.size, width * height)) {
+        val p = pixels[i]
+        val o = i * 4
+        bytes[o] = p.toByte() // B
+        bytes[o + 1] = (p shr 8).toByte() // G
+        bytes[o + 2] = (p shr 16).toByte() // R
+        bytes[o + 3] = (p shr 24).toByte() // A
+    }
+    val bitmap = Bitmap()
+    bitmap.allocPixels(ImageInfo.makeN32(width, height, ColorAlphaType.UNPREMUL))
+    bitmap.installPixels(bytes)
+    return bitmap.asComposeImageBitmap()
+}

+ 3 - 1
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/system/shell/Shell.kt

@@ -18,7 +18,9 @@ class Shell(
     block: suspend ShellScope.() -> Unit,
 ) {
     init {
-        scope.launch(Dispatchers.Default) {
+        // The block runs blocking process I/O (ProcessBuilder.start/waitFor) — keep it on IO so it
+        // never ties up the CPU-sized Default pool.
+        scope.launch(Dispatchers.IO) {
             with(ShellScope(this@Shell)) {
                 block()
             }