关于前端:从零开始的electron开发更新增量更新一

13次阅读

共计 9590 个字符,预计需要花费 24 分钟才能阅读完成。

更新 - 增量更新(一)

上一期咱们实现了 electron 的全量更新,本期咱们介绍的是如何只批改局部文件以实现增量更新的几种计划。

asar

咱们将 electron 软件进行装置后右键关上文件所在位置进入 resources 目录(mac 显示包内容),能够看到 app.asar 这个文件,这个文件是 electron 程序的主业务文件,这货色可不是什么加密文件,实际上是一种压缩文件,咱们能够用 npm 包解压这个文件

npm install -g asar
asar extract app.asar ./(和 app.asar 同级目录下执行,注:装置在 c 盘下的同学如果解压不了的话,用管理员身份运行 cmd 进入再解压)

解压后发现,实际上就是 dist_electron/bundled 外面的货色,其实咱们如果只批改了渲染过程外面的货色的话,并不需要进行齐全的打包更新,只有对 js,html,css 进行替换,那咱们的页面也会更新,那么咱们只须要更新几 M 的文件,并不需要让用户再下载一个残缺的新包,增量更新的长处也在于此。

然而呢说起来容易,实际操作起来呢还是有肯定的问题的,如果你设置的打包 asar:true 的话,那么在软件启动的时候进行 app.asar 替换会发现替换不了(win 下),正在被软件应用。那么这个计划必定用一般的替换是走不通了,上面我介绍几种计划供大家查考。

7z-Asar7z

这里呢我还提供一个 7z 的插件,让 7z 也能关上 asar,链接,如果你的 7z 是装置在 c 盘,把 Asar.64.dll(64 位零碎)放入C:\Program Files\7-Zip\Formats\ 里,Formats没有的话,本人新建一个。

计划一,asar:false

这是一种比拟常见的形式,比方 vscode 就是采纳的此计划,在进行打包是批改打包配置(vue.config.js 中的 builderOptions)asar:false,那么打包的时候 resources 下就不会产生app.asar,而是一个 app 文件夹,而这个文件夹呢是能够间接进行替换的,故不存在替换不了的问题。

简略来说就是,设置 asar:false,打包,进入打包的绿色安装包dist_electron/win-ia32-unpacked/resources(win32),将 app 文件压缩成app.zip 放到服务器,渲染过程检测增量更新告诉主过程,主过程下载 app.zip,解压替换。

  • 长处: 简略粗犷。
  • 毛病:装置及全量更新时装置较慢,主过程裸露在里面,替换时都是整体下载替换。

此计划的步骤和计划二差不多,具体做法可参考计划二的形式。

计划二,app.asar.unpacked + app.asar

app.asar.unpacked这个货色还是比拟常见的,因为 app.asar 的限制性,比方文件内只是可读,一些 node 命令不能应用等,咱们常常会把一些第三方的数据库或者 dll 等文件会用到这个,简略来说就是把原本应该放在 app.asar 中的文件放入到与 app.asar 同级的 app.asar.unpacked 目录中(其实和计划一的 app 文件夹相似),从而解除 app.asar 的限制性。

看到这里是不是就有新思路了,既然 app.asar 不能动,咱们能够把变动的文件给扔到 app.asar.unpacked 里,主过程及一些不变的货色还是放在 app.asar,增量更新时替换app.asar.unpacked 就行了。

  • 长处: 能够将主过程 js 等文件保留在app.asar,只对渲染过程文件进行替换。
  • 毛病:因为主过程 js 没有动,那么主过程注入的环境变量版本号也不会扭转,也就是说更新后在主过程应用环境变量获取的版本号不是更新后的版本号(能够从渲染过程拿)。

实现步骤:

1. 设置 app.asar.unpacked

首先设置一下咱们想要替换的那些文件,打包时会学生成 dist_electron/bundled 这个文件夹,而后再用 electron-builder 把这个文件夹打包成咱们的 electron 文件。

vue.config.js 的 builderOptions

