『GCTT 出品』仅需简单 5 步,给你的 Golang 程序添加 GUI (使用 Electron )

2018-02-07 11:14:28 译者fengchunsgit Go语言中文网

首发于:https://studygolang.com/articles/12065


创建一个 Golang app 是一件简单又轻松的事情,但是有时候你想给你的应用锦上添花:创建一个 GUI!

在本篇文章中,我将通过使用 astilectron 工具中的 bootstrap 以及 bundler 给一个简单的 Golang 程序添加 GUI。

我们的带有 GUI 的 Golang app 能够打开一个文件夹并且展示其中的内容。

你可以在这里找到完成后的 代码 :

第一步:组织项目结构

文件夹结构如下:

|--+ resources
   |--+ app
      |--+ static
         |--+ css
            |--+ base.css
         |--+ js
            |--+ index.js
         |--+ lib
            |--+ ... (all the css/js libs we need)
      |--+ index.html
   |--+ icon.icns
   |--+ icon.ico
   |--+ icon.png
|--+ bundler.json
|--+ main.go
|--+ message.go

你将看到,我们需要 3 种不同格式的图标以完成不同平台的编译:.icns 用于  darwin 平台、.ico 用于  windows 平台、.png 用于  linux 平台。

我们将使用以下 CSS/JS 库:

  • astiloader

  • astimodaler

  • astinotifier

  • chartjs

  • fontAwesome

第二步:搭建基础架构

Go

首先我们需要在 main.go 中导入 astilectron 的 bootstrap 源码包 :

package main

import (
   "flag"    "github.com/asticode/go-astilectron"    "github.com/asticode/go-astilectron-bootstrap"    "github.com/asticode/go-astilog"    "github.com/pkg/errors")// Varsvar (    AppName string    BuiltAt string    debug   = flag.Bool("d", false, "enables the debug mode")    w       *astilectron.Window )

func
main() {
   // Init    flag.Parse()    astilog.FlagInit()
   // Run bootstrap    astilog.Debugf("Running app built at %s", BuiltAt)
   if err := bootstrap.Run(bootstrap.Options{        AstilectronOptions: astilectron.Options{            AppName:            AppName,            AppIconDarwinPath:  "resources/icon.icns",            AppIconDefaultPath: "resources/icon.png",        },        Debug:    *debug,        Homepage: "index.html",        MenuOptions: []*astilectron.MenuItemOptions{{            Label: astilectron.PtrStr("File"),            SubMenu: []*astilectron.MenuItemOptions{                {Label: astilectron.PtrStr("About")},                {Role: astilectron.MenuItemRoleClose},            },        }},        OnWait: func(_ *astilectron.Astilectron, iw *astilectron.Window, _ *astilectron.Menu, _ *astilectron.Tray, _ *astilectron.Menu) error {            w = iw
           return nil        },        WindowOptions: &astilectron.WindowOptions{            BackgroundColor: astilectron.PtrStr("#333"),            Center:          astilectron.PtrBool(true),            Height:          astilectron.PtrInt(700),            Width:           astilectron.PtrInt(700),        },    }); err != nil {        astilog.Fatal(errors.Wrap(err, "running bootstrap failed"))    } }

2 个全局变量 AppNameBuiltAt 将会通过 bundler 打包自动添加进去。

随后我们将发现我们的主页变成了 index.html ,我们将有一个含有 2 个项目( aboutclose )的菜单并且会出现一个 700x700 , 中心对齐的#333 背景色的窗口。

我们要在 go 上添加 debug 选项,因为我们需要使用 HTML/JS/CSS 调试工具。

最后我们将指向 astilectron.Window 的指针存入全局变量 w,以备后续在使用 OnWait 选项时,它包含一个在窗口、菜单及其他所有对象被创建时立即执行的回调函数。

HTML

现在我们需要在 resources/app/index.html 中创建我们的 HTML 主页:

