使用Scala编写GTK程序(一)
简介
最近在研究llama.cpp的时候,看到有人做了个Scala的绑定,基于slinc这个库。而这个库,利用了最新的Java的foreign接口。 说起来我也有段时间没再接触Java的新特性了。正好最近又有激情了,再拿GTK练练手吧。
这次不像上次那样折腾交叉编译的内容,直接使用Flatpak打包项目。 主要目标还是走通流程:基于Java的Foreign API使用Scala写一个最简单的GTK程序,并使用Flatpak打包。
Scala调用C程序
这是基于Java的最新的特性。这个特性还在第二次预览阶段,尚未正式成为Java语言的正式一部分, 因此下一个版本仍可能有些修改。比如我最初是用Java 19写的部分内容,后改用Java 20写的时候,就修改了大量的API以及概念。 下个长期维护版本,也就是Java 21的时候依然会有大量的修改。
首先我们创建一个Scala 3的项目,一如既往采用sbt new scala/scala3.g8
即可。这次我们要使用的Scala版本是一个长期维护版本,也就是
3.3.0。说起来最早写博客的时候,连Scala 3都还在预览中,真是时光飞逝。这次我们必须使用Scala 3.3.0的原因是,我们会在调用外部库的时候
用到Java的一个比较罕见的功能,MethodHandler
。而这个功能,对于Scala 3而言,在3.3.0之前是不支持的。
具体原因嘛,参见这个博文。对应的Java版本在编写时采用的是Java 20。
生成项目后,我们就可以参照官方文档
来写一小段代码,来调用C语言的strlen
函数了。
首先我们要了解一些概念。在C语言中,程序员负责内存的分配与释放。在Java环境下,为了能够自动地完成这一操作,
提出了SegmentScope
这一概念,也就是类似于作用域。
每一个分配的内存,也就是MemorySegment
,
都会和一个特定的作用域绑定。当作用域消失的时候,分配的内存将会被回收,也就不能再被访问了。
作用域分为全局的、自动的和Arena
的。
全局的可在各个线程上访问,并且不会被释放。自动的则会在没有了引用之后由垃圾处理器自动收集,非常智能。而Arena的,或者说直译为场地的,则会取决于场地什么时候关闭。
在例子中,我们采用的是最后一种,场地的。在Java中,可以通过try-catch-resource
的语法来声明并捕捉异常。而在Scala中,我们可以使用
cats-effect
的Resource
来进行管理:
def arena: Resource[IO, Arena] = Resource.fromAutoCloseable(IO(Arena.openConfined()))
这样我们便可以在场地的存活期内进行一些基于场地的操作,例如:
object HelloWorld extends IOApp.Simple {
def arena: Resource[IO, Arena] = Resource.fromAutoCloseable(IO(Arena.openConfined()))
val run = arena.use { a =>
given arena: Arena = a
val len = strlen("Hello World")
IO.println(len)
}
def strlen(str: String)(using arena: Arena): Long = {
val nativeString = arena.allocateUtf8String(str)
val linker = Linker.nativeLinker()
val stdLib = linker.defaultLookup()
val strlen_addr = stdLib.find("strlen").get()
val strlen_sig = FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
val strlen = linker.downcallHandle(strlen_addr, strlen_sig)
strlen.invokeExact(nativeString).asInstanceOf[Long]
}
}
如此,我们便模仿了官方文档中的例子,但又加入了一丢丢Scala元素。
大概解释下这个例子。首先我们在Java堆外分配了一个生存期与场地同寿的字符串空间,并赋值字符串。之后我们获取了系统原生的链接器。
接下来我们需要知道在哪里能找到strlen
这个函数。我们知道它是C标准库,Java也意识到了这一点,因此我们只需要通过linker.defaultLookup()
即可获得默认的
搜索器。然后我们通过搜索器搜索strlen
这一函数并获得了其地址。之后我们为这个函数创造了一个类型的描述,并且通过链接器
获得了这一函数。我们将它作用于之前创造的字符串空间,并获得了返回值。
Scala调用GLib
之前的例子只是调用C语言标准库。这次我们来调用GTK中的GLib这一个库。
实现流程与官方文档大致相同,只是我们需要通过链接器找的库不是标准库,而是动态链接库libglib-2.0.so.0
,因此需要另外的搜索器:
def glib(using arena: Arena) = SymbolLookup.libraryLookup("libglib-2.0.so.0", arena.scope())
然后我们就可以以GList
的一些函数为例。比如说,往空的列表中加一个数字,再取出列表的首项。
object HelloWorld extends IOApp.Simple {
def glistAppend(list: MemorySegment, data: MemorySegment)(using arena: Arena): MemorySegment = {
val append_addr = glib.find("g_list_append").get()
val append_sig = FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS)
val append = linker.downcallHandle(append_addr, append_sig)
append.invokeExact(list, data).asInstanceOf[MemorySegment]
}
def glistNthData(list: MemorySegment, n: Int)(using arena: Arena): MemorySegment = {
val nth_data_addr = glib.find("g_list_nth_data").get()
val nth_data_sig = FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT)
val nth_data = linker.downcallHandle(nth_data_addr, nth_data_sig)
nth_data.invokeExact(list, n).asInstanceOf[MemorySegment]
}
def test(using arena: Arena): Int = {
val nativeInt = arena.allocate(ValueLayout.JAVA_INT, 100)
val list = glistAppend(MemorySegment.NULL, nativeInt)
val value = glistNthData(list, 0)
val segment = MemorySegment.ofAddress(value.address(), 4, arena.scope())
segment.get(ValueLayout.JAVA_INT, 0)
}
val run = arena.use { a =>
given arena: Arena = a
IO.println(test)
}
}
只要简单的sbt run
运行,我们就可以看到输出依然是100,确实就是我们最初放进去的数字。好耶!
且慢。这里其实并没有这么简单。我们之前提到,MemorySegment
就是指针。那么为什么在test
函数里,我们已经有了输出值value
,我们
却还要重新构造一个指针segment
再来访问呢?原来Java为了安全,对于外部库返回的指针,它不清楚具体的作用时间和对应的内存大小,于是
做最坏打算:作用时间设成永久,内存大小设为零。也就是说,如果我们直接采用value.get(ValueLayout.JAVA_INT, 0)
,我们会收到越位错误。
具体细节可以参考MemorySegment
的官方文档。
Flatpak打包Java程序
Flatpak是类似容器的软件打包方式。也即是说,每一个软件运行时会有自己的依赖库。如此一来,便可以保证每个软件都有正确的依赖库,以 减少版本错误带来的问题。当然,这也不代表每个软件的每个依赖库都要装一遍。只要有软件的依赖库版本相同,那么Flatpak也还是允许共用的。
Flatpak因为是针对桌面应用,提供的基底有四个。一个是最简单的FreeDesktop,一个是Gnome,一个是KDE,一个是Elementary。它们之间 也有继承关系。Gnome和KDE都理所当然基于FreeDesktop,Elementary则基于Gnome。那么问题来了,如果我依赖的不只是桌面库,我还要其他的, 比如说我要Java怎么办?可以构建的时候把依赖库给拷贝进去。当然,总是这么做也很麻烦,因此Flatpak也提供了一些“扩展”,可以在开发的时候 通过一些简单的指令把依赖库给装进去。Java就有这样的一个扩展,也是我们这里要用到的。
首先,我们需要安装flatpak
和flatpak-builder
,并设置好flathub
为商店。这里请查看官方文档。flatpak-builder
可以通过
flatpak
来安装,但是如果是在WSL2上开发,会遇到需要设置dbus相关的问题,因此还是直接用系统提供的版本更为方便。
之后我们需要下载我们的运行时与开发工具。
一切就绪后,我们只需要运行flatpak-builder --user --install --force-clean build-dir online.aoxiang.hello-java.yml
即可把软件安装在本地。
其中build-dir
指的是构造时用的文件夹,online.aoxiang.hello-java.yml
则是指向manifest文件。
我们这里给出两种方式打包Java程序:复制文件、复制自定义Java运行时(JRE)。
复制文件
首先是最简单的复制类文件。我们以文件HelloWorld.java
为例,通过javac HelloWorld.java
编译后,再用java HelloWorld
来运行。
对应的manifest文件如下:
app-id: online.aoxiang.hello-java
runtime: org.freedesktop.Platform
runtime-version: '22.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.openjdk17
command: hello-java.sh
modules:
- name: openjdk
buildsystem: simple
build-commands:
- /usr/lib/sdk/openjdk17/install.sh
- name: hello-java
buildsystem: simple
build-options:
env:
JAVA_HOME: /usr/lib/sdk/openjdk17/jvm/openjdk-17
build-commands:
- $JAVA_HOME/bin/javac HelloWorld.java
- install -t /app/bin -D HelloWorld.class hello-java.sh
sources:
- type: file
path: HelloWorld.java
- type: script
dest-filename: hello-java.sh
commands:
- exec /app/jre/bin/java -cp /app/bin HelloWorld
大致可以看出,我们首先给了一个软件的标识符。之后我们指定了软件的运行时和开发套装:FreeDesktop版本22.08。注意这运行时和开发套装都需要
另外用Flatpak安装以用于构造软件。之后我们指定了开发用的扩展:openjdk17。当进入开发后,其中的内容便会被挂载在/usr/lib/sdk/
下。
之后我们指定了两个模块,一个是openjdk
,另一个则是我们的hello-java
。
对于openjdk
,我们使用扩展自带的安装命令,将java
安装在默认位置,即/app/jre
。
对于我们的hello-java
,我们使用扩展提供的javac
来编译我们的源文件。可以看到,我们提供了构建时的环境,将JAVA_HOME
指向了
扩展中的openjdk-17
。编译后,我们使用install
将它与hello-java.sh
安装到/app/bin
下。最后的sources
部分定义了构建时可以
访问的文件。其中HelloWorld.java
以本地文件的形式挂载,而hello-java.sh
则是直接从我们提供的命令生成。之所以这么做,是因为
整个软件的command
,也就是运行指令,只能是一个可执行文件,因此需要参数的命令需要写在一个单独的脚本中后执行。
在扩展中还提供了其他的打包工具,如maven和gradle。如果想要查看具体被放在了哪里,一个简单的方法是进入build-shell
,也就是构建时的
命令行环境。这个可以通过运行flatpak-builder --user --install --force-clean build-dir online.aoxiang.hello-java.yml --build-shell=hello-java
命令,也就是在之前的命令后加上build-shell=hello-java
,来进入该模块的构造环境。
依照同样的方式,我们还可以直接复制打包好的JAR文件,或是用GraalVM生成的原生文件,这里就不赘述。
复制自定义JRE
一个更加有趣的方案是复制自定义Java运行时。这个功能是Java提出模块化的主要目的之一:通过打包必要的模块,来减小发布所需要的体积。 定义文件如下:
app-id: online.aoxiang.hello-java
runtime: org.freedesktop.Platform
runtime-version: '22.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.openjdk17
command: /app/jre/bin/helloworld
modules:
- name: hello-java
buildsystem: simple
build-options:
env:
JAVA_HOME: /usr/lib/sdk/openjdk17/jvm/openjdk-17
build-commands:
- $JAVA_HOME/bin/javac -d out module-info.java
- $JAVA_HOME/bin/javac -d out --module-path out online/aoxiang/hello/HelloWorld.java
- $JAVA_HOME/bin/jlink --no-header-files --no-man-pages --compress=2 --launcher helloworld=helloJavaModule/online.aoxiang.hello.HelloWorld --module-path $JAVA_HOME/jmods:out --add-modules helloJavaModule --output jre
- cp -ra jre /app/jre
sources:
- type: dir
path: .
此处我们假设当前文件夹中有一个位于online/aoxiang/hello/
之下的HelloWorld.java
文件,其中的类的包即为online.aoxiang.hello
。
另有一module-info.java
文件内容如下:
module helloJavaModule {
requires java.logging;
}
于是,我们便可以通过编译相关信息,再用jlink
链接我们所仅需的模块,构造出一个小体积的Java运行时。可以说成果不菲。
如果完全按照第一步安装完整的JRE,我们会得到一个大约183.3MB的软件。而通过自定义运行时并选择no-header-files
和no-man-pages
等
选项,我们可以得到一个仅为34.1MB的软件,体积仅为1/5不到。当然,越是复杂的软件所需要的模块便越多,节约空间效果也未必这么好。
Flatpak打包Scala调用GLib
于是我们将前几步合在一起。基于调用GLib
的Scala程序,我们用sbt-assembly插件打包,
并复制进Flatpak软件中。需要注意的是,这次我们要把基底换为Gnome。如此一来,我们便有了一个可以在Flatpak中运行的Scala调用GLib的软件了。
定义文件如下:
app-id: online.aoxiang.hello-gtk
runtime: org.gnome.Platform
runtime-version: '44'
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.openjdk
command: hello-java.sh
modules:
- name: openjdk
buildsystem: simple
build-commands:
- /usr/lib/sdk/openjdk/install.sh
- name: hello-gtk
buildsystem: simple
build-options:
env:
JAVA_HOME: /usr/lib/sdk/openjdk
build-commands:
- install -t /app/bin -D gtks.jar hello-java.sh
sources:
- type: file
path: gtks.jar
- type: script
dest-filename: hello-java.sh
commands:
- exec /app/jre/bin/java --enable-native-access=ALL-UNNAMED -jar /app/bin/gtks.jar
总结
折腾,非常有趣的折腾,将来接着折腾。