extraResources: [{
  from: "dist_electron/bundled",
  to: "app.asar.unpacked",
  filter: [
    "!**/icons",
    "!**/preload.js",
    "!**/node_modules",
    "!**/background.js"
    ]
  }],
  files: [
    "**/icons/*",
    "**/preload.js",
    "**/node_modules/**/*",
    "**/background.js"
  ],

extraResources 呢是设置 app.asar.unpacked 外面的货色,files 是设置 app.asar 里的货色,这里的意思是咱们把 dist_electron/bundled 外面的除了 iconsbackground.js 等文件放入 app.asar,其余的都放入app.asar.unpacked,打包看看,看看app.asar.unpacked 外面是否是咱们想要的货色。

2. 构建增量 zip

当初咱们有了app.asar.unpacked,然而咱们不可能每次都进入免安装包外面手动压缩app.asar.unpacked,太麻烦了,咱们这里利用打包实现的钩子,主动构建增量包。
adm-zip 是解决 zip 包,fs-extra 是 fs 的拓展,解决文件

npm i adm-zip
npm i fs-extra

electron-builder提供里打包实现的钩子 afterPack

vue.config.js 的 builderOptions 增加

afterPack: './afterPack.js',

./afterPack.js:
const path = require('path')
const AdmZip = require('adm-zip')

exports.default = async function(context) {
  let targetPath
  if(context.packager.platform.nodeName === 'darwin') {targetPath = path.join(context.appOutDir, `${context.packager.appInfo.productName}.app/Contents/Resources`)
  } else {targetPath = path.join(context.appOutDir, './resources')
  }
  const unpacked = path.join(targetPath, './app.asar.unpacked')
  var zip = new AdmZip()
  zip.addLocalFolder(unpacked)
  zip.writeZip(path.join(context.outDir, 'unpacked.zip'))
}

mac 和 win 的 resources 有所区别,当初咱们再打包看看,dist_electron目录下会生成一个unpacked.zip,这个就是咱们的增量包了。

3. 加载策略批改

在窗口启动篇咱们说过,咱们渲染过程的 html 加载是通过 app:// 协定加载的,这个协定呢以前是以 app.asar 为根目录的,这里把的渲染过程的文件给移出 app.asar 了,app://协定就找不到咱们的渲染过程 html,所以咱们这里须要批改一下,把 app.asar.unpacked 作为根目录。

主过程 main/index.js 下找到
// import {createProtocol} from 'vue-cli-plugin-electron-builder/lib' 咱们找到这个文件拷贝一份进去,// 批改 readFile(path.join(__dirname, pathName),这里能够看出这个协定读取的是__dirname(`app.asar`)下的文件,咱们通过传入一个 path 替换掉原来的__dirname

创立 createProtocol.js

import {protocol} from 'electron'
import * as path from 'path'
import {readFile} from 'fs'
import {URL} from 'url'

export default (scheme, serverPath = __dirname) => {
  protocol.registerBufferProtocol(
    scheme,
    (request, respond) => {let pathName = new URL(request.url).pathname
      pathName = decodeURI(pathName) // Needed in case URL contains spaces
      readFile(path.join(serverPath, pathName), (error, data) => {if (error) {
          console.error(`Failed to read ${pathName} on ${scheme} protocol`,
            error
          )
        }
        const extension = path.extname(pathName).toLowerCase()
        let mimeType = ''if (extension ==='.js') {mimeType = 'text/javascript'} else if (extension === '.html') {mimeType = 'text/html'} else if (extension === '.css') {mimeType = 'text/css'} else if (extension === '.svg' || extension === '.svgz') {mimeType = 'image/svg+xml'} else if (extension === '.json') {mimeType = 'application/json'} else if (extension === '.wasm') {mimeType = 'application/wasm'}

        respond({mimeType, data})
      })
    },
    (error) => {if (error) {console.error(`Failed to register ${scheme} protocol`, error)
      }
    }
  )
}

主过程引入咱们批改的createProtocol.js

import createProtocol from './services/createProtocol'
const resources = process.resourcesPath

将原来的 createProtocol('app')批改为
createProtocol('app', path.join(resources, './app.asar.unpacked'))

