1

hello,好久不见,最近笔者花了几天时间入门Electron,然后做了一个非常简单的应用,本文就来给各位分享一下过程,Electron大佬请随意~

笔者开源了一个Web思维导图,虽然借助showSaveFilePickerapi可以直接操作电脑本地文件,但终归不能离线使用,所以就萌发了做一个客户端的想法,作为一个只会前端的废物,做客户端,Electron显然是最好的选择,不过缺点也很明显,安装包体积比较大,如果你对此比较介意的话可以尝试tauri

笔者的需求很简单,能新建、打开本地文件进行编辑,另外能查看最近编辑过的文件列表。

思维导图的编辑页面直接用原来的Web版的页面即可,所以只需要新做一个主页。

最终效果如下:

主页:

编辑页:

项目引入Electron

笔者的项目是基于Vue2.x + Vue Cli开发的一个单页应用,路由用的是hash模式,引入Electron很简单,也不需要做啥大改动,直接使用vue-cli-plugin-electron-builder插件:

vue add electron-builder

然后启动服务:

npm run electron:serve

就会在Vue项目启动完成后自动帮你启动Electron,接下来就可以愉快的开发了。

主进程

Electron应用需要一个入口文件,用来控制主进程,需要在项目的package.json文件中的main字段指定:

{
    "main": "background.js"
}

主进程中存在一些基本代码,用于控制应用退出:

// background.js
import { app } from 'electron'
const isDevelopment = process.env.NODE_ENV !== 'production'

// 关闭所有窗口后退出
app.on('window-all-closed', () => {
  // 在macOS上,应用程序及其菜单栏通常保持活动状态,直到用户使用Cmd+Q明确退出
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

// 在开发模式下,应父进程的请求退出。
if (isDevelopment) {
  if (process.platform === 'win32') {
    process.on('message', data => {
      if (data === 'graceful-exit') {
        app.quit()
      }
    })
  } else {
    process.on('SIGTERM', () => {
      app.quit()
    })
  }
}

然后就是创建和打开应用的窗口:

// background.js
import { app, protocol, BrowserWindow } from 'electron'
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'

// 注册协议
protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { secure: true, standard: true } }
])

// 在ready事件里创建窗口
app.on('ready', async () => {
  createMainWindow()
})

app.on('activate', () => {
  // 在macOS上,当点击dock图标且没有其他窗口打开时,通常会在应用程序中重新创建一个窗口。
  if (BrowserWindow.getAllWindows().length === 0) {
    createMainWindow()
  }
})

// 创建主页面
let mainWindow = null
async function createMainWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    frame: false,
    titleBarStyle: 'hiddenInset',
    webPreferences: {
      webSecurity: false,
      preload: path.join(__dirname, 'preload.js')
    }
  })

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    await mainWindow.loadURL(
      process.env.WEBPACK_DEV_SERVER_URL + '/#/workbenche'
    )
  } else {
    createProtocol('app')
    mainWindow.loadURL('app://./index.html/#/workbenche')
  }
}

ready事件中创建新窗口,默认是打开主页面,开发环境打开本地启动的服务,生产环境直接打开本地文件。

frame设为false,创建的是一个无边框窗口,也就是没有默认的工具栏和控件,只有你的页面区域。

另外可以看到在创建窗口时指定了一个文件preload.js,这个文件是渲染进程和主进程的通信桥梁。

如果你要打开页面调试的控制台,可以调用openDevTools方法:

mainWindow.webContents.openDevTools()

渲染进程

通过BrowserWindow创建的每个窗口都是一个单独的渲染进程,为了安全,一般不允许渲染进程直接访问Node.js环境,也就是我们的页面无法直接调用Node.jsAPI,但是作为一个客户端,页面显然是需要这种能力的,比如最基本的功能,操作本地文件,这就是preload.js(预加载脚本)文件的作用。

预加载脚本会在渲染器进程加载之前加载,并有权访问:两个渲染器全局对象 ( windowdocument) 、Node.js 环境。

可以在预加载脚本中通过contextBridge.exposeInMainWorld方法在页面的window对象上挂载属性和方法,这样页面就能使用了,具体的使用后面会介绍。

页面控制器和拖拽区域

我们创建的是无边框页面,但是作为一个客户端页面,页面控制器(最小化、全屏、关闭)和拖拽区域是必不可少的。

拖拽区域

