Blog

Kotlin で GUI アプリを作るならやっぱり jetpack compose for desktop

Jetpack Compose for desktop によるアプリケーション開発

Kotlin で GUI アプリを開発する場合、Swing, awt と JavaFX と Jetpack compose とかぐらい。 今更 awt, Swing などを直接使うのはちょっと大変なので、まぁ kotlin ネイティブな Jetpack compose かなぁ、という感じ。Jetpack compose for desktop でそこそこの規模のアプリを作ったって報告はあまり見かけず、ちょっと不安な感じではありつつ。

現状としては、Jetpack compose 自体は Android で広く使われていることもあって、安定して動くし、情報も多い(ただし Android 用の情報)。 desktop として使う場合には、widget がちょっと物足りなかったりする。商用製品作るぞ!みたいな気持ちだと、ちょっと頼りないかも。エンジニアが自分用ツールを作って楽しむ分には十分すぎるぐらいの機能がすでにある。

JVM で動くので、Java のライブラリがそのまま動く点は魅力。 一方で、JVM で動く故の懸念もある。メモリの使用量。ちょっとメモリ使用量は native でちゃんと丁寧に書かれたアプリより多めかも。

kotlin native 対応が jetpack compose for desktop に入ってくれる未来が来ればそのへんよくなるかもなぁ、と個人的には思っていて、海外でもそういう意見がチラホラ。しかし、現状の jetpack compose for desktop は swing ベースらしいので、その道程は果てしなく遠いのかも。

まぁ、でも普段サーバーサイド用に書いてるプログラミング言語で GUI アプリが割と普通に作れるのって便利だもんで、jetpack compose for desktop もっと流行ったら嬉しいなぁ。

で、具体何つくってるかというと、なんか ChatGPT のモデルとか切り替えながらサクサク使えるやつを作ってて、普通に便利に毎日使っていたりする。

https://github.com/tokuhirom/reflect-ai

Kotlin で Mac の window list を得る

方法を探したが、結局 ProcessBuilder プロセスで AppleScript を実行するのが正攻法っぽい。

import java.io.BufferedReader
import java.io.InputStreamReader

class WindowNameCollector {
    data class WindowState(val processName: String, val processId: String, val bundleId: String, val windowName: String)

    fun getWindowStateList(): List<WindowState> {
        val windowListString = getWindowListString()

        return parseWindowState(windowListString)
    }

    private fun parseWindowState(input: String): List<WindowState> {
        // Regex pattern for matching the input string.
        val pattern = """Process: '(.+?)', PID: '(.+?)', Bundle ID: '(.+?)', Window: '(.+?)'""".toRegex(RegexOption.DOT_MATCHES_ALL)

        // Try to find a match in the input string.
        val matchResults = pattern.findAll(input)
        return matchResults.map { matchResult ->
            val (processName, processId, bundleId, windowName) = matchResult.destructured
            WindowState(processName, processId, bundleId, windowName)
        }.toList()
    }

    private fun getWindowListString(): String {
        val script = """
tell application "System Events"
    set procs to processes
    set results to ""
    repeat with proc in procs
        if exists (window 1 of proc) then
            repeat with w in windows of proc
                set results to results & "Process: '" & name of proc & "', PID: '" & unix id of proc & "', Bundle ID: '" & bundle identifier of proc & "', Window: '" & name of w & "'\n"
            end repeat
        end if
    end repeat
    return results
end tell
        """.trimIndent()

        val pb = ProcessBuilder("osascript", "-e", script)
        val p = pb.start()

        val lines = BufferedReader(InputStreamReader(p.inputStream)).use { reader ->
            reader.readText()
        }

        val exitStatus = p.waitFor() // Wait for the process to finish.
        if (exitStatus != 0) {
            throw RuntimeException("Failed to execute osascript. Exit status: $exitStatus")
        }

        return lines
    }
}

Kotlin multiplatform で生成するコードでは、application block にすべてのコードが入っているので注意。