<!DOCTYPE html>
<html lang="en"><head>    <meta charset="UTF-8">    <link rel="stylesheet" href="static/css/base.css"/>    <link rel="stylesheet" href="static/lib/astiloader/astiloader.css">    <link rel="stylesheet" href="static/lib/astimodaler/astimodaler.css">    <link rel="stylesheet" href="static/lib/astinotifier/astinotifier.css">    <link rel="stylesheet" href="static/lib/font-awesome-4.7.0/css/font-awesome.min.css">
</head>
<body>    <div class="left" id="dirs"></div>    <div class="right">        <div class="title"><span id="path"></span></div>        <div class="panel"><span class="stat" id="files_count"></span> file(s)</div>        <div class="panel"><span class="stat" id="files_size"></span> of file(s)</div>        <div class="panel" id="files_panel">            <div class="chart_title">Files repartition</div>            <div id="files"></div>        </div>    </div>    <script src="static/js/index.js"></script>    <script src="static/lib/astiloader/astiloader.js"></script>    <script src="static/lib/astimodaler/astimodaler.js"></script>    <script src="static/lib/astinotifier/astinotifier.js"></script>    <script src="static/lib/chart/chart.min.js"></script>    <script type="text/javascript">        index.init();
   </script>
</body>
</html>

这里没什么特殊的地方,我们声明我们的 cssjs 文件,我们设置 html 文件结构并且需要确保我们的 js 脚本通过 index.init() 进行了初始化

CSS

现在需要在 resources/app/static/css/base.css 文件中创建我们的 CSS:

* {box-sizing:  border-box;}
html, body {background-color: #fff;color: #333;height: 100%; margin: 0width: 100%;}
.left {    background-color: #333;    color: #fff;    float: left;    height: 100%;    overflow: auto;    padding: 15px;    width: 40%;}
.dir {    cursor: pointer;    padding: 3px;}
.dir .fa {    margin-right: 5px;}
.right {    float: right;    height: 100%;    overflow: auto;    padding: 15px;    width: 60%;}
.title {    font-size: 1.5em;    text-align: center;    word-wrap: break-word;}
.panel {    background-color: #f1f1f1;    border: solid 1px #e1e1e1;    border-radius: 4px;    margin-top: 15px;    padding: 15px;    text-align: center;}
.stat {    font-weight: bold;}.chart_title {    margin-bottom: 5px;}

JS

然后我们在 resources/app/static/js/index.js 中创建 JS :

let index = {
    init: function() {
       // Init        asticode.loader.init();        asticode.modaler.init();        asticode.notifier.init();    } };

通过 init 方法正确的将库初始化。

第三步:建立起 Go 与 Javascript 间的通信

万事俱备,只欠东风:我们需要将 Go 与 Javascript 建立起通信。

Javascript 通信 Go

为了让 Javascript 与 Go 进行通信,首先从 Javascript 向 Go 发送一条消息,并且在 GO 接受到消息后执行回调函数:

// This will wait for the astilectron namespace to be ready
document.addEventListener('astilectron-ready', function() {
   // This will send a message to Go    astilectron.sendMessage({name: "event.name", payload: "hello"}, function(message) {
       console.log("received " + message.payload)    }); })

同时我们在 Go 中监听来自 Javascript 的消息,并且通过 bootstrap 的 MessageHandler 给 Javascript 发送消息:

func main() {
    bootstrap.Run(bootstrap.Options{
        MessageHandler: handleMessages,    
    })
}

// handleMessages handles messages
func handleMessages(_ *astilectron.Window, m bootstrap.MessageIn) (payload interface{}, err error) {
   switch m.Name {
   case "event.name":
       // Unmarshal payload        var s string        if err = json.Unmarshal(m.Payload, &path); err != nil {            payload = err.Error()
           return        }        payload = s + " world"    }
   return
}

这是一个简单的例子,将在 js 的输出中打印出 received hello world

在这种情形中,我们需要更多的逻辑因为我们想要允许打开一个文件夹并且展示其中的内容。

因此我们将下面的代码加入到 resources/app/static/js/index.js 中:

let index = {
    addFolder(name, path) {
       let div = document.createElement("div");        div.className = "dir";        div.onclick = function() { index.explore(path) };        div.innerHTML = `<i class="fa fa-folder"></i><span>` + name + `</span>`;        document.getElementById("dirs").appendChild(div)    },    init: function() {        // Wait for astilectron to be ready        document.addEventListener('astilectron-ready', function() {            // Explore default path            index.explore();        })    },    explore: function(path) {        // Create message        let message = {"name": "explore"};        if (typeof path !== "undefined") {            message.payload = path        }        // Send message        asticode.loader.show();        astilectron.sendMessage(message, function(message) {            // Init            asticode.loader.hide();            // Check error            if (message.name === "error") {                asticode.notifier.error(message.payload);                return            }            // Process path            document.getElementById("path").innerHTML = message.payload.path;            // Process dirs            document.getElementById("dirs").innerHTML = ""            for (let i = 0; i < message.payload.dirs.length; i++) {
               index.addFolder(message.payload.dirs[i].name, message.payload.dirs[i].path);            }            // Process files            document.getElementById("files_count").innerHTML = message.payload.files_count;
           document.getElementById("files_size").innerHTML = message.payload.files_size;
           document.getElementById("files").innerHTML = "";
           if (typeof message.payload.files !== "undefined") {
               document.getElementById("files_panel").style.display = "block";
               let canvas = document.createElement("canvas");
               document.getElementById("files").append(canvas);
               new Chart(canvas, message.payload.files);            } else {
               document.getElementById("files_panel").style.display = "none";            }        })    } };

