很多Windows开发者第一次接触WinRT的时候,感觉像是打开了一个新世界的大门,又像是突然被扔进了一片迷雾里。这个概念本身并不复杂,但围绕它的名词实在是太多了——Windows Runtime、UWP、WinUI、WASDK、Composition、异步模型、语言投影……光是搞清楚这些词之间的关系,就够让人晕一阵的了。
所以我一直觉得,写一个WinRT的基础demo,最核心的价值其实不在代码本身,而在帮你把那层“怎么回事”的迷雾给拨开。这篇文章不打算讲太多学院派的理论,就用一个从业者的视角,把我自己从零跑通一个WinRT基础demo的完整过程、思考逻辑和踩坑记录都摊开来聊一聊。如果你正准备入门Windows应用开发,或者已经写过一阵子传统桌面程序、想了解一下新一代Windows API的打开方式,这篇文章可以帮你省掉不少摸索的时间。
1. WinRT到底是什么:先搞清楚它不是一个“框架”
1.1 三个最容易混淆的概念
我第一次接触WinRT的时候,误以为它跟MFC、WPF一样,是个UI框架。后来被拉去改一个已有的项目,才发现完全不是这么回事。WinRT全称是Windows Runtime,它本质上是一套API的规范和调用机制,而不是像WPF那样直接给你一堆控件和布局系统的框架。
为了把这层关系说清楚,我习惯用这样一个类比:WinRT是餐厅的“服务规则”,它规定了客人怎么点菜、厨房怎么出菜、账单怎么结算。而WPF、WinUI这些,是不同类型的餐厅——菜单内容不同、装修风格不同,但都遵循同一套服务规则。也就是说,WinRT为上层框架提供的是“API的契约和调用通道”,不是UI本身。
再往下拆,有几个名词必须放在一起理解:
- Windows Runtime(WinRT):一套基于COM技术演化而来的API规范,定义了Windows系统功能以何种方式暴露给开发者。
- UWP(Universal Windows Platform):基于WinRT API构建的一种应用模型,强调的是“一个应用适配多种设备”。
- WinUI 3 / Windows App SDK:目前主流的UI框架和部署形态,用WinRT API来调用系统能力,但不再局限于UWP的应用模型。
很多人一听说WinRT就联想到UWP,觉得UWP凉了WinRT就没什么好学的了。这是个很大的误区。实际上WinRT早就成了Windows应用开发的底层基础设施,WinUI 3也好、WASDK也好,本质上都在WinRT这个机制之上运行。
1.2 为什么说WinRT是一套“契约”
WinRT的核心设计思路,是把系统能力描述成一种“语言无关的API接口”。什么意思呢?就是说,同一个API,你用C++可以调用,用C#可以调用,用JavaScript甚至可以调用。系统只负责提供能力,具体怎么调用、用什么语法调用,由各语言的“投影层”(Projection)负责翻译。
这种设计带来的直接好处是:系统能力和上层语言解耦了。某个能力由系统团队用C++实现,但通过WinRT契约暴露给使用C#的开发者时,开发者看到的是一个符合C#风格的对象模型,甚至不需要关心底层是COM还是C++。
我在实际开发中感受最深的是一个词:异步。WinRT把几乎所有可能耗时超过50毫秒的操作都设计成了异步接口,比如读取文件、访问网络、解码图片。这些接口在C#里直接映射成async/await的用法,体验非常顺滑。但这背后其实是WinRT自身设计理念的体现:系统级的API不希望你阻塞UI线程,所以从契约层面就强制你做异步。
1.3 基础demo到底“基础”在哪里
明白了上面这些,所谓“winrt 基础demo”要解决的问题就清晰了。它不是要教你写一个漂亮的界面,而是要跑通一个完整的链路:应用启动、创建窗口、调用一个系统API、处理异步结果、把结果显示到界面上。这个过程虽然看起来很平常,但中间每一步都踩在WinRT的机制上。
当时我给自己定的目标是写一个最小可运行的程序:点一个按钮,读取用户“图片”目录下的文件列表,用列表形式展示在界面上。这个目标覆盖了WinRT开发的几个关键点——UI线程与异步调用的交互、系统目录的访问方式、数据绑定和集合变化通知。算是麻雀虽小五脏俱全。
2. 技术选型考量:为什么推荐WinUI 3作为起点
2.1 各个技术路线的处境
在真正动手之前,要先做一道选择题:用哪种技术路线来承载这个WinRT基础demo?我梳理了一下,目前可选的路线大致有四条:
| 技术路线 | 应用模型 | 当前状态 | 适合场景 |
|---|---|---|---|
| UWP + WinUI 2 | UWP | 维护期,新功能有限 | 已有UWP项目维护 |
| WinUI 3 + WASDK | 传统桌面 | 活跃迭代,微软主推方向 | 新项目首选 |
| C++/WinRT + XAML | UWP或桌面 | 进阶玩家路线 | 对性能敏感的场景 |
| 传统WPF/WinForms调WinRT API | 传统桌面 | 可用的“借用”方案 | 老项目中使用系统新能力 |
我个人推荐直接用WinUI 3作为方向,原因是它能让你聚焦在WinRT本身,而不被UWP那套打包、部署、权限模型的额外复杂度干扰。UWP里光是一个AppxManifest就够新人琢磨好几天,但那些内容跟“理解WinRT”这件事关系不大。
WinUI 3目前的迭代速度也很快,WASDK(Windows App SDK)的发布节奏基本是按月维度的。它是桌面应用,不需要考虑UWP的受限部署环境,直接用MSIX或者普通文件夹部署都可以。这对初学者来说,调试和部署的门槛都会低不少。
2.2 开发语言:C#是绕不开的第一选择
虽然WinRT本身支持多种语言的投影,但做基础demo的话,C#仍然是当之无愧的第一选择。不是说C++/WinRT不好,而是C#的异步支持跟WinRT的IAsyncOperation接口配合得太自然了。你写C#的时候,一个await就把异步调用接上了,完全不用关心底层AsyncOperation的进度、取消、错误处理这些细节怎么映射。
C++/WinRT我后面也试过,说实话,它是理解WinRT底层机制的最佳途径,因为投影层比较薄,很多概念都能直接看到。但作为基础demo,用它来入门容易打击信心——你会花很多时间在头文件包含、命名空间处理、智能指针管理这些事上,而这些跟WinRT核心概念关系不大。
所以,如果你是想快速建立起对WinRT的直觉,C# + WinUI 3是最优解;如果你想深入理解WinRT的底层机制,可以先拿C#跑通一遍流程,再去碰C++/WinRT。
2.3 我最终确定的demo结构
我的demo最终定的是一个单窗口应用,项目结构非常朴素:
- MainWindow.xaml:界面布局,放一个按钮和一个ListView
- MainWindow.xaml.cs:按钮点击事件,调用WinRT API获取目录文件列表
- app.manifest:声明所需权限
- 项目文件:通过PackageReference引入Windows App SDK
打开Visual Studio,创建项目时选择“Blank App, Packaged (WinUI 3 in Desktop)”。这个模板会自动帮你配好WASDK的依赖,启动就是一个空白窗口。我要做的就是在基础上把功能加进去。
3. 实操过程:一步步跑通WinRT基础demo
3.1 环境准备与项目创建
先说一下环境要求。WinUI 3开发需要Windows 10 1809或更高版本,开发工具建议直接用最新版的Visual Studio 2022,记得在安装器里勾选“Windows应用SDK”相关的工作负载。如果你用的是旧版的Visual Studio,装好Windows App SDK的扩展也可以,但不如直接用新环境省心。
项目创建的时候,有几个模板名字容易看花眼:
- Blank App, Packaged (WinUI 3 in Desktop):打包模式,生成MSIX包
- Blank App, Unpackaged (WinUI 3 in Desktop):未打包模式,直接输出exe
- Blank App (WinUI 3 in UWP):这是UWP项目,初学者不建议选这个
我做基础demo选择的是打包模式。原因很简单:打包模式下的权限声明、部署行为更接近Windows应用的标准形态,而且Windows App SDK在打包模式下的运行时行为最稳定。不打包模式虽然调试时少一些部署步骤,但有些系统API的调用行为跟打包模式会有差异,排查起来容易分心。
3.2 界面代码的最小骨架
搭建界面这一步没什么特别花哨的,但有两个细节值得留意。第一个是根元素用Window还是Page,WinUI 3的模板默认给的是Window,你可以在Window里面直接放内容,也可以放一个Frame来做页面导航。基础demo不需要导航,所以直接在Window里写就行。
界面就是一个按钮加一个列表,XAML代码如下:
<Window ...> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <Button x:Name="LoadButton" Content="加载图片目录" Click="LoadButton_Click" Margin="12"/> <ListView x:Name="FileList" Grid.Row="1" Margin="12,0,12,12"/> </Grid> </Window>这里要强调一个基础但重要的概念:ListView这类控件,默认情况下你把一个普通的List<string>赋给它,界面不会自动刷新。这不叫Bug,而是设计如此。因为XAML的数据绑定机制要求集合必须实现INotifyCollectionChanged接口,普通的List<T>没有这个能力。正确的做法是用ObservableCollection<T>,它在集合发生变化时会主动通知UI更新。
这个细节我在第一个版本就踩了坑,后面会详细说。
3.3 调用WinRT API获取文件列表
按钮的点击事件里要做三件事:获取“图片”文件夹的引用、遍历它的文件、把结果填进界面。用到的是Windows.Storage命名空间下的API,这是最典型的WinRT系统能力之一。
private async void LoadButton_Click(object sender, RoutedEventArgs e) { LoadButton.IsEnabled = false; try { var pictureFolder = await KnownFolders.GetFolderForUserAsync( null, KnownFolderId.PicturesLibrary); var files = await pictureFolder.GetFilesAsync(); var fileNames = new ObservableCollection<string>(); foreach (var file in files) { fileNames.Add(file.Name); } FileList.ItemsSource = fileNames; } catch (Exception ex) { // 在真实项目里这里应该用更好的方式展示错误 FileList.ItemsSource = new ObservableCollection<string> { "加载失败: " + ex.Message }; } finally { LoadButton.IsEnabled = true; } }这段代码看起来简单,但它已经涵盖了WinRT开发中最重要的两个机制。
第一个是GetFolderForUserAsync这个方法的调用方式。注意它需要传一个User类型的参数,传入null表示当前登录用户。这是WinRT的多用户支持设计思路的体现——系统API默认情况下都考虑了多用户场景,所以很多与用户数据相关的方法都会要求明确指定用户。
第二个是GetFilesAsync()返回的是一个异步操作。WinRT里所有异步操作都会返回实现了IAsyncOperation<T>接口的对象,在C#投影层它被封装成了我们可以直接await的类型。这不仅是为了防止UI卡顿,更重要的是,WinRT的异步模型是经过精心设计的——它天然支持取消、进度报告和错误处理。
3.4 权限声明:最容易遗漏的一环
如果你按照上面的代码直接运行,大概率会在调用GetFolderForUserAsync的时候抛出一个异常,提示“拒绝访问”之类的信息。这就是WinRT的权限机制在起作用了。
WinRT对敏感资源有严格的访问控制。访问图片库这类位置,必须在应用清单中声明对应的功能(Capability)。打包模式下,这个声明需要加到Package.appxmanifest文件里。
找.appxmanifest文件,打开XML编辑器,在<Capabilities>节点下添加:
<uap:Capability Name="picturesLibrary"/>注意,uap:这个前缀在模板项目的清单文件里可能已经定义好了,如果没有定义,需要在<Package>根元素上加上命名空间声明:
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"加完之后重新生成项目,再运行,就能正常访问图片库了。这个步骤特别容易忘,因为模板自带的清单文件看起来非常简洁,很容易让人觉得“不需要加东西”。
4. 核心细节拆解:WinRT的异步、投影与生命周期
4.1 为什么WinRT的异步方法后缀都是Async
如果你观察WinRT的API命名,会发现在C#里看到的方法都是GetFilesAsync、ReadTextAsync、OpenAsync这种带Async后缀的。这不是随意命名的,而是WinRT设计规范的一部分——它强制要求所有可能执行较长时间的操作都用异步模式。
这套规范在WinRT诞生之初就定下了,理由是移动设备的系统资源比桌面平台更敏感,任何阻塞UI线程的操作都会导致掉帧和卡顿。到了现在,虽然桌面设备性能大幅提升,但异步这一设计原则保留了下来,甚至成了Windows应用开发的基本礼仪。
作为一个对比,传统的Win32 API里,很多操作是同步的。比如你要读取一个文件,ReadFile这个API会一直等读取完成才返回。如果文件很大或者磁盘很慢,调用线程就这么被卡住了。WinRT则通过IAsyncOperation把这种耗时操作变成了“我先干别的,你完成后通知我”的模式。C#的async/await让这种模式写起来跟同步代码几乎没有区别,但底层的执行逻辑完全不同。
4.2 C#投影层的魔法
WinRT的API在底层其实是COM接口。所谓投影,就是把这层COM接口转换成各种语言熟悉的形态。在C#里,一个IAsyncOperation<string>会被投影成Task<string>——这就是为什么你能直接用await的原因。
投影层还处理了类型映射。比如WinRT的IVector<string>在C#里看起来就是IList<string>;WinRT的StorageFile在C#里就是一个普通的类对象。这种映射让C#开发者不需要理解COM的引用计数、接口查询这些底层概念,用起来就像在操作普通的.NET对象。
但投影层也不是完全没有存在感。它带来的一个实际问题是:有些类型不能被随意跨越程序集边界。具体表现为,如果你在一个WinRT组件和普通.NET类库之间传递数据,有些类型可能不被支持。基础demo不会遇到这种问题,但我在后续做更复杂的项目时确实被这个边界坑过一回。这里先提个醒,等你研究到需要写WinRT组件的时候,记得查一下“支持WinRT的.NET类型列表”。
4.3 UI线程与异步回调:WinRT的“亲和”规则
XAML的UI元素不是线程安全的,所有UI操作都必须在UI线程上执行。WinRT的异步操作完成之后,回调会默认返回到调用时的同步上下文(SynchronizationContext)。这意味着你从按钮点击事件里await一个异步操作,await之后的代码依然在UI线程上运行,可以直接操作界面元素。
这是WinRT和C#配合得天衣无缝的地方,你不需要像传统Win32编程那样手动调用Dispatcher.BeginInvoke来切换线程。但有一个前提条件:异步操作的起点必须本身就是UI线程。如果你在一个后台线程里调用await,那么await之后恢复的线程就是后台线程,此时操作界面元素就会抛异常。
这个规则理解起来不难,但很多人第一次遇到“从后台线程更新UI”的问题时,还是会懵。排查思路很清晰:检查调用链路的起点线程是什么。我见过不少同事把异步调用封装到一个帮助类里,然后在后台线程调用这个帮助类,结果UI更新异常,问题不在WinRT,而在调用链的起点。
4.4 应用生命周期:不是“关闭”就结束了
WinRT应用的生命周期模型跟传统桌面程序有显著差异。传统Win32程序,关掉窗口基本等于进程退出,资源随之释放。但WinRT程序(尤其是UWP应用模型)有Suspended、Resumed这些状态,系统可以在资源紧张时“冻结”你的应用,而它本身并不知道自己正在被冻结。
WinUI 3桌面程序保留了部分生命周期概念,但如果你不是用UWP模型,生命周期简单很多。不过,我仍然建议理解一下这个模型,因为你迟早会跟它打交道——比如应用被挂起后,再恢复时,文件句柄是否还能用、异步操作是否还在继续,这些都是实际开发中会遇到的问题。
在基础demo这个阶段,你要记住的最重要一点是:不要在应用进入后台挂起状态之后,依赖异步操作的自动恢复。如果用户按了Alt+Tab切走再切回来,你的代码应该能优雅地处理“操作被中断”的情况。最简单的做法是在OnSuspending事件里把未完成的操作状态保存下来,恢复时再从这里接着跑。
5. 实操中的常见问题与排查技巧
5.1 列表刷新不及时,界面一片空白
我第一个版本用List<string>直接绑定ItemsSource,点击按钮后界面没有任何反应。查了半天才发现是集合类型的问题。前面提过,XAML数据绑定需要集合实现INotifyCollectionChanged,List<T>不满足,所以即使集合内部加了数据,UI也不知道该刷新了。
解决办法就是换成ObservableCollection<T>。这是XAML绑定生态里的一个标准答案,几乎所有动态变化的集合都需要用到它。
还有另一种情况值得特别注意:如果你把ItemsSource绑定到一个普通属性上,而这个属性在代码里被重新赋值了,那么界面也可能不更新。原因是属性绑定要求属性所在的类实现INotifyPropertyChanged。这个机制和集合通知是两个不同层面的东西,一个是“属性值变了”,一个是“集合内容变了”,经常被混在一起谈,但实际上它们由不同的接口负责。
5.2 异步方法里的UI操作报错
这种情况通常发生在你封装的异步方法里。比如你写了一个帮助方法,内部调用了await KnownFolders.GetFolderForUserAsync,方法返回后还访问了UI元素,结果抛出异常说“应用程序正在被挂起”或“调用线程无法访问此对象”。
根本原因是帮助方法被调用的线程可能不是UI线程。一个很常见的场景:构造函数里调用了初始化方法,构造函数本身在UI线程上执行,但如果你在初始化方法里用了Task.Run把工作扔到线程池,那么await之后你的代码运行在线程池线程上,自然不能操作UI。
排查这类问题有个实用技巧:在你怀疑出问题的代码前抓一下当前线程的Id,跟UI线程的Id对比。在WinUI 3里可以在构造函数里记录Environment.CurrentManagedThreadId,然后在异步代码里再抓一次,比较两者是否相同,立刻就能定位问题。
5.3 认识错误的“用户”参数
有些人在调用KnownFolders.GetFolderForUserAsync的时候,抓到的第一参数是User类型,就直接传了一个新构造的User对象。实际上不是这样,这个参数的意思是“以哪个用户的名义去获取目录”,传null才是正确的用法。
User类本身是WinRT中多用户会话(MUA,Multi-User Applications)的一部分。在多用户场景下,每个用户都有独立的目录和权限,系统API需要明确知道在哪个用户上下文里操作。对于绝大多数常规应用,当前登录用户就是唯一的用户,所以传null就是正确的语义。
如果你在某个远程桌面或多会话环境中调试,可能会发现null参数返回的目录不是你预期的那个。那就需要先用User.GetUsersFromId获取当前会话的用户列表,再指定正确用户。这属于进阶话题,基础demo阶段先知道有这回事即可。
5.4 打包模式下的部署失败
用Visual Studio调试打包应用时,偶尔会遇到部署失败,提示“无法注册程序包”。最常见的原因是包名和已有的安装包冲突,或者版本号没变但签名不一致。
排查步骤一般是:先在PowerShell里执行Get-AppxPackage看看有没有同名包,如果有,用Remove-AppxPackage删掉再试。还有一个高频坑是:改了清单里的包名之后,旧的包名还在某个地方被引用,导致注册信息不一致。这种时候我会直接卸载整个项目相关的包,清空bin和obj目录,重新生成再部署,基本能解决问题。
5.5 错误处理:不要吞异常
这是从基础demo阶段就应该养成的好习惯。WinRT的异步操作如果抛异常,在C#里会以Exception的形式冒出来,所以try/catch是必要的。但很多人习惯catch到一个Exception后只记日志不处理,这在基础demo里无所谓,但到了生产环境就是定时炸弹。
特别是在访问文件系统这类场景下,一个文件被其他进程占用、路径太长、权限变化,都可能抛出意想不到的异常。建议在开发早期就养成一个习惯:每个catch块里至少要想清楚“这个异常说明什么、用户应该看到什么反馈、要不要重试”。哪怕是在demo里,也用FileList.ItemsSource展示一个错误项,而不是printf到控制台就完了。
6. 从基础demo延伸:下一步可以做的几个方向
6.1 加入图片预览功能
读完文件列表之后,很自然的下一步就是点击某个文件时预览它。这会涉及StorageFile的读取和图片解码。WinRT里的StorageFile.OpenAsync返回一个IRandomAccessStream,这个流类型和.NET的Stream不太一样,但可以通过AsStream()扩展方法转换。
图片预览这个功能虽然不复杂,但能把“文件句柄的获取与释放”“流的生命周期管理”“异步读取”这几个概念串起来。比单纯列文件名有意思得多,也更接近真实项目的形态。
6.2 接入数据绑定框架
基础demo里,我直接用了ItemsSource赋值来做数据展示,这种方式最快也最直接。但如果要做更复杂的界面,建议尽早引入MVVM(Model-View-ViewModel)模式。WinUI 3对数据绑定和命令绑定的支持非常完善,你可以定义ViewModel类,实现INotifyPropertyChanged,用x:Bind把UI绑定到ViewModel属性上。
MVVM的好处是逻辑和UI解耦,让代码可以单元测试。不过第一次接触MVVM的人容易陷入“过度设计”的陷阱——一个按钮的点击事件也要搞个Command类,一个临时变量也要搞个属性。我的建议是:一开始不必强制自己写MVVM,先从代码后置的写法开始,理解界面和数据的交互机制后,再逐步把逻辑抽到ViewModel里。
6.3 尝试C++/WinRT
如果你对基础demo背后“投影”的那层机制产生了好奇,C++/WinRT是很值得挑战的方向。在C++/WinRT里,你看不到C#那样的魔法,一切都更加显式:你要自己include头文件、自己处理IAsyncOperation<T>的get()方法、自己管理对象的生命周期。
第一次从C#转到C++/WinRT确实会觉得自己在“倒退”——写了太多废话才能完成同样的一件事。但正是这些废话,让你真正理解了WinRT的机制:投影层替我们做了什么、C#编译器帮我们生成了什么代码。这种理解反过来能让你在C#项目里排查问题时更有底气,因为你知道底层发生了什么。
6.4 接入系统服务
WinRT提供的能力远不止文件系统这一块。蓝牙、定位、传感器、后台任务、通知推送、媒体传输控制……这些都是以WinRT API的形式暴露的。如果把基础demo的核心价值定位为“理解WinRT的调用模型”,那把这些API逐个接入就是最高效的学习路径。
我的建议顺序是:先做文件访问(已经做完了),再做设置存储(ApplicationDataContainer),再做通知推送(ToastNotification)。这三个能力的基本模式是一样的——异步获取服务实例、调用方法、处理结果——但每个都有自己独特的细节。跑完这三个,你对WinRT的“感觉”就基本建立了。
7. 写给自己的总结:基础demo教会我的三件事
7.1 先理解机制,再堆代码
我把这个demo写完再回头看,最有价值的收获不是那几行代码,而是对Windows应用开发整体图景的认识。WinRT是一个底层API规范,WinUI是UI框架层,WASDK是打包部署层,C#帮我们做了大量的投影和适配工作。理解这些层级关系,比记住某个特定API的签名重要得多。
7.2 异步不是语法糖,是一种设计哲学
C#的async/await让异步编程看起来像同步,但这种表象容易让人忽略异步背后的真正意义。WinRT的设计者把“不能阻塞UI线程”变成了API层面的强制规则,而不是靠开发者的自觉。理解这一点之后,你在设计自己的应用架构时,也会更自然地往异步方向思考。
7.3 系统文档之外的东西,才是挡路最多的坑
权限声明、集合通知、线程亲和性、包部署冲突——这些问题在官方文档里都有解答,但分布在不同页面里,新手很难在遇到问题的当下把它们联系起来。基础demo最大的意义,就是让你把这些问题亲自踩一遍。踩过一遍之后,这些知识就真正属于你了。
我后来把这份基础demo扩展过好几个方向,做过文件预览版、做过C++/WinRT版、也做过加入MVVM框架的版本。每扩展一次,都能对WinRT的某些侧面多一层理解。如果你也打算从零开始写一个WinRT基础demo,我希望这篇文章能帮你少走一些弯路,把精力花在真正需要琢磨的事情上。