Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

引言

windows-cj 是一套用 仓颉(Cangjie) 编写的 Windows API 绑定与投影。它让你能够直接在仓颉中调用 Win32、COM 与 WinRT 接口,覆盖从底层 C 风格函数到现代 WinRT 运行时类的完整谱系,并尽量提供符合仓颉习惯、安全易用的编程模型。

这套库以一组相互独立、各司其职的包组织起来:字符串、错误码、COM 接口实现、WinRT 集合与异步、WinUI 3 支持,以及在编译期之外按需生成绑定的 windows-bindgen 工具。你可以只依赖其中一小部分,也可以把它们组合起来构建完整的桌面应用。

本书面向谁

  • 熟悉 Windows、初次接触仓颉的开发者:你已经了解 HRESULTIUnknown::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 风格)CreateThreadpoolWorkMessageBoxW、注册表函数直接调用导出函数,参数多为指针 / 句柄
COMIUnknownIDXGIFactory、Shell 接口通过虚函数表(vtable)调用,需要 QueryInterface
WinRTWindows.Foundation.Uri、集合、异步在 COM 之上,带运行时类型系统、激活工厂、泛型投影

越往下越底层、越接近裸 ABI;越往上越现代、越贴近仓颉习惯。三层共享同一套底座类型(GUIDHRESULTBOOL、字符串),它们集中在 windows-corewindows-resultwindows-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 独立于运行时之外:它是一个命令行工具,按需把元数据生成成仓颉源码包,供上面这些层消费。

我应该从哪里开始?

下一节先把环境装好。

安装与环境配置

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-resultwindows_resultHRESULT / Result<T> / BOOL / GUID / WIN32_ERROR 等错误码与底座类型
windows-stringswindows_stringsHString / BSTR / PCWSTR / PWSTR / CWideString 等字符串类型
windows-corewindows_coreCOM 接口底座、vtable、InspectableType 投影、激活工厂缓存、AgileReference、ABI 数组,以及共享的 WinRT-ABI 底座(DateTime / TimeSpan / Point / Rect / Size 等值类型、HResult、QI helper)
windows-polyfillwindows_polyfill语言 / 标准库层面的补齐与兼容 helper
windows-libloadingwindows_libloading动态库加载与导出函数解析(LoadLibrary / GetProcAddress 封装)

COM 接口

包名(目录)仓颉包名职责
windows-interfacewindows_interface声明 COM 接口及其 vtable 布局
windows-implementwindows_implement在仓颉类型上实现既有 COM 接口

WinRT 投影

包名(目录)仓颉包名职责
windows-foundationwindows_foundationFoundation 核心投影:Uri / PropertyValue / IReference / MemoryBuffer / Deferral / EventHandler / TypedEventHandler / IStringable / IClosable
windows-collectionswindows_collectionsWinRT 集合投影与 stock 实现:IVector / IMap / IIterable + StockVectorView / MapView / Iterator
windows-futurewindows_futureWinRT 异步投影与 await 机制:IAsyncOperation / IAsyncAction + handler 家族
windows-numericswindows_numericsNumerics 易用值类型 helper(向量 / 矩阵等)

Win32 子系统

包名(目录)仓颉包名职责
windows-registrywindows_registry注册表读写
windows-serviceswindows_services服务控制管理
windows-threadingwindows_threading线程 / 线程池相关
windows-versionwindows_version系统版本信息查询
windows-variantwindows_variantVARIANT 类型
windows-propvariantwindows_propvariantPROPVARIANT 类型
windows-safearraywindows_safearraySAFEARRAY 类型

工具与支持

包名(目录)仓颉包名职责
windows-targetswindows_targetsGNU 链接器所需的 Windows 导入库资源(链接期资产,非源码依赖)
windows-commonwindows_common中心仓维护的 generated 支持 / 投影子集,以及可复用的底层 native ABI helper 入口(注意:它是支持符号包,不是运行时
windows-winui3windows_winui3WinUI 3 / Windows App SDK 支持包
windows-bindgenwindows_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.dlladvapi32.dlluser32.dll 等)里导出的 C 风格函数。它们有几个共同特征:

  • 导出函数:每个函数都是某个 DLL 的导出符号,名字常带 A/W 后缀(ANSI / 宽字符),例如 RegGetValueA / RegGetValueW
  • 句柄(handle):系统资源(注册表项、进程、窗口……)用一个不透明的整数 / 指针句柄表示,例如 HKEYHANDLE
  • 指针参数:很多参数是缓冲区指针、或者“出参指针“(函数往你给的地址里写结果)。
  • 整数返回码:成功 / 失败用 BOOL(0 假、非 0 真)或 HRESULT / Win32 错误码(0 表示成功)表达,而不是抛异常。