拖拽区域一般放在页面顶部,宽度和页面宽度一致,高度随意,一个div即可:

<div class="workbencheHomeHeader"></div>
.workbencheHomeHeader {
    position: relative;
    width: 100%;
    height: 40px;
    background-color: #ebeef1;
    display: flex;
    align-items: center;
    flex-shrink: 0;
}

要让这个普通的div能被拖动也很简单,加上如下的样式即可:

.workbencheHomeHeader {
    // ...
    -webkit-app-region: drag;
}

如果这个区域内部的有些元素你不想作为拖拽区域的话,只要在这个元素上加上如下样式:

.innerElement {
    -webkit-app-region: no-drag;
}

控制器

Windows系统在无边框模式下默认不会显示控制器,但是Mac系统的控制器(红绿灯)是无法隐藏的,默认会显示在页面的左上方,所以笔者的做法是判断当前系统,如果是Windows则显示一个我们自己做的控制器,而Mac系统只要在红绿灯区域显示一个占位元素即可。

为了在页面内方便的判断当前的系统,我们可以在预加载脚本中注入一个全局变量:

// preload.js
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('platform', process.platform)
contextBridge.exposeInMainWorld('IS_ELECTRON', true)

这样我们就可以在页面中通过window.platform获取当前所在的系统了,另外还注入了一个全局变量window.IS_ELECTRON用来给页面判断是否处于Electron环境。

Mac系统的控制器默认在左上角,也就是我们的拖拽区域内,Windows上的控制器一般是在右上角的,但是笔者直接让WindowsMac保持一致,一起放在左上角:

<div class="workbencheHomeHeader">
      <MacControl></MacControl>
      <WinControl></WinControl>
</div>
// MacControl.vue
<template>
  <div class="macControl" v-if="IS_MAC"></div>
</template>

<style lang="less" scoped>
.macControl {
  width: 100px;
  height: 100%;
  flex-shrink: 0;
}
</style>
// WinControl.vue
<template>
  <div class="winControl noDrag" v-if="IS_WIN">
    <div class="winControlBtn iconfont iconzuixiaohua" @click="minimize"></div>
    <div
      class="winControlBtn iconfont"
      :class="[isMaximize ? 'icon3zuidahua-3' : 'iconzuidahua']"
      @click="toggleMaximize"
    ></div>
    <div class="winControlBtn iconfont iconguanbi" @click="close"></div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isMaximize: false
    }
  },
  methods: {
    // ...
  }
}
</script>

Windows控制器显然需要调用窗口的相关方法来控制窗口的最小化、关闭等。这就涉及到进程间的通信了,具体来说是渲染进程到主进程的通信。

渲染进程到主进程通信

进程间通信需要用到预加载脚本。

我们可以在预加载脚本中给页面注入一些全局方法,然后在方法中使用进程间通信 (IPC)通知主进程,拿前面的控制器为例:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  minimize: () => ipcRenderer.send('minimize'),
  maximize: () => ipcRenderer.send('maximize'),
  unmaximize: () => ipcRenderer.send('unmaximize'),
  close: () => ipcRenderer.send('close'),
}

给页面的window对象注入了一个值为对象的属性electronAPI,我们的所有通信方法都会挂在这个对象上,这样我们的控制器就可以调用相关方法了:

// WinControl.vue
<script>
export default {
  methods: {
    minimize() {
      window.electronAPI.minimize()
    },

    toggleMaximize() {
      if (this.isMaximize) {
        this.isMaximize = false
        window.electronAPI.unmaximize()
      } else {
        this.isMaximize = true
        window.electronAPI.maximize()
      }
    },

    close() {
      window.electronAPI.close()
    }
  }
}
</script>

接下来就是在主进程中接收消息:

// background.js
import { BrowserWindow, ipcMain } from 'electron'

const bindEvent = () => {
  ;['minimize', 'maximize', 'unmaximize', 'close'].forEach(eventName => {
    ipcMain.on(eventName, event => {
      // 获取发送消息的 webContents
      const webContents = event.sender
      // 获取给定webContents的窗口
      const win = BrowserWindow.fromWebContents(webContents)
      // 调用窗口的方法
      win[item]()
    })
  })
}

app.on('ready', async () => {
  createMainWindow()
  bindEvent()// ++ 监听事件
})

新建、打开、保存

新建

当点击新建按钮时,会创建一个新的思维导图编辑窗口:

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    create: () => ipcRenderer.send('create')
}
// background.js
import { v4 as uuid } from 'uuid'

