构建基于SwiftUI、Lit与Ruby的混合桌面应用双向通信桥


一个常见的工程困境:我们有一个成熟的macOS原生应用,基于SwiftUI构建,但其中某个模块,比如一个复杂的配置面板或者一个插件市场,需要非常频繁地进行UI迭代和逻辑更新。每次为了一点文案或样式调整就重新编译、打包、发布整个原生应用,不仅流程冗长,也极大地拖慢了业务响应速度。

初步构想是引入Web技术。通过在SwiftUI中嵌入一个WKWebView来承载这部分动态UI。这个决策本身并不新鲜,但魔鬼在于细节。我们需要一个轻量、高效且与原生环境无缝协作的前端方案,以及一个稳定可靠的本地服务和通信机制。

技术选型决策

  1. 前端组件:为什么是Lit?
    我们排除了React或Vue这类重型框架。对于一个嵌入式的UI模块,引入完整的框架运行时和复杂的构建工具链显得过于臃肿。Lit基于原生Web Components标准,它几乎没有运行时开销,产物体积极小,且天生具备样式隔离(Shadow DOM)和跨框架使用的能力。这使得它成为构建独立、可嵌入UI组件的理想选择。

  2. 本地服务:为什么是Ruby Sinatra?
    Web内容需要被一个服务承载。虽然可以直接从文件系统加载file://协议的资源,但这会带来跨域问题、API请求模拟困难等一系列麻烦。一个本地HTTP服务是更干净的方案。Node.js是一个选项,但这意味着项目中需要维护node_modules和npm/yarn生态。对于主要由原生和Ruby工程师组成的团队,引入一个轻量级的Ruby Web框架——Sinatra,更为契合。它启动快,依赖少,编写一个简单的API和静态文件服务器仅需几十行代码,并且可以轻松地与应用的生命周期绑定。

  3. 原生容器:SwiftUI与WKWebView
    这是既定选择。挑战在于如何构建一个健壮、可维护、双向的通信桥梁,连接SwiftUI的原生世界和运行在WKWebView中由Lit构建的Web世界。

架构与通信流程设计

我们的目标是建立一个响应式的双向通信通道。

  • Web -> Native: Lit组件中的用户操作(如点击保存按钮)需要调用Swift的原生能力(如写入UserDefaults或操作文件)。这通过window.webkit.messageHandlers实现。
  • Native -> Web: 原生应用的状态变化(如主题切换、外部事件)需要通知Web UI进行更新。这通过WKWebViewevaluateJavaScript方法实现。

为了保证通信的可靠性和可扩展性,我们定义一个统一的JSON-RPC风格消息格式:

{
  "id": "unique-message-id-timestamp",
  "action": "action-name",
  "payload": { ... }
}
sequenceDiagram
    participant Lit as Lit Component (in WKWebView)
    participant Sinatra as Local Ruby Server
    participant SwiftUI as SwiftUI Host (WKWebView)
    participant Native as Native Swift Logic

    Lit->>Sinatra: HTTP GET /api/config (Initial Load)
    Sinatra-->>Lit: JSON { initial data }
    Lit->>Lit: Render UI with data

    Note right of Lit: User clicks "Save"
    Lit->>SwiftUI: window.webkit.messageHandlers.bridge.postMessage('{"action":"saveConfig", "payload":{...}}')
    SwiftUI->>Native: didReceive(_ message)
    Native->>Native: Process saveConfig action
    Native->>Native: Writes to UserDefaults
    
    Note left of Native: Some external event occurs
    Native->>SwiftUI: State Changes
    SwiftUI->>Lit: webView.evaluateJavaScript("window.nativeEventHandler.handleEvent('{"action":"configUpdated", "payload":{...}}')")
    Lit->>Lit: Update UI with new data

步骤化实现

1. 本地API服务:Sinatra

我们需要一个简单的Sinatra应用,它能提供一个API端点和一个静态文件服务来承载Lit应用。

项目结构:

/hybrid-server
  - Gemfile
  - app.rb
  - /public
    - index.html
    - assets/
      - main.js

Gemfile

# Gemfile
source 'https://rubygems.org'

gem 'sinatra'
gem 'sinatra-contrib' # For JSON helper

运行bundle install安装依赖。

app.rb

# app.rb

require 'sinatra'
require 'sinatra/json'
require 'json'

# --- Configuration ---
# Bind to localhost to avoid network access prompts on macOS
set :bind, '127.0.0.1'
set :port, 4567 # A fixed port for the app to connect to
set :public_folder, File.dirname(__FILE__) + '/public'

# --- Logging ---
# In a real project, use a proper logger. For this example, stdout is fine.
$stdout.sync = true
puts "Sinatra server starting up..."

