团队面临一个典型的两难困境:iOS应用的功能迭代速度,总是被苹果的审核周期拖累。一些频繁变更的活动页面、信息展示模块,每次更新都意味着一次完整的App提交、审核、发布流程,周期长达数天甚至数周。这在快速响应市场需求的背景下,几乎是不可接受的。我们需要一种能绕开App Store审核,实现动态、快速更新非核心业务模块的方案。
初步构想是回归混合开发,但必须规避传统Hybrid方案的几个核心弊病:体验差、性能低、与原生系统脱节。我们的目标不是用Web完全替代原生,而是构建一个“原生为骨,Web为肉”的架构。具体来说,就是一个轻量级的SwiftUI原生应用作为“Shell(壳)”,负责提供核心的用户认证、设备能力、主题样式等底层服务。而那些易变的业务功能,则作为独立的“Web微应用”,由后端动态下发,在原生Shell提供的安全沙箱(WKWebView)中运行。
这个架构的核心组件如下:
- SwiftUI 原生Shell: 提供应用的主体框架、导航、以及底层能力接口。
- FastAPI 服务端: 负责托管打包后的Web微应用静态资源,并提供业务数据API。
- Web 微应用: 使用原生JavaScript、CSS开发的独立功能单元。
- Rollup 构建工具: 将每个Web微应用打包成精简、独立的JS和CSS文件。
- 原生-Web通信桥: 一套定义清晰的消息协议,用于SwiftUI和JavaScript之间的双向通信。
- 动态样式注入: 一种从原生层向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,或者为回调定义更严格的类型和错误码。安全性方面,必须对传入的action和payload做严格的校验和过滤,防止Web侧注入恶意代码执行非预期的原生能力。
再者是版本管理与灰度发布。当微应用越来越多,就需要一套完善的管理后台来控制版本。FastAPI服务端应该提供一个“清单”接口,告知原生Shell每个功能模块应该加载哪个版本的URL。这套机制还能轻松实现A/B测试和灰度发布,这是该架构最大的优势之一。
最后是离线能力。目前的方案完全依赖网络。对于需要离线使用的场景,可以利用Service Worker缓存微应用的静态资源,或者更进一步,将微应用打包成资源包,由原生进行下载和管理,加载时直接从本地文件系统读取,从而实现离线访问和更快的加载速度。这无疑会增加客户端和服务端的复杂度,但对于核心功能是必要的。