在仓颉里调用这类函数有两条路径,从上往下越来越接近裸 ABI:

  1. 优先:直接用 windows-cj 已经封装好的子系统包。它把句柄生命周期、宽字符串编码、错误码翻译都替你做好了,你写的是地道的仓颉代码。
  2. 进阶:当某个函数还没有现成封装时,用仓颉 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_MACHINEKey.borrowed(...) 预建好的根键常量,不需要你自己拼 HKEY 句柄。
  • open / getString 返回 Result<Key> / Result<String>,用 match 解构,不需要检查 Win32 状态码。
  • Key 实现了 Resourcetry (k = key) { ... } 会在作用域结束时自动调用 RegCloseKey,不会泄漏句柄。
  • 宽字符串(UTF-16)编码、缓冲区分配全部在内部完成,你只传普通仓颉 String

范式提醒:仓颉用 Option<T> 代替 null,用 Result<T> 表达可失败的操作;句柄类型由 GC 持有的 class(如 Key)管理生命周期,配合 Resourceclose() 释放原生资源——你不需要手动 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() 判空)。句柄(HMODULEHANDLE)在 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 / HANDLECPointer<Unit>
  • LPCSTR(窄字符串)→ CPointer<UInt8>
  • DWORDUInt32BOOL(C 的 int 返回)→ Int32

调用骨架

调用时,把仓颉数据转成 C 兼容形式,包进 unsafe,再判返回值。下面是 windows-libloadingLoadLibraryExA 的真实调用片段(保留要点):

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-resultHRESULT 类型表达,负值 / 高位置 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 状态码判断成败,并翻译成仓颉的错误表达。

下一步 / 相关

处理字符串

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 实现了 ResourceisClosed() / 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.dllSysAllocStringLen / 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 得到的 BSTRownsStorage()falseclose() 不会释放别人的内存——这正是处理「函数返回的指针」时需要的安全语义。还有 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()
BSTRfromRawTake / 自分配)是(SysAllocStringLen
BSTRfromRawView否(借用)否,close() 不会 free
CWideString持有托管数组close() 仅打标记,内存由 GC 回收
PCWSTR / PWSTR否(纯借用指针)无需,生命周期跟随被指向的内存

记住一条主线:拥有原生堆内存的类型(HString、自分配的 BSTR)才需要确定性回收;其余要么是借用指针,要么由 GC 兜底。 对象之间的引用全部由 GC 管理,你不需要手动 AddRef/Release。

下一步 / 相关

字符串处理之后,下一道绕不开的关是错误码——几乎每个 Windows API 都用 HRESULTBOOL 报告成败。继续阅读:错误处理与 HRESULT

错误处理与 HRESULT

Windows API 报告成败的方式五花八门:COM 和 WinRT 用 HRESULT,传统 Win32 函数常返回 BOOL 再让你去查 GetLastError,内核 / 驱动用 NTSTATUS,注册表等子系统用 WIN32_ERRORwindows-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-resultBOOL 是值类型,提供了和仓颉 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,可以转成 HRESULTError

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 表达,例如 IUnknownVtblIInspectableVtbl。它们的字段是 CFunc<...>,第一个字段 base_ 内联基接口的 vtable,从而复刻 C 的内存布局。 (来源:windows-interface/src/interface_wrapper.cj
  • 接口包装类(IUnknownIInspectable 等)继承自 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.cjqueryInterfaceRaw / 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         => ()
}

无论走哪条路径,失败时你拿到的是 NoneResult.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 里每个 CFunc thunk 收到原生调用时,第一个参数是这块槽的裸指针。thunk 先用 rootRegistryKeyFromRaw(...) 把它换算成根对象的注册表键,再去 comRegistry(一个受 Mutex 保护的 HashMap<UIntNative, Object>)里 comRegistryLookup 找回你的仓颉对象,最后把调用转发过去。 (来源:windows-implement/src/com_registry.cjwindows-implement/src/com_box.cj

引用计数和 QueryInterface 也由这套运行时统一处理:ComObjectRuntime<T> 用原子计数实现 AddRef/ReleasequeryInterfaceBase 按 IID 在已解析的描述符里查对应槽位返回指针;归零时从注册表里摘除并关闭内部资源。IUnknown/IInspectable/IAgileObject/IMarshal/IWeakReferenceSource 这些“系统接口”会被自动接住,无需你实现。 (来源:windows-implement/src/interface_impl_surface.cj

深继承链与 slot 计数

当接口有继承链(IDerived <: IBase)或一个类实现多个接口时,运行时要为每个“自定义接口”分配一个独立的 vtable 槽(slot 0 永远留给身份/IUnknownIInspectable)。

  • InterfaceDescriptor 通过 ownMethodCount / descriptorOwnMethodCount() 记录“本接口自己新增的方法数”,并把祖先 IID 展开进 ancestorIids,使 QueryInterface 能正确命中继承来的接口 IID。 (来源:windows-interface/src/interface_descriptor.cj
  • 运行时按继承深度挑选每个槽该用哪个描述符(深度更大的派生接口优先占槽),保证派生接口的 vtable 覆盖到全部继承方法。 (来源:windows-implement/src/interface_impl_surface.cjrequiredDescriptorIndicesForCustomSlots

好消息是:这些你都不用手写。下面两个宏会替你生成 vtable 结构、thunk、描述符和实现壳。

第一步:用 windows-interface 声明接口

@Interface 宏修饰一个 interface,把 IID 和(可选的)运行时类名写在方括号里。宏会自动生成对应的 Vtbl@C struct)、InterfaceDescriptordescriptorSchema()、包装类,以及给实现侧用的 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.cjpublic 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 通过 CoCreateFreeThreadedMarshalerole32.dll)按需创建它:

// windows-implement 内部封装:为 outer 创建自由线程封送器,失败返回 None
public func createFreeThreadedMarshaler(outer: CPointer<Unit>): Option<IMarshal>

(来源:windows-implement/src/agile_impl.cjwindows-implement/src/native.cj。一般情况下你不直接调用它——toComObject(agile: true) 已经把这条路接好了。)

一句话总结

你写的只是一个普通仓颉 class + 两个宏注解;windows-cj 在背后用 @C struct vtable + slot header + 注册表查找,把原生 COM 调用准确路由回你的对象,并用引用计数 + Resource 回收标记管好生命周期。这是对 COM vtable ABI 的等价表达,不是某门语言概念的搬运。

下一步 / 相关

调用 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 指针,前三槽是 IUnknownQueryInterface/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 投影(UriPropertyValueMemoryBufferDeferral 等)在 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>() 取到工厂、调 CreateUriUri.fromAbiTake(result) 接管返回指针。(来源:windows-foundation/src/foundation_runtime.cjUri.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.UriWindows.Foundation.dllWindows.dll),用 DllGetActivationFactory 取工厂。
  • MTA 兜底:若激活返回 CO_E_NOTINITIALIZED,会先 CoIncrementMTAUsage 把当前进程并入 MTA,再重试一次。这个 cookie 进程内只取一次(FactoryMTAUsageGuard),不会每次回退都泄漏。
  • FactoryCache<C, I>:可选的工厂缓存。它只缓存满足 IAgileObject(敏捷)的工厂——非敏捷工厂不缓存,避免跨单元误用。缓存本身实现 Resourceclose() 时释放缓存的工厂指针;closed 标记防止 ~initclose() 重复释放。

你平时不会直接碰这些——Uri.CreateUriPropertyValue.CreateInt32 这类便捷方法已经替你调用了 factory<...>()。理解它存在的意义在于:WinRT 对象的“构造”实质是一次激活工厂查找 + 一次工厂方法调用。

Type 投影:ABI 类型如何映射到仓颉

WinRT 的每个参数/返回值在 ABI 层都有一个具体的 C 表示(HSTRINGInt32、内联结构体、接口指针……),而在仓颉侧你想用的是 HStringInt32Point、接口包装类。把两者对应起来的,是 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.cjenum 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 含义
DateTimeUniversalTime: Int64绝对时间点(100ns 刻度)
TimeSpanDuration: Int64时间间隔(100ns 刻度)
PointX, Y: Float32二维点
SizeWidth, Height: Float32尺寸
RectX, Y, Width, Height: Float32矩形
EventRegistrationTokenValue: 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

装箱:PropertyValueIReference<T>

WinRT 用 IReference<T>(“可空的盒子”)和 PropertyValue(装箱原语)在“需要 IInspectable 的地方传一个标量”。windows-foundationPropertyValue 提供一组静态工厂,每个都返回装好的 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.cjPropertyValue.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.cjTypedEventHandler.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 集合

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>可变索引序列上面的 + SetAtInsertAtAppendRemoveAtRemoveAtEndClearGetView()ReplaceAll
IMapView<K, V>只读键值映射Lookup(key)Size()HasKey(key)Split(...)
IMap<K, V>可变键值映射上面的 + InsertRemoveClearGetView()
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.cjIIterable<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.cjIVector 方法 Append/InsertAt/SetAt/RemoveAt/RemoveAtEnd/Clear/GetView/ReplaceAll,以及 IMapInsert/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.cjtoVectorView。)

映射同理:

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.cjtoVectorView / toMapView / createStockInterfaceMulti 与各 Stock*Impl 类。)

范式提醒

  • 集合接口方法标 unsafe(最终走 vtable 裸调用),在 unsafe { } 里调用。
  • 取到的迭代器 / 视图 / 元素包装都实现 Resource,用 try/finally close();回收标记防 double free。
  • 缺失值用 Option<T>、没有 null;先 HasKeyLookup 是稳妥写法。
  • 消费侧优先用 for-inIIterable<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.cjextend 块):

  • join(): Result<T> —— 阻塞当前线程直到终态,然后把结果包成 Result:成功 Ok(值)、出错 Err(对应 HRESULT)、取消 Err(E_ABORT)。对 IAsyncOperation<TResult>Result<TResult>,对 IAsyncActionResult<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
