Open WangShuXian6 opened 2 weeks ago
欢迎来到完整C#大师班。在本课程中,您将学习关于C#的所有知识,因为您是本课程存在的原因。我创建这个课程是为了为您提供尽可能最好的C#编程学习体验。令人兴奋的是,您将在这个课程中获得的知识不仅适用于C#,还可以应用于其他编程语言。如果您已经了解编程,那么这对您来说将是小菜一碟,对吧?但如果您不熟悉编程,没关系,您依然会很好地掌握这些内容。
您将经历不同的示例,观看大量演示,这些将以简单易懂的方式将知识传递给您。此外,您还将进行一系列练习,我强烈建议您完成这些练习,因为这是您成长的关键所在。实际上,任何编程学习体验中最重要的部分就是应用。在这个过程中,您将亲自做一些事情,仅仅观看视频是不够的。虽然这是一个很好的开始,能够帮助您理解课程内容,但真正的学习体验来自于实际的应用。
我准备了许多练习和项目供您跟随,同时也鼓励您独立尝试。过程中可能会遇到一些困难,但没有关系,您可以在互联网上搜索解决方案。如果找不到答案,您当然可以随时在问答部分给我们留言,我们会帮助您解决问题。
感谢您参加本课程,祝您拥有最佳的学习体验,期待在下一个视频中见到您!
好吧,您已经购买了课程,非常感谢您参与这个课程。现在,您必须问自己最重要的问题是:您想要实现什么?您的总体目标是什么?您想创建视频游戏吗?您想编写PC程序?找到一份工作,开始作为C#程序员的自由职业者,还是仅仅学习一项新技能?无论如何,这门课程都是适合您的,您只需跟随课程进行学习。相信这个系统,按照步骤进行,您就能成为一名真正的开发者,实现所有这些目标。
当然,根据您的目标,您对课程和学习体验的心态会有所不同。我强烈建议您每周至少花费3到4小时专注于学习课程。虽然完成整个课程需要时间,但您会在过程中获得许多宝贵的学习体验,这将激励您投入更多的时间。因此,我建议您进行练习、参加课程,并尝试将自己的想法融入到您所学到的知识中。不要仅仅把视频当作Netflix系列剧一样观看,那不是我们的目标。您的目标应该是真正理解所学内容。一旦对某个主题有了清晰的理解,您再去学习下一个主题,因此一定要尽量进行练习,这对您将大有帮助。
希望您能为自己找到答案,也许您会在脑海中形成一个成功开发者的形象,可能是一个游戏开发者,或是作为开发者找到一份工作的样子。您可以把这个形象放在屏幕下方或上方,这种视觉提醒可以在困难时刻帮助您,推动您向前。现在,带着这种能量,让我们进入下一个视频!
欢迎回来。在本视频中,我们将安装Visual Studio,这是我们编写程序所需的软件。首先,让我们搜索下载Visual Studio。您也可以直接在搜索框中输入其名称。具体来说,2022年是新版本发布的一年,之前的版本是Visual Studio 2019,因此并不是每年都会有新版本。
搜索后,您将找到 Visual Studio Microsoft.com 的链接。让我们点击进入,该页面可能会因您打开的时间而有所不同,但基本思路是下载Visual Studio 2022的社区版,这是免费的,您无需支付任何费用。您当然可以了解Visual Studio能为您提供什么,它帮助您提高生产力,具有现代的理念和创新的功能,特别是强大的代码补全功能,这非常酷。您将看到它是如何帮助您编写代码的,很多时候您甚至不需要输入太多即可得到结果。
现在,让我们下载Visual Studio安装程序的exe文件,下载完成后运行它。这将不会直接安装Visual Studio本身,而是安装Visual Studio安装程序,这是一个帮助您安装Visual Studio的软件。Visual Studio是“集成开发环境”(Integrated Development Environment,IDE)的缩写。
一旦Visual Studio安装程序安装完成,您将自动看到一个界面,如果没有弹出,您会看到一个“已安装”的屏幕,显示没有安装任何内容,您可以从可用选项中进行选择。我要使用的是Visual Studio社区版,点击“安装”。
现在,您将回到默认的屏幕,这里有多个选项卡,我们将逐步了解它们。首先,我们来看看工作负载选项。在这里,您会看到网络和云相关的选项,比如ASP.NET和Web开发、Node.js、Python、Azure等。如果您需要其中的某个,可以进行安装,但这些都是高级主题,在开始时您并不需要。
对于我们来说,桌面和移动应用程序将是相关的,因为我们将专门开发WPF应用程序,因此我建议您直接安装这个工作负载。您当然也可以直接安装Unity,但我们会在课程的后期进行,所以此时无需担心。
现在,您可以切换到“单独组件”选项卡,您会找到可以选择的所有组件。例如,您甚至可以安装旧版的.NET Core运行时,如2.1或3.1,这些都已停止支持或为长期支持版(LTS)。许多解决方案是基于.NET Core 3.1构建的,但最新版本是.NET 6.0。随着时间的推移,命名约定已经改变,从.NET 5开始,微软将所有内容整合在一起,包括.NET Core和其他框架。
我希望您选择.NET 5运行时,因为在某些视频中我们将使用它。您可以从这里选择任何组件,但我建议您不要仅仅保留默认设置,除了我刚才提到的添加.NET 5运行时。 如果您想安装其他语言包,也可以随意选择。在我的情况下,默认选择了英语,但您可以使用提供的任何语言包进行安装。
您还可以更改安装位置以及下载缓存,并查看共享组件将放置的位置。这是我们创建新应用程序时默认的解决方案位置。好了,现在让我们点击“安装”,这将安装Visual Studio Community 2022。
您可以在Visual Studio安装程序中拥有多个Visual Studio实例。如果您想使用其他版本,可以在可用选项中安装企业版或专业版,但它们是收费版本。安装将需要一些时间,您会看到默认勾选“安装完成后启动”。
一旦Visual Studio安装完成,您会被要求登录。这将允许您跨设备同步设置,实时协作以及与Azure服务无缝集成。您可以创建一个新账户或登录已有的Microsoft账户,也可以选择稍后登录。接下来,您可以选择使用的主题。我主要使用深色主题,因为我觉得它比其他主题更好,但如果您喜欢默认的蓝色主题或亮色主题,也可以选择这些。在长时间使用时,我觉得深色主题更护眼。
现在启动Visual Studio。您可以创建一个新项目,或者选择“继续没有代码”来快速打开Visual Studio。当前没有打开的项目,我想快速展示一下,如果您决定更改颜色主题,比如觉得深色主题不合适,可以进入“工具” -> “选项”,在“环境”下的“常规”中找到颜色主题。您可以将其更改为蓝色,或者设置为亮色主题。
如我所说,我更喜欢深色主题,所以我将其设置回去。在下一个视频中,我们将学习如何设置我们的第一个项目。期待在下个视频中见到您!
欢迎回来。在本视频中,我们将使用Visual Studio设置我们的第一个项目。您将看到Visual Studio如何自动为我们生成代码并构建整个项目结构,而我们无需做太多工作。
打开Visual Studio后,您将看到一个窗口,您可以在此创建新项目、打开本地文件夹、项目解决方案或甚至克隆代码库。您也可以选择“继续没有代码”。打开Visual Studio后,这个窗口会弹出,同时Visual Studio会在后台打开。现在,我们可以登录到我们的Microsoft账户,或者如果还没有账户,可以创建一个。您也可以选择关闭它,这样Visual Studio仍然会打开,您可以继续使用而无需注册。但注册后有一些优势,比如您的设置可以被保存。
打开Visual Studio后,您会看到一个名为“最新消息”的选项卡,在这里可以找到关于您安装或更新的Visual Studio版本的新功能详细信息。好的,让我关闭这个窗口,直接从这里创建一个新项目。
点击“项目”,您会看到一个窗口,允许您选择不同类型的项目模板。例如,有控制台应用程序、类库等,很多可供选择。这些项目模板取决于您在Visual Studio安装程序中选择的包。我们感兴趣的是控制台应用程序,而这些项目模板之间的区别主要在于Visual Studio自动为我们生成的文件和文件中的代码。我们将选择控制台应用程序,这是一个创建命令行应用程序的项目,可以在.NET、Windows、Linux和macOS上运行。请确保选择C#版本而不是Visual Basic版本。
接下来,我们可以点击“下一步”,为控制台应用程序命名,我将命名为“Hello World”。在我的情况下,它显示“该目录不是空的”,因为我在名为“Repos”的位置中已经有一个“Hello World”项目。因此,解决方案名称是根据我的项目名称自动生成的,然后我可以继续点击“下一步”。
在这里,我需要选择要使用的框架,即目标框架。在此上下文中,框架基本上是一组大量代码和文件,具体说明我们编写的代码将如何被我们的机器执行。您可以选择.NET 6(长期支持版本)或.NET 7(标准支持版本)。根据您在Visual Studio安装程序中的设置,您可能会看到更多选项。对于我们初学者而言,选择长期支持的.NET 6版本就可以了,因为这些是底层的变化,只有在课程结束时才会对我们有影响。
让我们创建这个项目。一旦项目创建完成,您将看到程序“Hello World”已经为我们创建,默认情况下会显示这两行代码。可以使用控制键和滚轮放大视图,您也可以在Visual Studio应用程序左下角更改缩放级别,以便更好地查看代码。
在这里,我们有新的控制台模板。您可以按住Ctrl并单击查看详细信息。基本上,这里有一个注释(绿色文本)和一个链接(蓝色文本),我们通过在行首添加两个斜杠来表示注释。第二行是我们的Console.WriteLine,输出“Hello world”文本。这行代码将在控制台上打印出“Hello, world”。如果运行此应用程序,只需点击上方的按钮,它将开始运行,并弹出一个小窗口显示“Hello world”。您可以按任意键关闭此应用程序。
这是我们构建的第一个应用程序,我们甚至没有自己写一行代码。这是自动生成的代码。接下来,我有一个小挑战,希望您将程序修改为输出“Hello”,然后是您的名字。在我的例子中是Dennis。将“world”替换为您的名字,然后运行应用程序。
希望您暂停视频并快速尝试一下。因此,我将输出“Hello Dennis”,因为这是我的名字,然后再次运行应用程序。结果是“Hello Dennis”。这就是我们构建的第一个小应用程序,通常在学习新编程语言时会编写“Hello World”应用程序,以确保程序能够正确执行。
在下一个视频中,我们将查看项目的结构。期待与您再次见面!
欢迎回来。在本视频中,我们将查看Visual Studio构建的项目结构。右侧有一个名为“解决方案资源管理器”的小选项卡,您可以点击它,在那里找到您的项目。我的项目叫做“Hello World one”。在这个项目内部,有一个C#文件夹,里面有许多依赖项,还有一个名为“program.cs”的文件,这就是我们的C#文件。
您可以通过右键单击此文件夹在文件资源管理器中打开它。您会发现,在“Repos”文件夹中有一个名为“Hello World one”的文件夹。此文件夹中存储了我创建的所有不同项目,而“Hello World one”正是其中之一。您可以看到有一个SLN文件,这是一个Visual Studio解决方案文件。双击该文件将打开项目,但由于我们已经打开了项目,所以不需要这样做。
进入该文件夹后,您会发现有一个“bin”文件夹,里面有一个“debug”文件夹,包含我们选择的.NET 6框架。您可以看到我们的“Hello World”可执行文件。这就是我们创建的小程序。如果您点击“Hello World one”,将打开该程序。遗憾的是,程序在关闭时没有什么限制,只有当我们点击上方的“Hello World”按钮时,Visual Studio才会保持程序处于活动状态。
为了让项目保持打开状态,我们可以使用Console.ReadLine()
。这将使项目保持活跃。现在让我们保存项目并运行一次。这将使程序读取我们的文本。一旦运行项目,您会看到那里没有其他内容。我们可以在这里输入一些文本,尽管我们并没有对此进行处理,但程序在按下两次键后仍然会关闭。
完成后,您现在可以打开您的“Hello World”程序,您会看到项目保持活动状态,输出“Hello Dennis”,并且由于等待输入而保持活跃。之前没有做到这一点,所以它才会自动关闭。
在文件夹中,您会看到一些JSON文件和其他文件,这些都是自动生成的,位于“bin/debug”文件夹中。然后是“obj”文件夹,其中也包含一些JSON文件、debug文件夹、targets、props缓存以及各种为我们创建的文件。还有我们的C#项目文件,它同样是我们的项目“Hello World one”。如果我们点击该文件,它也会在Visual Studio中打开。
我们再次看到“program.cs”文件,它与之前的文件是相同的。您可以使用不同的编辑器打开它,比如默认编辑器,您会发现代码也在其中。当然,您也可以不使用Visual Studio,直接编写程序,然后使用命令行进行编译,但这将非常繁琐,这就是使用Visual Studio这种集成开发环境(IDE)的巨大优势。此外,它还提供了美观的语法高亮,使得软件开发更为轻松。
这就是我们项目的结构。当我们在Visual Studio中构建控制台应用程序时,它会自动生成这些文件。在下一个视频中,我将快速向您展示为控制台应用程序生成的不同模板,因为在本课程中我们将构建许多控制台应用程序,这将非常方便我们展示C#的特性和基础知识。期待在下个视频中见到您!
欢迎回来。在本视频中,您将了解旧模板。这是.NET 6之前的默认模板,在.NET 7中仍然保持不变。我想向您展示这个模板,因为它可以帮助您更好地理解后台发生的事情,同时确保您了解我们在本课程中将构建的其他项目的结构,这样当您突然看到一些我们之前没有讨论过的代码时,就不会感到困惑。
您看到,当我们创建这个项目时,自动生成了如下代码,同时给我们提供了一个链接,指向“aka.ms/new-console-template”。这个页面包含了很多信息,虽然我认为一开始深入探讨所有内容有些复杂,但我想向您展示的是,当我们在Visual Studio中创建新项目时,如果勾选一个额外的选项,我们将获得不同的代码,但其功能完全相同。
让我们一起创建一个新项目。点击“文件”>“新建项目”,也可以使用显示的快捷键。在我的例子中是Ctrl + Shift + N
,然后我将创建一个新的控制台应用程序,命名为“old style app”。我将保持其他选项不变,点击“下一步”。然后我勾选“不要使用顶级语句”,虽然使用.NET 7也是可以的,但我在这里使用的是.NET 6,勾选这个框后,它将不会使用顶级语句,这也是导致我们获得此代码的原因。
创建新项目后,我自动得到了如下代码。我们可以看到,唯一相同的是Console.WriteLine("Hello World");
,它的作用是将内容打印到控制台。除此之外,我们还有一个名为static void Main
的方法,返回类型是void
,意味着它不返回任何值,同时是static
的,表示我们可以在不创建Program
对象的情况下调用此方法。
所有这些信息对于初学者来说可能有些复杂,所以我只是大致向您解释一下。您不需要记住所有这些内容,因为我们会逐步建立这些知识。我们在这个文件中使用了许多概念,这些概念将在本课程的前七章中进行讲解。
Main
方法是我们程序的入口点,它会执行大括号内的内容。我们可以向Main
方法传递额外的字符串数组参数,意味着我们可以提供更多文本,基于这些文本,代码将以不同的方式执行。这部分内容我们稍后会讨论,但现在暂时不需要关注。
static void Main
方法位于internal class Program
内部。internal
关键字意味着这个类只能在同一个程序集(项目)内部使用,外部的程序无法访问这个程序文件。
所有这些代码都位于名为OldStyleApp
的命名空间内,这是我们给项目起的名称。每当某个东西位于另一个东西内部时,它就是这个东西的一部分。例如,这个internal class
是这个命名空间的一部分,static method
是这个类的一部分,代码则是Main
方法的一部分。
您可以折叠代码,右侧有一个小减号,可以折叠各级代码。这在您有很多代码时非常有用,可以让您隐藏不相关的部分。请注意,每个打开的括号必须有相应的闭合括号。如果多了一个括号,代码将无法执行,IDE Visual Studio会报错。
我们在语句后使用分号。缺少分号时,会报错提示“期望分号”。例如,Console.ReadLine();
将读取我们输入的文本并将其存储到一个变量中,我们稍后会讨论变量的使用。Console
类允许我们使用多种方法,代表控制台应用程序的标准输入、输出和错误流,其中包含多个可以使用的方法,例如WriteLine
、Write
、ReadLine
甚至Beep
方法。
让我们快速演示一下。我将添加一个Beep
方法并以分号结束。现在让我执行这个代码,看看效果。您会看到“Hello World”,然后我可以输入内容并按回车,听到了一声“哔”的声音。
接下来,我想挑战您在“Beep”之后写一段代码并再运行一次“Beep”。希望您尝试过了!例如,我将写Console.WriteLine("I beeped");
并在后面再加一个Console.Beep();
,然后再运行代码。
您会看到“Hello World”,然后输入内容,听到第一次“哔”声后,会输出“I beeped”和第二次“哔”声。这只是控制台可以做的一小部分,后续我们会逐步构建更多功能。
好的,以上就是本视频的内容。期待在下个视频中见到您!
欢迎回来。在本视频中,我将向您展示如何在Mac上使用Visual Studio启动一个C#项目。请按照以下步骤操作:
Hello_World
。Hello_World
文件夹中找到Program.cs
文件。在Windows上,您需要手动添加Console.ReadLine()
以保持控制台打开。而在Mac上,您也需要添加相应的代码行。为了让控制台保持开启,可以使用以下代码:
Console.ReadKey();
这行代码会使程序等待用户按下任意键,因此控制台不会立即关闭。
运行项目后,您将看到控制台等待输入。当您按下任意键后,控制台将输出“Press any key to continue”。如果在Windows上运行,该提示不会出现,程序将会直接关闭。
除了这些细节外,其他功能应该是相同的,当然,用户界面会有所不同。
期待在下个视频中与您见面!
欢迎回来。在本视频中,我想和您聊聊 Visual Studio 的用户界面。在上一个视频中,我直接开始了编码,但这次我们将稍微深入了解界面。
Visual Studio 是一个逐步发展的程序,功能强大,但并非所有功能都需要您了解。因此,如果您不理解界面上的每个按钮,也不必担心。以下是我们需要关注的几个重要部分:
在解决方案资源管理器中,您可以打开整个项目,项目名称为 Hello World
,而命名空间也叫 Hello World
。在命名空间中,您会看到 Program.cs
文件。在这里,您将看到 Program
类和 Main
方法。
在底部的输出窗口中,您可以查看文件创建的位置,以及运行程序时可能出现的错误信息。这对调试非常有帮助。
您可以根据需要自定义界面。例如,可以将解决方案资源管理器拖到窗口的右侧,使其更方便访问。您可以通过“视图”菜单找到更多窗口,未来的课程中我们会用到其中的一些。
如果您改变了窗口布局并希望保存,可以选择“窗口布局”>“保存窗口布局”,并为其命名。这样您可以随时切换到该布局。
在顶部菜单中,还有很多功能,例如:
在启动页面上,您还可以找到与 C# 及 Visual Studio 相关的开发者新闻,这对保持最新信息非常有用。
界面上还有一个按钮栏,其中“保存”和“启动”按钮是我们最常用的。通常,我更喜欢使用快捷键来操作。
这只是对 Visual Studio 用户界面的简要介绍。了解这些基本功能将帮助您更好地使用它。随着课程的深入,您将逐步熟悉这些功能。
期待在下个视频中与您见面!
恭喜您完成了第一章!在这一章中,您已经明确了自己的学习动机,并成功设置了开发环境,这是您在整个课程和软件开发生涯中不可或缺的工具。此外,您还创建了您的第一个软件——“Hello World”示例。虽然这是一个非常基础的示例,但它是您学习的第一步,而第一步往往是最艰难的。
现在,没有借口了!只需坚持学习课程,您将逐步掌握编程技能。希望您能积极参与课程讨论,并随时访问我们的博客。在博客中,我们将发布关于 C# 及其他编程主题的精彩文章。这些内容不仅可以帮助您提升技能,有时也会激励您保持学习动力。
接下来,我们将进入第一章的实际编程部分,即第二章。在这一章中,您将学习如何使用变量以及数据类型的相关知识。期待在下一章与您相见!
欢迎回来!在本视频中,我将教您关于变量的知识,因为变量是任何编程语言中非常重要的一个概念。变量是一个容器,可以存储一个值。随着时间的推移,我们可能会决定在同一个变量中存储另一个值,意味着在同一个容器中更改内容。
想象一下,您正在参加自助餐,想要拿一杯咖啡和一块美味的蛋糕。您需要一个杯子来装咖啡和一个盘子来放蛋糕,因为咖啡杯是用来盛液体的,而不是用来放蛋糕的。把蛋糕放进咖啡杯里是行不通的,既不合适,也可能会引起他人的侧目。变量的工作原理就像这个例子一样。
如果我们将变量比作咖啡的例子,那么每个变量必须有一个类型,就像杯子或盘子的类型。变量的类型告诉我们它可以存储什么类型的数据,而数据则相当于我们的食物或饮料。由于我们通常在程序中处理许多变量,因此需要通过给每个变量命名来区分它们。
在 C# 中,我们如何定义一个变量呢?假设我们想要存储一个整数,这里的整数是像 1、2、3 这样的完整数字。那么 int
是我们的变量类型,I am a number
是我们的整数变量的名称,而 5
是我们赋予它的值。因此,我们需要输入变量的名称和我们分配的值。这样,我们就创建了一个类型为 int
的变量,并命名为 I am a number
,并将值 5
存储在其中。
在 C# 中,还有其他数据类型,以下是一些我们目前需要了解的主要数据类型:
GPS enabled = false
。在这种情况下,我们可能想要向用户显示一条消息,提示他们启用 GPS 以使用应用程序。我们声明的变量越多,应用程序所需的内存就会越大。可以将其视作使用一个非常大的咖啡杯来装一小杯浓缩咖啡。
以上是关于 C# 中变量和数据类型的简单介绍。接下来,我们将通过实际示例来使用这些概念,以便更好地理解。
在接下来的视频中,我们将深入探讨更多数据类型,并学习它们的限制。尽管整数可以存储数字,但它不能存储像一万亿这样的数字,这就是 long
数据类型派上用场的地方。让我们在下一个视频中一起探讨吧!
欢迎回来!在本视频中,我们将探讨数据类型和变量,具体来说就是将数据存储在变量中的过程。让我们直接开始。
首先,变量可以在方法外部声明,也可以在方法内部声明。在这个例子中,我们有一个整数变量,它的类型是 int
,我们给它起了个名字叫 age
,并赋值为 15。我们定义了一个类型为 int
的变量 age
,并将值 15 赋给它。因此,这个变量存储了一个整数,如果我们想要使用它,只需引用变量的名称,它就会输出 15。
int age = 15;
Console.WriteLine(age); // 输出 15
接下来,我们可以看到变量的值被更改为 20。最开始是 15,但我们后来赋了一个新值 20。如果我们再次使用 Console.WriteLine
来打印变量的值,将会得到 20,而不是之前的 15。
如果我们声明一个没有赋值的变量,age
的默认值将是 0。在 C# 中,这与其他编程语言不同,因为在某些语言中,默认值可能是 null
,而在 C# 中,整数的默认值为 0。如果使用 Console.WriteLine(age)
,输出将是 0。
如果我们在方法内部声明一个变量,比如 h
,这个变量将仅在该特定方法内可用。如果我们在另一个方法中尝试使用它,将会出现错误。因此,变量的声明位置非常重要。如果变量在方法外部声明(例如,在类内),它将可在多个方法中使用,而在方法内部声明的变量只能在该方法内使用。
我们已经了解了变量的基本概念,接下来让我们快速介绍几种不同的数据类型:
byte:表示带符号字节(Signed Byte),它的取值范围是 -128 到 127,总共 256 个不同的值。这种数据类型适合存储小数据量,并且占用的存储空间小,适合需要高效性能的软件。
short:另一种原始数据类型,可以存储的整数范围是 -32,767 到 32,767。
int:可以存储范围从 -2,147,483,648 到 2,147,483,647 的整数。
long:用于存储非常大的整数,范围是 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807。
在使用这些数据类型时,请记住使用最小的数据类型以适应您的值。换句话说,存储的小数字用 byte
,存储中等数字用 short
,存储较大的数字用 int
,而存储超大数字时则使用 long
。
如果您需要使用浮点值,可以选择以下几种数据类型:
float:可以存储浮点数,例如 99.99。使用 F
来指示 C# 这是一个浮点值。没有 F
,C# 将把它视为 double
并可能会导致错误。
double:与 float
类似,但具有更高的精度(15 位)。在声明 double
时,无需后缀 D
。
decimal:用于存储更高精度的浮点值(28 位精度),通常用于金融计算以确保准确性。
bool:布尔类型,只有两个值:true 或 false。
char:字符类型,只能存储一个字符,使用单引号来定义,例如 'A'
。
string:用于存储多个字符,使用双引号来定义,例如 "Hello"
。
现在,让我们开始编码,深入实践吧!如果您喜欢这门课程,请别忘了评分!
在了解了变量和数据类型的理论之后,接下来我们将实际应用这些知识。我们将从整数、双精度浮点数(doubles)和浮点数(floats)开始。
首先,声明变量的方法是使用数据类型,接着给变量命名,并以分号结尾。比如,下面是一个声明变量的示例:
int num1; // 声明一个整数变量 num1
尽管没有赋值,我们仍然可以先声明它。之后,可以在下一行中给变量赋值:
num1 = 13; // 将值 13 赋给 num1
要在控制台输出变量,可以使用 Console.WriteLine
方法。在括号中输入变量名称:
Console.WriteLine(num1);
运行后,控制台会显示 13
。
接下来,让我们展示两个变量的和。首先声明并初始化另一个变量 num2
:
int num2 = 23; // 声明并初始化 num2
现在我们可以创建一个新的变量 sum
,用来存储 num1
和 num2
的和:
int sum = num1 + num2; // 计算 num1 和 num2 的和
我们可以输出计算结果:
Console.WriteLine("num1 + num2 = " + sum);
运行程序后,结果会是 num1 + num2 = 36
。
为了使输出更具可读性,我们可以使用字符串连接(concatenation)。例如:
Console.WriteLine("num1 is " + num1 + ", num2 is " + num2 + ", sum is " + sum);
运行后输出为 num1 is 13, num2 is 23, sum is 36
。
我们还可以一次性声明多个变量,例如:
int num1, num2, num3; // 声明多个整数变量
用逗号分隔变量名,并以分号结束。这种方式通常在方法或类的开始处使用,以便于管理变量。
变量的值可以在程序运行时被重赋值,例如:
num2 = 100; // 重赋值 num2
注意,重新计算和输出时需要确保代码顺序正确,以避免错误的结果。
使用 double
数据类型声明变量如下:
double d1 = 3.14; // 声明并初始化双精度浮点数
double d2 = 5.1;
double div = d1 / d2; // 进行除法运算
Console.WriteLine("d1 / d2 = " + div);
声明浮点数时,必须在数字后加 F
以确保它被视为浮点数:
float f1 = 3.14F; // 声明浮点数 f1
float f2 = 5.1F;
float fDiv = f1 / f2; // 除法运算
Console.WriteLine("f1 / f2 = " + fDiv);
在编写高效软件时,选择合适的数据类型非常重要。若只需整数,使用 int
;若需要长数字,使用 long
。
在进行不同数据类型之间的运算时,可能会遇到类型转换问题。对于 double
与 int
的运算,结果会是 double
类型。但若尝试将 double
赋值给 int
,则会报错。
当数据来自外部源(如数据库)时,你不能总是确定接收到的数据格式,因此在处理这些数据时需格外小心。
现在,你应该对如何使用变量和数据类型有了更好的理解,特别是 int
、float
和 double
的使用。期待在下一个视频中见到你!
欢迎回来。在本视频中,我们将讨论字符串(string),它用于表示文本。我在之前的视频中展示了如何使用字符串,字符串的首字母大写以表示系统字符串类。我们定义一个类型为字符串的变量,我将其命名为 myName
,并将其值设为 "Dennis"。你可以使用首字母大写的 String
,即类名,或者使用小写的 string
,根据编码标准,建议使用小写形式。
现在,让我们看看字符串能做什么。我们可以将字符串打印到控制台。例如,使用 Console.WriteLine
方法输出 myName
的值。我们启动程序后,会看到输出 "Dennis"。
我们可以在字符串之间进行连接,例如,不仅仅写 "Dennis",而是可以说 "My name is " 加上字符串,这就是字符串的连接,称为 连接(concatenation)。也就是说,可以将多个字符串合并成一个字符串。因此,你可以创建一个新字符串 message
,内容为 "My name is " 加上 myName
的值。运行这段代码后,输出将是 "My name is Dennis"。
需要注意的是,虽然在此处使用的是小写的 string
,但它实际上来自 System.String
类。该类具有多个方法,类似于我们在控制台中看到的那样。字符串的类型是 string
,所以我们可以对其使用字符串的方法。例如,可以调用 ToUpper
方法,将字符串转换为大写字母。为了存储这个转换后的结果,我需要创建一个新的字符串变量 capsMessage
,并将其设置为 message.ToUpper()
。然后将 capsMessage
输出到控制台,结果将是 "MY NAME IS DENNIS"。
同样,我们还可以创建一个新的字符串变量,将 message
转换为小写字母。代码如下:
string lowerCaseMessage = message.ToLower();
Console.WriteLine(lowerCaseMessage);
输出将是 "my name is dennis"。现在你已经看到了如何创建和使用字符串,以及可用的多种字符串方法,当然还有更多方法我们会在后续学习中使用。
在下一个视频中,我们将讨论命名约定。期待与你在那见面!
欢迎回来。在本视频中,你将学习值类型和引用类型。数据类型可以根据它们在内存中的占用方式分为两类:值类型和值类型。我们将逐一介绍这两类。
值类型通常存储在栈中,这意味着它们的分配和释放是自动管理的,随着程序的增长和缩小,数据会直接存储。常见的值类型包括基本数据类型,如 int
、float
、long
、double
、char
、bool
和 decimal
。在 C# 中,其他的值类型包括结构体(structs)和枚举(enums)。它们也被视为值类型。
可空值类型(nullable value types)是每种值类型都有一个对应的可空版本,可以持有该类型的任意值或 null。这些类型用问号(?)表示,例如 int?
或 double?
,这样它们可以为空,因此我们加上问号,以便编译器理解。尽管值类型通常存储在栈中,如果它们是引用类型的一部分(如类中的字段或数组中的元素),也可以存储在堆中。这在我们学习类和数组时会更清楚,所以请将这一点放在心中。
以下是一个值类型的示意图:
int z
存储值 234,并直接存储在 RAM 的特定位置。引用类型是另一种变量类型,它不是直接在内存中存储值,而是存储实际数据的内存位置。它只指明数据的位置。因此,变量存储的是数据的内存引用,而不是数据本身。引用数据类型包括字符串(strings)、类(classes)、数组(arrays)和其他对象。
当我们复制一个引用类型时,只会复制数据的内存地址,这样就会有两个变量指向同一数据。
以下是引用类型的示意图:
string firstName = "Dennis"
,变量存储的内存地址指向 RAM 中的值,而实际值则存储在不同的地址。因此,我们只存储变量的值地址,而不是值本身。地址所需的空间要小得多,因为一个对象可能很大,可能需要很多内存。如果我们不断复制该内存并将其存储在其他地方,应用程序将需要更多内存,也会占用更多 RAM,从而降低性能。因此,这种设计方式显著提高了性能。到此为止,让我们回到编码中。希望你对值类型和引用类型有了更清晰的理解。
欢迎回来。在本视频中,我想讨论编码标准。编码标准是一组指导方针、最佳实践和编程风格,开发人员在为项目编写源代码时遵循这些标准。所有大型软件公司都会遵循这些标准,而且公司通常会有自己特定的标准。以下是一些编码标准的示例。当然,当你在公司工作时,他们会告诉你以某种方式编写代码,因为这是他们的做法。
你应该始终给变量一个合理的名称。在声明变量时,开发人员必须为变量提供一个适当的名称。变量的名称应基于其用途。想一想:我用这个变量做什么?然后给它一个描述该变量真正用途的名称。例如,如果你想存储用户的年龄,那么该变量的好名称可以是 age
或 userAge
。
你也应该给函数一个适当的名称。函数的名称应基于其在代码或程序中所执行的功能。函数通常执行某种操作。例如,如果你想要一个检查互联网连接的函数,可以将其命名为 checkInternetConnection
。
在代码中留下注释是良好的实践,在大型科技公司中这更是必不可少。函数应该有注释,说明其用途和功能。这有助于其他开发人员理解该函数的作用。并且这不仅是为了其他开发人员,也是为了你自己。当你在几年后或几个月后回到代码时,如果没有适当的注释,你会发现很难理解自己的代码,可能需要从头开始理清思路,这会花费大量时间。
单行注释用于描述变量或 if
语句的用途。比如:
// 这是一个单行注释
多行注释用于注释多于一行的内容。它以 /*
开始,以 */
结束,可以覆盖多行:
/* 这是一个
多行注释 */
XML 文档注释用于创建函数或类的文档。在 C# 中,以三个斜杠开始,接着是标签 summary
,然后在括号内写下该类或函数的描述。这在与其他人共享代码时非常有用,因为 Visual Studio 会识别这些注释,并在调用方法时显示摘要。当你将鼠标悬停在方法上时,你会看到我们写的摘要。
例如,cool
方法的摘要是 "这是一个很酷的方法",当你悬停在 cool
方法上时,底部会显示该摘要,说明该方法的作用。
以上就是标准实践的介绍。当然,还有更多的实践,你在大型公司工作时会遇到,例如他们如何决定命名变量等。好的,我们下个视频再见。
欢迎回来。在本视频中,我想帮助你更好地理解我们正在使用的所有控制台方法,以及未来我们将频繁使用这些方法的原因。因此,我想提供一些背景信息。如果你只是想使用这些方法,大部分内容可能与您无关;但如果你真的想了解发生了什么,那么这个视频是适合你的。让我们开始吧。
控制台类中有多种方法可供使用,我们将重点关注与输入和输出相关的方法。首先,我们有 Console.Write
,它可以在同一行上打印文本并保持光标在同一行上。这意味着我们可以将内容打印到控制台并看到结果。例如,使用以下代码:
Console.Write("text here");
这段代码会将 "text here" 打印到控制台上,非常适合显示某些值,以验证代码是否正常工作。尤其是在编程初学阶段,这一点非常重要。
Write
和 WriteLine
Console.Write
是打印内容的一种方式,而 Console.WriteLine
则会在打印内容后换行。换句话说,WriteLine
打印文本并将光标移到下一行。二者的区别就在于是否换行。
接下来,我们有 Console.Read
,它接受一个字符串输入并返回该输入的 ASCII 值,即返回一个整数值。字符串是一种文本数据类型,而整数则是数字数据类型。ASCII,即美国标准信息交换码,是一种七位字符代码,用于让计算机识别字符。
例如,字符 'A' 的 ASCII 值是 65,作为二进制值表示为 01000001。总共有 256 个 ASCII 值,起始值为 0。虽然这些信息不必完全记住,但了解它们有助于更好地理解。
还有一种方法叫 Console.ReadLine
,它接受字符串或整数输入,并将输入值返回。这意味着用户输入的内容可以被重新利用,进行计算或进一步处理。
现在让我们看一些示例代码,这样能更直观地理解这些理论。我们有以下 Main
方法,这是我们类和项目的入口点:
static void Main()
{
Console.WriteLine("Hello, welcome.");
Console.Write("Hello ");
Console.Write("Welcome.");
Console.ReadKey();
}
在这里,Console.WriteLine
会打印文本并换行,而 Console.Write
会在同一行打印文本。最后,Console.ReadKey
会等待用户按下一个键,从而保持程序运行。
程序的输出将是 "Hello, welcome.",然后在同一行打印 "Hello Welcome.",因为 Console.Write
不会添加空格或换行。
接下来,我们来看一个获取用户输入的示例代码:
Console.WriteLine("Enter a string and press enter.");
string userInput = Console.ReadLine();
Console.WriteLine($"You have entered: {userInput}");
用户会看到提示,然后输入文本,程序将回显用户输入的内容。
我们还可以获取用户输入的 ASCII 值:
Console.Write("Enter a character: ");
char userChar = Console.ReadKey().KeyChar;
int asciiValue = (int)userChar;
Console.WriteLine($"\nASCII value is: {asciiValue}");
这段代码会读取用户输入的字符,并返回其对应的 ASCII 值。
以上就是对控制台类及其一些方法的简单介绍。如果你想了解更多关于控制台类的不同方法,可以查看文档,其中列出了许多方法及其用途。
记住,方法的重载允许同一个方法有多种不同的输入选项。我们将来会深入探讨这些内容,理解会逐渐变得自然。所以,不必担心现在是否完全理解所有细节。我们会在后续视频中更深入地探讨这些方法。
好的,我们下个视频见!
欢迎回来。在本视频中,我们将讨论一些命名约定和编码标准。关于类名,您应该使用帕斯卡命名法,这意味着例如,如果您有一个名为 "ClientActivity" 的类,首字母和第二个单词的首字母都应大写。也就是说,两个单词 "Client" 和 "Activity" 每个新词的首字母都要大写,但它们要连在一起。
类名示例:
ClientActivity
方法名也遵循相同的规则,例如 CalculateValue
,同样是首字母大写。
对于方法参数或局部变量,请使用小写开头的驼峰命名法,例如:
firstNumber
itemCount
请尽量避免使用缩写。例如,"UserControl" 不要用 "UserCTR" 这样的形式。使用完整的 "UserControl" 变量名称会更易于阅读。
3Cars
是不被允许的。您可以将数字放在变量的末尾,如 Cars3
。避免使用大写字母开头的类型名称,比如 String
、Int
或 Boolean
。使用小写开头的预定义类型名称,如 string
、int
、bool
,这是更符合 Microsoft .NET 框架的一种方式,更加自然易读。
为类命名时使用名词或名词短语,例如:
Program
Employee
BusinessDate
方法名通常是动词,表示动作,例如:
Calculate
Execute
如果您想了解更多关于 C# 的编码约定,我推荐访问 DoFactory。这个网站提供了详细的文章和示例,涵盖了类、方法以及后续会涉及的接口和枚举等内容。
在接下来的课程中,我们会涉及接口和枚举,因此牢记这些编码标准将非常有帮助。我会在课程创建过程中确保这些标准得到应用。
在下个视频中,我们将探讨如何将一个值从一种类型转换为另一种类型,或如何进行类型转换。期待在下个视频中见到你!
欢迎回来。在本视频中,我们将讨论转换,具体来说,我们会研究隐式转换和显式转换。首先,让我们来看一下显式转换。为此,我创建一个名为 myDouble
的双精度变量,并赋值为 1.337。接下来,我创建一个整数 myInt
,目前不初始化它,所以它还没有值。
如果我想将 myDouble
的值转换为整数并赋给 myInt
,可以通过强制转换来实现,即将双精度类型转换为整数。由于整数只能包含整数部分,因此小数部分将被截断。下面是具体的转换代码:
myInt = (int)myDouble;
现在我们在控制台中打印 myInt
的值:
Console.WriteLine(myInt);
Console.ReadLine();
运行后,输出为 1,而不是 1.337。这是因为整数只存储整数部分,直接截断了小数点后面的部分。
接下来,我们来看看隐式转换。例如,我有一个整数类型的数字,可以将其赋值给一个更大的数字类型,例如 long
。可以直接将较小的类型(如 int
)的值赋给较大的类型(如 long
):
int number = 42;
long bigNumber = number;
同样地,我们也可以将 float
转换为 double
:
float myFloat = 13.37F;
double myDoubleFromFloat = myFloat;
除了隐式和显式转换,我们还可以进行类型转换。例如,如果我想将双精度类型转换为字符串,可以使用以下方法:
string myString = myDouble.ToString();
这样 13.37
将被转换为字符串形式 "13.37"
。同样,我们也可以对整数进行转换。
请尝试将 myFloat
转换为字符串:
string myFloatString = myFloat.ToString();
Console.WriteLine(myFloatString);
运行后,您会看到 13.37
被打印出来。
我们还可以将布尔值转换为字符串。创建一个布尔变量:
bool sunIsShining = false; // 请根据实际情况调整
然后将其转换为字符串:
string myBoolString = sunIsShining.ToString();
Console.WriteLine(myBoolString);
输出将显示 false
,表示太阳没有在科隆照耀。
快速总结一下:
int
转为 long
)。double
转为 int
)。double
转为 string
)。在下个视频中,我们将学习解析(parsing),即如何将字符串转换为数据类型(例如,double
、float
、int
)。这在从用户获取信息时非常相关,因为通常我们获得的信息是字符串形式,必须将其转换为数字类型以便进行计算。期待在下个视频中见到你!
欢迎回来。在本视频中,我们将学习如何将字符串解析为整数。例如,我们有一个字符串 myString
,其值为 15。现在,如果我想将这个 15 作为数字使用,可以创建另一个字符串 mySecondString
,值为 13。如果我想将这两个值相加:
string result = myString + mySecondString;
在控制台中打印结果:
Console.WriteLine(result);
Console.ReadLine();
运行后,控制台显示的结果是 1513
,这并不是我们期望的 28,而是将两个字符串连接在一起的结果。
为了正确地进行数学运算,我们需要将这些字符串解析为整数。为此,我需要创建一个整数变量 number1
,并使用 Int32.Parse
方法:
int number1 = Int32.Parse(myString);
同样,我也可以为第二个字符串解析:
int number2 = Int32.Parse(mySecondString);
然后,我们可以计算它们的和,并将结果打印到控制台:
int resultInt = number1 + number2;
Console.WriteLine(resultInt);
这次输出将是 28,这是我们期望的结果。
需要注意的是,如果字符串无法解析为整数,比如如果 myString
的值是 "15A"
,解析将会失败,并抛出一个异常:
int number1 = Int32.Parse("15A"); // 将引发异常
为了处理这种情况,我们可以使用 try
和 catch
语句来捕获异常,或者使用 Int32.TryParse
方法,该方法可以返回一个布尔值,指示解析是否成功。
if (Int32.TryParse(myString, out int number1))
{
// 解析成功,使用 number1
}
else
{
// 解析失败,处理错误
}
快速总结一下:
Int32.Parse
可以将字符串解析为整数,但要小心可能的异常。Int32.TryParse
是一个更安全的选择,因为它不会抛出异常,而是返回一个布尔值,表示解析是否成功。在下个视频中,我们将进行一个小挑战。请务必在解析时小心,并继续关注课程以学习如何更好地处理这些情况。期待在下个视频中见到你!
欢迎回来。在本视频中,我将教你关于字符串操作的知识,这在你的编码经验和职业生涯中非常重要。这是一个额外的讲座,我们将讨论在屏幕上打印数据的不同方式,包括复合字符串、字符串格式化、字符串插值和逐字字符串。你可以选择一种方式,如果你喜欢其他方法也没问题,但在整个项目中尽量保持一致。
首先,在我的 Main
方法中定义几个变量,一个是整数 H
,另一个是字符串 name
,值为 "Alfonso"。接下来,我们将使用字符串拼接:
string result = "Hello, my name is " + name + ". I am " + H + " years old.";
运行后,控制台将输出:
Hello, my name is Alfonso. I am 31 years old.
这种方法能很好地与整数和字符串结合使用。
接下来,我们来看字符串格式化。这种方法更高级一些,使用索引来插入变量。示例代码如下:
string formattedResult = "Hello, my name is {0}. I am {1} years old.";
Console.WriteLine(formattedResult, name, H);
运行结果同样是:
Hello, my name is Alfonso. I am 31 years old.
第三种方法是字符串插值。通过在字符串前加上美元符号 $
,我们可以直接使用变量,而不必使用索引:
string interpolatedResult = $"Hello, my name is {name}. I am {H} years old.";
Console.WriteLine(interpolatedResult);
结果同样是:
Hello, my name is Alfonso. I am 31 years old.
字符串插值的好处是语法更清晰,不用担心索引问题。
最后,我们来看看逐字字符串。使用 @
符号可以让编译器将字符串字面量化,忽略任何空格和转义字符。举个例子:
string verbatimString = @"This is a verbatim string.
It allows for line breaks and spaces.";
Console.WriteLine(verbatimString);
逐字字符串在文件路径中非常有用,因为可以避免使用转义字符:
string filePath = @"C:\Users\Alfonso\Documents\file.txt";
在逐字字符串中,转义字符如 \n
不会产生效果,而是被视为普通文本。
以上是字符串操作的不同方式:
在前几种方法中,建议选择一种并在项目中一致使用。逐字字符串则有特定用途,特别是在处理文件路径时。
感谢观看,下个视频见!
欢迎回来。在本视频中,我们将探讨字符串及其方法。字符串是系统字符串类的一个对象,在编程中,字符串指的是一系列字符。这个类中包含许多函数,可以用来操作字符串。接下来,我们将看看其中的一些方法。
Substring
Substring
方法需要一个整数参数,用于从指定索引开始获取字符串的子字符串。例如,如果有一个字符串 car
,调用 Substring(1)
会返回 r
,因为我们从索引 1 开始,而 c
的索引是 0。ToLower 和 ToUpper
ToLower
方法将字符串转换为小写。例如,"HELLO" 转换后将变为 "hello"。ToUpper
方法将字符串转换为大写,例如,"hello" 将变为 "HELLO"。Trim
Trim
方法用于去除字符串开头和结尾的空白字符。这在输入电子邮件地址时尤其有用,常常会不小心复制到多余的空格。IndexOf
IndexOf
方法用于获取字符串或字符在另一个字符串中第一次出现的位置。例如,在一个句子中查找某个单词的位置。IsNullOrWhiteSpace
IsNullOrWhiteSpace
方法用于检查字符串是否为空或只包含空白字符。如果字符串为 null 或空白,返回 true;否则返回 false。这在判断字符串是否有实际意义时很有用。假设我们有以下代码示例:
string firstName = "Dennis";
string lastName = "Pun";
string fullName = string.Concat(firstName, " ", lastName); // 拼接字符串
Substring
:string subString = fullName.Substring(2); // 输出 "nnis Pun"
ToLower
和 ToUpper
:string lowerCase = firstName.ToLower(); // 输出 "dennis"
string upperCase = lastName.ToUpper(); // 输出 "PUN"
Trim
:string trimmed = fullName.Trim(); // 去除首尾空格
IndexOf
:int indexOfE = firstName.IndexOf('E'); // 输出 1,'E' 在 "Dennis" 中的索引为 1
IsNullOrWhiteSpace
:bool isEmpty = string.IsNullOrWhiteSpace(firstName); // 输出 false
C# 中还有一个重要的方法是 Format
。String.Format
方法用于将对象或变量值插入到字符串中。其语法如下:
string formattedString = string.Format("My name is {0}.", firstName);
这将输出:
My name is Dennis.
今天我们探讨了字符串的多种方法,包括:
Substring
ToLower
和 ToUpper
Trim
IndexOf
IsNullOrWhiteSpace
String.Format
这些方法在日常编程中非常有用,可以帮助你有效地操作和格式化字符串。谢谢观看,下一段视频再见!
欢迎回来。在本视频中,我们将学习如何在字符串中使用特殊字符。为此,我将创建一个名为 S1
的新字符串,内容是 "this is a string with a slash and a colon"。像往常一样,我们以分号结束这一行。接下来,我将使用 Console.WriteLine
来打印它。
string S1 = "this is a string with a slash and a colon.";
Console.WriteLine(S1);
运行后,您会看到输出为 "this is a string with a slash and a colon"。然而,有些字符在字符串中具有特殊含义,这些被称为转义字符。
例如,双引号字符用于表示字符串的开始和结束。如果您想在字符串中包含双引号,您需要使用反斜杠(backslash
)进行转义。
string example = "He said, \"This is a string with quotes.\"";
Console.WriteLine(example);
在这个例子中,如果我直接使用双引号,编译器会报错,提示语法错误(syntax error)。通过使用 backslash
,我们可以将引号视为字符串的一部分,而不是代码的一部分。
如果我需要在字符串中使用反斜杠,则必须使用两个反斜杠来转义它:
string path = "C:\\Program Files\\MyApp";
Console.WriteLine(path);
在输出中只会显示一个反斜杠,因为另一个反斜杠用作转义字符。
\n
另一个特别的转义序列是 \n
,它用于创建新行。例如:
string multilineString = "This is a string with a slash and a colon:\nThis is the next line.";
Console.WriteLine(multilineString);
运行这个代码,您会看到输出为:
This is a string with a slash and a colon:
This is the next line.
在本视频中,我们学习了以下内容:
\n
:用于在字符串中插入换行符。这些知识对于正确处理字符串中的特殊字符非常重要。感谢您的观看,我们下次再见!
欢迎回来!在本视频中,我们将开始编写一个简单的程序,获取用户的输入并对其进行处理。首先,我们将设置主方法,并在其中包含一个 Console.WriteLine
语句,提示用户输入他们的名字。
我们将创建一个字符串 myName
,并显示提示信息:
string myName;
Console.WriteLine("Please enter your name and press enter:");
我们将使用 Console.ReadLine()
来获取用户输入并将其存储在 myName
变量中:
myName = Console.ReadLine();
接下来,我们将把输入的名字转换为大写,使用 ToUpper()
方法:
string myNameUppercase = myName.ToUpper();
Console.WriteLine("Uppercase: " + myNameUppercase);
同样,我们将输入的名字转换为小写,并使用字符串格式化来显示结果:
string myNameLowercase = myName.ToLower();
Console.WriteLine("Lowercase: " + myNameLowercase);
接着,我们将使用 Trim()
方法来去除用户输入名字前后的空格:
string myNameTrimmed = myName.Trim();
Console.WriteLine("Trimmed: " + myNameTrimmed);
最后,我们将使用 Substring()
方法获取输入的前五个字符,并显示结果:
string myNameSubstring = myName.Substring(0, 5);
Console.WriteLine("Substring (0, 5): " + myNameSubstring);
以下是完整的代码示例:
using System;
class Program
{
static void Main()
{
string myName;
Console.WriteLine("Please enter your name and press enter:");
myName = Console.ReadLine();
string myNameUppercase = myName.ToUpper();
Console.WriteLine("Uppercase: " + myNameUppercase);
string myNameLowercase = myName.ToLower();
Console.WriteLine("Lowercase: " + myNameLowercase);
string myNameTrimmed = myName.Trim();
Console.WriteLine("Trimmed: " + myNameTrimmed);
string myNameSubstring = myName.Substring(0, 5);
Console.WriteLine("Substring (0, 5): " + myNameSubstring);
}
}
当您运行此代码并输入例如 " Dennis Pan " 时,输出将如下:
Please enter your name and press enter:
Dennis Pan
Uppercase: DENNIS PAN
Lowercase: dennis pan
Trimmed: Dennis Pan
Substring (0, 5): Deni
在这个练习中,我们学习了如何获取用户输入,并对字符串执行多种操作,包括转换大小写、修剪空格和获取子字符串。希望您能够尝试并完成这个练习。感谢您的观看,我们下次再见!
好的,现在让我们开始第二个字符串挑战。在这个练习中,我们将要求用户输入一个字符串,并查找该字符串中某个字符的索引。
首先,我们需要在主方法中要求用户输入一个字符串:
Console.WriteLine("Please enter a string:");
string input = Console.ReadLine();
接下来,我们将询问用户要查找的字符,并将其存储为字符类型:
Console.WriteLine("Enter a character to search:");
char searchInput = Console.ReadLine()[0]; // 获取输入的第一个字符
接着,我们将创建一个整数变量来存储字符的索引:
int searchIndex = input.IndexOf(searchInput);
Console.WriteLine($"Index of character '{searchInput}' in string: {searchIndex}");
整合上述步骤,完整代码如下:
using System;
class Program
{
static void Main()
{
Console.WriteLine("Please enter a string:");
string input = Console.ReadLine();
Console.WriteLine("Enter a character to search:");
char searchInput = Console.ReadLine()[0];
int searchIndex = input.IndexOf(searchInput);
Console.WriteLine($"Index of character '{searchInput}' in string: {searchIndex}");
}
}
当您运行此代码并输入字符串 “tutorials.eu” 时,输入字符 “.”,输出将是:
Please enter a string:
tutorials.eu
Enter a character to search:
.
Index of character '.' in string: 9
接下来,我们将进行第二部分的练习,使用字符串拼接。我们将要求用户输入他们的名字和姓氏,并将它们合并为一个字符串。
Console.WriteLine("Enter your first name:");
string firstName = Console.ReadLine();
Console.WriteLine("Enter your last name:");
string lastName = Console.ReadLine();
我们将使用 String.Concat
方法来拼接名字和姓氏,并在它们之间添加一个空格:
string fullName = String.Concat(firstName, " ", lastName);
Console.WriteLine($"Your full name is: {fullName}");
整合字符串拼接部分,完整代码如下:
using System;
class Program
{
static void Main()
{
Console.WriteLine("Please enter a string:");
string input = Console.ReadLine();
Console.WriteLine("Enter a character to search:");
char searchInput = Console.ReadLine()[0];
int searchIndex = input.IndexOf(searchInput);
Console.WriteLine($"Index of character '{searchInput}' in string: {searchIndex}");
Console.WriteLine("Enter your first name:");
string firstName = Console.ReadLine();
Console.WriteLine("Enter your last name:");
string lastName = Console.ReadLine();
string fullName = String.Concat(firstName, " ", lastName);
Console.WriteLine($"Your full name is: {fullName}");
}
}
当您运行完整的代码并输入名字和姓氏时,例如:
Enter your first name:
Dennis
Enter your last name:
Panopto
输出将是:
Your full name is: Dennis Panopto
在这个练习中,我们学习了如何获取用户输入、查找字符索引以及使用拼接方法合并字符串。希望您能够成功完成这个练习!感谢观看,我们下次再见!
欢迎回来。在本视频中,我们将检查上次讲座挑战的解决方案。让我们首先了解这个挑战的内容。
我希望你为 Microsoft 网站上列出的每种数据类型创建一个变量,并为每个变量赋予正确的值。下面,我们将逐一查看这些数据类型及其变量。
Byte:
byte myByte = 25; // 范围:0 到 255
Console.WriteLine(myByte);
SByte:
sbyte mySByte = -100; // 范围:-128 到 127
Console.WriteLine(mySByte);
Int:
int myInt = 12345; // 范围:-2,147,483,648 到 2,147,483,647
Console.WriteLine(myInt);
UInt:
uint myUInt = 123456; // 仅为正数,范围:0 到 4,294,967,295
Console.WriteLine(myUInt);
Short:
short myShort = -32768; // 范围:-32,768 到 32,767
Console.WriteLine(myShort);
UShort:
ushort myUShort = 65535; // 仅为正数,范围:0 到 65,535
Console.WriteLine(myUShort);
Float:
float myFloat = 3.14F; // 浮点数,带有 F 后缀
Console.WriteLine(myFloat);
Double:
double myDouble = 3.141592653589793; // 更高精度的浮点数
Console.WriteLine(myDouble);
Char:
char myChar = 'A'; // 单个字符,用单引号包围
Console.WriteLine(myChar);
Boolean:
bool myBool = true; // 布尔值,只有 true 或 false
Console.WriteLine(myBool);
String:
string myText = "Hello, World!"; // 字符串
Console.WriteLine(myText);
Num Text:
string numText = "15"; // 数字以字符串形式存储
int parsedNum = int.Parse(numText); // 将字符串解析为整数
Console.WriteLine(parsedNum);
整合所有这些变量和输出,代码如下:
using System;
class Program
{
static void Main()
{
byte myByte = 25;
sbyte mySByte = -100;
int myInt = 12345;
uint myUInt = 123456;
short myShort = -32768;
ushort myUShort = 65535;
float myFloat = 3.14F;
double myDouble = 3.141592653589793;
char myChar = 'A';
bool myBool = true;
string myText = "Hello, World!";
string numText = "15";
int parsedNum = int.Parse(numText);
Console.WriteLine(myByte);
Console.WriteLine(mySByte);
Console.WriteLine(myInt);
Console.WriteLine(myUInt);
Console.WriteLine(myShort);
Console.WriteLine(myUShort);
Console.WriteLine(myFloat);
Console.WriteLine(myDouble);
Console.WriteLine(myChar);
Console.WriteLine(myBool);
Console.WriteLine(myText);
Console.WriteLine(parsedNum);
}
}
在这个练习中,我们创建了多个变量以代表不同的数据类型,并展示了它们的范围和用途。了解这些数据类型以及它们使用的位数对于进一步的编程是非常重要的。将来你可以根据需求选择合适的数据类型,并且可以随时参考 Microsoft 网站以获取详细信息。
希望你能够成功完成这个挑战!如果你有其他解决方案,也欢迎分享。感谢观看,我们下次再见!
欢迎回来。在本视频中,我们将学习关于 var
关键字的内容。
var
关键字简介var
关键字是在 C# 3.0 中引入的,用于声明隐式类型的局部变量。这意味着,当你使用 var
声明一个变量时,你是在告诉编译器根据你赋给它的值的类型来确定变量的数据类型。
例如,我可以创建一个名为 number
的变量:
var number = 10; // number 将被推断为整数类型
在这个例子中,number
实际上是整数类型,因为我赋给它了一个整数值。如果我尝试只用 var
声明而不赋值,例如:
var num; // 这会报错,因为 num 没有初始化
这将导致错误,因为 num
必须在声明时被初始化。
var
可以用于多种数据类型:
var text = "Hello, World!"; // text 被推断为字符串类型
var isTrue = true; // isTrue 被推断为布尔类型
重要的是,变量的类型一旦确定,就不能被更改。例如,如果你将 number
声明为整数,就不能将其重新赋值为字符串:
number = "Hello"; // 这会报错,因为 number 是整数类型
同样,不能将浮点数或双精度数赋给一个整数变量。
var
在处理复杂的泛型类型、LINQ 查询或匿名类型时特别有用,因为在这些情况下,确切的类型要么复杂,要么在使用时未知。这部分内容在目前可能过于复杂,因此暂时可以忽略。
var
允许你创建一个变量,其类型根据赋值隐式推断。var
变量不能为 null
,因为它们是不可空值类型。null
赋值给一个 var
变量,编译器会报错,因为它无法确定变量的数据类型。var newVar = null; // 这会报错,因为无法将 null 赋值给隐式类型变量
好啦,这就是本视频的内容。期待在下一个视频中再见!
欢迎回来。现在你已经了解了如何创建变量,接下来我们将讨论不可变值,也称为常量。常量是指在创建后不能被更改的变量,这意味着在编译时它们的值在程序运行期间始终保持不变。这是一个非常有用的工具,因为有些变量是我们希望保持不变的,例如圆周率(π),它的值永远是固定的。
常量通常是字段(即在任何方法外部的变量),所以我们将在类中创建常量字段。要声明常量,需要使用 const
关键字,并指定数据类型。以下是创建常量的示例:
const double Pi = 3.14159265359; // 定义圆周率常量
const int WeeksInYear = 52; // 每年的周数常量
const int MonthsInYear = 12; // 每年的月数常量
这里,我们定义了 Pi
为一个精确的双精度常量,WeeksInYear
和 MonthsInYear
分别表示每年的周数和月数。
由于常量是不可变的,如果尝试更改常量的值,编译器将报错。例如:
const int DaysInYear = 365;
// DaysInYear = 366; // 这将报错,因为常量不能被更改
因此,不要尝试更改常量的值。
现在我给你一个小挑战:创建一个字符串类型的常量,表示你的生日。你可以按照以下方式进行:
const string Birthday = "1988年5月31日"; // 以字符串形式保存生日
请根据你所在国家的日期格式调整生日的写法。创建好常量后,你可以简单地打印出来:
Console.WriteLine($"我的生日是 {Birthday}"); // 使用插值字符串打印生日
完整示例代码如下:
class Program
{
const double Pi = 3.14159265359;
const int WeeksInYear = 52;
const int MonthsInYear = 12;
const string Birthday = "1988年5月31日"; // 你的生日常量
static void Main(string[] args)
{
Console.WriteLine($"我的生日是 {Birthday}");
Console.ReadKey(); // 等待用户按键以防止控制台立即关闭
}
}
运行该代码,你将看到输出你的生日的消息。
const
关键字声明常量。好啦,现在你已经知道如何创建和使用常量了。请继续下一视频!
很好!你已经完成了第二章。在这一章中,你学习了如何使用变量,了解了不同的数据类型,以及如何在这些数据类型之间进行转换。虽然不是所有数据类型都可以直接转换,但你已经掌握了一些基本的转换方法。
接下来的章节中,我们将探索方法。方法将帮助我们节省大量编程时间,因为我们不希望每次都重复输入相同的代码。编程更多的是思考过程,而不是简单的输入文本。通过使用方法,你可以:
在下一个章节中,我们将深入了解如何使用方法。期待在那儿见到你!
欢迎来到方法章节!在这一章中,你将学习如何使用方法、什么是方法,以及它们如何为你节省大量时间。你将了解方法的重要性,以及如何使用它们来组织你的代码。
接下来,我们将讨论如何使用用户输入,使你的程序能够接收并处理数据。这非常酷!此外,我们还会介绍try
和catch
,因为在输入数据时可能会出现错误,例如传入错误的数据类型,这可能导致程序崩溃。try
和catch
可以帮助你处理这些问题。
期待在下一个视频中见到你!
欢迎来到方法章节。在这一部分,我们将深入了解方法,了解它们的作用以及如何使用它们。方法在任何面向对象编程语言中都非常重要。我们将从定义开始,这个定义来源于微软的官方文档。
方法是一个代码块,包含一系列语句。程序通过调用方法并指定所需的参数来执行这些语句。在 C# 中,每条执行的指令都是在某个方法的上下文中执行的。Main
方法是每个 C# 应用程序的入口点,由公共语言运行时(CLR)在程序启动时调用。到目前为止,我们只见过一个方法,那就是 Main
方法,它在我们启动程序时运行。
方法的语法如下:
访问修饰符:决定其他类能否访问该方法。常见的修饰符包括 public
(可以被其他类访问)和 private
(不能被其他类访问)。我们将稍后详细讨论其他修饰符。
返回类型:方法可以返回一个值,也可以不返回。返回类型是方法返回值的数据类型。如果方法不返回任何值,则返回类型为 void
。
方法名:唯一标识方法,区分大小写,不能与类中的其他标识符相同。最佳实践是方法名以大写字母开头,后续单词使用小写字母(例如:AddNumbers
)。
参数列表:用于传递和接收数据,参数是可选的,方法可以没有参数。参数的顺序和数量由方法定义。
方法体:包含执行特定活动所需的指令。
让我们看一个简单的示例:
public int Add(int num1, int num2)
{
return num1 + num2;
}
在这个例子中:
public
是访问修饰符,表示这个方法可以被其他类访问。int
是返回类型,表示该方法返回一个整数。Add
是方法名,标识该方法的功能。(int num1, int num2)
是参数列表,表示该方法接收两个整数作为输入。return num1 + num2;
执行了两个参数的加法并返回结果。我们可以简化代码,如下所示:
public int Add(int num1, int num2) => num1 + num2;
这样可以更简洁地表达相同的功能。
在下一个视频中,我们将创建一个没有返回值的 void
方法,并继续探讨其他类型的方法。现在,让我们进行演示,看看方法可以做些什么,以及它们的目的是什么。
现在我们了解了方法的理论,接下来让我们看看方法的实际应用。我们将创建一个方法,首先我们定义一个访问修饰符,这里我们使用 public
。然后定义返回类型为 void
,接下来给这个方法一个名字,我将其命名为 WriteSomething
,这个方法将用于输出一些内容。
我们开始定义方法,如下所示:
public void WriteSomething()
{
Console.WriteLine("I am called from a method");
Console.Read();
}
这个方法非常简单,它将在控制台输出“I am called from a method”。为了调用这个方法并执行它,我们需要在 Main
方法中进行调用。Main
方法本身也是一个方法,通常不需要访问修饰符,因为它是应用程序的入口点。
static void Main(string[] args)
{
WriteSomething();
}
这里我们会遇到一个错误,因为 Main
方法是静态的,因此 WriteSomething
方法也必须声明为静态方法。我们在定义 WriteSomething
时需要加上 static
关键字,如下所示:
public static void WriteSomething()
在这里,方法的结构是:
public
)static
)void
)WriteSomething
)当你想要在一个静态方法中调用另一个方法时,必须确保被调用的方法也是静态的。现在我们已经解决了错误,调用方法后,控制台将显示:
I am called from a method
接下来,我们创建一个新的方法,它不仅仅输出固定的内容,而是根据调用时提供的文本输出。这个方法如下:
public static void WriteSomethingSpecific(string text)
{
Console.WriteLine(text);
}
在这里,我们定义了一个接受一个字符串参数 text
的方法。在调用这个方法时,我们需要传递一个具体的字符串作为参数:
WriteSomethingSpecific("I am an argument and I am called from a method.");
当你创建方法时,text
是参数;而在调用方法时传递的具体字符串(如 "I am an argument"
)被称为参数值。运行这个代码后,控制台将显示:
I am called from a method
I am an argument and I am called from a method.
在这部分中,我们了解了如何创建和调用方法,使用 void
返回类型,以及参数和参数值之间的区别。在下一个视频中,我们将探讨不同的返回类型,并学习如何使用多个参数。
现在我们已经了解了如何创建不带返回值的方法,接下来我们将讨论带有返回值的方法,例如整数类型的返回值。让我们开始创建一个新的方法。
我们定义一个方法,返回类型为 int
,方法名为 Add
,接受两个参数,分别是 num1
和 num2
,它们都是整数类型。
public static int Add(int num1, int num2)
{
return num1 + num2;
}
在这里,我们需要确保每条路径都返回一个值。如果没有返回值,编译器将会报错,提示“并非所有代码路径都返回一个值”。在这个简单的情况下,我们只需要返回 num1
和 num2
的和。
要调用这个 Add
方法并查看结果,我们可以将其返回值存储在一个变量中:
int result = Add(15, 31);
Console.WriteLine(result);
当我们运行这个代码时,控制台将显示结果 46
。此外,我们也可以直接在 Console.WriteLine
中调用 Add
方法,代码如下:
Console.WriteLine(Add(15, 31));
这样可以简化代码,不需要额外的变量。
现在,请尝试创建一个新的方法,用于计算两个值的乘积并返回结果。方法如下:
public static int Multiply(int num1, int num2)
{
return num1 * num2;
}
我们可以通过以下方式调用这个方法,例如:
Console.WriteLine(Multiply(25, 25));
这将返回 625
。
接下来,让我们创建一个用于除法的静态方法,返回值类型为 double
,以确保精度不丢失。
public static double Divide(int num1, int num2)
{
return (double)num1 / num2;
}
在调用这个方法时,可以如下操作:
Console.WriteLine(Divide(25, 13));
在整数相除时,结果也将是整数。如果 num1
和 num2
都是整数,计算结果将被截断。因此,如果想要得到浮点数的结果,必须将其中一个或两个参数转换为 double
。
通过这些示例,我们了解了如何创建具有返回值的方法,如何传递参数,以及如何调用这些方法。在接下来的内容中,你可以尝试自己创建一些方法,这将有助于巩固你的理解。期待在下一个视频中见到你!
欢迎回来!希望你已经成功编写了代码所需的方法和变量,并完成了挑战。接下来,让我们创建表示朋友的变量。
我们将创建三个朋友的字符串变量,代码如下:
string friend1 = "Frank";
string friend2 = "Michael";
string friend3 = "Vlad";
接下来,我们需要一个方法来问候这些朋友。这个方法将接受一个参数 friendName
,并在控制台上输出问候语。方法定义如下:
public static void GreetFriend(string friendName)
{
Console.WriteLine("Hi, " + friendName + ", my friend!");
}
现在,我们可以多次调用这个方法来问候每个朋友:
GreetFriend(friend1);
GreetFriend(friend2);
GreetFriend(friend3);
完整代码示例如下:
public class Program
{
public static void Main()
{
string friend1 = "Frank";
string friend2 = "Michael";
string friend3 = "Vlad";
GreetFriend(friend1);
GreetFriend(friend2);
GreetFriend(friend3);
}
public static void GreetFriend(string friendName)
{
Console.WriteLine("Hi, " + friendName + ", my friend!");
}
}
当然,你也可以选择使用一个方法来问候所有朋友,通过修改方法以接受多个参数。例如:
public static void GreetFriends(string friend1, string friend2, string friend3)
{
Console.WriteLine("Hi, " + friend1 + ", my friend!");
Console.WriteLine("Hi, " + friend2 + ", my friend!");
Console.WriteLine("Hi, " + friend3 + ", my friend!");
}
然后,你只需一次调用这个方法:
GreetFriends(friend1, friend2, friend3);
这两种方法都能达到相同的效果,具体使用哪种取决于你的编码风格。希望你能自由地尝试其他方法,创建一些新的方法并进行实验,因为在接下来的内容中,我们将频繁使用这些方法。期待在下一个视频中见到你!
欢迎回来!现在你已经掌握了如何创建方法,我们将探讨如何使用用户输入来运行这些方法。接下来,我们将学习如何获取用户输入,并利用这些输入来进行简单的计算。
首先,我们需要创建一个字符串变量来存储用户输入,代码如下:
string input = Console.ReadLine();
这个方法会等待用户输入,并将输入内容存储在 input
变量中。为了让用户知道他们需要输入什么,我们可以使用 Console.WriteLine
提示用户。
接下来,让我们创建一个简单的加法计算器,允许用户输入两个数字并计算它们的和。我们将定义一个方法 Calculate
,代码示例如下:
public static int Calculate()
{
Console.WriteLine("Please enter the first number:");
string number1Input = Console.ReadLine();
Console.WriteLine("Please enter the second number:");
string number2Input = Console.ReadLine();
// 将字符串转换为整数
int num1 = int.Parse(number1Input);
int num2 = int.Parse(number2Input);
// 计算结果
int result = num1 + num2;
return result;
}
在 Main
方法中,我们可以调用这个计算方法并显示结果:
public static void Main()
{
int sum = Calculate();
Console.WriteLine("The result is: " + sum);
}
以下是完整的代码示例,展示如何将上述部分结合在一起:
using System;
public class Program
{
public static void Main()
{
int sum = Calculate();
Console.WriteLine("The result is: " + sum);
}
public static int Calculate()
{
Console.WriteLine("Please enter the first number:");
string number1Input = Console.ReadLine();
Console.WriteLine("Please enter the second number:");
string number2Input = Console.ReadLine();
// 将字符串转换为整数
int num1 = int.Parse(number1Input);
int num2 = int.Parse(number2Input);
// 计算结果
int result = num1 + num2;
return result;
}
}
在此代码中,我们没有处理输入格式错误。如果用户输入非数字字符,程序会崩溃。为了避免这种情况,我们可以稍后学习使用 try-catch
块来捕获异常。此时,请确保用户输入有效数字以进行测试。
通过以上步骤,你可以创建一个简单的用户输入加法器。练习这些步骤,确保你熟悉如何获取用户输入并将其用于方法调用。期待在下一个视频中见到你!
欢迎回来。在本视频中,您将学习如何捕捉错误,以及在发生错误时如何处理,而不是让程序崩溃。try
、catch
和 finally
块的常见用法是,在 try
块中获取并使用资源,在 catch
块中处理异常情况,在 finally
块中释放资源。接下来,我们将详细探讨这些内容。
Try 块:
try
块中。如果发生错误,控制权将转移到 catch
块。Catch 块:
catch
块用于处理错误。您可以指定要捕获的异常类型(例如 FormatException
、OverflowException
)或捕获一般异常。try {
// 可能抛出异常的代码
}
catch (FormatException e) {
Console.WriteLine("请输入有效的数字。");
}
Finally 块:
finally
块在 try
和 catch
块之后执行,无论是否抛出异常。这在清理资源时非常有用,例如关闭文件流或网络连接。finally {
// 无论成功与否都会执行的代码
}
让我们从一个简单的示例开始,我们提示用户输入一个数字,解析它,并处理任何错误。
Console.WriteLine("请输入一个数字:");
string userInput = Console.ReadLine();
try {
int number = int.Parse(userInput);
Console.WriteLine("您输入的数字是:" + number);
}
catch (FormatException) {
Console.WriteLine("那不是有效的数字。");
}
catch (OverflowException) {
Console.WriteLine("数字太大或太小。");
}
finally {
Console.WriteLine("感谢您使用数字输入程序。");
}
现在,让我们处理除以零的挑战。下面是您可以实现此功能的代码:
int num1 = 5;
int num2 = 0;
try {
int result = num1 / num2;
Console.WriteLine("结果是:" + result);
}
catch (DivideByZeroException) {
Console.WriteLine("您不能除以零!");
}
要了解它的工作原理,您可以先运行没有 try
块的除法代码。您将遇到 DivideByZeroException
,然后可以使用 try-catch
结构进行处理。
您的任务是:
DivideByZeroException
。FormatException
,以防用户输入不是数字的内容。这个练习将帮助您巩固对 C# 中错误处理的理解。祝您好运,期待在下一个视频中见到您!
欢迎回来。在本视频中,我们将讨论运算符,这些小东西对我们的程序有很大的影响,所以我们赶紧开始吧。我们将创建两个有值的变量和一个目前为空的变量。为了简单起见,我将用数字一、二和三。第一个变量的值是5,第二个是3,第三个暂时为空。
首先,我们来看一元运算符。我将把-number1
的值存储到number3
中。这样做会将number1
乘以-1。如果我们运行这个代码:
number3 = -number1;
Console.WriteLine($"num3 is {number3}");
这样做后,number3
的值就是-5。正如您所看到的,它将number1
乘以-1。接下来,我们可以将number1
设置为-5,然后检查结果,这样number1
就会变成正数。
接下来,我们使用逻辑运算符。假设我们有一个布尔值isSunny
,设为true
。我们可以使用!
来进行取反操作:
bool isSunny = true;
Console.WriteLine($"Is it sunny? {isSunny}");
Console.WriteLine($"Is it not sunny? {!isSunny}");
这将显示Is it sunny? True
和Is it not sunny? False
。
接下来,我们来看看增量运算符。我将创建一个新的变量,初始化为0,然后使用++
运算符来递增它:
int num = 0;
num++;
Console.WriteLine($"NUM is {num}"); // 结果是1
如果我们在同一行中使用它并打印,递增操作将在下一行生效。
减量运算符与增量运算符类似,只需使用--
:
num--;
Console.WriteLine($"NUM is {num}"); // 结果是0
现在让我们看一下加法和乘法运算符:
int result = number1 + number2; // 5 + 3 = 8
Console.WriteLine($"Result of num1 + num2 is {result}");
result = number1 - number2; // 5 - 3 = 2
Console.WriteLine($"Result of num1 - num2 is {result}");
result = number1 * number2; // 5 * 3 = 15
Console.WriteLine($"Result of num1 * num2 is {result}");
result = number1 / number2; // 5 / 3 = 1(整数除法)
Console.WriteLine($"Result of num1 / num2 is {result}");
整数除法只返回商,而舍弃小数部分。如果使用double
类型,则会得到更精确的值。
取余运算符%
返回整除后的余数:
int remainder = number1 % number2; // 5 % 3 = 2
Console.WriteLine($"Remainder of num1 % num2 is {remainder}");
我们创建一个布尔变量isLower
,检查number1
是否小于number2
:
bool isLower = number1 < number2; // false
Console.WriteLine($"Result of number1 < number2 is {isLower}");
同样,可以使用等于运算符==
进行比较:
bool isEqual = number1 == number2; // false
Console.WriteLine($"Result of number1 == number2 is {isEqual}");
最后,我们来看逻辑运算符&&
(与)和||
(或):
bool isSunny = true;
bool isLowerAndSunny = isLower && isSunny; // 检查两个条件是否都为true
Console.WriteLine($"Result of isLower and isSunny is {isLowerAndSunny}");
如果我们使用||
,只要其中一个条件为真,结果就为真:
bool isLowerOrSunny = isLower || isSunny; // 只需其中一个为真
Console.WriteLine($"Result of isLower or isSunny is {isLowerOrSunny}");
这段视频涵盖了各种运算符,包括一元运算符、增量和减量运算符、算术运算符、取余运算符以及关系和逻辑运算符。虽然没有具体的练习,但理解这些基础知识对今后的编程非常重要。如果您对某个运算符不太确定,可以随时回头查看本视频并尝试理解每一步的代码。
希望您喜欢本视频,下一个视频我们将进行更短的内容,期待与您再次见面!
在这一章节中,你学习了方法的工作原理以及不同类型的方法。以下是你所掌握的内容:
你还学习了如何使用 try 和 catch 来捕捉可能出现的错误,例如在处理用户输入时。
你也了解了操作符的用法,包括如何进行数学计算和逻辑判断。
希望你完成了这一章节的所有练习,因为这对学习至关重要。通过练习,你会发现问题并寻找解决方案,而这些解决方案会帮助你在未来更好地记忆和理解所学内容。每次成功解决问题,都是你作为开发者成长的一部分。
感谢你继续与我学习!希望你对下一章节充满期待,期待在下一个章节见到你!
在现实生活中,做决策非常重要。在编程中也是如此。你需要编写一些程序,这些程序必须根据条件做出决策。你决定程序将做出哪些决策,以及这些决策的最终结果。
在本章中,我们将学习如何使用 if 语句 来实现这一点。学习完之后,你将能够编写一个根据条件执行代码的程序。
在本章结束时,你将进行相关练习,以巩固所学的内容。
让我们直接开始吧!希望你能享受这一章的学习过程。
有些人说,生活中一切都与决策有关。对于我来说,编程也是如此。我们将要学习 if 语句,它们帮助我们根据决策来执行代码。
一个基本的 if 语句包括一个条件,通常在大括号中定义要执行的代码。比如,我们可以检查天气是否好。如果天气好,我们就出去;如果天气不好,我们就待在里面。条件可以是“天气好吗?”或“阳光明媚吗?”这样简单的比较。
我们还可以使用 else 语句 来处理未满足的条件:
此外,还可以使用 else if 语句:
接下来,我们将通过一个示例来展示这些概念。在 C# 中,我们可以创建一个整数变量来表示温度:
int temperature = 10; // 设定温度为10摄氏度
if (temperature < 10) {
Console.WriteLine("穿上外套");
} else if (temperature == 10) {
Console.WriteLine("气温为10度C");
} else {
Console.WriteLine("温暖舒适");
}
我们会使用不同的温度值来查看输出。比如,温度设置为6度,将输出“穿上外套”;设置为25度,将输出“温暖舒适”。
现在,挑战一下自己,修改代码以考虑天气,并让用户手动输入温度。你可以提示用户输入温度,并根据输入输出相应的建议,比如:
在本章的最后,我们将学习如何提高代码的效率。当前的方法是顺序执行每个条件,但我们可以优化这一流程。比如,如果第一个条件为真,那么其他条件就不需要检查了。
使用 else if 结构可以使代码更加简洁和高效,因为它确保了只有一个条件会被满足并执行对应的代码。
if (temperature < 20) {
Console.WriteLine("穿上外套");
} else if (temperature == 20) {
Console.WriteLine("穿长裤和毛衣");
} else {
Console.WriteLine("今天只需要短裤");
}
通过这些示例和结构,你将更好地理解如何在编程中做出决策。这些基本的控制流语句是构建复杂程序的重要组成部分。接下来,我们将继续深入学习和练习这些内容,帮助你成为一名更有效的开发者。
在这一视频中,我们将探讨 TryParse 方法,它允许我们将字符串转换为数字数据类型。这在多个场合非常有用,我们将举一个例子。
TryParse 方法可以将字符串(例如 "128")转换为整数。在这里,重要的是字符串必须用引号括起来,因为 "128" 是文本而非数字。数据类型在 C# 中非常重要。
通常,TryParse 方法用于处理用户输入。例如,用户输入一个名称,但输入的数据是字符串,所以我们需要将其转换为数字,以便进行进一步处理。
以下是一个示例,我们在主方法中定义一个字符串变量 numberAsString,其值为 "128"。我们需要将其解析为整数:
string numberAsString = "128"; // 定义字符串
int parsedValue; // 创建变量来存储解析值
bool success = int.TryParse(numberAsString, out parsedValue); // 尝试解析字符串
在上面的代码中,int.TryParse
方法返回一个布尔值,指示解析是否成功。如果成功,parsedValue
将存储转换后的整数;如果失败,success
将为 false。
我们可以使用解析的结果进行输出。例如:
if (success) {
Console.WriteLine($"Parsing successful! Number is: {parsedValue}");
} else {
Console.WriteLine("Parsing failed.");
}
在这里,如果解析成功,将打印解析的值;否则,打印解析失败的信息。
你还可以使用 TryParse 方法解析其他数字数据类型,例如 float 或 double。示例如下:
string floatAsString = "128.75";
float floatParsedValue;
bool floatSuccess = float.TryParse(floatAsString, out floatParsedValue);
与 int.TryParse 类似,float.TryParse
方法也会返回一个布尔值,指示解析是否成功。
让我们来看一些解析失败的示例:
接下来,我们将进入一个实际示例,以查看 TryParse 方法的使用效果。感谢观看,我们马上开始!
现在我们已经了解了如何使用 if 语句,我们还可以确保代码运行顺利。当用户输入不正确的值(例如字母)时,代码可能会崩溃。为了解决这个问题,我们可以使用 TryParse 方法替代 Parse。
我将使用 int.TryParse
方法来解析温度,并创建一个新的整数变量 number:
int number;
bool success = int.TryParse(temperatureInput, out number);
如果解析成功,我将 numTemp 设置为 number。这样,如果解析失败,numTemp 仍然会有一个值。为了处理未输入正确值的情况,如果解析失败,我会将 numTemp 设置为 0。
在代码中,我还会告知用户未输入正确值。示例如下:
if (!success) {
numTemp = 0; // 设置为 0
Console.WriteLine("Value entered was no number. Zero set as temperature.");
}
这样,如果用户输入了无效值,例如非数字字符,程序将不会崩溃,而是给出提示并将温度设置为 0。
到目前为止,我们使用了 Parse,它简单明了,但若用户输入错误,程序会崩溃。通过使用 TryParse,我们可以安全地处理用户输入,避免程序崩溃。
在下一个视频中,我们将学习更复杂和嵌套的 if 语句。期待与大家再见!
欢迎回来。在这个视频中,我们将创建一个简单的登录系统示例,主要使用 if 语句和逻辑判断。我将为您展示一个类似于登录系统的示例。
首先,我们定义一些变量:
接下来,我们构建一个程序,检查用户是否已注册。如果用户已注册,我们将输出一条消息:
if (isRegistered) {
Console.WriteLine("Hi there, registered user.");
if (!string.IsNullOrEmpty(username)) {
Console.WriteLine("Hi there, " + username);
if (username.Equals("admin", StringComparison.OrdinalIgnoreCase)) {
Console.WriteLine("Hi there, admin.");
}
}
}
用户输入用户名:
Console.WriteLine("Please enter your username:");
username = Console.ReadLine();
在获取用户输入后,我们使用 if 语句来检查用户名是否为空,并根据条件输出不同的消息:
if (isRegistered) {
if (string.IsNullOrEmpty(username)) {
Console.WriteLine("Hi there, registered user.");
} else {
Console.WriteLine("Hi there, " + username);
if (username.Equals("admin", StringComparison.OrdinalIgnoreCase)) {
Console.WriteLine("Hi there, admin.");
}
}
}
我们可以将条件判断进行简化,避免多层嵌套的 if 语句。可以将多个条件合并到一个 if 语句中:
if (isRegistered && !string.IsNullOrEmpty(username)) {
Console.WriteLine("Hi there, " + username);
if (username.Equals("admin", StringComparison.OrdinalIgnoreCase)) {
Console.WriteLine("Hi there, admin.");
}
} else {
Console.WriteLine("Hi there, registered user.");
}
我们还可以使用逻辑运算符 ||(或)来检查多个条件。例如,如果用户是管理员或已注册:
if (isAdmin || isRegistered) {
Console.WriteLine("You are logged in.");
}
在本视频中,我们学习了如何使用 if 语句和嵌套的条件判断来构建一个简单的登录系统示例。下一个视频中,我们将进行一个小挑战,请尝试解决它。期待与您再见!
欢迎回来。在本视频中,我们将解决上节课的挑战,希望您已经尝试过并找到了解决方案。现在,我们来创建一个登录系统和注册系统。
首先,我们需要创建一个注册方法。我们将其定义为静态方法,以便可以在主方法中调用它。
public static void Register() {
Console.WriteLine("Please enter your username:");
static string username = Console.ReadLine();
Console.WriteLine("Please enter your password:");
static string password = Console.ReadLine();
Console.WriteLine("Registration completed.");
Console.WriteLine("--------------------");
}
在此代码中,我们首先要求用户输入用户名,然后输入密码。注册完成后,我们向用户确认注册已成功。
接下来,我们创建一个登录方法,结构与注册方法相似:
public static void Login() {
Console.WriteLine("Please enter your username:");
string enteredUsername = Console.ReadLine();
if (enteredUsername == username) {
Console.WriteLine("Please enter your password:");
string enteredPassword = Console.ReadLine();
if (enteredPassword == password) {
Console.WriteLine("Login successful.");
} else {
Console.WriteLine("Login failed. Wrong password. Restart the program.");
}
} else {
Console.WriteLine("Login failed. Wrong username. Restart the program.");
}
}
现在,我们可以在主方法中调用这两个方法:
static void Main(string[] args) {
Register();
Login();
}
整合以上代码,完整示例如下:
using System;
class Program {
static string username;
static string password;
public static void Register() {
Console.WriteLine("Please enter your username:");
username = Console.ReadLine();
Console.WriteLine("Please enter your password:");
password = Console.ReadLine();
Console.WriteLine("Registration completed.");
Console.WriteLine("--------------------");
}
public static void Login() {
Console.WriteLine("Please enter your username:");
string enteredUsername = Console.ReadLine();
if (enteredUsername == username) {
Console.WriteLine("Please enter your password:");
string enteredPassword = Console.ReadLine();
if (enteredPassword == password) {
Console.WriteLine("Login successful.");
} else {
Console.WriteLine("Login failed. Wrong password. Restart the program.");
}
} else {
Console.WriteLine("Login failed. Wrong username. Restart the program.");
}
}
static void Main(string[] args) {
Register();
Login();
}
}
通过这个简单的代码示例,我们创建了一个基本的注册和登录系统。虽然当前实现只在运行时存储数据,但未来我们将学习如何持久化存储数据。
希望您能成功构建一个类似的系统。如果您没有成功,不要担心,给自己一些时间,明天再尝试。期待在下一个视频中见到您!
欢迎回来。在本视频中,我们将讨论 switch
和 case
语句,这些语句与 if
语句类似。接下来,我们将创建一个 switch case
的示例。
首先,我们定义一个用于比较的变量。例如,我们可以使用一个整型变量来表示年龄。
int age = 25; // 假设我们设定年龄为25
接下来,我们使用 switch
语句来比较这个变量:
switch (age) {
case 15:
Console.WriteLine("Too young to party in the club.");
break;
case 25:
Console.WriteLine("Good to go.");
break;
default:
Console.WriteLine("How old are you then?");
break;
}
在这个例子中,我们检查 age
的值,并根据不同的情况打印不同的消息。每个 case
后面都需要一个 break
语句,以防止代码继续执行下一个 case
。
default
语句是一个可选部分,当没有其他情况匹配时,它将执行。它类似于 if
语句中的 else
部分。
假设我们将 age
设定为 25,运行代码后,控制台将显示:
Good to go.
如果我们将 age
更改为 19,并再次运行代码,控制台将输出:
How old are you then?
现在,作为一个小挑战,请尝试将上面的 switch case
逻辑转化为 if
语句:
if (age == 15) {
Console.WriteLine("Too young to party in the club.");
} else if (age == 25) {
Console.WriteLine("Good to go.");
} else {
Console.WriteLine("How old are you then?");
}
如您所见,if
语句也能实现相同的逻辑,但在某些情况下,switch case
语句更加直观和易于管理。
接下来,我们快速看一下如何使用字符串进行 switch
语句。假设我们有一个字符串变量表示用户名:
string username = "Dennis";
我们可以使用 switch
来检查用户名:
switch (username) {
case "Dennis":
Console.WriteLine("Username is Dennis.");
break;
case "root":
Console.WriteLine("Username is root.");
break;
default:
Console.WriteLine("Username is unknown.");
break;
}
如果我们将 username
更改为 "Frank" 并运行代码,控制台将输出:
Username is unknown.
这表明 switch case
不仅可以用于整数,也可以用于字符串和其他数据类型。
在本视频中,我们介绍了 switch
和 case
语句,并展示了如何将其应用于整数和字符串的比较。希望您能理解这些语句的用法,并在编程时根据需要选择使用 if
语句或 switch case
语句。下一个视频见!
欢迎回来,今天我们将讨论第二个 if
语句挑战——高分挑战。希望你们都能解决这个问题并完成代码编写。在本视频中,我将展示我解决这个问题的方法。通常,这个挑战可以有多种不同的解决方式。
首先,我们需要定义一些变量:
static int highScore = 300; // 旧的高分是300
static string highScorePlayer = "我"; // 记录高分的玩家
接下来,我们创建一个方法来检查玩家的新得分是否高于当前高分:
public static void CheckHighScore(int score, string playerName) {
if (score > highScore) {
highScore = score; // 更新高分
highScorePlayer = playerName; // 更新高分玩家
// 通知控制台
Console.WriteLine($"New High Score is {score}.");
Console.WriteLine($"It is now held by {playerName}.");
} else {
// 旧高分未被打破
Console.WriteLine($"The old high score could not be broken. It is still {highScore}, held by {highScorePlayer}.");
}
}
现在,我们可以调用这个方法来检查玩家的得分是否打破了高分。以下是一些示例:
CheckHighScore(250, "Maria"); // 250分由Maria获得
CheckHighScore(315, "Michael"); // 315分由Michael获得
CheckHighScore(350, "Dennis"); // Dennis打破了自己的记录
为了在控制台上看到输出,我们需要确保在代码的末尾加上 Console.ReadLine()
,这样可以防止控制台窗口立即关闭。
以下是完整的代码示例:
using System;
class Program {
static int highScore = 300;
static string highScorePlayer = "我";
public static void CheckHighScore(int score, string playerName) {
if (score > highScore) {
highScore = score;
highScorePlayer = playerName;
Console.WriteLine($"New High Score is {score}.");
Console.WriteLine($"It is now held by {playerName}.");
} else {
Console.WriteLine($"The old high score could not be broken. It is still {highScore}, held by {highScorePlayer}.");
}
}
static void Main(string[] args) {
CheckHighScore(250, "Maria");
CheckHighScore(315, "Michael");
CheckHighScore(350, "Dennis");
Console.ReadLine(); // 等待输入以查看输出
}
}
在这个视频中,我们展示了如何创建一个功能来检查高分是否被打破。通过使用 if
和 else
语句,我们能够更新高分和记录高分的玩家。这是一个简单但有效的机制,可以在更复杂的游戏中轻松扩展。希望你能自己完成这个挑战,期待在下一个视频中见到你!
欢迎回来!在本视频中,我将向你展示一种简化 if
语句的快捷方式,这就是增强型 if
语句。它们可以将整个 if
语句简化为一行代码,这非常酷!我们通常的结构是条件,接着是问号,第一表达式,冒号,第二表达式,最后以分号结束。这就是如何创建增强型 if
语句的基本方式。
首先,我们需要定义一些变量:
int temperature = -5; // 温度设定为-5度
string stateOfMatter = ""; // 状态变量初始化为空
if
语句接下来,如果温度低于零,我希望状态为固态;否则,状态为液态:
if (temperature < 0) {
stateOfMatter = "固态"; // 低于0度时为固态
} else {
stateOfMatter = "液态"; // 0度时为液态
}
然后输出状态到控制台:
Console.WriteLine($"状态是:{stateOfMatter}");
if
语句现在,我们可以使用增强型 if
语句来简化代码:
stateOfMatter = temperature < 0 ? "固态" : "液态"; // 一行代码处理
运行代码,验证状态。假设将温度从-5度提高到25度,代码如下:
temperature += 30; // 增加30度
stateOfMatter = temperature < 0 ? "固态" : "液态"; // 更新状态
Console.WriteLine($"状态是:{stateOfMatter}");
现在的挑战是添加气态状态。当温度高于100度时,状态应为气态。我们将使用增强型 if
语句来实现这一点:
stateOfMatter = temperature > 100 ? "气态" : (temperature < 0 ? "固态" : "液态");
在这个例子中,如果温度设定为130度,运行后的输出应为气态。更新温度的代码如下:
temperature += 100; // 增加100度
stateOfMatter = temperature > 100 ? "气态" : (temperature < 0 ? "固态" : "液态");
Console.WriteLine($"状态是:{stateOfMatter}");
在本视频中,我们展示了如何使用增强型 if
语句来简化代码逻辑。通过将多行 if
语句压缩为一行,我们可以使代码更加简洁易读。希望你能尝试完成这个挑战,并掌握增强型 if
语句的用法!
在本视频中,我将向你展示我对三元运算符挑战的解决方案。当然,你的实现方式可能会有所不同。让我们开始吧!
首先,我们需要定义输入温度、温度信息和输入值的变量。这里我使用 string.Empty
,虽然可以使用空字符串,但使用 string.Empty
提供了更多处理字符串的机会。
int inputTemperature;
string temperatureMessage = string.Empty; // 温度信息初始化为空
接下来,我们需要从控制台获取用户输入,并验证用户输入的值是否是有效的整数。我们将使用 TryParse
方法进行验证:
Console.WriteLine("请输入当前温度:");
bool isValidInteger = int.TryParse(Console.ReadLine(), out inputTemperature);
if
语句和三元运算符如果输入值是有效的整数,我们将使用 if
和 else
语句来处理温度消息。在实际的温度判断中,我们将使用三元运算符:
if (isValidInteger) {
temperatureMessage = inputTemperature <= 15 ? "这里太冷了。" :
(inputTemperature >= 16 && inputTemperature <= 28 ? "这里很冷。" :
(inputTemperature > 28 ? "这里很热。" : ""));
} else {
temperatureMessage = "无效的温度。";
}
inputTemperature
是否小于或等于15。
最后,我们输出温度信息到控制台:
Console.WriteLine(temperatureMessage);
让我们运行代码,看看它是否按预期工作。假设输入25度,程序应返回 "这里很冷。",因为根据设定,25度被认为是较凉的温度。
三元运算符的使用使得代码更简洁,同时也保持了清晰的逻辑结构。温度的感知是相对的,因此你可以根据个人的舒适度调整阈值。希望你能够正确完成这个挑战,期待在下一个视频中见到你!
到目前为止,你已经完成了关于决策的章节,包括 if
、else
、switch
和 case
等内容。现在你知道如何使用这些关键字来编写程序,根据传入的值或变量的条件运行代码。
在这个阶段,你已经能够编写更高级的程序,但我们还有很多内容要学习。我希望你享受这个章节,感到自己对使用条件语句、if
语句以及 switch
语句更加自信。
即使你目前还不完全理解这些概念,也不用担心。你将在整个课程中多次遇到这些关键字,逐渐掌握它们。学习编程语言就像学习一种自然语言:你会在不同的上下文中遇到同一个词,逐渐理解其含义。随着你在更多上下文中使用这些概念,它们会变得更加清晰。
接下来,我们将进入更高级的编程技巧,并继续在课程中使用 if
、else
以及 switch
语句。期待在下一个视频中见到你!
在这一章节中,你将学习如何使用不同类型的循环。实际上,有四种主要的循环:for
循环、while
循环、do while
循环和 foreach
循环。每种循环都有其独特的优势。
这些循环在许多场景中是可以互换的,但它们的使用场景各有不同。你将学习如何使用这些循环以及它们的具体用途。这使你能够编写能够多次迭代的程序,从而重复执行某段代码。
就像你之前学习的其他关键字一样,越多地在不同的上下文中看到这些循环,它们的含义和用法就会变得越清晰。接下来,我们将直接进入具体内容。
期待在下一个视频中见到你!
在本章中,我们将讨论不同类型的循环,并学习如何在实际中使用它们。首先,我们来看看理论部分,了解各种循环的优势。
节省时间
循环可以让你简化代码,而无需创建大量的重复代码。这可以帮助你节省编写和维护代码的时间。
快速且易于重复
循环允许你根据设定的条件轻松地多次重复代码。例如,如果你想运行某段代码十次,可以简单地使用循环来实现。
处理大量数据
通过循环,你无需手动检查数据,可以自动化这个过程。这在处理大数据集时尤为重要。
遍历数组
循环可以用于遍历数组或列表,尽管我们在这一章还没有涉及数组,但在后面的章节中会详细讨论。
我们将学习几种主要的循环类型:
for
循环
for
循环有一个起始值、条件和增量,所有这些都用分号分隔。在花括号中包含需要重复的代码块。这个循环非常适合计数器的使用。
while
循环
你可以先设置一个计数变量(例如,初始值为零)。接着使用 while
关键字和条件,例如:只要计数变量小于十,就继续执行循环。非常重要的一点是,必须在循环内递增计数变量,否则会导致无限循环,这会使程序崩溃。
do while
循环
虽然名字中有 while
,但 do while
循环先执行代码块,然后再检查条件。这样保证了代码至少执行一次。这在你不确定条件是否满足但需要初始化某些内容时特别有用。
foreach
循环
foreach
循环用于遍历数组或列表,只要数组中有内容就会继续执行。这可以避免使用常规 for
循环时可能出现的数组索引越界异常。虽然我们在这一章不会详细讲解 foreach
循环,但它将在数组章节中介绍。
现在,我们将进入示例演示部分,开始一些实际的代码示例。期待在接下来的部分见到你!
在本视频中,我们将详细了解 for
循环的用法。接下来,我将通过一个示例来演示如何使用 for
循环。
for
循环的基本结构首先,使用 for
关键字,接着在括号内需要包含三个部分:
初始化计数器
例如,我们可以使用一个整数变量作为计数器,通常用 i
来表示。例如,int i = 0;
表示计数器从 0 开始。
条件检查
例如,i < 10
,这意味着只要计数器小于 10,就会继续执行循环。
增量
使用 i++
来每次循环结束时将计数器加 1。
for (int i = 0; i < 10; i++)
{
Console.WriteLine(i);
}
运行上述代码,你将看到输出为 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
。循环从 0 开始,每次增加 1,直到 9。
接下来,让我们看一个从 0 到 50 的循环,每次增加 5:
for (int i = 0; i < 50; i += 5)
{
Console.WriteLine(i);
}
运行此代码,输出为 0, 5, 10, 15, 20, 25, 30, 35, 40, 45
。这个循环从 0 开始,增加 5,直到不再满足条件。
为了验证循环是否结束,可以在循环结束后添加一条输出:
Console.WriteLine("for loop is done");
现在给你一个小挑战:请编写一个 for
循环,打印出 0 到 20 之间的所有奇数。你可以这样做:
for (int i = 1; i < 20; i += 2)
{
Console.WriteLine(i);
}
这样,你将看到输出为 1, 3, 5, 7, 9, 11, 13, 15, 17, 19
。这就是如何使用 for
循环来生成奇数。
在下一个视频中,我们将研究 do while
循环。期待与你在下一节中见面!
do while
循环简介在本视频中,我们将研究 do while
循环。与其他循环不同的是,do while
循环先执行代码,然后再检查条件。它也被称为“先执行后判断”循环。
do while
循环首先,我们需要一个初始计数器变量,命名为 counter
。然后使用 do
关键字,并在花括号内写入代码主体。以下是一个基本示例:
int counter = 0;
do
{
Console.WriteLine(counter);
counter++;
} while (counter < 5);
如果运行上述代码,输出将是 0, 1, 2, 3, 4
。这个循环与之前看到的 for
循环相似,但有一个关键的区别。
如果将 counter
设置为 15 并运行代码:
int counter = 15;
do
{
Console.WriteLine(counter);
counter++;
} while (counter < 5);
输出仍然会是 15
。这是因为代码会先执行一次,然后再检查条件。在这种情况下,条件 15 < 5
不成立,所以不会再执行。
如果忘记在 do
循环中进行计数器的递增,将会导致无限循环。例如:
int counter = 0;
do
{
Console.WriteLine(counter);
} while (counter < 5);
这段代码将无限打印 0
,导致程序崩溃。因此,务必要确保每次循环都有递增,以便条件在某个时刻能够为假。
do while
循环的实际例子接下来,我们来创建一个程序,根据用户输入执行代码。我们将计算用户输入的字符长度,并在达到特定长度时结束输入。
int lengthOfText = 0;
string wholeText = "";
do
{
Console.WriteLine("请输入朋友的名字:");
string nameOfFriend = Console.ReadLine();
int currentLength = nameOfFriend.Length;
lengthOfText += currentLength;
wholeText += nameOfFriend + " ";
} while (lengthOfText <= 20);
Console.WriteLine($"谢谢,你的输入是:{wholeText}");
假设用户依次输入 “Michael”, “Sissy”, “Frank”, “Maria”,程序将输出:
谢谢,你的输入是:Michael Sissy Frank Maria
这个示例演示了如何使用 do while
循环来处理用户输入,直到输入的字符长度超过 20 个字符。
通过 do while
循环,我们可以在确认用户输入之前执行代码。这种结构非常适合需要至少执行一次代码的情况。
在下一个视频中,我们将学习 while
循环。期待在下一节中见到你!
while
循环简介在本视频中,我们将研究 while
循环的用法。首先,我们来了解一下语法。我们从一个计数器开始,将其设置为零。接下来使用 while
关键字,后面跟上条件。在本例中,我们将条件设为 counter < 10
,如果条件为真,则执行 while
循环内的代码。
以下是一个简单的 while
循环示例:
int counter = 0;
while (counter < 10)
{
Console.WriteLine(counter);
counter++;
}
Console.Read();
运行上述代码后,控制台将输出从 0
到 9
的数字。与 do while
循环不同,while
循环在执行任何代码之前会先检查条件。这意味着 while
循环在执行之前需要确认条件是否为真。
现在给你一个小挑战:创建一个程序,当用户按下回车键时,计数器增加。程序会显示当前计数的数字。假设你是一位老师,需要确保所有学生都上了公交车。
以下是如何实现这个人数计数器的代码:
int counter = 0;
string enteredText = "";
while (true)
{
Console.WriteLine("请按回车以增加人数,输入其他内容以结束计数。");
enteredText = Console.ReadLine();
if (enteredText == "")
{
counter++;
Console.WriteLine($"当前人数:{counter}");
}
else
{
break;
}
}
Console.WriteLine($"{counter} 人在公交车上。按回车键关闭程序。");
Console.Read();
运行程序后,用户按下回车键以增加计数:
请按回车以增加人数,输入其他内容以结束计数。
(按回车)
当前人数:1
(按回车)
当前人数:2
(输入其他内容,比如 K)
2 人在公交车上。按回车键关闭程序。
通过本视频,你了解了 while
循环的基本结构,以及如何使用它来创建简单的程序,例如人数计数器。接下来,你可以使用用户输入来实现更复杂的功能。
在下一个视频中,我们将面对一个新的挑战,期待与你在下次见面!
break
和 continue
语句介绍欢迎回来!在本视频中,我们将讨论 break
和 continue
语句。在我们进入下一个挑战之前,了解这两个概念是很重要的。现在,让我们创建一个小程序来演示它们的用法。
for
循环首先,我们来创建一个 for
循环,从 0
到 9
。代码如下:
for (int counter = 0; counter < 10; counter++)
{
Console.WriteLine(counter);
if (counter == 3)
{
Console.WriteLine("在三的时候停止。");
break; // 退出循环
}
}
Console.Read();
运行这段代码,我们会看到输出:
0
1
2
在三的时候停止。
在这里,循环从 0
开始,直到 9
。当计数器 counter
达到 3
时,输出相应的消息,然后执行 break
语句,这将使程序退出 for
循环。之后的 4, 5, 6, 7, 8, 9
不再被执行。
continue
语句介绍接下来,我们来看一下 continue
语句。我们将把它加入到上面的代码中,跳过特定的数字。例如,我们要跳过 3
:
for (int counter = 0; counter < 10; counter++)
{
if (counter == 3)
{
Console.WriteLine("跳过数字三。");
continue; // 跳过当前迭代
}
Console.WriteLine(counter);
}
Console.Read();
运行这段代码,我们会看到输出:
0
1
2
跳过数字三。
4
5
6
7
8
9
在这个例子中,当计数器为 3
时,continue
语句将使程序跳过当前迭代,直接进入下一个循环,因而不会输出 3
。
modulo
操作符我们还可以结合 continue
语句和 modulo
操作符,跳过偶数并打印出奇数。例如:
for (int counter = 0; counter < 10; counter++)
{
if (counter % 2 == 0)
{
continue; // 跳过偶数
}
Console.WriteLine($"这是一个奇数:{counter}");
}
Console.Read();
运行这段代码,我们会看到输出:
这是一个奇数:1
这是一个奇数:3
这是一个奇数:5
这是一个奇数:7
这是一个奇数:9
在这里,所有的偶数都被跳过,只打印出奇数。
通过本视频,你了解了 break
和 continue
语句的基本用法。break
用于退出循环,而 continue
用于跳过当前迭代。在接下来的课程中,我们将利用这些概念进行更多的挑战。
在下一个视频中,你将会看到我所承诺的挑战,期待与你再见!
欢迎回来!希望你已经找到了挑战的解决方案。现在,让我们来看一下我将如何实现这个程序。当然,挑战有多种解决方案,我将展示其中一种。如果你还没有完成,不用担心,我会在本视频中为你演示。
首先,我们需要定义几个输入变量:
int input = 0;
int count = 0; // 记录输入的数量
int total = 0; // 总分数
int currentNumber = 0; // 当前分数
这四个变量是我们程序中重要的部分。我们需要检查用户(或教师)输入的值是否为数字。如果是数字,我们将增加总分、计数,并将输入解析为数字。
while
循环接下来,我们创建一个 while
循环,以便在输入不等于 -1
时持续运行:
while (input != -1)
{
Console.WriteLine($"上一个分数是:{currentNumber}");
Console.WriteLine($"当前输入数量是:{count}");
Console.WriteLine("请输入下一个分数(输入 -1 结束):");
// 获取用户输入
string inputString = Console.ReadLine();
接着,我们检查用户输入的值:
if (inputString == "-1")
{
// 计算平均分数
break; // 退出循环
}
// 尝试解析输入为数字
if (int.TryParse(inputString, out currentNumber) && currentNumber > 0 && currentNumber <= 20)
{
total += currentNumber; // 更新总分
count++; // 更新输入数量
}
else
{
Console.WriteLine("请输入一个介于 1 和 20 之间的值。");
}
在用户输入 -1
后,我们将计算平均分数:
if (count > 0)
{
double average = (double)total / count; // 计算平均分
Console.WriteLine($"学生的平均分数是:{average:F2}");
}
else
{
Console.WriteLine("没有输入任何有效分数。");
}
将上述所有代码组合在一起,程序如下:
int input = 0;
int count = 0;
int total = 0;
int currentNumber = 0;
while (input != -1)
{
Console.WriteLine($"上一个分数是:{currentNumber}");
Console.WriteLine($"当前输入数量是:{count}");
Console.WriteLine("请输入下一个分数(输入 -1 结束):");
string inputString = Console.ReadLine();
if (inputString == "-1")
{
break; // 退出循环
}
if (int.TryParse(inputString, out currentNumber) && currentNumber > 0 && currentNumber <= 20)
{
total += currentNumber;
count++;
}
else
{
Console.WriteLine("请输入一个介于 1 和 20 之间的值。");
}
}
if (count > 0)
{
double average = (double)total / count;
Console.WriteLine($"学生的平均分数是:{average:F2}");
}
else
{
Console.WriteLine("没有输入任何有效分数。");
}
Console.ReadLine();
运行程序后,输入一些分数,例如 10
, 15
, 13
等。如果输入一个无效的分数(例如 31
或字符),程序会提示你输入有效的值。当你输入 -1
时,程序将计算并显示平均分。
通过这个程序,你可以看到如何使用我们到目前为止学到的知识来创建一个简单的平均分数计算器。我们仍在使用控制台,但将在后续课程中探讨如何使用 WPF 创建更复杂的用户界面。
在下一个视频中,我们将进入下一个主题。希望你能成功解决这个挑战,或者至少完成其中的一部分!请告诉我你的解决方案,这样其他同学和我可以比较并给出反馈。谢谢,期待在下一个视频中见到你!
你已经完成了循环章节!希望你完成了练习并且成功掌握了它们。现在你了解了不同循环的用法,掌握了相应的语法,并且有机会进行了一些实际操作。
你现在已经具备了一些核心编程概念和基本要素,包括:
这些基础知识使你能够开始编写自己的小程序。你已经走了很远,但学习的旅程并未结束。
编程是一个不断学习的过程。即使在多年之后,你仍然会发现新知识和新技能。学习的美妙之处在于总有新的东西可以掌握。即使没有新的知识出现,也总会有更新促使你去学习新的内容。
现在,让我们进入下一个章节——面向对象编程(OOP)。这是C#语言的核心部分,了解面向对象编程将帮助你更深入地掌握这门语言的强大之处。
期待在下一个视频中见到你!
欢迎进入面向对象编程(OOP)章节!这是整个学习过程中的重要章节之一,因为你即将学习C#中的核心概念之一。面向对象编程是一种编程范式,虽然还有其他编程方法,但OOP目前是使用最广泛且效率较高的方法,尤其是在开发复杂项目时表现尤为出色。
OOP的核心思想是通过对象来组织代码。这种方法非常适合于涉及现实生活中的对象的项目。例如,当你开发一个包含用户信息的数据库时,"用户"可以被视为一个对象,而你可以围绕这个用户创建一个类,用来包含特定的用户信息。
OOP能够将现实中的概念直接映射到代码中,使代码与现实更接近,也更容易理解。面向对象编程的优势在于可以帮助你构建更具可维护性、更易扩展和更灵活的代码。
在本章节中,你将学习以下内容:
在接下来的学习中,你会逐步掌握如何创建和使用对象、类以及属性,并将这些知识应用到你的项目中。准备好了吗?让我们一起进入面向对象编程的世界,学习如何创建自己定义的类和对象。
让我们开始吧!
欢迎回来!在本章节中,我们将深入探讨C#中的面向对象编程(OOP)。到目前为止,我们仅在一个类中进行编程,即包含Main
方法的主类,并且没有真正进行过OOP。在本视频中,我会为你简要介绍接下来章节的内容,帮助你理解什么是类,以及如何运用类来编写更结构化的代码。
一个类(Class)是一个对象的蓝图或模板。OOP(面向对象编程)中的“对象”源自类,可以通过类创建多个特定的对象。例如,我们可以定义一个“Car”(汽车)类,然后通过该类创建多个不同的汽车对象。
事实上,我们已经使用过一些类,例如Console
类和String
类等。它们也是我们所定义的特定类的样板。通常来说,类包含以下几个重要组成部分:
Console
类中,我们用过的Read
、ReadLine
、WriteLine
等都是方法。它们定义了Console
类所能执行的操作。String
类有一个属性Length
,用于存储字符串的长度。属性可以用来描述一个对象的不同特征。String
类可以当作数据类型使用,这就是我们创建string
变量的方式。一个对象(Object)是类的实例,或者说是类的具体实现。例如,我们可以将一个Car
类实例化为一个Audi
对象。具体来说:
Audi
对象可以有特定的属性,比如“马力(Horsepower)”、“轮子数量(Number of Wheels)”、“前灯亮度(Lumens)”、“车门数量”等。Car
类可以作为基类,Audi
类继承自Car
,进一步细分为A5
(奥迪A5)车型,每个子类都可以拥有更具体的特性。在下一步,我们将创建属于自己的类,亲自体验如何定义类的属性和方法,构建更复杂的对象。通过实践,你会更深入地理解类的作用和用途。
让我们继续进入下一节,创建第一个属于自己的类吧!
欢迎回来!在本视频中,我们将创建自己的第一个类,它是可以用来创建对象的蓝图。类在 C# 中基本上相当于一种自定义的数据类型。通过定义类,我们可以设定对象的属性和方法,然后基于这个类生成多个对象实例。
在 C# 以及其他面向对象编程语言中,类是创建对象的模板。类定义了一组属性(数据属性)和方法(功能函数),这些是该类的对象会拥有的特征和行为。接下来我们将创建一个类 Car
,并为它添加属性和方法。
Car
类Car
。Car
类。构造函数是一个特殊的方法,它在创建对象时被调用。类的构造函数通常用于初始化对象的属性。C# 会自动为类生成默认构造函数,我们可以自定义它。在 Car
类中,创建一个构造函数并加入一个 Console.WriteLine
来说明对象的创建。
public Car()
{
Console.WriteLine("Car was created");
}
Car
对象在 Program
类的 Main
方法中,我们可以使用 new
关键字来创建 Car
对象。例如:
Car audi = new Car();
audi
的对象,它的数据类型是 Car
。new
关键字会在堆内存(heap)上分配空间,并调用构造函数来初始化对象。Car
类添加方法在 Car
类中,我们可以定义对象的行为,例如一个叫 Drive
的方法,用于表示汽车开始驾驶。
public void Drive()
{
Console.WriteLine("Car is driving");
}
这样,每个 Car
对象都将拥有 Drive
方法。
Car
对象的方法回到 Main
方法中,我们可以通过 audi.Drive()
来调用 Drive
方法,使汽车开始“驾驶”。
audi.Drive();
运行代码后,将看到如下输出:
Car was created
Car is driving
Stop
方法请自己尝试添加一个 Stop
方法,输出“Car stopped”来表示汽车停止。
public void Stop()
{
Console.WriteLine("Car stopped");
}
在 Main
方法中,你可以通过用户输入来控制是否调用 Stop
方法:
Console.WriteLine("Press 1 to stop the car");
string userInput = Console.ReadLine();
if (userInput == "1")
{
audi.Stop();
}
Car
类,这是我们对象的蓝图。new
关键字创建了 Car
的对象。Car
类中定义了 Drive
和 Stop
方法,这些是每个 Car
对象可以执行的操作。接下来,我们将进一步学习如何给对象添加属性,并深入了解如何使用面向对象的核心概念来更好地管理代码。
欢迎回来!在本视频中,我们将解决上一个视频中提到的问题,我们有两个不同的对象(比如 Audi
和 BMW
),但是在输出中无法区分它们的状态(例如哪个正在驾驶、哪个停止)。我们将通过添加属性来解决这个问题,让我们能在输出中看到具体是哪辆车被创建、驾驶或停止。
在 Car
类中,我们将创建一个 私有字段来保存车的名称。这是一个常用于存储数据的属性。
private string _name;
_name
是我们的私有字段。_name
)是一种约定,用于标识私有字段。为了避免 _name
字段为空,我们可以在创建对象时为其分配一个值。我们将在 构造函数 中实现这一点。
public Car(string name)
{
_name = name;
Console.WriteLine(_name + " was created");
}
此构造函数需要一个参数 name
,并在创建对象时初始化该参数到 _name
字段中。这样,我们可以在输出中看到具体是哪个车对象被创建。
在 Program.cs
文件中,我们在创建 Car
对象时传递一个名字参数。
Car audi = new Car("Audi A4");
Car bmw = new Car("BMW M5");
运行代码后,输出将显示:
Audi A4 was created
BMW M5 was created
这样我们就能知道具体是哪辆车被创建了。
接下来,我们要修改 Drive
和 Stop
方法,以便输出特定的车名。例如:
public void Drive()
{
Console.WriteLine(_name + " is driving");
}
public void Stop()
{
Console.WriteLine(_name + " stopped");
}
在主程序中,我们可以调用这些方法,显示出特定对象的状态。
audi.Drive(); // 输出: Audi A4 is driving
bmw.Drive(); // 输出: BMW M5 is driving
运行以下代码:
Car audi = new Car("Audi A4");
Car bmw = new Car("BMW M5");
audi.Drive();
bmw.Drive();
audi.Stop();
输出结果将显示每辆车的名称以及它们的状态:
Audi A4 was created
BMW M5 was created
Audi A4 is driving
BMW M5 is driving
Audi A4 stopped
接下来,我们可以给 Car
类添加更多属性,例如马力 (Horsepower)。
private int _hp;
public Car(string name, int hp)
{
_name = name;
_hp = hp;
Console.WriteLine(_name + " with " + _hp + " HP was created");
}
在创建对象时,我们可以传递马力:
Car audi = new Car("Audi A4", 250);
Car bmw = new Car("BMW M5", 350);
这样,我们就能知道每辆车的具体配置了。
我们还可以创建一个方法 Details
,输出汽车的所有属性。
public void Details()
{
Console.WriteLine($"The {_name} has {_hp} horsepower.");
}
在主程序中调用该方法:
audi.Details(); // 输出: The Audi A4 has 250 horsepower.
bmw.Details(); // 输出: The BMW M5 has 350 horsepower.
这样就可以获取每辆车的详细信息。
通过本视频,我们学习了如何使用私有字段和构造函数来初始化对象的属性,同时也添加了更多属性(如马力)。我们还创建了一个 Details
方法,用于显示特定对象的详细信息。通过这些步骤,我们实现了面向对象编程中的基础功能,使得每个对象更加独立且具备唯一的属性。
欢迎回来!在本视频中,我们将学习如何在类中定义多个构造函数。这将使我们可以根据需求创建对象时,使用不同的参数来初始化属性。这种方法让我们更灵活地控制对象的初始化。
首先,让我们创建一个 默认构造函数(不带参数)。这个构造函数允许我们创建对象时不需要提供任何属性。
public Car()
{
_name = "Car";
_hp = 0;
_color = "Red";
}
"Car"
,马力为 0
,颜色为 "Red"
。在 Program.cs
文件中,我们可以使用默认构造函数来创建一个对象:
Car myCar = new Car();
myCar.Details();
运行代码后,输出结果为:
The red car Car has 0 HP.
这表明我们成功地创建了一个默认的汽车对象,并给它分配了默认的属性值。
有时,我们可能想要在创建对象时只设置一部分属性。我们可以创建一个 部分指定的构造函数,例如只设置汽车的名称和马力,而默认将颜色设置为 "Red"
。
public Car(string name, int hp)
{
_name = name;
_hp = hp;
_color = "Red"; // 默认颜色
}
这个构造函数需要用户提供汽车的名称和马力,而颜色默认设置为 "Red"
。
我们可以像下面这样创建一个部分指定的汽车对象:
Car myCar = new Car("Audi A4", 250);
myCar.Details();
运行后输出:
The red car Audi A4 has 250 HP.
这个示例表明我们可以在创建对象时指定部分属性,并依然使用默认值来填充其他属性。
如果我们想要在创建对象时设置所有属性(名称、马力、颜色),则可以定义一个 完全指定的构造函数,使得用户必须提供所有属性值。
public Car(string name, int hp, string color)
{
_name = name;
_hp = hp;
_color = color;
}
此构造函数要求用户提供汽车的所有属性值,因此它更灵活,但也要求用户输入更多信息。
我们可以使用完全指定的构造函数来创建一个汽车对象,并为其所有属性赋值:
Car myCar = new Car("BMW M5", 350, "Blue");
myCar.Details();
运行后输出:
The blue car BMW M5 has 350 HP.
这表明我们成功地创建了一个带有指定颜色的汽车对象,而不依赖于默认值。
在本视频中,我们学习了如何使用多个构造函数来灵活地创建对象:
使用多个构造函数可以使我们的类更加灵活和易用。根据需求,我们可以选择在对象初始化时使用不同数量的参数,从而适应不同的场景。
欢迎回来!在本视频中,我们将介绍一些在 Visual Studio 中非常有用的快捷键,这些快捷键可以帮助您加速编码和项目管理。掌握这些快捷键后,您可以显著提高编程效率,而不必频繁地使用鼠标。以下是一些实用的快捷键。
在代码中,如果想跳转到某个方法或类的定义,可以使用 F12
快捷键:
F12
会直接跳转到定义。Ctrl + -
可以快速返回到先前的文件位置。Ctrl + Shift + -
可以前进到之后的位置。IntelliSense 是 Visual Studio 中的代码提示功能,非常有助于加速编码:
Ctrl + Space
手动打开 IntelliSense。IntelliSense 提供了方法、类、枚举等多种代码建议,帮助您快速完成代码。
在 Visual Studio 中,注释和取消注释代码非常简单:
Ctrl + K + C
注释选定的代码行。Ctrl + K + U
取消注释选定的代码行。对于大型代码块,可以折叠和展开代码块以提高代码可读性:
Ctrl + M + M
可以折叠或展开代码块。快速运行和调试代码可以帮助您更快地测试代码:
F5
启动调试器运行代码。Ctrl + F5
不启动调试器直接运行代码。想要在整个项目中重命名某个方法或变量时,可以使用重构快捷键:
Ctrl + R + R
重命名方法、类或变量。重命名会在整个项目中自动更新。在多文件项目中,快速切换文件非常重要:
Ctrl + Tab
会弹出当前打开的文件列表,使用方向键可以选择要跳转的文件。掌握这些快捷键后,您可以减少使用鼠标的频率,提升编码效率。以下是本次视频中提到的快捷键汇总:
快捷键 | 功能 |
---|---|
F12 |
跳转到定义 |
Ctrl + - |
返回到前一个位置 |
Ctrl + Shift + - |
前进到下一个位置 |
Ctrl + Space |
打开 IntelliSense |
Ctrl + K + C |
注释选定代码 |
Ctrl + K + U |
取消注释选定代码 |
Ctrl + M + M |
折叠/展开代码块 |
F5 |
启动调试器运行代码 |
Ctrl + F5 |
无调试器运行代码 |
Ctrl + R + R |
重命名方法或变量 |
Ctrl + Tab |
切换到另一个文件 |
在下一个视频中,我们将探讨如何使用代码片段来加速编程。
欢迎回来!在本视频中,您将学习如何使用代码片段 (code snippets) 来加速编码。代码片段是预定义的代码模板,可以快速插入常用代码结构,从而减少手动输入的重复性。我们之前已经用到过代码片段,比如输入 CW
并按下 Tab
键可以直接插入 Console.WriteLine
,非常方便。
以下是如何高效使用代码片段的一些技巧。
Console.WriteLine
cw
然后按 Tab
键,自动生成 Console.WriteLine()
,并且光标会自动跳入括号中,便于您立即输入内容。cw + Tab → Console.WriteLine();
构造函数(Constructor)
ctor
然后按 Tab
,会自动生成一个默认构造函数。创建新类时需要构造函数,这个片段非常方便。ctor + Tab → public ClassName() { }
if 语句
if
然后按 Tab
,会生成一个基本的 if
语句框架,光标会自动跳到条件括号中。if + Tab → if (condition) { }
while 循环
while
然后按 Tab
,会生成一个 while
循环的框架。可以快速设置循环条件。while + Tab → while (condition) { }
try-catch 语句
try
然后按 Tab
,会自动生成一个 try-catch
代码块,便于异常处理。try + Tab → try { } catch (Exception ex) { }
属性 (Property)
输入 prop
并按 Tab
会生成一个自动实现的属性框架。
示例:
prop + Tab → public int MyProperty { get; set; }
输入 propg
并按 Tab
生成一个带 get
和 private set
的属性。
您可以通过以下步骤查看更多可用的代码片段:
exception
、for
循环片段等。Visual Studio 还允许您创建自定义代码片段,这样您可以设计适合自己需求的片段。在 Visual Studio 的代码片段管理器中,您可以进一步探索创建代码片段的方法。在 Visual Studio Productivity Masterclass 课程中,我提供了如何创建自定义代码片段的详细教程。
代码片段可以帮助您减少重复输入,提高效率。以下是一些快捷片段的总结:
代码片段 | 用途 |
---|---|
cw |
Console.WriteLine |
ctor |
默认构造函数 |
if |
if 语句 |
while |
while 循环 |
try |
try-catch 语句 |
prop |
自动属性 |
propg |
get 和 private set 属性 |
通过熟练使用这些代码片段,您可以更高效地编写代码。在接下来的课程中,您将看到代码片段在不同场景下的具体应用。
欢迎回来!在本视频中,我们将讨论 私有 (private) 和 公共 (public) 访问修饰符,它们是控制类成员访问权限的关键。我们将理解如何在 C# 中利用这些修饰符进行数据封装,确保数据安全,同时保持类的易用性。
在对象的成员变量(字段)前使用 private
关键字,就表示这个变量只能在该类内部访问,无法从外部直接修改或访问。例如,我们在类 Car
中定义了一个私有变量 _name
:
private string _name;
在类的外部,例如在主程序文件中,我们不能直接访问 _name
。如果尝试修改 myCar._name
,编译器会报错:“此变量由于保护级别不可访问”。
一种方法是将 _name
设为公共 (public),但这样会违背数据封装的原则。另一种更优雅的方法是使用 属性 (property),这将在下一节中介绍。
与 private
相对,public
关键字允许变量或方法在类外部访问。例如,若我们将 _name
设为 public
:
public string Name;
此时,主程序文件可以直接访问和修改 Name
,这也是我们一般为成员方法设置 public
的原因,使其可以被类外部调用。
访问修饰符不仅适用于变量,也适用于方法。比如,Drive
方法用于启动汽车,我们可以将它设为私有,这样外部类无法直接调用。
private void Drive()
{
Console.WriteLine($"{_name} is driving.");
}
现在,Drive
方法只能在 Car
类内部被调用。我们可以在构造函数中调用它,使得汽车在创建时自动启动:
public Car()
{
_name = "Car";
Drive();
}
当我们调用 new Car()
创建汽车时,构造函数会自动调用 Drive
方法。即使在主程序中无法直接调用 Drive
,汽车仍会启动。
在下一节中,我们将探讨 getter 和 setter 的概念,这将帮助我们在保持成员变量私有的同时,安全地提供访问权限。
欢迎回来!在本视频中,我们将讨论Setter方法的使用,这是一种通过方法来改变私有变量的值的方式。通过使用Setter方法,我们可以在保持变量私有的情况下,安全地从类外部设置变量值,从而确保数据的完整性和封装性。
Setter方法是一种公共方法,允许我们从类外部更改私有成员变量的值。例如,假设我们有一个private
修饰的成员变量_name
,我们可以通过一个public
的SetName
方法来更改它。这个方法不会直接返回变量值,而是用于设置值。
假设我们有以下的私有变量:
private string _name;
我们可以通过以下方式创建一个Setter方法:
public void SetName(string name)
{
_name = name;
}
这个SetName
方法允许我们在不直接访问_name
的情况下更改它的值。为了测试该方法,我们可以在Program.cs
文件中调用它:
Car myCar = new Car();
myCar.SetName("My Special Car");
运行代码后,myCar
的名字将被设置为"My Special Car"。
Setter方法不仅让我们能够从外部更改私有变量的值,而且还能在设置值时添加条件或限制。例如,我们可以在设置_name
时,确保它不为空字符串:
public void SetName(string name)
{
if (string.IsNullOrEmpty(name))
{
_name = "Default Name"; // 如果为空,则使用默认名称
}
else
{
_name = name; // 否则使用传入的值
}
}
在上面的代码中,如果用户传入的name
为空字符串,_name
将被设置为"Default Name"。这种方法可以确保我们不会为_name
分配无效的数据。
在实际开发中,Setter方法广泛应用于确保数据的完整性。例如,当从数据库中读取用户信息时,可能会遇到某些字段为空的情况。在这种情况下,可以通过Setter方法为这些空字段分配默认值,以确保系统正常运行。
在下一节视频中,我们将讨论Getter方法,帮助我们在保持变量私有的情况下安全地读取它的值。
欢迎回来!在上一个视频中,我们了解了Setter方法,现在我们将探讨Getter方法,或称“获取方法”。Getter方法允许我们在类外部访问私有变量的值,而不直接暴露该变量。通过这种方式,我们可以在保持数据封装性的同时,从类外部安全地获取变量的值。
Getter方法是一种公共方法,用于返回私有变量的值。例如,我们有一个私有成员变量_name
,通过创建一个GetName
方法,我们可以从类外部获取该变量的值。与Setter方法不同的是,Getter方法通常有一个返回类型,因为它返回一个值。
假设我们有以下的私有变量:
private string _name;
我们可以创建一个Getter方法GetName
,来获取该变量的值:
public string GetName()
{
return _name;
}
在这里,GetName
方法返回_name
的值。我们可以在Program.cs
文件中调用它来获取汽车的名字:
Car myCar = new Car();
myCar.SetName("My Special Car"); // 先设置名字
Console.WriteLine("My car's name is " + myCar.GetName()); // 然后获取名字
运行这段代码后,输出将显示"My car's name is My Special Car"。
Getter方法不仅允许我们安全地从类外部获取私有变量的值,而且还能让我们在返回值之前执行特定的逻辑。例如,我们可以添加一个后缀到汽车名称中:
public string GetName()
{
return _name + " Car"; // 在名称后添加“ Car”
}
现在,当调用GetName
方法时,返回的将是带有“ Car”后缀的名字。
Getter方法的灵活性使我们能够控制返回的值格式,这在需要对外展示数据时非常有用。例如,在一个应用程序中,当获取用户信息时,我们可能希望格式化返回的数据以适应界面要求。
一个简单的练习:创建一个返回汽车马力的Getter方法。
public int GetHP()
{
return _hp;
}
在Program.cs
中调用该方法:
Console.WriteLine("My car's horsepower is " + myCar.GetHP());
这将显示汽车的马力值。
在下一节视频中,我们将进一步探索属性,它结合了Getter和Setter的概念,并简化了访问和设置私有变量的方法。
欢迎回来!在本视频中,我们将深入了解属性(Properties),以及它们如何简化了私有成员变量的访问和控制。属性可以看作是结合了Getter和Setter方法的简便方式,它让我们能够更灵活地控制数据的访问和设置方式。
属性是类(Class)的成员,可以被看作是一种特殊的方法,用于读写或计算私有字段(Private Field)的值。属性在外部代码看来就像是公开的数据成员,但实际上,它们是通过特殊的方法(称为访问器 Accessor)来访问的。
通过属性,我们可以:
get
访问器。set
访问器。在上个视频中,我们手动创建了Getter和Setter方法来访问和更改私有变量。使用属性,我们可以简化这个过程。来看具体步骤:
我们可以使用快捷代码 prop
并按下Tab键,Visual Studio将自动生成属性结构。例如:
public string Name
{
get { return _name; }
set { _name = value; }
}
这里的Name
属性包含了:
_name
的值。_name
的值。value
是默认关键词,代表传入的新值。假设我们有一个私有成员变量 _name
,并希望通过属性Name
来访问和修改它:
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
现在,我们可以通过 Name
属性来设置和获取 _name
的值。例如,在 Program.cs
文件中:
Car myCar = new Car();
myCar.Name = "My Audi A3"; // 设置名称
Console.WriteLine(myCar.Name); // 获取名称并输出
这样,我们可以通过属性直接访问,而不必显式调用Getter或Setter方法。
属性的主要作用在于控制数据访问,同时确保对象的内部状态有效。与直接公开字段相比,属性提供了更高的灵活性和安全性。例如,通过添加条件语句,我们可以控制变量的设置方式:
set
{
if (string.IsNullOrWhiteSpace(value))
{
_name = "Default Name"; // 如果值为空,则使用默认名称
}
else
{
_name = value;
}
}
通过这种方式,我们可以在设置值时进行验证,确保不接受空字符串。
get
和 set
访问器实现。属性在C#开发中非常常用,它们既能增强代码的可读性,又能保护数据的完整性。在接下来的章节中,我们将继续深入探讨更多面向对象编程的概念。
欢迎回来!在本视频中,我们将探讨自动实现属性(Auto-Implemented Properties)。这是C#中的一个简洁特性,可以大大简化属性的创建。
当我们不需要在 get
或 set
方法中添加任何额外逻辑时,可以使用自动实现属性来简化代码。自动实现属性在内部会创建一个匿名的私有字段,我们不需要手动定义它。这个私有字段只能通过属性的 get
和 set
访问器进行访问。
例如,我们之前创建的 Name
属性是一个自动实现属性:
public string Name { get; set; }
这个 Name
属性实际上创建了一个隐藏的私有字段来存储数据,但我们不需要显式地编写这个字段。接下来我们将添加一个新属性来展示这一功能。
假设我们想为汽车添加一个 MaxSpeed
属性,表示最大速度。我们可以通过自动实现属性来实现:
public int MaxSpeed { get; set; }
这里的 MaxSpeed
属性是自动实现的,它会在内部自动创建一个私有的存储字段,我们可以通过 get
和 set
访问器来读取和设置 MaxSpeed
的值,而无需手动定义字段。
在 Program.cs
中,我们可以通过以下代码来设置和获取 MaxSpeed
属性:
Car myCar = new Car();
myCar.MaxSpeed = 180; // 设置最大速度为180
Console.WriteLine("Max speed is " + myCar.MaxSpeed); // 输出最大速度
运行这段代码,我们会得到输出:
Max speed is 180
如你所见,属性 MaxSpeed
的值被成功设置为180,并通过 Console.WriteLine
打印出来。这一切都通过自动实现属性完成,无需额外的字段定义。
get
和 set
访问器访问。自动实现属性非常适合不需要额外逻辑的简单属性。它既提供了对私有数据的封装,同时让代码更为简洁、清晰。只需一行代码,即可拥有 get
和 set
访问器,自动创建的私有字段在内部处理数据存储。
接下来,我们将进一步探讨如何使用只读和只写属性,来满足不同的数据访问需求。
欢迎回来!在本视频中,我们将探讨 只读 和 只写 属性。这些属性的用途在于控制如何访问类中的数据。通过只读或只写属性,我们可以限制属性的访问方式,从而增强代码的安全性和封装性。
只读属性具有 get
访问器,但没有 set
访问器。这意味着您可以读取该属性的值,但无法修改它的值。
假设我们不希望最大速度 (MaxSpeed
) 能在外部被设置。我们可以去掉 set
访问器,使其成为只读属性:
public int MaxSpeed { get; } = 150;
在这种情况下,MaxSpeed
的值在类内部定义,外部只能读取但不能修改它。
Car myCar = new Car();
Console.WriteLine("Max speed is " + myCar.MaxSpeed); // 只能读取,不能设置
运行代码将显示:Max speed is 150
。由于 MaxSpeed
是只读的,我们无法在程序中设置它的值。
只写属性具有 set
访问器,但没有 get
访问器。这意味着您可以在外部设置该属性的值,但不能读取它的值。
假设我们只想设置最大速度,但不希望从外部读取它。在这种情况下,我们可以为 MaxSpeed
创建一个只写属性:
private int _maxSpeed;
public int MaxSpeed
{
set { _maxSpeed = value; }
}
在 Program.cs
文件中,我们可以设置 MaxSpeed
,但无法读取它的值:
Car myCar = new Car();
myCar.MaxSpeed = 180; // 可以设置值
Console.WriteLine("Max speed is " + myCar.MaxSpeed); // 无法读取,编译错误
这样,MaxSpeed
成为只写属性,不能在外部获取该属性的值。
不可变数据:例如出生日期。出生日期在设置后不应再更改,适合用只读属性。
计算属性:当属性值是从其他数据计算而来时,可用只读属性。例如,矩形的 Area
(面积)属性可以是只读的,通过宽度和高度计算得出,而不允许手动设置。
敏感数据:例如密码。在用户凭据类中,可能需要将密码设置为只写,以防止应用程序读取密码。
触发操作:只写属性还可以用于触发某些操作。例如在日志类中,可以用一个只写属性 Message
来写入日志信息,但不允许直接读取它的值。
只读和只写属性在数据保护和封装方面非常有用。它们确保了数据只能在符合要求的情况下被访问或修改。虽然只写属性在实际应用中较少见,但在某些特殊场景下,仍然可以发挥重要作用。
希望这些示例帮助您理解如何有效使用属性的不同访问权限!我们下节课再见。
欢迎回来!在本视频中,我们将介绍 成员(Members) 的概念,并通过创建一个新的 Members
类来展示所有与面向对象编程相关的成员类型。成员是类的一部分,可以包括字段、属性、方法、构造函数和析构函数等内容。了解这些不同的成员类型将帮助您更好地设计和使用类。
首先,我们创建一个名为 Members
的类。该类包含多个不同类型的成员。
我们可以定义一些私有字段,用于存储类的内部数据。私有字段只能在类的内部访问,不能从类的外部直接访问。例如:
private string memberName = "Lucy";
private string jobTitle = "Developer";
private int age = 30;
private int salary = 60000;
这些字段表示成员的名字、职位、年龄和薪水。
在某些情况下,您可能需要将字段设为公有,以便可以从类的外部直接访问它。例如:
public int experienceYears = 5;
公有字段可以直接从类的外部访问,但通常不推荐将字段直接设为公有,因为这样会降低封装性。
属性是用于访问私有字段的成员。我们可以使用自动实现的属性,或者手动编写 getter 和 setter 方法。
public string JobTitle { get; set; } // 自动实现的属性
public string JobTitle
{
get { return jobTitle; }
set { jobTitle = value; }
}
手动实现的属性允许我们在获取和设置属性值时添加自定义逻辑。属性通常用于安全地暴露类中的私有字段。
方法是类的行为,通常为公有,以便从类外部调用。例如:
public void Introduce(bool isFriend)
{
if (isFriend)
{
SharePrivateInfo();
}
else
{
Console.WriteLine($"Hi, my name is {memberName}, my job title is {jobTitle}, and I am {age} years old.");
}
}
这个 Introduce
方法允许成员进行自我介绍。如果调用者是朋友(即 isFriend
为 true
),则调用私有方法 SharePrivateInfo()
。
私有方法只能在类的内部使用,通常用于内部逻辑或辅助计算。例如:
private void SharePrivateInfo()
{
Console.WriteLine($"My salary is {salary}.");
}
私有方法 SharePrivateInfo
会输出成员的薪水,但不会对外部公开。
构造函数用于初始化对象,是类的一个特殊成员。构造函数在创建对象时自动调用。它们可以设置初始值或执行其他初始化任务。
public Members()
{
Console.WriteLine("Object created");
age = 30;
memberName = "Lucy";
jobTitle = "Developer";
salary = 60000;
}
在这个构造函数中,我们设置了 age
、memberName
、jobTitle
和 salary
的初始值,并输出 "Object created" 以指示对象的创建。
析构函数(又称“终结器”)是在对象被垃圾回收时调用的特殊成员,用于清理资源。析构函数通常用于释放非托管资源。
~Members()
{
Console.WriteLine("Destruction of members object");
}
在 C# 中,析构函数的定义以 ~
开头。请注意,析构函数不应包含实际业务逻辑,而仅限于清理操作。如果析构函数为空,最好不要定义它,以免降低性能。
在 Program.cs
文件中,我们可以创建一个 Members
对象并调用方法来查看效果:
Members member1 = new Members();
member1.Introduce(true); // 调用带朋友标记的介绍方法,输出薪水信息
通过调用 Introduce
方法,我们可以让成员自我介绍并分享其私密信息(如薪水),这是因为我们设置了 isFriend
参数为 true
。
在本节中,我们讨论了以下成员类型:
这些成员类型是 C# 中面向对象编程的核心内容。理解它们并合理使用它们,将帮助您编写更加清晰、可维护的代码。
现在您已经了解了成员的不同类型及其用途。我们将在后续课程中继续深入探讨面向对象编程的更多内容。下节课见!
现在,您已经完成了面向对象编程(OOP)章节,并学习了如何创建类,这些类可以拥有属性和方法,还包含了不同类型的成员(如字段、构造函数等)。您还了解了如何使用构造函数,析构函数等重要的编程概念。
到目前为止,您可能会觉得 OOP 有些复杂或不必要,因为您可能在编写的小程序中没有真正感受到它的优势。确实,当我们编写一个简单的小程序时,确实不需要使用复杂的对象结构和类设计。然而,当我们进入更复杂的项目时,对象的力量将会显现出来。
通过 OOP,您可以更好地管理代码结构,实现代码复用,简化维护和修改,并使程序更易于扩展。当应用规模扩大并引入更多复杂的功能和逻辑时,OOP 的设计思想和原则可以显著提升程序的组织性和可读性。
接下来,我们将进入 数组 的学习章节。数组是一种将多个对象或多个变量存储在一起的数据结构。与对象类似,数组也可以帮助您更好地组织和处理数据,特别是在需要存储和操作大量数据时。
在下一章中,您将学到如何使用数组和列表。这些工具将为您提供有效管理数据的强大手段,特别是当您需要对一组数据进行操作时,数组和列表将变得非常有用。
请继续保持耐心,不要担心现在是否完全理解了 OOP。随着编程经验的积累,这些概念将会逐渐变得清晰,您也会更灵活地运用它们。接下来,让我们继续学习数组,这将为您的编程技能带来更多的提升!
在本章中,您将学习数组(Arrays)和列表(Lists)的工作原理。具体来说,我们将探索:
for
循环或foreach
循环)。在编程中,我们经常需要管理和操作较大数量的数据。假设在一个数据库中有 100 个用户,并且您需要为每位用户更改某些信息。这时,将这些用户放入数组或列表中将是非常高效的做法。不同于单独处理每个用户,通过数组或列表,您可以对整个数据集进行批量操作。
在学习中,您会发现数组和列表在某些方面有所不同,每种都有自己的优势:
在接下来的章节中,您将更深入地理解何时应该使用数组,何时适合使用列表,以及如何使用它们来高效地处理数据。
在本章,我们将提供相关练习和测验,帮助您巩固对数组和列表的理解。准备好了吗?让我们深入探讨这些重要的数据结构,揭开它们的强大之处!
在本视频中,我们将介绍 C# 中数组的理论部分。在下一个视频中,我们会通过实际操作和示例更深入地理解数组的使用。首先,让我们来了解数组的基本概念和作用。
数组是一种固定大小的顺序集合,用于存储相同类型的元素。需要特别注意,数组只能存储相同类型的数据元素,这意味着在同一个数组中,不能既有字符串(string)又有整数(int)等不同的数据类型。
数组可以是任意类型的数据集合。例如,您可以创建一个仅包含整数的数组、一个仅包含字符串的数组,甚至可以存储对象的数组。几乎任何类型都可以作为数组的元素类型,只需保证数组中的所有元素都为同一类型。
可以将数组想象成一个具有多个存储单元的存储结构,这些存储单元用于存放相同类型的数据。例如,以下图所示是一个长度为6的整数数组:
[13, 15, 5, 7, 8, 10]
在这个数组中,每个数据都有其索引,用于定位特定的数据元素。数组的索引从0开始,并逐步增加。因此,在上图中:
13
的元素位于索引 0
。15
的元素位于索引 1
。8
的元素位于索引 4
。这意味着在一个长度为 n
的数组中,索引范围为 0
到 n-1
。
在 C# 中声明数组时,需要指定数据类型和数组名称,例如:
int[] grades;
上面的代码表示声明了一个 int
类型的数组,名为 grades
,用来存储学生的成绩信息。
初始化数组需要以下步骤:
[]
+ 数组名称。new
关键字分配指定长度的空间。例如,下面的代码定义了一个包含 5
个整数的数组:
int[] grades = new int[5];
这表示数组 grades
可以存储 5
个 int
类型的整数。
赋值时,通过数组名称加方括号中的索引来指定要赋值的位置。如下所示:
grades[0] = 15;
grades[1] = 12;
这段代码为 grades
数组的第一个元素(索引为 0
)赋值为 15
,第二个元素(索引为 1
)赋值为 12
。
在本视频中,我们了解了数组的基础理论:
数组是编程中用于存储和管理一组数据的重要工具。在接下来的视频中,我们会进行实操,探索数组的实际应用及其在编程中的重要性。
在本视频中,我们将学习如何在 C# 中创建和使用数组,以及几种初始化数组的不同方法。让我们从在 Main
方法中创建一个简单的整数数组开始。
首先,我们创建一个整数类型的数组,命名为 grades
。数组的类型是 int
,并且它的大小为 5
,即它可以存储五个整数值:
int[] grades = new int[5];
这表示我们创建了一个 int
类型的数组,并为其分配了五个元素的存储空间。定义数组大小后,就可以为数组中的每个元素分配具体的值。
通过指定数组的索引来为数组中的元素赋值。请注意,数组的索引从 0
开始。
grades[0] = 20; // 第一个学生的成绩是 20
grades[1] = 15;
grades[2] = 18;
grades[3] = 10;
grades[4] = 17;
在上面的代码中,我们为数组 grades
的每个元素赋值。索引从 0
到 4
,正好对应数组的五个元素。
可以通过索引来访问数组中的某个元素。例如,要访问 grades
数组中第一个学生的成绩(索引 0
)并将其输出到控制台:
Console.WriteLine("Grade at index 0 is: {0}", grades[0]);
输出结果将是:
Grade at index 0 is: 20
在运行时,可以通过索引修改数组中的元素。下面我们实现一个例子,通过用户输入来更改数组中的第一个元素:
Console.WriteLine("Enter a new grade for the first student:");
string input = Console.ReadLine();
grades[0] = int.Parse(input);
Console.WriteLine("Updated grade at index 0 is: {0}", grades[0]);
在这里,我们通过 Console.ReadLine()
获取用户输入的值,然后使用 int.Parse()
将其转换为整数,并将其分配给 grades[0]
。
除了使用索引分配值之外,还可以通过其他方式初始化数组。
直接初始化数组(指定元素):
int[] gradesOfMathStudentsA = { 20, 13, 12, 8, 8 };
这段代码创建并初始化了一个名为 gradesOfMathStudentsA
的数组,其元素为 {20, 13, 12, 8, 8}
。
使用 new
关键字进行初始化:
int[] gradesOfMathStudentsB = new int[] { 15, 20, 3, 17, 20 };
这种方式使用 new
关键字显式地创建数组,指定了每个元素的值。
可以使用数组的 Length
属性来获取数组的长度(元素的个数)。例如:
Console.WriteLine("Length of gradesOfMathStudentsA: {0}", gradesOfMathStudentsA.Length);
输出结果将会显示 gradesOfMathStudentsA
的长度(例如 5
),因为它包含五个元素。
在本视频中,我们学习了如何创建和初始化数组,如何为数组中的元素赋值、访问和修改它们,以及如何使用 Length
属性来获取数组的长度。数组是存储和管理多个相同类型数据的强大工具。理解这些概念后,我们可以在下一个视频中使用 for-each
循环来遍历数组中的每个元素。
foreach
循环遍历数组的简单介绍在本视频中,我们将了解如何使用 foreach
循环遍历数组中的所有元素。foreach
循环是处理数组的一种更简单、更直观的方法,不需要指定循环的起点和终点。接下来,我们将创建一个数组,并使用 for
循环和 foreach
循环分别来操作和输出数组内容。
首先,我们创建一个名为 nums
的整数数组,包含十个元素:
int[] nums = new int[10];
这个数组的类型是 int
,大小为 10,可以存储 10 个整数值。
for
循环为数组赋值我们使用 for
循环为数组中的每个元素赋值。每个元素的值等于其索引值加上 10:
for (int i = 0; i < 10; i++)
{
nums[i] = i + 10;
}
在上面的代码中,nums[0]
被赋值为 10
,nums[1]
被赋值为 11
,依此类推,直到 nums[9]
被赋值为 19
。
for
循环遍历并输出数组内容接下来,我们使用 for
循环遍历数组并输出每个元素的值:
for (int j = 0; j < nums.Length; j++)
{
Console.WriteLine("Element at index {0} is: {1}", j, nums[j]);
}
这段代码会输出数组中每个元素的索引和对应的值。
foreach
循环遍历数组foreach
循环的语法更为简洁。我们可以使用 foreach
遍历数组而不需要指定数组的边界:
foreach (int k in nums)
{
Console.WriteLine("Value: {0}", k);
}
在 foreach
循环中,变量 k
会依次取数组中每个元素的值,直到遍历完数组中的所有元素。这样,foreach
循环会自动处理数组的起点和终点,避免了因索引错误导致的越界异常。
foreach
与 for
的区别和优势foreach
循环中,变量的数据类型会自动匹配数组元素的数据类型,而 for
循环的索引通常是整数类型。foreach
循环不需要指定开始或结束条件,避免了手动错误,直接遍历数组的所有元素。for
循环的性能比 foreach
循环稍好,尤其是在复杂的操作中。foreach
循环创建一个好友列表并问候现在,我们练习使用 foreach
循环来创建并遍历一个好友列表。假设我们要创建一个字符串数组存储好友的名字,并用 foreach
循环来向他们问好:
string[] myFriends = { "Michael", "Lutz", "Elia", "Andy", "Daniel" };
foreach (string name in myFriends)
{
Console.WriteLine("Hi there, {0}, my friend!", name);
}
输出结果将是:
Hi there, Michael, my friend!
Hi there, Lutz, my friend!
Hi there, Elia, my friend!
Hi there, Andy, my friend!
Hi there, Daniel, my friend!
在这个练习中,我们使用 foreach
循环自动向数组中的每个好友发送问候,不需要手动逐个地写出问候语。这个简单的示例展示了 foreach
循环的简洁和便捷。未来可以将此应用扩展为邮件发送服务或自动化系统。
在本视频中,我们了解了 for
循环和 foreach
循环的基础用法,尤其是在数组操作中的应用。foreach
循环能够简化代码编写和避免越界错误,适合在需要遍历整个数组的场景中使用。在接下来的视频中,我们将进一步探索数组的高级功能和操作。
foreach
、for
和 while
循环的用法及适用场景在本节中,我们将深入了解 foreach
循环与其他循环(如 for
和 while
)之间的区别,并讨论每种循环的适用场景。每种循环在编写代码时都有其独特的优势,因此理解它们的用法将帮助你选择最适合的循环类型。
foreach
循环遍历数组foreach
是一种非常适合用于集合(如数组或列表)的循环结构。我们先来看一个简单的示例。在这个示例中,我们有一个整型数组 numbers
,初始化为 {1, 2, 3, 4, 5}
,然后我们想打印出数组中的每个数字。
int[] numbers = {1, 2, 3, 4, 5};
foreach (int number in numbers)
{
Console.WriteLine(number);
}
在这里,我们使用了 foreach
关键字,并在括号内声明了一个 int
类型的变量 number
。每次循环时,这个变量都会保存数组 numbers
中的当前元素。in
关键字用于遍历集合中的每一个元素,相当于“在这个集合中的每一个元素”。这种方式让我们无需手动管理索引。
在 foreach
循环的每次迭代中,都会打印当前元素 number
。foreach
循环自动为我们处理迭代,因此代码更加简洁明了。
foreach
与 for
循环的比较如果你需要操作索引,例如跳过某些元素或倒序遍历,那么传统的 for
循环更为合适。for
循环可以让你更加灵活地控制循环的开始点、结束点以及步长。
例如,如果我们想跳过每隔一个元素打印一次数组内容,那么可以使用 for
循环实现:
int[] numbers = {1, 2, 3, 4, 5};
for (int i = 0; i < numbers.Length; i += 2)
{
Console.WriteLine(numbers[i]);
}
在这个例子中,我们使用了 for
循环,并初始化变量 i
为 0
,设置循环条件为 i < numbers.Length
,每次迭代将 i
增加 2
。这样可以确保循环每次跳过一个元素,输出结果为数组中的每隔一个元素。
while
循环while
循环适用于我们不知道循环次数的情况。例如,你可能希望不断要求用户输入,直到他们输入有效的值为止。在这种情况下,while
循环非常合适。
以下代码展示了如何使用 while
循环来持续请求用户输入有效的数字:
string input;
int number;
do
{
Console.WriteLine("Please enter a valid number:");
input = Console.ReadLine();
}
while (!int.TryParse(input, out number));
在这个例子中,我们使用 do...while
结构。do
块中的代码会先执行一次,然后检查条件 while
。条件为 !int.TryParse(input, out number)
,意思是如果 input
不能被转换为整数,则循环继续。如果转换成功,int.TryParse
返回 true
,循环结束。
这种方法让我们可以根据条件控制循环的继续与否,而无需提前知道循环的次数。
foreach
:当你需要对集合中的每个元素执行操作时且不需要操作索引。for
:当你需要更灵活地控制迭代过程,如跳过元素、倒序遍历或以特定步长遍历集合。while
:当你不确定需要循环的次数,例如不断请求用户输入直到获得有效值的情况。接下来,我们将进行一个实践练习,以巩固目前学习到的内容。
在本视频中,我们将深入探讨多维数组,特别是二维数组(2D数组)及其在C#中的声明和初始化。多维数组不仅可以是单行条目,还可以是二维、三维甚至更多维度的数组。我们会通过示例展示如何创建、访问和修改这些数组中的元素。
首先,我们来看如何声明一个二维数组。以下是一个字符串类型的二维数组的声明方式:
string[,] matrix;
我们使用了一个逗号来表示这是一个二维数组。如果想声明一个三维数组,则需要两个逗号:
int[,,] threeD;
二维数组就像一个矩阵,有行和列,而三维数组则增加了一个维度(深度),类似于三维空间中的立方体。
在声明完二维数组后,我们可以初始化它。以下是一个简单的二维整型数组的初始化示例:
int[,] array2D = new int[3, 3]
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
上述代码创建了一个3x3的整数数组,我们可以将其视为一个矩阵,其中第1行是 {1, 2, 3}
,第2行是 {4, 5, 6}
,第3行是 {7, 8, 9}
。
要访问二维数组中的元素,我们可以指定行和列的索引。例如,我们想要访问数组 array2D
中的数字 5
:
Console.WriteLine("中央值是:" + array2D[1, 1]);
在二维数组中,索引从 0
开始,因此 array2D[1, 1]
表示第2行、第2列的元素。
试试找到数组中的数字 7
,并将其打印到控制台:
Console.WriteLine("左下角的值是:" + array2D[2, 0]);
三维数组的概念类似于二维数组,只是增加了一个维度。以下是一个三维数组的声明和初始化:
string[,,] threeDArray =
{
{
{"000", "001", "002"},
{"010", "011", "012"},
{"020", "021", "022"}
},
{
{"100", "101", "102"},
{"110", "111", "112"},
{"120", "121", "122"}
}
};
在上述代码中,我们创建了一个 2x3x3
的三维数组。假设我们想访问三维数组中的字符串 "111"
,可以使用以下代码:
Console.WriteLine("值是:" + threeDArray[1, 1, 1]);
在编写代码时,可能需要检查数组的维度,C#提供了 .Rank
属性来获取数组的维度:
int dimensions = array2D.Rank;
Console.WriteLine("数组的维度是:" + dimensions);
对于二维数组,Rank
的值是 2
,对于三维数组,Rank
的值是 3
。
在初始化数组时,我们可以直接指定维度。例如:
string[,] array2DString = new string[3, 2]
{
{"一", "二"},
{"三", "四"},
{"五", "六"}
};
在这里,[3, 2]
表示数组有3行2列。我们可以访问或更改特定的元素:
array2DString[1, 1] = "鸡肉";
Console.WriteLine("修改后的值:" + array2DString[1, 1]);
尝试将数组中的一个特定值(例如"四"
)更改为 "牛肉"
并打印出来。
array2DString[1, 1] = "牛肉";
Console.WriteLine("新的值:" + array2DString[1, 1]);
在本节中,我们学习了如何创建、初始化和操作多维数组,尤其是二维和三维数组。理解这些概念将帮助你更有效地组织和处理数据。在下一视频中,我们会进行一个小的编程挑战,用二维数组来实现井字棋(Tic-Tac-Toe)游戏。确保你熟练掌握二维数组的基本操作,为下一节做好准备。
现在你已经了解了数组、二维数组和 for each
循环的用法,我们将结合这些概念,讨论嵌套 for
循环的使用及其与 for each
循环的对比,尤其是在处理多维数组时的优势。
for each
循环遍历二维数组首先,我们声明一个二维数组 matrix
,它包含了三行三列的整数值,如下所示:
static int[,] matrix =
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
然后,我们使用 for each
循环来遍历 matrix
中的每一个元素:
foreach (int item in matrix)
{
Console.Write(item + " ");
}
运行结果如下:
1 2 3 4 5 6 7 8 9
for each
循环简化了遍历的过程,可以轻松地访问每个元素。然而,在 for each
循环中,我们无法修改 matrix
的具体值,因为 item
仅是值的副本,而非实际的数组元素引用。
for
循环遍历二维数组如果我们希望对数组的元素进行修改或按特定顺序访问元素,则可以使用嵌套 for
循环。嵌套 for
循环允许我们遍历数组的行和列。
for (int i = 0; i < matrix.GetLength(0); i++)
{
for (int j = 0; j < matrix.GetLength(1); j++)
{
Console.Write(matrix[i, j] + " ");
}
Console.WriteLine();
}
在此代码中:
for
循环 i
遍历行数,行数由 matrix.GetLength(0)
提供。for
循环 j
遍历列数,列数由 matrix.GetLength(1)
提供。运行结果如下:
1 2 3
4 5 6
7 8 9
for
循环嵌套 for
循环的一个显著优势在于可以直接访问和修改具体元素。例如,我们可以将 matrix
的每个元素都设为 0
:
for (int i = 0; i < matrix.GetLength(0); i++)
{
for (int j = 0; j < matrix.GetLength(1); j++)
{
matrix[i, j] = 0;
}
}
现在,matrix
中的所有元素都变成了 0
,这是 for each
循环无法实现的,因为 for each
循环不允许对集合中的元素进行修改。
for
循环的其他用途通过嵌套 for
循环,我们可以控制遍历的顺序。例如,我们可以仅打印矩阵的对角线元素:
for (int i = 0; i < matrix.GetLength(0); i++)
{
Console.Write(matrix[i, i] + " "); // 只打印主对角线上的元素
}
结果为:
1 5 9
for each
循环或嵌套 for
循环使用 for each
循环:适合只需读取数组中的每个元素、无需更改元素值的情况。for each
使代码更简洁、更易读。
使用嵌套 for
循环:适合需要精确控制循环行为的场景,如需要按行列访问元素、修改数组内容或访问特定位置(如对角线)时。嵌套 for
循环允许直接访问并修改数组中的元素。
for each
循环适合简单遍历和只读操作。for
循环适合对数组元素的修改或特定访问需求。通过掌握这些循环方式,可以根据具体需求选择最合适的方式来处理数组。在下一个视频中,我们将进一步探索嵌套 for
循环的高级应用和不同的遍历技巧。
欢迎回来!在本视频中,我们将探讨如何使用嵌套 for
循环或单循环来打印二维数组的对角线元素,并了解如何仅打印数组中的奇数元素。
首先,我们将调整嵌套的 for
循环,使其仅打印矩阵中奇数的元素。假设我们有一个矩阵 matrix
:
static int[,] matrix =
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
我们可以使用嵌套 for
循环和 if
条件来过滤出奇数。这里 matrix[i, j] % 2 != 0
检查元素是否为奇数:
for (int i = 0; i < matrix.GetLength(0); i++)
{
for (int j = 0; j < matrix.GetLength(1); j++)
{
if (matrix[i, j] % 2 != 0)
{
Console.Write(matrix[i, j] + " ");
}
else
{
Console.Write(" "); // 保留位置对齐
}
}
Console.WriteLine(); // 换行
}
运行此代码将仅打印奇数元素,结果如下:
1 3
5
7 9
要打印矩阵的主对角线(从左上角到右下角,例如 1, 5, 9
),我们可以利用 i == j
的条件。此条件检查行索引和列索引是否相等:
for (int i = 0; i < matrix.GetLength(0); i++)
{
for (int j = 0; j < matrix.GetLength(1); j++)
{
if (i == j)
{
Console.Write(matrix[i, j] + " ");
}
else
{
Console.Write(" "); // 保持输出的对齐格式
}
}
Console.WriteLine();
}
此代码将输出:
1
5
9
实际上,我们可以用单个 for
循环来打印主对角线。因为主对角线上的元素总是 matrix[i, i]
位置:
for (int i = 0; i < matrix.GetLength(0); i++)
{
Console.WriteLine(matrix[i, i]);
}
输出结果将是:
1
5
9
要打印副对角线(从右上角到左下角,例如 3, 5, 7
),我们需要一个递减的列索引。可以在 for
循环中使用双计数器来实现这一点:
for (int i = 0, j = matrix.GetLength(1) - 1; i < matrix.GetLength(0); i++, j--)
{
Console.WriteLine(matrix[i, j]);
}
在此代码中:
i
从 0
开始并递增,表示行索引。j
从 matrix.GetLength(1) - 1
(最后一列)开始并递减,表示列索引。运行结果如下:
3
5
7
for
循环适合用于遍历和操作多维数组。for each
循环适合仅遍历数据而不修改数据。for
循环可以简化特定对角线操作,但需要谨慎使用以确保代码的可读性。通过这几种方法,我们可以灵活地处理多维数组中的不同元素。希望这些方法能够帮助您更好地理解二维数组的操作。下一个视频中,我们将继续深入探讨数组与循环的高级应用!
欢迎回来!在这个视频中,我们将接受一个小挑战,创建一个简单的井字棋(Tic Tac Toe)控制台游戏。这一项目将整合之前学习的二维数组、循环、条件语句以及输入验证等内容。我们将从基础的游戏界面开始,逐步实现玩家选择、获胜检测、错误提示以及游戏重置等功能。以下是实现的完整步骤。
首先,我们将定义一个 setField
方法,来在控制台上显示一个井字棋游戏板,初始状态为 1 到 9 的数字,代表可选位置。我们会使用控制台输出来绘制井字棋的样式。
public static void SetField()
{
Console.WriteLine(" | | ");
Console.WriteLine(" 1 | 2 | 3 ");
Console.WriteLine("_____|_____|_____");
Console.WriteLine(" | | ");
Console.WriteLine(" 4 | 5 | 6 ");
Console.WriteLine("_____|_____|_____");
Console.WriteLine(" | | ");
Console.WriteLine(" 7 | 8 | 9 ");
Console.WriteLine(" | | ");
}
为了管理游戏状态,我们将使用一个二维字符数组来表示井字棋的九个格子。初始时,每个格子用对应的数字字符表示。
static char[,] playField =
{
{'1', '2', '3'},
{'4', '5', '6'},
{'7', '8', '9'}
};
接下来,我们实现用户输入,使用 do-while
循环确保用户输入有效的数字并且选择一个未被占用的格子。同时我们会检测用户输入是否为数字。
int player = 2;
bool inputCorrect = false;
int input;
do
{
Console.WriteLine($"Player {player} - Choose your field: ");
try
{
input = Convert.ToInt32(Console.ReadLine());
// 检查输入是否在1到9之间,并且该位置是否已经被占用
if (input >= 1 && input <= 9 && CheckFieldAvailable(input))
{
inputCorrect = true;
}
else
{
Console.WriteLine("Incorrect input. Please use an available field.");
inputCorrect = false;
}
}
catch
{
Console.WriteLine("Please enter a valid number.");
}
} while (!inputCorrect);
我们将使用方法 UpdateField
来更新用户选择的格子。同时,设置玩家交替,以便轮到另一位玩家。
static void UpdateField(int input, int player)
{
char playerSign = player == 1 ? 'O' : 'X';
switch (input)
{
case 1: playField[0, 0] = playerSign; break;
case 2: playField[0, 1] = playerSign; break;
case 3: playField[0, 2] = playerSign; break;
case 4: playField[1, 0] = playerSign; break;
case 5: playField[1, 1] = playerSign; break;
case 6: playField[1, 2] = playerSign; break;
case 7: playField[2, 0] = playerSign; break;
case 8: playField[2, 1] = playerSign; break;
case 9: playField[2, 2] = playerSign; break;
}
}
胜利的条件是玩家的符号在行、列或对角线上连续出现。我们使用多个 if
条件来检查这些情况。
static bool CheckWinCondition(char playerChar)
{
if ((playField[0, 0] == playerChar && playField[0, 1] == playerChar && playField[0, 2] == playerChar) ||
(playField[1, 0] == playerChar && playField[1, 1] == playerChar && playField[1, 2] == playerChar) ||
(playField[2, 0] == playerChar && playField[2, 1] == playerChar && playField[2, 2] == playerChar) ||
(playField[0, 0] == playerChar && playField[1, 0] == playerChar && playField[2, 0] == playerChar) ||
(playField[0, 1] == playerChar && playField[1, 1] == playerChar && playField[2, 1] == playerChar) ||
(playField[0, 2] == playerChar && playField[1, 2] == playerChar && playField[2, 2] == playerChar) ||
(playField[0, 0] == playerChar && playField[1, 1] == playerChar && playField[2, 2] == playerChar) ||
(playField[0, 2] == playerChar && playField[1, 1] == playerChar && playField[2, 0] == playerChar))
{
return true;
}
return false;
}
当所有格子都被占用而未分出胜负时,游戏判定为平局。我们可以通过一个计数器来统计已用格子。
static bool CheckDraw()
{
foreach (var item in playField)
{
if (item != 'X' && item != 'O') return false;
}
return true;
}
当有胜利者或平局出现时,我们提示用户按任意键重新开始,并重置 playField
为初始状态。
static void ResetField()
{
playField = new char[,] {
{ '1', '2', '3' },
{ '4', '5', '6' },
{ '7', '8', '9' }
};
Console.Clear();
SetField();
}
在主程序中,我们将以上功能整合在 do-while
循环中,使玩家交替选择,并在游戏结束时重置。
static void Main(string[] args)
{
int player = 2;
bool inputCorrect = true;
SetField();
do
{
player = player == 1 ? 2 : 1;
char playerChar = player == 1 ? 'O' : 'X';
Console.WriteLine($"Player {player}'s turn ({playerChar}):");
int input = GetUserInput();
if (inputCorrect)
{
UpdateField(input, player);
SetField();
if (CheckWinCondition(playerChar))
{
Console.WriteLine($"Player {player} has won!");
Console.WriteLine("Press any key to reset the game.");
Console.ReadKey();
ResetField();
continue;
}
if (CheckDraw())
{
Console.WriteLine("It's a draw!");
Console.WriteLine("Press any key to reset the game.");
Console.ReadKey();
ResetField();
}
}
} while (true);
}
通过这个项目,我们完成了一个简单的井字棋控制台游戏,实现了二维数组的使用、循环、输入验证和游戏逻辑检测。这个挑战不仅复习了之前学过的概念,同时也增强了编程逻辑的应用能力。希望你在练习的过程中掌握了新技能!
在下一节中,我们将进入更深入的编程主题。再见!
欢迎回来!在这个视频中,我们将讨论锯齿数组的概念。不同于二维数组,锯齿数组中的每一行可以包含不同数量的元素。锯齿数组实际上是“数组中的数组”,这让它们非常灵活且适用于许多需要不规则数据结构的场景。
我们可以在 Main
方法中创建一个简单的锯齿数组。以下是声明和初始化一个包含整型值的锯齿数组的步骤:
int[][] jaggedArray = new int[3][]; // 声明一个包含 3 个数组的锯齿数组
在这行代码中,我们声明了一个包含三个数组的锯齿数组,但这些数组目前都是空的。我们可以为每个数组指定不同的长度。
接下来,我们将为 jaggedArray
中的每个子数组分配不同的长度,并为它们填充值:
jaggedArray[0] = new int[5]; // 第一个子数组包含 5 个元素
jaggedArray[1] = new int[3]; // 第二个子数组包含 3 个元素
jaggedArray[2] = new int[2]; // 第三个子数组包含 2 个元素
此外,我们可以直接在初始化时为每个数组分配具体的值:
jaggedArray[0] = new int[] { 2, 3, 5, 7, 11 };
jaggedArray[1] = new int[] { 1, 2, 3 };
jaggedArray[2] = new int[] { 13, 21 };
在这种情况下,我们在初始化时直接提供了数组的值,省去了单独分配数组长度的步骤。
我们还可以在声明时直接为锯齿数组赋值,这样代码更加简洁:
int[][] jaggedArray = new int[][]
{
new int[] { 2, 3, 5, 7, 11 },
new int[] { 1, 2, 3 },
new int[] { 13, 21 }
};
在这种方式下,锯齿数组和其中的子数组都在同一行代码中初始化。
要访问锯齿数组中的具体元素,我们可以通过“数组的索引 + 子数组的索引”来定位。例如,如果我们想访问第一个子数组的第三个元素,可以这样做:
Console.WriteLine($"The value at jaggedArray[0][2] is {jaggedArray[0][2]}");
在上面的示例中,jaggedArray[0][2]
代表第一个数组中的第三个元素(索引从 0 开始),因此它输出的是 5
。
我们可以使用嵌套的 for
循环或 foreach
循环来遍历整个锯齿数组。以下是如何遍历并打印锯齿数组所有值的示例:
for (int i = 0; i < jaggedArray.Length; i++)
{
Console.WriteLine($"Array {i}:");
for (int j = 0; j < jaggedArray[i].Length; j++)
{
Console.Write($"{jaggedArray[i][j]} ");
}
Console.WriteLine();
}
在这个例子中,外层 for
循环遍历 jaggedArray
的每一个子数组,内层 for
循环遍历每个子数组的元素,依次打印出每个元素的值。
foreach
循环为了让代码更简单清晰,我们还可以使用嵌套的 foreach
循环来遍历锯齿数组:
foreach (int[] array in jaggedArray)
{
foreach (int value in array)
{
Console.Write($"{value} ");
}
Console.WriteLine();
}
这里,外层 foreach
循环依次遍历 jaggedArray
中的每个子数组,内层 foreach
循环则遍历当前子数组中的每个元素。这样可以更直观地查看数组内容。
锯齿数组是一种强大的数据结构,能够存储不规则长度的数据,可以灵活适应许多编程场景。通过创建、初始化、访问以及遍历锯齿数组的不同方式,我们可以处理和管理复杂的数据结构。
在接下来的视频中,我们将学习如何将数组作为参数传递到方法中,以便更高效地处理数据。
在我们开始练习之前,确实有必要深入了解锯齿数组和多维数组的区别以及使用场景。这样可以帮助我们更好地理解如何在不同的情况下使用它们。
锯齿数组,也称为“数组的数组”,是一个数组,其中的元素也是数组。这种结构的关键特点是每个子数组的大小可以不同,从而提供了高度的灵活性。
假设我们需要用数字来表示一个三角形。锯齿数组是完成这项任务的理想选择,因为三角形的每一行可以包含不同数量的元素。
int[][] triangle = new int[][]
{
new int[] { 1 },
new int[] { 1, 2 },
new int[] { 1, 2, 3 },
new int[] { 1, 2, 3, 4 }
};
在这个例子中,我们创建了一个名为 triangle
的锯齿数组,其中包含四行,每行包含不同数量的元素。然后我们可以使用嵌套的 foreach
循环来遍历并打印出每一行的元素:
foreach (int[] row in triangle)
{
foreach (int num in row)
{
Console.Write(num + " ");
}
Console.WriteLine();
}
在这个循环中,外层的 foreach
循环遍历 triangle
数组的每一行,而内层的 foreach
循环则遍历当前行的每个元素,并将其打印出来。最终,我们获得了一个三角形结构,其中每行的元素数量逐渐增加。
锯齿数组的这种结构特性使其适用于表示形状不规则的数据结构,例如稀疏矩阵、阶梯状数据等。
与锯齿数组不同,多维数组在所有维度上大小固定且一致。这意味着每一行中的元素数量相同,适合需要行列对称的情况。
我们来看看如何使用多维数组创建一个2x2的数字矩阵:
int[,] grid = new int[2, 2]
{
{ 1, 2 },
{ 3, 4 }
};
这里我们定义了一个2x2的矩阵 grid
,并直接初始化其元素。在遍历和打印这个矩阵时,我们可以使用嵌套的 for
循环:
for (int i = 0; i < grid.GetLength(0); i++)
{
for (int j = 0; j < grid.GetLength(1); j++)
{
Console.Write(grid[i, j] + " ");
}
Console.WriteLine();
}
在这个示例中,外层 for
循环遍历行,内层 for
循环遍历列,并打印出每个位置的值。因为多维数组在所有维度上大小一致,它特别适合用于需要执行数学运算的网格结构,例如矩阵乘法、图像处理等。
使用锯齿数组:当你需要表示元素数量不等的多行数据时,例如稀疏矩阵、三角形、楼层结构等。锯齿数组允许每行包含不同数量的元素,从而提供更灵活的数据结构。
使用多维数组:当你需要执行网格上的数学运算,且需要一致的行列结构时,例如矩阵运算、图像处理和坐标映射等。多维数组提供了简洁且一致的行列结构,非常适合这些操作。
锯齿数组和多维数组各有优缺点,取决于具体需求。锯齿数组提供更大的灵活性,适合不同大小的数据结构;而多维数组则结构统一,更适合数学运算。
希望通过这些讲解,你对锯齿数组和多维数组的选择有了更清晰的认识。现在我们可以继续进行练习,进一步巩固这些知识。
在这个视频中,我们会通过一个小挑战,进一步巩固对锯齿数组的理解和使用。目标是创建一个包含不同朋友及其家庭成员的锯齿数组,并让其中的家庭成员互相介绍。
例如,如果有一个朋友叫 Marta,她的兄弟分别是 Joe 和 Michael,那么数组中会有一项是包含 Joe
和 Michael
的数组。
首先,我们来创建这个锯齿数组:
string[][] friendsAndFamily = new string[][]
{
new string[] { "Michael", "Sandy" }, // 第一个朋友的家庭成员
new string[] { "Frank", "Claudia" }, // 第二个朋友的家庭成员
new string[] { "Andrew", "Michelle" } // 第三个朋友的家庭成员
};
在上面的代码中,friendsAndFamily
是一个锯齿数组,它包含三个不同的家庭,每个家庭有两个成员。
接下来,我们将通过控制台将不同家庭的成员互相介绍。这里,我们会使用 Console.WriteLine
来格式化输出:
Console.WriteLine("Hi {0}, I would like to introduce {1} to you.", friendsAndFamily[0][0], friendsAndFamily[1][0]);
Console.WriteLine("Hi {0}, I would like to introduce {1} to you.", friendsAndFamily[1][1], friendsAndFamily[2][0]);
Console.WriteLine("Hi {0}, I would like to introduce {1} to you.", friendsAndFamily[0][1], friendsAndFamily[2][1]);
在此代码中,我们调用不同位置的数组成员,使用 {0}
和 {1}
占位符来生成类似以下格式的输出:
using System;
class Program
{
static void Main()
{
// 声明并初始化朋友和家庭成员的锯齿数组
string[][] friendsAndFamily = new string[][]
{
new string[] { "Michael", "Sandy" }, // 第一个朋友的家庭成员
new string[] { "Frank", "Claudia" }, // 第二个朋友的家庭成员
new string[] { "Andrew", "Michelle" } // 第三个朋友的家庭成员
};
// 介绍不同家庭的成员
Console.WriteLine("Hi {0}, I would like to introduce {1} to you.", friendsAndFamily[0][0], friendsAndFamily[1][0]);
Console.WriteLine("Hi {0}, I would like to introduce {1} to you.", friendsAndFamily[1][1], friendsAndFamily[2][0]);
Console.WriteLine("Hi {0}, I would like to introduce {1} to you.", friendsAndFamily[0][1], friendsAndFamily[2][1]);
// 防止控制台窗口立即关闭
Console.ReadKey();
}
}
new string[]
创建多个朋友的家庭成员数组。Console.WriteLine
将不同家庭的成员互相介绍,每行代码指定一个朋友的某个家庭成员与另一位朋友的家庭成员见面。{0}
和 {1}
实现字符串插值,使代码更简洁并便于复用。可以将每个朋友的家庭单独定义为数组,并在 friendsAndFamily
中引用这些数组,这样更具可读性。例如:
string[] michaelsFamily = { "Michael", "Sandy" };
string[] franksFamily = { "Frank", "Claudia" };
string[] andrewsFamily = { "Andrew", "Michelle" };
string[][] friendsAndFamily = { michaelsFamily, franksFamily, andrewsFamily };
这种方式将每个朋友的家庭单独存储,有助于理解哪些家庭成员属于哪个朋友。
本次练习展示了如何使用锯齿数组存储和处理不规则数据结构,并通过简单的字符串插值实现家庭成员之间的介绍。这不仅让我们熟悉了锯齿数组的基本操作,也增强了对复杂数据存储的理解。
在本视频中,我们介绍了如何将数组作为参数传递给方法。这使我们能够对数组中的数据进行集中处理,例如计算平均值、修改数组中的值等。以下是示例代码和练习,帮助你更深入地理解和掌握该技巧。
我们将创建一个方法 GetAverage
来计算给定成绩数组的平均值,并返回这个平均值。
using System;
class Program
{
static void Main()
{
// 创建并初始化成绩数组
int[] studentsGrades = { 15, 13, 8, 12, 6, 16 };
// 调用 GetAverage 方法并输出结果
double averageResult = GetAverage(studentsGrades);
Console.WriteLine("The average is: {0}", averageResult);
// 输出每个成绩的值
foreach (int grade in studentsGrades)
{
Console.WriteLine("Grade: {0}", grade);
}
Console.ReadKey();
}
// 计算成绩平均值的方法
static double GetAverage(int[] gradeArray)
{
int size = gradeArray.Length;
double sum = 0;
// 累加成绩数组中的所有值
for (int i = 0; i < size; i++)
{
sum += gradeArray[i];
}
// 计算平均值并返回
return sum / size;
}
}
GetAverage
方法接收成绩数组,计算其平均值,并返回给主方法。编写一个方法 SunIsShining
,通过将数组中的每个元素增加 2 来提高幸福值。然后输出修改后的数组值。
int
类型的数组 happiness
,包含五个值。SunIsShining
方法,接收 int
数组作为参数,并将每个值增加 2。SunIsShining
方法,并将 happiness
数组传入。foreach
循环遍历并输出修改后的数组值。using System;
class Program
{
static void Main()
{
// 创建幸福值数组
int[] happiness = { 2, 3, 4, 5, 6 };
// 调用 SunIsShining 方法提升幸福值
SunIsShining(happiness);
// 输出修改后的幸福值
foreach (int level in happiness)
{
Console.WriteLine("Happiness level: {0}", level);
}
Console.ReadKey();
}
// SunIsShining 方法,用于增加幸福值
static void SunIsShining(int[] x)
{
for (int i = 0; i < x.Length; i++)
{
x[i] += 2; // 将每个元素增加2
}
}
}
happiness
用于存储五个不同的幸福值。SunIsShining
方法:遍历数组,将每个元素增加 2。foreach
循环,输出更新后的幸福值。本视频中,我们学习了如何将数组作为参数传递给方法,以及如何在方法中处理数组数据。这种技巧在处理大量数据、需要集中计算或批量修改时非常有用。
请确保熟练掌握数组和方法参数的结合使用,这将让你能够编写更简洁、更模块化的代码。
params
关键字的用法在本视频中,我们深入探讨了 params
关键字。该关键字允许方法接收可变数量的参数,让你无需指定具体数量,可以根据需求传递任意数量的参数。这对于处理动态输入或不确定数量的参数非常有用。下面我们通过示例代码详细说明如何使用 params
关键字。
params
关键字实现动态参数我们首先创建一个 params
方法,接收任意数量的字符串,并在控制台上输出。
using System;
class Program
{
static void Main()
{
// 调用自定义的 ParamsMethod 方法
ParamsMethod("This", "is", "a", "dynamic", "sentence.");
// 尝试不同数量的参数
ParamsMethod("Another", "example", "with", "different", "words.");
// 调用方法时不传递任何参数
ParamsMethod();
Console.ReadKey();
}
// 使用 params 关键字的自定义方法
static void ParamsMethod(params string[] sentence)
{
for (int i = 0; i < sentence.Length; i++)
{
Console.Write(sentence[i] + " ");
}
Console.WriteLine(); // 换行
}
}
ParamsMethod
:传递不同数量的字符串参数,params
关键字允许我们传递任意数量的参数。ParamsMethod
方法:该方法接收一个 string
类型的数组参数 sentence
,并使用 for
循环逐个输出数组中的元素。params
允许不传递参数,当不传递任何参数时,数组 sentence
长度为零,代码不会抛出异常。运行结果:
This is a dynamic sentence.
Another example with different words.
在该示例中,我们将创建一个可以接受不同数据类型的 params
方法。通过使用 object
类型的数组,我们可以传递任意类型的参数。
using System;
class Program
{
static void Main()
{
// 定义不同类型的变量
int price = 50;
float pi = 3.14f;
char symbol = '@';
string book = "The Hobbit";
// 调用 ParamsMethod2 并传递不同类型的参数
ParamsMethod2(price, pi, symbol, book);
// 直接传递值
ParamsMethod2("Another Example", 55.3, '$');
Console.ReadKey();
}
// ParamsMethod2 接收不同类型的参数
static void ParamsMethod2(params object[] items)
{
foreach (var item in items)
{
Console.Write(item + " ");
}
Console.WriteLine(); // 换行
}
}
ParamsMethod2
:使用 params
关键字和 object
数组,这样可以接受任意类型和数量的参数。foreach
循环逐个输出传入的每个参数值。运行结果:
50 3.14 @ The Hobbit
Another Example 55.3 $
params
关键字的优势object
类型的 params
参数时,可以接收任意类型的参数。params
关键字在处理不确定数量和类型的参数时非常实用。例如,当需要打印日志、动态输出或接收用户输入的多个值时,params
能有效简化代码结构。希望通过本视频的讲解,你能够更灵活地运用 params
关键字来编写更加动态、健壮的程序。在下一视频中,我们将深入探讨 params
关键字在实际应用中的高级用法。
params
关键字的使用场景与优缺点我们已经知道 params
关键字可以让方法接受可变数量的参数,这在某些情况下非常有用,因为它允许调用者传递任意数量的参数甚至不传递任何参数。这为我们的代码增加了灵活性。接下来,我们会通过一些示例来详细了解 params
的实际应用、优缺点以及在实际项目中的使用建议。
params
来计算未知数量的整数之和假设我们要创建一个方法来计算多个整数的总和,而我们并不确定要传递多少个整数。这种场景下,params
可以很好地帮助我们实现灵活的参数传递。
using System;
class Program
{
static void Main()
{
Console.WriteLine(Sum(1, 2, 3)); // 输出 6
Console.WriteLine(Sum(10, 20, 30, 40)); // 输出 100
Console.WriteLine(Sum()); // 输出 0,因为没有传递参数
}
// Sum 方法使用 `params` 来接受可变数量的整数
static int Sum(params int[] numbers)
{
int total = 0;
foreach (int number in numbers)
{
total += number;
}
return total;
}
}
在此示例中,我们创建了一个 Sum
方法,接受 params
整数数组作为参数。无论传入多少个整数,方法都会计算它们的和,或者在没有参数时返回 0
。
params
计算平均值假设我们想创建一个方法来计算多个整数的平均值,params
可以帮助我们接收不确定数量的整数并计算平均值。然而,由于 params
参数必须是方法中的最后一个参数,因此如果需要额外的参数(比如单独传递一个 count
变量),就会受到限制。
using System;
class Program
{
static void Main()
{
Console.WriteLine(Average(4, 5, 6, 7)); // 输出 5.5
Console.WriteLine(Average(10, 20, 30, 40)); // 输出 25
}
// Average 方法计算未知数量的整数的平均值
static double Average(params int[] numbers)
{
if (numbers.Length == 0)
return 0;
int total = 0;
int count = 0;
foreach (int number in numbers)
{
total += number;
count++;
}
return (double)total / count;
}
}
在这个例子中,我们使用 params
来接受多个整数并计算平均值。由于 params
参数必须位于方法参数的最后一个位置,因此我们无法再添加额外参数(例如一个 int count
参数),需要在方法内部自行计算参数个数。
params
必须是方法中的最后一个参数,限制了它在某些场景下的实用性。params
会创建数组,这在某些情况下可能会带来轻微的性能损耗。params
params
是非常合适的选择。params
能够显著减少调用者的代码复杂度(例如避免手动创建数组),它是一个优雅的解决方案。params
的场景params
,以免误导调用者,认为可以传递任意数量的参数。params
过度使用会导致代码难以理解,尤其是当一个方法接受不同类型的参数时。最好保持代码简洁,减少不必要的灵活性。params
的单一限制可能会使实现复杂化。params
关键字为 C# 提供了灵活性,允许方法接受不确定数量的参数。然而,由于它只能用于方法的最后一个参数,并且可能会影响性能,因此在使用时需要谨慎。选择 params
的关键在于是否能显著简化代码以及满足实际的业务需求。
在接下来的视频中,我们会继续探讨一些 params
的实际应用场景,以帮助你更好地掌握这一关键字的应用。
params
关键字的实际案例:自定义最小值计算方法在上个视频中,我们了解了 params
关键字的基本用法和简单的示例。但这些示例相对较简单,缺乏真实的应用场景。因此,这里我们将深入到更具现实意义的应用中,创建一个自定义的最小值计算方法,能够接受不确定数量的参数并返回其中的最小值。通过这个例子,你将更好地理解如何在实际编程中使用 params
。
我们希望创建一个类似于 Math.Min
的方法,但不仅限于两个参数,而是可以接受多个参数,甚至可以达到七个或十五个参数,并返回这些参数中的最小值。
我们将创建一个新的 minV2
方法,它将使用 params
关键字来接受任意数量的整数参数,然后找到并返回其中的最小值。
using System;
class Program
{
static void Main()
{
// 调用我们的自定义 min 方法,传递多个整数作为参数
int result = MinV2(6, 4, 2, 8, 0, 1, 5);
Console.WriteLine($"The minimum number is: {result}"); // 期望输出:0
}
// MinV2 方法:接受任意数量的整数参数并返回最小值
public static int MinV2(params int[] numbers)
{
// 初始化最小值为 int 的最大值
int min = int.MaxValue;
// 使用 for each 循环遍历所有参数
foreach (int number in numbers)
{
// 如果当前数比当前最小值小,则更新最小值
if (number < min)
{
min = number;
}
}
// 返回找到的最小值
return min;
}
}
在这里,minV2
方法使用 params
来接受任意数量的整数。方法内部将 min
初始化为 int.MaxValue
(整数的最大值),然后遍历所有传入的参数,并将最小值赋给 min
。最终返回 min
作为结果。
调用 MinV2
方法并传递多个参数:
// 示例调用
Console.WriteLine(MinV2(6, -4, 15, 8, 10)); // 输出 -4
Console.WriteLine(MinV2(12, 25, 7, 3, 9)); // 输出 3
Console.WriteLine(MinV2(-10, -20, -30, -40)); // 输出 -40
min
被初始化为 int.MaxValue
,即一个非常大的数。foreach
遍历传入的参数。number
和当前 min
,如果 number
较小,则更新 min
。min
值,即所有参数中的最小值。params
此示例展示了在以下情况中使用 params
的优势:
MinV2
方法可用于多个项目或模块中,可以轻松用于寻找一组数据中的最小值,而不必每次都编写复杂的循环逻辑。params
是 C# 中的一个强大工具,可以让方法更加灵活,适用于需要处理可变参数数量的场景。虽然在某些情况下可能带来性能损耗(如传递大量数据时),但在大多数场景中,它极大地简化了代码,使方法的定义和调用更具灵活性。
在接下来的视频中,我们会探讨 params
在更多实际项目中的应用,让你在编写代码时更清楚何时使用这一功能。
在本课中,我们将学习 泛型集合 和 非泛型集合,这是 C# 编程中的重要主题之一。在此,我们将探索集合的基本概念,并介绍何时以及如何使用这些集合类型。
集合(Collection) 是一种数据结构,与数组相似,可以一次存储多个对象,形成对象的集合,因而称为“集合”。与数组不同,集合具有以下特点:
设想以下场景:我们正在为学校开发一个考勤系统,需要动态添加学生。虽然可以使用数组来存储学生对象,但数组存在以下局限性:
集合 允许我们更高效地存储、管理和操作多个对象,可以用来执行添加、删除、替换、搜索特定对象以及复制对象等操作。
C# 提供了两种集合类型:
System.Collections
命名空间中。System.Collections.Generic
命名空间中。非泛型集合允许存储不同类型的对象。例如,我们可以创建一个 ArrayList
,并向其中添加整数、浮点数、字符串等不同类型的数据。
using System;
using System.Collections;
class Program
{
static void Main()
{
// 定义不同类型的变量
int num1 = 5;
float num2 = 3.15f;
string name = "Dennis";
// 创建一个非泛型集合 ArrayList
ArrayList myArrayList = new ArrayList();
// 向集合中添加不同类型的元素
myArrayList.Add(num1); // 添加整数
myArrayList.Add(num2); // 添加浮点数
myArrayList.Add(name); // 添加字符串
// 使用 for each 遍历集合中的元素
foreach (object element in myArrayList)
{
Console.WriteLine(element);
}
}
}
在此示例中,ArrayList
是一个非泛型集合,允许我们向其中添加不同类型的数据,包括 int
、float
和 string
。遍历时使用 object
作为元素的类型,因为集合中的元素类型不唯一。
与非泛型集合不同,泛型集合只能存储单一类型的对象。比如,我们可以创建一个只存储字符串的 List
集合。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 定义一个泛型集合 List,用于存储字符串
List<string> myList = new List<string>();
// 向集合中添加字符串类型的元素
myList.Add("Animal1");
myList.Add("Animal2");
myList.Add("Animal3");
// 使用 for each 遍历集合中的元素
foreach (string animal in myList)
{
Console.WriteLine(animal);
}
}
}
在此示例中,List<string>
是一个泛型集合,仅能存储字符串。定义时通过 <string>
指定集合的类型,确保集合内的所有元素都是 string
类型。遍历集合时,无需使用 object
类型,而是直接使用 string
。
本节课介绍了 C# 中集合的基本概念以及泛型和非泛型集合的区别。在接下来的课程中,我们将详细探索不同的集合类型,并了解它们的具体应用场景。
本节将详细介绍 C# 中的 ArrayList 集合,包括其常用方法和属性。我们将了解如何声明、添加和操作 ArrayList 中的元素,同时探索为何使用对象 (object
) 数据类型来处理多种类型的数据。
ArrayList 是一种非泛型集合,可以存储不同类型的对象,且不受大小限制。这使得 ArrayList 特别适合存储多种类型数据。要使用 ArrayList,我们需要引入 System.Collections
命名空间,因为它属于此命名空间。
using System.Collections;
ArrayList 可以声明为不限制大小的,也可以指定初始大小。
// 不限制大小的 ArrayList
ArrayList myArrayList = new ArrayList();
// 指定初始大小为 100 的 ArrayList
ArrayList myArrayListWithCapacity = new ArrayList(100);
在 ArrayList 中,可以使用 .Add()
方法添加元素,并且可以存储不同类型的对象,例如 int
、string
、double
等:
myArrayList.Add(25); // 添加整数
myArrayList.Add("Hello"); // 添加字符串
myArrayList.Add(13.37); // 添加浮点数
myArrayList.Add(13);
myArrayList.Add(128);
myArrayList.Add(25.3);
删除指定值的元素:使用 .Remove()
方法删除第一个匹配的元素。
myArrayList.Remove(13); // 删除第一个值为 13 的元素
删除指定位置的元素:使用 .RemoveAt()
方法删除指定索引处的元素。
myArrayList.RemoveAt(0); // 删除索引 0 处的元素
计算元素个数:使用 .Count
属性获取 ArrayList 中的元素数量。
int count = myArrayList.Count;
Console.WriteLine("元素数量: " + count);
我们可以使用 for each
循环遍历 ArrayList 中的元素,并对不同类型的元素进行不同的操作。由于 ArrayList 存储多种类型的对象,因此使用 object
作为元素的类型,便于处理各种数据类型。
double sum = 0;
foreach (object element in myArrayList)
{
if (element is int)
{
sum += Convert.ToDouble(element); // 将 int 转换为 double 并加到 sum
}
else if (element is double)
{
sum += (double)element; // 直接将 double 类型加到 sum
}
else if (element is string)
{
Console.WriteLine("字符串值: " + element); // 输出字符串
}
}
Console.WriteLine("数值总和: " + sum);
object
数据类型?在遍历 ArrayList 时,使用 object
类型作为元素的类型可以灵活处理多种数据类型。因为在 C# 中,object
是所有类型的基类,允许我们在同一个集合中存储不同类型的元素。使用 object
可以确保代码在处理 ArrayList 中的整数、浮点数或字符串时灵活适应各种数据类型。
假设我们向 myArrayList
中添加了以下元素,并且执行了上面的代码:
// 添加元素
myArrayList.Add(25);
myArrayList.Add("Hello");
myArrayList.Add(13.37);
myArrayList.Add(13);
myArrayList.Add(128);
myArrayList.Add(25.3);
// 删除一些元素
myArrayList.Remove(13);
myArrayList.RemoveAt(0);
在此操作后,Count
属性返回元素数量,代码将计算数值类型元素的总和,并输出字符串类型的元素。
运行结果示例:
元素数量: 5
字符串值: Hello
数值总和: 166.67
ArrayList 提供了一个灵活的方式来存储和操作多种类型的数据,通过使用 object
类型,我们可以轻松遍历 ArrayList 中的所有元素并对不同类型进行处理。在后续课程中,我们将进一步探索 ArrayList 和其他集合类型的用法及其特定方法。
在 C# 中,列表 (List) 是一种非常灵活的集合类型,相比数组,它允许我们动态地增减元素。因此列表在需要存储动态对象集合时非常实用。我们将了解如何声明、添加和移除元素、访问特定索引位置的元素、清空列表以及遍历列表中的元素。
System.Collections.Generic
命名空间来使用它。using System.Collections.Generic;
创建一个空的整数列表,或直接初始化包含元素的列表:
// 声明空列表
List<int> numbers = new List<int>();
// 声明并初始化含有元素的列表
List<int> numbersWithValues = new List<int> { 1, 2, 3, 4, 5 };
使用 .Add()
方法可以添加元素,使用 .Remove()
方法可以移除元素。
// 添加元素
numbers.Add(7);
// 移除元素
numbers.Remove(7);
还可以使用 .RemoveAt()
方法按索引移除特定位置的元素:
// 移除索引 0 处的元素
numbers.RemoveAt(0);
与数组类似,可以通过索引来访问列表中的元素。例如,要获取索引为 0
的第一个元素:
int firstValue = numbers[0];
使用 .Clear()
方法可以清空列表中的所有元素:
// 清空列表
numbers.Clear();
清空后,列表将不包含任何元素,Count
属性将为 0
。
可以使用 for each
或 for
循环遍历列表:
for each
循环List<int> numbers = new List<int> { 5, 10, 15, 20, 25, 30, 35, 40 };
foreach (int element in numbers)
{
Console.WriteLine(element); // 输出每个元素
}
for
循环for (int i = 0; i < numbers.Count; i++)
{
Console.WriteLine(numbers[i]); // 输出每个元素
}
在循环中,Count
属性非常有用,它返回列表中的元素个数,帮助我们避免索引越界错误。
除了上述方法外,列表还包含其他有用的属性和方法,适合进一步探索。通过使用不同的方法和属性,您可以对列表进行查找、排序、复制等操作。建议多实践,创建并操作不同的列表,熟悉其用法和特点。
列表是一种灵活、易用的集合类型,适合动态数据存储需求。通过掌握列表的基本方法和属性,您将能够高效地管理对象集合并在项目中实现各种操作。在后续课程中,我们将更多地使用列表并探索其他高级特性。
在本课中,我们将学习如何使用哈希表和字典来存储键值对数据。它们在 C# 中是非常有用的集合类型。哈希表与字典都允许使用键 (key) 来快速查找对应的值 (value)。通过对比实际应用场景,我们可以更好地理解它们的作用和区别。
哈希表的主要特点是键值对的存储方式。每个键 (key) 对应一个唯一的值 (value),类似于现实生活中的字典。比如德英字典中的单词“Auto”对应着英语的单词“Car”,这就是一个典型的键值对。哈希表的设计使得我们可以通过键快速找到对应的值。
哈希表的结构:
在 C# 中,哈希表的键和值可以是不同的数据类型,例如键可以是 int
类型而值可以是 object
类型,具体根据需求灵活设定。
在代码中,我们首先通过 System.Collections
命名空间声明哈希表。以下是创建空哈希表的代码示例:
using System.Collections;
Hashtable studentsTable = new Hashtable();
我们将使用学生信息作为例子。假设每个学生有一个唯一的 ID、姓名和 GPA 分数。我们可以将学生对象以其 ID 为键添加到哈希表中:
studentsTable.Add(student1.ID, student1);
studentsTable.Add(student2.ID, student2);
studentsTable.Add(student3.ID, student3);
studentsTable.Add(student4.ID, student4);
在这里,student1.ID
是键,而 student1
本身作为对象存储为值。
要从哈希表中检索特定的数据,可以通过键直接访问对应的值:
Student storedStudent = (Student)studentsTable[student1.ID];
Console.WriteLine($"Student ID: {storedStudent.ID}, Name: {storedStudent.Name}, GPA: {storedStudent.GPA}");
此处我们将返回的数据类型转换为 Student
,以便访问其属性。这一步非常重要,因为哈希表的值默认是 object
类型。
如果我们想遍历哈希表中的所有条目,C# 提供了 DictionaryEntry
结构,可以帮助我们轻松获取每个键值对:
foreach (DictionaryEntry entry in studentsTable)
{
Student tempStudent = (Student)entry.Value;
Console.WriteLine($"Student ID: {tempStudent.ID}, Name: {tempStudent.Name}, GPA: {tempStudent.GPA}");
}
DictionaryEntry
是专门用于哈希表的结构,包含 Key
和 Value
属性。通过 entry.Value
获取学生对象,并转换为 Student
类型后,就可以访问每个学生的详细信息。
我们可以直接使用 studentsTable.Values
来遍历所有的值,而不需要 DictionaryEntry
:
foreach (Student student in studentsTable.Values)
{
Console.WriteLine($"Student ID: {student.ID}, Name: {student.Name}, GPA: {student.GPA}");
}
在这种方式下,代码更简洁,并且可以直接访问学生对象的属性。
哈希表是一种非常灵活的集合类型,可以用来高效地存储和查找键值对数据。在后续的课程中,我们将探索字典的用法以及更多实际场景中的应用。
在下一节中,我们将有一个小练习,帮助你进一步理解哈希表的使用。
在上一个视频中,我们学习了哈希表的基本操作,现在让我们完成一个小挑战任务。在本任务中,你将编写一个程序,遍历学生数组的每一个元素并将其插入到哈希表中。如果哈希表中已经存在具有相同 ID 的学生,则跳过插入,并显示错误消息:"抱歉,具有相同 ID 的学生已经存在"。
Student
类,它包含以下属性:ID、姓名和 GPA。ContainsKey
方法检查哈希表中是否已存在相同的学生 ID。定义哈希表
首先,我们需要声明一个哈希表来存储学生数据,并引入 System.Collections
命名空间:
using System.Collections;
Hashtable studentsTable = new Hashtable();
创建 Student
类和学生数组
假设我们已经创建了 Student
类,具有 ID、Name 和 GPA 属性。然后我们创建一个包含 5 个学生的数组:
Student[] students = {
new Student(1, "Maria", 98),
new Student(2, "Jason", 85),
new Student(6, "Steve", 92),
new Student(3, "Clara", 89),
new Student(1, "Louise", 80) // 重复的 ID 用于测试
};
遍历学生数组并插入哈希表
使用 for each
循环遍历 students
数组,每个学生对象都会被逐一检查。我们将通过 ContainsKey
方法判断哈希表中是否已经存在该学生的 ID:
foreach (Student student in students)
{
if (!studentsTable.ContainsKey(student.ID))
{
// 将学生插入哈希表
studentsTable.Add(student.ID, student);
Console.WriteLine($"学生已添加:ID = {student.ID}, Name = {student.Name}");
}
else
{
// 如果 ID 已存在,显示错误信息
Console.WriteLine($"抱歉,具有相同 ID ({student.ID}) 的学生已经存在。");
}
}
ContainsKey(student.ID)
:检查哈希表中是否存在当前学生的 ID。运行代码并测试
运行程序,你应该会看到类似的输出,其中会显示哪些学生成功添加,哪些学生因 ID 重复而无法添加。
学生已添加:ID = 1, Name = Maria
学生已添加:ID = 2, Name = Jason
学生已添加:ID = 6, Name = Steve
学生已添加:ID = 3, Name = Clara
抱歉,具有相同 ID (1) 的学生已经存在。
你可以进一步扩展此任务,确保如果 ID 重复,系统会为学生自动生成一个新的 ID,然后继续将其添加到哈希表中。尝试编写代码来实现这一功能。
通过这项任务,你熟悉了哈希表的使用,包括如何插入数据、检测重复项以及有效利用 ContainsKey
方法。掌握这些技能后,哈希表可以帮助你在实际项目中高效地管理和查询数据。希望你完成任务并理解哈希表的应用!
在前几节课中,我们讨论了哈希表的使用。本节课中,我们将讨论哈希表的泛型版本——字典(Dictionary)。字典与哈希表类似,通过键值对的形式存储数据,但字典是泛型集合,这意味着我们需要在声明时定义键和值的类型,以确保数据类型的一致性和安全性。
在 C# 中,字典的定义如下所示:
using System.Collections.Generic;
Dictionary<TKey, TValue> myDictionary = new Dictionary<TKey, TValue>();
TKey
表示键的类型,而 TValue
表示值的类型。这种定义方式与列表(List)类似,我们可以定义键和值的具体数据类型,比如整数、字符串或对象等。例如:
Dictionary<int, string> numberWords = new Dictionary<int, string>
{
{ 1, "One" },
{ 2, "Two" },
{ 3, "Three" }
};
在此例子中,numberWords
字典的键是 int
类型,值是 string
类型。
字典不仅限于基本数据类型,还可以存储更复杂的对象。例如,我们可以创建一个 Employee
类来表示员工信息,并将其存储在字典中:
public class Employee
{
public string Role { get; set; }
public string Name { get; set; }
public int Age { get; set; }
public float Rate { get; set; }
public Employee(string role, string name, int age, float rate)
{
Role = role;
Name = name;
Age = age;
Rate = rate;
}
public float Salary => Rate * 8 * 5 * 4 * 12; // 年薪计算
}
然后,我们可以创建一个字典,以员工角色作为键,员工对象作为值:
Dictionary<string, Employee> employeesDirectory = new Dictionary<string, Employee>();
假设我们有一个员工数据库,可以用 for
循环遍历数据库,并将每个员工添加到 employeesDirectory
字典中:
foreach (Employee emp in employeesDatabase)
{
employeesDirectory.Add(emp.Role, emp);
}
每个角色都是唯一的键,用于识别特定的员工对象。
可以使用键来获取字典中的特定项。例如,假设我们想获取职位为 "CEO" 的员工信息:
if (employeesDirectory.ContainsKey("CEO"))
{
Employee ceo = employeesDirectory["CEO"];
Console.WriteLine($"CEO Name: {ceo.Name}, Salary: {ceo.Salary}");
}
else
{
Console.WriteLine("CEO not found.");
}
这种方法有效防止了当键不存在时引发的异常。
TryGetValue
方法TryGetValue
是一种更安全的方式,用于检索数据并防止键不存在的情况:
if (employeesDirectory.TryGetValue("Intern", out Employee intern))
{
Console.WriteLine($"Intern Name: {intern.Name}, Salary: {intern.Salary}");
}
else
{
Console.WriteLine("Intern not found.");
}
TryGetValue
方法返回一个布尔值,如果键存在,则返回 true
并将值赋给 intern
,否则返回 false
。
ElementAt
方法通过索引访问元素字典内部使用了键值对的集合,可以通过 ElementAt
方法按顺序访问元素,但请注意这种方法的效率不如直接使用键:
for (int i = 0; i < employeesDirectory.Count; i++)
{
var kvp = employeesDirectory.ElementAt(i);
Console.WriteLine($"Key: {kvp.Key}, Name: {kvp.Value.Name}, Salary: {kvp.Value.Salary}");
}
这将按顺序输出字典中的所有键和值。
ContainsKey
和 TryGetValue
提供了安全检索的方法。在下一节课中,我们将深入了解如何更新字典中的现有数据以及如何删除数据。希望这节课帮助你更好地理解字典的使用。
在上一节课中,我们学习了如何创建字典、向字典中添加值以及遍历字典。这次我们来学习如何更新字典中的数据以及如何从字典中删除数据。
首先,让我们来看一个例子,其中我们希望更新特定键对应的值。比如,我们想要更新 HR
键对应的员工信息,将其替换为另一位员工。
string keyToUpdate = "HR";
if (employeesDirectory.ContainsKey(keyToUpdate))
{
// 更新 HR 的员工信息,将其替换为新的员工对象
employeesDirectory[keyToUpdate] = new Employee("HR", "Erika", 26, 35.0f);
Console.WriteLine($"Employee with role '{keyToUpdate}' was updated.");
}
else
{
Console.WriteLine($"Sorry, employee not found with key '{keyToUpdate}'.");
}
在这个例子中,我们首先检查字典 employeesDirectory
是否包含 HR
键。如果包含,我们使用新员工对象更新该键对应的值。使用 dictionary[key] = value
可以快速更新指定键的值。否则,如果键不存在,我们会输出错误消息。
运行代码后可以看到:
Employee with role 'HR' was updated.
这表明更新操作已成功。我们可以在控制台中检查员工的详细信息,确认更新是否成功。
接下来,我们看看如何从字典中删除特定的键值对。假设我们想要删除 Intern
键对应的员工信息:
string keyToRemove = "Intern";
if (employeesDirectory.Remove(keyToRemove))
{
Console.WriteLine($"Employee with role '{keyToRemove}' was removed.");
}
else
{
Console.WriteLine($"No employee found with key '{keyToRemove}'.");
}
在这个例子中,我们使用 Remove
方法,它会返回一个布尔值。如果删除成功,返回 true
,否则返回 false
。删除成功时会输出一条消息,如果键不存在则输出错误信息。
运行代码后可以看到:
Employee with role 'Intern' was removed.
我们可以再次检查字典,确认 Intern
键对应的员工信息已被删除。
Remove
方法非常方便,因为它可以直接检查删除操作是否成功,避免在键不存在时抛出异常。dictionary[key] = value
语法。Remove
方法,该方法返回一个布尔值,指示操作是否成功。希望这节课帮助你更好地理解如何操作字典中的数据。
栈 (Stack) 和队列 (Queue) 是计算机科学中的两种常见数据结构。数据结构是一种数据组织、管理和存储的格式,使数据访问和修改更加高效。不同的数据结构有不同的存储和管理数据的方式,这取决于它们的设计目的。本节课将重点介绍栈和队列,它们在特定的编程场景中非常有用。
栈的特点是遵循 "后进先出" (LIFO) 原则,即最后插入的数据最先被移除。我们可以把栈比作一堆叠放的石头,你可以很容易地拿走最上面的石头,但如果要取出中间的石头,则必须先移走上面的石头。
栈的数据管理方式在很多场景中都有应用,尤其是在数学算法和操作系统的数据管理中。以下是一些常见的栈的应用:
栈也是一种集合 (Collection),像列表 (List) 和字典 (Dictionary) 一样,栈也有其特有的操作:
队列遵循 "先进先出" (FIFO) 原则,即最先加入的数据最先被移除。队列的例子可以参考生活中的排队场景,例如机场登机、餐厅的驾车点餐等。队列通常用于处理数据的顺序非常重要的情况:
队列的特有操作:
接下来,我们将介绍如何在 C# 中实现并使用栈和队列。栈和队列在 C# 中都有内置的类来支持这些数据结构的操作。
C# 中的 Stack
类支持基本的栈操作,如 Push
、Pop
和 Peek
。可以如下创建和操作栈:
using System;
using System.Collections;
class Program
{
static void Main()
{
Stack stack = new Stack();
// 压入数据
stack.Push("第一项");
stack.Push("第二项");
stack.Push("第三项");
// 查看栈顶元素但不移除
Console.WriteLine("栈顶元素:" + stack.Peek());
// 逐一弹出数据
while (stack.Count > 0)
{
Console.WriteLine("弹出:" + stack.Pop());
}
}
}
C# 中的 Queue
类支持队列操作,如 Enqueue
、Dequeue
和 Peek
。可以如下创建和操作队列:
using System;
using System.Collections;
class Program
{
static void Main()
{
Queue queue = new Queue();
// 入队数据
queue.Enqueue("第一个");
queue.Enqueue("第二个");
queue.Enqueue("第三个");
// 查看队头元素但不移除
Console.WriteLine("队头元素:" + queue.Peek());
// 逐一出队数据
while (queue.Count > 0)
{
Console.WriteLine("出队:" + queue.Dequeue());
}
}
}
栈 (Stack):遵循 "后进先出" (LIFO) 原则。使用场景包括数据反转、撤销操作、浏览器后退按钮等。
Push
(压入)、Pop
(弹出)、Peek
(查看栈顶)队列 (Queue):遵循 "先进先出" (FIFO) 原则。使用场景包括操作系统队列管理、服务器请求处理、游戏输入队列等。
Enqueue
(入队)、Dequeue
(出队)、Peek
(查看队头)栈和队列是数据管理中非常有用的结构,掌握它们可以帮助我们更有效地组织和处理数据。在下一节课中,我们将进一步探讨如何在实际编程中灵活使用这些数据结构。
在本视频中,我们将学习如何在 C# 中使用栈。栈是一种先进后出的数据结构,即最后添加的元素最先被移除。接下来,我们将讨论如何定义一个栈、如何向栈中添加数据以及如何删除栈中的数据。
定义栈类似于定义其他集合。栈属于泛型集合,因此我们需要使用 using System.Collections.Generic
引入命名空间。在定义栈时,我们还需要指定栈中的数据类型,例如整数 (int)。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 定义一个整数类型的栈
Stack<int> stack = new Stack<int>();
// 添加元素到栈中
stack.Push(1);
// 查看栈顶元素但不移除
Console.WriteLine("栈顶元素是:" + stack.Peek());
}
}
栈使用 Push
方法在栈顶添加元素,例如:
stack.Push(1);
stack.Push(1337);
每次添加元素,新的元素都会成为栈顶元素。可以通过 Peek
方法查看栈顶元素:
Console.WriteLine("栈顶元素是:" + stack.Peek());
当我们运行代码时,最后被添加到栈中的 1337
将作为栈顶元素被显示。
使用 Pop
方法可以删除并返回栈顶的元素。Pop
的返回值是被删除的元素,例如:
int poppedItem = stack.Pop();
Console.WriteLine("弹出的元素是:" + poppedItem);
如果此时再调用 Peek
,将显示新的栈顶元素。
注意:在栈为空时调用
Pop
会引发错误,因此在使用Pop
前应检查栈是否为空:
if (stack.Count > 0)
{
stack.Pop();
}
可以使用 while
循环逐个删除栈中的元素,直到栈为空:
while (stack.Count > 0)
{
Console.WriteLine("弹出元素:" + stack.Pop());
}
该循环将持续到栈中不再有元素,这种方式展示了栈的 “后进先出 (LIFO)” 原则。
假设我们有一个数组,我们想将其内容反转。可以使用栈来实现:
int[] numbers = { 8, 2, 3, 7, 4, 6, 2 };
Stack<int> myStack = new Stack<int>();
// 将数组元素依次添加到栈中
foreach (int number in numbers)
{
Console.Write(number + " ");
myStack.Push(number);
}
// 换行
Console.WriteLine("\n反转后的数组:");
// 将栈中元素依次弹出,得到反转后的结果
while (myStack.Count > 0)
{
Console.Write(myStack.Pop() + " ");
}
运行结果将显示原数组和反转后的数组。栈在这里的应用展示了其 “后进先出” 特性,非常适合这种需要倒序的场景。
栈在需要倒序处理数据的场景中非常有用,例如浏览器的回退按钮、撤销操作等。在下一节中,我们将探讨另一个常见的数据结构:队列。
在本视频中,我们将学习如何在 C# 中使用队列。队列是一种数据结构,被广泛用于需要数据顺序处理的场景中,例如操作系统任务调度、请求排队等。队列的特性是 “先进先出”(FIFO),即最早加入队列的元素最先被处理。
队列与其他集合类似,通过 Queue
关键字定义队列。队列属于泛型集合,因此我们需要定义队列中存储的元素类型。例如,我们可以定义一个整数类型的队列:
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 定义一个整数类型的队列
Queue<int> queue = new Queue<int>();
}
}
向队列中添加元素使用 Enqueue
方法。此方法会将对象添加到队列的末尾。例如:
queue.Enqueue(1);
queue.Enqueue(2);
queue.Enqueue(3);
添加的第一个元素会位于队列的开头,后续添加的元素依次排在其后。
使用 Peek
方法可以查看队列中的第一个元素而不将其移除:
Console.WriteLine("队列开头的元素是:" + queue.Peek());
在上面的例子中,无论队列中有多少元素,Peek
都会返回 1
,因为这是第一个被加入的元素。
使用 Dequeue
方法可以移除并返回队列的第一个元素:
int dequeuedItem = queue.Dequeue();
Console.WriteLine("从队列中移除的元素是:" + dequeuedItem);
调用 Dequeue
方法后,Peek
将显示队列中的下一个元素。需要注意的是,如果队列为空时调用 Dequeue
会引发错误,所以在移除元素之前可以先检查队列是否为空:
if (queue.Count > 0)
{
queue.Dequeue();
}
我们可以使用 while
循环逐个移除队列中的元素,直到队列为空:
while (queue.Count > 0)
{
Console.WriteLine("移除的元素:" + queue.Dequeue());
}
假设我们要实现一个简单的电商平台,系统需要按顺序处理订单。我们可以用队列来模拟订单的处理过程。
首先,定义一个 Order
类,包含订单 ID 和数量,并提供一个方法来处理订单:
class Order
{
public int OrderId { get; set; }
public int Quantity { get; set; }
public Order(int orderId, int quantity)
{
OrderId = orderId;
Quantity = quantity;
}
public void ProcessOrder()
{
Console.WriteLine($"处理订单 ID: {OrderId}, 数量: {Quantity}");
}
}
在 Main
方法中,我们定义一个订单队列并将模拟订单按顺序添加进去:
Queue<Order> orderQueue = new Queue<Order>();
// 创建订单并添加到队列
orderQueue.Enqueue(new Order(1, 10));
orderQueue.Enqueue(new Order(2, 5));
orderQueue.Enqueue(new Order(3, 2));
接下来,依次处理队列中的订单:
while (orderQueue.Count > 0)
{
// 获取并移除队列中的第一个订单
Order currentOrder = orderQueue.Dequeue();
currentOrder.ProcessOrder();
}
当我们运行代码时,程序将按订单添加的顺序输出每个订单的处理信息。
队列在需要按顺序处理数据的场景中非常有用,例如订单处理、请求排队等。在下一节视频中,您将更深入了解如何在实际应用中使用队列。
到这里,我们已经完成了关于数组和列表的章节。当然,在整个课程中我们还会不断使用数组和列表,所以不用担心,这些内容会在不同的上下文中反复出现,您将更深入地理解它们在编程中的实际应用。
到目前为止,您已经学会了如何使用数组,了解了数组的不同类型。虽然有些类型的数组我们可能不会频繁使用,但了解它们的存在和用法仍然是很有价值的。有时仅仅知道一种工具的存在,就能在面对问题时找到更多的解决方法。即使记不住所有细节,也可以在未来的需求中回顾之前的内容,复习相关知识。
我们还学习了列表的概念和用法,并完成了一个比较大型的挑战练习——井字棋(Tic-Tac-Toe)。这个练习比我们之前的任务要困难许多,但这正是它的意义所在。您已经经历了基础的操作,这次练习让您在编写代码时必须更多地思考和运用已学知识。挑战中,您需要独立地构建逻辑,而不仅仅是按照指示编写代码。
如果您独立完成了这个练习,恭喜您!这是很了不起的成就。如果您还未完成,别担心,我们未来还有许多其他的练习和项目可以帮助您提升。您甚至可以在未来回到这个项目,重新尝试解决。如果您有其他类似的项目想法,也完全可以动手去实现。
非常感谢您一路学习到这里!接下来让我们进入新的章节,探索更多的编程知识。继续保持学习的热情,您一定会在编程旅程中不断进步。
欢迎进入调试章节。在这一章节中,您将学习如何使用集成开发环境(IDE)进行调试。这是一项至关重要的技能,因为无论代码经验多么丰富,程序员的工作中总免不了要面对各种 bug 和错误。有时错误只是一个简单的空指针异常,但您找不到问题的根源;有时错误更复杂,代码看似正确,但却在运行时出现了意外的异常。这正是调试过程的意义所在,调试可以帮助您理清思路,找到问题,并最终修复代码中的 bug。
调试过程不仅仅是编程工作的组成部分,它还往往是一个非常关键的部分,因为几乎每一个程序员在编写代码时都会遇到不可预期的问题。熟练掌握调试工具将使您的开发过程更加顺利,也将帮助您更高效地解决问题。
在这一章节中,您将学习到:
通过学习这些调试技能,您将能够更轻松地发现和解决代码中的问题,使您的开发效率更高。
那么,让我们马上开始吧!
在本视频及接下来的几个视频中,我们将学习如何在 Visual Studio 中使用调试功能,帮助您定位并修复代码中的 bug。调试的核心目的是消除程序中的 bug,而这些 bug 有时很容易找到,例如代码在运行时崩溃;但有时会出现逻辑错误,这种错误不会被 IDE 报告,通常只能由开发人员或用户在运行中发现。
为了演示调试功能,我创建了一个简单的工具:派对好友邀请工具。这个小工具用于挑选一部分名字较短的好友,以便邀请他们参加派对。在这个示例中,我们假设要邀请名字最短的三位朋友,工具将根据名字长度进行排序并选择。
这段代码包括几个关键部分:
get_party_friends()
:接受一个朋友列表,并返回名字较短的指定数量好友。由于这是逻辑错误,代码不会报错,但会返回错误结果。例如,输出了 "Michelle" 和 "Angelina" 等较长名字的朋友,而不是最短的几个名字。这种错误在列表较短时容易发现,但在实际开发中,我们可能从外部数据源(如 XML 文件或网络数据)获取好友列表,这时调试工具就显得尤为重要。
为帮助理解调试过程,我们可以设置断点,通过单步调试分析代码运行的每一步,观察变量的状态和变化。以下是一些基本的调试操作:
在代码行左侧点击灰色条,或使用快捷键 F9,设置断点。断点将停止代码的运行,使您可以逐行查看代码的执行。
点击 调试 > 开始调试,或按 F5 开始调试程序。程序将在断点处暂停,显示当前代码的执行位置。
将鼠标悬停在变量上可以查看当前值。Visual Studio 还提供了“监视窗口”(Watch Window),允许您同时监视多个变量的值,便于对比观察。
在调试过程中,我们可以:
friends
和 party_friends
列表的内容。get_party_friends()
和 get_party_friend()
,观察各变量在循环和条件语句中的状态。这样逐步观察变量的值,可以发现 shortest_name
变量与期望值的偏差,从而找到问题所在。
通过调试和设置断点,我们可以轻松追踪代码的执行情况,并在每一行代码执行后查看变量的状态。这种方式非常适合查找和解决逻辑错误。接下来的视频中,我们将进一步深入调试功能,探索如何高效管理多个变量状态,使调试过程更加简便和直观。
在本视频中,我们会更深入地了解 自动变量(Autos) 和 本地变量(Locals) 窗口的使用,以及如何有效地管理和操作断点。我们会复习如何设置断点,同时进一步分析断点的使用和管理方法,这对于大型项目和复杂代码尤为重要。
在上一个视频中,我们在代码中添加了一个断点,以便在调试过程中暂停程序并观察变量状态。在这个示例中,我们的代码量较少,仅使用了几个断点。如果代码变得复杂,并有多个文件和许多断点,可以使用 断点窗口 来更好地管理和查看所有断点。
Ctrl + Alt + B
。调试
> 窗口
> 断点
。断点窗口会显示所有断点的位置,例如 Program.cs
文件的第 10 行和第 21 行的断点。通过这个窗口,你可以启用或禁用断点,也可以删除不需要的断点。
点击断点图标,可以直接移除断点。如果只想暂时停用断点,可以右键点击断点并选择“禁用断点”,此时断点标志会变成白色,表示已禁用,但仍然保留在代码中,可以随时重新启用。
自动变量窗口 会自动显示当前断点附近的变量,因此不需要手动监视变量。自动变量窗口会显示最相关的变量,例如在主方法中的 args
变量和 friends
变量。
当我们使用 逐过程执行(Step Over) 时,窗口会更新,显示新生成或修改的变量。例如,当 friends
列表被赋值后,它就会在自动变量窗口中显示其元素和属性。这让我们能清楚地观察变量的当前值和数据结构。
本地变量窗口 显示当前方法中的所有变量,也就是当前作用域中的变量。与自动变量窗口不同,自动变量窗口只显示关键变量,而本地变量窗口则显示所有局部变量。
get_party_friends()
方法中,本地变量窗口将显示 list
、party_friends
等方法内部的变量。friends
或 party_friends
,它们将不可见。监视窗口 允许你手动添加需要观察的变量。可以随时添加变量并观察它们在执行过程中的变化。以下是操作步骤:
调试
> 窗口
> 监视
)。party_friends
。在调试过程中,我们还可以手动修改变量的值以观察程序如何响应。例如,可以将 count
变量的值从 3
改为 5
,然后运行代码查看不同值对程序的影响。通过这种方式,你可以测试不同的假设,从而帮助发现 bug 或逻辑错误。
假设当前断点在 get_party_friends()
方法中:
list
和 party_friends
的值。party_friends
的元素和长度。这样可以帮助我们发现一些潜在的问题。例如,friends
列表的值在不断减少,因为在 get_party_friends()
方法中我们错误地直接从列表中移除元素,从而导致最终剩余的朋友数量不正确。
通过使用 自动变量、本地变量 和 监视窗口,我们能够在调试过程中更直观地观察和控制代码中的变量,逐步查找并修复错误。这种方法在处理大型项目和复杂逻辑时尤为有用。
在下一个视频中,我们将修复代码中的 bug,并进一步探讨如何避免类似的问题。
在这个视频中,我们将修复代码中的一个 bug,并展示如何使用调试工具帮助我们发现和解决问题。这个示例是一个较为简单的程序,但它仍然包含一些常见的逻辑错误。我们通过逐步分析代码中的每个方法,了解它们的作用,然后利用调试工具一步步找到问题的根源。
GetPartyFriend
方法中。GetPartyFriend
方法前添加注释,解释它的功能,如“此方法用于确定谁是派对朋友”。GetPartyFriend
方法中设置断点,并启动调试模式来检查逻辑。shortestName
和其他相关变量的值,因为这些值决定了程序的逻辑结果。例如,代码中有一个比较语句 if (list[i].Length > shortestName.Length)
,如果我们期望得到名字最短的朋友,这个判断条件可能需要反转。>
改为 <
,从而正确地选择名字较短的朋友。ArgumentOutOfRangeException
,因为试图访问超出列表范围的元素。friends
列表进行了删除操作,而不是操作副本。buffer
副本列表。这可以确保原始 friends
列表在其他地方使用时不受影响。list.Remove(...)
改为 buffer.Remove(...)
,以便删除的是 buffer
中的元素,而不是原始列表中的元素。friends
列表。GetPartyFriends
方法时,如果我们指定的邀请人数超过列表中的朋友数量,将导致程序崩溃。可以在代码中添加逻辑,确保不尝试访问列表之外的元素。通过本视频的内容,我们学习了如何使用调试工具定位和解决代码中的逻辑错误,并如何在程序中合理使用副本来保护原始数据。调试和防止越界错误都是编写健壮代码的重要步骤。在下一节视频中,我们将继续优化代码并确保程序能够在各种情况下正常运行。
在本视频中,我们将最终解决之前程序中的问题,并讨论防御式编程的概念。防御式编程旨在编写更加健壮和安全的代码,以提前预测并防止潜在问题的发生。特别是在我们不确定数据来源或数据格式时,例如数据可能来自数据库或网络,这种编程方式显得尤为重要。通过提前验证数据的完整性,可以有效避免运行时异常,确保代码更稳定。
在更早的阶段捕获异常:我们可以在 GetPartyFriends
方法中添加条件检查,如果邀请人数超出列表人数或数量小于零,可以直接抛出异常,防止程序进一步运行导致更严重的后果。例如:
if (count > list.Count || count < 0)
{
throw new ArgumentOutOfRangeException("count", "Count cannot be greater than the elements in the list or less than zero.");
}
这种方式比在列表访问处捕获异常更有效,因为它提前终止了程序运行,并抛出更加直观的错误信息,便于定位和解决问题。
有时候,数据可能在传递时为空,例如从数据库读取的数据失败。这种情况下可以检查传入参数是否为 null
,并在发现为空时立即抛出异常:
if (list == null)
{
throw new ArgumentNullException("list", "The list should not be empty. Please check the data source.");
}
通过捕获空列表的情况,我们确保不会执行依赖于此列表的后续操作,避免了运行时异常。
调试过程中,我们可以使用调用堆栈(Call Stack)窗口来跟踪方法调用顺序:
防御式编程的核心在于提前预见潜在问题,确保程序在遇到非预期输入或数据时能够安全退出,而不会导致系统崩溃或产生不可预期的行为。通过这种方式,程序在各种边界条件下都会更具稳定性,特别是在与外部数据源(如数据库、网络请求等)交互时。
在本视频中,我们学习了如何通过有效使用调试工具和防御式编程来使代码更具鲁棒性和稳定性。这些技巧不仅适用于小型示例程序,更适用于大型项目中的复杂逻辑。在调试和编码过程中,如果您有任何问题,欢迎提供反馈,因为这将帮助改进教程内容并提升其质量。
继续保持良好的编程习惯,并在下一节视频中学习更多 C# 编程技巧。
在本章中,我们将学习继承的概念。继承是一种面向对象编程的核心机制,它允许一个类(子类)从另一个类(父类或基类)继承属性和方法。通过继承,子类可以直接获得父类中的功能,同时也可以进行扩展和定制。这种机制使得代码复用变得更加高效,减少了重复代码,提高了代码的可维护性。
继承可以类比为我们在人类基因中遗传的特性——我们会从父母那里继承一些基因,甚至是行为特征。类似地,继承指的是一个类从另一个类继承功能和属性。父类将自身的属性和方法传递给子类,子类则可以在继承这些功能的基础上进行扩展或重写。
继承通常用于以下几个目的:
在继承中,有以下几个重要的概念:
在代码中,继承的语法通常如下:
class ParentClass
{
public void ShowMessage()
{
Console.WriteLine("Hello from Parent Class");
}
}
class ChildClass : ParentClass
{
// ChildClass inherits from ParentClass
}
在这个例子中,ChildClass
继承了ParentClass
。这意味着ChildClass
可以直接调用ParentClass
中的ShowMessage
方法,而无需再次定义该方法。
继承的主要优势包括:
在继承章节中,我们还将了解接口。接口定义了一组不包含实现的功能,这些功能将由实现接口的类来提供具体实现。接口通常用于确保类具备某些特定功能,而不关心其具体实现细节。
接口的使用通常如下:
interface IPrintable
{
void Print();
}
class Document : IPrintable
{
public void Print()
{
Console.WriteLine("Printing document...");
}
}
在这个例子中,IPrintable
接口定义了一个Print
方法。实现了IPrintable
接口的Document
类必须提供Print
方法的具体实现。
在本章学习过程中,我们将设置两个挑战任务,帮助您巩固所学内容。通过这些挑战,您将实践如何定义和使用继承、基类、派生类和接口。
希望您在接下来的视频中学到关于继承的更多内容并享受学习的过程!
欢迎回来!在本视频中,我们将继续深入学习继承,并且先了解其定义和用途。在后续视频中,我们将通过演示进一步探讨其实际应用。
继承允许我们基于已有的类来定义新类,这使得应用的创建和维护变得更加简单。继承提供了代码复用的机会,加快了实现速度,因为:
此外,继承不仅仅可以用于我们自己创建的类,还可以对导入的库中的类进行继承,从而更广泛地复用功能。
假设我们有一个Car(汽车)基类,它具有通用的属性和方法:
Drive
)、鸣笛(Honk
)。基于这个Car类,我们可以派生出具体的汽车类型,如RaceCar(赛车)和StreetCar(街车):
Turbo
属性,用于提升速度。Race
方法,用于参与比赛。Comfort
属性,表示舒适性。StreetDrive
方法,表示在街道上行驶。通过继承,每种汽车类型可以共用通用的汽车属性和方法,同时根据特性添加新的属性和方法。
在企业中,我们可以定义一个Employee(员工)基类:
Salary
)、工作时间(WorkHours
)。DoJob
)、上班(GetToWork
)、领工资(GetPaid
)。基于Employee类,可以派生出不同类型的员工,如Designer(设计师)和Engineer(工程师):
YearsOfDesignExperience
)、创造力水平(CreativityLevel
)。Design
)、绘画(Draw
)。YearsOfProgrammingExperience
)、幽默水平(SarcasmLevel
)。Design
)、编程(Program
)。尽管设计(Design
)方法在Designer和Engineer类中都有定义,但它们的实现方式可以不同,并且这种方法对于每个员工都不是必须的。只有设计师和工程师需要这个方法,但普通员工(Employee)类本身不需要设计方法。
这些示例展示了继承的基本概念和应用方式。接下来,我们将在演示中进一步深入了解继承的实际应用,探讨基类、派生类的实现,以及如何利用继承来构建灵活的程序架构。
让我们在下一个视频中开始演示!
欢迎回来!在本视频中,我们将学习 C# 中的继承,并使用简单的例子来介绍其概念。继承是编程中的一个重要概念,为我们提供了代码复用的强大功能。通过继承,我们可以减少重复代码,实现更加高效的开发。我们会尽量保持内容简洁明了,以帮助你更好地理解。
继承是面向对象编程(OOP)的核心概念之一,它允许我们定义一个类(子类),以重用、扩展或修改另一个类(父类)的行为。具体来说:
如果我们有多个类具有相似的代码或功能,但在细节上略有不同,我们可以将这些重复的代码提取到一个单独的类中,然后让各个类继承它。这可以是方法、功能或属性,从而让代码更加简洁,便于维护。
假设我们要实现一个电器设备管理系统,并开始有两个类 Radio(收音机) 和 TV(电视) ,每个类中都包含一些属性和方法。比如:
isOn
,表示品牌的 brand
,以及方法 SwitchOn
、SwitchOff
和 ListenRadio
。isOn
和 brand
属性,以及类似的 SwitchOn
和 SwitchOff
方法。这两个类中包含了许多相同的属性和方法,比如开关状态和品牌属性、打开和关闭方法等。
要避免重复代码,我们可以创建一个新的基类,比如 ElectricalDevice(电器设备),将通用属性和方法放入这个基类中。
ElectricalDevice
isOn
和 brand
,表示设备状态和品牌。SwitchOn
和 SwitchOff
方法,用来控制设备的开关状态。public class ElectricalDevice
{
public bool IsOn { get; private set; }
public string Brand { get; private set; }
public ElectricalDevice(bool isOn, string brand)
{
IsOn = isOn;
Brand = brand;
}
public void SwitchOn()
{
IsOn = true;
Console.WriteLine("Device is switched on.");
}
public void SwitchOff()
{
IsOn = false;
Console.WriteLine("Device is switched off.");
}
}
ElectricalDevice
基类接下来,让 Radio 和 TV 类继承自 ElectricalDevice,从而共享通用属性和方法。
:
表示继承关系。Radio
和 TV
中的重复代码,只保留它们独特的功能。以下是 Radio
类的继承实现:
public class Radio : ElectricalDevice
{
public Radio(bool isOn, string brand) : base(isOn, brand) { }
public void ListenRadio()
{
if (IsOn)
{
Console.WriteLine("Listening to the radio...");
}
else
{
Console.WriteLine("Radio is switched off. Switch it on first.");
}
}
}
在这里,Radio
类继承了 ElectricalDevice,因此可以直接使用 SwitchOn
和 SwitchOff
方法,以及 isOn
和 brand
属性。此外,我们在 ListenRadio
方法中检查 isOn
属性的状态,以确定是否可以收听收音机。
在 TV
类中实现类似的继承逻辑:
public class TV : ElectricalDevice
{
public TV(bool isOn, string brand) : base(isOn, brand) { }
public void WatchTV()
{
if (IsOn)
{
Console.WriteLine("Watching TV...");
}
else
{
Console.WriteLine("TV is switched off. Switch it on first.");
}
}
}
TV
类同样继承了 ElectricalDevice,并可以使用基类的属性和方法。
在主程序中创建 Radio
和 TV
对象,并测试它们的功能。
class Program
{
static void Main(string[] args)
{
Radio myRadio = new Radio(false, "Sony");
myRadio.SwitchOn();
myRadio.ListenRadio();
TV myTV = new TV(false, "Samsung");
myTV.SwitchOn();
myTV.WatchTV();
}
}
执行后,你会看到输出内容表示收音机和电视分别被打开,并可以使用各自的独特方法 ListenRadio
和 WatchTV
。
通过继承,我们可以避免重复代码,提高代码的可维护性。继承使我们能够在基类中定义通用功能和属性,然后在派生类中实现特定功能。这种方式不仅使得代码更简洁,也便于后续扩展和维护。
virtual
和 override
关键字欢迎回来!在本视频中,我们将深入探讨 C# 中的继承,重点讲解 virtual
和 override
关键字的使用。理解这些概念对掌握面向对象编程中的继承功能至关重要。为了更好地演示继承的细节,我们会实现一个动物类 Animal
,并从中继承出具体的动物类(例如 Dog
),以便展示如何通过重写方法来定制子类的行为。
virtual
和 override
关键字概述virtual
:用来标记父类中的方法或属性,使其可以在子类中被重写。它是继承结构中方法和属性可扩展的基础。override
:用于在子类中重写父类的 virtual
方法或属性,实现特定的行为。通过这两个关键字,我们可以确保基类的代码既可以被子类复用,也可以被子类自定义或改写。
在这个例子中,我们会创建一个基本的 Animal
类,赋予它一些通用的属性和方法,比如:
Name
、Age
和 IsHungry
。MakeSound
、Eat
和 Play
。我们会在 Animal
类中将这些方法定义为虚方法(即 virtual
方法),从而允许子类(例如 Dog
)根据自身特性对这些方法进行自定义,实现特有的行为。
Animal
首先,我们创建 Animal
类并定义基础属性:
public class Animal
{
public string Name { get; set; }
public int Age { get; set; }
public bool IsHungry { get; set; }
public Animal(string name, int age)
{
Name = name;
Age = age;
IsHungry = true; // 默认情况下动物是饥饿的
}
}
我们会定义一些虚方法,以便子类可以选择性地进行重写。
public virtual void MakeSound()
{
// 默认情况下,这个方法不执行任何操作
}
public virtual void Eat()
{
if (IsHungry)
{
Console.WriteLine($"{Name} is eating.");
IsHungry = false;
}
else
{
Console.WriteLine($"{Name} is not hungry.");
}
}
public virtual void Play()
{
Console.WriteLine($"{Name} is playing.");
}
在 MakeSound
方法中,我们使用了空的实现,允许子类完全自定义这个方法。而 Eat
和 Play
方法具有一些默认行为,可以在子类中选择性地重写或保持不变。
Dog
Dog
类并继承 Animal
类public class Dog : Animal
{
public bool IsHappy { get; set; }
public Dog(string name, int age) : base(name, age)
{
IsHappy = true; // 默认狗是开心的
}
}
我们使用 base
关键字来调用 Animal
类的构造函数,以初始化 Name
和 Age
属性。此外,我们为狗类添加了一个新的属性 IsHappy
。
在 Dog
类中,我们可以选择性地重写 Animal
类中的方法,例如 Eat
和 MakeSound
。
public override void MakeSound()
{
Console.WriteLine("Woof!");
}
public override void Eat()
{
base.Eat(); // 调用父类的 `Eat` 方法
Console.WriteLine("The dog is happily eating."); // 追加特有的行为
}
public override void Play()
{
if (IsHappy)
{
Console.WriteLine($"{Name} is playing happily.");
}
else
{
Console.WriteLine($"{Name} doesn't feel like playing.");
}
}
MakeSound
方法中,我们直接重写了父类的行为,让狗的叫声为“Woof!”。Eat
方法中,我们调用了父类的 Eat
方法,然后添加了狗独有的行为描述。Play
方法中,我们引入了 IsHappy
的判断,只有狗在开心时才会玩耍。在主程序 Program.cs
中实例化 Dog
对象,测试其属性和方法。
class Program
{
static void Main(string[] args)
{
Dog myDog = new Dog("Buddy", 5);
Console.WriteLine($"{myDog.Name} is {myDog.Age} years old.");
myDog.MakeSound(); // 输出狗的叫声 "Woof!"
myDog.Eat(); // 狗开始吃东西
myDog.Play(); // 检查狗是否开心,并输出相应的玩耍行为
}
}
执行程序后,你将看到以下输出:
Buddy is 5 years old.
Woof!
Buddy is eating.
The dog is happily eating.
Buddy is playing happily.
这样我们便成功地通过继承和重写,实现了 Dog
类的特有行为,同时保留了 Animal
类的通用特性。
通过本节内容,我们了解到:
virtual
关键字:用于定义可被子类重写的方法和属性。override
关键字:子类用它来重写父类的虚方法。base
关键字:子类调用父类方法时使用。继承和多态性(通过 virtual
和 override
)是面向对象编程的重要组成部分,使我们能够创建结构清晰、扩展性强的代码。在之后的视频中,我们将继续深入探讨 C# 继承的更多特性。
virtual
和 override
的应用:实现社交媒体 Post
类欢迎回来!本视频将继续讲解 C# 中的继承,并通过创建一个 Post
类和它的派生类 ImagePost
来展示继承的应用。我们会以社交媒体的场景为例,比如推文、Facebook 帖子等,并创建一个基础类 Post
,然后扩展出 ImagePost
和其他类型的帖子。这将帮助我们更深入理解继承的概念和优势。
Post
基类,其中包含所有帖子通用的属性和方法。ImagePost
类继承自 Post
类,展示如何通过继承来扩展功能。virtual
和 override
方法,进一步理解如何自定义和覆盖继承的方法。Post
一个社交媒体帖子通常包含以下属性:
我们使用 protected
访问修饰符,这样这些属性可以在派生类中访问。
public class Post
{
private static int currentPostID;
protected int ID { get; set; }
protected string Title { get; set; }
protected string SentByUsername { get; set; }
protected bool IsPublic { get; set; }
}
为了给帖子分配唯一 ID,我们使用一个 private static
字段 currentPostID
。每当创建一个新帖子时,该字段会自增,为新帖子分配一个新的 ID。
public Post()
{
ID = 0;
Title = "My First Post";
IsPublic = true;
SentByUsername = "Admin";
}
public Post(string title, bool isPublic, string sentByUsername)
{
ID = GetNextID();
Title = title;
IsPublic = isPublic;
SentByUsername = sentByUsername;
}
GetNextID()
方法用于生成唯一的帖子 ID。
protected int GetNextID()
{
return ++currentPostID;
}
创建 Update
方法允许更新帖子标题和公开状态。
public void Update(string title, bool isPublic)
{
Title = title;
IsPublic = isPublic;
}
ToString
方法我们覆盖了 System.Object
中的 ToString()
方法,以便可以格式化输出帖子的详细信息。
public override string ToString()
{
return string.Format("{0} - {1} by {2}", ID, Title, SentByUsername);
}
ImagePost
ImagePost
类继承自 Post
类,添加一个特有属性 ImageURL
,用于存储图片链接。
public class ImagePost : Post
{
public string ImageURL { get; set; }
public ImagePost() { }
public ImagePost(string title, string sentByUsername, string imageURL, bool isPublic)
: base(title, isPublic, sentByUsername)
{
ImageURL = imageURL;
}
}
ToString
方法我们重写 ToString()
方法,以显示 ImagePost
的图片链接。
public override string ToString()
{
return string.Format("{0} - {1} by {2}, Image: {3}", ID, Title, SentByUsername, ImageURL);
}
Post
和 ImagePost
在 Program.cs
中创建 Post
和 ImagePost
的实例,测试它们的功能。
class Program
{
static void Main(string[] args)
{
Post post1 = new Post("Thanks for the birthday wishes!", true, "DennisPanuta");
Console.WriteLine(post1.ToString());
ImagePost imagePost1 = new ImagePost("Check out my new shoes", "DennisPanuta", "https://images.com/shoes", true);
Console.WriteLine(imagePost1.ToString());
}
}
运行代码后,你将看到输出:
1 - Thanks for the birthday wishes! by DennisPanuta
2 - Check out my new shoes by DennisPanuta, Image: https://images.com/shoes
通过这个例子,我们学习了:
ImagePost
类继承自 Post
类,获得了 Post
类的所有功能。ToString
方法通过 override
被重写,从而为 ImagePost
提供了更详细的输出。protected
访问修饰符:使属性和方法在派生类中可访问,但在外部类中不可访问。在下一节,我们将继续深入探索继承的更多高级功能,以及如何应用到其他场景中。
VideoPost
类及计时功能欢迎回来!本视频我们将继续深入探讨继承的概念。这次的挑战是创建一个新的派生类 VideoPost
,并实现其特有的属性和方法,比如 VideoURL
和视频长度 Length
。我们还将添加播放和停止视频的功能,进一步理解定时器(Timer
)和回调(Callback)在 C# 中的应用。
VideoPost
类,继承自 Post
基类,增加视频特有的属性。VideoPost
创建构造函数。ToString
方法,以展示视频相关信息。VideoPost
类VideoPost
类的特有属性VideoPost
类继承自 Post
类,并且增加了两个新属性:
VideoURL
:存储视频链接的字符串。Length
:存储视频长度的整数(以秒为单位)。public class VideoPost : Post
{
protected string VideoURL { get; set; }
protected int Length { get; set; }
}
在 VideoPost
类中,添加一个带有 title
、sentByUsername
、videoURL
、isPublic
和 length
参数的构造函数。
public VideoPost() { }
public VideoPost(string title, string sentByUsername, string videoURL, bool isPublic, int length)
: base(title, isPublic, sentByUsername)
{
this.ID = GetNextID();
this.VideoURL = videoURL;
this.Length = length;
}
ToString
方法覆盖 ToString
方法,以便在控制台输出中显示视频链接和长度信息。
public override string ToString()
{
return string.Format("{0} - {1} by {2}, Video: {3}, Length: {4}s", ID, Title, SentByUsername, VideoURL, Length);
}
Play
和 Stop
方法并添加计时功能在 VideoPost
类中创建 Play
和 Stop
方法,使用 Timer
类每秒更新视频播放进度。
Timer
对象我们将定义以下字段:
isPlaying
:表示视频是否正在播放。curDuration
:记录当前播放进度(秒)。Timer
:用于每秒更新进度的计时器对象。protected bool isPlaying;
protected int curDuration;
private Timer timer;
Play
方法Play
方法初始化 Timer
,并每秒触发一次回调方法 TimerCallback
,用于更新播放进度。
public void Play()
{
if (!isPlaying)
{
isPlaying = true;
Console.WriteLine("Playing video...");
timer = new Timer(TimerCallback, null, 0, 1000);
}
}
TimerCallback
方法TimerCallback
方法用于更新播放进度,并在达到视频长度时自动停止。
private void TimerCallback(Object o)
{
if (curDuration < Length)
{
curDuration++;
Console.WriteLine("Video at {0}s", curDuration);
GC.Collect(); // 强制垃圾回收
}
else
{
Stop();
}
}
Stop
方法Stop
方法停止计时器并重置播放状态。
public void Stop()
{
if (isPlaying)
{
isPlaying = false;
Console.WriteLine("Stopped at {0}s", curDuration);
curDuration = 0;
timer.Dispose();
}
}
Program.cs
中测试 VideoPost
在 Program.cs
文件中创建 VideoPost
实例,测试播放和停止功能。
class Program
{
static void Main(string[] args)
{
VideoPost videoPost1 = new VideoPost("Epic Fail Video", "DennisPanuta", "https://video.com/failvideo", true, 10);
Console.WriteLine(videoPost1.ToString());
// 开始播放视频
videoPost1.Play();
Console.WriteLine("Press any key to stop the video...");
// 等待用户按键停止视频
Console.ReadKey();
videoPost1.Stop();
}
}
程序运行后,您将看到类似以下的输出:
2 - Epic Fail Video by DennisPanuta, Video: https://video.com/failvideo, Length: 10s
Playing video...
Video at 1s
Video at 2s
...
Press any key to stop the video...
Stopped at 4s
在本次挑战中,我们学习了:
VideoPost
类继承 Post
类,并使用 override
关键字覆盖 ToString
方法。Timer
类创建视频播放进度更新器。在下一个视频中,我们将继续探讨继承和面向对象编程的其他高级概念。继续加油!
Employee
、Boss
和 Trainee
类欢迎回来!希望您已完成此挑战并尝试创建多个派生类。本次挑战中,我们将实现一个员工系统,其中包含一个 Employee
基类和两个派生类 Boss
和 Trainee
。通过这个示例,您将更深入地理解类继承及如何扩展和覆盖基类的方法和属性。
Employee
基类,并定义基本属性和方法。Boss
派生类,为老板定义特有属性和方法。Trainee
派生类,为实习生定义特有属性和方法。Employee
基类Employee
类将包含员工的姓名、名字和薪水属性。可以使用 prop
快速创建属性。
public class Employee
{
public string Name { get; set; }
public string FirstName { get; set; }
public int Salary { get; set; }
public Employee(string name, string firstName, int salary)
{
this.Name = name;
this.FirstName = firstName;
this.Salary = salary;
}
public void Work()
{
Console.WriteLine("I am working.");
}
public void Pause()
{
Console.WriteLine("I am having a break.");
}
}
Boss
派生类Boss
类派生自 Employee
,并添加一个新属性 CompanyCar
和一个 Lead
方法。
Boss
类及其属性和方法public class Boss : Employee
{
public string CompanyCar { get; set; }
public Boss(string companyCar, string name, string firstName, int salary)
: base(name, firstName, salary)
{
this.CompanyCar = companyCar;
}
public void Lead()
{
Console.WriteLine("I'm the boss. My name is {0} {1}.", FirstName, Name);
}
}
Trainee
派生类Trainee
类派生自 Employee
,添加 WorkingHours
和 SchoolHours
属性,并覆盖 Work
方法。
Trainee
类及其属性和方法public class Trainee : Employee
{
public int WorkingHours { get; set; }
public int SchoolHours { get; set; }
public Trainee(int workingHours, int schoolHours, string name, string firstName, int salary)
: base(name, firstName, salary)
{
this.WorkingHours = workingHours;
this.SchoolHours = schoolHours;
}
public new void Work()
{
Console.WriteLine("I work for {0} hours.", WorkingHours);
}
public void Learn()
{
Console.WriteLine("I'm learning for {0} hours.", SchoolHours);
}
}
Program.cs
中测试类的功能通过在 Program.cs
中创建 Employee
、Boss
和 Trainee
的实例来测试类的功能。我们将展示如何使用继承的属性和方法,以及如何调用子类的特有方法。
class Program
{
static void Main(string[] args)
{
// 创建 Employee 实例
Employee michael = new Employee("Miller", "Michael", 40000);
michael.Work();
michael.Pause();
// 创建 Boss 实例
Boss chuck = new Boss("Ferrari", "Norris", "Chuck", 9000);
chuck.Lead();
// 创建 Trainee 实例
Trainee michelle = new Trainee(32, 8, "Gardener", "Michelle", 10000);
michelle.Learn();
michelle.Work();
// 保持控制台打开
Console.ReadKey();
}
}
运行后,您将看到以下输出(根据不同的顺序):
I am working.
I am having a break.
I'm the boss. My name is Chuck Norris.
I'm learning for 8 hours.
I work for 32 hours.
Boss
和 Trainee
类继承自 Employee
类,从而获得其属性和方法。base
关键字调用基类构造函数来初始化继承的属性。Trainee
中使用 new
关键字隐藏 Employee
的 Work
方法。隐藏和重写(override
)方法的细节将在下一章多态性中进一步探讨。在本次挑战中,我们学习了:
new
关键字隐藏基类的方法,虽然这是一个基础的多态性特性。在下一章中,我们将更深入探讨多态性,通过 virtual
和 override
等关键字来灵活控制类的行为,继续加油!
欢迎回来!在这段视频中,我们将深入了解接口,并学习如何在 C# 中使用接口来扩展面向对象编程的功能。
接口可以被理解为一种“契约”。实现某个接口的类必须提供该接口所定义的所有方法和属性的实现。接口只定义需要实现的方法和属性的签名,而不包含实际的实现逻辑。这为代码提供了一种标准,允许类在实现中有灵活性,但必须满足接口的要求。通常,接口的命名以 I
开头,例如 IEquatable
,这帮助我们快速识别哪些是接口。
IEquatable
接口IEquatable
是一个用于比较对象的接口。在接下来的代码中,我们将实现一个类 Ticket
,它包含一个 durationInHours
属性。我们将通过 IEquatable
接口使得该类可以基于 durationInHours
属性来比较两个 Ticket
对象。
Ticket
类Ticket
类。durationInHours
属性和构造函数。public class Ticket
{
public int DurationInHours { get; set; }
public Ticket(int durationInHours)
{
this.DurationInHours = durationInHours;
}
}
IEquatable<Ticket>
接口Ticket
类中使用冒号 :
实现 IEquatable<Ticket>
接口。Ticket
没有实现接口成员 Equals(Ticket)
,所以我们需要实现它。public class Ticket : IEquatable<Ticket>
{
public int DurationInHours { get; set; }
public Ticket(int durationInHours)
{
this.DurationInHours = durationInHours;
}
// 实现 Equals 方法
public bool Equals(Ticket other)
{
// 检查传入的对象是否为空
if (other == null)
{
return false;
}
// 比较两个对象的 durationInHours 属性
return this.DurationInHours == other.DurationInHours;
}
}
Equals
方法会检查传入的对象是否为空,如果不为空,则比较 durationInHours
属性。Ticket
对象的 durationInHours
属性相等,则返回 true
,否则返回 false
。Equals
方法在 Program.cs
文件中,创建两个 Ticket
对象,并比较它们的 durationInHours
属性是否相等。
class Program
{
static void Main(string[] args)
{
// 创建两个 Ticket 对象
Ticket ticket1 = new Ticket(10);
Ticket ticket2 = new Ticket(10);
// 使用 Equals 方法进行比较
Console.WriteLine(ticket2.Equals(ticket1)); // 输出:True
// 修改 ticket2 的 durationInHours 属性
ticket2.DurationInHours = 6;
// 再次进行比较
Console.WriteLine(ticket2.Equals(ticket1)); // 输出:False
Console.ReadKey();
}
}
在这里:
Ticket
实例 ticket1
和 ticket2
,并将 durationInHours
属性设为 10。Equals
方法比较两个对象,输出为 True
,因为 durationInHours
属性相等。ticket2
的 durationInHours
属性为 6 后,再次比较,输出为 False
。Equals
方法会比较两个对象的引用是否相同,而不是它们的内容。IEquatable<T>
接口后,我们可以自定义 Equals
方法来比较对象的内容。本视频中,我们学习了:
IEquatable<T>
接口在 C# 中实现自定义的对象比较方法。在下一视频中,我们将学习如何创建自定义接口,进一步探索接口在编程中的应用。
欢迎回来!在本节课程中,您将学习如何创建并实现自己的接口。我们将通过一个具体的游戏示例来理解接口的使用,尤其是在类之间没有直接关系,无法共享基类的情况下,接口的重要性就显得尤为突出。
假设我们在开发一款游戏,玩家可以破坏不同的物体,而每个物体在被破坏时会有不同的效果。例如:
我们有以下类:
Chair
类继承自 Furniture
类。Car
类继承自 Vehicle
类。这些类没有直接关系(车和椅子没有继承关系)。因此,我们无法通过继承共享一个基类来实现破坏功能。
我们先看一下 Chair
类。Chair
继承自 Furniture
类,并通过构造函数初始化颜色和材质属性:
public class Chair : Furniture
{
public Chair(string color, string material)
{
this.Color = color;
this.Material = material;
}
}
Furniture
类包含颜色和材质属性,并提供默认构造函数。
public class Furniture
{
public string Color { get; set; }
public string Material { get; set; }
public Furniture()
{
this.Color = "White";
this.Material = "Wood";
}
}
Car
类类似,也有自己的属性 Speed
和 Color
,并继承自 Vehicle
类:
public class Car : Vehicle
{
public Car(int speed, string color)
{
this.Speed = speed;
this.Color = color;
}
}
Vehicle
类中定义了速度和颜色的默认值。
public class Vehicle
{
public int Speed { get; set; }
public string Color { get; set; }
public Vehicle()
{
this.Speed = 120;
this.Color = "White";
}
}
为了实现破坏功能,我们不能让 Car
和 Chair
类继承同一个破坏基类,因为 C# 是单继承语言,我们已经让 Car
继承了 Vehicle
,Chair
继承了 Furniture
。此外,车和椅子之间也没有直接关系。
最好的方法是使用接口。我们可以创建一个 IDestroyable
接口,让所有可破坏的类都实现它,这样就可以强制这些类遵循破坏的要求。每个类可以根据自己的需要实现接口中的方法和属性。
IDestroyable
接口IDestroyable
,命名以 I
开头以便于识别。DestructionSound
,以及一个用于执行破坏的 Destroy
方法。public interface IDestroyable
{
string DestructionSound { get; set; }
void Destroy();
}
IDestroyable
接口Car
类实现 IDestroyable
Car
类定义中添加 IDestroyable
接口。DestructionSound
属性分配一个爆炸音频文件。Destroy
方法,模拟破坏效果。public class Car : Vehicle, IDestroyable
{
public string DestructionSound { get; set; }
public List<IDestroyable> DestroyablesNearby { get; set; }
public Car(int speed, string color)
{
this.Speed = speed;
this.Color = color;
this.DestructionSound = "CarExplosionSound.mp3";
this.DestroyablesNearby = new List<IDestroyable>();
}
public void Destroy()
{
Console.WriteLine("Playing sound: " + DestructionSound);
Console.WriteLine("Creating fire effect...");
foreach (var destroyable in DestroyablesNearby)
{
destroyable.Destroy();
}
}
}
在 Destroy
方法中,我们播放爆炸声音并生成火焰效果。接着遍历 DestroyablesNearby
列表,触发附近所有可破坏物体的 Destroy
方法。
Chair
类实现 IDestroyable
Chair
类的实现也会包括 DestructionSound
属性和 Destroy
方法,但破坏效果将不同。
public class Chair : Furniture, IDestroyable
{
public string DestructionSound { get; set; }
public Chair(string color, string material)
{
this.Color = color;
this.Material = material;
this.DestructionSound = "ChairDestructionSound.mp3";
}
public void Destroy()
{
Console.WriteLine("Destroying the " + Color + " chair...");
Console.WriteLine("Playing sound: " + DestructionSound);
Console.WriteLine("Spawning chair parts...");
}
}
在 Program.cs
中,我们创建 Car
和 Chair
实例,模拟破坏效果。
class Program
{
static void Main(string[] args)
{
Chair officeChair = new Chair("Brown", "Plastic");
Chair gamingChair = new Chair("Red", "Wood");
Car damagedCar = new Car(80, "Blue");
// 将椅子添加到车的破坏物列表
damagedCar.DestroyablesNearby.Add(officeChair);
damagedCar.DestroyablesNearby.Add(gamingChair);
// 破坏汽车并触发连锁反应
damagedCar.Destroy();
Console.ReadKey();
}
}
运行程序后,您会看到输出:
Playing sound: CarExplosionSound.mp3
Creating fire effect...
Destroying the Brown chair...
Playing sound: ChairDestructionSound.mp3
Spawning chair parts...
Destroying the Red chair...
Playing sound: ChairDestructionSound.mp3
Spawning chair parts...
通过创建并实现接口,我们可以在不同的类中实现相同的方法和属性,而无需使用多重继承。这一方式提供了以下优点:
希望通过本课程的内容,您对接口的概念和使用有了更深入的理解。感谢观看,我们下节课再见!
在本节视频中,我们将深入学习一些关键的接口,这些接口对于希望成为高级C#开发人员的您非常重要。尤其是 AIEnumerable
接口和 AIEnumerator
接口。这些接口通常用于 C# 的集合,使我们能够迭代遍历集合中的元素。
AIEnumerable
是 C# 中许多集合的基础接口,旨在提供一种迭代集合的方式。这也是我们可以使用 foreach
循环遍历列表(List
)或字典(Dictionary
)的原因,因为它们都实现了 AIEnumerable
接口。
简单来说,当一个集合类实现了 AIEnumerable
接口后,它就变得“可计数”,允许我们逐个访问集合中的每个元素。AIEnumerable
接口有两个版本:
AIEnumerable<T>
),适用于特定类型的集合。AIEnumerable
),适用于非特定类型的集合。通常建议使用泛型版本,因为它的效率更高,而非泛型版本可能需要进行装箱和拆箱(boxing 和 unboxing),这会影响性能。
AIEnumerable
接口包含一个 GetEnumerator
方法。这个方法返回一个 AIEnumerator
对象,用于实际进行集合的迭代。AIEnumerator
则负责具体的迭代操作。它包含一个 Current
属性,指向当前正在迭代的对象。接下来,我们将通过一个具体示例来演示 AIEnumerable
和 AIEnumerator
的用法。
假设我们创建一个 Dog
类,并且我们要根据狗是否听话来奖励它不同数量的零食。然后,我们创建一个 DogShelter
类,该类包含一个 Dog
列表,我们希望能够迭代遍历这个列表。
Dog
类public class Dog
{
public string Name { get; set; }
public bool IsNaughtyDog { get; set; }
public Dog(string name, bool isNaughtyDog)
{
Name = name;
IsNaughtyDog = isNaughtyDog;
}
public void GiveTreat(int numberOfTreats)
{
for (int i = 0; i < numberOfTreats; i++)
{
Console.WriteLine($"{Name} said Woof!");
}
}
}
Dog
类中包含狗的名字 Name
和一个布尔值 IsNaughtyDog
,表示这只狗是否淘气。GiveTreat
方法根据参数 numberOfTreats
的值输出狗叫声。
DogShelter
类DogShelter
类包含一个 List<Dog>
类型的属性 Dogs
,用于存储多个 Dog
实例。
using System.Collections.Generic;
public class DogShelter : IEnumerable<Dog>
{
private List<Dog> Dogs;
public DogShelter()
{
Dogs = new List<Dog>
{
new Dog("Casper", false),
new Dog("Sif", true),
new Dog("Oreo", false),
new Dog("Pixel", false)
};
}
public IEnumerator<Dog> GetEnumerator()
{
return Dogs.GetEnumerator();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
在 DogShelter
类中,我们:
Dogs
列表,并在构造函数中初始化了几个 Dog
对象。AIEnumerable<Dog>
接口,使得 DogShelter
类可以被迭代。GetEnumerator
方法返回 Dogs.GetEnumerator()
,允许我们遍历 Dogs
列表。
注意:我们实现了 GetEnumerator
的泛型和非泛型版本,以确保兼容性。
DogShelter
类的迭代功能在 Program.cs
中创建 DogShelter
实例,并使用 foreach
遍历所有 Dog
对象,为每只狗提供不同数量的奖励。
class Program
{
static void Main(string[] args)
{
DogShelter shelter = new DogShelter();
foreach (Dog dog in shelter)
{
if (!dog.IsNaughtyDog)
{
dog.GiveTreat(2); // 听话的狗给两份奖励
}
else
{
dog.GiveTreat(1); // 淘气的狗只给一份奖励
}
}
Console.ReadKey();
}
}
当我们运行程序时,输出如下:
Casper said Woof!
Casper said Woof!
Sif said Woof!
Oreo said Woof!
Oreo said Woof!
Pixel said Woof!
Pixel said Woof!
每只狗根据其听话与否获得不同数量的奖励,并发出叫声。
通过实现 AIEnumerable<T>
接口,我们可以创建自定义集合类,使其可被 foreach
迭代。GetEnumerator
方法使我们能够遍历集合中的每个元素,并对每个元素执行特定操作。
AIEnumerable
迭代是一种更为高效的方式。AIEnumerable
可能不太适合。此时,使用数组或列表可能更为合适。在本视频中,我们初步介绍了 AIEnumerable
接口和 AIEnumerator
的概念及用法。在后续视频中,我们将进一步探讨它们在更复杂场景中的应用。
在上一节中,我们从技术角度了解了 AIEnumerable
的工作原理。在这一节中,我们将探讨如何利用 AIEnumerable
接口提供的灵活性。因为在 C# 中,像 List
、Queue
、Array
等集合类型都实现了 AIEnumerable
接口,因此它给了我们很大的自由度。
我们将创建一个名为 GetCollection
的方法。此方法根据不同的选项返回不同类型的集合(List
、Queue
或 Array
)。这是 AIEnumerable
的强大之处——即使集合类型不同,只要实现了 AIEnumerable
接口,我们仍可以将它们处理为相同的类型。
using System;
using System.Collections.Generic;
public class Program
{
public static void Main(string[] args)
{
IEnumerable<int> unknownCollection = GetCollection(1);
Console.WriteLine("Option 1 returns a List:");
DisplayCollection(unknownCollection);
unknownCollection = GetCollection(2);
Console.WriteLine("\nOption 2 returns a Queue:");
DisplayCollection(unknownCollection);
unknownCollection = GetCollection(5);
Console.WriteLine("\nOption 5 returns an Array:");
DisplayCollection(unknownCollection);
}
public static IEnumerable<int> GetCollection(int option)
{
// 创建一个包含 1 到 5 的列表
List<int> numbersList = new List<int> { 1, 2, 3, 4, 5 };
// 创建一个包含 6 到 10 的队列
Queue<int> numbersQueue = new Queue<int>();
for (int i = 6; i <= 10; i++)
{
numbersQueue.Enqueue(i);
}
// 根据选项返回不同的集合类型
if (option == 1)
{
return numbersList; // 返回 List
}
else if (option == 2)
{
return numbersQueue; // 返回 Queue
}
else
{
return new int[] { 11, 12, 13, 14, 15 }; // 返回 Array
}
}
public static void DisplayCollection(IEnumerable<int> collection)
{
foreach (int number in collection)
{
Console.Write(number + " ");
}
Console.WriteLine();
}
}
GetCollection 方法:此方法接受一个整数参数 option
,根据不同的选项返回不同类型的集合。
option == 1
返回 List<int>
。option == 2
返回 Queue<int>
。int[]
数组。DisplayCollection 方法:此方法接收一个 IEnumerable<int>
类型的集合参数。通过 foreach
循环迭代集合中的元素,并将其打印到控制台。
Main 方法:调用 GetCollection
方法,并将返回的结果传递给 DisplayCollection
方法。
根据不同选项,我们将会看到不同的输出:
Option 1 returns a List:
1 2 3 4 5
Option 2 returns a Queue:
6 7 8 9 10
Option 5 returns an Array:
11 12 13 14 15
通过 AIEnumerable
接口,我们能够:
List
、Queue
或 Array
)。IEnumerable<T>
接口将集合类型统一处理,简化了代码。使用 AIEnumerable
提供了极大的灵活性,可以让我们在代码中使用不同类型的集合,而无需对集合的具体实现类型进行关心。这使代码更加通用和可复用。在下一节视频中,我们将探讨如何将 AIEnumerable
集合作为方法参数传递,以进一步扩展其应用。
IEnumerable
作为方法参数的示例在本节视频中,我们将再次探索 IEnumerable
,并通过一个新的示例展示如何将 IEnumerable
集合作为方法参数传递。通过这种方式,我们可以在方法中灵活处理多种类型的集合,就像在面向对象编程中可以将子类实例传递给需要父类的参数一样。
IEnumerable
集合的方法我们首先创建一个方法 CollectionSum
,它将接受一个 IEnumerable<int>
类型的集合,并计算集合中所有数字的总和。这个方法的特点是,它接受任何实现了 IEnumerable
接口的集合类型,这样我们就能传入 List
、Array
等多种集合类型。
using System;
using System.Collections.Generic;
public class Program
{
public static void Main(string[] args)
{
// 创建一个整数列表
List<int> numberList = new List<int> { 8, 6, 2 };
// 创建一个整数数组
int[] numberArray = new int[] { 1, 7, 1, 3 };
// 调用 CollectionSum 方法,传入不同类型的集合
Console.WriteLine("Sum of numberList:");
CollectionSum(numberList);
Console.WriteLine("\nSum of numberArray:");
CollectionSum(numberArray);
}
public static void CollectionSum(IEnumerable<int> anyCollection)
{
int sum = 0;
// 迭代集合中的所有元素并求和
foreach (int num in anyCollection)
{
sum += num;
}
// 输出集合的总和
Console.WriteLine("Sum is " + sum);
}
}
CollectionSum 方法:此方法接收一个 IEnumerable<int>
类型的集合参数 anyCollection
。
sum
变量,用于存储集合中所有元素的总和。foreach
循环遍历 anyCollection
,将每个元素的值加到 sum
中。Main 方法:在主方法中,我们创建了一个 List<int>
和一个 int[]
数组。
CollectionSum
方法分别传入 numberList
和 numberArray
,验证该方法可以处理不同类型的集合。当运行程序时,输出结果如下:
Sum of numberList:
Sum is 16
Sum of numberArray:
Sum is 12
通过将 IEnumerable
作为参数传递,我们实现了以下灵活性:
List
、Array
等)。IEnumerable
的集合类型(如 Queue
或 Stack
),也可以传递给该方法。使用 IEnumerable
接口可以让方法具有更强的灵活性和扩展性,尤其是在处理各种集合时。无论是 List
、Array
还是其他实现 IEnumerable
的集合类型,都可以直接传入 CollectionSum
方法。这种通用性的实现使代码更简洁、可维护性更高。
恭喜完成继承章节的学习!需要说明的是,这并不是继承知识的终点,未来的课程中我们还会进一步深入探讨继承的更多内容。继承是比我们之前讨论的主题更复杂的概念,因为它涉及面向对象编程(OOP)的核心思想之一。
在这一章节中,我们回到了面向对象编程的基础,深入学习了继承这一核心概念。面向对象编程是基于几个核心概念构建的,而继承正是其中重要的一环。继承允许我们在编程中实现代码的复用和扩展,创建更具组织性的代码结构。
通过本章节的学习,你已经掌握了继承的基础概念。接下来,我们将进入另一个重要的面向对象编程核心概念——多态性(Polymorphism)。多态性是继承的延伸,它允许我们在继承的基础上,通过不同方式实现不同的行为。
在下一章节中,我们将深入探讨多态性,它是面向对象编程中非常重要的特性。通过多态性,我们可以在父类和子类之间实现更灵活的操作,让程序在处理对象时表现出不同的行为。这个概念在现实应用中非常强大,能够使代码更加模块化和易于维护。
那么,让我们一起进入下一章节,开始学习多态性吧!
在本章节中,你将学习面向对象编程的第三个也是最后一个核心概念——多态性(Polymorphism)。多态性在继承的基础上进一步扩展了对象的使用方式,使代码更加灵活和高效。以下是本章节的学习要点:
virtual
和 override
:你将理解如何通过 virtual
和 override
关键字在父类和子类中实现不同的行为。这是实现多态性的重要步骤。is-a
与 has-a
关系:本章节将介绍类之间的“is-a”(是一种)与“has-a”(具有)关系,这有助于你更好地设计类结构并实现更自然的继承和组合。多态性是使继承真正灵活并广泛应用的关键概念。它使我们能够以统一的方式对不同类型的对象进行操作,而无需了解每个对象的具体类型。例如,在父类中定义的一个方法可以在多个子类中被重写,每个子类都可以提供自己的实现。这使得程序能够根据实际对象的类型来动态地调用合适的方法实现。
通过学习多态性,你将学会如何在代码中实现更高的抽象,并让你的程序具备良好的可扩展性和可维护性。你会发现,使用多态性不仅能够减少代码的重复,还能让代码结构更清晰,功能更强大。
virtual
和 override
实现方法的多态性。is-a
与 has-a
关系,并用其优化类设计。让我们开始这个关于多态性的章节!在接下来的内容中,我们将逐步实现这些概念,并通过实际的代码示例,帮助你更好地理解和运用多态性。准备好迎接面向对象编程的更高层次吧!
在本视频中,我们通过一个多态性示例来深入了解多态的实现,同时还会讲解一些重要的关键词,如 new
和 override
。下面是该示例的逐步实现及其背后的概念:
首先,我们创建一个 Car
类作为基类,这个类包括以下几个内容:
属性:
HP
(整数类型):表示汽车的马力(Horse Power)。Color
(字符串类型):表示汽车的颜色。构造函数:
Car(int hp, string color)
:构造函数接受马力和颜色作为参数,并将这些参数赋值给类的属性。方法:
ShowDetails()
:显示汽车的详细信息。Repair()
:显示汽车修复的提示信息。public class Car {
public int HP { get; set; }
public string Color { get; set; }
public Car(int hp, string color) {
this.HP = hp;
this.Color = color;
}
public void ShowDetails() {
Console.WriteLine("HP: " + HP + ", Color: " + Color);
}
public virtual void Repair() {
Console.WriteLine("Car was repaired.");
}
}
接下来,创建 BMW
和 Audi
类,这两个类分别继承自 Car
类,并添加自己特有的属性和方法。
Brand
字符串字段并设置为 BMW
。Model
属性。ShowDetails()
方法,显示品牌、马力和颜色。Repair()
方法,显示特定的 BMW 维修提示。public class BMW : Car {
private string Brand = "BMW";
public string Model { get; set; }
public BMW(int hp, string color, string model) : base(hp, color) {
this.Model = model;
}
public new void ShowDetails() {
Console.WriteLine("Brand: " + Brand + ", HP: " + HP + ", Color: " + Color);
}
public override void Repair() {
Console.WriteLine("BMW " + Model + " was repaired.");
}
}
BMW
类类似,定义 Brand
为 Audi
,并重写相应的方法。public class Audi : Car {
private string Brand = "Audi";
public string Model { get; set; }
public Audi(int hp, string color, string model) : base(hp, color) {
this.Model = model;
}
public new void ShowDetails() {
Console.WriteLine("Brand: " + Brand + ", HP: " + HP + ", Color: " + Color);
}
public override void Repair() {
Console.WriteLine("Audi " + Model + " was repaired.");
}
}
new
关键词多态性允许我们使用基类引用来操作派生类对象。以下示例展示了多态性的不同用法:
创建基类类型的列表并存储派生类对象:
List<Car>
中存储 BMW
和 Audi
的对象,因为它们都继承自 Car
。在循环中调用多态方法:
foreach
遍历 cars
列表,并调用每个对象的 Repair()
方法。通过 virtual
和 override
,可以让不同的子类实现自己的 Repair()
方法,显示特定的信息。new
关键词的用法:
new
关键词而不是 override
时,子类中的方法不会完全覆盖基类的方法,而是用于隐藏基类的方法。当我们通过基类引用访问子类实例时,调用的是基类的方法,而不是被 new
隐藏的方法。public class Program {
static void Main(string[] args) {
List<Car> cars = new List<Car> {
new Audi(200, "blue", "A4"),
new BMW(250, "red", "M3")
};
foreach (var car in cars) {
car.Repair();
}
BMW bmwZ3 = new BMW(300, "black", "Z3");
Audi audiA3 = new Audi(100, "green", "A3");
bmwZ3.ShowDetails(); // 调用 BMW 类的 ShowDetails()
audiA3.ShowDetails(); // 调用 Audi 类的 ShowDetails()
Car car = new BMW(330, "white", "MX5");
car.ShowDetails(); // 调用 Car 类的 ShowDetails() 而非 BMW 的 ShowDetails()
}
}
new
、virtual
和 override
virtual
和 override
:通过在基类中使用 virtual
关键字,子类可以使用 override
来重写基类中的方法。new
关键字:当子类方法用 new
关键字标记时,它不会覆盖基类的方法,而是隐藏基类的方法。基类引用调用的是基类方法,而不是子类的同名方法。以上就是多态性及其相关关键词的基本应用。
sealed
关键字——方法与类的封闭在本视频中,我们会探讨 sealed
关键字的用法。sealed
关键字可以防止类或方法被进一步继承或重写。具体来说,当我们想要停止对某个方法的重写,或者不允许某个类被继承时,就可以使用 sealed
关键字。
让我们从一个简单的类层次结构开始,构建以下类:
Car
:这是一个基类,其中包含一个可以被重写的 Repair
方法。BMW
:继承自 Car
,并且重写了 Repair
方法。M3
:继承自 BMW
,尝试进一步重写 Repair
方法。我们在 Car
类中创建一个虚拟方法 Repair
,允许在子类中重写。
public class Car {
public virtual void Repair() {
Console.WriteLine("Car was repaired.");
}
}
然后在 BMW
类中,我们继承了 Car
,并重写了 Repair
方法。
public class BMW : Car {
public override void Repair() {
Console.WriteLine("BMW was repaired.");
}
}
假设现在我们有一个 M3
类,它继承自 BMW
,并希望再次重写 Repair
方法。
public class M3 : BMW {
public override void Repair() {
Console.WriteLine("M3 was repaired.");
}
}
在这个例子中,M3
成功地重写了 Repair
方法,因为在 BMW
类中,我们仅仅重写了 Repair
,并没有阻止进一步的重写。
sealed
关键字防止进一步重写假设我们希望 BMW
类中的 Repair
方法成为该方法的最终版本,不允许任何子类再重写它。这时,我们可以在 Repair
方法前面加上 sealed
关键字。
public class BMW : Car {
public sealed override void Repair() {
Console.WriteLine("BMW was repaired.");
}
}
在此设置下,如果我们在 M3
类中尝试重写 Repair
方法,就会出现编译错误:
public class M3 : BMW {
public override void Repair() { // Error: cannot override inherited member 'BMW.Repair' because it is sealed
Console.WriteLine("M3 was repaired.");
}
}
这意味着 Repair
方法在 BMW
类中被“密封”了,无法在 M3
类中被重写。
sealed
类——防止类被继承除了防止方法重写外,sealed
关键字还可以用于类级别,以防止类被继承。如果我们不希望任何类继承自 BMW
,可以将 BMW
类声明为 sealed
:
public sealed class BMW : Car {
public override void Repair() {
Console.WriteLine("BMW was repaired.");
}
}
现在如果我们尝试让 M3
继承 BMW
类,将会产生错误:
public class M3 : BMW { // Error: cannot derive from sealed type 'BMW'
// M3 implementation here
}
这样,BMW
成为继承链的最终类,不再允许进一步的继承。
sealed
:如果在重写方法时使用 sealed
关键字,则该方法不能被进一步重写。例如,将方法声明为 sealed override
。sealed
:将类声明为 sealed
会防止其他类从该类继承,例如 public sealed class BMW
。sealed
关键字在保护类结构和防止意外重写或继承时非常有用,尤其是在大型代码库中,可以增强代码的稳定性。
Has-A
关系简介在本视频中,我们将探讨 "Has-A" 关系,它不同于继承中的 "Is-A" 关系。在前面的例子中,我们看到 BMW 是一种汽车 (Car),也就是说,BMW
继承自 Car
类,因此是一种 "Is-A" 关系。例如,BMW
是一种 Car
,Audi
也是一种 Car
,M3
是一种 BMW
,等等。
现在我们要探索的 "Has-A" 关系代表着对象之间的一种组合关系。例如,我们希望为每一辆车都增加一些特定信息,比如车的唯一 ID 和车主。这种情况下,我们可以通过创建一个新的类来包含这些信息,而不是直接在 Car
类中增加新的属性。这样一来,每辆车 拥有 (Has) 一个 CarIDInfo
对象。
CarIDInfo
类首先,我们来创建一个专门存储车 ID 和车主的类 CarIDInfo
:
public class CarIDInfo {
public int IDNum { get; set; } = 0; // 车的唯一 ID
public string Owner { get; set; } = "No Owner"; // 车主,默认为 "No Owner"
}
在 CarIDInfo
中,我们定义了两个属性:
IDNum
:一个整数,用于存储每辆车的唯一 ID。Owner
:一个字符串,用于存储车主的名字。如果车辆还没有主人,默认值为 "No Owner"。CarIDInfo
添加到 Car
类中接下来,我们将 CarIDInfo
集成到 Car
类中,以创建一个 "Has-A" 关系。
public class Car {
public int HP { get; set; } // 汽车的马力
public string Color { get; set; } // 汽车的颜色
protected CarIDInfo CarIDInfo { get; set; } = new CarIDInfo(); // 每辆车都拥有一个 CarIDInfo 对象
// 构造函数
public Car(int hp, string color) {
HP = hp;
Color = color;
}
// 方法用于设置 CarIDInfo 的信息
public void SetCarIDInfo(int idNum, string owner) {
CarIDInfo.IDNum = idNum;
CarIDInfo.Owner = owner;
}
// 方法用于获取 CarIDInfo 的信息
public void GetCarIDInfo() {
Console.WriteLine($"The car has ID {CarIDInfo.IDNum} and is owned by {CarIDInfo.Owner}");
}
}
在 Car
类中:
CarIDInfo
属性,用于存储车的 ID 和车主信息。SetCarIDInfo
方法,用于设置 CarIDInfo
对象的属性。GetCarIDInfo
方法,用于显示 CarIDInfo
中的信息。BMW
和 Audi
类然后我们定义 BMW
和 Audi
类,让它们继承自 Car
,同时也继承 CarIDInfo
的信息。
public class BMW : Car {
public string Model { get; set; }
public BMW(int hp, string color, string model) : base(hp, color) {
Model = model;
}
}
public class Audi : Car {
public string Model { get; set; }
public Audi(int hp, string color, string model) : base(hp, color) {
Model = model;
}
}
每个子类都有自己特定的 Model
属性,并通过构造函数初始化它们各自的特定属性。
CarIDInfo
最后,我们在主程序中创建 BMW
和 Audi
对象,并为每辆车设置 CarIDInfo
信息。
class Program {
static void Main() {
// 创建 BMW 和 Audi 对象
BMW bmwZ3 = new BMW(260, "Red", "Z3");
Audi audiA3 = new Audi(220, "Green", "A3");
// 为每辆车设置 CarIDInfo
bmwZ3.SetCarIDInfo(1234, "Dennis Panetta");
audiA3.SetCarIDInfo(1235, "Frank White");
// 获取并打印每辆车的 CarIDInfo 信息
bmwZ3.GetCarIDInfo();
audiA3.GetCarIDInfo();
}
}
运行代码后,输出如下:
The car has ID 1234 and is owned by Dennis Panetta
The car has ID 1235 and is owned by Frank White
通过本视频,你学习了如何使用组合 (Composition) 来实现 "Has-A" 关系。相比于 "Is-A" 关系,"Has-A" 关系允许我们将一个类作为另一个类的属性,从而更灵活地管理对象之间的关系。这种方法可以在不改变继承结构的情况下为类添加额外的属性和方法,非常适用于对象包含其他对象的情况。
在本视频中,我们将探讨 抽象类 的概念。这是面向对象编程的重要组成部分,理解了它可以更好地掌握继承和多态的概念。我们会创建几个新的类,并展示抽象类在代码中的应用。
抽象类是我们不打算直接实例化的类,而是作为一种模板,供子类继承和实现。例如,我们创建一个 Shape
类,它代表一个几何形状,但我们不打算直接创建一个 Shape
对象。相反,我们会创建具体的形状(如立方体和球体)的类,这些类继承自 Shape
类,并实现它的特定属性和方法。
Shape
首先,我们来创建一个 Shape
抽象类:
public abstract class Shape {
public string Name { get; set; } // 形状名称
// 虚方法,允许子类重写
public virtual void GetInfo() {
Console.WriteLine($"This is a {Name}");
}
// 抽象方法,用于计算体积(子类必须实现)
public abstract double Volume();
}
在 Shape
类中,我们定义了以下内容:
Name
:存储形状的名称。GetInfo()
:打印形状的信息,可以在子类中重写。Volume()
:定义了一个计算体积的方法,但没有具体实现。所有继承 Shape
的子类必须实现 Volume()
方法。Cube
接下来,我们创建 Cube
类,它代表一个立方体,并继承自 Shape
:
public class Cube : Shape {
public double Length { get; set; } // 立方体的边长
// 构造函数
public Cube(double length) {
Name = "Cube";
Length = length;
}
// 重写 Volume 方法,计算立方体的体积
public override double Volume() {
return Math.Pow(Length, 3);
}
// 重写 GetInfo 方法
public override void GetInfo() {
base.GetInfo();
Console.WriteLine($"The {Name} has a length of {Length}");
}
}
在 Cube
类中,我们定义了:
Length
:用于存储立方体的边长。Name
和 Length
属性。Volume()
:计算立方体的体积。GetInfo()
:调用父类的 GetInfo()
,并额外输出立方体的边长。Sphere
我们再创建一个 Sphere
类,代表球体,并继承自 Shape
:
public class Sphere : Shape {
public double Radius { get; set; } // 球体的半径
// 构造函数
public Sphere(double radius) {
Name = "Sphere";
Radius = radius;
}
// 重写 Volume 方法,计算球体的体积
public override double Volume() {
return (4 / 3) * Math.PI * Math.Pow(Radius, 3);
}
// 重写 GetInfo 方法
public override void GetInfo() {
base.GetInfo();
Console.WriteLine($"The {Name} has a radius of {Radius}");
}
}
在 Sphere
类中,我们定义了:
Radius
:用于存储球体的半径。Name
和 Radius
属性。Volume()
:计算球体的体积。GetInfo()
:调用父类的 GetInfo()
,并额外输出球体的半径。现在我们可以在主程序中创建 Cube
和 Sphere
的对象,来测试抽象类和子类的功能:
class Program {
static void Main() {
// 创建一个 Shape 数组,包含 Cube 和 Sphere
Shape[] shapes = { new Sphere(4), new Cube(3) };
// 遍历每个 Shape 对象并显示信息和体积
foreach (Shape shape in shapes) {
shape.GetInfo();
Console.WriteLine($"{shape.Name} has a volume of {shape.Volume()}");
Console.WriteLine();
}
}
}
运行后输出如下:
This is a Sphere
The Sphere has a radius of 4
Sphere has a volume of 268.08
This is a Cube
The Cube has a length of 3
Cube has a volume of 27
通过这个视频,你学习了以下内容:
override
关键字重写。在下一个视频中,我们将继续探讨更多与抽象类相关的关键字和功能,比如 sealed
等。
在本视频中,我们将深入探讨 抽象类 的用法,特别是如何使用 类型转换(casting)来处理继承的对象。了解这些概念将帮助我们更好地运用 多态性(polymorphism)以及更灵活地管理类和对象。
as
和 is
关键字进行类型检查和转换我们可以使用 as
和 is
关键字来确定一个对象是否是特定类型,以及安全地将对象转换成该类型。
as
关键字假设我们有一个 Cube
类实例,名称为 IceCube
。使用 as
关键字可以尝试将一个对象转换成指定的类型。如果转换失败,它会返回 null
,而不会引发异常:
// 创建一个 Shape 类型的对象
Shape shape = new Sphere(5); // 例如一个 Sphere 对象,半径为5
// 使用 as 关键字尝试将其转换为 Cube
Cube iceCube = shape as Cube;
if (iceCube != null) {
Console.WriteLine("This shape is a cube.");
} else {
Console.WriteLine("This shape is no cube.");
}
is
关键字判断类型is
关键字可以用来检查一个对象是否属于某个类型。如果属于该类型,它会返回 true
:
if (shape is Cube) {
Console.WriteLine("This shape is a cube.");
} else {
Console.WriteLine("This shape is not a cube.");
}
这两个检查方法可以帮助我们安全地判断和处理对象的类型,以便在运行时执行不同的操作。
当我们确定某个对象的类型后,可以使用显式转换将其转为特定类型,并调用类型特有的属性或方法。
我们创建一个 Cube
实例并进行类型转换:
Cube cube1 = new Cube(7); // 创建一个边长为7的 Cube 实例
// 使用显式转换将 cube1 转换为一个新 Cube 对象
Cube cube2 = (Cube)cube1;
Console.WriteLine($"{cube2.Name} has a volume of {cube2.Volume()}");
在这个示例中,我们将 cube1
显式转换为 Cube
类型的 cube2
,然后调用 Volume
方法计算体积。
object
类的概念在 C# 中,所有类都默认继承自 object
类,object
类包含一些通用方法,例如 ToString
、GetHashCode
、Equals
等。这些方法对于每个类都是可用的,即使我们没有显式定义它们。
Shape shape = new Cube(3);
Console.WriteLine(shape.ToString()); // 输出对象的字符串表示形式
object
类的这些基础方法可以被任何对象使用。比如,我们可以调用 shape.ToString()
来获取 shape
对象的字符串表示形式。
在本视频中,我们学习了以下内容:
as
和 is
关键字:用于类型检查和类型转换。
as
用于尝试将对象转换为指定类型。is
用于检查对象是否属于某个类型。类型转换(Casting):用于将对象显式转换为特定类型,以便调用特定的属性和方法。
object
类的通用方法:所有类默认继承自 object
,因此可以使用 ToString
、Equals
等通用方法。
通过这些技术,我们可以更灵活地在多态性场景中处理对象的类型。在下一个视频中,我们将继续深入探讨抽象类和多态性的高级用法。
在本节课中,我们将讨论 抽象类 和 接口 的关键区别,并理解在设计应用程序时,何时使用抽象类、何时使用接口以及何时都不使用。理解这两者的区别对于设计 松耦合 和 可扩展 的应用程序非常重要,这样的设计可以让应用程序更易于修改而不破坏现有功能。
假设我们有一个 Motorcycle
类和一个 Car
类,它们有一些相似的功能。为了减少代码冗余,可以创建一个基本类 Vehicle
。为了防止直接创建 Vehicle
实例,我们将它标记为抽象类。这样可以确保派生类如 Motorcycle
和 Car
必须实现 Vehicle
中定义的通用方法,同时保持无法直接实例化 Vehicle
。
假设我们有三个类 Bicycle
、Motorcycle
和 Car
,它们都继承自 Vehicle
。如果我们想为 Car
类添加自动驾驶功能,不需要在 Vehicle
基类中添加该功能,因为并不是所有的派生类都需要它。更好的方式是创建一个接口 ISelfDriving
,并让需要自动驾驶功能的类实现该接口。
Cat
类型的对象存储在 abstract class Animal
类型的变量中,类似地,可以将 List
或 Stack
类型的对象存储在 IEnumerable
类型的变量中。特点 | 抽象类 | 接口 |
---|---|---|
实现与否 | 抽象类可以包含部分实现或没有实现的方法 | 接口完全没有实现,只是方法声明 |
构造函数 | 可以包含构造函数和字段 | 不能包含构造函数或字段 |
多继承支持 | C# 支持类实现多个接口,提供多重继承的能力 | C# 只允许单继承,但可以通过实现多个接口实现类似的多继承 |
方法实现 | 子类必须实现抽象方法,但可以直接继承非抽象方法 | 实现接口的类必须实现接口的所有成员 |
IEditable
接口,而不需要创建一个共同的基类。IEditable
表示对象可以编辑,而 IPrintable
表示对象可以打印)。通过理解这些概念,我们可以在接下来的课程和例子中更好地应用这些原则来编写灵活且可维护的代码。
在本视频中,我们将学习如何从文本文件中读取内容。我们创建了一个简单的 .txt
文件,并放置在一个名为 Assets
的文件夹中。文本文件内容如下:
Hello world
Dennis here
这是一个简单的文本文件,您可以自己创建一个类似的小文件来跟随本视频的演示。
首先,我们可以使用 System.IO
命名空间中的 File
类,该类提供了多种方法来读取、打开和处理文件内容。我们将使用 ReadAllText
方法来读取整个文件的内容。
// 保存文件内容的字符串
string text = System.IO.File.ReadAllText(@"C:\YourPath\Assets\textfile.txt");
// 输出文件内容
Console.WriteLine("Text file contains following text: \n{0}", text);
// 保持控制台窗口打开
Console.ReadKey();
在上述代码中:
ReadAllText
方法会读取指定文件的全部内容并返回一个字符串。@
符号确保文件路径中的反斜杠不被解释为转义字符。text
变量中,并使用 Console.WriteLine
输出内容。Text file contains following text:
Hello world
Dennis here
这样就可以读取整个文本文件的内容,并在控制台中显示。
另一种读取文件的方式是按行读取,适合需要逐行处理的情况。我们将使用 File.ReadAllLines
方法,它会将每一行内容存储在一个字符串数组中。
// 按行读取文件内容到字符串数组
string[] lines = System.IO.File.ReadAllLines(@"C:\YourPath\Assets\textfile.txt");
// 输出文件的每一行内容
Console.WriteLine("Contents of text file:");
foreach (string line in lines)
{
Console.WriteLine("\t" + line);
}
// 保持控制台窗口打开
Console.ReadKey();
在上述代码中:
ReadAllLines
方法会读取文件的每一行内容,并将其存储到字符串数组 lines
中。foreach
循环逐行输出每行内容,并在输出行前加入一个制表符 \t
,使输出更加清晰。Contents of text file:
Hello world
Dennis here
此方法逐行读取文件内容,并在控制台中显示。
文件读取功能可用于各种应用场景,比如:
在 C# 中可以通过 System.IO.File
类中的 ReadAllText
方法读取整个文件内容,也可以使用 ReadAllLines
方法逐行读取内容。选择哪种方法取决于需求:若需要一次性读取整个文件,可以使用 ReadAllText
;若需要逐行处理,则使用 ReadAllLines
。
这两种方法简洁且实用,适合在日常的文件操作中使用。
在本视频中,我们将学习如何将内容写入文件。之前我们已经学会了如何读取文件内容,现在我们来看看如何将数据写入文件,有多种不同的写入方法可供选择。
首先,我们可以使用字符串数组 string[]
,将每一行内容分别存储,然后写入文件。这个方法适用于我们希望在文件中逐行写入内容的情况。
// 创建字符串数组,每一行存储为一个元素
string[] lines = { "First line", "Second line", "Third line" };
// 使用 File.WriteAllLines 方法写入文件
System.IO.File.WriteAllLines(@"C:\YourPath\Assets\textfile2.txt", lines);
在上面的代码中:
WriteAllLines
方法将 lines
数组中的每一行内容写入到指定路径的新文件 textfile2.txt
中。在文件 textfile2.txt
中,内容将显示为:
First line
Second line
Third line
如果您想将完整的字符串写入文件,而不是逐行写入,可以使用 File.WriteAllText
方法。
// 提示用户输入文件名
Console.WriteLine("Please give the file a name:");
string fileName = Console.ReadLine();
// 提示用户输入文件内容
Console.WriteLine("Please enter the text for the file:");
string input = Console.ReadLine();
// 将用户输入的内容写入指定文件
System.IO.File.WriteAllText($@"C:\YourPath\Assets\{fileName}.txt", input);
在上面的代码中:
WriteAllText
方法,将用户输入的内容写入用户指定的文件名。$@"C:\YourPath\Assets\{fileName}.txt"
生成动态路径。如果用户输入了以下内容:
testfile
Hi there, this is a test.
在 testfile.txt
文件中,内容将显示为:
Hi there, this is a test.
使用 StreamWriter
是另一种方法,它允许我们逐行写入文件内容,可以更加灵活地控制写入的内容。StreamWriter 适用于需要按条件选择写入内容的情况。
// 创建 StreamWriter 实例并指定文件路径
using (System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\YourPath\Assets\mytext.txt"))
{
// 逐行检查并写入包含 "third" 的行
foreach (string line in lines)
{
if (line.Contains("third"))
{
file.WriteLine(line);
}
}
}
在上面的代码中:
StreamWriter
创建或覆盖指定文件 mytext.txt
。foreach
循环遍历 lines
数组,仅将包含单词 "third" 的行写入文件。在文件 mytext.txt
中,只会显示包含 "third" 的行:
Third line
在某些情况下,我们可能需要追加内容到已有文件中,而不是覆盖文件。我们可以通过 StreamWriter
实现,并指定 append
参数为 true
。
// 追加内容到文件
using (System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\YourPath\Assets\mytext2.txt", true))
{
file.WriteLine("Additional line");
}
在上面的代码中:
StreamWriter
并设置第二个参数为 true
,以追加内容到文件 mytext2.txt
中,而不是覆盖现有内容。Additional line
。在 mytext2.txt
文件的末尾,内容将显示为:
Additional line
在 C# 中,您可以使用多种方法将内容写入文件:
WriteAllLines
:适合逐行写入内容。WriteAllText
:适合写入整个文本内容。StreamWriter
:提供了更多写入控制,可以根据条件选择性地写入内容或追加内容。每种方法都适用于不同的使用场景,具体选择取决于需求。例如,可以使用 WriteAllLines
写入高分排行榜,或使用 StreamWriter
追加日志条目。
恭喜你完成了多态性的学习!现在,你已经理解了多态性的工作原理、它的概念、如何确保类不能从其他类继承,以及如何重写方法等等这些重要的内容。通过这些知识,你可以编写更复杂的程序,并具备了在团队中合作的能力。
在团队中编程比单独编程更具挑战性,因为不同的代码片段可能相互影响,导致程序无法顺利运行。而多态性是一个很好的工具,帮助我们在多人协作中避免代码冲突,确保程序更具可扩展性和可维护性。
接下来我们将进入 高级 C# 主题。在这一章节中,我们将学习一系列不同的知识点,这些知识可能难以归类到特定的章节或主题中,但它们都是你在 C# 编程生涯中会频繁用到的重要技能。
这章内容包含各种实用的高级技术,希望能为你打下坚实的 C# 基础,提升编程技能,使你的代码更高效、更灵活。
所以,让我们一起进入下一章吧!
欢迎回来!在本章中,你将学习许多不同的主题,虽然它们彼此之间并没有直接联系,但仍然是编程中的重要组成部分。具体来说,你将了解以下内容:
首先,你将学习什么是结构体。结构体是编程中用来组织相关数据的一种方式。你会明白如何定义结构体以及它们在编程中的作用和用法。
在游戏开发或仿真等领域,理解“敌人”如何在系统中表示和操作非常重要。本节将介绍如何定义和管理“敌人”对象或实体。
接下来,你会学习访问修饰符。访问修饰符用于控制程序中不同代码段之间的可见性和访问权限。了解如何使用访问修饰符可以帮助你保护数据,确保只有必要的部分可以访问或修改特定数据。
数学类提供了各种数学运算的功能,例如三角函数、指数、对数等。本章会介绍如何使用数学类进行这些常用的计算操作。
在许多应用中,生成随机数是常见需求。随机类为此提供了方法,可以用来生成随机整数、浮点数等。本章会演示如何正确使用随机类生成所需的随机数据。
状态类用于管理程序中状态的变化。它在游戏开发或复杂应用中尤为重要,因为它可以帮助你组织状态的不同变化并对其进行控制。
正则表达式是匹配字符串模式的强大工具,对于文本处理和数据验证非常有用。虽然学习正则表达式可能有些困难,但它在编程中是非常重要的。本节会详细解释如何编写和使用正则表达式。
你还会看到编程语言中“数字”的概念。不同编程语言对数字的定义和使用有所不同,但它们在基础概念上有相似之处。本节将帮助你理解各种编程语言对数字的不同处理方式。
垃圾回收器是编程语言用于管理内存的一种机制。它会自动回收不再使用的对象或数据以释放内存。本节会解释垃圾回收器的工作原理及其重要性。
抽象是面向对象编程中的一个重要概念。你将了解如何定义抽象类和抽象方法,并在实际开发中灵活运用这些概念来实现代码的重用和扩展。
本章还包含许多其他重要的概念和主题,这些知识将帮助你进一步理解编程的基本原理和高级功能。
现在,我们开始深入学习这些内容,享受这一章节带来的知识吧!
欢迎回来!在本视频中,我将介绍“访问修饰符”,它们允许你授予或限制代码的访问权限。为了更好地理解访问修饰符的作用以及它们背后的原理,我们需要了解一些相关的概念。
将字段和方法标记为特定的访问修饰符是面向对象编程 (OOP) 的一部分,它提高了代码的安全性,是封装的核心内容。封装是 OOP 的一个重要概念,在面向对象编程语言(如 C#)中,封装指的是两个相互关联但略有不同的概念,有时也指这两者的结合:
例如,我们可以使用setter和getter方法来提高程序的安全性。在接下来的课程中,当我们学习属性时,我们将深入了解如何通过属性执行许多类似的操作。
接下来,我们将依次介绍几个常用的访问修饰符,并通过示例进行说明。
private
修饰符只允许在类或结构体内部访问。当你创建一个私有变量或私有方法时,它只能在定义它的类或结构体内部使用,无法在其他地方访问。例如:
class ClassOne {
private int age = 18;
private void Walk() {
// 方法代码
}
}
class ClassTwo {
void AccessExample() {
ClassOne firstClass = new ClassOne();
// 无法访问 firstClass.age 或 firstClass.Walk(),因为它们是私有的
}
}
在上面的例子中,age
和 Walk
方法在 ClassTwo
中是不可访问的,因为它们在 ClassOne
中被设为私有。
public
修饰符允许从项目的任何地方访问。使用 public
修饰符声明的变量或方法可以在整个项目中的每个类中使用,没有任何限制。例如:
class ClassOne {
public int age = 18;
public void Walk() {
// 方法代码
}
}
class ClassTwo {
void AccessExample() {
ClassOne firstClass = new ClassOne();
// 可以访问 firstClass.age 和 firstClass.Walk()
}
}
在这个例子中,由于 age
和 Walk
都被声明为 public
,所以在 ClassTwo
中可以直接访问它们。
protected
修饰符允许从当前类及其派生类中访问。要理解 protected
的作用,必须理解面向对象编程的继承概念。使用 protected
修饰的变量和方法在派生类中可以直接访问,不需要创建父类的对象。例如:
class ClassOne {
protected int age = 18;
protected void Walk() {
// 方法代码
}
}
class ClassTwo : ClassOne {
void AccessExample() {
// 可以直接访问 age 和 Walk 方法
}
}
在这个例子中,ClassTwo
继承自 ClassOne
,因此它可以直接访问 age
和 Walk
,就像它们是 public
一样。
internal
修饰符限制变量和方法只能在同一个程序集内访问,也就是同一命名空间或项目内部访问。如果你有一个命名空间,内部声明的内容可以在该命名空间中的所有类之间访问。例如:
namespace ProjectNamespace {
internal class ClassOne {
internal int age = 18;
internal void Walk() {
// 方法代码
}
}
class ClassTwo {
void AccessExample() {
ClassOne firstClass = new ClassOne();
// 可以访问 firstClass.age 和 firstClass.Walk()
}
}
}
在这个例子中,age
和 Walk
可以在 ProjectNamespace
中的任何类中访问,因为它们是 internal
的。
如何正确地使用访问修饰符?通常,声明一个新的类成员或方法时,建议使用最严格的访问修饰符来确保代码的安全性。通常从 private
开始,当需要更广泛的访问权限时,再逐步放宽权限到 internal
、protected
或 public
。这种方式可以确保代码的可控性和安全性。
访问修饰符为你的方法和变量提供了完全的控制。一个简单的例子是年龄变量 (age
) 的使用:
假设你希望可以设置年龄,但如果将 age
设置为 public
,那么任何类都可以随意更改它,甚至设置一个无意义的值,比如 -20
或 -25
。然而,如果 age
是 private
,并且通过setter和getter来访问,你可以确保该变量的值有效。例如,当传入负值时,可以将其乘以 -1
或设置为 0
;当值超过合理范围(如 150
或 130
)时,可以抛出错误或请求用户输入有效值。
访问修饰符不仅提供了代码的安全性和控制力,还帮助你实现数据封装,使得代码更健壮、灵活。在后续视频中,我们将进一步探讨访问修饰符、封装以及更多相关概念。
欢迎回来!在本视频中,我们将讨论结构体 (structs)。结构体与类 (classes) 非常相似,但它们有一个显著的区别:类是引用类型 (reference types),而结构体是值类型 (value types)。这意味着创建一个类对象时,它可以是空的,但结构体必须包含一个值。
我们先创建一个结构体来了解基本用法。假设我们创建一个名为 Game
的结构体,使用 struct
关键字,然后为其命名。和类的定义非常相似,可以在其中创建变量。例如:
struct Game {
public string Name;
public string Developer;
public double Rating;
public string ReleaseDate;
}
在这里,我们定义了一个简单的 Game
结构体,包含游戏的名称、开发者、评分和发布日期字段。
接下来,我们创建一个 Game
对象,并为每个字段赋值:
Game game1;
game1.Name = "Pokémon Go";
game1.Developer = "Niantic";
game1.Rating = 3.5;
game1.ReleaseDate = "2016-07-01";
这样我们就创建了 Game
对象 game1
,并分别设置了游戏的名称、开发者、评分和发布日期。
为了输出这些信息,可以使用以下代码:
Console.WriteLine($"Game name is {game1.Name}");
Console.WriteLine($"Game was developed by {game1.Developer}");
Console.WriteLine($"Rating is {game1.Rating}");
Console.WriteLine($"Release date is {game1.ReleaseDate}");
运行程序后,你会看到输出的 game1
信息。
结构体和类的相似性显而易见,例如,它们都可以包含变量和方法,但两者有一些重要的区别:
引用类型 vs 值类型:
无参数的构造函数:
继承:
抽象、虚方法、受保护成员:
abstract
、virtual
或 protected
,只能是 public
或 private
。可以在结构体中创建方法来显示信息,例如:
public void Display() {
Console.WriteLine($"Name: {Name}");
Console.WriteLine($"Developer: {Developer}");
Console.WriteLine($"Rating: {Rating}");
Console.WriteLine($"Release Date: {ReleaseDate}");
}
然后,通过 game1.Display()
调用此方法,以便输出 game1
的所有信息。
虽然结构体不能包含无参数的构造函数,但可以定义参数化构造函数。例如:
public Game(string name, string developer, double rating, string releaseDate) {
Name = name;
Developer = developer;
Rating = rating;
ReleaseDate = releaseDate;
}
这样就可以通过构造函数直接初始化结构体:
Game game1 = new Game("Pokémon Go", "Niantic", 3.5, "2016-07-01");
但是,请注意,必须初始化所有字段,否则会报错,因为结构体中的每个成员都需要被赋值。
结构体和类都可以用于组合具有逻辑关系的多个变量,可以包含方法和事件,并且可以支持接口的实现。尽管一般情况下,类的实例存储在堆上,而结构体的实例存储在栈上,但也有一些例外情况,需要根据具体需求和性能考量来决定选择结构体还是类。
建议查看官方文档或参考 StackOverflow 等平台,获取更多关于结构体和类的深入信息,以便更好地理解两者的差异和应用场景。
欢迎回来!在本视频中,我们将讨论枚举 (enum) 的概念。枚举基本上是一组常量,具有不可变的特性。它通常被放置在命名空间的级别上,以便整个库可以访问它。枚举适用于固定的一组值,比如一周的七天。
假设我们创建一个代表“天”的枚举,因为一周只有七天,所以这个枚举将只包含以下值:
enum Day {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
}
这个 Day
枚举定义了一周中的每一天,这七个值是固定的,不会发生变化。
现在我们定义了一个 Day
枚举,可以在代码中使用它。让我们在主方法中创建 Day
枚举的对象:
Day fr = Day.Friday;
这里,fr
被设为 Day
枚举中的 Friday
。每个枚举值也都有一个索引,比如 Monday
是 0
,Tuesday
是 1
,依此类推。让我们创建一个示例:
Day a = Day.Friday;
Console.WriteLine(fr == a); // 输出: True
在这里,fr
和 a
都是 Friday
,因此它们相等,输出 True
。
我们还可以将枚举值直接打印到控制台:
Console.WriteLine(Day.Monday); // 输出: Monday
可以将枚举值转换为整数,以查看它的索引。以下是示例:
Console.WriteLine((int)Day.Monday); // 输出: 0
Day.Monday
的值为 0
,因为它是枚举中的第一个元素。这样可以获得每个枚举值的整数表示。
接下来,我们来创建一个包含一年的月份的枚举 Month
:
enum Month {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December
}
现在,我们可以用相同的方式访问 Month
枚举中的值。由于 Month
枚举定义在命名空间级别,因此可以在整个项目中访问它。
我们还可以更改枚举的起始值。例如,默认情况下,枚举索引从 0
开始,但我们可以设置 January
的值为 1
:
enum Month {
January = 1,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December
}
这样,January
的值变为 1
,依次递增,February
的值为 2
,以此类推。
可以通过以下方式检查 February
的值:
Console.WriteLine((int)Month.February); // 输出: 2
我们还可以为特定的枚举项自定义值。例如,将 July
的值设为 12
,它之后的值会从 12
开始递增:
enum Month {
January = 1,
February,
March,
April,
May,
June,
July = 12,
August,
September,
October,
November,
December
}
在这种情况下,July
的值为 12
,August
将是 13
,以此类推。我们可以检查:
Console.WriteLine((int)Month.August); // 输出: 13
枚举 (enum) 的关键要点在于它们提供了一组共享的常量,使库或应用程序保持一致性。枚举在整个命名空间中共享同一组常量值,这样不必在每个类中重复定义这些常量。使用枚举可以让代码更具可读性,简化了固定值的管理。
欢迎回来!在本视频中,我们将讨论数学类 (Math Class)。数学类不仅仅是我们在学校学习的数学,而是提供了一些常量和静态方法,用于执行各种数学计算,比如三角函数、对数函数等。你无需自己编写这些方法,它们已经在 Math
类中准备好了。
要使用 Math
类,只需输入 Math
。Math
类提供了一些常量和静态方法,可以用于三角运算、对数运算及其他常见数学操作。例如,如果想要向上取整,可以使用 Math.Ceiling
方法。
Math.Ceiling
double result = Math.Ceiling(15.3);
Console.WriteLine($"Ceiling: {result}"); // 输出: 16
即使 15.3
小于 15.5
,Math.Ceiling
仍然会将其取整到下一个整数,即 16
。
Math.Floor
Math.Floor
用于向下取整到最接近的整数。例如:
double result = Math.Floor(15.3);
Console.WriteLine($"Floor: {result}"); // 输出: 15
15.3
会向下取整为 15
。
Math.Min
和 Math.Max
可以使用 Math.Min
和 Math.Max
来找到两个值中的较小值或较大值。例如:
int num1 = 13;
int num2 = 9;
Console.WriteLine($"Lower of {num1} and {num2} is {Math.Min(num1, num2)}"); // 输出: 9
Console.WriteLine($"Higher of {num1} and {num2} is {Math.Max(num1, num2)}"); // 输出: 13
这些方法非常有用,尤其是在用户输入或数据库查询的场景中,不确定输入的具体值时。
Math.Pow
Math.Pow
用于计算一个数的幂。例如,计算 3
的 5
次幂:
double result = Math.Pow(3, 5);
Console.WriteLine($"3 to the power of 5 is {result}"); // 输出: 243
Math.PI
Math
类提供了 Math.PI
常量,可以直接使用:
Console.WriteLine($"Pi is {Math.PI}"); // 输出: Pi 是 3.141592653589793
这提供了 Pi
值的精确表示(双精度浮点数的精度)。
Math.Sqrt
可以使用 Math.Sqrt
来计算平方根。例如:
double result = Math.Sqrt(25);
Console.WriteLine($"Square root of 25 is {result}"); // 输出: 5
Math.Sqrt
接受双精度浮点数,因此你可以对非整数值进行平方根计算。
Math.Abs
Math.Abs
返回一个数的绝对值,无论输入的是正数还是负数,结果都会是正数。例如:
double result = Math.Abs(-25);
Console.WriteLine($"Always positive: {result}"); // 输出: 25
Math.Cos
Math
类中还包括各种三角函数方法,例如 Math.Cos
:
double result = Math.Cos(1);
Console.WriteLine($"Cosine of 1 is {result}"); // 输出大约为 0.5403
Math
类提供了许多其他有用的方法,如对数、指数、双曲函数等。例如:
Math.Log
:对数Math.Exp
:指数Math.Sin
、Math.Tan
等其他三角函数Math.Sinh
、Math.Cosh
等双曲函数这些方法可以帮助你在数学计算中轻松实现所需功能,而无需自行编写复杂的算法。
Math
类提供了一个丰富的数学函数库,适用于许多数学计算。无论是简单的取整操作、基本的加减乘除,还是复杂的幂运算和对数运算,都可以通过 Math
类实现。在编写数学密集型代码时,建议多利用 Math
类中的方法以提高代码的简洁性和效率。
我们在后续视频中会继续探讨更多编程概念。
欢迎回来!在本视频中,我们将讨论随机数生成器 Random
类。尤其是在开发视频游戏时,随机数生成非常有用,因为游戏中的随机性可以创造不可预测的情况,使游戏更具挑战性和趣味性。
首先,我们将创建一个随机骰子生成器。以下是创建随机数对象的基本步骤:
Random dice = new Random();
int numDice;
在这里,我们创建了一个 Random
对象 dice
。接下来,我们将生成十个随机数,模拟掷骰子的效果。代码如下:
for (int i = 0; i < 10; i++) {
numDice = dice.Next(1, 7); // 在1和6之间生成随机数,7不包括在内
Console.WriteLine(numDice);
}
Console.ReadLine(); // 保持控制台窗口打开
在这个代码中,dice.Next(1, 7)
方法生成一个介于 1
和 6
之间的随机整数。7
是不包含的上限。
运行代码后,你将看到一组随机数,例如 6, 1, 4, 5, 2
等,这些数字是每次掷骰子的结果。
现在,给你一个小挑战:创建一个简单的“占卜”程序,该程序会随机回答问题,用“是”、“可能”、“否”三个答案来回答。具体步骤如下:
以下是这个程序的实现:
Random yesNoMaybe = new Random();
int answerNum = yesNoMaybe.Next(1, 4); // 生成1到3之间的随机数
Console.WriteLine("请输入您的问题(是/否/可能类型的问题):");
Console.ReadLine(); // 读取问题(不实际使用,仅为了交互)
if (answerNum == 1) {
Console.WriteLine("是的");
} else if (answerNum == 2) {
Console.WriteLine("可能");
} else {
Console.WriteLine("不");
}
运行该程序时,你会被提示输入一个问题,然后程序会随机回答“是的”、“可能”或“不”。
yesNoMaybe.Next(1, 4)
:生成一个 1
到 3
之间的随机整数。1
代表“是的”,2
代表“可能”,3
代表“不”。Console.ReadLine()
:读取用户的问题。if-else
结构根据 answerNum
的值输出相应的答案。如果想要继续询问,可以将程序放入一个 while
循环中,允许用户多次提问:
while (true) {
Console.WriteLine("请输入您的问题(输入 'exit' 退出):");
string question = Console.ReadLine();
if (question.ToLower() == "exit") break;
answerNum = yesNoMaybe.Next(1, 4);
if (answerNum == 1) {
Console.WriteLine("是的");
} else if (answerNum == 2) {
Console.WriteLine("可能");
} else {
Console.WriteLine("不");
}
}
在这个改进版程序中,当用户输入 exit
时,程序会退出循环,停止占卜。
随机数生成器 Random
类在许多程序中都非常实用,尤其是在需要生成随机事件或随机结果时(如骰子、问答游戏等)。Random
类的 Next
方法可以生成指定范围内的随机数,适用于多种场景。希望你能从这个小练习中获得一些乐趣!我们在后续视频中会继续探索更多内容。
欢迎回来!本视频我们将讨论正则表达式 (Regex) 的概念。正则表达式是一种模式语言,正则表达式引擎会尝试在输入的文本中匹配这些模式。它非常强大,广泛用于各类编程语言中,比如用于检查用户输入是否为有效的电子邮件地址或链接等。
正则表达式可以用来在文本中查找特定模式,或者验证输入内容。常见的应用场景包括:
在编写正则表达式时,我们可以使用各种特殊字符和字符类。以下是一些常用的正则表达式符号及其含义:
\t
:匹配制表符(tab)。\n
:匹配换行符。.
:匹配除换行符外的任意单个字符。\d
:匹配任意数字 (0-9)。\D
:匹配任意非数字字符。\s
:匹配任意空白字符(空格、制表符、换行符等)。\S
:匹配任意非空白字符。\w
:匹配任意字母数字字符(包括下划线)。\W
:匹配任意非字母数字字符。我们可以在 Visual Studio 的查找工具中使用正则表达式来高亮文本中的特定模式。步骤如下:
Ctrl + F
打开查找工具。例如,查找所有数字可以输入 \d
,查找所有空白字符可以输入 \s
。这些字符类可以帮助你快速定位特定类型的内容。
\d // 匹配任意数字
\d{3} // 匹配三个连续的数字
\d{3,5} // 匹配三到五个连续的数字
[a-z]
:匹配小写字母 a
到 z
。[A-Z]
:匹配大写字母 A
到 Z
。[0-9]
:匹配数字 0
到 9
。[a-zA-Z0-9_]
:匹配任意字母、数字或下划线字符。^
:匹配行首位置。$
:匹配行尾位置。\b
:匹配单词边界。\B
:匹配非单词边界。(A|B)
:匹配 A
或 B
。[abc]
:匹配 a
、b
或 c
中的任意一个字符。[^abc]
:匹配除 a
、b
和 c
之外的任意字符。例如,我们可以使用正则表达式匹配一个德国电话号码格式 +49-123-4567890
:
^\+49-\d{3}-\d{7}$
^
表示字符串开始。\+49
匹配国际区号。\d{3}
匹配三个数字。\d{7}
匹配七个数字。$
表示字符串结束。使用随机数生成器,创建一个简单的占卜程序,该程序会随机回答问题,用“是”、“可能”、“否”三个答案来回答。
欢迎回来!在本视频中,我们将讨论 DateTime
类。DateTime
类可以帮助我们获取当前日期和时间,计算两个日期之间的差异等。使用 DateTime
类可以轻松进行日期和时间的操作。
可以通过多种方式来创建 DateTime
对象,例如:
DateTime myBirthday = new DateTime(1988, 5, 31); // 年、月、日
Console.WriteLine("我的生日是: " + myBirthday);
输出示例:
我的生日是: 1988-05-31 00:00:00
DateTime.Today
DateTime.Now
示例:
Console.WriteLine("今天是: " + DateTime.Today);
Console.WriteLine("现在时间是: " + DateTime.Now);
DateTime
没有直接的 Tomorrow
属性,但可以通过加一天来获取明天的日期:
DateTime tomorrow = DateTime.Today.AddDays(1);
Console.WriteLine("明天是: " + tomorrow);
可以通过 DateTime.DayOfWeek
属性获取某天的星期几:
Console.WriteLine("今天是: " + DateTime.Today.DayOfWeek);
我们可以创建一个方法来获取一年中的特定日期,例如 1 月 1 日:
static DateTime GetFirstDayOfYear(int year) {
return new DateTime(year, 1, 1);
}
调用方法并打印结果:
DateTime firstDay = GetFirstDayOfYear(1999);
Console.WriteLine("1999 年的第一天是: " + firstDay);
通过 DateTime.DaysInMonth
可以计算某年某月的天数。例如,计算 2000 年 2 月的天数:
int days = DateTime.DaysInMonth(2000, 2);
Console.WriteLine("2000 年 2 月有: " + days + " 天");
可以使用 DateTime.Now.Hour
、Minute
和 Second
来获取当前时间的各个部分。例如:
DateTime now = DateTime.Now;
Console.WriteLine($"当前时间是: {now.Hour} 点 {now.Minute} 分 {now.Second} 秒");
接下来,我们将编写一个小程序,让用户输入一个日期,计算该日期与当前日期之间的天数差异:
Console.WriteLine("请输入一个日期 (格式: yyyy-MM-dd):");
string input = Console.ReadLine();
DateTime userDate;
if (DateTime.TryParse(input, out userDate)) {
TimeSpan daysPassed = DateTime.Now - userDate;
Console.WriteLine($"距离 {userDate.ToShortDateString()} 已经过了 {daysPassed.Days} 天");
} else {
Console.WriteLine("输入的日期格式不正确。");
}
创建一个程序,要求用户输入生日,然后计算他们的年龄(以天数计):
Console.WriteLine("请输入您的生日 (格式: yyyy-MM-dd):");
string birthDateInput = Console.ReadLine();
DateTime birthDate;
if (DateTime.TryParse(birthDateInput, out birthDate)) {
TimeSpan ageInDays = DateTime.Now - birthDate;
Console.WriteLine($"您已经活了 {ageInDays.Days} 天");
} else {
Console.WriteLine("输入的日期格式不正确。");
}
通过此程序,用户可以轻松计算自己在地球上度过了多少天。
DateTime
类提供了非常丰富的日期和时间操作功能,包括创建特定日期、获取当前日期和时间、计算日期差异等。掌握这些操作可以帮助你轻松应对项目中涉及时间的需求。希望这些示例对你有所帮助!
欢迎回来!在本视频中,我们将讨论可空类型 (Nullable Types),即一种变量可以有一个值或者没有值(即为 null
)。在其他编程语言中,比如 Swift 中称之为选项 (Optional),这种概念被广泛使用。可空类型适用于可能未被赋值的变量,并确保未赋值时程序不会崩溃。
要定义可空类型的变量,只需在数据类型后添加一个问号 (?
)。例如,要创建一个可为空的 int
:
int? num1 = null;
int? num2 = 1337;
在以上代码中,num1
没有赋值(为 null
),而 num2
有一个具体值。
可空类型适用于多种数据类型,如 double
和 bool
:
double? num3 = null;
double? num4 = 3.14157; // 圆周率
bool? isMale = null;
在控制台输出可空类型变量时,未赋值的变量将显示为空值(即 "nothing"),而有值的变量将显示其具体值。
如果尝试输出一个未赋值的非可空类型变量,程序会编译错误:
int num5; // 没有初始值
Console.WriteLine(num5); // 编译错误
在上述代码中,num5
没有初始值,导致编译错误。使用可空类型(如 int?
)则不会出现这种问题,因为 null
值是允许的。
可空类型在处理可能不完整的数据集时非常有用。例如,在从数据库获取用户数据时,某些字段可能为空值。通过使用可空类型,可以避免因为空值导致的程序崩溃。
假设我们要判断用户的性别字段 (isMale
) 是否有值:
if (isMale == true) {
Console.WriteLine("用户为男性");
} else if (isMale == false) {
Console.WriteLine("用户为女性");
} else {
Console.WriteLine("未指定性别");
}
如果 isMale
为 null
,则输出 "未指定性别"。
可以通过显示转换 (casting) 从可空类型转换为非可空类型:
double? num6 = 13.1;
double num8;
if (num6 != null) {
num8 = (double)num6;
Console.WriteLine("num8 的值是: " + num8);
}
可以使用空合并运算符 (??
) 更简洁地进行转换:
double? num7 = null;
double num8 = num7 ?? 8.53; // 如果 num7 为空,则 num8 为 8.53,否则为 num7 的值
Console.WriteLine("num8 的值是: " + num8);
在这个例子中,如果 num7
没有值,num8
将被赋值为 8.53
。
空合并运算符 (??
) 是一种简洁的语法,用于将可空类型转换为非可空类型。在 num8 = num7 ?? 8.53;
中,num8
将取 num7
的值(如果 num7
有值),否则将取 8.53
。
?
来声明。??
) 是将可空类型转换为非可空类型的便捷方法。希望这些内容有助于你理解并使用可空类型,在处理数据不完整或缺失的情况下非常有用。
欢迎回来!在本视频中,我们将讨论垃圾收集器 (Garbage Collector, GC) 及其如何在 C# 中自动管理内存。垃圾收集是一种用于内存管理的工具,适用于多种编程语言。在一些语言中,垃圾收集需要手动触发 (如 C、C++),而在 .NET 框架中,它是自动完成的。
C# 的垃圾收集器会自动回收不再使用的对象所占用的内存空间,因此一般来说,你不需要手动调用垃圾收集器。只有在少数情况或非常复杂的程序中,才可能需要手动触发 GC。
当我们在程序中创建对象时,C# 会在内存中分配一块空间来存储该对象。例如:
Human dennis = new Human();
在这里,我们创建了一个 Human
类型的对象 dennis
,并分配了一块内存来存储它。一旦对象被创建,我们的程序便可以通过引用 (dennis
) 来访问该对象。
当一个对象不再被使用时(例如程序中不再有指向它的引用),它占用的内存会被标记为垃圾。这时,垃圾收集器会识别并回收这块内存,使其可以被其他对象重新使用。例如:
dennis
时,系统分配内存并创建引用。dennis
设为 null
,即 dennis = null;
,那么对象在程序中不再有引用。void MyFunction() {
Human dennis = new Human();
dennis.Teach();
dennis.SetAge(30);
dennis.GetOlder();
// `dennis` 在这里被创建并使用了一段时间
} // `dennis` 在 MyFunction 结束时超出范围,垃圾收集器可以回收它
在上述代码中,dennis
对象在 MyFunction
内部创建并使用。当 MyFunction
执行完毕后,dennis
变量超出作用域,系统将不再有对 dennis
的引用,垃圾收集器便可以回收该对象的内存。
尽管垃圾收集器会自动管理内存,但你可以在某些情况下手动调用它:
GC.Collect();
然而,手动调用 GC.Collect()
并不一定会立刻触发垃圾回收。这只是对垃圾收集器的一个“建议”,系统会根据当前内存情况决定是否执行垃圾回收。
垃圾收集器一般在以下情况下运行:
GC.Collect()
方法(虽然这只是一个建议)。Finalize
方法可以通过 Finalize
方法在对象被垃圾收集器回收前执行一些代码:
class Human {
~Human() {
// 清理代码,如释放非托管资源
}
}
Finalize
方法允许你在对象被回收前释放一些资源,例如关闭文件或数据库连接。
垃圾收集器是 .NET 框架自动内存管理的重要组成部分。在 C# 中,垃圾收集器负责回收不再使用的对象所占用的内存,减少内存泄漏的风险。
GC.Collect()
手动触发垃圾收集。~Finalize()
方法在对象回收前释放资源。通常情况下,你不需要手动干预垃圾收集器的工作,但理解它的原理有助于更高效地管理内存,尤其是当程序涉及大量对象或大内存分配时。
欢迎回来!在本视频中,我们将讨论 Main
方法中的 args
参数。args
是一个字符串数组,用来在启动应用程序时传递输入。了解 args
的用法对任何控制台应用程序来说都是很重要的,尽管在许多教程中往往被忽略。
args
?args
是一个字符串数组,包含启动应用程序时传递的所有参数。通过它可以在不暂停应用程序的情况下传递所需的输入,而不是通过 Console.ReadLine
来询问用户输入。
"Denis"
。在 Main
方法中,我们可以通过 args[0]
来访问第一个参数:
Console.WriteLine("Hello " + args[0]);
如果你在 args
中输入了 "Denis",程序将输出:
Hello Denis
在 args
中的每个空格都会分隔成一个单独的参数。例如,如果输入 "Denis Frank"
,则会生成两个参数。可以通过索引来访问这些参数:
Console.WriteLine("Hello " + args[0]);
Console.WriteLine("Friend: " + args[1]);
输出为:
Hello Denis
Friend: Frank
当没有传入参数时,访问 args
会抛出异常。我们可以检查 args
的长度,以确保程序在没有输入的情况下能正常运行:
if (args.Length == 0) {
Console.WriteLine("这是一个使用命令行参数的智能应用。请下次提供参数。");
return;
}
cmd
命令提示符。打开文件夹路径
来找到应用程序的路径)。输入以下命令来运行应用程序,并传递参数:
yourprogram.exe Denis
应用程序将输出:
Hello Denis
可以根据 args.Length
检查传入参数的数量,确保程序的逻辑不会因为参数缺失而崩溃。例如:
if (args.Length == 0) {
Console.WriteLine("请提供参数。使用 'help' 获取帮助信息。");
return;
}
if (args[0].ToLower() == "help") {
Console.WriteLine("使用方法:传递参数来执行操作。");
return;
}
Console.WriteLine("Hello " + args[0]);
以下是一个示例代码,它根据输入的参数数量和内容,输出不同的信息:
using System;
class Program {
static void Main(string[] args) {
if (args.Length == 0) {
Console.WriteLine("这是一个使用命令行参数的智能应用。请下次提供参数。");
return;
}
if (args[0].ToLower() == "help") {
Console.WriteLine("使用方法:提供您的名字作为第一个参数。");
return;
}
Console.WriteLine("Hello " + args[0]);
if (args.Length > 1) {
Console.WriteLine("Friend: " + args[1]);
}
}
}
help
,程序显示帮助信息。Hello
和第一个参数。args
数组允许在启动应用程序时传递参数。args.Length
检查参数的数量。通过这些步骤,你可以更好地理解如何使用命令行参数来增强控制台应用程序的功能。在下个视频中,我们将进一步探讨如何处理用户输入并使应用程序执行基本操作。
欢迎回来!在本视频中,我们将讨论如何使用命令行参数 (Command Line Arguments) 创建一个基本的操作程序。我们将学习如何处理用户输入,确保程序能够识别有效的输入,同时提供帮助手册,指导用户如何正确使用程序。
我们的应用程序不直接通过控制台向用户询问输入,而是通过命令行参数传递数据。用户需要在启动应用程序时提供这些参数。我们将实现以下功能:
help
,我们将显示帮助手册。首先,当用户输入 help
参数时,我们将显示帮助信息,例如:
if (args.Length == 1 && args[0].ToLower() == "help") {
Console.WriteLine("使用以下命令之一并跟随两个数字:");
Console.WriteLine("add - 执行两个数的加法");
Console.WriteLine("sub - 执行两个数的减法");
return;
}
如果用户输入了 help
,程序将显示支持的命令选项,并终止运行。
接下来,确保用户提供了正确数量的参数。我们的应用程序需要三个参数:
add
或 sub
)。如果参数数量不正确,显示错误信息并终止程序:
if (args.Length != 3) {
Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
return;
}
检查用户输入的数字是否为有效的浮点数。使用 float.TryParse
来尝试解析用户输入的数字:
float num1, num2;
bool isNum1Parsed = float.TryParse(args[1], out num1);
bool isNum2Parsed = float.TryParse(args[2], out num2);
if (!isNum1Parsed || !isNum2Parsed) {
Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
return;
}
如果解析失败,程序将显示错误信息并终止运行。
使用 switch
语句来检查第一个参数的值(即 args[0]
),并根据用户输入执行相应的操作:
float result;
switch (args[0].ToLower()) {
case "add":
result = num1 + num2;
Console.WriteLine($"两数之和是: {result}");
break;
case "sub":
result = num1 - num2;
Console.WriteLine($"两数之差是: {result}");
break;
default:
Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
break;
}
在 switch
语句中,我们通过检查第一个参数来决定是执行加法还是减法操作。如果参数无效,则显示错误信息。
以下是完整的代码示例:
using System;
class Program {
static void Main(string[] args) {
// 检查帮助指令
if (args.Length == 1 && args[0].ToLower() == "help") {
Console.WriteLine("使用以下命令之一并跟随两个数字:");
Console.WriteLine("add - 执行两个数的加法");
Console.WriteLine("sub - 执行两个数的减法");
return;
}
// 验证参数数量
if (args.Length != 3) {
Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
return;
}
// 解析数字参数
float num1, num2;
bool isNum1Parsed = float.TryParse(args[1], out num1);
bool isNum2Parsed = float.TryParse(args[2], out num2);
if (!isNum1Parsed || !isNum2Parsed) {
Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
return;
}
// 处理加法和减法操作
float result;
switch (args[0].ToLower()) {
case "add":
result = num1 + num2;
Console.WriteLine($"两数之和是: {result}");
break;
case "sub":
result = num1 - num2;
Console.WriteLine($"两数之差是: {result}");
break;
default:
Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
break;
}
}
}
编译程序后,使用以下命令在命令行中测试不同的输入情况:
显示帮助信息:
yourprogram.exe help
执行加法操作:
yourprogram.exe add 15 13
执行减法操作:
yourprogram.exe sub 5 6
输入无效参数:
yourprogram.exe invalid 5 6
输入无效数字格式:
yourprogram.exe add five six
通过这节课程,你学会了如何:
switch
语句执行基本的加法和减法操作。希望通过这些内容,你能够创建出自己的命令行应用程序,处理用户输入并执行基本操作。下次见!
欢迎回来!本章中,我们将深入探讨 事件 (Events) 和 委托 (Delegates),这是理解之后内容的重要基础。这是一个相对复杂的主题,但在学习之后,你将会更好地理解 WPF 中的代码运作,例如按钮点击时的机制。
虽然事件和委托涉及的技术细节比较复杂,但它们的核心概念对于编写灵活、动态的代码至关重要。通过学习这些内容,你不仅可以像驾驶员一样使用代码,还可以像机械师一样了解代码内部的工作原理。
在 WPF 应用程序中,事件和委托用于处理用户交互。比如,当用户点击按钮时触发某个事件。这个事件并不是简单的执行一个操作,而是依赖于委托来定义要执行的具体方法,类似于将任务交给一个“代理”来完成。
为了理解 WPF 中的事件驱动编程,深入掌握事件和委托将帮助你在复杂情况下灵活控制代码行为。掌握这些知识也让你在处理代码中的事件响应、数据传递和操作时更具自由度。
委托 (Delegate):委托是一种引用类型,用于定义方法的签名。它可以被视为一种类型安全的函数指针,用于引用特定签名的函数。例如,一个接受两个整数并返回整数的委托,只能引用这种类型的函数。
事件 (Event):事件是基于委托的,专门用于响应特定动作(如按钮点击)的机制。当一个事件被触发时,它会通知所有注册的委托执行对应的方法。这一过程类似于广播信号,所有订阅了事件的监听器都会收到通知并执行对应的代码。
理解事件和委托将帮助你更好地驾驭代码,构建出灵活、动态的应用程序。在之后的 WPF 中,这些知识将成为创建交互式用户界面的基础。本章的学习内容可能会稍显复杂,但掌握它们将使你的编程能力迈上新台阶。
让我们开始本章的学习!
在简单的术语中,委托 是一种可以存储对方法的引用的类型。当你调用该委托时,它引用的方法将被调用。让我们通过一个示例来说明这个概念。
假设我们正在开发一个 UI 库,供其他开发人员用于构建移动应用。我们提供了一个按钮类 (Button Class),它具有以下几个属性:
string
类型的 Text
属性(比如值为 "Send")Color
类型的 BackColor
属性(比如绿色)SizeW
和 SizeH
两个整数,用于定义按钮的宽度和高度现在,按钮还缺少一个关键功能:定义点击时执行的代码。
作为这个 UI 系统的开发者,我们不知道使用我们系统的其他开发人员希望按钮点击后执行的具体逻辑。因此,我们无法在按钮类中直接提供一个点击方法来满足所有需求。这时,委托成为了解决这一问题的理想选择。
通过提供一个委托来存储点击事件的方法引用,我们可以让其他开发人员定义自己的方法并将其分配给按钮的点击事件。这样,每个按钮可以在被点击时执行不同的逻辑。
我们可以将点击事件定义为委托。下面是如何定义这个点击事件的委托类型:
private
。delegate
关键字:使用 delegate
关键字告诉编译器我们正在定义一个新的类型。void
且无参数。代码如下:
private delegate void OnClickDelegate();
这里我们定义了一个名为 OnClickDelegate
的委托类型,用于存储返回类型为 void
、无参数的方法。
接下来,我们将创建一个该委托类型的变量来存储具体方法的引用:
public OnClickDelegate onClick;
在这里,我们定义了一个公共变量 onClick
,它的类型是 OnClickDelegate
。它可以存储任何符合 OnClickDelegate
签名(即返回 void
且无参数)的方法的引用。
如果尝试将不符合该委托签名的方法赋值给
onClick
,例如带参数或返回值类型不为void
的方法,编译器将抛出错误。
假设我们在设计器中添加了一个按钮(例如名为 sendButton
),并希望点击按钮后触发以下逻辑:
我们可以编写如下代码:
void SendButtonClick() {
// 执行发送消息的逻辑
ConnectToNetwork();
SendMessage();
ShowMessageSentDialog();
}
现在我们有了一个方法 SendButtonClick
,但是还没有将它连接到按钮的 onClick
事件。为了让按钮在点击时调用此方法,我们可以将 SendButtonClick
方法的引用赋值给 onClick
变量:
sendButton.onClick = SendButtonClick;
注意,这里我们没有写 SendButtonClick()
,而是直接写 SendButtonClick
,因为我们只是将方法的引用赋给 onClick
,并没有真正调用方法。
在底层,如果鼠标悬停在按钮上并点击了它,我们的 UI 系统会调用该按钮的 onClick
委托,而 onClick
委托会进一步调用被分配的方法。在这个例子中,当按钮被点击时,SendButtonClick
方法会被调用,并显示消息发送成功的对话框。
通过这种方式,使用我们 UI 系统的开发者可以创建他们自己的方法,并将这些方法分配给我们的委托,而我们可以在满足条件时调用它们。
除了点击事件,委托还可以用于处理其他事件。例如,我们可以创建应用启动或关闭时的委托,并触发相应的方法。
委托的概念和应用不仅限于事件处理,它是 C# 中一种强大而灵活的功能。通过它,程序可以在运行时决定调用的具体方法,从而实现高度的动态性。
在接下来的视频中,我们将学习如何使用 C# 中的内置委托类型,并逐步创建自己的委托类型,类似于本例中实现的点击事件委托。希望你享受本章的学习内容,我们下个视频见!
欢迎回来!现在我们已经了解了委托的基本概念,让我们实际操作一下。在 C# 中,有许多内置的委托可以用来简化代码。我们将以 Predicate
委托为例,通过一个实际应用来理解委托的功能和用途。
假设我们有一个字符串列表 names
,其中包含一些名字,比如:Aidan、Sif、Walter 和 Anatoly。我们希望移除列表中包含字母 “I” 的所有名字。为了实现这一目标,我们将使用 List<T>
类中的 RemoveAll
方法。这个方法允许我们传递一个 Predicate
委托,以定义移除列表项的规则。
Predicate
委托?Predicate
是一个委托类型,用于表示某个条件。它接收一个参数并返回一个 bool
值:
Predicate
的数据类型(与列表元素的类型相同,在这里是 string
)。bool
,用于判断列表中的元素是否满足某个条件。对于 RemoveAll
方法来说,它会对列表中的每个元素调用 Predicate
,如果 Predicate
返回 true
,则该元素将被移除。
names
。Predicate<string>
匹配的方法 Filter
,用于检查字符串是否包含字母 “I”。RemoveAll
方法:将 Filter
方法传递给 RemoveAll
方法,应用过滤条件来移除符合条件的名字。using System;
using System.Collections.Generic;
class Program {
static void Main() {
// 定义字符串列表
List<string> names = new List<string> { "Aidan", "Sif", "Walter", "Anatoly" };
// 显示移除前的名单
Console.WriteLine("移除前的名字列表:");
foreach (string name in names) {
Console.WriteLine(name);
}
// 使用 RemoveAll 方法并传递过滤方法
names.RemoveAll(Filter);
// 显示移除后的名单
Console.WriteLine("\n移除后的名字列表:");
foreach (string name in names) {
Console.WriteLine(name);
}
}
// 定义符合 Predicate<string> 委托的方法
static bool Filter(string name) {
// 如果名字包含 "I" 则返回 true
return name.Contains("I");
}
}
Filter
方法:此方法用于检查字符串中是否包含字母 "I"。它返回一个布尔值:
true
(表示该元素符合移除条件)。false
。RemoveAll
方法:RemoveAll
方法接收 Predicate
委托作为参数,在这里传递了 Filter
方法。因为 Filter
的签名与 Predicate<string>
匹配,所以可以直接传递给 RemoveAll
方法。
无括号传递方法:注意在传递 Filter
方法时没有使用括号 ()
。这是因为我们传递的是 Filter
方法的引用,而不是调用 Filter
方法的结果。
执行代码后,输出结果为:
移除前的名字列表:
Aidan
Sif
Walter
Anatoly
移除后的名字列表:
Walter
RemoveAll
方法调用 Filter
方法,对 names
列表的每个元素执行检查。Filter
方法返回 true
时,对应的元素将被移除。所以在此例中,包含 "I" 的 "Aidan"、"Sif" 和 "Anatoly" 被移除,Walter
保留下来。通过这个示例,我们看到了委托的实际应用,以及如何利用 C# 内置的 Predicate
委托来实现灵活的条件筛选。RemoveAll
方法的灵活性在于它接收一个委托,因此可以传入任意符合委托签名的过滤条件方法。
后续学习:如果你还对委托的概念感到疑惑,不用担心!在接下来的内容中,我们将逐步创建自定义的委托类型,从而更加深入地理解委托的核心原理。
在这节课中,我们将创建自己的委托来筛选一组人的列表。通过这个例子,你将更深入地了解委托在实际代码中的应用。
假设我们有一个简单的 Person
类,它包含 Name
和 Age
两个属性。现在我们希望筛选这组人,过滤出成年人(18岁以上)、未成年人(18岁以下)或老年人(65岁以上)。
Person
类和筛选条件委托首先,我们创建一个 Person
类:
public class Person {
public string Name { get; set; }
public int Age { get; set; }
}
接下来,我们定义一个委托类型,用于筛选符合条件的 Person
对象。这个委托将接受一个 Person
对象,并返回一个布尔值来表示是否满足条件:
// 定义筛选委托类型
public delegate bool FilterDelegate(Person p);
接下来,我们创建一些 Person
对象并将它们添加到列表中:
List<Person> people = new List<Person> {
new Person { Name = "Aidan", Age = 25 },
new Person { Name = "Sif", Age = 69 },
new Person { Name = "Walter", Age = 12 },
new Person { Name = "Anatoly", Age = 40 }
};
DisplayPeople
方法我们创建一个 DisplayPeople
方法,这个方法将接受以下参数:
string title
:用于输出的标题。List<Person> people
:包含人的列表。FilterDelegate filter
:定义筛选条件的委托。方法会根据筛选条件输出符合条件的人的名字和年龄。
// 用于显示符合条件的人的方法
public static void DisplayPeople(string title, List<Person> people, FilterDelegate filter) {
Console.WriteLine(title);
foreach (Person person in people) {
if (filter(person)) { // 通过筛选条件
Console.WriteLine($"{person.Name}, {person.Age}");
}
}
}
接下来,我们定义几个筛选方法,它们将与 FilterDelegate
匹配,用于筛选不同年龄段的人:
// 筛选未成年人的方法
public static bool IsMinor(Person p) {
return p.Age < 18;
}
// 筛选成年人的方法
public static bool IsAdult(Person p) {
return p.Age >= 18 && p.Age < 65;
}
// 筛选老年人的方法
public static bool IsSenior(Person p) {
return p.Age >= 65;
}
每个筛选方法都符合 FilterDelegate
的签名:接受一个 Person
对象并返回一个布尔值。
DisplayPeople
我们可以使用 DisplayPeople
方法并传入不同的筛选条件来输出不同年龄段的人的信息:
static void Main() {
// 输出未成年人
DisplayPeople("未成年人", people, IsMinor);
// 输出成年人
DisplayPeople("成年人", people, IsAdult);
// 输出老年人
DisplayPeople("老年人", people, IsSenior);
}
运行代码后,你会看到类似如下的输出:
未成年人
Walter, 12
成年人
Aidan, 25
Anatoly, 40
老年人
Sif, 69
委托作为参数:在 DisplayPeople
方法中,我们使用了 FilterDelegate
委托作为参数。这样就能灵活地传入不同的筛选条件,而无需修改 DisplayPeople
方法的逻辑。
方法引用:当我们调用 DisplayPeople
方法时,直接传入 IsMinor
、IsAdult
或 IsSenior
等方法,而不需要括号 ()
,因为我们传递的是方法的引用,而不是调用方法的结果。
筛选条件:在 DisplayPeople
方法中,对每个 Person
对象调用 filter
方法(即 FilterDelegate
),并根据返回的布尔值决定是否输出该对象的信息。
通过这个示例,我们不仅了解了如何创建自己的委托,还学习了如何将委托用于筛选和操作数据的情境中。这种模式让代码更加灵活,使我们可以随时更改或添加筛选条件,而不必修改主代码逻辑。
在下一节中,我们将进一步优化代码,学习如何使用匿名方法来消除重复的筛选方法,从而使代码更加简洁。
在本节课中,你将学习如何使用匿名方法(Anonymous Methods)。匿名方法是一种没有名称的方法,它可以直接定义在代码中,而无需专门去定义一个命名的方法。它的主要用途在于简化代码结构,尤其当你只需要调用一次方法而不想专门创建一个新方法时非常实用。
C#中的匿名方法可以通过 delegate
关键字来定义,并赋值给一个符合委托类型的变量。我们将扩展之前的示例(筛选年龄),通过匿名方法实现自定义筛选功能。
在某些场景下,例如快速定义过滤条件或简单逻辑时,定义一个完整的命名方法显得多余。匿名方法可以帮助简化代码,让代码更加简洁。尤其是在需要将方法作为委托传递给其他方法时,匿名方法可以避免定义一堆命名方法,提升代码的可读性和组织性。
假设我们有一个 FilterDelegate
委托,它接受一个 Person
对象作为参数并返回一个布尔值。我们将在 Main
方法中使用匿名方法为 FilterDelegate
定义自定义筛选规则。
首先,定义一个 FilterDelegate
委托:
// 定义筛选委托类型
public delegate bool FilterDelegate(Person p);
接下来,在 Main
方法中,我们通过匿名方法创建一个新的筛选器。这个筛选器将过滤出年龄在20到30岁之间的对象:
// 定义一个筛选委托并使用匿名方法
FilterDelegate filter = delegate(Person p) {
return p.Age >= 20 && p.Age <= 30;
};
上面的代码解释如下:
FilterDelegate
委托类型定义一个名为 filter
的变量。delegate
关键字定义一个匿名方法,并将它赋值给 filter
。Person
类型参数 p
,然后判断 p.Age
是否在20到30之间。如果满足条件,返回 true
;否则返回 false
。DisplayPeople
方法并传递匿名方法我们可以使用 DisplayPeople
方法,并将这个匿名方法作为筛选条件传递:
DisplayPeople("年龄在20到30岁之间", people, filter);
在 DisplayPeople
方法中,将会筛选并显示年龄在20到30岁之间的人员。
运行代码,将会输出符合条件的人员信息。
匿名方法还可以直接作为参数传递,而不必先赋值给一个变量。例如,若我们想显示列表中的所有人,可以直接在调用 DisplayPeople
时传递一个返回 true
的匿名方法:
DisplayPeople("所有人", people, delegate(Person p) {
return true;
});
在这里,我们直接定义了一个匿名方法,该方法简单地返回 true
,表示不进行任何筛选,显示列表中的所有人。
这段代码展示了如何简洁地使用匿名方法,而不必为每个筛选条件定义一个单独的方法。
通过匿名方法的使用,我们可以看到委托的灵活性:
在下一节课中,我们将继续深入学习Lambda 表达式,这是匿名方法的简洁语法,能够进一步提高代码的可读性和简洁性。
在上节课中,我们了解了匿名方法如何帮助我们直接在代码中传递委托。在 C# 3.0 中,引入了 Lambda 表达式,这提供了一种更加简洁和功能性的语法来编写匿名方法。Lambda 表达式的灵感来自于 Lambda 演算,在这种计算模型中,一切都可以通过函数来表达。使用 Lambda 表达式,我们可以更方便地创建匿名函数和方法。
Lambda 表达式有两种主要形式:
表达式 Lambda:只包含一个表达式的 Lambda 表达式,语法简洁,一行代码即是整个表达式。
输入参数 => 表达式
语句 Lambda:包含多个语句的 Lambda 表达式,通常在需要写多行逻辑时使用。
输入参数 => { 语句块 }
让我们使用 Lambda 表达式来改进之前的代码示例。假设我们已经定义了一个 Person
类,并且有一个 DisplayPeople
方法可以接受一个过滤条件(即 Lambda 表达式)来筛选符合条件的人员。现在我们来创建一些新的过滤条件,尝试使用 Lambda 表达式来替换之前的匿名方法。
首先,我们来创建一个带有 searchKeyword
的筛选条件。假设我们希望筛选出年龄大于 20 且名字包含特定字母的人员。
string searchKeyword = "A";
DisplayPeople("年龄大于20并包含字母A的人员", people, p =>
{
if (p.Name.Contains(searchKeyword) && p.Age > 20)
return true;
else
return false;
});
代码解析:
p => { ... }
表示我们正在使用 Lambda 表达式,其中 p
是输入参数,后面跟着一个语句块。p.Name.Contains(searchKeyword) && p.Age > 20
检查 p
对象的名字是否包含特定字母并且年龄大于 20。true
,否则返回 false
。这种用法中,因为逻辑较为复杂,使用了语句 Lambda。Lambda 表达式的参数和条件可以灵活地更改,不需要再额外创建一个方法。
现在,让我们尝试一个更加简单的筛选条件,只显示年龄为 25 的人员。对于简单的逻辑判断,可以使用更简洁的表达式 Lambda。
DisplayPeople("年龄等于25的人员", people, p => p.Age == 25);
代码解析:
p => p.Age == 25
表达式 Lambda 表达式,其中 p
是输入参数,p.Age == 25
是直接返回的布尔表达式。运行以上代码,我们可以看到控制台输出:
A
的人员(例如 Anatoliy 和 Aiden)Lambda 表达式的优势在于其简洁性和灵活性,它让代码更易读,更易维护。与匿名方法相比,Lambda 表达式使用 =>
符号进一步简化了代码结构,并使表达式的意图更加清晰。Lambda 表达式在只需一行代码的情况下尤其适用,比如简单的过滤条件。
在接下来的课程中,我们将进一步学习事件和多播委托,这将帮助我们更深入地理解委托的实际应用。
在本节课程中,我们将学习多播委托和事件。我们将用一个简单的游戏示例来探索这些概念,以便理解如何在代码中使用这些特性。
假设我们正在从头开发一个视频游戏。我们的代码由图形、音频库和玩家的逻辑组成。我们有以下简单的类:
Rendering
):负责启动和停止渲染引擎。AudioSystem
):负责启动和停止音频系统。Player
):有一个玩家名称属性,可以生成和移除玩家。在游戏主逻辑中,我们需要在游戏开始时调用各个类的 StartGame
方法,在游戏结束时调用各个类的 GameOver
方法。
在初始代码中,我们需要手动依次调用每个系统的 StartGame
和 GameOver
方法。随着系统增多,这样的调用会越来越繁琐且容易出错。为了解决这个问题,我们可以使用多播委托。
多播委托是一个可以存储多个方法引用的委托。通过它,我们可以用一个委托调用多个方法。
首先,我们创建一个 GameEventManager
静态类,其中定义一个 GameEvent
委托和两个事件变量 OnGameStart
和 OnGameOver
:
public static class GameEventManager {
public delegate void GameEvent();
public static event GameEvent OnGameStart;
public static event GameEvent OnGameOver;
public static void TriggerGameStart() {
if (OnGameStart != null) {
Console.WriteLine("游戏已开始");
OnGameStart.Invoke();
}
}
public static void TriggerGameOver() {
if (OnGameOver != null) {
Console.WriteLine("游戏已结束");
OnGameOver.Invoke();
}
}
}
在 GameEventManager
中,我们定义了 TriggerGameStart
和 TriggerGameOver
静态方法,用于触发 OnGameStart
和 OnGameOver
事件。
现在我们可以通过构造函数订阅这些事件:
public class AudioSystem {
public AudioSystem() {
GameEventManager.OnGameStart += StartGame;
GameEventManager.OnGameOver += GameOver;
}
private void StartGame() {
Console.WriteLine("音频系统启动,开始播放音频...");
}
private void GameOver() {
Console.WriteLine("音频系统停止...");
}
}
public class Rendering {
public Rendering() {
GameEventManager.OnGameStart += StartGame;
GameEventManager.OnGameOver += GameOver;
}
private void StartGame() {
Console.WriteLine("渲染引擎启动,显示视觉效果...");
}
private void GameOver() {
Console.WriteLine("渲染引擎停止...");
}
}
在 AudioSystem
和 Rendering
的构造函数中,我们使用 +=
操作符将 StartGame
和 GameOver
方法添加到 GameEventManager
的事件中。这样,我们无需手动调用这些方法,只需触发事件。
现在,代码结构已经优化了许多,我们可以在主方法中仅通过事件触发器来管理游戏的开始和结束:
static void Main(string[] args) {
AudioSystem audio = new AudioSystem();
Rendering render = new Rendering();
Player player1 = new Player("Cao");
Player player2 = new Player("De Silva");
GameEventManager.TriggerGameStart();
Console.WriteLine("游戏运行中...按任意键结束游戏");
Console.ReadKey();
GameEventManager.TriggerGameOver();
}
通过这种方式,我们将游戏的开始和结束管理集中到 GameEventManager
中,使得代码更加清晰、简洁,且减少了出错的可能性。
+=
操作符轻松添加或移除方法,避免了重复调用方法或错误调用方法的情况。event
关键字),可以确保只有特定的类可以触发事件,而其他类只能订阅或取消订阅,不能直接触发事件。通过本节课程,我们学到了:
event
关键字定义的委托可以更加安全地管理代码结构,确保只能由特定类触发事件。这些概念为我们接下来使用用户界面库中的事件处理机制提供了重要的基础,帮助我们更好地理解如何使用事件和委托来构建复杂的应用程序逻辑。
好了,我们终于完成了事件和委托的学习。这是整个课程中较为复杂的一个主题,希望你能够顺利地完成学习,并且理解了课程中的所有内容。如果你有些困惑也没关系,因为我们会在后续章节中逐步应用这些知识点,到时候应该会更加清晰。
如果你在学习过程中遇到不解之处,我建议你深入研究文档。文档是开发者的好帮手,无论是类的详细信息还是特定方法的用法,都可以在文档中找到详细的解释。在这个阶段,你已经具备了开发者的基本技能,应该可以自由地开始编写自己的代码项目。
在实际应用中,你会遇到各种陌生的代码和方法。这种时候,就好比面对未知的“野生环境”——只有了解这些“生物”并理解如何“猎捕”它们,才能将代码管理得当,而阅读文档就是你成为熟练“猎手”的关键技能。
在下一章中,我们将进入Windows Presentation Foundation (WPF) 的学习,开始学习如何创建用户界面。WPF 允许我们创建美观、互动的用户界面,相较于之前的命令行应用程序,这将大大增强用户体验。现在,终于不再是单调的控制台,而是能够构建一个真正的 GUI 界面啦!
所以,准备好进入下一章吧!在那里,我们将利用 WPF 实现更多的功能,构建一个更为有趣的应用程序界面。期待在下一章中见到你!
在本章节中,你将学习如何使用Windows Presentation Foundation (WPF) 命名空间或库来编写美观的用户界面 (UI)。在这里,你将通过 XAML 和 C# 代码两种方式来创建用户界面。
你可以通过拖拽控件来快速构建用户界面,这是最便捷的方法,但同时也限制了你对界面细节的控制。因此,如果你想要完全掌控界面样式和行为,理解如何通过代码来操作控件将非常有帮助。在本章节中,你将学习到:
经过前面章节的学习,我们一直在使用灰色(甚至是黑色)的命令行界面,这种界面虽然实用,但对用户的吸引力不强。而现在,我们将学习如何构建更具视觉效果的图形用户界面 (GUI),从而让应用程序不再单调。你将能够控制点击按钮的效果,定义界面上的响应逻辑,并将代码直接影响到用户体验上。
本章节将带领你一步步深入,逐渐掌握 WPF 的基础知识与高级概念。希望你和我一样兴奋,准备好创建自己的用户界面!让我们立即开始吧!
在本视频中,我们将为 Hello World WPF 应用程序设置环境。按照以下步骤操作,以确保你能够顺利创建并运行你的第一个 WPF 应用程序。
WPF01
并创建项目。MainWindow
。通过 XAML 文件,你可以设置窗口的 标题、高度、宽度 等属性。Ctrl + Alt + X
或通过 视图 > 工具箱 找到工具箱。TextBlock
控件并在属性面板或 XAML 代码中设置 Text
属性为 "Hello World"。Margin="147,147,0,0"
,这表示控件距上边和左边的距离。F5
启动应用程序。在 TextBlock
中修改 Text
属性:
<TextBlock Text="Hello, Dennis" ... />
在 Window
标签中修改 Title
属性:
<Window x:Class="WPF01.MainWindow" ... Title="My First GUI">
当你运行代码时,你会看到一个窗口,显示 "My First GUI" 作为标题,内容为 "Hello, [你的名字]"。这个应用程序是一个简单的演示,它为我们后续学习更多 WPF 控件和功能奠定了基础。
在接下来的视频中,我们将深入学习如何使用其他控件,如按钮和输入框,并进一步掌握 WPF 的 XAML 和 C# 代码编写方式。希望你对这个过程感到兴奋,让我们在下一节中探索更多 WPF 的可能性吧!
在本视频中,我们将深入探讨 XAML(发音为“Zemo”)。XAML 是一种基于 XML 的语言,允许我们用代码来编写用户界面(UI),从而不必通过拖放来构建界面元素和调整属性。
你可能会想:“我更喜欢视觉化的操作,直接拖放就可以。”确实,拖放是一种便捷的方法,但用 XAML 可以获得更强的灵活性和控制能力,并可以实现更复杂的功能,例如数据绑定、依赖属性等,这些功能在创建可维护的 UI 时非常重要。
XAML 和 HTML 类似,使用开闭标签的结构。如果你熟悉 HTML,你会对它比较熟悉:
<Button>
</Button>
<Button />
<Button Content="Click Me" Height="50" Width="100"/>
在上面的 XAML 代码中:
在 XAML 文件顶部,你会看到诸如 xmlns
和 xmlns:x
之类的定义:
使用以下方式在 XAML 中添加注释,便于说明代码:
<!-- 这是一个 XAML 注释,便于说明代码功能 -->
可以在 XAML 中创建多个按钮:
<Button Content="Click Me" Height="50" Width="100" />
<Button Content="Hi There" Height="50" Width="100" />
不过要注意,如果按钮在同一个网格(Grid)中,它们会彼此覆盖。我们将来会深入讲解网格布局如何使用来定位元素。
运行应用程序,你会看到按钮可以被点击、显示动画效果等。这些简单的功能都是 WPF 提供的默认行为。
你可以设置按钮的 字体大小 等更多属性:
<Button Content="Click Me" FontSize="32" Width="150"/>
除了通过 Content
属性,还可以直接在 <Button></Button>
标签之间设置内容。例如:
<Button>
Click Me
</Button>
可以通过 WrapPanel(包裹面板)添加多种内容在按钮中:
<Button Width="200" Height="100">
<WrapPanel>
<TextBlock Text="Multi" Foreground="Blue"/>
<TextBlock Text="Color" Foreground="Red"/>
<TextBlock Text="Button" Foreground="White"/>
</WrapPanel>
</Button>
运行代码时,你将看到一个多色文本按钮。
我们可以通过 C# 代码生成相同的 UI 结构,这就是所谓的 Code Behind(代码隐藏)。每个 XAML 文件都有一个对应的 .xaml.cs
文件,即它的代码隐藏文件。在这里,我们可以用 C# 代码来创建和设置 UI 元素。
MainWindow.xaml.cs
文件。MainWindow
,其中包含 InitializeComponent()
,用于初始化 XAML 元素。在这个构造函数中,我们可以用代码创建和设置 UI 元素,例如按钮和布局网格:
// 创建网格
Grid grid = new Grid();
this.Content = grid; // 将网格设为窗口内容
// 创建按钮
Button button = new Button();
button.FontSize = 26;
// 创建 WrapPanel
WrapPanel wrapPanel = new WrapPanel();
button.Content = wrapPanel;
// 创建 TextBlock 并添加到 WrapPanel
TextBlock txt1 = new TextBlock { Text = "Multi", Foreground = Brushes.Blue };
TextBlock txt2 = new TextBlock { Text = "Color", Foreground = Brushes.Red };
TextBlock txt3 = new TextBlock { Text = "Button", Foreground = Brushes.White };
wrapPanel.Children.Add(txt1);
wrapPanel.Children.Add(txt2);
wrapPanel.Children.Add(txt3);
// 将按钮添加到网格中
grid.Children.Add(button);
上面的代码通过创建 网格、按钮 和 WrapPanel,并将 TextBlock
作为按钮内容添加到 WrapPanel
,实现了和之前 XAML 代码相同的效果。
WrapPanel
和 Grid
等布局容器可以组织和排布 UI 元素。本视频我们探讨了 XAML 和代码隐藏文件中的设置 UI 方法。接下来的视频我们将深入学习更多 WPF 的控件和布局技术,敬请期待!
在本视频中,我们将介绍 StackPanel、ListBox、逻辑树和视觉树的概念。这些是 WPF 中管理布局和界面层次结构的关键概念。
首先,我们将删除之前的 Grid,因为还没有深入使用 Grid。取而代之,我们将使用 StackPanel,它可以将元素垂直或水平堆叠在一起。以下是如何实现的:
<StackPanel>
<TextBlock Text="Hello, World" HorizontalAlignment="Center" Margin="20"/>
</StackPanel>
在上述代码中,我们使用 HorizontalAlignment="Center"
将文本块居中显示,同时设置了 Margin="20"
,为四个方向都添加了 20 像素的边距。
接下来,我们添加一个 ListBox,展示多个项目。ListBox 用于显示一个项目列表,用户可以从中选择。以下是如何定义一个简单的 ListBox:
<ListBox Height="100" Width="100">
<ListBoxItem Content="Item 1"/>
<ListBoxItem Content="Item 2"/>
<ListBoxItem Content="Item 3"/>
</ListBox>
在这里,ListBox 默认占据 StackPanel 中的宽度,且高度刚好能够显示它的项目内容。我们还设置了 Height="100"
和 Width="100"
来限制它的大小。
运行代码后,我们将看到一个包含 Hello, World
文本块和一个带有多个项目的 ListBox 的主窗口。ListBox 中的项目可以选择,但没有任何交互事件绑定。
我们可以添加一个按钮并在点击时触发一个事件。例如,添加一个 Click Me
按钮,当按钮被点击时弹出消息框:
<Button Content="Click Me" Click="Button_Click" Margin="20"/>
在代码后置文件 MainWindow.xaml.cs
中添加事件处理程序:
private void Button_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Thanks for clicking me!");
}
当我们运行应用程序并点击按钮时,会弹出一个消息框,显示文本 “Thanks for clicking me!”。
逻辑树代表了 XAML 布局中的元素结构。例如,以下是我们界面布局的逻辑树结构:
Hello, World
Item 1
Item 2
Item 3
Click Me
逻辑树提供了 WPF UI 结构的简单视图,帮助我们了解布局层次。
视觉树则更为详细,展示了所有在应用程序中渲染的可视元素,包括 XAML 中没有显式定义的元素。我们可以通过设置断点进入调试模式,并打开 WPF Tree Visualizer 来查看视觉树。
视觉树不仅包含了逻辑树中的元素,还包括在 UI 中呈现它们所需要的附加元素。例如,ListBox 在视觉树中可能会包含 Border
、ScrollViewer
等附加元素,用于增强显示效果。以下是视觉树的部分结构示例:
理解这些概念可以帮助我们更好地组织和调试 WPF 应用程序。在下一个视频中,我们将深入研究事件的处理。
在本视频中,我们将深入探讨 XAML 中的事件,特别是 WPF 的路由事件(Routed Events)。WPF 提供了三种路由事件:直接事件、冒泡事件和隧道事件。我们将详细介绍每种事件的工作方式,并通过实际示例来演示它们的使用方法。
路由事件是 WPF 中的事件类型,允许事件沿着视觉树或逻辑树传播,以便其他元素处理它们。以下是三种主要的路由事件类型:
直接事件只在事件源元素上触发,不会在树结构中传播。以下是创建一个按钮的示例,点击该按钮将触发直接事件。
<Button Content="Click Me" Width="150" Height="100" Click="Button_Click"/>
在代码后置文件 MainWindow.xaml.cs
中添加事件处理器:
private void Button_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Button was clicked - Direct Event");
}
运行代码后,当点击按钮时会显示消息框“Button was clicked - Direct Event”。直接事件只会在按钮本身触发,不会向其他元素传播。
冒泡事件会从事件源开始,向上沿着视觉树传播。如果事件未被处理,它将继续向上传播,直到根元素。例如,我们可以使用 MouseUp
事件,这是一个典型的冒泡事件:
<Button Content="Click Me" Width="150" Height="100" MouseUp="Button_MouseUp"/>
在代码后置中添加处理器:
private void Button_MouseUp(object sender, RoutedEventArgs e)
{
MessageBox.Show("Mouse Button went up - Bubbling Event");
}
当我们点击并释放鼠标按钮时,该事件触发并显示消息框。如果在树中的其他元素中未处理该事件,事件会继续向上传播。这就是冒泡事件的特性。
隧道事件从根元素开始向下传播到事件源。这种事件的常见用法是预览事件(例如 PreviewMouseUp
)。通过隧道事件,我们可以更早地捕获和处理事件。
以下是使用 PreviewMouseUp
隧道事件的示例:
<Button Content="Click Me" Width="150" Height="100" PreviewMouseUp="Button_PreviewMouseUp"/>
在代码后置中添加处理器:
private void Button_PreviewMouseUp(object sender, RoutedEventArgs e)
{
MessageBox.Show("Mouse Button went up - Tunneling Event");
}
此事件在按钮内触发,但它会沿着视觉树向下传播,在抵达事件源之前可能被上层元素拦截。运行程序并点击按钮时,该事件会在释放鼠标按钮时立即触发。
为了展示隧道和冒泡事件的差异,我们可以添加更多的事件处理器,例如 PreviewMouseLeftButtonDown
和 PreviewMouseRightButtonUp
:
<Button Content="Click Me" Width="150" Height="100"
PreviewMouseLeftButtonDown="Button_PreviewMouseLeftButtonDown"
PreviewMouseRightButtonUp="Button_PreviewMouseRightButtonUp"/>
在代码后置中添加处理器:
private void Button_PreviewMouseLeftButtonDown(object sender, RoutedEventArgs e)
{
MessageBox.Show("Left Mouse Button went down - Tunneling Event");
}
private void Button_PreviewMouseRightButtonUp(object sender, RoutedEventArgs e)
{
MessageBox.Show("Right Mouse Button went up - Tunneling Event");
}
通过这些处理器,我们可以观察到不同类型事件的行为:
PreviewMouseLeftButtonDown
会在鼠标左键按下时立即触发,体现了隧道事件的特性。PreviewMouseRightButtonUp
会在鼠标右键释放时触发,也是一种隧道事件。WPF 中的路由事件提供了灵活的事件处理机制,允许事件在视觉树中传播。理解不同类型的事件有助于我们更有效地控制事件流。以下是这三种事件的关键区别:
掌握这些事件类型,我们可以在 WPF 应用程序中更灵活地处理用户交互。
在本视频中,我们将学习如何使用 Grid 布局。我们之前讨论过 StackPanel 和 WrapPanel,它们分别允许我们将元素垂直堆叠或按行排列。而 Grid 则提供了更强大的布局控制,允许我们将元素定位在一个网格中。我们将从简单的 2x2 网格开始,并逐步深入。
首先,我们需要在 XAML 文件中定义列和行。可以使用 ColumnDefinitions
和 RowDefinitions
来定义网格的列和行。
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
</Grid>
上面的代码创建了一个网格,它包含 2 列,每列宽度为 100 像素,和 1 行,行高根据内容自动调整。
接下来,我们可以在网格中添加按钮,并使用 Grid.Column
属性将它们定位在不同的列中:
<Button Content="Button 1" Grid.Column="0" Grid.Row="0"/>
<Button Content="Button 2" Grid.Column="1" Grid.Row="0"/>
这会将 Button 1 放在第 0 列,第 0 行;Button 2 则放在第 1 列,第 0 行。
运行代码后,我们可以看到这两个按钮分别被放置在两个不同的列中。注意,这些列的宽度为 100 像素。
除了固定宽度之外,我们还可以使用 Auto
和 *
符号来控制列的宽度:
例如,将第一列的宽度设置为 Auto
,第二列的宽度设置为 *
:
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
如果我们希望列之间的宽度按比例分配,可以使用多倍的星号。例如,我们希望第一列的宽度是第二列的两倍,可以设置如下:
<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="2*"/>
这会将总宽度分为 5 等份,第一个列占 3/5,第二个列占 2/5。
我们还可以定义多行,并控制每行的高度。可以使用类似的方式定义 RowDefinitions
:
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
上面的代码会创建 2 行,且高度均等。
我们可以通过指定 Grid.Row
和 Grid.Column
来定位控件到网格中的不同单元格。让我们将每个按钮放置在不同的行和列中:
<Button Content="Button 1" Grid.Column="0" Grid.Row="0"/>
<Button Content="Button 2" Grid.Column="1" Grid.Row="0"/>
<Button Content="Button 3" Grid.Column="0" Grid.Row="1"/>
<Button Content="Button 4" Grid.Column="1" Grid.Row="1"/>
练习:请尝试创建一个 3x3 的网格,其中包含 8 个按钮,最后一个位置放置一个 TextBlock
。按钮从 1 到 8 依次编号,TextBlock
应该在右下角显示“Text”。
首先,我们增加额外的列和行定义:
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
接着,我们添加按钮和 TextBlock
:
<Button Content="1" Grid.Column="0" Grid.Row="0"/>
<Button Content="2" Grid.Column="1" Grid.Row="0"/>
<Button Content="3" Grid.Column="2" Grid.Row="0"/>
<Button Content="4" Grid.Column="0" Grid.Row="1"/>
<Button Content="5" Grid.Column="1" Grid.Row="1"/>
<Button Content="6" Grid.Column="2" Grid.Row="1"/>
<Button Content="7" Grid.Column="0" Grid.Row="2"/>
<Button Content="8" Grid.Column="1" Grid.Row="2"/>
<TextBlock Text="Text" Grid.Column="2" Grid.Row="2"
HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="16"/>
这将创建一个 3x3 网格,包含 8 个按钮和一个居中的文本块。
通过结合使用 Grid、StackPanel 等布局容器,我们可以实现灵活的布局控制。例如,可以将 Grid
放在 StackPanel
中,或者将 StackPanel
放在 Grid
的某个单元格中。
<StackPanel>
<Grid>
<!-- 网格内容 -->
</Grid>
<Button Content="Extra Button" Height="100"/>
</StackPanel>
通过学习 Grid 布局,我们可以轻松地将控件精确地定位到窗口中的不同位置。这种布局方式使得用户界面设计变得灵活且易于控制。在后续视频中,我们将探索更多的 WPF 控件和布局,创建更加美观和复杂的界面。
现在您已经了解了依赖属性,接下来是数据绑定的部分,因为它们是相互关联的。在本视频中,我们将介绍四种不同的数据绑定模式:
让我们通过示例来详细了解这些绑定模式的使用。
我们将创建一个 TextBox
和一个 Slider
,并使用不同的绑定模式连接它们。首先,让我们在 XAML 中添加以下控件:
<StackPanel>
<TextBox Width="100" Margin="50"/>
<Slider Minimum="0" Maximum="100"/>
</StackPanel>
运行这段代码后,您会看到一个可以输入文本的 TextBox
和一个 Slider
。接下来,我们将通过不同的绑定模式连接这两个控件。
首先,让 TextBox
的文本与 Slider
的值绑定,即文本将显示滑块当前的值,但无法通过文本框修改滑块的值。我们将使用 OneWay
模式。
<TextBox Width="100" Margin="50" Text="{Binding ElementName=mySlider, Path=Value, Mode=OneWay}"/>
<Slider x:Name="mySlider" Minimum="0" Maximum="100"/>
在 TextBox
中设置 Text
绑定属性,使其与 Slider
的 Value
属性绑定,并指定 Mode=OneWay
。现在当滑块的值改变时,文本框的值也会更新,但无法通过修改文本框来改变滑块的值。
如果我们希望 TextBox
和 Slider
之间能够双向同步,即文本框改变时滑块值也跟随改变,可以使用 TwoWay
模式:
<TextBox Width="100" Margin="50" Text="{Binding ElementName=mySlider, Path=Value, Mode=TwoWay}"/>
<Slider x:Name="mySlider" Minimum="0" Maximum="100"/>
运行后,您会发现无论是滑动滑块还是在文本框中输入新值,两个控件的值都会实时同步更新。
使用 OneWayToSource
模式时,只有 TextBox
的值会影响 Slider
,而 Slider
的值变化不会影响 TextBox
。
<TextBox Width="100" Margin="50" Text="{Binding ElementName=mySlider, Path=Value, Mode=OneWayToSource}"/>
<Slider x:Name="mySlider" Minimum="0" Maximum="100"/>
在这种情况下,您在 TextBox
中输入值时,滑块会跟随更新,但滑动滑块不会改变文本框中的值。
在 OneTime
模式中,绑定仅在初始化时执行一次。例如,我们可以在代码中设置一次性绑定:
public MainWindow()
{
InitializeComponent();
mySlider.Value = 30;
myTextBox.Text = mySlider.Value.ToString();
}
在这里,我们在 MainWindow
构造函数中设置滑块的初始值,同时将文本框的文本设置为滑块的值。这种绑定在初始化时执行一次,以后滑块和文本框的值不会再相互影响。
如果我们希望文本框中的数据在输入时就能同步到滑块,可以使用 UpdateSourceTrigger
设置 PropertyChanged
:
<TextBox Width="100" Margin="50" Text="{Binding ElementName=mySlider, Path=Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
<Slider x:Name="mySlider" Minimum="0" Maximum="100"/>
UpdateSourceTrigger=PropertyChanged
表示每当 TextBox
中的文本发生变化时,Slider
的值会立即更新,不需要按下 Tab 键或改变焦点。
我们不只可以绑定 Value
,还可以绑定其他属性,例如 Background
。如下所示:
<TextBox Width="100" Margin="50" Background="{Binding ElementName=mySlider, Path=Background}"/>
<Slider x:Name="mySlider" Minimum="0" Maximum="100" Background="Aqua"/>
在此例中,TextBox
的背景色会随着 Slider
的背景色变化。
在本视频中,我们学习了四种不同的数据绑定模式及其应用。通过这些模式,您可以灵活地控制数据的流向,从而实现更复杂的交互逻辑。在后续视频中,我们将继续探讨更多 WPF 和数据绑定的高级功能。
在本视频中,我们将讨论 INotifyPropertyChanged
接口,它允许我们在属性更改时触发某些操作。我们在上一篇视频中已经见过 PropertyChanged
,当我们使用数据绑定时会涉及到它。今天我们将以稍微不同的方式来使用这个接口,并且你将看到如何自己使用它。
这次,我打算使用一个 StackPanel
,因为我希望将元素堆叠起来。我将首先堆叠一个 Label
,并将其内容设置为“number one”。然后我将关闭这个标签。
接下来,在标签下面,我将添加一个文本框,用于输入数值。我不会使用 TextBlock
,而是使用 TextBox
。设置好之后,我直接关闭它。
在 TextBox
中,我设置它的宽度为 100 像素,高度为 30 像素。接着我想设置文本内容,文本应该绑定到数据源,因此我将使用数据绑定。绑定的路径将是 number one
,绑定模式设置为双向。
我们已经了解了绑定路径和模式的含义。现在我们复制这段代码并在此处粘贴两次。第一个文本框将绑定到 number two
,第二个将绑定到 result
,分别对应“number two”和“result”。
你可能会问,为什么我使用 number one
、number two
和 result
,因为这些只是显示内容,并不是实际的变量名。为了连接这些内容,我们需要进入后台代码,因为我要在这里创建这些变量。
为了使用 INotifyPropertyChanged
接口,我们需要创建一个继承自它的新类。我们将创建一个名为 Some
的类,因为我们希望在输入 number one
和 number two
的值后,底部的 result
会自动显示这两个数字的和。
为了使用 INotifyPropertyChanged
,我们需要引入 System.ComponentModel
命名空间。所以我们在文件顶部添加 using System.ComponentModel
。
现在可以看到接口变成了绿色,这是 Visual Studio 中接口的标准颜色。接下来,我需要实现接口中的事件 PropertyChanged
。
我们需要创建一个名为 OnPropertyChanged
的方法,接收一个 string
类型的参数 property
。我们首先检查 PropertyChanged
事件是否为空。如果不为空,则调用 PropertyChanged
事件,将属性名称作为参数传递给它。
为了处理这些数据,我们首先创建三个私有字段:numOne
、numTwo
和 result
。接下来,我们为这些字段创建相应的属性。
numOne
和 numTwo
属性采用稍微不同的定义方式,我不使用默认的 get
和 set
,而是自定义 getter 和 setter。在设置属性时,我们会尝试解析用户输入的值,以防用户输入的是非数字字符。如果解析成功,则更新字段值,并触发 PropertyChanged
事件,通知属性已经更改。
result
属性返回 numOne
和 numTwo
的和,并且每次它的值变化时,都会触发 PropertyChanged
事件。
为了测试这些更改,我们需要在 .xaml
文件中创建一个 Some
类的对象,并将其设置为数据上下文。这样我们就能通过绑定来更新 UI。
在这次实现中,我们创建了一个类 Some
,它继承自 INotifyPropertyChanged
接口,通过属性变更事件来自动更新结果。界面中的 TextBox
控件绑定到这些属性,确保每次用户输入新的数值时,result
会实时更新,显示两个数字的和。
通过这种方式,我们可以实现数据驱动的 UI,确保 UI 和数据始终保持同步。当用户输入数据时,result
会自动计算并显示新的值。这种实现方式适用于需要实时更新显示的场景,比如表单计算器等。
你可以根据需要进一步扩展这个例子,例如增加更多的输入框,或者将其修改为进行乘法等其他操作。
欢迎回来!在这个视频中,我们将创建一个列表框(List Box)。如你所见,我们有一个小列表框,其中包含一些条目。比如拜仁慕尼黑(Bayern Munich)有3个积分或进球,皇家马德里(Real Madrid)有2个进球,而比赛即将结束。所以,我们这里使用了多个不同的元素,而这些元素我们还没有使用过。当我选择一个条目并点击这个按钮“显示已选”,一个小的消息框会弹出,告诉我已选择的条目信息,或者至少在这个例子中,它只是给我这些信息。当然,你可以在这里添加更多的信息,你甚至可以构建一个小应用程序,从互联网上获取数据并不断更新,显示当前比赛的即时比分。
好吧,让我们开始吧!我将为此创建一个新项目,命名为“list box”。我们先把这个名字定下。我将使用 XAML,所以我将把它拖到界面上。我们目前的界面或者窗口依然存在,但现在我想使用网格布局(Grid),当然,这里需要用到多个行(Rows)。所以让我们先创建列定义(Column Definition)、行定义(Row Definition)之类的东西。顺便提一下,我们实际上只需要两列,所以不会太复杂。我们只需要两列,而不需要多行,因此我们不需要行定义。如果你记得如何做,那当然很好,掌握这些技能总是有益的。所以你首先需要列定义,然后在这里你可以定义列。我将定义一列宽度为“*”的列,和一列宽度为100的列。实际上,我是想在这里创建一个按钮,作为第二列。
好的,现在我们有了两列。左边这一列将包含列表框(ListBox),右边这一列则包含一个可以点击的按钮,一旦点击就可以获取比赛的更多信息。那么现在我们来创建一个列表框。我将使用 ListBox
元素,并给它一个名字,我将命名为 matches
,并设置水平内容对齐方式(Horizontal Content Alignment),让它能够拉伸以填充整个内容区域。所以我将设置为“拉伸”模式,这样它就会拉伸以适应我的列表框内容。这时这个 ListBox
的名称应该是 lbMatches
。
接下来,我想为列表框创建一个特定的项目模板(Item Template)。ListBox
可以使用一个项目模板(ItemTemplate)。我将使用 ListBox.ItemTemplate
,并在其中创建一个数据模板(DataTemplate)。在这个数据模板中,我可以展示数据。我们将使用一个网格(Grid)来布局,网格的边距(Margins)设置为2。这个网格也需要列定义(Column Definitions)。在这里,我将创建一个新的 Grid.ColumnDefinitions
标签,并定义几个列。为了简单起见,我将只使用5列。
这五列分别是:第一列显示球队1,第二列显示第一个得分,第三列显示第二个得分,第四列显示第二支球队,最后一列显示进度(进度条)。接着,我们将放置多个文本块(TextBlock)。第一个文本块放在第一列,显示球队1的名称,第二个文本块显示球队1的得分。接下来,第三个文本块显示第二支球队的名称,第四个文本块显示第二支球队的得分。最后,我们放置一个进度条,用来显示比赛进行的进度。
进度条的最小值设置为0,最大值设置为90分钟(因为足球比赛是90分钟),并且我们将绑定进度条的值到 completion
,即比赛的完成百分比。
那么接下来,我们需要在代码后端(Code-behind)中定义一些数据。在代码后端,我将创建一个类,命名为 Match
,它将包含比赛的各种属性,例如:球队1(team1)、球队2(team2)、球队1的得分(score1)、球队2的得分(score2)、比赛进行的百分比(completion)。这样,我们就可以很容易地创建一个包含这些信息的比赛对象,并将它们绑定到界面上的列表框。
在主窗口中,我创建了一个 matches
列表,类型为 Match
的列表,来存储所有的比赛对象。接下来,我将向列表中添加一些比赛数据,例如拜仁慕尼黑与皇家马德里的比赛。每个比赛对象包含球队名称、得分以及比赛进行的时间百分比。
一旦这些数据准备好,我们将通过数据绑定(Data Binding)将这些数据展示在界面上。我们将把 lbMatches
的 ItemSource
设置为 matches
,这样列表框就会显示所有比赛的信息。
接下来,我在界面上添加了一个按钮,用于显示选择的比赛详情。按钮的点击事件会触发 ShowSelected
方法,这个方法会检查是否有选中的比赛项,如果有,它就会弹出一个消息框,显示所选比赛的详细信息,例如:球队名称、得分等。
最后,我挑战你去尝试添加更多的比赛,你可以选择其他种类的比赛,进度条可以显示比赛进行的时间。你只需简单地复制粘贴比赛对象,并修改球队名称和得分。
在此之后,你可以运行程序,看到界面上显示的所有比赛以及它们的进度信息。你还可以进一步改进界面,例如调整得分显示的位置或为进度条添加动画效果等。
视频的最后,我们还可以扩展这个类,加入更多的信息,比如球员名单等。你可以创建一个新的属性 LineUp
,用来存储球员的名称,并且在用户点击某个比赛时显示更多的球员信息。
这是我们视频的总结,涵盖了如何使用 ListBox
和 Grid
布局,还学习了如何使用数据绑定,将数据绑定到控件上显示。这个方法非常适合创建灵活且易于扩展的应用程序。希望你喜欢这个视频!
欢迎回来。在这个视频中,我们将介绍组合框(Combo Box),你可以在这里看到一个例子。它的数据源包含了所有我们可以使用的颜色。假设我们想使用“淡紫红”(Pale Violet Red),就像我们之前已经展示过的那样。我们可以直接选择它。正如你所看到的,我们的组合框已经选择了这个颜色,甚至展示了关于这个颜色名称的完整美丽文本。
好了,现在我们开始创建一个新项目来演示,我将把它命名为“WP F ten C combo box”,你可以根据自己的喜好命名。但我们需要在这个项目中创建一个示例。我们首先创建一个 Stack Panel(堆叠面板)。所以,我想用 Stack Panel 代替 Grid。我这里会把 XAML 代码放大一点,因为设计界面很漂亮,但更重要的是看到你正在编写的代码。
接下来,我将在这里创建一个叫做 Combo Box 的控件,基本上就是这个。这个是我的组合框,我可以给它一个名称,所以我将它命名为 ComboBoxColors
。
现在,这个组合框需要一个组合框项模板(ComboBoxItemTemplate),这是我们在上一个视频中使用过的,类似于我们使用 ListBox 时所做的。这里就有了这个组合框项模板。我们需要一个数据模板(DataTemplate),否则你会看到它提示错误。只要我们有了这个数据模板,就可以继续了,我会删除这里多余的几行代码。
在这里,我想要一个 Stack Panel,因为我想把所有内容堆叠在一起,并且它应该是水平排列的。接下来,这个 Stack Panel 应该包含一个矩形(Rectangle)。顺便说一下,这个 Stack Panel 将会是我们组合框内的内容,它应该包含一个矩形,我将给它填充颜色。所以,我将通过绑定名称来填充它,并且矩形的宽度设置为 32 像素,高度也设置为 32 像素。然后,我想为这个矩形添加一个 5 像素的边距(Margin),这样四个方向都会有一点空隙。
接下来,应该有一个文本块(TextBlock),它显示颜色名称。所以这个文本块的文本内容应该绑定到名称(binding name
)上。我们将绑定“名称”属性,所以我们需要创建一个名称属性。最后,我还想给这个文本块设置一个 32 的字体大小。
好了,这就是我们在 XAML 中需要的所有代码。现在,我们可以继续编写后台代码(code-behind)。因为我们需要的东西就是这个“名称”属性。让我们进入后台代码,在这里,我将直接访问我们的组合框控件 ComboBoxColors
,它位于主窗口(MainWindow)中。所以我们访问 MainWindow.ComboBoxColors
。这样,我们就能获取到这个组合框控件了。
接下来,我会设置这个组合框的 ItemSource
。ItemSource
将会是我们的颜色属性(Colors.GetProperties
)。但是,我们不能直接这样使用它。因此,我们需要指定 Colors.GetProperties
的类型。这样我们就可以获取到所有可用的颜色。
这个类是 System.Windows.Media.Colors
,其中有一个方法叫做 GetProperties
,它会返回所有颜色的列表。这就是我们所需要做的。我们把 ItemSource
设置为这个颜色属性。我们当然可以使用一个对象列表来填充这些元素,但是这样做的方式非常简洁。正如你所看到的,只有一行代码,就能提供多种可选值。
因此,我们只需要一个包含所有这些颜色的组合框,你可以选择像巧克力色(Chocolate),深灰色(Dark Slate Gray)等等颜色。正如你所看到的,你可以将 ItemSource
设置为一个很棒的内置列表,或者你也可以创建自己的 ItemSource
,它可以是来自数据库的数据,或者你自己创建的列表。
欢迎回来。在这个视频中,我们将创建一个披萨配料应用程序,它允许我们在想要披萨送达时随时添加配料。所以,当我们点披萨时,我们可以选择添加配料,比如萨拉米香肠、蘑菇或者额外的马苏里拉奶酪。如果我们将所有这些配料都添加上,正如你所看到的,顶部的“添加所有”选项会被激活,并且它有三种状态,如你所见,它有一个“是”(True)状态,一个空状态,和一个“否”(False)状态。
好了,这就是我们要创建的内容。接下来,让我们开始吧。首先,我们需要为界面腾出更多空间,我将使用 Stack Panel 代替 Grid。再次强调,Stack Panel 的使用是因为 Grid 设置起来比较麻烦,特别是当你需要一个比较简单的布局时,Stack Panel 会更加方便。
接下来,我想要添加一个标签(Label),只是一些文本,文本的字体粗细为加粗(bold)。文本内容将是“披萨配料”。然后,我需要一个复选框(CheckBox)。我将创建一个名为 KB
的复选框,代表“所有配料”的选择框,这个复选框将会支持三种状态。什么是三种状态呢?就是它与其他复选框连接,当它被选中时,它表示所有的配料都被选中了。它的状态包括:
因此,这就是为什么我们需要三种状态。然后,我们可以添加选中和未选中的事件处理方法(checked/unchecked)。所以我将手动创建一个新的事件处理程序。你们已经知道如何创建事件处理程序,可以自己试着做一下。接下来,让我们创建这个事件处理程序。我们将其命名为 CBAllCheckedChanged
,并且需要传入对象 sender
和事件参数 e
。
当复选框被选中时,事件 CBAllCheckedChanged
就会被调用,未选中时也是如此。所以,我需要为这两种情况编写相应的方法,确保它们能够同时处理。
接下来,我们为复选框添加文本:“添加所有”。现在,我们已经设置了一个大复选框,接下来在它下面创建一个 Stack Panel,其中包含另外三个复选框。我们为每个配料(如萨拉米、蘑菇、马苏里拉)都创建一个复选框。每个复选框都会有自己的事件处理方法。我为每个复选框创建一个单独的事件处理方法,命名为 SingleCheckedChanged
,每个复选框的名称分别为 CheckBoxSalami
、CheckBoxMushrooms
、CheckBoxMozzarella
。
复选框文本的显示会通过嵌套的 TextBlock 来实现。例如,萨拉米复选框会显示为“辣味”(spicy),并且通过设置 Foreground
和 FontWeight
来使文本更加美观。
接着,其他两个复选框(蘑菇和马苏里拉)也按照相同的方式创建,但没有像萨拉米那样增加额外的文本效果。复选框文本分别为“蘑菇”和“马苏里拉”。
为了使界面更加整齐,我为“添加所有”复选框和下面的三个复选框之间增加了一个边距(例如 5 或 10 像素),使它们看起来属于同一组。这样一来,“添加所有”复选框就与下面的配料复选框紧密关联。
现在,XAML 文件已经设置好了。接下来,我们需要进入后台代码(code-behind)并实现这些方法。我们已经声明了这些方法,但是它们目前还没有执行任何操作。我们可以通过以下方式修改它们:
添加所有配料的逻辑:首先,我们定义一个布尔值 newVal
,它的值取决于“所有配料”复选框是否被选中。如果被选中,则 newVal
为 true
,否则为 false
。然后,我们将这个值应用到每个配料复选框中。
单个配料复选框的状态:当用户选中或取消选中单个配料复选框时,我们会检查所有配料复选框的状态。如果所有配料都被选中,那么“添加所有”复选框也会被选中;如果任何一个配料未被选中,那么“添加所有”复选框会被取消选中。
下面是实现这个逻辑的代码示例:
private void CBAllCheckedChanged(object sender, RoutedEventArgs e)
{
bool newVal = CBAllToppings.IsChecked == true;
CBSalami.IsChecked = newVal;
CBMushrooms.IsChecked = newVal;
CBMozzarella.IsChecked = newVal;
}
private void SingleCheckedChanged(object sender, RoutedEventArgs e)
{
if (CBSalami.IsChecked == true && CBMushrooms.IsChecked == true && CBMozzarella.IsChecked == true)
{
CBAllToppings.IsChecked = true;
}
else if (CBSalami.IsChecked == false && CBMushrooms.IsChecked == false && CBMozzarella.IsChecked == false)
{
CBAllToppings.IsChecked = false;
}
else
{
CBAllToppings.IsChecked = null; // Tri-state
}
}
通过这种方式,我们就能够控制复选框的三种状态——选中、未选中以及空状态。
现在,你可以运行这个应用,当你点击“添加所有”复选框时,所有配料都会被自动选择或者取消选择。如果你手动选择或取消某个配料,其他配料和“添加所有”复选框的状态也会相应改变。
这就是如何使用复选框和三种状态来创建一个披萨配料应用程序。当然,你也可以像我这样,给所有复选框使用相同的事件处理方法,或者你可以为每个复选框创建独特的方法,甚至使用消息框来提示用户他们选择了哪些配料。
非常简单明了的实现方式。如果你有任何问题,欢迎留言!下个视频我们将继续学习下一个主题。
欢迎回来。在这个视频中,我想展示一个简单而快速的功能——如何为控件添加提示框(Tooltip)。为了演示这个功能,我创建了一个新的 WPF 项目,并在 Grid 中添加了一个按钮(Button)。按钮有一个名为 Tooltip
的属性,我们可以在这个属性中设置当用户悬停在按钮上时显示的提示内容。我将在提示框中输入一些文本,比如:“我是一个提示框,我很有用”这样简单有用的内容。
然后,我还可以给按钮添加文本,文本内容是“悬停查看更多信息”。同时,我将按钮的宽度设置为 150 高度设置为 100,使其稍微小一点。
现在,我们来看一下效果。你可以看到,当我们将鼠标悬停在按钮上时,提示框显示了“我是一个提示框,我很有用”。这就是如何使用提示框(Tooltip),它可以应用于多种不同的控件,不仅仅是按钮,也适用于其他控件,比如文本块(TextBlock)。
接下来,我创建了一个文本块(TextBlock),并为其添加了一个提示框(Tooltip)。当我将鼠标悬停在文本块上时,提示框会显示“请输入下面的年龄”这样的信息。这可以非常有效地帮助用户理解控件的功能。
当然,文本块本身不能接收文本输入,但如果我们在它下面添加一个文本框(TextBox),我们同样可以为文本框添加提示框。所以,提示框非常适用于为用户提供额外的信息,特别是在某些地方可能不够清楚时。
好了,这就是提示框的基本用法。在下一个视频中,我们将学习如何使用单选按钮(Radio Buttons)。敬请期待!
欢迎回来。在这个视频中,我们将学习如何使用单选按钮(Radio Buttons)。如你所见,你可以从工具箱中拖动一个单选按钮(Radio Button)到你的 UI 中,然后你会在 Grid 中看到它,并且它会有一些属性,这些我们之前已经看到过。不过,在这个视频中,我不会直接使用工具箱中的单选按钮,而是通过 XAML 来构建 UI,使用一个 StackPanel 来包含我们的控件。
首先,在 StackPanel 中,我想要一个标签(Label),标签上显示一些文本,比如“你喜欢我吗?”。接着,我会设置这个标签的字体加粗(FontWeight),并设置字体大小为 20。这样,标签就完成了。
然后,接下来我们需要为用户提供三个选择:是、否,或者也许,选项之间没有其他选择。这就是单选按钮的优势——在一个单选组中,只有一个按钮会被选中。
我们首先创建一个单选按钮,并使其更加个性化,不仅仅是一个简单的按钮。我们将它放在一个 WrapPanel
中,并且为按钮添加一个绿色的矩形和一个文本框。矩形的宽度和高度设置为 16,表示一个 16x16 的绿色方框。接下来,我们在矩形旁边添加一个文本块,显示“是”,并且将文本颜色设置为绿色。
接着,我会为 StackPanel 和其他控件添加一些间距(Margin),使界面看起来更整洁。例如,我们为 StackPanel 设置顶部间距,并且为文本块设置左侧间距为 5 像素。这样,控件之间的间距就得到了合理的调整。
接下来,我们复制第一个单选按钮,放置到它的旁边,并将其颜色改为红色。按钮的文本改为“否”,并将文本颜色改为红色。通过这种方式,我们创建了第二个选项。
第三个选项是“也许”,不同于前两个选项,我们将使用一张图片而不是矩形。首先,我删除矩形,使用 Image
控件,并指定图片的源路径(Source)。图片文件存储在我的电脑上,路径为“C:\C-sharp Master Class Course\projects\maybe.png”,我还调整了图片的宽度和高度为 32x32 像素,使其更适合显示。
这样,我们就完成了三个单选按钮,分别是“是”,“否”和“也许”。当你运行程序时,你会看到这三个按钮,并且它们是一个单选组的成员,只能选择其中一个选项。单选按钮的优势是,它们总是只会有一个选项处于选中状态,这与复选框(Checkbox)不同,复选框允许多个选项被选中。
如果你希望在程序启动时默认选中某个选项,你可以通过 IsChecked
属性来设置。例如,如果你希望“也许”选项默认选中,可以在 RadioButton
上设置 IsChecked="True"
。
如果你希望在用户选择某个选项时触发事件,可以通过 Checked
事件来实现。例如,双击单选按钮就会生成事件处理程序。在代码背后,你可以添加一个 MessageBox
来显示消息,例如:“请选择是”。
你也可以修改事件处理程序的名称。比如,将 RadioButton_Checked
改为 YesChecked
,并在代码中相应地更改。
通过这种方式,我们可以为每个按钮添加不同的事件处理程序,比如当“是”被选中时显示“谢谢”,当“否”被选中时显示“请说是”。此外,我们还可以在代码中手动触发 Checked
事件,比如在构造函数中使用 RBMaybe.IsChecked = true;
来设置默认选中的项。
通过这个示例,你学会了如何使用单选按钮以及如何为它们添加图像和事件处理程序。你还了解了如何设置默认选中项,并且学会了如何在事件发生时响应用户的选择。
在这个视频中,我想讨论一下属性数据和事件触发器,因为它们在动态调整界面时非常重要。举个例子,我将调整文本内容。当我将鼠标悬停在文本上时,文本的颜色和外观都会发生变化。接着是这个内容,“你好,伙伴”。你可以看到它的大小变得更大了,这里有一个动画效果。然后我们有一个复选框,旁边有一个文本显示“没有”,当我点击它时,文本会显示“哦,当然有人在这里,那就是我”。是的,我可以点击它。这些是我们将在本视频中讨论的内容,它们可以帮助你在制作用户界面时更加灵活,让界面在运行时自适应变化,并且提升用户体验。所以,让我们先创建一个新项目,我将它命名为“WPA 14C”,表示“属性数据和事件触发器”。我这里使用了网格和堆叠面板的组合。所以我将从一个堆叠面板开始。这样堆叠面板就创建好了。在堆叠面板中,我放置了一个网格。然后在网格里,我放置了一个文本块,显示类似“Hello, beloved world”之类的内容。接着我设置字体大小为32。我要让它居中,所以我设置它的水平对齐方式为“center”,并且垂直方向也需要居中,所以我设置垂直对齐方式为“center”。就这样,我们的文本“Beloved World”已经完成了,并且我稍微增大了一些XAML文件的尺寸。
在这个文本块中,我可以去更改一些样式。例如,我可以进行多种调整,并且为了使用样式触发器,我需要添加一个样式。首先,我为文本块添加样式:TextBlock.Style
,这样就可以创建样式了。比如,我需要添加一个目标类型,这里目标类型就是文本块(TextBlock)。在这个样式中,我可以继续添加setter和样式触发器。
首先,我需要一个setter,它将修改“foreground”属性,即我想将文字颜色设置为绿色。因此,它会将“foreground”属性的值设置为绿色。然后,我希望有一个触发器来触发这个更改,所以下一步我会用Style.Triggers
来设置触发器。现在在Style.Triggers
标签内,我创建不同的触发器,我将只使用一个触发器——MouseOver
,它的属性是“mouse over”,也就是说,当鼠标悬停在文本上时,我希望这个触发器被激活,并且我将触发的值设为true
(注意大写的T)。在这个触发器标签内,我可以继续添加setter来改变属性。
举个例子,我现在有绿色的文本,当鼠标悬停时,我希望它变成红色。所以,setter会将foreground
属性的值设置为红色。因此,当鼠标悬停时,触发器会被激活,所有设置的属性都会被执行。接下来,我想添加另一个setter,它的属性是TextDecorations
,它的值会设置为“underline”(下划线)。好,现在我们关闭这个setter,来测试一下触发器是否正常工作。
这是一个文本块,代码结构大致是这样:我们有一个包含文本块的网格,它位于堆叠面板中。现在我调整了文本块的样式,通过添加样式标签,指定目标类型为文本块。接着,我用setter直接设置样式,而不需要触发器的帮助。然后,我们加入样式触发器,设置了鼠标悬停时的触发条件。当IsMouseOver
为真时,设置触发器会被激活,它会将foreground
属性变成红色,TextDecorations
变成下划线。你可以在这个地方添加多个setter,它们会在IsMouseOver
为true
时执行。我们来运行代码,看看实际效果。你会发现,当我将鼠标悬停在“Hello Beloved World”上时,文本变为红色并且带有下划线。
此外,还可以看到,当鼠标离开时,颜色会恢复到原本的绿色,下划线也会消失。就是说,当触发器的条件不再满足时,设置会恢复到初始值,这就是样式触发器的一个特性。
接下来,我们要再创建一个文本块,这个文本块会放置在一个新的网格中。这样,我可以有多个网格层叠在一起。这里,我使用了另一个文本块,显示“Hello buddy”,字体大小设置为24,确保它居中对齐。然后,我将为这个文本块添加样式,像之前一样使用TextBlock.Style
,并为它添加触发器。与上一个例子不同,这次我将使用一个事件触发器(EventTrigger),而不是样式触发器。
首先,我们定义事件触发器的条件,这里是MouseEnter
,即当鼠标进入这个文本块时,触发器被激活。接下来,我们使用Storyboard
来实现动画效果,动画会在500毫秒内执行,并且会把字体大小变为72。这样当鼠标进入文本块时,字体大小就会变大。
现在我们来运行代码,看看效果。当鼠标进入时,文本会变得非常大,但它不会自动回到原来的大小。这个时候,我留了一个小小的挑战给你:请添加另一个事件触发器,使得当鼠标离开文本块时,字体大小能回到原来的24。
这里的提示是,你不需要重新编写所有的内容,只需要创建另一个事件触发器,并将其条件改为MouseLeave
,然后设置字体大小恢复为24,并且持续时间为300毫秒。完成后,代码应该能够在鼠标离开时将字体大小恢复到原始状态。
我复制了之前的事件触发器,并将MouseEnter
修改为MouseLeave
,将持续时间调整为300毫秒,字体大小恢复为24。这样,当鼠标离开时,字体大小会恢复,并且恢复的速度会比放大的速度快。
最后,我们来看看复选框的实现。复选框的内容是“Is someone there?”当勾选复选框时,下面的文本内容应该发生变化。我使用了数据绑定(Data Binding),通过绑定IsChecked
属性来触发文本变化。当复选框被勾选时,文本会显示“Of course”,同时字体颜色变为绿色。这个例子展示了如何使用数据触发器来动态改变界面的内容,而不是依赖于事件驱动触发器。
总结一下,视频展示了三种不同类型的触发器:样式触发器(Style Trigger),事件触发器(Event Trigger)和数据触发器(Data Trigger)。这些触发器可以帮助你在不编写后台代码的情况下,动态调整界面的显示效果。希望你能够理解这些内容,并尝试在XAML中创建更复杂的界面逻辑。
欢迎回来。在本视频中,我们将结合使用文本框和密码框,来创建一个简单的登录界面。让我们开始吧。
首先,我将放大XAML视图,并且不使用网格(Grid)布局,而是使用堆叠面板(StackPanel),因为它可以将控件垂直堆叠。首先,我将添加一个标签(Label),这样用户可以知道应该输入哪种信息。我们从“用户名”开始,然后在其下方添加一个文本框(TextBox),并为文本框命名为 TB_username
,这就是用户名文本框。
<StackPanel Margin="10">
<Label Content="Username" />
<TextBox Name="TB_username" />
</StackPanel>
如你所见,文本框距离边缘太近了,因此我为堆叠面板添加了一个10像素的边距,确保所有控件都有适当的间距。
接下来,我将添加另一个标签,内容为“密码”。然后,在标签下方添加一个密码框(PasswordBox),以便用户输入密码。
<Label Content="Password" />
<PasswordBox Name="PB_password" />
现在,密码框已添加完成。接着,我们为登录按钮(Button)添加一个操作,按钮的文本内容为“登录”,并且我为按钮设置了一个点击事件。
<Button Content="Log in" Margin="5" Click="OnLoginClicked" />
</StackPanel>
接下来,在按钮的事件处理函数中,我们可以显示一个消息框(MessageBox),内容为“欢迎,用户名”,这样用户点击“登录”后就会看到自己的用户名。
private void OnLoginClicked(object sender, RoutedEventArgs e)
{
MessageBox.Show("Welcome, " + TB_username.Text);
}
我们在文本框中输入用户名(例如 "Dennis"),然后在密码框中输入密码(例如 "password")。由于密码框会自动隐藏输入的字符,所以你会看到显示为点(●)。点击“登录”按钮后,会弹出一条消息,显示“欢迎,Dennis”。
<MessageBox Show="Welcome, Dennis" />
如果你不希望密码框显示点(●),而是想使用其他字符来代替这些点,可以通过设置 PasswordChar
属性来更改显示的字符。比如,可以设置为星号(*)。
<PasswordBox Name="PB_password" PasswordChar="*" />
再次运行程序时,如果输入密码,将会显示为星号(*)。
你也可以将密码显示的字符更改为其他任何符号,比如字母“H”:
<PasswordBox Name="PB_password" PasswordChar="H" />
这样,输入的密码就会显示为“H”。
密码框还可以设置最大长度(MaxLength)。通过设置这个属性,你可以限制用户输入密码的最大字符数。例如,如果你设置最大长度为8:
<PasswordBox Name="PB_password" MaxLength="8" />
此时,用户的密码将不能超过8个字符。如果尝试输入更多字符,密码框会自动限制输入。
虽然限制最大密码长度可以防止用户输入过长的密码,但要小心设置过短的最大长度。一些用户可能会希望使用更长的密码,这样的限制可能让他们感到不便。如果最大长度设置过短,用户可能会因为无法输入足够长的密码而感到沮丧。通常,设置20到25个字符的最大长度会更为合理。
通过结合使用 TextBox
和 PasswordBox
控件,我们可以创建一个简单的登录界面。通过密码框,我们可以保护用户的密码信息,并且还可以通过设置属性来自定义密码框的显示字符、限制最大字符长度等功能。
好的,现在我们完成了这一章节,虽然这章节内容比较长、复杂,但也非常吸引人。因为我们终于能够看到一个用户界面,终于可以看到我们编写的代码实际呈现出来了。在这部分内容中,我们学习了一些相对复杂的知识点,比如数据绑定、依赖属性、INotifyPropertyChanged
接口等。虽然这些概念看起来比较复杂,但一旦你亲自创建了一些用户界面,或者多次亲自动手实现这些界面后,这些知识点会变得如同第二天性一样。
所以即使现在看起来有些困难,但只要你不断地练习,自己多做几次,最终你会掌握这些内容的,别担心。
好了,这就是本章节的内容。我们学习了如何创建用户界面,如何将这些内容串联起来,看到我们到目前为止所学的所有代码如何汇聚在一起形成一个较为复杂的部分。但这并不是结束。在课程的后续部分,我们还会再次使用 WPF 来创建更多的用户界面。所以,我们下一个章节再见!
欢迎来到本节课程。在这一节中,我们将学习如何创建一个 WPF 项目。首先,你需要打开 Visual Studio 安装程序。在左下角搜索框中输入 Visual Studio,然后选择你的 Visual Studio 版本。现在,我选择的是 2022 版,然后点击“修改”。
接下来,给它一点时间来加载。我们将进入工作负载页面。请注意,如果你是 Linux 或 macOS 用户,你将无法找到或安装 "Dotnet Desktop Development" 工作负载(即用于构建 WPF 和 Windows Forms 的工作负载)。因此,如果你使用的是 Windows 电脑,请勾选这个框,然后点击“安装”。在下载并安装该工作负载后,你就能开始使用 WPF。
然而,如果你是 Linux 或 macOS 用户,你将无法运行和构建 WPF 应用程序。原因是 WPF 是专门为 Windows 电脑设计的。因此,你有三种选择:
这点需要特别提醒。如果你使用的是 Windows 电脑,勾选此框并点击“安装”,等待它安装完成。接下来,我们将在下一个视频中设置项目。
现在我们来看看如何设置一个新的 WPF 项目。再次提醒,如果你使用的是 macOS 或 Linux 系统,你将无法创建 WPF 应用程序。所以我建议你可以选择观看视频而不进行实际操作,或者使用 Windows 电脑、安装虚拟机等方式进行跟随学习。
好了,接下来我们开始吧。我们在这里创建一个新项目,首先搜索 WPF,给它一点时间加载。在这里你会看到 WPF 应用程序,它是用来创建 .NET WPF 应用程序的项目类型。我们选择它,注意这是 C# 项目,而不是 Visual Basic 或 F#,所以请选择 C# 版本,然后点击“下一步”。
接下来,我们为项目命名。接下来几节课我们将创建一个 WPF 演示应用程序,所以我会将项目命名为 "demo"。你可以将它放置在任何位置,点击“下一步”。
现在选择框架。我们打开框架选项,看看这里有 .NET Core 3.1、5、6 和 7。值得一提的是,"Core" 这个词已经被移除,因此 .NET 5、6 和 7 也可以看作是 Core 版本,但它们已经完全去除了 "Core" 的名称。我选择的是 .NET 7,它是目前最新的版本。你也可以选择 .NET 7,这是我推荐的版本。不过,如果你看到有 .NET 9 的版本,也可以尝试使用它。
接下来点击“创建”,项目就会被创建出来,创建完成后,你应该看到类似的界面。我们将在下一节课中详细探索这个界面。不过首先,我可以告诉你,你将看到两个主要的窗口:一个是设计窗口,它显示了应用程序的图形化界面,另一个是底部的 XAML 窗口,它显示的是应用程序的 XAML 代码表示。XAML 代表的是扩展应用程序标记语言(Extensible Application Markup Language),你可以直接使用它来创建用户界面元素。
好了,我们在下一节课中继续学习。
你刚刚创建了你的第一个 WPF 应用程序。嗯,至少是项目对吧?现在你应该看到的界面是这样的:在右侧是“解决方案资源管理器”,然后你有一个设计窗口,在底部是 XAML 窗口。如果你没有看到这种布局,可以点击 Visual Studio 的 窗口 菜单,选择 重置窗口布局,将布局重置为默认设置。
在我们开始创建图形用户界面并深入研究 XAML 和设计器之前,我想简要介绍一下 WPF 项目的整体结构。正如我所说,这是我们项目的可视化表示。所以当我们运行调试模式时,只需编译并启动应用程序,你就会看到一个应用窗口打开,这就是我们的项目。
这里有一个小工具栏,它仅用于调试目的。也就是说,如果你直接启动可执行文件(编译后的文件),它是不会显示这个工具栏的。它只是用于调试时查看应用程序的状态。
目前你的应用程序是空的,但它按预期工作。很好,让我们稍微检查一下解决方案。在 解决方案资源管理器 中,我们可以看到 MainWindow.xaml 文件。如果我们打开它,你会看到一个所谓的“代码隐藏”文件,它位于 XAML 文件后面。这里是我们的 XAML 文件,而在它后面有一个同名的代码文件,它的扩展名是 .cs
,表示这是一个 C# 文件。
接下来我们打开 MainWindow.xaml.cs,这个文件是主窗口的代码隐藏文件。查看这个文件,我们可以看到这是一个名为 MainWindow
的类,并且它继承自 Window
类。这里一些组件被初始化。基本上,这个方法做的事情是初始化用户界面的所有组件,这些组件在 XAML 文件中定义。我们将在接下来的讲解中深入探讨这个话题。
再看一眼 解决方案资源管理器,你还可以看到一个 App.xaml 文件及其代码隐藏文件。这是我们应用程序的入口点,就像控制台应用程序中的 static void main
方法一样。现在看起来它似乎没有做什么,没关系。如果我们点击 F12 跳转到 App.xaml
,你可以看到一些用于引导程序启动的代码。你不需要过多担心这些细节,但我只是想给你一些关于项目结构的基本信息。
另外需要知道的是,我们可以有多个窗口。现在我们只有一个主窗口及其相关的代码隐藏文件,但我们可以创建更多的窗口,每个窗口都有自己的代码隐藏文件。
好了,现在你已经准备好开始使用 XAML 创建你的第一个图形用户界面元素了。让我们在下一节课中开始吧。
欢迎回来。现在我们来看看我们当前的应用程序。正如我在上一节视频中提到的,当前应用程序的图形用户界面的 XAML 表示就在下方的 XAML 窗口中。如果我稍微放大一些,你可以看到一些键值对。这看起来有点像 HTML,在这里我们有一个 window
标签,紧接着这个 window
标签后面,我们可以看到几个键值对。比如我们有一个键 title
,它的值是 Main Window
,另一个键 height
,值是 450,另外一个键值是 width
,它的值是 800 像素。
现在,在 window
标签内部,就像在 HTML 中一样,我们有一个嵌套的元素——grid
。在 grid
内部,我们还可以有其他元素。所以我们面对的是一种层级结构。换句话说,元素可以有父元素、子元素,它们可以是兄弟元素,或者是嵌套的。接下来,让我们创建一个文本元素。
你现在看到的左侧是 工具箱,如果你没有找到它,你可以通过点击 视图 菜单中的 工具箱 来打开它,或者你可以直接按下快捷键 Ctrl + Alt + X。按下这些快捷键后,工具箱将出现在左侧。
现在,让我们打开 常用 WPF 控件。我们选择一个 Label 标签,并将它拖放到我们的 WPF 应用程序中。我这样做是为了向你展示,当我们创建一个新元素时,比如这个标签(Label),你会看到它在 XAML 表示中创建了一个新的元素。所以,在 grid
元素中,你会看到一个新的 label
标签。
我们创建了一个标签,它有一些新的属性或者键值对,比如 content
属性,这个属性保存了标签的实际文本内容。除此之外,还可以看到水平对齐(horizontal alignment
)、外边距(margin
)、垂直对齐(vertical alignment
)等属性。如果我们想调整文本内容,只需将 content
属性的值从 Label
改为比如 Hello World
。
我之所以要向你展示这些,是因为我们可以使用 C# 来动态地调整这些属性的值。比如,我们可以通过 C# 获取这个标签(Label),访问它的 content
属性,然后修改它关联的字符串。因此,看到你的图形用户界面在 XAML 中的表示是非常重要的。
过去,在开发 Windows Forms 应用时,我们只需要打开工具箱,把所有的控件拖拽到应用程序中。而在 WPF 中,我们利用 Grid 系统来保持应用的响应式布局。这个系统比 Windows Forms 更现代,也是一种动态构建应用程序的方式,所以你仍然可以拖放元素,但这不是非常推荐的做法。这是因为我们不希望使用那些“魔术式”的硬编码位置、外边距之类的设置。例如,左边距显示为 380,像这种硬编码的位置我们不想使用,我们希望它尽可能响应式。
所以,你目前需要记住的一点是,每当你在应用程序中创建一个新的图形元素时,你会看到它在 XAML 表示中作为一个新元素被写下。每个元素都有许多可以调整的属性,这一点非常重要。
在本节课中,我们将探讨 Grid 控件。首先,我想提到一点,在第八行(例如,我的代码中)你可以看到标题是 Main Window,这正是你在图形用户界面中看到的 Main Window。我们可以为其设置一个高度和宽度,比如150和800。如果我们想调整这些值,只需在这里修改数字即可。现在,应用程序的大小被固定为500像素。我只是想展示一下,你完全可以调整这些属性。
接下来,我们有一个 Grid,但目前它看起来不像一个网格。Grid 是由列和行组成的。可以把它想象成 Excel 表格,例如,你有三列,然后有三行或者五行。如果我们想要在 Grid 中创建行,我们需要添加 Column Definitions 和 Row Definitions。
首先要提到的是,我们确实有不同的标签。在这里,我们有一个 Grid 标签。这是一个开标签(open tag),与开标签对应的,我们始终有一个闭标签(closing tag),可以通过查找斜杠(/
)来识别。所以这里的 Grid 开标签,闭标签就在它之后。在它们之间,我们可以指定更多的标签。
为了设置我们的网格列定义,我们只需打开一个新的标签,创建一个开标签 Grid.ColumnDefinitions,然后自动添加闭标签。这样,我们节省了很多时间和精力。
在 Column Definitions 标签内,我们可以创建我们的列定义。让我们创建一个新的 ColumnDefinition 标签。我们可以为第一列指定一些属性。WPF 中有些文本是自闭合的,所以我们可以简化它。无需使用开闭标签,我们可以将开标签和闭标签合并,并在标签末尾加上斜杠。这样,我们就不能在标签中间添加内容,但仍然可以使用该列,并且可以为其添加一些属性。
例如,我们将该列的宽度设置为100像素。在这种方式下,我们就创建了第一列。
现在,让我们看一下应用程序的效果。虽然你可能看不出太大区别,但我们继续往下做。接下来,我们复制并重复这些列定义,总共创建三列,其中中间的列宽度设置为200像素。
当你回到设计界面,你会注意到现在有了三列,分别是第一列、第二列和第三列。
与列定义类似,我们也可以创建行定义。在 Grid 标签的同级位置,或者作为 Grid 标签的另一个子标签,我们打开 Grid.RowDefinitions 标签。闭标签会自动生成,我们点击 Enter 键后,就可以在其中创建行定义。
我们为每一行创建一个 RowDefinition 标签,并且设置它为自闭合标签。在这些行定义中,我们要设置每行的 Height,比如将第一行的高度设置为50像素。
接下来,我们复制这行定义,创建第二行,并将第二行的高度设置为100像素。
现在,让我们再次查看应用程序。你会看到有两行,第一行的高度是50像素,第二行的高度是100像素。
但是,你可能会注意到,这两行的高度并没有完全反映出我们所设置的值。例如,你可能希望看到第二行的高度是100像素,但实际显示的却比这个值大很多。问题出在 WPF 中,所有行或列的总高度或宽度并不会直接等于你在 XAML 中指定的数值。如果总和(例如,这里是150)没有匹配应用程序的总高度(例如,450像素),那么 WPF 会自动为最后一行或列分配剩余的空间。因此,最后一行的高度会填补剩余的空间,确保整体的高度和宽度与我们在应用程序中指定的值一致。
现在让我们看看如何使布局更具动态性,因为我们并不希望在很多情况下硬编码宽度和高度值(例如100像素、250像素等)。这样做在某些场景下有用,比如创建边框,但在大多数情况下,我们希望布局尽可能动态。接下来的课程中,我们将深入探讨如何使这个布局更灵活,这一点非常重要,请一定要关注!
让我们深入探讨一下 Grid 系统,以及如何使用它来定位元素,而不需要依赖任何硬编码的数值(像魔法数字一样)。在我们之前的代码中,我们已经创建了行和列,并且添加了一个 Label 控件。现在,我们将使用 Grid 系统来定位这个标签。
首先,在我们的 Label 控件中,我将删除所有属性,除了 Content 属性。现在,标签只有 Content 属性。如果我们想要定位它,可以使用 Grid.Row 属性。通过这个属性,我们可以指定标签所在的行。
例如,假设我们有两行:第一行的索引是 0,第二行的索引是 1。如果我们希望将标签放入第二行,我们只需将 Grid.Row 设置为 1。此时,标签会自动移动到第二行。
同样的方式适用于列。如果我们希望标签位于中间的列,我们可以使用 Grid.Column 属性,指定列的索引。假设我们有三列,第一列的索引是 0,第二列的索引是 1,第三列的索引是 2。如果我们想将标签放在中间的列,只需将 Grid.Column 设置为 1。这样,标签就会被放置到中间的列。
接下来,我将展示如何使用 Grid 来自动或相对地设置列和行的大小。我们将创建第三行,并将其高度设置为 50 像素,这样我们就有了三行三列。
然而,目前应用程序看起来并不像一个均匀的网格。如果我们希望中间的列和行(即包含标签 Hello World 的地方)能够自动调整大小,我们可以使用 Auto 关键字。通过在列或行的高度或宽度设置为 Auto,它将根据内容的大小自动调整。例如,如果我们调整 Hello World 的文本为更长的内容(比如改为 “Abcdefg”),那么包含该标签的列的宽度也会自动增加,以适应更长的文本。
另外,还有一个非常有用的符号是星号(*
),用于设置相对尺寸。让我们回顾一下列和行的定义:
同样的方式也适用于行。如果我们设置了第一行的高度为 50 像素,最后一行的高度也是 50 像素,那么中间的行将自动占据剩余的空间。如果我们设置总高度为 450 像素,第一行和最后一行分别占据 50 像素,那么剩下的 350 像素将会被分配给中间的行。
恭喜你,现在你已经学会了如何使用 Grid 系统来动态定位和调整元素,而不需要依赖硬编码的定位、填充或边距等数值。通过这种方法,你可以创建更灵活、响应式的布局,适应不同的屏幕大小和内容变化。
如果我们想要创建一个完美的网格布局,实际上非常简单。我们可以对每一行和每一列使用相对尺寸来实现。现在,我将为所有的列和行分配相对大小(使用星号 *
),这样每一行和每一列都会平分剩余空间,使得网格看起来更加均匀。
首先,我们为每一列设置相对大小,方法是为每一列的宽度使用 *``**,这样每列都会根据剩余空间自动调整宽度。如下所示:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
通过这种方式,我们已经为每一列分配了相同的空间,每列的宽度会根据总宽度均匀分配。
接下来,我们对每一行进行相同的设置,使用 *``** 来为每一行设置相对高度。这会让所有的行在父容器内均匀地分配垂直空间。例如:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
现在,所有的列和行都使用了相对大小 *
,使得网格中的每一列和每一行都占据均等的空间。你会看到在设计视图中,整个网格被完美地分配和对齐,每个单元格的大小相同,确保了视觉上的一致性。
通过将 *``** 用于列和行的宽度或高度,你可以非常方便地创建一个均匀分布、动态适应的网格布局,而不需要担心硬编码的尺寸。无论屏幕大小如何变化,网格都会根据剩余空间自动调整,使得布局保持响应式且灵活。这是 WPF 布局系统中非常强大和常用的特性之一。
在这一课中,我们将继续专注于布局,特别是网格布局。你将有一个挑战,尝试自己重现一个简单的 WPF 应用程序布局。这主要是为了让你对使用 XAML 创建图形用户界面变得更加熟悉。
你需要创建一个界面,其外观与我所创建的布局类似。这个练习的重点是让你习惯在 WPF 中使用网格布局(Grid)。我们暂时不关注功能实现,而是专注于布局的设计。
外部行和列:添加一些行和列来创建间距。那些外部的行和列的宽度应该设置为 10像素。
中间列:在中间增加一个新的列,并且设置这个列的宽度为 10像素。
控件添加:
Label
)。Grid.RowDefinitions
和 Grid.ColumnDefinitions
来定义网格中的行和列。Grid.Row
和 Grid.Column
来定位每个控件的位置。定义外部行列:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="10" />
<RowDefinition Height="*" />
<RowDefinition Height="10" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="10" />
</Grid.ColumnDefinitions>
</Grid>
添加控件:
示例代码:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="10" />
<RowDefinition Height="*" />
<RowDefinition Height="10" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="10" />
</Grid.ColumnDefinitions>
<!-- Label 1 -->
<Label Content="Label 1" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Label 2 -->
<Label Content="Label 2" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Button A -->
<Button Content="A" Grid.Row="1" Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Button B -->
<Button Content="B" Grid.Row="2" Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
在完成这个挑战后,你应该能够看到一个包含两个标签和两个按钮的布局,所有控件根据你在 XAML 中定义的行和列位置进行自动排列。通过使用相对布局方式,你的界面将在不同的屏幕尺寸下自适应调整,保持响应式。
如果在完成这个任务时遇到困难,可以查看下一节课,我们将从头开始一起做一遍。
在本节课程中,我们将继续探讨上一个视频中的挑战,并一起重现之前创建的布局。你将学到如何通过 XAML 使用网格布局来控制元素的位置,以及如何利用网格的属性来使布局更具响应性。
首先,我们需要通过增加更多的行和列来创建外部间距。这是为了确保我们的布局在视觉上更加整洁,并且在需要时能为控件提供足够的空间。
添加列:我们在两侧添加了新的列,并将宽度设置为 10 像素。这样可以为布局提供额外的边距。
添加行:为了创建垂直的间距,我们也在顶部和底部添加了新的行,设置高度为 10 像素。
添加列和行:
在你的 XAML 文件中,先添加更多的列和行来创建间距。代码示例如下:
<Grid>
<!-- 添加行 -->
<Grid.RowDefinitions>
<RowDefinition Height="10" />
<RowDefinition Height="*" />
<RowDefinition Height="10" />
</Grid.RowDefinitions>
<!-- 添加列 -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="10" />
</Grid.ColumnDefinitions>
</Grid>
放置控件:
Title of App
移动到中间列。Sample Text
,放置在合适的位置。以下是放置控件的 XAML 代码:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="10" />
<RowDefinition Height="*" />
<RowDefinition Height="10" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="10" />
</Grid.ColumnDefinitions>
<!-- Title Label -->
<Label Content="Title of App" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Sample Text Label -->
<Label Content="Sample Text" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Button A -->
<Button Content="A" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Button B -->
<Button Content="B" Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
调整行高度:
你可能会注意到按钮的高度较小。如果你希望按钮所在的行具有更高的高度,可以在 RowDefinition
中设置一个固定高度,如 50 像素。
<Grid.RowDefinitions>
<RowDefinition Height="10" />
<RowDefinition Height="*" />
<RowDefinition Height="50" /> <!-- 为按钮所在的行设置更大的高度 -->
</Grid.RowDefinitions>
通过以上步骤,你应该能创建出一个具有外部边距的网格布局,内部包括两个标签和两个按钮。你可以看到标题和样本文本标签自动适应列宽,并且按钮在底部对齐。
接下来,你将学到如何通过 ColumnSpan 和 RowSpan 来让控件跨越多列或多行。这样,当你增加内容时,控件能够自动扩展其宽度或高度,而不被限制在单个列或行内。
例如,如果你希望标题标签跨越多个列以适应更长的文本,可以使用 ColumnSpan
属性来实现这一点:
<Label Content="Title of App" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" HorizontalAlignment="Center" VerticalAlignment="Center" />
通过 ColumnSpan="3"
,标题标签将跨越三列,确保它能根据内容自动扩展。
通过这个练习,你应该对使用 XAML 来设计布局变得更加熟悉。你学会了如何:
Grid
控件来创建行和列布局。Grid.Row
和 Grid.Column
定位控件。在下节课中,我们将继续深入探讨如何使用 ColumnSpan 和 RowSpan 来进一步提升布局的灵活性。
在本节课程中,我们将继续深入学习 Grid
系统,并探讨如何使用 ColumnSpan 和 RowSpan 来让控件跨越多列或多行。这样做可以使控件在网格中占据更多的空间,适应不同大小的内容。
在 Grid
布局中,控件默认仅占据一个单一的行和列。然而,如果我们希望某些控件(如文本)在视觉上跨越多个列或行,我们可以使用 ColumnSpan 或 RowSpan 属性。
我们以示例文本 Sample Text
为例来说明如何设置跨越。
计算列和行的跨度:
Sample Text
标签跨越从第二列到第四列(即从中间列到右侧列),我们就需要设置它的跨度。0
、1
、2
和 3
。Sample Text
所在的列是第二列(Column 2
),并且我们希望它横跨到第四列。ColumnSpan
的值应该设置为 3
(即跨越三列:从列 2
到列 4
)。设置 ColumnSpan:
在 XAML 中,我们可以通过设置 Grid.ColumnSpan
来实现这一点。例如:
<Label Content="Sample Text" Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" HorizontalAlignment="Center" VerticalAlignment="Center" />
在这个例子中:
Grid.Row="2"
:表示 Sample Text
位于第三行(行从零开始编号,所以 Row 2
是第三行)。Grid.Column="1"
:表示 Sample Text
从第二列开始(列从零开始编号,所以 Column 1
是第二列)。Grid.ColumnSpan="3"
:表示该标签跨越三列,从第二列到第四列。增加文本内容来观察效果:
如果我们将 Sample Text
的内容增加,文本长度变长,跨越的列也会相应增加。例如,如果你复制并增加文本内容,Sample Text
标签将继续跨越更多的列,直至达到网格的边缘。
例如,增加文本后,跨越的列范围可能变为从第三列到第五列,这样就会占据更多空间。
在 XAML 代码中应用 ColumnSpan
后,你会发现 Sample Text
标签已经跨越了多列。通过增加内容并调整标签的宽度,你可以看到该标签的宽度和跨度自动调整。
当你运行应用时,界面会呈现一个干净的布局,去除了辅助线,最终呈现的应用看起来如下:
Sample Text
标签跨越了三列(通过设置 ColumnSpan="3"
)。这就是你使用 Grid
系统创建的布局,效果非常不错!
通过本节课程,你已经学会了如何使用 ColumnSpan 和 RowSpan 来调整控件的布局,使其能够跨越多个列或行。你也学会了如何通过这些属性来动态调整控件的大小和位置,确保它们适应不同的内容大小。
接下来,我们将进入下一阶段,开始学习如何结合 C# 编程来为你的应用程序添加功能。
祝贺你完成了网格系统的学习!这是一个非常好的起步,接下来让我们继续探索更多精彩的内容。
在 WPF 中,你不仅可以使用 XAML 来定义界面元素,还可以通过 C# 代码动态地创建和操作这些元素。今天我们将介绍如何在 C# 中使用代码创建、设置位置并将元素添加到布局中。
在本节中,我们将展示如何在 C# 中创建一个按钮并将其动态添加到 Grid
中。假设我们已经在 XAML 中有一个网格布局,现在我们需要通过 C# 动态创建按钮,并将其定位到特定的行和列。
首先,我们将重置窗口布局,确保我们可以重新开始,并且能够访问 MainWindow
中的所有文件。
this.ResetLayout();
通过此步骤,我们将移除所有按钮,并仅保留 row 3, column 4
中的内容。
接下来,我们将在 MainWindow
的代码后文件中创建一个按钮。首先进入代码后文件(即 MainWindow.xaml.cs
),并在构造函数中开始编写代码。
在 C# 中,我们首先需要创建一个按钮实例。每当我们创建一个控件时,都需要使用 C# 的面向对象方式进行实例化。例如:
Button myButton = new Button();
这样,我们就创建了一个新的按钮对象 myButton
。
按钮的内容可以通过设置 Content
属性来进行定义。这个属性定义了按钮上显示的文本或控件内容。例如:
myButton.Content = "A";
这样,按钮上就会显示文本 "A"。
接下来,我们需要将按钮添加到 Grid
布局中的特定位置。在 XAML 中,我们通常使用 Grid.Row
和 Grid.Column
来指定行和列,而在 C# 中,我们可以通过调用 SetRow
和 SetColumn
方法来进行相同的操作:
Grid.SetRow(myButton, 3); // 将按钮放置在第三行
Grid.SetColumn(myButton, 4); // 将按钮放置在第四列
Grid
控件为了将按钮添加到布局中,我们需要获取到 Grid
控件的引用。在 XAML 中,我们可以给控件指定一个名称,这样我们就能通过该名称在 C# 中访问它。例如,我们可以为 Grid
添加一个 x:Name
属性:
<Grid x:Name="myGrid">
<!-- 网格内容 -->
</Grid>
然后,在 C# 中使用 FindName
方法来查找这个控件,并将其转换为 Grid
类型:
Grid myGrid = (Grid)this.FindName("myGrid");
FindName
方法返回一个 object
类型,所以我们需要将其转换为 Grid
类型,以便能够使用它的特定方法。
Grid
中现在,我们有了 Grid
的引用,可以使用 Children.Add
方法将按钮添加到 Grid
的子控件集合中:
myGrid.Children.Add(myButton);
通过这一步,按钮就被成功添加到 Grid
中,并且位于我们之前设置的行和列位置。
将上述步骤结合起来,完整的 C# 代码如下:
public MainWindow()
{
InitializeComponent();
// 创建按钮
Button myButton = new Button();
myButton.Content = "A"; // 设置按钮内容
// 设置按钮的位置
Grid.SetRow(myButton, 3);
Grid.SetColumn(myButton, 4);
// 查找网格并将按钮添加到网格中
Grid myGrid = (Grid)this.FindName("myGrid");
myGrid.Children.Add(myButton); // 将按钮添加到网格
}
在运行应用时,按钮将出现在 Grid
布局中的指定位置(第3行第4列),就像我们在 XAML 中定义的那样。
Grid
)中。通过这节课,你已经学会了如何在 C# 中动态创建和管理 WPF 元素,并将它们添加到布局中。这为你将来开发更复杂的动态应用提供了强大的工具。
在 WPF 中,我们可以通过 XAML 或 C# 来创建和调整 UI 元素。例如,按钮、标签和其他控件都有许多可以自定义的属性,比如字体大小、颜色、文本样式等。如果我们想要快速查看这些属性并进行调整,最简单的方法之一是使用 属性窗口。
重置窗口布局
如果你没有看到属性窗口,可以通过点击 Window
菜单中的 Reset Window Layout
来恢复默认的布局。
选择元素
选择你想查看或调整的控件,比如按钮。你可以点击控件,确保它被选中。
查看属性
在右侧的属性窗口中,你将看到当前选中控件的所有属性。例如,如果你选中了一个按钮,你可以看到它的属性,如字体大小、文本样式、颜色等。
Font Size
属性。通过调整该属性的值,你会看到按钮的文本大小会发生变化。例如,将 Font Size
设置为 40
,文本就会变得更大。
这时,XAML 中的
FontSize
属性也会更新为40
,表示你通过属性窗口调整了这个值。
在属性窗口中,你还可以找到 Font Weight 属性。如果你勾选了 Bold
,按钮的文本将变为加粗。如果你取消勾选,它将恢复为常规字体。
通过属性窗口调整时,XAML 中的
FontWeight
属性会相应地改变,变为Bold
或Normal
。
对齐方式
你可以设置按钮文本的水平和垂直对齐方式,比如 HorizontalAlignment
和 VerticalAlignment
。这将决定文本在按钮内的位置。例如,设置 HorizontalAlignment
为 Center
可以使文本水平居中。
颜色设置
属性窗口允许你轻松地设置按钮的背景色。例如,选择 Background
属性,你可以设置按钮的颜色,像是 Red
或 Blue
。这在 UI 设计中非常有用。
布局设置
你还可以设置控件的 宽度 和 高度。如果设置为 Auto
,控件的大小将自动调整以适应其内容。同时,你还可以设置 Margin 和 Padding 来控制控件周围的空间。
透明度
你可以通过 Opacity 属性调整控件的透明度,使控件变得更透明或完全不透明。
行列设置
对于布局在 Grid
中的控件,属性窗口会显示该控件所在的 Row 和 Column 位置,甚至可以设置 RowSpan 和 ColumnSpan 属性,来让控件跨越多个行或列。
无论是通过 XAML 编写还是通过属性窗口调整,所有的这些属性最终都会映射到 XAML 或 C# 代码中。例如,在属性窗口中调整了按钮的 FontSize
或 Background
后,XAML 文件中相应的属性会自动更新。因此,了解如何在属性窗口中调整这些属性,可以帮助你更快地理解和操作 XAML 或 C# 代码中的相应属性。
FontSize
属性,你可以在 XAML 中看到类似 <Button FontSize="40">
的变化,或者在 C# 中通过 myButton.FontSize = 40
来设置。通过使用属性窗口,你可以更直观地了解 WPF 元素的可调整属性,从而提高开发效率并更好地控制界面设计。
在本视频中,我们将学习如何创建一个 按钮事件处理程序,即当用户点击按钮时,执行某些操作。我们还将看到如何使用 MessageBox 显示信息。
我们首先移除代码后端文件中的第二个按钮,以简化操作。这是一个简单的测试项目,我们只需要一个按钮来演示。
每个 WPF 控件(如按钮)都可以触发一些事件。在这个例子中,我们使用的是按钮的 Click
事件。当用户点击按钮时,这个事件会被触发,并执行相应的代码。
自动生成事件处理方法
当你双击事件图标时,Visual Studio 会自动为你创建一个新的方法。该方法通常会命名为 Button_Click
或类似的名称,这个方法会在按钮被点击时执行。
查看事件处理方法
事件处理方法的代码会如下所示:
private void Button_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Hello World");
}
sender
参数指向触发事件的对象(在这个例子中是按钮)。RoutedEventArgs
是与事件相关的其他信息,在这里不需要特别关注。在事件处理方法中,我们通过 MessageBox.Show("Hello World")
来显示一个消息框,通知用户按钮已被点击。
启动应用程序
现在,我们启动应用程序并点击按钮,应该能看到一个消息框弹出,显示 "Hello World"。
修改事件名称
如果我们将事件处理程序的方法名称修改为 Button_Click2
,例如:
private void Button_Click2(object sender, RoutedEventArgs e)
{
MessageBox.Show("Hello World");
}
然后重新运行应用程序,会发现编译时出现错误,因为在按钮的 Click
事件中,我们仍然引用了旧的 Button_Click
方法,而该方法已被重命名为 Button_Click2
。
解决方法:我们需要手动更新 XAML 文件,确保按钮的 Click
事件指向正确的方法名,例如:
<Button Content="Click Me" Click="Button_Click2" />
然后,重新启动应用程序,这时点击按钮应该会正确显示消息框。
事件的使用
事件处理程序是用户界面交互的核心。当用户点击按钮、输入文本框或执行其他操作时,我们可以通过事件处理程序来响应这些操作。
例如,你可以创建一个事件处理程序来处理按钮点击、文本框输入、删除操作等。在这些事件触发时,相应的事件处理程序就会执行。
通过这种方式,我们可以让用户与应用程序进行交互,并通过按钮点击等事件执行相应的代码。通过为控件(如按钮)添加事件处理程序,我们能够使我们的应用程序具有更强的交互性。这是构建任何应用程序的基本要素之一。
现在,我们已经掌握了如何处理按钮的点击事件,接下来我们可以开始着手创建我们的第一个简单应用程序,开始实现更有趣的功能。
在这一课中,我们将开始构建一个简单的 待办事项应用程序,用来巩固你在 WPF 中学到的知识,同时也会学习一些新的概念。应用的功能很简单,我们可以输入待办事项,并将它们添加到列表中,显示在一个可滚动的文本框里。
首先,创建一个新的 WPF 应用程序。步骤如下:
TodoApp
。调整窗口大小
打开 XAML 文件,在 MainWindow.xaml
中设置窗口宽度。比如:
<Window x:Class="TodoApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="450" Width="400">
这里我们将窗口宽度设置为 400。
设置应用程序名称
在同一文件中,修改 Title
属性为 Todo app
,以便窗口显示正确的名称。
禁用窗口调整大小
如果你不希望用户调整窗口大小,可以在 XAML 文件中设置 ResizeMode
为 NoResize
,这样用户就无法拖动窗口边缘来改变大小:
<Window x:Class="TodoApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Todo app" Height="450" Width="400"
ResizeMode="NoResize">
测试应用程序
启动应用程序,你会发现窗口不能调整大小、最大化或最小化。
现在你已经设置好了应用的基础,可以开始创建待办事项应用的布局。在下一课中,我们将继续创建 UI 组件,比如输入框、按钮和显示待办事项的区域。
这就是我们开始构建待办事项应用的第一步,接下来我们会创建更详细的布局和交互功能,继续你的 WPF 学习之旅!
在这一课中,我们将继续构建我们的 待办事项应用程序,并重点关注布局的设置。接下来,我们会利用你之前学到的知识,创建网格系统,并在其上添加各种界面元素。
首先,我们有一个空的 Grid 元素。通过设置 Grid 的列和行定义,我们可以构建应用的布局。
定义列和行
在 XAML 文件中,我们首先定义列和行的布局。我们将创建 3 列和 6 行,其中两列的宽度设置为固定大小,另一列和几行的大小设置为自适应。代码如下:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="10"/>
<RowDefinition Height="*"/>
<RowDefinition Height="75"/>
<RowDefinition Height="10"/>
<RowDefinition Height="50"/>
<RowDefinition Height="10"/>
</Grid.RowDefinitions>
</Grid>
列定义:
*
),占用剩余空间。行定义:
接下来,我们将添加按钮和文本框到布局中。
添加按钮
我们创建一个按钮,让用户可以点击添加新的待办事项。将其放置在第一列和第五行中:
<Button Grid.Row="4" Grid.Column="0" Content="Create Todo" />
这会创建一个位于左上角的按钮,标签为 "Create Todo"。我们稍后将为此按钮添加事件处理。
添加文本框
然后我们添加一个文本框,让用户可以输入待办事项的内容。将其放置在第一列和第三行中:
<TextBox Grid.Row="2" Grid.Column="0" />
这样文本框就会出现在中间的区域,用户可以在其中输入待办事项。
为了确保界面友好和可用,我们需要调整文本框的背景色和前景色(文本颜色):
设置背景色
在右侧的属性窗口中,选择 TextBox,然后设置背景色。我们可以设置为深色背景,以便与文本框的白色文本区分开:
<TextBox Grid.Row="2" Grid.Column="0" Background="#333" />
设置前景色
为了确保文本可见,我们将前景色设置为白色:
<TextBox Grid.Row="2" Grid.Column="0" Background="#333" Foreground="White" />
现在,文本框有了深色的背景和白色的文本,用户可以清晰地看到他们输入的内容。
为了显示待办事项列表,我们需要使用 ScrollViewer 和 StackPanel。
添加滚动视图
使用 ScrollViewer 来包装待办事项列表,允许用户滚动查看所有待办事项。如果待办事项过多,它们将超出视窗时,用户可以滚动查看:
<ScrollViewer Grid.Row="1" Grid.Column="0">
<StackPanel>
<!-- 待办事项项将放在这里 -->
</StackPanel>
</ScrollViewer>
这里,ScrollViewer 使得内部的 StackPanel 可滚动。待办事项将以垂直排列的方式显示在 StackPanel 中。
添加待办事项
我们会在后续使用 C# 代码动态地将待办事项添加到 StackPanel 中。在此之前,我们只需确保布局设置正确。
现在,应用程序的主要布局已经完成。你可以启动应用程序,检查界面布局是否符合预期:
到此为止,我们已经完成了应用的基本布局:包括网格定义、按钮、文本框、滚动视图和堆叠面板。接下来,我们将继续添加事件处理程序,并编写 C# 代码来处理待办事项的增删改查。
在这一课中,我们将继续构建待办事项应用程序,专注于添加滚动视图和堆叠面板。这两个元素将使我们能够展示动态生成的待办事项列表。
我们需要一个滚动视图来确保当待办事项超过显示区域时,用户可以滚动查看所有的待办事项。首先,打开左侧的工具箱,查看可用的控件。你会找到 ScrollViewer(滚动视图),这是我们需要的元素。
添加 ScrollViewer
双击工具箱中的 ScrollViewer,它会自动添加到 XAML 文件中。你也可以直接手动输入来添加它。
<ScrollViewer Grid.Row="1" Grid.Column="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- 待办事项将添加到这里 -->
</StackPanel>
</ScrollViewer>
StackPanel 是一个非常适合垂直排列元素的容器,正好适用于我们的待办事项列表。我们将其放在 ScrollViewer 内部,这样它就能在滚动视图中垂直排列所有待办事项。
<StackPanel>
<TextBlock Text="待办事项 1" />
<TextBlock Text="待办事项 2" />
<!-- 更多待办事项 -->
</StackPanel>
每个待办事项都将是一个 TextBlock 元素,自动堆叠在一起,形成一个垂直列表。你可以进一步设置样式、背景颜色等,但目前我们先保持简单。
如果你希望对滚动视图和堆叠面板进行样式调整,可以通过设置背景颜色来改变它们的外观。例如,可以为 StackPanel 设置一个浅灰色的背景,或者使用其他颜色来符合你的设计风格。
设置 StackPanel 背景色
选择 StackPanel 元素,然后在属性窗口中选择 Background。你可以选择一种颜色,比如浅灰色:
<StackPanel Background="#f0f0f0">
<!-- 待办事项列表 -->
</StackPanel>
选择合适的颜色
你可以选择不同的颜色,例如深色、蓝色或其他喜欢的颜色。重要的是要确保颜色与其他界面元素区分开来,避免用户混淆。
完成布局后,我们可以启动应用程序,查看界面的效果。此时,滚动视图和堆叠面板应该会显示在应用窗口中,虽然它们还没有实际的内容。
为了能够在后台代码中访问这些元素并添加实际的功能,我们需要为 TextBox 和 StackPanel 元素设置 Name 属性。这样,我们就能从 C# 代码中访问它们,并动态地向界面添加待办事项。
为 TextBox 设置名称
在 XAML 文件中,为 TextBox 添加一个名称属性,这样我们就可以在代码中访问它:
<TextBox Name="todoInput" Grid.Row="2" Grid.Column="1" />
为 StackPanel 设置名称
同样地,我们为 StackPanel 添加名称,以便在 C# 中访问它:
<StackPanel Name="todoListPanel" Background="#f0f0f0">
<!-- 待办事项项 -->
</StackPanel>
在接下来的课程中,我们将开始编写 C# 代码,处理按钮点击事件,从 TextBox 中读取内容,并将新的待办事项作为 TextBlock 添加到 StackPanel 中。
到目前为止,我们已经成功添加了滚动视图和堆叠面板,并配置了它们的基本样式和布局。接下来,我们将通过 C# 代码实现待办事项的动态添加功能,进一步完善我们的应用程序。
现在我们已经创建了整个布局,并包含了所有必要的元素,接下来我们将进入下一步:为这些元素设置 Name 属性,以便能够在后端代码(C# 文件)中访问它们。这一操作对于后续实现应用功能非常关键,特别是当我们希望通过用户点击按钮时,读取输入框的内容并动态更新待办事项列表。
我们首先需要为 Add To Do 按钮添加一个点击事件处理器。每当用户点击按钮时,我们希望从文本框中读取内容,并将新的待办事项作为 TextBlock 添加到堆叠面板中。
创建事件处理器
在按钮的 XAML 中,我们不需要手动设置 X:Name
属性来访问它,而是直接为其创建一个点击事件处理器。在按钮的 Click 事件中,添加一个自定义的事件处理函数名称。
在 XAML 中设置按钮点击事件:
<Button Content="Add To Do" Click="AddToDoButton_Click" />
在 C# 后台代码中创建事件处理器
接着,我们需要在后台的 C# 代码中实现这个事件处理器。在 C# 文件中,创建一个方法 AddToDoButton_Click,该方法将会在按钮被点击时执行。
private void AddToDoButton_Click(object sender, RoutedEventArgs e)
{
// 在这里处理按钮点击后的逻辑
}
为了在后台代码中访问 TextBox,我们需要为其设置一个 X:Name 属性,这样就可以通过该名称从 C# 代码中访问到它。
设置 TextBox 名称
我们为文本框设置一个易于识别的名称,像 ToDoInput
这样。这样,在后台代码中,我们就可以通过该名称来获取文本框的内容。
<TextBox x:Name="ToDoInput" />
访问 TextBox 内容
在 C# 代码中,点击按钮时,我们可以通过 ToDoInput
来访问文本框的内容:
string todoText = ToDoInput.Text;
为了将新的待办事项(TextBlock 元素)添加到堆叠面板中,我们需要为堆叠面板设置一个 X:Name 属性。这样,我们就可以在后台代码中引用并修改该堆叠面板。
设置 StackPanel 名称
给堆叠面板设置一个名称,比如 ToDoList
,以便我们能够从后台代码中访问它。
<StackPanel x:Name="ToDoList" />
访问 StackPanel
在 C# 代码中,我们可以通过 ToDoList
直接访问堆叠面板,并将新的 TextBlock 添加到其中:
ToDoList.Children.Add(new TextBlock { Text = todoText });
到目前为止,我们已经完成了以下工作:
在下一节课中,我们将实现主要的功能:当用户点击按钮时,我们将读取文本框中的内容,并创建一个新的 TextBlock,然后将其添加到堆叠面板中。这样,我们就能动态展示用户输入的待办事项了。
通过这一过程,我们可以逐步完善待办事项应用程序的核心功能,最终实现一个可以输入、显示和管理待办事项的应用程序。
现在我们已经完成了布局和基本的设置,接下来让我们开始实现应用程序的主要功能。首先,我想指出,我的开发方式是先设计应用程序的外观和功能,然后实现它们。在设计过程中,我会考虑应用程序的需求和功能,再决定如何实现。对于我来说,这种方式很有效,因为我可以清楚地知道每个元素的作用并确保每个功能正确实现。当然,你也可以采用不同的方法,找到最适合你的开发方式。
我们首先要实现的功能是,当用户点击“创建待办事项”按钮时,从文本框中获取用户输入的内容,并将其添加到堆叠面板(StackPanel)中。
当用户在 TextBox 中输入内容并点击按钮时,我们需要从文本框中获取文本。假设我们已经在 XAML 中为文本框命名为 ToDoInput
,在 C# 后台代码中,可以通过以下方式读取文本框的内容:
string todoText = ToDoInput.Text;
接下来,我们需要确保只有当文本框中有内容时,才会添加新的待办事项。如果文本框为空,或者用户没有输入任何内容,我们将不会添加任何待办事项。
为了处理这种情况,我们可以使用以下代码检查文本框内容是否为空:
if (string.IsNullOrEmpty(todoText))
{
// 如果文本框为空,直接返回,不做任何操作
return;
}
如果文本框中有内容,我们就可以创建一个新的 TextBlock 元素,并将其添加到 StackPanel 中,堆叠显示在页面上。
首先,我们创建一个新的 TextBlock,并将其 Text 属性设置为从文本框获取的内容:
TextBlock todoItem = new TextBlock();
todoItem.Text = todoText;
创建了待办事项之后,我们需要将其添加到 StackPanel 中。在 StackPanel 中,所有的元素都会被添加到 Children 集合中,所以我们可以直接将 TextBlock 添加到 Children 集合中:
ToDoList.Children.Add(todoItem);
为了让用户体验更流畅,我们在每次添加待办事项之后,需要清空文本框,以便用户可以继续输入下一个待办事项:
ToDoInput.Clear();
到目前为止,我们已经实现了获取输入、创建待办事项并将其添加到堆叠面板的功能。为了验证这一点,我们可以先启动应用程序,尝试输入待办事项并点击“创建待办事项”按钮。每当我们点击按钮时,待办事项应该会被添加到堆叠面板中,且文本框会被清空。
在应用程序功能基本完成后,我们可以进行一些样式和布局上的调整。比如,添加一些 Margin 来增加文本的间距,确保界面看起来不那么拥挤,或者调整 TextBlock 的颜色,使得文字更加可读。
为了让每个待办事项之间有一定的间隔,我们可以为 TextBlock 设置边距。我们可以使用 Thickness
结构体来定义边距:
todoItem.Margin = new Thickness(10);
为了提高可读性,我们可以将文本颜色设置为白色。可以通过 Foreground
属性来实现:
todoItem.Foreground = new SolidColorBrush(Colors.White);
随着待办事项数量的增加,可能会超出可视区域。为了处理这种情况,我们需要确保 ScrollViewer 能够在内容超出时显示滚动条。
我们可以输入足够多的待办事项,检查滚动条是否出现。当内容超出界面时,滚动条应该会自动出现,用户可以通过滚动条查看所有待办事项。
到目前为止,我们已经成功实现了一个简单的待办事项列表应用,具有以下功能:
现在你已经成功实现了基本的待办事项列表应用,接下来可以进行一些个性化的修改,比如改变界面的配色、添加标题或者描述,甚至可以进一步扩展应用,增加更多功能,例如删除待办事项或保存待办事项到文件中。
在下一课中,我们将继续深入探索 WPF,学习更多关于布局、控件和事件处理的技巧。
希望你在实现这个应用的过程中获得了乐趣,继续尝试并实验不同的功能和界面设计,提升你的开发技能!
你已经成功构建了一个简单的待办事项列表应用,这是一个很好的起点。但在现实开发中,大多数应用程序并不那么简单,不论是 Web 应用、WPF 应用,还是移动应用,通常都需要更多的功能。例如,大多数网站在你访问时都需要你先登录,才能使用其服务。比如,使用 YouTube 或 Facebook 时,你需要先登录才能访问你的账户和个性化内容。
这意味着,在很多情况下,我们的应用会有多个视图。例如:
因此,如何在应用程序中切换视图和管理不同的用户界面变得非常重要。今天,我们将开始探讨如何在 WPF 中实现这一功能,尤其是通过 ContentControl 和 UserControl 来实现视图切换。
在 WPF 中,ContentControl
是一种可以容纳其他元素的控件。它允许我们将不同的视图嵌入到应用程序的同一个窗口中,并根据需要动态切换它们。通过使用 ContentControl
,你可以灵活地改变显示的内容,这对于处理登录、主页或其他视图非常有用。
UserControl
是另一种非常重要的控件,它可以帮助我们将复杂的界面分解为更小、更可重用的组件。通过使用 UserControl
,你可以将某一部分界面独立出来,进行模块化设计。比如,你可以将登录界面和主页分别设计为两个 UserControl,然后通过切换 ContentControl 来切换显示哪个 UserControl。
接下来,我们将创建两个视图——登录视图和主页视图,并实现视图的切换。
首先,我们创建一个新的 UserControl,名为 LoginView.xaml
。这个视图包含一个登录表单,用户可以在其中输入用户名和密码。
<UserControl x:Class="WpfApp.LoginView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="200" Width="300">
<StackPanel>
<TextBox Name="Username" Width="200" Margin="10" PlaceholderText="Username" />
<PasswordBox Name="Password" Width="200" Margin="10" PlaceholderText="Password" />
<Button Content="Login" Width="200" Margin="10" Click="LoginButton_Click" />
</StackPanel>
</UserControl>
接下来,我们再创建一个新的 UserControl,名为 HomeView.xaml
。这是用户登录后将看到的主页。
<UserControl x:Class="WpfApp.HomeView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="400" Width="600">
<StackPanel>
<TextBlock Text="Welcome to the homepage!" FontSize="24" Margin="20"/>
<!-- 其他主页内容 -->
</StackPanel>
</UserControl>
在主窗口中,我们使用 ContentControl 来显示这两个视图。当用户点击登录按钮时,我们将切换显示的内容。以下是 MainWindow.xaml 的示例:
<Window x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF App" Height="450" Width="800">
<Grid>
<ContentControl Name="MainContent" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Window>
在 MainWindow.xaml.cs 中,我们将根据用户的操作切换不同的视图:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
MainContent.Content = new LoginView(); // 默认显示登录视图
}
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
// 验证用户名和密码,如果成功则切换到主页
MainContent.Content = new HomeView();
}
}
通过使用 ContentControl 和 UserControl,你可以轻松地在 WPF 中实现视图切换,进而实现类似于登录、主页等不同功能页面的展示。这个方法使得你的应用程序更加模块化和灵活,同时也为未来增加更多功能奠定了基础。
在接下来的课程中,我们将继续探索 WPF 的更多功能,学习如何更深入地定制界面和处理复杂的用户交互。
首先,我们回到 WPF 演示项目。项目中目前没有内容,可以将之前的内容全部删除,保留 Grid 控件就可以了。你可以返回你的旧项目,删除其中的所有内容,保持 Grid 即可。记住要删除代码后台文件中的所有内容。这个项目的目的是创建一个能够在不同视图之间切换的应用程序。
在我们的 WPF 应用程序中,我们的目标是实现一个视图切换的功能。具体来说,当用户点击“登录”按钮后,切换到另一个视图,并显示相应的内容。我们关注的是如何通过 ContentControl 和 UserControl 来动态切换显示的内容,而不仅仅是单纯的更改某个控件的内容。例如,你可以在界面上显示一个 TextBox
、StackPanel
或其他任何控件,重点是在于如何实现视图之间的切换。
我们首先创建一个新的控件,称为 ContentControl,它能够控制和显示应用程序的实际内容。为了便于后续操作,给这个控件命名为 mainContent
:
<ContentControl x:Name="MainContent" />
ContentControl 用于容纳不同的内容。你可以根据应用的复杂性,使用多个 ContentControl 来分别显示不同的内容。例如,假设你需要收集用户的个人信息和教育历史等数据,你可以将这两个部分分别放在不同的 ContentControl 中。这样你就能够更灵活地管理和展示内容。
对于简单的应用,可以仅使用一个 ContentControl 来管理内容。通过动态设置其 Content
属性,可以控制显示什么内容。
一旦定义了 ContentControl,我们可以在代码后台访问它并设置其内容。你可以在 MainWindow.xaml.cs 中访问 MainContent
控件并设置其内容:
MainContent.Content = new LoginView();
这里的 LoginView
是我们将要创建的第一个 UserControl。
接下来,我们将创建一个新的 UserControl,用于显示登录界面。按照以下步骤操作:
Add
-> New Item
。UserControl
,选择 WPF UserControl
。LoginView.xaml
,然后点击添加。创建完成后,你会看到一个新的文件 LoginView.xaml
,它已经继承了 UserControl 类。现在我们可以在这个 UserControl 中添加登录表单或任何其他控件。
下面是 LoginView.xaml
的基本示例:
<UserControl x:Class="WpfApp.LoginView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="200" Width="300">
<StackPanel>
<TextBox Name="Username" Width="200" Margin="10" PlaceholderText="Username" />
<PasswordBox Name="Password" Width="200" Margin="10" PlaceholderText="Password" />
<Button Content="Login" Width="200" Margin="10" Click="LoginButton_Click" />
</StackPanel>
</UserControl>
最后,我们将在 MainWindow.xaml 中使用 ContentControl,并在代码后台通过设置 MainContent.Content
为新的 LoginView
来显示它。
<Window x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF App" Height="450" Width="800">
<Grid>
<ContentControl Name="MainContent" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Window>
在 MainWindow.xaml.cs
中,设置 MainContent.Content
为新创建的 LoginView
:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
MainContent.Content = new LoginView(); // 显示登录视图
}
}
通过使用 ContentControl 和 UserControl,我们可以轻松地在 WPF 中实现视图切换,从而显示不同的用户界面内容。在这个例子中,我们展示了如何实现登录视图,并通过 ContentControl
动态切换不同的视图。在接下来的课程中,我们将继续深入探讨如何设计更加复杂的视图,并处理更多的用户交互。
我们已经创建了第一个用户控件 LoginView
,现在我们来设计它的界面。当前如果你直接启动应用程序,你会发现 MainWindow
中并没有显示任何内容,尽管我们已经创建了 ContentControl
和 UserControl
。这意味着我们需要为 LoginView
用户控件添加界面元素,并通过设计来优化它的显示效果。
在 LoginView.xaml
中,你会看到与 MainWindow.xaml
不同的属性。在 MainWindow.xaml
中,我们有 Height
和 Width
,而在 LoginView.xaml
中,则有 DesignHeight
和 DesignWidth
属性。这些属性仅在设计时起作用,帮助我们在设计界面时进行布局调整,但不会影响应用程序的实际运行尺寸。例如,如果我们设置设计宽度为 450 和设计高度为 800,运行时应用的大小会根据窗口的实际设置进行调整。
接下来我们使用 Grid 布局来设计我们的登录视图。首先,我们需要定义列和行,设置它们的尺寸。
Auto
,另外两个使用相对大小(*
)。代码如下:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
</Grid>
接下来,我们在 LoginView.xaml 中实现这些界面元素。
在 Grid 中添加一个 Label 控件,设置其文本为 "Login":
<Label Content="Login"
Grid.Row="0" Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="20" />
在 Grid 中添加一个 Button,设置其内容为 "Login":
<Button Content="Login"
Grid.Row="2" Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Click="LoginButton_Click" />
在这里,Grid.Row
和 Grid.Column
用于确定控件的位置。我们将 Label 放置在第一行和第二列,Button 放置在第三行和第二列。HorizontalAlignment
和 VerticalAlignment
用来将控件居中对齐。
为了使按钮不显得过大,我们可以调整 RowDefinitions 中的高度值。比如将按钮所在的行高度设置为 Auto
,并为上方的行设置一个较小的高度:
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
这样,按钮就不会占据太大的空间,而文本和按钮之间会有适当的间距。
经过这些步骤后,LoginView.xaml
会显示一个登录框,包括一个标题和一个登录按钮。此时,界面会看起来比较简洁,但你可以根据需要随时进行调整,比如增加更多的控件或修改样式。
接下来,我们需要为 Login 按钮创建一个点击事件处理程序。点击按钮时,我们希望触发某些操作,比如更改 MainWindow
中的内容(例如跳转到另一个视图)。这个操作将在下一个教程中进行实现。
在 LoginView.xaml.cs 中,你可以为按钮创建一个事件处理程序,例如:
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
// 登录逻辑,或者切换视图
// 例如,设置 MainWindow 的 ContentControl 内容为另一个视图
}
通过这些步骤,我们已经完成了登录界面的初步设计。现在,LoginView
包含了一个标题和一个按钮,按钮点击后会触发事件。接下来的工作是处理按钮点击事件,修改 MainWindow
中的内容,切换到不同的视图。在接下来的课程中,我们将继续深入探讨如何处理这些交互,并完成应用的功能实现。
在之前的步骤中,我们已经设计了登录视图(LoginView
)并添加了登录按钮。接下来,我们将为该按钮添加一个点击事件处理程序。虽然可以直接在 XAML 中添加事件处理器,但我们将手动设置它,这样可以更加灵活。
在 LoginView.xaml
中,找到登录按钮并为其添加 Click
事件。这里,我们不通过 XAML 中的事件属性来添加事件处理器,而是直接在代码中设置它:
LoginView.xaml
中,我们将按钮的 Click
事件与事件处理器关联:<Button Content="Login"
Grid.Row="2" Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Click="LoginButton_Click"/>
LoginView.xaml.cs
中,我们定义事件处理器:private void LoginButton_Click(object sender, RoutedEventArgs e)
{
// 在这里处理登录按钮的点击事件
}
通过这种方式,我们将事件处理程序 LoginButton_Click
与按钮的点击事件进行了绑定。
接下来,我们希望当应用程序启动时,显示 LoginView
作为起始界面。我们已经创建了一个 ContentControl
,现在我们将通过 MainWindow.xaml
来显示 LoginView
。
在 MainWindow.xaml
中,我们有一个 ContentControl
,它用于显示不同的用户控件。我们可以通过访问该控件的 Content
属性来改变显示的内容。
在 MainWindow.xaml.cs
的代码后面,我们通过设置 MainContent
的 Content
属性来显示 LoginView
。具体操作如下:
public MainWindow()
{
InitializeComponent();
// 设置 ContentControl 的内容为 LoginView
MainContent.Content = new LoginView();
}
这段代码会在应用程序启动时,创建一个 LoginView
的新实例,并将其作为内容添加到 MainWindow
的 ContentControl
中。因此,用户启动应用程序时会看到 LoginView
界面。
现在,我们需要为登录按钮的点击事件添加逻辑。点击登录按钮后,我们希望切换到另一个视图,这个视图将展示发票数据(InvoiceView
)。
InvoiceView
用户控件创建新的用户控件: 右键点击项目,在“添加”菜单中选择“新建项”,然后选择 User Control,将其命名为 InvoiceView.xaml
。
设计 InvoiceView
界面: 类似于 LoginView
,我们为 InvoiceView
设计所需的控件。假设我们展示发票数据,可以包含一些数据绑定和样式设置。
修改 LoginButton_Click
事件:
当用户点击登录按钮时,我们希望将 MainWindow
的内容切换为 InvoiceView
。在 LoginView.xaml.cs
中,我们为登录按钮的点击事件编写代码,如下所示:
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
// 创建 InvoiceView 的新实例
InvoiceView invoiceView = new InvoiceView();
// 切换 MainWindow 的内容
var mainWindow = Application.Current.MainWindow as MainWindow;
mainWindow.MainContent.Content = invoiceView;
}
为了使登录界面看起来更加友好,我们可以稍微调整 LoginView
中的 Label
,将其文本从 "Login" 更改为 "Please Login",让它与按钮文本区分开来。这样可以使界面更加美观。
修改后的代码如下:
<Label Content="Please Login"
Grid.Row="0" Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="20" />
LoginButton
添加了点击事件处理程序。MainWindow.xaml
中设置 ContentControl
来显示 LoginView
。LoginButton_Click
事件添加了逻辑,使得点击登录按钮后,可以切换到新的视图(例如 InvoiceView
)。LoginView
中的 Label
文本,以改善界面的外观。通过这些步骤,我们完成了用户从登录视图到发票视图的基本流程,并为将来的功能扩展打下了基础。接下来,我们将继续完善发票视图和其他逻辑。
现在,我们将开始设置第二个用户控件,这个控件名为 InvoiceView,用于展示一些假数据。在此示例中,我们将仅展示一个简单的“Hello World”文本,以便演示如何在应用程序中切换视图。稍后,你可以根据需要扩展这个控件的功能。
InvoiceView
用户控件添加新的用户控件
右键单击项目,选择 Add -> New Item,然后搜索 User Control,选择 User Control,并将其命名为 InvoiceView。点击 Add,它将生成一个名为 InvoiceView.xaml
的文件和相应的代码后文件 InvoiceView.xaml.cs
。
复制设计结构
我们不需要重新开始设计布局,而是可以复用之前在 LoginView
中定义的布局。复制 LoginView.xaml
中的 Grid,并将其粘贴到 InvoiceView.xaml
中的相应位置。
修改内容
在 InvoiceView.xaml
中,我们将 LoginView
中的 Label
内容修改为 "It's a mock invoice data",以使其与登录视图有所不同。然后,我们删除登录按钮并添加一个 TextBlock
,该控件用于展示发票数据的占位符文本。
以下是修改后的代码:
<UserControl x:Class="YourNamespace.InvoiceView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="350" Width="525">
<Grid>
<!-- 复制自 LoginView.xaml 的 Grid 结构 -->
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 修改后的 Label -->
<Label Content="It's a mock invoice data"
Grid.Row="0" Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="20"/>
<!-- 添加 TextBlock -->
<TextBlock Text="Hello World"
Grid.Row="3" Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</UserControl>
当用户点击 LoginView
中的登录按钮时,我们希望切换到 InvoiceView。为了实现这一点,我们需要在 LoginView.xaml.cs
中处理点击事件,并在事件处理程序中更新主窗口的内容。
LoginButton_Click
事件处理程序访问主窗口并更改内容
在 LoginView.xaml.cs
中的 LoginButton_Click
事件处理程序中,我们将访问主窗口(MainWindow
)并更新其 ContentControl
的内容为 InvoiceView
。
代码实现
在 LoginButton_Click
事件处理程序中,获取主窗口并将其 ContentControl
的内容切换为 InvoiceView
:
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
// 获取当前主窗口
var window = Application.Current.MainWindow;
// 将主窗口的内容更改为 InvoiceView
window.Content = new InvoiceView();
}
创建 InvoiceView
用户控件
我们首先创建了一个新的用户控件 InvoiceView
,它展示了一个简单的标签和一个 TextBlock
,用于显示占位符数据(如 "Hello World")。
更改 LoginButton_Click
事件处理程序
在 LoginView
中,我们为登录按钮添加了一个点击事件处理程序。该事件处理程序在点击按钮后将主窗口的内容切换为 InvoiceView
。
切换视图
在应用程序启动时,主窗口会显示 LoginView
,而点击登录按钮后,主窗口的内容会切换到 InvoiceView
。
LoginView
),另一个用于展示假发票数据(InvoiceView
)。ContentControl
,实现了从一个视图切换到另一个视图的功能。接下来,你可以根据需求继续扩展这个应用程序,增加更多的视图和功能。
理解如何在 WPF 中绑定数据对于构建动态应用程序非常重要。在这个演示项目中,我创建了一个非常简单的应用程序,使用了我们迄今为止所学的知识。现在我将简要解释这个演示项目的结构和如何实现数据绑定。
项目结构
在这个项目中,我使用了一个基本的布局,包含了 Grid
、RowDefinitions
等基本元素。我们有一个文本框、标签和按钮。通过这些简单的控件,我展示了如何使用数据绑定来动态更新 UI。
项目源代码
为了更好地理解,我已经将源代码上传到了 GitHub 页面,你可以下载它并按照步骤操作。你可以删除之前在演示项目中创建的所有内容,重新下载这个项目,或者简单地进入 WPF 文件夹,找到 MainWindow.xaml
和 MainWindow.xaml.cs
文件,然后将其中的内容复制到你的项目中。这样,你就能获得一个与你看到的一模一样的界面。
步骤说明
MainWindow.xaml
中的布局和 MainWindow.xaml.cs
中的代码。请确保在复制时,代码后端文件也一并复制,这样你就能避免手动编写代码时出现错误。Grid
、Label
、TextBox
等)在之前的 WPF 章节中都已经讲解过,所以你应该对这些控件不陌生。确保程序正常运行
一旦你成功设置了项目并运行应用程序,你应该看到一个简单的界面,允许你输入姓名和年龄,并通过点击按钮来显示 "Hello World"。如果你没有遇到任何错误,那么你可以继续跟着接下来的课程一起学习数据绑定和更多的功能。
在本节中,我们将开始讲解 WPF 中的数据绑定。数据绑定是 WPF 的一个核心特性,它可以帮助我们将 UI 元素与数据源连接起来,使得 UI 和数据之间的更新变得自动化和高效。WPF 提供了多种绑定方式,包括:
单向绑定
这是最常见的一种绑定方式,数据从数据源流向 UI 控件。通常用于显示数据,例如将一个文本框绑定到某个数据模型的属性。
双向绑定
双向绑定允许数据在 UI 控件和数据源之间进行双向更新。当 UI 控件的内容发生变化时,数据源会自动更新,反之亦然。这对于表单输入和其他需要同步的 UI 元素非常有用。
命令绑定
在 WPF 中,命令绑定用于将按钮的点击事件等与某些命令处理程序绑定。命令可以是简单的动作,例如显示消息或更新数据。
让我们继续深入了解如何实现数据绑定,并探索 WPF 中不同的数据绑定方式。
这样你就能够在项目中应用数据绑定,进一步增强 UI 和数据交互的功能。
一般来说,数据绑定是 UI 元素和我们应用程序中的数据之间的流动。我们有多种数据绑定方式,例如单向数据绑定和双向数据绑定。在本节中,我们将从单向数据绑定开始,然后逐步深入双向数据绑定。
创建类
在当前的示例项目中,我们已经使用了简单的变量(如字符串和整数)。但在实际的应用程序中,通常我们会使用 C# 的面向对象编程方法来管理数据。因此,我们需要为 name
和 age
创建一个类。在本示例中,我们将创建一个 Person
类,它包含 name
和 age
属性。
创建 Person
类
Data
,以便将数据类与其他代码分开。Data
文件夹中,右键点击并添加一个新的 C# 类,命名为 Person
。Person
类中,我们将添加两个属性:一个 string
类型的 Name
和一个 int
类型的 Age
。使用快捷代码 prop
来快速生成属性代码。public class Person
{
public int Age { get; set; }
public string Name { get; set; }
}
你可以随意调整属性的顺序,这里我选择先定义 Age
,然后是 Name
。
Person
实例并绑定到 UI创建实例
现在我们已经创建了 Person
类,但这只是一个模板,还没有实际的对象。接下来,我们将在 MainWindow
中创建 Person
的一个实例,并初始化它的属性,例如 Age
设置为 30,Name
设置为某个默认值。
Person person = new Person
{
Name = "John Doe",
Age = 30
};
设置数据上下文
在 WPF 中,数据上下文(DataContext
)指示了数据绑定的源。在 MainWindow
的构造函数中,我们将 DataContext
设置为我们刚刚创建的 person
实例。
public MainWindow()
{
InitializeComponent();
this.DataContext = person; // 设置数据上下文为 person
}
DataContext
是一个非常关键的概念,它将 UI 元素与数据源连接起来,让 WPF 知道哪些数据需要绑定到哪些 UI 元素上。
绑定数据
一旦设置了数据上下文,下一步就是将 UI 元素(如文本框)绑定到数据类的属性上。在 WPF 中,数据绑定通常是通过 XAML 中的绑定语法实现的。我们将通过绑定 TextBox
元素的 Text
属性到 Person
类中的 Name
和 Age
属性来展示数据。
下一步
在下一个视频中,我们将继续演示如何在 XAML 中实现数据绑定,让 UI 元素自动与 Person
对象的属性同步。
通过这种方式,我们成功地将数据类与 UI 元素进行了连接,接下来将进一步实现绑定,确保数据能在 UI 和数据源之间流动。
在本节中,我们将实现单向数据绑定。我们已经有了一个 Person
类,它包含 name
和 age
属性,现在我们要在 UI 元素中显示这些属性的值。
在我们的应用程序中,我们有两个文本框,一个用于显示 Name
,另一个用于显示 Age
。当启动应用程序时,我们希望能够自动填充这两个文本框,其中 Name
显示 "Yannick",Age
显示 "30"。
为了实现这个目标,我们将使用单向数据绑定:将数据从应用程序的 Person
对象绑定到 UI 元素(文本框)。单向数据绑定意味着数据从源(Person
对象)流向目标(UI 元素),但不会反向更新数据。
设置数据绑定:
在 XAML 中,使用 Binding
元素来将文本框的 Text
属性绑定到 Person
类中的 Name
和 Age
属性。我们通过设置绑定的 Mode
为 OneWay
来实现单向数据绑定。
<TextBox Text="{Binding Name, Mode=OneWay}" />
<TextBox Text="{Binding Age, Mode=OneWay}" />
设置数据上下文:
通过在 MainWindow
中设置 DataContext
来让 UI 元素知道数据源。我们将 DataContext
设置为 Person
对象实例。
this.DataContext = person; // 绑定数据源
效果:
启动应用程序时,文本框会自动填充为 "Yannick" 和 "30",这就是单向数据绑定的效果。
在实际应用中,单向数据绑定特别适用于从外部源(如 Web 服务器或本地文件)加载数据并将其显示在 UI 上的场景。例如,假设从 Web 服务器加载了一个人的数据,包括姓名和年龄。使用单向数据绑定,您可以将这些数据展示给用户,而无需用户进行修改。
到目前为止,使用的是单向数据绑定。也就是说,当我们修改文本框中的数据时,Person
对象中的数据不会更新。单向数据绑定仅仅是从数据源到 UI 元素的单向流动。
然而,在某些场景中,我们希望用户能够修改 UI 元素的内容,且这些修改应该反映回数据源。例如,用户输入新的姓名或年龄后,数据源(Person
对象)中的值应同步更新。
让我们假设,当点击一个按钮时,我们想显示当前的 Person
数据。按钮点击事件中,我们会生成一个字符串,显示 Person
对象的姓名和年龄。
private void InfoButton_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show(person.Name + " is " + person.Age + " years old.");
}
在当前的实现中,如果用户在文本框中修改了姓名或年龄,点击按钮时显示的仍然是初始的 "Yannick" 和 "30",因为数据并没有反向更新。这个现象正是单向数据绑定的表现。
为了让文本框的更改能够反映到 Person
对象中,我们需要实现双向数据绑定。双向数据绑定意味着 UI 元素的改变会自动更新数据对象,反之亦然。
例如,如果用户将文本框中的 "Yannick" 改为 "Peter",并点击按钮,数据源中的 Person
对象应更新为新的 Name
和 Age
,显示 Peter
和新的年龄。
<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Age, Mode=TwoWay}" />
通过将绑定模式设置为 TwoWay
,我们可以确保 UI 元素和数据对象之间的双向数据流动。
Person
)流向目标(UI 元素),但 UI 元素的修改不会反映回数据源。通过双向数据绑定,我们可以实现更强大的用户交互,确保 UI 元素和数据源保持同步。接下来,我们将继续探索如何实现双向数据绑定。
现在我们来实现双向数据绑定。与单向数据绑定不同,双向数据绑定允许数据在 UI 元素和数据源(例如 Person
对象)之间双向流动。这意味着,当 UI 元素的值发生变化时,数据源也会同步更新;同样,数据源的变化会实时反映在 UI 元素中。
在我们的应用中,我们之前使用了单向数据绑定,绑定模式为 OneWay
。现在,我们将绑定模式改为 TwoWay
,以便能够从 UI 元素更新数据源,反之亦然。
我们在 XAML 中将 TextBox
的 Text
属性绑定到 Person
类的 Name
和 Age
属性。原先的绑定模式是 OneWay
,现在我们将其改为 TwoWay
:
<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Age, Mode=TwoWay}" />
这意味着,当用户修改文本框中的值时,它会自动更新 Person
对象中的相应属性,反之亦然。
在 Binding
中,我们还可以使用 Path
来指定要绑定的属性。如果没有特别声明,Path
默认为目标属性(例如 Name
和 Age
)。但是,如果需要,我们可以显式指定 Path
:
<TextBox Text="{Binding Path=Name, Mode=TwoWay}" />
<TextBox Text="{Binding Path=Age, Mode=TwoWay}" />
这样做的效果与前面的绑定方式相同,都是双向绑定,只是通过 Path
显式指定属性名。
在启动应用程序后,文本框中的 Name
会显示为 "Yannick",Age
显示为 "30"。当你修改文本框中的内容时(例如将 Name
改为 "Peter",将 Age
改为 "20"),Person
对象中的数据也会同步更新。
Name
文本框中输入 "Peter"。Age
文本框中输入 "20"。这证明了双向数据绑定的工作原理。UI 元素的更改直接反映到数据对象中,我们也能通过代码访问到最新的数据。
通过双向数据绑定,我们能够实现 UI 元素和数据源之间的双向数据流动。用户在 UI 元素中输入的新值会实时更新到数据对象,而数据对象的变化也会反映到 UI 上。这种方式特别适用于需要在 UI 和数据源之间进行同步的应用场景。
这样,我们就完成了双向数据绑定的实现。在下一节中,我们将继续探索其他 WPF 中的数据绑定技巧。
在这节中,我们将探索数据绑定的最后一种模式——单向到源数据绑定(OneWayToSource)。这是数据绑定的一种特殊形式,它的工作方式与前面讨论的单向数据绑定和双向数据绑定有所不同。
之前我们讨论了单向数据绑定(OneWay),它是数据从源到目标的流动,即从数据源(例如 Person
对象)到 UI 元素。现在,我们要讨论的是单向到源数据绑定,它的工作方式是将数据从 UI 元素(目标)流动回数据源(源)。这种方式通常用于将用户在 UI 上输入的数据更新到后台的数据源中。
为了实现单向到源数据绑定,我们将绑定模式从 OneWay
改为 OneWayToSource
。这样,数据流将从 UI 元素(如文本框)流回数据源。
<TextBox Text="{Binding Name, Mode=OneWayToSource}" />
<TextBox Text="{Binding Age, Mode=OneWayToSource}" />
Yannick
和 30
。Peter
和 50
。Yannick
和 30
。这是因为数据流动的方向发生了改变,现在是从 UI 元素流回到数据源。在这个模式下,UI 元素的内容(如文本框中的名称和年龄)会覆盖后台的数据。
单向到源数据绑定并不是最常用的模式,它的应用场景相对较少。它适用于那些需要从 UI 元素收集数据并将其传递回数据源的情况。例如,用户填写表单时,可以使用这种模式将表单字段的输入值传回后台数据源。
在大多数应用中,单向数据绑定(从源到 UI 元素)和双向数据绑定(UI 元素和数据源之间双向更新)更常用。然而,单向到源数据绑定也有它的特殊用途,尤其是在需要更新数据源而不需要显示回数据的场合。
虽然 OneWay
和 TwoWay
是最常用的绑定模式,OneTime
和 OneWayToSource
在特定场景下也非常有用,例如配置加载或从 UI 元素到数据源的流动。
至此,我们已经覆盖了 WPF 数据绑定的各种模式,了解了它们的适用场景和实现方式。希望你在实际开发中能灵活运用这些知识!
在我们之前讨论了单向数据绑定和双向数据绑定之后,现在介绍另外一种数据绑定模式——单次数据绑定(OneTime Data Binding)。这种方式比单向和双向数据绑定要简单一些,适用于一些特定的场景。
正如名字所示,单次数据绑定只会在数据初始化时更新一次,并且之后不会再进行任何更新。它非常适用于那些配置数据或常量数据,这些数据在程序运行期间不会改变。
实现单次数据绑定时,你需要将绑定模式设置为 OneTime
,这表示数据只会绑定并显示一次。
<TextBox Text="{Binding Name, Mode=OneTime}" />
<TextBox Text="{Binding Age, Mode=OneTime}" />
在这种模式下,数据只会在应用程序启动时加载一次并显示在 UI 元素中。如果后续修改了数据,UI 元素将不会自动更新,因为绑定只发生了一次。
单次数据绑定常用于以下场景:
加载配置数据:例如,加载一个配置文件或者常量数据。这些数据在整个应用程序生命周期中都不发生变化,只需要在启动时加载一次并显示出来。
静态数据:当你有一些不需要变化的静态数据,如版本号、应用名称等,使用单次数据绑定非常合适。
虽然单向数据绑定(OneWay)和单次数据绑定(OneTime)都涉及到从数据源到 UI 元素的数据流,但它们之间有一个显著的区别:
单向数据绑定(OneWay):数据流动是单向的,并且会持续保持更新。如果数据源发生变化,UI 元素会自动更新。
单次数据绑定(OneTime):数据绑定只会在应用启动时或绑定初次设置时更新一次,不会随着数据的更改而更新。它适用于那些不需要修改的数据,如配置文件中的值。
假设我们有一个配置文件或常量需要显示在界面上:
<TextBox Text="{Binding AppVersion, Mode=OneTime}" />
这里,AppVersion
是一个静态的配置信息,使用单次数据绑定,文本框的值会被设置为该版本号,并且该值不会在运行时更新。
单次数据绑定(OneTime):数据绑定只在初始化时设置一次,适用于静态数据或常量数据。这些数据不会随时间变化,因此 UI 元素也不会更新。
单向数据绑定(OneWay):数据从源流向 UI 元素,并且在数据源发生变化时,UI 会自动更新。
双向数据绑定(TwoWay):数据在 UI 元素和数据源之间双向流动,允许 UI 更新数据源,数据源的更改也会反映到 UI。
单次数据绑定常用于配置加载和常量值展示,不会随着数据源的变化而更新,适用于不需要动态更新的数据。
在现实世界的应用程序中,我们经常处理的数据并不是单一条目,比如“嘿,我有一个发票”或“嘿,我有一个人”。实际上,我们通常需要处理的是大量的条目,如成千上万的发票,或者大学里成千上万的学生数据。对于这些场景,我们的应用程序必须能够处理和显示数据集合,比如列表(List)、数组(Array)等。
例如,如果你正在构建一个发票管理应用,通常不仅仅是编辑单个发票,更多时候你需要从多个发票中选择一个进行编辑。所以,大多数应用程序都涉及到数据集合的处理,而不仅仅是单一的数据实体。这些数据可以通过多种方式加载,例如:
在这类应用中,你通常会面对一个数据集合,像是一个包含成千上万条数据的列表。
在 WPF 中,ListBox 是一个非常有用的控件,它能够帮助我们展示多个条目的数据集合,并提供很多功能。让我们快速查看一下这个控件的实现和用法。
首先,打开你的 WPF 项目,并在 MainWindow.xaml
中添加一个 ListBox 控件。假设我们已经创建了一个简单的窗口布局,并且我们需要将数据绑定到 ListBox
中。
<Grid Height="350" Width="350">
<Grid.RowDefinitions>
<RowDefinition Height="10*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="10*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="10*" />
</Grid.ColumnDefinitions>
<!-- 创建 ListBox 控件 -->
<ListBox x:Name="listBoxNames" Grid.Row="1" Grid.Column="1" />
</Grid>
在这个例子中,ListBox
控件被放置在网格的中间,行和列的索引分别是 1
。我们使用 x:Name
为控件命名为 listBoxNames
,这样我们就能在代码背后访问和操作它。
在代码背后,我们可以将一个数据集合绑定到 ListBox
。例如,我们可以创建一个字符串列表,里面包含几个名字,并将其作为数据源绑定到 ListBox
。
public MainWindow()
{
InitializeComponent();
// 创建一个包含名字的字符串列表
List<string> names = new List<string> { "Yannick", "Peter", "Maria", "Mark" };
// 将列表绑定到 ListBox 的 ItemSource 属性
listBoxNames.ItemsSource = names;
}
在这个示例中,我们创建了一个 List<string>
类型的集合,包含了一些名字,并将其绑定到 listBoxNames
的 ItemsSource
属性上。ItemsSource
是 ListBox
用来显示数据的属性。
启动应用程序后,你将看到一个包含这些名字的 ListBox
。你可以点击列表中的任何项,例如选择 Yannick
、Maria
或 Peter
,每个选择都会变为选中的状态。
这样,ListBox
控件不仅可以显示多个数据项,还可以使用户进行交互,例如选择一个条目进行编辑。
ListBox
是一个非常强大的控件,它有许多额外的功能,比如:
ListBox
提供了多种事件,如选择改变(SelectionChanged)等,允许开发者做进一步的交互。在现实世界的应用程序中,数据集合的处理是非常常见的。无论是加载发票列表、学生名册,还是任何包含多个条目的数据,我们都需要在 UI 中展示这些数据。ListBox
控件是一个非常适合用来显示数据集合的控件,它不仅可以显示静态数据,还可以与用户进行交互,允许选择或编辑数据项。
在接下来的课程中,我们将继续深入探讨 ListBox
的更多功能和如何在应用程序中更高效地使用它。
在之前的视频中,我们展示了如何将一个简单的字符串列表绑定到 ListBox
控件,并在界面中显示这些字符串,如 Yannick
、Peter
、Maria
和 Mark
。但这些只是简单的字符串数据类型,而在现实应用中,我们往往需要处理更复杂的数据类型,比如一个包含多个属性的 Person
类。
假设我们有一个包含多个属性的 Person
类,它可能包括 name
和 age
。现在我们希望在 ListBox
中显示这些 Person
对象,而不仅仅是显示它们的名字。为了做到这一点,我们需要做一些额外的设置。
Person
类首先,确保你的 Person
类是公开的,这样它才能被外部访问并用于数据绑定。如果类没有设置为 public
,你将无法访问它,会遇到访问错误。
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
在这个类中,我们定义了两个属性:Name
和 Age
,分别代表一个人的姓名和年龄。
List<Person>
并绑定到 ListBox
接下来,我们在代码中创建一个 List<Person>
类型的集合,并将其绑定到 ListBox
控件的 ItemSource
属性。ItemSource
属性要求一个 IEnumerable
接口,而 List<T>
本身实现了 IEnumerable
接口,因此我们可以直接将其传递给 ItemSource
。
public List<Person> People { get; set; }
public MainWindow()
{
InitializeComponent();
// 创建一个包含默认值的 List<Person> 对象
People = new List<Person>
{
new Person { Name = "Yannick", Age = 30 },
new Person { Name = "Peter", Age = 25 },
new Person { Name = "Maria", Age = 22 },
new Person { Name = "Mark", Age = 28 }
};
// 将 List<Person> 绑定到 ListBox 的 ItemSource
listBoxPeople.ItemsSource = People;
}
在上面的代码中,我们首先创建了一个名为 People
的 List<Person>
,并用一些示例数据初始化它。然后我们将这个 List
绑定到 ListBox
的 ItemSource
属性。
然而,ListBox
默认只能显示简单数据类型(如字符串)。因为我们绑定的是 Person
类型的对象,所以 ListBox
默认调用这些对象的 ToString()
方法,通常会得到类似 Person
或其内存地址的结果。这显然不是我们想要的。
为了正确显示 Person
对象的属性(如 Name
和 Age
),我们需要提供一个数据模板(ItemTemplate),定义如何展示每个条目的内容。
在 ListBox
中,我们使用 ItemTemplate
来指定如何展示数据。具体来说,我们可以定义一个 DataTemplate
,该模板指明了如何显示每个 Person
对象的 Name
和 Age
。
<ListBox x:Name="listBoxPeople" Grid.Row="1" Grid.Column="1">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Age}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
在这个 DataTemplate
中,我们使用了两个 TextBlock
控件来显示 Person
的 Name
和 Age
属性。{Binding Name}
和 {Binding Age}
分别表示绑定到 Person
对象的 Name
和 Age
属性。
ListBox
名称和数据绑定为了保持代码的整洁和一致性,我们可以将 ListBox
控件的名称修改为 listBoxPeople
,并确保代码背后的 ItemSource
属性绑定到正确的 People
列表:
listBoxPeople.ItemsSource = People;
确保控件的名称与数据绑定匹配,这样代码会更加清晰和一致。
通过上述步骤,我们成功地将一个复杂的数据类型 Person
显示在 ListBox
中。通过使用 ItemTemplate
,我们能够定制每个条目的显示方式,使得 ListBox
能够显示 Person
对象的多个属性,而不仅仅是对象的默认字符串表示。
在接下来的视频中,我们将进一步探讨如何使用 ItemTemplate
来进行更多的自定义,提升 ListBox
的功能和表现。
在这节课中,我们将深入探讨如何为 ListBox
创建一个 ItemTemplate。通过定义这个模板,我们可以指定 ListBox
中每个项的布局和样式,从而使我们的数据展示更加清晰和有意义。
ListBox
的 ItemTemplate
首先,我们需要将 ListBox
的自闭合标签修改为标准标签,因为在定义 ItemTemplate
时需要在 ListBox
标签内包含一些内容。
<ListBox x:Name="listBoxPeople" Grid.Row="1" Grid.Column="1">
<ListBox.ItemTemplate>
<DataTemplate>
<!-- 在这里定义每个项的布局 -->
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
DataTemplate
来定义项的布局在 ItemTemplate
中,我们使用 DataTemplate
来定义每个项的具体展示方式。可以通过一些常见的布局控件,比如 StackPanel
,来组织每个 ListBox
项的内容。
<ListBox x:Name="listBoxPeople" Grid.Row="1" Grid.Column="1">
<ListBox.ItemTemplate>
<DataTemplate>
<!-- 使用 StackPanel 来垂直排列项 -->
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Age}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
在这里,我们通过 StackPanel
垂直排列了每个 Person
对象的 Name
和 Age
属性。通过 {Binding Name}
和 {Binding Age}
,我们绑定了每个条目的名称和年龄属性。
ListBox
项添加更多上下文信息虽然我们已经成功绑定了 Name
和 Age
属性,但是仅仅显示这些值对用户来说可能并不够清晰。为了提供更多的上下文信息,我们可以使用 字符串格式化 来为每个字段添加说明性文字。
<ListBox x:Name="listBoxPeople" Grid.Row="1" Grid.Column="1">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name, StringFormat='Name: {0}'}" />
<TextBlock Text="{Binding Age, StringFormat='Age: {0}'}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
这里,我们使用了 StringFormat
来为每个绑定的值添加前缀,例如,"Name: {0}"
和 "Age: {0}"
。这样,显示出来的文本会更具可读性,比如 "Name: Yannick" 和 "Age: 30",从而更容易理解。
修改后,当你运行程序时,每个 ListBox
项将显示为 Name: Yannick
和 Age: 30
这样的格式。用户看到的内容更加直观,能清楚地知道 "30" 代表的是年龄,而不是其他不明确的数字。
通过定义 ItemTemplate
和使用 DataTemplate
,我们可以灵活地展示复杂数据类型(如 Person
类)在 ListBox
中。通过绑定数据属性并使用 StringFormat
,我们还可以为用户提供更多的上下文信息,使界面更加友好和易于理解。
在接下来的课程中,我们将进一步探索如何使用 ListBox
实现更多的功能,比如响应用户的选择和交互。
在这节课中,我们将探讨如何在 ListBox
中选择多个项,并且如何获取所选的项进行操作。这是 ListBox
的一个强大特性,允许用户选择一个或多个项,并通过编程来处理这些选中的项。
ListBox
的选择模式首先,ListBox
支持多种选择模式,可以通过设置 SelectionMode
属性来选择不同的模式。以下是三种选择模式的说明:
Ctrl
或 Shift
键来多选。Ctrl
或 Shift
键进行扩展选择。例如,设置 SelectionMode
为 Multiple
,用户可以通过按住 Ctrl
键来选择多个项:
<ListBox x:Name="listBoxPeople" SelectionMode="Multiple" />
为了能够处理选中的项,我们可以添加一个按钮,点击按钮时显示所有被选中的项。首先,我们在界面上添加一个按钮,并为其设置点击事件:
<Button x:Name="btnShowSelected" Content="Show Selected" Click="Button_Click" />
接着,在按钮的点击事件中,我们可以获取 ListBox
中所有被选中的项。
要获取 ListBox
中所有被选中的项,我们可以使用 SelectedItem
或 SelectedItems
属性:
Single
)。Multiple
或 Extended
模式)。由于我们设置的是 Multiple
选择模式,因此我们使用 SelectedItems
来获取选中的项。代码如下:
var selectedItems = listBoxPeople.SelectedItems;
这里的 selectedItems
是一个包含所有被选中项的集合。接下来,我们可以通过循环遍历这个集合,并获取每个选中的项。
我们可以使用 foreach
循环遍历 selectedItems
集合,然后获取每个选中项的详细信息。由于选中的项是 Person
对象的实例,我们需要将它们从 object
类型转换(强制类型转换)为 Person
类型,以便访问其属性(如 Name
和 Age
)。
foreach (var item in selectedItems)
{
// 将 item 转换为 Person 类型
var person = (Person)item;
// 显示选中项的名称
MessageBox.Show($"Name: {person.Name}, Age: {person.Age}");
}
通过这种方式,我们可以轻松获取并展示用户选择的每个 Person
对象的名称和年龄等属性。
当用户点击 "Show Selected" 按钮时,应用程序会显示每个选中项的详细信息。例如,选择了 Mark 和 Scott 后,点击按钮,程序会显示:
Name: Mark, Age: 35
Name: Scott, Age: 28
通过配置 ListBox
的 SelectionMode
属性和使用 SelectedItems
,我们可以方便地处理多个选中的项。使用强制类型转换,将 object
转换为实际的数据类型(如 Person
),我们可以访问这些项的具体属性。通过这种方式,用户与 ListBox
的交互不仅能够选择项,还能对这些选中的项进行进一步的操作。
在接下来的课程中,我们将继续探索如何利用 ListBox
实现更多的功能,比如响应用户选择的变化并进行实时更新。
在这一部分,我们将构建一个登录界面,用户可以输入密码进行验证。如果密码正确,登录按钮将被激活并显示相关信息。除了字符串比较外,我们还将使用 环境变量 来存储敏感数据,例如密码、API 密钥等。这种做法在实际软件开发中非常常见,因为它有助于保护敏感信息,避免将其硬编码在应用程序中。
首先,我们需要一个基本的界面,其中包含一个文本框用于输入密码,一个按钮用于登录验证。密码验证成功后,按钮可以激活,并显示相应的消息。
<Window x:Class="LoginApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Login" Height="200" Width="300">
<Grid>
<TextBox x:Name="txtPassword" HorizontalAlignment="Left" VerticalAlignment="Top" Width="200" Height="30" Margin="50,30,0,0" />
<Button x:Name="btnLogin" Content="Login" HorizontalAlignment="Left" VerticalAlignment="Top" Width="100" Height="30" Margin="100,70,0,0" IsEnabled="False" Click="btnLogin_Click"/>
</Grid>
</Window>
在上述代码中,TextBox
用于输入密码,Button
用于触发登录验证。初始时,按钮被禁用(IsEnabled="False"
),直到用户输入正确的密码才会启用。
我们需要在后台代码中处理密码输入并根据密码的正确性启用按钮。为此,我们将监听 TextBox
的内容变化,当内容符合预设密码时,启用登录按钮。
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
txtPassword.TextChanged += TxtPassword_TextChanged;
}
private void TxtPassword_TextChanged(object sender, TextChangedEventArgs e)
{
// 比较用户输入的密码与预设密码
btnLogin.IsEnabled = txtPassword.Text == "correct_password";
}
private void btnLogin_Click(object sender, RoutedEventArgs e)
{
if (txtPassword.Text == "correct_password")
{
MessageBox.Show("Entered Correct Password");
}
else
{
MessageBox.Show("Entered Wrong Password");
}
}
}
在 MainWindow
类中,TxtPassword_TextChanged
事件处理程序会在用户输入时检查密码。如果密码正确,按钮会被启用,用户可以点击登录按钮,显示相关提示。
在实际应用中,密码等敏感信息不应直接在代码中硬编码。我们可以通过 环境变量 来存储密码,并在运行时进行读取。这样可以增强应用的安全性,并避免将敏感信息暴露在代码中。
首先,我们需要在操作系统的环境变量中设置一个名为 APP_PASSWORD
的变量(你可以在系统的环境变量设置中手动添加,或者在代码中进行设置)。然后,在应用程序中读取该环境变量进行密码验证。
在 Windows 中,你可以通过以下步骤设置环境变量:
APP_PASSWORD
和对应的密码值。接下来,我们可以修改代码,使应用从环境变量中读取密码,而不是硬编码密码。可以使用 Environment.GetEnvironmentVariable
方法获取环境变量的值。
public partial class MainWindow : Window
{
private string correctPassword;
public MainWindow()
{
InitializeComponent();
// 从环境变量中读取密码
correctPassword = Environment.GetEnvironmentVariable("APP_PASSWORD");
txtPassword.TextChanged += TxtPassword_TextChanged;
}
private void TxtPassword_TextChanged(object sender, TextChangedEventArgs e)
{
// 比较用户输入的密码与环境变量中的密码
btnLogin.IsEnabled = txtPassword.Text == correctPassword;
}
private void btnLogin_Click(object sender, RoutedEventArgs e)
{
if (txtPassword.Text == correctPassword)
{
MessageBox.Show("Entered Correct Password");
}
else
{
MessageBox.Show("Entered Wrong Password");
}
}
}
在上述代码中,我们通过 Environment.GetEnvironmentVariable("APP_PASSWORD")
从操作系统的环境变量中获取密码,并在用户输入密码时进行验证。
通过这种方式,我们不仅完成了简单的密码验证,还学会了如何利用环境变量来存储和读取敏感数据,确保应用程序的安全性。
在这节课中,我们了解了如何创建一个简单的登录界面并实现密码验证。同时,我们还学习了如何使用环境变量存储密码和其他敏感数据,这种做法在实际应用中非常重要,可以有效提高应用的安全性。在下一步中,你可以考虑将更多的敏感数据(如 API 密钥)存储在环境变量中,进一步增强安全性。
我们将从一个全新的 WPF 项目开始,这个项目的名称是 Invoice Management。在 Visual Studio 中创建项目时,选择 WPF 模板并启动新项目,最终你将看到一个空白的界面,包含一个默认的 Grid 控件。
我们首先要做的就是在主窗口中创建一个 ContentControl,该控件将用于容纳后续的视图内容。在 MainWindow.xaml 中,我们添加一个 ContentControl
,并为它设置一个名称(例如:mainContent
)。
<Window x:Class="InvoiceManagement.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Invoice Management" Height="350" Width="525">
<Grid>
<!-- 创建 ContentControl 用于显示不同的视图 -->
<ContentControl x:Name="mainContent" />
</Grid>
</Window>
此时,启动应用时,你会看到一个空白的窗口,因为我们还没有为 mainContent
提供内容。
接下来,我们需要创建一个 LoginView 用户控件,这是我们登录界面的一部分。为了创建这个用户控件,右击项目并选择“添加” → “用户控件(WPF)”,然后命名为 LoginView
。
这将创建一个包含 LoginView.xaml 和其代码后端 LoginView.xaml.cs 的文件。我们可以在此文件中定义我们的登录界面布局。
一旦创建了 LoginView
,我们可以通过代码在 MainWindow
中加载它。打开 MainWindow.xaml.cs 文件,在构造函数中添加如下代码来将 mainContent
的内容设置为 LoginView
:
public MainWindow()
{
InitializeComponent();
// 将主窗口的内容设置为 LoginView
mainContent.Content = new LoginView();
}
此时,启动应用程序后,mainContent
会显示 LoginView
,但该控件是空的,我们还没有设计界面。
现在我们来设计 LoginView.xaml
。首先,我们需要创建一个布局,通常使用 Grid
来布局控件,并定义列和行。这里,我们将创建 3 列和 7 行,部分行和列使用相对大小。
<UserControl x:Class="InvoiceManagement.LoginView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="400" Height="250">
<Grid>
<!-- 创建 3 列 -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 创建 7 行 -->
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="10" />
<RowDefinition Height="Auto" />
<RowDefinition Height="10" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 添加内容 -->
<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Text="Login" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="24" Margin="0,20"/>
<TextBlock Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Text="Enter your password:" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="16" />
<PasswordBox Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" x:Name="passwordBox" Margin="10" />
<Button Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="3" Content="Login" HorizontalAlignment="Center" VerticalAlignment="Center" Width="100" Margin="0,20"/>
</Grid>
</UserControl>
在这个布局中:
Grid
,定义了 3 列和 7 行。PasswordBox
,用于用户输入密码。我们在 MainWindow.xaml 中已经创建了 ContentControl
。此时 LoginView
应该被正确显示。如果启动应用程序,用户将首先看到登录界面。
接下来,我们要在 LoginView.xaml.cs
中处理用户点击登录按钮时的行为。首先,获取 PasswordBox
控件的密码,然后与预设的正确密码进行比较。为了简单起见,假设正确密码是 "password123"
。
public partial class LoginView : UserControl
{
public LoginView()
{
InitializeComponent();
}
private void OnLoginClick(object sender, RoutedEventArgs e)
{
string enteredPassword = passwordBox.Password;
if (enteredPassword == "password123")
{
MessageBox.Show("Login successful!");
}
else
{
MessageBox.Show("Incorrect password.");
}
}
}
在 XAML 中,我们需要将登录按钮的 Click
事件处理程序绑定到 OnLoginClick
方法:
<Button Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="3" Content="Login" HorizontalAlignment="Center" VerticalAlignment="Center" Width="100" Margin="0,20" Click="OnLoginClick"/>
我们已经成功创建了一个简单的登录界面,并将其集成到主窗口中。通过这种方式,我们可以通过 ContentControl
动态地加载不同的视图(如登录视图)。这种方法对于实现灵活的界面和用户体验非常有用。
接下来,我们可以继续扩展这个应用程序,加入更多功能,如错误提示、加载动画、以及实际的发票管理界面。
我们刚刚完成了登录视图的网格布局设计,现在我们将继续添加标签、密码框和登录按钮等控件,并完善它们的功能。
首先,我们要为登录界面添加一个标签,显示“Login”。该标签将位于第一行(grid.row=1
),第一列(grid.column=1
)。
<Label Grid.Row="1" Grid.Column="1" Content="Login" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="18" FontWeight="Bold"/>
Center
,使得标签居中。这样,我们就创建了一个用于显示登录标题的标签。
接下来,我们需要添加一个标签,提示用户输入密码。该标签位于第三行(grid.row=3
)。
<Label Grid.Row="3" Grid.Column="1" Content="Enter Password" HorizontalAlignment="Center" FontSize="14"/>
Center
,使其居中显示。接下来,添加一个密码框(PasswordBox
),用于接收用户输入的密码。它将位于与“Enter Password”标签相同的位置,即第三行(grid.row=3
),并且与标签对齐(grid.column=1
)。
<PasswordBox Grid.Row="4" Grid.Column="1" Width="300" Height="30"/>
最后,我们需要一个按钮,用户点击后将尝试登录。这个按钮将位于第五行(grid.row=5
),并且列也设置为第一列(grid.column=1
)。
<Button Grid.Row="5" Grid.Column="1" Content="Login" Width="200" Height="50" HorizontalAlignment="Center"/>
Center
,让按钮水平居中。下面是完整的 LoginView.xaml 布局代码,包含了上述所有的控件和布局设置:
<UserControl x:Class="InvoiceManagement.LoginView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="400" Height="250">
<Grid>
<!-- 定义列 -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 定义行 -->
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="10" />
<RowDefinition Height="Auto" />
<RowDefinition Height="10" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Login 标签 -->
<Label Grid.Row="1" Grid.Column="1" Content="Login" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="18" FontWeight="Bold"/>
<!-- Enter Password 标签 -->
<Label Grid.Row="3" Grid.Column="1" Content="Enter Password" HorizontalAlignment="Center" FontSize="14"/>
<!-- 密码框 -->
<PasswordBox Grid.Row="4" Grid.Column="1" Width="300" Height="30"/>
<!-- 登录按钮 -->
<Button Grid.Row="5" Grid.Column="1" Content="Login" Width="200" Height="50" HorizontalAlignment="Center"/>
</Grid>
</UserControl>
完成布局后,我们可以启动应用程序,查看是否正确显示登录界面以及所有控件。启动时,用户将看到一个包含“Login”标题、密码输入框和登录按钮的界面。
此时,用户可以输入密码并点击登录按钮。然而,目前我们尚未实现实际的登录功能,因此需要进一步处理用户输入。
我们需要在 LoginView.xaml.cs
中处理登录逻辑。当用户点击
在开发应用程序时,我们常常需要存储一些敏感数据,如密码、API 密钥、文件路径等。为此,使用环境变量是一种安全的做法。接下来,我们将讲解如何在 Windows 系统中创建和使用环境变量来存储这些敏感数据。
首先,我们需要打开 Windows 的环境变量设置窗口。操作步骤如下:
在环境变量设置窗口中,我们有两类环境变量:
我们可以在 系统变量 中创建一个新的环境变量来存储我们的敏感数据。操作步骤如下:
在弹出的窗口中,设置变量名和变量值:
InvoiceManagement
(或其他你喜欢的名称)。test123
(例如你设置的密码)。比如,我们可以将变量名设置为 InvoiceManagement
,变量值设置为 test123
,这就是我们存储的密码。
InvoiceManagement
变量及其值。重要提示:在创建或修改环境变量后,系统不会立即加载这些变化。如果不重启计算机,你的程序将无法读取到这些新的环境变量,它们的值会是 null
。因此,务必重启计算机以确保环境变量生效。
在应用程序中,我们可以通过 System.Environment.GetEnvironmentVariable
方法读取已设置的环境变量。例如:
string password = Environment.GetEnvironmentVariable("InvoiceManagement");
这段代码会读取名为 InvoiceManagement
的环境变量,并将其值赋给 password
变量。这样,我们就可以安全地使用存储在环境变量中的敏感数据,而不必将它们硬编码在应用程序中。
InvoiceManagementPassword
或 AppSecretKey
),这有助于你以后查找和管理环境变量。完成环境变量的创建并重启计算机后,我们就可以在应用程序中开始使用它了。记得使用正确的键名来访问和验证密码或其他敏感数据。
下一个步骤,我们将继续开发登录功能,利用刚刚设置的环境变量来验证用户输入的密码。
在本节中,我们将为程序添加登录验证的功能。当用户输入密码时,点击登录按钮后,程序将读取密码框中的内容,检查我们之前设置的环境变量,并与用户输入的密码进行比较,确认是否输入了正确的密码。
首先,我们需要为登录按钮添加点击事件的处理器。当按钮被点击时,我们将触发一个方法,该方法将处理密码验证逻辑。
OnLoginButtonClicked
。例如,在 XAML 中为按钮设置事件:
<Button Content="Login" Click="OnLoginButtonClicked"/>
接下来,在 Code-behind 文件中实现 OnLoginButtonClicked
方法。我们需要做以下几件事:
public void OnLoginButtonClicked(object sender, RoutedEventArgs e)
{
// 获取密码框中的用户输入
var passwordEntered = passwordBox.Password;
// 读取环境变量中的密码
string? environmentPassword = Environment.GetEnvironmentVariable("InvoiceManagement");
// 判断环境变量是否为空
if (environmentPassword != null)
{
// 检查用户输入的密码是否与环境变量中的密码一致
if (passwordEntered == environmentPassword)
{
MessageBox.Show("Entered correct password");
}
else
{
MessageBox.Show("Entered wrong password");
}
}
else
{
MessageBox.Show("Environment variable not found");
}
}
passwordBox.Password
:从密码框中获取用户输入的密码。Environment.GetEnvironmentVariable("InvoiceManagement")
:从环境变量中获取存储的密码。在这里,我们使用键 "InvoiceManagement"
获取对应的密码值。现在,运行应用程序,测试登录功能。操作步骤如下:
禁用登录按钮:为了提升用户体验,可以在没有输入密码时禁用登录按钮。这样可以避免用户在未输入密码的情况下点击登录按钮。
可以通过检查 passwordBox.Password
是否为空来控制按钮的启用和禁用:
loginButton.IsEnabled = !string.IsNullOrEmpty(passwordBox.Password);
确保环境变量正确设置:如果环境变量没有设置,用户将无法通过验证。请确保在启动应用之前设置了正确的环境变量。
通过添加密码验证功能,我们使得应用程序能够从环境变量中读取存储的密码,并与用户输入的密码进行比较。这种方法避免了硬编码密码,提高了安全性。在实际开发中,环境变量常用于存储敏感数据,如API 密钥和数据库密码等。
接下来,我们将进一步提升应用的功能。在下一节中,我们将继续完善应用,增加更多的功能。
在本节中,我们将通过监听密码框内容的变化来启用或禁用登录按钮。我们将使用密码框的 PasswordChanged
事件来触发相应的逻辑,确保在密码框为空时禁用登录按钮,输入密码后才启用按钮。
PasswordChanged
事件首先,我们需要为密码框添加一个事件监听器。当密码框的内容发生变化时,我们将检查密码是否为空,并根据结果启用或禁用登录按钮。
在 Login View 中,为密码框添加 PasswordChanged
事件:
<PasswordBox Name="passwordBox" PasswordChanged="OnPasswordChanged"/>
OnPasswordChanged
事件处理器接下来,在 Code-behind 文件中实现 OnPasswordChanged
方法。在这个方法中,我们将检查密码框中的内容,并根据密码是否为空来启用或禁用登录按钮。
public void OnPasswordChanged(object sender, RoutedEventArgs e)
{
// 获取登录按钮
var loginButton = this.FindName("loginButton") as Button;
// 检查密码框是否为空
loginButton.IsEnabled = !string.IsNullOrEmpty(passwordBox.Password);
}
OnPasswordChanged
方法中,我们使用 passwordBox.Password
获取用户输入的密码。string.IsNullOrEmpty(passwordBox.Password)
检查密码框是否为空。
loginButton.IsEnabled = false
)。loginButton.IsEnabled = true
)。在应用程序启动时,我们希望登录按钮初始时是禁用的,直到用户输入密码。为了实现这一点,我们可以在 XAML 中设置按钮的 IsEnabled
属性为 false
。
<Button Name="loginButton" Content="Login" IsEnabled="False" Click="OnLoginButtonClicked"/>
这样,即使密码框最初为空,登录按钮也会保持禁用状态。然后,当用户输入密码时,PasswordChanged
事件会触发,检查密码框是否为空,并启用或禁用按钮。
现在,运行应用程序并测试以下场景:
这种方法提供了一种更好的用户体验设计,因为它确保只有在用户输入密码时才能点击登录按钮。通过监听 PasswordChanged
事件,我们能够实时地更新按钮的状态,增强了交互的流畅性和安全性。
在下一节中,我们将继续完善程序,进一步提升用户体验和功能。
我们已经完成了本章的内容,您现在掌握了足够的知识,可以开始扩展您的应用程序。下面是您可以尝试的一些方向:
Invoice
类您已经了解了如何创建一个简单的类,例如之前的 Person
类。现在,您可以创建一个类似的 Invoice
类,作为您的应用程序的一部分。Invoice
类可以包括诸如发票号码、客户姓名、金额等属性。
public class Invoice
{
public int InvoiceNumber { get; set; }
public string CustomerName { get; set; }
public decimal Amount { get; set; }
public DateTime Date { get; set; }
public Invoice(int invoiceNumber, string customerName, decimal amount, DateTime date)
{
InvoiceNumber = invoiceNumber;
CustomerName = customerName;
Amount = amount;
Date = date;
}
}
不仅仅是在密码正确时显示消息框,您还可以进一步增强应用程序的功能。例如,在密码正确后,您可以显示另一个用户控制(UserControl),如 PersonView
或 InvoiceView
,来展示用户的相关数据。您可以使用类似 ListBox
或 DataGrid
控件来显示和管理数据。
<UserControl x:Class="YourApp.InvoiceView" ...>
<ListBox Name="invoiceList" />
</UserControl>
当用户成功登录并输入正确密码时,您可以在现有视图中切换到新的 InvoiceView
视图,展示发票数据。例如,您可以在密码验证成功后通过代码打开新视图:
if (passwordEntered == correctPassword)
{
// 密码正确,显示发票视图
InvoiceView invoiceView = new InvoiceView();
invoiceView.Show();
}
您现在已经掌握了如何创建和操作数据,如何处理事件和用户输入,以及如何在 WPF 中使用视图和用户控制来展示数据。您可以根据自己的需求进一步扩展应用程序,例如:
ObservableCollection
来动态更新发票数据。现在,您已经掌握了开发 WPF 应用程序的基本技能,并且有足够的知识来创建更复杂的应用程序。我希望您在学习本章内容的过程中感到愉快,并且在接下来的章节中继续深入探索更多的 WPF 和 C# 开发技巧。祝您在后续的学习中收获更多,享受编程的乐趣!
欢迎回来。那么,我们来看看在这个视频中我们将构建的内容。我们将使用C#和WPF来构建这个应用程序。WPF(Windows Presentation Foundation)将允许我们创建一个货币转换器,正如你在这里看到的,界面上有图像和一个漂亮的输入框,我们可以在其中输入数值,然后可以将一种货币转换成另一种货币。好的,但我们从这个简单的例子开始。正如你看到的,我可以将我在这里输入的任意金额转换成另一种货币。比如从美元到欧元,或者从印度卢比到欧元,来看一下。我们可以看到₹1,337等于€15.72,或者$0.73,大概是这样的。然后我可以点击清除按钮,以清空数据并重新开始。那么,我们来看看€420兑换多少美元。我们可以看到,它兑换成476美元。好了,现在我们先用WPF构建这个界面,然后再添加功能。我们将在后台代码中进行这些操作。因此,首先我们创建一个新的项目。为了让它正常工作,我们需要确保一件事,就是我们有一个独立的组件。所以你可以去安装“NuGet包管理器”这个组件。你可以在这里搜索它,然后安装它。这个是我们所需要的。此外,如果你需要其他的功能,可能还想安装SQL Server,或者ASP.NET,当然,如果你需要使用ASP.NET的话。但在这里我们不会使用ASP.NET,暂时就这些。所以你可以启动你的Visual Studio Community版,比如我的是2019版,但其他版本也应该是一样的。然后你可以继续创建一个新的项目。重要的是要选择WPF应用程序 .NET框架, 你可以在这里搜索它,并一直滚动到找到WPF .NET框架,因为我们需要这个模板。选择这个模板,给它命名,我将它命名为“currency_converter_static”,因为我们将使用静态数字和货币转换的值,然后我们就可以创建这个项目了。顺便提一下,如果你想要跟随一个逐步的指南,包括代码和所有内容,你可以点击视频描述中的链接。那里会有这个博客文章的链接,里面包括所有的代码、许多截图以及非常详细的逐步指南,你可以在观看视频的同时跟着做。
项目创建完毕后,你会看到一个主窗口的XAML文件被打开,同时也会看到主窗口的代码文件。你应该了解这些文件,因为你至少需要理解C#的基础知识,并且要明白XAML部分的内容,它是WPF的部分。WPF实际上是一种XML格式的语言。所以我们将使用XML来创建我们的UI。XAML中定义的内容会被翻译成界面,基本上我们要重建视频开头展示的界面。如果你想查看项目中还有什么文件,可以打开解决方案资源管理器,你会看到你有一个app.config
文件,它也是XML格式的。这个文件可以根据需求进行修改,管理员可以在此控制应用程序能访问哪些受保护的资源,应用程序将使用哪些版本的程序集,以及数据库的连接字符串(我们稍后会看到)。基本上这是app.config
文件。接下来是App.xaml
文件,你也可以在这里添加应用程序资源。App.xaml
文件是用于订阅应用程序事件的地方,比如应用程序启动、未处理的异常等。它是应用程序的声明式启动点,而背后的代码文件是App.xaml.cs
,这是代码文件,你可以在其中添加逻辑代码。但我们将使用主窗口的代码文件来处理主窗口的逻辑,因为基本上我们希望在主窗口内运行代码并执行一些业务逻辑。
接下来,我们来看一下主窗口的XAML文件。你会看到,默认为我们创建了一个特定高度和宽度的窗口。如果你现在运行这个应用程序,你将看到一个空的窗口,它的高度为450像素,宽度为800像素,正如你在这里看到的那样。这基本上就是我们的应用程序。如果你愿意,你可以继续拖放一些元素到窗口中。例如,从工具箱里拖动一个按钮进去,然后重新运行应用程序,你会看到按钮出现在窗口中。这个按钮目前什么都不做,但你可以看到它位于网格内,并且其内容显示为“按钮”,你可以将其更改为“Hello”。然后,在顶部,你可以看到这个按钮现在显示为“Hello”而不是“按钮”。然后,按钮的一些属性会自动分配给它,比如水平对齐方式,它是左对齐的,还有边距属性,比如左边距为388像素,顶部边距为116像素。如果你拖动这个按钮,你会看到这些数值会发生变化。它的垂直对齐是顶部对齐,并且它的宽度是75像素。你可以随时将这个宽度改为100像素,那样按钮就会变得更宽。好的,但我不打算以这种方式进行操作,因为,当然,我可以通过拖放元素来做这些,但我更喜欢编写代码来控制UI,因为这给了我更多对UI的控制。正如我所说,你当然可以打开工具箱,查看你可以在WPF项目中使用哪些控件。工具箱顶部显示的是常用控件,如指针、边框、按钮、复选框、图片等,然后是所有WPF控件,里面有更多控件可以使用。你还可以看到拖放这些元素到项目中的时候,自动为它们分配了一些属性。例如,如果我拖一个标签控件进去,你会看到它有content
属性、水平对齐属性、边距以及垂直对齐属性。显然,除了这些属性外,还有很多其他的属性你可以修改。你可以在这里输入空白字符,看到它会列出一些你可以直接修改的属性。并且不仅是属性,还有一些命令可以在这里执行,某些事件也可以在这里处理,比如上下文菜单的打开、关闭等。所以有很多不同的属性和命令,你可以直接使用它们,玩一玩看看它们是做什么的。但当然,我们会关注在这个应用程序中最重要的那些。
好了,让我们开始吧。
首先,我将进行一些更改,我要修改这个窗口的标题。我将它从“main window”改为“currency converter”(货币转换器)。接下来,我将调整窗口大小,设置为size to content
(适应内容),并为窗口设置width
(宽度)和height
(高度),窗口的启动位置设为center owner
(居中显示)。如果你想要始终在屏幕中央打开窗口,也可以选择center screen
。让我们先测试一下,运行应用程序,你会看到它直接在屏幕中央打开,尽管如你所见,窗口内目前没有任何内容。
现在,由于设置了size to content
,窗口的大小会根据内容自动调整。即便我已经在这里硬编码了窗口的高度和宽度,但由于size to content
的设置,它会覆盖这些值。因此,编译器会忽略我在上面定义的高度和宽度。如果我将它们删除,再次运行应用程序,你会看到窗口的尺寸会恢复为我定义的默认值。但如果你希望窗口的大小与窗口内的内容大小一致,可以使用size to content
设置。如果我没有任何内容,窗口会非常小,但一旦加入内容,它的大小会随着内容的增加而自动调整,避免出现看起来很奇怪的情况。
接下来,让我们来看看窗口的结构。我们正在使用WPF
(Windows Presentation Foundation)来构建UI,它基于XML的语法。你可以看到我们使用了一些XML名称空间,它们允许我们在程序中使用一些特定的功能。然后,我们有一个grid
(网格),它包含了rows
(行)和columns
(列)。在此,我将添加一些行定义。首先,我们要定义grid row
,然后为每行指定高度。
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="62"/>
<RowDefinition Height="83"/>
<RowDefinition Height="54"/>
<RowDefinition Height="105"/>
<RowDefinition Height="150"/>
</Grid.RowDefinitions>
</Grid>
如你所见,这里我们有了五个行定义,每一行的高度都已指定。接下来我们要定义列,但暂时我们只关注行。每一行都将在grid
中占有一定的空间,这些空间的高度已通过上述代码定义。
现在,我们开始往网格中添加内容。我打算从添加一个border
(边框)开始。以下是我为这个边框添加的代码:
<Border Grid.Row="2" Width="800" BorderBrush="Red" BorderThickness="5" CornerRadius="10">
<Rectangle Fill="Red">
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#EC2075" Offset="0"/>
<GradientStop Color="Red" Offset="1"/>
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle>
</Border>
我们创建了一个border
,并将其放置在grid row 2
,它的宽度是800像素,边框颜色为红色,边框厚度为5像素,且具有10像素的圆角半径。在这个border
中,我们还放置了一个矩形rectangle
,它被填充了渐变色。
在这个矩形内,我们使用了LinearGradientBrush
来创建渐变色。渐变从左到右(StartPoint="0,0"
到EndPoint="1,0"
),并通过GradientStop
定义了两种颜色:一种是粉红色(#EC2075
),另一种是红色。我们为每种颜色指定了一个偏移量(Offset
),这决定了渐变的过渡方式。
GradientStop
颜色为粉红色,偏移量为0,表示从左侧开始。GradientStop
颜色为红色,偏移量为1,表示渐变的结束位置在右侧。BorderBrush
创建边框效果为了让设计看起来更有层次,我在border
中还添加了一个border brush
(边框画刷),这将使边框更加突出。你可以看到,虽然边框的颜色很细微,但它增强了整个视觉效果。
尽管这个矩形可能看起来比较简单,但它的设计方式其实提供了很大的自由度。你可以根据设计需求调整颜色、边框厚度、圆角半径等属性。对于界面的每个元素,你都有大量的属性可以进行自定义,以便创造出理想的UI效果。
StackPanel
接下来,我将开始使用StackPanel
来添加更多的内容。StackPanel
是一种常用的布局容器,它允许你按照垂直或水平方向排列子元素。在这个应用中,我将利用它来组织我们需要的各类控件。
首先,改变窗口的标题为“Currency Converter”,并设置窗口的大小为“适应内容”(Size to Content)。我们将窗口的启动位置设置为居中显示(Center Owner)。在测试时,你会发现窗口会自动居中显示,尽管此时窗口内没有任何内容。这是因为“Size to Content”会覆盖你设定的窗口宽高,即使你已经在代码中明确设置了窗口的宽度和高度。
接下来,我们开始创建Grid布局。在WPF中,Grid是用来排列和定位UI元素的容器。Grid由多个行和列组成,在使用Grid时,我们需要定义这些行和列的结构。我们可以为Grid添加“行定义”(RowDefinitions)。例如,定义三行的高度分别为62、83和150像素。你可以看到Grid中的每一行的高度已经设置,但还没有列定义。接下来,我们可以添加内容到Grid中并在特定的行中定义位置。
我们开始在Grid中添加内容,首先是一个边框(Border)。在Grid的第二行(Grid Row 2)中添加一个边框,并在其中创建一个矩形(Rectangle)。矩形的背景使用渐变色(Linear Gradient Brush),从左到右有一个渐变效果,起始点为0,0
,结束点为1,0
。我们定义了渐变的颜色起止:起点是粉红色(#EC2075),终点是红色。使用这种渐变效果使得背景颜色看起来更加丰富。
接着,我们开始使用StackPanel
。StackPanel
是一个垂直或水平排列其子元素的容器。在StackPanel
中,我首先添加一个标签(Label),其内容为“Hello World”。你可以通过设置StackPanel
的Width
属性,使其占据整个窗口宽度,或者为其设置一定的Height
来控制其显示。默认情况下,StackPanel
会自动占据Grid中的第一个可用位置,但我们也可以显式指定它在Grid中的位置,比如Grid.Row="0"
。
在此基础上,我将StackPanel
的Orientation
属性设置为Horizontal
,使得标签水平排列。接下来,我修改标签的内容为“Currency Converter”,并调整标签的宽度为1000,填充整个宽度,同时将文本内容居中对齐。你可以使用HorizontalContentAlignment
和VerticalContentAlignment
来控制文本的对齐方式。
标签的字体大小被设置为25,并使用之前定义的粉红色(#EC2075)作为字体颜色。需要注意的是,标签的颜色属性使用的是Foreground
,而不是Color
。这样我们能够通过为Foreground
设置颜色来改变文本的显示颜色。
在UI设计过程中,如果你遇到错误,XAML编辑器会显示出错误信息。例如,缺少某些引用的资源,或者某些控件的资源没有定义。在这个例子中,我们遇到了一个关于FontAwesome
图标的问题。解决此问题的步骤是打开NuGet Package Manager
,安装FontAwesome WPF
包,并在XAML
中添加对应的命名空间。
为了让按钮变得圆角化,我们需要在App.xaml
文件中定义一个按钮样式。通过设置Button
控件的ControlTemplate
,我们可以为按钮设置圆角边框。使用TemplateBinding
可以将按钮的背景属性与控件模板中的背景关联,最终实现我们希望的圆角按钮效果。
通过这些步骤,我们逐步构建了一个简洁的UI布局。首先,定义了窗口的基本属性,然后使用Grid
和StackPanel
来安排UI元素,接着通过设置各种对齐和样式属性来调整布局和外观,最终实现了一个功能完整且美观的界面。
在这段代码中,我们继续讲解如何通过在 WPF (Windows Presentation Foundation) 中使用 StackPanel
来构建用户界面,并通过不同的控件和布局属性调整元素的位置和样式。我们主要讨论了如何使用 StackPanel
进行布局,如何实现按钮的圆角样式,以及如何通过 XAML
和后端 C# 代码交互来动态更新界面。
我们首先介绍了如何创建一个圆角按钮样式。通过在 App.xaml
文件中定义一个全局样式,可以使应用程序中的所有按钮都变为圆角按钮。具体步骤如下:
App.xaml
中添加样式,设置 Button
控件的 ControlTemplate
,使其具有圆角和特定的背景色。StaticResource
引用这个样式,使按钮在使用时自动应用圆角样式。<Style x:Key="ButtonRound" TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" BorderBrush="Black" BorderThickness="0.5" CornerRadius="5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
通过这种方式,我们可以在全局范围内为按钮应用圆角效果,而不需要为每个按钮单独设置样式。
在这个示例中,使用了多个 StackPanel
来布局 UI 元素。每个 StackPanel
用来垂直或水平堆叠子元素,并通过设置 Orientation
属性来决定堆叠方向。
我们首先通过 StackPanel
的 Orientation
属性设置为 Horizontal
,将多个 Label
水平排列。
<StackPanel Orientation="Horizontal">
<Label Content="Hello World"/>
<Label Content="Currency Converter"/>
</StackPanel>
此时,Hello World
和 Currency Converter
将会并排显示在同一行中。
如果将 Orientation
设置为 Vertical
,元素将会垂直排列在一起。下面是修改后的代码:
<StackPanel Orientation="Vertical">
<Label Content="Converted Currency"/>
<Label Content="Enter Amount"/>
</StackPanel>
这会将两个标签垂直显示。
我们将 StackPanel
放置在 Grid
的特定行中,并为其设置宽度和高度。通过定义 Grid.Row
和 Grid.Column
来明确元素的位置。
例如,以下代码将 StackPanel
放置在 Grid
的第一行,并为其设置宽度和高度:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Width="1000" Height="50">
<Label Content="Currency Converter" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
</Grid>
通过 C# 代码,我们可以动态地修改界面元素的内容。例如,在按钮点击事件中,我们可以更新 Label
的文本。
x:Name
属性,在 C# 代码中引用该控件。Label
的 Content
属性。以下是一个按钮点击事件的处理方法:
private void ConvertButton_Click(object sender, RoutedEventArgs e)
{
currencyLabel.Content = "Hello Button Clicker";
}
这样,当用户点击按钮时,currencyLabel
的文本将更新为 "Hello Button Clicker"。
在项目中使用图片时,我们首先需要将图片添加到项目文件夹中。例如,将图片拖放到 Images
文件夹中,并在 XAML 文件中引用它:
<Image Source="Images/logo.png" Width="100" Height="100"/>
这样,我们就可以在应用程序中显示图片。
通过为控件设置事件处理方法,我们可以处理用户的输入。例如,在 TextBox
控件中,我们可以验证用户输入的文本,确保它符合特定的格式。
PreviewTextInput
或 TextChanged
等事件,绑定到 C# 代码中的方法。例如,以下代码将设置一个文本框的输入验证事件:
<TextBox Name="AmountTextBox" PreviewTextInput="AmountTextBox_PreviewTextInput"/>
private void AmountTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
// 只允许输入数字
if (!char.IsDigit(e.Text, 0))
{
e.Handled = true; // 阻止非法字符输入
}
}
在这个教程中,我们展示了如何通过 StackPanel
和 Grid
控件创建灵活的布局,并通过 C# 代码动态更新 UI 元素。我们还学习了如何创建圆角按钮样式,如何处理用户输入事件,以及如何在 WPF 应用程序中使用图片。通过这种方式,我们可以将 UI 和逻辑分离,从而使开发更加高效和模块化。
欢迎回来。接下来我们有另一个 StackPanel
,它也位于 Grid
的第二行,并且也是水平对齐。它的高度是 90,宽度是 800,基本上包含了我在这里放的三个元素。这个元素是一个文本框,TextBox
是一种你可以输入文本的框。为了能够获取用户输入的值,我为这个文本框设置了一个名称,这样我就可以通过这个名称在程序中访问它的值。所以我将文本框命名为 textCurrency
,并为它设置了宽度 200 和高度 30,还给它设置了一些边距,这意味着它与左侧有一定的间距,边距的值是 40。如果我将其设置为 0,比如说,你可以看到它会直接靠左对齐。所以这个边距 40 的作用是这样。如果我想要在其他方向上添加边距,比如说在顶部,可以通过第二个值来设置;如果想设置右侧的边距,可以用第三个值;如果是底部的边距,则用最后一个值。不过需要注意的是,如果我增加了底部边距,它可能会让元素从 UI 中消失,所以在调整这些边距值时需要小心。
接下来是一个 PreviewTextInput
,用于实现数字验证文本框。这个功能是当用户输入文本时,我们限制他们只能输入数字。因此,我们创建了一个名为 numberValidationTextBox
的函数,用于处理用户输入的文本,并过滤掉非数字字符。numberValidationTextBox
函数会通过 TextCompositionEventArgs
触发,这个参数会提供关于用户输入的信息,我们可以利用它来限制非数字字符的输入。
文本框的垂直对齐设置为顶部,内容居中显示,这就是我们创建的文本框的基本设置。
接下来是一个 ComboBox
,即下拉框,它允许我从多个选项中选择值。这个值是我们在代码后端中定义的。它的名称是 CNBFromCurrency
,并且我们可以通过它来访问这个下拉框。我们需要给这个 ComboBox
设置一个 ItemSource
,这很重要,因为它需要知道可以显示哪些项,稍后我们会详细讲解。
然后,我们有一个图像元素,这是一个图标,实际上是一个用于显示转换操作的图标。为此,我们之前需要通过 Font Awesome
来加载图标。Font Awesome
是一个图标库,它为我们提供了大量的图标资源,正是通过这种方式,我们才能在项目中使用这个转换图标。
接下来是第二个 ComboBox
,它与第一个相似,允许我选择多个值。这两个下拉框分别是用来选择“从哪种货币”转换到“哪种货币”的。
每个 ComboBox
都有一些设置,我们用到了 MaxDropDownHeight
属性,限制了下拉列表的最大高度为 150 像素。
接下来是我们的两个按钮。这些按钮位于第三行的 StackPanel
中,它的高度是 100,宽度是 1000,水平对齐,表示按钮将并排显示在一起。在这个 StackPanel
中我们有两个按钮,第一个是 Convert
按钮,第二个是 Clear
按钮。
对于 Convert
按钮,我们给它设置了一个名称 Convert
,虽然通常我们会给按钮一个更具描述性的名称,如 BTNConvert
,但这里使用 Convert
也是可以的。按钮的文本内容是 Convert
,并且按钮的 Click
属性设置为当按钮被点击时,执行 ConvertClick
方法。在这里,我们通过双击按钮,自动生成了一个事件处理函数 ConvertClick
,这个函数的参数包括一个发送者(sender
),即被点击的按钮,以及一个 RoutedEventArgs
,用于处理事件。
你当然可以创建自己的方法来处理点击事件。例如,你可以手动创建一个名为 MyClickEvent
的方法。为了使它能够正常工作,事件处理方法必须包括 sender
和 RoutedEventArgs
参数。但我们通常直接使用自动生成的 ConvertClick
方法,它已经能够完成大部分的工作。
每个按钮的样式都可以通过 XAML 来定义,像按钮的背景色、前景色、字体大小等。对于 Convert
按钮,我们设置了 FontSize
为 20,并且给它设置了一个 LinearGradientBrush
背景,用于显示渐变色。你也可以使用简单的颜色,如 AliceBlue
,并通过 Button
的 Background
属性来设置颜色。如果想要用渐变色,就需要像我们之前一样使用 LinearGradientBrush
。
对于 Clear
按钮的设置和 Convert
按钮类似,我们也设置了 FontSize
和 Background
,并且同样指定了点击时触发的事件处理函数。
最后,我们有一个 StackPanel
,它位于第四行,用于显示我们的项目logo。这个 StackPanel
包含一个图像元素,并且该图像的源(Source
)设置为 images/logo.png
,这是我们在 images
文件夹中存放的logo文件。为了让这个图像显示正确,我们必须确保文件已经被正确添加到项目中。在这里,我们指定了图像的高度和宽度,并且设置了它在界面中的垂直对齐方式。
通过这种方式,我们已经完成了整个UI的设置和布局,确保它在窗口中显示正确,并且能够与后端逻辑进行交互。
好的,现在我们来讨论一下逻辑部分。正如你之前所见,我们可以直接在我们的代码背后文件中添加逻辑,这个文件就是 MainWindow.xaml.cs
文件。这个文件中包含了程序的主逻辑,所有的功能都从这里开始。
首先,我们需要为我们的两个 ComboBox
绑定值。用户可以通过这两个下拉框选择不同的选项。为了能够使用这些下拉框中的值,我们必须确保它们被正确绑定到数据源。为此,我将创建一个新的方法,名为 BindCurrency
,用于绑定数据。
private void BindCurrency()
{
// 创建一个新的数据表
DataTable dataTableCurrency = new DataTable();
// 添加两列到数据表
dataTableCurrency.Columns.Add("Text");
dataTableCurrency.Columns.Add("Value");
// 添加行数据
dataTableCurrency.Rows.Add("Select", 0);
dataTableCurrency.Rows.Add("Indian Rupees", 1);
dataTableCurrency.Rows.Add("US Dollars", 75);
dataTableCurrency.Rows.Add("Euros", 85);
dataTableCurrency.Rows.Add("Pound", 100); // 此处“Pound”只是示例,实际可替换为真实货币
// 将数据表作为 ItemSource 绑定到 ComboBox
CNBFromCurrency.ItemsSource = dataTableCurrency.DefaultView;
// 设置显示列和对应值列
CNBFromCurrency.DisplayMemberPath = "Text";
CNBFromCurrency.SelectedValuePath = "Value";
// 设置默认选中项
CNBFromCurrency.SelectedIndex = 0;
}
在上面的代码中,我们创建了一个 DataTable
对象 dataTableCurrency
,并为它添加了两列:Text
和 Value
。这些列分别用于显示文本和存储选定的值。然后,我们为每个货币类型添加了一些行。
接下来,我们将数据表的默认视图设置为 CNBFromCurrency
的 ItemsSource
。 DisplayMemberPath
被设置为 Text
,以确保显示货币名称,而 SelectedValuePath
则设置为 Value
,确保每个选项背后有对应的值。
为了执行这个方法,我们需要在窗口加载时调用它。例如,可以在窗口的构造函数中调用:
public MainWindow()
{
InitializeComponent();
BindCurrency(); // 调用绑定方法
}
对于第二个 ComboBox
(CNBToCurrency
),我们将使用相同的数据源。这是因为它们需要显示相同的货币选择。
CNBToCurrency.ItemsSource = dataTableCurrency.DefaultView;
CNBToCurrency.DisplayMemberPath = "Text";
CNBToCurrency.SelectedValuePath = "Value";
CNBToCurrency.SelectedIndex = 0;
现在,我们来看看如何处理 Convert
按钮的点击事件。当用户点击转换按钮时,我们不仅要检查输入的值是否为空,还需要确保输入的是有效的数字。
private void ConvertClick(object sender, RoutedEventArgs e)
{
double convertedValue = 0;
// 检查货币输入框是否为空
if (string.IsNullOrEmpty(TextCurrency.Text))
{
MessageBox.Show("Please enter currency", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
TextCurrency.Focus(); // 将焦点设置到货币输入框
return;
}
// 这里还可以添加数字验证逻辑,确保用户输入的是有效数字
if (!double.TryParse(TextCurrency.Text, out convertedValue))
{
MessageBox.Show("Please enter a valid number", "Information", MessageBoxButton.OK, MessageBoxImage.Warning);
TextCurrency.Focus(); // 错误输入时,将焦点设置到货币输入框
return;
}
// 获取选择的货币
int fromCurrencyValue = (int)CNBFromCurrency.SelectedValue;
int toCurrencyValue = (int)CNBToCurrency.SelectedValue;
// 示例转换逻辑
double conversionRate = GetConversionRate(fromCurrencyValue, toCurrencyValue);
// 计算转换后的值
convertedValue *= conversionRate;
// 显示转换后的结果
MessageBox.Show($"Converted Value: {convertedValue}", "Result", MessageBoxButton.OK, MessageBoxImage.Information);
}
private double GetConversionRate(int fromCurrency, int toCurrency)
{
// 这里可以添加实际的货币转换逻辑,比如根据货币类型查询汇率
// 目前假设从一种货币转换到另一种货币的汇率为1.1
return 1.1;
}
文本框验证:我们首先检查 TextCurrency
输入框是否为空。如果为空,弹出一个提示框提醒用户输入货币金额,并将焦点重新设置到文本框中。如果输入的内容不是有效的数字(使用 TryParse
检查),我们同样给出提示并让用户重新输入。
汇率转换:在 ConvertClick
方法中,我们根据选择的货币类型来获取相应的转换汇率。这里我们简化了转换的逻辑,假设汇率为1.1。实际情况中,你可以调用 API 或使用更复杂的逻辑来处理汇率转换。
结果展示:最后,计算转换后的金额并通过 MessageBox
显示给用户。
当我们运行应用程序时,首先会看到两个下拉框,它们显示了可选的货币类型。如果我们没有输入任何金额并点击“Convert”按钮,程序会提示用户输入金额。输入有效数字后,程序会计算并显示转换结果。
这个过程中,我们实现了:
ComboBox
绑定货币数据。这些都是基础的逻辑部分,之后我们还可以进一步扩展和优化,比如处理不同的货币汇率,或者将转换的逻辑通过调用外部API来实现。
接下来,让我们分析并扩展这个应用的代码,理解如何处理用户输入、验证以及界面交互。
首先,我们对输入进行验证,确保用户输入的数据有效。如果用户没有输入货币值,或者选择的货币类型为空,我们会通过消息框提示用户:
if (string.IsNullOrEmpty(textCurrency.Text))
{
MessageBox.Show("Please enter currency", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
textCurrency.Focus();
return;
}
如果用户没有在文本框中输入有效的货币值,我们会显示“请输入货币”的消息,并将焦点重新设置到文本框中。
我们还检查了是否用户已经在两个下拉框中选择了货币类型。如果未选择,我们会提示用户选择货币:
else if (fromCurrencyComboBox.SelectedValue == null || fromCurrencyComboBox.SelectedIndex == 0)
{
MessageBox.Show("Please select currency from", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
fromCurrencyComboBox.Focus();
return;
}
else if (toCurrencyComboBox.SelectedValue == null || toCurrencyComboBox.SelectedIndex == 0)
{
MessageBox.Show("Please select currency to", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
toCurrencyComboBox.Focus();
return;
}
如果 comboBoxFromCurrency
或 comboBoxToCurrency
的选择是“Select”,那么程序会弹出提示框让用户选择正确的货币类型。
当用户选择了源货币和目标货币,并输入了金额后,我们计算转换后的货币值。如果源货币和目标货币相同,则无需转换:
if (fromCurrencyComboBox.SelectedValue.ToString() == toCurrencyComboBox.SelectedValue.ToString())
{
lblCurrency.Content = $"{toCurrencyComboBox.Text} {convertedValue:F3}";
}
此处,convertedValue
是通过货币选择和用户输入的金额计算得出的。我们通过设置 F3
来确保结果显示为小数点后3位。
如果源货币与目标货币不同,我们会执行一个乘法和除法的操作:
else
{
double fromCurrencyValue = Convert.ToDouble(fromCurrencyComboBox.SelectedValue.ToString());
double toCurrencyValue = Convert.ToDouble(toCurrencyComboBox.SelectedValue.ToString());
double amount = Convert.ToDouble(textCurrency.Text);
double convertedValue = (fromCurrencyValue * amount) / toCurrencyValue;
lblCurrency.Content = $"{toCurrencyComboBox.Text} {convertedValue:F3}";
}
此计算公式假设所有的货币转换率已经以某种方式定义,并通过选择框绑定。
清除按钮的功能是重置所有控件的值。我们通过创建 ClearControls
方法来实现这个功能:
private void ClearControls()
{
textCurrency.Clear();
fromCurrencyComboBox.SelectedIndex = 0;
toCurrencyComboBox.SelectedIndex = 0;
lblCurrency.Content = "";
textCurrency.Focus();
}
每次点击清除按钮时,都会清空输入框,重置下拉框的选择并清空标签。
为了确保用户只能输入有效的数字,我们对 textCurrency
使用了正则表达式验证。这确保了只能输入数字:
private void NumberValidationTextBox(object sender, TextCompositionEventArgs e)
{
Regex regex = new Regex("[^0-9]+");
e.Handled = regex.IsMatch(e.Text);
}
这个正则表达式限制了用户只能输入数字,不允许任何非数字字符(如字母或符号)。如果用户输入了非法字符,这个字符将被忽略。
为了提高用户体验,我们还为窗口设置了最小尺寸,以防止窗口缩得过小影响布局。通过设置 MinHeight
和 MinWidth
,我们可以确保窗口始终保持足够的大小:
this.MinHeight = 400;
this.MinWidth = 1000;
这些设置确保了即使用户尝试手动缩小窗口,界面布局仍能正常显示。
一切设置完成后,用户可以输入金额,选择源货币和目标货币,点击“转换”按钮后,界面会显示转换后的金额。若选择相同的源货币和目标货币,则会直接显示原始金额。清除按钮可以重置所有输入字段,方便用户重新输入。
通过这些操作,用户不仅能获得实时的货币转换结果,还能得到友好的错误提示和操作反馈。
MessageBox
提示用户确保输入和选择有效数据。欢迎来到数据库章节。在这一章中,我们将学习如何使用数据库,或者说“数据库”(根据你的发音习惯而定,两个都可以)。在这里,我们将深入了解如何在程序中使用大量数据、如何创建数据,甚至如何建立一个数据库,以及与之相关的各个组成部分。这是编程中非常重要的一部分。如果你想成为一名全职的 C# 开发者,那么了解如何创建和使用数据库非常关键。更重要的是,你将学会如何从数据库中获取数据,并对这些数据进行处理。
在本章结束时,你将具备如何与数据库交互的基本能力,并能够在你的程序中高效地使用这些数据。接下来,我们还将通过 LINQ 来提升效率,这部分内容将在下一章中介绍。但问题是,在掌握 LINQ 之前,你需要先了解数据库的基本知识。因此,让我们从数据库的基础知识开始,然后再进入更高级的部分。
我们将在接下来的内容中深入探讨这些概念,并通过实践帮助你掌握数据库的使用方法。
欢迎来到这节课程。我是 Yannick,Dennis 是我的共同讲师。时间已经过去了一段时间,我们觉得有必要更新这节课程。所以,在这节课中,我们将介绍如何设置 SQL Server 实例以及如何在 Visual Studio 中设置连接。
在旧版本的这节课程中,我们使用的是 Microsoft SQL Server 2017,但该版本已不再维护,现在我们开始使用 Microsoft SQL Server 2019 Express。所以,如果你跟随这个视频操作,请搜索 SQL Server 2019 Express,并点击第一个链接进行下载。你也可以直接访问微软官网,向下滚动直到看到如下窗口,选择你的语言。我会选择英语,然后点击下载,开始下载 SQL Server 2019 Express 安装程序。
一旦下载完成,打开它,开始安装实例。选择“自定义”安装,然后选择一个文件夹用于安装,点击“安装”。安装可能需要一些时间,安装完成后,你将进入 SQL Server 安装中心,在这里我们可以设置并创建一个新的 SQL Server 独立安装实例。选择第一个选项,点击进入。接下来将弹出一个窗口,开始配置 SQL Server。点击“下一步”,等待加载完成。继续点击“下一步”,直到看到这个窗口,显示“执行 SQL Server 新安装或向现有安装中添加内容”。
由于我已经多次安装过 SQL Server,因此这一步对我来说可能稍显不同。但我们还是选择进行全新的 SQL Server 2019 安装。选择第一个选项,点击“下一步”。接着,接受许可协议并再次点击“下一步”。此时可以选择一些功能。我会给你一个我正在安装的功能概览:将安装数据库引擎服务,取消勾选“机器学习服务和语言”选项,这样可以节省很多空间。SQL Server 复制功能保持勾选,全文和语义提取功能也勾选。对于共享功能,默认所有选项都勾选了,确保你知道我正在安装哪些内容。
继续点击“下一步”,选择默认实例,并获得 MySQL 服务器名称作为默认实例名称。你也可以选择自己的名字。我会从之前视频中复制 Pantagruel 的 SQL 作为实例名称,以便你在接下来的教学中明白我为何选择这个名称。接下来,实例 ID 会自动更新为“Pantagruel's SQL”,然后点击“下一步”,等待加载完成。
接着,选择 SQL Server 数据库引擎并点击“下一步”。此时可以设置身份验证模式。我们可以选择 Windows 身份验证模式,但我建议选择混合模式,这样你就可以设置自己的账户和密码。请写下密码并确保记住它,以便以后登录。为了方便演示,我会使用简单的密码“123456”。不过,在实际应用中,你应该选择更复杂的密码,尤其是在生产环境中。
接下来,点击“添加当前用户”,你会看到这里显示的是我的计算机用户名。通常情况下,安装时它会自动选择当前用户,所以请确保这个字段不为空。然后点击“下一步”。最后,你将看到一个屏幕,显示“完成 SQL Server 2019 安装,产品更新成功”。一切安装完成后,点击“关闭”按钮。
现在,我们已经安装了 SQL Server 实例,但如果我们希望以图形化界面查看和管理数据库、表格、条目,执行 SQL 查询等操作,就需要安装一个工具,叫做 SQL Server Management Studio(SSMS)。重申一下,刚刚安装的 SQL Server 本身并没有图形用户界面。如果你希望通过 GUI 来管理数据库,你需要安装 SQL Server Management Studio。
点击相应的链接,就会打开一个新的页面。你可以看到“下载 SQL Server Management Studio”的选项,点击免费下载安装。下载完成后,按照提示进行安装,安装过程中无需特别注意任何选项。安装完成后,打开它,搜索“SQL Server Management Studio”或简称“SSMS”,你就能看到 Microsoft SQL Server Management Studio 18。
打开 Microsoft SQL Server Management Studio 后,你应该会看到一个“连接到服务器”的窗口。你可能已经看到服务器名称被自动填充,如果没有,可以点击下拉箭头,选择“浏览更多”并打开“数据库引擎”,找到你之前设置的 SQL Server 实例名(在我的例子中是 Pantagruel's SQL)。选择该实例并点击“确定”,然后在身份验证方式中选择 SQL Server 身份验证,输入设置时的管理员账户(默认是 SA)以及你的密码,然后点击“连接”。
现在,你应该已经成功连接到 SQL Server 实例,并在左侧看到“数据库”节点。此时你可能只看到系统数据库,因为我们还没有创建任何自定义数据库。一旦创建数据库,它将显示在此处。SQL Server Management Studio 是一个非常方便的工具,帮助你以可视化的方式查看和管理数据库。
接下来,我们将在 Visual Studio 中设置与数据库的连接。此时,我使用的是 Visual Studio 2019,但对于任何版本的 Visual Studio,过程应该是类似的。在 Visual Studio 中,找到“服务器资源管理器”面板,如果你没有看到它,可以点击“视图”然后选择“服务器资源管理器”来打开它。
另外,注意 SQL Server 对象资源管理器,你也可以通过它连接到 SQL Server 或创建新数据库。但现在我们主要使用服务器资源管理器。在“服务器资源管理器”中,右键点击“数据连接”,然后选择“创建新的 SQL Server 数据库”。
正如之前所说,我们并没有创建新的数据库,只是安装了 SQL Server 实例。所以,现在我们来设置一个新的数据库。在“服务器名称”下拉框中选择你的 SQL Server 实例名称。如果你幸运的话,你可能会看到它。如果没有,可以在 SQL Server Management Studio 中右键点击你的 SQL Server 实例,选择“属性”,在这里可以看到服务器名称,复制它,然后回到 Visual Studio 填写这个名称。
接着,使用 SQL Server 身份验证,输入管理员账户(如 SA)和密码,然后选择“保存密码”。为数据库命名,我这里命名为“panel DB”,你也可以取其他名字。点击“确定”,就创建了我们的数据库。你可以看到它出现在“数据连接”下。
如果你再回到 SQL Server Management Studio,右键点击刷新,你将看到新创建的数据库(panel DB)。此时,我们已经成功创建了一个 SQL Server 实例,连接它,并通过 Visual Studio 创建了一个 SQL 数据库。
到此为止,我们已经完成了 SQL Server 实例的安装、配置和数据库创建过程。接下来,你可以开始在项目中使用这个数据库进行开发和管理了。感谢观看!
欢迎回来。在本视频中,我们将创建我们的第一个表格。在我们开始之前,我想向你展示一下本章节的最终成果。正如你所看到的,我这里有一个名为“Main Window”的窗口,其中包含了多个不同的控件。所以我们有标签控件,有列表框,还有多个按钮和多个列表框。你在这里看到的是多个动物园(zoos),嗯,这些名字可能已经被我稍微修改过了,但你可以在这里选择一个动物园,你将看到该动物园内的相关动物。
假设我们有一个位于东京的动物园,里面有鲨鱼,嗯,实际上有多条鲨鱼,还有鳄鱼、壁虎、鹰等等动物。而在纽约,我们有不同种类的动物。纽约的动物园比较小,只有一只鳄鱼,但后来我们决定给纽约动物园添加一只猴子。所以我们将把这只猴子添加到纽约动物园。你可以看到,这里列出了相关的动物。
然后,一旦我点击右侧的某个动物,或者点击某个动物园,底部的文本框将改变其值。它会将城市名称的值显示在文本框中。例如,这里我有“Cairo 2”,我想更新这个值。所以我将删除数字“2”,然后更新这个动物园。现在,你可以看到,“Cairo”已经更新。我也可以对我的“米兰动物园”做同样的操作。我将把“米兰”更新为“blah blah zoo”(一个虚构的动物园名称),然后彻底删除它。同样的操作我也会对我们的“44动物”进行,完全删除这只动物。
总体来说,你可以看到,我们现在有多个不同的元素需要考虑。在这里,我们使用了多个不同的表格。例如,我们有一个“动物园”表,一个“动物”表,以及一个将这两个表连接在一起的中间表,这将是我们下一步挑战的内容。接下来,我们将构建这个用户界面,虽然它不算是最漂亮的,但它非常实用,能够帮助你理解数据库的基本构建和如何在 C# 和一些 SQL 中使用数据库。
好的,那我们就开始吧。所以我们返回到 Visual Studio。你可以看到,我在这里有两个连接,而你应该只有一个连接。我还创建了另一个连接,这是我刚才用来展示演示的连接。接下来,我们将使用我新创建的连接。正如你所看到的,这里还没有任何表格,所以我们需要创建一个新表格。右击“表格”选项,选择“新建表格”,这时会弹出一个小窗口。
接下来,我将把服务器资源管理器稍微隐藏一下,让它不那么干扰。现在看起来好多了。我们的小表格有一个 ID 列,类型为整数,不允许为空,并且没有默认值。你可以看到这里有一个小钥匙图标,这意味着这是主键列。你基本上是在这里创建表格的列。我创建了一个名为“ID”的列,现在我将创建另一个列,命名为“location”(位置)。然后,我不允许它为空。接下来,我需要更改数据类型。虽然这里的数据类型和 C# 中的类型不完全相同,但它们有很多相似之处。例如,你可以看到日期、字符、位(bit)、整数等类型。我希望使用的是“varchar(50)”类型,这允许我在“location”列中存储最多50个字符的字符串。
另一个需要更改的重要设置是,我希望将 ID 列设置为“身份列”。我会在左侧属性面板中选择“身份列”(identity specification),然后将“是否为身份”设置为“是”。这样,每次插入新记录时,ID 会自动递增。这意味着每一条记录都会有一个唯一的 ID,就像我们在继承示例中提到的通知 ID 等一样。
现在,我们基本上完成了表格的设置。接下来,我要更改表格的名称。我不希望它叫“table”,而是想给它取一个更合适的名字——“Zoo”表格。注意,我使用了单数形式的“Zoo”,而不是复数形式“Zoos”,因为有很多理由支持这种做法。如果你有兴趣了解更多理由,可以查阅 StackOverflow 上的一篇精彩回答。
好了,我们现在有了一个名为“Zoo”的表格,但它还没有创建出来。你可以看到现在我们还处于 T-SQL 选项卡中,这里是创建表格的地方。当你点击顶部的“更新”按钮时,SQL 代码将会执行,进而在数据库中创建名为“Zoo”的表格。该表格将包含一个“ID”列(类型为整数,且不允许为空,作为主键),还有一个“location”列(类型为“varchar(50)”并且不允许为空)。
点击“更新”按钮后,你会看到数据库没有立刻变化。如果刷新一下,你就会看到“Zoo”表格已经出现了,并且该表格包含“ID”和“location”两个字段。
接下来,我想访问这个“Zoo”表格并修改它的值。你可以右击“Zoo”表格,然后选择“显示表格数据”(Show Table Data),这样就会弹出一个表格数据视图。在这里,你将看到“ID”和“location”两个字段,目前的“location”是空的(null),因为我们还没有插入数据。接下来,我将插入一些位置数据,比如“New York”。你会看到,ID 为 1 的记录已经插入,location 设置为“New York”。接着,我可以继续添加其他位置数据,比如“Tokyo”,“Berlin”,“Cairo”和“Milano”。当然,你可以继续添加更多的城市。
至此,我们已经在 Zoo 表格中插入了多个不同的城市,每个城市都有一个唯一的 ID,ID 从 1 到 5。
接下来,我们将创建一个新的项目,因为我们已经在服务器资源管理器中配置好了表格,但还没有创建实际的项目。我们新建一个项目,命名为“WPF Zoo Manager”,然后点击 OK。
创建好 WPF 项目后,我们接下来要做的是添加数据源。点击“数据源”按钮,如果你没有看到这个选项,可以右击项目名称,选择“添加数据源”。然后,选择“数据库”作为数据源类型,点击“下一步”,选择数据集作为数据库模型。接下来,选择你之前创建的数据库连接。
然后点击“完成”,这样你就可以看到你的数据源已被添加。接下来,你可以右击“Zoo”表格,选择“编辑数据集”进行设计。这里会显示数据库设计,展示“Zoo”表格的结构和操作。
我们还需要在主窗口中设置 SQL 字符串。在代码中,我们需要使用“配置管理器”(Configuration Manager)来读取连接字符串。为了让它能够正常工作,我们需要添加一个对“System.Configuration”程序集的引用,然后在代码中导入“using System.Configuration”。
设置完这些后,我们就可以在下一步视频中继续处理与 SQL 部分相关的应用程序开发了。
欢迎回来!在本视频中,我们将创建两个表,并设置它们,以便能够编写我们在上一个视频中看到的那个小程序。好了,让我们开始吧,首先在这里创建一个新表。我打开服务器资源管理器中的“表”部分,选择正确的数据连接,然后创建一个新表。点击“添加新表”。新表应该命名为animal
,因为它将包含我们所有的不同动物。然后,如你所见,我在这里更改表名为create table dbo animal
。接下来,我们需要添加一列,我将其命名为name
,数据类型选择为varchar
,长度为50。所以这个列最多将包含50个字符。同样,需要注意的是,这个ID列将作为我们的主键。因此,我们设置其ID规格或者身份规格(Identity Specification),并将其设置为“is identity”为真,这样它会自动递增。你可以看到它将自动递增,这是我们在zoo
表中看到的相同做法,现在我们在animal
表中也做了相同的操作。一旦完成,可以按下更新键,更新数据库。更新完成后,我们可以刷新一下,看到我们在表格中有了一个animal
表。
现在,让我们往这个表中添加一些数据。点击“显示数据”或者“表数据”,然后在这里我将添加一些动物。例如,我将添加一条鲨鱼,然后是小丑鱼、猴子、狼、壁虎、鳄鱼、猫头鹰和鹦鹉。就这样,我输入了这些八个动物的名字。你也可以使用其他名字,这只是一些常见的动物名称。可以看到,每个动物都有一个唯一的ID,并且每次输入一个新的值时,ID都会自动递增。现在我们已经在表中有了这八个动物。
接下来,我想做的是创建一个动物和动物园之间的关系。这样,鲨鱼可以出现在纽约的动物园里,也可以出现在迈阿密、米兰或柏林的动物园里。类似地,狼可以在迈阿密出现,也可以在纽约出现,但不一定是这样。所以我需要创建一个关系表,用来连接动物和动物园。接下来,让我们创建这个新表。它将有一个ID,并且包含一个zoo_id
,类型为int
,允许空值。然后我将关闭这个选项。同时,表中还将包含一个animal_id
,它也是int
类型,允许空值。现在,表的主键ID不能满足要求了,我希望它也能自动递增。于是,我在右侧的属性中找到identity specification
,将其从“false”改为“true”。这样,表中的ID就会自动递增了。这将创建我们的表。我还会将它的名字改成Zoo_Animal
,因为它将表示动物和动物园之间的关系,即哪些动物在哪些动物园里。完成这些操作后,点击更新,创建这个表格。然后刷新一下,我们就会看到Zoo_Animal
表出现在了表格中。
接下来,我们需要做一些配置,才能查看这些数据,或者让这些动物和动物园的数据可用。为此,我们需要更新数据源。为此,我将按下Alt + Shift + DX
,然后选择一个动物园管理器,打开数据源配置向导。在向导中,点击“完成”,它会获取数据。在这里你可以看到,只有Zoo
表被选中了,我还需要选中Animal
和Zoo_Animal
这两个表,选择它们后点击“完成”。这样,它们就被添加到我们的数据源中了。现在,我们的数据源包含了所有的表数据。如果没有这些配置,它们就会为空。现在,我们可以用设计器来编辑我们的数据集了。我将打开它,可以看到我已经有了三张表:Zoo
表、Animal
表和Zoo_Animal
表,这非常棒。虽然这些表还没有彼此连接,但我们稍后会做这个操作。首先,Zoo
表有一个ID和一个位置,Animal
表有ID和名称,而Zoo_Animal
表包含了zoo_id
和animal_id
。那么,我们该如何将它们连接在一起呢?因为Zoo
表应该连接到Zoo_Animal
表,Animal
表也应该连接到Zoo_Animal
表。
接下来,我们回到服务器资源管理器,修改Zoo_Animal
表。为了做这个修改,我右键点击Zoo_Animal
表,选择“打开表定义”。接下来,我需要添加外键。我将创建一个新的外键,命名为zoo_foreign_key
,它将连接表中的一个列到另一张表的列。在这里,我将外键连接到Zoo_Animal
表的zoo_id
列,并将它引用到Zoo
表中的ID
列。然后我复制它,改成动物ID,这样我就会为动物添加一个外键,连接到Animal
表中的animal_id
列。完成这些操作后,我们更新表格。在此之后,我们可以添加一些数据到Zoo_Animal
表。现在,它的内容几乎是空的。我们打开表数据,准备为其添加数据。
假设我们已经知道了每个动物和动物园的关系,接下来可以进行数据填充。比如我们可以设置Zoo_ID
为1(表示纽约动物园),Animal_ID
为1(表示鲨鱼)。然后我们再将Zoo_ID
设置为1,Animal_ID
为2(表示小丑鱼)。同样的操作,我们可以将其他动物分配到不同的动物园中。例如,东京动物园(Zoo_ID = 2
)可能包含猴子(Animal_ID = 3
)和狼(Animal_ID = 4
)。通过类似的方式,我们可以将所有动物分配到不同的城市和动物园。最终,我们有了5个动物园和8种不同的动物,其中每个动物园都有一些特定的动物。
现在,我们如何查看这些数据呢?我们可以创建一个新的查询。我点击“新建查询”,然后编写SQL查询语句。在这个查询中,我可以选择所有来自Zoo
和Animal
的表数据。例如,我可以运行SELECT * FROM Animal
,这将返回所有动物的记录。如果你想检查所有动物园的数据,可以使用SELECT * FROM Zoo
。如果你想查看某个特定动物园中的动物,比如纽约的动物园(ID = 1),你可以使用INNER JOIN
连接Animal
表和Zoo_Animal
表,通过Zoo_Animal
的zoo_id
连接到Zoo
表,查看纽约动物园中有哪些动物。通过这种方式,我们可以快速地检索到每个动物园中都有哪些动物。
总之,这就是我们如何通过外键和关系表来管理动物和动物园之间的关系。在下一个视频中,我们将使用C#编写相应的代码来操作这些数据。
欢迎回来。现在你已经知道如何创建你的第一个查询、如何设置数据库等,接下来我们就可以开始处理UI并做一些疯狂的事情了。所以我们先来看看主窗口的XAML文件。我们可以像这样保持文件打开,稍微关闭一下其它部分,这样可以获得更多空间,实际上这样就好了。好,首先我们需要什么?我们需要一个列表框。在这里我使用了一个ListBox
,我们来看看它的效果。我们可以看到有标签(label),然后是ListBox
、按钮等。我们先从标签和列表框开始,后续再添加其他内容。虽然我们可以通过非常规范的方式创建所有这些元素,使用网格布局(grid)以及不同的列和行等,但我决定直接将所有东西拖放到窗口中,让它们自动完成工作。因为这一章教程并不主要讲解WPF,它更多的是关于数据库组件。所以我不会花太多时间在XAML上。
为了在主窗口中添加内容,我先放大一点。实际上我们不需要太大的缩放,因为我们只需要查看代码是如何添加的。接下来,我将开始拖放所需的组件。首先,我需要一个标签(label),所以我拖放一个标签并将它放置在左上角,标签内容修改为"Zoo List"。然后,我将添加一个ListBox
,它将包含所有不同的列表项。我把它拖到“Zoo List”标签下面,并适当调整一下大小,设置为500 x 725。现在,我可以稍微让它更宽一些。正如你所看到的,所有布局设置都已经自动完成了。标签有一定的边距,垂直对齐在顶部,水平对齐在左边,而列表框水平对齐在左侧,并设置了特定的高度,还有顶部的边距。这样,所有元素都已经准备好了。
ListBox
并为其命名接下来,我希望能看到列表项或列表元素出现在“Zoo List”中。为此,我需要给它设置一个名称。我将为ListBox
添加名称属性,命名为ListZeus
(因为这是一个包含所有动物园的列表)。
现在,让我们回到代码部分,因为我想将所有动物园展示在这个列表中。我们可以运行查询等,但这还不够,我们需要使用特定的C#方法,并且可以通过LINQ
来更简洁地实现(不过LINQ
将在下一个章节讲解)。为了保持简单,我们先采用传统的方法。首先,我们需要建立SQL连接。我们来创建一个SQL连接对象,并为此添加所需的命名空间:
using System.Data.SqlClient;
接下来,我们在MainWindow
的构造函数中初始化SQLConnection
对象:
SQLConnection connection = new SQLConnection(connectionString);
此时,程序已经通过connectionString
连接到数据库了。
接下来,我需要创建一个方法来展示动物园数据。为了实现这一点,我将创建一个私有方法,命名为ShowZoos
,只在当前类中使用。我们首先要准备SQL查询:
string query = "SELECT * FROM Zoo";
然后,使用SQL适配器(SQLDataAdapter
)来运行查询,并填充一个数据表:
SqlDataAdapter adapter = new SqlDataAdapter(query, connection);
DataTable zooTable = new DataTable();
adapter.Fill(zooTable);
SQL适配器会管理SQL连接的打开与关闭,这样我们就不需要手动管理连接的生命周期。接下来,我将通过设置ListBox
的ItemSource
属性来展示数据:
ListZeus.DisplayMemberPath = "Location";
ListZeus.SelectedValuePath = "ID";
ListZeus.ItemsSource = zooTable.DefaultView;
以上代码的作用是将从数据库中检索到的动物园地点作为ListBox
的显示内容,并将ID
作为选中项的值。ItemsSource
被设置为数据表的默认视图,这样就将数据库中的数据填充到ListBox
中了。
最后,我添加了一个简单的错误处理机制。在数据库操作时,异常时有发生,因此我使用try-catch
来捕获异常并显示错误信息:
try
{
ShowZoos();
}
catch (Exception e)
{
MessageBox.Show(e.ToString());
}
这样,如果出现任何异常,程序会显示一个消息框,告知用户发生了什么错误。
现在,如果我们运行这个应用程序,应该可以看到在ListBox
中列出了所有动物园。如果没有显示数据,请确保在数据库表中确实有数据,并且数据库连接是正确的。
下一节将讲解如何根据用户点击某个动物园,显示该动物园的相关动物信息。
欢迎回来。在本视频中,我们将添加另一个列表,用于显示与特定动物园相关的动物列表。正如你所知道的,我们有一个动物园列表,还有一个动物列表。我们需要一个列表,显示在特定动物园内的动物。例如,如果我点击纽约,我希望在这个附加的列表视图中看到纽约动物园内的动物。所以让我们开始吧,首先我们需要进入我们的 XAML 文件,因为我们需要添加另一个列表。接下来我将访问这两个元素,然后复制并粘贴它们,以便可以将它们拖到一边。
现在这个列表不再是显示动物园列表,而是显示与动物相关的动物列表。因此,我将把它命名为 "Associated Animals List"(与动物相关的动物列表)。接下来,我将为其更改名称为 listAssociatedAnimals
,保持其他设置不变。
现在,如果我们想要实现点击某个项目时更新其他列表的功能,我们当然可以手动编写代码,或者我们可以直接双击它,系统会自动为我们创建一个 SelectionChanged
事件处理程序。对于 listZoos
列表,点击时会触发这个事件。在这里,我们可以简单地将 listZoos was clicked
显示到控制台,以便查看点击操作的反馈。此时,只要我点击纽约、东京等动物园,就会看到 listZoos was clicked
的输出。
当然,我不想在这里显示消息框,这只是为了测试 SelectionChanged
事件是否能正常工作。现在我们需要做的是创建另一个方法,这个方法与之前显示动物园的代码类似,但我们需要用来显示与动物园相关的动物。
我们可以通过复制显示动物园列表的代码,并适当调整来实现这一点。首先,我复制并粘贴了 showZoos
方法,并将其重命名为 showAssociatedAnimals
。然后我们保留了 try-catch
块,以便在出现错误时显示消息框。接下来,我们需要构建一个查询,该查询不仅要从 Zoo
表中选择所有内容,还需要使用内连接(INNER JOIN
)来连接 Animal
表和 ZooAnimal
表。我们需要通过 a.ID = z.a.AnimalID
来匹配动物和动物园的 ID,并进一步设置筛选条件,确保查询只返回与当前选择的动物园相关的动物。
查询语句会是:
SELECT * FROM Animal a
INNER JOIN ZooAnimal za ON a.ID = za.AnimalID
WHERE za.ZooID = @ZooID;
在这里,ZooID
是一个参数,我们将在代码中动态赋值。为了能够传递参数,我们需要使用 SQLCommand
类,而不是直接使用 SQLDataAdapter
。创建一个 SQLCommand
对象,并将查询和连接传递给它。接着,我们可以在命令中添加参数,并将当前选中的动物园 ID 作为参数传递进去。
通过如下方式,我们可以将 ZooID
作为参数添加到 SQLCommand
中:
sqlCommand.Parameters.AddWithValue("@ZooID", listZoos.SelectedValue);
然后,我们继续使用 SQLDataAdapter
来填充数据。此时我们用 SQLCommand
对象来代替原先的查询字符串,从而传递参数并执行查询。最后,我们把查询结果绑定到 AssociatedAnimalsList
列表框。
为了验证这是否有效,我在 SelectionChanged
事件中显示当前选中的 ZooID
。这样,在点击某个动物园时,我们会看到选中的 ZooID
值。比如点击纽约时会看到 ID 1,东京会显示 ID 2,等等。
接下来,运行代码,点击纽约时,列表中会显示相关的动物——比如纽约有鲨鱼和小丑鱼。点击东京时,显示猴子和狼。点击柏林时会显示壁虎和鳄鱼,开罗显示岛屿鹦鹉,而米兰则会显示所有动物。这样,我们的关联动物列表就完成了。
通过这个功能,我们不仅能够显示动物园,还能够根据选中的动物园动态显示该动物园内的动物。我们使用了 SQL 查询、SQLCommand
和 SQLDataAdapter
来结合数据库和 C# 代码,处理数据库查询,并将结果显示在界面上。
至此,我们已经实现了关联动物的列表,接下来在下一个视频中,我们将添加一个显示所有动物的列表,并加入一些额外的功能。所以,下次视频见。
欢迎回来。现在你已经知道了如何创建动物园表格以及与动物相关的动物表格,是时候来创建我们的动物表格了。这个表格将包含数据库中的所有动物,并将显示在这个列表框中。因此,我们将创建一个动物表格,并将所有的数据展示在此列表框中。请按照视频的步骤尝试实现,应该不会太难。你可以将之前视频中学到的知识应用到这个任务上。
接下来,复制现有的列表框,粘贴并拖动到适当位置,确保两个列表框之间的距离差不多。然后稍微调整列表框的大小,延长它的长度。之后,我将在这里添加一个按钮,虽然现在数字并没有完美对齐,但这可以在稍后调整窗口大小时解决。现在我们还不添加标签,因为我稍后会添加一个按钮,它包含“添加动物”和“删除动物”按钮。
现在,我们已经有了这个列表框。接下来,我们将创建一个方法来显示所有动物。我们可以参考之前的 showZoo
方法。让我们创建一个新方法,命名为 showAllAnimals
。首先,我们需要编写一个字符串,表示我们的查询,用来选择所有动物的数据:
SELECT * FROM Animal;
然后,我们需要使用 SQLDataAdapter
来运行这个查询。我将创建一个新的 SQLDataAdapter
对象,并将查询和数据库连接传递给它。数据库连接我们已经在构造函数中设置好了。接下来,利用 SQLDataAdapter
,我们可以不需要手动打开和关闭连接,直接填充数据。
private void showAllAnimals()
{
string query = "SELECT * FROM Animal"; // 查询所有动物
SqlDataAdapter sqlDataAdapter = new SqlDataAdapter(query, sqlConnection); // 创建SQL数据适配器
DataTable animalTable = new DataTable(); // 创建数据表
sqlDataAdapter.Fill(animalTable); // 填充数据表
}
填充完数据表后,我们需要将数据绑定到列表框。我们已经有了 AssociatedAnimals
列表框,但还没有为所有动物创建一个列表框。为了方便,我们将其命名为 listAllAnimals
。接下来,我们将数据表的 DefaultView
绑定到这个列表框,并设置正确的 DisplayMemberPath
和 SelectedValuePath
。DisplayMemberPath
将用于显示动物的名称,而 SelectedValuePath
用于存储动物的 ID。
listAllAnimals.ItemsSource = animalTable.DefaultView; // 将数据源设置为动物表的默认视图
listAllAnimals.DisplayMemberPath = "Name"; // 显示动物的名字
listAllAnimals.SelectedValuePath = "ID"; // 选中项的值是动物的ID
由于我们在代码中使用了硬编码的字符串(如表名、列名),如果这些字段有误或者查询失败,可能会出现问题。为了避免程序崩溃,我们应该使用 try-catch
块来捕获异常。这样,如果出现任何错误,我们能够看到详细的错误信息。
try
{
listAllAnimals.ItemsSource = animalTable.DefaultView;
listAllAnimals.DisplayMemberPath = "Name";
listAllAnimals.SelectedValuePath = "ID";
}
catch (Exception e)
{
MessageBox.Show(e.ToString()); // 显示异常信息
}
最后,我们需要在构造函数中调用 showAllAnimals
方法,这样程序启动时就会自动加载动物列表。我们可以在 MainWindow
构造函数中调用它。
public MainWindow()
{
InitializeComponent();
showAllAnimals(); // 显示所有动物
}
运行代码后,列表框中应该会显示所有动物的名称,如鲨鱼、小丑鱼、猴子、狼、壁虎和鳄鱼等。
你可以通过直接在数据库中修改数据来验证列表的更新。在修改数据库中的数据后,记得刷新连接并重新运行查询。你也可以通过更改数据表中的记录来添加新动物,刷新列表框中的数据。
到这里,我们已经创建了一个显示所有动物的列表,并通过查询数据库中的 Animal
表来获取数据。接下来,我们将在下一个视频中添加按钮功能,展示如何添加和删除动物,并完成其他相关功能。下次视频见。
欢迎回来。在本视频中,我们将添加剩余的用户界面,并且现在实现一个功能,即删除动物园(Zoo)。让我们开始吧。如你所见,这就是最终界面的基本布局,我们将几乎完全按照这个布局进行构建。我们可以通过手动输入相同的信息来实现,但是这次主要是通过拖放来完成。因此,我们可以执行所需的所有SQL代码,或者说是我们在C#中需要的数据库操作。
首先,打开工具箱,查看我们需要的控件。我们将需要按钮(Button)。接下来,我将在界面中拖动一个按钮,放置在合适的位置,保持一点距离。这个按钮应当显示为“删除内容”(Delete Content)。然后,我会复制这个按钮,复制粘贴后,放置在旁边,并且将它的文本改为“删除动物园”(Delete Zoo)。接着,再复制粘贴创建“移除动物”(Remove Animal)按钮。完成这些之后,我们再调整它们的位置,确保有足够的空间用于放置按钮。
接下来,我们对界面做一些微调,确保按钮布局合理并且能清晰展示。我们可以通过拖动这些按钮使它们在窗口中排列得更整齐。然后,我们可以在窗体标题中输入“Zoo Manager”,以便明确界面功能。
接下来,双击“删除动物园”按钮,进入它的点击事件处理方法。我不太喜欢默认的事件名称,所以我会将其修改为“DeleteZoo_Click”。之后,我会为这个事件创建一个新的方法——private void DeleteZoo_Click
。在方法中,我们首先验证按钮是否能正常工作,因此可以弹出一个简单的消息框显示“Delete Zoo was clicked”。运行程序,检查是否可以正确触发事件。
一旦按钮点击事件确认工作正常,我们可以继续实现删除动物园的功能。删除操作涉及SQL命令,首先构造一个删除语句:
DELETE FROM Zoo WHERE ID = @ZooID
接下来,我们需要创建一个ZooID
的参数,用于从数据库中删除指定ID的动物园。在这个过程中,我们通过SQLCommand
来执行删除操作,而不使用数据适配器(SQLDataAdapter)。具体步骤如下:
SQLCommand
对象。在命令执行之后,我们要确保关闭数据库连接,以避免连接泄漏。
SQLConnection.Open();
SQLCommand.Parameters.AddWithValue("@ZooID", ListZeus.SelectedValue);
SQLCommand.ExecuteScalar();
SQLConnection.Close();
在删除动物园时,我们还需要删除动物园与动物之间的关系记录。在数据库中,可能存在一个连接表(例如ZooAnimal
),它存储了哪些动物属于哪些动物园。如果我们删除一个动物园,我们也需要删除这些关系记录。为了简化操作,可以在数据库设计时为外键添加“级联删除”(ON DELETE CASCADE)。这样,当删除动物园时,关联的动物记录会自动被删除。
我们可以通过更新表约束来实现这一点:
这将确保在删除动物园时,相关的动物记录自动删除。
在删除操作过程中,可能会发生异常(例如外键约束错误)。为了更好地处理这些异常,我们可以使用try-catch-finally
块:
try
{
SQLConnection.Open();
// 执行删除操作
SQLCommand.ExecuteScalar();
}
catch (Exception e)
{
MessageBox.Show(e.ToString());
}
finally
{
SQLConnection.Close();
}
通过这种方式,即使出现错误,程序也能继续运行,并显示详细的错误信息。
在删除动物园或动物时,我们需要更新界面上的数据。在删除动物园后,我们调用显示所有动物园的方法来刷新列表,以便用户看到最新的状态。我们可以在适当的地方调用ShowZeus
方法:
ShowZeus();
这将确保删除操作后,界面显示的是最新的数据。
现在,你已经学会了如何在应用程序中删除动物园,并处理级联删除和异常。接下来的内容,我们将学习如何向列表中添加元素。在下一节视频中,我们将实现这个功能。
现在我们已经学会了如何从数据库或表中删除条目,但由于目前的表格比较空,我们需要向其中添加一些内容。因此,我们需要添加一个文本框,并且需要有一个“添加动物园”的按钮。让我们开始构建这些功能并添加相应的逻辑。
首先,我们需要添加一个新方法,命名为 private void add_zoo_click
,并且它将接收一个 object sender
和 routed_event_args
。当用户点击“添加动物园”按钮时,此方法会被触发。为了实现这一点,我们需要在按钮的 Click
事件中绑定这个方法。因此,在按钮的事件绑定中,我们会将 add_zoo_click
与 Click
事件关联,确保用户点击按钮时执行我们的方法。
接下来,我们将实现与删除功能类似的代码,但需要插入新数据。具体来说,我们将使用 INSERT INTO
SQL 语句向特定表中添加一行数据。这里我们添加的内容是“位置”,即添加动物园的位置。我们还需要将文本框的输入作为值插入到数据库中。为了做到这一点,首先我们给文本框命名为 myTextBox
,然后通过 myTextBox.Text
获取用户输入的文本内容。
我们将创建 SQL 命令,执行 INSERT INTO
查询,将用户输入的内容插入到数据库中。然后,打开数据库连接,执行 SQL 命令并添加相应的参数。若没有出现错误,插入操作成功后,我们需要刷新页面显示新增的动物园。
为了让用户看到新增的动物园,操作完成后,我们需要调用一个显示动物园的函数。否则,页面上不会显示出新增的条目。
接下来,我们需要添加一个新的按钮,用来将动物添加到指定的动物园。我们首先实现了一个 add_animal_to_zoo_click
方法,这个方法将在按钮点击时被触发。此时,按钮会显示一个消息框,以确认按钮点击事件被成功触发。
在数据库中,zoo_animal
表用于关联动物和动物园。我们需要执行一个 INSERT INTO zoo_animal
操作,将选定的动物 ID 和动物园 ID 插入到该表中。通过这种方式,每个动物和动物园之间建立了关联。之后,我们也会显示动物园和它们相关的动物。
我们还需要实现删除动物的功能。当用户点击“删除动物”按钮时,系统将删除选中的动物。同时,我们要刷新动物列表,确保用户看到删除操作后的更新结果。
在完成所有按钮功能后,我们还需要确保所有按钮绑定了正确的事件,避免操作时出现错误。例如,删除动物时,需要确保删除的是当前选中的动物,并且更新列表显示最新的动物数据。
目前我们的功能中缺少了“更新动物园”和“更新动物”功能。虽然用户可以添加新的动物园和动物,但如果想要更新已有的动物园或动物信息,这部分功能还未实现。未来,我们会针对这部分进行补充,确保数据能够实时更新。
到目前为止,我们已经完成了很多功能的实现,包括删除、添加动物园和动物的功能,并且通过按钮绑定事件完成了交互操作。接下来,我们将实现更新动物园和动物的功能,并完善界面,以便用户能够更新动物园和动物的相关信息。如果你在开发过程中遇到问题,可以参考以上步骤并根据具体需求调整代码。
在这个视频中,我们将添加“更新动物园”和“更新动物”按钮。首先,我希望更新文本框中的内容,根据我选择的动物园或动物显示相应的信息。所以,让我们创建这个功能并进入后台代码。首先,我会创建一个新方法,命名为“Show selected zoo in text box”。这个名字比较长,但它确切地表达了它的功能:将选中的动物园显示在文本框中。
为了正确运行代码,我实际上会使用与“Show associated animals”或“show zoos”类似的方式。我们复制现有的“Show Associated Animals entries”代码,然后做一些调整。查询会简单很多,不再显示所有内容,而是只显示位置。所以我们写下“select location from zoo where the ID is equal to at zoo ID”。这里的zoo ID
对应的是选中的动物园的ID。这样,我们就可以根据选中的值更新文本框了。
这段代码几乎与之前的代码相同。唯一不同的是,我们不再更新动物园的关联列表,而是直接更新文本框的内容。所以我需要访问文本框并更新它的text
属性,设置为zoo data table
的值。这里的zoo data table
和之前的动物数据表不同,它包含的是动物园的相关数据。我将从中获取第一行,并选择“Location”列的值。这个值需要转换为字符串。
zoo data table
实际上就是一个C#对象,它包含多行数据。所以,我通过获取第一行的数据,选择“Location”列,来更新文本框的内容。由于我们查询返回的是唯一的结果,只有一个动物园记录,所以我可以安全地使用零索引来提取数据。
现在我们有了更新文本框的代码,但还没有调用这个方法。接下来,我需要在list zoo selection changed
事件处理程序中调用这个方法。这样,当我们选择不同的动物园时,文本框中的内容就会根据所选的动物园进行更新。
好了,当我点击不同的动物园时,比如开罗(Cairo)和柏林(Berlin),你会看到文本框的内容实时更新了。这就是我们想要的效果,接下来我们可以实现更新动物园的功能。
但在这之前,我给你一个小挑战。请你实现右侧动物列表的相同功能。当我点击“猴子”(Monkey)时,我希望文本框也能更新。所以,请尝试实现这个功能。
我会简单一些,直接复制之前创建的“Show selected zoo in text box”方法,并将它命名为“Show selected animal in text box”。然后,我需要将查询中的“location”改为“name”,并将查询从“zoo”改为“animal”,这样就可以显示动物的名称了。调整完查询后,更新的功能和之前相同。
现在,我们可以继续测试这个功能。假设我们选择了“猴子”或“虎”,文本框中的内容应该相应更新。这样,当我们选择不同的动物时,文本框中的内容就会自动更新。
接下来,我们将实现“更新动物园”按钮的功能。如果用户在文本框中修改了动物园的位置,我们希望能够更新数据库中的动物园信息。为此,我们将创建一个新的方法,叫做“update zoo click”。这个方法将会执行一条SQL查询,更新动物园的位置。
SQL查询会更新动物园的“location”字段,并将其设置为文本框中的新值。我们将使用zoo ID
来标识需要更新的动物园。然后,调用“show zoos”方法来刷新动物园列表。
一切准备好后,点击“更新”按钮时,动物园的位置应该会被更新到数据库中,并反映到列表中。然而,按钮本身还没有连接到相应的事件处理程序。我们需要为“更新动物园”按钮添加一个事件处理程序,并将其绑定到“update zoo click”方法。
好了,现在我们可以测试更新功能了。当我们修改开罗(Cairo)的名称并点击“更新”时,你会看到列表中的动物园位置已经成功更新。同样,你也可以修改其他动物园的位置,如纽约(New York)等。
接下来,我们将实现“更新动物”按钮。与更新动物园类似,我们将创建一个名为“Update Animal Click”的方法,用来更新动物的信息。我们需要修改的不是动物园的位置,而是动物的名称。更新操作的SQL查询会类似,只是针对“animal”表,而不是“zoo”表。
我们会为“更新动物”按钮添加事件处理程序,绑定到“Update Animal Click”方法。这将确保用户点击按钮时,能够根据文本框中的新名称更新相应的动物记录。
测试时,选择某个动物并修改它的名称(比如“猴子”或“小丑鱼”),点击“更新”后,动物的名称将会被更新。而且,更新后的动物名称会立即反映到列表中。
现在,我们完成了所有的功能,包含了更新动物园和动物的功能,并且成功实现了这些功能与数据库的交互。
最后,如果你在实现过程中遇到问题或没有完全理解某些内容,别担心,你可以随时回头查阅代码,或者去网上查找相关信息,理解SQL命令的作用和使用方法。
在下一个章节中,我们将探讨LINQ,它将大大简化我们的操作,提高工作效率。LINQ让我们可以通过C#的查询语法直接操作数据,而不再需要编写复杂的SQL查询。
好的,现在我们已经完成了数据库章节。这一章是最复杂的章节之一。我们不仅使用了用户界面,还处理了大量的数据,并且我们已经学会了如何使用这些数据,甚至自己创建数据。我希望你喜欢这一章的内容。
接下来,我们将学习如何使用一个名为LINQ的第三方库,它将使我们的工作变得更加轻松。LINQ允许我们以一种更流畅的方式来处理数据,从我的角度来看,它更加直观和吸引人。让我们继续进入下一个章节,了解如何使用LINQ。
欢迎回来。好的,让我们开始吧。今天视频中我们要构建的是一个货币转换器,正如你所看到的那样。在第一部分视频中,我们已经构建了一个基本的货币转换器,大家应该已经看过了。在第一部分中,我们使用了静态数据,而这一次我们将使用数据库。所以我们会扩展这个功能,添加两个不同的标签页。一个标签页用于货币转换器,另一个标签页用来输入不同的汇率或特定货币的汇率值。
例如,这里欧元是基本货币,€100 就是 €100,十分基础;但对于 100 美元,我能兑换 85 欧元;对于 100 英镑,我可以兑换 111 欧元。这样数据库就设置好了。你当然可以稍后根据需要调整数据库。你可以通过这个 UI 删除数据库中的条目,或者直接通过这个 UI 编辑数据库中的条目。
而且有趣的是,你将学习到数据库的基础知识。你将更多地了解 WPF。顺便说一下,我强烈建议你查看一下第一部分视频,因为我不会再详细讲解 WPF 的代码,尤其是在 XAML 中发生的内容,因为这些内容在上一期视频中已经讲解过了。你看到的是我们构建的用户界面。而现在我们引入了标签页,这是我们要介绍的一个新功能。另外,我们还有这个网格,它是一个数据网格。我们将使用这个数据网格来显示数据库中的数据。
我们将设置一个 SQL 数据库,获取数据库中的数据并将其显示在这里。我们将能够进行调整,比如编辑和删除等操作。好了,假设现在我有这三种货币,现在我可以查看 100 印度卢比可以兑换多少欧元。例如,100 印度卢比大约能兑换 1.13 欧元。我们就假设 100 印度卢比可以兑换 1 欧元。然后,我点击了这个行,US 美元的汇率发生了变化,但现在如果我创建一个新条目,比如再次创建 US 美元,金额是 88,虽然我不确定,但这不太重要,重点是核心功能。
现在你可以看到,我们在这里有了 US 美元的汇率。如果我现在切换到货币转换器标签页,我可以从这里选择这些值。这些货币名称现在会出现在下拉框里,我可以选择任何我想要的。例如,我想知道 1000 印度卢比能兑换多少欧元?结果是 10 欧元。然后,1000 欧元能兑换多少美元呢?是 1136 美元,尽管现在的汇率稍微有所不同。但没关系,货币汇率波动很大。所以你可以使用这个软件每天调整这些设置,这样你就会有每日的汇率,并可以使用这个货币转换器。当然,要做到这一点,你需要手动获取当前的汇率数据,当然你也可以直接使用谷歌来查询汇率。
但基本上,这个软件的作用是帮助你创建任何类型的数据库软件,基于你在这个视频中学到的知识,你将能够构建自己的复杂数据库软件,并且你甚至可以把它卖出去。非常棒的内容。
好了,不管怎样,我建议我们开始构建这个软件。和往常一样,至少几乎每次,我的视频下方都会有一个完整的文章,其中包含所有的代码,你可以通过文章中的指导跟着做。如果你不想手动输入代码,你也可以直接复制网站上的代码。这样你就可以按照文本形式的指南操作,所有的代码都可以找到。如果你不想手动打字,所有内容都会提供给你。
这次视频中,我们会设置一个数据库,所以你需要了解如何设置数据库。在这里你可以跟着视频学习如何设置。但当然,视频中我也会展示所有的步骤。好的,但还是建议你检查一下我的网站。那么,让我们开始吧。就像我之前说的,我会使用上一期视频中的代码。如果你没有上一期视频中的代码,可以去看那期视频并跟着做,这样会帮助你更好理解,或者你也可以从网站上复制代码,省去手动输入的麻烦。
关于 WPF 的代码我不会详细解释,因为我们主要关注数据库内容。好,准备好了吗?让我们开始调整用户界面。首先,我们需要有标签页。为了将标签页添加到我们的 UI 中,我们需要添加一个名为 TabControl 的控件。我要在哪儿添加呢?在最外层,即在 window 标签的开始位置。在我们的 window 标签中有一个 grid,这个 grid 基本上就是我们整个 UI 的框架。在上一期视频中,我们的 UI 是一堆堆叠的面板,包含五行。现在,我要把整个界面放入一个标签页中。所以目前为止的一切都应该放在一个标签页内。
我把整个 grid 移除后,你会看到它现在只有一行。我点击了这边的减号,现在我要使用 TabControl,并给它起个名字,叫做 TabMain。接着我使用了一个叫做 TabStripPlacement 的属性,它允许我设置标签页的位置,默认设置标签页在顶部,因为在 PC 上我们通常把标签放在顶部。这样,我们就有了一个基本的 TabControl。在这个 TabControl 内部,我们可以创建多个标签页。每个标签页叫做 TabItem,我们可以给它起个名字。我把第一个标签页命名为 converter,并为它设置一个标题,告诉用户它是做什么的。
现在,我把之前的整个 grid 拷贝到这个 TabItem 中,并给它加上标题 Currency Converter。我启动软件看看效果,看看这个标签页是否能正常显示。你可以看到,现在我们有了一个名为 currency converter 的标签页,虽然它的实际名称是 TB converter,但我们在标题栏显示的是 Currency Converter。
接下来给你一个小挑战:在 TabControl 中创建另一个标签页。暂停视频并尝试一下。强烈推荐你在学习后立即动手实践。
我将创建一个新的 TabItem,命名为 TB master,并设置标题为 Currency Master。然后,我们可以从简单的内容开始,只是放一个标签,看看它是否正常显示。标签的内容设置为 Hello World。保存并刷新后,我发现它没有热重载,但没关系,我停止并重新启动程序,现在我就可以看到新建的标签页 Currency Master,里面有个显示 Hello World 的标签。
这样,你就学会了如何在 WPF 中创建不同的标签页,非常简单。
欢迎回来!如您所见,我们现在要创建这个界面。它与之前的界面非常相似。我们有一个红色的框,并且顶部有粉色的文字。我们不仅仅有一个文本编辑框,也没有组合框,而是有两个输入框,按钮位置和之前的类似,底部有两个按钮,按钮的文本略有不同。此外,我们还没有图标,实际上,底部是有图标的。接下来就是一个数据网格。好,那么我们如何实现这些呢?
我将从我的网站上直接复制代码,因为我会逐行解释这段代码。您当然可以自己手动构建一切,因为实际上您可以从中学到很多东西。所以,我将给出一些构建步骤的指导。我们现在先来分析这段代码。
在代码中,您可以看到我们使用了一个有五行的网格布局。换句话说,界面被划分成了五个不同的部分:
为了实现这一点,您需要定义一个网格,并为每一行设置不同的行高。例如,定义行的大小如下:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
</Grid>
这样一来,网格就会有五行,每行的高度相等,当然您可以根据需要调整每一行的高度值。
接下来,我将直接粘贴代码,并逐一解释每一部分。首先,我们需要创建一个 border
元素,这个元素会包围其他内容,形成一个有边框的区域。
<Border BorderBrush="Black" BorderThickness="1">
<Rectangle Fill="Red" Width="100" Height="100"/>
</Border>
这个 Border
就是这个红色框的部分,内部的 Rectangle
则是填充了红色。
接下来,您需要添加按钮和其他UI元素。例如:
<Button Name="EditButton" Content="Edit" Width="75" Height="30"/>
<Button Name="DeleteButton" Content="Delete" Width="75" Height="30"/>
这两行代码就是实现图标按钮的部分。这些按钮会有相应的点击事件,允许您编辑或删除数据。确保在后台代码中定义相应的事件处理方法。
在UI代码中,您看到有几个资源被引用,例如按钮的图标。您需要将这些图标添加到项目中的资源文件夹下。例如,您可以将图标图片添加到 Images
文件夹,并且在代码中使用如下路径:
<Image Source="Images/edit_button.png" />
<Image Source="Images/delete_button.png" />
确保在项目中正确引用了这些文件,并且在代码中使用了正确的文件路径。如果没有正确设置资源文件,可能会导致程序无法找到图标。
在前端UI部分中,我们已经创建了布局和按钮,但这些按钮并不具备实际的功能。为了让它们有效,您需要在代码后台定义按钮点击事件。例如:
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
// 处理保存逻辑
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
// 处理取消逻辑
}
您可以通过右键点击事件触发器,选择“转到定义”来自动生成这些方法。如果自动生成没有生效,您可以手动添加方法并链接到按钮的事件。
如果您有一个数据网格来显示数据,您还需要为数据网格设置选中行变化的事件处理函数。例如:
<DataGrid Name="CurrencyGrid" SelectionChanged="CurrencyGrid_SelectionChanged"/>
在后台代码中,您需要为 SelectionChanged
事件定义处理方法:
private void CurrencyGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// 处理选择变化
}
在您实现了上述步骤后,运行程序看看它是否能够正常显示。您可能会遇到一些错误,比如找不到资源文件或未实现的事件方法。在这种情况下,检查您的文件路径,确保所有资源都正确添加到项目中。
通过这些步骤,您可以创建一个类似的界面,并且在后台实现相关逻辑。您可能需要逐步进行调试,以确保所有功能正常工作。如果有任何疑问,建议再次查看第一部分的内容,确保您理解了UI和后台逻辑的搭建方式。
希望这些指导能帮助您顺利构建自己的货币转换器界面!
让我们深入了解这个数据网格(DataGrid),因为这是我们在上一个视频中没有涉及到的一个新功能。数据网格基本上是这个整个框架——从上到下的矩形框。数据网格内包含列(Columns),每列有不同的定义。在我们当前的例子中,我们有四列:一个数据网格文本列(DataGridTextColumn),两个模板列(TemplateColumn),以及另一个数据网格文本列。
为了创建这样的数据网格,我们可以为其设置多个属性。例如,我们可以设置高度、宽度、边距等。在此示例中,宽度设置为480像素,背景被设定为透明。我们还设置了一个属性 CanUserAddRows
为 false
,这意味着我们不允许用户直接通过UI添加行,而是希望通过代码手动控制这一操作。
数据网格也需要一个名称,便于在代码中访问。在这里,我们将数据网格命名为 DgvCurrency
(表示货币数据网格)。
一个重要的事件是 SelectedCellsChanged
。这个事件会在用户选择不同单元格时触发。具体来说,当我们在数据网格中的某一行进行更改时,事件会触发,并且会自动更新显示在该行的数据。
例如,当你点击某一行并更改金额后,所选行中的数据将自动更新。虽然没有点击“添加”按钮,但系统仍然会知道是哪个单元格被选中了,这就是通过 SelectedCellsChanged
事件实现的。
在这个事件中,你还可以定义选择单元格的单位。比如,可以选择“单个单元格”,或者选择“整行”。在我们当前的设置中,我们选择了单个单元格,因此每次选择一个单元格时,只会选中该单元格。如果你设置为“整行”,那么点击任何单元格都会选中整行。
滚动条(VerticalScrollbar)设置为 Auto
,这意味着滚动条将根据需要显示——只有当数据量超出视图范围时,滚动条才会自动出现。如果设置为 Visible
,滚动条将始终可见;如果设置为 Hidden
,则始终隐藏滚动条。这样,Auto
设置确保了界面的整洁,仅在必要时显示滚动条。
数据网格内的列有不同的定义方式。我们有一些列没有显示名称,例如显示图标的列,另一些列则显示内容,如“金额”和“货币名称”。
数据网格支持数据绑定,我们将列绑定到相应的字段。例如,“金额”和“货币名称”列绑定到数据中的 Amount
和 CurrencyName
字段。我们将数据源(ItemSource
)设置为数据库或其他数据源,以确保每一行显示正确的数据。
例如,我们将数据绑定到一个数据库,这样每一行会显示不同的货币金额和货币名称。通过设置 Binding
,我们将数据库中的字段与数据网格的列关联起来。
我们设置了以下几个列属性:
false
,意味着用户不能重新排序行。true
,意味着这些列的内容不可直接编辑。用户只能通过点击“编辑”按钮来修改这些数据。数据网格中包含两个图标按钮:一个是“编辑”按钮,另一个是“删除”按钮。这些按钮通过 DataGridTemplateColumn
进行处理,意味着每个单元格都将包含这两个按钮,并且它们的显示是固定的。点击这些按钮时,会触发相应的事件,如编辑或删除某一行数据。
这些列和绑定配置确保数据网格的功能性和可用性。虽然目前的数据网格界面已经基本构建完成,但数据的具体操作还没有完全实现。接下来,我们将使用数据库进行数据填充,并通过代码来控制数据的显示与修改。
通过这些配置,数据网格不仅提供了显示数据的能力,还支持用户与数据的交互,如选择、编辑、删除操作。所有这些功能通过 Binding
、TemplateColumn
和相关事件实现,使得数据网格既强大又灵活。
首先,我们需要创建一个新的文件夹。由于我的程序正在运行,因此我无法直接创建该文件夹,所以我首先停止程序,然后在 Solution Explorer 中右击并选择 Add New Folder。我将这个文件夹命名为 database。
接下来,在 database 文件夹中,我们需要创建一个新的项目。右击文件夹,选择 Add New Item,然后选择 Data,选择 Service-Based Database。接着,我们给它取个名字,比如 currency converter,当然你也可以将其命名为 currency converter data。此时,你会看到一个名为 currency converter.mdf 的文件。
当你双击这个文件时,Server Explorer 会在左侧打开。在这里,你会看到 Data Connections 下显示了 Tables,虽然目前没有任何表、视图或者函数。我们希望创建一个表,因此右击 Tables 选择 Add New Table。
在弹出的 DBO.table design 窗口中,我们可以设计表结构。你可以直接手动创建表的列,也可以通过 SQL 代码来定义。我们使用 SQL 代码来创建表。以下是我们创建表的 SQL 代码:
CREATE TABLE Currency_Master
(
ID INT IDENTITY(1,1) PRIMARY KEY, -- 主键,自动递增
Amount FLOAT NULL, -- 金额列,可以为空
Currency_Name NVARCHAR(50) NULL -- 货币名称列,最大长度50个字符,可以为空
);
FLOAT
,可以为空。NVARCHAR(50)
,也可以为空,表示可以存储最多50个字符的文本。在 SQL 中,主键 是用于唯一标识每一行数据的字段。如果你有大量数据,比如10 million个记录,如果没有主键,可能会出现数据混乱的情况。比如有两个名字相同的人(如John Smith),你可能无法区分他们。因此,ID 列作为主键会确保每一行都有唯一的标识。
一旦创建了表,点击 Update 来保存更改并更新数据库。接着刷新 Server Explorer,你会看到 Currency_Master 表已经创建成功,并且包含了 ID, Amount, 和 Currency_Name 三列。
为了让我们的应用程序能够访问这个数据库,我们需要在 app.config 文件中设置连接字符串。打开 app.config 文件,并在其中的
<connectionStrings>
<add name="ConnectionString" connectionString="Data Source=(LocalDB)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\CurrencyConverter.mdf;Integrated Security=True" providerName="System.Data.SqlClient"/>
</connectionStrings>
(LocalDB)\MSSQLLocalDB
,表示我们使用的是本地数据库。CurrencyConverter.mdf
)的路径。你可以从 Solution Explorer 中复制该文件的完整路径并替换这里的路径。Data Source
:指定数据库服务器。(LocalDB)\MSSQLLocalDB
表示我们使用的是 SQL Server 本地数据库实例。AttachDbFilename
:指定我们数据库文件的路径。|DataDirectory|
是一个特殊的标记,它会被替换为数据库文件的实际存放路径。在本地开发环境中,它通常是项目文件夹的 bin 目录。Integrated Security=True
:表示使用 Windows 身份验证登录数据库。目前,我们已经完成了以下任务:
下一步,我们将继续编写代码,利用这些设置连接数据库并操作数据。
欢迎回来!我们现在继续我们的项目,首先让我们进入 MainWindow
部分类的顶部,因为所有的逻辑都将在这里进行。我们将创建一些对象,因此我需要一个 SQL 连接对象、一个 SQL 命令对象和一个 SQL 数据适配器对象,因为我将需要它们来操作数据库并调整数据库中的值。
为了使这些对象能够正常工作,我们需要确保已添加正确的命名空间。为了做到这一点,我将使用 using System.Data.SqlClient
,这是必需的。添加完这个命名空间后,你会看到所有的类都被加载出来。SQLConnection
允许我连接到 SQL 数据库,SQLCommand
是用来运行 SQL 命令的,而 SQLDataAdapter
则用于处理数据的格式转换。
接下来,我们将定义以下三个对象:
为了实现这些,我们还需要定义一些字段来存储临时的数据:
这些字段将在类的不同方法中使用,所以我们把它们定义在 MainWindow
类的顶部。
接下来,我们将创建一个方法来连接到 SQL 数据库。这个方法被命名为 myCon
,它的作用是使用我们在 app.config
文件中配置的连接字符串来连接数据库。连接字符串会通过 ConfigurationManager.ConnectionStrings
来读取并存储在变量 con
中。
public void myCon()
{
string con = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString;
SqlConnection conn = new SqlConnection(con);
conn.Open();
}
接下来,我们需要从数据库中获取数据并将其绑定到 ComboBox 中。为了实现这个目标,我们需要对 BindCurrency
方法做出一些修改。之前,我们使用手动填充数据表的方式来设置 ComboBox 的数据源,但是现在我们要使用数据库中的数据。
首先,我们将创建一个新的 DataTable
对象,然后通过 SQL 命令 SELECT ID, CurrencyName FROM CurrencyMaster
来查询 CurrencyMaster
表中的数据。
public void BindCurrency()
{
SqlConnection conn = new SqlConnection(con);
SqlCommand cmd = new SqlCommand("SELECT ID, CurrencyName FROM CurrencyMaster", conn);
SqlDataAdapter da = new SqlDataAdapter(cmd);
DataTable dt = new DataTable();
da.Fill(dt);
if (dt != null && dt.Rows.Count > 0)
{
comboBoxCurrencyName.ItemsSource = dt.DefaultView;
}
}
在这里,我们执行 SQL 查询,并将结果填充到 DataTable
中。DataTable
是一个 C# 中的数据结构,用来存储数据库查询的结果。之后,我们将通过 DefaultView
将其作为 ComboBox
的数据源进行绑定。
通过这段代码,我们从数据库中提取了数据,并将其设置为 ComboBox 的 ItemSource
,这样 ComboBox 就会显示出数据库中的货币名称。
完成数据操作后,我们需要确保关闭 SQL 连接。通过以下代码来关闭连接:
conn.Close();
以下是连接到数据库并绑定 ComboBox 的完整方法:
public void myCon()
{
string con = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString;
SqlConnection conn = new SqlConnection(con);
conn.Open();
}
public void BindCurrency()
{
SqlConnection conn = new SqlConnection(con);
SqlCommand cmd = new SqlCommand("SELECT ID, CurrencyName FROM CurrencyMaster", conn);
SqlDataAdapter da = new SqlDataAdapter(cmd);
DataTable dt = new DataTable();
da.Fill(dt);
if (dt != null && dt.Rows.Count > 0)
{
comboBoxCurrencyName.ItemsSource = dt.DefaultView;
}
conn.Close();
}
SqlCommand
对象来执行查询(SELECT
)命令。SqlDataAdapter
将查询结果填充到 DataTable
中。通过以上步骤,我们成功地将数据库中的数据绑定到应用程序的 ComboBox 中,这就是数据库交互的基础。在接下来的步骤中,我们将实现更多的功能,比如对数据库的修改、更新、删除等操作。
欢迎回来!现在到了关键的一步,我们需要确保点击保存按钮时能够执行相应的功能。虽然我已经运行了应用程序,并且它似乎没有问题,但是如果我们查看“Currency Master”部分,它并不会做任何事情。如果点击保存或取消按钮,什么也不会发生,因为我们还没有实现相关的功能。
在你的主窗口 (MainWindow
) 文件中,你会看到有一个包含按钮的 StackPanel
。这部分位于第二个标签页 TabItem Master
内。让我们打开这个部分,你可以看到里面有所有的 StackPanel
和 TextBox
,还有按钮。现在,按钮的点击事件还没有实现。
我们需要为 Save
按钮添加点击事件。首先,打开 Button Save Click
事件的定义,你会看到一个按钮,但是它的功能还没有实现。接下来,我们会使用 try-catch
块,因为我们正在操作数据库,而在与数据库交互时,可能会遇到连接中断等错误。为了避免程序崩溃,我们将使用 try-catch
来捕获异常,并在出现错误时显示一个消息框。
try
{
// 数据库操作的逻辑
}
catch (Exception ex)
{
MessageBox.Show($"Error: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
如果点击保存按钮时出现了错误,弹出的消息框将显示具体的错误信息。
接下来,我们要确保用户输入了有效的数据。首先,我们需要检查金额输入框(TextAmount
)是否为空。如果为空,我们会提示用户输入金额。如果输入框不为空,我们接着检查货币名称输入框(TextCurrencyName
)是否为空。只有当两个输入框都有值时,才会继续进行保存数据的逻辑。
if (string.IsNullOrEmpty(TextAmount.Text))
{
MessageBox.Show("Please enter an amount", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
TextAmount.Focus();
return;
}
if (string.IsNullOrEmpty(TextCurrencyName.Text))
{
MessageBox.Show("Please enter a currency name", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
TextCurrencyName.Focus();
return;
}
当两个输入框都有值时,我们执行保存操作。在执行保存之前,我们首先检查 CurrencyID
是否大于零。因为如果 CurrencyID
为零,说明这是一个新的条目,我们不应该进行更新操作。只有在 CurrencyID
大于零时,才执行更新数据库的逻辑。此时,弹出一个确认对话框,询问用户是否确认更新数据。
if (CurrencyID > 0)
{
MessageBoxResult result = MessageBox.Show("Are you sure you want to update?", "Confirmation", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result == MessageBoxResult.No)
{
return; // 如果用户选择"否",则退出
}
}
如果用户确认更新,我们继续执行数据库操作。我们需要通过 SQLite
连接来更新数据。首先,通过 MyConn
方法打开数据库连接,然后使用 SQL Command
来执行更新语句。
using (var conn = new SQLiteConnection(connectionString))
{
conn.Open();
string sql = "UPDATE CurrencyMaster SET Amount = @Amount, CurrencyName = @CurrencyName WHERE ID = @ID";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@Amount", TextAmount.Text);
cmd.Parameters.AddWithValue("@CurrencyName", TextCurrencyName.Text);
cmd.Parameters.AddWithValue("@ID", CurrencyID);
cmd.ExecuteNonQuery();
}
conn.Close();
}
每次打开数据库连接后,我们都需要确保在操作完成后关闭连接。确保没有连接泄漏,并避免出现其他错误。
conn.Close();
一旦更新操作成功执行,我们会通过消息框通知用户数据已成功更新。
MessageBox.Show("Data updated successfully", "Information", MessageBoxButton.OK, MessageBoxImage.Information);
TextAmount
和 TextCurrencyName
是否为空;CurrencyID
大于零,弹出确认框;以上就是当点击保存按钮时执行的完整功能。
当 currencyID
大于零时,我们进行更新操作;如果 currencyID
小于或等于零,说明是新增一行数据。在这种情况下,我们需要执行插入操作。插入数据时,我们首先需要确认用户是否真的想要保存。如果用户选择“是”,则执行以下代码。
首先,我们建立数据库连接。与更新操作相似,我们通过 command
创建一个 SQL 命令。这次,我们使用 INSERT INTO
语句将新的数据插入到数据库中。这里的 SQL 命令是:
INSERT INTO currency_master (amount, currency_name)
VALUES (@amount, @currency_name);
currency_master
表示数据库中的目标表格。amount
和 currency_name
是我们需要插入的列。SQL 命令中使用了 @amount
和 @currency_name
作为占位符,这些占位符稍后将通过参数的方式提供实际的值。我们通过 AddWithValue
方法为这些参数赋值,确保数据传递给 SQL 查询。
command.Parameters.AddWithValue("@amount", textAmount.Text);
command.Parameters.AddWithValue("@currency_name", textCurrencyName.Text);
执行 SQL 命令后,我们关闭数据库连接,确保连接得到正确释放。关闭连接是很重要的,如果不关闭连接可能会导致资源泄漏或者错误。
成功插入新数据后,我们调用 ClearMaster
方法清除输入字段。这个方法会执行以下操作:
textAmount
和 textCurrencyName
输入框。GetData
方法加载并显示最新的数据。currencyID
为零,表示当前不在编辑模式。代码实现如下:
private void ClearMaster()
{
textAmount.Text = "";
textCurrencyName.Text = "";
btnSave.Content = "Save"; // 重置保存按钮文本
GetData(); // 重新加载数据
currencyID = 0; // 重置currencyID
}
GetData
方法GetData
方法负责从数据库中获取并显示所有的货币数据。首先,我们需要建立连接并执行 SQL 查询来获取所有数据:
private void GetData()
{
using (var connection = new SqlConnection(connectionString))
{
string query = "SELECT * FROM currency_master";
SqlDataAdapter dataAdapter = new SqlDataAdapter(query, connection);
DataTable dataTable = new DataTable();
dataAdapter.Fill(dataTable); // 填充数据表
// 检查数据表是否有数据
if (dataTable.Rows.Count > 0)
{
dgvCurrency.ItemsSource = dataTable.DefaultView; // 显示数据
}
else
{
dgvCurrency.ItemsSource = null; // 没有数据则清空
}
}
}
SELECT * FROM currency_master
获取所有的记录。SqlDataAdapter
将结果填充到 DataTable
中。DataTable
是否包含数据。如果有数据,我们将 DataGridView
(即 dgvCurrency
)的数据源设置为该 DataTable
的默认视图。否则,将数据源设置为 null
,即清空数据。dgvCurrency
在这里,dgvCurrency
代表我们 UI 中的 DataGridView
控件,用于展示数据库中的数据。如果数据库返回了有效数据,我们将其显示在 UI 上。否则,DataGridView
将显示为空。
currencyID
为零时,我们通过 INSERT INTO
将数据插入到数据库中。ClearMaster
方法清除用户输入,并重置相关控件和状态。GetData
方法负责从数据库获取并显示所有数据,确保 UI 始终展示最新的内容。通过这些步骤,我们能够有效地管理数据库中的货币数据,无论是插入新数据还是更新现有数据。
到目前为止,我们已经能够运行这个应用并将数据存入数据库。通过输入金额并点击保存,我们可以成功保存数据,并且在数据网格视图中显示它。现在,我们已经具备了将数据添加到数据库、从数据库获取数据的能力,接下来我们要实现的是能够编辑数据库中的数据。
首先,让我们测试一下货币转换器是否仍然有效。我将随便添加一些值进行测试:
保存这些值后,你会看到它们出现在这里。这是因为我们将这些数据框的 ItemsSource
设置为数据表中的数据,这样一来,它们就能在界面中显示了。
现在,我们的转换功能已经可以正常工作,但是还存在一些问题,例如无法添加或删除数据。接下来,我们将处理这些问题。
首先,我们需要为取消按钮添加功能。如果我们查看界面,我们会发现取消按钮目前没有任何功能。让我们来快速添加一下。
private void ButtonCancel_Click(object sender, RoutedEventArgs e)
{
try
{
ClearMaster(); // 清空所有输入的内容
GetData(); // 重新加载数据
}
catch (Exception ex)
{
MessageBox.Show("发生错误: " + ex.Message, "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
这个方法很简单,当用户点击取消按钮时,我们调用 ClearMaster()
方法来清除所有的输入内容,并重新加载数据。
接下来,我们将添加编辑和删除功能。首先,我们需要实现 Currency_SelectedCellsChanged
事件。这个事件会在用户点击数据网格中的单元格时触发。
private void Currency_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e)
{
try
{
// 获取触发事件的 DataGrid
DataGrid grid = sender as DataGrid;
// 获取当前选中的行
var rowSelected = grid.CurrentItem as DataRowView;
if (rowSelected != null)
{
// 确保有数据
if (grid.Items.Count > 0)
{
// 获取选中行的 ID
var currencyID = rowSelected["ID"].ToString();
// 如果点击的是编辑列
if (grid.CurrentCell.Column.DisplayIndex == 0)
{
// 将选中行的数据填充到文本框
TextAmount.Text = rowSelected["Amount"].ToString();
TextCurrencyName.Text = rowSelected["CurrencyName"].ToString();
// 将保存按钮内容设置为“更新”
ButtonSave.Content = "Update";
}
// 如果点击的是删除列
else if (grid.CurrentCell.Column.DisplayIndex == 1)
{
// 弹出确认框,询问用户是否删除
var result = MessageBox.Show("确认删除选中的货币?", "删除确认", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (result == MessageBoxResult.Yes)
{
// 执行删除操作
DeleteCurrency(currencyID);
}
}
}
}
}
catch (Exception ex)
{
MessageBox.Show("发生错误: " + ex.Message, "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
在这个方法中,我们首先通过 sender
获取到触发事件的 DataGrid
,然后从当前选中的行中提取数据并填充到文本框中。如果用户点击的是编辑列,我们会将选中行的金额和货币名称填充到相应的文本框中,并将保存按钮的内容改为“更新”。如果点击的是删除列,我们会弹出一个确认框询问用户是否删除选中的货币。如果用户确认删除,我们调用 DeleteCurrency()
方法执行删除操作。
private void DeleteCurrency(string currencyID)
{
try
{
// 建立数据库连接
using (var connection = new SQLiteConnection("Data Source=currency.db"))
{
connection.Open();
// 创建 SQL 删除命令
var command = new SQLiteCommand("DELETE FROM CurrencyMaster WHERE ID = @ID", connection);
command.Parameters.AddWithValue("@ID", currencyID);
// 执行删除操作
command.ExecuteNonQuery();
// 显示成功提示
MessageBox.Show("删除成功!", "成功", MessageBoxButton.OK, MessageBoxImage.Information);
// 更新数据
ClearMaster();
}
}
catch (Exception ex)
{
MessageBox.Show("删除失败: " + ex.Message, "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
在 DeleteCurrency
方法中,我们首先建立与数据库的连接,创建一个删除命令,并根据选中的 currencyID
执行删除操作。删除成功后,我们会显示一个提示框,并调用 ClearMaster()
来更新数据网格,确保界面和数据库的数据同步。
现在,我们要确保在应用启动时,数据网格能够加载数据。为此,我们可以在应用启动时调用 BindCurrency()
和 GetData()
方法,以便自动填充数据网格。
private void Window_Loaded(object sender, RoutedEventArgs e)
{
try
{
BindCurrency(); // 绑定货币数据
GetData(); // 获取并显示数据
}
catch (Exception ex)
{
MessageBox.Show("初始化失败: " + ex.Message, "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
通过在 Window_Loaded
事件中调用这两个方法,确保了当应用启动时,数据网格就能够加载并显示数据。
到目前为止,我们已经实现了以下功能:
接下来,我们可以进行测试,确保这些功能正常工作。首先,我们可以尝试添加数据并确保它显示在数据网格中。然后,我们可以尝试编辑和删除数据,并验证数据库和界面是否同步更新。
到此为止,我们已经实现了一个基本的数据库管理应用,能够进行数据的添加、编辑、删除操作,并确保界面和数据库保持同步。通过这些步骤,你已经学会了如何在 C# WPF 应用中处理数据库操作,并通过事件驱动的方式来更新界面。希望这对你有所帮助!
在本章节中,您将学习如何使用一个名为 Linq 的第三方库。这个库将使您能够更高效地使用数据库,并且我们将探讨如何使用您可以在线找到的第三方库。
每当您需要使用第三方库时,首先要做的就是查阅该库的文档,深入了解它的功能和用法。如今有许多第三方库,每个库都为编程带来了不同的特色,它们能够在您开发程序时提供更简便的功能,从而让您的工作变得更加轻松。
本章的重点是 Linq 库,它可以帮助我们将之前学到的知识以更加简洁和高效的方式结合在一起。通过 Linq ,您将能够更加高效地操作数据库,减少代码量,使您的编程工作更加流畅。
接下来,我们将深入了解如何使用 Linq 来简化数据库操作,并结合您到目前为止学到的内容,提升编程效率。敬请期待下一视频的讲解。
在本视频中,我将向您简要介绍 Link。Link 是一种用于从数据源中检索数据的工具,通过使用 Link 查询操作,您可以从不同类型的数据源中获取数据,这正是 Link 强大的地方。无论是数组、数据库、XML 文件,还是其他多种数据源,都可以用于查询操作。
查询操作包括三个步骤:
这三个步骤是获取或调整数据所必需的基本步骤。
我们来看一个简单的示例:打印出一个字符串数组中按名称排序的条目。假设我们有一个名为 names
的数组,里面有 Berta、Clause 和 Atom。
数据源是我们要查询的数据,示例中是 names
数组。
查询的创建使用 Link 查询语法:
var query = from name in names
orderby name ascending
select name;
我们遍历所有的名字,按字母顺序对它们进行升序排序,然后选择每个名字。
接下来,我们可以使用 foreach
循环来执行查询,并将结果打印到控制台:
foreach (var i in query)
{
Console.WriteLine(i);
}
输出将会是:
Adam
Berta
Clause
在这个例子中,我们仅仅是重新排序了数据,而没有改变原始字符串的内容。我们以不同的顺序打印出来而已。
现在,我们来看另一个示例:打印出整个数组中的条目,按大小排序,但忽略小于 5 的值。假设我们有一个数字数组 numbers
,它包含 7、5、13、125 和 4。
数据源是包含数字的数组 numbers
。
查询创建如下所示:
var query = from number in numbers
where number > 5
orderby number descending
select number;
我们首先筛选出大于 5 的数字,然后按降序排序(从大到小),最后选择这些数字。
查询执行使用 foreach
循环来输出结果:
foreach (var i in query)
{
Console.WriteLine(i);
}
输出将会是:
125
13
7
其中,数字 4 被排除在外,因为它不满足大于 5 的条件。
这只是 Link 的一个非常基础的介绍,展示了如何从数组中获取和操作数据。但实际上,您可以从各种数据源中获取数据,Link 在这些数据源之间的表现是一致的,这就是 Link 的真正强大之处。无论数据源是数组、XML 文件还是数据库,Link 都能以相同的方式工作。
接下来,我们将深入探讨如何在数据库和其他数据源中使用 Link。希望您喜欢这个内容,并且准备好学习更高级的技术,您将能够将这些技能应用到更实际的编程中。
期待在下一个视频中与您见面!
在本次演示中,我们将开始使用 Link。所以,让我们先创建一个新的控制台应用程序,我将它命名为 LinkOne
。创建完成后,接下来我们将创建一个简单的整数数组,命名为 numbers
。这是一个 new int
类型的数组,我将提前在其中加入一些值:1、2、3、4、5,直到9。这个数组就是我们的 numbers
。
我们的目标是从这个数组中提取出所有的奇数。实现这一功能的方法有很多种。例如,我们可以使用取余操作符(modulo),通过检查每个数字是否能被 2 整除且余数不为 0 来判断它是否为奇数。这样的方法是有效的,但我们将采用一种更为精妙的方式。
创建一个新方法
我将创建一个名为 oddNumbers
的静态方法,该方法需要一个整数数组作为参数。这里,我们的参数将是 numbers
数组。
输出提示
在方法中,首先在控制台上输出一个提示,告诉我们将要打印所有的奇数。
Console.WriteLine("Odd numbers:");
使用 IEnumerable
获取奇数
为了提取奇数,我们将使用 IEnumerable
。IEnumerable
是一个集合类型,类似于列表,但它具有一些附加功能。我们会使用 IEnumerable<int>
来表示一个整数的集合。然后我们会创建一个名为 oddNumbers
的变量,这个变量将保存所有奇数。
接下来,我们将使用 Link 查询语法来获取奇数:
IEnumerable<int> oddNumbers = from number in numbers
where number % 2 != 0
select number;
这行代码的意思是:
from number in numbers
:这类似于一个 foreach
循环,遍历 numbers
数组中的每个数字。where number % 2 != 0
:这是一个过滤条件,检查数字是否为奇数。即数字除以 2 后的余数不为 0。select number
:这是一个投影操作符,决定将什么内容添加到 oddNumbers
列表中。在这里,我们选择将奇数添加到 oddNumbers
列表中。打印奇数列表
我们可以使用 foreach
循环打印 oddNumbers
列表中的每个奇数:
foreach (var odd in oddNumbers)
{
Console.WriteLine(odd);
}
打印所有原始数字
为了比较,我们还可以打印出 numbers
数组中的所有数字:
foreach (var i in numbers)
{
Console.WriteLine(i);
}
将上述步骤结合在一起,我们的代码如下:
static void OddNumbers(int[] numbers)
{
Console.WriteLine("Odd numbers:");
// 使用 LINQ 从数组中获取奇数
IEnumerable<int> oddNumbers = from number in numbers
where number % 2 != 0
select number;
// 打印奇数
foreach (var odd in oddNumbers)
{
Console.WriteLine(odd);
}
// 打印所有数字
foreach (var i in numbers)
{
Console.WriteLine(i);
}
}
如果我们运行这段代码,输出将如下所示:
Odd numbers:
1
3
5
7
9
1
2
3
4
5
6
7
8
9
在这个示例中,我们通过 Link 从简单的整数数组中提取出了所有的奇数。这展示了 Link 的强大之处:即使我们只用一个简单的数组,Link 也能以类似 SQL 的方式进行数据处理。更酷的是,Link 不仅限于数组,它同样适用于数据库、XML 文件等其他数据源。
接下来,我们将在下一个视频中探讨 Link 的更多功能,敬请期待!
这是第二个 Link 演示。在这个演示中,我们将准备一些稍后会用到的内容。首先,我们将创建两个额外的类,然后再创建第三个类来管理这两个类。这两个类将用于创建有关大学和学生的对象。让我们开始吧。
我们首先创建一个 University
类,表示大学。每个大学有一些基本信息,比如大学的 ID
和 Name
。然后我们创建一个 print
方法,用来打印大学的详细信息。
public class University
{
public int ID { get; set; } // 大学ID
public string Name { get; set; } // 大学名称
// 打印大学信息
public void Print()
{
Console.WriteLine($"University {Name} with ID {ID}");
}
}
接下来我们创建一个 Student
类,表示学生。每个学生有 ID
、Name
、Gender
(性别)和 Age
(年龄)等属性。此外,学生还需要一个 universityID
,作为外键,关联到所属的大学。
public class Student
{
public int ID { get; set; } // 学生ID
public string Name { get; set; } // 学生姓名
public string Gender { get; set; } // 性别
public int Age { get; set; } // 年龄
public int UniversityID { get; set; } // 外键,关联到大学
// 打印学生信息
public void Print()
{
Console.WriteLine($"Student {Name} with ID {ID}, Gender {Gender}, Age {Age} from university with ID {UniversityID}");
}
}
接下来,我们将创建一个 UniversityManager
类,管理大学和学生。这个类将包含大学和学生的列表,并提供一些方法来处理这些数据。例如,我们可以列出所有男性学生、女性学生等。这里我们使用 IEnumerable
来处理 Link 查询。
public class UniversityManager
{
public List<University> Universities { get; set; } // 存储大学列表
public List<Student> Students { get; set; } // 存储学生列表
// 构造器,初始化数据
public UniversityManager()
{
Universities = new List<University>();
Students = new List<Student>();
// 添加大学
Universities.Add(new University { ID = 1, Name = "Yale" });
Universities.Add(new University { ID = 2, Name = "Beijing Tech" });
// 添加学生
Students.Add(new Student { ID = 1, Name = "Carla", Gender = "Female", Age = 17, UniversityID = 1 });
Students.Add(new Student { ID = 2, Name = "Toni", Gender = "Male", Age = 21, UniversityID = 1 });
Students.Add(new Student { ID = 3, Name = "Lila", Gender = "Female", Age = 19, UniversityID = 2 });
Students.Add(new Student { ID = 4, Name = "James", Gender = "Transgender", Age = 22, UniversityID = 2 });
Students.Add(new Student { ID = 5, Name = "Linda", Gender = "Female", Age = 22, UniversityID = 2 });
}
// 获取所有男性学生
public IEnumerable<Student> GetMaleStudents()
{
var maleStudents = from student in Students
where student.Gender == "Male"
select student;
return maleStudents;
}
// 获取所有女性学生
public IEnumerable<Student> GetFemaleStudents()
{
var femaleStudents = from student in Students
where student.Gender == "Female"
select student;
return femaleStudents;
}
}
接下来,我们将使用创建的 UniversityManager
类来显示所有男性和女性学生。
class Program
{
static void Main(string[] args)
{
// 创建大学管理器对象
UniversityManager um = new UniversityManager();
// 获取并打印所有男性学生
var maleStudents = um.GetMaleStudents();
Console.WriteLine("Male Students:");
foreach (var student in maleStudents)
{
student.Print();
}
// 获取并打印所有女性学生
var femaleStudents = um.GetFemaleStudents();
Console.WriteLine("Female Students:");
foreach (var student in femaleStudents)
{
student.Print();
}
// 防止控制台窗口立即关闭
Console.ReadKey();
}
}
当运行程序时,控制台将显示以下内容:
Male Students:
Student Toni with ID 2, Gender Male, Age 21 from university with ID 1
Student James with ID 4, Gender Transgender, Age 22 from university with ID 2
Female Students:
Student Carla with ID 1, Gender Female, Age 17 from university with ID 1
Student Lila with ID 3, Gender Female, Age 19 from university with ID 2
Student Linda with ID 5, Gender Female, Age 22 from university with ID 2
通过这次演示,我们已经使用 Link 查询来筛选出男性和女性学生。Link 的强大之处在于它能够简洁地操作数据集,且不仅适用于数组,还可以与列表、数据库、XML 等其他数据源配合使用。
在下一节中,我们将继续扩展 UniversityManager
类,添加更多的功能,比如根据不同信息进行筛选或排序,敬请期待!
在本视频中,我们将对学生进行排序,并且还会查看如何查找特定大学的所有学生。让我们先创建一个方法。我在我的 UniversityManager
类里面,接下来我要创建一个名为 SortStudentsByAge
的方法。
首先,我创建一个新的变量 SortedStudents
,它将从 students
中获取数据,并按年龄对学生进行排序:
var SortedStudents = from student in students
orderby student.Age
select student;
在这里,我们使用了 orderby
操作符,它会按给定的值对输出进行排序。此处我们根据年龄对学生进行排序。为了简化代码,我使用了 var
作为类型,它会自动推导出类型为 IEnumerable<Student>
,但请注意,使用 var
可能会稍微影响性能,因为编译器需要推导出类型。
接下来,我们将学生按年龄排序后输出到控制台。我们使用一个 foreach
循环来遍历 SortedStudents
:
Console.WriteLine("Students sorted by age:");
foreach (var student in SortedStudents)
{
student.Print();
}
运行程序后,控制台会显示按年龄排序的学生信息。例如,学生 Carla(年龄17岁)会排在最前面,Layla(19岁)会排在第二位,依此类推。
接下来,我们要创建一个方法,查找所有来自特定大学的学生。假设我们要查找来自北京理工大学(Beijing Tech)的所有学生。为了实现这个目标,我们需要根据学生的 universityID
来匹配大学的名称。这就像是将两个表连接起来的过程,类似于在数据库中使用 JOIN
。
创建一个新的方法 AllStudentsFromBeijingTech
:
public void AllStudentsFromBeijingTech()
{
var BGTStudents = from student in students
join university in universities
on student.UniversityID equals university.ID
where university.Name == "Beijing Tech"
select student;
Console.WriteLine("Students from Beijing Tech:");
foreach (var student in BGTStudents)
{
student.Print();
}
}
在这里,我们使用了 join
操作符来连接 students
和 universities
。我们通过学生的 UniversityID
与大学的 ID
进行匹配,并过滤出大学名称为 “Beijing Tech” 的学生。
运行程序时,控制台会显示来自北京理工大学的所有学生的信息。例如,学生 Frank 和 Tony(在北京理工大学,性别男,年龄分别为22岁和21岁)会显示在控制台上。
现在的挑战是创建一个方法,接受一个 int
类型的 ID
,并根据该 ID 查找并打印出该大学的所有学生。用户将输入大学的 ID,如果输入为 1
,则显示 Yale 大学的所有学生,如果输入为 2
,则显示北京理工大学的学生。
方法的创建如下:
public void AllStudentsFromUni(int ID)
{
var myStudents = from student in students
join university in universities
on student.UniversityID equals university.ID
where university.ID == ID
select student;
Console.WriteLine($"Students from university {ID}:");
foreach (var student in myStudents)
{
student.Print();
}
}
在 Main
方法中,我们通过 Console.ReadLine
获取用户输入,并将其转换为整数:
Console.WriteLine("Enter university ID:");
string input = Console.ReadLine();
int inputID = int.Parse(input);
AllStudentsFromUni(inputID);
在运行程序时,输入 1
将显示 Yale 大学的所有学生,输入 2
将显示北京理工大学的所有学生。
除了按年龄排序,我们还可以使用其他方式对数据进行排序。以下是使用 IEnumerable
来排序整数数组的一些示例:
var sortedInts = from i in someInts
orderby i
select i;
这样会按升序排列数组中的整数。如果要按降序排列,可以使用 Reverse
方法:
var reversedInts = sortedInts.Reverse();
或者,使用 LINQ 的 OrderByDescending
方法直接进行降序排列:
var reversedSortedInts = from i in someInts
orderby i descending
select i;
我们在本视频中展示了如何排序学生列表、查找特定大学的学生以及根据用户输入的大学 ID 查找该大学的所有学生。我们还探讨了多种排序方法,如升序、降序等。通过使用 LINQ 和其他集合操作,我们能够灵活地处理和排序数据。
希望本视频对你有所帮助,下一步我们将在接下来的课程中扩展更多功能,敬请期待!
欢迎回来!现在你已经了解了如何反转、排序数组和列表,及其他一些与 IEnumerable
相关的操作。接下来,我们要做一些非常有趣的事情——我们将合并两个列表并将其放入一个新的列表中。这个过程和将两个表合并非常相似,不过我们会用 LINQ 来实现。所以让我们创建一个新方法,命名为 student and university name collection
。
我们将创建一个新集合,这个集合不再包含学生的原始信息(如 ID、姓名、性别、年龄和大学 ID),而是只包含学生姓名和他们所在大学的名称。具体来说,我们希望知道每个学生的姓名以及他们所在大学的名称。
首先,我们创建一个新的集合,使用一个 foreach
循环遍历所有学生,并通过 join
操作将学生与大学信息结合起来。代码如下:
public void StudentAndUniversityNameCollection()
{
var newCollection = from student in students
join university in universities on student.UniversityID equals university.ID
orderby student.Name
select new { StudentName = student.Name, UniversityName = university.Name };
foreach (var collection in newCollection)
{
Console.WriteLine($"Student {collection.StudentName} from University {collection.UniversityName}");
}
}
join
操作:我们使用 join
通过学生的 UniversityID
与大学的 ID
进行关联,将每个学生与其所在的大学数据连接起来。orderby
操作:对连接后的数据按学生的姓名进行排序。select
操作:我们选择一个新的匿名类型,其中包括学生姓名和大学名称。这是我们想要在新集合中存储的信息。foreach
循环:遍历新集合并打印每个学生及其所在大学的信息。在 Main
方法中调用 StudentAndUniversityNameCollection
,然后运行程序。输出的结果会是按学生姓名排序的学生和他们的大学名称:
Student Carla from University Yale
Student Frank from University Beijing
Student Layla from University Yale
...
通过这段代码,我们演示了如何使用 LINQ 来合并两个集合(学生和大学)并创建一个新的集合,其中仅包含学生的姓名和大学的名称。我们没有涉及学生的 ID 或其他数据,只选择了我们感兴趣的字段。这种方法类似于 SQL 中的 JOIN
操作,但我们在 C# 中使用 LINQ 来实现。
你还可以看到,使用 LINQ 来操作集合可以非常高效,尤其是在没有使用数据库时。你可以使用这种方法来处理 XML、集合、数据库等多种数据源,而无需依赖数据库适配器或复杂的代码逻辑。
接下来的视频我们将探讨更多 LINQ 的高级用法。敬请期待!
在这一视频中,我们将学习如何将 LINQ 与 XML 结合使用。我创建了一个名为 Link with XML 的项目,并且编写了一个字符串,叫做 Students XML,它包含了一些 XML 数据。接下来我们将展示如何使用 XML 数据与 LINQ 进行操作。
在这个示例中,XML 文件包含了一个名为 students
的对象,它有多个学生对象。每个学生对象包含 name
(姓名)、age
(年龄)和 university
(大学)等属性。我们这里以三个学生为例,数据格式如下:
<students>
<student>
<name>Toni</name>
<age>21</age>
<university>Yale</university>
</student>
<student>
<name>Carla</name>
<age>17</age>
<university>Yale</university>
</student>
<student>
<name>Leila</name>
<age>19</age>
<university>Beijing Tech</university>
</student>
</students>
XML(可扩展标记语言)是一种用于结构化数据的语言,在很多场合中都有应用。例如,许多网站使用 XML 来共享数据,通过 API 将数据以 XML 格式提供,你可以通过读取这些 XML 文件来将它们集成到你的程序或网站中。
为了开始使用 XML 数据,我们首先需要添加命名空间 System.Xml.Linq
,这个命名空间包含了我们需要的 XDocument
类,能够帮助我们解析 XML 字符串。接下来,我们可以通过 XDocument.Parse()
方法将字符串转换为 XML 文档对象。
using System.Xml.Linq;
string studentsXml = "<students>...</students>"; // 你的 XML 数据
XDocument xDoc = XDocument.Parse(studentsXml);
通过 LINQ,我可以从 XDocument
中提取出学生信息。在 LINQ 中,我们可以用 xDoc.Descendants("student")
获取所有学生元素,并使用 select
创建一个新的匿名对象,提取每个学生的姓名、年龄和大学。
var students = from student in xDoc.Descendants("student")
select new
{
Name = student.Element("name").Value,
Age = student.Element("age").Value,
University = student.Element("university").Value
};
这样,students
变量将包含一个学生信息的集合,每个学生的信息包括姓名、年龄和大学。
我们可以遍历 students
集合,并将每个学生的信息打印出来:
foreach (var student in students)
{
Console.WriteLine($"Student {student.Name}, Age {student.Age}, University {student.University}");
}
这段代码将输出:
Student Toni, Age 21, University Yale
Student Carla, Age 17, University Yale
Student Leila, Age 19, University Beijing Tech
接下来,我们可以使用 LINQ 对学生进行排序。例如,我们可以按学生的年龄排序,代码如下:
var sortedStudents = from student in students
orderby student.Age
select student;
foreach (var student in sortedStudents)
{
Console.WriteLine($"Student {student.Name}, Age {student.Age}, University {student.University}");
}
这样,我们就可以按年龄升序输出学生信息,结果如下:
Student Carla, Age 17, University Yale
Student Leila, Age 19, University Beijing Tech
Student Toni, Age 21, University Yale
在 XML 文件中,我们还可以为每个学生添加额外的信息。例如,我们可以为每个学生添加学期信息(例如,Tony 是第六学期),并在 LINQ 查询中将其包括进来。
我们先修改 XML 数据,增加一个 semester
元素:
<students>
<student>
<name>Toni</name>
<age>21</age>
<university>Yale</university>
<semester>6</semester>
</student>
<student>
<name>Carla</name>
<age>17</age>
<university>Yale</university>
<semester>1</semester>
</student>
<student>
<name>Leila</name>
<age>19</age>
<university>Beijing Tech</university>
<semester>3</semester>
</student>
<student>
<name>Frank</name>
<age>25</age>
<university>Harvard</university>
<semester>10</semester>
</student>
</students>
然后,修改 LINQ 查询以包括 semester
信息:
var studentsWithSemester = from student in xDoc.Descendants("student")
select new
{
Name = student.Element("name").Value,
Age = student.Element("age").Value,
University = student.Element("university").Value,
Semester = student.Element("semester").Value
};
foreach (var student in studentsWithSemester)
{
Console.WriteLine($"Student {student.Name}, Age {student.Age}, University {student.University}, Semester {student.Semester}");
}
输出将包括学期信息:
Student Toni, Age 21, University Yale, Semester 6
Student Carla, Age 17, University Yale, Semester 1
Student Leila, Age 19, University Beijing Tech, Semester 3
Student Frank, Age 25, University Harvard, Semester 10
通过使用 LINQ 和 XML,您可以轻松地将结构化数据(如学生信息)加载到您的程序中,并进行各种操作,如排序、筛选和查询。XML 在很多应用场景中都非常有用,尤其是当你需要处理外部数据源时,LINQ 提供了一个强大而简便的工具来处理这些数据。
在这个视频中,我们将使用 Link 和 SQL。让我们开始创建一个新的项目。在这种情况下,我将使用一个 WPF 项目,因为我希望以一种漂亮的方式显示数据。所以我将这个项目命名为 Link to SQL 或 SQL,然后按下 OC 创建项目。我想在这里使用一个数据网格。因此,我将在这里创建一个新的数据网格,命名为 Main Data Grid。这个数据网格应该显示我们数据库中的所有数据,并与 SQL 一起使用。
在此,我们设置数据网格的水平对齐方式为居中,实际上让我们将其调整为左对齐。左对齐就好。然后设置高度为 400,并设置四个方向的边距为 10。接着,我们设置垂直对齐方式为顶部。现在关闭这个数据网格。这样,我们就有了这个数据网格,但如你所见,它还没有设置宽度。所以,我将宽度设置为 450 或者类似的数值,或者可能是 520,差不多是我们主窗口宽度的整个宽度。所以,正如你看到的,500 就是合适的宽度。如果我们希望高度为 400,那我们就应该相应地增加窗口的大小。
好了,现在你有了这个窗口,我们可以继续设置服务器连接。让我们转到视图,然后选择“服务器资源管理器”或按快捷键 Ctrl + Alt + SW,它将打开数据连接屏幕或服务器资源管理器。在这里,我将创建一个新表。就我而言,我有两个连接,所以我将使用其中一个,接着我创建一个新表。这个表应该叫做 university
,它的 ID 列应该设置为身份列(Identity),所以身份列设置为 True。接下来,我希望在这里有另一个列,它将表示大学的名称,并且将其设置为 nvarchar(50)
类型,并且不允许为空。
university
表好,至此我们已经创建了 university
表,现在我们更新数据库来创建这个表。如果你刷新一下,应该能够看到已经创建了 university
表,包含 ID 和 Name 列。接下来,我们创建另一个表,称为 student
。这个表的 ID 列也应该设置为身份列。接下来,我们将设置表中的名称为 nvarchar(50)
类型,性别为 nvarchar(50)
类型,最后是 university_id
,该字段表示该表所属的大学,并且其数据类型也应该是 nvarchar(50)
。现在表格名称是 student
。
在我运行之前,我需要做一些更改。我想为外键添加约束。我要创建一个新的外键约束,命名为 University_FK
,外键列应该是 university_id
,并且它引用的是 dbo.university
表中的 id
列。然后,我会添加 ON DELETE CASCADE
语句,这意味着如果某个大学被删除,所有与该大学相关的记录也会被自动删除。
现在的问题是,university_id
字段的类型和 university
表的 ID 字段不匹配。university_id
是 nvarchar(50)
,但是 university
表的 ID 字段是整数类型。所以我需要将 university_id
字段的数据类型改为 int
,并且不允许为空。这样就意味着每个学生必须属于一个特定的大学,不能有多个大学。
lecture
表接下来,我们创建另一个表,叫做 lecture
表。每个学生应该有一个或多个讲座,因此 lecture
表也需要有一个 ID 作为主键,且其数据类型为 int
。讲座的名称字段设置为 nvarchar(50)
,并且不允许为空。
student_lecture
表最后,我们需要创建一个 student_lecture
表,作为学生和讲座之间的关联表。这个表的结构如下:每个记录都有一个 student_id
和一个 lecture_id
,它们都是 int
类型。这个表的作用是将学生和他们所上的讲座联系起来,因此它是一个关联表。
在创建完这些表后,我们需要为其添加外键约束,确保学生和讲座表之间的关系是正确的。我们为 student_lecture
表添加两个外键约束,一个是指向 student
表的 student_id
,另一个是指向 lecture
表的 lecture_id
。
完成表的创建后,我们可以继续配置数据源。首先,按下 Shift + D
,在右侧选择你的项目,然后按下 Shift + D
再次打开数据源管理器。选择 “添加新的数据源”,然后选择数据库类型。在连接数据库时,可以选择你之前设置的连接。
为了使用 Link to SQL,你需要确保已经安装了 SQL 工具或 Link to SQL 工具。如果没有安装,你需要打开 Visual Studio 安装程序,选择 “修改” 你的安装,然后确保选中了 SQL 运行时以及 Link to SQL 工具。
安装完工具后,我们可以返回到代码中,设置数据库连接。在 code behind
中,我们需要创建一个连接字符串,使用 configurationManager
来获取连接字符串,并与数据库建立连接。通过配置连接后,我们可以继续创建 Link to SQL 数据上下文类并进行数据操作。
在完成了所有的设置后,我们的数据库和 Link to SQL 都已准备好,接下来,我们将学习如何使用 Link 和 SQL 结合的知识,处理 SQL 上下文的数据。
void insertUniversities
,这个方法应该简单地将大学插入到“大学”表格中。一旦我们调用它,它就会把大学添加到我们的“大学”表格中。首先,在这个方法里,我需要创建一个大学对象。为了使用这个大学类(因为我们还没有创建这个大学类),我想使用大学对象,我需要去我的链接到 SQL 数据类,DB,HTML 中,把“大学”类拖进来。从我的表格中,我拖动“大学”到这里,现在我就可以使用它了。稍等一会,现在你可以看到它变成了绿色,这样就可以使用它了。现在让我们创建一个对象,我叫它“耶鲁”,它将是一个新的“大学”对象,并且这个耶鲁对象应该有一个名字。正如你所看到的,“耶鲁”有一个名字,这非常酷,因为当我们访问它的ID时,我们可以访问它的名字,因此我们可以访问我们设置的所有属性。当我们设置这些属性时,是在我们的表格中设置的。所以你可以创建你表格对象的实例,这很酷。所以你可以简单地使用这个链接到 SQL 数据类的 HTML,拖动你的表格到这里,它就会为你创建类。这是非常酷的。所以我们现在可以使用名字,我将名字设置为“耶鲁”。
为了将耶鲁插入到我的数据表中,我需要使用我们在上一个视频中设置的 DataContext
。在这里,“大学”表有一个属性叫做 universities
,它包含了一个叫做 InsertOnSubmit
的方法。我们想插入什么呢?我们想插入一个“大学”对象,而我要插入的是“耶鲁”对象。所以我做的其实就是将数据插入到“大学”表格中。这行代码完成了这个操作,这非常整洁,对吧?为了提交这些更改,我只需在 DataContext
上调用 SubmitChanges
,就这样。现在我提交了这些更改,意味着我提交了这个 InsertOnSubmit
方法。正如你所看到的,只有在提交时它才会插入,而在这一行中,我正提交这些更改。
为了在我的表格中显示大学,我需要调用我的主数据网格,并将其 ItemSource
设置为 DataContext.Universities
。如果我们检查一下这个数据网格,它是我们在 XAML 文件中的“DataGrid”控件。它目前没有任何数据,几乎是空的。对吧?但这是我们设置了名称为 MainDataGrid
的数据网格,现在我们将其 ItemSource
设置为 DataContext.Universities
,这样它就会显示我们数据库中或这个表格里所有的大学。所以我将调用 InsertUniversities
方法,运行代码。如果我这么做,我们会看到表格里有了 ID 为1,名称为“耶鲁”的条目。就是这个代码做的事情。
现在作为一个小挑战,将另一个大学添加到表格中并运行应用程序。我希望你尝试一下。我将创建一个新的大学,它的名字是“北京科技”,并且它是一个新的大学。现在“北京科技”需要一个名字,我将名字设置为“Beijing Tech”。当然,我还需要调用 DataContext.Universities.InsertOnSubmit
,这时我想提交的是“北京科技”。正如你所看到的,SubmitChanges
只需要在这里调用一次。这不是每次插入时都需要调用,而是我们准备好提交所有这些更改时再执行。
现在再次运行代码,我们应该在表格里看到两个条目,事实上,我们看到了三个条目,为什么会这样呢?你可能会感到困惑,但是,实际上我们已经运行过一次代码,现在再次运行它。所以我们又添加了所有大学。因此,如果我们执行并再次运行,或者关闭再执行,我们会看到有三条记录。所以到底发生了什么呢?其实就是我们想要的效果——至少在我们的代码中。我们添加了新的条目,但没有说“好,这就是我们想要的所有大学”,而是说“好,添加这两条新数据”。因此,如果我们再次执行,或关闭并执行,我们会看到多次重复的“耶鲁”和“北京科技”。为了清理数据,只显示我们希望的那些条目,我们可以像这样做:
DataContext.ExecuteCommand("DELETE FROM University");
这样就删除了所有数据条目,然后再插入。所以现在我们应该只看到两个条目:耶鲁和北京科技。
但如你所见,ID没有重置,它还是继续增长。所有之前的条目都已删除,但 ID 仍然没有回到起始值。需要记住这一点。
现在你知道如何插入数据了。接下来是一个小挑战,插入学生。事实上,我认为这个稍微有点困难,所以我将展示如何做。让我们创建一个新的方法,叫做 InsertStudents
。我打算使用 Lambda 表达式。我们之前还没有用到 Lambda 表达式,或者如果你还没到这部分课程,那你可能对 Lambda 不太熟悉。
我将使用 Lambda 表达式创建一个“耶鲁”大学对象,但我会使用 DataContext.Universities
这个属性,并且我要使用 First
方法。First
方法会返回序列中的第一个元素。所以,我可以在这里运行一个 Lambda 表达式。
var yale = DataContext.Universities.First(n => n.Name == "Yale");
这行代码非常简洁,但做了很多事情。它相当于 SQL 查询中的:
SELECT * FROM University WHERE Name = 'Yale' LIMIT 1;
这行代码会返回“耶鲁”大学对象。与返回一个集合不同,First
方法会直接返回一个大学对象,这样我们就可以在后续代码中使用它。这行代码非常强大且简洁。
我将为“北京科技”大学执行同样的操作。然后,我会创建一个学生列表,并将学生添加到这些大学中。在此之前,我们需要在 Server Explorer
中拖动学生类,这样它就会被识别并且能够使用。
我将创建一个学生列表并将学生添加到列表中:
List<Student> students = new List<Student>();
students.Add(new Student { Name = "Karla", Gender = "Female", UniversityID = yale.ID });
这里我向学生列表中添加了一个学生对象,包含了学生的姓名、性别和大学ID。你也可以使用大学对象本身,而不仅仅是大学ID,因为它们是相关联的。
一旦我们有了一个学生列表,我们可以通过以下方式将它们添加到 DataContext
中:
DataContext.Students.InsertAllOnSubmit(students);
DataContext.SubmitChanges();
这将会插入所有学生并提交更改。然后,我们可以将 MainDataGrid
的 ItemSource
设置为 DataContext.Students
,这样就可以看到这些学生了。
运行代码后,我们可以看到学生数据和他们对应的大学。每个学生都关联了一个大学,并且我们看到学生的性别、姓名以及大学ID。
这就是我们目前的操作,通过 Lambda 表达式和数据上下文操作数据库,成功插入了大学和学生数据。
在这个视频中,我们将介绍如何添加和管理课程。首先,我们将向数据库中插入课程数据,并创建一个关联表来管理学生与课程之间的关系。接下来,我将演示如何通过方法来插入课程数据,并通过关联表将学生和课程连接起来。
我们首先创建一个新方法 insertLectures
来插入课程。如果你已经了解如何将数据插入表中,你可以尝试自己做。你可以暂停视频并尝试自己添加课程,甚至可以自己定义一些课程。
首先,我们使用以下代码将数据插入课程表:
DataContext.Lectures
如果系统提示“没有找到课程”之类的错误,我们需要先定义课程表。通过在代码中定义好课程表后,课程就会显示出来。在CSS文件中,课程会在几秒钟内自动加载。之后,我们可以通过一个表单提交新课程的名称,比如“数学”(Math),然后再添加另一门课程,比如“历史”(History)。
接下来,我们通过以下方式提交并更新数据:
DataContext.SubmitChanges();
DataContext.Lectures
这样,我们的课程就被插入到了数据库中,并可以通过更新数据源显示在界面上。
在学生与课程之间,我们需要一个关联表 StudentLecture
来存储学生和课程的对应关系。这是因为并非每个学生都学习所有课程。我们可以通过一个新的方法 insertStudentLectureAssociations
来实现这一功能。
首先,我们要获取所有学生和课程的数据,并使用它们来创建学生与课程之间的连接。例如,我们创建一个学生对象“Carla”,并从数据库中获取第一个学生数据:
var student = DataContext.Students.First(s => s.Name == "Carla");
接着,我们做同样的事情为“Tony”、"Layla" 和 “James” 创建学生对象,并插入到表中。
然后,我们还需要为课程定义对象。例如,“数学”课程可以通过以下方式获取:
var mathLecture = DataContext.Lectures.First(l => l.Name == "Math");
同样,我们可以为“历史”课程定义对象。
接下来,创建学生与课程的关联。我们通过以下方式将学生和课程关联在一起:
var studentLecture = new StudentLecture
{
Student = student,
Lecture = mathLecture
};
DataContext.StudentLectures.InsertOnSubmit(studentLecture);
DataContext.SubmitChanges();
通过这种方式,我们将学生与课程连接起来。我们可以为每个学生创建多个课程关联,并将它们插入到关联表中。
接下来,我们来看一个简单的查询,用于获取特定学生的大学。比如,我们想知道 Tony 所在的大学,可以通过以下方法获取:
var student = DataContext.Students.First(s => s.Name == "Tony");
var university = student.University;
接着,我们可以将大学信息显示在主数据网格中。注意,虽然 Tony 的大学是一个对象,但我们需要将其转换为一个列表以显示在数据网格中:
var universities = new List<University> { university };
MainDataGrid.ItemsSource = universities;
最后,我们来处理如何获取学生所有的课程信息。在这种情况下,我们使用查询来获取 Tony 所有的课程。我们可以通过以下代码获取学生的课程:
var lectures = from sl in Tony.StudentLectures
select sl.Lecture;
MainDataGrid.ItemsSource = lectures;
通过这种方式,我们可以显示 Tony 所有的课程,并可以进一步处理这些课程信息,比如显示他的成绩或其他相关信息。
本视频介绍了如何在数据库中插入课程和学生数据,并通过关联表管理学生与课程之间的关系。我们还演示了如何获取学生的大学和课程信息。通过这些基本操作,您可以更好地管理和查询学生及其课程信息。
在下一个视频中,我们将继续扩展功能,演示如何更新和删除数据,以及如何查询特定大学的所有课程等操作。敬请期待!
首先,我们来实现一个新的方法,用于获取特定大学(例如:耶鲁大学)中的所有学生。你可以尝试自己实现这个方法,暂停视频来完成。
public void GetAllStudentsFromYale()
{
var studentsFromYale = from student in dataContext.Students
where student.University.Name == "Yale"
select student;
// 将结果绑定到DataGrid
mainDataGrid.ItemsSource = studentsFromYale;
}
在这个方法中,我们创建了一个 studentsFromYale
变量,它是一个从 dataContext.Students
中查询所有大学名称为“Yale”的学生的集合。我们通过查询条件 student.University.Name == "Yale"
来筛选这些学生。
然后,我们将这个查询结果作为 ItemSource
显示在 mainDataGrid
中。
在主方法中调用这个方法:
GetAllStudentsFromYale();
运行代码后,我们会看到只有两个学生:Carla 和 Toni,这两个人都在耶鲁大学。其他的学生则在北京科技大学。
接下来,我们来实现一个方法,返回所有有跨性别学生的大学。
public void GetAllUniversitiesWithTransgenders()
{
var transgenderUniversities = from student in dataContext.Students
join university in dataContext.Universities
on student.University.Id equals university.Id
where student.Gender == "Transgender"
select university;
// 将结果绑定到DataGrid
mainDataGrid.ItemsSource = transgenderUniversities;
}
在这个方法中,我们首先通过 join
将 Students
表和 Universities
表连接起来,并根据学生的性别筛选出跨性别学生。然后,我们选择符合条件的大学。
在主方法中调用这个方法:
GetAllUniversitiesWithTransgenders();
运行代码后,我们看到只有北京科技大学有跨性别学生。
这是一个更具挑战性的问题,我们需要获取所有在北京科技大学教授的课程。
public void GetLecturesFromBeijingTech()
{
var lecturesFromBeijingTech = from sl in dataContext.StudentLectures
join student in dataContext.Students
on sl.StudentId equals student.Id
join lecture in dataContext.Lectures
on sl.LectureId equals lecture.Id
where student.University.Name == "Beijing Tech"
select lecture;
// 将结果绑定到DataGrid
mainDataGrid.ItemsSource = lecturesFromBeijingTech;
}
在这个方法中,我们通过多次 join
将 StudentLectures
、Students
和 Lectures
三个表连接起来。我们通过 student.University.Name == "Beijing Tech"
来过滤出北京科技大学的学生,然后返回他们所参加的课程。
在主方法中调用这个方法:
GetLecturesFromBeijingTech();
运行代码后,结果显示只有“历史”课程在北京科技大学教授。问题在于我们只为 Layla 指定了历史课,而没有为 Jane 分配任何课程。要解决这个问题,我们需要确保所有学生都分配了课程。
通过这些例子,我们展示了如何在 C# 中使用 LINQ 查询进行数据库操作,无需直接编写 SQL 语句。接下来,我们将学习如何更新和删除数据,进一步掌握数据操作的技巧。
在下一期视频中,我们将展示如何更新数据和删除数据。
在本视频中,我们将使用 LINQ 来更新和删除数据。首先,我们来学习如何更新数据。
我们首先创建一个新的方法 updateTony
,目的是更新 Tony 的姓名。例如,Tony 的名字是一个简称,我们可以将其更新为 Antonio。
public void UpdateTony()
{
// 获取Tony对象
var tony = dataContext.Students.FirstOrDefault(s => s.FirstName == "Tony");
// 如果Tony对象存在,更新名字
if (tony != null)
{
tony.FirstName = "Antonio";
dataContext.SubmitChanges(); // 提交更改
}
// 更新数据源
itemSource = dataContext.Students;
}
这里我们首先通过 FirstOrDefault
方法查找名为 Tony 的学生。如果找到了 Tony,我们将其姓名更新为 Antonio,并调用 SubmitChanges()
方法来提交更改。最后,我们将更新后的学生列表显示在数据网格中。
调用此方法并运行代码后,Tony 的姓名会被更新为 Antonio。
接下来,我们将学习如何删除一个学生。假设我们要删除一个名为 Jane 的学生,因为她没有课程,并且名字写错了。
public void DeleteJane()
{
// 获取Jane对象
var jane = dataContext.Students.FirstOrDefault(s => s.FirstName == "Jane");
// 如果Jane对象存在,删除它
if (jane != null)
{
dataContext.Students.DeleteOnSubmit(jane); // 删除学生
dataContext.SubmitChanges(); // 提交更改
}
// 更新数据源
itemSource = dataContext.Students;
}
我们同样通过 FirstOrDefault
查找名为 Jane 的学生,并删除该对象。之后调用 SubmitChanges()
提交更改。运行代码后,Jane 将从学生列表中被删除。
在实际开发中,删除或更新数据时可能会遇到各种错误。例如,如果我们尝试访问一个不存在的学生,程序可能会崩溃。为了避免这种情况,我们可以使用 try-catch
语句来捕获潜在的错误。
try
{
// 更新Tony的姓名
UpdateTony();
// 删除Jane
DeleteJane();
}
catch (Exception ex)
{
// 捕获并处理异常
Console.WriteLine("发生错误: " + ex.Message);
}
使用 try-catch
可以确保即使出现错误,程序不会崩溃,并且可以显示错误信息以供调试。
在本视频中,我们展示了如何使用 LINQ 更新和删除数据。首先,更新数据时我们通过获取对象并修改其属性,然后提交更改;而删除数据时,我们通过查找并删除对象,再提交更改。这些操作都通过 dataContext
提交到数据库。我们还强调了错误处理的重要性,使用 try-catch
语句来防止程序崩溃。
在数据更新和删除过程中,我们还讨论了如何通过 LINQ 结合多个表(例如学生与大学、学生与课程)来处理复杂的数据库操作。LINQ 提供了一种强大的方式来简化数据操作,使得处理数据库中的数据变得更加直观和方便。
在接下来的学习中,你可以尝试更新其他学生、课程或大学的信息,或者删除其他不需要的记录。LINQ 提供了一个简洁且高效的方式来进行这些操作,不需要手动编写复杂的 SQL 语句。
希望你能够掌握这些基本的 LINQ 操作,并能够在实际项目中熟练应用。
好的,现在我们已经完成了 LINQ 章节,这也是另一个非常重要的章节。我认为它很棒,希望你也喜欢。通过这个章节,你学到了很多与数据库相关的内容,包括如何筛选数据、如何编写小型软件等。我们还通过一个实际示例帮助你加深了理解。所以希望你能够充分吸收这些知识。
接下来,我们将进入下一个章节——线程,我们将讨论如何基于开发需求来实现并行运行的任务。线程允许我们同时处理多个任务,极大地提高程序的效率和响应速度。
在下一个章节中,我们将探索更多关于线程的内容,包括如何创建、管理和同步线程。希望你能够继续跟上进度并享受接下来的学习内容!
大家好!在这个视频中,我将向你们展示如何使用我们在前两个视频中用API构建的货币转换器。因此,你不需要使用你在数据库示例中看到的代码。实际上,你甚至不需要看过数据库示例,因为我们将使用我们在静态示例中创建的代码。以静态的WP F货币转换器为例,我们使用的是静态数据,我们将扩展它,使其能够使用来自API的数据。这意味着我们可以从互联网获取数据,并使用这些数据来进行最精确的货币转换,获得最新的货币价值和汇率。基本上,这就是这个想法。你当然可以查看这篇文章,我们为此创建了完整的文章。你可以在其中找到所有的代码、各种解释等内容。
我们将使用的API来自Open Exchange Rates,这个网站提供汇率服务,我们每个月可以免费调用最多1000次。这对于学习来说非常棒,但如果你想将其用于一个你要出售的服务,例如,你需要付费。但正如我所说,它是免费的,适合测试,这正是我们需要的。所以你可以随时去查看他们的定价页面,了解他们的计划。在这里你可以看到开发者计划、企业计划、无限计划,但这些和我们无关。我们感兴趣的是免费的计划,它提供每小时更新,以美元作为基准货币,并允许每月最多1000次请求,这非常好。因此,你可以选择免费的计划,然后注册才能使用。
如果你想跟着我一起操作,你可以在这里注册。一旦你注册完毕,你就需要获得你的应用ID,这个ID就是我们进行API调用时需要用到的。我们将使用这个ID来发出请求并获取数据。如何获取数据呢?我们将以这种格式获取数据,即JSON格式。JSON是一种非常有用的数据格式,可以用文本以一种可读的方式展示数据。所以,基本上它将数据分解开来,这不是你通常看到的数据表,而是以一种文本格式呈现,你仍然可以很好地阅读。
我们将获得的数据包括免责声明、许可证、时间戳、基准货币和汇率。汇率部分包含不同货币的汇率,基准货币将是美元。例如,你将看到,1美元相当于3.6某种货币,或者1美元相当于1.39澳大利亚元等。这些值就是我们将要获取的内容。我们可以利用这些数据来调整我们的程序,使其能够获取最新的货币和汇率,而不需要像之前的视频那样手动输入它们,也不需要像上一个视频中那样在数据库中为它们单独设置表格。
现在我们来看看我们的项目。这是一个静态项目。正如我所说,如果你还没看过这个视频,一定要去看看我之前的视频,在那里我展示了如何构建WPF部分和其他内容,因为在这个视频中我将不会涉及WPF部分。我只会关注API相关的内容。
为了使这个项目正常工作,我们需要额外的类,因为我们获取的数据是以某种格式返回的。正如我所说,获取的数据格式就是这个JSON格式。因此,我们需要创建一些类来表示这些数据。比如我们会创建一个名为Root
的类来处理这些数据。然后,在Root
类中,我们会有一些属性,可能是其他类的实例,例如Rates
类。Rates
类将表示汇率数据,而Timestamp
则是一个长整型(long),License
则是一个字符串(string)。接下来,我们一步步地来设置这些类。
首先,我将在主窗口类中创建一个新的类,命名为Root
。在这个类中,我会有一个名为Rates
的属性,它是Rates
类的实例,还有一个名为Timestamp
的长整型属性。然后,我们还需要获取许可证(License)信息,因此我也会加上一个License
属性。
接下来,我们需要获取汇率。正如我之前提到的,汇率是比较复杂的,因为它包括多个不同的汇率。所以我将创建一个额外的类,命名为Rate
。重要的是,在我们的类中,属性名称要和API返回的名称完全一致。比如,在API返回的JSON数据中,汇率的名称是“USD”、“EUR”等,我们在代码中定义的属性名称也要和这些完全相同,这样才能正确解析数据。
Rate
类将包含多个汇率属性,每个属性对应一个不同的货币。比如,印度卢比、日元、美元、欧元、加元等货币的汇率都可以作为Rate
类的属性。
接下来,我们需要创建一个方法来获取数据。我们将使用异步任务(async task)来进行API请求。这是因为从网络上获取数据时,我们需要异步执行操作,以免在等待响应时阻塞主线程,使得应用程序冻结。因此,我们将使用async
关键字和Task
类来实现异步操作。
让我们来看看这个方法。这里,我使用了async
任务,通过异步的方式从API获取数据。返回的数据类型是我们之前创建的Root
类。每当我们从互联网获取数据时,都应该采用异步的方式,这样可以防止程序在等待数据时卡住,从而保证程序的流畅运行。
任务的目标是获取数据并返回一个特定类型的结果对象。在这个案例中,目标是返回一个“root”对象类型的数据,数据可以进一步用于不同的功能,如货币汇率的计算。为了实现这一点,首先我们创建了一个新的 root
对象,因为我们希望能够返回一个根对象(root),它将包含从API获取的数据。
由于在网络请求过程中可能会发生错误,我们需要使用 try
和 catch
语句来防止应用程序崩溃。这样,即使发生异常,也能保证程序的稳定性。在 try
语句中,我们进行数据获取,如果数据获取失败,catch
块将会处理异常。
我们使用 HTTPClient
类来发送 HTTP 请求和接收响应。HTTPClient
类是通过 System.Net.Http
命名空间提供的,它允许我们发送和接收HTTP请求/响应。在这个过程中,我们设置了一个超时(TimeSpan
),使得客户端在获取数据时最多等待一分钟。这个时间足够长,确保我们能够从API获取数据。
var client = new HttpClient();
client.Timeout = TimeSpan.FromMinutes(1);
为了避免阻塞主线程并导致应用程序卡顿,我们使用了异步的 GetAsync
方法来发送HTTP请求。这个方法接受一个 URL,返回一个异步的任务(Task
),这个任务将在后台进行处理,不会阻塞主线程。使用 await
关键字来等待异步操作的结果,一旦结果准备好,我们将其存储在 response
对象中。
var response = await client.GetAsync(url);
获取到的响应数据是一个字节流,无法直接使用。因此,我们首先需要将其转换为字符串,这样我们才能处理它。使用 HttpResponseMessage.Content.ReadAsStringAsync()
方法,我们将响应的内容以字符串的形式提取出来:
var responseString = await response.Content.ReadAsStringAsync();
接下来,我们需要将字符串格式的响应数据转换为 JSON 对象,以便我们能够在程序中使用它。为此,我们使用 JsonConvert
类,它是 Newtonsoft.Json
库的一部分。JsonConvert
提供了方法,可以将字符串反序列化为特定类型的对象。我们将响应字符串反序列化为我们预先定义的 root
类。
Root rootObject = JsonConvert.DeserializeObject<Root>(responseString);
一旦将 JSON 数据转换为 C# 对象,我们可以像访问普通对象属性一样,访问这些数据。比如,我们可以轻松地获取 timestamp
、rates
等字段:
var timestamp = rootObject.timestamp;
这使得我们可以方便地处理来自API的数据,而不需要手动解析复杂的 JSON 结构。
如果 HTTP 响应状态码表示请求成功(例如 200),我们将继续处理返回的数据。如果状态码不为 200,则我们返回一个空的 root
对象。这是为了确保即使在请求失败时,程序也不会崩溃,并且返回一个有效的空数据对象。
if (response.IsSuccessStatusCode)
{
return JsonConvert.DeserializeObject<Root>(responseString);
}
else
{
return new Root();
}
在 main window
中,我们通过异步方法获取货币数据,并将其存储在 root
类型的对象中。之后,我们使用这些数据来更新界面上的货币汇率。
private async void GetValue()
{
Root val = await GetDataAsync(url);
BindCurrency(val);
}
我们通过调用外部API获取货币汇率数据,并使用返回的汇率来进行货币转换。我们也做了一些调整,确保计算的正确性,比如使用正确的汇率方向(例如从 USD 转换为 EUR)。
double convertedAmount = amount * rates[currency];
通过这个过程,我们学会了如何:
Newtonsoft.Json
将返回的 JSON 数据转换为可操作的 C# 对象。这些步骤展示了如何从网络获取数据并在程序中使用它,无论是处理货币汇率数据,还是其他类型的 API 数据。
在本章节中,我们将学习线程。这个章节是在我完成这门课程后添加的,因为我收到了很多反馈,大家都提到线程的相关内容。就像委托章节一样,我决定将新章节添加到课程中。因此,如果你觉得课程中缺少了某些非常重要的内容,请随时告诉我,我会将其添加到课程中。
在这个章节中,我们将学习线程的基础知识,包括如何确保线程不会重叠以及如何确保线程能够重叠。我们还将探讨线程池的概念,如何在后台运行线程,如何连接线程,并确保线程仍然存活,而不是已经完成并“死掉”。
本章节为课程增添了线程管理的内容,接下来我们会进一步讲解每个部分的具体实现。我希望你能喜欢这个章节!在下一段视频中,我们将开始深入讨论线程的相关概念。
在本视频中,我们将深入探讨线程。我们将从线程的基本概念开始,学习如何创建线程以及如何创建多个线程。让我们通过控制台的输出开始。
首先,我将创建多个输出,例如四个不同的“Hello World”输出,分别是:“Hello World 1”、“Hello World 2”、“Hello World 3”和“Hello World 4”。当我们运行这些代码时,首先需要确保控制台的窗口保持打开,因此需要使用 Console.ReadLine()
来保持控制台窗口激活。运行时,屏幕上将依次显示四次 "Hello World",分别是 1、2、3 和 4。
这对我们来说并不是什么新鲜的事情,之前我们已经多次见过这样的操作。但是,线程有一个很重要的类,叫做 Thread
。要使用这个类,我们需要引用 System.Threading
命名空间。因此,我们需要在代码开头使用以下语句:
using System.Threading;
这样我们就可以使用线程相关的功能了。
现在我们可以使用 Thread
类了。Thread
类负责创建和控制线程,设置线程的优先级并获取线程的状态。接下来,我们可以用 Thread.Sleep()
来让线程暂停一段时间,例如暂停 1000 毫秒(即 1 秒钟)。这将使得当前线程(在这种情况下是主线程)暂停执行一段时间。
例如,我可以在每个输出语句后加入 Thread.Sleep(1000)
,使得程序在每输出一行 "Hello World" 后暂停 1 秒。这样,你会看到每条消息依次显示,但每条之间有 1 秒的延迟。
Thread.Sleep()
会暂停当前线程的执行一段指定的时间(以毫秒为单位)。在我们的例子中,暂停的时间是 1000 毫秒。需要注意的是,Thread.Sleep()
会阻塞当前线程,因此它会暂停整个线程的执行。如果我们在 UI 应用程序中使用它,例如在 WPF 中,这将导致界面在暂停期间不响应用户的任何操作,这是非常不推荐的做法。
为了查看线程的工作状态,我们可以在代码中添加调试点。通过在调试窗口中查看线程信息,我们可以看到当前程序运行的线程。具体操作是在调试窗口中选择“线程”(Threads)视图,或者使用快捷键 Ctrl + Alt + H
来打开线程窗口。
当我们运行程序时,我们可以看到主线程(Main Thread)正在运行。如果添加了多个线程,我们可以在调试窗口中看到它们的状态。例如,如果我们创建了多个线程,并且在调试过程中逐步执行,我们会看到每个线程的状态及其运行情况。
接下来,我们将创建多个线程,以实现并行执行。在默认情况下,代码是顺序执行的,即一个接着一个。但如果我们希望并行执行任务,可以创建多个线程,并将这些任务分配给不同的线程。
我们可以通过以下代码创建一个新的线程:
Thread thread1 = new Thread(() =>
{
Console.WriteLine("Thread 1");
Thread.Sleep(1000); // 模拟延迟
});
thread1.Start();
上述代码中,我们创建了一个新的线程,并在该线程中执行了一些操作。thread1.Start()
会启动线程,这会导致操作系统将该线程的状态更改为“正在运行”。线程中的代码会执行,并且我们可以在其中添加不同的操作。
我们可以创建多个线程,并让它们并行执行。例如,创建多个线程分别输出 "Thread 1"、"Thread 2" 等。通过修改代码,创建多个线程并启动它们,你会发现这些线程几乎是同时启动的,它们并行执行。
在调试过程中,你可以看到主线程和其他线程同时在执行。这表明我们实现了多线程并行执行。
尽管我们在代码中设置了线程的顺序,线程的执行顺序并不一定会与我们预期的一致。由于线程是并行执行的,它们的执行时间会受到许多因素的影响,例如线程的工作负载、CPU 的调度等。因此,即使线程是在特定的顺序中启动的,它们的执行结果可能并不会按顺序显示。
例如,尽管线程是按顺序启动的(Thread 1, Thread 2, Thread 3, Thread 4),它们的输出顺序可能会有所不同,因为它们的执行时间可能不同。某些线程可能会先完成,而其他线程则需要更长的时间才能执行完成。
使用多线程时,需要特别小心。虽然多线程可以提高程序的执行效率,但也可能带来一些问题。比如,线程之间可能会相互干扰,导致程序行为异常。因此,学习如何正确使用线程是非常重要的。
如果你的程序运行在多核处理器上,线程可以在不同的 CPU 核心上并行执行,从而提高程序的效率。例如,四核处理器可以同时运行四个线程。但如果你的计算机只有一个处理器,所有线程仍然会在同一个核心上轮流执行。
通过本视频,我们了解了如何创建和使用线程。线程可以让我们并行执行任务,提高程序的效率。但是,线程的执行顺序和行为可能会有一定的随机性,因此在使用线程时需要小心,确保线程之间的协调和同步。
在上一期视频中,你学习了如何使用线程,以及如何设置线程并简单地启动它们。在本期视频中,我们将重点介绍任务的完成。我们希望使用线程做一些事情,只有当某个特定任务完成后,才继续执行后续的操作。让我们来看一下如何实现这一点。
我将创建一个新的变量,称为 taskCompletionSource
,它将是一个 TaskCompletionSource<bool>
类型。为了实现这一点,我需要使用 System.Threading.Tasks
命名空间。因此,我需要先引入这个命名空间。接着,我会创建一个 task
变量,调用 taskCompletionSource
,并为其设置任务结果。这样就可以用来表示某个任务是否已经完成。
TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>();
接下来,我们运行代码,看看发生了什么。注意,尽管没有显示 Console.ReadLine
来保持控制台窗口打开,但实际上控制台仍然保持开启状态,因为任务并没有完成。taskCompletionSource
从未被设置为完成状态,因此没有触发后续的执行。
Console.WriteLine("Task not completed yet.");
为了确保任务完成,我们需要使用线程。我们创建一个新线程并在其中执行任务逻辑。与我们之前直接启动线程的方式不同,这次我们先创建线程对象,但不立即启动。通过这种方式,我们可以稍后再启动它并控制执行。
Thread thread = new Thread(() => {
Thread.Sleep(1000); // 模拟任务执行
taskCompletionSource.SetResult(true); // 设置任务完成
});
我们需要通过 Start
方法来启动线程。启动线程后,它会执行指定的任务逻辑。执行完任务后,任务会被标记为完成。
thread.Start(); // 启动线程
在这个过程中,我们可以查看线程的 ID。通过使用 Thread.ManagedThreadId
,我们可以得到当前线程的唯一标识符,并将其打印出来。
Console.WriteLine($"Thread number: {Thread.CurrentThread.ManagedThreadId}");
我们还可以通过 Thread.CurrentThread
获取当前线程的状态,并在控制台中打印出线程的开始和结束情况。
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} started.");
Thread.Sleep(5000); // 等待 5 秒
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} ended.");
在任务完成后,我们可以通过 taskCompletionSource.Task.Result
获取任务的结果。例如,如果任务成功完成,则我们可以输出结果。
Console.WriteLine($"Task was done: {taskCompletionSource.Task.Result}");
执行代码时,你会看到线程的执行顺序并不总是按照我们在代码中的书写顺序来执行。即使线程在代码中被顺序启动,线程的执行顺序可能会因为操作系统的调度而发生变化。
例如,即使我们在代码中创建了多个线程,它们的执行时间可能不同,导致某些线程先执行完毕,而其他线程可能需要更长时间才能完成。
在本期视频中,你学习了如何使用 TaskCompletionSource
来标记任务是否完成。当任务完成后,可以使用线程继续执行后续操作。此外,你还学习了如何创建线程对象并启动它,以及如何查看线程的 ID。通过这些基础知识,你能够更好地理解线程如何在后台执行任务,并且能够控制任务的完成状态。
在下一期视频中,我们将继续深入探讨如何同时创建多个线程,并研究操作系统如何调度这些线程的执行。
在本期视频中,我们将讨论线程池(Thread Pools)。我们将创建多个线程,看看它们是如何工作的。接下来,我们将先创建一个基础线程,并查看它的行为,然后再介绍线程池的使用。
首先,我们创建一个非常基础的线程,不涉及任何任务完成源等额外的东西。我们将简单地创建线程,并让它等待一段时间(比如 1 秒),然后启动它。
Thread thread = new Thread(() => {
Thread.Sleep(500); // 等待 500 毫秒
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} started.");
});
thread.Start(); // 启动线程
在这里,我们使用 Thread.Sleep
模拟线程的工作。启动线程后,它会在指定的时间后打印线程的 ID。
IEnumerable
创建多个线程接下来,我们用 IEnumerable
来创建多个线程并执行。我们将使用 Enumerable.Range
来生成从 0 到 100 的整数,然后为每个整数创建一个线程并执行。
using System.Linq; // 引入 LINQ 库
var threads = Enumerable.Range(0, 100).ToList();
foreach (var i in threads)
{
Thread thread = new Thread(() => {
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} started.");
Thread.Sleep(500); // 等待 500 毫秒
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} ended.");
});
thread.Start();
}
运行以上代码时,你会看到多个线程被创建并启动。输出的线程 ID 顺序通常与我们创建它们的顺序不同,因为线程调度是由操作系统管理的,线程的执行顺序并不一定是线性的。随着线程的数量增加,我们会发现有时线程会先结束,而另一些线程可能还在启动中,这表明线程的管理和调度并不是完全同步的。
如果我们创建大量线程(例如 1000 个),可能会出现一些效率问题。每个线程的创建和启动会消耗一定的资源,而且线程可能在还未完成时就已经被销毁,这显得很低效。
Enumerable.Range(0, 1000).ToList().ForEach(i => {
Thread thread = new Thread(() => {
Thread.Sleep(100); // 等待 100 毫秒
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} finished.");
});
thread.Start();
});
为了优化线程的使用,我们可以使用线程池。线程池会根据需要动态地管理线程数量,并尽量避免不必要的线程创建。线程池会重用已完成的线程,而不是每次都创建新的线程,这样可以提高效率并减少资源消耗。
我们可以使用 ThreadPool.QueueUserWorkItem
方法将工作项加入线程池,线程池会在有空闲线程时自动执行它们。
object obj = new object();
ThreadPool.QueueUserWorkItem((state) => {
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} started.");
Thread.Sleep(500); // 等待 500 毫秒
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} ended.");
}, obj);
线程池有许多优势:
线程池通过队列管理工作项,只有当有空闲线程时,工作项才会被执行。这样,当系统的 CPU 正在空闲时,线程池可以开始执行新的工作项,而在 CPU 繁忙时,线程池则会等待直到有空闲线程。
ThreadPool.QueueUserWorkItem((state) => {
// 执行任务
});
线程池的管理是有限的,它只能处理一定数量的线程。如果任务非常繁重,可能会导致线程池的线程无法及时处理所有任务。此时,可能需要根据具体情况调整线程池的大小或使用其他异步方法来处理任务。
isBackground = true
),那么它将不会阻止应用程序退出。本期视频讲解了如何使用线程池来处理多个并发任务,而不是创建大量的单独线程。通过使用线程池,可以高效地管理线程,避免不必要的资源消耗。线程池的工作原理和线程管理的方式使得它在处理大量并发任务时更为高效和安全。
在实际开发中,线程池常常用于处理后台任务,比如网络请求、文件操作等,以避免主线程(UI线程)被阻塞。如果需要进行长时间运行的任务,线程池是一个非常合适的选择。
欢迎回来。在本视频中,我将讨论 join
和 is alive
。join
是线程类的方法,而 is alive
是它的一个属性。接下来,我们将创建两个线程,并讨论它们的使用。
首先,我们创建两个线程,并且分别为每个线程创建了两个方法 threadOneFunction
和 threadTwoFunction
。这两个方法的作用仅仅是打印线程开始的信息。代码如下:
public void threadOneFunction()
{
Console.WriteLine("Thread 1 function started");
}
public void threadTwoFunction()
{
Console.WriteLine("Thread 2 function started");
}
接下来,我们在主线程中启动这些线程:
public static void Main()
{
Thread thread1 = new Thread(threadOneFunction);
Thread thread2 = new Thread(threadTwoFunction);
Console.WriteLine("Main thread started");
thread1.Start();
thread2.Start();
Console.WriteLine("Main thread ended");
}
执行这段代码后,控制台会输出以下内容:
Main thread started
Main thread ended
Thread 1 function started
Thread 2 function started
此时,主线程很快结束了,两个子线程虽然开始执行,但并未结束。为了等待子线程的执行完成,我们使用 join
方法来阻塞主线程,直到子线程执行完毕。
join
方法join
方法会阻塞调用线程,直到被调用的线程执行完成为止。修改后的代码如下:
public static void Main()
{
Thread thread1 = new Thread(threadOneFunction);
Thread thread2 = new Thread(threadTwoFunction);
Console.WriteLine("Main thread started");
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("Thread 1 function done");
Console.WriteLine("Thread 2 function done");
Console.WriteLine("Main thread ended");
}
执行上述代码后,输出如下:
Main thread started
Thread 1 function started
Thread 2 function started
Thread 1 function done
Thread 2 function done
Main thread ended
在调用 Join
后,主线程将等待 thread1
和 thread2
完成后才会继续执行,因此 "Thread 1 function done" 和 "Thread 2 function done" 会在相应的子线程执行完后输出。
为了更好地展示 join
的效果,我们让线程休眠几秒钟。修改代码如下:
public void threadOneFunction()
{
Console.WriteLine("Thread 1 function started");
Thread.Sleep(3000); // 休眠 3 秒
Console.WriteLine("Thread 1 function done");
}
public void threadTwoFunction()
{
Console.WriteLine("Thread 2 function started");
Thread.Sleep(3000); // 休眠 3 秒
Console.WriteLine("Thread 2 function done");
}
当主线程运行时,它会等到每个线程都完成后才会结束:
Main thread started
Thread 1 function started
Thread 2 function started
Thread 1 function done
Thread 2 function done
Main thread ended
如你所见,主线程在 thread1
和 thread2
完成之前不会结束。
Join
方法也有一个重载版本,可以设置超时时间。我们可以限制线程等待的最大时间,如果线程没有在规定时间内完成,就会继续执行主线程。修改代码如下:
public static void Main()
{
Thread thread1 = new Thread(threadOneFunction);
Thread thread2 = new Thread(threadTwoFunction);
Console.WriteLine("Main thread started");
thread1.Start();
thread2.Start();
if (!thread1.Join(1500)) // 等待 1.5 秒
{
Console.WriteLine("Thread 1 function wasn't done within 1500 ms");
}
if (!thread2.Join(1500)) // 等待 1.5 秒
{
Console.WriteLine("Thread 2 function wasn't done within 1500 ms");
}
Console.WriteLine("Main thread ended");
}
如果线程没有在指定的时间内完成,控制台将输出:
Main thread started
Thread 1 function started
Thread 2 function started
Thread 1 function wasn't done within 1500 ms
Thread 2 function wasn't done within 1500 ms
Main thread ended
isAlive
属性isAlive
属性可以用来检查一个线程是否仍在运行。代码示例如下:
public static void Main()
{
Thread thread1 = new Thread(threadOneFunction);
thread1.Start();
if (thread1.IsAlive)
{
Console.WriteLine("Thread 1 is still doing stuff");
}
thread1.Join(); // 等待 thread1 完成
if (!thread1.IsAlive)
{
Console.WriteLine("Thread 1 was completed");
}
Console.WriteLine("Main thread ended");
}
在这个示例中,isAlive
被用来判断 thread1
是否还在运行。如果线程在执行时还活着,输出 "Thread 1 is still doing stuff",否则输出 "Thread 1 was completed"。
你也可以定期检查线程的状态。以下是一个例子,其中我们在循环中每隔 300 毫秒检查一次线程是否还在运行:
public static void Main()
{
Thread thread1 = new Thread(threadOneFunction);
thread1.Start();
for (int i = 0; i < 10; i++)
{
if (thread1.IsAlive)
{
Console.WriteLine("Thread 1 is still doing stuff");
}
Thread.Sleep(300); // 每 300 毫秒检查一次
}
thread1.Join(); // 等待线程完成
Console.WriteLine("Main thread ended");
}
执行时,Thread 1 is still doing stuff
会在 thread1
运行时被多次打印出来,直到线程完成。
在这段代码中,我们展示了如何使用 join
方法等待线程完成,如何设置超时机制来限制等待时间,以及如何使用 isAlive
属性检查线程是否仍在运行。这些操作对线程管理和同步非常有用,尤其是在涉及到多个线程执行时。
在本视频中,我们将介绍任务(Tasks),特别是在使用WPF时如何工作。因此,让我们创建一个新项目,并使用WPF F。在我的例子中,我将其命名为“WP F tasks and EE”。然后,按下OC创建项目。接下来,在我的UI中,我要添加一个按钮,这样我就可以通过工具箱来添加它。我只需选择一个按钮并将其拖放到我的UI中。好了,按钮已经添加进去,我将其稍微做大一点,并将字体大小改为32,也许像这样。现在它更容易阅读了。接下来,我将给它一个名称,名称为“my button”,因为我稍后想在代码中使用它。
目前,UI就这样。稍后我们会添加一个Web浏览器,但现在这个按钮就足够了。在我的主窗口中,我想使用这个按钮,但我需要一个点击事件。因此,每当有人点击按钮时,我希望从网络上下载一些内容,下载完成后,我想将HTML内容分配给按钮的文本或按钮的内容。因此,我需要一个点击事件。我要双击这个按钮,它会自动创建一个新的“my button click”事件方法。每当点击按钮时,这个方法就会被触发。最简单的检查方法是使用一个消息框,显示类似“Hi”或“它成功了”之类的内容。
现在我们运行程序,看看我们的代码是否有效。在做任何其他事情之前,先看看按钮是否正常工作,它会显示“Hi”。好的,没问题。现在,如你所见,文本的大小并不像按钮的文本那么大,看起来不太美观。但这不是本视频的重点,对吧?本视频是关于任务的,关于如何在后台下载内容而不让UI卡住。
好了,让我们来看如何避免UI卡住。为了实现下载,我将使用一个HTTP客户端,命名为“web client”。现在HTTP客户端是我们必须添加的类,命名空间为System.Net.Http
。因此,web client现在会是一个新的HTTP客户端。接下来,我想要一个字符串,用于存储HTML内容,我们将使用web client的GetStringAsync
方法来下载Google.com的网页内容并存储到HTML中。也就是说,我们希望下载这个网站,并把结果存储在HTML中。
接下来,我想查看我当前线程的信息。因此,我将用Thread.CurrentThread.ManagedThreadId
来打印当前线程ID。在使用Thread时,我们需要引用System.Threading
,同时需要引用System.Diagnostics
来使用调试功能。所以我们先添加这两个命名空间。现在,我们可以获取当前线程的管理线程ID。
当我点击按钮时,这个调试信息将会打印出来,并且开始下载。假设我现在想改变按钮的内容,我会将按钮的Content
设置为“Done”。现在测试一下。按下按钮,你可以看到“Done”字样显示出来,说明下载完成。这样看似一切正常,但请注意,这种操作发生得非常快,因为我们只是在下载一个Google.com的页面,这个页面非常小,仅几个KB,所以很快就能完成。但通常,下载任务会花费更长时间,尤其是下载较大的文件时。
让我们再试一次。这次我将尝试下载一个更大的文件,大约是200MB。当我点击按钮时,你会看到程序冻结了,直到下载完成才更新为“Done”。这是因为我们正在主线程(即UI线程)中进行下载操作,这使得UI在下载期间被锁死。这样会导致UI无法响应用户的其他操作,因此这是一个危险的操作,你不应该在UI线程中执行耗时的任务。
为了避免这种情况,我们可以使用任务(Task)来在后台线程中执行下载操作,从而避免阻塞UI线程。现在,我们将使用Task.Run
来创建一个新的任务,并在其中执行下载代码。这样,我们可以确保下载操作在后台线程中运行,而不会阻塞主线程。
在这里,我将创建一个新的任务来执行下载操作,并将原来的代码复制到任务中。接下来,我们进入调试模式,看看在这个过程中涉及的线程。按下按钮后,你会看到程序在线程5中运行,而线程1依然是UI主线程。这样,我们就可以确保下载任务在后台线程(线程5)中执行,而UI线程(线程1)可以继续处理UI更新。
然而,这样做会有一个问题——当我们尝试修改按钮内容时,我们发现“操作异常”提示了一个错误:“调用线程无法访问该对象,因为该对象属于另一个线程。”这意味着UI控件(如按钮)是由UI线程(线程1)管理的,而我们正在从后台线程(线程5)尝试访问它,这违反了线程安全的原则。为了避免这种情况,我们需要使用Dispatcher
来在UI线程中更新按钮的内容。
Dispatcher
是用来在正确的线程中执行UI更新的机制。我们可以通过按钮的Dispatcher
来执行代码,在UI线程中更新按钮的内容。首先,我们需要获取按钮的Dispatcher
对象,然后使用它在UI线程中执行设置按钮内容的操作。
接下来,我将修改代码,使用Dispatcher
来确保按钮内容在UI线程中更新。执行代码后,我们看到按钮的内容正确地显示为“Done”,而且UI线程没有被阻塞。这样,我们就可以在后台线程中执行耗时操作,并安全地更新UI。
另一种方法是使用异步(async
)任务来处理下载操作。在这种方法中,我们将使用async
和await
关键字来确保代码在后台线程中异步执行,而不会阻塞UI线程。通过这种方式,我们可以避免使用Dispatcher
,直接在UI线程中更新控件的内容。
通过这些方法,我们可以确保WPF应用程序中的UI不会被耗时任务卡住,同时能够顺利更新界面。这就是在多线程编程中使用任务和线程的关键概念。
最后,我们还演示了如何在WPF中使用Web浏览器控件。通过下载HTML内容并将其显示在Web浏览器中,我们可以将下载的网页内容呈现给用户,进一步增强了UI的互动性。
在本章中,您了解了如何使用任务(tasks)、线程(threads),以及它们之间的关系。您还学会了如何检查线程是否仍然处于活动状态,如何组合线程等重要概念。
至此,本课程的核心C#特性部分已经完成。如果您觉得有任何重要的内容遗漏了,请随时告诉我。除此之外,我们将进入下一章,开始学习Unity,Unity是一个非常出色的游戏引擎,它能够帮助我们创建几乎任何类型的游戏。接下来我们将深入探讨Unity的使用。
感谢您的学习,下一章见!
恭喜你加入了这个课程,这意味着你对软件工程职业有着认真的态度。在这里,我要祝贺你开始这门课程,并祝愿你在软件工程行业的未来一切顺利。
在这门课程中,你将学习自动化测试与手动测试之间的差异。你将学习如何设置测试项目,并且我们将讨论多种技术,如“给定-当-然后(Given-When-Then)”、红绿重构模式(Red-Green-Refactor)、测试的可信度、"魔鬼代言人"游戏、参数化测试、在编写测试后发现替代路径、遵循TDD(测试驱动开发)法则、以及测试应用层等内容。
我们从回顾如何手动测试应用程序开始。你将学习手动测试与自动化测试的区别。同时,我们会立刻开始,创建一个项目并编写第一个测试。
在第一章结束时,你将了解什么是测试优先开发(Test-First Development),以及如何编写良好的断言消息。最后,我们将安装一些用于编写单元测试的库。
通过本课程的学习,你将掌握测试驱动开发的核心概念,并能够应用到实际的开发工作中。
如果你在谷歌搜索什么是测试驱动开发(TDD),你会看到许多简短的描述,但你可能并不会从中获得真正有价值的信息。定义是没有错的,你也足够聪明,但问题在于,TDD与其他方法不同。你之前可能学过一些软件开发的内容,而TDD并不是通过几个简单的句子就能解释清楚的。因此,与其从理论的介绍开始,我会通过一个实际的例子来引导你了解TDD的基本概念。
我们先从一个熟悉的话题开始:什么是手动测试?我们如何测试我们的应用程序?
首先,我们写一些基础代码,然后运行它并进行测试,检查它是否按预期工作。如果没有,可能会发现一些bug,或者需求发生了变化。那么我们就需要修改代码,再次运行进行测试,依此类推。
问题是,每次你修改代码时,可能会破坏一些已经正常工作的功能。为了确保程序仍然按预期工作,你必须每次修改代码时都对所有受影响的功能进行测试。这个过程非常耗时,并且不可靠。每次修改代码,你对程序的信心就会降低,信心的下降会导致更差的开发体验和更低的生产力。另一方面,测试所需的时间呈指数增长,每次运行程序时,你可能会忘记测试某些重要的内容。
你可能会问,是否有更好的方法来解决这些问题?答案是肯定的!这就是我们在这里学习的原因。与其每次修改代码后都进行手动测试,我们可以编写一些代码来测试程序中最关键的部分,这样每次修改代码时,只需要运行这段测试代码。这不仅速度更快,而且更可靠,解决了很多手动测试中存在的问题。
我来通过一个例子再解释一次。假设我们有一个应用程序,它的功能是求两个数字的和。我们该如何测试这个功能呢?
当我们运行程序时,输入两个数字,点击求和按钮,我们应该看到的结果是什么?两个数字相加的结果当然是 4,对吧?如果你不想每次都手动测试这个功能,我们可以为此编写代码进行测试。
假设我们有一个处理这个用户界面背后逻辑的求和函数,不论它的技术如何,我们可以编写一些代码来测试这个求和函数。
我们先忘掉界面技术,只考虑应用程序的核心逻辑部分——也就是这个求和函数。我们要测试的是这个函数是否按预期工作。我们可以编写代码来检查求和函数是否能正确计算出 2 和 2 的和。
这个测试实际上和我们手动测试时做的测试是一样的,只不过是自动化的。每次我修改求和函数时,我都可以运行这个测试方法,自动检查它是否按预期工作。如果我破坏了这个功能,测试就会抛出异常。
接下来,我们把测试代码移到一个方法中,这个方法就叫做测试方法。我们称求和方法为系统,而在这个上下文中,测试方法就是用来验证系统是否成功满足期望的。
我们期望 2 加 2 的结果是 4。你可以看到方法的名字准确地描述了我们要测试的内容。现在,忘掉复杂的术语,我们有一个简单的测试方法,它告诉我们要在这个方法的逻辑中测试什么。然后我们只需调用求和方法,传入参数并检查结果。这样,每次调用这个自动化的测试方法时,它就会自动进行测试。
通过这种方式,我们可以在每次修改代码后,自动验证功能是否仍然按预期工作,极大地提高了开发效率和信心。
现在我们进入 Visual Studio 2022,接下来我将创建一个新项目。如果你正在跟着课程一起学习,请立即打开 Visual Studio 并创建一个项目。我强烈建议你这样做。不要只是坐在那里看视频,打开 Visual Studio,创建一个项目,并通过实际操作来跟上课程的进度。我们的目标是为你提供最好的学习体验,而这需要你积极参与。
首先,创建一个新的项目,我们将使用一个名为 XUnit 的测试框架。只需搜索 XUnit 并创建一个 XUnit 测试项目。XUnit 最初是由 NUnit 框架的作者创建的。你可能知道 NUnit 这个测试框架,或者你可能已经听说过这个术语。然而,XUnit 也可以在 .NET Core 上运行,支持 Windows、Linux 和 Mac OS。现在,只需选择 XUnit,点击“下一步”,然后给它命名为“Calculator”,接着点击“下一步”并选择 .NET 6,最后点击“创建”。
接下来,我们编写之前在视频中定义的求和函数。在你的代码中,输入如下内容:
public int Sum(int left, int right)
{
return left + right;
}
我们创建了一个返回类型为 int
的方法,它接受两个参数:left
和 right
,然后返回这两个参数的和。
现在,让我们来看一下你在测试类中可能看到的 C# 脚本。这里我们有一个名为 UnitTest1
的类,里面有一个 Fact
属性。这个 Fact
属性标志着这是一个测试方法。
为了快速测试我们刚刚创建的 Sum
方法,我们只需创建一个 if
语句来验证其是否按预期工作:
if (Sum(2, 2) != 4)
{
throw new System.Exception("Test failed: 2 + 2 should equal 4.");
}
这里,如果 Sum(2, 2)
的结果不是 4,我们就会抛出一个异常。这样,我们可以知道测试是否失败。注意,我们没有引入 using
语句来节省代码的长度,实际上你需要添加 using System;
来解决命名空间问题。
现在你已经有了一个简单的测试方法,Fact
属性表示这是一个测试方法。在方法内部,我们调用了 Sum
方法并检查 2 + 2
是否等于 4。如果它不等于 4,抛出异常表示测试失败。
在 Visual Studio 中,你可能认为可以通过点击顶部的绿色播放按钮或“开始”按钮来运行测试,但其实这是不对的。要运行测试,你可以右键点击测试方法并选择“运行测试”。
这样会打开一个新的“测试资源管理器”窗口,你将看到测试正在执行。稍等片刻,测试结果会显示出来。你会看到类似于“UnitTest1:Test1”的测试方法名称,这就是你刚才测试的那个方法。我们可以看到结果是成功的,因为它通过了测试,显示“通过了 1 个测试”。
如果你更改测试条件,例如将 Sum(2, 2)
改为 Sum(2, 3)
,并保存后重新运行测试,测试会失败。这是因为 2 + 3
不是 4
,测试抛出了异常,导致测试失败。
我们刚刚创建了第一个测试方法,并看到了通过或失败的结果。现在我们需要改进一下命名。我们将以更加专业的方式重构项目,确保命名符合最佳实践。
恭喜你,你刚刚创建了第一个测试方法!你学会了如何编写一个简单的测试方法、如何运行测试,并理解了测试结果的两种可能性:成功或失败。接下来,我们会继续探索如何在项目中应用更加专业的测试实践。
现在,请注意,我们刚才创建的项目仅用于测试场景。这个项目包含了我们刚刚定义的 Sum
方法,然而,如果我们将计算器功能集成到测试项目中,那就不太符合实际开发中的做法了。理想情况下,我们应该把计算器的功能方法放到一个独立的项目中,而不是在测试项目中。我们将这个功能代码提取到一个 领域层 中,即一个包含实际业务逻辑的项目。
首先,右键点击你的解决方案,而不是项目本身,然后添加一个新的项目到解决方案中。这种做法在很多真实世界的应用中都会遇到,通常会有一个测试项目和一个包含算法和业务逻辑代码的项目。接下来,搜索 Class Library(类库),然后选择它。
点击 下一步,选择 .NET 6,然后点击 创建。
接下来,我们需要重命名 Class1.cs
文件。在 Domain 项目中右键点击文件并选择 重命名。将其命名为 Calculator,点击 应用。这样做会自动更新所有引用。
在 UnitTest1 类中,你会看到我们之前创建的 Sum
方法。这个方法应该在 Calculator 类中,而不是在测试项目中。因此,我们将方法从测试项目中移到 Calculator 类中。
Sum
方法的代码,按下 Ctrl + X 剪切。现在,我们已经将 Sum
方法移到 Calculator 类中,接下来需要确保测试项目可以引用到 Domain 项目中的 Calculator 类。
接下来,我们需要调整命名空间和方法的访问权限,以确保测试项目能够访问到计算器的逻辑代码。
Sum
方法设置为 public,以便它能够被外部调用。CalculatorTest
(这样做是为了避免与其他命名空间冲突)。修改完命名空间后,我们需要确保在测试项目中正确引用 Calculator 类。
var calculator = new Calculator();
Sum
方法:var result = calculator.Sum(2, 2);
现在,我们已经将所有的类和命名空间整理好了,接下来可以运行测试了。记住,在测试驱动开发(TDD)中,每次修改代码后,都要运行测试以确保代码仍然正常工作。
你会看到测试通过了。即使我们做了很多改动,添加了新项目,更新了类名和命名空间,只要运行测试,最终都能确保我们的代码是正确的。
通过这一步,我们已经成功地将计算器的业务逻辑从测试项目中提取到了 Domain 项目中,并通过创建项目引用和调整命名空间,使得测试项目能够访问到计算器的方法。最重要的是,我们运行了测试并验证了修改后的代码仍然正确。
现在,你已经掌握了如何组织代码、管理项目之间的引用以及如何在 TDD 中进行验证,确保代码在进行修改后仍然能够正常工作。
目前,我们有两个项目:一个是测试项目,另一个是包含计算器业务逻辑的 领域层 项目(Domain)。这些项目的结构符合专业应用程序的常见做法。在接下来的步骤中,我们将进一步扩展应用程序,添加一个新的 ASP.NET Core Web API 项目。这样,应用程序就可以通过网络与外部系统进行交互,这也是专业软件开发的常见做法。
首先,右键点击解决方案并添加一个新的项目,这次我们选择 ASP.NET Core Web API。如果你之前没有接触过 ASP.NET Core,那么你真的错过了很多东西。在这里,我们只是想展示如何将计算器功能暴露为 Web API 接口,而不是涉及部署和托管。
简单来说,Web API 允许我们通过 HTTP 协议在 Web 上公开功能。我们将计算器的功能暴露为 API,这样用户就可以通过 Web 请求来使用我们的计算器方法。虽然我们不会在这里深入探讨部署和托管的问题,但要理解,创建 Web API 的主要目的是让外部应用能够通过请求访问计算器服务。
接下来,我们需要将 Domain 项目添加为 Web 项目的引用。这个步骤非常重要,因为 Web 项目需要访问 Domain 项目的代码,才能调用计算器的业务逻辑。
Web 项目不需要引用 Test 项目,因为 Test 和 Domain 之间是唯一的联系,Web 项目只需要连接到 Domain 项目。
在 Web 项目中,默认有一个 WeatherForecastController。我们需要将其修改为与我们的计算器逻辑相关的控制器。首先,右键点击 WeatherForecastController 并重命名为 CalculateController。同时,重命名文件为 CalculateController.cs。
然后,删除原有的天气预报代码,并将 Get
方法更改为处理计算任务的逻辑。新方法应该能够接收两个参数(即加数),并返回它们的和。我们将设置 API 路径为 /add/{left}/{right}
,这样用户可以通过 URL 提供两个数字,然后返回它们的和。
我们将通过如下的代码创建一个 API 方法,来处理加法操作:
[ApiController]
[Route("api/[controller]")]
public class CalculateController : ControllerBase
{
[HttpGet("add/{left}/{right}")]
public IActionResult Add(int left, int right)
{
var calculator = new Calculator();
var result = calculator.Sum(left, right);
return Ok(result); // 返回计算结果
}
}
在这个代码中,我们定义了一个新的 Add 方法,它接收两个 URL 参数:left
和 right
。该方法将使用我们在 Domain 项目中定义的 Calculator 类来执行加法操作,并将结果作为响应返回给调用者。
现在,我们已经创建了一个 Web API,可以通过访问 http://localhost:{port}/api/calculate/add/{left}/{right}
来调用。例如,访问 http://localhost:5000/api/calculate/add/5/10
将返回 5 和 10 的和。
如果我们需要更改计算逻辑,例如从加法改为减法,修改会非常简单。我们只需修改 Calculator 类中的 Sum 方法(例如,将 +
运算符替换为 -
)。这意味着所有对该方法的调用(包括测试和 Web API)都会自动反映这一更改,无需做额外的修改。
接下来,我们可以运行测试,以确保 Calculator 类的功能没有被破坏。我们可以通过右键点击测试方法并选择 运行测试 来验证。
如果我们更改了计算逻辑(例如,从加法变为减法),测试会失败。然后我们可以使用 Test First 方法来处理这种情况,确保我们的代码在实现新功能之前是正确的。这个过程将确保我们的更改不会破坏现有的功能。
通过这些步骤,我们成功地将计算器功能暴露为一个 Web API,并实现了以下功能:
这个过程展示了如何构建一个现代化、模块化的应用程序,其中包含了领域层、测试层和 Web 层的分离。这是构建专业应用程序的推荐方式。
我们刚才编写了 sum
函数,并且写了一个简单的自动化测试。但是,我们没有遵循测试驱动开发(TDD)的方法。根据 TDD 的方法论,你必须先编写测试,再编写实际的生产代码。接下来,我将向你展示什么是先写测试再编写生产代码,并稍后讨论为什么要这样做。
sum
函数——遵循测试优先的方法可能现在这个过程感觉有点奇怪,但我们将按照 TDD 的方法先删除 calculator
类。因为正如我之前所说,TDD 的方法就是先编写测试,再编写生产代码。所以我们现在就这么做。不要为删除 calculator
觉得难过,它会在之后重新回来。
首先,删除 calculator
项目。然后在我们的 calculator controller
中,去掉 using domain
,删除 var calculator = new Calculator()
,删除相关代码,并抛出 NotImplementedException
。这一步也很重要:当你写测试时,可能会遇到你需要抛出 NotImplementedException
的情况。
现在,转到我们的单元测试项目(Unit Test Project),保持它为空。这样,当前我们有一个 Web API 项目什么都不做,单元测试项目也没有任何内容,领域层(Domain Layer)也不包含任何元素。现在我们准备好了,可以按照 TDD 的方法开始编写代码。
首先,我们应该问自己:我们到底想要什么?这是每个开发者都应该经常问自己的问题。很多人忽视了这一点,但你应该问自己:我的程序应该做什么?在这个特定场景下,我们想要一个 sum
函数。
想象一下,sum
函数应该能做什么?很简单,比如 2 + 2
应该等于 4
。虽然这是一个非常简单的场景,但在 TDD 中,更重要的是实际的工作流和方法论,而不是具体的算法。
我们先删除自动生成的测试代码,或者你也可以重命名它,但我建议从头开始编写。首先,我们定义一个测试方法,给它一个非常清晰的名称:sum of two and two should be four
。这个方法的作用是:给 sum
函数传入 2
和 2
,期望返回值是 4
。现在我们编写这个测试方法:
[Fact]
public void SumOfTwoAndTwoShouldBeFour()
{
var result = sum(2, 2);
Assert.Equal(4, result);
}
这段代码描述了测试的场景——我们将 2
和 2
传入 sum
函数,期望返回的结果是 4
。
接下来,编写实际的 sum
函数。这个时候我们并没有实际的 sum
函数实现,编译器会报错,提示 sum
方法不存在。这时候,我们需要创建一个 sum
方法来通过这个测试。
public int sum(int left, int right)
{
throw new NotImplementedException();
}
虽然此时 sum
函数没有实现实际逻辑,但我们让它编译通过,移除错误,并确保项目能编译。这样我们就能逐步通过编写测试来消除错误。
编译通过后,运行测试。测试应该会失败,因为我们还没有实现 sum
函数的逻辑。看到失败是很正常的,这是 TDD 流程的一部分。此时,我们根据错误信息去修复代码。
错误提示说方法没有实现,那么我们应该实现 sum
方法的逻辑。算法非常简单,我们让它返回 left + right
:
public int sum(int left, int right)
{
return left + right;
}
重新运行测试,确保它们通过。现在,我们的测试和代码都通过了。
虽然我们的 sum
方法现在已经正常工作了,但它应该放在领域层(Domain Layer)中,而不是放在 Web API 控制器里。我们将 sum
方法移到 Calculator
类中,并确保它是 public
,这样 Web API 项目就可以调用它了。
public class Calculator
{
public int Sum(int left, int right)
{
return left + right;
}
}
然后,我们可以在测试项目中创建 Calculator
实例,并调用 sum
方法:
var calculator = new Calculator();
var result = calculator.Sum(2, 2);
Assert.Equal(4, result);
最后,我们回到 Web API 项目,确保它正确调用了 Calculator
类中的 sum
方法。在 Web API 项目中,我们添加 using Domain
,并返回 calculator.Sum(left, right)
。
[Route("api/[controller]")]
[ApiController]
public class CalculatorController : ControllerBase
{
[HttpGet("sum/{left}/{right}")]
public IActionResult GetSum(int left, int right)
{
var calculator = new Calculator();
var result = calculator.Sum(left, right);
return Ok(result);
}
}
通过这种方式,我们遵循了 TDD 的流程:先编写测试,然后逐步编写代码并修复错误,直到所有测试都通过。通过这种方式,我们确保了代码的正确性并提高了可维护性。
当测试失败时,错误信息应该明确指出出错的原因。因为想象一下,如果你的应用程序中有成百上千个测试,并且这是非常常见的做法,那么某些测试会因最近修改的代码而失败。你应该能够快速地找出原因,对吧?现在,为了做一个快速的测试,我们可以简单地修改 sum
方法,让它返回 5
,这显然是错误的。
在 Calculator
类的 sum
方法中,我们暂时注释掉原本的逻辑,并让方法总是返回 5
:
public int Sum(int left, int right)
{
// return left + right;
return 5; // 错误的返回值
}
这样,无论传入什么参数,sum
方法都会返回 5
。我们再去运行测试,结果会失败,因为我们期望的结果是 4
,但返回的是 5
。
运行测试后,我们看到测试失败了,错误信息显示:
System.Exception: Exception of type 'System.Exception' was thrown.
这个错误信息并没有帮助我们明确了解出错的原因。它只告诉我们发生了异常,但没有告诉我们具体出了什么问题。所以,我们需要改进这个错误信息,使它更具可读性,能帮助我们更快地找到问题。
为了让错误信息更有用,我们可以在抛出异常时传递一个格式化的字符串,包含预期值和实际返回值:
public int Sum(int left, int right)
{
int result = left + right;
if (result != 4)
{
throw new Exception($"The sum of {left} and {right} was expected to be 4, but it is {result}.");
}
return result;
}
这样,错误信息就会告诉我们实际的计算结果与期望值的差异。我们重新运行测试:
System.Exception: The sum of 2 and 2 was expected to be 4, but it is 5.
现在,我们的错误信息更清晰了。它直接告诉我们哪里出了问题:2 + 2
应该是 4
,但是返回了 5
。所以,我们可以立即定位到 sum
方法的错误,并修正逻辑:
public int Sum(int left, int right)
{
return left + right; // 正确的逻辑
}
保存并重新运行测试,测试应该会通过:
Test passed successfully.
尽管我们已经修复了错误并使测试通过,但测试代码中的 if
语句使得代码看起来有点复杂。为了使测试代码更简洁,我们可以使用一个叫做 Fluent Assertions 的库,它可以帮助我们简化断言,并使代码更清晰。
首先,我们需要在项目中安装 FluentAssertions
库。你可以通过 NuGet 包管理器安装它,或者在包管理控制台中运行以下命令:
Install-Package FluentAssertions
安装完 FluentAssertions
后,我们可以改进测试代码,避免使用传统的 if
语句来验证结果。FluentAssertions
提供了更直观、流式的断言方式。例如,我们可以这样重写测试:
[Fact]
public void SumOfTwoAndTwoShouldBeFour()
{
var calculator = new Calculator();
var result = calculator.Sum(2, 2);
result.Should().Be(4, because: "the sum of 2 and 2 should be 4");
}
在这里,我们使用了 FluentAssertions
的 Should().Be()
方法来断言结果。这种写法不仅简洁,而且语义明确,帮助我们清晰地表达预期和实际结果的比较。
通过改进错误信息,我们能够更快地定位和修复测试失败的原因。同时,通过引入 Fluent Assertions,我们简化了测试代码,提高了可读性。整个过程展示了如何更高效地进行单元测试,并且通过清晰的错误信息和简洁的代码结构提升开发效率。
首先,我们需要为我们的项目安装 Fluent Assertions 库。进入 Visual Studio,依次点击 Tools > NuGet Package Manager > Manage NuGet Packages for Solution,然后在左侧的 Browse 选项卡中搜索 Fluent Assertions。
你会看到这个库有超过 1.64 亿的下载量,说明它已经被广泛使用。接下来,选择你的测试项目(在这个例子中是 Calculator Test),然后点击 Install 进行安装。如果出现许可协议,点击同意即可。
安装完 Fluent Assertions 后,我们回到 Calculator Test 中。之前的测试方法使用了 if
语句,代码看起来有些冗长,我们希望移除这些冗余部分。
if
语句我们可以通过 Fluent Assertions 来简化这部分代码。移除原有的 if
语句,改为以下语法:
result.Should().Be(4);
这比 if
语句更加简洁、易读,且更符合人类的阅读习惯。比如:result.Should().Be(4)
直接表明了测试的期望结果是 4。
接下来,我们让 sum
方法故意返回错误的值(比如返回 5
),然后重新运行测试。运行结果会显示一个错误信息:
Expected result to be 4, but found 5.
虽然这个错误信息比我们自己写的错误信息稍微简单一些,但结合测试方法的名字(sum of two and two should be four
)和错误信息(Expected result to be 4, but found 5
),我们依然能清楚地了解到出错的原因,即 sum
方法的实现存在逻辑问题。
我们回到 Calculator 类,将 sum
方法中的错误修复:
public int Sum(int left, int right)
{
return left + right; // 正确的逻辑
}
保存并重新运行测试,这时测试应该顺利通过。
为了使代码更加简洁,我们可以进一步精简测试代码。比如,去掉大括号,直接在一行中写出完整的测试代码:
new Calculator().Sum(2, 2).Should().Be(4);
这样,我们将测试代码写得更加简洁、清晰,并且删除了不必要的噪音。
在生产代码中,我们也可以采取类似的优化,简化代码。比如,将 left + right
直接作为返回值,而不使用大括号,进一步提升代码的简洁性:
public int Sum(int left, int right) => left + right;
这样,不仅代码更简洁,而且可读性更强。
为了让代码更具可读性,我们还需要对测试类进行重命名。将原本的 UnitTest1
重命名为 CalculatorTests
,使其更符合实际功能。
为了帮助理解 TDD(测试驱动开发),我们可以借用一个吉他调音器的类比。吉他调音器帮助吉他手调整琴弦的音高,确保吉他的声音符合标准。你可以将调音器看作是一个测试,吉他的音色则是生产代码。
在调音之前,调音器知道每个音符应该发出什么样的声音,就像测试知道结果应该是什么样子。吉他手通过调节琴弦,使其发出调音器预期的声音。类似地,在 TDD 中,我们首先编写测试,明确代码应该如何工作,然后编写生产代码使其通过这些测试。
TDD 的第一条规则是:只有通过失败的测试后,才编写生产代码。写测试而不是生产代码的过程被称为 先写测试(Test First Approach)。
在第一章中,我们回顾了手动测试过程,创建了一个测试项目并编写了第一个自动化测试。然后,我们根据测试驱动开发(TDD)重写了代码,去除了不必要的部分,写了更好的断言信息,并安装并使用了 Fluent Assertions 库,使测试代码更加简洁。
恭喜你已经完成了第一章的学习!现在你已经能够编写自动化测试了。接下来的章节将继续深入理论并提供一些实际案例。如果你现在不是通过 All Access Subscription 来观看课程,你可以考虑订阅 All Access,这样你可以获得个人任务和练习,同时也能获取课程的详细资料。你可以免费试用七天,立即开始学习。如果你已经订阅了 All Access,那么继续保持努力,学习更深的知识吧!
在上一章中,我们对测试驱动开发(TDD)进行了一个实用的介绍。本章将开始更深入的理论部分,并结合实践来进行讲解。通过本章的学习,你将了解以下几个内容:
软件开发的目的是为利益相关者的问题提供解决方案。在开发高质量的软件之前,你必须考虑以下几点:
换句话说,你应该知道期望函数完成什么任务,而不是它是如何完成这些任务的。
TDD 专家倾向于尽可能明确地表达预期需求,然后开始编写生产代码,直到生产代码满足预期需求。
这可能听起来有些混乱,不过别担心,我们会逐步解释清楚。假设你想编写一个除法函数,在开始编写函数主体之前,你应该首先考虑:
例如,四除以二应该等于二。所有客户端都期望函数具有这样的行为,这种期望就是一个测试场景。每当你编写生产代码时,你应该考虑以下三个问题:
假设你想测试汽车的点火是否正常。你按下点火按钮后,应该能听到汽车发动机的声音。测试的输入是启动点火,输出是发动机的声音。如果汽车没有启动,因为它没有油,这是否意味着点火系统不工作呢?显然不是。为了测试点火系统,汽车应该有油。加油就是该测试场景的前置条件。
前置条件 是测试场景的一部分。任何测试场景可以有零个或多个前置条件。有些测试场景可能没有前置条件,但每个测试场景都应该包括输入、系统运行和输出。
为了更容易理解这一点,我们来看一个真实世界的例子。假设我们正在开发一个航班预订应用,类似于我们在 21 天 ASP.Net Core 和 ASP 课程中构建的应用。当我预订一个座位时,航班的剩余座位数量应该减少。
作为乘客,如果我预定了一个座位,航班上应该还剩多少座位呢?这取决于我预定时航班的座位总数。因此,这个测试场景缺少了一些内容,它需要一个前置条件。我们需要在预定座位之前定义航班的初始座位数,以便检查预订方法是否正确减少了航班对象的座位数。
这时,场景就完整了:我们需要首先设置航班的座位数作为前置条件,然后进行预定操作。
本章介绍了先写测试的原因,以及如何使用前置条件来完善测试场景。理解了这些概念后,你会更清楚在编写生产代码之前,测试的期望结果是什么,以及如何通过测试来验证代码的正确性。接下来,我们将通过一个新的项目来实践这些概念,进一步加深对 TDD 的理解。
现在我们将创建一个新的项目,名为 flight
,用于测试我们在前一部分演示中的航班预定场景。这个项目将专注于航班预订的功能,确保我们的代码按预期执行。
首先,我们将在 Visual Studio 中创建一个新的 单元测试项目。你可以选择将项目命名为 flight
,或者你可以根据自己的习惯命名为 flight test
或其他名称。这个项目将用于测试你的代码,因此你需要选择 单元测试项目模板。
.NET 6
或 .NET 7
)。flight
,然后点击 下一步。一旦项目创建完成,我们需要安装 Fluent Assertions 包。Fluent Assertions 是一个用于编写更具可读性的断言的库,它可以让我们更加简洁和易读地表达期望结果。
Fluent Assertions
。安装完成后,你将准备好进行后续的开发和测试工作。此时,我们已经创建好了一个测试项目并安装了所需的依赖项,接下来我们可以开始编写实际的测试案例,并实现预定航班的功能。
你可以在接下来的任务中继续实现具体的功能,并通过编写测试用例来验证你的代码是否按照预期工作。
现在,我们将继续使用 TDD(测试驱动开发)方法来实现一个航班预定的功能。我们会按照以下三个步骤进行:
我们开始编写测试,首先创建一个容量为 3 的航班对象:
var flight = new Flight(3);
然后,我们模拟预定一个座位的操作:
flight.Book("Yannick@tutorials.newcom");
接下来,我们验证剩余座位是否正确:
flight.RemainingNumberOfSeats.Should().Be(2);
到此为止,我们并没有实际实现代码,而是通过 TDD 方法,先定义了我们期望的行为和测试场景。
接下来,我们通过 TDD 的方式逐步解决错误,最终实现 Flight
类的功能。
由于我们遇到了 flight 是命名空间而不是类型
的错误,我们首先需要创建一个 Flight
类。我们通过以下步骤来创建它:
Domain.Test
,这是专门用于测试的类库。Flight
类,初步定义构造函数:public class Flight
{
public int RemainingNumberOfSeats { get; private set; }
public Flight(int seatCapacity)
{
RemainingNumberOfSeats = seatCapacity;
}
public void Book(string email)
{
// 暂时不实现,稍后处理
throw new NotImplementedException();
}
}
flight
测试项目中,右击项目选择 添加引用,并选择刚才创建的 Domain.Test
项目。由于出现了命名冲突,Flight
在不同命名空间中,我们可以选择直接修改命名空间,避免命名冲突。可以选择将测试项目中的 flight
类命名为 FlightTest
,或者直接在 Flight
类中使用完全限定的命名空间。
在测试运行时,我们遇到了 Flight
类没有定义 Book
方法的问题。我们可以添加 Book
方法,并抛出一个 NotImplementedException
,以便继续执行测试:
public void Book(string email)
{
throw new NotImplementedException();
}
同时,我们还需要为 RemainingNumberOfSeats
添加一个属性:
public int RemainingNumberOfSeats { get; private set; }
在完成这些更改后,我们运行测试,看到 Fluent Assertions 给出的错误信息是:
Expected flight remaining number of seats to be 2, but found 0.
这是因为我们尚未实现 Book
方法的逻辑,也没有调整剩余座位数。
为了让测试通过,我们现在需要实现实际的业务逻辑:
Flight
类中,初始化时设置剩余座位数:public Flight(int seatCapacity)
{
RemainingNumberOfSeats = seatCapacity;
}
Book
方法,减少剩余座位数:public void Book(string email)
{
if (RemainingNumberOfSeats > 0)
{
RemainingNumberOfSeats--;
}
else
{
throw new InvalidOperationException("No remaining seats.");
}
}
我们按照 TDD 的步骤进行开发:
Flight
类的各个方法和属性。通过 TDD 方法,我们首先明确了测试的需求,逐步编写了测试并实现了相应的生产代码。虽然测试一开始失败,但通过不断修复代码,我们确保了每个功能都符合预期。这种方法不仅帮助我们更好地理解了需求,也确保了代码的正确性和可维护性。
接下来,你可以继续扩展这个功能,处理更多的业务场景,增加例如座位剩余为负数的错误处理,或者是取消预定等功能。
在 TDD(测试驱动开发)中,红绿重构 是一个关键的开发过程。这个过程分为三个阶段:
我们通过具体操作,来一步步讲解如何进行 红绿重构。
首先,我们对测试方法进行重命名,使其更加描述其功能。我们将原来的测试方法名称改为更具体的 BookingReducesTheNumberOfSeats
,这个名字清晰地反映了测试的目标——验证预定操作是否减少了座位数量。
public void BookingReducesTheNumberOfSeats()
{
// 代码...
}
接下来,我们将测试类 UnitTest1
重命名为 FlightSpecifications
,这样测试类的名称更加符合其功能,能够清晰地表达其测试的是航班相关的功能。
public class FlightSpecifications
{
// 代码...
}
现在,我们已经完成了测试方面的初步工作,接下来我们会将生产代码从 domain.test
移动到正式的 domain
类库中。我们需要创建一个新的生产类库并将 Flight
类移到生产环境中。
创建生产类库:
Domain
。将 Flight
类移到生产库:
Flight
类文件,选择 快速操作和重构,然后选择 移动到命名空间。Domain
命名空间,点击 确定,将 Flight
类移到新的生产命名空间中。删除原测试库中的 Flight
类:
domain.test
中删除原来的 Flight
类,确保项目中只保留一个 Flight
类实例。完成了类的迁移后,接下来需要更新项目引用。在 FlightSpecifications
测试类中,引用的 Flight
类现在已经被移到 Domain
项目中,因此我们需要更新引用:
添加项目引用:
FlightSpecifications
测试项目,选择 添加引用,并添加对 Domain
项目的引用。Domain.Test
的引用,可以删除它。确保 FlightSpecifications
中引用了 Domain
项目:
FlightSpecifications
测试类中正确引用了 Domain
命名空间,现在就能使用我们刚刚迁移到生产代码中的 Flight
类了。在 Flight
类中,我们重命名了 Book
方法的参数,以使代码更加清晰易懂:
public void Book(string passengerEmail, int numberOfSeats)
{
// 代码实现
}
这一步确保了方法参数更加具描述性,代码的可读性大大增强。
完成上述重构后,我们回到测试类 FlightSpecifications
中,右键点击测试方法,选择 运行测试,确保测试能够顺利通过。
此时,测试再次通过,表明我们没有改变代码的外部行为,仅仅是对代码进行了重构,改进了其结构和可维护性。
在完成 红绿重构 过程后,我们主要达到了以下目标:
Domain
。红绿重构 是 TDD 中至关重要的一部分,它帮助开发人员在写完测试并通过后,确保代码的质量不断得到提升。在开发过程中,我们应始终专注于重构现有代码,并避免一次性编写过多的代码。重构的核心目标是改善代码的设计,而不改变其外部行为。这使得我们的代码更加清晰、易于维护,同时能确保功能的正确性。
在 TDD(测试驱动开发)中,我们首先通过思考和可视化测试场景来规划我们的测试。接着,我们将这些场景翻译成测试代码,然后逐步实现。这个过程帮助我们确保代码按照预期运行。
我们首先使用便签(sticky notes)来可视化我们的测试场景。通过这种方式,我们可以明确测试的每个步骤。测试场景通常包括几个部分,常见的结构是 给定(Given)、当(When) 和 然后(Then):
在我们的航班预定场景中,测试分为以下几个步骤:
这些步骤采用了 给定-当-然后 格式,这种格式使得开发人员和业务人员之间的沟通更加顺畅。通过清晰地分隔 给定、当 和 然后 的步骤,代码也更易于理解。
在我们的航班预定系统中,我们需要确保系统能够避免超额预定的情况。如果航班的剩余座位不足以满足乘客的预定需求,系统应该返回一个错误,而不是继续进行预定。
我们要先思考如何测试这个场景。避免超额预定的规则是:当剩余座位不足时,应该返回一个错误,而不是完成预定。具体来说,如果剩余座位大于乘客想要预定的座位数,系统应该返回一个 “超额预定错误”。
我们首先考虑如何测试这个场景。测试的参数需要传递给 Flight
实体,而测试的结果则是一个错误提示。我们可以按照以下步骤来测试:
通过这种方式,我们可以模拟预定超出座位数的场景,并检查系统是否正确返回了错误。
根据上述测试场景,我们的测试代码可以写成以下形式:
[TestMethod]
public void AvoidOverbooking()
{
// Given: 创建一个座位数为3的航班
var flight = new Flight(3);
// When: 我尝试预定4个座位
var result = flight.Book("test@example.com", 4);
// Then: 应该返回超额预定错误
Assert.AreEqual("Overbooking Error", result.Error);
}
现在我们已经定义了测试,并且有了明确的预期行为,我们可以开始编写代码来实现避免超额预定的功能。
Flight
类:我们需要在 Flight
类中更新 Book
方法,添加防止超额预定的逻辑。具体来说,当请求的座位数大于剩余座位数时,应该返回一个错误。
public class Flight
{
private int _seatsAvailable;
public Flight(int seatCapacity)
{
_seatsAvailable = seatCapacity;
}
public BookingResult Book(string passengerEmail, int numberOfSeats)
{
if (numberOfSeats > _seatsAvailable)
{
return new BookingResult { Error = "Overbooking Error" };
}
_seatsAvailable -= numberOfSeats;
return new BookingResult { Error = null };
}
}
public class BookingResult
{
public string Error { get; set; }
}
在这个实现中,我们添加了一个 BookingResult
类来封装预定结果。如果预定的座位数超过了剩余座位数,我们就返回一个包含“超额预定”错误的 BookingResult
对象。
一旦我们实现了上述逻辑,就可以运行我们的测试,确保代码能够按照预期工作。如果测试通过了,意味着我们成功地避免了超额预定的情况。
在整个过程中,我们首先通过 给定-当-然后 模式规划了测试场景,然后将这个场景翻译为代码。我们通过先思考如何测试需求,再编写代码来实现需求,遵循了 TDD 的实践。
这种方法确保了我们在开发过程中始终保持对需求的明确理解,并通过 TDD 确保代码的正确性和可维护性。
在这一部分,我们将开始实现避免超额预定的测试方法。我们将按照以下步骤来编写测试,并逐步修复可能出现的错误,直到测试通过。
首先,我们需要使用 [Fact] 属性来标记我们的测试方法。在 xUnit 中,我们需要为每个测试方法添加 [Fact] 属性。接着,我们创建一个公共的 void
方法,并命名为 AvoidsOverbooking
。测试方法名应该描述我们正在测试的功能或行为。
[Fact]
public void AvoidsOverbooking()
{
// Given: 创建一个座位数为3的航班
var flight = new Flight(3);
// When: 尝试为4个座位预定
var error = flight.Book("yannick@tutorials.com", 4);
// Then: 应该返回一个超额预定错误
error.Should().BeOfType<OverbookingError>();
}
在这段代码中,我们按照 给定(Given)-当(When)-然后(Then) 的顺序编写了测试:
OverbookingError
类型的错误。在运行测试时,我们会遇到一个编译错误:Cannot assign void to an implicitly typed variable
(无法将 void
分配给隐式类型的变量)。我们可以通过将 Book
方法的返回类型从 void
改为 object
来修复这个错误。这样,我们就可以在方法中返回一个错误对象。
public class Flight
{
private int _seatsAvailable;
public Flight(int seatCapacity)
{
_seatsAvailable = seatCapacity;
}
public object Book(string passengerEmail, int numberOfSeats)
{
if (numberOfSeats > _seatsAvailable)
{
return new OverbookingError();
}
_seatsAvailable -= numberOfSeats;
return null;
}
}
我们将 Book
方法的返回类型从 void
改为 object
,并且如果预定超出了可用座位数,我们会返回一个 OverbookingError
实例。如果预定成功,则返回 null
。
OverbookingError
类我们需要为超额预定创建一个专门的错误类。可以在 Domain
层创建一个新的类,也可以将现有的类重命名。这里我们选择创建一个新的 OverbookingError
类。
public class OverbookingError
{
// 可以在这里添加与错误相关的额外信息
}
接下来,我们运行测试。你可以看到,在 Flight 类中的 Book
方法修改后,我们已经没有编译错误了。但是,测试仍然会失败,错误信息显示:“期望错误类型为 OverbookingError
,但返回的是 null
”。
我们需要在 Book
方法中添加逻辑,当航班座位数不足时,返回 OverbookingError
。具体来说,如果请求的预定座位数大于剩余座位数,则返回 OverbookingError
。
public class Flight
{
private int _seatsAvailable;
public Flight(int seatCapacity)
{
_seatsAvailable = seatCapacity;
}
public object Book(string passengerEmail, int numberOfSeats)
{
if (numberOfSeats > _seatsAvailable)
{
return new OverbookingError(); // 返回超额预定错误
}
_seatsAvailable -= numberOfSeats;
return null;
}
}
现在,重新运行测试,确保 AvoidsOverbooking
测试通过。如果逻辑实现正确,测试将成功通过,并且 OverbookingError
会在发生超额预定时被正确返回。
通过这个过程,我们展示了 TDD(测试驱动开发)的力量。首先,我们定义了一个测试场景,然后将其翻译成测试代码。每当测试失败时,我们修复错误,直到测试通过。通过这种方式,我们能够在不启动应用程序的情况下验证系统行为的正确性。
通过这种方法,我们能够确保软件的行为符合预期,且保持代码的可维护性。TDD 的核心是 红-绿-重构 循环:
通过不断循环这三步,TDD 帮助我们创建更加可靠和高质量的软件。
在上一章中,我们介绍了 红-绿-重构(Red-Green-Refactor)模式的核心,并且一起实践了这个过程。在本章中,我们将学习如何编写可靠的测试、发现新的测试场景,并了解如何使用参数化测试来提升测试的质量。
当前我们有一个 FlightSpecifications 类,其中包含两个测试:Booking reduces the number of seats(预定减少座位数)和 Avoids overbooking(避免超额预定)。现在我们打开 Flight 类并查看其实现,看看是否有可能修改 Book
方法而不破坏现有的测试。
让我们先尝试一下,暂时注释掉 Book
方法中的 if
语句和 return null
,并改为在所有情况下都返回一个新的 OverbookingError
。
public object Book(string passengerEmail, int numberOfSeats)
{
// Commented out the original condition
// if (numberOfSeats > _seatsAvailable)
return new OverbookingError(); // Always return an overbooking error
}
此时,看起来不太对,但我们还是运行一下测试,看看会发生什么。结果显示所有测试都通过了。这告诉我们现有的测试并不可靠,因为它们并没有能够捕捉到 Book
方法中的这个问题。
为了让测试变得更可靠,我们需要添加更多的测试。让我们在 FlightSpecifications 中添加一个新的测试:Books flights successfully(成功预定航班)。
[Fact]
public void BooksFlightsSuccessfully()
{
var flight = new Flight(3); // 创建一个座位数为3的航班
var error = flight.Book("yannick@tutorials.com", 1); // 预定1个座位
error.Should().BeNull(); // 没有错误
}
这个新测试的逻辑是:我们创建一个座位数为3的航班,并尝试为一个乘客预定1个座位。如果没有发生错误,那么 error
应该为 null
。
现在,我们运行这个新的测试,结果它会失败。这是因为我们之前在 Book
方法中做了不合理的修改,导致无论如何都返回了一个 OverbookingError
。虽然其他测试通过了,但这个新测试显然是失败的。
Booking reduces the number of seats: Passed
Books flights successfully: Failed (returns OverbookingError instead of null)
为了修复这个问题,我们将恢复 Book
方法中的逻辑:
public object Book(string passengerEmail, int numberOfSeats)
{
if (numberOfSeats > _seatsAvailable)
{
return new OverbookingError(); // 超额预定时返回错误
}
_seatsAvailable -= numberOfSeats; // 成功预定后减少座位数
return null; // 没有错误
}
然后,我们重新运行测试。现在我们可以看到,BooksFlightsSuccessfully
测试通过了,但 AvoidsOverbooking
测试失败了。为了修复这个问题,我们需要再次确保在 Book
方法中有正确的超额预定逻辑。
通过增加更多的测试,我们可以大大提高测试的可靠性。在 TDD(测试驱动开发)方法中,这种增加测试来应对潜在漏洞的过程被称为 Devil's Advocate(魔鬼代言人)技术。
魔鬼代言人是一种测试策略,它通过让生产代码故意不按预期工作,帮助我们发现现有测试的弱点。这个过程有两个角色:
在 Devil's Advocate 阶段,魔鬼代言人会试图修改生产代码,使其行为不正确,同时不让现有测试失败。如果成功,测试员需要分析并添加新的测试来防御这种攻击,确保生产代码再次按预期工作。
假设我们已经编写了一个测试并使其通过,然后进行重构。这时,作为测试员,我们要确保我们所编写的测试覆盖了所有可能的边界情况。接下来,我们转换角色,成为魔鬼代言人,试图修改生产代码,看看现有的测试是否能够捕捉到新的问题。
如果魔鬼代言人成功修改了生产代码并且测试仍然通过,我们作为测试员就需要添加新的测试,以防止这种问题再次发生。
通过使用 Devil's Advocate 技术,我们可以确保测试覆盖到所有的边界情况,并且增加测试的可靠性。每次魔鬼代言人进行攻击时,测试员都会增加新的测试,确保生产代码不再出现错误。
这种方法不仅帮助我们发现代码中的潜在缺陷,还促进了测试的完善,使我们能够编写出更强大、可靠的测试用例,从而提高软件的质量和稳定性。
在本章中,我们学习了如何通过增加新的测试来发现新场景,如何使用魔鬼代言人技术来提升测试的信任度,并且了解了 TDD 中红-绿-重构模式的实际应用。
我们来玩一下魔鬼辩护人游戏。假设我们有一个测试,测试内容是:预订会减少座位数。那么我们现在有一个航班,预订了一个座位。最初我们有三个座位,如果预订一个座位,剩下的座位数应该减少1。所以剩余座位数应该是2,对吧?从魔鬼辩护人角度来看,完全可以直接进入 book
方法,在其中将剩余座位数直接设置为2,这样我们就不需要从参数中减去座位数了,对吧?我们可以直接设置剩余座位数为2,而不必使用参数。这样测试就会通过。
所以我现在复制那行代码,把它注释掉。然后把剩余座位数直接设置为2。这样测试将会通过。让我们再检查一下测试内容:测试期望剩余座位数应该为2。因此,如果直接设置为2,测试就会通过。让我们右键点击,运行测试。结果如你所见,所有的测试都通过了,这确实是魔鬼辩护人的一个例子。
从TDD(测试驱动开发)的角度来看,解决这个问题非常简单。我们只需复制整个测试代码,重新编写一个类似的测试,使用不同的值。并不是说重复写一些基础功能代码,而是在编写测试时使用不同的值,测试不同的场景。这与编写功能代码时的“不要重复自己”(DRY,Don't Repeat Yourself)原则略有不同,因为测试是可以重复的,只要它们测试的是不同的场景。
现在我们来修改测试,我们创建一个包含6个座位的航班,然后预订3个座位。剩余的座位数应该是3。让我们看看,如果我们右键点击运行测试,结果会发生什么。现在我们有四个测试,其中一个还没有运行。稍等片刻,你会看到其中一个测试失败了。测试失败了。
我们想要确保在应用程序部署之前,所有的测试都通过了。对吧?如果有一个测试失败了,我们肯定不想部署应用程序,因为有些功能没有按照预期工作。
现在让我们回到航班类中,使用我们刚才的参数。剩余座位数应该根据预订的座位数减少,这样就能解决问题了。让我们再切换回去,重新运行测试。现在,你可以看到所有的测试都成功通过了。
现在,让我解释一下,也许一些开发者已经遇到过类似的情况。假设我们不使用上面的方式,而是采用这样的代码:如果座位数等于1,则剩余座位数设置为2;如果座位数等于3,则剩余座位数设置为3。我们在代码中使用了“魔术数字”和硬编码的值。
这种做法在某些情况下可能可以用,但很多时候初学者容易犯这种错误。我们在代码中硬编码了魔术数字,而这种做法显然不是灵活的。如果我们这样做,测试仍然会通过,但这并不意味着代码是正确的。
为了提高我们测试的可靠性,我们可以再次创建不同的测试场景,比如:创建一个包含10个座位的航班,预订6个座位,剩余座位数应该是4。如果我们保存这样的代码,测试会失败,因为这些值并未正确设置。虽然现在不太可能再次使用类似的 if 语句,但实际上是有可能的。因此,为了增加测试的可信度,我们应该为不同的场景编写更多的测试。
你现在应该记住的一点是,进行多值测试非常重要,而不是像我们最初的测试那样只测试一个单一的值。比如,我们可以使用6个座位和3个座位、10个座位和6个座位、3个座位和1个座位等不同的组合来测试。通过这种方式,我们可以确保程序员编写的算法不会无意中破坏测试。
这就是魔鬼辩护人的游戏。一个方面试图破坏测试,使其变得不可靠,而另一个方面则是通过编写更多、更好的测试来提高测试的可信度。
现在我们有了所有测试,用于验证预订是否减少座位数。其实,进行多次测试是完全可以接受的,因为这些测试只是验证不同的场景,而不涉及逻辑实现,因此它们是可以维护的。然而,和简单的逻辑代码一样,如果代码不重复,那么维护起来会更加方便。所以,虽然在测试中允许重复,但从测试的角度来看,很多时候将重复的部分优化成参数化测试会更加高效。现在我们来看看如何通过参数化测试来提高可维护性,并简化航班测试规范。
首先,我们将从第一个测试开始,并在稍后从其他测试中复制相应的值。因此,我们首先专注于第一个测试:预订是否减少座位数。我们将不再使用 Fact
属性,而是改为使用 Theory
。这是 XUnit 提供的功能,表示某些测试数据来自方法外部。接下来,我们需要使用 InlineData
属性,这个属性提供了内联数据源,为数据理论提供数据。现在我们为测试添加参数。
我们首先定义几个参数:
seatCapacity
:航班的座位容量,即航班总共可容纳的座位数。numberOfSeats
:预订的座位数。remainingSeats
:预订后的剩余座位数。接下来,我们使用这些参数来进行测试。
[Theory]
[InlineData(3, 1, 2)] // 创建一个3个座位的航班,预订1个座位,剩余2个座位
public void BookingReducesNumberOfSeats(int seatCapacity, int numberOfSeats, int remainingSeats)
{
var flight = new Flight(seatCapacity);
flight.Book(numberOfSeats);
Assert.Equal(remainingSeats, flight.RemainingSeats);
}
这里我们定义了一个理论方法 BookingReducesNumberOfSeats
,它接受三个参数:seatCapacity
,numberOfSeats
,remainingSeats
。我们通过 InlineData
提供测试数据,例如:创建一个包含3个座位的航班,预订1个座位,剩余座位数为2。
运行测试后,我们可以看到它成功通过了,并且所有测试都通过了。测试的结果表明,预订座位后,剩余座位数符合预期。
为了测试更多场景,我们只需复制 InlineData
并更改其值。以下是我们可能会测试的一些场景:
[Theory]
[InlineData(3, 1, 2)] // 3个座位,预订1个座位,剩余2个座位
[InlineData(6, 3, 3)] // 6个座位,预订3个座位,剩余3个座位
[InlineData(10, 6, 4)] // 10个座位,预订6个座位,剩余4个座位
public void BookingReducesNumberOfSeats(int seatCapacity, int numberOfSeats, int remainingSeats)
{
var flight = new Flight(seatCapacity);
flight.Book(numberOfSeats);
Assert.Equal(remainingSeats, flight.RemainingSeats);
}
现在,我们测试了三种不同的场景:3个座位,预订1个;6个座位,预订3个;10个座位,预订6个。每个测试都通过了,且验证了座位数的正确性。
通过参数化测试,我们大大简化了测试代码,使得维护变得更加容易。在实际应用中,如果需要更多测试场景,只需添加新的 InlineData
,不必重新编写相同的测试逻辑。这提高了测试代码的可维护性,减少了冗余。
在魔鬼辩护人游戏中,你可以随时创建新的测试场景。例如,你可以为航班创建一个新的测试场景,假设航班有12个座位,预订了8个座位,剩余座位数应该是4。只需添加一个新的 InlineData
即可:
[InlineData(12, 8, 4)] // 12个座位,预订8个,剩余4个
这就是魔鬼辩护人测试的优势:可以轻松地增加新的测试场景,而不需要修改核心逻辑或重复编写测试代码。
在测试中,我们不会将像电子邮件这样的虚拟数据作为参数传递,因为电子邮件在此场景中只是一个占位符,它并不影响座位数的逻辑。作为测试的参数,应该仅传递对测试有实际意义的变量,如座位数和剩余座位数,而像电子邮件这样的数据是“虚拟的”,它不应参与到测试的主要逻辑中。如果我们将电子邮件地址作为参数传递,那么这个测试的目的就会变得不明确。就像常量一样,虚拟数据(如电子邮件)只是为了填充而存在,而测试参数才是测试逻辑的关键变量。记住,不要将“虚拟数据”作为测试参数传递。
在前面的视频中,我们已经实现了避免超售的测试场景。现在,我们需要处理的是记住实际的预订记录。也就是说,我们必须追踪航班的预定记录,了解谁预定了哪一班航班以及预定了多少座位。简而言之,我们需要“记住所有的预定”。
让我们从“当”步骤开始考虑。当我预定航班时,航班的预定列表应该包括我的预定。当然,航班应该在预定之前已经存在。这个场景的测试代码应该非常简洁且易于理解。我们可以使用类似 when a passenger with the email a@b.com books four seats off the flight, then the flight's booking list should contain a booking made by a@b.com for four seats
的描述。
首先,我们来写一个新的测试方法,名为 remembers bookings
。这个方法会模拟一个预定情境,确保预定被记录。
public void RemembersBookings()
{
// 创建航班对象,假设航班有150个座位
var flight = new Flight(150);
// 执行预定操作
flight.Book("a@b.com", 4);
// 验证预定列表中是否包含指定的预定
flight.BookingList.Should().Contain(b => b.Email == "a@b.com" && b.NumberOfSeats == 4);
}
这段代码首先创建了一个航班实例,然后模拟一个用户使用电子邮件 a@b.com
预定了 4 个座位。最后,它通过断言确认航班的预定列表包含了这条预定记录。
为了实现这一功能,我们需要设计一个 Booking
类,用来记录每个预定的乘客邮箱和座位数。以下是 Booking
类的设计:
public class Booking
{
public string Email { get; set; }
public int NumberOfSeats { get; set; }
public Booking(string email, int numberOfSeats)
{
Email = email;
NumberOfSeats = numberOfSeats;
}
}
接下来,我们需要在 Flight
类中添加一个预定列表,用于存储所有的预定记录。这个列表应当是 Booking
对象的集合。下面是如何在 Flight
类中添加 BookingList
属性:
public class Flight
{
public List<Booking> BookingList { get; set; }
public Flight(int seatCapacity)
{
BookingList = new List<Booking>(); // 初始化预定列表
}
public void Book(string email, int numberOfSeats)
{
// 这里假设我们已经进行了座位数的检查(避免超售)
BookingList.Add(new Booking(email, numberOfSeats));
}
}
一旦测试失败,我们可以开始编写生产代码。在 Book
方法中,我们应该将预定添加到 BookingList
中,确保每次预定都会被记录。
执行测试时,我们可能会遇到“对象引用未设置为对象的实例”错误。该错误表示 BookingList
在访问时为 null
。为了解决这个问题,我们需要在 Flight
类的构造函数中初始化 BookingList
,如下所示:
public Flight(int seatCapacity)
{
BookingList = new List<Booking>(); // 初始化预定列表
}
确保所有修改后,我们再次运行测试,检查它是否通过。如果一切顺利,测试应该会通过。
ContainEquivalentOf
在进行断言时,我们使用了 ContainEquivalentOf
而不是 Contain
。原因是,Contain
检查的是对象引用是否相同,而我们希望的是检查对象的属性值是否相同,因此我们使用 ContainEquivalentOf
来检查预定列表中是否包含具有相同值的 Booking
对象,而不是完全相同的引用。
BookingList.Should().ContainEquivalentOf(new Booking("a@b.com", 4));
通过这种方法,我们成功实现了一个测试,确保每次预定时都将相关的预定记录添加到航班的预定列表中。这是一个典型的测试驱动开发(TDD)过程:首先编写测试,再根据测试来编写和修改生产代码。通过这种方式,我们不仅确保了代码的正确性,还提高了代码的可维护性和可扩展性。
现在我们已经完成了记住预定的实现,接下来我们可以对代码进行重构,提升其质量和可维护性。在这一部分,我们将重点改进 Flight
类中的 BookingList
的访问权限。
当前的 BookingList
是公开的,这意味着任何能访问我们代码的人都可以向 BookingList
添加预定,显然我们并不希望这样。因此,我们需要将 BookingList
设置为私有,只能在 Flight
类内部进行修改。
为了使 BookingList
只能在 Flight
类内部修改,我们需要将它从公开的列表改为私有的列表,并提供一个只读的公开接口供外部读取。
BookingList
设置为私有:我们首先将 BookingList
声明为私有,这样它只能在 Flight
类中被访问和修改。
private List<Booking> bookingList = new List<Booking>();
为了让外部能够读取预定列表,我们提供一个只读的 IEnumerable<Booking>
接口,而不提供可以修改的 Add
方法。这样,外部只能查看预定列表中的内容,而不能直接向其中添加新预定。
public IEnumerable<Booking> BookingList => bookingList;
public class Flight
{
// 私有的预定列表,只能在类内部访问
private List<Booking> bookingList = new List<Booking>();
// 公开的只读接口,用于外部访问预定列表,但不能修改
public IEnumerable<Booking> BookingList => bookingList;
public Flight(int seatCapacity)
{
// 初始化其他属性
}
public void Book(string email, int numberOfSeats)
{
// 预定时,往私有的 bookingList 添加新的预定
bookingList.Add(new Booking(email, numberOfSeats));
}
}
bookingList
被声明为私有 List<Booking>
,确保只有 Flight
类可以修改它。BookingList
公开为 IEnumerable<Booking>
,使得外部只能读取预定列表,而无法修改其中的内容。这通过 BookingList => bookingList
的方式实现,确保了封装性。通过这种方式,我们不仅增强了代码的封装性,还避免了外部代码直接修改预定列表的风险。只有 Flight
类的内部逻辑可以控制对 BookingList
的修改。
在这一部分中,我们通过对 BookingList
的访问权限进行重构,改进了代码的封装性。通过使用私有字段和公开只读接口,我们确保了 BookingList
只能在 Flight
类内进行修改,从而提升了代码的安全性和可维护性。
在本章中,我们不仅学习了如何通过测试驱动开发(TDD)来实现和改进预定系统,还通过重构提升了代码的封装性和安全性。我们使用了“魔鬼代言人”技巧和参数化测试,进一步提高了测试的可信度。通过这些改进,我们的代码变得更加健壮、可扩展。
在下一章中,我们将进一步探索更多的测试场景,并深入学习 TDD 的原则和实践。敬请期待,我们很快就会进入下一个激动人心的阶段。
欢迎来到《测试驱动开发》课程的第四章。在上一章中,我们学习了如何为待测试系统编写一个可信的测试集合。在这一章中,我们将把测试驱动开发(TDD)流程以检查清单的形式展示,帮助你更容易地遵循并编写测试。我们还将学习 TDD 的三大法则,并在实践中应用它们。最后,我们将学习一种发现新场景的技巧。
在开始编写代码之前,让我们先回顾一下到目前为止的工作流程,而不是直接跳入代码中。我们首先思考测试场景,然后将场景转化为代码,进入红绿重构(Red-Green-Refactor)过程。当最终重构完成后,我们通过“魔鬼代言人”技巧来检查测试是否足够可信。接着,我们检查待测试系统是否缺少任何其他行为。
你可以通过查看生产代码来判断待测试系统是否缺少其他行为来完成它的工作。例如,在我们的示例中,我们查看了 Flight
类,发现它没有记住预定,因此我们新增了一个“航班记住预定”场景。通过这种方法,如果待测试系统能够完全执行它的功能而不需要其他行为,则可以开始寻找新的场景。
如果系统中还缺少行为,你可以指定一个新的单元测试来添加缺失的行为。
你可以使用以下流程图作为练习 TDD 的检查清单。这将帮助你在编写测试时保持对测试驱动开发规则的关注。
思考场景
我们的第一个步骤是思考待测试系统应该具备的场景。
转化为测试代码
将场景翻译成代码并编写相应的测试。
红绿重构
执行红绿重构过程,先让测试失败,再让其通过,最后进行重构。
魔鬼代言人技术
使用魔鬼代言人技术检查测试是否可信。
检查是否缺少行为
查看生产代码,判断待测试系统是否缺少任何行为。
TDD 有三条基本法则,遵循这些法则将帮助你更有效地进行测试驱动开发。
只编写生产代码以通过一个失败的测试
在你编写任何生产代码之前,必须有一个失败的测试。只有在一个测试失败时,才开始编写代码来解决这个问题。
只写足够的单元测试以使其失败
编写单元测试时,只需确保测试能够失败。当你编写单元测试时,不要为任何未来的实现细节做多余的准备,避免过度设计。
只编写足够的生产代码以通过一个失败的单元测试
在写生产代码时,要避免过度编写。只写足够的代码,使得测试能够通过。如果在通过一个测试时发现生产代码中缺少某个行为,应该为这个行为编写新的单元测试,而不是为其他行为提前编写代码。
假设你有一个失败的测试,并且你正在编写足够的生产代码来通过这个测试。根据 TDD 的第二条法则,你不应该编写超过必要的生产代码。你要确保只为当前的测试编写代码,而不在这个过程中尝试解决其他潜在问题。
例如,在处理预定减少座位数的测试失败时,我们并没有直接去检查超卖问题,而是专门为避免超卖编写了一个新的测试。这正是遵循第二条法则的体现。
现在,假设我们已经为 Flight
类实现了预定功能,接下来我们要为 Flight
类添加取消预定的功能。取消预定时,Flight
类需要知道要取消哪些座位,并且要知道哪个乘客取消了预定。因此,Flight
类需要接受两个参数:乘客的电子邮件和取消的座位数。
在设计 Flight
类时,我们需要特别明确在取消预定时应发生什么。一个预期的结果是,座位数应该减少。
假设航班的初始容量是 3 个座位,且已经有人预定了 1 个座位。现在,如果取消了这个预定,那么航班应该重新有 3 个座位可以预定。因此,取消预定的效果就是增加航班上可用的座位数。
根据 TDD 的第二条法则,我们在编写生产代码之前,应该先编写一个可以失败的测试。这个测试应该覆盖“取消预定”场景,确保当我们取消预定时,航班的座位数能够正确减少。
我们可以编写如下的测试:
[Test]
public void CancelsBookingSuccessfully()
{
// Arrange: 创建一个航班,假设其座位容量为3
var flight = new Flight(3);
// 预定一个座位
flight.Book("a@b.com", 1);
// Act: 取消预定
flight.CancelBooking("a@b.com", 1);
// Assert: 验证航班上可用的座位数已恢复
Assert.AreEqual(3, flight.AvailableSeats);
}
Book
方法为乘客预定了 1 个座位。CancelBooking
方法来取消预定。本章中,我们学习了 TDD 的三大法则并应用它们,编写了取消预定的测试,并设计了相应的功能。在下一章中,我们将继续深入探索更多 TDD 的技术和策略,进一步提升我们的测试能力。
在前面的演示中,我们发现了一个场景,即取消预定能够释放座位。现在我们来创建一个新的测试方法,验证这一场景的实现。
首先,我们创建一个测试方法来验证取消预定是否正确释放座位。我们将按照以下步骤编写测试:
[Fact]
public void CancellingBookingsFreesUpSeats()
{
// Given: 创建一个航班,初始时有3个座位
var flight = new Flight(3);
// 第二个步骤: 预定一个座位
flight.Book("atb.com", 1);
// 第三步: 取消预定
flight.CancelBooking("atb.com", 1);
// 然后: 确认取消预定后,剩余座位为3
flight.RemainingSeats.Should().Be(3);
}
flight
。flight.Book
方法为某个乘客预定了 1 个座位。flight.CancelBooking
方法来取消该乘客的预定。最后,我们检查航班上剩余的座位是否恢复为 3。根据 TDD 的第二条法则:“编写足够的单元测试以使其失败”,我们在这里编写了测试方法。当前编译会因为 CancelBooking
方法未定义而失败,这实际上是 TDD 的一个重要提示,我们应该先修复这个问题,再继续编写其他代码。
为了遵循第二条法则,我们修复这个编译错误。首先,我们需要为 Flight
类添加 CancelBooking
方法。你可以通过 IDE 中的“显示潜在修复”或直接按 Ctrl + .
来自动生成该方法:
public void CancelBooking(string passengerEmail, int numberOfSeats)
{
// 尚未实现
}
此时,我们已经解决了编译错误,但该方法还没有具体的实现逻辑。
根据 TDD 的第三条法则:“写不超过必要的生产代码来通过测试”,我们暂时用一个简单的逻辑来让测试通过,例如我们直接把剩余座位数设置为 3:
public void CancelBooking(string passengerEmail, int numberOfSeats)
{
RemainingSeats = 3; // 直接返回 3,作为临时解决方案
}
虽然这种做法并不符合实际的业务逻辑,但它能够通过当前的测试,作为一种“魔鬼代言人”式的临时方案,验证测试是否能够成功。
运行测试时,我们发现测试通过了,验证了 CancelBooking
方法至少在编译层面没有问题。
我们进一步优化测试,将其参数化,以便能够处理更多的情况。我们可以使用 Theory
来代替 Fact
,并通过 InlineData
提供不同的测试数据。
[Theory]
[InlineData(3, 1, 1, 3)] // 初始 3 个座位,预定 1 个座位,取消 1 个座位,剩余 3 个座位
[InlineData(4, 2, 2, 4)] // 初始 4 个座位,预定 2 个座位,取消 2 个座位,剩余 4 个座位
[InlineData(7, 5, 4, 6)] // 初始 7 个座位,预定 5 个座位,取消 4 个座位,剩余 6 个座位
public void CancellingBookingsFreesUpSeats(int initialCapacity, int seatsToBook, int seatsToCancel, int remainingSeats)
{
// Given: 创建一个航班,初始座位数为 initialCapacity
var flight = new Flight(initialCapacity);
// When: 预定 seatsToBook 个座位
flight.Book("atb.com", seatsToBook);
// When: 取消 seatsToCancel 个座位
flight.CancelBooking("atb.com", seatsToCancel);
// Then: 验证剩余座位数
flight.RemainingSeats.Should().Be(remainingSeats);
}
在这个参数化测试中,我们通过 InlineData
提供了多个测试用例,分别验证不同的初始座位数、预定座位数、取消座位数和最终的剩余座位数。
当运行第二个测试用例时,测试失败了,显示剩余座位数仍然是 3,而不是预期的 4。这是因为我们在 CancelBooking
方法中硬编码了剩余座位数为 3,导致逻辑不正确。
现在,我们需要修复这个问题,编写正确的业务逻辑。我们应该根据取消的座位数来更新剩余座位数:
public void CancelBooking(string passengerEmail, int numberOfSeats)
{
RemainingSeats += numberOfSeats; // 取消座位,恢复座位数
}
完成逻辑后,重新运行所有测试。这时,所有测试都应该成功,验证了我们按照 TDD 的法则逐步实现并验证了取消预定功能。
通过这次练习,我们遵循了 TDD 的三大法则,逐步编写了取消预定的功能,并通过测试验证了其正确性。最终,我们成功实现了取消座位的业务逻辑,并使用参数化测试覆盖了多种不同的情况。这种方式确保了我们编写的代码符合预期,且能够应对不同的业务需求。
在我们检查取消预定方法的完整性之前,我们首先回顾一下现有的测试。我们已经通过测试确保取消预定会恢复座位的可用性,接下来的任务是确保我们的 cancelBooking
方法能够处理所有相关的场景,尤其是当试图取消一个未预定航班时。
我们先来思考一个新场景:如果乘客未预定该航班,取消预定应该不成功。我们可以通过以下的测试来验证这一点:
doesn't cancel bookings for passengers who have not booked
。abc.com
)的座位。我们期望这个操作会失败并返回相应的错误。在 FlightSpecifications
中,创建一个新的测试方法:
public void doesn’t cancel bookings for passengers who have not booked()
{
// Given: 创建一个有三个座位的航班
var flight = new Flight();
// 当:尝试为未预定航班的乘客取消预定
var error = flight.CancelBooking("abc.com", 2);
// Then: 确认返回一个预定未找到的错误
error.Should().BeOfType<BookingNotFoundError>();
}
此时,我们编译时会遇到一个错误,因为 CancelBooking
方法目前没有处理这种未预定乘客的情况。因此,我们首先需要处理编译错误。
CancelBooking
方法的返回类型为可空对象,并返回 null
,这样可以解决编译错误:public object CancelBooking(string passengerEmail, int numberOfSeats)
{
return null; // 暂时返回 null
}
在测试中,我们希望当乘客未预定航班时返回一个自定义错误类型:BookingNotFoundError
。因此,我们需要在 domain
命名空间下创建这个错误类:
public class BookingNotFoundError
{
public string Message { get; set; } = "The booking could not be found.";
}
根据 TDD 的第二条法则,我们要写最少的生产代码来通过测试。所以,在 CancelBooking
方法中,我们首先返回一个 BookingNotFoundError
,以通过当前测试:
public object CancelBooking(string passengerEmail, int numberOfSeats)
{
return new BookingNotFoundError();
}
运行测试后,我们应该看到 doesn’t cancel bookings for passengers who have not booked
测试通过了。此时,代码尽管通过了测试,但实际的业务逻辑并没有实现,所以我们要继续完善它。
现在我们需要根据业务逻辑修改 CancelBooking
方法的实现。如果乘客没有预定该航班,我们应该返回 BookingNotFoundError
,否则我们返回 null
表示成功取消预定。
bookingList
是否包含指定乘客的预定。如果没有,则返回 BookingNotFoundError
。如果有,则取消预定并返回 null
。代码如下:
public object CancelBooking(string passengerEmail, int numberOfSeats)
{
if (!bookingList.Any(booking => booking.Email == passengerEmail))
{
return new BookingNotFoundError();
}
// 这里可以添加取消预定的逻辑
return null; // 成功取消预定
}
此时,我们可以重新运行所有的测试,确保所有的测试用例都通过。通过这一步,我们确认了我们的 CancelBooking
方法已经正确地处理了未预定的乘客情况,并且业务逻辑符合预期。
通过这一步骤,我们完成了以下任务:
CancelBooking
方法能够正确处理未预定乘客的取消请求。通过这些步骤,我们不仅确保了方法的正确性,还进一步提高了测试的可信度和可靠性。
Booking
方法是否完整当前我们已经通过了大部分测试,接下来的任务是进一步完善我们的代码,确保所有潜在的场景都被覆盖到。我们来回顾一下 CancelBooking
方法,看看它是否已经完全处理了所有情况。首先,我们需要考虑取消预定后,航班的 bookingList
(预定列表)应该做什么?
bookingList
?当我们取消一个预定时,预定应该从航班的 bookingList
中移除。那么,我们如何测试这一点呢?简单来说,我们可以在测试中验证:取消预定后,预定应该不再存在于 bookingList
中。
bookingList
变化我们需要编写一个新的测试来验证这一场景:当取消预定时,预定应该从航班的 bookingList
中移除。
在 FlightSpecifications
中,创建一个新的测试方法:
public void removes booking from the list after canceling()
{
// Given: 创建一个航班并预定两个座位
var flight = new Flight();
flight.Book("a@b.com", 1); // 预定1个座位
flight.Book("c@d.com", 1); // 预定另1个座位
// 当: 取消一个座位
flight.CancelBooking("a@b.com", 1);
// Then: 确认预定已从 `bookingList` 中移除
flight.BookingList.Should().NotContain(booking => booking.Email == "a@b.com");
}
除了上述测试,我们还可以考虑一些其他可能的场景:
预定两个座位,但只取消一个:
bookingList
中。预定一个座位,但取消两个:
这些场景可以通过简单的修改现有的测试代码来实现。
在分析 CancelBooking
方法时,我们不仅仅可以通过查看生产代码来发现新的场景。更重要的是,我们可以利用 “what-if” 分析法(假设分析),通过思考可能的场景来发现新的测试用例。通过将这些假设写在便签上,可以帮助我们全面考虑每种可能的情况,确保测试涵盖所有边界条件。
恭喜你完成了课程的第四章!在这一章中,你不仅学会了如何发现和定义新的测试场景,还深入了解了 TDD(测试驱动开发)中的三条基本法则,并通过实践解决了其中的挑战。
到目前为止,我们主要测试了单一的类,但在实际开发中,应用程序需要在更高层次上进行全面测试。下一章,我们将开始实现 FlightService
,并根据测试来指导我们的开发过程。
继续保持关注,我们将在下一章中开始新的挑战!
在本章中,我们将学习如何测试应用程序服务。那么,什么是应用服务呢?我们迄今为止已经学习了如何测试单个类。这个类通常是实体类,而测试实体类是非常重要的。然而,单独测试实体类并不足够。在实际的应用程序中,我们从数据库中加载实体,对其进行修改,并最终将其保存回数据库。加载和保存数据到数据库是应用层的职责,因此我们也需要测试应用层的逻辑。
应用层负责协调其他组件,通常的任务是从数据库中加载实体,调用它们的API进行修改,然后再将它们保存回数据库。如果你熟悉Web开发或ASP.Net Core,你一定知道如何通过像Entity Framework这样的ORM(对象关系映射)工具来加载和保存数据库中的数据。
在本章中,我们将学习如何实际测试应用层,以确保它能够正确地协调不同的组件。
首先,我们将为应用层和应用层的测试创建两个新的项目:
application.tests
,选择 .NET 6
,然后点击创建。完成后,创建第二个项目:
application
,然后点击创建。这时,我们已经有了两个新的项目:
接下来,我们会进入 application.tests 项目中。首先,我们修改 UnitTest1 类的名称。将类名修改为 FlightApplicationSpecifications
,并且将测试方法命名为 BooksFlightsSaving
,表示测试的业务逻辑是关于航班预定的保存。
public class FlightApplicationSpecifications
{
public void BooksFlightsSaving()
{
// 这里我们将编写关于航班预定保存的测试逻辑
}
}
应用层负责保存数据,但实际的数据对象(如航班实体)应当位于一个新的 数据库 项目中。此数据项目将包含实际的数据模型,便于应用层与数据库交互。
虽然你可能没有接触过多层架构的开发,但不要担心,随着项目的进展,你会对这种架构变得更加熟悉。在大多数企业级应用中,通常会有很多子项目,主项目会协调这些子项目进行不同的任务。我们接下来将为数据层创建一个新的类库:
data
,然后点击创建。此时,我们已经为应用程序的各个部分准备好了相应的项目:
我们已经完成了本章的准备工作,接下来,我们将开始编写应用层逻辑的测试,探索一个新的场景,并根据该场景编写测试代码。
在接下来的内容中,我们将深入学习如何通过测试驱动开发(TDD)的方法来编写和验证应用层服务的功能。
在下一章节中,我们将通过测试应用层的逻辑来进一步理解如何在实际项目中使用TDD进行开发。
我们接下来的测试场景是,当我预定一个航班时,这个航班应该包含预定记录,并且在能够预定之前,必须确保航班存在。你可能会问,这个场景不是我们之前已经覆盖过了吗?是的,之前我们确实涉及过类似的场景,但这次的场景不同,因为我们是在 服务层 进行操作。
与直接调用实体的 book
方法不同,我们现在调用的是 预定服务(booking service)的 book
方法。调用这个方法时,它会从数据库加载实体,调用实体的 book
方法,最后再将结果保存回数据库。因此,我们现在处在一个完全不同的层次上,目的是检查应用层的状态是否发生了变化。
我们需要验证以下内容:
让我们开始编写相关的测试代码。在 应用层测试项目 中,首先我们要创建一个新的 预定服务(Booking Service)。
public class BookingService
{
public void Book(BookDTO bookDTO)
{
// 方法逻辑会在后面逐步完善
}
public IEnumerable<BookingM> FindBookings()
{
// 返回预定列表的逻辑
}
}
此时,我们会遇到错误,因为 BookingService
类中的 Book
方法和 FindBookings
方法还没有实现。这是 TDD 的常见情况:编写测试之前,先要确保测试能够找出代码中缺失的部分。
为了向 Book
方法传递必要的信息,我们需要定义一个 BookDTO(预定数据传输对象),通常这个类会包含一些简单的属性,如航班信息、预定人信息等。
public class BookDTO
{
public string FlightId { get; set; }
public int NumberOfSeats { get; set; }
public string Email { get; set; }
}
此时,我们进入了 红色状态,因为 BookingService
中的 Book
方法需要一个 BookDTO
类型的参数,但我们还没有创建这个类。
创建好 BookDTO
后,继续实现 BookingService
中的 Book
方法,并修改方法签名以接受 BookDTO
参数。
public void Book(BookDTO bookDTO)
{
// 在这里将逻辑添加到Book方法
}
为了返回已预定的航班信息,我们需要定义一个 BookingM 类。这个类是我们的 读取模型(Read Model),用于表示从数据库查询并返回的预定数据。
public class BookingM
{
public string FlightId { get; set; }
public int NumberOfSeats { get; set; }
public string Email { get; set; }
}
BookingM
类的设计不同于 DTO 类。DTO 类用于数据传输,而读取模型(Read Model)则用于展示或查询数据。
接下来,我们实现 BookingService
中的 FindBookings
方法,返回一个包含所有预定记录的列表。
public IEnumerable<BookingM> FindBookings()
{
// 这里模拟查询数据库的逻辑,返回一个包含所有预定的集合
return new List<BookingM>
{
new BookingM { FlightId = "A123", NumberOfSeats = 2, Email = "test@example.com" }
};
}
在 应用层的测试类 中,使用 Fluent Assertions 来编写测试:
public class FlightApplicationSpecifications
{
[Fact]
public void Book_Flight_Should_Add_Booking_To_Flight()
{
// 创建预定服务
var bookingService = new BookingService();
// 创建预定DTO
var bookDTO = new BookDTO
{
FlightId = "A123",
NumberOfSeats = 2,
Email = "test@example.com"
};
// 调用预定服务的Book方法
bookingService.Book(bookDTO);
// 查询所有预定
var bookings = bookingService.FindBookings();
// 断言预定列表中包含刚才创建的预定
bookings.Should().ContainEquivalentOf(new BookingM
{
FlightId = "A123",
NumberOfSeats = 2,
Email = "test@example.com"
});
}
}
通过使用 Fluent Assertions,我们可以方便地验证 FindBookings
方法返回的列表中是否包含预定对象。上面的代码中的断言:
bookings.Should().ContainEquivalentOf(new BookingM
{
FlightId = "A123",
NumberOfSeats = 2,
Email = "test@example.com"
});
这一行代码将验证 FindBookings
返回的预定列表中是否包含一个与我们创建的 BookingM
对象等效的预定。
运行测试时,当前测试会失败,因为 FindBookings
方法并没有真正查询数据库,它只是返回了一个硬编码的结果。为了使测试通过,我们需要在后续的开发中实现实际的数据库查询逻辑。
BookingService
,并为其实现了 Book
和 FindBookings
方法。BookDTO
来传递数据,并使用 BookingM
作为读取模型来表示返回的数据。FindBookings
方法返回数据的验证。在下一个步骤中,我们将继续完善这个服务,处理实际的数据库操作以及其他业务逻辑。
NotImplementedException
异常当前测试失败的原因是 findBookings
方法抛出了一个 NotImplementedException
异常。为了解决这个问题,我们将方法修改为返回一个包含新 Booking
对象的数组,这样就可以模拟一个预期的返回,而不会导致异常抛出。这样可以让测试顺利进行。
Booking[]
数组,数组中包含一个新的 Booking
对象。findBookings
方法的预期返回数据。public IEnumerable<Booking> FindBookings()
{
return new Booking[] { new Booking() }; // 返回一个模拟的预定数据
}
Booking
对象问题当测试运行时,它依然会失败,因为 Booking
对象是空的,并且没有任何属性。这样 Fluent Assertions 在进行对象比较时,找不到任何可以比较的成员。为了让比较有效,我们需要为 Booking
模型添加属性。
Booking
类添加一个构造函数,并接受相关参数。PassengerEmail
和 NumberOfSeats
)。public class Booking
{
public string PassengerEmail { get; set; }
public int NumberOfSeats { get; set; }
public Booking(string passengerEmail, int numberOfSeats)
{
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
BookingDTO
类我们还需要更新 BookingDTO
类,添加构造函数和属性。这样我们就可以在测试中创建有效的实例,进行比较。
BookingDTO
添加属性,如 FlightId
、PassengerEmail
和 NumberOfSeats
。public class BookingDTO
{
public Guid FlightId { get; set; }
public string PassengerEmail { get; set; }
public int NumberOfSeats { get; set; }
public BookingDTO(Guid flightId, string passengerEmail, int numberOfSeats)
{
FlightId = flightId;
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
一旦属性和构造函数添加完成,我们就可以修改测试,创建匹配的数据,以便进行比较。Fluent Assertions 将能够基于这些属性进行对象比较。
var booking = new Booking("a@b.com", 2); // 创建一个带有特定数据的预定对象
var bookingDTO = new BookingDTO(Guid.NewGuid(), "a@b.com", 2); // 创建一个带有相似数据的 DTO
// 修改测试,检查 FindBookings 是否返回了预期数据
FindBookings().Should().ContainEquivalentOf(new Booking("a@b.com", 2));
经过必要的调整后,测试应该通过,因为:
Booking
对象。FindBookings
方法返回了有效的对象,可以与测试中的数据进行比较。在 Test Explorer
中运行测试,应该能看到测试通过。
findBookings
方法未实现,并且 Booking
模型缺少了必要的属性来进行比较。findBookings
方法返回模拟数据,以及为 Booking
和 BookingDTO
类添加属性和构造函数来解决问题。通过这个过程,我们演示了在 TDD 中写单元测试的更复杂场景,我们模拟了与数据库的交互(数据的存储和提取),并确保正确的数据在应用程序内部传递。
Entities
类首先,我们在数据层的项目中创建了一个新的 Entities
类,它将模拟数据库的行为。这个类将用于存储与业务逻辑相关的数据,比如航班和预定信息。
Entities
。Flights
)和预定(Bookings
)数据。public class Entities
{
public List<Flight> Flights { get; set; } = new List<Flight>();
public List<Booking> Bookings { get; set; } = new List<Booking>();
}
为了使应用层能够访问 Entities
类,我们需要将数据项目的引用添加到应用程序的测试项目中。通过这种方式,应用层可以使用数据层的命名空间。
using data;
来使用 Entities
类。BookingService
构造函数接下来,我们将修改 BookingService
,让它能够接受并使用 Entities
实例,表示数据源(数据库)。
BookingService
添加一个构造函数,接受 Entities
对象并将其赋值给类的一个字段。public class BookingService
{
private Entities _entities;
public BookingService(Entities entities)
{
_entities = entities;
}
// 其他方法,例如预定航班、查找航班等
}
DBContext
和 Entity Framework Core为了让 Entities
类能够与数据库交互,我们将 Entities
类变成一个 DbContext
。这意味着我们将使用 Entity Framework Core 来模拟数据库的行为。
Microsoft.EntityFrameworkCore
包,以便使用 Entity Framework Core。DbSet
属性来表示 Flights
和 Bookings
表。public class Entities : DbContext
{
public DbSet<Flight> Flights { get; set; }
public DbSet<Booking> Bookings { get; set; }
// DBContext 配置,例如连接字符串等
}
为了避免在测试中连接到真正的数据库,我们可以使用一个内存数据库。这将允许我们在运行时模拟数据库操作,而不需要实际的 SQL 数据库。
InMemoryDatabase
,这是一种在内存中创建数据库的方式,适用于单元测试。public class BookingServiceTests
{
private Entities _entities;
private BookingService _bookingService;
public BookingServiceTests()
{
var options = new DbContextOptionsBuilder<Entities>()
.UseInMemoryDatabase(databaseName: "TestDatabase")
.Options;
_entities = new Entities(options);
_bookingService = new BookingService(_entities);
}
[Fact]
public void TestBookFlight()
{
// 创建一个航班并将其添加到内存数据库
var flight = new Flight { Capacity = 3 };
_entities.Flights.Add(flight);
_entities.SaveChanges();
// 创建一个预定并进行预定操作
var booking = new Booking { FlightId = flight.Id, PassengerEmail = "a@b.com", NumberOfSeats = 2 };
_bookingService.Book(booking);
// 查找并验证预定是否成功
var result = _bookingService.FindBookings(flight.Id);
Assert.Contains(result, b => b.PassengerEmail == "a@b.com");
}
}
Flight
类为了将航班与预定进行关联,我们在 Flight
类中添加了 FlightId
属性,这样每个航班就有了一个唯一标识符。
Flight
类中添加 FlightId
属性。public class Flight
{
public Guid FlightId { get; set; } = Guid.NewGuid();
public int Capacity { get; set; }
}
我们接着将逻辑添加到 BookingService
中,以便可以预定航班并在内存数据库中查找预定。
BookingService
中实现预定航班(Book
)和查找航班(FindBookings
)的功能。public class BookingService
{
private Entities _entities;
public BookingService(Entities entities)
{
_entities = entities;
}
public void Book(Booking booking)
{
_entities.Bookings.Add(booking);
_entities.SaveChanges();
}
public IEnumerable<Booking> FindBookings(Guid flightId)
{
return _entities.Bookings.Where(b => b.FlightId == flightId);
}
}
Entities
类,其中包含 Flights
和 Bookings
表(使用 DbSet
)。DbContext
和 Entity Framework Core,我们可以模拟数据库的交互。InMemoryDatabase
,我们可以避免在测试中使用真实数据库,这对单元测试至关重要。这些步骤展示了如何在测试中使用内存数据库,并通过模拟真实的数据库操作来测试应用程序逻辑,而不需要依赖外部数据库。在这个过程中,我们保持了 TDD(测试驱动开发)的原则,先编写测试,解决编译错误,最后实现功能。
Entity Framework Core In-Memory
数据库提供程序在进行测试之前,我们需要确保应用程序配置了数据库提供程序。此时,我们遇到的错误是“没有为 DbContext
配置数据库提供程序”。为了修复这个问题,我们需要安装一个内存数据库提供程序,这样我们可以模拟一个数据库,而不需要使用真实的数据库。
Entity Framework Core In-Memory
。这将允许我们在内存中模拟一个数据库,而不需要实际的 SQL 数据库。
DbContext
和内存数据库在应用程序中,我们需要创建一个 DbContext
实例,并配置它使用内存数据库。我们将通过 DbContextOptionsBuilder
来配置 Entities
类,使其使用内存数据库。
Entities
类,以便它能够使用内存数据库。DbContextOptionsBuilder
并指定一个内存数据库的名称。var options = new DbContextOptionsBuilder<Entities>()
.UseInMemoryDatabase(databaseName: "Flights")
.Options;
var entities = new Entities(options);
Entities
类的构造函数为了使 Entities
类能够接收并使用 DbContextOptions
,我们需要添加一个构造函数。这个构造函数将调用 DbContext
的基类构造函数。
Entities
类添加一个接受 DbContextOptions
的构造函数。public class Entities : DbContext
{
public Entities(DbContextOptions<Entities> options) : base(options) { }
public DbSet<Flight> Flights { get; set; }
public DbSet<Booking> Bookings { get; set; }
}
Flight
类的无参构造函数为了让 Entity Framework 正确处理 Flight
类的实例,我们需要为 Flight
类提供一个无参构造函数。Entity Framework 需要能够创建 Flight
类的实例,才能将数据映射到数据库中。
Flight
类添加一个无参数的构造函数,并标记为 Obsolete
,这是 Entity Framework 需要的标记。public class Flight
{
public Guid Id { get; set; }
public int Capacity { get; set; }
// Entity Framework requires an empty constructor
[Obsolete("Required for EF")]
public Flight() { }
}
OnModelCreating
方法为了定义 Flight
类和数据库之间的映射关系,我们需要重写 DbContext
类中的 OnModelCreating
方法。在这个方法中,我们可以设置实体的映射规则,例如将 Flight
类的 ID
属性设置为主键。
Entities
类中,重写 OnModelCreating
方法,并通过 modelBuilder
映射 Flight
类。protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Flight>(entity =>
{
entity.HasKey(f => f.Id); // 设置 ID 为主键
});
}
由于一个 Flight
可以有多个 Booking
,我们需要在 OnModelCreating
方法中配置这种一对多的关系。我们可以使用 modelBuilder
来定义这个关系。
Entities
类的 OnModelCreating
方法中,配置 Flight
和 Booking
之间的关系。protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Flight>(entity =>
{
entity.HasKey(f => f.Id);
entity.OwnsMany(f => f.BookingList); // 定义 Flight 与 Booking 之间的一对多关系
});
}
一旦我们完成了这些更改,就可以回到测试中运行我们的代码。现在,我们配置了内存数据库,并确保 Flight
和 Booking
正确映射到数据库中。
FlightApplicationSpecifications
并运行测试。此时,测试应该会通过,因为我们已经配置了数据库。[Fact]
public void TestBookFlight()
{
// 创建一个新的航班并将其添加到内存数据库
var flight = new Flight { Capacity = 3 };
_entities.Flights.Add(flight);
_entities.SaveChanges();
// 创建并进行航班预定
var booking = new Booking { FlightId = flight.Id, PassengerEmail = "abc@xyz.com", NumberOfSeats = 2 };
_bookingService.Book(booking);
// 查找并验证是否成功预定
var result = _bookingService.FindBookings(flight.Id);
Assert.Contains(result, b => b.PassengerEmail == "abc@xyz.com");
}
为了提高测试的可靠性和可重复性,我们可以将测试参数化。通过不同的测试数据,我们可以验证预定是否正确工作。
BooksFlight
测试,以确保测试的可靠性。[Theory]
[InlineData(3, "abc@xyz.com", 2)]
[InlineData(1, "def@xyz.com", 1)]
public void TestBookFlight(int capacity, string email, int seats)
{
var flight = new Flight { Capacity = capacity };
_entities.Flights.Add(flight);
_entities.SaveChanges();
var booking = new Booking { FlightId = flight.Id, PassengerEmail = email, NumberOfSeats = seats };
_bookingService.Book(booking);
var result = _bookingService.FindBookings(flight.Id);
Assert.Contains(result, b => b.PassengerEmail == email);
}
Entity Framework Core In-Memory
提供程序,模拟一个内存数据库来进行测试。DbContextOptionsBuilder
和 OnModelCreating
方法,我们为实体类(如 Flight
和 Booking
)配置了映射关系。这些步骤使我们能够通过模拟的数据库来验证应用程序的业务逻辑,而不依赖于外部的数据库。我们遵循了 TDD(测试驱动开发)的原则,逐步修复编译错误,最终实现了功能。
BooksFlights
测试我们已经对 BooksFlights
测试进行了参数化,以便使用不同的输入数据进行测试。现在,我们将使用 InlineData
来提供测试数据,使得测试可以通过不同的电子邮件和座位数量进行验证。
[Fact]
改为 [Theory]
,然后使用 [InlineData]
为测试方法提供不同的参数。[Theory]
[InlineData("m@m.com", 2)]
[InlineData("a@a.com", 1)]
public void TestBookFlight(string passengerEmail, int numberOfSeats)
{
// 创建新的航班并将其添加到内存数据库
var flight = new Flight { Capacity = 3 };
_entities.Flights.Add(flight);
_entities.SaveChanges();
// 创建预定对象并进行预定
var booking = new Booking { FlightId = flight.Id, PassengerEmail = passengerEmail, NumberOfSeats = numberOfSeats };
_bookingService.Book(booking);
// 查找并验证是否成功预定
var result = _bookingService.FindBookings(flight.Id);
Assert.Contains(result, b => b.PassengerEmail == passengerEmail);
}
BooksFlight
方法使用参数在测试方法中,我们现在使用传递的参数 passengerEmail
和 numberOfSeats
替代了硬编码的值。这样可以确保测试是基于不同的输入数据进行的,而不是固定的值。
BooksFlight
方法中使用这些参数替代原有的硬编码值。public void TestBookFlight(string passengerEmail, int numberOfSeats)
{
var flight = new Flight { Capacity = 3 };
_entities.Flights.Add(flight);
_entities.SaveChanges();
// 创建预定对象时使用传递的参数
var booking = new Booking { FlightId = flight.Id, PassengerEmail = passengerEmail, NumberOfSeats = numberOfSeats };
_bookingService.Book(booking);
// 查找并验证是否成功预定
var result = _bookingService.FindBookings(flight.Id);
Assert.Contains(result, b => b.PassengerEmail == passengerEmail);
}
当我们运行测试时,测试失败是正常的,因为当前的 FlightBooking
实现始终返回相同的值。比如,我们可能只返回了一个硬编码的电子邮件(例如 "abc.com"
),而我们实际传入的电子邮件是 "m@m.com"
或 "a@a.com"
。
m@m.com
),但实际返回的是 abc.com
。Expected value: m@m.com
Actual value: abc.com
这意味着我们的 FlightBooking
方法没有正确处理每个预定,而是总是返回相同的预定。我们需要在 Book
方法中进行改进,确保每次预定时返回正确的电子邮件地址。
FlightBooking
方法当前的实现总是返回一个相同的预定数据,而不是根据不同的输入创建新的预定。我们需要确保每次预定时都会返回正确的乘客电子邮件和座位数量。
Book
方法中,确保每次都创建新的预定,并保存到数据库中。public void Book(Booking booking)
{
// 确保每次预定都被正确保存到数据库
_entities.Bookings.Add(booking);
_entities.SaveChanges();
}
修复完 Book
方法后,我们可以重新运行测试。现在,我们传入的每个电子邮件地址和座位数应该能够正确匹配数据库中的预定数据。
InlineData
我们让测试方法支持多组数据输入,从而提高了测试的覆盖面。FlightBooking
方法,确保每个预定都返回正确的电子邮件和座位数量。通过这种方式,我们不仅改进了测试的可维护性,还确保了代码逻辑能够处理不同的输入情况。这是 TDD 中常见的一个实践,我们通过不断修改代码来使其通过所有的测试。
FindBookings
方法我们现在要在 BookingService
中配置 FindBookings
方法,使其不仅返回一个硬编码的 imdb.com
,而是根据给定的 flightID
查找数据库中的航班和预定信息。
Book
方法,确保我们通过数据库找到一个航班并更新其相关数据。BookingService
中,使用 flightID
查找航班,并处理预定操作。public void Book(BookingDTO bookDTO)
{
// 查找航班并更新其数据
var flight = _entities.Flights.Find(bookDTO.FlightID);
if (flight != null)
{
// 为该航班创建预定
var booking = new Booking
{
FlightId = flight.Id,
PassengerEmail = bookDTO.PassengerEmail,
NumberOfSeats = bookDTO.NumberOfSeats
};
// 将预定添加到数据库
_entities.Bookings.Add(booking);
_entities.SaveChanges();
}
}
FlightID
属性到 BookingDTO
为了使 Book
方法能够处理传入的 flightID
,我们需要确保 BookingDTO
中包含 FlightID
属性。
BookingDTO
中添加 FlightID
属性,并确保它可以在构造函数中进行初始化。public class BookingDTO
{
public int FlightID { get; set; }
public string PassengerEmail { get; set; }
public int NumberOfSeats { get; set; }
public BookingDTO(int flightID, string passengerEmail, int numberOfSeats)
{
FlightID = flightID;
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
我们现在需要在 BookingService
中修改 FindBookings
方法,确保它查询数据库中的相关航班,并返回该航班的所有预定信息。
public List<BookingRemodel> FindBookings(int flightID)
{
// 从数据库中查找指定 ID 的航班
var flight = _entities.Flights.FirstOrDefault(f => f.Id == flightID);
if (flight != null)
{
// 从航班的预定列表中获取相关的预定
var bookings = flight.BookingList.Select(b => new BookingRemodel
{
PassengerEmail = b.PassengerEmail,
NumberOfSeats = b.NumberOfSeats
}).ToList();
return bookings;
}
return new List<BookingRemodel>(); // 如果没有找到航班,返回空列表
}
在查询数据库时,我们使用了 LINQ(语言集成查询)来从航班的预定列表中选取数据,并将其转换为 BookingRemodel
类型。BookingRemodel
类用于封装预定信息,并仅包含我们需要的字段(例如 PassengerEmail
和 NumberOfSeats
)。
Select
方法来选择并返回数据库中每个预定的邮箱和座位数。using System.Linq
。using System.Linq;
entities
对象为 null
的问题在测试过程中,如果 entities
对象为 null
,我们需要确保正确实例化它,以便数据库操作能够正常进行。
entities
。public BookingService(DbContext entities)
{
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
}
最后,我们运行之前的 BooksFlights
测试,确认是否能够成功执行查询并返回正确的预定数据。现在,BookingService
已经配置为实际查询数据库,并根据 flightID
返回正确的预定信息。
FindBookings
方法:我们通过查询数据库并返回预定列表,成功实现了 FindBookings
方法。使用 LINQ 查询和实体框架,我们可以方便地从数据库中获取数据并进行转换。entities
为 null
的问题:确保在 BookingService
中正确初始化 entities
,避免 null
引用错误。这个过程展示了如何使用 TDD 开发一个涉及数据库操作的功能,并且如何通过不断的重构和调试,最终实现一个稳定且有效的服务。
现在,我们已经完成了初步的开发,并且现在是时候对代码进行重构了。我们有一个单一的 C# 文件,其中包含了多个类,为了使代码更加模块化、可维护,我们将把这些类移到不同的文件和命名空间中。
BookingService
类的文件,并将其移动到一个新的命名空间 Application
中。namespace Application
{
public class BookingService
{
// 这里是 BookingService 类的实现
}
}
为了使代码更加清晰,我们将 DTO(数据传输对象)类也移到应用层,并确保它们在正确的命名空间中。
Application
文件夹中创建两个新类文件:
BookingDTO.cs
BookingRemodel.cs
BookingDTO
和 BookingRemodel
类复制到相应的新文件中。BookingDTO.cs
namespace Application
{
public class BookingDTO
{
public int FlightID { get; set; }
public string PassengerEmail { get; set; }
public int NumberOfSeats { get; set; }
public BookingDTO(int flightID, string passengerEmail, int numberOfSeats)
{
FlightID = flightID;
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
}
BookingRemodel.cs
namespace Application
{
public class BookingRemodel
{
public string PassengerEmail { get; set; }
public int NumberOfSeats { get; set; }
public BookingRemodel(string passengerEmail, int numberOfSeats)
{
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
}
在文件结构变更后,我们需要确保所有相关类之间的引用已经正确更新。如果遇到缺少引用的错误,可以通过以下方式解决:
Application.Tests
项目,选择 "添加项目引用",并选择 Application
项目作为引用。FlightSpecification
和其他需要引用的地方,正确导入了所需的命名空间。using Application; // 确保引用了 Application 命名空间
重构和修改代码结构后,最重要的一步是确保所有功能依然正确工作。为此,我们可以运行所有的单元测试,确保项目没有出现任何回归错误。
如果测试结果显示一切正常且没有错误,则说明我们成功地完成了重构。此时代码更加清晰、模块化,便于维护和扩展。
BookingService
类移动到了 Application
命名空间,并将 BookingDTO
和 BookingRemodel
类分别放入各自的文件中。这些变化使得代码更加整洁、模块化。这就是如何在保持应用功能完整的情况下,进行结构重构和模块化的过程。
在 Flight Application 规格中,我们将添加一个新的测试方法用于测试取消预定功能。我们已经测试了 BookFlights
方法,现在我们将创建一个新的测试方法 CancelsBooking
。
CancelsBooking
,并使用 Given/When/Then 模式进行测试。BookingService
的新方法 CancelBooking
。[Fact]
public void CancelsBooking()
{
// Given
var entities = new Entities();
entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
// When
var bookingService = new BookingService(entities);
var cancelBookingDTO = new CancelBookingDTO
{
FlightId = new Good().FlightID,
PassengerEmail = "m@m.com",
NumberOfSeats = 2
};
bookingService.CancelBooking(cancelBookingDTO);
// Then
bookingService.GetRemainingNumberOfSeats(cancelBookingDTO.FlightId).Should().Be(3);
}
CancelBookingDTO
类为了支持取消预定的功能,我们需要创建一个 CancelBookingDTO
类,用于传递取消预定所需的信息,例如航班 ID、乘客邮箱以及座位数。
CancelBookingDTO
类,并提供适当的构造函数。public class CancelBookingDTO
{
public int FlightId { get; set; }
public string PassengerEmail { get; set; }
public int NumberOfSeats { get; set; }
public CancelBookingDTO(int flightId, string passengerEmail, int numberOfSeats)
{
FlightId = flightId;
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
然后我们将 CancelBookingDTO
类移动到 Application
项目中,创建一个新的 C# 文件来保存这个类。
Application
项目中创建 CancelBookingDTO.cs
文件。BookingService
以支持取消预定在 BookingService
中,我们需要添加一个新的方法 CancelBooking
,该方法接收一个 CancelBookingDTO
对象并执行取消操作。
BookingService
中实现 CancelBooking
方法。public void CancelBooking(CancelBookingDTO cancelBookingDTO)
{
// 实现取消预定的逻辑
}
为了验证取消预定是否成功,我们需要在 BookingService
中添加一个方法 GetRemainingNumberOfSeats
,该方法接收一个航班 ID,返回剩余的座位数。
BookingService
中实现 GetRemainingNumberOfSeats
方法。public int GetRemainingNumberOfSeats(int flightId)
{
// 返回航班剩余座位数的逻辑
}
为了确保取消预定测试的有效性,我们需要做一些数据初始化工作:
Given
部分,创建一个航班并添加到数据库中。When
部分,使用 BookingService
进行航班预定。Then
部分,验证取消预定后剩余座位数是否正确。// Given
var entities = new Entities();
entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
entities.Flights.Add(new Flight { FlightId = 1, SeatsAvailable = 3 });
// When
var bookingService = new BookingService(entities);
bookingService.Book(new BookDTO(1, "m@m.com", 2));
var cancelBookingDTO = new CancelBookingDTO(1, "m@m.com", 2);
bookingService.CancelBooking(cancelBookingDTO);
// Then
bookingService.GetRemainingNumberOfSeats(1).Should().Be(3);
为了避免在多个测试方法中重复相同的数据库初始化和 BookingService
创建逻辑,我们可以将这些通用操作提取到共享的代码中。使用只读字段来存储 Entities
和 BookingService
实例,并通过构造函数进行初始化。
BookingService
创建到共享的只读字段中。readonly Entities _entities = new Entities();
readonly BookingService _bookingService;
public FlightTests()
{
_entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
_bookingService = new BookingService(_entities);
}
然后,在每个测试方法中,直接使用这些共享的字段,避免重复初始化代码。
CancelsBooking
,使用了 Given/When/Then 模式进行测试。CancelBookingDTO
类。BookingService
:我们在 BookingService
中添加了 CancelBooking
和 GetRemainingNumberOfSeats
方法。通过这些步骤,我们为应用添加了取消预定的功能,并确保了它的正确性。
在 GetRemainingNumberOfSeats
方法中,我们模拟返回 3 来让测试通过。虽然这种做法暂时使得测试通过,但它并未实现真正的逻辑。为了继续开发,我们需要实现真正的取消预定逻辑。
GetRemainingNumberOfSeats
中返回固定值 3
。CancelBooking
方法中的未实现异常 throw new NotImplementedException()
。public int GetRemainingNumberOfSeats(int flightId)
{
return 3; // 固定返回 3
}
public void CancelBooking(CancelBookingDTO cancelBookingDTO)
{
throw new NotImplementedException();
}
在测试中,我们能够看到,尽管没有实现取消预定的功能,但测试仍然通过。这是因为返回了一个固定值,符合期望结果。
为了提高测试的可靠性,我们将使用参数化测试,确保测试覆盖更多场景。通过将 Theory
属性和 InlineData
一起使用,我们可以为不同的初始座位容量提供多种测试数据。
Fact
改为 Theory
,并使用 InlineData
提供初始座位容量。Given
步骤中,创建具有不同初始容量的航班,并确保在 Then
步骤中检查最终剩余座位数。[Theory]
[InlineData(3)]
[InlineData(10)]
public void CancelsBooking(int initialCapacity)
{
// Given
var entities = new Entities();
entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
entities.Flights.Add(new Flight { FlightId = 1, SeatsAvailable = initialCapacity });
// When
var bookingService = new BookingService(entities);
bookingService.Book(new BookDTO(1, "m@m.com", 2));
var cancelBookingDTO = new CancelBookingDTO(1, "m@m.com", 2);
bookingService.CancelBooking(cancelBookingDTO);
// Then
bookingService.GetRemainingNumberOfSeats(1).Should().Be(initialCapacity);
}
我们可以通过改变 InlineData
的值来验证不同初始容量下的行为:
initialCapacity
为 3 时,预定了两个座位后取消,剩余座位应该为 3。initialCapacity
为 10 时,预定两个座位后取消,剩余座位应该为 10。运行测试后,initialCapacity
为 10 的测试将会失败,因为我们尚未实现真正的取消逻辑。
接下来,我们将为 CancelBooking
方法实现取消预定的功能。在这个方法中,我们将根据 CancelBookingDTO
查找航班,并更新其剩余座位数。更新后,我们需要保存数据库的更改。
CancelBooking
方法中查找航班并更新其座位数。entities.SaveChanges()
来保存更改。public void CancelBooking(CancelBookingDTO cancelBookingDTO)
{
// 查找航班
var flight = entities.Flights.Find(cancelBookingDTO.FlightId);
if (flight != null)
{
// 取消预定,恢复座位数
flight.SeatsAvailable += cancelBookingDTO.NumberOfSeats;
// 保存更改
entities.SaveChanges();
}
}
GetRemainingNumberOfSeats
方法我们将修改 GetRemainingNumberOfSeats
方法,不再返回固定值,而是根据航班 ID 查找实际剩余座位数。
GetRemainingNumberOfSeats
中,根据航班 ID 查找并返回剩余座位数。public int GetRemainingNumberOfSeats(int flightId)
{
var flight = entities.Flights.Find(flightId);
return flight?.SeatsAvailable ?? 0; // 返回座位数,若找不到航班则返回 0
}
CancelBookingDTO
类为了确保我们能够传递必要的信息,我们需要确保 CancelBookingDTO
类的属性设置正确。我们将为 flightId
、passengerEmail
和 numberOfSeats
提供合适的构造函数和属性。
CancelBookingDTO
中添加属性,并确保通过构造函数初始化这些属性。public class CancelBookingDTO
{
public int FlightId { get; }
public string PassengerEmail { get; }
public int NumberOfSeats { get; }
public CancelBookingDTO(int flightId, string passengerEmail, int numberOfSeats)
{
FlightId = flightId;
PassengerEmail = passengerEmail;
NumberOfSeats = numberOfSeats;
}
}
完成所有修改后,重新运行测试。现在我们已经实现了 CancelBooking
方法,并确保其与实际数据库交互。测试应该通过:
initialCapacity
为 3 时,座位数恢复到 3。initialCapacity
为 10 时,座位数恢复到 10。为了避免在多个测试方法中重复初始化数据库和 BookingService
,我们将这些公共代码提取到共享的字段中。这样,每个测试方法可以直接使用这些字段,而不需要每次都初始化它们。
Entities
和 BookingService
初始化逻辑到构造函数中。readonly Entities _entities = new Entities();
readonly BookingService _bookingService;
public FlightTests()
{
_entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
_bookingService = new BookingService(_entities);
}
现在,每个测试方法可以直接使用 _entities
和 _bookingService
,而不需要重复初始化代码。
CancelBooking
方法根据实际情况修改座位数并保存更改。GetRemainingNumberOfSeats
方法,使其返回实际的剩余座位数,而不是固定值。通过这些步骤,我们成功实现了一个可用的航班预定和取消系统,并通过 TDD 确保其功能的正确性。
在命名测试方法时,最重要的是确保名称清晰地表达了测试的行为,而不是死板地遵守命名规则。虽然存在一些常见的命名约定,如 GWT(Given-When-Then)模式,但我更倾向于关注测试方法名称的清晰度和简洁性。接下来,我们将讨论如何根据 GWT 模式调整现有测试方法的名称,并提供更具可读性的示例。
GWT(Given-When-Then)模式是一种常见的命名约定,它有助于明确表示测试的各个步骤。每个部分都用简短的描述来表示:
例如,现有的 books flights
测试方法可以根据 GWT 模式进行重命名:
public void booksFlights() { ... }
根据 GWT 模式,方法名应改为:
public void givenAFlight_whenIBookTheFlight_thenTheFlightShouldContainMyBooking() { ... }
这个命名方法很详细,但显然非常冗长,尤其是在测试方法数量增多时,长长的名称会影响代码的可读性。
为了提高代码的可读性,我们需要创造性地改进方法名称,确保简洁且依然能够清晰表达行为。可以对命名进行优化,使得方法名既简短又直观。
public void remembersBookings() { ... }
通过这样的命名,我们保留了行为的清晰度,同时让方法名变得更加简洁易懂。
对于取消预定的测试方法,使用 GWT 模式也同样可以进行调整。在这个测试中,我们实际上不是测试“取消”操作本身,而是测试“取消后座位是否被释放”。因此,方法名应更好地描述测试的目标——是否能释放座位。
public void cancelBooking() { ... }
根据 GWT 模式,方法名可能是:
public void givenABookedFlight_whenICancelTheBooking_thenSeatsShouldBeFreedUp() { ... }
public void freesUpSeatsAfterBooking() { ... }
命名测试方法时,关键是要确保名称:
虽然 GWT 模式是一种有效的命名方式,但我们可以根据具体情况进行调整,使得方法名称既简洁又准确,方便后续维护和阅读。
在测试驱动开发(TDD)中,测试不仅仅是用来验证代码是否按预期工作,还能作为项目的文档。通过测试,任何开发人员都可以清楚地了解一个方法的行为和预期效果,无需查看源代码本身。这些测试方法就像是应用程序的“活文档”,它们会随着项目的变化而自动更新,始终与代码保持同步。
当你打开 Test Explorer 时,可以快速看到所有的测试方法及其目标。在这里,测试方法清楚地描述了每个功能的行为和期望结果,因此,测试本身可以充当文档来帮助你理解系统的功能。例如,测试方法名称通常直接表达了测试的目的,且方法内的代码展示了如何实现这些行为。
测试方法清晰地展示了应用程序的行为,并且通过测试验证了它是否真的做到了它所声称的内容。例如,你可以在测试中看到:
通过这些测试用例,开发人员可以一目了然地看到系统当前的行为和功能实现。
通过测试,我们不仅能够验证功能,还能确保功能的正确性,且这些测试本身就构成了项目文档。每当项目发生变化时,测试方法都会更新,从而确保文档始终是最新的。
举个例子,假设你查看了一个叫做 flight specifications 的测试类,你会看到以下几个方面的测试:
这种做法不仅验证了功能,还直接提供了功能的描述,让其他开发者可以通过阅读这些测试来理解项目的业务逻辑。
测试套件的最大优势之一是它可以成为其他开发人员快速了解项目的途径。假设新的开发人员加入到你的项目中,他们可以通过快速浏览测试文件,理解系统的核心功能和行为,而无需深入查阅每一行代码。
例如,在 application test 中,你可以看到以下几个测试方法:
通过这些测试,任何新加入的开发人员都可以清晰地知道系统是如何管理预定和座位的。这种文档化的方式使得项目更加透明,团队协作更加高效。
总的来说,测试驱动开发不仅帮助你验证代码的正确性,而且能够使测试代码成为“活文档”,这一点对整个团队非常有价值。通过测试,开发人员不仅能够理解代码如何实现业务需求,还能够确保这些需求在代码变更时得到保持。因此,测试是项目中不可忽视的重要组成部分,它同时担任了验证工具和文档的角色,提升了项目的可维护性和可扩展性。
在进行测试时,访问真实的资源(如数据库)通常会使测试代码变得复杂,并且会增加测试执行的时间。每次执行测试时都需要连接到数据库并进行数据操作,既费时又容易导致不必要的依赖关系。因此,为了提高测试效率并避免复杂的数据库设置,我们可以选择使用内存数据库进行测试。
为了提高软件的可维护性和可测试性,通常会将应用服务与具体的I/O技术(如MVC、Web API、WPF等)分离。这样做的好处在于:
通过将内存数据库与TDD(测试驱动开发)结合使用,你可以在不依赖真实数据库的情况下,测试应用程序的业务逻辑。TDD方法强调先写测试,再实现功能,从而保证代码的质量和功能的正确性。
恭喜你!现在你已经掌握了如何使用内存数据库来测试软件的应用服务,并结合TDD方法进行实际开发。这不仅能提升代码的可测试性、可维护性,还能确保系统能够正确实现预期的业务逻辑,同时避免了真实数据库带来的复杂性和性能问题。
在本章中,您将学习 Unity 的基础知识。首先,我想说几句关于接下来内容的事情。接下来的两章内容最初并不是这门课程的一部分,而是我 Unity 课程中的内容。所以我为您精心准备了一些额外的内容,作为一个额外的奖励,提供给您更多有趣的小项目,帮助您在编程方面取得更好的进展。
Unity 是一个游戏开发引擎,它使用 C# 作为核心编程语言。因此,在接下来的章节中,我们将编写大量的代码,使用我们在之前章节中学到的所有知识来制作游戏。如果您对游戏开发不感兴趣,当然也可以跳过这些内容,继续做自己的项目,因为您已经有能力开始自己的创作了。
但如果您希望从不同的角度更深入地学习 C#,并看到它在游戏开发中的应用,这将有助于您成为一个更好的程序员,并且可以帮助您更好地理解到目前为止所学的一切。因为我们将以一种不同的视角去看待它,这种不同的视角能帮助您更清晰地理解之前学到的知识。
在这一章中,我们将介绍 Unity 的基本概念,这些基础知识将帮助您创建自己的游戏。当然,我们还会一起开发一些游戏。如果您想制作更多的游戏,您可以随时访问我的 YouTube 频道,我有一个频道叫做 Tutorials EU,网址也是 tutorials.eu,在这里您可以找到更多的项目、更多的游戏开发内容,以及一些特别的项目。如果您想去那里学习更多内容,帮助您成为一个更好的开发者,那就赶紧去看看吧。
但请注意,这一部分内容和之前的 C# 课程有所不同。由于这一部分并非最初课程的一部分,因此在风格和方向上会有所不同。过渡可能不会那么平滑,所以请您做好准备。
非常感谢您坚持到现在。我很高兴看到您仍然跟随到这个阶段,您可能是少数(大约 5% 或 10%)能够坚持到这里的人。做得好!期待在下一节视频中与您见面。
在本视频中,我们将一起了解 Unity 的用户界面。首先,打开 Unity,然后点击 新建 按钮创建一个新项目。接着为项目命名,我将其命名为 Unity Basics,并选择 3D 作为项目类型。如果我们已经有一些想要添加的资源包,比如图形对象或已有的功能代码,可以在这里添加它们。接着设置保存路径,选择一个文件夹来存储你的项目。你还可以启用 Unity Analytics,它会收集项目数据,并提供与类似项目的对比基准以及玩家行为的分析。然而,考虑到我们当前并不需要发布游戏,所以我们暂时不启用它。
接下来,点击 创建 按钮,等待几秒钟,Unity 将会启动。启动后,您会看到一个包含多个区域和屏幕部件的界面。接下来,我们将逐一介绍这些界面部分,帮助你更好地理解它们。
工具栏(Toolbar):
在界面顶部是工具栏,它让我们能够改变与场景交互的方式。比如:
这些按钮都属于 Transform 工具,用于改变游戏对象的变换组件。关于这些变换组件的内容,我们会在之后的课程中详细讲解。
层级视图(Hierarchy View):
层级视图位于左侧,显示当前场景中的所有游戏对象(即您的游戏或关卡中的元素)。例如,我们可以在这里看到当前场景中的所有元素,包括 Main Camera(主摄像机)、Directional Light(方向光) 和其他对象。
场景视图(Scene View):
场景视图展示了开发者视角下的游戏场景,你可以在这里看到并编辑所有的游戏对象。比如,我们可以在这里快速创建一个立方体,并且看到它出现在层级视图中。场景视图也允许你使用 WASD 键或箭头键移动视角,并通过按住右键来旋转视角。
游戏视图(Game View):
游戏视图展示了玩家在游戏中看到的内容,实际上是场景视图的运行时版本。在这里,你可以看到游戏的实时渲染效果。比如,如果我们点击 Play(播放) 按钮,游戏视图将显示正在运行的游戏画面。
检查器视图(Inspector View):
检查器视图位于右侧,展示了所选游戏对象的详细信息。例如,主摄像机会有一个 Camera 组件,而我们创建的立方体则拥有 Transform 组件、Cube 组件、Box Collider 组件 和 Mesh Renderer 组件 等信息。每个游戏对象的检查器视图显示了它的各类组件,并允许我们对这些组件进行编辑。
资产商店(Asset Store):
在屏幕中央,您可以访问 Unity 的资产商店。这里提供了各种可以免费或付费的游戏资源,包括图形、音效、模型等。如果你需要特定的图形或功能,可以在这里找到并导入到项目中。
项目视图(Project View):
项目视图位于屏幕底部,显示了项目中所有的资源和文件。所有的资源(例如脚本、模型、纹理等)都会在项目视图中列出,并且它们在硬盘上的物理存储位置也可以通过右键点击 Reveal in Finder 来查看。
控制台视图(Console View):
控制台视图用于显示与代码相关的调试信息。例如,我们可以在这里查看代码的执行状态,查看输出的错误信息或日志。这对于开发和调试是非常重要的。
窗口(Window):
如果您误关闭了某些窗口,您可以通过 Window 菜单重新打开它们。例如,如果您关闭了 Game 视图,可以通过 Window > Game 来重新打开。
变换 Gizmo 开关(Transform Gizmo Toggle):
这些按钮帮助我们调整游戏对象的变换工具,如位置、旋转和缩放的显示方式。通过这些按钮,我们可以选择对象的全局位置或局部位置。
播放、暂停、逐帧按钮:
这些组成部分让我们可以有效地管理和开发游戏项目。但这只是基本界面,我们将会在后续的视频中介绍如何自定义界面,调整它以便更好地适应你的开发需求。
在下一个视频中,我们将继续深入了解如何自定义 Unity 的界面。感谢您的收看,我们下次见!
在本视频中,我们将学习如何更改 Unity 的界面布局。你可以通过调整布局来根据需要优化你的开发环境。方法很简单,首先在界面的右上角,你会看到一个按钮,默认情况下会显示为 Default。点击这个按钮,你会看到多种布局选项。
2x3 布局:
在这种布局中,场景视图 和 游戏视图 位于左侧,并且是上下排列的。右侧分别显示 层级视图、项目视图 和 检查器视图。
默认布局(Default):
这是我们目前使用的布局,界面中所有视图都是按常规方式分隔的,你可以看到每个面板的位置。
四分视图布局(Four Split View):
这个布局将场景分为四个视角,你可以从不同的角度查看场景。比如:
高视图布局(Tall View):
这个布局让场景视图占据更大的空间,而 层级视图 和 项目视图 则位于场景视图上方。
宽视图布局(Wide View):
这种布局则使场景视图更宽,其他视图(如检查器视图等)则相对较小。
你可以根据自己的工作习惯创建并保存自定义的布局。比如,我创建了一个叫做 DPI Layout 的布局,它的结构是:
如果你想尝试自定义布局,可以按照以下步骤操作:
你还可以通过 窗口 菜单添加更多的视图。例如,你可能需要一直打开 动画编辑器(Animator),在这种情况下,你可以把它作为一个单独的窗口添加并固定在界面上。同样,如果你希望随时访问 资产商店,也可以把它添加到界面中。
不过请注意,某些窗口可能会占用较多的系统资源,所以在设计布局时需要考虑到性能。
你可以随时创建、保存并调整你自己的布局,确保你的开发环境符合个人习惯。随着你使用 Unity 的深入,你会逐步了解哪种布局最适合自己的开发工作流。
感谢你的收看,我们下个视频再见!
在本视频中,我们将学习如何让一个物体在游戏中移动,并且这个物体会由玩家控制。你将学会如何使用用户输入以及基本的物理学。我们将会深入探讨物理学的内容,但这将在接下来的几期视频中详细讲解。
删除现有的立方体
首先,我们删除现有的立方体对象。接着,我们将创建两个新的 3D 对象:
添加平面和球体
在 层级视图 中右键点击,选择 3D 对象,然后选择 平面(Plane)。你会看到平面的位置为 (0, 0, 0),即 X、Y、Z 坐标都为零。
你可以通过 变换组件(Transform) 来调整物体的位置。例如,改变 Y 轴的位置来将球体移到平面上方。你可以手动输入数值,或者通过拖动 Y 轴的滑块来调整。
调整球体位置
我们把球体的位置调整为 (0, 0.37, 0),确保它不与地面相交。
创建材质并更改颜色
为了让球体和地面看起来不那么单调,我们可以给它们添加材质。创建一个新的材质,命名为 Ground,然后将颜色设置为绿色。接着,将这个材质拖动到平面上,你会看到平面变成了绿色。
为球体添加红色材质
创建另一个材质,命名为 Player,并设置为红色。将这个材质拖到球体上,球体的颜色就会变成红色。
删除旧脚本并创建新脚本
我们之前创建的 test 脚本不符合要求,所以我们将删除它。接着创建一个新的脚本,命名为 PlayerMovement,用于控制玩家(球体)的移动。
编写脚本
在脚本中,我们首先需要创建两个变量:
在 Start 方法中,我们通过 GetComponent<Rigidbody>()
初始化刚体组件。
接下来,在 Update 方法中,我们获取玩家的输入(即上下左右的按键),并将这些输入转换为水平和垂直方向的值。我们将这两个值组合成一个 Vector3 向量,表示球体的移动方向。
移动球体
使用刚体的物理引擎来移动球体,我们将 Vector3 向量与速度相乘来控制球体的速度。每当玩家按下键盘上的移动键时,球体就会根据方向和速度移动。
添加脚本到球体
将 PlayerMovement 脚本拖到球体对象上,或者通过 添加组件 找到并添加脚本。
修改摄像机视角
游戏默认的摄像机角度不太理想,我们可以通过调整摄像机的位置和旋转来获得更好的视角。将摄像机稍微移动,并旋转到一个合适的位置,确保我们能够看到球体的移动。
调整旋转角度
我们将摄像机的旋转角度调整为 45 度,确保游戏画面显示得更加合适。
添加刚体组件
在球体上添加 RigidBody 组件,这使得球体成为一个物理物体,具有质量、重力、阻力等属性。你可以在 物理 中找到 RigidBody 选项。
控制重力
在 RigidBody 组件中,确保勾选了 Use Gravity 选项,这样球体会受到重力的影响。如果你取消勾选,球体就不会掉落。
测试玩家移动
当一切设置好后,点击运行按钮,游戏开始运行。你会看到,通过按键盘上的方向键,球体可以在场景中移动。
观察物理效果
如果球体移动到场景边缘,它会因为重力的作用而掉落到地下。这就是刚体组件和重力起作用的表现。
我们创建了一个简单的玩家控制脚本,通过用户输入来控制球体的移动,同时使用物理引擎使得球体具有真实的物理行为。接下来,我们会进一步探讨物理学和脚本编写,帮助你更好地理解游戏对象的行为并创建自己的游戏。
在后续的课程中,我们会详细讲解如何使用 RigidBody 和其他物理组件,帮助你构建更复杂的游戏机制。
在本视频中,您将学习如何确保在游戏播放模式下进行的更改是安全的。因为在播放模式下所做的任何更改,如果不小心保存下来,可能会对场景产生不可预期的影响。
让我们首先开始游戏并进入场景视图。在游戏视图中,我们可以看到当前对象的状态。比如,我们将一个立方体的缩放调整为每个方向放大5倍,并且旋转它,使得X和Y轴的旋转角度为55度。此时,立方体变得非常大。
当我们停止游戏时,您会看到立方体返回到它的初始状态,这就是播放模式的特点之一。所有在播放模式下所做的更改都会在停止游戏后丢失,恢复为之前的状态。
Unity提供了一个非常有用的功能来提醒您当前是否处于播放模式。通过在播放模式下改变编辑器界面的颜色,您可以清楚地看到哪些更改仅会影响游戏播放时的状态,而不会影响场景本身。
当您启动游戏时,编辑器的顶部和层级面板的颜色会发生变化,从而帮助您确认当前是否处于播放模式。这确保了您不会不小心在播放模式下修改不应更改的场景内容。
通过这种方式,您可以避免误操作,比如在播放模式下修改物体的属性,而这些修改在停止游戏时会被丢弃。
设置播放模式提示颜色是一个非常简单但又非常有效的工具,可以帮助您清晰地意识到当前游戏的状态。这样,您就能避免在不该修改的情况下对场景进行更改。
在本视频中,您将学习Unity 3D中的物理基础。我们将首先快速回顾几个最重要的概念:刚体(Rigid Body)、碰撞器(Collider) 和 触发器(Trigger),并分别详细讲解它们的作用和使用方法。
刚体是一个物理组件,它包含了关于物体质量的信息。通过刚体,您可以使物体受到物理引擎的影响,进而实现与其他物体的交互。刚体的属性包括:
碰撞器用于确定物体与其他物体是否发生碰撞。Unity中有多种类型的碰撞器,每种都适用于不同类型的物体:
触发器是一种特殊的碰撞器,它不会阻挡物体,而是允许物体通过。当您启用一个碰撞器的 Is Trigger 属性时,该碰撞器就会成为一个触发器。触发器的特点是:
通过本视频,您应该对Unity 3D中的物理系统有了基本的了解,特别是刚体、碰撞器和触发器的使用。在接下来的教程中,我们将深入讲解各种碰撞器以及触发器的使用方法。
在本视频中,我们将深入探讨刚体(Rigid Body)。首先,我将保存我的场景,以确保我们在进行任何操作之前都有备份。保存场景非常重要,您应该定期进行保存。
Main
。接下来,我将选择我们的玩家角色,因为它已经包含了一个刚体组件。我们会逐一查看刚体的多个属性。
为了更好地查看游戏效果,我将使用自定义布局,这样就可以同时看到 场景视图 和 游戏视图。当前,2D视图已激活,所以我看不到3D场景。我们可以禁用2D模式,切换到3D视图,方便观察物体的行为。
在游戏模式下,我将按下 播放按钮,并开始查看球体的行为。首先,我会打开 层级视图,并选择玩家对象。
接下来,我们来看一下刚体的 质量(Mass) 属性:
为了让球体重新开始移动,我将 速度(Speed) 设置为 200。现在,球体开始缓慢移动,但仍然比较慢。接着,我将质量调整回 1,并将速度设置为 50。此时,球体的移动速度变得非常快,甚至有时会从屏幕上飞出。
为了限制球体的最大速度,我将 阻力(Drag) 属性设置为 10。这样即使我继续按住键盘,球体的速度也不会无限增加,而会受到限制。阻力的作用是逐渐减缓物体的速度,直到它达到某个固定的速度。
如果我启用 重力(Gravity),球体会受重力影响开始下落。如果禁用重力,球体将悬浮在空中,不会再向下掉落。
如果将 运动学(Is Kinematic) 设置为启用,球体将不会再受物理引擎的影响,无法移动。这意味着即使我们在玩家控制脚本中添加了施加力的代码,球体也不会响应这些物理操作。这种功能可以用于墙壁等物体上,让它们不会受物理引擎的影响,但仍然起到阻挡作用。
如果角色动画依赖于物理系统,使用 插值(Interpolate) 可以使物体的动画和物理计算更加平滑,这样角色的动作不会因为物理引擎的计算而出现不协调的情况。
默认情况下,刚体的碰撞检测为 离散(Discrete)。这对于大多数情况来说已经足够了。如果您的物体移动非常快,可能需要使用 连续动态(Continuous Dynamic) 碰撞检测来确保即使物体快速运动,也不会错过碰撞事件。大多数情况下,离散模式已经足够,而且对资源的消耗也较少。
我们可以通过约束来冻结物体的某些位置或旋转。例如,如果我不希望球体在Y轴方向上下落,可以通过冻结 Y轴位置 来实现。这时,即使启用了重力,球体也不会向下掉落。
如果冻结了 X轴位置,球体就不能再向左右移动,只能沿Z轴方向前进或后退。
同样,我们也可以冻结物体的旋转。例如,冻结 X轴旋转,那么球体就只能绕Z轴旋转,不会再绕其他轴旋转。
接下来,我将为玩家添加一个立方体对象。通过调整立方体的尺寸(如设置为1x1x1),可以观察到当立方体与球体交互时,球体的行为发生了变化。
如果将旋转冻结,只允许沿某个方向旋转,我们就可以防止球体过度旋转,保持物体在物理世界中的稳定性。这对于不希望物体做出过多旋转的场景非常有用。
刚体是任何需要受到物理引擎影响的物体必不可少的组件。通过调整刚体的属性,您可以精确控制物体的运动、旋转、重力影响以及其他物理行为。在接下来的教程中,我们将进一步探讨 碰撞器(Colliders) 和它们如何与刚体一起工作,帮助您实现更精确的物理交互。
不要忘记经常保存您的场景,以确保您的工作不丢失!
在本视频中,您将学习 Unity 中的不同碰撞体类型。正如您在演示中已经看到的那样,碰撞体有多种类型,并且我们已经使用过其中的两种类型,实际上甚至是三种。现在让我们来看看这些碰撞体类型。例如,我们的玩家有一个球形碰撞体,您可以在这里看到它。让我们点击玩家,您会看到玩家周围有一个绿色的线圈,这就是我们的球形碰撞体。它自动获取了一个半径值 0.5,这个值正好适合我们玩家的大小。所以如果我将这个值改为 1,球形碰撞体就会变得比我们所看到的图形还要大。
接下来我们启用玩家的重力并运行游戏,看看会发生什么。我们切换回场景视图。在这里,您可以看到我们的球在地下的上方,所以它看起来好像在飞,但是其实这与球形碰撞体有关。因为球形碰撞体与地下的网格碰撞体发生了碰撞。网格碰撞体是碰撞体的第二种类型,它的外观基于其网格。通过点击网格,您可以看到右下角显示了它的形状,而这个网格是非常具体的,它可以根据物体的形状来变化。比如,如果您的角色是汽车,那么网格就可以代表汽车的整个表面。
网格碰撞体通常用于表面,而网格碰撞体就是给这些表面添加碰撞体。在我们的例子中,地下物体使用了网格碰撞体。您可以看到,从底部没有碰撞体,而从顶部有这个绿色的颜色。我们只在顶部给它添加了这个绿色的颜色。如果我们想让网格碰撞体有更高的层级,可以启用凸起(Convex)选项,如您所见,它会在物体的顶部和底部都添加碰撞体。您甚至可以放大它,这样它的碰撞体就会变得更大。所以,如果您看一下现在的效果,球就会悬浮在这块物理表面上。
如果我们将球形碰撞体的半径减少到 0.5,球依然会悬浮在这块物理碰撞体上。所以碰撞体的大小不一定非要和物体本身的大小一致,但在大多数游戏中,使它们一致是有意义的。如果您的游戏设计需要碰撞体与物体大小不一致,当然也可以进行调整。您还可以更改碰撞体的中心位置,至少在这个例子中可以做到。例如,我可以将碰撞体的中心位置设置为比球高一点,这样如果我们查看时,Y轴位置就会变成 1。此时我们的球就会处于地下物体内部。正如您现在看到的那样,物理效果依然按照预期正常运行。
好,我们将球形碰撞体的半径恢复到 0.5,保持默认值。接下来我们来看一下其他类型的碰撞体,比如胶囊碰撞体。您可以看到,它的形状就像一个胶囊,此外它还有中心、半径和高度等属性,您甚至可以更改碰撞体的方向。例如,Y轴是向上的,X轴是向右的,Z轴是向前的。
现在我们将其恢复为 Y 轴,因为它是标准值。接下来我们启动游戏,尝试让球撞击这个胶囊碰撞体,看看会发生什么。我们尝试让它掉下来,但正如您所见,它并没有掉下去。为什么会这样?它是不是太强了,还是为什么呢?您可以自己想一想,可能是什么原因呢?
是的,原因就是它缺少刚体。我们可以给胶囊添加一个物理刚体,然后再次尝试让它和球碰撞,看看会发生什么。现在我们可以看到,胶囊会掉下来。这样它就会自动获得物理属性,您可以用它来创建保龄球游戏或多米诺骨牌游戏之类的内容。
接下来是盒形碰撞体。我们创建一个立方体,您可以看到,立方体周围也有一个绿色的线圈,这就是盒形碰撞体。您可以改变盒形碰撞体的大小,您可以看到,现在我将 X 轴的值更改为 2,Y 轴或 Z 轴也可以按需求调整。您可以使碰撞体比实际物体大,正如您所看到的那样。
当然,您也可以移动碰撞体的中心。现在您可以将它移到 1 的位置,这样它就会处于立方体的中心,或者将它移动到立方体的顶部。此时立方体会穿过碰撞体,但碰撞体仍然存在并悬挂在地面上。为了让物理效果生效,我们还需要为立方体添加一个刚体。现在,您可以看到,立方体会从底部掉下去,尽管它的图形在底部,但物理效果会作用在碰撞体上。
接下来,我将删除立方体和胶囊,只保留玩家。假设我们想要一个弹性的地下物体,我们将球放回顶部。如果我们想让地下物体变得有弹性,我们需要添加一个物理材质。您可以看到,地下的网格碰撞体还没有任何材质,我们可以创建一个新的物理材质。在资产菜单中选择创建并选择物理材质,命名为弹性地面。
弹性地面的物理材质有动态摩擦、静态摩擦和弹性等属性,您可以根据与物体碰撞的物体来调整摩擦和弹性。我们将摩擦设置为 0,并将弹性设置为 1。弹性值的范围是 0 到 1,弹性为 1 意味着物体会以相同的力度反弹回来。现在我们将这个弹性地面材质应用到地下物体上,并启动游戏。此时,您会看到我们的球开始反弹。
为什么它反弹不回去呢?我们需要为玩家添加相同的弹性材质。现在,您会看到球和地下物体都会反弹,而且反弹的力度与之前一样。根据您的设置,它会越来越高,因为弹性会影响每次反弹的力度。如果我们将材质的摩擦结合设置为平均,反弹结合设置为平均,那么两者的反弹效果会相乘,从而产生更大的反弹效果。
接下来,我们看一下摩擦的效果。我将从玩家身上移除弹性地面材质,添加一个新的摩擦地面材质。我们可以设置动态摩擦和静态摩擦为 1。现在我们启动游戏,球开始下落,并且在我移动球时,球的速度会逐渐减慢。这就像是路面与非常光滑的地面之间的差异,如果没有摩擦,地面就像冰面一样滑;而摩擦为 1 时,就像是非常粗糙的地面,物体会被减速。
总结一下,当您想给物体添加碰撞体时,可以选择不同类型的碰撞体。碰撞体的添加会显著改变物体的行为和物理反应。最后,别忘了保存您的场景,期待在下一个视频中,我们将研究触发器。
欢迎回来!在本视频中,我们将探讨触发器(Trigger)的概念。你已经见识过物理体(Rigidbody)和碰撞器(Collider)等内容,现在让我们来添加一个新组件:触发器(Trigger)。其实,触发器并不是一个新组件,它仅仅是碰撞器的一种特殊状态。你可以将一个碰撞器设置为触发器。例如,我们的玩家可以是触发器,或者我们可以在游戏中创建一个新的对象作为触发器。
首先,创建一个新的3D对象(Cube),然后将其位置重置。接着将其稍微移动一下,上下调整位置,调整Y轴缩放为5,X轴缩放为2,Z轴缩放为3。现在我们有了一个墙壁对象。为了方便演示,可以将墙壁位置调整到侧边,并将X轴设置为1,Z轴设置为4。此时我们有一个较大的立方体墙壁。
我们想要实现的效果是:当玩家穿过或球体穿越这个立方体时,某些事情会被触发,例如墙壁消失。为了做到这一点,我们将给立方体添加一个“Is Trigger”选项,表示这个立方体不再作为一个实际的墙壁,而是变成了一个触发器。
当你设置了“Is Trigger”后,碰撞器会变得不再阻挡对象。所以球体将会忽略它,直接穿过它。如果我们希望这个墙体在被球体或玩家穿过时触发某种效果,我们就需要创建一个新的脚本。
接下来,我们创建一个新的C#脚本,命名为TriggerScript
。将这个脚本添加到立方体对象上。
using UnityEngine;
public class TriggerScript : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
Debug.Log("Triggered");
}
}
上面这个脚本中,OnTriggerEnter
方法会在碰撞体进入触发器时被调用。other
参数代表进入触发器的对象,在这个例子中,可能是我们的玩家或球体。此时,我们通过Debug.Log
在控制台打印出“Triggered”。
当你运行游戏时,当球体穿过这个触发器时,控制台会打印出“Triggered”。每次球体通过触发器时,都会打印一次。
接下来,我们改进脚本,使得当触发器被激活时,销毁这个墙体对象。修改TriggerScript
脚本如下:
using UnityEngine;
public class TriggerScript : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
Debug.Log("Triggered");
Destroy(gameObject); // 销毁包含此脚本的游戏对象
}
}
当球体穿过触发器时,墙体对象就会被销毁。通过这种方式,你可以实现让物体消失的效果。
接下来我们将多个触发器对象复制,并排列在一起。每个立方体都有一个触发器组件,并且在球体穿过时会销毁自己。为了更好地测试效果,我们需要为每个立方体添加物理属性,确保它们能够被物理系统影响。我们给立方体添加Rigidbody
组件,使其可以与物理引擎交互。
现在,你可以看到,当球体碰到这些立方体时,如果立方体上启用了触发器,它会消失。如果没有启用触发器,它将不会消失。
通过将这些触发器对象组合起来,你可以创建一个多米诺骨牌效果。每个立方体在被击中后都会倒下。为了增强效果,我们可以将球体的大小增大,调整它的Rigidbody
组件,使得它能够有效地撞击并推动这些立方体。
到目前为止,我们已经展示了如何使用触发器来改变游戏对象的行为。通过触发器,你可以让游戏对象在被触发时消失、改变状态,或者触发其他事件。触发器非常适合用来实现如门的开关、物品收集或障碍消除等效果。
在以后的视频中,我们将会更多地使用碰撞器、刚体和触发器,帮助你深入理解这些物理概念,并在实际游戏中使用它们。
希望这些内容对你有所帮助,我们将在下一个视频中继续深入探讨!
欢迎回来!在本视频中,我想和大家谈论一个非常重要的概念,这不仅是 Unity 开发中的关键,也是保持项目整洁的好方法。到目前为止,我们创建了一些资产和游戏对象,但我们没有很好地整理资产文件夹,也没有使用一个非常重要的功能——预制体(Prefabs)。今天,我将向你们展示如何正确使用预制体并保持项目的整洁。
首先,我们应该对不同类型的资产创建不同的文件夹。例如,可以创建一个“Scripts”文件夹,里面存放所有的脚本。比如我们的玩家移动脚本(Player Movement)和触发器脚本(Trigger Script),以及以后创建的所有新脚本,都应该放到“Scripts”文件夹中。
接下来是“Materials”文件夹。你可以创建一个文件夹专门存放普通材料(Materials),还可以再创建一个“Physics Materials”文件夹来存放物理材质,但我个人习惯将它们放在同一个文件夹中。只要将材料文件拖放到“Materials”文件夹中即可。
至于场景文件(Scenes),你可以选择将其保存在资源文件夹之外,也可以创建一个专门的“Scenes”文件夹。个人来说,我更喜欢将场景直接放在资源文件夹根目录下,但你也可以根据需要创建一个专门的文件夹来存放。
一个非常重要的注意事项是保存场景。在关闭项目之前,确保保存你的场景,因为场景在关闭时并不会自动保存。如果你没有保存场景,可能会丢失场景中所有的游戏对象。场景中的每个对象都属于“游戏对象”(Game Objects)。比如主相机(Main Camera)、定向光(Directional Light)、玩家(Player)等等,都是游戏对象。
如果我们创建一个新的空对象(Empty Object),它除了“Transform”组件外,不包含任何其他信息。每个游戏对象,即使是空对象,都至少会有一个“Transform”组件。你可以通过这种方式创建各种类型的游戏对象,包括2D和3D对象。
如果你希望让某些游戏对象在不同的场景中共享或者想要在程序中重复使用某些对象,那么你可以将它们转换为预制体。例如,我们想把当前的立方体(Cube)作为预制体,这样我们就可以在以后任何时候重复使用它。
方法很简单:将立方体拖放到“Assets”文件夹中,这时它就会变成一个名为“Cube.prefab”的预制体。你可以随时将它拖回场景中,调整位置,并且可以重复使用多个立方体。
例如,现在我删除了场景中的所有立方体,但我可以随时将预制体拖入场景中,并且不必每次都重新创建它。这使得我们可以方便地在不同的场景中使用这些预制体,甚至在程序中动态地实例化它们。
通常,我们会将很多对象创建成预制体,尤其是那些你希望在多个场景中重复使用的对象。例如,玩家对象就是一个必须作为预制体存在的对象。因为当你切换到不同的关卡时,你可能希望在每个关卡中都能重新生成玩家,并且保持玩家的状态和能力。通过预制体,你可以方便地在不同场景中复用同一个玩家对象。
现在,我们已经有了这个立方体的预制体,并且我们想将所有的预制体整理到一个文件夹里。我们可以创建一个“Prefabs”文件夹,然后将所有的预制体拖放到该文件夹中。这样,我们的项目就变得更加整洁,也能更好地管理和使用这些预制体。
预制体(Prefabs)是 Unity 游戏开发中非常重要的工具,能够帮助你将游戏对象复用到多个场景中,甚至在程序中动态生成和控制这些对象。因此,理解和正确使用预制体是开发过程中不可忽视的一部分。
通过本视频,你不仅了解了什么是游戏对象,还学会了如何创建和使用预制体。接下来的视频中,我们将讨论组件(Components)这一概念,敬请期待!
在 Unity 中,一个非常基础但非常强大的功能就是组件(Components)。组件是非常强大的工具,它们允许我们为对象添加功能、外观或者物理效果。例如,我们的玩家对象有一个 Transform 组件、一个球体组件(Sphere)、刚体组件(Rigidbody)等,这些组件为玩家对象添加了功能和外观。这是你需要理解的一个非常重要的概念,因为每当你想要为某个对象添加不同的行为时,就需要使用组件。
例如,当前我们的玩家有玩家移动的功能(Player Movement),但是假设你希望玩家能射击,那么你就需要为玩家对象创建一个新的组件。这个组件将是一个脚本组件(Script Component),你可以创建一个新的脚本来处理射击行为,或者你可以在现有脚本的基础上修改。通过这种方式,你可以为对象添加不同的功能或效果。
你可以根据需要为对象添加组件,也可以随时移除它们。例如,假设你希望为玩家添加粒子效果,你可以为玩家对象添加一个粒子系统(Particle System)。另外,关于物理效果,虽然到目前为止我们只有一个刚体组件(Rigidbody)和一个球形碰撞器(Sphere Collider),你也可以为玩家添加更多的物理组件。如果你想为球体添加一个额外的碰撞器,你可以添加一个盒形碰撞器(Box Collider)。这样,玩家对象就有了两个碰撞器:一个球形碰撞器和一个盒形碰撞器,两个组件都添加到玩家对象上。
如果你希望将某个组件从一个对象复制到另一个对象,可以很轻松地完成。例如,如果你希望将玩家移动组件(Player Movement)添加到其他对象上,你可以通过复制该组件,并粘贴到另一个对象中。具体操作是:在目标对象的 Inspector 面板中,点击 "Copy Component" 按钮,然后选择另一个对象,点击 "Paste Component as New"。这样,你就可以把这个组件复制到新的对象上。你可以根据需要删除不合适的组件。
如果你希望改变组件的顺序,Unity 也允许你通过拖动组件的顺序来调整。比如,如果你想将玩家移动组件(Player Movement)向上移动,只需按住并拖动它。需要注意的是,这种操作可能会影响预制体实例。如果你在场景中的实例上进行此操作,Unity 会提示你是否继续,可能会影响该预制体的实例。因此,建议你在处理预制体时,直接操作预制体本身,而不是场景中的实例。
如果你在场景中的实例上对组件进行了修改,并希望这些修改应用到预制体上,可以点击 "Apply" 按钮来保存这些更改。假设我们的玩家预制体的移动速度设置为 100,但是场景中的玩家实例的速度被设置为 20。如果你希望将这种速度修改应用到预制体中,只需点击 "Apply"。这样,你的玩家预制体的速度也会变为 20。值得注意的是,只有点击 "Apply" 后,修改才会反映到预制体上,而场景中的实例的修改不会影响到预制体,除非你手动应用这些更改。
组件是 Unity 游戏开发中至关重要的组成部分,能够让你为对象添加各种功能、外观和行为。你可以根据需求动态添加、移除、复制和调整组件。此外,对于预制体(Prefabs)的管理和修改也非常重要,通过理解如何应用更改,你可以保持项目的一致性和可维护性。
在 Unity 中,随着游戏中对象的数量不断增加,尤其是在一些游戏中,你可能会遇到这种情况:你的层级视图(Hierarchy)中会有成百上千个不同的对象。例如,当你通过编程创建子弹时,游戏中会有很多子弹。你射出子弹,敌人也会射出子弹,甚至所有人都会射出子弹,到处都是子弹。最终,你的层级视图就会被这些子弹填满,而你将无法看到其他重要的游戏对象了。那么,我们该如何解决这个问题呢?
一个简单的解决方案就是使用文件夹来组织对象。文件夹可以帮助你更好地管理和组织大量的对象,让你更容易找到需要的游戏对象。
例如,在之前的视频中,我创建了一个游戏对象,现在我将删除它,并重新创建一个新的空游戏对象。我们可以重置该对象的 Transform 组件,使其位于原点(0,0,0)。现在,我们可以将该空游戏对象作为文件夹使用。可以将它重命名为“Bullets”,然后将所有的子弹对象拖放到这个文件夹内。
如果我们有多个相同的对象实例,使用文件夹也能帮助我们保持层级视图的清晰。例如,我们将一个名为 "Cube" 的对象重命名为 "Domino"(多米诺骨牌),然后将所有的多米诺骨牌对象放入 "Dominos" 文件夹。通过这种方式,如果我们复制这个对象,新的实例也会自动被放入文件夹中。
当你创建了文件夹后,你可以像操作普通对象一样操作它。你可以展开和折叠文件夹,在层级视图中隐藏不需要看的部分,只显示文件夹名称,这样可以大大减少混乱。
文件夹的使用不仅限于相同类型的对象。它们也适用于以下几种情况:
总之,使用文件夹是一种非常有效的管理方法,尤其是在项目变得复杂时。只要在层级视图中看到大量对象时,就可以考虑使用文件夹来提高可读性和可维护性。
通过创建和使用文件夹,你可以将相关的游戏对象组织在一起,使你的层级视图更加整洁,操作更加方便。在本课程中,我们也将频繁使用文件夹来组织我们的游戏对象。所以,不要忘记在项目中合理使用文件夹。
在本视频中,我们将来看一下我们新创建的行为脚本,这个脚本就是我们在上一个视频中创建的。如果你自己想创建一个脚本,你可以右键点击你的资源(Assets)文件夹,选择 Create,然后点击 C# Script。另一种创建脚本的方法是,点击你某个游戏对象(例如,一个 Cube),然后点击 Add Component,在右侧你可以创建一个新脚本。在这里你可以给脚本命名,例如命名为 New Behavior Script,并选择编程语言。如果你想用 JavaScript 编程,也可以选择 JavaScript。之后点击创建,脚本就会被添加到你的资源文件夹中。但我这里不会使用这种方法,因为我已经有了这个脚本。那么我们打开这个脚本,通常情况下,如果我们双击它,脚本会在我们的集成开发环境(IDE)中打开,例如 Visual Studio。如果你使用的是 MonoDevelop,脚本也会在那个环境中打开,或者任何支持 C# 和 Unity 的其他 IDE。尽管如此,我还是强烈建议使用 Visual Studio。
让我们来看看这个新创建的行为脚本。我们已经有了 16 行代码,尽管我们还没有写过任何实际的代码或符号。当前的内容包括三行 using
,这意味着我们在使用一个命名空间。你可以将这些命名空间看作是模块,它们为脚本提供了功能。这些功能我们目前并没有使用,所以你会看到它们被灰色显示出来,但我们确实在使用 Unity 引擎中的一些功能,而这些功能是在 MonoBehaviour
中定义的。MonoBehaviour
是 Unity 引擎中的一个类,如果你想了解更多关于 MonoBehaviour
的信息,你可以按住命令键并点击 MonoBehaviour
,这会打开程序集浏览器,并且你可以在这里找到关于这个类的详细信息,里面有很多代码,可能对你作为一个初学者来说有些多,但如果你已经了解编程,那么应该能理解一些。在最终的分析中,MonoBehaviour
中有很多方法可以在我们的游戏或脚本中使用。
我们有一个公共类 NewBehaviorScript
,它继承自 MonoBehaviour
。这个冒号意味着我们继承了 MonoBehaviour
,从而能够使用 MonoBehaviour
的功能。我们所写的这个脚本类就是 MonoBehaviour
类的一个子类,在这个类中,Start
和 Update
方法已经存在,我们无需手动编写它们是何时被调用的,Unity 引擎会自动处理这些。这个特性让我们编程变得更加简单,我们可以在 Start
方法被调用时做某些事情,在 Update
方法被调用时做其他事情。Start
和 Update
方法是类的一部分,类通常由变量和方法构成。我们可以在这个类中创建自己的变量,在下一个视频中我们会介绍变量的使用,并且我们还可以创建自己的方法,或者使用 Unity 提供的内置方法,我们不必重新编写它们,我们只需要调用它们,或者我们也可以重写这些方法来定制它们的功能。所有这些内容我们都会在后续的课程中逐步学习。
接下来,我们可以简单地在 Start
和 Update
方法中分别加入一个 print
语句,输出到我们的控制台。例如,我们在 Start
方法中写:
print("Start method was called");
然后在 Update
方法中写:
print("Update method was called");
这样一来,当我们运行游戏时,我们就能看到 Unity 控制台中打印出 "Start method was called" 和 "Update method was called"。这样我们就能知道 Start
方法和 Update
方法分别是什么时候被调用的。
例如,如果我们有 60 帧每秒,那么 Update
方法就会每秒调用 60 次。
现在让我们保存这个脚本,并回到 Unity 编辑器。如果我们此时运行游戏,我们会发现控制台没有任何输出。这是因为我们还没有将脚本添加到任何游戏对象上。为了测试它,我们需要将 NewBehaviorScript
添加到我们的 Cube 对象中。
我们可以通过以下三种方式之一来添加脚本:
NewBehaviorScript
。添加脚本后,再次运行代码,你会看到控制台中的输出发生了变化。控制台会显示 "Start method was called" 只被调用了一次,而 "Update method was called" 已经被调用了很多次。这样,你就可以直观地看到 Start
和 Update
方法的执行情况。
你可以看到 Update
方法会随着每一帧的调用而频繁执行。如果你在 Update
方法中放置了大量复杂的逻辑,它可能会影响游戏的性能。因此,在 Update
方法中尽量避免运行重的操作,保持它轻量化,并把初始化操作放到 Start
方法中,这样可以确保程序运行的效率。
另外,代码中的双斜杠 //
表示注释。所有跟在双斜杠后的内容都会被忽略,不会在游戏中执行。注释的作用是帮助我们更好地理解代码,特别是当代码量增加时,注释非常有用,可以帮助我们记住代码的功能。
所有的代码块,如类和方法,都被大括号 {}
包裹。大括号表示代码块的开始和结束,每个语句后面需要有分号 ;
,表示语句的结束。
在本视频中,我们介绍了如何创建并使用 Unity 脚本,以及 Start
和 Update
方法的工作原理。我们还了解了如何将脚本添加到游戏对象中并查看输出信息。接下来,我们将在下一个视频中深入探讨变量的使用。如果你现在没有完全理解这些内容,不用担心,随着课程的深入,你会逐渐掌握这些概念的。
在本视频中,我想向你介绍一些我们还没有使用过的类,这些类可以帮助我们实现很多功能,而我们无需自己编写这些功能。总的来说,我想向你展示的是,Unity 和 C# 中有一些内置的类,你可以利用这些类来实现一些常见的功能,而不用从头开始编写代码。
首先,我们要讨论的是 随机数,这是视频游戏中非常常见和重要的功能。在很多情况下,游戏需要有一些随机性,这样玩家就无法预测接下来会发生什么,从而增加游戏的趣味性。如果你想在游戏中引入随机性,你不必自己编写随机生成的代码。你可以直接使用一个已经内置的类—— Random 类。这个类专门用于生成随机数据,Unity 提供了一个特定版本的这个类。
我们来看一下 Random
类。你可以在代码中输入 Random
,然后按下 .
(点号),这时你会看到 Random
类提供的不同方法和属性。例如,Random
类提供了一个叫做 Range
的方法。这个方法可以返回一个在指定的最小值和最大值之间的随机浮动值。
Random.Range
方法假设我们想要得到一个从 0 到 15 之间的随机浮动值,代码如下:
float min = 0;
float max = 15;
float myRandomNumber = Random.Range(min, max);
print(myRandomNumber);
这样,我们就可以得到一个介于 0 和 15 之间的随机浮动值,并将其保存在变量 myRandomNumber
中。为了让它每一帧都生成一个新的随机数,我们可以将其放入 Update
方法中,而不是在 Start
方法中。这样,游戏每一帧都会生成一个新的随机数并打印出来,代码如下:
void Update()
{
float myRandomNumber = Random.Range(0f, 15f);
print(myRandomNumber);
}
运行这段代码后,你会在控制台看到不断生成的随机数,每一帧都会产生一个新值,例如 11.6、6.3、9.1 等等,这些值在 0 到 15 之间随机波动。
这种随机数生成功能在很多游戏中都有使用,尤其是在需要模拟随机事件或者生成随机物品、敌人等元素的场景中。可以说,游戏的不可预测性和惊喜感往往来源于这种随机因素。
另一个非常有用的类是 MathF 类。它是一个包含常见数学功能的结构体类。你可以使用它来进行各种数学运算,尤其是在你需要处理角度、指数、三角函数等时非常方便。
例如,如果你想得到某个数值的绝对值,可以使用 MathF.Abs
方法;如果你想计算某个角度的反余弦值,可以使用 MathF.Acos
方法;如果你需要对数值进行向下取整,可以使用 MathF.Floor
方法。还有许多其他有用的数学函数,比如 MathF.Pow
(幂运算)、MathF.Sin
(正弦函数)、MathF.Cos
(余弦函数)等。
你可以通过以下代码来访问这些方法:
float value = -5.67f;
float absoluteValue = MathF.Abs(value);
print(absoluteValue); // 输出:5.67
float angle = 1.0f;
float cosineValue = MathF.Cos(angle);
print(cosineValue); // 输出:0.540302
这些数学函数和随机函数在很多情况下都能帮你大忙。如果你遇到某些任务,比如计算角度、进行随机化操作或者进行数学运算,不妨先检查一下这些类是否有你需要的方法。这样,你就不需要手动实现这些功能,直接调用已有的类和方法会让编程更加高效。
总的来说,在编程时,尽量不要尝试编写复杂的代码,而是首先检查现有的类和方法,看是否已经有现成的解决方案。例如,在处理随机性时,你可以直接使用 Random
类,而不必自己编写随机数生成算法。在处理数学运算时, MathF
类可以为你提供各种常见的数学功能。
我强烈推荐你在编程过程中,遇到问题时先查阅相关的类和函数,看看是否已经有现成的工具可以使用。在很多情况下,Google 或 Unity 官方文档是你最好的朋友,它们能帮助你快速找到解决方案,避免花费大量时间编写你不必要的代码。
在下一个视频中,我们将继续探索更多的工具和方法。希望你在本视频中学习到的内容能够帮助你更高效地开发游戏,欢迎你尝试使用 Random
类和 MathF
类,掌握这些工具后,你会发现编程变得更加轻松。
好的,到了这个阶段,你已经掌握了 Unity 的基础知识。现在,你了解了 Unity 的工作原理以及其核心功能,至少是一些基础功能。接下来,你需要做的就是将这些知识应用到实际的项目中。这就是我们接下来几章的内容,因为到目前为止,你学到的所有东西如果不应用到实际的游戏中,那它们就是没有用的。只有在实际开发中,你才会真正理解这些概念的意义,因为你可能已经发现,它们和你之前学到的东西非常不同。游戏开发是非常直观的,且比我们之前的编码学习要少很多,更多的是在用户界面中操作,实际编程的部分相对较少,但这也是很有趣的。我个人很喜欢这种编程与编辑器操作相结合的方式,个人认为这是一个很好的混合。
接下来我们将制作一个 Pong 游戏,这是一个非常基础的游戏,但与此同时,它将帮助你运用在上一章和本章中学到的功能,并加深你对它们的理解。每增加一个新的游戏元素,你都会学到一些新的东西,这些东西可以在你以后开发自己的游戏时使用。
其实,无论你学习任何编程的部分,都是如此。你学到一个新概念时,刚开始可能会感到有些困难,因为你并不完全理解它。但是,真正的学习是在你开始应用这些知识时发生的。只有通过实践,它们才会变得自然而然,成为你自己掌握的知识。
所以我建议我们继续前进,进入下一个章节,开始制作游戏吧!
欢迎来到 Pong 游戏章节!在这一章中,我们将制作一个 Pong 游戏 的克隆版本,这款游戏最早的版本可以追溯到上世纪七十年代,是当时的巨大成功,几乎是我所知道的第一款电子游戏。我们将重新制作这款游戏,基本上复现它的玩法。
如你所见,这个游戏包含了玩家 1 和玩家 2,每个玩家都有一个 球拍 和一个 飞来的球。我们将在游戏中使用音效,并且设有 计分系统,每次球被击打后,球的速度都会加快,这使得游戏变得越来越有挑战性,因为球拍的速度不会变化,玩家需要更加迅速地反应。
这是我们在本章中最终要实现的目标,你可以看到:
我们将使用多个场景来实现游戏的不同部分:
在这个过程中,你将学到很多不同的技能:
这章的内容会非常有趣,涵盖了很多不同的技术。我们将一步步走过这个过程,最终完成一个完整的 Pong 游戏。顺便提一下,本章还配有文本材料,你可以在学习过程中通过阅读这些材料来帮助理解。
好啦,准备好了吗?让我们进入下一个视频,开始制作 Pong 游戏吧!
欢迎回来。在本视频中,我们将看看如何使用 UI 元素,特别是 文本。我创建了一个新的项目,叫做 Pong Basics。在这里,我想要创建一个 UI 元素。首先,在 层级视图 中右键点击,选择 UI,你会看到多个选项,包括文本、图像、原始图像、按钮等等。现在我们先使用 文本。正如你所见,新的文本已经出现在屏幕的左下角。如果我们缩小视图到合适的角度,就能看到文本的位置。
这是我们当前的 Canvas 渲染模式,设置为 Screen Space Overlay(屏幕空间叠加)。这个模式的作用是将我们分配给 UI 层的内容,叠加到游戏视图的顶部,无论游戏视图中有什么物体,都不会影响 UI 层。
例如,如果我们添加一个 3D 立方体 并将其位置设置为 (0, 0, 0),它会出现在游戏视图的某个位置,像是一个物体。你可以看到,游戏视图本身在相对于整个 UI 层来说显得非常小。UI 层(即 Canvas)覆盖在上面,通常会占据更大的区域。
除了 Screen Space Overlay 之外,还有其他几种渲染模式:
在本课程中,我们会使用不同的渲染模式,所以下面的视频或章节中会详细讲解这些模式。
在 Canvas 内部,我们创建了一个 文本 元素。创建 UI 元素时,Unity 会自动为我们创建一个 Canvas 和一个事件系统。如果没有之前的 UI 元素,Canvas 和事件系统会自动生成。我们的文本元素是 Canvas 的子元素,所以它被放在了 Canvas 文件夹内。
接下来,我们可以更改文本的内容。默认的文本是 New Text,我们可以将其修改为 0,但你会发现这个数字几乎看不见。接下来,我们将字体大小设置为 50,但你可能会发现它依然看不见,这是什么原因呢?是因为文本框的 高度和宽度 不足以容纳更大的字体。文本框的高度默认为 30,所以需要增加它的高度才能看到文字。我们将其高度调整为 60,这样就能看到文本了。
如果你想保留文本框的高度为 30 但依然能看到文本,怎么办呢?你可以使用 溢出设置,设置 水平溢出 和 垂直溢出,这样文本就会显示出来,即使它被切掉了一部分。
另外,你可以调整文本的位置。可以通过 Transform 工具 来拖动文本框,或者直接在 Transform 面板中输入数值来精确调整位置。
文本的位置还受到 对齐 和 锚点 设置的影响。例如,我们可以设置文本的 对齐方式,比如选择将文本水平和垂直方向都居中对齐。这样文本就会准确地显示在 Canvas 中间。如果你将锚点设为 左上角,文本就会固定在左上角,调整后,文本的 位置 X 和 Y 会发生变化。
文本的颜色也可以很容易地调整。你可以选择将文本颜色更改为 红色、白色、绿色 等等,或者选择你喜欢的任何颜色。比如,我们可以将文本设置为 粉红色,它与背景的蓝色很好地对比,十分醒目。
你还可以调整 行间距,如果文本有多行,可以通过行间距控制行与行之间的距离。行间距默认为 1,你可以增加它来得到更大的行距。
最后,你还可以随时更改字体。Unity 默认的字体是 Arial,但你也可以选择其他字体。如果你需要额外的字体,可以从 Asset Store 下载,或者从你电脑上导入任何字体文件。下载字体时要小心,确保你拥有该字体的使用权,因为一些字体是收费的。你可以搜索免费的或免版权的字体来使用。
以上就是关于 文本 元素的基本操作。接下来,在下一个视频中,我们将展示如何通过代码来动态更改文本内容。
欢迎回来。在本视频中,我们将展示如何通过编程更改文本元素的值。首先,让我们创建一个空对象,并命名为 Game Manager。这个 Game Manager 对象可以用来收集分数、管理游戏状态等。如果你想要添加一些功能,比如改变文本的内容,我们可以创建一个新的脚本,命名为 TextBehavior。接下来,让我们打开这个脚本。
在 Unity 中,如果你要操作文本元素,你需要在代码中添加以下命名空间:
using UnityEngine.UI;
这是一个非常重要的命名空间,否则你将无法创建或操作 Text 对象。你会看到有一个名为 Text 的类,它是 Unity 提供的用来将文本渲染到屏幕上的默认类。
我们将创建一个公共的 Text 变量,并命名为 myText
:
public Text myText;
在 Start() 方法中,我们可以初始化这个文本对象。你可以通过查找 Text 对象来实现,也可以像下面这样直接将它绑定到组件:
void Start() {
myText = GetComponent<Text>();
}
这样,你就可以在 Unity 编辑器中将 Text 元素拖到这个字段中进行绑定了。
现在,我们将设置一个监听事件,监听 Space 键的按下。如果按下空格键时,我们会改变文本的内容。我们将创建一个整数变量 textNumber 并初始化为 0,每次按下空格键时将其增加 1,同时更新文本显示的内容。
void Update() {
if (Input.GetKeyDown(KeyCode.Space)) {
int textNumber = 0; // 初始化文本数字
textNumber += 1; // 每次按下空格,数字加1
myText.text = textNumber.ToString(); // 更新文本显示
}
}
在上面的代码中,我们已经设置了文本的内容,每次按下 Space 键时,文本将显示增加后的数字。接下来,我们可以尝试其他按键操作,比如按下 S 键时,显示 "S was pressed"。
if (Input.GetKeyDown(KeyCode.S)) {
myText.text = "S was pressed"; // 当按下 S 键时,显示文本 "S was pressed"
}
回到 Unity 编辑器,我们的 GameManager 对象现在需要绑定 Text 元素。你只需要将 Canvas 中的文本对象拖动到 TextBehavior 脚本的 myText 字段中。
接着,运行游戏并按下 Space 键,文本内容应该会随着每次按下 Space 键而增加。你还可以尝试按下 S 键,查看文本是否正确更新为 "S was pressed"。
如果你发现文本框不能完全显示文本,可以调整文本框的宽度和高度。例如,你可以将文本框的宽度调整为 300,这样可以显示更多的文本内容。
在进行这些更改时,记得保存你的场景。可以选择文件菜单中的 保存场景,并给场景命名为 Main 或其他你喜欢的名字。
通过这些步骤,你现在已经学会了如何在 Unity 中操作文本元素并通过编程动态更改其内容。在接下来的 Pong 游戏 中,我们将继续使用文本元素,并且在大多数游戏中,UI 元素(如文本、按钮等)都是必不可少的。
欢迎回来。在本视频中,我们将了解按钮的使用。按钮非常重要,因为它们允许我们在场景之间切换,例如可以用来选择游戏中的关卡、设置菜单等。按钮在 UI 中非常常见,因此了解如何使用它们非常重要。
为了在 UI 中添加按钮,我们需要在 Canvas 中创建一个按钮。在 Canvas 上右击,选择 UI,然后选择 Button。这样会在 Canvas 中添加一个按钮,并且按钮本身包含一个子元素,即 Text。你可以随时修改这个 Text,它与我们之前见过的文本类似,唯一的不同之处是它会根据父元素(按钮)的大小进行伸缩。因此,按钮中的文本会占据整个按钮的空间。
按钮本身有一些常用的属性,比如 Transform,我们之前已经操作过;它有宽度、高度等设置。按钮还包含一个 Image 组件,里面有一个名为 Source Image 的属性,你可以随时更改按钮的外观。如果你想更改按钮的外观,可以将一个新的图像拖动到 Source Image 属性中。
到目前为止,当我们按下按钮时,按钮会做出按下的反馈,但并不会发生任何其他事情。为了让按钮做出响应,我们需要编写一个脚本,定义按钮按下时应该执行的功能。你可以选择将该脚本添加到一个空对象上,然后将这个脚本绑定到按钮的 On Click 事件上,或者你也可以直接将脚本添加到按钮上。
我们将创建一个名为 ButtonBehavior 的脚本,并为按钮编写交互逻辑。当前我们希望按钮按下时,能够增加分数。首先,在脚本中,我们将访问 TextBehavior 脚本,从而更新显示的分数。
using UnityEngine.UI;
public class ButtonBehavior : MonoBehaviour {
public Text scoreText; // 引用文本
// 按钮按下时调用的方法
public void OnButtonPressed() {
scoreText.text = "Button was pressed"; // 当按钮按下时,更新文本
}
}
在 Unity 中,我们已经创建了 ButtonBehavior 脚本,现在我们需要将其绑定到按钮的 On Click 事件中。
这样,每次按下按钮时,OnButtonPressed 方法就会被触发,从而更新文本。
现在,我们可以运行游戏并测试按钮功能。点击按钮时,文本应更新为 “Button was pressed”。
如果你按下其他按键,比如 Space 或 S,文本将相应地更新为不同的内容(例如 “S was pressed” 或 “Button was pressed”)。
通过这些步骤,你学会了如何在 Unity 中使用按钮及其交互功能。下一节视频我们将介绍如何使用按钮来切换不同的场景。
欢迎回来。在本视频中,我将向你展示如何从一个场景跳转到另一个场景。首先,我们来创建一个新的场景。我们可以通过多种方式来创建场景,例如创建一个新场景,或者复制我们的主场景。我们这次直接创建一个新的场景,命名为 Level One。
现在,我们希望在 Level One 场景中点击按钮后返回到主场景,而在主场景中点击按钮时跳转到 Level One。为此,我们首先需要修改按钮的行为。打开按钮的行为脚本,并将 OnButtonPressed
方法改为加载不同的场景。
我们不再需要设置文本,而是需要使用 SceneManager
来实现从一个场景跳转到另一个场景。首先,添加 SceneManager
的命名空间:
using UnityEngine.SceneManagement; // 引用场景管理器
public class ButtonBehavior : MonoBehaviour {
public void OnButtonPressed() {
// 载入场景
SceneManager.LoadScene("Level One"); // 这里的字符串是场景的名称
}
}
在上面的代码中,我们通过调用 SceneManager.LoadScene
方法来加载名为 Level One 的场景。
当你尝试从一个场景加载另一个场景时,可能会遇到一个错误,提示场景没有添加到构建设置中。解决这个问题的方法是:
记住每个场景的索引位置,比如 Main Scene 是 0,Level One 是 1。
现在你已经能从主场景跳转到 Level One 场景,但如果你希望从 Level One 场景返回主场景,你需要在 Level One 中也添加一个按钮,并为其编写相应的跳转功能。
你可以通过以下步骤来完成:
using UnityEngine.SceneManagement;
public class ButtonBehavior : MonoBehaviour {
public void MoveToScene(int sceneID) {
SceneManager.LoadScene(sceneID);
}
}
除了通过按钮触发场景跳转外,你还可以在游戏逻辑中根据需要动态加载场景。例如,当玩家死亡时,自动跳转到 Game Over 场景,或当玩家完成某个关卡后跳转到下一个关卡。你可以在任何需要的地方调用 SceneManager.LoadScene
来实现这一功能。
在本视频中,你学会了如何使用按钮和方法在不同的场景之间跳转。你还学到了如何通过 SceneManager 动态加载场景,并根据按钮点击来触发场景切换。这种功能非常适用于游戏中的场景切换,如从主菜单进入游戏、从一个关卡进入下一个关卡等。
希望你能将这些知识应用到你的项目中,祝你编程愉快!
欢迎回来。在本视频中,我们将讨论如何在游戏中使用声音及其功能。你可以通过 Google 搜索“免版税游戏音效”,找到许多提供免费声音资源的网站,比如 1000 多个免费的音效、音乐曲目和游戏循环音效等资源。
例如,你可以访问一些网站,如 Free Game Music,它提供了非常适合游戏的背景音乐。你还可以尝试 Free Sound,这是一个由 Carsten Frosch 创建的网站,提供了多种音效资源,如太空音效、神秘的声音等。如果你需要一个光束的声音,只需要在这些网站中下载相应的音效即可。
我们来看看如何将声音导入到 Unity 并在游戏中播放。首先,你可以通过 Unity 的资源商店下载免费的音乐包。例如,可以下载由 Vertex Studio 提供的 Absolutely Free Music 资源包。你可以选择下载整个包,或者只导入你需要的音乐文件。
下载并解压资源包后,你会看到一个包含音频文件的文件夹。在 Unity 中,你可以将该音频文件拖拽到你的场景中。接下来,我们要将声音添加到一个对象上,比如一个立方体(Cube):
现在,音效已经被添加到立方体上。你可以调整以下属性:
你还可以通过代码来控制声音的播放。让我们创建一个新的脚本,命名为 SoundBehavior,并将其添加到立方体对象上。这个脚本将在玩家按下空格键时播放音乐。
using UnityEngine;
public class SoundBehavior : MonoBehaviour {
public AudioSource music; // 声明一个音频源
void Update() {
// 检查玩家是否按下空格键
if (Input.GetKeyDown(KeyCode.Space)) {
StartSound();
}
}
void StartSound() {
if (music != null) {
music.Play(); // 播放音乐
}
}
}
在脚本中,我们首先声明了一个 AudioSource
类型的变量 music
,然后在 Update 方法中检查玩家是否按下了空格键。如果按下了空格键,则调用 StartSound
方法来播放音乐。
接下来,将音频源(Audio Source)拖拽到 music 变量中,以确保脚本能够找到并播放音乐。
你还可以控制音乐的播放状态,例如:
void Update() {
if (Input.GetKeyDown(KeyCode.Space)) {
if (music.isPlaying) {
music.Pause(); // 暂停音乐
} else {
music.Play(); // 播放音乐
}
}
}
在这个实现中,当玩家按下空格键时,如果音乐正在播放,它将暂停;如果音乐没有播放,它将开始播放。
除了基本的播放和暂停,AudioSource
还提供了许多其他功能,以下是几个常用的方法:
例如,你可以使用 music.Stop()
来停止音频播放。
今天你学到了如何在 Unity 中使用音效,并通过代码控制它们的播放。你可以:
通过这些功能,你可以让你的游戏更加生动和有趣。如果你对音效的其他功能感兴趣,可以进一步探索 AudioSource 类的其他方法和属性。
欢迎来到本项目大纲和本视频。在这一部分,我将展示本章的最终结果,并告诉你如何构建这个项目。我会快速介绍一下,不想深入细节,因为你现在应该已经学到了大部分所需的知识。如果有些内容缺失,你可以参考文档,或者如果卡住了,可以观看接下来的视频,我会逐步讲解。如果你不想自己尝试构建游戏,也可以跟随视频一起做。
首先,让我们快速看一下我们正在构建的游戏。在我们的项目中,有多个不同的场景:游戏场景、游戏结束场景和主菜单。我们现在所打开的场景是 游戏场景,它包含了以下几个部分:
我创建了一个 Canvas 画布,里面有两个得分标签:一个是玩家 1 的得分,另一个是玩家 2 的得分。你需要编写脚本来更新得分,根据球的运动轨迹来改变得分。
游戏的主菜单包括一个标题 "Pong",以及一个“Play”按钮,点击后会进入游戏场景。
我强烈建议你尝试自己动手构建这个游戏,因为通过实际构建,你能学到更多的知识。即使你只是跟随视频教程学习,也会有所收获。如果你发现视频中没有解释某些功能,或者在文档中找不到你需要的信息,请告诉我,我会尽量补充。但是如果你能自己解决问题,会更有助于你理解如何寻找解决方案,这对你未来构建自己的项目非常有帮助。
继续尝试构建游戏,或者跟着视频一起学习。希望在下一个章节见到你!
在本视频中,我们将开始为我们的游戏创建主菜单。首先,我们需要创建一个新项目。打开 Unity,然后点击“新建”按钮,创建一个新的项目。我们将项目命名为 Pong Clone,并选择 2D 游戏类型。组织名称保持默认即可。然后点击创建项目,这个过程可能需要几秒钟,完成后 Unity 将会以新项目重新启动。
一旦项目打开,我们可以看到默认的场景名称为“Untitled”,因为我们还没有保存该场景,并且在场景中可以看到主摄像头(Main Camera)。在我的布局中,如你所见,右侧是检查器(Inspector),底部是层级视图(Hierarchy),还有项目视图(Project)和控制台(Console)标签。在中间,我的游戏和场景视图中只看到摄像头。场景中没有背景,但在游戏视图中,游戏已经有了背景。
首先,我们要修改主摄像头的背景。点击右侧检查器中的 Main Camera,在摄像头组件下找到 Background 属性,点击该属性可以选择背景颜色。我们将背景颜色设置为白色。
这时游戏视图中的背景变成了白色,但在场景视图中,背景颜色并没有变化。这里需要注意的是,Unity 的 2D 设置已经激活。你可以随时激活它来查看 3D 模式下的效果。在摄像头的 Transform 组件中,Z 值默认为 -10。如果将其设置为 0,摄像头的位置会有所变化。
接下来,我们将创建一个 Quad 作为背景。在主摄像头下,右击并选择“创建 3D 对象”>“Quad”。这时会创建一个正方形对象,我们将其缩放为 1600x900(16:9 比例)。然后,将其重命名为 Background。
如果我们缩放视图,就会发现主摄像头相对较小,而背景则非常大。为了解决这个问题,我们需要缩放摄像头。在 Camera 组件中,将 Size 设置为 450,这样背景的大小就与摄像头一致。
背景的颜色现在已经是白色,但背景的材质还没有设置。我们可以创建一个新的材质,将其命名为 Black,然后将其应用到背景上。接着,在材质的 Albedo 属性中选择黑色。如果激活 Emission,背景颜色会更加纯黑,虽然在黑色情况下,启用与否影响不大,但其他颜色会有所不同。
现在,我们看到场景中的背景已经是黑色,但游戏视图中的背景仍然是白色。这个问题是由于摄像头的 Z 值小于背景的 Z 值。为了让背景显示在游戏视图中,我们需要将背景的 Z 值设置为大于摄像头的值,譬如设置为 10,这样背景就会显示出来。
当前场景已经完成了设置,我们可以保存它。在左下角点击 保存场景,并将其命名为 Main Menu。这样,我们的主菜单场景就准备好了。Unity 中的“场景”就像剧场中的不同场景一样,可以用来表示游戏中的不同状态,例如:主菜单、游戏场景、游戏结束场景等。
接下来,我们将为游戏添加一些 UI 元素。首先,我们将添加一个 Text,作为游戏的标题。在左侧的层级视图中,右击 Main Menu 并选择 UI > Text,这时 Unity 会自动为我们创建一个 Canvas 和一个事件系统。UI 元素会被放置在 Canvas 上,这意味着 Canvas 与游戏中的对象无关,它只负责显示 UI 元素。
我们将 Canvas 的名称更改为 Main Menu,然后在 Text 组件中,将文本颜色更改为白色。
可能你会发现,Canvas 的大小相对于游戏视图来说非常小。这是因为游戏视图的大小被调整得很大。通常情况下,Canvas 会比游戏视图大很多。如果我们希望 Canvas 与背景和摄像头的大小一致,可以在 Canvas 组件中将 Render Mode 设置为 Screen Space - Camera,然后将主摄像头拖入 Camera 属性中。接下来,调整 Canvas 的 Scale 设置为 Scale With Screen,并将参考分辨率设置为 1600x900。这样,无论屏幕尺寸如何变化,UI 都会根据屏幕大小进行扩展。
由于文本太小且位置不合适,我们将调整其大小。将 Text 的宽度设置为 500,高度设置为 200,字体大小设置为 150。接着,为了让文本居中显示,我们将其 X 和 Y 坐标都设置为 0,这样它就会出现在屏幕的正中心。如果需要将其向上移动,可以调整 Y 坐标为 300。
接下来,我们将添加一个 Play 按钮。在 Canvas 下,右击并选择 UI > Button,这时会自动创建一个按钮。将按钮的宽度设置为 200,高度设置为 100。然后,我们将按钮的颜色改为黑色,并将其文本颜色设置为白色。为了使按钮文本更加清晰,我们还将按钮的 Font Size 设置为 120。
为了使按钮能够正常显示,我们需要调整它的尺寸。将按钮的高度设置为 130,这时你会看到按钮的文本溢出了。为了避免溢出,可以将按钮的高度调整为 300,然后将其位置设置为 Y = -200,使按钮位于屏幕底部。
Unity 默认的字体非常普通,并不适合游戏风格。我们可以在网上寻找一些有趣的游戏字体。我找到了一款叫 Monroe 的字体,它非常适合我们的游戏。下载并导入字体文件后,我们可以将其应用到标题文本和按钮文本中,立即看到效果更为精致。
到此为止,我们已经完成了主菜单的创建,包含了游戏标题和一个播放按钮。下一步,我们将添加播放按钮的功能,使玩家能够点击按钮进入游戏。在下一视频中,我们将通过编写脚本实现这一功能。
记得保存你的场景,这样你的更改才会生效。保存好场景后,我们就可以进入下一个视频了。
欢迎回来。在这段视频中,我们将创建游戏场景的第一部分,并通过使用播放按钮从当前场景切换到下一个场景。为了使播放按钮能够执行功能,我们需要给它添加一个脚本,否则它不会有任何作用。所以,我们需要创建一个新的脚本。我右键点击,创建一个新的脚本,并将其命名为 "PlayButton"(播放按钮),因为在本视频中我将脚本保持简单,因为最终创建多个只完成一件事的脚本会更容易管理,每个脚本只需要专注于完成它必须执行的一个重要任务,像这个播放按钮脚本也是如此。所以,这个脚本将只负责这个按钮,唯一的任务就是将我们从当前场景带到下一个场景。
首先,我们需要为这个播放按钮赋予功能。我们如何做到这一点呢?我们将创建一个新的方法,这个方法是 public void
,我将使用 PlayGame
作为方法名称。方法的名称首字母大写,因为在 C# 中方法名称通常是以大写字母开头的。接下来,我将使用 Debug
功能来进行简单的调试输出,即在控制台中打印 "Play Game was pressed"(播放按钮被按下)。目前,代码就这么简单,唯一的作用是创建一个调试日志。
public void PlayGame()
{
Debug.Log("Play Game was pressed");
}
这段代码的作用是,当按下播放按钮时,它会在控制台输出一条消息。接下来,保存这个脚本(按下 Ctrl + S
或 Cmd + S
),然后回到 Unity,选择我们的播放按钮。在主菜单中,你可以看到播放按钮右侧的按钮脚本组件,里面有一个 OnClick
区域,目前这个列表是空的。我们需要往这个列表中添加内容,点击加号按钮。在这里,我们可以选择一个对象,现在可以选择场景中的对象或者资产中的对象。最佳的做法是将刚刚创建的脚本拖入按钮中。
现在你可以看到,播放按钮脚本已经添加到按钮中了。接着,我们选择播放按钮本身作为 OnClick
的响应对象。在下拉菜单中选择我们刚才创建的 PlayGame
方法,如此一来,每当按钮被点击时,就会触发我们的方法。现在,保存并测试游戏,点击播放按钮,你可以在控制台中看到 "Play Game was pressed",这就表示按钮正常工作了。
现在我们知道按钮能够正常工作了,接下来我们要做的是让这个按钮实现从当前场景切换到下一个场景的功能。首先,我们需要创建一个新的场景。我们可以右键点击资产区,选择 "Create" -> "Scene",并命名为 "GameScene"(游戏场景),这是我们后续将实现游戏功能的场景。
进入新创建的游戏场景,你会看到这个场景是空的,没有任何东西。在这里,我们需要设置一个与主菜单场景相同的主相机和背景。你可以猜到,如何将这些设置从主菜单场景复制到游戏场景中,我来给你演示。
回到主菜单场景,选中主相机,将其拖动到资产区,从而创建一个预制体。你会看到在资产区中出现了一个新的主相机预制体。接下来,切换到游戏场景,删除原有的主相机(此时场景中的摄像头已经消失)。然后将刚才的主相机预制体拖到游戏场景中,你会发现它继承了主菜单场景中的所有设置,例如相机的大小(450),背景材质和黑色背景。
接下来,我们需要在脚本中添加代码,让按钮实现从一个场景切换到另一个场景的功能。为了实现这个功能,我们需要使用 SceneManager
类。要使用 SceneManager
,首先需要在脚本顶部导入相关的命名空间:
using UnityEngine.SceneManagement;
通过 SceneManager
,我们可以在不同场景之间切换。为了从当前场景切换到 "GameScene",我们使用 SceneManager.LoadScene
方法,并传入场景名称("GameScene")作为参数:
SceneManager.LoadScene("GameScene");
记住,传入的场景名称必须与实际场景名称完全一致,否则会报错。保存脚本后,回到 Unity 编辑器,重新运行游戏。点击播放按钮后,我们会发现场景切换成功了,控制台也没有错误。
如果你在运行时遇到错误,提示 "Scene could not be loaded because it has not been added to the build settings"(无法加载场景,因为它没有被添加到构建设置),这时你需要将场景添加到构建设置中。在 Unity 中,打开 "File" -> "Build Settings"(或者使用快捷键 Shift + Command + B),将你希望包含在构建中的场景从资产区拖到 "Scenes In Build" 列表中。完成后,重新运行游戏,你就能顺利切换场景了。
在项目中,我们应该保持资产的整洁。尽管目前只有几个文件,但随着游戏开发的进展,组织结构会变得非常重要。我们可以创建多个文件夹来管理不同的资产,例如:
PlayButton
脚本拖到该文件夹中。MainCamera
预制体拖到该文件夹中。MunroeFont
字体拖到该文件夹中。BlackMaterial
材质拖到该文件夹中。对于场景,你可以选择将所有场景文件保存在 "Assets" 文件夹的根目录下,或者你也可以创建一个专门的场景文件夹来存放这些场景文件。
现在,我们已经学会了如何创建和使用按钮,如何从一个场景跳转到另一个场景,以及如何组织和管理我们的游戏资产。下一步,在下一段视频中,我们将开始填充游戏场景,并进行一些整理工作。
现在我们已经有了主菜单,接下来我们将进入游戏场景。在这个场景中,我们需要添加一些元素:墙壁、两个球拍、玩家一和玩家二的名字、分数以及非常重要的中间的球。让我们开始吧。
首先,我们需要为游戏创建墙壁。我们将使用立方体来做这些墙壁。首先创建一个新的3D对象,选择立方体,并将其命名为“墙壁上方”。此时,立方体会非常小,不太适用,因此我们需要调整其比例。我们将X轴的缩放设置为1400,Y轴的缩放设置为5。接着,将其位置调整到Y轴的350,位置就像是上方的墙壁。
接下来,我们需要为这个墙壁添加一个白色材质。我们将创建一个新的材质,命名为“白色”,然后为其设置白色发光效果。最后,把这个材质拖拽到我们的墙壁上。
现在我们已经有了上方墙壁。我们可以复制这个墙壁并命名为“墙壁下方”。唯一需要调整的是Y轴的位置,将其值设置为-350。接下来,我们创建左右墙壁。右墙壁和左墙壁的X轴缩放不需要改变,依旧是5,但是我们需要把Y轴缩放设置为670。然后调整位置,右墙壁的X轴位置为700,左墙壁为-700。
现在,我们已经完成了四个墙壁的创建,接下来是中间的分隔线墙壁。
我们将创建一个新的空游戏对象,并命名为“中间墙壁”。这个墙壁将用于显示中间的虚线分隔线。首先,创建一个新的立方体,并将其命名为“中间墙壁1”。然后,我们需要调整它的比例,X轴设置为5,Y轴设置为100。接着,将白色材质拖拽到该墙壁上。
为了创建多个虚线,我们将复制这个墙壁。首先将第一个墙壁位置调整到(200, 0, 0),然后将其他两个墙壁分别放置到(140, 0, 0)和(280, 0, 0)的位置。
接下来,我们创建两个球拍,分别对应玩家一和玩家二。首先,创建一个新的空游戏对象,命名为“球拍”。然后,创建两个立方体,分别命名为“球拍玩家一”和“球拍玩家二”。为它们设置合适的大小,分别为X轴150,Y轴20,并调整位置,玩家一的球拍设置为-600,玩家二的球拍设置为600。记得为球拍添加白色材质。
接下来是球的创建。我们创建一个新的立方体,并命名为“球”。设置球的大小为X轴和Y轴为20,并将球的位置调整到(-100, 0, 0)。
我们接下来将创建HUD(头部显示区域),显示玩家的名字和分数。首先,创建一个UI文本,命名为“玩家一标签”,并将其拖拽到一个新的空游戏对象下,这样玩家一的名字和分数就会集中在同一个游戏对象中,方便管理。
我们还需要调整Canvas的设置。选择“画布”对象,在“画布”设置中,将其模式设置为“屏幕空间-摄像机”,并指定主摄像机。然后将画布缩放模式设置为“按屏幕缩放”,以便在不同分辨率下,UI能够自适应。
调整玩家一标签的位置,将其放置到左上角,位置为X轴125,Y轴54。接着调整“玩家一”的Canvas,设置X轴为-350,Y轴为300,宽度为700,高度为200。
接下来,调整字体。我们使用Monroe字体,并将字体颜色改为白色。调整字体大小,确保它能够清晰可见。然后,修改玩家一的分数标签。复制“玩家一标签”,并将其命名为“玩家一分数”。将分数标签的位置调整到右下角,设置X轴为-100,Y轴为50。将文本内容设置为0,并居中对齐。
为了为玩家二设置UI,复制玩家一的UI元素。改变玩家二的标签为“玩家二标签”和“玩家二分数”。调整玩家二标签的位置,将其放置到右上角,分数标签放置到左下角。记得调整相应的位置和对齐方式。
现在,我们已经完成了游戏场景的布局。我们有了墙壁、球拍、球和中间的虚线分隔。HUD显示了玩家的名字和分数。
接下来,我们将实现这些元素的功能,在下一个视频中将编写相关的代码来使游戏场景开始工作。此时,您可以试着重新构建这个场景,根据自己的喜好调整值、颜色和字体,享受创建游戏的过程。
在这段视频中,我们将处理我们的球。到目前为止,我们的球只是场景中间的一个小方块,我们需要为它添加功能,使它能够左右飞行,来回反弹,每当它碰到球拍时都会反弹。当然,我们还希望它能够反弹到顶部和底部的墙壁。现在我们暂时不让它与侧墙发生碰撞,但我们将为此添加一些功能,以便进行测试。接下来我们开始修改场景中的球。
首先,我们的球需要有一个碰撞器(Collider),目前它已经有一个了。接下来我们需要为球添加一个物理材料(Physical Material)。到目前为止,球没有物理属性,只有一个碰撞检测。换句话说,球只是一个能够发生碰撞的物体。如果我们希望球能动起来,就需要给它添加一个刚体(RigidBody)。如果我们希望球能够反弹,还需要添加一个物理材质。我们先来添加刚体。
首先我们要为球添加刚体(RigidBody)。如果我们想要使用物理效果,比如让球运动,我们必须确保球有一个物理刚体。点击“添加组件”(Add Component),选择“Physics”并添加一个“RigidBody 2D”。
接下来,我们会遇到一个错误:不能同时添加2D刚体和现有的3D碰撞器(Box Collider)。因为Box Collider(3D)不能与2D的RigidBody组件一起工作,所以我们需要删除当前的3D碰撞器,并添加2D碰撞器。
删除现有的Box Collider(3D),然后添加一个Box Collider 2D。在这里,偏移量(Offset)应设置为零。完成后,我们就能给球添加2D刚体了。
接下来,我们需要创建一个物理材质(Physics Material),这个材质将赋予我们的球物理特性,比如摩擦力和反弹力。我们进入资产(Assets)文件夹,右键选择“Create” -> “Physics 2D Material”,并命名为Ball
。
物理材质有两个重要的属性:
现在将创建的物理材质拖入到球的Box Collider 2D组件中。
为确保球能正常反弹,我们将重力的比例(Gravity Scale)设置为100,这样球会更快地掉下来。现在我们可以看到,球在碰到底部墙壁时会反弹,并且反弹的速度和高度是相同的。
由于我们的球使用了2D碰撞器(Box Collider 2D),而墙壁现在仍然使用的是3D的碰撞器,因此我们需要将所有墙壁的碰撞器从Box Collider(3D)改为Box Collider 2D。我们将逐一修改:
同样地,我们还需要修改球拍的碰撞器,将它们的碰撞器从Box Collider(3D)改为Box Collider 2D。然后,我们将偏移量(Offset)设置为零,并将大小(Size)设置为1x1
。
现在,球应该能反弹到球拍上了。为了让球从左到右飞行,我们还需要为球添加一个脚本。目前,球只是受重力影响,并没有其他运动。我们将在下一段视频中为球添加脚本,使它能够从左到右飞行。
欢迎回来。在本视频中,我们将为我们的球添加功能,使其能够左右移动并与球拍碰撞,这是我们预期的功能。接下来,我们当然还需要让我们的球拍也能移动,这是我们将在后续视频中做的事情。完成这些后,游戏就差不多完成了。我们现在开始为球创建一个新的脚本,命名为“BallMovement”。接着,我们可以创建并在 Visual Studio 中打开这个脚本。
在 Visual Studio 中,我们将看到 Start
和 Update
方法。由于我们不需要 Update
方法,可以将其删除。我们需要的是一个方法来控制球的移动速度,因此我们需要一个公开的变量,方便在 Unity 中调整它的值。这个变量可以命名为 movementSpeed
,通常,速度值在 Unity 中使用 float
类型,因为物理计算中大多使用浮动值。
public float movementSpeed; // 控制球的基本移动速度
此外,我们还需要一个变量来控制每次球与球拍碰撞后球的加速值,以便随着游戏进展,游戏难度逐渐增加。我们可以创建一个 float
类型的变量,命名为 extraSpeedPerHit
。这样,每当球与球拍碰撞时,球的速度会略微增加。
public float extraSpeedPerHit; // 每次碰撞后球的加速值
接着,我们需要一个最大速度值,这样球就不会一直加速,最终变得无法捕捉。我们设置一个上限 maxExtraSpeed
,以确保球的速度不会无限增长。
public float maxExtraSpeed; // 最大加速速度
还需要一个计数器,来记录球已经与球拍碰撞了多少次。这个计数器会帮助我们计算球的加速。
private int hitCounter; // 碰撞计数器
现在我们可以创建一个方法来控制球的移动。这个方法将是公开的,允许外部脚本调用。我们可以命名它为 MoveBall
,并接受一个 Vector2
类型的方向参数。
public void MoveBall(Vector2 direction)
{
direction = direction.normalized; // 标准化方向,确保它的大小为1
float speed = movementSpeed + (hitCounter * extraSpeedPerHit); // 计算速度
speed = Mathf.Min(speed, maxExtraSpeed); // 确保速度不会超过最大值
Rigidbody2D rb = this.gameObject.GetComponent<Rigidbody2D>(); // 获取刚体组件
rb.velocity = direction * speed; // 应用速度
}
为了避免在每帧都调用 MoveBall
,我们使用协程(Coroutine)来实现。协程允许我们控制游戏的节奏,确保在每次调用之间有间隔等待,而不是立即连续操作。我们将创建一个返回 IEnumerator
的公共方法 StartBall
。
public IEnumerator StartBall(bool isPlayerOne = true)
{
hitCounter = 0; // 重置碰撞计数器
yield return new WaitForSeconds(2f); // 等待2秒
Vector2 startDirection = isPlayerOne ? new Vector2(-1, 0) : new Vector2(1, 0); // 判断是玩家1还是玩家2开始
MoveBall(startDirection); // 调用 MoveBall 方法
}
在启动球的移动时,我们会调用协程 StartBall
,并指定哪个玩家开始游戏。我们将在 Unity 中使用 StartCoroutine
来启动这个协程。
StartCoroutine(StartBall(true)); // 假设是玩家1开始
现在我们已经完成了球的基础移动逻辑。接下来,在 Unity 中测试时,我们需要调整球的质量和重力比例。为了让球变得非常轻,我们将质量设置为 0.01,并且将重力比例(Gravity Scale)设置为 0,因为球不需要受到重力影响。
Rigidbody2D rb = this.gameObject.GetComponent<Rigidbody2D>();
rb.mass = 0.01f; // 设置质量
rb.gravityScale = 0f; // 去除重力影响
在 Unity 中,我们将为 movementSpeed
、extraSpeedPerHit
和 maxExtraSpeed
设置初始值。我们可以将 movementSpeed
设置为 400,extraSpeedPerHit
设置为 50,maxExtraSpeed
设置为 1000。这些值是在测试后得出的合理数值。
movementSpeed = 400f;
extraSpeedPerHit = 50f;
maxExtraSpeed = 1000f;
当我们点击播放按钮时,球将等待 2 秒后开始向左移动(因为是玩家1开始),然后来回反弹。在开始时,球的速度应该是恒定的,不会变化,直到我们碰撞球拍时,球的速度才会增加。
为了让球在每次碰撞后加速,我们需要创建一个方法 IncreaseHitCounter
,它会增加碰撞计数器的值。每次碰撞后,我们会调用此方法。
public void IncreaseHitCounter()
{
if (hitCounter * extraSpeedPerHit < maxExtraSpeed)
{
hitCounter++; // 增加碰撞计数器
}
}
这个方法将被球拍脚本调用,以便在每次碰撞时增加球的速度。我们将在后续的视频中详细讲解如何实现球拍的脚本。
到目前为止,我们已经完成了球的基础移动逻辑,并通过调整碰撞计数来增加游戏的难度。我们还在 Unity 中进行了一些测试,并调整了球的运动参数。下一步,我们将继续完善球拍的控制逻辑,确保游戏可以正常进行。
感谢收看,我们在下一个视频中继续!
欢迎回来。在这个视频中,我们将处理我们的球拍。到目前为止,球拍无法移动,这是我们要在本视频中解决的问题。为此,我们需要两个脚本,一个用于玩家一的球拍,另一个用于玩家二的球拍。我们先添加一个新组件,创建一个新的脚本,命名为 RecordPlayerOne
。至于 RecordPlayerTwo
脚本,您可以自己尝试编写。接下来,我们先处理 RecordPlayerOne
脚本。
在 RecordPlayerOne
脚本中,我们需要做的非常简单,主要就是一件事:创建一个 FixedUpdate
方法。FixedUpdate
是 Unity 中的一个内部方法,它会以固定的频率(通常为每秒 60 次)被调用。我们需要在这个方法中检查用户是否按下了某个按钮,如果用户按下了特定按钮,那么球拍就应该移动,并且应该按照特定的速度移动。
我们需要一个 public float
类型的 movementSpeed
变量。一个合理的值可能是 200,意味着球拍的移动速度是 200。接下来我们需要创建 FixedUpdate
方法。这个方法会被 Unity 自动调用,并且会不断检查是否有按钮按下。
在方法内部,我们需要检查用户是否按下了控制球拍移动的按钮,并把结果保存为一个浮动的 v
值。我们可以通过 Unity 的 Input.GetAxis
方法来获取用户输入。在这里,我们使用的是 "Vertical"
轴,这个轴会根据玩家按下的按钮返回一个值:
"W"
键,返回值为 1,表示球拍应该向上移动。"S"
键,返回值为 -1,表示球拍应该向下移动。这样,我们就能根据输入的值来控制球拍的移动。
public float movementSpeed = 200f;
void FixedUpdate() {
// 获取玩家输入的垂直方向
float v = Input.GetAxis("Vertical");
// 获取当前球拍的刚体组件
Rigidbody2D rb = GetComponent<Rigidbody2D>();
// 修改刚体的速度,保持X轴不变,Y轴根据玩家输入进行调整
rb.velocity = new Vector2(0, v * movementSpeed);
}
在 Unity 中,我们通过 Edit
-> Project Settings
-> Input
访问输入设置。默认情况下,Vertical
轴已经设置好,其中 "W"
和 "S"
键用于垂直方向的控制。如果要为玩家二创建类似的设置,我们需要做一些调整。可以按照以下步骤操作:
Vertical2
。"DownArrow"
和 "UpArrow"
,这样就能使玩家二使用上下箭头键来控制球拍的移动。完成这些步骤后,Vertical
轴就对应玩家一,Vertical2
轴就对应玩家二。
为了让球拍不受重力的影响,我们需要为球拍添加 Rigidbody2D
组件,并设置重力值为 0。同时,为了避免球拍因物理引擎的影响发生旋转,我们需要冻结球拍在 Z 轴上的旋转。
在 Unity 中,我们需要做一些调试来确保球拍能按预期移动。首先,我们为 RecordPlayerOne
脚本设置一个合理的 movementSpeed
,比如 200。然后检查球拍是否能够根据用户的输入(使用 "W"
和 "S"
键)在屏幕上移动。
现在,我们需要为玩家二添加 RecordPlayerTwo
脚本。你可以参考 RecordPlayerOne
脚本,只需要调整 Input.GetAxis("Vertical2")
来控制玩家二的球拍移动。其余代码和设置基本相同。
确保玩家二的球拍也有 Rigidbody2D
组件,并且同样禁用重力,冻结 Z 轴旋转。然后,设置 movementSpeed
使得玩家二的球拍移动速度合理。
为了让游戏的难度不平衡,你可以调整玩家一和玩家二的球拍速度。例如,给玩家二的球拍一个更高的移动速度,这样玩家二就会更容易击中球。反之,你也可以降低玩家一的球拍速度,以增加游戏的挑战性。
一旦完成了所有设置,我们需要做一些清理工作。将 RecordPlayerOne
和 RecordPlayerTwo
脚本拖入 Scripts
文件夹,并保存场景。这样,我们的球拍就可以根据玩家的输入进行移动了。
在下一个视频中,我们将进一步优化球拍的物理效果。目前,球只有在与球拍碰撞时沿水平方向(X 轴)移动,而我们希望在碰撞的不同位置(球拍的顶部或底部)产生不同的反弹效果。这样可以让 Pong 游戏更具挑战性和趣味性。
欢迎回来。在本视频中,我们将处理球的反弹逻辑,使得游戏更加互动并且有趣。你可以看到,球从左侧飞来并撞击到球拍时,如果它撞到球拍的底部,球应该朝下反弹。所以 Y 值应该是负数。如果它撞到球拍的顶部,球应该朝上反弹,所以 Y 值应该是正数。为了实现这一点,我们需要添加一个脚本,这个脚本将被添加到球体上。我们将创建一个新的脚本,命名为 Collision Controller
,它将控制我们的碰撞检测,检查碰撞的位置以及碰撞的对象等。我们不仅需要检测球拍的碰撞,还要检测上下墙壁的碰撞。
Collision Controller
脚本首先,我们需要创建一个新的脚本并添加到球体上。在 Unity 中创建一个新的脚本,命名为 Collision Controller
。该脚本将处理碰撞逻辑,并决定球的反弹方向。我们还需要引用球的移动控制器,以便能够访问它的移动方法。
public class CollisionController : MonoBehaviour
{
public BallMovement ballMovement; // 引用球的移动控制器
// 处理球与球拍的反弹
void BounceFromRacket(Collision2D c)
{
Vector3 ballPosition = this.transform.position; // 获取球的位置
Vector3 racketPosition = c.gameObject.transform.position; // 获取球拍的位置
float racketHeight = c.collider.bounds.size.y; // 获取球拍的高度
// 计算 Y 方向的反弹
float y = (ballPosition.y - racketPosition.y) / racketHeight;
// 判断是玩家 1 还是玩家 2
float x = (c.gameObject.name == "RacketPlayer1") ? 1 : -1; // 玩家 1 从左往右,玩家 2 从右往左
// 调用球的移动方法
ballMovement.MoveBall(new Vector2(x, y));
// 增加击球计数,进而加速球的移动
ballMovement.IncreaseHitCounter();
}
// 碰撞进入检测
void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.name == "RacketPlayer1" || collision.gameObject.name == "RacketPlayer2")
{
BounceFromRacket(collision); // 如果碰撞的是球拍,调用反弹逻辑
}
else if (collision.gameObject.name == "WallLeft")
{
Debug.Log("Collision with Wall Left");
// 在这里增加玩家 2 的分数
}
else if (collision.gameObject.name == "WallRight")
{
Debug.Log("Collision with Wall Right");
// 在这里增加玩家 1 的分数
}
}
}
在上面的代码中,我们做了以下几件事:
计算反弹方向: 我们首先获取球和球拍的位置,并计算球与球拍顶部或底部的碰撞点。这是通过 ballPosition.y - racketPosition.y
计算的。然后,计算结果除以球拍的高度(racketHeight
),以得到 Y 轴的反弹强度。
判断玩家: 根据碰撞的是玩家 1 还是玩家 2,分别设置 X 轴的反弹方向。如果是玩家 1,X 方向是正值(从左往右);如果是玩家 2,X 方向是负值(从右往左)。
增加击球计数: 调用 ballMovement.IncreaseHitCounter()
方法,每次击球时增加计数,从而加速球的速度。
处理碰撞: 在 OnCollisionEnter2D
中,我们根据碰撞对象的名称判断是与球拍还是墙壁碰撞。如果是墙壁,我们会进行相应的分数处理。
接下来,我们需要更新 BallMovement
脚本,确保它能够接收新的移动方向并根据 Y 值的变化控制球的运动。
public class BallMovement : MonoBehaviour
{
private int hitCounter = 0; // 记录击球次数
private float speed = 10f; // 初始速度
public void MoveBall(Vector2 direction)
{
// 设定球的速度和方向
this.GetComponent<Rigidbody2D>().velocity = direction * speed;
}
public void IncreaseHitCounter()
{
// 每次击球时增加计数器,进而提高速度
hitCounter++;
speed = Mathf.Min(20f, speed + hitCounter * 0.5f); // 增加速度
}
}
给球体添加刚体: 为了使球能够根据物理引擎反弹,我们需要给球体添加一个 Rigidbody2D
组件。确保 Gravity Scale
设置为 0,防止重力影响球的运动。
连接脚本: 在 Unity 中,确保 CollisionController
脚本已经正确连接到球体,并且将 BallMovement
脚本作为公共变量关联到 CollisionController
脚本中。
在 Unity 中运行游戏,球应该能够根据球拍的顶部或底部位置反弹。如果球碰到球拍的顶部,应该朝上反弹;如果碰到底部,应该朝下反弹。每次击球后,球的速度会逐渐加快,这样游戏会变得更加具有挑战性。
到目前为止,我们已经实现了球的反弹逻辑,确保球会根据它与球拍的碰撞位置反弹。接下来,我们将继续完善分数系统,并让球重新回到场地中央。感谢观看,下一集我们将开始实现这些功能。
欢迎回来。在本视频中,我们将添加一个得分系统。我们需要做的是创建一个新的脚本,名为 ScoreController
。让我们开始吧,首先我们需要将这个脚本添加到场地对象中,因为场地将充当我们的得分控制器(ScoreController
)游戏对象。那么我们来创建一个新的脚本,命名为 ScoreController
,然后编辑它。
ScoreController
脚本在脚本中,我们需要记录玩家的得分。首先,我们需要为每个玩家创建一个变量来保存他们的得分。
public class ScoreController : MonoBehaviour
{
private int scorePlayer1 = 0; // 玩家1的得分
private int scorePlayer2 = 0; // 玩家2的得分
public GameObject scoreTextPlayer1; // 玩家1得分的UI文本
public GameObject scoreTextPlayer2; // 玩家2得分的UI文本
public int goalsToWin = 5; // 获胜所需的得分
// 增加玩家1的得分
public void GoalPlayer1()
{
scorePlayer1++;
}
// 增加玩家2的得分
public void GoalPlayer2()
{
scorePlayer2++;
}
// 更新UI
void FixedUpdate()
{
UpdateScoreUI();
}
// 更新得分的显示
void UpdateScoreUI()
{
Text uiScorePlayer1 = scoreTextPlayer1.GetComponent<Text>(); // 获取玩家1得分的文本组件
uiScorePlayer1.text = scorePlayer1.ToString(); // 设置玩家1的得分文本
Text uiScorePlayer2 = scoreTextPlayer2.GetComponent<Text>(); // 获取玩家2得分的文本组件
uiScorePlayer2.text = scorePlayer2.ToString(); // 设置玩家2的得分文本
}
// 检查是否有玩家获胜
void Update()
{
if (scorePlayer1 >= goalsToWin)
{
Debug.Log("Player 1 wins!");
// 在这里我们通常会切换到下一个场景
}
else if (scorePlayer2 >= goalsToWin)
{
Debug.Log("Player 2 wins!");
// 在这里我们通常会切换到下一个场景
}
}
}
CollisionController
脚本来增加得分在我们的碰撞控制器中,我们需要在球撞到墙壁时增加得分。如果球碰到左边的墙壁,我们会给玩家 2 增加分数;如果碰到右边的墙壁,我们会给玩家 1 增加分数。我们还需要将 ScoreController
脚本与碰撞控制器关联起来。
public class CollisionController : MonoBehaviour
{
public ScoreController scoreController; // 引用ScoreController脚本
void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.name == "WallLeft")
{
Debug.Log("Collision with Wall Left");
scoreController.GoalPlayer2(); // 玩家2得分
}
else if (collision.gameObject.name == "WallRight")
{
Debug.Log("Collision with Wall Right");
scoreController.GoalPlayer1(); // 玩家1得分
}
}
}
连接 ScoreController
脚本: 确保将 ScoreController
脚本添加到场地游戏对象上,并将对应的 UI 文本对象(例如显示得分的文本)拖放到 scoreTextPlayer1
和 scoreTextPlayer2
变量中。
设置获胜目标: 在 ScoreController
脚本的 goalsToWin
变量中设置获胜所需的得分,比如设定为 5 分。
连接 CollisionController
脚本: 将 ScoreController
脚本引用到 CollisionController
中,并确保正确连接。
运行游戏并测试得分系统。当球碰到左右墙壁时,玩家的得分应该会增加,并且 UI 上的得分文本会实时更新。
当其中一位玩家的得分达到设定的获胜分数时,控制台会打印出该玩家获胜的消息。
为了防止球体旋转,我们可以冻结球的 Z 轴旋转。在 Unity 编辑器中,选择球体并勾选 Rigidbody2D
组件中的 Z 轴冻结旋转选项。
目前的代码只会记录得分并显示获胜者的消息。接下来,我们需要添加一个功能,用于在游戏结束时重置游戏状态,重新开始一个新的回合。这个功能将在下一个视频中实现,我们还将添加一个“游戏结束”屏幕。
到目前为止,我们已经实现了简单的得分系统,每当球击中左右墙壁时,玩家的得分就会增加。UI 也会实时更新,并且当某个玩家获得足够的分数时,游戏会显示获胜消息。接下来,我们将继续完善游戏,添加游戏结束画面并重置游戏。感谢观看,我们下次见!
欢迎回来。在本视频中,我们将创建重置功能。也就是说,每当有玩家得分时,球将根据当前玩家的回合被重置到特定位置。如果是玩家 1 的回合,球将在左侧;如果是玩家 2 的回合,球将在右侧。除此之外,我们还将添加游戏结束界面,在达到某个得分时,玩家将进入游戏结束画面。好,现在让我们开始吧!
首先,我们需要在 BallMovement
脚本中添加一个方法,用来根据当前是哪个玩家的回合来重置球的位置。我们还需要在得分后停止球的移动。
public class BallMovement : MonoBehaviour
{
// 重置球的位置方法
public void PositionBall(bool isStartingPlayer1)
{
// 停止球的移动,将速度设置为 0
Rigidbody2D rb = this.GetComponent<Rigidbody2D>();
rb.velocity = Vector2.zero;
// 如果是玩家1的回合,球重置到左侧
if (isStartingPlayer1)
{
this.gameObject.transform.localPosition = new Vector3(-100, 0, 0); // 球位置在左侧
}
else
{
// 否则是玩家2的回合,球重置到右侧
this.gameObject.transform.localPosition = new Vector3(100, 0, 0); // 球位置在右侧
}
}
}
接下来,我们要在 CollisionController
脚本中添加逻辑,当球撞到墙壁时,根据得分情况调用 PositionBall
方法来重置球的位置。
public class CollisionController : MonoBehaviour
{
public BallMovement ballMovement; // 引用 BallMovement 脚本
// 当球与墙壁发生碰撞时
void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.name == "WallLeft")
{
// 如果碰到左边的墙壁,玩家2得分,球重置到右侧
StartCoroutine(ballMovement.StartBall(true)); // 玩家1得分后,重置球在左侧
}
else if (collision.gameObject.name == "WallRight")
{
// 如果碰到右边的墙壁,玩家1得分,球重置到左侧
StartCoroutine(ballMovement.StartBall(false)); // 玩家2得分后,重置球在右侧
}
}
}
我们已经成功地实现了得分和球重置的功能,接下来我们需要添加游戏结束画面。我们将在下一个视频中实现这个功能,同时还将添加音效来增强游戏体验。
本视频中,我们做了以下几项工作:
PositionBall
方法,重置球的位置并停止其移动。CollisionController
中根据球与墙壁的碰撞,调用 PositionBall
来重置球的位置。在下一个视频中,我们将实现游戏结束界面并为游戏添加音效。感谢观看,我们下次见!
欢迎回来。在本视频中,我们将为游戏添加游戏结束场景。首先,我们将复制并修改主菜单场景,改成游戏结束场景。接着,我们需要实现从游戏场景到游戏结束场景的跳转。最后,我们还会讨论如何处理胜者的显示,尽管这部分我们将在后续的项目中进一步探讨。
首先,在 Unity 编辑器中,我们复制主菜单场景。可以使用 Mac 上的 Command + D
或 Windows 上的 Ctrl + D
来复制场景。
GameOver
。GameOver
场景。Pong
文本修改为 Game Over
。Play
按钮文本修改为 Replay
。接下来,查看按钮的功能。当前的按钮使用的是 PlayGame
脚本,我们需要检查这个脚本。这个脚本的功能是加载下一个场景:
public class PlayButton : MonoBehaviour
{
public void PlayGame()
{
SceneManager.LoadScene("Game"); // 加载游戏场景
}
}
由于我们想在游戏结束时跳转到 GameOver
场景,接下来要调整现有的脚本或在需要的时候使用它。
现在,我们需要从游戏场景跳转到游戏结束场景。打开 ScoreController
脚本,找到显示 "Game Won"
的 Debug.Log
,并在此时实现场景的跳转。
SceneManagement
命名空间:using UnityEngine.SceneManagement;
Game Won
的地方添加代码跳转到 GameOver
场景:if (this.scorePlayer1 > this.goalsToWin || this.scorePlayer2 > this.goalsToWin)
{
Debug.Log("Game Won");
SceneManager.LoadScene("GameOver"); // 加载游戏结束场景
}
在 Unity 中,确保所有场景都已添加到构建设置中。否则,无法加载场景。按照以下步骤进行操作:
File
-> Build Settings
。GameOver
场景拖动到场景列表中。GoalsToWin
为 1(便于测试)。GameOver
场景。GameOver
场景中,点击 Replay
按钮,看看是否返回到游戏场景。在本视频中,我们完成了以下任务:
GameOver
场景。ScoreController
中实现了从游戏场景到游戏结束场景的跳转。GameOver
场景被添加到构建设置中。如果你想进一步显示哪个玩家获胜,可以使用静态变量来存储获胜玩家的信息,或者使用 DontDestroyOnLoad
来跨场景共享数据。我们将在后续的项目中详细讨论这些内容。
感谢观看,我们下次见!
欢迎回来!在本视频中,我们将为我们的游戏添加音效。我们将介绍如何添加碰撞音效,如球击中墙壁或球拍时的声音。接下来,我们还将创建一个 SoundController
脚本来管理这些音效,并将它们正确地绑定到球对象上。
首先,访问 OpenGameArt.org,在该网站上你可以找到很多免费的游戏音效资源。例如,你可以搜索“ball”音效,并找到类似乒乓球的撞击声。这些声音可以用于当球击中墙壁时播放。
你可以下载这些音效文件,或者如果你使用的是本项目中提供的音效文件,可以直接将其添加到 Unity 项目中。
为了管理和存放这些资源,我们先创建一个 Resources
文件夹。在 Resources
文件夹中,我们将放置所有的音效文件、预制件、材质和脚本:
Assets
目录下,创建一个名为 Resources
的新文件夹。SoundController
脚本接下来,我们需要为球添加一个新的脚本,负责处理音效。为此,选择 Ball
游戏对象并创建一个新的脚本,命名为 SoundController
。脚本内容如下:
using UnityEngine;
public class SoundController : MonoBehaviour
{
public AudioSource wallSound; // 墙壁音效
public AudioSource racketSound; // 球拍音效
// 每当发生碰撞时调用
void OnCollisionEnter2D(Collision2D collision)
{
// 判断碰撞对象是否是球拍
if (collision.gameObject.name == "RacketPlayer1" || collision.gameObject.name == "RacketPlayer2")
{
racketSound.Play(); // 播放球拍声音
}
else
{
wallSound.Play(); // 播放墙壁声音
}
}
}
在 SoundController
脚本中,我们有两个 AudioSource
组件:一个用于球拍的音效,另一个用于墙壁的音效。接下来,我们需要为球创建这些音频源,并将音频文件分配给它们:
Ball
游戏对象。Ball
游戏对象,选择 Create Empty
创建空对象,并命名为 AudioSource
。Ball
对象下创建两个 AudioSource
组件,分别命名为 RacketSound
和 WallSound
。AudioClip
属性中。Play On Awake
,以防音效在游戏开始时自动播放。返回 SoundController
脚本,确保它正确地引用了两个 AudioSource
组件:
SoundController
脚本添加到 Ball
游戏对象。Ball
的 Inspector
窗口中,看到 SoundController
脚本的 RacketSound
和 WallSound
字段。RacketSound
音效拖到 RacketSound
字段,将 WallSound
音效拖到 WallSound
字段。现在,播放游戏并测试音效:
在添加音效后,我们可以考虑其他的改进:
Player 2
创建一个 AI 对手。AI 玩家可以根据球的位置自动移动球拍。你需要考虑如何让 AI 玩家根据球的位置来判断移动。在本视频中,我们完成了以下任务:
SoundController
脚本来处理音效。Ball
游戏对象中配置并绑定了音频源组件。你还可以继续改进和扩展这个游戏,添加更多功能并优化游戏体验。挑战之一是为 Player 2
实现 AI 控制,这需要你思考如何通过编程控制球拍的移动,确保 AI 能够根据球的坐标进行判断。
祝你在接下来的章节中学到更多有用的知识,并享受改进游戏的过程!
欢迎回来!在本视频中,我们将为 Player 2
添加一个基本的 AI 控制,来让它自动跟随球的运动。这个任务是一个小挑战,我强烈建议你自己动手尝试。通过这个练习,你可以学习如何通过代码控制物体的移动。你之前已经做过类似的操作,但这次我们会以不同的方式进行,AI 将会跟随球的运动。你可以暂停视频,自己尝试一下,完成后再查看我如何做。如果你不想自己尝试,你也可以直接观看我演示的实现过程。
RecordPlayer2AI
脚本首先,我们需要为 Player 2
创建一个新的脚本,用来控制其 AI。脚本命名为 RecordPlayer2AI
。这个脚本需要做两件事:一是设置移动速度,二是让 Player 2
根据球的位置来移动。
using UnityEngine;
public class RecordPlayer2AI : MonoBehaviour
{
public float movementSpeed = 200f; // 移动速度
public GameObject ball; // 球的引用
// 每帧更新,固定时间步长更新
void FixedUpdate()
{
// 计算球和球拍之间的 Y 轴位置差
float distance = Mathf.Abs(transform.position.y - ball.transform.position.y);
// 如果球和球拍的 Y 轴距离超过 50
if (distance > 50)
{
// 如果球在球拍的上方
if (transform.position.y > ball.transform.position.y)
{
// 向上移动球拍
GetComponent<Rigidbody2D>().velocity = new Vector2(0, 1) * movementSpeed;
}
else
{
// 向下移动球拍
GetComponent<Rigidbody2D>().velocity = new Vector2(0, -1) * movementSpeed;
}
}
else
{
// 如果球和球拍的距离小于 50,停止移动
GetComponent<Rigidbody2D>().velocity = Vector2.zero;
}
}
}
Player 2
绑定脚本和配置参数RecordPlayer2AI
脚本添加到 Player 2
的球拍对象上。Inspector
中,你会看到 movementSpeed
和 ball
两个公共字段。movementSpeed
为 200。Ball
对象拖到 ball
字段中,以便 AI 能够跟踪球的位置。保存场景并开始播放游戏,看看 Player 2
是否能够自动跟随球:
Player 2
的球拍应该会自动跟随球的 Y 轴位置。在这个任务中,你学会了如何为一个玩家(Player 2
)创建基本的 AI 控制,来使其自动跟随球的运动。尽管这是一个非常简单的 AI 控制,但它已经可以根据球的位置做出反应,这为你以后创建更复杂的 AI 系统打下了基础。
挑战:尽管我们已经为 Player 2
实现了一个简单的跟随 AI,你可以尝试进一步改进它,例如:
Player 2
添加一个自动暂停或等待的机制,使其不会总是全力追击球。无论如何,这个 AI 只是一个起点,随着你对游戏开发的理解加深,你可以不断优化它。
在接下来的章节中,你将学习更多的技巧,并能够将这些技巧应用到你的游戏中。希望你能享受这个过程,并尝试实现这些挑战!我们下个视频再见!
恭喜你完成了 Pong 游戏的构建!当然,虽然你是跟着特定的教程一步步进行的,但现在的关键是,你可能还不能完全独立地从零开始重建这个游戏。不过,这其实是很正常的,因为游戏开发从来就不是一蹴而就的。
如果你遇到一个功能不懂如何实现,通常的做法是去 Google 搜索、查看相关的教程或示例代码,再根据这些资源进行实验和测试。实际上,开发过程中很多时候是通过查找资料、参考其他开发者的示例和经验来解决问题的。这是开发者日常生活中的一部分。你不必担心自己现在不能完全独立完成一个游戏,随着你逐渐积累经验,这种能力会自然提高。
游戏开发就像是一个不断 积累和实验 的过程。只要你不断构建更多的原型、不断尝试新的功能,逐步你会变得更加熟练,掌握更复杂的技巧。每次完成一个项目,无论是简单还是复杂,都会让你离成为一个 优秀的游戏开发者 更近一步。
当你完成了这些基础的项目之后,就可以开始挑战更高级的内容了。掌握了基本的原理和技能后,你可以更自信地面对更复杂的开发任务。这样,你会逐渐深入理解如何高效地开发游戏,并且开始具备开发复杂游戏的能力。
感谢你坚持到最后,继续跟着我一起学习。希望你在本章节中学到的内容能够帮助你顺利进阶,掌握更多游戏开发的技能。下一章将带来更高级的挑战,我们一起继续前行!
希望你保持热情,玩得开心,学得更多!
在这个章节中,我们将一起制作 Zig Zag 游戏。Zig Zag 是一个非常基础的 3D 游戏,操作也非常简单。游戏唯一的输入就是 点击屏幕,这意味着玩家只需要在正确的时机点击屏幕,就可以玩得非常开心。尽管如此,Zig Zag 依然是一个非常上瘾且有趣的游戏,我真的很佩服那些设计出这种简单却如此吸引人的游戏的人。
在制作这个游戏的过程中,你将学到以下内容:
我们将会详细讲解如何从头开始构建这个游戏,包括关卡设计、物理效果、动画控制、粒子系统的实现等内容。你将能够通过这次练习学到很多关于 3D 游戏开发的技巧。
所以,准备好开始制作这个简单但又极具魅力的 Zig Zag 游戏了吗?让我们一起深入探索游戏开发的奥秘,看看如何用最少的输入打造出让玩家欲罢不能的游戏体验!我希望你已经迫不及待了,快来加入我们吧!
下一段视频我们将正式开始创建这个游戏,敬请期待!
在这个章节中,你将学习如何构建一款成功的 Zig Zag 克隆游戏。这款游戏类似于目前非常流行的 无尽跑酷游戏,其玩法简单但充满挑战,玩家可以享受几乎 无尽的游戏体验。你将学习如何使用 3D 角色,如何根据自己的需求自定义玩家的运动方式,而不是依赖预设的行为,如何创建 可收集物品 来增加得分,还会学习如何创建 3D 世界,并且更重要的是,如何让这个世界通过 程序化生成 扩展。最后,你还会学习如何在游戏中使用声音效果以及如何通过重置场景来重置游戏。
这个章节的目标是让你能够从零开始构建一个功能齐全的 Zig Zag 风格游戏,并且在过程中学习游戏开发的许多关键概念和技巧。无论你是刚接触 3D 游戏开发还是已经有一定经验,本章都会给你带来宝贵的实践经验,帮助你在游戏开发的路上走得更远。
我希望你能享受这个章节,学到新的技能,并在构建游戏的过程中获得乐趣!在接下来的章节里,我们将深入探讨每一个环节,帮助你完成这款令人上瘾的游戏。
准备好开始了吗?让我们一起动手创建这个精彩的游戏吧!
在这个视频中,我们将学习如何实例化对象。为了演示这个概念,我们首先创建一个新的 3D 对象。我将使用一个立方体,并希望通过代码将这个立方体实例化多次。
首先,我们创建一个立方体并稍微旋转它,以便更好地查看。你可以为它添加一个材质,这样更容易区分它。或者,我们可以将背景颜色设置为纯色,这样立方体就会更清晰地显示出来。
接下来,我们将立方体保存为一个 Prefab,这样可以在代码中实例化多个立方体。
Script
。Script
。现在,我们要在空对象上添加一个脚本,命名为 InstantiateCubes
,并编写代码来实例化多个立方体。
using UnityEngine;
public class InstantiateCubes : MonoBehaviour
{
public Transform prefab; // 用于存储立方体的 Prefab
void Start()
{
for (int i = 0; i < 10; i++) // 创建 10 个立方体
{
// 通过 Instantiate 创建克隆对象
Instantiate(prefab, new Vector3(i * 3.0f, 0, 0), Quaternion.identity);
}
}
}
InstantiateCubes
脚本附加到 Script
对象上。Cube Prefab
拖动到脚本的 Prefab
属性上。如果我们希望立方体从不同的起始位置开始,可以调整实例化的位置。例如,将 X 坐标设置为 -15 + i * 3.0f
,这样立方体就不会从 0 开始,而是从负值开始,形成更加清晰的排列。
Instantiate(prefab, new Vector3(-15 + i * 3.0f, 0, 0), Quaternion.identity);
除了在 Start
方法中自动实例化对象,我们还可以通过用户输入来触发实例化。例如,每次按下空格键时,实例化一个新的立方体。修改代码如下:
using UnityEngine;
public class InstantiateCubes : MonoBehaviour
{
public Transform prefab; // 用于存储立方体的 Prefab
private int counter = 0; // 计数器,用于跟踪创建的立方体数量
void Update()
{
if (Input.GetKeyDown(KeyCode.Space)) // 按下空格键时创建新的立方体
{
Instantiate(prefab, new Vector3(-15 + counter * 3.0f, 0, 0), Quaternion.identity);
counter++; // 每次实例化时,增加计数器
}
}
}
在这种情况下,每当按下空格键时,新的立方体将在 X 坐标上按顺序排列,并且随着每次实例化,计数器都会递增。
保存脚本并运行场景。每次按下空格键时,新的立方体就会出现在屏幕上,每个立方体之间的间距为 3 单位。通过这种方式,我们可以动态地创建对象。
现在你已经学会了如何通过代码实例化对象。无论是自动在开始时创建多个对象,还是通过用户输入动态生成对象,这个技巧都会在后续的项目中非常有用。希望你在下一个项目中能够使用这个技巧!
玩得开心,期待在下一个视频见到你!
在这个视频中,我们将学习如何使用 InvokeRepeating
,它可以让我们在一段时间后重复执行某个方法,按照设定的时间间隔反复执行。让我们回到之前创建的 InstantiateCubes
脚本,并使用 InvokeRepeating
方法来不断创建立方体。
CreateNewCube
方法我们首先定义一个新的方法,CreateNewCube
,这个方法将负责创建新的立方体。
public void CreateNewCube()
{
// 在指定位置实例化新的立方体
Instantiate(prefab, new Vector3(-10 + counter * 3.0f, 0, 0), Quaternion.identity);
counter++; // 每次创建新的立方体时,增加计数器
}
在这个方法中,我们创建了一个新的立方体,并将它放置在指定的位置。每次调用这个方法时,counter
都会增加,这样新创建的立方体会在 X 轴上按顺序排列。
InvokeRepeating
调用 CreateNewCube
方法接下来,我们在 Start
方法中使用 InvokeRepeating
来定期调用 CreateNewCube
方法。
void Start()
{
// 使用 InvokeRepeating 方法调用 CreateNewCube 方法
InvokeRepeating("CreateNewCube", 3.0f, 1.0f); // 第一个参数是方法名称,第二个是开始延迟时间,第三个是重复时间间隔
}
当我们运行场景时,CreateNewCube
方法将在 3 秒后首次被调用,并且每隔 1 秒就会重复调用一次。这意味着每秒钟都会创建一个新的立方体。你会看到所有的立方体沿 X 轴逐渐排开,直到无限创建新的对象,直到内存耗尽或游戏崩溃。
InvokeRepeating
我们可以使用 CancelInvoke
来停止重复调用。例如,如果我们希望在创建 5 个立方体后停止重复创建,我们可以添加以下代码来取消调用:
if (counter >= 5)
{
CancelInvoke("CreateNewCube"); // 停止调用 CreateNewCube 方法
}
如果我们按照上面的代码进行设置,立方体的创建将在创建 5 个后停止。运行场景后,你将看到只会创建 5 个立方体。
Invoke
调用单次方法如果你希望延迟调用某个方法一次而不是重复调用,可以使用 Invoke
方法。Invoke
方法在指定的时间后只会执行一次指定的方法。
例如,我们可以在 5 秒后调用 CreateNewCube
方法一次:
void Start()
{
Invoke("CreateNewCube", 5.0f); // 延迟 5 秒后调用 CreateNewCube 方法
}
InvokeRepeating
:用于在指定时间后开始调用一个方法,并以固定的间隔重复调用该方法。CancelInvoke
:用于取消已设置的 InvokeRepeating
调用。Invoke
:用于在指定延迟后只调用一次某个方法。通过使用这些方法,你可以控制游戏中的行为,并在指定的时间间隔内执行重复任务或延迟执行任务。希望你掌握了如何使用这些工具,并能在你的项目中灵活应用它们!
在这个视频中,我们将学习如何使用玩家偏好设置(Player Preferences)来保存数据,如最高分、玩家名字等等。这样,你可以在游戏中保存并加载这些数据,提供更好的用户体验。
首先,我们创建一个新的脚本,命名为 SavingData
。在这个脚本中,我们将实现一个功能,当按下“空格”键时,数字会增加,并将其保存到玩家偏好设置中。
public class SavingData : MonoBehaviour
{
int number = 0; // 初始化数字
}
接下来,我们将通过检测按下“空格”键来增加数字。
void Update()
{
if (Input.GetKeyDown(KeyCode.Space)) // 如果按下空格键
{
number++; // 数字加1
}
}
我们希望在每次数字增加时保存当前的最大数字。所以,我们需要定义一个方法来获取存储的数字,并比较当前的数字。如果当前数字更大,我们就覆盖保存的数字。
int GetNumber()
{
// 从 PlayerPrefs 获取存储的数字
int myNumber = PlayerPrefs.GetInt("MyNumber", 0); // 默认值为 0
return myNumber;
}
PlayerPrefs.GetInt
方法用于获取存储在玩家偏好设置中的整数值。如果没有找到对应的值,将使用默认值(在这里是 0
)。
接下来,在 Update
方法中,我们检查当前数字是否超过了存储的数字。如果超过,我们就更新存储的数字。
void Update()
{
if (Input.GetKeyDown(KeyCode.Space)) // 每次按下空格键
{
number++; // 数字增加
// 获取存储的数字
int storedNumber = GetNumber();
// 如果当前数字大于存储的数字,则更新存储的数字
if (number > storedNumber)
{
PlayerPrefs.SetInt("MyNumber", number); // 更新存储的数字
Debug.Log("New high score: " + number); // 在控制台输出新的最高分
}
}
}
在 Start
方法中,我们可以输出存储的数字(例如,显示最高分)。这样每次启动游戏时,玩家就可以看到他们的最高分。
void Start()
{
// 获取存储的数字并显示
int storedNumber = GetNumber();
Debug.Log("Stored number is: " + storedNumber);
}
现在,当你运行游戏时,每按下“空格”键,数字会增加并更新存储的最高分。停止游戏后重新启动,你将看到之前保存的最高分。
Stored number is: 0
New high score: 1
, New high score: 2
, 等等Stored number is: 35
(如果之前的最高分是 35)通过使用 PlayerPrefs
,我们可以轻松保存简单的数据,如最高分或玩家名称。需要注意的是,PlayerPrefs
更适合保存简单的、少量的数据,如整数、浮点数或字符串。如果你需要保存更复杂的数据(例如,整个游戏状态或多个对象),你将需要学习如何使用序列化技术。
希望你能跟随这个视频的步骤完成,并尝试自己实现这一功能。接下来,我们将在下一个视频中进行更有趣的操作!
在本视频中,你将学习如何使用射线(Raycast),它是一个强大的工具,可以用于许多不同的应用场景。例如,你可以检查自己前面、后面、上方、下方是否有物体,或者检查鼠标所在的点是否有物体。我们将在两个不同的游戏中使用射线,来演示它的应用。
首先,我们需要创建一个立方体(Cube)并放入游戏场景中。同时,我们还需要创建一个平面(Plane),作为地下物体。我们将检查立方体下方是否有物体。
Cube
和 Plane
。接下来,我们将为立方体添加一个新的脚本,检查它是否与地下的物体发生碰撞。我们将使用射线检测(Raycast)来实现这一功能。
创建一个名为 CheckForObjects
的脚本,并在 Update
方法中实现射线检测。以下是代码的实现:
using UnityEngine;
public class CheckForObjects : MonoBehaviour
{
void Update()
{
RaycastHit hit;
// 射线从立方体的当前位置沿着向下的方向发射
if (Physics.Raycast(transform.position, -Vector3.up, out hit, 100f))
{
// 如果射线与物体发生碰撞
Debug.Log("We hit something at: " + hit.point);
}
else
{
// 如果射线没有碰撞到任何物体
Debug.Log("Nothing underneath us.");
}
}
}
将这个脚本添加到立方体对象上,并运行游戏。你将在控制台看到以下输出:
We hit something at: (0, 0, 0)
)。Nothing underneath us.
你可以移动立方体并查看控制台中的变化,确保射线检测正常工作。
除了检测物体下方,我们还可以使用鼠标来检测是否有物体被射线击中。我们将演示如何使用鼠标位置来射线检测场景中的物体。
回到 Visual Studio
,我们将使用 Ray
类来根据鼠标位置发射射线。
using UnityEngine;
public class CheckForObjects : MonoBehaviour
{
void Update()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 从鼠标位置发射射线
RaycastHit hit;
// 检查射线是否击中物体
if (Physics.Raycast(ray, out hit, 100f))
{
// 如果击中了物体,输出物体的名字
Debug.Log("Hit: " + hit.collider.gameObject.name);
}
}
}
Hit: Cube
)。现在运行游戏,并移动鼠标到不同的位置。你将在控制台看到类似以下的输出:
Hit: Cube
。Hit: Plane
。通过这种方式,你可以轻松检测鼠标指向的位置,并判断是否与场景中的物体发生了碰撞。
这个射线检测的方法可以应用于很多不同的场景,尤其是在像《Farmville》这种模拟建设类游戏中。你可以使用鼠标射线来确定玩家放置建筑物的位置,确保建筑物只会放置在可用的区域上。
通过本视频,你学到了如何使用射线检测来判断物体之间的碰撞,不论是检测物体下方的碰撞,还是根据鼠标位置射线检测。射线检测是一个非常实用的功能,可以在多个场景中派上用场,尤其是在需要用户交互的游戏中。
在接下来的章节中,我们将使用这个技术来构建更复杂的游戏元素,比如模拟建造和放置建筑物。希望你能将这个技巧运用到自己的项目中!
在这一章中,我们将创建一个“Zig Zag”克隆游戏。
你可能知道这个游戏,它是一个无限的3D跑酷游戏,玩家只需左右移动并收集水晶。在这个游戏中,水晶被替换成了简单的钻石或水晶。
我们将要构建这个游戏,并添加一个简单的高分系统,允许玩家左右移动,并使用一个3D动画角色。我们将使用一个来自资源商店的角色资产,它带有动画。
首先,我们从创建一个新的项目开始。将项目命名为“Zig Zag clone”。
选择3D项目并创建。
项目创建完成后,我会使用我常用的布局,你可以选择你喜欢的布局。
接下来,我们需要从资源商店添加资源,因为我们将使用一些外部资源。打开资源商店,下载“手绘石材纹理(Hand-Painted Stone Texture)”和“画石纹理(Painted Stone Texture)”。这只是我选择的资源,你可以选择任何其他纹理。
如果这些资源已经不再免费或不可用,你可以简单地选择任何你喜欢的免费纹理。下载完成后,导入资源包到我们的项目中。
导入完成后,资源将会出现在资产窗口中。现在我们回到场景,创建一个3D物体——立方体(Cube)。我的相机默认位置在-10,立方体在(0, 0, 0)的位置。
此时,立方体是白色的,看起来并不漂亮。我们可以通过将“手绘石材纹理”中的“石材地面(Stone Floor)”拖拽到立方体上,来使立方体看起来更加精美。
我们可以复制这个立方体并将其移到旁边。这样,你就可以看到它可能会看起来像是一条街道或道路。接下来,我们创建一个空物体作为父级物体,命名为“Road”,然后将立方体命名为“Road Part”。
将“Road Part”拖入“Road”中,确保“Road”物体的位置已经重置到(0, 0, 0)。这样所有的路面部分都会在“Road”物体下。
保存当前场景,命名为“Main”。
接下来,我们需要一个玩家角色。在“Zigzag”游戏中,玩家是一个球形物体。你可以创建一个简单的球体,但为了演示动画,我们将使用一个已经有动画的资产。
在资源商店中,我们搜索并下载“Character Pack Free Sample”。这是一个很棒的角色包,其中包含了一个动画角色,它有跑步和其他一些动画。
下载并导入该包后,我们将其拖入场景中的“Road”上。你会看到,这个角色已经有了简单的角色控制、动画控制器(Animator)、碰撞器(Collider)和刚体(RigidBody)等动画和移动所需要的组件。
启动游戏,你会看到角色已经开始动画了。你可以观察角色是否能够执行跑步、左右移动、跳跃等动作。如果你不小心掉下了边界,你还会看到角色会有掉落的动画。
但是这个角色已经有太多的功能,而我们只是需要一个简单的左右移动。因此,我们需要简化这个角色的行为,可能需要修改现有的脚本,或者创建我们自己的控制脚本。
接下来,我们将学习如何使用动画、如何实现3D角色的移动,以及更多的内容。同时,我们还将创建一个无限的程序化地图,玩家可以在其中收集水晶,创建一个既有趣又有吸引力的手机游戏。
在下一个视频中,我们将继续设置正确的视角。
欢迎回来。在这个视频中,我们将继续调整游戏的视角,并进一步设置项目。首先,我们将把我们的玩家(或角色)重命名为 "Character",然后移除简单角色控制器。尽管这是一个简单的角色控制器,但它已经编写了很多程序代码。我们当然可以阅读这些代码并试着理解其工作原理,但我们更希望自己动手完成。所以,我们想自己编写玩家的移动逻辑,理解其中的细节。
好的,首先我们移除这个控制器。可以看到,这里有200行代码,处理了很多序列化字段、敌人等内容。我们将其简化一些。接下来,它使用了一个动画控制器,这个控制器也可以删除。删除按钮在选择控制器后就可以按下。
接下来,我们需要稍微调整一下摄像机的角度。在做这件事之前,我们要将摄像机的投影方式更改为正交(Orthographic)。正交投影可以让物体看起来无论距离远近都保持相同的尺寸,这样可以让场景中的元素在不同的距离下看起来一致。
然后,我们可以稍微调整摄像机的位置,进行一些细节上的修改,因为目前的角度并不适合我们的玩家角色。具体来说,我将摄像机的X位置设置为1,Y位置为1,Z位置为-5。这是我经过一些测试后得到的一个合适位置。接着,我们需要调整旋转角度,调节为大约5度,这样的旋转角度应该会更合适。接着,我们还需要调整摄像机的“Size”(大小)为2,这样视角会更加贴近一些。
接下来,我们需要调整摄像机的裁剪面(Clipping Pane),裁剪面决定了你能看到的区域。我们希望能看到一些玩家角色背后的内容,因此我们将“Near”裁剪面设为-10,这样后方的内容就可以被渲染出来。
现在我们应该调整一下角色的朝向。因为我不希望角色向前走,而是希望它稍微朝右上方的方向移动。所以我们需要调整角色的旋转,特别是它的Y轴旋转角度。将其设置为45度,这样角色就会朝着右上方的方向移动。同时,我还会对地面上的路面部分进行同样的旋转调整。
现在角色和路面部分都已经设置好朝向,接下来我们将复制道路部分,并将其拖到合适的位置。为了确保它们能精确对接,你可以在复制时按住Command(Mac)或Ctrl(Windows)+Shift,这样就可以确保它们完美对接。如果你发现全局坐标下难以对齐,可以切换到本地坐标系统(Local),这会更容易对齐。
然后我们继续复制并移动这些道路部分,直到我们创建出一个小型的“Zigzag”道路。每复制一次,我都会按住Shift和Command(或Ctrl)键,以确保对齐。最终,我们就可以看到玩家可以看到的游戏世界。
接下来,我想更改一下背景的颜色。首先,将背景的 "Clear Flags" 从 Skybox 改为 Solid Color,然后将背景色调整为一种较为浅的棕灰色,或者像米色的颜色,这样看起来会更加柔和。
然后,我们可以稍微调整一下光照的方向。可以看到,当前光照强度为50,假设我们将它设置为200,你会看到画面变得很暗,这就像是夜晚。你可以通过旋转光照源来改变环境的光照效果,模拟一天中不同的时间段。找到一个合适的光照方向和强度,模拟出一个白天的效果。你可以根据自己的喜好进行微调,直到得到一个理想的效果。
最后,我们可以调整一下光照的颜色。比如,将其调整为稍微暖色一些,或者让它更暗一些等。通过这样的调整,我们可以优化游戏的视觉效果。
到这里,游戏的基本外观已经搭建完成,地图设计也初步完成了。稍后我们将学习如何通过程序生成无限地图,并在其中收集水晶等元素。
不过,当前有一个问题,游戏开始后,玩家无法再移动了。你可能已经猜到原因:我们删除了移动控制器,导致WASD键不再起作用,玩家也不再有动画效果了。这是因为我们的角色没有动画控制器,也没有可以控制移动的组件。
好的,接下来我们保存当前的场景,并在下一个视频中继续进行游戏开发。
欢迎回来。在这个视频中,我们将让我们的角色能够移动。这将是一个非常简单的左右移动,角色只会左右走。每当我们点击或按下按钮时,角色就会朝左或朝右移动。所以,接下来我们就来实现这一功能。
首先,我们选中我们的角色对象,然后为其添加一个组件。我将使用脚本,创建一个新的脚本,并命名为 CharController
(字符控制器),然后打开这个脚本。
我们将使用 Awake
方法而不是 Start
方法,同时我们需要一个 Rigidbody
来访问角色的移动组件。因此,我们先创建一个 Rigidbody
变量,命名为 RB
。然后,我们还需要一个布尔变量来判断角色是朝右走还是朝左走。具体来说,当角色朝右走时,我们的旋转角度是45度,而朝左走时是-45度。这两个角度对于我们来说非常重要。
接下来,我们回到脚本。在 Awake
方法中,我将初始化 Rigidbody
,使用 GetComponent<Rigidbody>()
获取当前角色的 Rigidbody
。因为控制器已经被分配到玩家角色上,所以这部分会正常工作。接下来,我们将使用 FixedUpdate
来让玩家角色移动。
在 FixedUpdate
方法中,我会设置角色的位置,通过修改 transform.position
,让角色根据当前朝向移动。具体实现如下:
transform.position = transform.position + transform.forward * 2 * Time.deltaTime;
这段代码的作用是根据时间的推移让角色前进。transform.forward
代表角色当前朝向的前方,因此,如果角色的朝向是45度,那么角色就会朝右移动;如果是-45度,那么角色就会朝左移动。这样不论角色的旋转角度是多少,都会根据朝向移动。
接下来,在 Update
方法中,我们将检测用户的输入。例如,当按下空格键时,角色的朝向将发生改变。具体实现如下:
if (Input.GetKeyDown(KeyCode.Space))
{
SwitchDirection();
}
然后,我们定义一个新的方法 SwitchDirection
来切换角色的朝向。这个方法会改变布尔值 walkingRight
的值。如果原来是 true
,则变为 false
;如果原来是 false
,则变为 true
。根据这个布尔值的不同,我们会改变角色的旋转:
private void SwitchDirection()
{
walkingRight = !walkingRight;
if (walkingRight)
{
transform.rotation = Quaternion.Euler(0, 45, 0);
}
else
{
transform.rotation = Quaternion.Euler(0, -45, 0);
}
}
如果 walkingRight
为 true
,角色将旋转45度;如果为 false
,角色将旋转-45度。这段代码完成了角色方向的切换。
完成以上代码后,我们保存脚本,返回 Unity 编辑器。在 Unity 中,我们将脚本放到一个新的文件夹里,命名为 Scripts
,然后将 CharController
脚本拖到角色对象上。
在测试之前,我们还需要确保在 Update
方法中调用了 SwitchDirection
方法,这样每当按下空格键时,角色就会切换朝向。
接下来,我们运行游戏,观察角色是否可以根据空格键的输入切换方向。玩家角色应该能够正常地向左和向右移动。然而,由于目前没有动画,角色看起来只是左右摆动的手臂,这样的效果可能有些怪异。我们将在接下来的视频中改进这一点,给角色添加动画。
此外,还有一个问题:目前摄像机并没有跟随角色移动。因此,当角色向前走时,可能会跑出视野看不见它。我们将在下一个视频中解决这个问题,调整摄像机以跟随玩家。
欢迎回来。在这个视频中,我们将处理摄像机,使其能够跟随我们的玩家。我们将为此创建一个新的脚本,命名为 FollowCam
(跟随摄像机),这个脚本的作用是让摄像机跟随玩家角色。接下来,我们将把这个脚本直接分配给我们的主摄像机。
首先,在 Unity 中创建一个新的脚本,命名为 FollowCam
,然后将其拖动到主摄像机的组件列表中,作为组件附加到摄像机上。接下来,打开脚本文件,进行编辑。
在脚本中,我们将使用 Awake
方法来初始化设置,而不是使用 Update
方法。我们需要两个主要的变量:
public
,这样我们可以在 Unity 中进行赋值。在 Awake
方法中,我们将设置这个偏移量,代码如下:
offset = transform.position - target.position;
这段代码会将摄像机当前位置减去玩家的当前位置,从而得到一个 Vector3
变量,表示摄像机和玩家之间的相对位置,并保存为 offset
。
然后,我们在 LateUpdate
方法中更新摄像机的位置。LateUpdate
是 Unity 中每帧调用一次的方法,它比 Update
更适合用来更新摄像机位置,因为它保证了摄像机的更新是在所有物体更新之后发生的。我们将在 LateUpdate
中设置摄像机的位置:
transform.position = target.position + offset;
这段代码会将摄像机的位置设置为玩家的位置加上偏移量。通过这种方式,摄像机会始终保持与玩家之间的相对距离,避免摄像机穿透玩家。
完成这些代码后,我们保存脚本并返回 Unity 编辑器。在 Unity 中,主摄像机的组件现在会出现一个新的公共变量 Target
,我们可以在此处将玩家对象拖入作为目标。
例如,我们可以设置摄像机的位置为 (1, 5, -5)
,而玩家的位置为 (0, 0.5, 0)
,这样就形成了一个距离偏移。X
轴差异是 1
,Y
轴差异是 4.5
,Z
轴差异是 -5
。这意味着摄像机将始终保持这个距离,跟随玩家的位置。
保存场景并运行代码后,观察摄像机是否正确跟随玩家。运行时你会发现摄像机会始终保持与玩家的相对位置,并跟随玩家移动。
现在,我们已经成功地让摄像机跟随玩家了,这对于制作一个无尽奔跑类的游戏非常合适。在接下来的视频中,我们将为玩家添加动画效果,所以敬请期待!
在这个视频中,我们将处理玩家角色的动画。首先,让我们创建一个角色。在 Unity 中,进入 Assets 文件夹,创建一个新文件夹,命名为 Character
。然后,将你的角色拖入该文件夹,因为这个角色和我们从 Asset Store 下载的版本不同,因为我们已经对其做了一些修改。例如,我们删除了控制器、动画控制器并创建了新的脚本等。
接下来,我们进入 Character
文件夹,创建一个新的动画控制器。每当你想为物体添加动画时,都需要一个动画控制器。我们将其命名为 char_anim_controller
。你可以根据个人喜好命名,但 char_anim_controller
这个名称已经很清楚地表明了它的功能。
打开该动画控制器后,进入 Animator
窗口。你会看到几个状态:Any State
、Entry
和 Exit
,这些是创建动画控制器时自动生成的。我们需要添加一个新的状态,因此右键点击空白区域,选择 Create State
,然后选择 Empty
。你会看到 Entry
和 Any State
已经有一条过渡线,这表示一旦动画控制器启动,动画将从 Entry
开始,并自动过渡到 Any State
。
我们现在要设置的动画状态是 Run
(跑步动画)。首先,我们将其命名为 Run
,然后将 Run
动画片段拖入该状态。Run
动画片段已经存在,我们可以直接使用它。
双击 Run
动画片段,查看它的详细信息。你会看到它位于 Common People
文件夹下,属于我们使用的 Super Saiyan Character Free Pack
。在这里你可以看到动画的基本设置,特别是 Loop Time
,它决定了动画是否循环播放。Run
动画的时长为 0.667 秒(大约 2/3 秒),这意味着每次动画播放完毕后会立即重复。
我们将 Loop Time
激活,这样动画会持续循环播放,确保玩家在跑步时动画不断重复。如果你关闭 Loop Time
,动画就只会播放一次,播放完毕后就不再播放。
现在我们的角色有了跑步动画,但还需要为掉落设置一个动画。我们创建一个新的空状态,命名为 Falling
,然后将 Jump Down
动画片段拖入该状态。Jump Down
是我们需要的掉落动画。
为了让角色在掉落时切换到掉落动画,我们需要设置从 Run
到 Falling
的过渡。点击 Run
状态的过渡箭头,创建一个从 Run
到 Falling
的过渡。然后我们需要为过渡设置条件,决定什么时候从跑步状态切换到掉落状态。
为了添加过渡条件,我们首先需要在动画控制器中创建一个新的参数。点击左侧的 Parameters
标签,点击加号,选择 Trigger
类型,并将其命名为 isFalling
。这个触发器将用于判断角色是否已经不再接触地面。
接下来,我们需要在脚本中设置这个触发器。当角色不再接触地面时,我们就触发 isFalling
参数,从而切换到掉落动画。
我们需要在角色控制脚本中进行一些修改。首先,我们创建一个新的公共变量 RayStart
,用于标识射线起始的位置。这个位置应该位于玩家角色的脚下或者其他合适的位置,以便发射射线检查角色是否接触地面。
然后,我们创建一个私有变量 animator
,用于控制动画的播放。接着,在 Update
方法中,我们使用射线检测(Raycast
)来检查角色是否接触地面。若没有接触地面,我们将触发 isFalling
参数,切换到掉落动画。
public Transform rayStart;
private Animator anim;
void Awake() {
anim = GetComponent<Animator>();
}
void Update() {
RaycastHit hit;
if (!Physics.Raycast(rayStart.position, -transform.up, out hit, Mathf.Infinity)) {
anim.SetTrigger("isFalling");
}
}
在上面的代码中,rayStart
是射线起点,它从角色的下方发射射线向下。如果没有碰到地面,我们就触发 isFalling
参数。
回到 Unity 编辑器,在角色上创建一个新的空物体,命名为 RayStart
,并将其放置在角色的脚下。然后,将 RayStart
拖入脚本中的 rayStart
变量中。
最后,我们运行游戏并查看动画效果。角色跑步时,应该播放跑步动画,而当角色离开地面时,应该播放掉落动画。我们可以通过调整动画片段,或者试验不同的动画来确保效果更好。
接下来,我们将继续改进游戏的启动方式,确保游戏的启动体验更好。
保存场景并进入下一个视频。
为了让玩家能够在游戏内启动游戏,我们需要创建一个新的脚本来管理游戏状态。首先,创建一个名为 GameManager
的 C# 脚本。这个脚本将控制游戏的不同状态。接着,我们需要创建一个名为 Game Manager
的游戏对象,并将 GameManager
脚本拖入该对象中。将 Game Manager
的位置重置为原点(0,0,0)。
GameManager
脚本内容public class GameManager : MonoBehaviour
{
public bool gameStarted = false;
// 启动游戏的方法
public void StartGame()
{
gameStarted = true; // 设置游戏已经开始
}
}
在 GameManager
脚本中,我们只需声明一个 bool
类型的变量 gameStarted
,用于指示游戏是否开始。同时,提供一个 StartGame()
方法,用于设置 gameStarted
为 true
,表示游戏已启动。
GameManager
在玩家控制器脚本中,我们需要访问 GameManager
以便在玩家按下空格键时,游戏开始。首先,创建一个私有的 GameManager
变量,然后在 Awake()
方法中初始化它。
private GameManager gameManager;
void Awake()
{
gameManager = FindObjectOfType<GameManager>(); // 查找场景中的 GameManager 对象
}
接着,在 FixedUpdate()
方法中,检查游戏是否已启动:
void FixedUpdate()
{
if (!gameManager.gameStarted)
{
return; // 如果游戏未启动,则不做任何处理
}
// 其他控制逻辑
}
保存并返回 Unity,运行游戏后,我们可以看到玩家不会移动,因为 gameStarted
为 false
。玩家的动画仍然会播放,但他会假装在跑步(实际上并未开始游戏)。为了避免这种情况,我们需要创建一个新的状态,称为“Idle”(空闲状态),表示玩家没有开始游戏时的状态。
在 Animator
中,创建一个新的空状态并命名为 Idle
。然后,设置该状态为默认状态,这意味着当游戏未开始时,玩家处于该状态。我们还需要为此状态添加一个名为 Idle
的动画(可以在已下载的资源中找到)。
创建 Idle
到 Run
的过渡。在过渡条件中,我们需要添加一个新的触发器(Trigger)来判断游戏是否已开始。为此,创建一个名为 GameStarted
的触发器参数:
Animator animator;
animator.SetTrigger("GameStarted");
在 FixedUpdate()
中,如果游戏已开始,调用 animator.SetTrigger("GameStarted")
触发游戏开始并进入跑步状态。
为了让玩家能够通过按下 Return
键来启动游戏,我们需要在 GameManager
中添加一个 Update()
方法来监听键盘输入。
void Update()
{
if (Input.GetKeyDown(KeyCode.Return)) // 检测 Return 键
{
StartGame(); // 调用 StartGame 方法启动游戏
}
}
现在,如果你按下 Return
键,游戏应该会开始,玩家的动画也应该从 Idle
过渡到 Run
。但你可能会发现,玩家会在空中漂浮一段时间,然后才开始跑步。为了解决这个问题,我们需要调整动画的过渡。
在 Animator
中,为 Idle
到 Run
的过渡添加条件,并禁用 Exit Time
,这样动画会立即从 Idle
过渡到 Run
,而不是等到 Idle
动画播放完成。
为了在玩家掉落时触发掉落动画,我们还需要创建一个从 Run
到 Falling
的过渡,并在合适的时机添加条件来启动掉落动画。类似地,我们创建一个触发器 IsFalling
,当玩家离开地面时触发该动画。
public class CharacterController : MonoBehaviour
{
private Animator animator;
private GameManager gameManager;
private void Awake()
{
animator = GetComponent<Animator>();
gameManager = FindObjectOfType<GameManager>();
}
void Update()
{
if (!gameManager.gameStarted)
{
return; // 如果游戏未开始,直接返回
}
// 检查玩家是否在空中
if (!Physics.Raycast(transform.position, Vector3.down, 1f))
{
animator.SetTrigger("IsFalling"); // 触发掉落动画
}
}
}
按下 Enter
键后,游戏应该能正常启动,玩家的动画也会根据不同状态(跑步、空闲、掉落)进行过渡。如果玩家从空中掉落,应该触发掉落动画。
在接下来的视频中,我们将讨论如何重新启动游戏。
在这个视频中,我们将实现游戏重启的功能。目前我们只是在按下回车键时启动了游戏,但现在我们希望在玩家掉落时重新启动游戏。
首先,我们需要检测玩家是否掉落。我们可以通过检查玩家的 Y 轴位置来判断是否掉落。如果玩家的 Y 轴位置低于 -2,则认为游戏结束,应该重启游戏。
在玩家控制器脚本中,我们可以添加一个条件语句来判断玩家的 Y 坐标。如果 Y 坐标小于 -2,则意味着玩家已经掉落到场景下方,我们需要结束游戏并重新加载场景。
void Update()
{
if (transform.position.y < -2)
{
gameManager.EndGame(); // 调用 GameManager 中的 EndGame 方法
}
}
GameManager
中添加结束游戏方法在 GameManager
中,我们需要创建一个新的方法 EndGame
,用来重新加载当前场景。为此,我们需要引入 SceneManager
来加载场景。首先,确保脚本中包含 using UnityEngine.SceneManagement;
,然后在 GameManager
中添加以下方法:
using UnityEngine.SceneManagement;
public class GameManager : MonoBehaviour
{
// 结束游戏并重新加载场景
public void EndGame()
{
SceneManager.LoadScene(0); // 加载索引为 0 的场景
}
}
此时,SceneManager.LoadScene(0)
会重新加载索引为 0 的场景。你可以通过查看 Unity 的 Build Settings
来确认当前场景的索引值。如果场景没有添加到构建中,记得将它添加进去。
返回 Unity,保存所有脚本并运行游戏。按下 Enter
启动游戏后,玩家会走动并掉落。当玩家掉落的 Y 坐标低于 -2 时,游戏将结束并重新加载场景。你可以通过以下步骤进行测试:
Enter
启动游戏。构建游戏并运行时,你可能会发现游戏的场景看起来有所不同。这是因为 Unity 在构建游戏时可能会呈现不同的效果。为了测试这个效果,可以通过以下步骤来构建并运行游戏:
File
> Build and Run
。ZigZagClone
)。当你在构建的游戏中测试时,你会看到和 Unity 编辑器中类似的效果。玩家按下 Enter
启动游戏,走动并掉落时,游戏会重新加载,并且场景的视觉效果与编辑模式中保持一致。
在下一个视频中,我们将讨论如何添加水晶收集系统以及如何为玩家添加分数功能。
欢迎回来!现在,我们需要去资产商店导入水晶模型,因为游戏中有水晶需要玩家收集。一个很棒的水晶资产是 Stylized Crystal。你可以在资产商店中找到它,搜索关键词“Stylized Crystal”。它是一个低多边形风格的免费资产,非常适合用作游戏中的水晶。
接下来,我们将使用导入的水晶模型创建一个预设。在项目视图中,找到 Stylized Crystal,然后将它拖到场景中。
为了让水晶能够触发事件,我们需要设置它的触发器。将 Box Collider 组件的 Is Trigger 属性设置为 True。这样当玩家与水晶碰撞时,它会触发事件,并增加分数。
接下来,我们为水晶添加一个新的标签。点击 Tag,选择 Add Tag,然后创建一个名为 Crystal 的标签。
为了方便管理,我们需要将水晶对象转化为预设:
现在,我们将水晶放置在场景中,并调整它的位置和旋转:
你可以根据需要调整水晶的位置,甚至在场景中复制多个水晶,增加挑战性。通过将水晶对象移动到不同的位置,可以增加游戏的难度,让玩家更容易掉落。
为了保持层级结构的清晰,我们将水晶对象移到一个专门的文件夹中:
现在,我们来测试水晶是否能正确地与玩家发生碰撞。运行游戏后,玩家应该能够穿过水晶,并且水晶应该在碰撞后消失。
接下来,我们为游戏添加一个分数系统。每次玩家收集到一个水晶时,分数应该增加。
public class GameManager : MonoBehaviour
{
public int score = 0;
public void IncreaseScore()
{
score += 1;
}
}
在 CharacterController 脚本中,我们需要实现一个 OnTriggerEnter 方法。当玩家与水晶发生碰撞时,销毁水晶并增加分数:
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Crystal"))
{
Destroy(other.gameObject); // 销毁水晶
gameManager.IncreaseScore(); // 增加分数
}
}
为了显示当前分数,我们需要在游戏中添加一个 UI 元素。我们将在屏幕的左上角显示分数。
接下来,我们需要在 GameManager 中更新文本显示分数。首先,确保在 GameManager 脚本中引用 Text 组件:
using UnityEngine.UI;
public class GameManager : MonoBehaviour
{
public Text scoreText;
public int score = 0;
public void IncreaseScore()
{
score += 1;
scoreText.text = score.ToString(); // 更新 UI 显示的分数
}
}
在 Unity 编辑器 中,将 Text 对象拖动到 scoreText 字段中。
运行游戏并测试分数系统。玩家收集水晶时,分数应该增加,并且在屏幕左上角显示。
在下一个视频中,我们将讨论如何设置 高分系统,记录玩家的最高分数,并在游戏结束后显示最高分。
在游戏中添加一个 高分 显示功能。首先,我们需要复制分数文本(score text)并将其命名为 high score text,这样我们就可以在屏幕上同时显示当前分数和高分。
public Text highScoreText;
为了获取和设置高分,我们将使用玩家的本地存储(PlayerPrefs)。本地存储可以存储数据,在游戏关闭后仍然保存下来。
private void Awake()
{
highScoreText.text = "Best: " + GetHighScore().ToString();
}
public int GetHighScore()
{
return PlayerPrefs.GetInt("highScore", 0); // 如果没有高分,默认为0
}
每当玩家的分数超过当前高分时,我们就更新高分并将其显示在屏幕上。我们将在增加分数时进行检查,确保 current score 大于 high score。
public void IncreaseScore()
{
score += 1;
// 检查是否超过当前高分
if (score > GetHighScore())
{
PlayerPrefs.SetInt("highScore", score); // 更新本地存储中的高分
highScoreText.text = "Best: " + score.ToString(); // 更新高分显示
}
}
我们需要在 UI 中正确显示高分文本。创建一个文本框来显示当前的高分,并将其放置在屏幕的合适位置。
当游戏启动时,我们需要显示保存的高分。在 Awake 方法中,我们获取并设置显示的高分:
private void Awake()
{
highScoreText.text = "Best: " + GetHighScore().ToString(); // 显示高分
}
为了让文本更清晰,我们可以修改显示格式。确保 highScoreText 始终以 "Best: x" 的格式显示,更新方法如下:
highScoreText.text = "Best: " + score.ToString(); // 始终更新高分
你可以通过重新运行游戏并尝试得分来测试高分功能,确保每次玩家超过当前最高分时,都会更新并显示新的高分。
在下一个视频中,我们将为水晶添加特效,使得玩家收集水晶时能够看到一些视觉反馈。
为了增加游戏的视觉效果,当玩家收集到水晶时,我们将添加一个粒子效果。具体操作如下:
在 Hierarchy 中右击,选择 Effects,然后选择 Particle System,以创建一个新的粒子系统。
默认情况下,粒子系统会被添加到场景中。我们需要将其移动到合适的位置,以便它能显示在玩家角色的附近。
将粒子系统的位置设置为 (0, 0, 0)
,然后将其拖动到水晶的位置。假设水晶的位置是 (0.5, 0, 0)
,将粒子系统移到水晶的位置。
粒子系统生成的效果是持续不断的,我们可以通过修改其属性来让其更符合需求。
颜色设置:在 Start Color 选项中,可以更改粒子的起始颜色。我们可以选择与水晶颜色匹配的颜色。为了更有动感,可以让颜色在两种颜色之间随机变化。右键点击 Start Color,选择 Random Between Two Colors,然后设置两种颜色之间的随机变化。
发射模式:默认情况下,粒子系统会持续发射粒子。我们可以修改为 Burst 模式,使粒子在短时间内爆发出来。这样,粒子将在瞬间生成,然后消失。
持续时间:调整粒子的 Duration(持续时间)和 Lifetime(生命周期)。例如,将粒子的 Lifetime 设置为 2 秒,让粒子效果在短时间内消失。
旋转设置:可以设置粒子的旋转角度,使其朝着不同的方向发射。这里我们将 X 轴旋转 设置为 -90
,让粒子朝上发射。
发射形状:粒子的发射形状可以选择 Sphere,使粒子从一个球体中发射。我们还可以调整球体的大小,来改变粒子扩散的范围。
渐变效果:为了让粒子逐渐消失,我们可以在 Color Over Lifetime 中设置渐变效果。通过编辑 Alpha 值,可以让粒子从不透明逐渐变为透明,从而产生逐渐消失的效果。
大小变化:通过 Size Over Lifetime 可以控制粒子的大小变化。可以选择让粒子逐渐变大或逐渐变小。
噪声效果:如果想让粒子在不同的方向上随机浮动,可以启用 Noise 并调整噪声强度,这样粒子的运动会更加随机,模拟风的效果。
配置好粒子效果后,我们将其保存为一个预制件。首先,右键点击粒子系统,选择 Create Prefab,将其命名为 CrystalEffect。
拖动 CrystalEffect 预制件到 Prefabs 文件夹中,这样就可以在其他地方重复使用这个粒子效果。
public GameObject crystalEffect;
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Crystal"))
{
// 在玩家的位置生成粒子效果
GameObject effect = Instantiate(crystalEffect, transform.position, Quaternion.identity);
// 粒子效果持续 2 秒后销毁
Destroy(effect, 2f);
// 销毁水晶
Destroy(other.gameObject);
}
}
为了让粒子效果显示在玩家的中心位置(而不是脚下),我们可以将粒子效果的生成位置设置为玩家胸部的位置。具体实现如下:
// 获取玩家胸部的位置,通常使用模型中的 "RayStart" 作为参考
GameObject effect = Instantiate(crystalEffect, rayStart.transform.position, Quaternion.identity);
通过这样设置,粒子效果将在玩家胸部附近发射,而不是脚部。
通过添加粒子效果,我们为水晶收集行为增加了视觉反馈,增强了游戏的表现力。你可以根据需要进一步调整效果参数,尝试不同的视觉风格和粒子行为,使其更符合游戏的主题。
在下一个视频中,我们将添加背景音乐,为游戏增添更多的氛围。
好的,让我们给你的游戏加入一些动感十足的背景音乐吧,因为拥有循环播放的背景音乐非常棒,我们正是要添加这样的音乐。首先,前往 Asset Store 搜索 "absolutely free music",因为有一个非常棒的资源,叫做 Absolutely Free Music,并且评价很高。我们可以直接下载它。所以让我们下载这个资源。这个文件有 160 MB,所以可能需要一些时间,但我们并不打算导入所有的音频文件,只想使用其中的第七个文件,名为 With Love from Vertex Studio 7,它很适合我们的游戏风格——一款无尽奔跑类游戏,背景音乐有一种酷酷的感觉。接下来,先下载这个文件,文件有点大,所以我会暂停下载,直到下载完成。
下载完毕后,我们不想导入所有的音频文件,因为这会让我们的游戏变得非常庞大。我们只需要导入其中的第七个音轨。所以,接下来,我会进入 Asset Folder,创建一个新的文件夹,命名为 Third Party,然后把所有第三方资源放进这个文件夹,包括游戏音乐、手绘石纹理、风格化水晶和超级赛亚人角色包等。这样做的目的是保持项目文件结构的整洁。
接下来,我们创建一个新的空对象,命名为 Background Loop,然后为它创建一个新的脚本,也命名为 Background Loop。把这个脚本拖到 Background Loop 对象上,并将其位置重置。
现在,我们需要为 Background Loop 对象添加一个 Audio Source 组件。选择音频文件 With Love from Vertex Studio 7,并确保勾选了 Play On Awake 和 Loop 选项,因为我们希望这段音乐从游戏开始时就播放并且循环。接着,将音量设置为 0.3,避免音量过大。完成后,运行游戏并检查效果。
默认情况下,当场景重新加载时,音频播放器会被销毁,导致背景音乐停止。为了让背景音乐在场景之间持续播放,我们使用 DontDestroyOnLoad
方法。
void Awake()
{
// 如果实例为空,则设置为当前对象
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject); // 不销毁该对象
}
else if (instance != this)
{
Destroy(gameObject); // 销毁当前对象,只保留一个实例
}
}
这样做的目的是确保只有一个背景音乐对象存在,并且它能够在场景切换时持续播放。
接下来,我们添加一个静态变量 instance
,它将用于存储背景音乐对象的唯一实例。每次场景切换时,我们检查是否已经有一个背景音乐对象存在,如果没有,则创建一个新的;如果已经存在,则销毁当前创建的对象,保持原有的背景音乐实例。
在 Awake 方法中添加以下代码:
public static BackgroundLoop instance;
void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else if (instance != this)
{
Destroy(gameObject); // 销毁重复的对象
}
}
保存脚本后,返回游戏,进行测试。当你死亡并重新开始时,音乐应该能够继续播放,而不会被重新加载或中断。
为了避免背景音乐过大,可以通过调整 Audio Source 组件中的音量来控制,设置为 0.3 就非常合适。如果觉得太大,随时可以进一步调低音量。
现在,当游戏开始时,背景音乐会持续播放,且在切换场景时不会被销毁。你可以继续进行游戏,背景音乐会始终在背后播放,带给玩家一个连贯的体验。
到这里,我们已经成功地为游戏添加了背景音乐循环,并确保它在场景切换时不断播放。接下来,我们将在下一视频中展示如何根据程序动态生成游戏世界。
如果有一个对无限奔跑游戏最重要的元素,那就是无限性。为了创建一个无限的世界,我们需要使用程序化生成。也就是说,我们必须通过代码来实现它,而不是手动创建数十亿个道路元素或路段。我们想要的是通过程序化的方式来创建这些元素。那么现在我想做的是,首先设置我的水晶,并且将水晶的放置位置改为在道路路径上,而不是随机的世界中的某个位置。接下来,我们将水晶放置到道路路径上。
首先,我们需要把所有其他的水晶删除。接下来,删除我们所有的道路部分。然后我们将水晶拖到游戏中。大概放置在这个位置,确保水晶在道路上方。如果是放置正确的话,就放在那里,接下来我要将水晶对象停用掉,这样水晶就不再显示了。现在,这个道路部分就会成为一个新的预制件。我们将这个道路部分拖动到预制件文件夹中,之后我们将使用这个预制件来程序化地创建更多的道路部分。
在我们继续之前,我们有一个游戏的基础元素将始终保持不变,那就是道路。为了让程序化生成更方便,我们首先去掉水晶,然后复制水晶并按照需求移动它。游戏的前几个步骤是固定的,然后路段开始向一边扩展。我们可以查看游戏视图,确保道路部分一直保持相同。道路的下一部分也会有相同的规则。现在这就是我们地图的起点。
接下来,我们将创建一个脚本来实现道路的程序化生成。在Unity中,我们为道路部分添加一个新的组件,并创建一个新的脚本,命名为Road
。打开这个脚本,我们需要一个公共的游戏对象类型的变量roadPrefab
,用于存储我们前面创建的道路预制件。在Unity编辑器中,我们将这个预制件赋值给roadPrefab
。接下来,我们还需要知道从哪个位置开始创建新的道路部分。
我们需要知道从哪个点开始创建新的道路部分。通过检查道路部分的位置,我们发现它的初始位置是0, 7, 9
。所以我们将在脚本中创建一个公共的Vector3
变量lastPost
,并将其设置为0, 7, 9
。接下来,我们需要计算每个道路部分之间的偏移量。在Unity中,选中一个道路部分,按住Control
和Shift
键进行复制并移动,可以看到每次移动的偏移量是0.707
,这也就是道路块的宽度。
我们继续在脚本中创建一个新的公共方法CreateNewRoadPart
,用于生成新的道路部分。在更新方法Update
中,我们使用Input.GetKeyDown
来检测玩家是否按下了空格键,如果是,程序就会调用CreateNewRoadPart
方法生成新的道路。
CreateNewRoadPart
方法会生成一个新的道路位置,这个位置是基于上一个道路位置的偏移量计算出来的。偏移量会根据随机值决定是向左还是向右生成新的道路部分。如果chance
小于50,表示道路向左生成,否则向右生成。
接下来,我们会使用Instantiate
方法实例化新的道路预制件,并将其放置到计算出的spawnPosition
位置。然后我们会更新lastPost
变量,保存当前新生成的道路部分的位置,以便为下一个道路部分计算位置。
目前的代码可以让我们在按下空格键时生成新的道路部分,但我们希望它能自动生成。为了实现这一点,我们不再使用Update
中的GetKeyDown
检测按键,而是在Awake
方法中使用InvokeRepeating
来定期调用CreateNewRoadPart
方法。每隔一秒钟,新的道路部分就会被生成。
但是,当前的方法会在游戏启动时就开始生成道路,这样可能导致生成一个过大的世界。因此,我们将不再使用Awake
,而是创建一个名为StartBuilding
的公共方法,通过GameManager
在游戏开始时调用它。我们只希望在游戏启动时生成道路,而不是在Awake
阶段自动开始。
为了让游戏更加有趣,我们希望每五个道路生成一个水晶。为此,我们在Road
脚本中添加了一个新的变量roadCount
,它会随着每生成一个新道路而增加。如果roadCount
能被5整除,我们就激活水晶。
回到Unity后,我们重新运行游戏,发现每五个道路生成一个水晶,并且游戏的生成速度似乎还不够快,因此我们将生成新道路的间隔时间从1秒缩短为0.5秒。
然而,我们发现生成道路时,某些地方可能会出现小的间隙,导致角色动画出现问题。为了解决这个问题,我们可以增加一个检查,确保在生成新道路时没有间隙,或者在角色掉落时调整动画参数。
我们通过给角色的动画状态添加一个新的过渡条件,例如“没有再掉落”来解决这个问题。通过这种方式,当角色从空中回到地面时,可以触发相应的动画。
到目前为止,我们已经成功地创建了一个通过程序化生成的无限世界。游戏的基本功能都已经实现,玩家可以收集分数,查看高分,并且不断地向前奔跑。接下来,玩家可以根据需要调整游戏的难度,例如通过增加角色的速度,或者更改道路生成的速度。
现在你已经掌握了如何制作一个程序化生成的无限奔跑游戏,接下来可以根据自己的想法和需求,加入更多的功能,使游戏更加丰富和有趣。
在本章中,我们将创建一款经典的游戏——水果忍者。这是一个非常好玩的游戏,我曾经在香港时玩了无数小时,甚至记得我曾通过火车从香港去中国。我的朋友,一位英国人,玩这款游戏非常厉害,打破了很多纪录,这让我非常有动力去打破他的记录。我也开始疯狂玩这款游戏,虽然这款游戏非常简单,但却极其有趣。你只需要滑动屏幕,切割水果,避免炸弹。这就是游戏的全部内容。
本章的目标是帮助你创建一个类似的游戏,水果忍者,并且让你掌握以下内容:
回想起当时在香港的日子,我的朋友真的是水果忍者的高手,而我为了赶上他的记录,几乎每天都在玩这款游戏。虽然这款游戏玩法简单,但是它的游戏机制非常有趣。通过切割水果而避免炸弹,简单的游戏机制却能让人沉浸其中。
通过构建这款游戏,你不仅可以学到游戏开发的基本概念,还可以:
完成这个游戏后,我希望你能把它分享出去,无论是上传到Google Play商店,还是其他平台。如果你愿意,欢迎分享你上传的链接,看看你是如何将这个游戏做得更好的。我非常期待看到你根据这次教程所做的游戏,并且能够与你的朋友分享。
无论是水果忍者还是其他游戏,我希望你在开发过程中不断学习和进步,最终成为一名优秀的开发者。
感谢你跟随我一起完成本章内容,我期待在接下来的课程中与你共同探索更多有趣的游戏开发技巧。无论你将来想做什么样的游戏,保持对编程和开发的热情,一定能够实现你的目标。
祝你好运,期待在下个视频中见到你!
在本章中,我们将创建一个水果忍者的克隆游戏。如果你不熟悉这款游戏,可以去YouTube查找一些相关视频。它是一个非常基础的游戏,你需要切割水果。只需要简单地滑动屏幕,然后切割水果,水果会掉下来,你将获得相应的分数。游戏的目标是尽可能地获得更多的分数。当然,游戏中有一些东西会阻碍你的得分,那就是炸弹。一旦你切到炸弹,你就会输掉游戏。基本上就是这些内容,我们将构建这些基础功能。
首先,我们需要创建一个新的项目,并将其命名为Fruit Ninja clone(水果忍者克隆)。在我的例子中,我将其命名为V1,但你不需要这样做,除非你已经在项目中有了一个克隆版本。
接下来,我们将导入一些资源,这些资源可以从本章的讲座附件中下载。包括:
将所有这些资源拖到Unity的资源管理器中,并创建一个文件夹,命名为models,然后将这些资源和材质文件移入其中。
我们首先来处理橙子模型:
在Fruit脚本中,我们需要实现一个切割水果的方法,使得水果在被切割时分成两部分:
public GameObject slicedFruitPrefab;
void CreateSlicedFruit() {
// 在当前水果的位置和旋转下实例化切割后的水果
Instantiate(slicedFruitPrefab, transform.position, transform.rotation);
// 销毁当前的水果对象
Destroy(gameObject);
}
void CreateSlicedFruit() {
// 实例化切割后的水果
GameObject inst = Instantiate(slicedFruitPrefab, transform.position, transform.rotation);
// 获取切割后部分的刚体
Rigidbody[] rbs = inst.GetComponentsInChildren<Rigidbody>();
// 为每个切割部分添加随机旋转
foreach (var rb in rbs) {
rb.transform.Rotate(Vector3.forward * Random.Range(0f, 360f));
// 添加爆炸效果
rb.AddExplosionForce(Random.Range(500f, 1000f), transform.position, 5f);
}
// 销毁当前的水果
Destroy(gameObject);
}
void Update() {
if (Input.GetKeyDown(KeyCode.Space)) {
CreateSlicedFruit();
}
}
当你按下空格键时,你会看到橙子被切割成两部分,并且这两部分会以随机的旋转和爆炸力向不同方向飞出。这是一个基本的切割水果的实现,接下来,我们将实现水果的生成机制,使得水果可以自动生成并飞向空中,等待玩家去切割。
不要忘记保存场景,准备好进入下一章,继续构建你的游戏!
在本视频中,我们的目标是生成一些水果,这些水果将从屏幕底部生成并快速飞向游戏场景。我们可以在后续阶段切割这些水果。目前我们无法切割它们,但以后会实现这一功能。此外,我们还希望水果生成的间隔时间是随机的,且每个水果生成的时间间隔不相同。
创建 Spanner 脚本
我们将为水果生成创建一个新的脚本,命名为 Spanner
,因为它的功能是生成水果。打开脚本并做如下设置:
Update
方法。GameObject
类型的公共变量,命名为 fruitToSpawn
,用于指定要生成的水果类型。minWait
和 maxWait
。例如,minWait
设置为 0.3 秒,maxWait
设置为 1.5 秒。启动协程生成水果
在 Start
方法中,我们将启动一个协程,该协程会按随机时间间隔生成水果。通过 StartCoroutine
启动协程,并在协程中使用 yield return new WaitForSeconds
来控制生成时间。具体代码如下:
public GameObject fruitToSpawn;
public float minWait = 0.3f;
public float maxWait = 1.5f;
void Start()
{
StartCoroutine(SpawnFruits());
}
IEnumerator SpawnFruits()
{
while (true)
{
yield return new WaitForSeconds(Random.Range(minWait, maxWait));
Debug.Log("Fruit spawned");
}
}
创建 Spanner 对象
Spanner
,然后为其添加刚才编写的脚本。minWait
和 maxWait
的值,选择 fruitToSpawn
(例如 Orange
)来进行测试。观察水果生成
Spanner
添加了一个 Gizmo,使用紫色来标识其位置。多点生成水果
添加水果的旋转
应用力使水果飞向空中
Rigidbody
),并通过 AddForce
方法向水果施加力。transform.up * 10
来为水果施加向上的力,创建飞向空中的效果。销毁生成的水果
SpawnFruits
协程中,添加了如下代码:Destroy(fruit, 5f);
调整生成的力和角度
minForce
和 maxForce
),以确保水果飞行的方向和力度符合需求。查看和调整生成点位置
下一步,我们将实现切割水果的功能,并将切割操作绑定到鼠标操作上。
欢迎回来!现在如果我们开始游戏并检查一个行为,那就是如果我们切割橙子,它们将永远存在于游戏中。我们已经确保橙子会在一定时间后被删除,但切割后的橙子却不会。因此,我们需要修改这一点。让我们进入脚本,具体来说是进入我们的 Fruit 脚本。
在 Fruit
脚本中,我们可以加入一个方法,使切割后的水果在 5 秒后销毁。可以在以下代码行进行修改:
Destroy(insert.gameObject, 5f);
这将使被实例化的切割水果(切割后的橙子)在 5 秒后被删除。现在我们来测试一下这个修改是否生效。我将切割水果并看看这些橙子切割后的物体是否会在 5 秒后被删除。正如你所看到的,它们确实消失了。这是非常重要的,如果不处理这些物体,可能会导致性能问题。虽然 PC 的硬件比较强大,问题不大,但在智能手机上,这可能会造成问题。所以一定要小心创建和删除游戏物体。
接下来,我们需要创建刀刃。首先,我们创建一个空的游戏物体并命名为 Blade。然后,重置其位置。接下来,给它添加一个组件——Trail Renderer(拖尾渲染器)。拖尾渲染器会产生一个视觉效果,模拟刀刃移动时产生的尾迹。
现在,保存脚本并测试它。移动刀刃,调整 X 和 Y 的值,你会看到它创建了一个品红色的尾迹。由于我们尚未为刀刃设置正确的材质,所以它默认显示为品红色。接下来,我们需要为刀刃创建一个材质。
首先,进入 Assets 文件夹,创建一个新文件夹,命名为 Materials。然后,在该文件夹中创建一个新的材质,并将其命名为 Blade。我们选择一个淡蓝色作为颜色,或者你可以选择任何你喜欢的颜色。我们将颜色设置为稍微明亮一些的蓝色。然后,将该材质拖拽到刀刃的材质槽中,测试一下效果。如果你将刀刃移动来回,你就会看到蓝色的尾迹。
接下来,我们要让刀刃的拖尾渲染器跟随鼠标。拖尾渲染器基于鼠标的世界坐标来移动。我们的游戏是 2D 游戏,但鼠标实际上位于 3D 世界中,所以我们将使用适合 2D 游戏的组件。
查看拖尾渲染器时,发现它保持存在的时间太长,这可能是由于默认的时间设置所致。将其修改为 0.2 秒,这样拖尾就会只存在 0.2 秒,生成短暂的拖尾效果。测试后你会发现拖尾更短,更符合预期。
为了使效果更好,我们还需要调整拖尾的宽度。你可以通过调整拖尾的 Key 来设置不同的宽度。在拖尾渲染器的曲线编辑器中,创建新的关键帧并调整它们的位置。这样,刀刃的拖尾就会呈现出更像刀刃的形态。
为了让刀刃能够与物体发生物理互动,我们需要给刀刃添加 RigidBody2D 组件,并设置为 Kinematic,这样它就不会受到其他物体的影响,但仍然可以影响其他物体。我们还需要设置碰撞检测为 Continuous,以确保刀刃与物体的碰撞更加流畅。
为了让刀刃能够随着鼠标移动,我们需要编写一个脚本来实现这一功能。首先,我们在脚本中创建一个 RigidBody2D 变量,并在 Awake 方法中初始化它。接着,我们编写一个名为 SetBladeToMouse 的方法,使用 Camera.main.ScreenToWorldPoint
将鼠标的屏幕位置转换为世界空间位置,进而使刀刃跟随鼠标。
private void SetBladeToMouse() {
Vector3 mousePosition = Input.mousePosition;
mousePosition.z = 10; // 设置 Z 坐标以确保正确的位置
rb.position = Camera.main.ScreenToWorldPoint(mousePosition);
}
然后在 Update 方法中调用该函数,确保刀刃每帧都能根据鼠标位置更新。
有时候,直接使用 Input.mousePosition
会导致问题,因为鼠标位置是一个 3D 坐标,但我们的游戏是 2D 游戏。因此,我们需要手动调整 Z 坐标来确保它在正确的 2D 平面上。通过设置 Z 值为 10(这实际上是向摄像机推远 10 个单位),就可以确保刀刃能够正确跟随鼠标。
接下来,我们需要让刀刃切割水果。在 Fruit 脚本中,我们添加一个 OnTriggerEnter2D 方法。当刀刃与水果发生碰撞时,我们就切割水果。具体来说,当刀刃的碰撞器与水果的碰撞器相遇时,我们创建一个新的切割水果对象。
private void OnTriggerEnter2D(Collider2D collision) {
Blade b = collision.GetComponent<Blade>();
if (b != null) {
// 切割水果的逻辑
CreateSlicedFruit();
}
}
同时,我们需要给刀刃添加碰撞器,确保它是触发器,并且水果的碰撞器也设置为触发器。
我们需要为刀刃设置适当的碰撞器大小,以便在切割水果时准确地检测到碰撞。通过调整刀刃的 Collider2D 的大小,使其与水果的大小匹配。测试时,如果刀刃与水果碰撞,水果就会被切割。
现在,当刀刃碰到水果时,水果就会被切割成两部分。你可以测试一下,确认水果是否能够正确切割。
在下一个视频中,我们将继续完善游戏,添加更多功能,优化游戏体验,提升游戏的视觉效果。
我们首先要在游戏中添加一些UI元素,比如得分、最高分和一个计时器。为了实现这些功能,我们需要创建一个新的对象,命名为game manager
,并为它添加一个脚本。
创建脚本
在“scripts”文件夹中创建一个新的脚本,并将其命名为game manager
。接着将该脚本拖到游戏管理器对象上。
编辑游戏管理器脚本
打开并编辑game manager
脚本,我们将先让它处理分数的计数。首先,我们需要一个整数来存储分数值,并且还需要一个文本对象来显示分数。因此,需要在脚本中添加Unity的UI命名空间:
using UnityEngine.UI;
然后声明一个public
文本变量来显示得分:
public Text scoreText;
增加分数的逻辑
接着,我们创建一个方法来增加分数,每次调用时增加2分,并更新显示的文本:
public void IncreaseScore()
{
score += 2;
scoreText.text = score.ToString();
}
回到Unity编辑器
在Unity中创建一个空对象,命名为UI
,并为其添加一个Canvas。在Canvas下,添加一个UI文本组件,并将scoreText
拖到脚本中对应的位置。调整文本的大小和位置,将其设置为在左上角显示。
接下来,我们设置一个显示得分和最高分的UI:
设置得分文本
创建一个新的UI文本,命名为scoreText
,并调整其大小、位置和颜色,例如将颜色设置为橙色。确保文本大小适合显示分数。
添加最高分文本
复制scoreText
并将其命名为highScoreText
,然后将其放置在屏幕的底部。可以修改文本大小为36并显示“Best: 0”。
添加文本边框
为文本添加一个轮廓效果,使其在不同背景色下更加清晰。在Unity编辑器中,选择UI组件,添加一个UI效果并选择Outline
。
现在,我们已经设置了UI,接下来要将分数更新逻辑连接到游戏中:
链接UI文本
将scoreText
拖到game manager
脚本中的scoreText
字段上,并确保初始得分为0。
增加分数的调用
在切水果的逻辑中,我们每次切到水果时,调用gameManager
中的IncreaseScore()
方法来增加分数。例如,在水果的脚本中,我们可以这样调用:
GameManager gameManager = FindObjectOfType<GameManager>();
gameManager.IncreaseScore();
这样,每当切到水果时,分数就会增加。
为了使游戏更具挑战性,我们需要添加一些机制来停止游戏,例如计时器到期或切到炸弹时游戏结束。
添加炸弹
我们需要将炸弹添加到游戏中。首先,找到炸弹模型并将其缩小(例如,设置其scale
为0.3)。然后给炸弹添加物理组件,如Rigidbody2D
和CircleCollider2D
。
炸弹脚本
为炸弹创建一个脚本Bomb
,在其中添加OnTriggerEnter2D
方法,当炸弹与刀片碰撞时,停止游戏:
void OnTriggerEnter2D(Collider2D collision)
{
Blade blade = collision.GetComponent<Blade>();
if (blade != null)
{
GameManager gameManager = FindObjectOfType<GameManager>();
gameManager.OnBombHit();
}
}
暂停游戏
在gameManager
脚本中创建OnBombHit()
方法,当炸弹被切到时,我们停止游戏:
public void OnBombHit()
{
Time.timeScale = 0; // 停止游戏
Debug.Log("Bomb Hit!");
}
使用Time.timeScale
控制游戏的时间流速,0
表示暂停。
为了让炸弹出现得更有挑战性,我们需要在游戏中随机生成炸弹和水果。修改水果生成逻辑:
修改物体生成器
我们将物体生成器中的fruitToSpawn
改为objectsToSpawn
,并将其类型改为GameObject[]
,这样它可以包含不同类型的物体(水果和炸弹)。然后,通过随机数决定生成水果还是炸弹:
public GameObject[] objectsToSpawn;
在生成水果时,生成一个随机数,如果值小于10,则生成炸弹,否则生成水果:
int p = Random.Range(0, 100);
if (p < 10)
{
go = objectsToSpawn[0]; // 生成炸弹
}
else
{
go = objectsToSpawn[Random.Range(1, objectsToSpawn.Length)]; // 生成水果
}
在Unity中设置生成物体
将水果和炸弹的预制体拖到objectsToSpawn
数组中,并调整炸弹出现的概率(例如,设为10%)。
测试游戏
运行游戏,确保炸弹和水果按预期生成。如果切到炸弹,游戏应暂停并显示“游戏结束”。
到这里,我们已经完成了游戏中得分、最高分显示、炸弹生成、以及游戏暂停等基本功能。在接下来的步骤中,我们可以进一步优化游戏的UI,添加更多的游戏玩法,例如计时器和重新开始游戏的功能。
欢迎回来。在我们游戏的当前行为中,每当我们碰到一个炸弹时,游戏就会停止。显然,这不是我们想要的最佳行为。我们期望的是,在炸弹触发时,能够展示一个“游戏结束”界面,或者类似的东西。对于我们的情况,我认为最好的选择是暂停背景中的游戏,同时弹出一个面板,显示“游戏结束”,并允许我们重新开始游戏。今天我们就要实现这一点。
首先,让我们创建一个面板。我不想让它拉伸,只希望它居中,宽度设置为 500,高度设置为 250。这就是我们的面板了。接着,我们在面板内添加一个文本组件,显示“游戏结束”。我们将文本居中,并将其向面板的顶部移动。位置可以调整为 -30 或者 -40,以确保显示效果良好。然后将字体大小改为 48。
接下来,我们设置文本溢出,以确保文本能够完整显示。现在,文本应显示为“游戏结束”。为了避免混淆,我们将面板的名字改为 GameOverPanel
,文本改为 GameOverText
。
现在,我们要根据游戏状态来激活或隐藏面板。如果游戏结束,我们希望显示游戏结束面板;如果游戏没有结束,我们则不显示它。
在我们的 GameManager
脚本中,我们需要添加一个新的公共游戏对象:GameOverPanel
,这个对象将存储我们的游戏结束面板。在添加完后,我们可以将 GameOverPanel
赋值到 GameManager
中,并在游戏开始时将其设为不可见。在 Awake
方法中,我们通过 gameOverPanel.SetActive(false)
来确保面板在游戏开始时是隐藏的。
当游戏结束时,我们希望能激活该面板,因此在游戏结束时,我们调用 gameOverPanel.SetActive(true)
来显示游戏结束面板。接下来,我们可以通过点击炸弹来触发游戏结束事件,并在后台看到暂停的游戏状态和“游戏结束”的文字。
接下来,我们为面板添加一个显示分数的文本。我们可以复制现有的游戏结束文本,将其修改为 ScoreText
,然后将其位置稍微调整至 -100。文本内容可以先设为 “score 0”,但我们希望分数能够动态更新。
为了在脚本中操作,我们需要一个新的变量来存储 ScoreText
。所以我们在 GameManager
中创建一个公共文本变量 gameOverPanelScoreText
,并将其赋值。然后我们将分数转换为字符串并显示在游戏结束面板中,更新为 Score: 3
或者 Score: 0
,具体取决于游戏的分数。
我们还需要添加一个重启按钮,让玩家可以重新开始游戏。首先,创建一个 UI 按钮,文本设置为“Restart”,并将其位置调整到合适的位置。接下来,我们为按钮添加一个 OnClick
事件,连接到 GameManager
脚本中的 RestartGame
方法。
在 RestartGame
方法中,我们需要做几件事:
Score: 0
。我们可以通过查找带有标签“Interactable”的所有游戏对象,然后销毁它们。确保所有互动对象(如炸弹、橙子和切割橙子)都被赋予该标签。在 RestartGame
方法中,我们使用 FindGameObjectsWithTag
来找到所有带有这个标签的对象,并使用 Destroy
销毁它们。
现在,我们可以测试游戏。在游戏过程中,获取分数后,碰到炸弹时,游戏会暂停,并显示游戏结束界面。点击重启按钮时,游戏会重置,分数清零,并销毁所有当前的游戏对象。
通过这一系列的步骤,我们实现了以下功能:
在接下来的视频中,我们将为游戏添加高分功能,目前虽然显示了分数,但我们还没有保存任何分数数据。我们将在之后的教程中完成这个部分。
欢迎回来!在本视频中,我们将添加高分功能。正如你在这里看到的,当前显示的“最佳得分”是零。接下来,我们将实现一个功能,使其能够真正记录和显示最高得分。
首先,我们需要在 GameManager
脚本中创建一个新的变量。这个变量用于存储高分文本,代码如下:
public Text highScoreText;
这个变量将帮助我们访问和更新高分文本。
Awake
方法中初始化高分在 Awake
方法中,我们需要从 PlayerPrefs
中读取存储的高分并更新显示的文本。PlayerPrefs
是 Unity 用来存储玩家设置的小型数据类,它可以保存整数、浮动数值和字符串。我们将使用它来存储和检索高分。
public int highScore;
void Awake() {
// 获取存储的高分,如果没有存储过,则默认值为 0
highScore = PlayerPrefs.GetInt("highScore", 0);
// 更新高分文本
highScoreText.text = "Best: " + highScore.ToString();
}
接下来,我们需要在玩家得分时检查当前得分是否超过了已保存的高分。如果是的话,我们更新高分并保存到 PlayerPrefs
中。
public int score;
void IncreaseScore() {
score++; // 增加分数
// 如果当前分数大于已保存的高分,更新高分
if (score > highScore) {
highScore = score;
PlayerPrefs.SetInt("highScore", highScore); // 保存新的高分
}
// 更新高分文本
highScoreText.text = "Best: " + highScore.ToString();
}
当游戏结束时,我们希望显示高分信息,因此需要在 GameOver
面板上添加一个新的文本对象,用于显示高分。我们可以在 Unity 编辑器中复制现有的“得分”文本对象,将其位置调整,并将其命名为“GameOverHighScoreText”。
接着,我们在 GameManager
中添加一个新的变量来引用这个文本对象:
public Text gameOverPanelHighScoreText;
在 OnBumpHit()
方法中,我们更新游戏结束时面板上的高分文本:
void OnBumpHit() {
// 显示游戏结束面板
gameOverPanel.SetActive(true);
// 更新游戏结束面板上的分数和高分
gameOverPanelScoreText.text = "Score: " + score.ToString();
gameOverPanelHighScoreText.text = "Best: " + highScore.ToString();
}
为了使代码更清晰,我们可以把获取高分的逻辑提取到一个独立的方法中:
private void GetHighScore() {
highScore = PlayerPrefs.GetInt("highScore", 0);
highScoreText.text = "Best: " + highScore.ToString();
}
在 Awake
方法中调用它:
void Awake() {
GetHighScore();
}
当玩家点击重启按钮时,我们需要重置当前分数,并确保高分保持不变。我们可以通过以下方法重置得分:
public void RestartGame() {
score = 0; // 重置得分
gameOverPanel.SetActive(false); // 隐藏游戏结束面板
GetHighScore(); // 获取并显示最新的高分
}
在 Unity 编辑器中,将 RestartGame
方法绑定到重启按钮的 OnClick()
事件中。
一旦完成了上述所有步骤,就可以测试这个功能。你应该能看到以下行为:
这样,我们就成功地实现了高分系统!在下一个视频中,我们将学习如何在 Blender 中创建自己的水果模型,并将其添加到游戏中。
欢迎回来!在本视频中,我们将把之前创建的所有不同模型添加到游戏中。你可以将它们与我们已经创建的橙子 prefab 进行比较,按照同样的方法构建西瓜、香蕉等其他水果的 prefab,并使它们能够在游戏中随机生成并正常工作。
首先,我们将添加西瓜模型。将西瓜模型拖入场景中,看到它很大,因此我们需要将它缩小。将西瓜的缩放值设置为 0.4 并应用。
同样,我们对切开的西瓜模型进行相同的操作,缩放至 0.4 并应用。
接下来,为西瓜添加所需的组件:
接下来,我们处理西瓜切块的部分。我们给每一块切开的西瓜添加 Rigidbody 和 Box Collider。这部分我们可以简化,使用 Box Collider 来代替 Circle Collider。记得,切开的西瓜模型不需要触发器。
完成这些后,将切开的西瓜对象拖入 Prefabs 文件夹中。然后,将切开的西瓜 prefab 拖入西瓜水果的 Sliced Fruit Prefab
变量中。
然后,我们进行香蕉的设置。将香蕉模型拖入场景中,同样需要缩放至 0.3 并应用。接着,进行同样的操作,为切开的香蕉添加缩放设置。
为香蕉添加以下组件:
对于切开的香蕉模型,我们需要为每一块添加 Rigidbody 和 Box Collider。使用两个 Box Collider 来为两块香蕉建模。完成后,我们将切开的香蕉拖入 Prefabs 文件夹,并将切割后的香蕉 prefab 拖入香蕉的 Sliced Fruit Prefab
变量中。
现在,我们将水果添加到游戏中的随机生成器中。在 Spawner
脚本中,目前只有橙子作为生成的水果。我们要为香蕉和西瓜添加随机生成的选项。将 香蕉 拖到 Spawner
脚本的第二个位置,将 西瓜 拖到第三个位置。
如果你有其他的水果模型,比如苹果、椰子等,可以继续将它们添加到这里。
现在,我们开始测试游戏。启动游戏并看看是否能够随机生成橙子、香蕉和西瓜。当你切割这些水果时,它们应该会被销毁,并且当游戏结束时,所有水果都会正确消失。
通过这一系列步骤,你已经成功地将多个水果模型添加到了游戏中,并实现了它们的随机生成与切割功能。
至此,我们的游戏已经接近完成,类似于 Fruit Ninja 的游戏已经有了基本的水果切割功能。接下来的步骤将是准备游戏的 Android 版本。在下一个视频中,我们将一起看如何准备 Android 版本的游戏。
欢迎回来!在本视频中,我们将准备代码,使得游戏不仅可以在电脑上运行,还可以在 Android 上运行。目前,我们的游戏使用鼠标进行操作,但是在 Android 设备上,我们需要使用触摸屏。因此,我们需要做一些调整,以确保只有在鼠标或触摸屏发生移动时,刀刃才会激活,水果才会被切割。
为了实现这一目标,我们需要检查鼠标是否在移动,而不是仅仅检测鼠标的当前位置。具体步骤如下:
我们需要添加以下变量:
MinVelo
(最小速度):用于判断鼠标移动是否足够明显,只有当速度超过此值时才算有效的移动。lastMousePosition
:记录上一次的鼠标位置。mouseVelocity
:用于计算鼠标的移动速度。collider2D
:用于控制刀刃的碰撞器是否激活。public float MinVelo = 0.1f; // 最小速度,只有当鼠标移动的速度超过这个值时,才认为是有效的移动
private Vector3 lastMousePosition;
private Vector3 mouseVelocity;
private Collider2D call; // 刀刃的碰撞器
在 Awake
方法中初始化刀刃的碰撞器:
void Awake()
{
call = GetComponent<Collider2D>(); // 获取刀刃的 2D 碰撞器
}
接下来,我们需要创建一个方法来判断鼠标是否在移动。这将通过比较当前鼠标位置与上次鼠标位置的差异来计算移动距离。如果移动距离大于最小速度值,则认为鼠标在移动。
bool IsMouseMoving()
{
Vector3 currentMousePosition = transform.position; // 获取当前鼠标位置
float traveled = (lastMousePosition - currentMousePosition).magnitude; // 计算鼠标移动的距离
lastMousePosition = currentMousePosition; // 更新最后的鼠标位置
if (traveled > MinVelo) // 如果移动的距离大于最小速度,则返回 true,表示鼠标在移动
return true;
else
return false;
}
在 Update
方法中,我们检查鼠标是否在移动,并据此激活或禁用刀刃的碰撞器。
void Update()
{
call.enabled = IsMouseMoving(); // 只有当鼠标移动时,刀刃的碰撞器才会被启用
}
保存脚本并回到游戏中进行测试。在游戏运行时,如果你没有移动鼠标或触摸屏,水果将不会被切割;只有当鼠标或触摸屏发生明显移动时,刀刃才会激活并切割水果。
通过这种方式,我们确保了只有在用户实际进行移动时,刀刃才会起作用,从而模拟了触摸屏的行为。
现在,代码已经准备好进行 Android 版本的移植。我们将在下一个视频中配置 Android Studio,为 Android 设备准备游戏。
这段代码和测试确保了游戏能够根据设备的输入方式(鼠标或触摸屏)来正确激活刀刃,避免无效的切割事件。
在本视频中,我们将介绍如何将游戏导出并在 Android 设备上运行。由于我自己没有 Android 设备,所以下面会展示一段朋友录制的视频,演示如何设置你的 Windows 机器来运行游戏。
首先,你需要安装 Android Studio,并在 developer.android.com 网站上下载它。
安装完成后,你不需要启动 Android Studio,只需要进行以下配置:
C:\Users\<你的用户名>\AppData\Local\Android\SDK
,复制该路径。现在,打开 Unity,并将 Android SDK 路径配置到 Unity 中:
通过 USB 将 Android 设备连接到计算机。
接下来,配置 Android 的其他设置:
com.<你的公司名称>.test
。Fruit Ninja Clone
。安装完毕后,游戏将直接在你的设备上运行。你可以测试游戏是否正常工作,切割水果和碰到炸弹时的效果。
虽然游戏已经可以运行在 Android 设备上,但显示可能有些小。接下来我们将在下一视频中进行一些界面调整,确保游戏在 Android 上显示正常。
通过以上步骤,你可以将游戏导出为 APK 文件并在 Android 设备上运行,确保游戏可以在 Android 手机上直接体验。
在本视频中,我们将对游戏的界面做一些调整,包括更改相机的背景颜色和优化 UI 设置。
更改相机背景颜色:
UI 画布设置:
接下来,我们将对游戏结束面板(Game Over Panel)进行一些调整,以提升界面的视觉效果。
更改游戏结束面板背景颜色:
调整相机背景颜色:
调整重启按钮和文本:
调整分数文本:
完成这些调整后,保存场景,并构建和运行游戏,确保它在 Android 设备上能够正常显示。
通过这些调整,游戏的界面看起来更加协调,颜色对比度也有了显著提升。尤其是 重启按钮,虽然还可以进一步优化,但已经明显比之前更大并且适应得更好。
这些修改为用户提供了更友好的界面,使游戏体验更加顺畅。
在本视频中,我们将学习如何为游戏添加广告。通过添加广告,你可以监控游戏的表现并通过广告获得收益。我们将使用 Unity Ads 来实现这一功能。
创建 Unity 账户:
启用 Unity 广告服务:
获取游戏 ID:
在代码中添加广告:
using UnityEngine.Advertisements;
Awake()
方法中,初始化广告:
Advertisement.Initialize("your_game_id");
这里,"your_game_id"
是你从 Unity 服务中获取的 ID。
显示广告:
if (Advertisement.IsReady())
{
Advertisement.Show();
}
测试广告:
通常,仅仅展示广告并不是最赚钱的方式。为提高广告的盈利效果,最好结合奖励机制。例如,当玩家观看完 30 秒的广告后,可以给玩家一些游戏内奖励,如金币或其他资源。
设置奖励广告:
激励系统设计:
避免自己点击广告:
以下是广告集成的总结步骤:
通过这些步骤,你现在可以在 Unity 中成功集成广告,并通过广告获得收入。同时,合理设计奖励系统,可以让玩家更愿意观看广告,从而提高游戏的盈利潜力。在下一视频中,我们将讨论如何避免因点击自己的广告而导致的处罚问题。
在上一步中,你已经学习了如何为游戏添加广告,并且通过 Unity Ads 将其连接到 Play Store。现在,我们需要确保你的设备不会因测试广告而为你带来收益。为此,我们将注册测试设备,并确保在测试过程中你不会从点击广告中获得任何收入。
登录到 Unity Dashboard:
注册测试设备:
查找广告 ID:
完成设备注册:
测试时的效果:
通过将设备注册为测试设备,你可以在不产生实际收入的情况下进行广告的测试。这对于开发和调试广告功能非常重要,尤其是在你还在开发阶段时,确保你的广告收入不会因为自己点击广告而受到影响。在发布到 Play Store 或其他平台之前,确保完成了这些设置,以避免因误操作而被处罚。
在这段视频中,我们将介绍如何创建并使用你自己的声音效果。这个过程非常简单,我们将使用嘴巴模拟一些基本的声音,然后将其用于我们的游戏。为了录制和编辑声音,我们将使用 Audacity,这是一款免费的录音和编辑软件,非常适合制作声音效果。
下载并安装 Audacity:
录制声音:
编辑录制的音频:
保存和导出音频文件:
将声音导入到 Unity 中:
现在,我们已经将声音文件导入到 Unity 项目中,接下来我们将在游戏中播放该声音。
在游戏管理器中添加声音变量:
初始化音频源:
audioSource
:
audioSource = GetComponent<AudioSource>();
为切割声音创建播放方法:
public void PlayRandomSliceSound() {
AudioClip randomSound = sliceSounds[Random.Range(0, sliceSounds.Length)];
audioSource.PlayOneShot(randomSound);
}
Random.Range
用来从 sliceSounds
数组中随机选择一个声音。调用声音播放方法:
PlayRandomSliceSound
方法:
// 例如,在切水果的代码中添加以下代码
GameManager.instance.PlayRandomSliceSound();
测试声音效果:
AudioSource
组件已经正确添加到 GameManager 上。sliceSounds
数组并拖放音频文件(例如之前的 "slash.wav")到数组元素中。运行游戏进行测试:
你可以为游戏中的其他事件(例如炸弹爆炸、得分等)添加类似的声音效果。你只需要为每个事件创建不同的音频文件,并使用类似的方法播放。
比如,为炸弹点击事件添加声音:
// 在 BombHit 方法中添加播放声音
public void PlayBombHitSound() {
audioSource.PlayOneShot(bombHitSound);
}
现在,你已经知道如何创建简单的音效并将其导入 Unity 游戏中。通过这些基本步骤,你可以轻松地为游戏中的各种事件添加音效,提升游戏体验。如果你有其他功能需要添加,或者觉得有缺失的部分,可以自己尝试添加或者向我们反馈,我们将很高兴看到你如何改进和扩展这个项目。
好了,你已经完成了这门课程。在这两年的时间里,我意识到自己没有在课程的结尾留下最终的感言,而现在是时候补上了。你可能注意到,我看起来比两年前好了一些,我希望你喜欢我现在的模样,但这并不是重点。这段视频的重点是感谢你。
我非常感激你依然陪伴着我走到这一步,感谢你在这段超过 30 小时的课程中一路走来。我知道要花费大量时间来完成这门课程并掌握其中的知识,但如果你认真且持续地学习,那么你已经成为了一名真正的开发者。我由衷地感激你做到了这一点,也感激能有机会教你这些内容。
我的愿景是教授 1000 万人如何编程,听起来这个目标或许很疯狂,但它一直在我的心中。虽然你不能看到我写在纸上的愿景,但我可以告诉你,这个目标我已经为自己设定,并且深信不疑。
你可能会问,为什么是 1000 万人?我的想法是,如果我能帮助 1000 万人学会编程,那么其中的一部分人将会开发出改变我们所有人生活的软件。试想一下,如果像扎克伯格这样的天才程序员从我这里学习编程,虽然现在他已经是一个了不起的开发者,并且拥有像 Facebook 这样改变世界的巨大公司,但假设在未来有某个新的软件创新,可能我无法预见它的样貌,但有一个从我这里学习的开发者,或许就是你,能创造出改变全球的应用软件。
我相信从这 1000 万人当中,哪怕只有 1000 人成为杰出的开发者,那么他们的成功将是我教导的一部分。那就是我的目标,也就是我所坚持的理念。为了实现这一点,我一直在制作这些视频,去帮助更多的人实现梦想。
因此,我非常感谢你坚持走完了这门课程,感谢你从我这里学习了编程。我希望你现在能够创造出一些伟大的软件,用它来改变世界,或者至少改变你自己的世界。其实改变世界不一定意味着影响全球,有时候,仅仅是改善你自己的生活、为你的家庭带来更好的未来,获得一份更好的工作,甚至创办自己的公司,开始创业,这些也是非常值得的目标。
甚至,如果你只是将编程当作一种爱好,做一个简单的游戏,带给几个人快乐,或者让一小部分人感到愉悦,那也是非常美好的。其实能做到这些,就已经很了不起了。
我衷心希望你能在这条编程的路上走得更远,取得更大的成就。我非常感谢你和我一起走过这段旅程,并祝你一切顺利!
我还会继续开设更多课程,我会让这些课程越来越好,因为在这过程中,我自己也在不断学习、不断进步,不仅作为一名开发者,也作为一名讲师。所以,我希望你能继续关注我的课程,未来会有更多有用的内容和教学资源提供给你。
此外,我也将推出一个网站 tutorials.eu,这是我发布教程和编程相关博客文章的平台。今天我们将首次发布博客文章,未来,当你观看这段视频时,网站上可能已经有成百上千篇文章可以供你学习了。而且,我们将制作每篇博客的配套视频,让你不仅能通过文字学习,还能通过视频进一步理解。我们绝不会仅仅满足于写文章,而是会结合视频内容提供更深入的学习体验。
说实话,我可能有点语无伦次了,但我真的非常感谢你与我一起完成这门课程,祝愿你在未来的编程之旅中一切顺利。
完整的C#大师课程 Complete C# Masterclass
1 - 你的第一个C程序与Visual Studio概述 1 - 引言 2 - 你想达成什么 3 - 安装Visual Studio社区版 5 - 第一个程序:你好,世界 6 - 你好,世界项目结构 7 - 理解新旧格式,以及如何在控制台中发出声音 8 - Mac上的你好,世界 9 - Visual Studio界面 13 - 第一章总结
2 - 数据类型与变量 20 - 更多数据类型及其限制 22 - 数据类型:整型、浮点型与双精度型 23 - 字符串数据类型及其方法 24 - 值类型与引用类型 25 - 编码标准 26 - 控制台类及其方法 27 - 命名约定与编码标准 28 - 隐式与显式转换 29 - 将字符串解析为整数 30 - 字符串操作 31 - 一些字符串方法 32 - 如何使用转义字符在字符串中使用特殊字符 34 - 练习字符串1的解决方案 36 - 练习字符串2的解决方案 38 - 数据类型与变量挑战的解决方案 39 - 使用var关键字 40 - 常量 42 - 数据类型总结
3 - 函数、方法及如何节省时间 43 - 方法简介 44 - 函数与方法介绍 45 - 无返回值的方法 46 - 有返回值和参数的方法 48 - 方法挑战的解决方案 49 - 用户输入 50 - 尝试-捕获与最终 51 - 运算符 52 - 方法总结
4 - 决策 53 - 决策简介 54 - C语言中的决策制作介绍 55 - 尝试解析简介 56 - IF与ELSE IF尝试解析 57 - 嵌套IF语句 59 - IF语句挑战的解决方案 60 - 开关语句 62 - IF语句挑战2的解决方案 63 - 增强IF语句:三元运算符 65 - 增强IF语句:三元运算符挑战的解决方案 66 - 决策总结
5 - 循环 67 - 循环简介 68 - 循环基础 69 - For循环 70 - Do While循环 71 - While循环 72 - break与continue 74 - 循环挑战的解决方案 75 - 循环总结
6 - 面向对象编程(OOP) 76 - 对象简介 77 - 类与对象介绍 78 - 我们的第一个自定义类 79 - 构造函数与成员变量 80 - 使用多个构造函数 82 - Visual Studio中的快捷方式 84 - Visual Studio中的代码片段 85 - 理解方法与变量的私有与公共 86 - C#中的设置器 87 - C#中的获取器 88 - C#中的属性 89 - 自动实现属性 90 - 只读与只写属性 91 - 成员与终结器(析构函数) 92 - 对象总结
7 - C中的集合 93 - 数组简介 94 - 数组基础 95 - 声明与初始化数组及长度属性 96 - Foreach循环 97 - 为什么使用Foreach 98 - 多维数组 99 - 嵌套For循环与二维数组 100 - 嵌套For循环与二维数组两个例子 101 - 挑战:井字棋 102 - 锯齿数组 103 - 锯齿数组或多维数组 104 - 挑战:锯齿数组 105 - 将数组作为参数使用 107 - params关键字 108 - 为什么使用params 109 - 使用params获取多个数字的最小值 110 - 概述:通用与非通用集合 111 - ArrayLists 112 - 列表 113 - 哈希表 114 - 哈希表挑战 115 - 字典 116 - 编辑与删除字典中的条目 117 - 队列与堆栈概述 118 - C#中的堆栈 119 - 队列 120 - 数组总结
8 - 调试 122 - 调试简介 123 - 调试基础 124 - 局部变量与自动变量 125 - 调试:创建列表副本并解决一些bug 126 - 调试:调用栈抛出错误与防御性编程
9 - 继承与更多关于OOP 127 - 欢迎来到继承 128 - 继承介绍 129 - 简单的继承示例 130 - 虚拟与重写关键字 131 - 继承演示 132 - 继承挑战:视频发布与带回调的计时器 134 - 继承挑战2:员工、老板与实习生的解决方案 135 - 接口简介 136 - 创建并使用自己的接口 137 - IEnumerator与IEnumerable 138 - IEnumerable示例1 139 - IEnumerable示例2 140 - 继承结束语
10 - 多态与更多OOP文本文件 141 - 多态简介 142 - 多态参数 143 - 密封关键字 144 - 关联关系 145 - 抽象 146 - 抽象与多态的关键字 147 - 接口与抽象类 148 - 从文本文件读取 149 - 写入文本文件 150 - 多态总结
11 - 高级C话题 151 - 高级话题简介 153 - 访问修饰符 154 - 结构体 155 - 枚举 156 - 数学类 157 - 随机类 159 - 正则表达式 160 - 日期时间 161 - 可空类型 162 - 垃圾回收器 163 - Main参数解释(第一部分) 164 - 使用用户输入创建CMD应用的Main参数解释
12 - 事件与委托 165 - 委托简介 166 - 委托介绍 167 - 委托基础 168 - 创建自己的委托 169 - 匿名方法 170 - Lambda表达式 172 - 事件与多播委托 173 - 委托结束语
13 - WPF(Windows Presentation Foundation)替代品(2023年8月底前) 175 - WPF简介 176 - WPF及其使用时机介绍 177 - XAML基础与代码后置 178 - StackPanel、ListBox的可视与逻辑树 179 - 路由事件:直接冒泡与隧道 181 - 网格 182 - 数据绑定 183 - INotifyPropertyChanged接口 184 - ListBox与当前匹配列表 185 - ComboBox 186 - CheckBox 187 - ToolTip 188 - 单选按钮与图像 189 - 属性数据与事件触发 190 - PasswordBox 191 - WPF总结
14 - WPF(Windows Presentation Foundation) 192 - 安装WPF工作负载 193 - 创建WPF项目 194 - WPF项目结构与代码后置文件 195 - 创建我们的第一个GUI元素 196 - 创建带有列和行的网格 197 - 固定、自动与相对大小 198 - 创建一个完美的网格 199 - WPF挑战:重建此GUI 200 - WPF挑战解决方案:重建此GUI 201 - 列跨越与行跨越 202 - 用C#创建GUI元素 203 - 元素属性:样式与定位 204 - 按钮点击与事件处理 205 - 待办事项列表应用简介与项目设置 206 - 创建网格按钮与文本框 207 - 创建滚动视图与堆栈面板 208 - 设置x名称属性以便访问 209 - 添加待办事项创建逻辑 210 - ContentControl与UserControl简介 211 - 创建用于登录的ContentControl与UserControl 212 - 设计LoginView 213 - 显示LoginView UserControl 214 - 创建与显示InvoiceView UserControl 215 - 数据绑定简介 216 - 设置要绑定的数据 217 - 单向数据绑定 218 - 双向数据绑定 219 - 单向到源的数据绑定 220 - 一次性数据绑定 221 - ListBox简介 222 - ListBox项源 223 - ListBox项模板 224 - ListBox访问选中的数据 225 - 下一个应用:登录功能 226 - 创建项目与登录用户控件 227 - 添加密码框 228 - 环境变量 229 - 使用环境变量登录 230 - 密码更改事件 231 - 如何继续
15 - WPF项目:货币转换器第一部分 232 - WPF货币转换器项目概述与设置 233 - WPF货币转换器:矩形与渐变 234 - WPF货币转换器:设置堆栈面板与标签 235 - WPF货币转换器:自定义按钮与实现点击事件 236 - WPF货币转换器:创建带有文本框与下拉框的输入字段 237 - WPF货币转换器:将值绑定到下拉框 238 - WPF货币转换器:输入验证与逻辑完成
16 - 使用数据库与C 240 - 数据库简介 241 - 设置MS SQL Server和VS进行数据库工作 242 - 数据集和表的介绍与设置 243 - 关系或关联表 244 - 在ListBox中显示数据 245 - 显示关联数据 246 - 在ListBox中显示所有动物 247 - 通过点击从表中删除 249 - 删除动物、移除动物和添加动物功能 250 - 更新我们表中的条目 251 - 数据库总结
17 - WPF项目货币转换器第二部分 252 - WPF货币转换器:构建一个带数据库集成的货币转换器 253 - WPF货币转换器:设计货币转换的用户界面 254 - WPF货币转换器:理解数据网格功能和属性 255 - WPF货币转换器:为货币转换设置数据库 256 - WPF货币转换器:实现数据库的SQL连接 257 - WPF货币转换器:实现保存按钮功能 258 - WPF货币转换器:添加新的货币条目 259 - WPF货币转换器:在数据库中插入和编辑数据
18 - Linq 261 - Linq简介 262 - Linq温和介绍 263 - Linq演示 264 - Linq与列表和我们的大学管理者第一部分 265 - 使用Linq进行排序和过滤 266 - 基于其他集合创建集合 267 - Linq与XML 268 - 为LinqToSQL设置项目 269 - 将对象插入数据库 270 - 使用关联表与Linq 271 - 更高级的表连接 272 - 删除和更新 273 - Linq总结
19 - WPF项目货币转换器与GUI数据库和API第三部分 275 - WPF货币转换器:使用API和JSON获取实时货币值
20 - 编码面试的练习
21 - 线程 278 - 线程简介 279 - 线程基础 280 - 线程开始和结束完成 281 - 线程池与后台线程 282 - Join和IsAlive 283 - 任务与WPF 285 - 线程总结
22 - 单元测试 驱动开发(TDD) 286 - TDD介绍 287 - 什么是TDD(测试驱动开发) 288 - 创建项目并编写第一个测试 289 - 重构和添加领域 290 - 添加Web API 291 - 测试优先方法 292 - 断言消息 293 - 流畅断言 294 - 测试条件和前提 295 - 设置航班项目 296 - 将场景翻译为测试 297 - 红绿重构 298 - 给定-当-然后模式及避免超订场景的发现 299 - 避免超订场景 300 - 测试可信度与逆向提问 301 - 对剩余座位数进行实际逆向提问 302 - 参数化测试 303 - 通过检查生产代码是否完整发现新场景 304 - 重构rememberbookings 305 - TDD规则 306 - 使用测试驱动开发规则取消预订场景 307 - 处理取消预订未找到预订 308 - 如何发现新场景 309 - 应用层测试 310 - 应用层预订场景第一部分 311 - 应用层预订场景第二部分 312 - 应用层预订场景第三部分 313 - 配置内存数据库 314 - 参数化预订航班测试 315 - 实现预订服务 316 - 重构预订服务 317 - 创建取消预订的测试 318 - 最终确定取消预订 319 - 命名约定 320 - 测试套件作为文档 321 - 应用层
23 - UNITY基础 322 - Unity基础介绍 324 - Unity界面概述 325 - 创建自己的布局 326 - 玩家移动 327 - 确保我们正确修改 328 - 物理基础 329 - RigidBody物理体 330 - 碰撞器及其不同类型 331 - 触发器 332 - 预制件与游戏对象 333 - 组件及预制件的更多内容 334 - 保持层级整洁 335 - 类结构 336 - Mathf和随机类 337 - Unity基础总结
24 - UNITY使用Unity构建游戏Pong 338 - Pong介绍 339 - 基础UI元素 340 - 基础通过代码访问文本 341 - 基础按钮 342 - 基础切换场景 343 - 基础播放声音 344 - Pong项目概述 345 - 创建主菜单 346 - 切换场景并使用按钮 347 - 构建我们的游戏场景 348 - 2D与3D碰撞器和Rigidbody为我们的球 349 - 左右移动我们的球 350 - 球拍移动 351 - 正确反弹 352 - 得分系统 353 - 重新开始一轮 354 - 游戏结束画面 355 - 为游戏添加声音 356 - 添加基础AI 357 - 章节总结
25 - UNITY使用Unity构建Zig Zag克隆 358 - 章节介绍 359 - Zig Zag介绍 360 - 基础实例化通过代码创建对象 361 - 基础Invoke与InvokeRepeating进行延迟和重复调用 362 - 基础PlayerPreferences保存数据 363 - 基础Raycast 364 - Zig Zag的设置 365 - 设置透视 366 - 移动角色 367 - 使相机跟随玩家 368 - 动画角色 369 - 开始游戏 370 - 重新开始游戏 371 - 收集水晶并增加分数 372 - 添加高分 373 - 添加粒子效果 374 - 背景音乐循环 375 - 程序生成我们的地图
26 - UNITY使用Unity构建水果忍者克隆 376 - 章节介绍 377 - 创建水果并将其爆炸 378 - 水果生成器 379 - 创建我们的刀 380 - GUI和炸弹 381 - 游戏结束与重新开始 382 - 添加高分 383 - 扩展游戏 384 - 为Android准备代码 385 - 在Android设备上测试 386 - 进行一些调整 387 - 为游戏添加Unity广告 388 - 将设备设置为开发者设备 389 - 添加声音
27 - 感谢您完成课程 390 - 感谢您完成课程
1 - Your First C Program And Overview Of Visual Studio 1 - Introduction 2 - What Do You Want To Achieve 3 - Installing Visual Studio Community 5 - Hello World First Program 6 - Hello World Project Structure 7 - Understanding the new and old Format and how to make a sound in the console 8 - Hello World on a Mac 9 - Visual Studio Interface 13 - Chapter 1 Summary
2 - DataTypes And Variables 20 - More Datatypes and Their Limits 22 - Datatypes Int Float and Double 23 - Datatype String And Some Of Its Methods 24 - Value vs Reference Types 25 - Coding Standards 26 - Console Class and some of its Methods 27 - Naming Conventions and Coding Standards 28 - Implicit and Explicit Conversion 29 - Parsing a String To An Integer 30 - String Manipulation 31 - Some String Methods 32 - How to use special characters in strings with the escape character 34 - Solution For Exercise Strings 1 36 - Solution For Exercise Strings 2 38 - Solution For The Challenge Datatypes And Variables 39 - Using The var Keyword 40 - Constants 42 - DataTypes Summary
3 - Functions Methods And How To Save Time 43 - Methods Intro 44 - Intro To Functions Methods 45 - Void Methods 46 - Methods With Return Value And Parameters 48 - Solution For The Challenge Methods 49 - User Input 50 - Try Catch and Finally 51 - Operators 52 - Methods Summary
4 - Making Decisions 53 - Making Decisions Intro 54 - Introduction To Decision Making In C 55 - Intro to TryParse 56 - IF And Else If Try Parse 57 - Nested If Statements 59 - Solution For The Challenge If Statements 60 - Switch Statement 62 - Solution For The Challenge If Statements 2 63 - Enhanced If Statements Ternary Operator 65 - Enhanced If Statements Ternary Operator Challenge Solution 66 - Making Decisions Summary
5 - Loops 67 - Loops Intro 68 - Basics of Loops 69 - For Loops 70 - Do While Loops 71 - While Loops 72 - break and continue 74 - Solution For The Challenge Loops 75 - Loops Summary
6 - Object Oriented Programming OOP 76 - Objects Intro 77 - Introduction To Classes And Objects 78 - Our First Own Class 79 - Constructors and Member Variables 80 - Using Multiple Constructors 82 - Shortcuts in VS 84 - Code Snippets in VS 85 - Understanding private vs public for methods and variables 86 - Setters in CSharp 87 - Getters in CSharp 88 - Properties in CSharp 89 - Auto Implemented Properties 90 - ReadOnly and WriteOnly Properties 91 - Members And FinalizersDestructors 92 - Objects Summary
7 - Collections in C 93 - Arrays Intro 94 - Basics of Arrays 95 - Declaring and Initializing Arrays and the Length Property 96 - Foreach Loops 97 - Why Foreach 98 - Multi Dimensional Arrays 99 - Nested For Loops And 2D Arrays 100 - Nested For Loops And 2D Arrays Two Examples 101 - Challenge Tic Tac Toe 102 - Jagged Arrays 103 - Jagged Arrays or Multidimensional Arrays 104 - Challenge Jagged Arrays 105 - Using Arrays As Parameters 107 - Params Keyword 108 - Why would we use Params 109 - Getting The Min Value Of Many Given Numbers Using Params 110 - Overview Generic and NonGeneric Collections 111 - ArrayLists 112 - Lists 113 - Hashtables 114 - Hashtables Challenge 115 - Dictionaries 116 - Editing And Removing Entries in a Dictionairy 117 - Queues and Stacks Overview 118 - Stacks in Csharp 119 - Queues 120 - Arrays Summary
8 - Debugging 122 - Debugging Intro 123 - Debugging Basics 124 - Locals and Autos 125 - Debugging Creating Copies of Lists and solving some bugs 126 - Debugging Call Stack Throwing Errors and defensive programming
9 - Inheritance And More About OOP 127 - Welcome to Inheritance 128 - Introduction To Inheritance 129 - Simple Inheritance Example 130 - Virtual and Override Keywords 131 - Inheritance Demo 132 - Inheritance Challenge Videopost and Timer with Callback 134 - Inheritance Challenge 2 Employees Bosses and Trainees Solution 135 - Interfaces Intro 136 - Creating And Using Your Own Interfaces 137 - IEnumerator and IEnumerable 138 - IEnumerable Example 1 139 - IEnumerable Example 2 140 - Inheritance Outro
10 - Polymorphism And Even More On OOP Text Files 141 - Polymorphism Intro 142 - Polymorphic Parameters 143 - Sealed Key Word 144 - Has A Relationships 145 - Abstract 146 - Abstract and as is Keyword Polymorphism 147 - Interfaces vs Abstract Classes 148 - Read from a Textfile 149 - Write into a Text File 150 - Polymorphism Summary
11 - Advanced C Topics 151 - Advanced Topics Intro 153 - Access Modifiers 154 - Structs 155 - Enums 156 - Math Class 157 - Random Class 159 - Regular Expressions 160 - DateTime 161 - Nullables 162 - Garbage Collector 163 - Main Args Explained part 1 164 - Main Args Explained Using User Input Create A CMD App
12 - Events and Delegates 165 - Delegates intro 166 - Delegates Introduction 167 - Delegates Basics 168 - Creating your own Delegates 169 - Anonymous Methods 170 - Lambda Expressions 172 - Events and Multicast Delegates 173 - Delegates Outro
13 - WPF Windows Presentation Foundation Replaced end of August 2023 175 - WPF Intro 176 - Introduction To WPF And When To Use It 177 - XAML Basics and Code Behind 178 - StackPanel Listbox Visual and Logical Tree 179 - Routed Events Direct Bubbling and Tunneling 181 - Grid 182 - Data Binding 183 - INotifyPropertyChanged Interface 184 - ListBox and a List of Current Matches 185 - ComboBox 186 - CheckBox 187 - ToolTip 188 - RadioButtons and Images 189 - Property Data and Event Triggers 190 - PasswordBox 191 - WPF Summary
14 - WPF Windows Presentation Foundation 192 - Installing the WPF workload 193 - Creating a WPF project 194 - WPF project structure and code behind files 195 - Creating our first GUI Element 196 - Creating a grid with columns and rows 197 - Fixed auto and relative sizing 198 - Creating a perfect grid 199 - WPF Challenge Recreate this GUI 200 - WPF Challenge Solution Recreate this GUI 201 - Column span and row span 202 - Creating GUI elements with C Sharpmp4 203 - Element properties for styling and positioning 204 - Button click and event handlers 205 - Todo List application intro and project setup 206 - Creating the grid button and text box 207 - Creating the scrollview and stackpanel 208 - Setting x name attributes for access 209 - Adding the todo creation logic 210 - Introduction ContentControl and UserControl 211 - Creating ContentControl and UserControl for login 212 - Designing the LoginView 213 - Displaying the LoginView UserControl 214 - Creating and displaying InvoiceView UserControl 215 - Data Binding introduction 216 - Setting up the data to bind 217 - OneWay data binding 218 - Two Way Databinding 219 - One Way To Source Databinding 220 - One Time Databinding 221 - ListBox introduction 222 - ListBox ItemSource 223 - ListBox ItemTemplate 224 - ListBox accessing selected data 225 - Next application login functionality 226 - Creating the project and login user control 227 - Adding the password box 228 - Environment variables 229 - Using the environment variable for login 230 - Password change event 231 - How to move on
15 - WPF Project Currency Converter Part 1 232 - WPF Currency Converter Project overview and setup 233 - WPF Currency Converter Rectangles and Gradients 234 - WPF Currency Converter Setting Up Stack Panel and Labels 235 - WPF Currency Converter Customizing Buttons and Implementing Click Events 236 - WPF Currency Converter Creating Input Fields with Text Box and Combo Box 237 - WPF Currency Converter Binding Values to Combo Boxes 238 - WPF Currency Converter Input Validation and Completing the Logic
16 - Using Databases With C 240 - Databases Intro 241 - Setup MS SQL Server and VS For DB work 242 - Intro And Setting Up Our DataSet And Table 243 - Relationship or Associative Tables 244 - Showing Data in a ListBox 245 - Showing Associated Data 246 - Displaying all Animals In The ListBox 247 - Deleting From A Table With A Click 249 - Delete Animals Remove Animals and Add Animals Functionality 250 - Updating Entries in Our Tables 251 - Databases Outro
17 - WPF Project Currency Converter Part 2 252 - WPF Currency Converter Building a Currency Converter with Database Integration 253 - WPF Currency Converter Designing the User Interface for Currency Conversion 254 - WPF Currency Converter Understanding Data Grid Functionality and Properties 255 - WPF Currency Converter Setting up a Database for Currency Conversion 256 - WPF Currency Converter Implementing SQL Connections for Database 257 - WPF Currency Converter Implementing Save Button Functionality 258 - WPF Currency Converter Adding a New Currency Entry 259 - WPF Currency Converter Inserting and Editing Data in the Database
18 - Linq 261 - Linq Intro 262 - Linq gentle Introduction 263 - Linq Demo 264 - Linq with Lists and our University Manager Part 1 265 - Sorting and Filtering with Linq 266 - Creating collections based on other collections 267 - Linq with XML 268 - Setting up the project for LinqToSQL 269 - Inserting Objects into our Database 270 - Using assiociative tables with Linq 271 - Joining tables next level 272 - Deleting and Updating 273 - Linq Outro
19 - WPF Project Currency Converter with GUI Database and API Part 3 275 - WPF Currency Converter Using Live Currency Values Using An API And JSON
20 - The exercises for your coding interviews
21 - Threads 278 - Threads Intro 279 - Threads Basics 280 - Thread Start and End Completion 281 - ThreadPools and Threads in The Background 282 - Join And IsAlive 283 - Tasks and WPF 285 - Threads Outro
22 - Unit Testing Test Driven Development TDD 286 - TDD Introduction 287 - What is TDD Test Driven Development 288 - Create Project and Write First Test 289 - Refactoring and Adding Domain 290 - Adding Web API 291 - Test First Approach 292 - Assertion Message 293 - Fluent Assertions 294 - Test Conditions and Prerequisites 295 - Setting Up Flight Project 296 - Translating a Scenario to Test 297 - Red Green Refactor 298 - Given When Then Pattern And Avoid Overbooking Scenario Discovery 299 - Avoid Overbooking Scenario 300 - Test Trustwhortiness And Devils Advocate 301 - Practical Devils Advocate For Remaining Number of Seats 302 - Paremeterized Tests 303 - Discovering new scenarios by checking if the production code is complete 304 - Refactoring rememberbookings 305 - Rules of TDD 306 - Scenario Cancel bookings using TestDriven Development Rules 307 - Handle Cancel Booking No Booking Found 308 - How You Discover New Scenarios 309 - Application Layer Testing 310 - Scenario Application Layer Booking Part One 311 - Scenario Application Layer Booking Part Two 312 - Scenario Application Layer Booking Part Three 313 - Configure In Memory Database 314 - Parameterize Book Flights Test 315 - Implementing Booking Service 316 - Refactoring Booking Service 317 - Create Test for Cancelling Bookings 318 - Finalize Cancel Booking 319 - Naming Conventions 320 - Test Suit as Documentation 321 - Application Layer
23 - UNITY Basics 322 - Intro Unity Basics 324 - Overview of the Unity Interface 325 - Creating your own Layout 326 - Player Movement 327 - Making Sure We Make Changes Correctly 328 - Physis Basics 329 - RigidBody A Physical Body 330 - Colliders And Their Different Types 331 - Triggers 332 - Prefabs And GameObjects 333 - Components And More On Prefabs 334 - Keeping The Hierarchy Tidy 335 - Class Structure 336 - Mathf And Random Class 337 - Unity Basics Outro
24 - UNITY Building the Game Pong with Unity 338 - Pong Introduction 339 - Basics UI Elements 340 - Basics Accessing Text Through Code 341 - Basics Buttons 342 - Basics Switching Scenes 343 - Basics Play Sound 344 - Project Outline Pong 345 - Creating The Main Menu 346 - Switching Scenes and Using Buttons 347 - Building Our Game Scene 348 - 2D vs 3D Colliders and Rigidbody For Our Ball 349 - Moving Our Ball Left And Right 350 - Racket Movement 351 - Bouncing Off Correctly 352 - Scoring System 353 - Restarting A Round 354 - The Game Over Screen 355 - Adding Sound To The Game 356 - Adding a Basic AI 357 - Chapter Summary
25 - UNITY Building a Zig Zag Clone With Unity 358 - Chapter Intro 359 - Zig Zag Intro 360 - Basics Instatiating Creating Via Code An Object 361 - Basics Invoke And InvokeRepeating For Delayed Calls And Repeated Calls 362 - Basics Playerpreferences Saving Data 363 - Basics Raycast 364 - Setup For Zig Zag 365 - Setting The Perspective 366 - Moving The Character 367 - Make Camera Follow Player 368 - Animate The Character 369 - Start The Game 370 - Restart The Game 371 - Collecting Crystals And Increasing The Score 372 - Adding A Highscore 373 - Adding The Particle Effect 374 - Background Music Loop 375 - Procedural Creation Of Our Map
26 - UNITY Building a Fruit Ninja Clone With Unity 376 - Chapter Intro 377 - Create Fruits And Explode Them 378 - Fruit Spawner 379 - Creating Our Blade 380 - GUI and Bombs 381 - Game Over and Restart 382 - Adding The Highscore 383 - Extend The Game 384 - Prepare Code For Android 385 - Test On An Android Device 386 - Make Some Adjustments 387 - Adding Unity Ads to Your Game 388 - Setting Up Your Device as Developer Device 389 - Adding Sound
27 - Thank you for completing the course 390 - Thanks for finishing the course