1. 项目概述
作为一名长期从事跨平台应用开发的工程师,我深知维护多端更新逻辑的痛苦。最近在开发一个同时支持Android和Windows平台的uni-app项目时,遇到了一个典型问题:Electron自带的autoUpdater功能不够灵活,而Android端已经有一套成熟的更新机制。经过多次实践和优化,我总结出一套完全脱离Electron autoUpdater的跨端统一更新方案。
这个方案的核心价值在于:
- 复用Android端已有的更新逻辑,减少重复开发
- 摆脱Electron autoUpdater的限制,实现完全自主可控的更新流程
- 保证Windows和Android两端用户体验的一致性
- 降低长期维护成本,一套代码适配多平台
2. 核心设计思路
2.1 为什么选择脱离Electron autoUpdater
Electron的autoUpdater模块虽然提供了开箱即用的更新功能,但在实际项目中存在几个明显痛点:
- 灵活性不足:无法自定义更新流程的各个环节,如下载进度显示、安装前校验等
- 平台限制:Windows平台依赖Squirrel框架,配置复杂且容易出问题
- 体验割裂:与Android端的更新流程差异大,难以保持一致性
- 调试困难:错误信息不透明,问题排查成本高
2.2 统一更新架构设计
我们的解决方案采用分层架构设计:
code复制┌───────────────────────────────────────┐
│ 跨端通用工具层 │
│ (版本检查、提示弹窗、错误处理等) │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ 平台特定实现层 │
├───────────────────┬───────────────────┤
│ Electron主进程 │ Android原生层 │
│ (Node.js文件操作、 │ (原生下载安装逻辑)│
│ 进程通信、网络请求)│ │
└───────────────────┴───────────────────┘
这种设计的优势在于:
- 通用逻辑集中管理,避免代码重复
- 平台特定功能隔离实现,互不干扰
- 易于扩展支持更多平台
3. 具体实现步骤
3.1 封装跨端公共工具
创建updateHelper.js作为核心工具模块,包含以下关键功能:
javascript复制// 版本比较逻辑
export const hasNewVersion = (currentVersion, remoteInfo) => {
if (!remoteInfo?.version) return false;
// 支持语义化版本比较 (如1.2.3 > 1.1.0)
const compareVersions = (a, b) => {
const pa = a.split('.');
const pb = b.split('.');
for (let i = 0; i < 3; i++) {
const na = Number(pa[i]);
const nb = Number(pb[i]);
if (na > nb) return 1;
if (nb > na) return -1;
if (!isNaN(na) && isNaN(nb)) return 1;
if (isNaN(na) && !isNaN(nb)) return -1;
}
return 0;
};
return compareVersions(remoteInfo.version, currentVersion) > 0;
};
// 下载进度格式化
export const formatDownloadProgress = (progress) => {
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return {
percent: progress.percent || 0,
transferred: formatBytes(progress.transferred || 0),
total: formatBytes(progress.total || 0),
speed: progress.speed ? formatBytes(progress.speed) + '/s' : '0 Bytes/s'
};
};
3.2 Electron主进程实现
在Electron的background.js中实现核心功能:
javascript复制const { app, ipcMain, shell } = require('electron');
const fs = require('fs');
const path = require('path');
const https = require('https');
const crypto = require('crypto');
// 创建下载目录
const ensureDownloadDir = () => {
const downloadDir = path.join(app.getPath('userData'), 'updates');
if (!fs.existsSync(downloadDir)) {
fs.mkdirSync(downloadDir, { recursive: true });
}
return downloadDir;
};
// 增强版下载方法(支持断点续传)
ipcMain.handle('download-update', async (event, { version, fileUrl, md5 }) => {
const downloadDir = ensureDownloadDir();
const exePath = path.join(downloadDir, `app-v${version}.exe`);
// 检查已有下载进度
let startByte = 0;
if (fs.existsSync(exePath)) {
startByte = fs.statSync(exePath).size;
}
return new Promise((resolve, reject) => {
const options = {
headers: {}
};
// 断点续传支持
if (startByte > 0) {
options.headers.Range = `bytes=${startByte}-`;
}
const req = https.get(fileUrl, options, (res) => {
// 处理206(部分内容)和200(全部内容)状态码
if (res.statusCode !== 206 && res.statusCode !== 200) {
return reject(new Error(`服务器返回错误状态码: ${res.statusCode}`));
}
const totalLength = startByte + parseInt(res.headers['content-length'], 10);
let downloadedBytes = startByte;
let lastUpdateTime = Date.now();
let lastDownloaded = startByte;
// 计算下载速度
const calcSpeed = () => {
const now = Date.now();
const timeDiff = (now - lastUpdateTime) / 1000; // 秒
const bytesDiff = downloadedBytes - lastDownloaded;
lastUpdateTime = now;
lastDownloaded = downloadedBytes;
return bytesDiff / timeDiff;
};
const fileStream = fs.createWriteStream(exePath, {
flags: startByte > 0 ? 'a' : 'w'
});
res.on('data', (chunk) => {
downloadedBytes += chunk.length;
const speed = calcSpeed();
event.sender.send('update-progress', {
percent: Math.floor((downloadedBytes / totalLength) * 100),
transferred: downloadedBytes,
total: totalLength,
speed: speed
});
});
fileStream.on('finish', () => {
// 校验文件完整性
if (md5) {
const fileBuffer = fs.readFileSync(exePath);
const hash = crypto.createHash('md5').update(fileBuffer).digest('hex');
if (hash !== md5) {
fs.unlinkSync(exePath);
return reject(new Error('文件校验失败,MD5不匹配'));
}
}
resolve({ success: true, path: exePath });
});
fileStream.on('error', (err) => {
fs.unlinkSync(exePath);
reject(err);
});
res.pipe(fileStream);
});
req.on('error', (err) => {
reject(err);
});
});
});
3.3 uni-app渲染进程实现
更新页面核心逻辑:
vue复制<template>
<view class="update-container">
<view class="update-header">
<text class="title">应用更新</text>
<text class="version">当前版本: {{ currentVersion }}</text>
</view>
<view v-if="updateInfo" class="update-info">
<text class="new-version">新版本: v{{ updateInfo.version }}</text>
<text class="release-time">发布时间: {{ formatDate(updateInfo.uploadTime) }}</text>
<text class="release-notes" v-if="updateInfo.description">
更新内容:\n{{ updateInfo.description }}
</text>
</view>
<view v-if="downloadProgress.percent > 0" class="progress-container">
<progress
class="progress-bar"
:value="downloadProgress.percent"
max="100"
/>
<text class="progress-text">
{{ downloadProgress.percent }}% ({{ downloadProgress.transferred }}/{{ downloadProgress.total }})
</text>
<text class="speed" v-if="downloadProgress.speed">
下载速度: {{ downloadProgress.speed }}
</text>
</view>
<button
class="update-button"
@click="handleUpdate"
:disabled="isUpdating"
>
{{ buttonText }}
</button>
</view>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useUpdate } from '@/composables/useUpdate';
export default {
setup() {
const {
currentVersion,
updateInfo,
downloadProgress,
isUpdating,
checkUpdate,
startDownload,
installUpdate
} = useUpdate();
const buttonText = computed(() => {
if (isUpdating.value) return '处理中...';
if (downloadProgress.value.percent > 0) return '正在下载';
if (updateInfo.value?.isDownloaded) return '立即安装';
return '检查更新';
});
const handleUpdate = async () => {
if (!updateInfo.value) {
await checkUpdate();
return;
}
if (updateInfo.value.isDownloaded) {
await installUpdate();
} else {
await startDownload();
}
};
onMounted(async () => {
await checkUpdate();
});
return {
currentVersion,
updateInfo,
downloadProgress,
isUpdating,
buttonText,
handleUpdate,
formatDate: (timestamp) => {
return new Date(timestamp).toLocaleString();
}
};
}
};
</script>
4. 关键问题与解决方案
4.1 跨平台兼容性处理
问题:不同平台对文件路径、权限管理的差异
解决方案:
javascript复制// 在updateHelper.js中添加平台判断
export const getDownloadPath = (fileName) => {
if (process.platform === 'win32') {
return path.join(app.getPath('userData'), 'updates', fileName);
} else if (process.platform === 'android') {
// Android使用特定的外部存储目录
return path.join(cordova.file.externalDataDirectory, fileName);
}
// 其他平台...
};
// 统一安装方法
export const installPackage = async (filePath) => {
if (process.platform === 'win32') {
shell.openPath(filePath);
app.quit();
} else if (process.platform === 'android') {
// 调用cordova插件进行安装
await window.cordova.plugins.fileOpener2.open(
filePath,
'application/vnd.android.package-archive'
);
}
};
4.2 下载稳定性优化
问题:网络不稳定导致下载中断
解决方案:
- 实现断点续传:
javascript复制// 改造下载方法支持Range头
const downloadFile = (url, filePath, onProgress) => {
return new Promise((resolve, reject) => {
let startByte = 0;
if (fs.existsSync(filePath)) {
startByte = fs.statSync(filePath).size;
}
const options = {
headers: startByte > 0 ? { Range: `bytes=${startByte}-` } : {}
};
const req = https.get(url, options, (res) => {
// 处理206(部分内容)和200响应
if (res.statusCode === 206 || res.statusCode === 200) {
const fileStream = fs.createWriteStream(filePath, {
flags: startByte > 0 ? 'a' : 'w'
});
// 进度处理...
} else {
reject(new Error(`服务器返回错误状态码: ${res.statusCode}`));
}
});
});
};
- 添加重试机制:
javascript复制const downloadWithRetry = async (url, filePath, maxRetries = 3) => {
let retryCount = 0;
while (retryCount < maxRetries) {
try {
return await downloadFile(url, filePath);
} catch (err) {
retryCount++;
if (retryCount >= maxRetries) throw err;
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
}
};
4.3 安全性保障
问题:下载文件可能被篡改
解决方案:
- HTTPS传输:确保所有下载请求使用HTTPS
- 文件校验:
javascript复制const verifyFile = (filePath, expectedHash, algorithm = 'md5') => {
return new Promise((resolve) => {
const hash = crypto.createHash(algorithm);
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => {
const actualHash = hash.digest('hex');
resolve(actualHash === expectedHash);
});
stream.on('error', () => resolve(false));
});
};
- 数字签名验证(Windows端):
javascript复制const verifySignature = async (exePath) => {
if (process.platform !== 'win32') return true;
try {
const { execSync } = require('child_process');
const command = `Get-AuthenticodeSignature -FilePath "${exePath}" | Select-Object -ExpandProperty Status`;
const result = execSync(`powershell -Command "${command}"`, { encoding: 'utf-8' }).trim();
return result === 'Valid';
} catch {
return false;
}
};
5. 性能优化实践
5.1 内存管理优化
问题:大文件下载时内存占用过高
解决方案:
javascript复制// 使用流式处理替代缓冲
const downloadFile = (url, filePath, onProgress) => {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
const fileStream = fs.createWriteStream(filePath);
let downloadedBytes = 0;
const totalBytes = parseInt(res.headers['content-length'], 10);
res.on('data', (chunk) => {
downloadedBytes += chunk.length;
onProgress({
percent: Math.floor((downloadedBytes / totalBytes) * 100),
transferred: downloadedBytes,
total: totalBytes
});
});
res.pipe(fileStream);
fileStream.on('finish', () => resolve(filePath));
fileStream.on('error', reject);
});
req.on('error', reject);
});
};
5.2 后台下载优化
问题:应用切换到后台时下载可能被暂停
解决方案:
javascript复制// Electron主进程
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// 保持应用在后台运行直到下载完成
if (activeDownloads > 0) {
return;
}
app.quit();
}
});
// 注册下载状态追踪
let activeDownloads = 0;
ipcMain.handle('download-update', async (event, params) => {
activeDownloads++;
try {
const result = await doDownload(event, params);
return result;
} finally {
activeDownloads--;
if (activeDownloads === 0 && app.isQuiting) {
app.quit();
}
}
});
6. 实际应用中的经验总结
6.1 版本号管理最佳实践
-
语义化版本控制:
- 使用
major.minor.patch格式(如1.2.3) - 重大更新递增major,向后兼容的功能更新递增minor,问题修复递增patch
- 使用
-
版本比较逻辑增强:
javascript复制export const compareVersions = (a, b) => {
// 处理带v前缀的版本号 (v1.2.3)
const cleanA = a.replace(/^v/, '');
const cleanB = b.replace(/^v/, '');
const partsA = cleanA.split('.');
const partsB = cleanB.split('.');
for (let i = 0; i < 3; i++) {
const numA = parseInt(partsA[i] || 0, 10);
const numB = parseInt(partsB[i] || 0, 10);
if (numA > numB) return 1;
if (numB > numA) return -1;
}
// 处理带后缀的版本 (1.2.3-beta)
const suffixA = partsA[3];
const suffixB = partsB[3];
if (suffixA && !suffixB) return -1;
if (!suffixA && suffixB) return 1;
if (suffixA && suffixB) {
return suffixA.localeCompare(suffixB);
}
return 0;
};
6.2 更新策略选择
-
静默下载:
- 小更新(patch版本)可以自动下载但不安装
- 需要确保WiFi环境下才自动下载
-
强制更新:
- 对于重大安全更新或必须修复的问题
- 后端接口返回
forceUpdate: true - 前端禁用跳过按钮
-
差异化更新:
- 根据更新大小决定策略
- 小更新(<5MB)自动下载
- 大更新(>50MB)提示用户确认
6.3 错误处理与日志记录
javascript复制// 增强的错误处理
export const handleUpdateError = (error, context = '') => {
const errorMap = {
'ENOENT': '文件不存在',
'ECONNRESET': '连接被重置',
'ETIMEDOUT': '连接超时',
'EACCES': '权限不足'
};
const errorCode = error.code || '';
const userMessage = errorMap[errorCode] || error.message || '未知错误';
// 记录详细错误日志
logError({
timestamp: new Date().toISOString(),
context,
errorCode,
errorMessage: error.message,
stack: error.stack,
systemInfo: getSystemInfo()
});
// 用户友好提示
showModal({
title: '更新失败',
content: `操作无法完成: ${userMessage}`,
confirmText: '重试',
cancelText: '稍后再试'
}).then((retry) => {
if (retry) {
// 重新尝试
}
});
};
7. 进阶功能实现
7.1 增量更新支持
javascript复制// 后端接口返回增量包信息
interface DeltaUpdate {
baseVersion: string; // 基于哪个版本
deltaUrl: string; // 增量包地址
deltaSize: number; // 增量包大小
fullUrl: string; // 完整包地址
fullSize: number; // 完整包大小
}
// 客户端实现
const applyDeltaUpdate = async (currentVersion, deltaInfo) => {
// 1. 检查本地是否有baseVersion对应的文件
const basePath = getAppPath(currentVersion);
if (!fs.existsSync(basePath)) {
// 没有基础文件,回退到完整更新
return downloadFullUpdate(deltaInfo.fullUrl);
}
// 2. 下载增量包
const deltaPath = await downloadFile(deltaInfo.deltaUrl);
// 3. 应用增量更新
try {
const newPath = await applyBsdiff(basePath, deltaPath);
return newPath;
} catch (err) {
// 增量更新失败,回退到完整更新
return downloadFullUpdate(deltaInfo.fullUrl);
}
};
7.2 多版本回滚机制
javascript复制// 保存历史版本
const backupCurrentVersion = (version) => {
const backupDir = path.join(app.getPath('userData'), 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const appPath = getAppPath();
const backupPath = path.join(backupDir, `v${version}`);
// 复制当前版本到备份目录
fs.cpSync(appPath, backupPath, { recursive: true });
// 保留最近3个版本
const backups = fs.readdirSync(backupDir)
.sort()
.reverse();
if (backups.length > 3) {
for (const oldBackup of backups.slice(3)) {
fs.rmSync(path.join(backupDir, oldBackup), { recursive: true });
}
}
};
// 回滚到指定版本
const rollbackToVersion = (version) => {
const backupDir = path.join(app.getPath('userData'), 'backups');
const backupPath = path.join(backupDir, `v${version}`);
if (!fs.existsSync(backupPath)) {
throw new Error(`找不到版本 ${version} 的备份`);
}
const appPath = getAppPath();
// 清空当前应用目录
fs.rmSync(appPath, { recursive: true, force: true });
// 恢复备份
fs.cpSync(backupPath, appPath, { recursive: true });
// 重启应用
app.relaunch();
app.quit();
};
这套跨端统一更新方案经过多个项目的实际验证,能够稳定支持日活百万级的应用。关键在于平衡灵活性与一致性,既保持各平台原生特性,又提供统一的用户体验。对于uni-app开发者来说,这种方案可以显著降低维护成本,同时提供比官方autoUpdater更强大的自定义能力。