指令集技术站祝各位技术大佬新年好!
开年第一篇,我可是整个假期都在搞代码呢,感谢老婆大人对我的工作的支持,有家人的支持,干起活来事半功倍(你们要认为这是狗粮那也必须吃了!)。
对于开发 UI 这块业务来说,总有两种声音,一种是命令式的,也就是传统的代码来进行开发,而另一种则是现在更为流行的声明式。究其区别乃是编程思想上的截然不同,命令式强调的是通过给机器下达详细的命令,令机器完成所要的结果,而声明式更为强调想要的结果,而过程则是交由机器自动处理。
目前几种涉及 UI 开发的框架均是声明式的,如 SwiftUI
, JetPack Compose
等,然而我很早就不再写移动端的 APP 了,转而写后端和底层应用,照理说这些东西与我无缘,然而最近的一些需求,又让我不得不拿出桌面开发的技能来折腾一番。
选定的 UI 方案是 Swing,虽然古老了点,但是开发起来简单,我们的日用品 IntelliJ IDEA 也是用 Swing 开发的呢。
那下面就是一些代码了,我随手截取了一小段:
private fun initFrame() {
leftPane.add(splitPane(JSplitPane.VERTICAL_SPLIT, sourcePane, destinationPane, config.sourcePaneDivider))
outputPane.add(JTabbedPane().apply {
globalOutputTab = this
addTab("Log", logPane)
addTab("Terminal", terminalPane)
addTab("Search Result", searchResultPane)
selectedIndex = 0
})
... ...
}
是不是看出什么问题来了,虽然在这里也用了 kotlin 的全局 apply
扩展,代码看起来相对简洁,但是本质的问题没解决,我们依然需要用大量的 add
,来进行布局,要明确的知道一个 Tab 的名称和内部容器,当界面很复杂的时候,光是理清层级关系已经不是易事。这也就是命令式的一个弊端,你必须把每一件事情都描述得清清楚楚,机器才能不折不扣的帮你执行。
那么我们希望得到的结果是什么呢?当然是如下的声明式代码了:
pager {
panelTab(title = "Log", canClose = false) {
... ...
label(title = "Log") { }
list<String> {
... ...
}
}
panelTab(title = "Terminal", canClose = true) {
... ...
}
... ...
}
此处当然是伪代码,只表明意图,即我们不需要关心组件的组织方式,而是简单的将上级组件写在容器组件内,即完成布局,这样就使得界面在代码上一览无遗,不再需要去找子组件的定义,也不用担心某个组件的 Owner 指错而引起界面绘制不正确的问题。
在接下去讲之前,有个事先声明,通常情况下,声明式的 UI 框架会带有 DataBinding
的能力,即对变量在界面上的呈现作出绑定动作,以实现当变量发生变化时,刷新界面的能力。而我们今天要讲的,不包含 DataBinding
,只是一个纯粹的声明式编码思路。
为了完成这一动作,我们需要将 add
方法进行抽象,并且对容器类进行扩展,直接贴代码吧,很简单的:
fun JPanel.pager(tabPlacement: Int = JTabbedPane.TOP, tabLayoutPolicy: Int = JTabbedPane.SCROLL_TAB_LAYOUT, position: String? = null, block: JTabbedPane.() -> Unit): JTabbedPane {
val pager = JTabbedPane(tabPlacement, tabLayoutPolicy).apply(block)
if (position != null) add(pager, position) else add(pager)
return pager
}
这就实现了上面所述的 pager
方法,注意方法最后的那个 block
参数,它对函数构成尾闭包,同时又展开了 JTabbedPane
,因此这个 pager 函数的后面就可以写一个大括号,并且在该括号内,this
被替换为了 JTabbedPane 实例。这种写法请非常注意并且牢记,这在 Kotlin 开发场景下非常的有用。
同样的,上面所述的 panelTab
方法,其实现如下:
fun JTabbedPane.panelTab(title: String? = null, icon: Icon? = null, canClose: Boolean = false, layout: LayoutManager? = BorderLayout(), block: JPanel.() -> Unit): JPanel {
val pnl = JPanel(layout).apply(block)
if (canClose) addTabWithClose(title, icon, pnl) else addTab(title, icon, pnl)
return pnl
}
这个自然是要扩展 JTabbedPane
了,所以由此可见,要扩展的东西不是一般的多,当然了,为了后续开发方便,前期苦一点也是值得的。
接着就是扩展菜单,一样也很简单:
fun JMenuBar.menu(title: String? = null, block: JMenu.() -> Unit): JMenu {
val m = JMenu(title).apply(block)
add(m)
return m
}
fun JMenu.menu(title: String? = null, block: JMenu.() -> Unit): JMenu {
val m = JMenu(title).apply(block)
add(m)
return m
}
fun JMenu.menuitem(title: String? = null, icon: Icon? = null, block: JMenuItem.() -> Unit): JMenuItem {
val i = JMenuItem(title, icon).apply(block)
add(i)
return i
}
有了这些代码后,构建 Swing 菜单将变得很简单:
jMenuBar = rootMenuBar {
menu(title = "File") {
menuitem(title = "New") { }
menuitem(title = "Open") { }
menuitem(title = "Save") { }
}
menu(title = "Edit") {
menuitem(title = "Copy") { }
menu(title = "Find") {
menuitem(title = "Find Text") { }
}
}
}
然而,在实际开发中,只有 swing 标准的组件是完全不够的,这样会使我们丢失了使用第三方组件的能力。但是第三方组件名目繁多,完全不可能以依赖和扩展的形式来引入,这要怎么办呢?来看下面的这段代码:
inline fun<reified T: Component> JPanel.custom(position: String? = null, vararg params: Any, block: T.() -> Unit): T {
val comp = newClassInstance<T>(*params).apply(block)
if (position != null) add(comp, position) else add(comp)
return comp
}
这是往 JPanel 内添加一个自定义组件,通过 Kotlin 的强化泛型支持,可以用简单的方法将组件 T 实例化并添加到界面上。其中 newClassInstance
方法实现如下:
inline fun<reified T> newClassInstance(vararg params: Any): T {
val pparam = params.map {
when(it::class.java.name) {
"java.lang.Integer" -> Int::class.java
"java.lang.Double" -> Double::class.java
"java.lang.Boolean" -> Boolean::class.java
"java.lang.Byte" -> Byte::class.java
"java.lang.Short" -> Short::class.java
"java.lang.Long" -> Long::class.java
"java.lang.Float" -> Float::class.java
"java.lang.Character" -> Char::class.java
else -> it::class.java
}
}.toTypedArray()
return T::class.java.getDeclaredConstructor(*pparam).apply { isAccessible = true }.newInstance(*params)
}
这里有两个坑,一是 vararg 参数的数据类型,当传入的参数内包含 Int,Double,Float 等数据类型时,vararg 会自动将参数包装成包装类型,因此导致了按参数类型搜索构造函数失败,所以在 newClassInstance
内,需要对包装类型作还原处理。其二依然是 vararg,在传入参数时,需要使用参数展开操作符,即 *
号,否则的话,传入的参数在经过两次传递后,会直接变成 Array<out Any>
的投影,引起对构造函数传参的异常。
这样一来,我们也直接拥有了接入第三方组件的能力,比如说接入一个语法高亮编辑器,就可以这样写:
custom<RSyntaxTextArea>("my text") {
... ...
}
在这里传入的 my text
即是 RSyntaxTextArea
的构造参数,原理也已经很清楚了,即是根据类型去创造一个类实例,并且调用相应的构造函数同时传入参数以完成构造。
到这里可能你已经觉得,完成这些体力劳动后,能迎来一个光明的未来,但是 Swing 却还有一些灵活的东西,需要额外进行扩展,这里只举两个例子,一个是设置组件大小,另一个是 JList, JTable, JTree 等的内部 Cell
组件。
那先来看设置大小吧,通常使用的代码是这样的:
btn.preferredSize = Dimension(100, 30)
这样即创造了一个宽 100,高 30 的按钮。然而这样的代码很累赘,首先这个 Dimension 对象就不是我们想看到的,在这里我采用另一种思路来完成对这一块的改造:
infix fun Int.x(y: Int): Dimension = Dimension(this, y)
fun JComponent.size(block: () -> Dimension) {
this.preferredSize = block()
}
这里扩展了一个针对 Int 的中缀表达式,即 x
,在此处用作乘号,这样写起来就会很舒服了,如:
button {
size { 100 x 30 }
}
同理,这个扩展也可以被加到 JFrame
上,以使窗口的大小可以用同样的方法来设置:
fun Window.size(block: () -> Dimension) {
val d = block()
this.setSize(d.width, d.height)
}
接下去再来看看 Cell
,不得不说一句,Cell 这种东西,在 Swing 里还是比较复杂的,它不太直观,但是我们有办法让它变得直观:
open class KCellRender<C : JComponent, T>(private val cls: Class<C>, val block: C.(value: T?, index: Int, selected: Boolean, cellHasFocus: Boolean) -> Unit) : ListCellRenderer<T> {
override fun getListCellRendererComponent(list: JList<out T>, value: T?, index: Int, selected: Boolean, cellHasFocus: Boolean): Component =
cls.newInstance().apply {
block(this, value, index, selected, cellHasFocus)
}
}
首先先来扩展一个内部的类,这个类实现了 ListCellRenderer
接口,并且拥有自己的回调。这里还是有一个坑,或者说这是 Kotlin 语言的坑,因为到目前为止,Kotlin 仅支持在方法级别上进行对泛型的 reified 操作,而对于 class,并不支持,因此如果你试图写下这样的代码,是会报错的:
inline class Sample<reified T> { }
这个问题在 Kotlin 官方的 YT 也有讨论,只不过至今为止也没给解决罢了:
https://discuss.kotlinlang.org/t/reified-type-parameters-in-classes/1567/6
https://discuss.kotlinlang.org/t/can-generic-parameters-be-reified-at-class-level/13791/3
当然了,就如以前在 Java 中编码那样,你逃不开泛型擦除的问题,也只能通过传入一个额外的 Class
参数来进行实例化。当然我们在前端有办法绕开这个问题,如:
inline fun<reified C: JComponent, T> JList<T>.cell(noinline block: C.(value: T?, index: Int, selected: Boolean, cellHasFocus: Boolean) -> Unit) {
cellRenderer = KCellRender(C::class.java, block)
}
这下就没问题了吧,在方法级别上进行的 reified 操作,其类型是可以被 C::class.java
获取的,即是说 KCellRender
可以拥有一个具有确切类型的泛型类以供实例化。
这样一来,cell 的问题就解决了,我们可以写出以下代码来定义:
list(array = arrayOf("a", "b", "c")) {
cell<JPanel, String> { value, index, selected, cellHasFocus ->
if (selected) {
label(title = value) {
foreground = Color.RED
}
} else {
label(title = value) {
foreground = Color.BLACK
}
}
}
}
似乎到这里,我们已经解决了所有的问题,接下去就是愉快的 Coding 时间了,写一个界面练练手:
class MainForm: JFrame("Main") {
init {
contentPane = rootVertPanel {
val tbl = table(arrayRowData = arrayOf(arrayOf("A", true), arrayOf("B", false)), arrayColumnNames = arrayOf("Name", "Checked")) {
cell<JCheckBox> { cell, value, selected, cellHasFocus, row, col ->
// 此处的 this 是 JCheckBox
this.isSelected = value as Boolean
addActionListener { cell.setEditValue(this.isSelected, row, col) }
}
}
borderPanel {
button("Check") {
addActionListener {
val d1 = tbl.model.getValueAt(0, 1)
val d2 = tbl.model.getValueAt(1, 1)
println("d1 = $d1, d2 = $d2")
}
}
}
}
size { 500 x 300 }
isVisible = true
}
}
运行后看到的界面就是这样的了:
总的来说,把代码写成声明式的,要比原先命令式的简洁不少,特别是在 UI 开发时,对界面的描述越简单,开发效率是越高的,在熟悉以后基本上也可以不需要写几行代码就跑一下看效果。当然了,我比较懒,这篇就到此结束吧,看看啥时候有空再写个 DataBinding 的实现。
最后,不放代码不是我的风格,这样一个库当然是已经实现了的,点击左下角 阅读原文 就可以白嫖这一份代码啦,当然如果你只想使用而不想读代码也完全没有问题,直接 gradle 引入就好了:
compile "com.github.isyscore:common-swing:1.0.8"
说不定你还能从里面发现一些神奇的代码哦(手动滑稽)