# --- Mock Database ---
# In a real app, this would interact with SQLite or UserDefaults via the native bridge.
# Here, we simulate it with a JSON file for simplicity.
CONFIG_DB_PATH = File.join(File.dirname(__FILE__), 'config.json')

def read_config
  return { theme: 'light', notifications: false } unless File.exist?(CONFIG_DB_PATH)
  JSON.parse(File.read(CONFIG_DB_PATH), symbolize_names: true)
rescue JSON::ParserError => e
  puts "[ERROR] Failed to parse config.json: #{e.message}"
  { theme: 'light', notifications: false }
end

def write_config(new_config)
  File.write(CONFIG_DB_PATH, JSON.pretty_generate(new_config))
rescue => e
  puts "[ERROR] Failed to write config.json: #{e.message}"
  false
end


# --- API Routes ---

# GET /api/config - Fetches the current configuration
get '/api/config' do
  content_type :json
  json read_config
end

# POST /api/config - Updates the configuration
post '/api/config' do
  content_type :json
  begin
    request.body.rewind
    data = JSON.parse(request.body.read, symbolize_names: true)
    
    # Basic validation
    unless data.key?(:theme) && data.key?(:notifications)
      status 400
      return json error: 'Invalid payload. Missing theme or notifications key.'
    end

    current_config = read_config
    updated_config = current_config.merge(data)
    
    if write_config(updated_config)
      json updated_config
    else
      status 500
      json error: 'Failed to save configuration.'
    end

  rescue JSON::ParserError
    status 400
    json error: 'Invalid JSON payload.'
  end
end

# --- Static File Server ---
# Serves the main HTML file for the Lit app
get '/' do
  send_file File.join(settings.public_folder, 'index.html')
end

# --- Cleanup ---
at_exit do
  puts "Sinatra server shutting down."
end

这个服务启动在localhost:4567,提供了获取和更新配置的API,并托管了public目录下的前端资源。

2. 前端UI模块:Lit

我们使用TypeScript和Lit来构建一个简单的设置面板。

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Settings Panel</title>
  <script type="module" src="/assets/main.js"></script>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background-color: transparent; }
  </style>
</head>
<body>
  <settings-panel></settings-panel>
</body>
</html>

src/settings-panel.ts (编译后放入 public/assets/main.js)

import { LitElement, html, css, property } from 'lit';
import { customElement } from 'lit/decorators.js';

// --- Type Definitions for Communication Bridge ---
interface NativeMessage<T> {
  action: string;
  payload: T;
}

interface AppConfig {
  theme: 'light' | 'dark';
  notifications: boolean;
}

// --- The Lit Component ---
@customElement('settings-panel')
export class SettingsPanel extends LitElement {
  
  @property({ type: Object })
  config: AppConfig = { theme: 'light', notifications: false };

  @property({ type: Boolean })
  isLoading = true;

  @property({ type: String })
  errorMessage = '';

  static styles = css`
    /* Component styles omitted for brevity */
  `;

  constructor() {
    super();
    // Register global event handler for messages from Swift
    (window as any).nativeEventHandler = {
      handleEvent: this.handleNativeEvent.bind(this)
    };
  }
  
  async connectedCallback() {
    super.connectedCallback();
    await this.fetchConfig();
  }

  private async fetchConfig() {
    this.isLoading = true;
    this.errorMessage = '';
    try {
      // Fetches initial data from the local Sinatra server
      const response = await fetch('/api/config');
      if (!response.ok) {
        throw new Error(`Server responded with status: ${response.status}`);
      }
      this.config = await response.json();
    } catch (error) {
      console.error('Failed to fetch config:', error);
      this.errorMessage = 'Could not load settings. Please try again.';
    } finally {
      this.isLoading = false;
    }
  }

  private handleThemeChange(e: Event) {
    const newTheme = (e.target as HTMLSelectElement).value as AppConfig['theme'];
    this.config = { ...this.config, theme: newTheme };
  }

  private handleNotificationChange(e: Event) {
    const enabled = (e.target as HTMLInputElement).checked;
    this.config = { ...this.config, notifications: enabled };
  }

  /**
   * Sends a message to the native Swift host.
   * This is the core of the Web -> Native communication.
   */
  private postMessageToNative<T>(action: string, payload: T) {
    // Check if the message handler exists. It's only available within the WKWebView context.
    if (window.webkit?.messageHandlers?.bridge) {
      const message: NativeMessage<T> = { action, payload };
      window.webkit.messageHandlers.bridge.postMessage(message);
      console.log('Posted message to native:', message);
    } else {
      console.warn('Native message handler "bridge" not found. Running in a standard browser?');
      // Fallback for browser-based development: POST to Sinatra
      this.saveConfigViaHttp();
    }
  }

