说起 HBuilderX 开发 iOS,大部分人想到的应该都是写 Vue,然后打包成 App 吧。
我之前一直有个想法:能不能用 HBuilderX 管理项目,在 Windows 上写 JS,同时用 SwiftUI 来做 iOS 的原生页面?这样是不是就能用 Windows 开发原生 iOS App 了?
后来翻了 DCloud 的 UTS 文档,发现这条路还真能走。不过先说一下,可以在 Windows 上写代码、通过云端打包 iOS App,不代表 Windows 本地能直接运行 Xcode 或编译所有 Swift 代码。 iOS 原生部分最终还是需要苹果的编译工具链,只不过 HBuilderX 的云打包可以承担这个环节。
我拿一个音乐 App 试了一下,示例工程就叫 test。音乐接口、播放状态、收藏和历史记录放在 JS 里,设置页、播放器、TabBar 这些界面用 SwiftUI 和 UIKit。中间通过 uni-app 的 UTS 插件通信。
下面把我的调用方式分享一下。为了方便展示,项目名和相关类名都统一写成了 test,部分代码也做了精简。文章重点是理解实现方式,不是完整的上架教程。
1. JS 和 Swift 分别负责什么?
我把它拆成了三部分,这样看代码时比较容易理清楚。
| 部分 | 使用的技术 | 负责的事情 |
|---|---|---|
| 业务层 | JavaScript、Vue、Pinia | 接口请求、播放队列、登录状态、收藏等 |
| 通信层 | UTS 插件 | 把 JS 的调用传给 Swift,再把原生事件回传 JS |
| 原生层 | Swift、SwiftUI、UIKit | 设置面板、播放器、原生列表、系统 TabBar、音频和媒体控制 |
目录大概是这样,其他和这篇文章没关系的文件先省略了:
test/
├── App.vue
├── pages/index/index.vue
├── services/api.js
├── stores/player.js
├── utils/nativeGlass.js
└── uni_modules/test-ios-glass-ui/
└── utssdk/
├── interface.uts
└── app-ios/
├── index.uts
├── Bridge/TestGlassUINative.swift
├── Pages/GlassPages.swift
├── Overlays/GlassSettings.swift
├── Player/GlassPlayer.swift
└── Shell/GlassSystemTabBar.swift
刚看到这个目录时,我觉得最容易弄混的就是 .js、.uts 和 .swift 的关系。
普通 uni-app 的 .vue 页面在 iOS 上还是走 JS 运行环境。UTS 插件中的 .uts 代码,在 iOS 平台会编译成 Swift。而目录里的 .swift 文件本来就是原生 Swift 代码,不是 JS 生成的。
这个区别在 DCloud 的跨端原理文档 里也有说明。简单理解,就是 JS 管业务,UTS 负责连接,Swift 管原生能力。
2. JS 怎么打开一个原生 SwiftUI 设置页?
我觉得直接拿设置页面举例比较好理解。比如在 JS 里点击「设置」,希望打开一个 SwiftUI 写的原生弹窗。
JS 这边其实不用知道弹窗里的 SwiftUI 是怎么布局的,把需要显示的数据传过去就行。
utils/nativeGlass.js 中的调用是这样的:
export const nativeSettings = {
show(state) {
// 第一次调用时挂上原生事件回调,之后直接跳过(第 3 节细说)
ensureBridge();
return showGlassSettings(JSON.stringify(state));
},
// 面板已打开时,只传需要改的字段
update(state) {
updateGlassSettings(JSON.stringify(state));
},
dismiss() {
dismissGlassSettings();
},
};
我这里用 JSON.stringify 把状态转成字符串。倒不是 UTS 只能传字符串,只是页面状态可能有好几层嵌套,用 JSON 比较容易让两边约定同一套格式。
接着会调用到 uni_modules/test-ios-glass-ui/utssdk/app-ios/index.uts:
export const showGlassSettings: ShowGlassSettings = function (stateJson: string): boolean {
return TestGlassUINative.showSettings(stateJson)
}
UTS 这层很薄,就是转一手。它再去调用 Bridge/TestGlassUINative.swift 里的方法:
public static func showSettings(_ json: String) -> Bool {
guard let state = parseObject(json) else { return false }
onMain { GlassSettings.show(state) } // UI 必须在主线程
return true
}
Swift 先把传进来的 JSON 解析成字典,再调用 GlassSettings.show(state) 打开原生设置面板。界面相关的操作放到主线程处理。
我把调用顺序记成了下面这样:
JS 调用 nativeSettings.show(state)
↓
UTS 调用 TestGlassUINative.showSettings(...)
↓
Swift 解析 JSON
↓
GlassSettings 展示原生设置面板
这里还有个小细节。showSettings 返回 true,只能说明 JSON 解析成功,而且已经安排了展示操作。因为 onMain 可能是异步执行的,所以不能拿这个 true 当成「弹窗已经显示成功」。
3. 原生按钮被点击后,怎么通知 JS?
上面解决了 JS 调 Swift 的问题,但反过来呢?用户在 SwiftUI 里点了某个按钮,JS 里的播放状态或者收藏列表也得跟着更新吧。
我的做法是保留一个事件回调。Swift 这边有操作发生时,把事件打包成 JSON 发回去,JS 收到后再处理。
TestGlassUINative.swift 里的关键部分是这样:
// JS 那边注册进来的回调,全局只有一个
private static var eventHandler: ((String) -> Void)?
public static func setEventHandler(_ handler: @escaping (String) -> Void) {
onMain { eventHandler = handler }
}
static func emit(_ payload: [String: Any]) {
// 没注册回调或者序列化失败,直接丢弃
guard let handler = eventHandler,
let data = try? JSONSerialization.data(withJSONObject: payload),
let text = String(data: data, encoding: .utf8) else { return }
handler(text)
}
UTS 这一层把 Swift 的回调转给 JS:
// 回调会被反复触发(tab 点击、菜单、弹窗等),所以要 keepAlive
@UTSJS.keepAlive
export function onGlassUIEvent(callback: GlassUIEventCallback): void {
TestGlassUINative.setEventHandler((json: string) => {
callback(json)
})
}
我一开始看到 @UTSJS.keepAlive 还特意去查了一下。像 Tab 点击、菜单、弹窗这种事件会触发很多次,回调需要一直保留,不能第一次执行完就没了。其实按 UTS 的规则,以 on 开头的方法默认就会 keepAlive,我这里还是显式写上了,免得以后改名踩坑。
JS 这边:ensureBridge 到底干了什么
前面 show() 第一行的 ensureBridge(),当时一笔带过了,这里补上。其实它就是把上面这条回传链路的最后一段接到 JS 上:
// type -> 处理函数集合,比如 'settings'、'page'、'search'
const listeners = new Map();
let bridgeReady = false;
// Swift 发来的所有事件都先到这里,再按 type 分发
const dispatch = (json) => {
let event;
try {
event = JSON.parse(json);
} catch (_) {
return;
}
listeners.get(event?.type)?.forEach((handler) => handler(event));
};
// 只在第一次调用时注册,后面再调直接返回
const ensureBridge = () => {
if (bridgeReady) return;
bridgeReady = true;
onGlassUIEvent(dispatch);
};
// 业务代码用它订阅某一类原生事件,返回取消订阅的函数
export const onNativeGlassEvent = (type, handler) => {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(handler);
return () => listeners.get(type)?.delete(handler);
};
看到这里可能会觉得多此一举:每个页面自己调一次 onGlassUIEvent 不就完了?
还真不行。回头看 Swift 那边,eventHandler 只有一个变量,setEventHandler 调一次就覆盖一次。设置页刚注册好,播放器一注册,设置页的回调就被顶掉了,而且不会有任何报错,只是设置页的点击突然没反应了。
所以我让原生那边只认 dispatch 这一个入口,一对多的分发放在 JS 里做。bridgeReady 也是为了防止重复注册。
至于为什么不在 App 启动时注册,而是放在 show()、setup() 这些方法里:这样不管哪个原生界面先打开,回调都能先挂上。update() 和 dismiss() 里没调,是因为它们只会在 show() 之后用,那时候桥肯定已经接好了。
(实际项目里 ensureBridge 还顺带做了主题同步,和这篇关系不大,就省略了。)
设置面板里每个操作,Swift 都是这样发出去的:
static func send(_ action: String, _ value: Any? = nil) {
var payload: [String: Any] = ["type": "settings", "action": action]
if let value { payload["value"] = value }
TestGlassUINative.emit(payload)
}
JS 这边只订阅 settings 这一类:
onNativeGlassEvent('settings', ({ action, value }) => {
switch (action) {
case 'theme':
setTheme(value);
break;
case 'closed':
// 面板关了,做点清理
break;
}
});
把整个来回串起来,就是这样:
第一次打开原生界面
nativeSettings.show(state)
→ ensureBridge() // 只跑一次
→ onGlassUIEvent(dispatch) // UTS
→ setEventHandler(...) // Swift 把回调存起来
→ showGlassSettings(json) // 正式打开面板
用户在 SwiftUI 里切到深色模式
GlassSettings.send("theme", "dark")
→ emit({ type: "settings", action: "theme", value: "dark" })
→ eventHandler(json) // 就是刚才存的那个
→ UTS callback(json)
→ JS dispatch(json)
→ listeners 里 'settings' 的处理函数
→ setTheme('dark'),再 nativeSettings.update(...) 同步回面板
这样 JS 和 SwiftUI 不用互相管理对方的内部状态,只需要把「发生了什么」和「要显示什么」传过去。页面打开、关闭、刷新、加载更多这些,也都是同一套路子,约定好 type 和 action 就行。当然,事件格式要两边都写对,否则排查起来会有点麻烦。
4. 既然用了 uni-app,为什么看到的还是原生界面?
那既然还是 uni-app,最后看到的不应该是 Vue 页面吗?
我的做法是让 pages/index/index.vue 只保留一个空的 view:
<template>
<view class="host" />
</template>
这个空页面主要用来承载 uni-app 的运行环境。App.vue 启动时初始化原生页面,再把 SwiftUI/UIKit 的界面显示出来。
例如 GlassSystemTabBar.swift 使用的是 iOS 系统的 UITabBarController,把它全屏呈现在 uni-app 根控制器上方。其他页面也由原生代码绘制。
这样看到的页面确实是原生控件。不过 App 里依然有 uni-app 的运行环境,不能说整个 App 已经完全没有 WebView 或 JS 引擎,只是用户看到的主要页面不再由 Vue 渲染。
因为我想尝试 iOS 26 的系统界面效果,所以示例里把最低版本设成了 26.0,在 manifest.json 中这样配置:
"ios": {
"deploymentTarget": "26.0"
}
这个版本号只是当前示例的选择,并不是说 HBuilderX 开发 iOS 就必须要求 iOS 26。如果要兼容更老的 iPhone,还得重新考虑用到的 SwiftUI 和 UIKit API。
5. 调试和打包有哪些坑?
代码写完只是第一步,真想在 iPhone 上跑起来,还得过调试和打包这两关。
自定义基座不能省
像示例中的 test-ios-glass-ui、test-ios-media-session 都属于自定义原生插件。HBuilderX 的标准运行基座里不会自带这些模块,所以调试时需要制作包含插件的自定义基座。不然 JS 找不到原生方法,写得再对也运行不起来。
实际操作时还要看当前 HBuilderX 版本的要求。
不要把「安心打包」和传统云打包当成一回事
我查文档时还看到一个需要注意的地方。DCloud 提到,iOS 安心打包对 Swift 原生插件可能存在兼容问题。如果提交 App Store 时遇到 ITMS-90426: Invalid Swift Support,官方建议改用传统打包。
当然,不代表用了 Swift 插件就一定会遇到这个错误。只是如果后面打包或者上传出问题,可以先把这个因素排查一下。
事件格式需要两边约定好
因为用 JSON 传状态,所以字段名也要特别注意。假如 Swift 发的是 type: "page",JS 监听的却是其他类型,原生界面可能已经响应了,JS 这边却没有任何反应。
遇到这种问题,我会顺着 JS → UTS → Swift 或 Swift → UTS → JS 这条路线检查,主要看这些地方:
- 插件有没有被编译进基座;
- 回调有没有注册,是否需要持续触发。Swift 端没拿到 handler 时会静默 return,不会报错,所以事件「没反应」要先查这里;
- JSON 字段名和数据类型是否一致;
- UI 更新有没有在主线程处理。
证书和描述文件仍然需要准备
我之前也想过,既然能在 Windows 上开发,那证书和描述文件怎么办?后来发现这部分也可以在 Windows 上处理。
如果是付费 Apple 开发者账号,可以直接去 Apple Developer 创建正确的 Bundle ID、发布证书和描述文件,之后把证书和描述文件提供给 HBuilderX 云打包。
那没交年费的免费 Apple 账号呢?我搜了一圈,发现有个叫 Appuploader 的工具,用免费账号也能在 Windows 上生成开发证书和描述文件。限制也很明显:描述文件 7 天过期,测试设备最多 3 台,也上不了 App Store。不过如果只是做个自己用的 App,确实能先把每年 99 美元的年费省下来。付费账号的话,它还能走命令行上传,接到 CI 流水线 或者 AI Agent 里自动上架。
有一点我觉得要记住:DCloud 的 appid(比如 __UNI__...)跟 Apple 的 Bundle ID 是两回事。发布证书、描述文件、Bundle ID 和需要的 App 能力得对应上。
另外,.p12 文件和密码不要直接放到公开的 GitHub 仓库里。
6. 这种混合开发适合什么项目?
说实话,如果只是想简单做个 App,没必要这么搞。除了 JS,还得看得懂 UTS 和 Swift;两边的数据、事件要自己对好;调试基座和云打包也比普通 uni-app 麻烦不少。
我会折腾这一套,一是手上只有 Windows,又不想为了写个 App 专门买台 Mac;二是想用上 iOS 26 的系统界面效果,这种东西用 Vue 页面是模仿不出那个味道的。
所以如果你也是已经有一部分 JS 业务代码,又想让播放器、分享面板、TabBar 这些地方用上真正的原生界面,这套写法值得试试。
至少现在能确定一件事:通过 HBuilderX 配合 UTS 插件,JS 业务逻辑和 SwiftUI 原生界面确实可以放在同一个 iOS 项目里。折腾是折腾了点,但能在 Windows 上写 SwiftUI、真机跑起来,对我来说是够了。
参考资料
- DCloud:uni-app 组成和跨端原理
- DCloud:UTS 插件开发
- DCloud:HBuilderX 创建、运行与发布 uni-app
- DCloud:安心打包使用指南
- DCloud:iOS 发布证书与描述文件申请说明