使用Electron实现一个iPic

iPic 是一个很赞的应用,可以快速将图片上传到图床上。由于非会员只能使用免费的新浪图床,因为最近新浪图床防盗链和图片有效期的缘故,因此决定自己实现一个图片快速上传的应用。

大致对比了一下Flutter DesktopPyQTElectron等框架,最后决定使用Electron,花了两三个晚上实现了将剪切板的图片快速上传到七牛上(非广告~)。

本文将回顾整个开发流程,并记录第一次正儿八经开发Electron的经验。

<!--more-->

项目完整代码已放在github上。

1. 准备工作

1.1. 开发安环境

electron-forge是一个用来开发、打包和发布 Electron 的脚手架,首先安装electronelectron-forge

# 全局安装
npm install -g electron
npm install -g electron-forge

# 初始化项目
electron-forge init oPic
# ...初始化的时间可能会有点长

electron-forge 为我们生成了基本的项目模板。

如果是开发类似于 GUI 应用,可以修改src/index.html里面相关的视图文件,体验使用 Web 技术开发桌面应用。如果引入了 Vue、React 等框架,也可以在开发环境下直接将file://文件替换为webpack-dev-server服务 URL

// mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.loadURL(`http://localhost:8080`);

1.2. 需求梳理

由于本次开发目标是工具类应用,很少涉及到 UI 层面的开发,梳理一下整个工具的需求

  • 点击顶部任务栏应用图标,展示剪贴板内的图片(如复制图片文件、截图等),下面是 iPic 的工作页面

  • 点击待上传图片,后台将图片上传到图床,自动将图片 URL 填充到剪贴板

    • 需要提供图床配置
    • 出于图床的容量和流量的考虑,希望在图片上传之前进行压缩
  • 更新已上传图片列表,点击已上传图片,会重新复制该图片的 URL

整个需求比较简单,主要需要去Electron 文档查下面几个接口

  • 在 Mac 顶部应用栏展示应用图标,点击弹出选项菜单
  • 获取剪切板图片信息,定制选项菜单栏展示图片
  • 图床配置弹窗 UI 开发,与主进程交互
  • 图片上传,很早之前写了一个img_qiniu_cdn,貌似现在还可以用
  • 图片压缩,本来想使用TinyPng的 API,发现有次数限制~找个其他的库吧

大概就这些,开始写代码啦

2. 开发

2.1. 顶部应用栏

查看系统托盘 API,构造一个Tray实例

import { Tray } from "electron";
// 创建顶部图标
const createTray = app => {
      // upload@3x是展示在托盘的图标
    const icon16 = path.resolve(__dirname, "../assets/upload@3x.png");
    const tray = new Tray(icon16);
    // 监听图标点击事件,打开选项菜单
    tray.on("click", () => {
        const config = configUtil.getConfig();
        const template = [
            { label: "待上传", type: "normal", enabled: false },
            { label: "", type: "separator" },
            { label: "已上传", type: "normal", enabled: false },
            { label: "", type: "separator" }
        ];
        // todo 构建图片选项

        // 创建contextMenu并弹出
        const contextMenu = Menu.buildFromTemplate(template);
        tray.popUpContextMenu(contextMenu);
    });
};

2.2. 获取剪贴板图片

参考

  • 剪贴板 API,使用clipboard.readImage获取剪贴板内的图片
  • NativeImagereadImage获取的是一个 NativeImage 包装对象,需要查看它与原始图片文件之间的转换
import { clipboard } from "electron";

const uploadList = []; // 将已上传的图片保存在内存中
const clipboardImageList = []; // 保存最近未上传的图片

// 根据剪切板图片创建menuItem
const createClipboardImageItem = () => {
    const clipboardImage = clipboard.readImage();
    // 剪切板如果有数据,则保存到clipboardImageList中
    if (clipboardImage && !clipboardImage.isEmpty()) {
        const radio = clipboardImage.getAspectRatio();
        // 创建一个用于在菜单栏展示的图标
        const img = clipboardImage.resize({
            width: 100,
            height: radio / 100
        });

        // 将图片暂存在clipboardImageList中
        addToImageList(clipboardImageList, { img, raw: clipboardImage }, 1);
    }

    return clipboardImageList.map((row, index) => {
        const { raw, img } = row;
        // 点击菜单选项时执行upload

        const upload = () => {
            const buffer = raw.toPNG();
            // 调用uploadBufferImage方法上传图片
            uploadBufferImage(buffer).then(url => {
                // 更新列表
                addToImageList(uploadList, { img, url });
                removeFromClipboardList(img);

                // 自动复制url
                copyUrl(url);
                Util.showNotify(`上传到七牛成功,链接${url}已经复制到剪切板`);
            });
        };
        // 返回菜单栏配置
        return {
            label: (index + 1).toString(),
            icon: row.img, // 缩小版的图片传给icon配置项,这样就可在菜单栏展示了
            type: "normal",
            click: upload
        };
    });
};

// 同理,创建已上传的图片记录
const createUploadItem = () =>
    uploadList.map(({ img, url }, index) => {
        const handler = () => {
            const text = copyUrl(url);
            Util.showNotify(`链接${text}已经复制到剪切板`);
        };
        return {
            label: (index + 1).toString(),
            icon: img,
            type: "normal",
            click: handler
        };
    });

2.3. 七牛配置

希望应用足够轻量,因此在数据存储方便并没有使用诸如nedb等工具,而是直接简单粗暴地保存在本地文件。

当点击菜单栏的配置项时,将弹出一个配置窗口填写配置项,确定时将数据保存在本地文件中。