ipcMain.on('create', createEditWindow)

const createEditWindow = async (event, id) => {
    id = id || uuid()
    const win = new BrowserWindow({
      width: 1200,
      height: 800,
      frame: false,
      titleBarStyle: 'hiddenInset',
      webPreferences: {
        webSecurity: false,
        preload: path.join(__dirname, 'preload.js')
      }
    })
    if (process.env.WEBPACK_DEV_SERVER_URL) {
      win.loadURL(
        process.env.WEBPACK_DEV_SERVER_URL + '/#/workbenche/edit/' + id
      )
    } else {
      win.loadURL('app://./index.html/#/workbenche/edit/' + id)
    }
}

编辑页面需要一个唯一的id,然后打开编辑页面窗口即可。

打开

打开是指打开本地的文件,首先笔者自定义了一个文件扩展名.smm,作为应用支持的文件,本质就是json格式。

打开本地文件需要使用到dialog模块:

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    selectOpenFile: () => ipcRenderer.send('selectOpenFile')
}
// background.js
import { dialog } from 'electron'

// 打开本地文件
ipcMain.on('selectOpenFile', event => {
    const res = dialog.showOpenDialogSync({
        title: '选择',// 对话框窗口的标题
        filters: [{ name: '思维导图', extensions: ['smm'] }]// 指定一个文件类型数组,用于规定用户可见或可选的特定类型范围
    })
    if (res && res[0]) {
        openFile(null, res[0])
    }
})

// 打开文件进行编辑
const idToFilePath = {}// 关联id和文件路径
const openFile = (event, file) => {
    let id = uuid()
    idToFilePath[id] = file
    createEditWindow(null, id)
}

指定只能选择.smm文件,选择完成后会返回选择文件的路径。

然后调用openFile方法打开编辑窗口,同样会生成一个唯一的id,另外我们创建了一个对象用来关联idid对应的文件路径,用于后续的保存操作。

页面打开后,页面需要获取文件的数据,作为初始数据渲染到画布,这个需要渲染进程给主进程发信息,并且能接收数据,还是渲染进程到主进程的通信,只不过是双向的。

渲染进程到主进程通信(双向)

同样是使用ipcRenderer对象,只不过不是使用sendon方法,而是使用invokehandle方法。

// 页面
const getData = async () => {
    try {
        let data = await window.electronAPI.getFileContent(this.$route.params.id)
    } catch(err) {
        console.errror(err)
    }
}
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    getFileContent: id => ipcRenderer.invoke('getFileContent', id)
}
// background.js
// 获取文件内容
ipcMain.handle('getFileContent', (event, id) => {
    return new Promise((resolve, reject) => {
        let file = idToFilePath[id]
        if (!file) {
            resolve(null)
            return
        }
        fs.readFile(file, { encoding: 'utf-8' }, (err, data) => {
            if (err) {
                reject(err)
            } else {
                resolve({
                    name: path.parse(file).name,
                    content: JSON.parse(data)
                })
            }
        })
    })
})

拖拽文件到页面

除了打开文件选择对话框选择文件外,当然也可以直接拖拽文件到页面,这和普通的web页面实现逻辑是一样的,也就是使用拖放API

<div
    class="workbencheHomeContainer"
    @drop="onDrop"
    @dragenter="onPreventDefault"
    @dragover="onPreventDefault"
    @dragleave="onPreventDefault"
  >
</div>

<script>
export default {
    // 放置文件
    onDrop(e) {
      e.preventDefault()
      e.stopPropagation()

      let df = e.dataTransfer
      let dropFiles = []
      // 从拖拽的文件中过滤出.smm文件
      if (df.items !== undefined) {
        for (let i = 0; i < df.items.length; i++) {
          let item = df.items[i]
          if (item.kind === 'file' && item.webkitGetAsEntry().isFile) {
            let file = item.getAsFile()
            if (/\.smm$/.test(file.name)) {
              dropFiles.push(file)
            }
          }
        }
      }
      if (dropFiles.length === 1) {
        // 如果只有一个文件,直接打开编辑
        window.electronAPI.openFile(dropFiles[0].path)
      } else if (dropFiles.length > 1) {
        // 否则添加到最近文件列表
        // ...
      }
    },

    onPreventDefault(e) {
      e.preventDefault()
      e.stopPropagation()
    }
}
</script>