  // Example of a native-only action
  private handleOpenFile() {
    this.postMessageToNative('openLogFile', { path: '/Users/Shared/app/debug.log' });
  }

  // Example of a data-saving action
  private handleSave() {
    this.postMessageToNative('saveConfig', this.config);
  }

  // Development fallback
  private async saveConfigViaHttp() {
    // Simulates saving via HTTP for browser testing
    await fetch('/api/config', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(this.config)
    });
  }

  /**
   * Handles incoming events pushed from the native Swift side.
   * This is the core of the Native -> Web communication.
   */
  public handleNativeEvent(messageString: string) {
    try {
      const message: NativeMessage<any> = JSON.parse(messageString);
      console.log('Received message from native:', message);

      switch (message.action) {
        case 'configUpdated':
          this.config = message.payload as AppConfig;
          // You might want to show a subtle "updated" confirmation
          break;
        case 'systemAppearanceChanged':
          // Could dynamically adapt UI even without a full config reload
          console.log(`System appearance changed to ${message.payload.theme}`);
          break;
        default:
          console.warn(`Unknown native event action: ${message.action}`);
      }
    } catch (error) {
      console.error('Failed to parse message from native:', error);
    }
  }
  
  render() {
    // Render logic omitted for brevity, includes form elements bound to properties
    // and buttons that call handleSave() and handleOpenFile()
    return html`<!-- ... form UI ... -->`;
  }
}

3. 原生容器与桥接:SwiftUI

现在,我们在SwiftUI应用中集成这一切。

ServerManager.swift - 管理Sinatra进程
在真实项目中,我们会将Ruby环境和脚本打包到App Bundle中。这里为了演示,我们假设脚本在开发目录。

import Foundation

class ServerManager {
    static let shared = ServerManager()
    private var serverProcess: Process?
    private let port = 4567
    let serverURL = URL(string: "http://127.0.0.1:4567")!

    private init() {}

    func startServer(completion: @escaping (Bool) -> Void) {
        // In a real app, the path to the ruby executable and script
        // should be determined dynamically from the app bundle.
        let rubyPath = "/usr/bin/ruby" // Or a bundled Ruby path
        let scriptPath = "/path/to/your/hybrid-server/app.rb"

        guard FileManager.default.fileExists(atPath: scriptPath) else {
            print("[ERROR] Server script not found at \(scriptPath)")
            completion(false)
            return
        }

        serverProcess = Process()
        serverProcess?.executableURL = URL(fileURLWithPath: rubyPath)
        serverProcess?.arguments = [scriptPath]
        
        // Ensure the server process is terminated when the app exits
        serverProcess?.terminationHandler = { process in
            print("Server process terminated.")
        }

        do {
            try serverProcess?.run()
            print("Server process started with PID: \(serverProcess?.processIdentifier ?? -1)")
            
            // Simple health check
            DispatchQueue.global().async {
                self.waitForServer(completion: completion)
            }
        } catch {
            print("[ERROR] Failed to start server: \(error)")
            completion(false)
        }
    }

    func stopServer() {
        serverProcess?.terminate()
        serverProcess = nil
    }

    private func waitForServer(completion: @escaping (Bool) -> Void) {
        // A simple retry mechanism to check if the server is up
        let maxRetries = 5
        var retries = 0
        
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
            let task = URLSession.shared.dataTask(with: self.serverURL) { _, response, _ in
                if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
                    timer.invalidate()
                    DispatchQueue.main.async { completion(true) }
                }
            }
            
            task.resume()
            retries += 1
            
            if retries >= maxRetries {
                timer.invalidate()
                print("[ERROR] Server did not become available in time.")
                DispatchQueue.main.async { completion(false) }
            }
        }
    }
}

WebView.swift - WKWebView的SwiftUI封装和桥接逻辑

import SwiftUI
import WebKit

struct WebView: NSViewRepresentable {
    
    let url: URL
    private let webView = WKWebView()

    func makeNSView(context: Context) -> WKWebView {
        let configuration = WKWebViewConfiguration()
        let userContentController = WKUserContentController()

        // Register the message handler named "bridge"
        // This name must match the one used in JavaScript: window.webkit.messageHandlers.bridge
        userContentController.add(context.coordinator, name: "bridge")
        configuration.userContentController = userContentController

        // For local development, disable caches
        configuration.websiteDataStore = .nonPersistent()
        
        let webView = WKWebView(frame: .zero, configuration: configuration)
        webView.navigationDelegate = context.coordinator
        
        // Allow transparent backgrounds
        webView.setValue(false, forKey: "drawsBackground")
        
        return webView
    }