一旦 Javascript 的 astilectron 命名空间准备好,它执行新的 explore 方法,该方法会给 Go 发送一条消息,接收返回的信息,并且更新相应的 HTML 。

然后我们将下面代码加入到 message.go 中:

package main
import (
   "encoding/json"    "io/ioutil"    "os"    "os/user"    "path/filepath"    "sort"    "strconv"    "github.com/asticode/go-astichartjs"    "github.com/asticode/go-astilectron"    "github.com/asticode/go-astilectron-bootstrap"
)

// handleMessages handles messages
func handleMessages(_ *astilectron.Window, m bootstrap.MessageIn) (payload interface{}, err error) {
   switch m.Name {
   case "explore":
       // Unmarshal payload        var path string        if len(m.Payload) > 0 {
           // Unmarshal payload            if err = json.Unmarshal(m.Payload, &path); err != nil {                payload = err.Error()
               return            }        }
       // Explore        if payload, err = explore(path); err != nil {            payload = err.Error()
           return        }    }
   return
}

// Exploration represents the results of an exploration
type Exploration struct {    Dirs       []Dir              `json:"dirs"`    Files      *astichartjs.Chart `json:"files,omitempty"`    FilesCount int                `json:"files_count"`    FilesSize  string             `json:"files_size"`    Path       string             `json:"path"`
}

// PayloadDir represents a dir payload
type Dir struct {    Name string `json:"name"`    Path string `json:"path"`
}

// explore explores a path.
// If path is empty, it explores the user's home directory
func explore(path string) (e Exploration, err error) {
   // If no path is provided, use the user's home dir    if len(path) == 0 {
       var u *user.User
       if u, err = user.Current(); err != nil {
           return        }        path = u.HomeDir    }
   // Read dir    var files []os.FileInfo
   if files, err = ioutil.ReadDir(path); err != nil {
       return    }
   // Init exploration    e = Exploration{        Dirs: []Dir{},        Path: path,    }
   // Add previous dir    if filepath.Dir(path) != path {        e.Dirs = append(e.Dirs, Dir{            Name: "..",            Path: filepath.Dir(path),        })    }
   // Loop through files    var sizes []int    var sizesMap = make(map[int][]string)
   var filesSize int64    for _, f := range files {
       if f.IsDir() {            e.Dirs = append(e.Dirs, Dir{                Name: f.Name(),                Path: filepath.Join(path, f.Name()),            })        } else {
           var s = int(f.Size())            sizes = append(sizes, s)            sizesMap[s] = append(sizesMap[s], f.Name())            e.FilesCount++            filesSize += f.Size()        }    }
   // Prepare files size    if filesSize < 1e3 {        e.FilesSize = strconv.Itoa(int(filesSize)) + "b"    } else if filesSize < 1e6 {        e.FilesSize = strconv.FormatFloat(float64(filesSize)/float64(1024), 'f', 0, 64) + "kb"    } else if filesSize < 1e9 {        e.FilesSize = strconv.FormatFloat(float64(filesSize)/float64(1024*1024), 'f', 0, 64) + "Mb"    } else {        e.FilesSize = strconv.FormatFloat(float64(filesSize)/float64(1024*1024*1024), 'f', 0, 64) + "Gb"    }
   // Prepare files chart    sort.Ints(sizes)
   if len(sizes) > 0 {        e.Files = &astichartjs.Chart{            Data: &astichartjs.Data{Datasets: []astichartjs.Dataset{{                BackgroundColor: []string{                    astichartjs.ChartBackgroundColorYellow,                    astichartjs.ChartBackgroundColorGreen,                    astichartjs.ChartBackgroundColorRed,                    astichartjs.ChartBackgroundColorBlue,                    astichartjs.ChartBackgroundColorPurple,                },                BorderColor: []string{                    astichartjs.ChartBorderColorYellow,                    astichartjs.ChartBorderColorGreen,                    astichartjs.ChartBorderColorRed,                    astichartjs.ChartBorderColorBlue,                    astichartjs.ChartBorderColorPurple,                },            }}},            Type: astichartjs.ChartTypePie,        }
       var sizeOther int        for i := len(sizes) - 1; i >= 0; i-- {
           for _, l := range sizesMap[sizes[i]] {
               if len(e.Files.Data.Labels) < 4 {                    e.Files.Data.Datasets[0].Data = append(e.Files.Data.Datasets[0].Data, sizes[i])                    e.Files.Data.Labels = append(e.Files.Data.Labels, l)                } else {                    sizeOther += sizes[i]                }            }        }
       if sizeOther > 0 {            e.Files.Data.Datasets[0].Data = append(e.Files.Data.Datasets[0].Data, sizeOther)            e.Files.Data.Labels = append(e.Files.Data.Labels, "other")        }    }
   return
}

在接收到正确的信息时,它将执行新的 explore 方法,并返回关于目录的有价值的信息。

建立从 Go 向 Javascript 通信

为了建立从 Go 向 Javascript 的通信,我们首先需要从 Go 中向 Javascript 发送一条消息并且在 Javascript 收到消息后执行回调。

// This will send a message and execute a callback
// Callbacks are optional
bootstrap.SendMessage(w, "event.name", "hello", func(m *bootstrap.MessageIn) {
   // Unmarshal payload    var s string    json.Unmarshal(m.Payload, &s)
   // Process message    log.Infof("received %s", s) })

同时我们在 Javascript 中监听来自 Go 的消息并发送一个选项消息给 Go:

// This will wait for the astilectron namespace to be ready
document.addEventListener('astilectron-ready', function() {
   // This will listen to messages sent by GO    astilectron.onMessage(function(message) {
       // Process message        if (message.name === "event.name") {
           return {payload: message.message + " world"};        }    }); })

这个简单的例子将在 Go 的输出中打印 received hello world 。在我们的项目里,我们先将下面的代码加入到 main.go 中:

func main() {
    bootstrap.Run(bootstrap.Options{
        MenuOptions: []*astilectron.MenuItemOptions{{
            Label: astilectron.PtrStr("File"),
            SubMenu: []*astilectron.MenuItemOptions{
                {
                    Label: astilectron.PtrStr("About"),
                    OnClick: func(e astilectron.Event) (deleteListener bool) {
                       if err := bootstrap.SendMessage(w, "about", htmlAbout, func(m *bootstrap.MessageIn) {
                           // Unmarshal payload                            var s string                            if err := json.Unmarshal(m.Payload, &s); err != nil {                                astilog.Error(errors.Wrap(err, "unmarshaling payload failed"))
                               return                            }                            astilog.Infof("About modal has been displayed and payload is %s!", s)                        }); err != nil {                            astilog.Error(errors.Wrap(err, "sending about event failed"))                        }
                       return                    },                },                {Role: astilectron.MenuItemRoleClose},            },        }},        OnWait: func(_ *astilectron.Astilectron, iw *astilectron.Window, _ *astilectron.Menu, _ *astilectron.Tray, _ *astilectron.Menu) error {            w = iw
           go func() {                time.Sleep(5 * time.Second)
               if err := bootstrap.SendMessage(w, "check.out.menu", "Don't forget to check out the menu!"); err != nil {                    astilog.Error(errors.Wrap(err, "sending check.out.menu event failed"))                }            }()
           return nil        },    }) }

它使得 关于 选项变成可点击的,并且渲染出一个有合适内容的模态框,在 Go app 完成初始化 5s 后它会显示一个提示框。

最后我们将下面的代码加入到 resources/app/static/js/index.js 中:

let index = {
    about: function(html) {
   let c = document.createElement("div");        c.innerHTML = html;        asticode.modaler.setContent(c);        asticode.modaler.show();    },    init: function() {
       // Wait for astilectron to be ready        document.addEventListener('astilectron-ready', function() {
           // Listen            index.listen();        })    },    listen: function() {        astilectron.onMessage(function(message) {
           switch (message.name) {
           case "about":                    index.about(message.payload);
                   return {payload: "payload"};
                   break;
           case "check.out.menu":                    asticode.notifier.info(message.payload);                    break;            }        });    } };

它将监听 Go 发送过来的消息并做出相应的反应。

第四步: 打包到 app

现在代码已经完成,我们需要确保我们能够以最好的方式把 Golang GUI app 呈现给我们的用户:

  • 一个 MacOSX app 给 darwin 用户

  • 一个含有好看图标的 .exewindows 用户

  • 一个简单的源码文件给 linux 用户

幸运的是,我们可以通过 astilectron 的 bundler 来进行操作。

首先我们通过下面命令进行安装:

$ go get -u github.com/asticode/go-astilectron-bundler/...

然后我们在 main.go 中给 bootstrap 添加配置项:

func main() {
    bootstrap.Run(bootstrap.Options{
        Asset: Asset,
        RestoreAssets:  RestoreAssets,
    })
}

然后我们创建配置文件,命名为 bundler.json

{
  "app_name": "Astilectron demo",
  "icon_path_darwin": "resources/icon.icns",
  "icon_path_linux": "resources/icon.png",
  "icon_path_windows": "resources/icon.ico",
  "output_path": "output"
}

最后我们在项目文件夹下运行下面的命令(确保 $GOPATH/bin 在你的 $PATH 中)

$ astilectron-bundler -v

第五步: 实际效果

啊哈!结果在 output/<os>-<arch> 文件夹下,快来去试一试 :)

你当然可以打包你的 Golang GUI app 给其他环境,在 bundler 打包文档中查看如何将程序打包到其他环境。

结论

感谢 astilectron 的 bootstrap 和 bundler ,有了一点点的组织和结构,给你的 Golang 程序添加 GUI 从未如此简单。

需要指出的是这种方法有 2 个主要的缺点:

  • 代码包的大小至少有 50 MB,第一次执行后,文件大小至少将有 200 MB

  • 内存的消耗有些疯狂,因为 Electron 并不擅长对内存的管理

但是如果你准备掌握它,那么你在给你的程序添加 GUI 时将非常便利!

享受 GUI 编码的快乐吧!


via: https://medium.com/@social_57971/how-to-add-a-gui-to-your-golang-app-in-5-easy-steps-c25c99d4d8e0

作者:Asticode
译者:fengchunsgit
校对:rxcai,polaris1119

本文由 GCTT 原创编译,Go 中文网 荣誉推出


喜欢本文的朋友们,欢迎长按下图关注订阅号Go语言中文网,收看更多精彩内容