如果只拖拽了一个文件,那么直接打开编辑窗口,否则添加到最近的文件列表上。

保存

保存存在两种情况,一是新建还未保存过的情况,这种需要先创建本地文件,再进行保存,第二种就是文件已经存在了,直接保存到文件即可。

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    save: (id, data, fileName) => ipcRenderer.invoke('save', id, data, fileName)
}
// background.js
ipcMain.handle('save', async (event, id, data, fileName = '未命名') => {
    // 从idToFilePath对象中获取id对应的文件路径
    // id没有关联的文件路径,代表文件没有创建,那么先创建文件
    if (!idToFilePath[id]) {
        const res = dialog.showSaveDialogSync({
            title: '保存',
            defaultPath: fileName + '.smm',
            filters: [{ name: '思维导图', extensions: ['smm'] }]
        })
        // 创建成功后返回文件路径
        if (res) {
            idToFilePath[id] = res
            fs.writeFile(res, data)
        }
    } else {
        // 文件已经存在,那么直接保存
        fs.writeFile(idToFilePath[id], data)
    }
})

根据ididToFilePath对象中获取是否存在关联的文件路径,存在的话则代表文件已经创建了,否则先创建一个文件,并且和id关联起来。

拦截页面关闭事件

当在编辑页面进行了编辑,还未保存的情况下,如果直接点击关闭页面,通常需要进行二次确认,防止误关闭导致数据丢失。

因为Mac系统的关闭是使用默认的控制器,所以无法拦截关闭方法,只能拦截关闭事件:

// 页面
window.onbeforeunload = async e => {
    e.preventDefault()
    e.returnValue = ''
    // 没有未保存内容直接关闭
    if (!this.isUnSave) {
        window.electronAPI.destroy()
    } else {
        try {
            // 否则询问用户是否关闭
            await this.checkIsClose()
            // 用户选择关闭会走这里
            window.electronAPI.destroy()
        } catch (error) {
            // 用户选择不关闭会走这里
        }
    }
}

// 询问是否关闭页面
checkIsClose() {
    return new Promise((resolve, reject) => {
        this.$confirm('有操作尚未保存,是否确认关闭?', '提示', {
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            type: 'warning'
        })
            .then(async () => {
            resolve()
        })
            .catch(() => {
            reject()
        })
    })
}

判断当前是否存在未保存的操作,是的话询问用户是否关闭,关闭窗口调用的是destroy,因为使用close方法又会被这个事件拦截,就进入死循环了。

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    destroy: () => ipcRenderer.send('destroy')
}
// background.js
;[..., 'destroy'].forEach(item => {
    ipcMain.on(item, event => {
        const webContents = event.sender
        const win = BrowserWindow.fromWebContents(webContents)
        win[item]()
    })
})

最近文件

客户端需要存储、更新、删除最近操作的文件记录,存储使用的是electron-json-storageAPIlocalstorage的差不多。

创建文件、打开文件、拖入文件、复制文件、删除文件等操作都需要更新最近文件列表,比如前面提到的打开文件:

// background.js
// 打开文件
const openFile = (event, file) => {
    let id = uuid()
    idToFilePath[id] = file
    saveToRecent(file)// ++ 保存到最近文件
    createEditWindow(null, id)
}

// 保存到最近文件
import storage from 'electron-json-storage'
const RECENT_FILE_LIST = 'recentFileList'
const saveToRecent = file => {
  return new Promise((resolve, reject) => {
    let list = getRecent()
    // 如果文件已经存在,那么先删除
    let index = list.findIndex(item => {
      return item === file
    })
    if (index !== -1) {
      list.splice(index, 1)
    }
    // 再添加,也就是使之变成最近的一个文件
    list.push(file)
    storage.set(RECENT_FILE_LIST, list, err => {
      if (err) {
        reject(err)
      } else {
        resolve()
      }
    })
  })
}

// 获取最近文件列表
const getRecent = () => {
  let res = storage.getSync(RECENT_FILE_LIST)
  return (Array.isArray(res) ? res : []).filter(item => {
    return !!item
  })
}

当然,这个操作只是更新了客户端的存储,还需要通知页面更新才行,这就涉及到主进程到渲染进程的通信了。

主进程到渲染进程通信

还是以前面的打开文件编辑方法为例:

// background.js
// 打开文件
const openFile = (event, file) => {
    let id = uuid()
    idToFilePath[id] = file
    saveToRecent(file).then(() => {// 保存到最近文件完成后通知页面刷新
        notifyMainWindowRefreshRecentFileList()// ++
    })
    createEditWindow(null, id)
}

// 通知主页面刷新最近文件列表
const notifyMainWindowRefreshRecentFileList = () => {
    mainWindow.webContents.send('refreshRecentFileList')
}

调用指定窗口的webContents对象的send方法发送信息,同样需要在预加载脚本中中转:

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
    onRefreshRecentFileList: callback => ipcRenderer.on('refreshRecentFileList', callback)
}

然后在页面中调用onRefreshRecentFileList方法注册回调:

// 页面
window.electronAPI.onRefreshRecentFileList(() => {
    this.getRecentFileList()
})

这样预加载脚本中监听到主进程发送的信息后,就会执行传入的回调方法。

页面获取最近文件列表使用前面介绍的渲染进程和主进程的双向通信方法即可。

在文件夹里显示某个文件

这也是一个常见的功能,打开文件所在文件夹,并且定位到文件。

这个功能需要使用到shell模块。

// background.js
import { shell } from 'electron'

// 打开文件所在目录,并定位文件
ipcMain.on('openFileInDir', (event, file) => {
    shell.showItemInFolder(file)
})

使用系统默认浏览器打开页面

如果直接使用a标签打开页面,Electron默认会新开一个窗口显示,当然这个窗口就不被你控制了,所以会显示丑丑的默认控件,通常打开这种非客户端页面的url都是使用系统默认的浏览器打开,实现上,直接使用open库即可。

// background.js
import open from 'open'

// 使用默认浏览器打开指定url
ipcMain.on('openUrl', (event, url) => {
    open(url)
})

设置应用为文件的默认打开应用

这是一个很重要的功能,比如我们双击.txt文件,默认会打开txt编辑器,如果我们的应用支持打开某种文件,或者自定义了一种类型的文件,比如笔者的.smm文件,那么显然在双击这些文件时应该打开我们的应用,否则还要用户自己去设置默认应用,那体验是非常不好的。

要实现这个功能,首先需要在打包配置里设置,vue-cli-plugin-electron-builder插件使用的显然是electron-builder,具体的配置字段为fileAssociations

笔者的配置为:

// vue.config.js
module.exports = {
    pluginOptions: {
        electronBuilder: {
            fileAssociations: [
                {
                    ext: 'smm',
                    name: 'mind map file',
                    role: 'Editor',
                    icon: './build/icons/icon.ico'
                }
            ]
        }
    }
}

ext指定支持的文件扩展名,icon用于该种类型文件在文件夹里显示的图标,这样当安装了我们的应用,支持的文件默认就会显示我们配置的图标:

以上只解决了文件关联的功能,双击也能打开我们的应用,但是通常情况下,还需要直接在应用中打开该文件,比如双击html文件,要的不是打开浏览器主页,而是直接在浏览器中打开该文件。

这就是需要在应用中支持了,要获取双击打开文件的路径,可以在主进程中监听will-finish-launching事件,当应用程序完成基础的启动的时候会触发该事件,然后分平台处理,在Windows平台可以直接通过process.argv来获取文件路径,在Mac系统上通过监听open-file事件来获取:

// background.js

// 存储被双击打开的文件路径
const initOpenFileQueue = []
app.on('will-finish-launching', () => {
  if (process.platform == 'win32') {
    const argv = process.argv
    if (argv) {
      argv.forEach(filePath => {
        if (filePath.indexOf('.smm') >= 0) {
          initOpenFileQueue.push(filePath)
        }
      })
    }
  } else {
    app.on('open-file', (event, file) => {
      if (app.isReady() === false) {
        initOpenFileQueue.push(file)
      } else {
        // 应用已经启动了,直接打开文件
      }
      event.preventDefault()
    })
  }
})

// 可以在ready事件触发后处理initOpenFileQueue数据

当然,目前的实现存在一个问题,就是多次双击文件,会重复打开应用,理论上来说打开一次就够了,这个知道怎么解决的朋友欢迎评论区见~

打包

功能开发完了,最后一步当然是打包了,想要打包出Windows应用和Mac应用,你至少需要两台电脑,在Windows电脑上可以打包出Windows应用,在Mac系统上可以打包出MacLinux应用。

打包使用的是electron-builder,它有非常多的配置,支持签名和自动更新等功能,笔者并没有深入研究,更多的功能只能各位自己探索了,下面是笔者参考其他项目的打包配置。

