一、离线缓存机制
业务场景
用户在地铁、电梯等弱网环境下,需要快速打开应用并浏览之前访问过的内容。例如新闻类 App 需要离线阅读,电商 App 需要缓存商品列表。
技术方案
1. 分层缓存架构
接口数据层
↓
业务缓存层(LRU)
↓
持久化存储层(SQLite / IndexedDB)
2. uni-app 实现
// utils/cache.js
class CacheManager {
constructor() {
this.memoryCache = new Map(); // 内存缓存
this.maxMemorySize = 50; // 最多缓存50个
}
// 设置缓存
async set(key, data, options = {}) {
const {
expire = 3600000, // 默认1小时
persist = true // 是否持久化
} = options;
const cacheData = {
data,
timestamp: Date.now(),
expire
};
// 内存缓存
this.memoryCache.set(key, cacheData);
this._checkMemorySize();
// 持久化
if (persist) {
try {
uni.setStorageSync(key, cacheData);
} catch (e) {
console.error('持久化失败:', e);
}
}
}
// 获取缓存
async get(key) {
// 先查内存
let cache = this.memoryCache.get(key);
// 再查持久化
if (!cache) {
try {
cache = uni.getStorageSync(key);
if (cache) {
this.memoryCache.set(key, cache);
}
} catch (e) {
console.error('读取缓存失败:', e);
}
}
if (!cache) return null;
// 检查过期
if (Date.now() - cache.timestamp > cache.expire) {
this.remove(key);
return null;
}
return cache.data;
}
// 删除缓存
remove(key) {
this.memoryCache.delete(key);
uni.removeStorageSync(key);
}
// 清空缓存
clear() {
this.memoryCache.clear();
uni.clearStorageSync();
}
// LRU 淘汰
_checkMemorySize() {
if (this.memoryCache.size > this.maxMemorySize) {
const firstKey = this.memoryCache.keys().next().value;
this.memoryCache.delete(firstKey);
}
}
}
export default new CacheManager();
3. Flutter 实现
// lib/utils/cache_manager.dart
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
class CacheManager {
static final CacheManager _instance = CacheManager._internal();
factory CacheManager() => _instance;
CacheManager._internal();
final Map<String, dynamic> _memoryCache = {};
final int _maxSize = 50;
// 设置缓存
Future<void> set(String key, dynamic data, {
int expire = 3600000,
bool persist = true
}) async {
final cacheData = {
'data': data,
'timestamp': DateTime.now().millisecondsSinceEpoch,
'expire': expire
};
// 内存缓存
_memoryCache[key] = cacheData;
_checkMemorySize();
// 持久化
if (persist) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, jsonEncode(cacheData));
}
}
// 获取缓存
Future<dynamic> get(String key) async {
// 先查内存
var cache = _memoryCache[key];
// 再查持久化
if (cache == null) {
final prefs = await SharedPreferences.getInstance();
final str = prefs.getString(key);
if (str != null) {
cache = jsonDecode(str);
_memoryCache[key] = cache;
}
}
if (cache == null) return null;
// 检查过期
final now = DateTime.now().millisecondsSinceEpoch;
if (now - cache['timestamp'] > cache['expire']) {
await remove(key);
return null;
}
return cache['data'];
}
// 删除缓存
Future<void> remove(String key) async {
_memoryCache.remove(key);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);
}
void _checkMemorySize() {
if (_memoryCache.length > _maxSize) {
_memoryCache.remove(_memoryCache.keys.first);
}
}
}
4. 缓存策略
// api/request.js
import cache from '@/utils/cache';
async function request(url, options = {}) {
const { useCache = true, cacheTime = 3600000 } = options;
const cacheKey = `api_${url}_${JSON.stringify(options.params)}`;
// 读取缓存
if (useCache) {
const cachedData = await cache.get(cacheKey);
if (cachedData) {
console.log('命中缓存:', url);
return cachedData;
}
}
// 请求接口
try {
const res = await uni.request({
url,
...options
});
// 写入缓存
if (useCache && res.statusCode === 200) {
await cache.set(cacheKey, res.data, {
expire: cacheTime
});
}
return res.data;
} catch (error) {
// 请求失败,尝试返回过期缓存
const expiredCache = await cache.get(cacheKey);
if (expiredCache) {
console.log('使用过期缓存');
return expiredCache;
}
throw error;
}
}
面试话术
面试官问:你们是怎么做离线缓存的?
我们的离线缓存设计了三层架构:内存层、业务缓存层、持久化层。
首先是内存层,使用 Map 做 LRU 缓存,最多缓存 50 个热点数据,访问速度最快。
然后是业务缓存层,根据不同业务设置不同的过期时间。比如用户信息缓存 1 小时,商品列表缓存 30 分钟,文章内容缓存 24 小时。
最底层是持久化存储,uni-app 用的是 Storage API,Flutter 用的是 shared_preferences,数据即使 App 重启也不会丢失。
在请求接口时,我们会先查缓存,命中就直接返回。如果缓存过期或不存在,就请求接口并更新缓存。特别的是,如果接口请求失败,我们还会尝试返回过期的缓存,保证用户能看到内容,只是数据可能不是最新的。
这套方案实施后,我们监控到缓存命中率在 60% 左右,弱网环境下的页面加载时间从 3 秒降到了 500ms。
二、启动优化
业务场景
用户打开 App 首屏渲染时间过长,特别是冷启动需要 3-5 秒,热启动也要 1-2 秒,影响用户体验。
技术方案
1. 冷启动优化
uni-app 优化
// main.js
import App from './App'
import Vue from 'vue'
// 懒加载非首屏组件
Vue.component('heavy-component', () => import('./components/HeavyComponent.vue'))
// 减少首屏数据请求
const app = new Vue({
...App,
onLaunch() {
// 只请求必需数据
this.loadCriticalData();
// 非关键数据延迟加载
setTimeout(() => {
this.loadNonCriticalData();
}, 1000);
},
methods: {
async loadCriticalData() {
// 用户信息、配置信息
const [userInfo, config] = await Promise.all([
this.getUserInfo(),
this.getConfig()
]);
this.$store.commit('setUser', userInfo);
this.$store.commit('setConfig', config);
},
async loadNonCriticalData() {
// 消息通知、推荐内容等
this.getNotifications();
this.getRecommends();
}
}
})
app.$mount()
Flutter 优化
// main.dart
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 延迟初始化非关键服务
runApp(MyApp());
// 启动后再初始化
WidgetsBinding.instance.addPostFrameCallback((_) {
_initNonCriticalServices();
});
}
void _initNonCriticalServices() {
// 推送服务
PushService.init();
// 统计服务
AnalyticsService.init();
// 分享服务
ShareService.init();
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: FutureBuilder(
// 只等待关键数据
future: _loadCriticalData(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return HomePage();
}
return SplashScreen();
},
),
);
}
Future<void> _loadCriticalData() async {
await Future.wait([
UserService.loadUser(),
ConfigService.loadConfig(),
]);
}
}
Electron 优化
// main.js (主进程)
const { app, BrowserWindow } = require('electron');
let mainWindow;
app.on('ready', () => {
// 立即创建窗口
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false, // 先不显示
backgroundColor: '#ffffff',
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
// 加载页面
mainWindow.loadFile('index.html');
// 首次渲染完成后显示
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// 预加载子窗口
setTimeout(() => {
preloadSubWindows();
}, 3000);
});
function preloadSubWindows() {
// 预创建隐藏窗口
const subWindow = new BrowserWindow({
width: 800,
height: 600,
show: false
});
subWindow.loadFile('sub.html');
}
2. 热启动优化
缓存首页数据
// pages/index/index.vue
export default {
data() {
return {
list: [],
loading: true
}
},
onLoad() {
this.loadData();
},
async loadData() {
// 先展示缓存数据
const cached = await this.$cache.get('homepage_data');
if (cached) {
this.list = cached;
this.loading = false;
}
// 后台刷新数据
try {
const res = await this.$api.getHomeData();
this.list = res.data;
this.$cache.set('homepage_data', res.data, {
expire: 600000 // 10分钟
});
} catch (e) {
if (!cached) {
this.showError();
}
} finally {
this.loading = false;
}
}
}
预渲染首屏
// Electron renderer.js
window.addEventListener('DOMContentLoaded', () => {
// 显示骨架屏
document.getElementById('skeleton').style.display = 'block';
// 加载真实内容
loadContent().then(() => {
document.getElementById('skeleton').style.display = 'none';
document.getElementById('content').style.display = 'block';
});
});
性能指标
// utils/performance.js
class PerformanceMonitor {
constructor() {
this.metrics = {};
}
// 记录启动时间
recordLaunch() {
const now = Date.now();
this.metrics.launchTime = now;
// 记录到首屏渲染的时间
setTimeout(() => {
this.metrics.firstPaint = Date.now() - now;
this.report();
}, 0);
}
// 上报性能数据
report() {
console.log('性能指标:', this.metrics);
// 上报到监控平台
}
}
面试话术
面试官问:启动优化你们做了哪些工作?
我们从冷启动和热启动两个方面做了优化。
冷启动方面,主要做了三件事:
第一是懒加载。首屏不需要的组件和模块全部改成异步加载,比如图表库、地图SDK这些大的依赖,用到时才引入。首屏 JS 包体积从 800KB 降到了 300KB。
第二是数据预加载策略。App 启动时只请求必需的数据,像用户信息、系统配置这些。其他的消息通知、推荐内容,都延迟 1 秒后再请求。而且我们用了 Promise.all 并行请求关键数据,不用串行等待。
第三是首屏直出。我们把首页的 HTML 结构预渲染好,App 启动时直接展示,数据后台异步加载。用户能立即看到页面框架,不用等白屏。
热启动方面,我们做了页面缓存。首页的数据缓存 10 分钟,用户再次打开时直接从缓存读取,瞬间就能看到内容。然后后台静默刷新数据,如果有更新就替换掉。
还有就是预加载。用户在首页停留时,我们会预加载次级页面的资源和数据,这样用户点进去时加载会很快。
优化完之后,冷启动从 3.5 秒降到了 1.2 秒,热启动从 1.5 秒降到了 300ms 以内。
三、移动端离线包方案
业务场景
H5 页面在 App 中加载慢,每次都要从服务器下载资源。需要实现类似小程序的离线包机制,首次下载后本地加载。
技术方案
1. 离线包结构
offline-package.zip
├── manifest.json # 版本信息
├── index.html
├── static/
│ ├── js/
│ │ └── app.js
│ └── css/
│ └── style.css
└── pages/
├── home.html
└── detail.html
2. manifest.json
{
"version": "1.0.5",
"minAppVersion": "2.0.0",
"updateTime": 1704096000000,
"files": [
{
"path": "index.html",
"hash": "abc123",
"size": 1024
},
{
"path": "static/js/app.js",
"hash": "def456",
"size": 51200
}
]
}
3. 离线包管理器(uni-app)
// utils/offline-package.js
class OfflinePackageManager {
constructor() {
this.baseDir = `${uni.env.USER_DATA_PATH}/offline_packages`;
this.currentVersion = '';
}
// 初始化
async init() {
// 检查更新
await this.checkUpdate();
}
// 检查更新
async checkUpdate() {
try {
// 请求服务器获取最新版本
const res = await uni.request({
url: 'https://api.example.com/offline-package/version'
});
const { version, downloadUrl } = res.data;
const localVersion = this.getLocalVersion();
if (this.compareVersion(version, localVersion) > 0) {
console.log('发现新版本:', version);
await this.download(downloadUrl, version);
}
} catch (e) {
console.error('检查更新失败:', e);
}
}
// 下载离线包
async download(url, version) {
const tempPath = `${this.baseDir}/temp.zip`;
// 显示下载进度
const downloadTask = uni.downloadFile({
url,
filePath: tempPath,
success: async (res) => {
if (res.statusCode === 200) {
// 解压
await this.unzip(tempPath, version);
// 删除临时文件
uni.getFileSystemManager().unlinkSync(tempPath);
// 更新版本号
this.setLocalVersion(version);
console.log('离线包更新成功');
}
}
});
downloadTask.onProgressUpdate((res) => {
console.log('下载进度:', res.progress);
});
}
// 解压离线包
async unzip(zipPath, version) {
const targetDir = `${this.baseDir}/${version}`;
return new Promise((resolve, reject) => {
// uni-app 解压API
uni.getFileSystemManager().unzip({
zipFilePath: zipPath,
targetPath: targetDir,
success: resolve,
fail: reject
});
});
}
// 获取本地路径
getLocalPath(relativePath) {
const version = this.getLocalVersion();
if (!version) return null;
return `${this.baseDir}/${version}/${relativePath}`;
}
// 加载页面
loadPage(pagePath) {
const localPath = this.getLocalPath(pagePath);
if (localPath && this.fileExists(localPath)) {
// 使用本地文件
return `file://${localPath}`;
} else {
// 降级到线上
return `https://h5.example.com/${pagePath}`;
}
}
// 版本比较
compareVersion(v1, v2) {
const arr1 = v1.split('.').map(Number);
const arr2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (arr1[i] > arr2[i]) return 1;
if (arr1[i] < arr2[i]) return -1;
}
return 0;
}
// 获取本地版本
getLocalVersion() {
return uni.getStorageSync('offline_package_version') || '';
}
// 设置本地版本
setLocalVersion(version) {
uni.setStorageSync('offline_package_version', version);
}
// 检查文件是否存在
fileExists(path) {
try {
uni.getFileSystemManager().accessSync(path);
return true;
} catch (e) {
return false;
}
}
}
export default new OfflinePackageManager();
4. WebView 加载离线包
<template>
<web-view :src="pageUrl"></web-view>
</template>
<script>
import offlinePackage from '@/utils/offline-package';
export default {
data() {
return {
pageUrl: ''
}
},
onLoad(options) {
const { page } = options;
// 尝试加载本地离线包
this.pageUrl = offlinePackage.loadPage(page);
}
}
</script>
5. 差量更新
// 差量更新管理
class DiffUpdateManager {
async checkDiffUpdate() {
const localVersion = this.getLocalVersion();
const res = await uni.request({
url: 'https://api.example.com/diff-package',
data: { fromVersion: localVersion }
});
if (res.data.hasDiff) {
// 下载差量包
await this.downloadDiff(res.data.diffUrl);
// 应用差量包
await this.applyDiff(res.data.changes);
}
}
async applyDiff(changes) {
for (const change of changes) {
if (change.type === 'add' || change.type === 'modify') {
// 下载并替换文件
await this.downloadFile(change.url, change.path);
} else if (change.type === 'delete') {
// 删除文件
this.deleteFile(change.path);
}
}
}
}
面试话术
面试官问:离线包方案具体怎么实现的?
我们参考了微信小程序的离线包机制,实现了一套完整的方案。
首先是离线包的结构设计。我们把 H5 页面的所有资源打包成一个 zip 文件,包含 HTML、JS、CSS、图片等。每个包有一个 manifest.json,记录版本号、文件列表、每个文件的 hash 值。
然后是更新策略。App 启动时会请求服务器检查是否有新版本。如果有,就后台下载新的离线包,解压到本地目录。下次用户打开 H5 页面时,就从本地加载,不用再从网络下载。
为了减少流量,我们还做了差量更新。服务器会对比用户的当前版本,只返回变更的文件。比如只改了一个 JS 文件,就只下载这个文件,不用下载整个包。
加载时的降级策略也很重要。如果本地没有离线包或者文件损坏,会自动降级到线上 URL,保证页面能正常打开。
另外我们还做了版本校验。离线包有最低 App 版本要求,如果用户的 App 版本太低,不支持某些 API,就不会下载新的离线包。
这套方案上线后,H5 页面的首屏时间从 2 秒降到了 300ms,流量消耗减少了 80%。
四、Native 能力调用 & JSBridge
业务场景
H5 需要调用原生能力:拍照、定位、支付、分享等。需要统一的 JSBridge 方案,支持 iOS、Android、Electron。
技术方案
1. JSBridge 协议设计
// bridge/protocol.js
const BRIDGE_PROTOCOL = {
// 相机
camera: {
takePhoto: { params: ['quality'], returns: 'path' },
chooseImage: { params: ['count', 'sourceType'], returns: 'paths[]' }
},
// 位置
location: {
getLocation: { params: ['type'], returns: 'location' },
openLocation: { params: ['latitude', 'longitude', 'name'] }
},
// 支付
payment: {
pay: { params: ['orderInfo'], returns: 'result' }
},
// 分享
share: {
shareText: { params: ['text', 'platform'] },
shareImage: { params: ['imageUrl', 'platform'] }
},
// 存储
storage: {
get: { params: ['key'], returns: 'value' },
set: { params: ['key', 'value'] }
}
};
2. JSBridge 核心实现
// bridge/index.js
class JSBridge {
constructor() {
this.callbacks = {};
this.callbackId = 0;
// 注册全局回调
window.onNativeCallback = this.onNativeCallback.bind(this);
}
// 调用 Native 方法
call(module, method, params = {}) {
return new Promise((resolve, reject) => {
const callbackId = this.callbackId++;
// 保存回调
this.callbacks[callbackId] = { resolve, reject };
const data = {
callbackId,
module,
method,
params
};
// iOS 调用
if (window.webkit && window.webkit.messageHandlers[module]) {
window.webkit.messageHandlers[module].postMessage(data);
}
// Android 调用
else if (window[module] && window[module][method]) {
window[module][method](JSON.stringify(data));
}
// Electron 调用
else if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('native-call', data);
}
// 降级处理
else {
reject(new Error('Native bridge not found'));
}
// 超时处理
setTimeout(() => {
if (this.callbacks[callbackId]) {
delete this.callbacks[callbackId];
reject(new Error('Bridge call timeout'));
}
}, 10000);
});
}
// Native 回调
onNativeCallback(callbackId, error, result) {
const callback = this.callbacks[callbackId];
if (!callback) return;
delete this.callbacks[callbackId];
if (error) {
callback.reject(new Error(error));
} else {
callback.resolve(result);
}
}
}
const bridge = new JSBridge();
export default bridge;
3. 业务 API 封装
// bridge/api.js
import bridge from './index';
export const NativeAPI = {
// 拍照
async takePhoto(quality = 80) {
const result = await bridge.call('camera', 'takePhoto', { quality });
return result.path;
},
// 选择图片
async chooseImage(options = {}) {
const { count = 1, sourceType = ['album', 'camera'] } = options;
const result = await bridge.call('camera', 'chooseImage', {
count,
sourceType
});
return result.paths;
},
// 获取定位
async getLocation(type = 'wgs84') {
const result = await bridge.call('location', 'getLocation', { type });
return {
latitude: result.latitude,
longitude: result.longitude,
address: result.address
};
},
// 支付
async pay(orderInfo) {
const result = await bridge.call('payment', 'pay', { orderInfo });
return result.success;
},
// 分享
async share(options) {
const { type, content, platform } = options;
if (type === 'text') {
await bridge.call('share', 'shareText', {
text: content,
platform
});
} else if (type === 'image') {
await bridge.call('share', 'shareImage', {
imageUrl: content,
platform
});
}
},
// 本地存储
async getStorage(key) {
const result = await bridge.call('storage', 'get', { key });
return result.value;
},
async setStorage(key, value) {
await bridge.call('storage', 'set', { key, value });
}
};
4. Native 端实现(iOS)
// iOS WKWebView
class NativeBridge: NSObject, WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let data = message.body as? [String: Any],
let callbackId = data["callbackId"] as? Int,
let module = data["module"] as? String,
let method = data["method"] as? String,
let params = data["params"] as? [String: Any] else {
return
}
// 路由到具体模块
switch module {
case "camera":
handleCamera(method: method, params: params, callbackId: callbackId)
case "location":
handleLocation(method: method, params: params, callbackId: callbackId)
default:
sendCallback(callbackId: callbackId, error: "Unknown module")
}
}
func handleCamera(method: String, params: [String: Any], callbackId: Int) {
if method == "takePhoto" {
let quality = params["quality"] as? Int ?? 80
// 调用相机
let imagePicker = UIImagePickerController()
imagePicker.sourceType = .camera
// ... 拍照逻辑
// 返回结果
sendCallback(callbackId: callbackId, result: ["path": "/path/to/photo.jpg"])
}
}
func sendCallback(callbackId: Int, error: String? = nil, result: Any? = nil) {
var script = "window.onNativeCallback(\(callbackId)"
if let error = error {
script += ", '\(error)', null)"
} else if let result = result {
let jsonData = try? JSONSerialization.data(withJSONObject: result)
let jsonString = String(data: jsonData!, encoding: .utf8)!
script += ", null, \(jsonString))"
}
webView.evaluateJavaScript(script)
}
}
5. Native 端实现(Android)
// Android WebView
public class NativeBridge {
private WebView webView;
@JavascriptInterface
public void call(String dataJson) {
try {
JSONObject data = new JSONObject(dataJson);
int callbackId = data.getInt("callbackId");
String module = data.getString("module");
String method = data.getString("method");
JSONObject params = data.getJSONObject("params");
// 路由到具体模块
if ("camera".equals(module)) {
handleCamera(method, params, callbackId);
} else if ("location".equals(module)) {
handleLocation(method, params, callbackId);
}
} catch (Exception e) {
e.printStackTrace();
}
}
private void handleCamera(String method, JSONObject params, int callbackId) {
if ("takePhoto".equals(method)) {
int quality = params.optInt("quality", 80);
// 启动相机
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
// ... 拍照逻辑
// 返回结果
JSONObject result = new JSONObject();
result.put("path", "/path/to/photo.jpg");
sendCallback(callbackId, null, result);
}
}
private void sendCallback(int callbackId, String error, JSONObject result) {
String script = "window.onNativeCallback(" + callbackId;
if (error != null) {
script += ", '" + error + "', null)";
} else {
script += ", null, " + result.toString() + ")";
}
webView.evaluateJavascript(script, null);
}
}
6. Electron 实现
// Electron main.js
const { ipcMain, dialog } = require('electron');
ipcMain.on('native-call', async (event, data) => {
const { callbackId, module, method, params } = data;
try {
let result;
if (module === 'camera' && method === 'chooseImage') {
const files = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: ['jpg', 'png'] }]
});
result = { paths: files.filePaths };
}
// 返回结果给渲染进程
event.sender.executeJavaScript(
`window.onNativeCallback(${callbackId}, null, ${JSON.stringify(result)})`
);
} catch (error) {
event.sender.executeJavaScript(
`window.onNativeCallback(${callbackId}, '${error.message}', null)`
);
}
});
面试话术
面试官问:JSBridge 是怎么设计的?
我们的 JSBridge 设计了一套统一的协议,兼容 iOS、Android、Electron 三端。
核心思路是异步回调。JS 调用 Native 时,会生成一个唯一的 callbackId,然后把这个 ID、模块名、方法名、参数打包成 JSON,发送给 Native。Native 执行完后,带着这个 callbackId 和结果回调 JS。JS 根据 ID 找到对应的 Promise,resolve 或 reject。
具体通信方式,iOS 用的是 WKWebView 的 messageHandlers,Android 用的是 addJavascriptInterface,Electron 用的是 ipcRenderer。我们在 JSBridge 里统一了这三种调用方式,业务层无需关心平台差异。
为了安全和可维护性,我们还做了几个设计:
一是协议约束,每个 Native 方法都定义了参数类型和返回值,JSBridge 会做校验。
二是超时处理,如果 10 秒 Native 没有回调,Promise 会自动 reject,防止永久等待。
三是降级方案,如果检测到没有 Native 环境(比如在浏览器打开),会降级到 H5 实现或者提示用户。
四是版本兼容,Native 方法会带版本号,旧版本 App 调用新方法时会有友好提示。
这套 JSBridge 封装后,业务开发只需要调用 NativeAPI.takePhoto() 这样的简单接口,不用关心底层实现。我们团队 30+ 个 H5 页面都在用,非常稳定。
五、异常监控 & 日志上报
业务场景
跨端应用的线上问题难以排查,需要完整的异常监控和日志系统,快速定位问题。
技术方案
1. 异常捕获
// utils/monitor.js
class Monitor {
constructor() {
this.init();
}
init() {
// JS 错误
this.catchJSError();
// Promise 错误
this.catchPromiseError();
// 资源加载错误
this.catchResourceError();
// 接口错误
this.catchAPIError();
}
// 捕获 JS 错误
catchJSError() {
window.addEventListener('error', (event) => {
if (event.target !== window) return; // 过滤资源错误
this.report({
type: 'jsError',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
timestamp: Date.now()
});
}, true);
}
// 捕获 Promise 错误
catchPromiseError() {
window.addEventListener('unhandledrejection', (event) => {
this.report({
type: 'promiseError',
message: event.reason?.message || event.reason,
stack: event.reason?.stack,
timestamp: Date.now()
});
});
}
// 捕获资源加载错误
catchResourceError() {
window.addEventListener('error', (event) => {
const target = event.target;
if (target === window) return;
this.report({
type: 'resourceError',
resourceType: target.tagName,
resourceUrl: target.src || target.href,
timestamp: Date.now()
});
}, true);
}
// 捕获接口错误
catchAPIError() {
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const startTime = Date.now();
try {
const response = await originalFetch(...args);
// 记录慢接口
const duration = Date.now() - startTime;
if (duration > 3000) {
this.report({
type: 'slowAPI',
url: args[0],
duration,
timestamp: Date.now()
});
}
// 记录失败接口
if (!response.ok) {
this.report({
type: 'apiError',
url: args[0],
status: response.status,
statusText: response.statusText,
timestamp: Date.now()
});
}
return response;
} catch (error) {
this.report({
type: 'apiError',
url: args[0],
message: error.message,
timestamp: Date.now()
});
throw error;
}
};
}
// 上报错误
report(data) {
// 添加环境信息
const errorInfo = {
...data,
platform: this.getPlatform(),
appVersion: this.getAppVersion(),
userId: this.getUserId(),
deviceInfo: this.getDeviceInfo()
};
console.error('[Monitor]', errorInfo);
// 上报到服务器
this.sendToServer(errorInfo);
}
// 发送到服务器
sendToServer(data) {
// 使用 sendBeacon 确保数据发送
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/monitor/error', JSON.stringify(data));
} else {
fetch('/api/monitor/error', {
method: 'POST',
body: JSON.stringify(data),
keepalive: true
}).catch(() => {});
}
}
getPlatform() {
// #ifdef APP-PLUS
return 'app';
// #endif
// #ifdef H5
return 'h5';
// #endif
// #ifdef MP-WEIXIN
return 'weixin';
// #endif
}
getAppVersion() {
// #ifdef APP-PLUS
return plus.runtime.version;
// #endif
return '1.0.0';
}
getUserId() {
return uni.getStorageSync('userId') || 'guest';
}
getDeviceInfo() {
return {
model: uni.getSystemInfoSync().model,
system: uni.getSystemInfoSync().system,
platform: uni.getSystemInfoSync().platform
};
}
}
export default new Monitor();
2. Flutter 异常捕获
// lib/utils/monitor.dart
class Monitor {
static void init() {
// 捕获 Flutter 异常
FlutterError.onError = (FlutterErrorDetails details) {
_reportError(details.exception, details.stack, 'FlutterError');
};
// 捕获未捕获异常
PlatformDispatcher.instance.onError = (error, stack) {
_reportError(error, stack, 'UnhandledError');
return true;
};
}
static void _reportError(dynamic exception, StackTrace? stack, String type) {
final errorInfo = {
'type': type,
'message': exception.toString(),
'stack': stack?.toString(),
'timestamp': DateTime.now().millisecondsSinceEpoch,
'platform': Platform.operatingSystem,
'appVersion': '1.0.0',
};
print('[Monitor] $errorInfo');
// 上报到服务器
http.post(
Uri.parse('https://api.example.com/monitor/error'),
body: jsonEncode(errorInfo),
);
}
}
3. 日志系统
// utils/logger.js
class Logger {
constructor() {
this.logs = [];
this.maxLogs = 100;
}
debug(message, ...args) {
this.log('debug', message, args);
}
info(message, ...args) {
this.log('info', message, args);
}
warn(message, ...args) {
this.log('warn', message, args);
}
error(message, ...args) {
this.log('error', message, args);
// 错误日志立即上报
this.flush();
}
log(level, message, args) {
const log = {
level,
message,
args: args.map(arg => {
try {
return JSON.stringify(arg);
} catch (e) {
return String(arg);
}
}),
timestamp: Date.now(),
page: this.getCurrentPage()
};
console[level](message, ...args);
this.logs.push(log);
// 超过限制,删除旧日志
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
// 定时上报
this.scheduleFlush();
}
scheduleFlush() {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flush();
}, 10000); // 10秒上报一次
}
flush() {
if (this.logs.length === 0) return;
clearTimeout(this.flushTimer);
this.flushTimer = null;
const logs = [...this.logs];
this.logs = [];
// 批量上报
fetch('/api/monitor/logs', {
method: 'POST',
body: JSON.stringify({
logs,
userId: this.getUserId(),
deviceInfo: this.getDeviceInfo()
}),
keepalive: true
}).catch(() => {});
}
getCurrentPage() {
// #ifdef APP-PLUS
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
// #endif
return window.location.pathname;
}
getUserId() {
return uni.getStorageSync('userId') || 'guest';
}
getDeviceInfo() {
return uni.getSystemInfoSync();
}
}
export default new Logger();
4. 性能监控
// utils/performance.js
class PerformanceMonitor {
// 监控页面性能
monitorPagePerformance() {
if (!window.performance) return;
window.addEventListener('load', () => {
setTimeout(() => {
const timing = performance.timing;
const metrics = {
// DNS 查询耗时
dns: timing.domainLookupEnd - timing.domainLookupStart,
// TCP 连接耗时
tcp: timing.connectEnd - timing.connectStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// DOM 解析耗时
domParse: timing.domContentLoadedEventEnd - timing.domLoading,
// 首屏时间
firstPaint: timing.domContentLoadedEventEnd - timing.navigationStart,
// 完全加载时间
load: timing.loadEventEnd - timing.navigationStart
};
this.report('pagePerformance', metrics);
}, 0);
});
}
// 监控接口性能
monitorAPI(url, duration, success) {
this.report('apiPerformance', {
url,
duration,
success,
timestamp: Date.now()
});
}
// 监控内存
monitorMemory() {
if (!performance.memory) return;
setInterval(() => {
const memory = performance.memory;
// 内存使用超过 80% 报警
if (memory.usedJSHeapSize / memory.jsHeapSizeLimit > 0.8) {
this.report('memoryWarning', {
used: memory.usedJSHeapSize,
total: memory.jsHeapSizeLimit,
percentage: (memory.usedJSHeapSize / memory.jsHeapSizeLimit * 100).toFixed(2)
});
}
}, 30000); // 30秒检查一次
}
report(type, data) {
console.log(`[Performance] ${type}:`, data);
fetch('/api/monitor/performance', {
method: 'POST',
body: JSON.stringify({ type, data }),
keepalive: true
}).catch(() => {});
}
}
export default new PerformanceMonitor();
面试话术
面试官问:异常监控是怎么做的?
我们建立了完整的异常监控体系,覆盖 JS 错误、Promise 错误、资源加载错误、接口错误四大类。
实现上,我们在全局注册了 error 和 unhandledrejection 事件监听器,捕获所有未被 catch 的错误。对于接口错误,我们劫持了 fetch 和 XMLHttpRequest,记录所有失败的请求和慢接口。
错误捕获后,我们会收集完整的上下文信息:错误堆栈、用户 ID、设备信息、App 版本、当前页面、操作路径等,然后批量上报到服务器。
为了不影响性能,我们做了几个优化:
一是使用 sendBeacon 上报,这个 API 在页面关闭时也能保证数据发送,而且不会阻塞页面。
二是批量上报,日志先缓存在本地,10 秒或者达到 100 条时才批量发送,减少请求次数。
三是采样上报,对于高频错误,比如某个接口一直失败,我们会做采样,避免服务器被刷爆。
除了异常监控,我们还做了性能监控,记录首屏时间、接口耗时、内存使用等指标。当内存使用超过 80% 时会触发报警,帮助我们提前发现内存泄漏。
这套系统上线后,线上问题的定位效率提升了很多。之前用户反馈问题,我们要花半天去复现,现在直接查日志,几分钟就能定位到具体代码行。
六、热更新机制
业务场景
App 发版周期长,紧急 bug 修复需要等审核。需要热更新能力,绕过应用商店快速修复问题。
技术方案
1. uni-app 热更新(wgt 包)
// utils/hot-update.js
class HotUpdate {
constructor() {
this.updateUrl = 'https://api.example.com/update/check';
}
// 检查更新
async checkUpdate() {
try {
// #ifdef APP-PLUS
const res = await uni.request({
url: this.updateUrl,
data: {
platform: plus.os.name,
version: plus.runtime.version
}
});
const { hasUpdate, version, downloadUrl, updateInfo } = res.data;
if (hasUpdate) {
uni.showModal({
title: '发现新版本',
content: `版本 ${version}\n${updateInfo}`,
success: (res) => {
if (res.confirm) {
this.downloadUpdate(downloadUrl, version);
}
}
});
}
// #endif
} catch (e) {
console.error('检查更新失败:', e);
}
}
// 下载更新包
downloadUpdate(url, version) {
// #ifdef APP-PLUS
uni.showLoading({ title: '下载中...' });
const downloadTask = uni.downloadFile({
url,
success: (res) => {
if (res.statusCode === 200) {
this.installUpdate(res.tempFilePath, version);
}
},
fail: (err) => {
uni.hideLoading();
uni.showToast({
title: '下载失败',
icon: 'none'
});
}
});
downloadTask.onProgressUpdate((res) => {
uni.showLoading({
title: `下载中 ${res.progress}%`
});
});
// #endif
}
// 安装更新
installUpdate(filePath, version) {
// #ifdef APP-PLUS
plus.runtime.install(filePath, {
force: false
}, () => {
uni.hideLoading();
uni.showModal({
title: '安装成功',
content: '应用将重启以完成更新',
showCancel: false,
success: () => {
plus.runtime.restart();
}
});
}, (err) => {
uni.hideLoading();
uni.showToast({
title: '安装失败',
icon: 'none'
});
});
// #endif
}
// 强制更新
async forceUpdate() {
// #ifdef APP-PLUS
plus.runtime.getProperty(plus.runtime.appid, (info) => {
const localVersion = info.version;
uni.request({
url: this.updateUrl,
data: { version: localVersion },
success: (res) => {
const { mustUpdate, downloadUrl } = res.data;
if (mustUpdate) {
uni.showModal({
title: '发现新版本',
content: '请更新到最新版本',
showCancel: false,
success: () => {
// 跳转应用商店
if (plus.os.name === 'Android') {
plus.runtime.openURL(downloadUrl);
} else {
plus.runtime.openURL('itms-apps://itunes.apple.com/app/id你的AppID');
}
// 退出应用
setTimeout(() => {
plus.runtime.quit();
}, 1000);
}
});
}
}
});
});
// #endif
}
}
export default new HotUpdate();
2. Flutter 热更新(代码推送)
// lib/utils/hot_update.dart
import 'package:flutter/services.dart';
import 'dart:io';
class HotUpdate {
static const platform = MethodChannel('com.example.app/update');
// 检查更新
static Future<void> checkUpdate() async {
try {
final response = await http.get(
Uri.parse('https://api.example.com/flutter/update/check'),
);
final data = jsonDecode(response.body);
if (data['hasUpdate']) {
_showUpdateDialog(data);
}
} catch (e) {
print('检查更新失败: $e');
}
}
// 下载更新
static Future<void> downloadUpdate(String url) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/update.zip');
// 下载文件
final response = await http.get(Uri.parse(url));
await file.writeAsBytes(response.bodyBytes);
// 解压并安装
await _installUpdate(file.path);
}
// 安装更新
static Future<void> _installUpdate(String filePath) async {
try {
await platform.invokeMethod('installUpdate', {
'filePath': filePath
});
} catch (e) {
print('安装更新失败: $e');
}
}
static void _showUpdateDialog(Map data) {
// 显示更新弹窗
}
}
3. Electron 热更新
// main.js
const { app, autoUpdater } = require('electron');
class ElectronUpdater {
constructor() {
this.feedUrl = 'https://update.example.com';
this.init();
}
init() {
if (process.platform === 'win32') {
this.feedUrl += '/win32';
} else if (process.platform === 'darwin') {
this.feedUrl += '/darwin';
}
autoUpdater.setFeedURL({
url: `${this.feedUrl}/${app.getVersion()}`
});
// 检查更新
autoUpdater.on('checking-for-update', () => {
console.log('检查更新...');
});
// 发现更新
autoUpdater.on('update-available', () => {
console.log('发现新版本');
});
// 无可用更新
autoUpdater.on('update-not-available', () => {
console.log('已是最新版本');
});
// 更新下载完成
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox({
type: 'info',
title: '更新提示',
message: '新版本已下载完成',
buttons: ['立即重启', '稍后'],
}).then((result) => {
if (result.response === 0) {
autoUpdater.quitAndInstall();
}
});
});
// 更新错误
autoUpdater.on('error', (err) => {
console.error('更新失败:', err);
});
}
// 检查更新
checkForUpdates() {
autoUpdater.checkForUpdates();
}
}
const updater = new ElectronUpdater();
// App 启动时检查
app.on('ready', () => {
setTimeout(() => {
updater.checkForUpdates();
}, 3000);
});
4. 热更新服务端
// server/update.js
const express = require('express');
const router = express.Router();
// 版本配置
const versions = {
android: {
current: '1.0.5',
minVersion: '1.0.0',
downloadUrl: 'https://cdn.example.com/app-1.0.5.wgt',
updateInfo: '修复了若干bug',
force: false
},
ios: {
current: '1.0.5',
minVersion: '1.0.0',
downloadUrl: 'https://cdn.example.com/app-1.0.5.wgt',
updateInfo: '修复了若干bug',
force: false
}
};
// 检查更新
router.get('/check', (req, res) => {
const { platform, version } = req.query;
const config = versions[platform];
if (!config) {
return res.json({ hasUpdate: false });
}
// 比较版本号
const hasUpdate = compareVersion(config.current, version) > 0;
res.json({
hasUpdate,
version: config.current,
downloadUrl: config.downloadUrl,
updateInfo: config.updateInfo,
mustUpdate: config.force || compareVersion(version, config.minVersion) < 0
});
});
function compareVersion(v1, v2) {
const arr1 = v1.split('.').map(Number);
const arr2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (arr1[i] > arr2[i]) return 1;
if (arr1[i] < arr2[i]) return -1;
}
return 0;
}
module.exports = router;
面试话术
面试官问:热更新是怎么实现的?
我们针对不同平台实现了不同的热更新方案。
uni-app 用的是 wgt 包更新。原理是把应用的 JS、CSS、图片等资源打包成一个 wgt 文件,上传到服务器。App 启动时请求服务器检查版本,如果有新版本就下载 wgt 包,然后调用 plus.runtime.install 安装。安装完成后重启应用,新代码就生效了。这种方式可以绕过应用商店审核,适合紧急修复 bug。
Flutter 相对复杂,因为 Flutter 是 AOT 编译的,不能像 JS 那样动态替换代码。我们用的是代码推送方案,结合了热重载和原生插件。具体是把 Flutter 产物打包成 so 文件,通过原生代码动态加载。但这种方式有限制,只能更新业务逻辑,不能改变原生接口。
Electron 用的是官方的 autoUpdater。我们搭建了一个更新服务器,按照 Electron 的协议返回版本信息。App 检测到新版本后会自动下载,下载完成后提示用户重启安装。
为了安全,我们做了几个限制:
一是版本校验,wgt 包带有签名,防止被篡改。
二是回滚机制,如果新版本有问题,可以一键回滚到上一个版本。
三是灰度发布,可以先让 10% 的用户更新,观察稳定性再全量。
四是强制更新,对于重大安全问题,可以强制用户更新,旧版本无法使用。
不过需要注意的是,iOS 的热更新有限制,如果改动太大,可能会被苹果拒审。所以我们一般只用热更新修复小 bug,大功能还是走正常发版流程。
七、跨端统一日志收集
业务场景
uni-app、Flutter、Electron 三端日志分散,难以统一分析。需要统一的日志收集方案。
技术方案
1. 日志格式定义
// 统一日志格式
{
"level": "error", // 日志级别
"message": "接口请求失败", // 日志内容
"timestamp": 1704096000000, // 时间戳
"platform": "android", // 平台
"appVersion": "1.0.5", // App版本
"userId": "user123", // 用户ID
"deviceId": "device456", // 设备ID
"page": "/pages/home", // 当前页面
"extra": { // 额外信息
"url": "/api/user/info",
"code": 500
}
}
2. 统一 Logger(uni-app)
// utils/unified-logger.js
class UnifiedLogger {
constructor() {
this.queue = [];
this.maxQueue = 50;
this.uploadUrl = 'https://log.example.com/collect';
}
log(level, message, extra = {}) {
const logData = {
level,
message,
timestamp: Date.now(),
platform: this.getPlatform(),
appVersion: this.getAppVersion(),
userId: this.getUserId(),
deviceId: this.getDeviceId(),
page: this.getCurrentPage(),
extra
};
// 输出到控制台
console[level](message, extra);
// 加入队列
this.queue.push(logData);
// 达到阈值或错误日志立即上传
if (this.queue.length >= this.maxQueue || level === 'error') {
this.flush();
}
}
debug(message, extra) {
this.log('debug', message, extra);
}
info(message, extra) {
this.log('info', message, extra);
}
warn(message, extra) {
this.log('warn', message, extra);
}
error(message, extra) {
this.log('error', message, extra);
}
// 批量上传
async flush() {
if (this.queue.length === 0) return;
const logs = [...this.queue];
this.queue = [];
try {
await uni.request({
url: this.uploadUrl,
method: 'POST',
data: {
logs,
deviceInfo: this.getDeviceInfo()
}
});
} catch (e) {
// 上传失败,重新加入队列
this.queue.unshift(...logs);
console.error('日志上传失败:', e);
}
}
getPlatform() {
// #ifdef APP-PLUS
return plus.os.name.toLowerCase();
// #endif
// #ifdef H5
return 'h5';
// #endif
// #ifdef MP-WEIXIN
return 'weixin';
// #endif
return 'unknown';
}
getAppVersion() {
// #ifdef APP-PLUS
return plus.runtime.version;
// #endif
return '1.0.0';
}
getUserId() {
return uni.getStorageSync('userId') || '';
}
getDeviceId() {
let deviceId = uni.getStorageSync('deviceId');
if (!deviceId) {
deviceId = this.generateDeviceId();
uni.setStorageSync('deviceId', deviceId);
}
return deviceId;
}
getCurrentPage() {
// #ifdef APP-PLUS
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
// #endif
return '';
}
getDeviceInfo() {
return uni.getSystemInfoSync();
}
generateDeviceId() {
return 'device_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
}
export default new UnifiedLogger();
3. Flutter 日志
// lib/utils/unified_logger.dart
class UnifiedLogger {
static final UnifiedLogger _instance = UnifiedLogger._internal();
factory UnifiedLogger() => _instance;
UnifiedLogger._internal();
List<Map<String, dynamic>> _queue = [];
final int _maxQueue = 50;
final String _uploadUrl = 'https://log.example.com/collect';
void log(String level, String message, [Map<String, dynamic>? extra]) {
final logData = {
'level': level,
'message': message,
'timestamp': DateTime.now().millisecondsSinceEpoch,
'platform': Platform.operatingSystem,
'appVersion': '1.0.0',
'userId': _getUserId(),
'deviceId': _getDeviceId(),
'page': _getCurrentPage(),
'extra': extra ?? {},
};
print('[$level] $message ${extra ?? ""}');
_queue.add(logData);
if (_queue.length >= _maxQueue || level == 'error') {
flush();
}
}
void debug(String message, [Map<String, dynamic>? extra]) {
log('debug', message, extra);
}
void info(String message, [Map<String, dynamic>? extra]) {
log('info', message, extra);
}
void warn(String message, [Map<String, dynamic>? extra]) {
log('warn', message, extra);
}
void error(String message, [Map<String, dynamic>? extra]) {
log('error', message, extra);
}
Future<void> flush() async {
if (_queue.isEmpty) return;
final logs = List.from(_queue);
_queue.clear();
try {
await http.post(
Uri.parse(_uploadUrl),
body: jsonEncode({'logs': logs}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
_queue.insertAll(0, logs);
print('日志上传失败: $e');
}
}
String _getUserId() {
// 从本地存储获取
return '';
}
String _getDeviceId() {
// 从本地存储获取
return '';
}
String _getCurrentPage() {
// 获取当前路由
return '';
}
}
4. Electron 日志
// renderer/logger.js
class ElectronLogger {
constructor() {
this.queue = [];
this.maxQueue = 50;
this.uploadUrl = 'https://log.example.com/collect';
// 监听主进程日志
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('main-process-log', (event, log) => {
this.queue.push(log);
});
}
}
log(level, message, extra = {}) {
const logData = {
level,
message,
timestamp: Date.now(),
platform: 'electron',
appVersion: this.getAppVersion(),
userId: this.getUserId(),
deviceId: this.getDeviceId(),
page: window.location.pathname,
extra
};
console[level](message, extra);
this.queue.push(logData);
if (this.queue.length >= this.maxQueue || level === 'error') {
this.flush();
}
}
debug(message, extra) {
this.log('debug', message, extra);
}
info(message, extra) {
this.log('info', message, extra);
}
warn(message, extra) {
this.log('warn', message, extra);
}
error(message, extra) {
this.log('error', message, extra);
}
async flush() {
if (this.queue.length === 0) return;
const logs = [...this.queue];
this.queue = [];
try {
await fetch(this.uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ logs })
});
} catch (e) {
this.queue.unshift(...logs);
console.error('日志上传失败:', e);
}
}
getAppVersion() {
if (window.require) {
const { app } = window.require('@electron/remote');
return app.getVersion();
}
return '1.0.0';
}
getUserId() {
return localStorage.getItem('userId') || '';
}
getDeviceId() {
let deviceId = localStorage.getItem('deviceId');
if (!deviceId) {
deviceId = 'device_' + Date.now();
localStorage.setItem('deviceId', deviceId);
}
return deviceId;
}
}
export default new ElectronLogger();
5. 服务端日志收集
// server/log-collector.js
const express = require('express');
const router = express.Router();
const { Kafka } = require('kafkajs');
const kafka = new Kafka({
brokers: ['localhost:9092']
});
const producer = kafka.producer();
// 收集日志
router.post('/collect', async (req, res) => {
const { logs } = req.body;
if (!logs || !Array.isArray(logs)) {
return res.status(400).json({ error: 'Invalid logs' });
}
try {
// 写入 Kafka
await producer.send({
topic: 'app-logs',
messages: logs.map(log => ({
key: log.userId,
value: JSON.stringify(log)
}))
});
res.json({ success: true });
} catch (error) {
console.error('写入日志失败:', error);
res.status(500).json({ error: 'Failed to save logs' });
}
});
module.exports = router;
面试话术
面试官问:跨端日志是怎么统一收集的?
我们设计了一套统一的日志格式和收集方案,三端都遵循这个规范。
首先定义了标准的日志格式,包含日志级别、消息、时间戳、平台、App 版本、用户 ID、设备 ID、当前页面等字段。这样不管是 uni-app、Flutter 还是 Electron 上报的日志,都有统一的结构,方便后续分析。
然后在每个平台实现了 UnifiedLogger,API 完全一致,都是 debug、info、warn、error 四个方法。业务代码调用方式完全相同,不用关心底层实现。
日志收集采用批量上传策略。客户端会缓存最近 50 条日志,达到阈值或者有 error 级别日志时才批量上传,减少网络请求。如果上传失败,会重新加入队列,下次再试。
服务端我们用了 Kafka 做日志收集,吞吐量大,可以承受高并发。日志写入 Kafka 后,再用 Logstash 消费,写入 Elasticsearch,最后通过 Kibana 做可视化分析和告警。
这套方案有几个好处:
一是统一,不管哪个平台的日志都在一个地方查,不用切换系统。
二是实时,日志上报后几秒钟就能在 Kibana 看到,排查问题很快。
三是可追溯,每条日志都有用户 ID 和设备 ID,可以追踪用户的完整操作路径。
四是可扩展,后面要接入新平台,只需要按照日志格式实现一个 Logger 就行。
目前我们每天收集约 500 万条日志,通过日志分析发现了很多隐藏的问题,对提升产品质量帮助很大。