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

创建一个 Golang app 是一件简单又轻松的事情,但是有时候你想给你的应用锦上添花:创建一个 GUI!
在本篇文章中,我将通过使用 astilectron 工具中的 bootstrap 以及 bundler 给一个简单的 Golang 程序添加 GUI。
我们的带有 GUI 的 Golang app 能够打开一个文件夹并且展示其中的内容。
你可以在这里找到完成后的 代码

image

第一步:组织项目结构

文件夹结构如下:

|--+ 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库

第二步:搭建基础架构

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个全局变量 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: 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 完成初始化 5 s 后它会显示一个提示框。

最后我们将下面的代码加入到
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> 文件夹下,快来去试一试 :)

image

你当然可以打包你的 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
本文由 GCTT 原创编译,Go 中文网 荣誉推出
[1]: http://p0khjtoyx.bkt.clouddn.com/0.png?e=1512614718&token=U9_WlpL9xDhIuC0OvwzgYz5OmwFSaT0mRch0uuLm:El4EfsIVIW0SLlHyFWyp0NGX_34

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容