Jetpack compose for desktop でアプリケーションを書く場合、 https://kmp.jetbrains.com/ で最初のコードを生成すると思うのだが、こいつが生成するコードには少し癖がある。

fun main() = application {
        Window(onCloseRequest = ::exitApplication, state = rememberWindowState()) {
            mainApp.App()
        }
}

のようになっている。

application block は、Compose 側の都合で何回か呼ばれるので、普通の Kotlin app の感覚で、適当に中で Thread { }.start() とか雑に発行してると、スレッドが何個も作られて死ぬので注意。

Jetpack Compose for Desktop で Caused by: java.lang.ClassNotFoundException: javax.naming.NamingException って言われるとき

アプリが完成したなぁ、とおもっていざ ./gradlew packageDmg したとき。

Caused by: java.lang.ClassNotFoundException: javax.naming.NamingException

というエラーがでて起動しなかったりする。

これは、javax.naming などのモジュールが参照できてないので、参照先を追加していくしかない。

compose.desktop {
    application {
        nativeDistributions {
            modules("java.naming")
            // alternatively: includeAllModules = true
        }
    }
}

のように追加していけば OK

Kotlin で数字を3桁区切りで区切ってコンマを入れる

整数値を3桁区切りでコンマを入れるためには、NumberFormatクラスを使用することができます。以下は、Kotlinでの例です。

import java.text.NumberFormat
import java.util.Locale

fun main() {
    val number = 1000000
    val formattedNumber = NumberFormat.getNumberInstance(Locale.getDefault()).format(number)
    println(formattedNumber)  // 結果: 1,000,000
}

ローラーマウスモバイルの COPY/PASTEをリマップする

[ローラーマウスモバイル]のCOPY/PASTEボタン、COPY/PASTE に使いたい感じは全くしないので、[Karabiner Elements]でリマップする。

~/.config/karabiner/assets/complex_modifications/roller_mouse.json に以下のように書く。

