本章导读:打开一个新建的 Uno 项目,你可能会被眼前琳琅满目的文件和文件夹所困惑——它们是什么?为什么需要这么多?哪些代码该放在哪里?本章将带你像解剖学家一样,系统地理解 Uno 项目的每一块"骨骼"和"肌肉"。当你理解了项目结构背后的设计哲学,你就能够自信地组织代码,构建可维护的大型应用。
在深入具体文件之前,让我们先思考一个根本问题:如何组织一套需要同时运行在多个操作系统上的代码?这个问题没有标准答案,不同的框架采取了不同的策略,每种策略都有其取舍。
历史上,跨平台框架采取了两种主要的代码组织方式,我们可以用城市规划来类比理解它们。
多项目结构就像是建立多个独立的城市,每个城市(平台)有自己的市政厅(入口点)、自己的基础设施(平台配置),但共享同一个图书馆和学校(共享代码)。这种方式的优点是边界清晰,每个平台可以独立演进;缺点是需要在多个项目间同步配置,维护成本较高。
单项目结构则像是建立一座大都市,用不同的街区来管理不同的事务。市中心是共享的商业区(核心业务逻辑),边缘是各具特色的住宅区(平台特定代码)。这种方式的优点是配置集中,易于管理;缺点是项目文件可能变得复杂,单个文件中的条件编译可能让代码难以阅读。
第一性原理:无论采用哪种结构,核心目标都是最大化代码复用,同时保留调用平台特定功能的能力。理解了这一点,你就能根据项目的实际需求做出明智的选择。
Uno 5.x 引入了单项目结构作为默认选项,这是对 .NET MAUI 类似设计的响应。让我们详细剖析这种结构。
当你创建一个新的 Uno 项目时,会得到一个类似下面这样的目录结构:
MyUnoApp/
├── Platforms/ # 平台特定代码的家园
│ ├── Android/ # Android 的"签证"
│ │ ├── Main.Android.cs # 应用入口点
│ │ └── AndroidManifest.xml # 应用元数据
│ ├── iOS/ # iOS 的"签证"
│ │ ├── Main.iOS.cs # 应用入口点
│ │ └── Info.plist # 应用元数据
│ ├── WebAssembly/ # WebAssembly 的"签证"
│ │ ├── Program.cs # 启动引导程序
│ │ └── wwwroot/ # 静态资源
│ │ └── index.html # 入口页面
│ ├── MacCatalyst/ # macOS Catalyst
│ └── Windows/ # Windows 特定配置
├── Resources/ # 共享资源(图片、字体等)
│ ├── Images/
│ └── Fonts/
├── Styles/ # 全局样式定义
│ ├── ColorPalette.xaml
│ └── AppTheme.xaml
├── Views/ # XAML 视图文件
│ ├── MainPage.xaml
│ └── SecondPage.xaml
├── ViewModels/ # 视图模型(MVVM 架构)
│ ├── MainViewModel.cs
│ └── SecondViewModel.cs
├── Services/ # 服务层
│ └── IDataService.cs
├── Models/ # 数据模型
│ └── User.cs
├── App.xaml # 应用程序根元素
├── App.xaml.cs # 应用程序生命周期管理
└── MyUnoApp.csproj # 项目配置文件
为什么这样组织? 这种结构遵循了"关注点分离"的软件工程原则。Views 文件夹只关心 UI 如何呈现,ViewModels 文件夹只关心 UI 背后的逻辑,Services 文件夹只关心数据如何获取和处理。当你需要修改某个功能时,你清楚地知道应该去哪里找。
打开 MyUnoApp.csproj,你会看到它的核心配置。这个看似简单的 XML 文件,实际上承载了多平台编译的所有秘密。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- 多目标框架:告诉编译器这个项目需要编译成多个平台的输出 -->
<TargetFrameworks>net8.0-windows10.0.19041;net8.0-android;net8.0-ios;net8.0-maccatalyst;net8.0-browserwasm</TargetFrameworks>
<!-- 单输出版本:为没有明确指定版本的平台设置默认值 -->
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
<!-- Windows 特定配置 -->
<RuntimeIdentifier Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">win10-x64</RuntimeIdentifier>
</PropertyGroup>
<!-- NuGet 包引用 -->
<ItemGroup>
<PackageReference Include="Uno.WinUI" Version="5.2.0" />
<PackageReference Include="Uno.Extensions.Hosting" Version="4.1.0" />
</ItemGroup>
</Project>
TargetFrameworks 是什么意思? 这个属性告诉 .NET 编译器:请把这个项目编译成多个不同的版本。当你构建项目时,编译器会为每个目标框架生成独立的输出。net8.0-windows10.0.19041表示 Windows 10 版本 19041(Windows 10 2004)及以上,net8.0-android表示 Android,net8.0-ios表示 iOS,等等。
每个 Uno 应用都有一个核心——App 类。它是应用程序的入口点,也是全局资源的容器。理解这个类的工作原理,对于构建复杂应用至关重要。
App.xaml 文件定义了应用程序级别的资源。这些资源在整个应用中都可以访问,就像全局变量,但更加优雅和安全。
<Application x:Class="MyUnoApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<!-- 全局颜色定义 -->
<Color x:Key="PrimaryColor">#0078D4</Color>
<Color x:Key="SecondaryColor">#106EBE</Color>
<!-- 全局样式 -->
<Style x:Key="BaseTextBlockStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
资源字典的工作原理:当你使用 {StaticResource PrimaryColor} 引用一个资源时,XAML 引擎会从当前元素开始,沿着视觉树向上搜索,直到找到匹配的资源键。如果到达 App 级别仍未找到,就会报错。这种"就近原则"允许你在局部覆盖全局资源。
App.xaml.cs 是应用程序生命周期管理的中枢。它继承自 Application 类,处理应用启动、暂停、恢复和关闭等事件。
public sealed partial class App : Application
{
private Window? _window;
public App()
{
// 初始化日志、依赖注入容器等
this.InitializeComponent();
}
/// <summary>
/// 应用启动时调用,这是你设置主窗口的地方
/// </summary>
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
// 创建或获取当前窗口
_window = Window.Current ?? new Window();
// 创建导航框架
var rootFrame = _window.Content as Frame;
if (rootFrame == null)
{
rootFrame = new Frame();
_window.Content = rootFrame;
}
// 如果框架中没有内容,导航到主页
if (rootFrame.Content == null)
{
rootFrame.Navigate(typeof(MainPage), args.Arguments);
}
// 激活窗口
_window.Activate();
}
}
让我们像解剖青蛙一样,仔细观察这段代码的每个部分。
_window = Window.Current ?? new Window(); 这行代码可能让人困惑。为什么有时候需要新窗口,有时候又使用现有窗口?答案在于应用的生命周期模型。在 Windows 桌面应用中,Window.Current 通常返回 null,因为应用刚启动时还没有窗口。但在某些特殊场景(如应用被系统从休眠中恢复)时,可能已经存在一个窗口对象。
Frame 是什么? Frame 是一个导航容器,类似于浏览器的标签页。它可以"容纳"一个页面,并提供前进、后退等导航功能。当你调用 Navigate(typeof(MainPage)) 时,Frame 会创建一个 MainPage 实例并将其作为自己的内容。这种设计模式被称为"导航框架模式",是 Windows 应用开发的标准做法。
不同平台有不同的应用生命周期模型,但 Uno 通过 Application 类提供了一个统一的抽象。
启动(Launched) 在所有平台上都会触发,这是你初始化应用的最佳时机。在 Windows 上,这可能来自用户点击开始菜单;在 Android 上,可能来自用户点击应用图标或另一个应用的调用;在 WebAssembly 上,则是用户访问网页。
暂停(Suspending) 在移动平台上尤为重要。当用户切换到另一个应用时,你的应用会被暂停以节省资源。你应该在这个事件中保存未完成的用户数据,因为系统可能会在后台终止暂停的应用。
移动端与桌面端的生命周期差异:在 Windows 桌面上,用户最小化窗口后,应用仍然在运行。但在 Android 和 iOS 上,系统会积极管理内存,可能随时终止后台应用。这种差异意味着你需要在移动端更加谨慎地处理状态保存。
虽然 Uno 追求最大化的代码共享,但每个平台都有其无法回避的"入场券"——系统要求你提供特定的入口点和配置。这就是 Platforms 文件夹存在的意义。
在 Platforms/Android/ 文件夹中,最重要的文件是 Main.Android.cs。这个文件定义了 Android 应用的入口点——一个继承自 Uno.UIActivity 的 Activity。
[Activity(
MainLauncher = true,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize,
WindowSoftInputMode = SoftInput.AdjustPan | SoftInput.StateHidden
)]
public class MainActivity : Uno.UIActivity
{
// 大多数情况下,你不需要在这里添加代码
// Uno 框架已经处理了大部分初始化工作
}
AndroidManifest.xml 则定义了应用的元数据,包括应用名称、图标、权限声明等。Uno 会自动合并这个清单文件和框架要求的默认配置。
Activity 是什么? 在 Android 中,Activity 是一个"屏幕"或"窗口"的概念。每个 Activity 代表用户可以与之交互的一个界面。Uno 将你的整个应用封装在一个 Activity 中,内部使用 Frame 来管理页面导航。这种设计简化了与 Android 生态的集成,同时保持了 WinUI 的导航语义。
iOS 平台的入口是 Main.iOS.cs,它定义了应用程序的委托(AppDelegate)。
public class EntryPoint
{
// 这是 iOS 应用的真正入口点
public static void Main(string[] args)
{
Uno.UI.Hosting.UnoPlatformHost.Create()
.Run();
}
}
Info.plist 文件包含 iOS 特定的配置,如支持的设备方向、状态栏样式、隐私权限描述等。当你在 Info.plist 中添加 NSCameraUsageDescription 时,iOS 系统会在应用首次访问摄像头时向用户显示这段描述。
为什么 iOS 需要权限描述? 这是 Apple 的隐私保护政策。应用必须在 Info.plist 中预先声明它可能需要的敏感权限,并解释为什么需要这些权限。如果应用尝试访问摄像头而没有提供描述,系统会直接终止应用。这种设计虽然增加了开发者的工作量,但有效地防止了隐私滥用。
WebAssembly 平台的入口是 Program.cs,它的职责是初始化 .NET 运行时并启动应用。
public class Program
{
private static App? _app;
public static int Main(string[] args)
{
// 初始化 Uno 的 WebAssembly 主机
Microsoft.UI.Xaml.Application.Start(_ => _app = new App());
return 0;
}
}
wwwroot/index.html 是浏览器加载的入口页面。你可以在这里添加 CSS 样式、加载脚本,或配置 .NET 运行时的启动参数。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>MyUnoApp</title>
</head>
<body>
<div id="uno-body"></div>
<!-- Uno 会在这里注入应用内容 -->
<script type="module" src="./main.js"></script>
</body>
</html>
WebAssembly 的加载过程:当浏览器加载这个页面时,它会下载 .NET WebAssembly 运行时(一个约 2MB 的文件)、你的应用代码,以及所有依赖的库。然后,JavaScript 引擎会启动 .NET 运行时,运行时再加载你的应用代码。这个过程在首次访问时可能需要几秒钟,但后续访问会使用浏览器缓存。
虽然 Uno 实现了绝大多数 WinUI API,但有时你仍然需要编写平台特定的代码。条件编译是处理这种情况的主要工具。
C# 编译器在编译代码之前,会先处理预处理指令。#if、#elif、#else、#endif 这些指令告诉编译器:根据特定条件包含或排除代码块。
public void ShowNotification(string message)
{
#if NET6_0_ANDROID
// 这段代码只在编译 Android 版本时存在
var context = Android.App.Application.Context;
Android.Widget.Toast.MakeText(context, message, Android.Widget.ToastLength.Short)?.Show();
#elif NET6_0_IOS
// 这段代码只在编译 iOS 版本时存在
var alert = UIKit.UIAlertController.Create("", message, UIKit.UIAlertControllerStyle.Alert);
alert.AddAction(UIKit.UIAlertAction.Create("OK", UIKit.UIAlertActionStyle.Default, null));
UIKit.UIApplication.SharedApplication.KeyWindow.RootViewController.PresentViewController(alert, true, null);
#elif NET6_0_BROWSERWASM
// 这段代码只在编译 WebAssembly 版本时存在
Uno.Foundation.WebAssemblyRuntime.InvokeJS($"alert('{message}')");
#else
// 默认实现(通常是 Windows)
System.Diagnostics.Debug.WriteLine(message);
#endif
}
为什么使用NET6_0_ANDROID而不是__ANDROID__?__ANDROID__是传统的 Xamarin 定义,而NET6_0_ANDROID是 .NET 6+ 引入的新标准。新的命名更加一致:NET<版本>_<平台标识符>。使用新标准可以避免与旧代码的冲突,也更符合 .NET 生态的演进方向。
当条件编译块变得很长时,代码的可读性会急剧下降。这时,部分类(Partial Class)提供了更优雅的解决方案。
你可以在共享项目中定义接口和公共逻辑:
// Services/INotificationService.cs
public interface INotificationService
{
void ShowNotification(string message);
}
// Services/NotificationService.Shared.cs (公共部分)
public partial class NotificationService : INotificationService
{
// 公共逻辑和辅助方法
protected string FormatMessage(string raw) => $"[App] {raw}";
}
然后在各平台的 Platforms 文件夹中提供具体实现:
// Platforms/Android/Services/NotificationService.android.cs
public partial class NotificationService
{
public void ShowNotification(string message)
{
var context = Android.App.Application.Context;
Android.Widget.Toast.MakeText(context, FormatMessage(message), Android.Widget.ToastLength.Short)?.Show();
}
}
// Platforms/WebAssembly/Services/NotificationService.wasm.cs
public partial class NotificationService
{
public void ShowNotification(string message)
{
Uno.Foundation.WebAssemblyRuntime.InvokeJS($"alert('{FormatMessage(message)}')");
}
}
部分类的编译魔法:编译器会将同名部分类的所有部分合并成一个完整的类。关键是要确保编译时只有一个平台的实现被包含进来。Uno 的项目系统会自动处理这个问题——根据当前编译目标,只包含相应平台文件夹中的文件。
对于复杂的场景,依赖注入(Dependency Injection)提供了最灵活的解决方案。你可以在启动时根据平台注册不同的实现。
// 在 App.xaml.cs 中
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var services = new ServiceCollection();
// 注册平台特定服务
#if NET6_0_ANDROID
services.AddSingleton<INotificationService, AndroidNotificationService>();
#elif NET6_0_IOS
services.AddSingleton<INotificationService, IosNotificationService>();
#else
services.AddSingleton<INotificationService, DefaultNotificationService>();
#endif
var serviceProvider = services.BuildServiceProvider();
// ... 使用 serviceProvider 获取服务
}
为什么依赖注入更好? 依赖注入将对象的创建和使用分离。你的业务代码只需要依赖接口,而不需要知道具体实现。这不仅使代码更易于测试(可以用模拟实现替换真实服务),也使平台切换更加容易(只需要更改注册的 Implementation 类型)。
跨平台应用需要处理各种资源文件,其中最常见的是图片。不同平台对图片格式和分辨率有不同的要求,Uno 提供了统一的资源管理系统来简化这个过程。
在传统的 Windows 应用中,你可能会使用 logo.scale-100.png、logo.scale-150.png、logo.scale-200.png 这样的命名约定来支持不同 DPI 的屏幕。Uno 继承了这一传统,并将其扩展到所有平台。
当你将一张名为 logo.scale-200.png 的图片放入 Resources/Images/ 文件夹时,你可以在 XAML 中简单地引用它:
<Image Source="ms-appx:///Assets/logo.png" />
注意,引用时不需要包含 .scale-200 后缀。Uno 会在运行时根据当前屏幕的 DPI 自动选择最合适的图片。
为什么需要多张图片? 不同设备的屏幕像素密度差异巨大。一台 4K 显示器和一部普通手机可能具有相同的物理尺寸,但像素数量相差数倍。如果只提供一张低分辨率图片,在高 DPI 屏幕上会显得模糊;只提供高分辨率图片,在低 DPI 设备上则浪费内存和带宽。提供多个版本让系统能够根据实际情况做出最佳选择。
Uno 支持在应用中嵌入自定义字体。将字体文件(如 .ttf 或 .otf)放入 Resources/Fonts/ 文件夹,然后在 XAML 中引用:
<TextBlock Text="Hello Custom Font!"
FontFamily="ms-appx:///Assets/Fonts/MyCustomFont.ttf#MyCustomFont" />
字体引用的格式:# 符号后面的部分是字体家族名称,不是文件名。这个名称需要打开字体文件才能看到,通常与文件名相同但不总是如此。如果字体显示为默认字体,很可能是名称写错了。
在结束本章之前,让我们总结一些经过实战检验的项目组织最佳实践。
当项目变得庞大时,按类型(所有 ViewModels 放在一起,所有 Views 放在一起)组织代码会变得难以维护。更好的方式是按功能模块组织:
Features/
├── Authentication/ # 登录/注册功能模块
│ ├── Views/
│ │ ├── LoginPage.xaml
│ │ └── RegisterPage.xaml
│ ├── ViewModels/
│ │ ├── LoginViewModel.cs
│ │ └── RegisterViewModel.cs
│ └── Services/
│ └── IAuthService.cs
├── Profile/ # 用户资料功能模块
│ ├── Views/
│ │ └── ProfilePage.xaml
│ └── ViewModels/
│ └── ProfileViewModel.cs
└── Settings/ # 设置功能模块
└── ...
这种组织方式让你在修改某个功能时,所有相关的文件都在同一个文件夹中,大大提高了开发效率。
将业务逻辑(数据处理、网络请求、算法等)放在不依赖 UI 框架的类库中,可以显著提高代码的可测试性和可复用性。
MyUnoApp/
├── MyUnoApp.Core/ # 核心业务逻辑(不依赖 UI)
│ ├── Models/
│ ├── Services/
│ └── Interfaces/
├── MyUnoApp/ # UI 层(依赖 Core)
│ ├── Views/
│ ├── ViewModels/
│ └── Platforms/
└── MyUnoApp.Tests/ # 单元测试(只依赖 Core)
└── Services/
为什么要分离? 当业务逻辑与 UI 耦合时,你很难编写单元测试——测试需要创建窗口、加载 XAML,这既慢又容易失败。将核心逻辑放在独立的类库中,你可以用纯 C# 代码快速测试算法和数据处理的正确性。
在这一章,我们深入解剖了 Uno 项目的内部结构。我们理解了单项目架构如何简化多平台开发,探索了 App 类作为应用程序心脏的角色,学习了如何处理平台特定代码,以及如何有效地组织项目资源。
关键要点是:Uno 的项目结构设计旨在最大化代码复用,同时保留访问平台特定功能的能力。当你理解了这种设计的哲学,你就能够做出明智的架构决策,构建可维护、可扩展的跨平台应用。
在下一章,我们将深入学习 XAML——Uno 应用的视觉语言。XAML 是一种强大的声明式 UI 语言,掌握它将让你能够创建精美的、响应式的用户界面。
动手实验:
- 创建一个新的 Uno 项目,尝试在 Windows、WASM 和 Android 上运行它。观察 Platforms 文件夹中每个平台的入口点代码。
- 创建一个
INotificationService接口,并为不同平台实现不同的通知方式。使用条件编译或部分类来实现。- 尝试将一个大的 ViewModel 拆分成多个小文件,使用
partial class关键字。观察这样做是否提高了代码的可读性。
还没有人回复