您正在查看静态缓存页面 · 查看完整动态版本 · 登录 参与讨论

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

✨步子哥 (steper) 2026年02月17日 05:28 0 次浏览

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

本章导读:打开一个新建的 Uno 项目,你可能会被眼前琳琅满目的文件和文件夹所困惑——它们是什么?为什么需要这么多?哪些代码该放在哪里?本章将带你像解剖学家一样,系统地理解 Uno 项目的每一块"骨骼"和"肌肉"。当你理解了项目结构背后的设计哲学,你就能够自信地组织代码,构建可维护的大型应用。

🏛️ 3.1 从混乱到秩序:理解多平台代码组织

在深入具体文件之前,让我们先思考一个根本问题:如何组织一套需要同时运行在多个操作系统上的代码?这个问题没有标准答案,不同的框架采取了不同的策略,每种策略都有其取舍。

⚖️ 3.1.1 两种组织哲学

历史上,跨平台框架采取了两种主要的代码组织方式,我们可以用城市规划来类比理解它们。

多项目结构就像是建立多个独立的城市,每个城市(平台)有自己的市政厅(入口点)、自己的基础设施(平台配置),但共享同一个图书馆和学校(共享代码)。这种方式的优点是边界清晰,每个平台可以独立演进;缺点是需要在多个项目间同步配置,维护成本较高。

单项目结构则像是建立一座大都市,用不同的街区来管理不同的事务。市中心是共享的商业区(核心业务逻辑),边缘是各具特色的住宅区(平台特定代码)。这种方式的优点是配置集中,易于管理;缺点是项目文件可能变得复杂,单个文件中的条件编译可能让代码难以阅读。

第一性原理:无论采用哪种结构,核心目标都是最大化代码复用,同时保留调用平台特定功能的能力。理解了这一点,你就能根据项目的实际需求做出明智的选择。

📁 3.1.2 Uno 5.x 的单项目结构

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 文件夹只关心数据如何获取和处理。当你需要修改某个功能时,你清楚地知道应该去哪里找。

📄 3.1.3 项目文件的秘密

打开 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,等等。

❤️ 3.2 应用程序心脏:App.xaml 与 App.xaml.cs

每个 Uno 应用都有一个核心——App 类。它是应用程序的入口点,也是全局资源的容器。理解这个类的工作原理,对于构建复杂应用至关重要。

🎭 3.2.1 App.xaml:全局资源的宝库

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 级别仍未找到,就会报错。这种"就近原则"允许你在局部覆盖全局资源。

⚙️ 3.2.2 App.xaml.cs:生命周期的指挥家

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 应用开发的标准做法。

🔄 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。

[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)。

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 运行时并启动应用。

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 运行时,运行时再加载你的应用代码。这个过程在首次访问时可能需要几秒钟,但后续访问会使用浏览器缓存。

🔀 3.4 条件编译:优雅地处理平台差异

虽然 Uno 实现了绝大多数 WinUI API,但有时你仍然需要编写平台特定的代码。条件编译是处理这种情况的主要工具。

🎯 3.4.1 预处理指令的工作原理

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 生态的演进方向。

🧩 3.4.2 部分类:更优雅的平台特定代码

当条件编译块变得很长时,代码的可读性会急剧下降。这时,部分类(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 的项目系统会自动处理这个问题——根据当前编译目标,只包含相应平台文件夹中的文件。

💉 3.4.3 依赖注入:最灵活的方案

对于复杂的场景,依赖注入(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 类型)。

📦 3.5 资源管理:图片、字体和文件

跨平台应用需要处理各种资源文件,其中最常见的是图片。不同平台对图片格式和分辨率有不同的要求,Uno 提供了统一的资源管理系统来简化这个过程。

🖼️ 3.5.1 图片资源的自动适配

在传统的 Windows 应用中,你可能会使用 logo.scale-100.pnglogo.scale-150.pnglogo.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 设备上则浪费内存和带宽。提供多个版本让系统能够根据实际情况做出最佳选择。

🔤 3.5.2 自定义字体的使用

Uno 支持在应用中嵌入自定义字体。将字体文件(如 .ttf.otf)放入 Resources/Fonts/ 文件夹,然后在 XAML 中引用:

<TextBlock Text="Hello Custom Font!"
           FontFamily="ms-appx:///Assets/Fonts/MyCustomFont.ttf#MyCustomFont" />
字体引用的格式# 符号后面的部分是字体家族名称,不是文件名。这个名称需要打开字体文件才能看到,通常与文件名相同但不总是如此。如果字体显示为默认字体,很可能是名称写错了。

🏗️ 3.6 项目组织的最佳实践

在结束本章之前,让我们总结一些经过实战检验的项目组织最佳实践。

📐 3.6.1 按功能模块组织代码

当项目变得庞大时,按类型(所有 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/                 # 设置功能模块
    └── ...

这种组织方式让你在修改某个功能时,所有相关的文件都在同一个文件夹中,大大提高了开发效率。

🧪 3.6.2 分离核心逻辑与 UI

将业务逻辑(数据处理、网络请求、算法等)放在不依赖 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 语言,掌握它将让你能够创建精美的、响应式的用户界面。


动手实验
  1. 创建一个新的 Uno 项目,尝试在 Windows、WASM 和 Android 上运行它。观察 Platforms 文件夹中每个平台的入口点代码。
  2. 创建一个 INotificationService 接口,并为不同平台实现不同的通知方式。使用条件编译或部分类来实现。
  3. 尝试将一个大的 ViewModel 拆分成多个小文件,使用 partial class 关键字。观察这样做是否提高了代码的可读性。

讨论回复

0 条回复

还没有人回复