当初就能够通过 app:// 协定载入 app.asar.unpacked 下的文件了,打个包试试看看页面是否失常加载,当然如果你是间接用 file:// 协定加载本地文件的其改变也差不多,就是扭转一下加载地址,筹备工作实现了,咱们开始渲染过程增量更新的逻辑。

4. 模仿接口

这里呢就不多说什么了,和上一期全量更新一样,不理解能够去看看上一期内容,用 http-server 模仿接口返回,批改 .env.dev0.0.2,打包生成unpacked.zip,放入 server 目录下

{
  "code": 200,
  "success": true,
  "data": {
    "forceUpdate": false,
    "fullUpdate": false,
    "upDateUrl": "http://127.0.0.1:4000/unpacked.zip",
    "restart": false,
    "message": "我要升级成 0.0.2",
    "version": "0.0.2"
  }
}

5. 渲染过程增量更新

这里的页面逻辑和上一期全量更新差不多,咱们检测到更新用 win-increment 向主过程发送更新信息:

<template>
  <div class="increment">
    <div class="version"> 以后版本为:{{config.VUE_APP_VERSION}}</div>
    <a-button type="primary" @click="upDateClick(true)"> 检测更新 </a-button>
  </div>
</template>

<script>
import cfg from '@/config'
import update from '@/utils/update'
import {defineComponent, getCurrentInstance} from 'vue'

export default defineComponent({setup() {const { proxy} = getCurrentInstance()
    const config = cfg
    const api = proxy.$api
    const message = proxy.$message
    function upDateClick(isClick) {api('http://localhost:4000/index.json', {}, {method: 'get'}).then(res => {console.log(res)
        if (cfg.NODE_ENV !== 'development') {update(config.VUE_APP_VERSION, res).then(() => {if (!res.fullUpdate) {window.ipcRenderer.invoke('win-increment', res)
            }
          }).catch(err => {if (err.code === 0) {isClick && message.success('已为最新版本')
            }
          })
        } else {message.success('请在打包环境下更新')
        }
      })
    }
    return {
      config,
      upDateClick
    }
  }
})
</script>

6. 主过程解决

ipcMain.js 增加
import increment from '../utils/increment'

ipcMain.handle('win-increment', (_, data) => {increment(data)
})

增量更新解决 increment.js,通过upDateUrl 下载增量包,下载实现之后,咱们先把原来的 app.asar.unpacked 重命名备份,如果出错的话能够还原,而后将下载的解压,解决实现之后咱们能够用 reloadIgnoringCache 从新加载页面即可,当然你也能够用 app.relaunch() 重启利用

import downloadFile from './downloadFile'
import global from '../config/global'
import {app} from 'electron'
const path = require('path')
const fse = require('fs-extra')
const AdmZip = require('adm-zip')

export default (data) => {
  const resourcesPath = process.resourcesPath
  const unpackedPath = path.join(resourcesPath, './app.asar.unpacked')
  downloadFile({url: data.upDateUrl, targetPath: resourcesPath}).then(async (filePath) => {backups(unpackedPath)
    const zip = new AdmZip(filePath)
    zip.extractAllToAsync(unpackedPath, true, (err) => {if (err) {console.error(err)
        reduction(unpackedPath)
        return
      }
      fse.removeSync(filePath)
      if (data.restart) {reLoad(true)
      } else {reLoad(false)
      }
    })
  }).catch(err => {console.log(err)
  })
}

function backups(targetPath) {if (fse.pathExistsSync(targetPath + '.back')) { // 删除旧备份
    fse.removeSync(targetPath + '.back')
  }
  if (fse.pathExistsSync(targetPath)) {fse.moveSync(targetPath, targetPath + '.back') // 备份目录
  }
}

function reduction(targetPath) {if (fse.pathExistsSync(targetPath + '.back')) {fse.moveSync(targetPath + '.back', targetPath)
  }
  reLoad(false)
}

function reLoad(close) {if (close) {app.relaunch()
    app.exit(0)
  } else {global.sharedObject.win.webContents.reloadIgnoringCache()
  }
}

封装的下载文件downloadFile.js

const request = require('request')
const fs = require('fs')
const fse = require('fs-extra')
const path = require('path')