打包配置

// vue.config.js
module.exports = {
    pluginOptions: {
        electronBuilder: {
            preload: 'src/electron/preload.js',
            builderOptions: {
                productName: '思绪思维导图',
                copyright: 'Copyright © 思绪思维导图',
                asar: true,
                // 设置为文件的默认应用
                fileAssociations: [
                    {
                        ext: 'smm',
                        name: 'mind map file',
                        role: 'Editor',
                        icon: './build/icons/icon.ico'
                    }
                ],
                directories: {
                    output: 'dist_electron'
                },
                mac: {
                    target: [
                        {
                            target: 'dmg',
                            arch: ['x64', 'arm64', 'universal']
                        }
                    ],
                    artifactName: '${productName}-${os}-${version}-${arch}.${ext}',
                    category: 'public.app-category.utilities',
                    darkModeSupport: false
                },
                win: {
                    target: [
                        {
                            target: 'portable',
                            arch: ['x64']
                        },
                        {
                            target: 'nsis',
                            arch: ['x64']
                        }
                    ],
                    publisherName: '思绪思维导图',
                    icon: 'build/icons/icon.ico'
                },
                linux: {
                    target: [
                        {
                            target: 'AppImage',
                            arch: ['x64']
                        }
                    ],
                    category: 'Utilities',
                    icon: './build/icon.icns'
                },
                dmg: {
                    icon: 'build/icons/icon.icns'
                },
                nsis: {
                    oneClick: false,// 取消一键安装
                    allowToChangeInstallationDirectory: true,// 允许用户选择安装路径
                    perMachine: true
                }
            }
        }
    }
}

然后在package.json文件中添加打包命令:

// package.json
{
    "scripts": {
        "electron:build": "vue-cli-service electron:build -p never",
        "electron:build-all": "vue-cli-service electron:build -p never -mwl",
        "electron:build-mac": "vue-cli-service electron:build -p never -m",
        "electron:build-win": "vue-cli-service electron:build -p never -w",
        "electron:build-linux": "vue-cli-service electron:build -p never -l"
    }
}

第一个命令会自动根据当前系统打包对应的应用。

打包过程中可能会在下载electron的原型包的一步卡住,这个只能多试几次,或者手动下载,具体操作可以百度一下。

应用图标

前面的打包配置中可以看到配置了几种不同格式的图标,也就是我们的应用图标,Windows系统用的是.ico格式的图片,而MacLinux系统用的是.icns的图标。

首先你需要准备一张1024*1024png图片icon.png

生成.ico的图片很简单,网上搜索一下找一个在线网站转一下就行了,比如这个:png-to-ico

要生成.icns图片,你需要在Mac系统的命令行中执行一些命令:

// 命令行进入图片所在路径

// 创建一个临时文件夹
mkdir temp.iconset

// 在临时文件夹中生成10种大小的图片
sips -z 16 16 icon.png --out temp.iconset/icon_16x16.png
sips -z 32 32 icon.png --out temp.iconset/icon_16x16@2x.png
sips -z 32 32 icon.png --out temp.iconset/icon_32x32.png
sips -z 64 64 icon.png --out temp.iconset/icon_32x32@2x.png
sips -z 128 128 icon.png --out temp.iconset/icon_128x128.png
sips -z 256 256 icon.png --out temp.iconset/icon_128x128@2x.png
sips -z 256 256 icon.png --out temp.iconset/icon_256x256.png
sips -z 512 512 icon.png --out temp.iconset/icon_256x256@2x.png
sips -z 512 512 icon.png --out temp.iconset/icon_512x512.png
sips -z 1024 1024 icon.png --out temp.iconset/icon_512x512@2x.png

// 生成.icns图片
iconutil -c icns temp.iconset -o icon.icns

然后临时文件夹可以删除,不过最好把临时生成的10张图片也复制到icon.png所在文件,否则在打包Mac应用时可能会用到,但是不存在就会报错。

总结

本文分享了一下笔者做的一个简单的应用的细节,因为也是刚入门,所以某些方面可能会存在错误,或者有更好的实现方式,欢迎评论区见。有兴趣的朋友也可以下载体验一下~

源码地址:https://github.com/wanglin2/mind-map/tree/electron

下载地址:https://github.com/wanglin2/mind-map/releases/tag/v0.1.0


街角小林
886 声望773 粉丝