构建一个由SwiftUI原生Shell承载、Rollup打包并由FastAPI提供服务的Web微应用混合架构


团队面临一个典型的两难困境:iOS应用的功能迭代速度,总是被苹果的审核周期拖累。一些频繁变更的活动页面、信息展示模块,每次更新都意味着一次完整的App提交、审核、发布流程,周期长达数天甚至数周。这在快速响应市场需求的背景下,几乎是不可接受的。我们需要一种能绕开App Store审核,实现动态、快速更新非核心业务模块的方案。

初步构想是回归混合开发,但必须规避传统Hybrid方案的几个核心弊病:体验差、性能低、与原生系统脱节。我们的目标不是用Web完全替代原生,而是构建一个“原生为骨,Web为肉”的架构。具体来说,就是一个轻量级的SwiftUI原生应用作为“Shell(壳)”,负责提供核心的用户认证、设备能力、主题样式等底层服务。而那些易变的业务功能,则作为独立的“Web微应用”,由后端动态下发,在原生Shell提供的安全沙箱(WKWebView)中运行。

这个架构的核心组件如下:

  1. SwiftUI 原生Shell: 提供应用的主体框架、导航、以及底层能力接口。
  2. FastAPI 服务端: 负责托管打包后的Web微应用静态资源,并提供业务数据API。
  3. Web 微应用: 使用原生JavaScript、CSS开发的独立功能单元。
  4. Rollup 构建工具: 将每个Web微应用打包成精简、独立的JS和CSS文件。
  5. 原生-Web通信桥: 一套定义清晰的消息协议,用于SwiftUI和JavaScript之间的双向通信。
  6. 动态样式注入: 一种从原生层向Web层注入样式变量的机制,确保视觉风格统一。

整个系统的运行流程可以用下面的图来概括:

sequenceDiagram
    participant User
    participant SwiftUI_Shell as SwiftUI Shell
    participant WKWebView
    participant FastAPI_Server as FastAPI Server
    participant Rollup_Build as Rollup (构建时)

    User->>SwiftUI_Shell: 启动App或点击功能入口
    SwiftUI_Shell->>FastAPI_Server: 请求微应用HTML (e.g., /micro-apps/profile)
    FastAPI_Server-->>SwiftUI_Shell: 返回HTML骨架
    SwiftUI_Shell->>WKWebView: 加载HTML内容
    WKWebView->>FastAPI_Server: 请求JS/CSS资源 (e.g., /static/profile.js)

    Note right of Rollup_Build: 开发阶段
    Rollup_Build->>FastAPI_Server: 将源码打包成静态资源

    FastAPI_Server-->>WKWebView: 返回打包后的静态资源
    WKWebView->>SwiftUI_Shell: 渲染完成, 通过JSBridge通知原生
    SwiftUI_Shell->>WKWebView: (可选) 注入动态样式 (CSS变量)
    
    loop 双向通信
        WKWebView->>SwiftUI_Shell: JS调用原生能力 (e.g., 获取Token)
        SwiftUI_Shell-->>WKWebView: 原生处理后回调JS
        SwiftUI_Shell->>WKWebView: 原生主动推送消息给Web
        WKWebView-->>SwiftUI_Shell: JS响应原生消息
    end

技术选型决策很简单,也非常务实。SwiftUI是苹果官方的现代UI框架,与系统集成度最高。FastAPI基于Python的Starlette和Pydantic,开发效率和运行性能都极为出色,对于我们这种以IO密集型为主的场景绰绰有余。选择Rollup而非Webpack,是因为我们的微应用足够简单,不需要Webpack庞大的生态和复杂的配置,Rollup的ESM原生支持和更简洁的输出,非常适合这种“库”级别的打包需求。

第一步: 搭建FastAPI服务端

服务端的职责有两个:一是作为静态文件服务器,托管Rollup打包好的微应用;二是作为API服务器,为微应用提供数据。

在真实项目中,静态资源通常会放在CDN上,但为了简化模型,这里我们让FastAPI一并处理。

项目结构:

hybrid_server/
├── micro_apps_dist/      # Rollup打包产物存放处
│   ├── profile/
│   │   ├── bundle.js
│   │   └── style.css
│   └── dashboard/
│       ├── bundle.js
│       └── style.css
├── micro_apps_src/       # 微应用源码
│   └── ...
├── templates/            # HTML模板
│   └── app_loader.html
├── main.py               # FastAPI应用主文件
└── requirements.txt

main.py 的实现:

这个文件是整个后端的入口。我们使用Jinja2Templates来渲染一个通用的HTML加载器,并通过StaticFiles来挂载打包后的资源目录。