function download(url, targetPath, cb = () => {}) {
  let status
  const req = request({
    method: 'GET',
    uri: encodeURI(url)
  })
  try {const stream = fs.createWriteStream(targetPath)
    let len = 0
    let cur = 0
    req.pipe(stream)
    req.on('response', (data) => {len = parseInt(data.headers['content-length'])
    })
    req.on('data', (chunk) => {
      cur += chunk.length
      const progress = (100 * cur / len).toFixed(2)
      status = 'progressing'
      cb(status, progress)
    })
    req.on('end', function () {if (req.response.statusCode === 200) {if (len === cur) {console.log(targetPath + 'Download complete')
          status = 'completed'
          cb(status, 100)
        } else {stream.end()
          removeFile(targetPath)
          status = 'error'
          cb(status, '网络稳定,下载文件不全')
        }
      } else {stream.end()
        removeFile(targetPath)
        status = 'error'
        cb(status, req.response.statusMessage)
      }
    })
    req.on('error', (e) => {stream.end()
      removeFile(targetPath)
      if (len !== cur) {
        status = 'error'
        cb(status, '网络稳定,下载失败')
      } else {
        status = 'error'
        cb(status, e)
      }
    })
  } catch (error) {console.log(error)
  }
}

function removeFile(targetPath) {
  try {fse.removeSync(targetPath)
  } catch (error) {console.log(error)
  }
}

export default async function downloadFile({url, targetPath, folder = './'}, cb = () => {}) {if (!targetPath || !url) {throw new Error('targetPath or url is nofind')
  }
  try {await fse.ensureDirSync(path.join(targetPath, folder))
  } catch (error) {throw new Error(error)
  }
  return new Promise((resolve, reject) => {const name = url.split('/').pop()
    const filePath = path.join(targetPath, folder, name)
    download(url, filePath, (status, result) => {if (status === 'completed') {resolve(filePath)
      }
      if (status === 'error') {reject(result)
      }
      if (status === 'progressing') {cb && cb(result)
      }
    })
  })
}

增量更新的根本逻辑就实现了,如果你是采纳计划一的话,也能够参考一下流程,点击渲染过程的检测更新,看看版本变成 0.0.2 没有

计划缺点解决

后面咱们说了,此计划有个毛病就是主过程中的环境变量不会扭转,那么咱们在主过程中通过 process.env.VUE_APP_VERSION 获取版本号拿到的还是之前的版本号。
咱们的渲染过程是从新打包的,故其环境变量都是精确的,此时咱们能够在页面加载时,从渲染过程把配置信息发送给主过程。

renderer 的 App.vue:import cfg from '@/config'
window.ipcRenderer.invoke('win-envConfig', cfg)

global.js:global.envConfig = {}

main 的 ipcMain.js:import global from '../config/global'
ipcMain.handle('win-envConfig', (_, data) => {global.envConfig = data})

不再应用 process.env.VUE_APP_VERSION 获取版本号信息,应用 global.config.VUE_APP_VERSION 获取,从新打个 0.0.2 的包试试。

补充阐明

  • 这里呢只是简略的实现了增量更新的逻辑,如果你想要一个下载进度呀,能够本人实现一下
  • 一般来说这类增量更新包在上传时会将地址保留到数据库中,能够做一下平安解决,比方在保留时附加文件的 md5 或 sha 呀,而后在增量更新下载实现后本地校验是否统一再进行解压,保障文件准确性。
  • 当然还有解压失败解决,如果咱们的增量更新包损坏了,尽管咱们有备份,然而重启还是会拉取更新包进行更新,如果应用了重启更新的话,就陷入了死循环了,这里能够做一个版本更新重启记录,超过多少次后,就不再对这个版本的包进行解决了。

当然增量更新还有其余的形式实现,一期讲完太多了,其余计划咱们放到下一期持续。

本系列更新只有利用周末和下班时间整顿,比拟多的内容的话更新会比较慢,心愿能对你有所帮忙,请多多 star 或点赞珍藏反对一下

本文地址:链接
本文 github 地址:链接

正文完
 0