基于 ScalaFX 实现压缩软件

一个简单压缩软件的设计与实现系列(四)

Posted by John Mactavish on February 1, 2021

最后终于到了实现压缩软件的时候。

参考资料

我们的 GUI 基于 ScalaFX, 而它又基于 JavaFXJavaFX 的示例与文档对于 ScalaFX 也很有帮助, 毕竟后者只是薄薄的一层包装。更何况后者自己的示例与文档实在是太简单太浅层了,能拿来参考 的太稀缺了。JavaFX 的官方资料貌似也很不全面,不过对于 get started 还足够,尤其是关于 UI 控件的教程;除此之外,Jakob Jenkov’s JavaFX Tutorial 也十分不错。

ScalaFX 启动

ScalaFX 要求我们在某个地方调用 JFXApp 父类的 main 方法移交线程控制权, 此后框架运行时(runtime)就会渲染出我们用代码构造的界面,并在某个循环内监听用户在 GUI 上的操作、 处理事件(event)等。一般来说,我们可以在创建界面前初始化好应用程序的配置等, 如果在 GUI 交互期间我们还需要做额外的后台任务,可以先创建线程或拉起进程再移交线程控制权。

object Application extends Thread with JFXApp {
  stage = new PrimaryStage
  stage.height = 600
  private val scene = new PrimaryScene(stage)
  stage.setScene(scene)

  // theme
  new JMetro(Style.LIGHT).setScene(scene)

  // JFXApp will take control and run JavaFX Application
  override def start(): Unit = {
    // initialize
    // ...
    main(Array()) // results in a loop
  }
}

UI 与通信

创建界面前自然是要配置好 UI。ScalaFX 相较于 JavaFX 进步的一个地方在于可以用原生 DSL 书写 UI。UI 控件都是一个个 data model,通过调用其上的方法(充满着语法糖)可以配置 数据的属性。配置的时候是与 GUI 框架运行时无关的,从上面的代码也可以看到,scene 配置 时机是 Application 单例类初始化时,而运行时要到 start 方法调用才开始。

搭建 UI 的第一步是选择 HBoxVBoxBorderPanel 之类的布局控件,这是 UI 的骨架, 它们通过内部嵌套其他组件达到布局功能。 它们布局内部组件的方式各不相同,具体可以查阅文档。

class PrimaryScene(stage: Stage) extends Scene {
  root = new BorderPane {
    left = ???

    center = new VBox {
      children = ???
    }
  }
}

接下来就是要向骨架里按照需求填充 ButtonTextField 之类的有实际用途的控件。 它们可以设置一些基本属性,如 text(文字)、graphic(图标)等。它们可以承载可变数据, 与用户交互。这将利用 ScalaFXproperties。 它本质上是观察者模式中的被观察者,我们可以在其上注册回调函数(如 onChange),以在 其值被改变时收到通知进行处理;同样的,不难想到,框架也会注册回调函数以自动更新界面。

class PrimaryScene(stage: Stage) extends Scene {
  root = new BorderPane {
    left = new TextField {
      text = "edit me"
      // `text` is a `StringProperty`
      text.onChange {
        (_, oldText, newText) =>
          println(s"`$oldText` changed to `$newText`")
      }
      
      new Timer().schedule(
        new TimerTask {
          override def run(): Unit = {
            // we can change text's value
            text.value = "5 seconds passed"
            // we can modify TextField's other fields
            background = ???
          }
        }, 5000)
    }

    center = new VBox {
      children = ???
    }
  }
}

如上所示,我们在 BorderPane 的左侧区域放置一个可修改内容的 TextField,当用户 编辑该文本框时,我们注册的回调就会触发。而在应用程序侧也可修改 text 的内容。 我们在上面设置了一个定时任务,用于模拟程序的行为(根据保存的历史记录自动填写表单等), 当定时任务启动时,框架也会监测到 text property 的变化而重新渲染文本框给用户看。 值得注意的是,property 可不管到底是哪个方向(用户或程序)改变了数据,所以我们自己 修改 property 时,我们的回调也同样会触发。如下,终端打印的前两次变化反映是我们手动 删除文本框内的最后两个字符,最后一行则是定时任务干的。