{
  "title": "ローラーマウスモバイル用設定",
  "rules": [
    {
      "description": "C-c を option-shift-↓にマッピング。C-v を option-shift-↑にマッピング (v4)",
      "manipulators": [
        {
          "type": "basic",
          "from": {
            "key_code": "c",
            "modifiers": {
              "mandatory": ["control"]
            }
          },
          "to": [
            {
              "key_code": "down_arrow",
              "modifiers": ["left_option", "left_shift"]
            }
          ],
          "conditions": [
            {
              "type": "device_if",
              "identifiers": [
                {
                  "vendor_id": 2867,
                  "product_id": 12288
                }
              ]
            }
          ]
        },
        {
          "type": "basic",
          "from": {
            "key_code": "v",
            "modifiers": {
              "mandatory": ["control"]
            }
          },
          "to": [
            {
              "key_code": "up_arrow",
              "modifiers": ["left_option", "left_shift"]
            }
          ],
          "conditions": [
            {
              "type": "device_if",
              "identifiers": [
                {
                  "vendor_id": 2867,
                  "product_id": 12288
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

これで、Slack の「未読のメッセージにジャンプする」が出来て便利。

Hello, world レベルの JVM を作った

Java エンジニアなら JVM を実装してみたほうがいいなぁ、ということで簡単に。

の2ステップで進めていけば OK.

class file の読み込み

バイナリファイルなので、DataInputStream とか使って読んでいけば OK。

https://qiita.com/mima_ita/items/a42f3f016a411627bd7a#constant_methodref

クラスファイルの中身は

実行フェーズ

普通の VM を実装していけば OK。

stack, program counter とかを持った普通の VM を作る。 Java は普通の VM と違って、stdout に書く命令が VM レベルで実装されていないんで、そのへんもランタイムとしてロードしておく必要がある。

実装

https://github.com/tokuhirom/picojvm

というわけで、Hello, world がなんとなく動くところまで実装したのがこちらです。 だいたいここまでで2人日ぐらい。

libs.versions.toml に記載されているが利用されていないバージョン番号を探す

長年システムを運用していると、libs.versions.toml に記載があるが存在しないシステムが発生することがある。これを検出したい。

import toml
import os
import re
from collections import defaultdict

def scan_files(root_dir):
    usage_counts = defaultdict(int)
    for subdir, _, files in os.walk(root_dir):
        for file in files:
            if file.endswith('.gradle.kts'):
                with open(os.path.join(subdir, file), 'r') as f:
                    content = f.read()
                    for match in re.finditer(r'libs\.([a-zA-Z0-9._-]+)', content):
                        library = match.group(1).replace('.', '-')
                        usage_counts[library] += 1
    return usage_counts

def main():
    # TOML ファイルを読み込む
    with open('gradle/libraries.versions.toml', 'r') as f:
        toml_data = toml.load(f)
    
    libraries = toml_data['libraries']
    usage_counts = scan_files('.')
    
    # すべてのライブラリに対して使用状況をチェックする
    for library in libraries.keys():
        usage_count = usage_counts[library]
        unused_indicator = ' (*)' if usage_count == 0 else ''
        print(f'{usage_count} {library}{unused_indicator}')

if __name__ == "__main__":
    main()

ナベアツは数字がでかくなるほどアホになる割合がアップする

https://twitter.com/jagarikin/status/1711855799184785732?s=20

Let's plot を練習するのにちょうどいいなと思ったので、久々に let's plot してみる。

%use lets-plot

val max = 1000000
val rates = mutableListOf<Double>()
var nabeatsu = 0
for (i in 0..max) {
    if (i % 3 == 0 || i.toString().contains('3')) {
        nabeatsu += 1
    }
    val r = nabeatsu.toDouble() / i.toDouble()
    if (r.isInfinite()) {
        rates.add(1.0)
    } else {
        rates.add(r)
    }
}

val data = mapOf(
    "x" to 0..max,
    "nabeatsu-rate" to rates
)

var p = letsPlot(data)
p += geomLine() { x = "x"; y = "nabeatsu-rate" }
p + ggsize(700, 350)

DataLore 使うとこういうの簡単に試せて便利。

2つのディレクトリの jar ファイルを比較するスクリプト

ChatGPT、こういうのの生成はやたら得意なので、ChatGPT で生成した。 こういう単純なスクリプトは手で書くより早いよねぇ。

import os
import sys
import subprocess
import difflib
import zipfile
import subprocess
import tempfile
from pathlib import Path

def get_git_commit_hash(directory):
    """Return the latest git commit hash of the specified directory."""
    try:
        commit_hash = subprocess.check_output(['git', '-C', directory, 'rev-parse', 'HEAD']).decode('utf-8').strip()
        return commit_hash
    except subprocess.CalledProcessError:
        print(f"Error: Failed to retrieve git commit hash for {directory}")
        sys.exit(1)

def get_jar_files(directory):
    """Return a list of jar files in the specified directory."""
    basedir = Path(directory)
    return [f.relative_to(basedir) for f in basedir.rglob("*.jar") if f.is_file()]

def are_jars_identical(jar1_path, jar2_path):
    """Check if the contents of the two JAR files are identical and display unified diff for .html files if they're different."""

    def is_binary(content):
        """Determine if the given content is binary."""
        return b'\x00' in content

    def get_javap_output(class_file_path):
        """Get the bytecode dump using javap."""
        try:
            output = subprocess.check_output(['javap', '-verbose', '-c', class_file_path], stderr=subprocess.STDOUT)
            return output.decode('utf-8')
        except subprocess.CalledProcessError as e:
            return str(e)

    with zipfile.ZipFile(jar1_path, 'r') as jar1, zipfile.ZipFile(jar2_path, 'r') as jar2:
        jar1_files = set(jar1.namelist())
        jar2_files = set(jar2.namelist())

        if jar1_files != jar2_files:
            return False, "File lists are different"

        for file_name in sorted(jar1_files):
            with jar1.open(file_name) as file1, jar2.open(file_name) as file2:
                file1_contents = file1.read()
                file2_contents = file2.read()

                if file1_contents != file2_contents:
                    # If the files are .html and not binary, display the unified diff
                    if file_name.endswith('.class'):
                        # If the files are .class files, get the javap output and compare
                        with tempfile.NamedTemporaryFile(suffix='.class', delete=True) as tmp1, tempfile.NamedTemporaryFile(suffix='.class', delete=True) as tmp2:
                            tmp1.write(file1_contents)
                            tmp2.write(file2_contents)
                            tmp1.flush()
                            tmp2.flush()

                            javap_output1 = get_javap_output(tmp1.name)
                            javap_output2 = get_javap_output(tmp2.name)

                            diff = difflib.unified_diff(
                                javap_output1.splitlines(),
                                javap_output2.splitlines(),
                                fromfile=f"{jar1_path}/{file_name}",
                                tofile=f"{jar2_path}/{file_name}"
                            )
                            return False, file_name + "\n" + "\n".join(diff)
                    elif file_name.endswith('.html') and not is_binary(file1_contents) and not is_binary(file2_contents):
                        diff = difflib.unified_diff(
                            file1_contents.decode().splitlines(),
                            file2_contents.decode().splitlines(),
                            fromfile=f"{jar1_path}/{file_name}",
                            tofile=f"{jar2_path}/{file_name}"
                        )
                        return False, "\n".join(diff)
                    return False, f"File contents are different: {file_name}"

    return True, ""

def main():
    if len(sys.argv) != 3:
        print("Usage: script_name directory1 directory2")
        sys.exit(1)

    dir1, dir2 = sys.argv[1], sys.argv[2]

    # Display git commit hashes for both directories
    commit_hash_dir1 = get_git_commit_hash(dir1)
    commit_hash_dir2 = get_git_commit_hash(dir2)

    print(f"Git commit hash for directory 1: {dir1} {commit_hash_dir1}")
    print(f"Git commit hash for directory 2: {dir2} {commit_hash_dir2}\n\n\n")

    jar_files_dir1 = set(get_jar_files(dir1))
    jar_files_dir2 = set(get_jar_files(dir2))

    common_jar_files = jar_files_dir1.intersection(jar_files_dir2)

    for jar_file in common_jar_files:
        if "-sources.jar" in str(jar_file):
            continue
        file1_path = os.path.join(dir1, jar_file)
        file2_path = os.path.join(dir2, jar_file)

        is_same, reason = are_jars_identical(file1_path, file2_path)
        if not is_same:
            print(f"## {jar_file} has different contents.")
            print(reason)
            print("\n\n")
        else:
            #print(f"{jar_file} has the same contents.")
            pass

    # Report files only in directory 1
    unique_files_dir1 = jar_files_dir1 - common_jar_files
    for jar_file in unique_files_dir1:
        print(f"{jar_file} exists only in directory 1.")

    # Report files only in directory 2
    unique_files_dir2 = jar_files_dir2 - common_jar_files
    for jar_file in unique_files_dir2:
        print(f"{jar_file} exists only in directory 2.")

if __name__ == "__main__":
    main()

Pebble という Java のテンプレートエンジンが Jinja2 みたいで良い

https://pebbletemplates.io/

最近、コードをテンプレートで生成するのに mustache を使っていたのだが、表現力がしょぼすぎて辛かったので pebble にしてみた。Jinja2 みたいな感じで使いやすい。

mustache は else すら書けないので冗長になりがちでイヤン。

StarField 一周目クリア

Bethesdaゲームを順当に宇宙に移したな〜という感想。 いつも通りシステムに対する説明がたりてなかったりするけど十分面白い。

gradle が遅いなと思ったら gradle-profiler を使おう

https://github.com/gradle/gradle-profiler

gradle の処理がなんだか遅いなってときがある。

サッとググッて出てくる情報だと、--profile つけろとか、--scan 使えとか書いてある。 --profile は情報がざっくりしすぎていてよくわからないし、--scan は gradle.org に情報がアップロードされちゃうのがちょっと。。 --profile って結局は、「どのタスクが遅いか」はわかるんだけど、どのプラグインが遅いか、とかはよくわからなかったりするんだよね。

gradle-profiler を使うと、どのプラグインが時間食ってるか、みたいなことをサッとしれて便利。 何回か動かしたベンチマークの結果をレポートにまとめてくれるので、gradle の速度チューニングした結果の評価にも便利。

benchmark の取り方

$ gradle-profiler --benchmark --project-dir . assemble

とかやって動かす。

数回試行された結果が以下のような HTML で生成される。

profiling の仕方

jfr や async-profiler をかけながらgradleを動かせるのも便利すぎる。

なんだか最近gradle遅いな、と思ったらgradle-profilerかけると良いと思う。

gradle-profiler --profile async-profiler --project-dir . assemble

async-profiler 以外にも jfr や YouProfiler などを使うことも可能。

以下のような見慣れた flamegraph を得ることができる。

これをやった結果、spring の dependencyManagement に個別の dependency をいっぱい指定すると遅いから platform project 作ったほうが良さそうだなぁ、とかそういうことがわかったりする。

let's plot kotlin で X 軸を YearMonth にしたときにソートされないよって場合

https://github.com/JetBrains/lets-plot-kotlin/issues/48

epoch millis にして scaleXDateTime するのが良さそう

JDK の JIT の様子を眺める方法 6選

JDK の JIT の様子を眺める方法について考える。

実際には 現代のサーバーサイドエンジニアリングでは JDK の JIT がパフォーマンスイシューの原因になっているケースは極めて少ない が、JIT がどんなふうなタイミングで発生しているかを把握しておくことは人生にとって有益だと思う。

ログを出すと若干のオーバーヘッドがあると思うけど、JIT のログは起動直後にちょっと出るだけで量も大したことないので気にしなくていいレベルな気がする。

JFR で見る

王道っぽい。

JFR Event Streaming を使って収集する

Java 14 以後で利用可能な JEP 349: JFR Event Streaming を使って micrometer などに出力する。この方法だと、JFR が動く前の JIT イベントが取れない気がする。 https://matsumana.info/blog/2020/09/20/jvm-jit-compilation-metrics/

Unified Logging に JIT log を出す

https://matsumana.info/blog/2020/09/22/jvm-jit-compilation-metrics-with-mtail/

-Xlog:jit+compilation=debug というオプションを指定することで、JIT のログを取れる。Unified JVM Logging 形式なので取り回しがわりとしやすいかも。

ref. https://www.slideshare.net/slideshow/embed_code/key/BLJsjK56iDL0y

-XX:+PrintCompilation

-XX:+PrintCompilation をつけると、JIT コンパイルログが stdout に出力される。軽く見るにはいいかも。Unified Logging でとれる今となってはあえてこれを使う理由はないかも。

ref. https://www.slideshare.net/nttdata-tech/jit-compiler-jjugccc-2020-fall-nttdata-sakata?slide=42

-XX:+LogCompilation

-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation つけるとファイルにログが出力される。ファイル名は current directory の hotspot_pid$$.log となる。XML で出力されるので処理しやすいかもしれない。

JitWatch で眺めると楽しい。

ref. https://yuya-hirooka.hatenablog.com/entry/2021/04/30/234227

https://github.com/AdoptOpenJDK/jitwatch/releases/tag/1.4.7 このへんからダウンロードできるので使うとよい。

wget https://github.com/AdoptOpenJDK/jitwatch/releases/download/1.4.7/jitwatch-ui-1.4.7-shaded-mac-m1.jar
java -jar jitwatch-ui-1.4.7-shaded-mac-m1.jar

Remnant 2 クリア

Remnant 2 をクリア。難易度はサバイバー。サバイバーでも十分すぎるぐらいに難しい。特にボス。ラスボスとラビリンスがキツかった。。 ソウル系のTPSというやつ。

かなりゲームバランス良くて、楽しめた。雰囲気もダクソっぽくて楽しい。

自動生成ダンジョンなのは野心的だが、結局のところ、ソウル系ってボスが命ってことになっちゃうので、ボスまわりは毎回同じなので、刺激は薄れちゃうかもなぁ。

ゲーム内がかなり説明が不親切で、例えば特性が65が上限なのにそれがゲーム内だとさっぱりわからんとかそういうのはある。。 が、基本的には楽しめた。

攻略につまった場合は野良の協力者を召喚すればうまくいくこともあるので楽しい。

自作ブログの構成更新

k8s を趣味でも使うか、、と思ってこのブログでも DigitalOcean の k8s を使うようにしてみたのだが、DigitalOcean への月々の支払いが $100/mo とかになってきて、さすがにたけーなと。円安だし。。

というわけで、もう少し安くすることを考えてみた。

DigitalOcean Apps というのがあったので、これを使うようにしてみる。これは AWS App Runner みたいなやつで、簡単に docker image をホスティングできるし、TLS 終端とかもいい感じにやってくれる。

といった感じのオペレーションをしたらとりあえず完了。

これでとりあえず $40/mo ぐらいにはなった。本当は、admin サイトと web サイトを統合して一つの app に入れるようにしたらもう少しコストを圧縮可能だが、、アプリのコードをそこそこいじらないと行けないと思うので、一旦ここまで。

kravis をやめて letsPlot に移行した

どちらも ggplot2 を意識したインターフェース。

というか kravis は ggplot2 の wrapper なので、ggplot2 の実装をそのまま使えて便利っちゃ便利。 一方で、ggplot2 をインストールしなきゃいけないので面倒なこともある。docker で動かすこともできるのだが、動作にちょっとコツが必要。

kravis は krangl と同じ作者なので、相性がよくて便利だったのだが、kotlinx-dataframe 時代になったのでその優位性は失われた。

letsPlot kotlin は kotlin で実装されてるのでインストールが楽。 昔は letsPlot は機能が少なすぎたが、最近は letsPlot は機能がだいぶ増えてきたので letsPlot に移行することにする。

    implementation("org.jetbrains.lets-plot:lets-plot-common:3.2.0")
    implementation("org.jetbrains.lets-plot:lets-plot-image-export:3.2.0")
    implementation("org.jetbrains.lets-plot:lets-plot-kotlin-jvm:4.4.1")

とかやる。

    private fun renderSummary(df: DataFrame<Any?>) {
        val p = letsPlot(df.toMap()) +
            geomBar(alpha = 0.3) { x = "year"; fill = "severity" } +
            facetWrap(facets = arrayOf("product")) +
            themeLight()

        ggsave(p, "./reports/summary.png", path = ".")
    }

みたいに実装する。ggsave() とか ggplot2 感がすごい。

LocalDate をそのまま入れておくと描画できないとか微妙に癖はあるが。。 datalore との相性も jetbrains がメンテしているだけあって良い気がするので、今後はこっちをつかっていきたい所存。

Spring の @Scheduled は 1つのスレッドで動く。

https://qiita.com/ksh-fthr/items/34126324cc71bfc4d469 https://tech.excite.co.jp/entry/2022/12/09/101008

Spring の @Scheduled はデフォルトでは1つのスレッドで動くので、必要ならば spring.task.scheduling.pool.size を設定してプールサイズを大きくしておく必要があります。

queue にどのぐらい積まれているかは actuator を prometheus で出している場合、 executor_queued_tasks で確認できるので、これを監視すると良い。

fsmonitor のソケットが原因で generateGitProperties がエラーになる

	> java.io.IOException: Cannot snapshot .git/fsmonitor--daemon.ipc: not a regular file

というエラーが generateGitProperties タスクで発生する。

rm -rf .git/**/fsmonitor-* をした上で、git config --global fsmonitor.socketDir "$HOME/.git-fsmonitor" としたほうが良いと思われる。

これでダメなら git config --global core.fsmonitor false か。