返回笔记首页

跨端开发核心技术细节

主题配置

一、离线缓存机制

业务场景

用户在地铁、电梯等弱网环境下,需要快速打开应用并浏览之前访问过的内容。例如新闻类 App 需要离线阅读,电商 App 需要缓存商品列表。

技术方案

1. 分层缓存架构

plain
接口数据层
    ↓
业务缓存层(LRU)
    ↓
持久化存储层(SQLite / IndexedDB)

2. uni-app 实现

javascript
// 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 实现

dart
// 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. 缓存策略

javascript
// 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 优化
javascript
// 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 优化
dart
// 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 优化
javascript
// 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. 热启动优化

缓存首页数据
javascript
// 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;
    }
  }
}
预渲染首屏
javascript
// 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';
  });
});

性能指标

javascript
// 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. 离线包结构

markdown
offline-package.zip
├── manifest.json      # 版本信息
├── index.html
├── static/
│   ├── js/
│   │   └── app.js
│   └── css/
│       └── style.css
└── pages/
    ├── home.html
    └── detail.html

2. manifest.json

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)

javascript
// 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 加载离线包

vue
<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. 差量更新

javascript
// 差量更新管理
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 协议设计

javascript
// 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 核心实现

javascript
// 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 封装

javascript
// 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)

swift
// 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)

java
// 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 实现

javascript
// 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. 异常捕获

javascript
// 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 异常捕获

dart
// 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. 日志系统

javascript
// 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. 性能监控

javascript
// 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 包)

javascript
// 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 热更新(代码推送)

dart
// 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 热更新

javascript
// 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. 热更新服务端

javascript
// 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. 日志格式定义

javascript
// 统一日志格式
{
  "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)

javascript
// 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 日志

dart
// 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 日志

javascript
// 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. 服务端日志收集

javascript
// 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 万条日志,通过日志分析发现了很多隐藏的问题,对提升产品质量帮助很大。