    func updateNSView(_ nsView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        nsView.load(request)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    // Public method to send messages to the web view
    func sendMessageToWeb(action: String, payload: [String: Any]) {
        do {
            let messageData = try JSONSerialization.data(withJSONObject: ["action": action, "payload": payload], options: [])
            guard let messageString = String(data: messageData, encoding: .utf8) else { return }
            
            // Important: Escape the string for JavaScript execution
            let escapedString = messageString.replacingOccurrences(of: "'", with: "\\'")
            let script = "window.nativeEventHandler.handleEvent('\(escapedString)')"
            
            DispatchQueue.main.async {
                webView.evaluateJavaScript(script) { result, error in
                    if let error = error {
                        print("[ERROR] JavaScript evaluation failed: \(error)")
                    }
                }
            }
        } catch {
            print("[ERROR] Failed to serialize message payload: \(error)")
        }
    }

    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        var parent: WebView

        init(_ parent: WebView) {
            self.parent = parent
        }

        // Delegate method for receiving messages from JavaScript
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            guard message.name == "bridge" else { return }
            
            guard let body = message.body as? [String: Any],
                  let action = body["action"] as? String,
                  let payload = body["payload"] as? [String: Any]
            else {
                print("[ERROR] Received invalid message format from web.")
                return
            }
            
            print("Received action from web: \(action)")
            
            // --- Action Router ---
            // This is where native logic is triggered based on web events
            switch action {
            case "saveConfig":
                if let theme = payload["theme"] as? String,
                   let notifications = payload["notifications"] as? Bool {
                    // In a real app, this would update a ViewModel or call a service
                    UserDefaults.standard.set(theme, forKey: "appTheme")
                    UserDefaults.standard.set(notifications, forKey: "appNotifications")
                    print("Saved config to UserDefaults: theme=\(theme), notifications=\(notifications)")
                    
                    // Acknowledge back to the web view
                    parent.sendMessageToWeb(action: "configUpdated", payload: payload)
                }
            case "openLogFile":
                 // Example of calling a system API
                 if let path = payload["path"] as? String {
                    NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
                 }
            default:
                print("[WARN] Received unhandled action: \(action)")
            }
        }
        
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("Web view finished loading.")
        }
    }
}

ContentView.swift - 组装所有部分

import SwiftUI

struct ContentView: View {
    @State private var isServerReady = false
    @State private var serverError = false
    
    // Create a stable reference to the WebView to call its methods
    private var webView = WebView(url: ServerManager.shared.serverURL)

    var body: some View {
        VStack {
            if isServerReady {
                VStack {
                    webView
                    // Example of Native -> Web communication trigger
                    Button("Force Theme to Dark from Native") {
                        let payload = ["theme": "dark", "notifications": true]
                        webView.sendMessageToWeb(action: "configUpdated", payload: payload)
                    }
                    .padding()
                }
            } else if serverError {
                Text("Failed to start local server. Please restart the application.")
                    .foregroundColor(.red)
            } else {
                ProgressView("Starting local service...")
            }
        }
        .frame(width: 400, height: 500)
        .onAppear(perform: start)
        .onDisappear(perform: stop)
    }

    private func start() {
        ServerManager.shared.startServer { success in
            if success {
                isServerReady = true
            } else {
                serverError = true
            }
        }
    }

    private func stop() {
        ServerManager.shared.stopServer()
    }
}

局限性与未来迭代路径

这套架构解决了核心问题,但在生产环境中,还有几个方面需要加固:

  1. 通信的类型安全: 目前基于JSON字符串和字典的通信是脆弱的。任何一端的拼写错误或数据结构变更都可能导致运行时失败。一个改进方向是使用JSON Schema进行验证,或者通过代码生成工具(如protobuf或自定义脚本)为Swift和TypeScript自动生成类型安全的编解码代码和接口定义,从而在编译期捕获错误。

  2. 打包与分发: 将Ruby运行时、Gems和Sinatra脚本可靠地打包进.app bundle是一个挑战。traveling-ruby是一个解决方案,但会增大包体积。另一种方法是依赖系统预装的Ruby,但这会带来版本兼容性问题。一个更稳健的方案可能是在App首次启动时,通过脚本检查并安装一个私有的、版本固定的Ruby环境到应用的Application Support目录。

  3. 安全性: 本地服务器虽然绑定在127.0.0.1,但仍需考虑端口冲突和潜在的本地恶意软件扫描。应使用随机端口或确保端口的独占性。同时,WKWebView的配置应更加严格,限制其只能加载来自本地服务的内容,并禁用不必要的Web API来减小攻击面。

  4. 性能: 对于高频通信场景,JSON序列化/反序列化的开销可能会成为瓶颈。虽然对于设置面板这类低交互场景足够,但对于需要实时数据同步的模块,可能需要探索性能更高的二进制序列化格式。


  目录