IAsyncActionAsyncActionCompletedHandler
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.cjtestNativeAsyncCompletedGettersReturnStoredHandlers,那里用一模一样的 AsyncOperationCompletedHandler<Int32>.new({ info, status => ... }) 闭包构造,并用 SetCompleted / Completed 往返):

  • handler 由仓颉闭包支撑、是个 COM 对象——存活由 GC 管,不要手写 AddRef/Release;不再需要时 close() 释放 COM 包装。
  • 注册后还能用 unsafe { operation.Completed() } 取回当前挂着的 handler。
  • 三种终态全靠 statusAsyncStatus_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.cjtestSpawnedActionCancelTransitionsToCanceledinfo.Cancel()Status()Canceledjoin() 返回 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.cjIAsyncOperation.spawnAsync / readyProgressReporter<TProgress>.report,以及 async_helpers_test.cjtestSpawnedOperationWithProgressPreservesWindowsExceptionHRESULT。)

生命周期与线程注意事项

  • 阻塞 vs 非阻塞。 join() 阻塞当前线程;在 UI/敏捷线程上长时间阻塞不可取,那种场合用 when()(后台跑)或 completed handler(回调)。
  • handler 触发线程。 completed handler 可能在发起线程之外被回调;如果它要触碰只能在特定单元(apartment)使用的对象,先用 AgileReference<T> 把对象包成敏捷引用再跨线程解引用(见 COM API)。
  • 原生引用确定性回收。 IAsyncInfo、handler、异步对象都实现 Resource,用 try/finally close();回收标记防 ~initclose() 重复释放。
  • 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 函数(比如 RegGetValueWCoCreateInstanceRoGetActivationFactory)时,仓颉只知道这个符号的声明,并不知道它的机器码在哪。把“符号名“对应到“系统 DLL 里的导出地址“这件事,发生在链接期:链接器需要一份导入库(import library),里面记录了每个导出符号属于哪个 DLL、怎么跳转过去。没有这份导入库,链接会以“undefined reference“失败。

windows-cj 走的是 GNU 工具链(mingw 风格)。GNU 链接器用 -l... 选项来引入库:-lkernel32libkernel32.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.cjwindows-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 等字段。注意 archiveNameOption<String>:unsupported target 取值为 None,这是它“没有载荷“的直接编码方式(仓颉用 Option<T> 代替 null)。

libwindows.0.53.0.a 覆盖的是 Win32 基础导入符号。COM / WinRT 还需要额外的系统库,它们用各自的 -l 选项引入,最常见的是:

  • -lole32 —— COM 运行时核心(CoCreateInstanceCoTaskMemFree 等)。
  • -loleaut32 —— OLE 自动化(BSTRSysAllocStringLen / SysFreeStringVARIANT 等)。
  • -lwindowsapp —— WinRT 的伞库(RoGetActivationFactoryWindowsCreateString 等运行时入口)。

这些选项写在消费方项目(你的可执行程序)的 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 的公开异常类,带 targetNamereason 两个字段,消息形如:

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 生成绑定

到目前为止,你用到的都是仓库里已经签入的支持包(windows_corewindows_stringswindows_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 宏、GUIDIInspectable
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 —— ApplicationCallbackWindowHandlestartApplication,以及加载 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

资源生命周期

ApplicationCallbackWindowHandle、以及你自己的应用对象(demo 里的 TodoApp,它本身 <: Resource)都持有原生 COM 指针。仓颉的 GC 会管理仓颉对象之间的引用,但原生指针必须显式释放——这就是这些类型都实现 Resource 并提供 close() 的原因。

demo 的 cleanupGeneratedWinuiRoots 演示了正确的关闭顺序:先关应用、再关窗口、最后关回调,每一步都先 matchOption 里的句柄,关掉后把根置回 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 不会把静态依赖里的 native link-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、运行时类。
  • 引言:回到全书入口,按需挑选其它主题。