引言
windows-cj 是一套用 仓颉(Cangjie) 编写的 Windows API 绑定与投影。它让你能够直接在仓颉中调用 Win32、COM 与 WinRT 接口,覆盖从底层 C 风格函数到现代 WinRT 运行时类的完整谱系,并尽量提供符合仓颉习惯、安全易用的编程模型。
这套库以一组相互独立、各司其职的包组织起来:字符串、错误码、COM 接口实现、WinRT 集合与异步、WinUI 3 支持,以及在编译期之外按需生成绑定的 windows-bindgen 工具。你可以只依赖其中一小部分,也可以把它们组合起来构建完整的桌面应用。
本书面向谁
- 熟悉 Windows、初次接触仓颉的开发者:你已经了解
HRESULT、IUnknown::QueryInterface、WinRT 激活工厂这些概念,本书帮你把它们映射到仓颉的类型系统与内存模型上。 - 熟悉仓颉、初次接触 Windows 的开发者:你习惯了仓颉的
Option<T>、class/struct语义和 GC,本书帮你理解 Windows ABI 在这些范式下是如何落地的。
关于「行为等价」
windows-cj 的设计目标是与原生 Windows ABI 行为等价——相同的输入产生相同的 Win32 / WinRT ABI 效果——而不是照搬其它语言的 API 表面。仓颉是 GC 语言,因此你不会在这里看到借用、所有权、生命周期标注这类概念:
- 仓颉对象之间的引用由 GC 管理,无需手动
AddRef/Release。 - 持有原生 COM 指针的类型实现
Resource接口,配合try-with-resource或析构器回收。 Option<T>取代了空指针;没有null关键字。
阅读本书时请记住这一点:当某段代码看起来与你在其它语言里的写法不同时,多半是因为它遵循了仓颉的范式,而非缺失了功能。
如何阅读
建议从 概述 开始,按顺序读完 安装与环境配置 和 包结构总览,再根据需要跳转到具体主题——调用 Win32、处理字符串、实现 COM 接口、消费 WinRT、生成绑定,直到最后的 WinUI 3 实战。
相关链接
概述
windows-cj 把庞大的 Windows API 拆分成若干个职责单一的仓颉包。这一节先建立全局视野:你会调用哪些层次的 API,它们分别由哪些包支撑,以及该从哪里入手。
三个层次的 Windows API
Windows 平台的 API 大致分三层,windows-cj 对三者都提供支持:
| 层次 | 典型代表 | 调用风格 |
|---|---|---|
| Win32(C 风格) | CreateThreadpoolWork、MessageBoxW、注册表函数 | 直接调用导出函数,参数多为指针 / 句柄 |
| COM | IUnknown、IDXGIFactory、Shell 接口 | 通过虚函数表(vtable)调用,需要 QueryInterface |
| WinRT | Windows.Foundation.Uri、集合、异步 | 在 COM 之上,带运行时类型系统、激活工厂、泛型投影 |
越往下越底层、越接近裸 ABI;越往上越现代、越贴近仓颉习惯。三层共享同一套底座类型(GUID、HRESULT、BOOL、字符串),它们集中在 windows-core、windows-result、windows-strings 等基础包里。
一张依赖速写
windows-result ── 错误码 / HRESULT / BOOL / GUID
windows-strings ── HString / BSTR / PCWSTR / PWSTR
│
▼
windows-core ── COM 接口底座、vtable、Type 投影、WinRT-ABI 底座
│
├── windows-interface / windows-implement ── 声明并实现 COM 接口
├── windows-collections ── WinRT 集合(IVector / IMap …)
├── windows-future ── WinRT 异步(IAsyncOperation …)
└── windows-foundation ── Foundation 投影(Uri / PropertyValue …)
│
▼
windows-winui3 ── WinUI 3 / Windows App SDK 支持
windows-bindgen 独立于运行时之外:它是一个命令行工具,按需把元数据生成成仓颉源码包,供上面这些层消费。
我应该从哪里开始?
- 只想调用一个传统 Win32 函数?→ 调用第一个 Win32 API
- 要在字符串、错误码上和 Windows 打交道?→ 处理字符串 和 错误处理与 HRESULT
- 要拿到并使用一个 COM 接口?→ 调用 COM API 与查询接口
- 要把自己的仓颉类型暴露成 COM 接口?→ 实现 COM 接口
- 要消费 WinRT 运行时类、集合、异步?→ 调用 WinRT API
- 要写图形界面?→ WinUI 3 实战
下一节先把环境装好。
安装与环境配置
windows-cj 直接绑定 Win32 / COM / WinRT 的原生 ABI,因此只能在 Windows 上构建和运行。本节带你把工具链装好,并解释几个容易踩坑的环境约定。
1. 安装仓颉工具链
windows-cj 锁定 cjc 1.1.0(STS 通道)。从 仓颉官网下载中心 获取 Windows x64 安装包:
cangjie-sdk-windows-x64-1.1.0.exe(图形化安装),或cangjie-sdk-windows-x64-1.1.0.zip(解压即用)
安装完成后,确认 cjc(编译器)和 cjpm(包管理器)已在 PATH 中:
cjc -v
cjpm -h
版本一致性:仓库内每个包的
cjpm.toml都声明了cjc-version = "1.1.0"。使用其它版本的 cjc 可能因标准库 API 差异而无法编译。
2. 获取源码
git clone https://github.com/Zxilly/windows-cj.git
cd windows-cj
仓库根目录是一个 cjpm 工作区([workspace]),把各个包作为成员统一管理:
[workspace]
members = [
"windows-core",
"windows-strings",
"windows-result",
"windows-foundation",
# …其余包
]
3. 构建
在工作区根目录直接构建全部成员:
cjpm build
或在某个包目录下单独构建该包及其依赖。
关于编译内存上限
多包工作区一次性编译时,仓颉运行时默认的 GC 堆上限可能不够,触发 OOM。构建前把上限调高即可(这是上限而非预留,不会预占物理内存):
$env:cjHeapSize = '32GB'
cjpm build
单包构建通常用不到这么高,但在工作区级别的全量构建中建议设置。
4. 在项目中使用 windows-cj
在你自己项目的 cjpm.toml 里,用路径依赖引入需要的包。注意目录名用连字符、包名用下划线:
[package]
name = "my_app"
version = "0.1.0"
output-type = "executable"
cjc-version = "1.1.0"
# COM / WinRT 通常需要链接这几个系统库
link-option = "-lole32 -loleaut32 -lwindowsapp"
[dependencies]
windows_core = { path = "../windows-cj/windows-core" }
windows_strings = { path = "../windows-cj/windows-strings" }
在仓颉源码中按包名(下划线)导入:
import windows_core.*
import windows_strings.HString
5. 运行产物
构建产物在 target/ 下。运行可执行文件时,需要让运行时找到正确的工具链与系统运行时环境。本仓库统一通过仓颉版本管理器的 cjv exec 包裹运行,而不是直接双击 .exe:
cjv exec .\target\release\bin\my_app.exe
这样能保证运行时链接到与编译期一致的仓颉运行时,COM / WinRT 调用才不会因环境不匹配而失败。
下一步
环境就绪后,先浏览 包结构总览,了解每个包负责什么;然后从 调用第一个 Win32 API 写下第一行真正调用 Windows 的代码。
包结构总览
windows-cj 的工作区由一组职责单一的包组成。你几乎不会一次性用到全部——按需挑选即可。下表按层次分组,方便你定位入口。
基础底座
这几个包提供了贯穿三层 API 的公共类型,几乎所有上层包都依赖它们。
| 包名(目录) | 仓颉包名 | 职责 |
|---|---|---|
windows-result | windows_result | HRESULT / Result<T> / BOOL / GUID / WIN32_ERROR 等错误码与底座类型 |
windows-strings | windows_strings | HString / BSTR / PCWSTR / PWSTR / CWideString 等字符串类型 |
windows-core | windows_core | COM 接口底座、vtable、Inspectable、Type 投影、激活工厂缓存、AgileReference、ABI 数组,以及共享的 WinRT-ABI 底座(DateTime / TimeSpan / Point / Rect / Size 等值类型、HResult、QI helper) |
windows-polyfill | windows_polyfill | 语言 / 标准库层面的补齐与兼容 helper |
windows-libloading | windows_libloading | 动态库加载与导出函数解析(LoadLibrary / GetProcAddress 封装) |
COM 接口
| 包名(目录) | 仓颉包名 | 职责 |
|---|---|---|
windows-interface | windows_interface | 声明 COM 接口及其 vtable 布局 |
windows-implement | windows_implement | 在仓颉类型上实现既有 COM 接口 |
WinRT 投影
| 包名(目录) | 仓颉包名 | 职责 |
|---|---|---|
windows-foundation | windows_foundation | Foundation 核心投影:Uri / PropertyValue / IReference / MemoryBuffer / Deferral / EventHandler / TypedEventHandler / IStringable / IClosable 等 |
windows-collections | windows_collections | WinRT 集合投影与 stock 实现:IVector / IMap / IIterable + StockVectorView / MapView / Iterator |
windows-future | windows_future | WinRT 异步投影与 await 机制:IAsyncOperation / IAsyncAction + handler 家族 |
windows-numerics | windows_numerics | Numerics 易用值类型 helper(向量 / 矩阵等) |
Win32 子系统
| 包名(目录) | 仓颉包名 | 职责 |
|---|---|---|
windows-registry | windows_registry | 注册表读写 |
windows-services | windows_services | 服务控制管理 |
windows-threading | windows_threading | 线程 / 线程池相关 |
windows-version | windows_version | 系统版本信息查询 |
windows-variant | windows_variant | VARIANT 类型 |
windows-propvariant | windows_propvariant | PROPVARIANT 类型 |
windows-safearray | windows_safearray | SAFEARRAY 类型 |
工具与支持
| 包名(目录) | 仓颉包名 | 职责 |
|---|---|---|
windows-targets | windows_targets | GNU 链接器所需的 Windows 导入库资源(链接期资产,非源码依赖) |
windows-common | windows_common | 中心仓维护的 generated 支持 / 投影子集,以及可复用的底层 native ABI helper 入口(注意:它是支持符号包,不是运行时) |
windows-winui3 | windows_winui3 | WinUI 3 / Windows App SDK 支持包 |
windows-bindgen | windows_bindgen | 命令行绑定生成器,按需把元数据生成成仓颉源码包 |
怎么选
- 写一个调用传统 Win32 函数的小程序:
windows-result+windows-strings(必要时加对应子系统包)。 - 消费一个 COM 接口:再加上
windows-core。 - 实现自己的 COM 接口:加上
windows-interface+windows-implement。 - 消费 WinRT(集合 / 异步 / Foundation):按需加
windows-collections/windows-future/windows-foundation。 - 写 WinUI 3 界面:从
windows-winui3入手,它会带入所需的支持符号。
接下来从最简单的场景开始:调用第一个 Win32 API。
调用第一个 Win32 API
Win32 是 Windows 最底层、也是历史最悠久的一层 API:它就是一组从系统 DLL(kernel32.dll、advapi32.dll、user32.dll 等)里导出的 C 风格函数。它们有几个共同特征:
- 导出函数:每个函数都是某个 DLL 的导出符号,名字常带
A/W后缀(ANSI / 宽字符),例如RegGetValueA/RegGetValueW。 - 句柄(handle):系统资源(注册表项、进程、窗口……)用一个不透明的整数 / 指针句柄表示,例如
HKEY、HANDLE。 - 指针参数:很多参数是缓冲区指针、或者“出参指针“(函数往你给的地址里写结果)。
- 整数返回码:成功 / 失败用
BOOL(0 假、非 0 真)或HRESULT/ Win32 错误码(0表示成功)表达,而不是抛异常。
在仓颉里调用这类函数有两条路径,从上往下越来越接近裸 ABI:
- 优先:直接用 windows-cj 已经封装好的子系统包。它把句柄生命周期、宽字符串编码、错误码翻译都替你做好了,你写的是地道的仓颉代码。
- 进阶:当某个函数还没有现成封装时,用仓颉
foreign func+@C自己声明,并在unsafe块里调用。
下面分别走一遍。
路径一:用现成的高层封装(推荐)
最省事的方式是找到对应子系统包,直接调用它的公开函数。以查询操作系统版本为例 —— windows-version 包已经把 RtlGetVersion 封装成了一个干净的值类型 OsVersion。
写依赖:cjpm.toml
注意 windows-cj 的命名约定:目录名用连字符,仓颉包名用下划线。引依赖时键名是包名(下划线),路径指向目录(连字符)。
[package]
name = "my_app"
version = "0.1.0"
output-type = "executable"
cjc-version = "1.1.0"
[dependencies]
windows_version = { path = "../windows-cj/windows-version" }
写代码
import windows_version.*
main(): Int64 {
// 读取当前系统版本(内部调用 Win32 的 RtlGetVersion)
let version = OsVersion.current()
println("Windows 版本:${version}") // 例如 10.0.0.26200
// 判断是不是服务器版(内部读取 OSVERSIONINFOEXW.wProductType)
if (is_server()) {
println("这是服务器版 Windows")
} else {
println("这是工作站版 Windows")
}
// 读取修订号 UBR(内部走注册表 RegGetValueA)
println("修订号:${revision()}")
0
}
OsVersion.current() / is_server() / revision() 都是 windows-version 的公开 API(见 windows-version/src/lib.cj)。你完全看不到指针、句柄、宽字符——封装层已经把这些都吞掉了。OsVersion 是一个实现了 ToString 的值类型(struct),字符串插值时会得到 major.minor.pack.build 的形式。
高层 API 怎么报告错误
更复杂的子系统会把“成功 / 失败“建模成返回值,而不是让你检查整数码。以 windows-registry 为例,它的读写函数返回 Result<T>:
[dependencies]
windows_registry = { path = "../windows-cj/windows-registry" }
import windows_registry.*
import windows_result.*
main(): Int64 {
// LOCAL_MACHINE 是预定义的根键(对应 HKEY_LOCAL_MACHINE)
let opened = LOCAL_MACHINE.open("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")
match (opened) {
case Ok(key) =>
// Key 实现了 Resource,用 try-with-resource 自动关闭句柄
try (k = key) {
match (k.getString("ProductName")) {
case Ok(name) => println("产品名:${name}")
case Err(e) => println("读取失败:${e}")
}
}
case Err(e) =>
println("打开注册表项失败:${e}")
}
0
}
这里几件事都是封装层替你做的:
LOCAL_MACHINE是Key.borrowed(...)预建好的根键常量,不需要你自己拼HKEY句柄。open/getString返回Result<Key>/Result<String>,用match解构,不需要检查 Win32 状态码。Key实现了Resource,try (k = key) { ... }会在作用域结束时自动调用RegCloseKey,不会泄漏句柄。- 宽字符串(UTF-16)编码、缓冲区分配全部在内部完成,你只传普通仓颉
String。
范式提醒:仓颉用
Option<T>代替null,用Result<T>表达可失败的操作;句柄类型由 GC 持有的class(如Key)管理生命周期,配合Resource的close()释放原生资源——你不需要手动AddRef/Release。
路径二:自己声明 foreign func(进阶)
如果某个 Win32 函数还没有现成封装,你可以照 C 头文件的签名,用仓颉 FFI 自己声明。底层的 windows-libloading 包本身就是这么写的,可以当模板参考(见 windows-libloading/src/lib.cj)。
关键语法点(以仓颉官方 FFI 文档为准)
foreign func声明外部函数,只能有声明、不能有函数体。- Win32 导出函数用 STDCALL 调用约定,用
@CallingConv[STDCALL]标注;它可以修饰单个函数,也可以修饰一整个foreign块(块内每个函数都套上同样的约定)。不标注时默认是CDECL。 - 调用
foreign函数必须包在unsafe { ... }块里——仓颉的 unsafe 是块级别的,不是函数级别。 - 指针参数用
CPointer<T>;空指针 / NULL 用CPointer<T>()(再用.isNull()判空)。句柄(HMODULE、HANDLE)在 ABI 上就是指针,对应CPointer<Unit>。 - C 字符串参数对应
CPointer<UInt8>(窄字符)或CPointer<UInt16>(宽字符)。
声明骨架
下面照搬 windows-libloading 里对三个 kernel32 导出函数的真实声明:
@CallingConv[STDCALL]
foreign {
func GetProcAddress(hModule: CPointer<Unit>, lpProcName: CPointer<UInt8>): CPointer<Unit>
func FreeLibrary(hLibModule: CPointer<Unit>): Int32
func LoadLibraryExA(lpLibFileName: CPointer<UInt8>, hFile: CPointer<Unit>, dwFlags: UInt32): CPointer<Unit>
}
HMODULE/HANDLE→CPointer<Unit>LPCSTR(窄字符串)→CPointer<UInt8>DWORD→UInt32,BOOL(C 的 int 返回)→Int32
调用骨架
调用时,把仓颉数据转成 C 兼容形式,包进 unsafe,再判返回值。下面是 windows-libloading 里 LoadLibraryExA 的真实调用片段(保留要点):
func loadLibraryWithFlags(moduleNameBytes: Array<UInt8>, flags: UInt32): CPointer<Unit> {
unsafe {
// 把仓颉数组的底层缓冲区借出原生指针
let moduleNameHandle = acquireArrayRawData(moduleNameBytes)
try {
let module = LoadLibraryExA(moduleNameHandle.pointer, CPointer<Unit>(), flags)
if (module.isNull()) { // NULL 返回即失败
throw Exception("failed to load module")
}
module
} finally {
releaseArrayRawData(moduleNameHandle) // 用完务必释放
}
}
}
要点:
CPointer<Unit>()构造了一个空指针,对应 C 侧的NULL(这里作为hFile传入)。- 返回的句柄用
.isNull()判断是否失败——这正是 Win32“返回 NULL / 0 表示出错“的惯例。 - 把仓颉
Array的内存交给 C 时,要用acquireArrayRawData/releaseArrayRawData成对借出 / 归还,并放在try { ... } finally { ... }里保证释放。
还有一条更动态的路径:
windows-libloading提供了resolveProc(moduleName, procName),运行时用LoadLibrary+GetProcAddress解析出函数地址,再用CFunc<...>把地址转成可调用的函数指针。windows-result内部就用这种方式调用kernel32/oleaut32的函数(见windows-result/src/bindings.cj)。当你不想在链接期绑定符号、或要按需加载时,这很有用。
返回值:BOOL 与 HRESULT
自己声明 FFI 时要记住 Win32 的错误约定,并尽量翻译成仓颉的表达方式:
- 返回
BOOL的函数:0是失败。windows-result提供了BOOL值类型,.as_bool()转成仓颉Bool,.ok()转成Result<Unit>(失败时自动带上GetLastError的错误信息),.unwrap()在失败时 panic。 - 返回
HRESULT的函数(COM 风格):用windows-result的HRESULT类型表达,负值 / 高位置 1 表示失败。 - 返回 Win32 状态码(如注册表函数返回
LSTATUS):0成功,非 0 是错误码,可用errorFromWin32(code)翻成Error。
错误码到异常 / Result 的完整映射,参见 错误处理与 HRESULT。
小结
- 能用现成封装就用现成封装:你写的是带
Result/Option/Resource的地道仓颉代码,看不到指针和句柄。 - 没有封装时,按 C 签名写
foreign func(@CallingConv[STDCALL]),在unsafe块里调用,指针用CPointer<T>、句柄用CPointer<Unit>、字符串用CPointer<UInt8>/CPointer<UInt16>。 - 始终用
BOOL/HRESULT/ Win32 状态码判断成败,并翻译成仓颉的错误表达。
下一步 / 相关
- 处理字符串 —— Win32 的
W函数要求 UTF-16 宽字符串,这一节讲HString/PCWSTR/PWSTR/CWideString怎么用。 - 错误处理与 HRESULT ——
BOOL/HRESULT/Error/Win32Exception的完整体系。 - 调用 COM API 与查询接口 —— 从 C 风格函数进阶到 vtable 调用与
QueryInterface。 - 链接与 windows-targets —— 这些导出符号在链接期是怎么被解析的。
处理字符串
Windows 内部几乎全用 UTF-16 宽字符(UInt16),而仓颉的 String 是 UTF-8。和 Windows 打交道时,几乎每一次跨边界都要做一次「UTF-8 ↔ UTF-16」的转换。windows-strings 把这件事封装成几个职责清晰的类型,你只要根据 API 期待的形态挑一个用就行。
越往下越底层、越接近裸 ABI;越往上越现代、越贴近仓颉习惯:
PCWSTR / PWSTR ── 裸宽字符指针(只读 / 可写),不拥有内存
CWideString ── 仓颉持有的、以 NUL 结尾的 UTF-16 缓冲,喂给 Win32 入参
BSTR ── 长度前缀的 OLE 字符串,COM / 自动化常用
HString ── WinRT 的 HSTRING,运行时投影最常用
它们都来自 windows_strings 包。下面逐一拆解。
String 与 UTF-16 的关系
仓颉 String 按 UTF-8 存储字节;Windows 想要的是 UTF-16 码元序列。windows-strings 在内部用一套编解码 helper 完成两边互转,并且对非法序列做了防御(遇到坏字节会替换成 U+FFFD,不会崩)。
大多数时候你不需要手动调编解码函数——构造一个 HString / BSTR / CWideString 时转换会自动发生;取回内容时再转回 String。关键是理解:这些包装类型内部存的是 UTF-16,对外的「人话」接口才是仓颉 String。
HString:WinRT 的字符串
HString 对应 WinRT 的 HSTRING,是你在运行时投影里最常碰到的字符串类型。它是一个 class(引用类型),由 GC 管理对象本身的引用关系,但底层 UTF-16 缓冲是它自己持有的堆内存,需要回收——所以它实现了 Resource。
构造
import windows_strings.*
// 从仓颉 String 直接构造(最常用)
let hs = HString("Hello, WinRT")
// 空字符串(HSTRING 的空值就是 null 句柄)
let empty = HString()
// 从一段 UTF-16 码元数组构造
let fromUnits = HString.fromWide([0x0048u16, 0x0069u16]) // "Hi"
取回内容、长度、是否为空
let hs = HString("café")
let text: String = hs.get() // 有损解码,等价于 toString()
let same: String = hs.toString() // ToString 实现,同 get()
// 无损解码:内容是合法 UTF-16 时返回 Some,否则 None
match (hs.tryToString()) {
case Some(s) => println("解出来了:${s}")
case None => println("含非法代理对,无法无损解码")
}
let n: Int64 = hs.length() // UTF-16 码元个数
let blank: Bool = hs.isEmpty()
get() / toString() 走的是 lossy 解码(坏数据替换成 U+FFFD),适合「我只想拿到能显示的文本」;tryToString() 返回 Option<String>,适合「我要确认内容确实是合法 UTF-16」。
生命周期:close 或 try-with-resource
HString 实现了 Resource(isClosed() / close()),离开作用域时底层缓冲必须被释放。推荐用 try-with-resource,自动调用 close():
try (hs = HString("scoped string")) {
println(hs.get())
} // 离开块时自动 close(),释放 UTF-16 缓冲
如果不用 try-with-resource,可以手动 close():
let hs = HString("manual")
println(hs.get())
hs.close() // 显式回收
println(hs.isClosed()) // true
即便你忘了 close(),HString 也有 ~init() 析构器兜底;但仍建议显式管理,让回收时机可控。注意 HString 内部用了回收标记(closed),close() 和析构器之间不会重复释放。
在指针边界上桥接
很多底层 API 需要一个宽字符指针。HString 提供了零拷贝借用:
let hs = HString("path")
// 借出 PCWSTR(只读宽指针),仅在闭包内有效
hs.withPCWSTR { p =>
// p: PCWSTR,可以传给期待 PCWSTR 的 API
}
// 借出裸 CPointer<UInt16>
hs.withPtr { ptr =>
// ptr: CPointer<UInt16>
}
借出的指针 / 切片只在
HString存活且未close期间有效,不要把它们存到闭包外面。
BSTR:OLE / 自动化字符串
BSTR 是长度前缀的 UTF-16 字符串,由 OLE 自动化(oleaut32.dll 的 SysAllocStringLen / SysFreeString)分配和释放,在 COM、脚本自动化、VARIANT 场景里随处可见。它同样是 class 且实现 Resource——当它拥有存储时需要回收。
// 自己分配一个 BSTR(拥有存储)
try (b = BSTR("automation")) {
let s: String = b.get()
let n: Int64 = b.length() // 字符数
let bytes: UInt32 = b.byteLength()
let owns: Bool = b.ownsStorage() // true,这块内存归它管
} // close() 调用 SysFreeString
当一个 API 把 BSTR 指针交还给你时,要分清「所有权是否转移」:
// 所有权转移给你:用完由这个包装负责 SysFreeString
let owned = BSTR.fromRawTake(rawPtr)
// 只是借用:你不拥有它,close() 不会去 free
let borrowed = BSTR.fromRawView(rawPtr)
fromRawView 得到的 BSTR 的 ownsStorage() 是 false,close() 不会释放别人的内存——这正是处理「函数返回的指针」时需要的安全语义。还有 intoRaw() 可以把裸指针的所有权交还给调用方(之后这个 BSTR 实例失效,由调用方负责 SysFreeString)。
PCWSTR / PWSTR:裸宽字符指针
这两个是 struct(值类型),只是 CPointer<UInt16> 的轻量包装,不拥有任何内存,对应 Win32 签名里的 LPCWSTR(只读)和 LPWSTR(可写)。
| 类型 | 含义 | 典型用途 |
|---|---|---|
PCWSTR | 指向 UTF-16 的只读指针 | 把字符串传入 Win32 函数 |
PWSTR | 指向 UTF-16 的可写指针 | 接收 Win32 函数写出的缓冲 |
// 从裸指针包一层
let pc = PCWSTR.fromRaw(somePtr)
let pw = PWSTR.fromRaw(outBuffer)
let isNull: Bool = pc.isNull()
let raw: CPointer<UInt16> = pc.asPtr()
// PWSTR 可以降级成只读的 PCWSTR
let ro: PCWSTR = pw.toPCWSTR()
因为它们只是借用别人的内存、长度要靠扫描 NUL 终止符,所以解码 / 取长度类操作标了 unsafe,调用者要自己保证指针有效且以 NUL 结尾:
unsafe {
let len: Int64 = pc.length() // 扫描到 NUL 为止
let arr: Array<UInt16> = pc.toArray()
let lossy: String = pc.toStringLossy()
let maybe: Option<String> = pc.toString() // toString 返回 Option<String>(解码失败为 None)
let hs: HString = pc.toHString() // 需要 HString 时用 toHString()
}
这些指针类型不管理生命周期:底层内存属于产生它的那一方(一个还活着的
HString、一个 Win32 调用栈上的缓冲等)。PCWSTR/PWSTR失效与否完全取决于那块内存。
CWideString:喂给 Win32 入参的宽缓冲
当你要把一个仓颉字符串作为 LPCWSTR 传给 Win32 函数时,CWideString 是最顺手的选择:它把 String 编码成以 NUL 结尾的 UTF-16 缓冲并由仓颉持有,再借出指针给你调用:
let title = CWideString("提示")
title.withPtr { ptr =>
// ptr: CPointer<UInt16>,以 NUL 结尾,可直接传给 MessageBoxW 之类
}
let n: Int64 = title.length() // 逻辑长度(不含结尾 NUL)
// 反向:从一个以 NUL 结尾的宽指针读回 String
let back: String = CWideString.fromPointer(ptr)
CWideString 也实现了 Resource,但它持有的是仓颉托管数组,close() 只是打个标记,主要的内存由 GC 回收。它和上面三者的分工是:输入参数走 CWideString,输出 / 借用走 PWSTR / PCWSTR,COM 走 BSTR,WinRT 走 HString。
字面量风格的构造(无编译期宏)
仓颉没有编译期字面量宏,所以 windows-strings 用工厂 / 惰性单例来表达「写死的常量字符串」这件事。literals.cj 提供了一组工厂和便捷函数:
// 工厂:先建一次,可重复借出指针
let wf: WideStringFactory = wideStringFactory("C:\\Windows")
wf.withPtr { p => // p: PCWSTR
// ...
}
let cw: CWideString = wf.value()
// HString 工厂会缓存已创建的 HString(惰性单例)
let hf: HStringFactory = hStringFactory("Windows.Foundation")
let hs: HString = hf.value() // 第二次调用复用同一个未关闭的实例
// 便捷直达函数
let lit: HString = hStringLiteral("ready")
let wide: CWideString = wideStringLiteral("path")
HStringFactory.value() 内部缓存了上一次创建的 HString,只要它没被 close,重复取用就是同一个对象——这就是用惰性单例等价表达「编译期常量字符串」的方式。
内存与生命周期速记
| 类型 | 是否拥有底层存储 | 何时需要 close |
|---|---|---|
HString | 是(堆上的 UTF-16 缓冲) | 是,用 try-with-resource 或 close() |
BSTR(fromRawTake / 自分配) | 是(SysAllocStringLen) | 是 |
BSTR(fromRawView) | 否(借用) | 否,close() 不会 free |
CWideString | 持有托管数组 | close() 仅打标记,内存由 GC 回收 |
PCWSTR / PWSTR | 否(纯借用指针) | 无需,生命周期跟随被指向的内存 |
记住一条主线:拥有原生堆内存的类型(HString、自分配的 BSTR)才需要确定性回收;其余要么是借用指针,要么由 GC 兜底。 对象之间的引用全部由 GC 管理,你不需要手动 AddRef/Release。
下一步 / 相关
字符串处理之后,下一道绕不开的关是错误码——几乎每个 Windows API 都用 HRESULT 或 BOOL 报告成败。继续阅读:错误处理与 HRESULT。
错误处理与 HRESULT
Windows API 报告成败的方式五花八门:COM 和 WinRT 用 HRESULT,传统 Win32 函数常返回 BOOL 再让你去查 GetLastError,内核 / 驱动用 NTSTATUS,注册表等子系统用 WIN32_ERROR。windows-result 把这些统一成几个可组合的类型,并提供 Result<T> 让你在「抛异常」和「显式传播」之间自由选择。
它们都来自 windows_result 包。
HRESULT:成功还是失败
HRESULT 是一个 32 位结果码(struct,值类型),内部是一个 Int32。判定规则很简单:非负为成功,负数为失败(最高位是 SEVERITY 位)。
import windows_result.*
let hr = HRESULT(0i32) // S_OK
hr.isOk() // true —— value >= 0
hr.isErr() // false —— value < 0
hr.succeeded() // isOk() 的别名,贴近 Win32 SUCCEEDED 习惯
hr.failed() // isErr() 的别名,贴近 FAILED
包里预定义了常用的 HRESULT 常量:
S_OK // HRESULT(0)
S_FALSE // HRESULT(1),注意它也是「成功」
E_FAIL
E_INVALIDARG
E_NOINTERFACE
E_NOTIMPL
E_POINTER
E_OUTOFMEMORY
E_ACCESSDENIED
// …
HRESULT 还能取人类可读的消息、格式化成十六进制:
let hr = E_INVALIDARG
println(hr.toString()) // "0x80070057"(8 位大写十六进制)
println(hr.message()) // 调 FormatMessageW 拿系统消息文本
从 HRESULT 得到 Result 或抛异常
HRESULT 提供了三种「往下走」的方式:
let hr: HRESULT = someComCall()
// 1) 转成 Result<Unit>:成功 Ok(()),失败 Err(Error)
let r: Result<Unit> = hr.ok()
// 2) 失败就抛 WindowsException,成功正常返回
hr.check()
// 3) 串联后续操作:成功才执行 op,并把返回值包成 Ok
let mapped: Result<Int64> = hr.map { => computeSomething() }
let chained: Result<Int64> = hr.andThen { => anotherFallibleStep() }
还有 unwrap() / expect(message),失败时直接 panic(适合「这里绝不该失败」的断言场景)。
Result<T>:可失败操作的显式表达
Result<T> 是一个 enum,要么 Ok(T),要么 Err(Error)。当一个操作可能失败、而你想让调用方决定怎么处理(而不是直接抛异常)时,就返回 Result<T>。
public enum Result<T> {
| Ok(T)
| Err(Error)
}
取值、判定、传播:
let r: Result<Int64> = doWork()
// 判定
r.isOk()
r.isErr()
// 取值(失败会 panic)
let v: Int64 = r.unwrap()
let v2: Int64 = r.expect("doWork 不该失败")
// 带默认值地取
let v3: Int64 = r.unwrapOr(0)
let v4: Int64 = r.unwrapOrElse { e => fallback(e) }
// 用 match 解构,两条路径都显式处理
match (r) {
case Ok(value) => println("成功:${value}")
case Err(error) => println("失败:${error}")
}
链式组合(成功才继续,失败直接短路传播):
let pipeline: Result<String> =
doWork()
.map { n => n * 2 } // 变换 Ok 值
.andThen { n => formatResult(n) } // 继续一个可失败步骤
.mapErr { e => e } // 变换 Err
// 想退回 Option,丢弃错误细节
let opt: Option<Int64> = doWork().ok()
let errOpt: Option<Error> = doWork().err()
Err 携带的是 Error 类型,它封装了一个 HRESULT 以及(在 Windows 上)可选的 IErrorInfo 富信息:
match (doWork()) {
case Ok(_) => ()
case Err(error) =>
let code: HRESULT = error.code() // 拿到底层 HRESULT
println(error.message()) // 错误文本
println(error.toString()) // "文本 (0x........)"
}
需要自己造一个错误时:Error.fromHRESULT(hr)、Error.fromWin32(code)、Error.fromThread()(读取当前线程的 last error),或带消息的 Error(code, "出错原因")。
BOOL:和仓颉 Bool 互转
很多 Win32 函数返回 BOOL(其实是 Int32,非零为真)。windows-result 的 BOOL 是值类型,提供了和仓颉 Bool 的双向转换:
// Bool -> BOOL
let b1: BOOL = BOOL.from(true)
let b2: BOOL = BOOL.fromBool(false)
let b3: BOOL = BOOL(true) // 构造器也接受 Bool
// 预定义常量
let yes = TRUE // BOOL(1)
let no = FALSE // BOOL(0)
// BOOL -> Bool
let ok: Bool = b1.as_bool() // value != 0
// 取反
let flipped: BOOL = !b1
更有用的是把 BOOL 直接转成错误处理流程——失败时它会去读 GetLastError:
let result: BOOL = someWin32Call()
// 1) 转 Result<Unit>:true -> Ok(()),false -> Err(errorFromThread())
let r: Result<Unit> = result.ok()
// 2) false 就 panic
result.unwrap()
// 3) false 就 panic,带自定义前缀
result.expect("someWin32Call 失败")
BOOL.ok() 在 false 时调用的 errorFromThread() 会捕获当前线程的 last error,这正好匹配 Win32「返回 FALSE,错误码在 GetLastError 里」的约定。
底座类型:WIN32_ERROR、NTSTATUS、GUID
WIN32_ERROR —— 无符号 Win32 错误码(注册表、服务等子系统常用)。0 表示 ERROR_SUCCESS,可以转成 HRESULT 或 Error:
let err = WIN32_ERROR(5u32) // ERROR_ACCESS_DENIED
err.isOk() // false(非 0)
let hr: HRESULT = err.toHRESULT() // 编码成 FACILITY_WIN32(0x8007xxxx)
let r: Result<Unit> = err.ok()
let last = WIN32_ERROR.fromThread() // 读 GetLastError
NTSTATUS —— NT 内核 / 驱动状态码,同样「非负为成功」,可 toHRESULT()(失败码会被打上 FACILITY_NT_BIT)、ok()、unwrap()。
GUID —— 16 字节的接口 / 类标识符,是 @C struct(保证和 Windows ABI 布局一致),支持 == / != 比较。你在 COM QueryInterface、WinRT 激活时会反复用到它;这里只需知道它是底座类型,由更上层的包按需构造。
这几者最终都能汇流到 HRESULT / Error,所以你的错误处理可以统一在一条管线上。
串起来:调用一个返回 HRESULT 的操作
下面是一个把上述类型组合起来的典型形态——一个会返回 HRESULT 的操作,成功 / 失败两条路径都显式处理:
import windows_result.*
// 假设它封装了某个 COM / WinRT 调用,返回 HRESULT
func openWidget(name: String): HRESULT {
// … 实际调用底层 API,把返回的 i32 包成 HRESULT(...) …
HRESULT(0i32)
}
func useWidget(name: String): Result<Unit> {
let hr = openWidget(name)
// 把 HRESULT 转成 Result,让调用方决定如何处理
match (hr.ok()) {
case Ok(_) =>
println("打开成功:${name}")
Result<Unit>.Ok(())
case Err(error) =>
println("打开失败:${error}") // 含文本和 0x.... 码
println("HRESULT = ${error.code()}") // 底层 HRESULT
Result<Unit>.Err(error)
}
}
main() {
match (useWidget("display")) {
case Ok(_) => println("一切就绪")
case Err(error) => println("放弃,原因:${error.message()}")
}
}
如果你更想用「失败即抛异常」的风格,可以把中间那段换成一行 hr.check()——它在失败时抛 WindowsException(其 .code() 给出 HRESULT),成功则继续往下。两种风格按场景选用即可:库的内部边界倾向 Result<T> 显式传播,应用顶层用 check() / 异常更省事。
下一步 / 相关
掌握了字符串和错误码这两块底座,你就可以正式开始消费真正的接口了。继续阅读:调用 COM API 与查询接口。
调用 COM API 与查询接口
很多 Windows 能力是以 COM 接口的形式暴露的。这一页讲清楚 COM 的核心模型,以及 windows-cj 如何把它映射进仓颉:怎样拿到一个接口、调用它的方法、查询它实现的其它接口,以及如何正确管理原生 COM 指针的生命周期。
当某段代码看起来与你在其它语言里的写法不同时,多半是因为它遵循了仓颉的范式,而非缺失了功能。
COM 模型速讲
一个 COM 对象在内存里是一个指向 vtable(虚函数表)的指针。vtable 是一组按固定顺序排列的函数指针,调用方通过“第几个槽位”来调用方法,这就是 COM 的 ABI 约定。
每个 COM 接口都继承自 IUnknown,它的前三个槽位永远是:
槽 0: QueryInterface(riid, ppvObject) —— 问“你支不支持这个接口?”
槽 1: AddRef() —— 引用计数 +1
槽 2: Release() —— 引用计数 -1,归零则销毁
- 引用计数决定对象何时销毁:每持有一份引用就
AddRef,用完就Release。 QueryInterface用一个 IID(接口 GUID)问对象要另一个接口;支持就返回新指针(并已AddRef),不支持就返回E_NOINTERFACE。
windows-cj 怎么映射
底座类型定义在 windows-interface 包里,并由 windows-core 重导出(你通常 import windows_core 即可)。关键约定:
- 接口契约
ComInterface:每个 COM 接口包装类都实现它,提供asRaw(): CPointer<Unit>(拿到底层指针)和静态的iid()。 (来源:windows-interface/src/interface_descriptor.cj) - vtable 用
@C struct表达,例如IUnknownVtbl、IInspectableVtbl。它们的字段是CFunc<...>,第一个字段base_内联基接口的 vtable,从而复刻 C 的内存布局。 (来源:windows-interface/src/interface_wrapper.cj) - 接口包装类(
IUnknown、IInspectable等)继承自InterfaceWrapperBase,它实现了Resource,负责持有原生指针并在关闭时Release。 InterfaceDescriptor<T>是“调用侧契约”:它把一个接口名、IID、以及“如何从 ABI 指针构造仓颉包装”绑在一起。查询和投影都围绕它进行。
仓颉是 GC 语言。仓颉对象之间的引用由 GC 管理,你不需要、也不应该手写 AddRef/Release 来维持仓颉侧的存活。AddRef/Release 只在跨越仓颉 ↔ 原生 COM 边界、需要明确“谁拥有这块原生引用”时才出现,而 windows-cj 已经把这部分封装进了 Resource 生命周期里(见本页最后一节)。
拿到一个接口并调用它的方法
接口包装类把每个 vtable 槽位暴露成一个普通方法。因为调用最终会读裸指针、走 CFunc,所以这些方法标了 unsafe,需要在 unsafe { } 块里调用。
以 IInspectable(所有 WinRT 对象都实现它)为例,它的方法直接对应 vtable 槽位:
import windows_core.*
// 假设你从某处拿到了一个实现 IInspectable 的对象包装 inspectable: IInspectable
func printRuntimeClass(inspectable: IInspectable): Unit {
// getRuntimeClassName 内部读 vtbl,调用 GetRuntimeClassName 槽位,
// 并把返回的 HSTRING 包装成 HString(失败时抛 WindowsException)
let name = unsafe { inspectable.getRuntimeClassName() }
println("运行时类名: ${name}")
}
IInspectable 还提供了底层一点的入口,比如直接读 vtable 调用某个槽位:
// 直接走 ABI:传入 out 槽位指针,拿回 HRESULT
var trustLevel = 0i32
let hr = unsafe { inspectable.getTrustLevel(CPointer<Int32>(inout trustLevel)) }
if (hr.succeeded()) {
println("trust level = ${trustLevel}")
}
要点:
- 方法返回
HRESULT(薄封装的Int32),用.succeeded()/.failed()判断,或在更高层封装里直接抛WindowsException。 - out 参数是
CPointer<...>,用CPointer<T>(inout localVar)取本地变量地址传进去——这就是 COM 的“传出参数”约定。
绝大多数 Win32/WinRT 接口由绑定生成器产出,使用方式与上面一致:拿到包装对象,unsafe { obj.SomeMethod(...) }。
查询接口(QueryInterface)
“这个对象还实现了别的接口吗?”——这正是 QueryInterface 回答的问题。windows-cj 提供了几条等价路径,全部基于 InterfaceDescriptor<T>。
顶层 cast:返回 Result<U>
cast 把一个接口查询成另一个接口,成功给 Result.Ok(目标接口),失败给 Result.Err(错误):
import windows_core.*
// 把任意 ComInterface 查询成 IAgileObject
func tryAsAgile<T>(source: T): Option<IAgileObject> where T <: ComInterface & Interface<T> {
match (unsafe { cast<T, IAgileObject>(source, IAgileObject.descriptor()) }) {
case Result<IAgileObject>.Ok(agile) => Some(agile)
case Result<IAgileObject>.Err(_) => None
}
}
(cast 来源:windows-core/src/interface.cj;它内部调用 queryInterfaceResult,对失败结果会正确 Release 出参,避免泄漏。)
从裸指针直接查询:返回 Option
如果手头是一个裸 CPointer<Unit>,可以用 queryInterfaceAs(成功返回 Some(包装),否则 None),或更底层的 queryInterfaceRaw(返回 Option<CPointer<Unit>>):
// 用 descriptor 一步查询并包装
match (unsafe { queryInterfaceAs(rawPointer, IInspectable.descriptor()) }) {
case Some(inspectable) => unsafe { inspectable.getRuntimeClassName() }
case None => throw Exception("对象未实现 IInspectable")
}
(来源:windows-interface/src/interface_wrapper.cj 的 queryInterfaceRaw / queryInterfaceAs,由 windows-core 重导出。)
包装对象上的 .query(...)
InterfaceWrapperBase 自带一个实例方法 query<T>(descriptor),等价于“拿自己的 asRaw() 去 QueryInterface”,返回 Option<T>:
// inspectable 是某个接口包装;查询它是否也实现 IWeakReferenceSource
match (unsafe { inspectable.query(IWeakReferenceSource.descriptor()) }) {
case Some(source) => /* 拿到 IWeakReferenceSource */ ()
case None => ()
}
无论走哪条路径,失败时你拿到的是 None 或 Result.Err,而不是 null——仓颉没有 null,缺失值一律用 Option<T>/Result<T> 表达。
AgileReference<T>:跨线程安全传递接口
很多 COM 对象绑定在创建它的单元(apartment)上,不能裸跨线程使用。AgileReference<T> 把一个接口包成可在任意线程解引用的敏捷引用,底层调用 RoGetAgileReference。
import windows_core.*
// 把一个接口对象 obj 包成敏捷引用
func makeAgile<T>(obj: T, descriptor: InterfaceDescriptor<T>): Result<AgileReference<T>> where T <: ComInterface {
AgileReference<T>.new(obj, descriptor)
}
// 在另一个线程里解引用,重新拿回接口
func useOnOtherThread<T>(reference: AgileReference<T>): Result<T> where T <: ComInterface {
reference.resolve() // 返回 Result<T>:Ok(接口) 或 Err(HRESULT)
}
(来源:windows-core/src/agile_reference.cj。签名为 AgileReference<T>.new(object: T, descriptor: InterfaceDescriptor<T>): Result<AgileReference<T>> 与 resolve(): Result<T>,其中 T <: ComInterface。)
AgileReference<T> 本身实现 Resource:用完后 close() 会释放它内部持有的敏捷引用。
原生 COM 指针的生命周期
这是与“GC 管理仓颉对象”相对的另一半:原生 COM 引用不归 GC 管,必须确定性地释放。 windows-cj 的做法是让接口包装实现 Resource:
InterfaceWrapperBase(所有接口包装的基类)实现Resource,提供close()。它内部用closed_: Bool作回收标记,并对“拥有所有权的句柄”用一个带原子标志的OwnedHandleState兜底——这样 析构器~init与显式close()不会重复Release(避免 double free)。 (来源:windows-interface/src/interface_wrapper.cj)- 取得所有权的包装(
fromAbiTake系列,对应 QueryInterface/激活返回的“已 AddRef”指针)在close()时正好做一次Release;只是“借用视图”(viewOf)则不释放。 closed_标记保证close()幂等:第二次调用直接返回,不会二次释放。
实践上,用仓颉的 try-finally 做确定性回收(与官方 Resource 的 try-with-resource 语义一致):
import windows_core.*
func consume(rawFromQuery: CPointer<Unit>): Unit {
// fromAbiTake 接管所有权:close() 时会 Release 一次
let inspectable = IInspectable.fromAbiTake(rawFromQuery)
try {
let name = unsafe { inspectable.getRuntimeClassName() }
println(name)
} finally {
inspectable.close() // 确定性释放原生引用
}
}
对于由你实现并交给系统的对象(ComObject<T>),生命周期略有不同——它用引用计数驱动 releaseBase(),详见下一页。
要点回顾:
- 仓颉对象之间的引用:GC 管,不要手写 AddRef/Release 去维持。
- 跨边界的原生 COM 引用:用
Resource+close()确定性回收,回收标记防 double free。 - 缺失值用
Option<T>,错误用Result<T>/HRESULT,没有null。 - 接口实现关系用
<:(如IUnknown <: InterfaceWrapperBase & ComInterface)。
下一步 / 相关
- 实现 COM 接口:当你需要把一个仓颉对象交给系统/其它 COM 组件时。
- 调用 WinRT API:基于
IInspectable的更高层投影。 - 错误处理:
HRESULT/Result<T>/WindowsException的完整用法。 - 字符串:
HString/HSTRING与 COM/WinRT 的交互。
实现 COM 接口
上一页讲的是消费已有的 COM 接口;这一页讲反方向:实现一个 COM 接口,让你的仓颉对象能被系统或其它 COM 组件回调。windows-cj 用 windows-interface(声明接口)+ windows-implement(在仓颉类上实现)两个包配合完成。
这套机制不是“移植某门语言的 trait”,而是仓颉对 COM vtable ABI 的等价表达。当它的形态和你在别处见过的写法不同时,是因为它在 GC 语言里复刻了原生 vtable 的内存布局与路由,而不是缺了什么。
什么时候需要“实现”一个接口
- 回调 / 事件处理:系统要求你传入一个实现了某接口的对象,之后由它回调你(典型如 WinRT 的 handler、枚举回调)。
- 把仓颉对象交给系统或其它 COM 组件:对方拿到的是一个标准 COM 指针(带 vtable、能
QueryInterface、能引用计数),但背后跑的是你的仓颉代码。
如果你只是调用别人的接口,不需要本页内容——回到调用 COM API即可。
仓颉范式下的实现机制
先理解一个核心约束,后面的设计就都顺理成章了。
COM 要求暴露给原生侧的是一个 @C struct vtable(一排 CFunc 函数指针),它的内存布局必须严格符合 C ABI。而仓颉官方文档明确规定:@C 修饰的 struct 成员类型必须满足 CType 约束、不能实现或扩展接口、不能持有 managed(GC)对象。
所以 vtable 不能直接内嵌一个指向“你的仓颉对象”的引用。windows-cj 用 slot header(槽位头)+ registry(注册表)查找 来解决:
原生侧拿到的指针 ─► ComBox 槽(@C struct,第一字段是 vtable 指针)
│ rootKey: UIntNative ← 用作注册表键
▼
comRegistry: HashMap<UIntNative, Object>
│
▼
你的仓颉对象(GC 管理)
ComBox是一个@C struct,第一个字段就是 vtable 指针,所以这块槽可以被直接当成 COM 接口指针看待;额外字段(rootOffset/slotIndex/slotCount/rootKey)记录它在多接口对象里的位置和回查键。 (来源:windows-implement/src/com_box.cj)- vtable 里每个
CFuncthunk 收到原生调用时,第一个参数是这块槽的裸指针。thunk 先用rootRegistryKeyFromRaw(...)把它换算成根对象的注册表键,再去comRegistry(一个受Mutex保护的HashMap<UIntNative, Object>)里comRegistryLookup找回你的仓颉对象,最后把调用转发过去。 (来源:windows-implement/src/com_registry.cj、windows-implement/src/com_box.cj)
引用计数和 QueryInterface 也由这套运行时统一处理:ComObjectRuntime<T> 用原子计数实现 AddRef/Release,queryInterfaceBase 按 IID 在已解析的描述符里查对应槽位返回指针;归零时从注册表里摘除并关闭内部资源。IUnknown/IInspectable/IAgileObject/IMarshal/IWeakReferenceSource 这些“系统接口”会被自动接住,无需你实现。
(来源:windows-implement/src/interface_impl_surface.cj)
深继承链与 slot 计数
当接口有继承链(IDerived <: IBase)或一个类实现多个接口时,运行时要为每个“自定义接口”分配一个独立的 vtable 槽(slot 0 永远留给身份/IUnknown 或 IInspectable)。
InterfaceDescriptor通过ownMethodCount/descriptorOwnMethodCount()记录“本接口自己新增的方法数”,并把祖先 IID 展开进ancestorIids,使QueryInterface能正确命中继承来的接口 IID。 (来源:windows-interface/src/interface_descriptor.cj)- 运行时按继承深度挑选每个槽该用哪个描述符(深度更大的派生接口优先占槽),保证派生接口的 vtable 覆盖到全部继承方法。
(来源:
windows-implement/src/interface_impl_surface.cj的requiredDescriptorIndicesForCustomSlots)
好消息是:这些你都不用手写。下面两个宏会替你生成 vtable 结构、thunk、描述符和实现壳。
第一步:用 windows-interface 声明接口
用 @Interface 宏修饰一个 interface,把 IID 和(可选的)运行时类名写在方括号里。宏会自动生成对应的 Vtbl(@C struct)、InterfaceDescriptor、descriptorSchema()、包装类,以及给实现侧用的 XXX_Impl 接口。
方法当前以原始 ABI 形式声明:返回 Int32(HRESULT),参数是 ABI 类型。
import windows_interface.macros.Interface
@Interface["11111111-1111-1111-1111-111111111111", "Fixture.Counter"]
public interface IFixtureCounter {
func Count(value: UInt32): Int32
}
继承也支持,用 <: 指定单一直接基接口:
@Interface["33333333-3333-3333-3333-333333333333", "Fixture.Base"]
public interface IFixtureBase {
func BaseValue(value: UInt32): Int32
}
@Interface["44444444-4444-4444-4444-444444444444", "Fixture.Derived"]
public interface IFixtureDerived <: IFixtureBase {
func DerivedValue(value: UInt32): Int32
}
(以上写法与 windows-interface/tests/macros/interface_implement_fixture.cj 的真实 fixture 完全一致。宏定义见 windows-interface/src/macros/windows_interface_macros.cj 的 public macro Interface。)
宏生成的产物里有几个关键件,后面会用到:
IFixtureCounter.descriptor()——InterfaceDescriptor<IFixtureCounter>,查询/投影时用。IFixtureCounter.descriptorSchema()—— 描述符 schema,建对象时用。IFixtureCounter.vtablePtr()—— 指向该接口 vtable 的原生指针(一份共享单例,@C struct没有 const fn,所以用 lazy static 等价表达)。IFixtureCounter_Impl—— 你的类需要实现的接口(由@Implement自动挂上)。
第二步:用 windows-implement 在仓颉类上实现
用 @Implement 宏修饰你的 class,方括号里列出要实现的接口名(可多个)。宏会把对应的 _Impl 接口加到类的父类型上,并生成一个 toComObject(...) 扩展方法,帮你把这个对象包成可交给原生侧的 ComObject<T>。
你只需要写出每个接口方法的方法体——参数和返回类型与接口声明里一致:
import windows_core.{ComObject, E_INVALIDARG, S_FALSE}
import windows_interface.macros.Implement
@Implement[IFixtureCounter]
public class FixtureCounter {
public func Count(value: UInt32): Int32 {
if (value == 7u32) {
return S_FALSE.value
}
E_INVALIDARG.value
}
}
// 把仓颉对象包成 COM 对象,交给系统/其它组件
public func createFixtureCounter(): ComObject<FixtureCounter> {
FixtureCounter().toComObject()
}
实现多个接口,只要在 @Implement 里列出来,并把每个接口的方法都写齐:
@Implement[IFixtureCounter, IFixtureLabel]
public class CompositeFixtureCounter {
public func Count(value: UInt32): Int32 { /* ... */ E_INVALIDARG.value }
public func Label(value: UInt32): Int32 { /* ... */ E_INVALIDARG.value }
}
(以上同样来自真实 fixture。@Implement 宏定义见 windows-interface/src/macros/windows_interface_macros.cj;生成的 toComObject 内部调用 windows_core.createImplementedComObject(this, schema(s), vtablePtr(s), agile: ...),该函数定义在 windows-implement/src/class_factory.cj。)
toComObject 默认 agile: Bool = true,即让对象额外支持 IAgileObject / IMarshal(自由线程封送),这通常是你想要的。
第三步:使用并回收 ComObject<T>
ComObject<T> 既是“你的实现的持有者”,也是它的 COM 身份。它实现 Resource,用引用计数管理生命周期:
import windows_core.*
let object = FixtureCounter().toComObject()
try {
// 取出某个接口视图来调用(toInterface 走 QueryInterface 路由到对应槽)
let counter = object.toInterface(IFixtureCounter.descriptor())
try {
let hr = unsafe { counter.Count(7u32) }
println("hr = ${hr.value}")
} finally {
counter.close() // 释放这份接口引用
}
} finally {
let _ = object.releaseBase() // 引用计数 -1,归零时销毁并从注册表摘除
}
(这正是 fixture main() 里的真实用法,见 windows-interface/tests/macros/interface_implement_fixture.cj。)
要点:
ComObject<T>的releaseBase()/close()走的是引用计数;归零时运行时会从comRegistry摘除注册项、关闭内部资源、释放 native vtable/槽句柄。回收标记(closed+ 运行时的destroyed原子标志)防止重复释放。- 取出的接口视图(
toInterface的返回值)是独立的Resource,单独close()。 - 你不需要为仓颉对象之间的引用手写 AddRef/Release——GC 管这部分;引用计数只对应“原生侧持有了多少份这个 COM 身份”。
自由线程封送(Free-Threaded Marshaler)
当 agile: true 时,运行时会在 QueryInterface(IID_IMarshal) 上提供一个自由线程封送器,使对象可以安全跨单元使用。windows-implement 通过 CoCreateFreeThreadedMarshaler(ole32.dll)按需创建它:
// windows-implement 内部封装:为 outer 创建自由线程封送器,失败返回 None
public func createFreeThreadedMarshaler(outer: CPointer<Unit>): Option<IMarshal>
(来源:windows-implement/src/agile_impl.cj 与 windows-implement/src/native.cj。一般情况下你不直接调用它——toComObject(agile: true) 已经把这条路接好了。)
一句话总结
你写的只是一个普通仓颉 class + 两个宏注解;windows-cj 在背后用 @C struct vtable + slot header + 注册表查找,把原生 COM 调用准确路由回你的对象,并用引用计数 + Resource 回收标记管好生命周期。这是对 COM vtable ABI 的等价表达,不是某门语言概念的搬运。
下一步 / 相关
- 调用 COM API 与查询接口:消费侧的视角与
QueryInterface。 - 调用 WinRT API:基于本页机制的更高层 WinRT 投影。
- 错误处理:
HRESULT/Result<T>与从 thunk 返回错误码。 - 包结构总览:
windows-interface/windows-implement在工作区里的位置。
调用 WinRT API
WinRT(Windows Runtime)是 Win32 之上更现代的一层。如果说 Win32 是一组扁平的 C 函数,COM 是带 vtable 的接口,那么 WinRT 就是在 COM 之上再加了一套运行时类型系统:每个类型都有运行时类名、每个对象都实现 IInspectable、字符串统一用 HSTRING、对象通过激活工厂创建、泛型类型在运行时按签名投影。
越往上越现代、越贴近仓颉习惯——但底层仍然是 COM 指针和 vtable 槽位,所以前一页 COM API 的所有规则(Resource 回收、Option<T> 代替 null、<: 实现接口)在这里继续适用。
WinRT 是什么
把 WinRT 拆成几条具体约定,理解起来就清晰了:
- 建立在 COM 之上。 每个 WinRT 接口仍然是一个 vtable 指针,前三槽是
IUnknown的QueryInterface/AddRef/Release。 - 统一根接口
IInspectable。 所有 WinRT 对象都实现它(紧跟在IUnknown之后的三个槽:GetIids/GetRuntimeClassName/GetTrustLevel),所以任何 WinRT 对象都能问出自己的运行时类名。 - 运行时类型系统。 类型有字符串签名(如
"rc(Windows.Foundation.Uri;{...})"),泛型接口的 IID 由签名计算得出——这就是 windows-cj 里IReference<T>.iid()调用GUID.from_signature(...)的原因。 - 激活工厂。 你不能直接
new一个 WinRT 运行时类;而是先拿到它的激活工厂(一个静态接口),再调用工厂上的CreateXxx。windows-cj 把这套流程封装好了。 - 字符串用
HSTRING。 WinRT 方法收发字符串都用HSTRING,在仓颉里对应HString(见 字符串)。
windows-cj 把这些投影分散在几个包里:基础值类型与激活底座在 windows-core,Foundation 投影(Uri、PropertyValue、MemoryBuffer、Deferral 等)在 windows-foundation,集合在 windows-collections,异步在 windows-future。
一个完整的例子:创建并使用 Uri
Windows.Foundation.Uri 是最适合入门的 WinRT 运行时类:它要走激活工厂、收发 HString、并实现了 IStringable。
先在 cjpm.toml 里声明依赖(路径按你的仓库布局调整):
[dependencies]
windows_core = { path = "../windows-core" }
windows_foundation = { path = "../windows-foundation" }
然后:
import windows_core.*
import windows_foundation.*
func demoUri(): Unit {
// HSTRING 形式的输入字符串。HString 实现 Resource,用完要 close。
let uriText = HString("https://example.com/path?x=1&y=two")
try {
// Uri.CreateUri 内部走激活工厂:拿到 IUriRuntimeClassFactory,
// 调用 CreateUri 槽位,把返回指针包成 Uri。
// 这是静态工厂方法,标了 unsafe(最终读裸指针、走 CFunc)。
let uri = unsafe { Uri.CreateUri(uriText) }
try {
// AbsoluteUri / Domain / Port 等都对应 IUriRuntimeClass 的 vtable 槽位,
// 返回 HString 的方法把 HSTRING 包成 HString 交给你。
let absolute = unsafe { uri.AbsoluteUri() }
try {
println("绝对地址: ${absolute.get()}") // get() 取出仓颉 String
} finally {
absolute.close()
}
println("端口: ${unsafe { uri.Port() }}")
// Uri 实现了 IStringable,所以可以 ToString()(同样返回 HString)。
let text = unsafe { uri.ToString() }
try {
println("ToString: ${text.get()}")
} finally {
text.close()
}
} finally {
uri.close() // 确定性释放原生 COM 引用
}
} finally {
uriText.close()
}
}
要点:
Uri.CreateUri(...)是激活工厂调用的便捷封装。它内部用windows_core.factory<Uri, IUriRuntimeClassFactory>()取到工厂、调CreateUri、Uri.fromAbiTake(result)接管返回指针。(来源:windows-foundation/src/foundation_runtime.cj的Uri.CreateUri/IUriRuntimeClassFactory.CreateUri。)- WinRT 投影方法标
unsafe,因为底层读裸指针、走CFunc——在unsafe { }块里调用即可。 - 收发字符串用
HString;返回的HString由你负责close()。HString.get()把它转成仓颉String。 - 错误以
WindowsException(HRESULT 失败)抛出,按 错误处理 的方式接住。
这个 Uri 例子真实可信
windows-foundation 的冒烟测试就跑了几乎一模一样的流程:Uri.CreateUri(HString(...)) → AbsoluteUri() → QueryParsed() 拿到 WwwFormUrlDecoder → 遍历查询参数,全部跨真实 vtable ABI 往返。(来源:windows-foundation/src/windows_foundation_smoke_test.cj。)
激活工厂缓存做了什么
“拿到工厂”这一步并不便宜——它要按运行时类名去 combase.dll 里查 DLL、解析激活工厂、QueryInterface 到你要的工厂接口。windows-cj 把这条路径集中在 windows-core/src/factory_cache.cj 里:
loadFactoryByName<I>(runtimeName, descriptor)/factory<C, I>():核心入口。先调RoGetActivationFactory(系统激活);若返回“类未注册”,再回退到 reg-free 路径——按命名空间逐段截断猜 DLL 名(Windows.Foundation.Uri→Windows.Foundation.dll→Windows.dll),用DllGetActivationFactory取工厂。- MTA 兜底:若激活返回
CO_E_NOTINITIALIZED,会先CoIncrementMTAUsage把当前进程并入 MTA,再重试一次。这个 cookie 进程内只取一次(FactoryMTAUsageGuard),不会每次回退都泄漏。 FactoryCache<C, I>:可选的工厂缓存。它只缓存满足IAgileObject(敏捷)的工厂——非敏捷工厂不缓存,避免跨单元误用。缓存本身实现Resource,close()时释放缓存的工厂指针;closed标记防止~init与close()重复释放。
你平时不会直接碰这些——Uri.CreateUri、PropertyValue.CreateInt32 这类便捷方法已经替你调用了 factory<...>()。理解它存在的意义在于:WinRT 对象的“构造”实质是一次激活工厂查找 + 一次工厂方法调用。
Type 投影:ABI 类型如何映射到仓颉
WinRT 的每个参数/返回值在 ABI 层都有一个具体的 C 表示(HSTRING、Int32、内联结构体、接口指针……),而在仓颉侧你想用的是 HString、Int32、Point、接口包装类。把两者对应起来的,是 windows-core/src/type_system.cj 里的 Type 接口:
public interface Type<TProjected, TAbi, TDefault> <: WindowsType {
static func typeKind(): TypeKind // Interface / Clone / Copy
static func projectAbi(value: TProjected): TAbi
static func assumeInitRef(abi: TAbi): TProjected
static func fromAbi(abi: TAbi): Result<TProjected>
static func fromDefault(defaultValue: TDefault): Result<TProjected>
}
理解这套设计的几个点:
- 三个类型参数而非关联类型。 仓颉泛型不支持关联类型,所以这里用
Type<TProjected, TAbi, TDefault>把“仓颉投影类型 / ABI 表示 / 默认值类型”三者显式列出。这是语言范式差异下的等价方案,不是缺陷。 TypeKind区分三类(type_system.cj的enum TypeKind { | Interface | Clone | Copy }):值类型(Copy,如DateTime)、需要克隆的类型(Clone,如HString)、接口(Interface)。projectAbi/assumeInitRef是一对:把仓颉值打成 ABI 结构、再从 ABI 结构还原仓颉值。泛型方法把这对操作借给 vtable 调用,让IReference<T>.Value()、IVector<T>.GetAt(i)这类泛型方法能在运行时按T正确收发。
你写业务代码时几乎不会直接实现 Type——绑定生成器为每个值类型生成好了。但当你看到泛型方法签名里要求 where T <: windows_core.RuntimeType & windows_core.WinrtGenericType<T> 时,背后就是这套投影在工作。
Foundation 值类型与 IReference<T> 装箱
WinRT 有一批小的内联值类型,windows-cj 把它们定义在 windows-core/src/foundation_values.cj(放在 core 是为了让 collections 和 foundation 都能用而不形成依赖环):
| 仓颉类型 | 字段 | WinRT 含义 |
|---|---|---|
DateTime | UniversalTime: Int64 | 绝对时间点(100ns 刻度) |
TimeSpan | Duration: Int64 | 时间间隔(100ns 刻度) |
Point | X, Y: Float32 | 二维点 |
Size | Width, Height: Float32 | 尺寸 |
Rect | X, Y, Width, Height: Float32 | 矩形 |
EventRegistrationToken | Value: Int64 | 事件注册句柄 |
它们都是仓颉 struct(值语义),并通过 extend 实现了 CopyType & Type<...>,带有 ABI 伴生结构(@C struct DateTimeAbi 等)用于跨边界传递:
import windows_core.*
let now = DateTime() // UniversalTime 默认 0
let topLeft = Point() // X = 0, Y = 0
var box = Rect()
box.Width = 320.0
box.Height = 240.0
装箱:PropertyValue 与 IReference<T>
WinRT 用 IReference<T>(“可空的盒子”)和 PropertyValue(装箱原语)在“需要 IInspectable 的地方传一个标量”。windows-foundation 的 PropertyValue 提供一组静态工厂,每个都返回装好的 IInspectable:
import windows_core.*
import windows_foundation.*
// 把一个 Int32 装箱成 IInspectable(内部走 IPropertyValueStatics 激活工厂)
let boxed = unsafe { PropertyValue.CreateInt32(42i32) }
try {
// 之后可以把 boxed 当作 IInspectable 传给任何接收装箱值的 WinRT API
// 也能 QueryInterface 回 IPropertyValue 读出原值。
()
} finally {
boxed.close()
}
(来源:windows-foundation/src/foundation_runtime.cj 的 PropertyValue.CreateInt32 / CreateString / CreateGuid / CreateDateTime / CreatePoint 等,以及它们背后的 IPropertyValueStatics。)
IReference<T> 是泛型版本:它的 Value() 方法按 T 投影出真实值,Type() 报告底层 PropertyType。它要求 T <: windows_core.RuntimeType & windows_core.WinrtGenericType<T>——也就是 T 必须是能在运行时投影的 WinRT 类型。
PropertyValue 还能装数组(CreateInt32Array(Array<Int32>)、CreateStringArray(Array<HString>) 等),对应 IReferenceArray<T>,用法同理。
事件:EventHandler<T> 与 TypedEventHandler<TSender, TResult>
WinRT 事件通过委托接口分发回调。windows-foundation 提供两个:
EventHandler<T>:经典事件,回调签名(sender: IInspectable, args: T)。TypedEventHandler<TSender, TResult>:强类型发送者与参数,回调签名(sender: TSender, args: TResult)。
两者都用静态 new(...) 接受一个闭包来构造——这正是仓颉处理回调的方式(无需手写 vtable):
import windows_core.*
import windows_foundation.*
// 构造一个 TypedEventHandler;闭包就是收到事件时执行的逻辑。
let handler = TypedEventHandler<IInspectable, IInspectable>.new(
{ sender: IInspectable, args: IInspectable =>
// 在这里处理事件。sender / args 由调用方传入。
let _ = sender
let _ = args
}
)
try {
// 之后把 handler 交给某个 WinRT 对象的 add_Xxx 事件注册方法,
// 它会返回一个 EventRegistrationToken,用于后续注销。
()
} finally {
handler.close()
}
(来源:windows-foundation/src/foundation_runtime.cj 的 TypedEventHandler.new(invoke: (TSender, TResult) -> Unit) 与 EventHandler.new(invoke: (InParam<IInspectable>, T) -> Unit)。两个泛型参数都受 WinrtGenericType 约束。)
注意 new(...) 构造出的 handler 是一个由仓颉对象支撑的 COM 对象——它的存活由 GC 管理,你不需要手写 AddRef/Release;只需在不再需要时 close() 释放底层 COM 包装。
范式提醒
- GC 管仓颉对象,你管原生引用。 接口包装实现
Resource,用try/finally或 try-with-resource 做确定性close();回收标记防 double free。 - 没有 null。 缺失值用
Option<T>,可失败结果用Result<T>/HRESULT。 - 接口实现用
<:,条件表达式必须带括号(if (hr.succeeded()))。 - 回调用闭包,不用手写委托类。
- 不要为了“对齐某种语言”而生造借用/所有权概念——仓颉的范式就是这样。
下一步 / 相关
- WinRT 集合:
IVector/IMap/IIterable家族与 stock 实现。 - WinRT 异步操作:
IAsyncOperation<T>/IAsyncAction与等待结果。 - 字符串:
HString/HSTRING的完整用法。 - 错误处理:
HRESULT/Result<T>/WindowsException。 - 调用 COM API:WinRT 底下那一层 COM 模型。
WinRT 集合
WinRT 有一套自己的集合接口家族,命名空间是 Windows.Foundation.Collections。它们都是 WinRT 泛型接口——建立在 COM 之上、IID 由签名计算、元素按 Type 投影。windows-cj 把它们投影在 windows-collections 包里。
这一页讲两件事:消费别人给你的 WinRT 集合(遍历、取元素),以及用 stock 实现把仓颉数据当作 WinRT 集合交出去。
越往上越贴近仓颉习惯:消费侧的迭代器直接接进了仓颉的 for-in。
集合接口家族
| 接口 | 语义 | 关键方法 |
|---|---|---|
IIterable<T> | 可迭代——能要到一个迭代器 | First(): IIterator<T> |
IIterator<T> | 游标——逐个走过元素 | Current()、HasCurrent()、MoveNext()、GetMany(...) |
IVectorView<T> | 只读索引序列 | GetAt(index)、Size()、IndexOf(...)、GetMany(...) |
IVector<T> | 可变索引序列 | 上面的 + SetAt、InsertAt、Append、RemoveAt、RemoveAtEnd、Clear、GetView()、ReplaceAll |
IMapView<K, V> | 只读键值映射 | Lookup(key)、Size()、HasKey(key)、Split(...) |
IMap<K, V> | 可变键值映射 | 上面的 + Insert、Remove、Clear、GetView() |
IKeyValuePair<K, V> | 映射里的一个条目 | Key()、Value() |
记忆方式:View 结尾的是只读快照,不带 View 的是可变集合,可变集合都能 GetView() 给出一份只读视图。IVector/IVectorView/IMap/IMapView 都继承 IIterable<T>,所以都能遍历。
(来源:windows-collections/src/collections_runtime.cj,每个接口的 descriptorSchema() 列出了它的真实 vtable 槽位与方法名。)
所有泛型参数都受同一套约束:where T <: windows_core.RuntimeType & windows_core.WinrtGenericType<T>——T 必须是可在运行时投影的 WinRT 类型(标量、HString、值类型、或接口)。
遍历一个集合
方式一:索引(Size + GetAt)
对 IVectorView<T> / IVector<T>,最直接的就是按下标取:
import windows_core.*
import windows_collections.*
// view: IVectorView<Int32>,从某个 WinRT API 拿到
func sumView(view: IVectorView<Int32>): Int64 {
var total: Int64 = 0
let count = unsafe { view.Size() } // 元素个数(UInt32)
var i = 0u32
while (i < count) {
let item = unsafe { view.GetAt(i) } // 第 i 个元素,按 T 投影回 Int32
total += Int64(item)
i += 1
}
total
}
windows-collections 的冒烟测试就用了这条路径:构造一个 IVectorView<Int32>,断言 Size() 为 3、GetAt(0) 为 10、IndexOf(20, ...) 命中下标 1,全部跨真实 vtbl ABI 往返。(来源:windows-collections/src/windows_collections_smoke_test.cj。)
方式二:迭代器(First / IIterator)
IIterable<T>.First() 给你一个 IIterator<T> 游标。WinRT 迭代器的协议是:迭代器创建时就指向第一个元素,HasCurrent() 问“当前是否有效”,Current() 取当前元素,MoveNext() 前进一格并返回前进后是否仍有效。
import windows_core.*
import windows_collections.*
// iterable: IIterable<Int32>
func printAll(iterable: IIterable<Int32>): Unit {
let it = unsafe { iterable.First() } // 拿到 IIterator<Int32>
try {
while (unsafe { it.HasCurrent() }) {
let value = unsafe { it.Current() }
println(value)
unsafe { it.MoveNext() }
}
} finally {
it.close() // 释放迭代器的原生引用
}
}
方式三:直接用仓颉 for-in
windows-cj 让 IIterator<T> 和 IIterable<T> 都提供了 iterator(): Iterator<T>(仓颉标准库的迭代器接口),所以你可以直接 for-in,不必手写 HasCurrent/MoveNext:
import windows_core.*
import windows_collections.*
// iterable: IIterable<Int32>,转成仓颉迭代器后用 for-in
func printAllForIn(iterable: IIterable<Int32>): Unit {
for (value in iterable) { // IIterable<T> 实现了 iterator()
println(value)
}
}
(来源:collections_runtime.cj 的 IIterable<T>.iterator() / intoIterator() 返回 IIteratorIterator<T>,后者实现仓颉 Iterator<T>。这是消费侧最贴近仓颉习惯的写法。)
映射:Lookup / HasKey
import windows_core.*
import windows_collections.*
// mapView: IMapView<Int32, Int32>
func lookupOr(mapView: IMapView<Int32, Int32>, key: Int32, fallback: Int32): Int32 {
if (unsafe { mapView.HasKey(key) }) { // 先确认键存在
return unsafe { mapView.Lookup(key) } // 取值,按 V 投影
}
fallback
}
遍历映射时,元素类型是 IKeyValuePair<K, V>,用 Key() / Value() 拆开:
// mapView: IMapView<Int32, Int32>
for (pair in mapView) { // 元素是 IKeyValuePair<Int32, Int32>
let k = unsafe { pair.Key() }
let v = unsafe { pair.Value() }
println("${k} => ${v}")
}
修改可变集合
IVector<T> / IMap<K, V> 多出一组写方法:
import windows_core.*
import windows_collections.*
// vector: IVector<Int32>
func mutate(vector: IVector<Int32>): Unit {
unsafe {
vector.Append(40i32) // 尾部追加
vector.InsertAt(0u32, 5i32) // 在下标 0 插入
vector.SetAt(1u32, 99i32) // 覆盖下标 1
vector.RemoveAtEnd() // 移除最后一个
}
// 取一份只读视图交给只接受 IVectorView 的 API
let view = unsafe { vector.GetView() }
try {
println("现在有 ${unsafe { view.Size() }} 个元素")
} finally {
view.close()
}
}
(来源:collections_runtime.cj 的 IVector 方法 Append/InsertAt/SetAt/RemoveAt/RemoveAtEnd/Clear/GetView/ReplaceAll,以及 IMap 的 Insert/Remove/Clear/GetView。)
stock 实现:把仓颉数据当作 WinRT 集合交出去
反过来的需求也很常见:一个 WinRT API 要你传入一个 IVectorView<Int32> 或 IMapView<K, V>,而你手上是仓颉的 Array / ArrayList。这时不用自己实现 vtable——windows-collections/src/stock.cj 提供了一组 stock(现成)转换函数,把任意仓颉 Iterable 包成 WinRT 集合 COM 对象。
| 函数 | 输入 | 产出 |
|---|---|---|
toIterator<T>(source: Iterable<T>) | 仓颉可迭代 | IIterator<T> |
toIterable<T>(source: Iterable<T>) | 仓颉可迭代 | IIterable<T> |
toVectorView<T>(source: Iterable<T>) | 仓颉可迭代 | IVectorView<T> |
toKeyValuePair<K, V>(key, value) | 一对键值 | IKeyValuePair<K, V> |
toMapView<K, V>(source: Iterable<(K, V)>) | 键值对序列 | IMapView<K, V> |
import std.collection.*
import windows_core.*
import windows_collections.*
// 把仓颉的 ArrayList<Int32> 交成一个只读 WinRT IVectorView<Int32>
func handOff(): Unit {
let data = ArrayList<Int32>([10i32, 20i32, 30i32])
let view = toVectorView(data) // 得到 IVectorView<Int32>
try {
// 现在 view 是一个真正的 WinRT COM 对象,可以传给任何
// 接受 IVectorView<Int32> 的 WinRT 方法。
println("交出 ${unsafe { view.Size() }} 个元素")
} finally {
view.close()
}
}
这正是冒烟测试的写法:toVectorView(ArrayList<Int32>([10, 20, 30])) 然后跨 ABI 调 Size()/GetAt()/IndexOf()。(来源:windows_collections_smoke_test.cj + stock.cj 的 toVectorView。)
映射同理:
import windows_core.*
import windows_collections.*
// 把一组 (Int32, Int32) 键值对交成只读 WinRT IMapView<Int32, Int32>
let entries = [(1i32, 100i32), (2i32, 200i32)]
let mapView = toMapView(entries) // 得到 IMapView<Int32, Int32>
try {
println(unsafe { mapView.Lookup(1i32) }) // 100
} finally {
mapView.close()
}
stock 实现在背后做了什么
值得了解一点机制,便于理解约束来自哪里:
- 泛型版
toVectorView<T>要求T <: RuntimeType & HandleWinrtType<T> & Equatable<T>——Equatable是因为IVectorView.IndexOf要比较元素。常用标量(Int32等)还有专门的非泛型重载(如toVectorView(source: Iterable<Int32>)),路径更直接。 - 它先用
snapshotList<T>(source)把仓颉数据拍快照存进一个内部 impl 对象(StockVectorViewImpl<T>/StockInt32VectorViewImpl等),交出去的是这份快照——之后改动原ArrayList不会影响已交出的视图。 - 然后用
createStockInterfaceMulti(...)把 impl 包成一个多接口 COM 对象:同时注册IIterable<T>和IVectorView<T>两张独立 vtbl,让对IID_IIterable<T>的 QueryInterface 返回它自己的 vtbl 槽位,而不是错误地复用IVectorView的。 - 返回的集合是由仓颉 impl 对象支撑的 COM 对象,存活由 GC 管理;你只需在交出方用完后
close()释放 COM 包装。
(来源:stock.cj 的 toVectorView / toMapView / createStockInterfaceMulti 与各 Stock*Impl 类。)
范式提醒
- 集合接口方法标
unsafe(最终走 vtable 裸调用),在unsafe { }里调用。 - 取到的迭代器 / 视图 / 元素包装都实现
Resource,用try/finallyclose();回收标记防 double free。 - 缺失值用
Option<T>、没有 null;先HasKey再Lookup是稳妥写法。 - 消费侧优先用
for-in(IIterable<T>已接进仓颉迭代器协议);交出侧优先用toVectorView/toMapView,不要手写 vtable。 - 泛型约束
where T <: RuntimeType & WinrtGenericType<T>不是噪音,它保证T能在运行时投影。
下一步 / 相关
- WinRT 异步操作:很多集合是异步方法的返回结果,配合
IAsyncOperation<T>使用。 - 调用 WinRT API:激活工厂、Type 投影、Foundation 值类型。
- 字符串:集合里常见
HString键值。 - 错误处理:
HRESULT/WindowsException的处理。
WinRT 异步操作
WinRT 里凡是可能耗时的调用(读文件、访问网络、唤起设备)都返回一个异步对象,而不是直接阻塞。调用方拿到这个对象后,可以注册完成回调、查询状态、或阻塞等待结果。windows-cj 把这套模型投影在 windows-future 包里。
越往上越贴近仓颉习惯:底层是 IAsyncInfo 状态机和 completed handler,往上 windows-cj 给了 join() / when() 这种直接拿结果的 helper,让异步代码读起来像同步。
WinRT 异步模型
有四个异步接口,区别只在“有没有返回值”和“有没有进度”:
| 接口 | 有返回值 | 有进度 |
|---|---|---|
IAsyncAction | 否 | 否 |
IAsyncActionWithProgress<TProgress> | 否 | 是 |
IAsyncOperation<TResult> | 是(TResult) | 否 |
IAsyncOperationWithProgress<TResult, TProgress> | 是(TResult) | 是 |
它们都继承 IAsyncInfo,这是异步状态机的公共底座:
Id(): UInt32—— 操作的唯一 id。Status(): AsyncStatus—— 当前状态。ErrorCode(): HResult—— 失败时的 HRESULT。Cancel()—— 请求取消。Close()—— 释放。
AsyncStatus 是一个薄封装的 Int32,有四个具名取值(来源:windows-future/src/async_runtime.cj):
AsyncStatus_Started (0) —— 进行中
AsyncStatus_Completed (1) —— 成功完成
AsyncStatus_Canceled (2) —— 已取消
AsyncStatus_Error (3) —— 出错
一个异步操作的生命就是从 Started 走向三个终态之一(Completed / Canceled / Error)。它们用 == 比较:
import windows_future.*
// info: IAsyncInfo
if (unsafe { info.Status() } == AsyncStatus_Completed) {
// 成功
}
TResult / TProgress 都受 where T <: windows_core.RuntimeType & windows_core.WinrtGenericType<T> 约束——必须是可运行时投影的 WinRT 类型。
等待结果:join() 与 when()
最常见的需求是“等它完成、拿结果”。windows-cj 为每个异步接口扩展了两个 helper(来源:windows-future/src/async_helpers.cj 的 extend 块):
join(): Result<T>—— 阻塞当前线程直到终态,然后把结果包成Result:成功Ok(值)、出错Err(对应 HRESULT)、取消Err(E_ABORT)。对IAsyncOperation<TResult>是Result<TResult>,对IAsyncAction是Result<Unit>。when(): Future<Result<...>>—— 不阻塞,把join()丢到一个仓颉线程里跑,返回仓颉的Future,你之后再.get()。
join() 内部并不忙等:它通过 IAsyncInfo.Id() 找到该操作的终态事件并在 Win32 事件上等待(自家实现的异步对象),对外来对象则回退到轮询。终态后它会读 Status() 决定走 GetResults() 还是 ErrorCode(),并在内部正确释放临时的 IAsyncInfo。
import windows_core.*
import windows_future.*
// operation: IAsyncOperation<Int32>,从某个 WinRT API 拿到
func awaitValue(operation: IAsyncOperation<Int32>): Unit {
// 阻塞等待,直接拿 Result<Int32>
match (operation.join()) {
case Result<Int32>.Ok(value) => println("结果: ${value}")
case Result<Int32>.Err(error) => println("失败: HRESULT=${error.code()}")
}
}
不想阻塞当前线程时用 when():
import std.time.*
import windows_core.*
import windows_future.*
// operation: IAsyncOperation<Int32>
func awaitWithTimeout(operation: IAsyncOperation<Int32>): Unit {
let future = operation.when() // 立即返回 Future<Result<Int32>>
// ...这中间可以干别的...
match (future.get(Duration.second * 2)) { // 最多等 2 秒
case Result<Int32>.Ok(value) => println("结果: ${value}")
case Result<Int32>.Err(_) => println("失败或取消")
}
}
(join() / when() 与 future.get(Duration) 的用法直接取自 windows-future/src/async_helpers_test.cj 里的 testReadyOperationJoinAndWhenReturnValue。)
IAsyncAction(无返回值)同理,只是结果类型是 Result<Unit>:
import windows_core.*
import windows_future.*
// action: IAsyncAction
match (action.join()) {
case Result<Unit>.Ok(_) => println("完成")
case Result<Unit>.Err(error) => println("失败: ${error.code()}")
}
注册 completed handler(用闭包)
如果你想在完成时被回调而不是阻塞等待,就注册一个 completed handler。每个异步接口有一个对应的 handler 委托,用静态 new(...) 接受闭包构造,再用 SetCompleted(...) 挂上去:
| 异步接口 | completed handler |
|---|---|
IAsyncAction | AsyncActionCompletedHandler |
IAsyncOperation<TResult> | AsyncOperationCompletedHandler<TResult> |
IAsyncActionWithProgress<TProgress> | AsyncActionWithProgressCompletedHandler<TProgress> |
IAsyncOperationWithProgress<TResult, TProgress> | AsyncOperationWithProgressCompletedHandler<TResult, TProgress> |
闭包收到两个参数:异步对象本身(InParam<...>)和终态 AsyncStatus。成功 / 错误 / 取消三种情况都从 status 区分:
import windows_core.*
import windows_future.*
// operation: IAsyncOperation<Int32>
func registerCallback(operation: IAsyncOperation<Int32>): Unit {
let handler = AsyncOperationCompletedHandler<Int32>.new(
{ info: InParam<IAsyncOperation<Int32>>, status: AsyncStatus =>
if (status == AsyncStatus_Completed) {
// 成功:从 info 取出操作并读结果(InParam.get() 解出 T)
let op = info.get()
println("完成: ${unsafe { op.GetResults() }}")
} else if (status == AsyncStatus_Canceled) {
println("已取消")
} else {
// AsyncStatus_Error:读 IAsyncInfo.ErrorCode() 取 HRESULT
println("出错")
}
}
)
try {
unsafe { operation.SetCompleted(handler) } // 挂上回调
// 若操作已经完成,SetCompleted 会立即在当前线程触发一次回调
} finally {
handler.close()
}
}
要点(来源:async_helpers_test.cj 的 testNativeAsyncCompletedGettersReturnStoredHandlers,那里用一模一样的 AsyncOperationCompletedHandler<Int32>.new({ info, status => ... }) 闭包构造,并用 SetCompleted / Completed 往返):
- handler 由仓颉闭包支撑、是个 COM 对象——存活由 GC 管,不要手写 AddRef/Release;不再需要时
close()释放 COM 包装。 - 注册后还能用
unsafe { operation.Completed() }取回当前挂着的 handler。 - 三种终态全靠
status:AsyncStatus_Completed/AsyncStatus_Canceled/AsyncStatus_Error。错误的具体 HRESULT 在IAsyncInfo.ErrorCode()。
查询状态与取消
异步对象通过 asIAsyncInfo() 拿到它的 IAsyncInfo 面(QueryInterface),就能查状态、读错误码、请求取消:
import windows_core.*
import windows_future.*
// action: IAsyncAction
func cancelIt(action: IAsyncAction): Unit {
let info = action.asIAsyncInfo()
try {
unsafe { info.Cancel() } // 请求取消
if (unsafe { info.Status() } == AsyncStatus_Canceled) {
println("已进入取消态")
}
// 出错时读 HRESULT:
if (unsafe { info.Status() } == AsyncStatus_Error) {
println("错误码: ${unsafe { info.ErrorCode() }.Value}")
}
} finally {
info.close()
}
}
取消后再 join(),会得到 Result.Err,其 HRESULT 为 E_ABORT。(来源:async_helpers_test.cj 的 testSpawnedActionCancelTransitionsToCanceled:info.Cancel() 后 Status() 变 Canceled,join() 返回 E_ABORT。)
自己发起一个异步操作
不只是消费——你也能把一段仓颉工作包成 WinRT 异步对象交出去。windows-future 提供静态 spawnAsync(...) 与 ready(...):
import windows_core.*
import windows_future.*
// 把一段会返回 Int32 的工作丢到后台线程,立即得到 IAsyncOperation<Int32>
let operation = IAsyncOperation<Int32>.spawnAsync({ =>
// 这里是后台执行的逻辑
21i32 * 2
})
// 之后照常 join() / when() / 注册 handler
println(operation.join().unwrap()) // 42
// 已知结果时用 ready(...) 造一个“已完成”的操作
let done = IAsyncOperation<Int32>.ready(7i32)
spawnAsync 内部用 spawn { ... } 起一个仓颉线程跑你的闭包,正常结束就 complete(result),抛 WindowsException 就把它的 HRESULT 透传给操作的错误态,被取消就转 Canceled。带进度的变体闭包会多收到一个 reporter 参数,调用 reporter.report(value) 上报进度:
import windows_core.*
import windows_future.*
// 带进度:闭包收到一个 ProgressReporter,可多次 report
let op = IAsyncOperationWithProgress<Int32, UInt32>.spawnAsync({ reporter =>
reporter.report(50u32) // 上报 50% 进度
reporter.report(100u32)
99i32 // 最终结果
})
(来源:async_helpers.cj 的 IAsyncOperation.spawnAsync / ready、ProgressReporter<TProgress>.report,以及 async_helpers_test.cj 的 testSpawnedOperationWithProgressPreservesWindowsExceptionHRESULT。)
生命周期与线程注意事项
- 阻塞 vs 非阻塞。
join()阻塞当前线程;在 UI/敏捷线程上长时间阻塞不可取,那种场合用when()(后台跑)或 completed handler(回调)。 - handler 触发线程。 completed handler 可能在发起线程之外被回调;如果它要触碰只能在特定单元(apartment)使用的对象,先用
AgileReference<T>把对象包成敏捷引用再跨线程解引用(见 COM API)。 - 原生引用确定性回收。
IAsyncInfo、handler、异步对象都实现Resource,用try/finallyclose();回收标记防~init与close()重复释放。 - GC 管仓颉对象。 闭包捕获的仓颉对象由 GC 维持存活,不需要手写引用计数。
- 没有 null。 结果用
Result<T>,缺失值用Option<T>,错误透传为HRESULT。 - 条件表达式带括号(
if (status == AsyncStatus_Completed)),接口实现用<:。
下一步 / 相关
- WinUI 3:UI 场景下大量使用异步与 typed 事件。
- 生成绑定:异步方法(以及它们的 handler/进度类型)是怎么被生成出来的。
- WinRT 集合:异步方法常返回集合结果。
- 调用 WinRT API:激活工厂、Type 投影、事件 handler。
- 错误处理:
Result<T>/HRESULT/WindowsException。
链接与 windows-targets
当你调用一个 Win32 / COM / WinRT 函数(比如 RegGetValueW、CoCreateInstance、RoGetActivationFactory)时,仓颉只知道这个符号的声明,并不知道它的机器码在哪。把“符号名“对应到“系统 DLL 里的导出地址“这件事,发生在链接期:链接器需要一份导入库(import library),里面记录了每个导出符号属于哪个 DLL、怎么跳转过去。没有这份导入库,链接会以“undefined reference“失败。
windows-cj 走的是 GNU 工具链(mingw 风格)。GNU 链接器用 -l... 选项来引入库:-lkernel32 找 libkernel32.a、-l:libwindows.0.53.0.a 直接按文件名引入某个归档。windows-cj 把所有需要的 Win32 导入符号都打包进了一个归档文件,由 windows-targets 包提供。
windows-targets 是什么
windows-targets 是一个链接期资产包,不是普通的源码依赖。它的职责只有一个:捆绑 GNU 导入库归档(x86_64_gnu/lib/libwindows.0.53.0.a),并提供一组 helper API,让链接工具能定位归档路径、生成正确的 -L / -l 选项。
正因为它是资产包而非源码依赖,普通源码包不应该只为了拿到链接资产就把 windows_targets 写进自己的 [dependencies]。真正消费它的是链接 / 构建工具:它们找到包的根目录,再向 helper API 要归档路径或 GNU 链接选项。
历史上的生成 / 链接工具用这个归档做两件事:一是枚举可用的 Win32 导入符号,二是把一大串按 DLL 拆分的 -l... 替换成单一的 -L<lib> -l:libwindows.0.53.0.a。
支持矩阵
windows-targets 只为真正捆绑了归档文件的 target 声明“支持“。下表是当前矩阵(见 windows-targets/src/lib.cj 与 windows-targets/README.md):
| Target key | 架构 | 工具链 | 仓颉 env | 状态 | 归档载荷 |
| -------------- | ------- | ------ | -------- | ----------- | ------------------------------------- |
| x86_64_gnu | x86_64 | GNU | gnu | supported | x86_64_gnu/lib/libwindows.0.53.0.a |
| i686_gnu | i686 | GNU | gnu | unsupported | 无 |
| aarch64_gnu | aarch64 | GNU | gnu | unsupported | 无 |
| x86_64_msvc | x86_64 | MSVC | (空) | unsupported | 无 |
| i686_msvc | i686 | MSVC | (空) | unsupported | 无 |
| aarch64_msvc | aarch64 | MSVC | (空) | unsupported | 无 |
要点:
- 目前只有
x86_64_gnu真正捆绑了归档载荷。 这也是 windows-cj 现阶段唯一支持的链接 target(Windows x86_64 GNU)。 - “unsupported” 的 target 是矩阵里已知但没有载荷的 target。它们不会放占位归档文件——没有载荷就是没有载荷。
- MSVC 系列在矩阵里列出只是为了可被检视(introspection),但因为这个包不带 MSVC 导入库,所以是 unsupported。
每个 target 由值类型 ImportLibTarget 描述,带 name / arch / toolchain / cangjieEnv / archiveDirectory / archiveName: Option<String> / supported 等字段。注意 archiveName 是 Option<String>:unsupported target 取值为 None,这是它“没有载荷“的直接编码方式(仓颉用 Option<T> 代替 null)。
COM / WinRT 的 link-option
libwindows.0.53.0.a 覆盖的是 Win32 基础导入符号。COM / WinRT 还需要额外的系统库,它们用各自的 -l 选项引入,最常见的是:
-lole32—— COM 运行时核心(CoCreateInstance、CoTaskMemFree等)。-loleaut32—— OLE 自动化(BSTR的SysAllocStringLen/SysFreeString、VARIANT等)。-lwindowsapp—— WinRT 的伞库(RoGetActivationFactory、WindowsCreateString等运行时入口)。
这些选项写在消费方项目(你的可执行程序)的 cjpm.toml 的 [package] 段里,用 link-option 字段:
[package]
name = "my_app"
version = "0.1.0"
output-type = "executable"
cjc-version = "1.1.0"
# COM / WinRT 通常需要链接这几个系统库
link-option = "-lole32 -loleaut32 -lwindowsapp"
[dependencies]
windows_core = { path = "../windows-cj/windows-core" }
windows_strings = { path = "../windows-cj/windows-strings" }
为什么放在最终产物这一层?因为链接是在生成可执行文件 / 动态库时发生的——只有最终把所有 .a 拼到一起的那一步,链接器才需要知道全部系统库。中间的静态库包(output-type = "static")只是把目标码攒起来,符号留到最后再解析。所以 link-option 写在 output-type = "executable" 的项目里。
纯 Win32(不碰 COM/WinRT)的小程序往往连这几个
-l都不需要——libwindows.0.53.0.a已经把kernel32/advapi32等基础导入符号包含进去了。等你用到 COM / WinRT 再按需加。
ImportLibTarget 的 helper API
如果你在写自己的链接 / 构建工具、需要程序化地拿到归档路径或链接选项,windows-targets 暴露了下面这些函数(签名见 windows-targets/src/lib.cj)。它们分“探测式“(返回 Option,不抛异常)和“强制式“(拿不到就抛 UnsupportedTarget)两类。
查询 target
import windows_targets.*
// 探测式:unsupported / 未知 target 返回 None,不抛异常
let probe: Option<ImportLibTarget> = findSupportedImportLibTarget("x86_64_gnu")
// 强制式:unsupported / 未知 target 抛 UnsupportedTarget
let target: ImportLibTarget = requireSupportedImportLibTarget("x86_64_gnu")
// 自动选当前编译环境对应的 target(仅在 Windows x86_64 GNU 下解析成功,否则抛异常)
let current: ImportLibTarget = requireCurrentImportLibTarget()
requireCurrentImportLibTarget() 内部用 @When[...] 条件编译判断当前 os / arch / env:只有 Windows && x86_64 && gnu 才返回 x86_64_gnu,其它组合直接抛 UnsupportedTarget("current", "windows-targets only bundles import libraries for x86_64 Windows GNU")。
取归档路径与链接选项
ImportLibTarget 的方法(同样有 archive...() 探测式和 requireArchive...() 强制式两套):
let target = requireSupportedImportLibTarget("x86_64_gnu")
// 相对包根的归档路径
target.archiveRelativePath()
// => Some("x86_64_gnu/lib/libwindows.0.53.0.a"),unsupported target 为 None
target.requireArchiveRelativePath()
// => "x86_64_gnu/lib/libwindows.0.53.0.a",unsupported 时抛 UnsupportedTarget
// 拼上调用方给定的包根目录
let root = "E:/toolchain/windows-targets"
target.requireArchivePath(root)
// => "E:/toolchain/windows-targets/x86_64_gnu/lib/libwindows.0.53.0.a"
// 生成 GNU 链接器需要的两个选项
target.requireGnuLinkOptions(root)
// => ["-LE:/toolchain/windows-targets/x86_64_gnu/lib", "-l:libwindows.0.53.0.a"]
requireGnuLinkOptions(root) 返回的两项正是把一份归档喂给 GNU 链接器的标准组合:-L<目录> 指定搜索路径,-l:<文件名> 按精确文件名引入归档。注意它只对 toolchain == "gnu" 的 target 有效,对非 GNU target(即便受支持)会抛 UnsupportedTarget。
不支持的 target 会抛 UnsupportedTarget
UnsupportedTarget 是一个 <: Exception 的公开异常类,带 targetName 和 reason 两个字段,消息形如:
unsupported windows-targets import library target `aarch64_gnu`: no bundled import library payload exists under aarch64_gnu/lib
所有 require... 系列都在拿到链接选项 / 做任何 ABI 假设之前就抛出它,让你尽早 fail-fast。如果你只是想检视矩阵、不想触发异常,就用 findImportLibTarget / findSupportedImportLibTarget / archiveRelativePath() 这些返回 Option 的探测式 API。
顶层还有一组同名的自由函数包装(requireImportLibGnuLinkOptions(target, root)、importLibArchivePath(target, root) 等),行为和对应的方法一致,方便不想持有 ImportLibTarget 实例时直接调用。
小结
- 调用 Windows API 需要链接期的导入库把符号解析到系统 DLL;windows-cj 用 GNU 工具链 + 单一归档
libwindows.0.53.0.a。 windows-targets是链接期资产包,目前只支持x86_64_gnu;其余 target 在矩阵里列出但无载荷(unsupported)。- COM / WinRT 在最终可执行项目的
cjpm.toml里用link-option = "-lole32 -loleaut32 -lwindowsapp"补充系统库。 - 用
requireSupportedImportLibTarget/requireCurrentImportLibTarget解析 target,用requireGnuLinkOptions(root)拿链接选项;不支持的 target 会抛UnsupportedTarget。
下一步 / 相关
- 用 windows-bindgen 生成绑定 —— 绑定生成器如何按需把元数据生成成仓颉源码包。
- WinUI 3 实战 —— WinUI 3 / Windows App SDK 项目的链接与运行环境。
- 安装与环境配置 —— 工具链版本、
cjHeapSize、cjv exec运行约定。
用 windows-bindgen 生成绑定
到目前为止,你用到的都是仓库里已经签入的支持包(windows_core、windows_strings、windows_common 等)。但 Windows 的 API 表面极其庞大,仓库不可能把每一个命名空间都预先投影进来。当你需要某个还没被签入的命名空间时,就轮到 windows-bindgen 登场了——它按需把 Windows 元数据生成成仓颉源码包。
windows-bindgen 是什么
windows-bindgen 是一个独立于运行时的命令行工具。它的职责只有一个:读取 Windows 元数据(.winmd),按你选定的 feature 把对应的类型、函数、接口渲染成仓颉源码,写到一个目录里。
记住这一点:它本身不是可直接消费的 Windows API 投影。它是生成器,不是被生成物。你不会 import windows_bindgen 去调用 Windows API;你运行它,得到一个新的源码包,然后在自己的项目里以路径依赖去消费那个包。
它的仓颉包名是 windows_bindgen,面向用户的命令名是 windows-bindgen。元数据读取走的是内置的仓颉原生 .winmd 解析器——你不需要任何外部转换器;只要把 .winmd 文件、.winmd 目录,或关键字 default(解析仓库自带的元数据)交给它即可。
构建这个 CLI
windows-bindgen 是工作区里 output-type = "executable" 的一个成员。先把它构建出来:
$env:cjHeapSize = '32GB'
cjpm build
构建产物是一个可执行文件,位于:
windows-bindgen\target\release\bin\main.exe
和本书一贯的约定一样,不要直接双击或裸跑这个 .exe。通过仓颉版本管理器的 cjv exec 包裹运行,才能保证运行时链接到与编译期一致的仓颉运行时:
cjv exec .\windows-bindgen\target\release\bin\main.exe --help
下文所有命令示例都假设你已经构建好 CLI。为简洁起见,示例统一写成
cjv exec .\windows-bindgen\target\release\bin\main.exe ...,请在仓库根目录执行。
CLI 参数逐个看
下面这些参数都来自生成器的真实参数解析逻辑,含义以源码为准。
--list-features:先看看有哪些 feature
不带选择规则时,先列出当前元数据里可用的 feature(即命名空间)。这通常是你的第一步:
cjv exec .\windows-bindgen\target\release\bin\main.exe default --list-features
default 表示解析仓库自带的元数据。输出里每一行就是一个可作为 --feature 值的命名空间,外加一个特殊的 all(表示全选)。
--feature:选择要生成的命名空间
--feature <namespace> 指定一个要生成的命名空间。它可以重复出现,多次叠加:
cjv exec .\windows-bindgen\target\release\bin\main.exe default `
--feature Windows.Foundation `
--feature Windows.Win32.System.Com
生成器会把你选中的符号,连同它们依赖的其它符号一起拉进来(依赖闭包自动计算),所以你通常只需要点名顶层命名空间即可。
--out:生成到哪个目录
--out <dir> 指定输出目录。默认是 .generated/windows:
cjv exec .\windows-bindgen\target\release\bin\main.exe default `
--feature Windows.Foundation `
--out .generated\my-windows
--clean:生成前清空输出目录
--clean 在写入前先删除输出目录,避免上一次生成残留的过期文件混进来:
cjv exec .\windows-bindgen\target\release\bin\main.exe default `
--feature Windows.Foundation `
--out .generated\my-windows `
--clean
--dry-run:只算不写
--dry-run 走完整的选择与渲染流程,但不真正写文件。用它来预演一次生成会产出哪些内容、有没有报未知 feature 的错,而不污染磁盘:
cjv exec .\windows-bindgen\target\release\bin\main.exe default `
--feature Windows.Foundation `
--dry-run
--manifest:把清单写到指定路径
每次生成都会产出一份 codegen-manifest.json,记录请求的 feature、选中的符号列表、生成的文件清单以及各文件的哈希。默认情况下它被写进输出目录;--manifest <path> 让你把这份清单单独写到指定路径:
cjv exec .\windows-bindgen\target\release\bin\main.exe default `
--feature Windows.Foundation `
--out .generated\my-windows `
--manifest .generated\my-windows.manifest.json
--common:面向中心仓维护者
--common 是一个特殊模式,面向仓库维护者而非普通消费者。它按稳定支持包(windows-version / windows-registry / windows-threading / windows-foundation / windows-collections / windows-winui3 等)所需的命名空间,生成或更新与它们并列的 windows-common 包。开启它会把包名固定为 windows_common,并把默认输出目录改为 windows-common。
cjv exec .\windows-bindgen\target\release\bin\main.exe default --common
作为普通使用者,你一般不需要它——它存在的意义是让维护者刷新签入仓库的中心支持包。概念上理解即可。
除了上面这些,生成器还接受一些进阶参数:
--filter <rule>(更细粒度的符号选择,支持通配符与!排除)、--package-name <name>(覆盖生成包名)、--input-json/--input-dir(喂入已转换好的离线 JSON 元数据,而不是直接解析.winmd)、--print-link-options、--sys、--implement、--no-comment、--derive等。运行--help可以看到完整说明。本页只展开最常用的那几个。
端到端:从列举到消费
把上面的步骤串起来,就是一条完整的工作流。
第一步:列出可用 feature。
cjv exec .\windows-bindgen\target\release\bin\main.exe default --list-features
第二步:选一个 feature,干净地生成到 --out 目录。
cjv exec .\windows-bindgen\target\release\bin\main.exe default `
--feature Windows.Foundation `
--out .generated\windows `
--clean
第三步:在你自己的项目里以路径依赖消费这个生成包。 生成的根 cjpm.toml 把包名记为 windows(除非你用 --package-name / --common 改过),所以在你项目的 cjpm.toml 里这样写:
[dependencies]
windows = { path = "../windows-cj/.generated/windows" }
然后在仓颉源码中按生成的命名空间导入。生成包按命名空间分成子包,例如 Windows.Foundation 会落在 windows.Foundation 子包下:
import windows.Foundation.*
范式提醒:仓颉的 import 是按**包名(下划线/点路径)**而非目录名来写的,目录名用连字符、包名用下划线或点。生成器会为你处理好这层映射。
生成产物长什么样
一次典型的生成会写出这样一个目录结构:
.generated/windows/
├── cjpm.toml # 包声明 + [dependencies](指向所需的稳定支持包)
├── codegen-manifest.json # 本次生成的清单(feature / 符号 / 文件 / 哈希)
└── src/
├── mod.cj # 根包 package 声明
├── impl/ # 真正的实现:vtable、native helper、类型渲染
│ ├── mod.cj
│ └── symbols_0.cj … # 符号分块写入(每块约 512 个符号)
└── Foundation/ # 按命名空间分出的对外 facade
├── mod.cj
└── facade_0.cj … # public type 别名,把 impl 里的实现暴露出去
设计上分成两层:src/impl/ 放真正的实现细节,按符号数量切成 symbols_N.cj 多个块;命名空间目录(如 Foundation/)里则是 facade 文件,用 public type 别名把实现层的类型以稳定名字暴露给消费者。这样切分既控制了单个编译单元的大小,又给了你一个干净的对外表面。
它依赖哪些稳定支持包
生成包不是自包含的。生成器会扫描渲染结果用到了哪些底座类型,自动在 cjpm.toml 的 [dependencies] 里记下对应的稳定支持包路径依赖。常见的有:
| 支持包 | 何时被引入 |
|---|---|
windows_core | 用到 COM/WinRT 底座、Result、接口宏时 |
windows_interface | 用到 @Interface 宏、GUID、IInspectable 时 |
windows_polyfill | 用到语言/标准库补齐 helper 时 |
windows_strings | 用到 HString 等字符串类型时 |
windows_libloading | 用到动态库加载(LoadLibrary 封装)时 |
也就是说,消费一个生成包时,这些它依赖的稳定支持包必须同时在你的工作区里可达——它们是生成代码能编译通过的前提。各包的职责见包结构总览。
下一步 / 相关
- 包结构总览:搞清楚生成包依赖的那些稳定支持包各自负责什么。
- WinUI 3 实战:把生成的 WinUI 投影和
windows-winui3支持包组合起来,跑出一个真正渲染的窗口。
WinUI 3 实战
前面几章你调用过 Win32、消费过 COM、投影过 WinRT。本章把这些能力组合到一起,目标很具体:用 windows-cj 跑起一个真正渲染的 WinUI 3 窗口。
WinUI 3 不是普通的 Win32 窗口。它运行在 Windows App SDK 之上,需要先用 bootstrap 机制把 App SDK 运行时挂载进进程,再初始化 COM 套间,最后通过 Application.Start 把控制权交给 XAML 框架。下面我们严格按照一个已经跑通的 demo(windows-cj-demo)走一遍这套启动序列。
支持包:windows-winui3
你不需要手写这套 ABI 胶水。windows-winui3 是一个只面向运行时的支持包,它把 WinUI 3 / Windows App SDK 的启动逻辑封装成了三个子模块:
windows_winui3.appsdk—— 挂载/卸载 Windows App SDK 运行时(bootstrap)。windows_winui3.win32—— 初始化/反初始化 COM 套间。windows_winui3.xaml——ApplicationCallback、WindowHandle、startApplication,以及加载 XAML、接线按钮事件用的可复用句柄。
在你项目源码里这样导入(与 demo 一致):
import windows_winui3.appsdk
import windows_winui3.win32
import windows_winui3.xaml.{ApplicationCallback, WindowHandle, startApplication}
启动序列
整个 main 的骨架是:挂载 App SDK → 初始化 COM 套间 → 启动应用(创建并激活窗口)→ 在 finally 中逐层清理。下面逐段拆解,代码取自 windows-cj-demo/src/main.cj。
第一步:找到 bootstrap DLL 并挂载 App SDK
WinUI 3 的运行时不在系统目录里,需要你提供 Windows App SDK 的 bootstrap DLL(Microsoft.WindowsAppRuntime.Bootstrap.dll)的路径。demo 优先读环境变量 WINDOWS_APPSDK_BOOTSTRAP_DLL,读不到再回落到本地 NuGet 缓存里的候选路径:
func bootstrapDllPath(): String {
match (getVariable("WINDOWS_APPSDK_BOOTSTRAP_DLL")) {
case Some(path) =>
if (!path.isEmpty()) {
return path
}
case None => ()
}
for (candidate in LOCAL_BOOTSTRAP_DLL_CANDIDATES) {
if (exists(candidate)) {
return canonicalize(candidate).toString()
}
}
return ""
}
范式提醒:环境变量读出来是
Option<String>,不是会抛null的字符串——用match解构Some/None,这是仓颉处理“可能不存在”的标准方式。
拿到路径后,把它交给 appsdk.initialize。它返回一个 HRESULT;用 .failed() 判断是否失败(而不是去比较某个魔数):
let bootstrapPath = bootstrapDllPath()
let appSdkHr = appsdk.initialize(bootstrapDllPath: bootstrapPath)
if (appSdkHr.failed()) {
eprintln("[demo] Windows App SDK initialization failed: ${appSdkHr}")
return 1
}
注意 bootstrapDllPath: 是一个命名参数——initialize 的签名是 initialize(bootstrapDllPath!: String = ""),调用时要带上参数名。
第二步:初始化 COM 套间
WinUI 3 是 COM/WinRT 之上的框架,必须先初始化一个套间。win32.initializeApartmentThreaded() 底层就是以 COINIT_APARTMENTTHREADED 调用 CoInitializeEx,同样返回 HRESULT:
let comHr = win32.initializeApartmentThreaded()
if (comHr.failed()) {
eprintln("[demo] CoInitializeEx STA failed: ${comHr}")
appsdk.shutdown()
return 1
}
如果套间初始化失败,要记得把上一步已经挂载的 App SDK 卸载掉(appsdk.shutdown()),再退出。
第三步:启动应用,创建并激活窗口
WinUI 3 的入口是 Application.Start:你把一段回调交给框架,框架在内部把 XAML 运行时准备好之后,回调到你这里,你才能创建窗口和 UI。
在 windows-cj 里,这段回调用 ApplicationCallback({ => ... }) 包装——一个无参闭包({ => ... }),里面创建 WindowHandle、装入内容、show()、activate(),最后用 startApplication(callback) 把回调投递给框架:
func startWinui(): Unit {
let callback = ApplicationCallback({ =>
let window = WindowHandle.create()
let app = TodoApp.createInitial(window)
app.show()
window.activate()
// 把句柄挂到全局根上,避免被 GC 提前回收,留待 finally 清理
todoAppRoot = Some(app)
windowRoot = Some(window)
})
applicationCallbackRoot = Some(callback)
startApplication(callback)
}
这里的关键 API:
WindowHandle.create()—— 通过 WinRT 激活工厂创建一个Window,返回一个WindowHandle(实现了Resource)。WindowHandle.activate()—— 调用底层IWindow.Activate,把窗口显示出来。window.setTitle(...)/window.setContent(...)—— 设置标题与内容(demo 的TodoApp.show()内部会调用它们,通过UIElementHandle.loadXaml(...)把一段 XAML 字符串解析成可挂载的 UI 元素)。
范式提醒:当这段代码看起来与你在其它语言里的写法不同时,多半是因为它遵循了仓颉的范式。这里的
{ => ... }就是仓颉的无参 lambda——没有参数也不能省略=>。
第四步:在 finally 中逐层清理
启动流程包在 try 里,无论成功还是抛异常,都要在 finally 中把资源还回去——顺序是与申请相反的:先关 WinUI 句柄,再反初始化套间,最后卸载 App SDK。
var exitCode: Int64 = 0
try {
startWinui()
} catch (err: Exception) {
eprintln("[demo] WinUI startup failed: ${err}")
exitCode = 1
} finally {
cleanupGeneratedWinuiRoots()
win32.uninitializeApartment()
appsdk.shutdown()
}
return exitCode
资源生命周期
ApplicationCallback、WindowHandle、以及你自己的应用对象(demo 里的 TodoApp,它本身 <: Resource)都持有原生 COM 指针。仓颉的 GC 会管理仓颉对象之间的引用,但原生指针必须显式释放——这就是这些类型都实现 Resource 并提供 close() 的原因。
demo 的 cleanupGeneratedWinuiRoots 演示了正确的关闭顺序:先关应用、再关窗口、最后关回调,每一步都先 match 出 Option 里的句柄,关掉后把根置回 None:
func cleanupGeneratedWinuiRoots(): Unit {
match (todoAppRoot) {
case Some(app) => app.close()
case None => ()
}
match (windowRoot) {
case Some(window) => window.close()
case None => ()
}
match (applicationCallbackRoot) {
case Some(callback) => callback.close()
case None => ()
}
applicationCallbackRoot = None
windowRoot = None
todoAppRoot = None
}
为什么不靠 GC 的析构器(~init)自动释放?因为原生 COM 资源的释放时机是确定性的,不能等 GC 不确定何时跑。这些句柄内部都带了一个 closed: Bool 回收标记,保证 close() 和析构器不会对同一个原生指针重复释放(double free)。规则很简单:你创建的每个句柄,都要在退出路径上 close()。
cjpm.toml
demo 的包配置(取自 windows-cj-demo/cjpm.toml):
[package]
name = "windows_cj_demo"
version = "0.1.0"
output-type = "executable"
cjc-version = "1.1.0"
compile-option = "-Woff unused -Woff package-import"
link-option = "-lole32 -loleaut32 -lwindowsapp"
[dependencies]
windows_winui3 = { path = "../windows-cj/windows-winui3" }
两点值得注意:
output-type = "executable"—— 这是一个可执行程序,不是库。link-option = "-lole32 -loleaut32 -lwindowsapp"—— COM/WinRT 需要链接这几个系统库。cjpm不会把静态依赖里的 nativelink-option向上传播给根可执行文件,所以即便windows_winui3是个静态依赖,根项目仍要在自己的cjpm.toml里把这些导入库写全。
构建与运行
在 demo 目录下构建。工作区级别的编译建议把 GC 堆上限调高,避免 OOM:
$env:cjHeapSize = '32GB'
cjpm build
然后用 cjv exec 运行产物(不要直接裸跑 .exe,否则可能链接不到正确的仓颉运行时):
cjv exec .\target\release\bin\windows_cj_demo.exe
运行前提:你的机器上必须有 Windows App SDK 运行时,且程序能找到它的 bootstrap DLL。最省事的方式是设置环境变量指定路径:
$env:WINDOWS_APPSDK_BOOTSTRAP_DLL = 'C:\path\to\Microsoft.WindowsAppRuntime.Bootstrap.dll'不设的话,demo 会回落去本地 NuGet 缓存里找候选路径——找不到就拿不到 App SDK,窗口起不来。
跑通后,你会看到一个真正渲染的 WinUI 3 窗口。至此,你已经把 Win32 套间初始化、WinRT 激活工厂、COM 接口调用、以及确定性资源回收全部串了起来。
下一步 / 相关
- 调用 WinRT API:深入 WinUI 背后的 WinRT 投影机制——激活工厂、
IInspectable、运行时类。 - 引言:回到全书入口,按需挑选其它主题。