`edit me` changed to `edit m`
`edit m` changed to `edit `
`edit ` changed to `5 seconds passed`

不难想到,property 借助观察者模式实现了数据通信;除了 property 外,ScalaFX 内 的另一种通信手段是事件(event)。内置事件中比较常见的有鼠标点击事件(onMouseClicked), 如下所示,按钮被双击后会在原有的 text 基础上增加显示一个 icon。

// Icon.scala

// add "file:" to indicate that img comes from local file system
class Icon(f: File) extends ImageView(new Image("file:" + f.getAbsolutePath)) {
  def this(f: String) {
    this(new File(f))
  }
}

// PrimaryScene.scala

// at some node of the panel
new Button {
  text = "Double Click Me"
  onMouseClicked = e => {
        if (e.getClickCount >= 2) {
          graphic = new Icon("/icon.png")
        }
      }
}

ScalaFXeventproperty 都是框架内置的用于解决用户与程序通信问题的方案。 event 触发时可以提供更多的信息(事件类型、附加信息等,如 getClickCount); 而 property 只通知数据的修改,但更轻量级,用起来更简单。我们的程序内部通信(Controller 之间、Controller 与 UI 之间等)既可用这两个内置方案,也可选择 Binding.scala 等库。

后端设计

鉴于这个软件功能比较简单,我们只需要设计一个 Controller,来与 UI 通信, 利用之前设计的 SevenZ4S 库完成压缩的功能。

object ArchiveController {
  def listEntries(f: Path): Seq[ExtractionEntry] = ???

  def extractAll(f: Path, to: Path): Unit = ???

  def extractEntries(f: Path, relPaths: Set[String], to: Path): Unit = ???

  def compress(format: CreatableArchiveFormat,f: Path, to: Path) = ???
}

SevenZ4S 提供的是以线性表(Seq)组织的 entry(压缩包内条目,代表压缩的文件或目录) 集合。而我们希望压缩软件能像文件浏览器一样 进入目录、查看文件属性、返回上级目录等,这要求我们用合适的数据结构重新组织 entry。 不难想到,我们需要的数据结构是 Trie 树。 树的边根据 entry 的属性 path(相对压缩包内根目录的相对路径)构建。

case class TrieTree[K, V](
                           parent: TrieTree[K, V],
                           key: K,
                           value: V,
                           private val children_ : mutable.Set[TrieTree[K, V]] = mutable.Set[TrieTree[K, V]]()
                         ) {
  def children: Set[TrieTree[K, V]] = children_.toSet

  def insert(tail: List[(K, V)]): TrieTree[K, V] = {
    tail match {
      case Nil => this
      case next :: remaining =>
        children_.find(_.key == next._1) match {
          case Some(child) =>
            child.insert(remaining)
          case None =>
            val child = TrieTree(this, next._1, next._2)
            children_.add(child)
            child.insert(remaining)
        }
        this
    }
  }

  def search(path: List[K]): Option[TrieTree[K, V]] = {
    path match {
      case Nil => Some(this)
      case next :: remaining =>
        this.children_.find(_.key == next) match {
          case Some(child) => child.search(remaining)
          case None => None
        }
    }
  }

  def isRoot = this.parent == null
}

object TrieTree {
  def empty[K, V](identity: (K, V)): TrieTree[K, V] =
    TrieTree[K, V](parent = null, key = identity._1, value = identity._2)
}

我们在打开新的压缩包时构造一棵新的 TrieTree,然后根据用户在 GUI 上的操作 维护它(删除、添加 entry 等)。我们同时记录用户当前查看的节点在压缩包文件树 上的位置,这样通过遍历子节点就可确定用户将看到哪些子级文件夹或文件。同时, 这些文件夹或文件可用 ScalaFXTableView 来展示。

完工

最后,把一切拼接起来就能得到最后的软件。

open

open

说老实话,因为刚用 ScalaFX 且对软件的总体设计不够全面,这个项目我个人感觉 完成地不够好,以后可以考虑设计一个更复杂的软件来练手。 项目在这里开源,可供参考。


如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe