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

发布于 2017-12-30 作者 Charlotte 951次 浏览 版块 提问

创建一个 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"
)

// Vars
var (
    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 个全局变量 AppName 和 BuiltAt 将会通过 bundler 打包自动添加进去。

随后我们将发现我们的主页变成了 index.html ,我们将有一个含有 2 个项目( about 和 close )的菜单并且会出现一个 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>

这里没什么特殊的地方,我们声明我们的 css 和 js 文件,我们设置 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: 0;
    width: 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 用户
一个含有好看图标的 .exe 给 windows 用户
一个简单的源码文件给 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/- 文件夹下,快来去试一试

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

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

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

代码包的大小至少有 50 MB,第一次执行后,文件大小至少将有 200 MB
内存的消耗有些疯狂,因为 Electron 并不擅长对内存的管理
但是如果你准备掌握它,那么你在给你的程序添加 GUI 时将非常便利!

享受 GUI 编码的快乐吧!

文章原文地址

收藏
1回复
shen1001楼•2018-01-02

有空我也折腾下Golang + Electron