Loading...
正在加载...
请稍候

第三章:Uno 项目结构深度剖析

✨步子哥 (steper) 2026年02月17日 05:28
# 第三章:Uno 项目结构深度剖析 > **本章导读**:打开一个新建的 Uno 项目,你可能会被眼前琳琅满目的文件和文件夹所困惑——它们是什么?为什么需要这么多?哪些代码该放在哪里?本章将带你像解剖学家一样,系统地理解 Uno 项目的每一块"骨骼"和"肌肉"。当你理解了项目结构背后的设计哲学,你就能够自信地组织代码,构建可维护的大型应用。 --- ## 🏛️ 3.1 从混乱到秩序:理解多平台代码组织 在深入具体文件之前,让我们先思考一个根本问题:如何组织一套需要同时运行在多个操作系统上的代码?这个问题没有标准答案,不同的框架采取了不同的策略,每种策略都有其取舍。 ### ⚖️ 3.1.1 两种组织哲学 历史上,跨平台框架采取了两种主要的代码组织方式,我们可以用城市规划来类比理解它们。 **多项目结构**就像是建立多个独立的城市,每个城市(平台)有自己的市政厅(入口点)、自己的基础设施(平台配置),但共享同一个图书馆和学校(共享代码)。这种方式的优点是边界清晰,每个平台可以独立演进;缺点是需要在多个项目间同步配置,维护成本较高。 **单项目结构**则像是建立一座大都市,用不同的街区来管理不同的事务。市中心是共享的商业区(核心业务逻辑),边缘是各具特色的住宅区(平台特定代码)。这种方式的优点是配置集中,易于管理;缺点是项目文件可能变得复杂,单个文件中的条件编译可能让代码难以阅读。 > **第一性原理**:无论采用哪种结构,核心目标都是最大化代码复用,同时保留调用平台特定功能的能力。理解了这一点,你就能根据项目的实际需求做出明智的选择。 ### 📁 3.1.2 Uno 5.x 的单项目结构 Uno 5.x 引入了单项目结构作为默认选项,这是对 .NET MAUI 类似设计的响应。让我们详细剖析这种结构。 当你创建一个新的 Uno 项目时,会得到一个类似下面这样的目录结构: ```text 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 文件夹只关心数据如何获取和处理。当你需要修改某个功能时,你清楚地知道应该去哪里找。 ### 📄 3.1.3 项目文件的秘密 打开 `MyUnoApp.csproj`,你会看到它的核心配置。这个看似简单的 XML 文件,实际上承载了多平台编译的所有秘密。 ```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,等等。 ## ❤️ 3.2 应用程序心脏:App.xaml 与 App.xaml.cs 每个 Uno 应用都有一个核心——`App` 类。它是应用程序的入口点,也是全局资源的容器。理解这个类的工作原理,对于构建复杂应用至关重要。 ### 🎭 3.2.1 App.xaml:全局资源的宝库 `App.xaml` 文件定义了应用程序级别的资源。这些资源在整个应用中都可以访问,就像全局变量,但更加优雅和安全。 ```xml <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 级别仍未找到,就会报错。这种"就近原则"允许你在局部覆盖全局资源。 ### ⚙️ 3.2.2 App.xaml.cs:生命周期的指挥家 `App.xaml.cs` 是应用程序生命周期管理的中枢。它继承自 `Application` 类,处理应用启动、暂停、恢复和关闭等事件。 ```csharp 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 应用开发的标准做法。 ### 🔄 3.2.3 理解应用生命周期 不同平台有不同的应用生命周期模型,但 Uno 通过 `Application` 类提供了一个统一的抽象。 **启动(Launched)** 在所有平台上都会触发,这是你初始化应用的最佳时机。在 Windows 上,这可能来自用户点击开始菜单;在 Android 上,可能来自用户点击应用图标或另一个应用的调用;在 WebAssembly 上,则是用户访问网页。 **暂停(Suspending)** 在移动平台上尤为重要。当用户切换到另一个应用时,你的应用会被暂停以节省资源。你应该在这个事件中保存未完成的用户数据,因为系统可能会在后台终止暂停的应用。 > **移动端与桌面端的生命周期差异**:在 Windows 桌面上,用户最小化窗口后,应用仍然在运行。但在 Android 和 iOS 上,系统会积极管理内存,可能随时终止后台应用。这种差异意味着你需要在移动端更加谨慎地处理状态保存。 ## 🌍 3.3 平台特定代码:Platforms 文件夹的魔法 虽然 Uno 追求最大化的代码共享,但每个平台都有其无法回避的"入场券"——系统要求你提供特定的入口点和配置。这就是 Platforms 文件夹存在的意义。 ### 🤖 3.3.1 Android 平台的入场券 在 `Platforms/Android/` 文件夹中,最重要的文件是 `Main.Android.cs`。这个文件定义了 Android 应用的入口点——一个继承自 `Uno.UIActivity` 的 Activity。 ```csharp [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 的导航语义。 ### 🍎 3.3.2 iOS 平台的入场券 iOS 平台的入口是 `Main.iOS.cs`,它定义了应用程序的委托(AppDelegate)。 ```csharp 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 中预先声明它可能需要的敏感权限,并解释为什么需要这些权限。如果应用尝试访问摄像头而没有提供描述,系统会直接终止应用。这种设计虽然增加了开发者的工作量,但有效地防止了隐私滥用。 ### 🌐 3.3.3 WebAssembly 平台的入场券 WebAssembly 平台的入口是 `Program.cs`,它的职责是初始化 .NET 运行时并启动应用。 ```csharp 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 运行时的启动参数。 ```html <!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 运行时,运行时再加载你的应用代码。这个过程在首次访问时可能需要几秒钟,但后续访问会使用浏览器缓存。 ## 🔀 3.4 条件编译:优雅地处理平台差异 虽然 Uno 实现了绝大多数 WinUI API,但有时你仍然需要编写平台特定的代码。条件编译是处理这种情况的主要工具。 ### 🎯 3.4.1 预处理指令的工作原理 C# 编译器在编译代码之前,会先处理预处理指令。`#if`、`#elif`、`#else`、`#endif` 这些指令告诉编译器:根据特定条件包含或排除代码块。 ```csharp 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 生态的演进方向。 ### 🧩 3.4.2 部分类:更优雅的平台特定代码 当条件编译块变得很长时,代码的可读性会急剧下降。这时,部分类(Partial Class)提供了更优雅的解决方案。 你可以在共享项目中定义接口和公共逻辑: ```csharp // 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 文件夹中提供具体实现: ```csharp // 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 的项目系统会自动处理这个问题——根据当前编译目标,只包含相应平台文件夹中的文件。 ### 💉 3.4.3 依赖注入:最灵活的方案 对于复杂的场景,依赖注入(Dependency Injection)提供了最灵活的解决方案。你可以在启动时根据平台注册不同的实现。 ```csharp // 在 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 类型)。 ## 📦 3.5 资源管理:图片、字体和文件 跨平台应用需要处理各种资源文件,其中最常见的是图片。不同平台对图片格式和分辨率有不同的要求,Uno 提供了统一的资源管理系统来简化这个过程。 ### 🖼️ 3.5.1 图片资源的自动适配 在传统的 Windows 应用中,你可能会使用 `logo.scale-100.png`、`logo.scale-150.png`、`logo.scale-200.png` 这样的命名约定来支持不同 DPI 的屏幕。Uno 继承了这一传统,并将其扩展到所有平台。 当你将一张名为 `logo.scale-200.png` 的图片放入 `Resources/Images/` 文件夹时,你可以在 XAML 中简单地引用它: ```xml <Image Source="ms-appx:///Assets/logo.png" /> ``` 注意,引用时不需要包含 `.scale-200` 后缀。Uno 会在运行时根据当前屏幕的 DPI 自动选择最合适的图片。 > **为什么需要多张图片?** 不同设备的屏幕像素密度差异巨大。一台 4K 显示器和一部普通手机可能具有相同的物理尺寸,但像素数量相差数倍。如果只提供一张低分辨率图片,在高 DPI 屏幕上会显得模糊;只提供高分辨率图片,在低 DPI 设备上则浪费内存和带宽。提供多个版本让系统能够根据实际情况做出最佳选择。 ### 🔤 3.5.2 自定义字体的使用 Uno 支持在应用中嵌入自定义字体。将字体文件(如 `.ttf` 或 `.otf`)放入 `Resources/Fonts/` 文件夹,然后在 XAML 中引用: ```xml <TextBlock Text="Hello Custom Font!" FontFamily="ms-appx:///Assets/Fonts/MyCustomFont.ttf#MyCustomFont" /> ``` > **字体引用的格式**:`#` 符号后面的部分是字体家族名称,不是文件名。这个名称需要打开字体文件才能看到,通常与文件名相同但不总是如此。如果字体显示为默认字体,很可能是名称写错了。 ## 🏗️ 3.6 项目组织的最佳实践 在结束本章之前,让我们总结一些经过实战检验的项目组织最佳实践。 ### 📐 3.6.1 按功能模块组织代码 当项目变得庞大时,按类型(所有 ViewModels 放在一起,所有 Views 放在一起)组织代码会变得难以维护。更好的方式是按功能模块组织: ```text Features/ ├── Authentication/ # 登录/注册功能模块 │ ├── Views/ │ │ ├── LoginPage.xaml │ │ └── RegisterPage.xaml │ ├── ViewModels/ │ │ ├── LoginViewModel.cs │ │ └── RegisterViewModel.cs │ └── Services/ │ └── IAuthService.cs ├── Profile/ # 用户资料功能模块 │ ├── Views/ │ │ └── ProfilePage.xaml │ └── ViewModels/ │ └── ProfileViewModel.cs └── Settings/ # 设置功能模块 └── ... ``` 这种组织方式让你在修改某个功能时,所有相关的文件都在同一个文件夹中,大大提高了开发效率。 ### 🧪 3.6.2 分离核心逻辑与 UI 将业务逻辑(数据处理、网络请求、算法等)放在不依赖 UI 框架的类库中,可以显著提高代码的可测试性和可复用性。 ```text 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 语言,掌握它将让你能够创建精美的、响应式的用户界面。 --- > **动手实验**: > 1. 创建一个新的 Uno 项目,尝试在 Windows、WASM 和 Android 上运行它。观察 Platforms 文件夹中每个平台的入口点代码。 > 2. 创建一个 `INotificationService` 接口,并为不同平台实现不同的通知方式。使用条件编译或部分类来实现。 > 3. 尝试将一个大的 ViewModel 拆分成多个小文件,使用 `partial class` 关键字。观察这样做是否提高了代码的可读性。

讨论回复

0 条回复

还没有人回复,快来发表你的看法吧!