let settingWindow;
const openSettingWindow = () => {
    settingWindow = new BrowserWindow({
        width: 600,
        height: 400
    });
    // 使用electron渲染一个页面
    const url = `file://${path.resolve(__dirname, "./setting.html")}`;
    settingWindow.loadURL(url);
    // settingWindow.webContents.openDevTools();
    settingWindow.on("closed", () => {
        settingWindow = null;
    });
};

const template = [
    // ...增加一个菜单选项
    { label: "偏好设置", type: "normal", click: openSettingWindow }
];

setting.html中,实现一个表单提交的页面,

然后通过封装的本地存储工具configUtil读取和保存配置

const defaultConfig = {
    autoMarkdown: true,
    upload: {
        // 七牛图床配置
        qiNiu: {
            accessKey: "",
            secretKey: "",
            bucket: "", // 仓库名
            host: "" // 资源域名
        }
    }
};
const configFile = "../config.json";
// 获取配置
function getConfig() {
    try {
        return require(configFile);
    } catch (e) {
        return defaultConfig;
    }
}
// 保存配置
function saveConfig(config) {
    const fileName = path.resolve(__dirname, configFile);
    return fs.writeFile(fileName, JSON.stringify(config));
}

除了图床配置,configUtil还可以可以保存应用偏好设置,在设计上也需要支持后续其他图床的扩展(虽然我目前用七牛就够啦~)

2.4. 图片上传

回到前面的uploadBufferImage方法,由于没有找到直接上传 Electron NativeImage 的方法,因此这里的实现思路是:

首先读取七牛配置,然后将 buffer 写入本地临时文件,接着通过 qiniu SDK 将文件上传到服务器上,最后删除临时文件就 OK 了

function qiNiuUpload(img) {
    try {
        const { upload: uploadConfig } = configUtil.getConfig();
        const upload = createUploadQiNiu(uploadConfig.qiNiu);

        return upload(img);
    } catch (e) {
        console.log("缺少config.json配置文件");
        return Promise.reject(e);
    }
}

// 上传二进制文件
async function uploadBufferImage(buffer) {
    // 写入临时图片
    const fileName = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
    const filePath = path.resolve(__dirname, `../tmp/${fileName}.png`);

    await fs.writeFile(filePath, buffer); // 创建临时本地文件
    const url = await qiNiuUpload(filePath); // 上传到七牛
    await fs.unlinkSync(filePath); // 删除临时文件
    return url;
}

下面这个createUploadQiNiu是封装qiniuSDK 的方法,三年前的代码了[/捂脸],凑活着用

const qiniu = require("qiniu");
const path = require("path");
const createUploadQiNiu = opts => {
    const { accessKey, secretKey, bucket, host } = opts;

    return filePath => {
        const key = `oPic/${path.basename(filePath)}`;

        // 设置上传策略
        const putPolicy = new qiniu.rs.PutPolicy({
            scope: `${bucket}:${key}`
        });

        // 根据密钥创建鉴权对象mac,获取上传token
        const mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
        const uploadToken = putPolicy.uploadToken(mac);

        // 配置对象
        const config = new qiniu.conf.Config();
        // 上传机房,z2是华南
        config.zone = qiniu.zone.Zone_z2;

        // 扩展参数,主要是用于文件分片上传使用的,这里可以忽略
        const putExtra = new qiniu.form_up.PutExtra();

        // 实例化上传对象
        const formUploader = new qiniu.form_up.FormUploader(config);

        return new Promise((resolve, reject) => {
            formUploader.putFile(
                uploadToken,
                key,
                filePath,
                putExtra,
                (respErr, respBody, respInfo) => {
                    if (respErr) {
                        reject(respErr);
                    }
                    if (respInfo && respInfo.statusCode === 200) {
                        // 拼接服务器路径
                        const filename = host + key;
                        resolve(filename);
                    } else {
                        reject("respInfo is error");
                    }
                }
            );
        });
    };
};

2.5. 图片压缩

前面提到,希望在图片上传之前对文件进行压缩,目前用过最好的图片压缩还是TinyPNG,不过貌似没开源压缩算法,目前一个月只能调用500次API,所以试了下imagemin,看起来效果也能接受,就它啦。

const imageMin = require("imagemin");
const imageJPEG = require("imagemin-jpegtran");
const imagePNG = require("imagemin-pngquant");

// 图片压缩
function compressImage(filePath, destination) {
    return imageMin([filePath], {
        destination,
        plugins: [
            imageJPEG(),
            imagePNG({
                quality: [0.6, 0.8]
            })
        ]
    });
}

然后再上传前进行压缩即可

await fs.writeFile(filePath, buffer); // 创建临时本地文件
// 图片压缩为同名图片
if (needCompress) {
    await compressImage(filePath, folder);
}
const url = await qiNiuUpload(filePath); // 上传到七牛

2.6. 打包

功能开发完毕后,使用electron-forge打包就可以啦,会在项目根目录下输出out文件夹

npm run package
npm run make

此外,Electron 打包的应用是在是太大了,上面这点代码打包出来居然有 150M,不知道是不是我的姿势不对,有空研究下

3. 小结

至此,就完成了一个简易版的图片上传应用,目前基本能实现日常的需求了(PS:现在终于不用担心博客的图片被新浪图床吞掉了~),也算是完成了自己的一个挂念。

整个项目已放在github上,由于开发时间有点短,加上之前也基本没用过 Electron,所以代码写的有点烂~ 还有一些可以迭代的地方,比如

  • 上传进度展示
  • 系统右键菜单快速上传图片

有时间再处理,折腾其他东西去了。

搭建开发流程需要的系统在Flutter中封装redux的使用