# main.py
import uvicorn
import logging
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from typing import Optional

# --- 日志配置 ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# --- 应用实例 ---
app = FastAPI(title="Hybrid Micro-App Server")

# --- 挂载静态文件目录 ---
# 这里的路径 `/static` 将映射到服务器的 `micro_apps_dist` 目录
app.mount("/static", StaticFiles(directory="micro_apps_dist"), name="static")

# --- 模板引擎配置 ---
templates = Jinja2Templates(directory="templates")

# --- 数据模型 ---
class UserProfile(BaseModel):
    userId: str
    username: str
    email: str
    memberSince: str

# --- 模拟数据库 ---
FAKE_DB = {
    "user-123": UserProfile(
        userId="user-123",
        username="Alice",
        email="[email protected]",
        memberSince="2023-01-15"
    )
}

# --- 路由定义 ---

@app.get("/micro-apps/{app_name}", response_class=HTMLResponse)
async def get_micro_app_loader(request: Request, app_name: str):
    """
    提供微应用的HTML加载器骨架。
    这个HTML非常轻量,只负责引入对应的JS和CSS。
    """
    logger.info(f"Serving loader for micro-app: {app_name}")
    # 在真实项目中,这里会检查app_name是否存在,防止路径遍历等安全问题
    # 这里我们简化处理
    return templates.TemplateResponse(
        "app_loader.html",
        {
            "request": request,
            "app_name": app_name
        }
    )

@app.get("/api/v1/profile/{user_id}", response_model=UserProfile)
async def get_user_profile(user_id: str):
    """
    为 'profile' 微应用提供数据API。
    """
    logger.info(f"Fetching profile for user: {user_id}")
    profile = FAKE_DB.get(user_id)
    if not profile:
        logger.warning(f"User profile not found for ID: {user_id}")
        raise HTTPException(status_code=404, detail="User not found")
    return profile

# --- 应用启动入口 ---
if __name__ == "__main__":
    # 在生产环境中,应该使用Gunicorn等WSGI服务器来运行
    uvicorn.run(app, host="0.0.0.0", port=8000)

templates/app_loader.html 模板:

这是一个通用模板,通过传入的app_name动态加载对应的资源。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>Micro-App Loader</title>
    <!-- 动态加载对应微应用的CSS -->
    <link rel="stylesheet" href="/static/{{ app_name }}/style.css">
    <style>
        /* 基础重置和原生风格占位符 */
        body {
            margin: 0;
            padding: 16px;
            font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "sans-serif";
            background-color: var(--system-background-color, #ffffff);
            color: var(--system-text-color, #000000);
            -webkit-font-smoothing: antialiased;
        }
    </style>
</head>
<body>
    <div id="root"></div>
    <!-- 动态加载对应微应用的JS -->
    <script src="/static/{{ app_name }}/bundle.js" type="module"></script>
</body>
</html>

第二步: 使用Rollup构建Web微应用

现在我们来创建 profile 微应用,并用Rollup进行打包。

源码结构:

micro_apps_src/
└── profile/
    ├── main.js
    └── style.css
rollup.config.js
package.json

micro_apps_src/profile/main.js:

这个JS文件是微应用的核心逻辑。它包含与原生通信的辅助函数,并在加载后调用API获取数据并渲染UI。

// micro_apps_src/profile/main.js

/**
 * JSBridge: 与Swift原生代码通信的核心辅助模块。
 * 这里的实现是一个常见的模式:通过 window.webkit.messageHandlers 发送消息。
 */
const JSBridge = {
    /**
     * 调用原生方法
     * @param {string} action - 约定的操作名
     * @param {object} payload - 传递给原生的数据
     * @returns {Promise<any>} - 返回一个Promise,原生代码通过回调解决它
     */
    invokeNative: (action, payload = {}) => {
        return new Promise((resolve, reject) => {
            const callbackId = `cb_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
            
            // 存储回调,等待原生调用
            window[callbackId] = (error, data) => {
                if (error) {
                    console.error(`Native call failed for action '${action}':`, error);
                    reject(new Error(error.message || 'Unknown native error'));
                } else {
                    resolve(data);
                }
                // 清理回调,防止内存泄漏
                delete window[callbackId];
            };

            const message = {
                action,
                payload,
                callbackId,
            };

            // 检查 messageHandlers 是否存在,这是与 WKWebView 通信的关键
            if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge) {
                window.webkit.messageHandlers.bridge.postMessage(message);
            } else {
                // 异常处理:在非WKWebView环境(如桌面浏览器)中调试时提供降级方案
                console.warn('Native bridge is not available. Simulating success for action:', action);
                // 模拟一个成功的回调
                setTimeout(() => window[callbackId](null, { simulated: true, action }), 50);
            }
        });
    }
};

/**
 * 渲染用户信息的UI
 * @param {object} profileData - 从API获取的用户数据
 */
function renderProfile(profileData) {
    const root = document.getElementById('root');
    if (!root) return;

    root.innerHTML = `
        <div class="profile-card">
            <h2>${profileData.username}</h2>
            <p class="email">Email: ${profileData.email}</p>
            <p class="member-since">Member Since: ${profileData.memberSince}</p>
            <button id="show-native-alert">Show Native Alert</button>
        </div>
    `;

    document.getElementById('show-native-alert').addEventListener('click', () => {
        JSBridge.invokeNative('showAlert', {
            title: 'Hello from Web',
            message: `This alert is triggered from the '${profileData.username}' profile micro-app.`
        }).then(response => {
            console.log('Native alert acknowledged:', response);
        });
    });
}

/**
 * 应用主入口函数
 */
async function main() {
    try {
        console.log('Profile micro-app initializing...');
        // 1. 通过JSBridge从原生获取用户ID和API地址
        const config = await JSBridge.invokeNative('getInitialData');
        
        if (!config.userId || !config.apiBaseUrl) {
            throw new Error('Initial data from native is invalid.');
        }

        // 2. 使用获取到的配置调用FastAPI
        const response = await fetch(`${config.apiBaseUrl}/api/v1/profile/${config.userId}`);
        if (!response.ok) {
            throw new Error(`API request failed with status ${response.status}`);
        }
        const profileData = await response.json();

        // 3. 渲染UI
        renderProfile(profileData);

        // 4. 通知原生加载完成
        JSBridge.invokeNative('didFinishLoading');

    } catch (error) {
        console.error('Failed to load profile micro-app:', error);
        document.getElementById('root').innerHTML = `<p class="error">Failed to load profile. Please try again later.</p>`;
        // 5. 通知原生加载失败
        JSBridge.invokeNative('didFailLoading', { error: error.message });
    }
}

// 启动应用
main();

micro_apps_src/profile/style.css:

注意这里使用了CSS自定义属性(变量),这些变量将由SwiftUI侧动态注入。

/* micro_apps_src/profile/style.css */
.profile-card {
    background-color: var(--system-secondary-background-color, #f0f0f0);
    border-radius: 12px;
    padding: 20px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}

h2 {
    color: var(--system-primary-color, #007aff);
    margin-top: 0;
}

.email, .member-since {
    color: var(--system-secondary-text-color, #555);
    font-size: 14px;
}

button {
    margin-top: 16px;
    padding: 10px 15px;
    border: none;
    background-color: var(--system-primary-color, #007aff);
    color: white;
    border-radius: 8px;
    font-size: 16px;
    cursor: pointer;
    width: 100%;
}

.error {
    color: var(--system-destructive-color, red);
}

rollup.config.js:

这个配置文件定义了如何打包 profile 微应用。在真实项目中,这个配置会更复杂,用来处理多个微应用。

// rollup.config.js
import { terser } from 'rollup-plugin-terser';
import styles from 'rollup-plugin-styles';
import { nodeResolve } from '@rollup/plugin-node-resolve';

const isProduction = process.env.NODE_ENV === 'production';

// 在实际项目中,这里会通过一个函数生成多个应用的配置
const appName = 'profile';

export default {
    input: `micro_apps_src/${appName}/main.js`,
    output: {
        file: `micro_apps_dist/${appName}/bundle.js`,
        format: 'es', // 使用 ES Module 格式
        sourcemap: !isProduction,
    },
    plugins: [
        // 处理CSS,将其提取到单独的文件
        styles({
            mode: ['extract', `style.css`],
            minimize: isProduction,
        }),
        // 解析 node_modules 中的依赖 (本例中没有,但最好加上)
        nodeResolve(),
        // 生产环境下压缩JS代码
        isProduction && terser(),
    ],
};

运行 npx rollup -c 即可将源码打包到 micro_apps_dist/profile/ 目录。

第三步: 实现SwiftUI原生Shell与通信桥

这是整个架构的粘合剂。我们需要一个可复用的WebView组件,以及一个负责处理JSBridge消息的Coordinator

WebView.swift:

// WebView.swift
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    let url: URL
    
    // 使用一个ViewModel来处理所有与WebView的交互逻辑
    @StateObject private var viewModel: WebViewModel

    init(url: URL, config: WebViewConfig) {
        self.url = url
        self._viewModel = StateObject(wrappedValue: WebViewModel(config: config))
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = viewModel.getWebView()
        // 将 Coordinator 设置为 message handler
        webView.navigationDelegate = context.coordinator
        
        // 发起网络请求
        let request = URLRequest(url: url)
        webView.load(request)
        
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        // 在URL变化时可以重新加载,但在这个架构中我们通常保持URL不变
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(viewModel: viewModel)
    }

    // Coordinator 负责处理 WKNavigationDelegate 和 WKScriptMessageHandler 的回调
    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        private var viewModel: WebViewModel

        init(viewModel: WebViewModel) {
            self.viewModel = viewModel
        }

        // --- WKScriptMessageHandler ---
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            // 只处理我们约定的 "bridge" handler
            guard message.name == "bridge" else { return }
            
            // 将消息转发给ViewModel处理
            viewModel.handleJSMessage(message.body)
        }
        
        // --- WKNavigationDelegate ---
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("[WebView] Page finished loading.")
            // 页面加载完成后,注入动态样式
            viewModel.injectDynamicStyles(into: webView)
        }

        func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
            print("[WebView] Failed to load page: \(error.localizedDescription)")
            // 这里可以处理加载失败的UI逻辑
        }
    }
}

// 传递给ViewModel的配置
struct WebViewConfig {
    let apiBaseUrl: String
    let userId: String
    // 其他需要传递给Web的初始数据
}

WebViewModel.swift:

这是逻辑核心,它负责创建和配置WKWebView,处理来自JS的消息,并向JS发送数据。

// WebViewModel.swift
import WebKit

@MainActor
class WebViewModel: ObservableObject {
    private let config: WebViewConfig
    private var webView: WKWebView!

    init(config: WebViewConfig) {
        self.config = config
        self.webView = createWebView()
    }
    
    func getWebView() -> WKWebView {
        return self.webView
    }

    private func createWebView() -> WKWebView {
        let configuration = WKWebViewConfiguration()
        let userContentController = WKUserContentController()

        // 注册JSBridge,这里的 "bridge" 必须和JS中的 messageHandlers.bridge 名称一致
        // Coordinator 将作为 self 传入 userContentController
        userContentController.add(self.makeCoordinator(), name: "bridge")
        configuration.userContentController = userContentController
        
        // 允许内联播放视频等设置
        configuration.allowsInlineMediaPlayback = true
        
        let webView = WKWebView(frame: .zero, configuration: configuration)
        webView.isOpaque = false
        webView.backgroundColor = .clear // 背景透明,使其能融入原生UI
        
        #if DEBUG
        // 允许在Safari中进行调试
        webView.isInspectable = true
        #endif

        return webView
    }
    
    // 这只是为了满足 Coordinator 的初始化,实际的 Coordinator 应该在 WebView 中创建
    private func makeCoordinator() -> WebView.Coordinator {
        return WebView.Coordinator(viewModel: self)
    }

    // 处理来自JS的消息
    func handleJSMessage(_ body: Any) {
        // 1. 解析消息,一个常见的坑是JS传来的可能是NSDictionary而非[String: Any]
        guard let dict = body as? [String: Any],
              let action = dict["action"] as? String,
              let callbackId = dict["callbackId"] as? String else {
            print("[JSBridge] Invalid message format received.")
            return
        }
        
        let payload = dict["payload"] as? [String: Any] ?? [:]
        
        print("[JSBridge] Received action: \(action) with callbackId: \(callbackId)")

        // 2. 根据action执行不同的原生逻辑
        switch action {
        case "getInitialData":
            // 返回初始化Web应用所需的数据
            let initialData: [String: Any] = [
                "apiBaseUrl": config.apiBaseUrl,
                "userId": config.userId
            ]
            resolveJsCallback(callbackId: callbackId, data: initialData)
            
        case "showAlert":
            // 调用原生UI能力
            let title = payload["title"] as? String ?? "Alert"
            let message = payload["message"] as? String ?? ""
            // 这里应该调用一个UI管理器来显示弹窗,为简化直接打印
            print("--- NATIVE ALERT ---")
            print("Title: \(title)")
            print("Message: \(message)")
            print("--------------------")
            resolveJsCallback(callbackId: callbackId, data: ["acknowledged": true])

        case "didFinishLoading":
            print("[MicroApp] Successfully loaded.")
            // 可以在此更新原生UI状态,例如隐藏加载指示器
            
        case "didFailLoading":
            if let error = payload["error"] as? String {
                print("[MicroApp] Failed to load with error: \(error)")
            }
            
        default:
            print("[JSBridge] Unknown action: \(action)")
            rejectJsCallback(callbackId: callbackId, message: "Unknown action")
        }
    }
    
    // --- 向JS发送数据的辅助方法 ---
    private func resolveJsCallback(callbackId: String, data: [String: Any]) {
        guard let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []),
              let jsonString = String(data: jsonData, encoding: .utf8) else {
            rejectJsCallback(callbackId: callbackId, message: "Failed to serialize response data.")
            return
        }
        
        let script = "window['\(callbackId)'](null, \(jsonString));"
        evaluateJavaScript(script)
    }
    
    private func rejectJsCallback(callbackId: String, message: String) {
        let script = "window['\(callbackId)']({ message: '\(message.escapedForJavaScript())' }, null);"
        evaluateJavaScript(script)
    }

    private func evaluateJavaScript(_ script: String) {
        DispatchQueue.main.async {
            self.webView.evaluateJavaScript(script, completionHandler: { result, error in
                if let error = error {
                    print("[WebView] JS evaluation error: \(error.localizedDescription)")
                }
            })
        }
    }
    
    // --- 动态样式注入 ---
    func injectDynamicStyles(into webView: WKWebView) {
        let css = """
        :root {
            --system-background-color: #FFFFFF;
            --system-secondary-background-color: #F2F2F7;
            --system-text-color: #000000;
            --system-secondary-text-color: #8A8A8E;
            --system-primary-color: #007AFF;
            --system-destructive-color: #FF3B30;
        }
        /* 在暗黑模式下,可以注入不同的颜色值 */
        @media (prefers-color-scheme: dark) {
            :root {
                --system-background-color: #000000;
                --system-secondary-background-color: #1C1C1E;
                --system-text-color: #FFFFFF;
                --system-secondary-text-color: #8D8D93;
            }
        }
        """
        let script = """
        var style = document.createElement('style');
        style.innerHTML = `\(css.escapedForJavaScript())`;
        document.head.appendChild(style);
        """
        webView.evaluateJavaScript(script)
    }
}

// 字符串扩展,用于安全地插入到JS代码中
extension String {
    func escapedForJavaScript() -> String {
        return self.replacingOccurrences(of: "\\", with: "\\\\")
                   .replacingOccurrences(of: "\"", with: "\\\"")
                   .replacingOccurrences(of: "\'", with: "\\\'")
                   .replacingOccurrences(of: "\n", with: "\\n")
                   .replacingOccurrences(of: "\r", with: "\\r")
    }
}

ContentView中使用:

// ContentView.swift
import SwiftUI

struct ContentView: View {
    // 假设这是从你的本地开发服务器加载
    private let profileAppURL = URL(string: "http://localhost:8000/micro-apps/profile")!
    
    // 配置信息
    private let webViewConfig = WebViewConfig(
        apiBaseUrl: "http://localhost:8000",
        userId: "user-123"
    )

    var body: some View {
        NavigationView {
            VStack {
                Text("Native Header")
                    .font(.largeTitle)
                    .padding()
                
                // WebView作为应用的一部分嵌入
                WebView(url: profileAppURL, config: webViewConfig)
                    .clipShape(RoundedRectangle(cornerRadius: 12))
                    .padding()
                
                Spacer()
            }
            .navigationTitle("Hybrid App")
            .background(Color(UIColor.systemGroupedBackground))
        }
    }
}

方案的局限性与未来迭代

这个架构虽然解决了动态更新的问题,但在真实项目中,还有几个必须正视的遗留问题和优化方向。

首先是性能与体验WKWebView的首次加载并非零成本,会有一段白屏时间。在生产环境中,需要实现一套WebView预热和复用池机制,在用户感知之前就准备好一个随时可用的WebView实例。

其次是通信桥的健壮性。目前简单的action/callbackId模式在功能复杂后会变得难以维护。可以考虑引入更结构化的协议,例如将所有调用封装成Promise,或者为回调定义更严格的类型和错误码。安全性方面,必须对传入的actionpayload做严格的校验和过滤,防止Web侧注入恶意代码执行非预期的原生能力。

再者是版本管理与灰度发布。当微应用越来越多,就需要一套完善的管理后台来控制版本。FastAPI服务端应该提供一个“清单”接口,告知原生Shell每个功能模块应该加载哪个版本的URL。这套机制还能轻松实现A/B测试和灰度发布,这是该架构最大的优势之一。

最后是离线能力。目前的方案完全依赖网络。对于需要离线使用的场景,可以利用Service Worker缓存微应用的静态资源,或者更进一步,将微应用打包成资源包,由原生进行下载和管理,加载时直接从本地文件系统读取,从而实现离线访问和更快的加载速度。这无疑会增加客户端和服务端的复杂度,但对于核心功能是必要的。


  目录