WangShuXian6 / blog

FE-BLOG
https://wangshuxian6.github.io/blog/
MIT License
46 stars 10 forks source link

完整的C#大师课程 Complete C# Masterclass[进行中] #216

Open WangShuXian6 opened 2 weeks ago

WangShuXian6 commented 2 weeks ago

完整的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

WangShuXian6 commented 2 weeks ago

1 - 你的第一个C程序与Visual Studio概述

1 - 引言

完整C#大师班介绍

欢迎来到完整C#大师班。在本课程中,您将学习关于C#的所有知识,因为您是本课程存在的原因。我创建这个课程是为了为您提供尽可能最好的C#编程学习体验。令人兴奋的是,您将在这个课程中获得的知识不仅适用于C#,还可以应用于其他编程语言。如果您已经了解编程,那么这对您来说将是小菜一碟,对吧?但如果您不熟悉编程,没关系,您依然会很好地掌握这些内容。

学习内容

您将经历不同的示例,观看大量演示,这些将以简单易懂的方式将知识传递给您。此外,您还将进行一系列练习,我强烈建议您完成这些练习,因为这是您成长的关键所在。实际上,任何编程学习体验中最重要的部分就是应用。在这个过程中,您将亲自做一些事情,仅仅观看视频是不够的。虽然这是一个很好的开始,能够帮助您理解课程内容,但真正的学习体验来自于实际的应用。

练习与项目

我准备了许多练习和项目供您跟随,同时也鼓励您独立尝试。过程中可能会遇到一些困难,但没有关系,您可以在互联网上搜索解决方案。如果找不到答案,您当然可以随时在问答部分给我们留言,我们会帮助您解决问题。

感谢您参加本课程,祝您拥有最佳的学习体验,期待在下一个视频中见到您!

2 - 你想达成什么

课程目标与学习心态

好吧,您已经购买了课程,非常感谢您参与这个课程。现在,您必须问自己最重要的问题是:您想要实现什么?您的总体目标是什么?您想创建视频游戏吗?您想编写PC程序?找到一份工作,开始作为C#程序员的自由职业者,还是仅仅学习一项新技能?无论如何,这门课程都是适合您的,您只需跟随课程进行学习。相信这个系统,按照步骤进行,您就能成为一名真正的开发者,实现所有这些目标。

学习时间与心态

当然,根据您的目标,您对课程和学习体验的心态会有所不同。我强烈建议您每周至少花费3到4小时专注于学习课程。虽然完成整个课程需要时间,但您会在过程中获得许多宝贵的学习体验,这将激励您投入更多的时间。因此,我建议您进行练习、参加课程,并尝试将自己的想法融入到您所学到的知识中。不要仅仅把视频当作Netflix系列剧一样观看,那不是我们的目标。您的目标应该是真正理解所学内容。一旦对某个主题有了清晰的理解,您再去学习下一个主题,因此一定要尽量进行练习,这对您将大有帮助。

可视化目标

希望您能为自己找到答案,也许您会在脑海中形成一个成功开发者的形象,可能是一个游戏开发者,或是作为开发者找到一份工作的样子。您可以把这个形象放在屏幕下方或上方,这种视觉提醒可以在困难时刻帮助您,推动您向前。现在,带着这种能量,让我们进入下一个视频!

3 - 安装Visual Studio社区版

安装 Visual Studio

欢迎回来。在本视频中,我们将安装Visual Studio,这是我们编写程序所需的软件。首先,让我们搜索下载Visual Studio。您也可以直接在搜索框中输入其名称。具体来说,2022年是新版本发布的一年,之前的版本是Visual Studio 2019,因此并不是每年都会有新版本。

下载 Visual Studio

搜索后,您将找到 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。当前没有打开的项目,我想快速展示一下,如果您决定更改颜色主题,比如觉得深色主题不合适,可以进入“工具” -> “选项”,在“环境”下的“常规”中找到颜色主题。您可以将其更改为蓝色,或者设置为亮色主题。

下一步

如我所说,我更喜欢深色主题,所以我将其设置回去。在下一个视频中,我们将学习如何设置我们的第一个项目。期待在下个视频中见到您!

5 - 第一个程序:你好,世界

设置第一个项目

欢迎回来。在本视频中,我们将使用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”应用程序,以确保程序能够正确执行。

下一步

在下一个视频中,我们将查看项目的结构。期待与您再次见面!

6 - 你好,世界项目结构

项目结构

欢迎回来。在本视频中,我们将查看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#的特性和基础知识。期待在下个视频中见到您!

7 - 理解新旧格式,以及如何在控制台中发出声音

老式模板介绍

欢迎回来。在本视频中,您将了解旧模板。这是.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类允许我们使用多种方法,代表控制台应用程序的标准输入、输出和错误流,其中包含多个可以使用的方法,例如WriteLineWriteReadLine甚至Beep方法。

示例演示

让我们快速演示一下。我将添加一个Beep方法并以分号结束。现在让我执行这个代码,看看效果。您会看到“Hello World”,然后我可以输入内容并按回车,听到了一声“哔”的声音。

接下来,我想挑战您在“Beep”之后写一段代码并再运行一次“Beep”。希望您尝试过了!例如,我将写Console.WriteLine("I beeped");并在后面再加一个Console.Beep();,然后再运行代码。

您会看到“Hello World”,然后输入内容,听到第一次“哔”声后,会输出“I beeped”和第二次“哔”声。这只是控制台可以做的一小部分,后续我们会逐步构建更多功能。

总结

好的,以上就是本视频的内容。期待在下个视频中见到您!

8 - Mac上的你好,世界

在Mac上使用C#启动Visual Studio项目

欢迎回来。在本视频中,我将向您展示如何在Mac上使用Visual Studio启动一个C#项目。请按照以下步骤操作:

创建新解决方案

  1. 打开Visual Studio Community:在您的Mac上打开Visual Studio Community。
  2. 创建新解决方案:选择“文件”>“新建解决方案”。
  3. 选择项目类型
    • 在“其他.NET”下,找到“控制台项目”。
    • 选择C#,然后点击“下一步”。

命名项目

  1. 输入项目名称:例如,命名为Hello_World
  2. 创建项目:点击“创建”按钮。

运行项目

  1. 查看项目结构:创建完成后,您将在Hello_World文件夹中找到Program.cs文件。
  2. 运行程序:点击顶部的运行按钮,控制台将启动,您的小程序会在控制台中输出“Hello World”以及“Press any key to continue”。

保持控制台打开

在Windows上,您需要手动添加Console.ReadLine()以保持控制台打开。而在Mac上,您也需要添加相应的代码行。为了让控制台保持开启,可以使用以下代码:

Console.ReadKey();

这行代码会使程序等待用户按下任意键,因此控制台不会立即关闭。

运行示例

运行项目后,您将看到控制台等待输入。当您按下任意键后,控制台将输出“Press any key to continue”。如果在Windows上运行,该提示不会出现,程序将会直接关闭。

用户界面

除了这些细节外,其他功能应该是相同的,当然,用户界面会有所不同。

期待在下个视频中与您见面!

9 - Visual Studio界面

Visual Studio 用户界面介绍

欢迎回来。在本视频中,我想和您聊聊 Visual Studio 的用户界面。在上一个视频中,我直接开始了编码,但这次我们将稍微深入了解界面。

用户界面概述

Visual Studio 是一个逐步发展的程序,功能强大,但并非所有功能都需要您了解。因此,如果您不理解界面上的每个按钮,也不必担心。以下是我们需要关注的几个重要部分:

解决方案资源管理器

在解决方案资源管理器中,您可以打开整个项目,项目名称为 Hello World,而命名空间也叫 Hello World。在命名空间中,您会看到 Program.cs 文件。在这里,您将看到 Program 类和 Main 方法。

输出窗口

在底部的输出窗口中,您可以查看文件创建的位置,以及运行程序时可能出现的错误信息。这对调试非常有帮助。

界面自定义

您可以根据需要自定义界面。例如,可以将解决方案资源管理器拖到窗口的右侧,使其更方便访问。您可以通过“视图”菜单找到更多窗口,未来的课程中我们会用到其中的一些。

窗口布局保存

如果您改变了窗口布局并希望保存,可以选择“窗口布局”>“保存窗口布局”,并为其命名。这样您可以随时切换到该布局。

其他功能

在顶部菜单中,还有很多功能,例如:

开发者新闻

在启动页面上,您还可以找到与 C# 及 Visual Studio 相关的开发者新闻,这对保持最新信息非常有用。

工具栏和快捷键

界面上还有一个按钮栏,其中“保存”和“启动”按钮是我们最常用的。通常,我更喜欢使用快捷键来操作。

总结

这只是对 Visual Studio 用户界面的简要介绍。了解这些基本功能将帮助您更好地使用它。随着课程的深入,您将逐步熟悉这些功能。

期待在下个视频中与您见面!

13 - 第一章总结

第一章总结

恭喜您完成了第一章!在这一章中,您已经明确了自己的学习动机,并成功设置了开发环境,这是您在整个课程和软件开发生涯中不可或缺的工具。此外,您还创建了您的第一个软件——“Hello World”示例。虽然这是一个非常基础的示例,但它是您学习的第一步,而第一步往往是最艰难的。

鼓励继续学习

现在,没有借口了!只需坚持学习课程,您将逐步掌握编程技能。希望您能积极参与课程讨论,并随时访问我们的博客。在博客中,我们将发布关于 C# 及其他编程主题的精彩文章。这些内容不仅可以帮助您提升技能,有时也会激励您保持学习动力。

下一章预告

接下来,我们将进入第一章的实际编程部分,即第二章。在这一章中,您将学习如何使用变量以及数据类型的相关知识。期待在下一章与您相见!

WangShuXian6 commented 2 weeks ago

2 - 数据类型与变量

19 - 变量和数据类型的高级概述

变量与数据类型

欢迎回来!在本视频中,我将教您关于变量的知识,因为变量是任何编程语言中非常重要的一个概念。变量是一个容器,可以存储一个值。随着时间的推移,我们可能会决定在同一个变量中存储另一个值,意味着在同一个容器中更改内容。

变量的类比

想象一下,您正在参加自助餐,想要拿一杯咖啡和一块美味的蛋糕。您需要一个杯子来装咖啡和一个盘子来放蛋糕,因为咖啡杯是用来盛液体的,而不是用来放蛋糕的。把蛋糕放进咖啡杯里是行不通的,既不合适,也可能会引起他人的侧目。变量的工作原理就像这个例子一样。

变量的类型

如果我们将变量比作咖啡的例子,那么每个变量必须有一个类型,就像杯子或盘子的类型。变量的类型告诉我们它可以存储什么类型的数据,而数据则相当于我们的食物或饮料。由于我们通常在程序中处理许多变量,因此需要通过给每个变量命名来区分它们。

在 C# 中定义变量

在 C# 中,我们如何定义一个变量呢?假设我们想要存储一个整数,这里的整数是像 1、2、3 这样的完整数字。那么 int 是我们的变量类型,I am a number 是我们的整数变量的名称,而 5 是我们赋予它的值。因此,我们需要输入变量的名称和我们分配的值。这样,我们就创建了一个类型为 int 的变量,并命名为 I am a number,并将值 5 存储在其中。

其他数据类型

在 C# 中,还有其他数据类型,以下是一些我们目前需要了解的主要数据类型:

变量与内存

我们声明的变量越多,应用程序所需的内存就会越大。可以将其视作使用一个非常大的咖啡杯来装一小杯浓缩咖啡。

小结

以上是关于 C# 中变量和数据类型的简单介绍。接下来,我们将通过实际示例来使用这些概念,以便更好地理解。

深入了解数据类型

在接下来的视频中,我们将深入探讨更多数据类型,并学习它们的限制。尽管整数可以存储数字,但它不能存储像一万亿这样的数字,这就是 long 数据类型派上用场的地方。让我们在下一个视频中一起探讨吧!

20 - 更多数据类型及其限制

数据类型与变量

欢迎回来!在本视频中,我们将探讨数据类型和变量,具体来说就是将数据存储在变量中的过程。让我们直接开始。

变量的声明

首先,变量可以在方法外部声明,也可以在方法内部声明。在这个例子中,我们有一个整数变量,它的类型是 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,存储中等数字用 short,存储较大的数字用 int,而存储超大数字时则使用 long

浮点值数据类型

如果您需要使用浮点值,可以选择以下几种数据类型:

其他数据类型

代码示例与课程反馈

现在,让我们开始编码,深入实践吧!如果您喜欢这门课程,请别忘了评分!

22 - 数据类型:整型、浮点型与双精度型

实践中的变量与数据类型

在了解了变量和数据类型的理论之后,接下来我们将实际应用这些知识。我们将从整数、双精度浮点数(doubles)和浮点数(floats)开始。

声明变量

首先,声明变量的方法是使用数据类型,接着给变量命名,并以分号结尾。比如,下面是一个声明变量的示例:

int num1; // 声明一个整数变量 num1

尽管没有赋值,我们仍然可以先声明它。之后,可以在下一行中给变量赋值:

num1 = 13; // 将值 13 赋给 num1

输出变量

要在控制台输出变量,可以使用 Console.WriteLine 方法。在括号中输入变量名称:

Console.WriteLine(num1);

运行后,控制台会显示 13

计算变量之和

接下来,让我们展示两个变量的和。首先声明并初始化另一个变量 num2

int num2 = 23; // 声明并初始化 num2

现在我们可以创建一个新的变量 sum,用来存储 num1num2 的和:

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

类型转换

在进行不同数据类型之间的运算时,可能会遇到类型转换问题。对于 doubleint 的运算,结果会是 double 类型。但若尝试将 double 赋值给 int,则会报错。

从外部数据源获取数据

当数据来自外部源(如数据库)时,你不能总是确定接收到的数据格式,因此在处理这些数据时需格外小心。

结语

现在,你应该对如何使用变量和数据类型有了更好的理解,特别是 intfloatdouble 的使用。期待在下一个视频中见到你!

23 - 字符串数据类型及其方法

欢迎回来。在本视频中,我们将讨论字符串(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"。现在你已经看到了如何创建和使用字符串,以及可用的多种字符串方法,当然还有更多方法我们会在后续学习中使用。

在下一个视频中,我们将讨论命名约定。期待与你在那见面!

24 - 值类型与引用类型

欢迎回来。在本视频中,你将学习值类型和引用类型。数据类型可以根据它们在内存中的占用方式分为两类:值类型和值类型。我们将逐一介绍这两类。

值类型

值类型通常存储在栈中,这意味着它们的分配和释放是自动管理的,随着程序的增长和缩小,数据会直接存储。常见的值类型包括基本数据类型,如 intfloatlongdoublecharbooldecimal。在 C# 中,其他的值类型包括结构体(structs)和枚举(enums)。它们也被视为值类型。

可空值类型

可空值类型(nullable value types)是每种值类型都有一个对应的可空版本,可以持有该类型的任意值或 null。这些类型用问号(?)表示,例如 int?double?,这样它们可以为空,因此我们加上问号,以便编译器理解。尽管值类型通常存储在栈中,如果它们是引用类型的一部分(如类中的字段或数组中的元素),也可以存储在堆中。这在我们学习类和数组时会更清楚,所以请将这一点放在心中。

值类型示例

以下是一个值类型的示意图:

引用类型

引用类型是另一种变量类型,它不是直接在内存中存储值,而是存储实际数据的内存位置。它只指明数据的位置。因此,变量存储的是数据的内存引用,而不是数据本身。引用数据类型包括字符串(strings)、类(classes)、数组(arrays)和其他对象。

复制引用

当我们复制一个引用类型时,只会复制数据的内存地址,这样就会有两个变量指向同一数据。

引用类型示例

以下是引用类型的示意图:

总结

到此为止,让我们回到编码中。希望你对值类型和引用类型有了更清晰的理解。

25 - 编码标准

欢迎回来。在本视频中,我想讨论编码标准。编码标准是一组指导方针、最佳实践和编程风格,开发人员在为项目编写源代码时遵循这些标准。所有大型软件公司都会遵循这些标准,而且公司通常会有自己特定的标准。以下是一些编码标准的示例。当然,当你在公司工作时,他们会告诉你以某种方式编写代码,因为这是他们的做法。

编码标准的组成部分

变量命名

你应该始终给变量一个合理的名称。在声明变量时,开发人员必须为变量提供一个适当的名称。变量的名称应基于其用途。想一想:我用这个变量做什么?然后给它一个描述该变量真正用途的名称。例如,如果你想存储用户的年龄,那么该变量的好名称可以是 ageuserAge

函数命名

你也应该给函数一个适当的名称。函数的名称应基于其在代码或程序中所执行的功能。函数通常执行某种操作。例如,如果你想要一个检查互联网连接的函数,可以将其命名为 checkInternetConnection

注释

在代码中留下注释是良好的实践,在大型科技公司中这更是必不可少。函数应该有注释,说明其用途和功能。这有助于其他开发人员理解该函数的作用。并且这不仅是为了其他开发人员,也是为了你自己。当你在几年后或几个月后回到代码时,如果没有适当的注释,你会发现很难理解自己的代码,可能需要从头开始理清思路,这会花费大量时间。

注释的类型

单行注释

单行注释用于描述变量或 if 语句的用途。比如:

// 这是一个单行注释

多行注释

多行注释用于注释多于一行的内容。它以 /* 开始,以 */ 结束,可以覆盖多行:

/* 这是一个
多行注释 */

XML 文档注释

XML 文档注释用于创建函数或类的文档。在 C# 中,以三个斜杠开始,接着是标签 summary,然后在括号内写下该类或函数的描述。这在与其他人共享代码时非常有用,因为 Visual Studio 会识别这些注释,并在调用方法时显示摘要。当你将鼠标悬停在方法上时,你会看到我们写的摘要。

例如,cool 方法的摘要是 "这是一个很酷的方法",当你悬停在 cool 方法上时,底部会显示该摘要,说明该方法的作用。

总结

以上就是标准实践的介绍。当然,还有更多的实践,你在大型公司工作时会遇到,例如他们如何决定命名变量等。好的,我们下个视频再见。

26 - 控制台类及其方法

欢迎回来。在本视频中,我想帮助你更好地理解我们正在使用的所有控制台方法,以及未来我们将频繁使用这些方法的原因。因此,我想提供一些背景信息。如果你只是想使用这些方法,大部分内容可能与您无关;但如果你真的想了解发生了什么,那么这个视频是适合你的。让我们开始吧。

控制台类概述

控制台类中有多种方法可供使用,我们将重点关注与输入和输出相关的方法。首先,我们有 Console.Write,它可以在同一行上打印文本并保持光标在同一行上。这意味着我们可以将内容打印到控制台并看到结果。例如,使用以下代码:

Console.Write("text here");

这段代码会将 "text here" 打印到控制台上,非常适合显示某些值,以验证代码是否正常工作。尤其是在编程初学阶段,这一点非常重要。

WriteWriteLine

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 值示例

我们还可以获取用户输入的 ASCII 值:

Console.Write("Enter a character: ");
char userChar = Console.ReadKey().KeyChar;
int asciiValue = (int)userChar;
Console.WriteLine($"\nASCII value is: {asciiValue}");

这段代码会读取用户输入的字符,并返回其对应的 ASCII 值。

总结

以上就是对控制台类及其一些方法的简单介绍。如果你想了解更多关于控制台类的不同方法,可以查看文档,其中列出了许多方法及其用途。

记住,方法的重载允许同一个方法有多种不同的输入选项。我们将来会深入探讨这些内容,理解会逐渐变得自然。所以,不必担心现在是否完全理解所有细节。我们会在后续视频中更深入地探讨这些方法。

好的,我们下个视频见!

27 - 命名约定与编码标准

欢迎回来。在本视频中,我们将讨论一些命名约定和编码标准。关于类名,您应该使用帕斯卡命名法,这意味着例如,如果您有一个名为 "ClientActivity" 的类,首字母和第二个单词的首字母都应大写。也就是说,两个单词 "Client" 和 "Activity" 每个新词的首字母都要大写,但它们要连在一起。

类名和方法名

类名示例:

方法名也遵循相同的规则,例如 CalculateValue,同样是首字母大写。

参数和局部变量命名

对于方法参数或局部变量,请使用小写开头的驼峰命名法,例如:

避免缩写

请尽量避免使用缩写。例如,"UserControl" 不要用 "UserCTR" 这样的形式。使用完整的 "UserControl" 变量名称会更易于阅读。

数字和下划线的使用

使用预定义类型

避免使用大写字母开头的类型名称,比如 StringIntBoolean。使用小写开头的预定义类型名称,如 stringintbool,这是更符合 Microsoft .NET 框架的一种方式,更加自然易读。

类名和方法名的语法

为类命名时使用名词或名词短语,例如:

方法名通常是动词,表示动作,例如:

编码约定的资源

如果您想了解更多关于 C# 的编码约定,我推荐访问 DoFactory。这个网站提供了详细的文章和示例,涵盖了类、方法以及后续会涉及的接口和枚举等内容。

在接下来的课程中,我们会涉及接口和枚举,因此牢记这些编码标准将非常有帮助。我会在课程创建过程中确保这些标准得到应用。

下一步

在下个视频中,我们将探讨如何将一个值从一种类型转换为另一种类型,或如何进行类型转换。期待在下个视频中见到你!

28 - 隐式与显式转换

欢迎回来。在本视频中,我们将讨论转换,具体来说,我们会研究隐式转换和显式转换。首先,让我们来看一下显式转换。为此,我创建一个名为 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,表示太阳没有在科隆照耀。

总结

快速总结一下:

在下个视频中,我们将学习解析(parsing),即如何将字符串转换为数据类型(例如,doublefloatint)。这在从用户获取信息时非常相关,因为通常我们获得的信息是字符串形式,必须将其转换为数字类型以便进行计算。期待在下个视频中见到你!

29 - 将字符串解析为整数

欢迎回来。在本视频中,我们将学习如何将字符串解析为整数。例如,我们有一个字符串 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"); // 将引发异常

为了处理这种情况,我们可以使用 trycatch 语句来捕获异常,或者使用 Int32.TryParse 方法,该方法可以返回一个布尔值,指示解析是否成功。

if (Int32.TryParse(myString, out int number1))
{
    // 解析成功,使用 number1
}
else
{
    // 解析失败,处理错误
}

小结

快速总结一下:

在下个视频中,我们将进行一个小挑战。请务必在解析时小心,并继续关注课程以学习如何更好地处理这些情况。期待在下个视频中见到你!

30 - 字符串操作

欢迎回来。在本视频中,我将教你关于字符串操作的知识,这在你的编码经验和职业生涯中非常重要。这是一个额外的讲座,我们将讨论在屏幕上打印数据的不同方式,包括复合字符串、字符串格式化、字符串插值和逐字字符串。你可以选择一种方式,如果你喜欢其他方法也没问题,但在整个项目中尽量保持一致。

字符串拼接

首先,在我的 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 不会产生效果,而是被视为普通文本。

总结

以上是字符串操作的不同方式:

在前几种方法中,建议选择一种并在项目中一致使用。逐字字符串则有特定用途,特别是在处理文件路径时。

感谢观看,下个视频见!

31 - 一些字符串方法

欢迎回来。在本视频中,我们将探讨字符串及其方法。字符串是系统字符串类的一个对象,在编程中,字符串指的是一系列字符。这个类中包含许多函数,可以用来操作字符串。接下来,我们将看看其中的一些方法。

字符串方法

  1. Substring

    • Substring 方法需要一个整数参数,用于从指定索引开始获取字符串的子字符串。例如,如果有一个字符串 car,调用 Substring(1) 会返回 r,因为我们从索引 1 开始,而 c 的索引是 0。
  2. ToLower 和 ToUpper

    • ToLower 方法将字符串转换为小写。例如,"HELLO" 转换后将变为 "hello"。
    • ToUpper 方法将字符串转换为大写,例如,"hello" 将变为 "HELLO"。
  3. Trim

    • Trim 方法用于去除字符串开头和结尾的空白字符。这在输入电子邮件地址时尤其有用,常常会不小心复制到多余的空格。
  4. IndexOf

    • IndexOf 方法用于获取字符串或字符在另一个字符串中第一次出现的位置。例如,在一个句子中查找某个单词的位置。
  5. IsNullOrWhiteSpace

    • IsNullOrWhiteSpace 方法用于检查字符串是否为空或只包含空白字符。如果字符串为 null 或空白,返回 true;否则返回 false。这在判断字符串是否有实际意义时很有用。

示例代码

假设我们有以下代码示例:

string firstName = "Dennis";
string lastName = "Pun";
string fullName = string.Concat(firstName, " ", lastName); // 拼接字符串
string subString = fullName.Substring(2); // 输出 "nnis Pun"
string lowerCase = firstName.ToLower(); // 输出 "dennis"
string upperCase = lastName.ToUpper();   // 输出 "PUN"
string trimmed = fullName.Trim(); // 去除首尾空格
int indexOfE = firstName.IndexOf('E'); // 输出 1,'E' 在 "Dennis" 中的索引为 1
bool isEmpty = string.IsNullOrWhiteSpace(firstName); // 输出 false

字符串格式化

C# 中还有一个重要的方法是 FormatString.Format 方法用于将对象或变量值插入到字符串中。其语法如下:

string formattedString = string.Format("My name is {0}.", firstName);

这将输出:

My name is Dennis.

总结

今天我们探讨了字符串的多种方法,包括:

这些方法在日常编程中非常有用,可以帮助你有效地操作和格式化字符串。谢谢观看,下一段视频再见!

32 - 如何使用转义字符在字符串中使用特殊字符

欢迎回来。在本视频中,我们将学习如何在字符串中使用特殊字符。为此,我将创建一个名为 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.

总结

在本视频中,我们学习了以下内容:

这些知识对于正确处理字符串中的特殊字符非常重要。感谢您的观看,我们下次再见!

34 - 练习字符串1的解决方案

欢迎回来!在本视频中,我们将开始编写一个简单的程序,获取用户的输入并对其进行处理。首先,我们将设置主方法,并在其中包含一个 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

结论

在这个练习中,我们学习了如何获取用户输入,并对字符串执行多种操作,包括转换大小写、修剪空格和获取子字符串。希望您能够尝试并完成这个练习。感谢您的观看,我们下次再见!

36 - 练习字符串2的解决方案

好的,现在让我们开始第二个字符串挑战。在这个练习中,我们将要求用户输入一个字符串,并查找该字符串中某个字符的索引。

获取用户输入

首先,我们需要在主方法中要求用户输入一个字符串:

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

总结

在这个练习中,我们学习了如何获取用户输入、查找字符索引以及使用拼接方法合并字符串。希望您能够成功完成这个练习!感谢观看,我们下次再见!

38 - 数据类型与变量挑战的解决方案

欢迎回来。在本视频中,我们将检查上次讲座挑战的解决方案。让我们首先了解这个挑战的内容。

挑战目标

我希望你为 Microsoft 网站上列出的每种数据类型创建一个变量,并为每个变量赋予正确的值。下面,我们将逐一查看这些数据类型及其变量。

数据类型示例

  1. Byte:

    byte myByte = 25; // 范围:0 到 255
    Console.WriteLine(myByte);
  2. SByte:

    sbyte mySByte = -100; // 范围:-128 到 127
    Console.WriteLine(mySByte);
  3. Int:

    int myInt = 12345; // 范围:-2,147,483,648 到 2,147,483,647
    Console.WriteLine(myInt);
  4. UInt:

    uint myUInt = 123456; // 仅为正数,范围:0 到 4,294,967,295
    Console.WriteLine(myUInt);
  5. Short:

    short myShort = -32768; // 范围:-32,768 到 32,767
    Console.WriteLine(myShort);
  6. UShort:

    ushort myUShort = 65535; // 仅为正数,范围:0 到 65,535
    Console.WriteLine(myUShort);
  7. Float:

    float myFloat = 3.14F; // 浮点数,带有 F 后缀
    Console.WriteLine(myFloat);
  8. Double:

    double myDouble = 3.141592653589793; // 更高精度的浮点数
    Console.WriteLine(myDouble);
  9. Char:

    char myChar = 'A'; // 单个字符,用单引号包围
    Console.WriteLine(myChar);
  10. Boolean:

    bool myBool = true; // 布尔值,只有 true 或 false
    Console.WriteLine(myBool);
  11. String:

    string myText = "Hello, World!"; // 字符串
    Console.WriteLine(myText);
  12. 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 网站以获取详细信息。

希望你能够成功完成这个挑战!如果你有其他解决方案,也欢迎分享。感谢观看,我们下次再见!

39 - 使用var关键字

欢迎回来。在本视频中,我们将学习关于 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 newVar = null; // 这会报错,因为无法将 null 赋值给隐式类型变量

好啦,这就是本视频的内容。期待在下一个视频中再见!

40 - 常量

欢迎回来。现在你已经了解了如何创建变量,接下来我们将讨论不可变值,也称为常量。常量是指在创建后不能被更改的变量,这意味着在编译时它们的值在程序运行期间始终保持不变。这是一个非常有用的工具,因为有些变量是我们希望保持不变的,例如圆周率(π),它的值永远是固定的。

创建常量

常量通常是字段(即在任何方法外部的变量),所以我们将在类中创建常量字段。要声明常量,需要使用 const 关键字,并指定数据类型。以下是创建常量的示例:

const double Pi = 3.14159265359; // 定义圆周率常量
const int WeeksInYear = 52; // 每年的周数常量
const int MonthsInYear = 12; // 每年的月数常量

这里,我们定义了 Pi 为一个精确的双精度常量,WeeksInYearMonthsInYear 分别表示每年的周数和月数。

不可变性

由于常量是不可变的,如果尝试更改常量的值,编译器将报错。例如:

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(); // 等待用户按键以防止控制台立即关闭
    }
}

运行该代码,你将看到输出你的生日的消息。

小结

好啦,现在你已经知道如何创建和使用常量了。请继续下一视频!

42 - 数据类型总结

很好!你已经完成了第二章。在这一章中,你学习了如何使用变量,了解了不同的数据类型,以及如何在这些数据类型之间进行转换。虽然不是所有数据类型都可以直接转换,但你已经掌握了一些基本的转换方法。

下一步:方法

接下来的章节中,我们将探索方法。方法将帮助我们节省大量编程时间,因为我们不希望每次都重复输入相同的代码。编程更多的是思考过程,而不是简单的输入文本。通过使用方法,你可以:

在下一个章节中,我们将深入了解如何使用方法。期待在那儿见到你!

WangShuXian6 commented 2 weeks ago

3 - 函数、方法及如何节省时间

43 - 方法简介

欢迎来到方法章节!在这一章中,你将学习如何使用方法、什么是方法,以及它们如何为你节省大量时间。你将了解方法的重要性,以及如何使用它们来组织你的代码。

本章内容

接下来,我们将讨论如何使用用户输入,使你的程序能够接收并处理数据。这非常酷!此外,我们还会介绍trycatch,因为在输入数据时可能会出现错误,例如传入错误的数据类型,这可能导致程序崩溃。trycatch可以帮助你处理这些问题。

期待在下一个视频中见到你!

44 - 函数与方法介绍

欢迎来到方法章节。在这一部分,我们将深入了解方法,了解它们的作用以及如何使用它们。方法在任何面向对象编程语言中都非常重要。我们将从定义开始,这个定义来源于微软的官方文档。

方法定义

方法是一个代码块,包含一系列语句。程序通过调用方法并指定所需的参数来执行这些语句。在 C# 中,每条执行的指令都是在某个方法的上下文中执行的。Main 方法是每个 C# 应用程序的入口点,由公共语言运行时(CLR)在程序启动时调用。到目前为止,我们只见过一个方法,那就是 Main 方法,它在我们启动程序时运行。

方法语法

方法的语法如下:

各部分解释

示例

让我们看一个简单的示例:

public int Add(int num1, int num2)
{
    return num1 + num2;
}

在这个例子中:

方法的简化

我们可以简化代码,如下所示:

public int Add(int num1, int num2) => num1 + num2;

这样可以更简洁地表达相同的功能。

接下来的内容

在下一个视频中,我们将创建一个没有返回值的 void 方法,并继续探讨其他类型的方法。现在,让我们进行演示,看看方法可以做些什么,以及它们的目的是什么。

45 - 无返回值的方法

现在我们了解了方法的理论,接下来让我们看看方法的实际应用。我们将创建一个方法,首先我们定义一个访问修饰符,这里我们使用 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()

方法的结构

在这里,方法的结构是:

当你想要在一个静态方法中调用另一个方法时,必须确保被调用的方法也是静态的。现在我们已经解决了错误,调用方法后,控制台将显示:

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 返回类型,以及参数和参数值之间的区别。在下一个视频中,我们将探讨不同的返回类型,并学习如何使用多个参数。

46 - 有返回值和参数的方法

现在我们已经了解了如何创建不带返回值的方法,接下来我们将讨论带有返回值的方法,例如整数类型的返回值。让我们开始创建一个新的方法。

创建返回整数值的方法

我们定义一个方法,返回类型为 int,方法名为 Add,接受两个参数,分别是 num1num2,它们都是整数类型。

public static int Add(int num1, int num2)
{
    return num1 + num2;
}

在这里,我们需要确保每条路径都返回一个值。如果没有返回值,编译器将会报错,提示“并非所有代码路径都返回一个值”。在这个简单的情况下,我们只需要返回 num1num2 的和。

调用返回值的方法

要调用这个 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));

注意事项

在整数相除时,结果也将是整数。如果 num1num2 都是整数,计算结果将被截断。因此,如果想要得到浮点数的结果,必须将其中一个或两个参数转换为 double

总结

通过这些示例,我们了解了如何创建具有返回值的方法,如何传递参数,以及如何调用这些方法。在接下来的内容中,你可以尝试自己创建一些方法,这将有助于巩固你的理解。期待在下一个视频中见到你!

48 - 方法挑战的解决方案

欢迎回来!希望你已经成功编写了代码所需的方法和变量,并完成了挑战。接下来,让我们创建表示朋友的变量。

创建朋友的变量

我们将创建三个朋友的字符串变量,代码如下:

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);

结论

这两种方法都能达到相同的效果,具体使用哪种取决于你的编码风格。希望你能自由地尝试其他方法,创建一些新的方法并进行实验,因为在接下来的内容中,我们将频繁使用这些方法。期待在下一个视频中见到你!

49 - 用户输入

欢迎回来!现在你已经掌握了如何创建方法,我们将探讨如何使用用户输入来运行这些方法。接下来,我们将学习如何获取用户输入,并利用这些输入来进行简单的计算。

获取用户输入

首先,我们需要创建一个字符串变量来存储用户输入,代码如下:

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 块来捕获异常。此时,请确保用户输入有效数字以进行测试。

结论

通过以上步骤,你可以创建一个简单的用户输入加法器。练习这些步骤,确保你熟悉如何获取用户输入并将其用于方法调用。期待在下一个视频中见到你!

50 - 尝试-捕获与最终

欢迎回来。在本视频中,您将学习如何捕捉错误,以及在发生错误时如何处理,而不是让程序崩溃。trycatchfinally 块的常见用法是,在 try 块中获取并使用资源,在 catch 块中处理异常情况,在 finally 块中释放资源。接下来,我们将详细探讨这些内容。

理解 Try、Catch 和 Finally

  1. Try 块

    • 可能会导致错误的代码放在 try 块中。如果发生错误,控制权将转移到 catch 块。
  2. Catch 块

    • catch 块用于处理错误。您可以指定要捕获的异常类型(例如 FormatExceptionOverflowException)或捕获一般异常。
    • 示例:
      try {
       // 可能抛出异常的代码
      }
      catch (FormatException e) {
       Console.WriteLine("请输入有效的数字。");
      }
  3. Finally 块

    • finally 块在 trycatch 块之后执行,无论是否抛出异常。这在清理资源时非常有用,例如关闭文件流或网络连接。
    • 示例:
      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 结构进行处理。

挑战任务

您的任务是:

这个练习将帮助您巩固对 C# 中错误处理的理解。祝您好运,期待在下一个视频中见到您!

51 - 运算符

欢迎回来。在本视频中,我们将讨论运算符,这些小东西对我们的程序有很大的影响,所以我们赶紧开始吧。我们将创建两个有值的变量和一个目前为空的变量。为了简单起见,我将用数字一、二和三。第一个变量的值是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? TrueIs 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}");

总结

这段视频涵盖了各种运算符,包括一元运算符、增量和减量运算符、算术运算符、取余运算符以及关系和逻辑运算符。虽然没有具体的练习,但理解这些基础知识对今后的编程非常重要。如果您对某个运算符不太确定,可以随时回头查看本视频并尝试理解每一步的代码。

希望您喜欢本视频,下一个视频我们将进行更短的内容,期待与您再次见面!

52 - 方法总结

方法章节总结

在这一章节中,你学习了方法的工作原理以及不同类型的方法。以下是你所掌握的内容:

方法类型

异常处理

你还学习了如何使用 try 和 catch 来捕捉可能出现的错误,例如在处理用户输入时。

操作符

你也了解了操作符的用法,包括如何进行数学计算和逻辑判断。

练习的重要性

希望你完成了这一章节的所有练习,因为这对学习至关重要。通过练习,你会发现问题并寻找解决方案,而这些解决方案会帮助你在未来更好地记忆和理解所学内容。每次成功解决问题,都是你作为开发者成长的一部分。

感谢你继续与我学习!希望你对下一章节充满期待,期待在下一个章节见到你!

WangShuXian6 commented 2 weeks ago

4 - 决策

53 - 决策简介

决策的重要性

在现实生活中,做决策非常重要。在编程中也是如此。你需要编写一些程序,这些程序必须根据条件做出决策。你决定程序将做出哪些决策,以及这些决策的最终结果。

本章内容概览

在本章中,我们将学习如何使用 if 语句 来实现这一点。学习完之后,你将能够编写一个根据条件执行代码的程序。

主要内容包括:

练习

在本章结束时,你将进行相关练习,以巩固所学的内容。

让我们直接开始吧!希望你能享受这一章的学习过程。

54 - C语言中的决策制作介绍

决策与编程

有些人说,生活中一切都与决策有关。对于我来说,编程也是如此。我们将要学习 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("今天只需要短裤");
}

小结

通过这些示例和结构,你将更好地理解如何在编程中做出决策。这些基本的控制流语句是构建复杂程序的重要组成部分。接下来,我们将继续深入学习和练习这些内容,帮助你成为一名更有效的开发者。

55 - 尝试解析简介

欢迎回来

在这一视频中,我们将探讨 TryParse 方法,它允许我们将字符串转换为数字数据类型。这在多个场合非常有用,我们将举一个例子。

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 方法解析其他数字数据类型,例如 floatdouble。示例如下:

string floatAsString = "128.75";
float floatParsedValue;
bool floatSuccess = float.TryParse(floatAsString, out floatParsedValue);

int.TryParse 类似,float.TryParse 方法也会返回一个布尔值,指示解析是否成功。

解析失败的示例

让我们来看一些解析失败的示例:

实践演示

接下来,我们将进入一个实际示例,以查看 TryParse 方法的使用效果。感谢观看,我们马上开始!

56 - IF与ELSE IF尝试解析

确保代码的安全运行

现在我们已经了解了如何使用 if 语句,我们还可以确保代码运行顺利。当用户输入不正确的值(例如字母)时,代码可能会崩溃。为了解决这个问题,我们可以使用 TryParse 方法替代 Parse

使用 TryParse 处理输入

我将使用 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 语句。期待与大家再见!

57 - 嵌套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 语句和嵌套的条件判断来构建一个简单的登录系统示例。下一个视频中,我们将进行一个小挑战,请尝试解决它。期待与您再见!

59 - 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();
    }
}

总结

通过这个简单的代码示例,我们创建了一个基本的注册和登录系统。虽然当前实现只在运行时存储数据,但未来我们将学习如何持久化存储数据。

希望您能成功构建一个类似的系统。如果您没有成功,不要担心,给自己一些时间,明天再尝试。期待在下一个视频中见到您!

60 - 开关语句

Switch 和 Case 语句介绍

欢迎回来。在本视频中,我们将讨论 switchcase 语句,这些语句与 if 语句类似。接下来,我们将创建一个 switch case 的示例。

创建 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?

使用 If 语句的挑战

现在,作为一个小挑战,请尝试将上面的 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 语句

接下来,我们快速看一下如何使用字符串进行 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;
}

运行字符串 Switch 语句

如果我们将 username 更改为 "Frank" 并运行代码,控制台将输出:

Username is unknown.

这表明 switch case 不仅可以用于整数,也可以用于字符串和其他数据类型。

总结

在本视频中,我们介绍了 switchcase 语句,并展示了如何将其应用于整数和字符串的比较。希望您能理解这些语句的用法,并在编程时根据需要选择使用 if 语句或 switch case 语句。下一个视频见!

62 - IF语句挑战2的解决方案

高分挑战简介

欢迎回来,今天我们将讨论第二个 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(); // 等待输入以查看输出
    }
}

总结

在这个视频中,我们展示了如何创建一个功能来检查高分是否被打破。通过使用 ifelse 语句,我们能够更新高分和记录高分的玩家。这是一个简单但有效的机制,可以在更复杂的游戏中轻松扩展。希望你能自己完成这个挑战,期待在下一个视频中见到你!

63 - 增强IF语句:三元运算符

简介

欢迎回来!在本视频中,我将向你展示一种简化 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 语句的用法!

65 - 增强IF语句:三元运算符挑战的解决方案

欢迎回来

在本视频中,我将向你展示我对三元运算符挑战的解决方案。当然,你的实现方式可能会有所不同。让我们开始吧!

初始化变量

首先,我们需要定义输入温度、温度信息和输入值的变量。这里我使用 string.Empty,虽然可以使用空字符串,但使用 string.Empty 提供了更多处理字符串的机会。

int inputTemperature;
string temperatureMessage = string.Empty; // 温度信息初始化为空

获取用户输入

接下来,我们需要从控制台获取用户输入,并验证用户输入的值是否是有效的整数。我们将使用 TryParse 方法进行验证:

Console.WriteLine("请输入当前温度:");
bool isValidInteger = int.TryParse(Console.ReadLine(), out inputTemperature);

使用 if 语句和三元运算符

如果输入值是有效的整数,我们将使用 ifelse 语句来处理温度消息。在实际的温度判断中,我们将使用三元运算符:

if (isValidInteger) {
    temperatureMessage = inputTemperature <= 15 ? "这里太冷了。" :
                         (inputTemperature >= 16 && inputTemperature <= 28 ? "这里很冷。" :
                         (inputTemperature > 28 ? "这里很热。" : ""));
} else {
    temperatureMessage = "无效的温度。";
}

逻辑解释

  1. 条件:检查 inputTemperature 是否小于或等于15。
    • 如果为真:返回 "这里太冷了。"
    • 如果为假:检查是否在 16 到 28 之间。
      • 如果为真:返回 "这里很冷。"
      • 如果为假:检查是否大于28。
      • 如果为真:返回 "这里很热。"
      • 否则:返回一个空字符串。

输出结果

最后,我们输出温度信息到控制台:

Console.WriteLine(temperatureMessage);

测试代码

让我们运行代码,看看它是否按预期工作。假设输入25度,程序应返回 "这里很冷。",因为根据设定,25度被认为是较凉的温度。

总结

三元运算符的使用使得代码更简洁,同时也保持了清晰的逻辑结构。温度的感知是相对的,因此你可以根据个人的舒适度调整阈值。希望你能够正确完成这个挑战,期待在下一个视频中见到你!

66 - 决策总结

完成决策章节

到目前为止,你已经完成了关于决策的章节,包括 ifelseswitchcase 等内容。现在你知道如何使用这些关键字来编写程序,根据传入的值或变量的条件运行代码。

编写更高级的程序

在这个阶段,你已经能够编写更高级的程序,但我们还有很多内容要学习。我希望你享受这个章节,感到自己对使用条件语句、if 语句以及 switch 语句更加自信。

学习过程

即使你目前还不完全理解这些概念,也不用担心。你将在整个课程中多次遇到这些关键字,逐渐掌握它们。学习编程语言就像学习一种自然语言:你会在不同的上下文中遇到同一个词,逐渐理解其含义。随着你在更多上下文中使用这些概念,它们会变得更加清晰。

展望未来

接下来,我们将进入更高级的编程技巧,并继续在课程中使用 ifelse 以及 switch 语句。期待在下一个视频中见到你!

WangShuXian6 commented 2 weeks ago

5 - 循环

67 - 循环简介

欢迎来到循环章节

在这一章节中,你将学习如何使用不同类型的循环。实际上,有四种主要的循环:for 循环、while 循环、do while 循环和 foreach 循环。每种循环都有其独特的优势。

循环的优势

这些循环在许多场景中是可以互换的,但它们的使用场景各有不同。你将学习如何使用这些循环以及它们的具体用途。这使你能够编写能够多次迭代的程序,从而重复执行某段代码。

深入理解

就像你之前学习的其他关键字一样,越多地在不同的上下文中看到这些循环,它们的含义和用法就会变得越清晰。接下来,我们将直接进入具体内容。

期待在下一个视频中见到你!

68 - 循环基础

欢迎来到循环的介绍

在本章中,我们将讨论不同类型的循环,并学习如何在实际中使用它们。首先,我们来看看理论部分,了解各种循环的优势。

循环的优势

  1. 节省时间
    循环可以让你简化代码,而无需创建大量的重复代码。这可以帮助你节省编写和维护代码的时间。

  2. 快速且易于重复
    循环允许你根据设定的条件轻松地多次重复代码。例如,如果你想运行某段代码十次,可以简单地使用循环来实现。

  3. 处理大量数据
    通过循环,你无需手动检查数据,可以自动化这个过程。这在处理大数据集时尤为重要。

  4. 遍历数组
    循环可以用于遍历数组或列表,尽管我们在这一章还没有涉及数组,但在后面的章节中会详细讨论。

循环类型

我们将学习几种主要的循环类型:

  1. for 循环
    for 循环有一个起始值、条件和增量,所有这些都用分号分隔。在花括号中包含需要重复的代码块。这个循环非常适合计数器的使用。

  2. while 循环
    你可以先设置一个计数变量(例如,初始值为零)。接着使用 while 关键字和条件,例如:只要计数变量小于十,就继续执行循环。非常重要的一点是,必须在循环内递增计数变量,否则会导致无限循环,这会使程序崩溃。

  3. do while 循环
    虽然名字中有 while,但 do while 循环先执行代码块,然后再检查条件。这样保证了代码至少执行一次。这在你不确定条件是否满足但需要初始化某些内容时特别有用。

  4. foreach 循环
    foreach 循环用于遍历数组或列表,只要数组中有内容就会继续执行。这可以避免使用常规 for 循环时可能出现的数组索引越界异常。虽然我们在这一章不会详细讲解 foreach 循环,但它将在数组章节中介绍。

示例与演示

现在,我们将进入示例演示部分,开始一些实际的代码示例。期待在接下来的部分见到你!

69 - For循环

欢迎回来

在本视频中,我们将详细了解 for 循环的用法。接下来,我将通过一个示例来演示如何使用 for 循环。

for 循环的基本结构

首先,使用 for 关键字,接着在括号内需要包含三个部分:

  1. 初始化计数器
    例如,我们可以使用一个整数变量作为计数器,通常用 i 来表示。例如,int i = 0; 表示计数器从 0 开始。

  2. 条件检查
    例如,i < 10,这意味着只要计数器小于 10,就会继续执行循环。

  3. 增量
    使用 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 循环。期待与你在下一节中见面!

70 - 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 循环。期待在下一节中见到你!

71 - While循环

while 循环简介

在本视频中,我们将研究 while 循环的用法。首先,我们来了解一下语法。我们从一个计数器开始,将其设置为零。接下来使用 while 关键字,后面跟上条件。在本例中,我们将条件设为 counter < 10,如果条件为真,则执行 while 循环内的代码。

基本示例

以下是一个简单的 while 循环示例:

int counter = 0;
while (counter < 10)
{
    Console.WriteLine(counter);
    counter++;
}
Console.Read();

示例运行

运行上述代码后,控制台将输出从 09 的数字。与 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 循环的基本结构,以及如何使用它来创建简单的程序,例如人数计数器。接下来,你可以使用用户输入来实现更复杂的功能。

在下一个视频中,我们将面对一个新的挑战,期待与你在下次见面!

72 - break与continue

breakcontinue 语句介绍

欢迎回来!在本视频中,我们将讨论 breakcontinue 语句。在我们进入下一个挑战之前,了解这两个概念是很重要的。现在,让我们创建一个小程序来演示它们的用法。

创建一个 for 循环

首先,我们来创建一个 for 循环,从 09。代码如下:

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

在这里,所有的偶数都被跳过,只打印出奇数。

总结

通过本视频,你了解了 breakcontinue 语句的基本用法。break 用于退出循环,而 continue 用于跳过当前迭代。在接下来的课程中,我们将利用这些概念进行更多的挑战。

在下一个视频中,你将会看到我所承诺的挑战,期待与你再见!

74 - 循环挑战的解决方案

挑战解决方案

欢迎回来!希望你已经找到了挑战的解决方案。现在,让我们来看一下我将如何实现这个程序。当然,挑战有多种解决方案,我将展示其中一种。如果你还没有完成,不用担心,我会在本视频中为你演示。

定义变量

首先,我们需要定义几个输入变量:

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 创建更复杂的用户界面。

在下一个视频中,我们将进入下一个主题。希望你能成功解决这个挑战,或者至少完成其中的一部分!请告诉我你的解决方案,这样其他同学和我可以比较并给出反馈。谢谢,期待在下一个视频中见到你!

75 - 循环总结

循环章节总结

你已经完成了循环章节!希望你完成了练习并且成功掌握了它们。现在你了解了不同循环的用法,掌握了相应的语法,并且有机会进行了一些实际操作。

编程基础

你现在已经具备了一些核心编程概念和基本要素,包括:

这些基础知识使你能够开始编写自己的小程序。你已经走了很远,但学习的旅程并未结束。

持续学习

编程是一个不断学习的过程。即使在多年之后,你仍然会发现新知识和新技能。学习的美妙之处在于总有新的东西可以掌握。即使没有新的知识出现,也总会有更新促使你去学习新的内容。

进入下一个章节

现在,让我们进入下一个章节——面向对象编程(OOP)。这是C#语言的核心部分,了解面向对象编程将帮助你更深入地掌握这门语言的强大之处。

期待在下一个视频中见到你!

WangShuXian6 commented 2 weeks ago

6 - 面向对象编程(OOP)

76 - 对象简介

面向对象编程章节简介

欢迎进入面向对象编程(OOP)章节!这是整个学习过程中的重要章节之一,因为你即将学习C#中的核心概念之一。面向对象编程是一种编程范式,虽然还有其他编程方法,但OOP目前是使用最广泛且效率较高的方法,尤其是在开发复杂项目时表现尤为出色。

什么是面向对象编程?

OOP的核心思想是通过对象来组织代码。这种方法非常适合于涉及现实生活中的对象的项目。例如,当你开发一个包含用户信息的数据库时,"用户"可以被视为一个对象,而你可以围绕这个用户创建一个类,用来包含特定的用户信息。

OOP的优势

OOP能够将现实中的概念直接映射到代码中,使代码与现实更接近,也更容易理解。面向对象编程的优势在于可以帮助你构建更具可维护性更易扩展更灵活的代码。

学习内容概览

在本章节中,你将学习以下内容:

具体内容和练习

在接下来的学习中,你会逐步掌握如何创建和使用对象、类以及属性,并将这些知识应用到你的项目中。准备好了吗?让我们一起进入面向对象编程的世界,学习如何创建自己定义的类和对象。

让我们开始吧!

77 - 类与对象介绍

面向对象编程简介

欢迎回来!在本章节中,我们将深入探讨C#中的面向对象编程(OOP)。到目前为止,我们仅在一个类中进行编程,即包含Main方法的主类,并且没有真正进行过OOP。在本视频中,我会为你简要介绍接下来章节的内容,帮助你理解什么是类,以及如何运用类来编写更结构化的代码。


类(Class)是什么?

一个(Class)是一个对象的蓝图或模板。OOP(面向对象编程)中的“对象”源自类,可以通过类创建多个特定的对象。例如,我们可以定义一个“Car”(汽车)类,然后通过该类创建多个不同的汽车对象。

事实上,我们已经使用过一些类,例如Console类和String类等。它们也是我们所定义的特定类的样板。通常来说,类包含以下几个重要组成部分:


什么是对象?

一个对象(Object)是类的实例,或者说是类的具体实现。例如,我们可以将一个Car类实例化为一个Audi对象。具体来说:


面向对象编程的基本概念

  1. :定义对象的模板,描述对象的基本特征和行为。
  2. 对象:类的实例,拥有具体的属性和行为。
  3. 属性:描述对象特征的变量。
  4. 方法:定义对象行为的函数。
  5. 继承:允许子类继承父类的特性和行为,使代码更加模块化、复用性更强。

接下来的内容

在下一步,我们将创建属于自己的类,亲自体验如何定义类的属性和方法,构建更复杂的对象。通过实践,你会更深入地理解类的作用和用途。

让我们继续进入下一节,创建第一个属于自己的类吧!

78 - 我们的第一个自定义类

创建第一个类

欢迎回来!在本视频中,我们将创建自己的第一个类,它是可以用来创建对象的蓝图。类在 C# 中基本上相当于一种自定义的数据类型。通过定义类,我们可以设定对象的属性和方法,然后基于这个类生成多个对象实例。


什么是类(Class)?

在 C# 以及其他面向对象编程语言中,是创建对象的模板。类定义了一组属性(数据属性)和方法(功能函数),这些是该类的对象会拥有的特征和行为。接下来我们将创建一个类 Car,并为它添加属性和方法。


创建 Car

  1. 打开解决方案资源管理器(Solution Explorer),如果找不到它,可以在视图(View)菜单中找到它。
  2. 右键点击当前项目,选择添加(Add) > 类(Class),将新类命名为 Car
  3. 点击添加(Add)后,我们就创建了一个 Car 类。

类的构造函数(Constructor)

构造函数是一个特殊的方法,它在创建对象时被调用。类的构造函数通常用于初始化对象的属性。C# 会自动为类生成默认构造函数,我们可以自定义它。在 Car 类中,创建一个构造函数并加入一个 Console.WriteLine 来说明对象的创建。

public Car()
{
    Console.WriteLine("Car was created");
}

在主程序中创建 Car 对象

Program 类的 Main 方法中,我们可以使用 new 关键字来创建 Car 对象。例如:

Car audi = new Car();

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();
}

复习与总结

  1. 创建类:我们创建了一个 Car 类,这是我们对象的蓝图。
  2. 构造函数:构造函数在创建对象时被自动调用,用于初始化对象。
  3. 创建对象:我们使用 new 关键字创建了 Car 的对象。
  4. 方法:在 Car 类中定义了 DriveStop 方法,这些是每个 Car 对象可以执行的操作。

接下来,我们将进一步学习如何给对象添加属性,并深入了解如何使用面向对象的核心概念来更好地管理代码。

79 - 构造函数与成员变量

创建一个带属性的类并区分不同对象

欢迎回来!在本视频中,我们将解决上一个视频中提到的问题,我们有两个不同的对象(比如 AudiBMW),但是在输出中无法区分它们的状态(例如哪个正在驾驶、哪个停止)。我们将通过添加属性来解决这个问题,让我们能在输出中看到具体是哪辆车被创建、驾驶或停止。


使用私有字段 (Private Fields)

Car 类中,我们将创建一个 私有字段来保存车的名称。这是一个常用于存储数据的属性。

private string _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

这样我们就能知道具体是哪辆车被创建了。


添加方法并使用对象名称

接下来,我们要修改 DriveStop 方法,以便输出特定的车名。例如:

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 方法,用于显示特定对象的详细信息。通过这些步骤,我们实现了面向对象编程中的基础功能,使得每个对象更加独立且具备唯一的属性。

80 - 使用多个构造函数

使用多个构造函数

欢迎回来!在本视频中,我们将学习如何在类中定义多个构造函数。这将使我们可以根据需求创建对象时,使用不同的参数来初始化属性。这种方法让我们更灵活地控制对象的初始化。


默认构造函数

首先,让我们创建一个 默认构造函数(不带参数)。这个构造函数允许我们创建对象时不需要提供任何属性。

public Car()
{
    _name = "Car";
    _hp = 0;
    _color = "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.

这表明我们成功地创建了一个带有指定颜色的汽车对象,而不依赖于默认值。


总结

在本视频中,我们学习了如何使用多个构造函数来灵活地创建对象:

使用多个构造函数可以使我们的类更加灵活和易用。根据需求,我们可以选择在对象初始化时使用不同数量的参数,从而适应不同的场景。

82 - Visual Studio中的快捷方式

Visual Studio 快捷键入门

欢迎回来!在本视频中,我们将介绍一些在 Visual Studio 中非常有用的快捷键,这些快捷键可以帮助您加速编码和项目管理。掌握这些快捷键后,您可以显著提高编程效率,而不必频繁地使用鼠标。以下是一些实用的快捷键。


跳转到方法定义

在代码中,如果想跳转到某个方法或类的定义,可以使用 F12 快捷键:

使用 IntelliSense

IntelliSense 是 Visual Studio 中的代码提示功能,非常有助于加速编码:

IntelliSense 提供了方法、类、枚举等多种代码建议,帮助您快速完成代码。

注释和取消注释代码

在 Visual Studio 中,注释和取消注释代码非常简单:

折叠和展开代码块

对于大型代码块,可以折叠和展开代码块以提高代码可读性:

运行和调试代码

快速运行和调试代码可以帮助您更快地测试代码:

重命名方法或变量

想要在整个项目中重命名某个方法或变量时,可以使用重构快捷键:

文件间快速切换

在多文件项目中,快速切换文件非常重要:

总结

掌握这些快捷键后,您可以减少使用鼠标的频率,提升编码效率。以下是本次视频中提到的快捷键汇总:

快捷键 功能
F12 跳转到定义
Ctrl + - 返回到前一个位置
Ctrl + Shift + - 前进到下一个位置
Ctrl + Space 打开 IntelliSense
Ctrl + K + C 注释选定代码
Ctrl + K + U 取消注释选定代码
Ctrl + M + M 折叠/展开代码块
F5 启动调试器运行代码
Ctrl + F5 无调试器运行代码
Ctrl + R + R 重命名方法或变量
Ctrl + Tab 切换到另一个文件

在下一个视频中,我们将探讨如何使用代码片段来加速编程。

84 - Visual Studio中的代码片段

代码片段简介

欢迎回来!在本视频中,您将学习如何使用代码片段 (code snippets) 来加速编码。代码片段是预定义的代码模板,可以快速插入常用代码结构,从而减少手动输入的重复性。我们之前已经用到过代码片段,比如输入 CW 并按下 Tab 键可以直接插入 Console.WriteLine,非常方便。

以下是如何高效使用代码片段的一些技巧。


常用代码片段

  1. Console.WriteLine

    • 输入 cw 然后按 Tab 键,自动生成 Console.WriteLine(),并且光标会自动跳入括号中,便于您立即输入内容。
    • 示例:
      cw + Tab → Console.WriteLine();
  2. 构造函数(Constructor)

    • 输入 ctor 然后按 Tab,会自动生成一个默认构造函数。创建新类时需要构造函数,这个片段非常方便。
    • 示例:
      ctor + Tab → public ClassName() { }
  3. if 语句

    • 输入 if 然后按 Tab,会生成一个基本的 if 语句框架,光标会自动跳到条件括号中。
    • 示例:
      if + Tab → if (condition) { }
  4. while 循环

    • 输入 while 然后按 Tab,会生成一个 while 循环的框架。可以快速设置循环条件。
    • 示例:
      while + Tab → while (condition) { }
  5. try-catch 语句

    • 输入 try 然后按 Tab,会自动生成一个 try-catch 代码块,便于异常处理。
    • 示例:
      try + Tab → try { } catch (Exception ex) { }
  6. 属性 (Property)

    • 输入 prop 并按 Tab 会生成一个自动实现的属性框架。

    • 示例:

      prop + Tab → public int MyProperty { get; set; }
    • 输入 propg 并按 Tab 生成一个带 getprivate set 的属性。


如何查看代码片段库

您可以通过以下步骤查看更多可用的代码片段:

  1. 进入 工具 (Tools) 菜单,选择 代码片段管理器 (Code Snippets Manager)
  2. 在代码片段管理器中选择 C#,然后查看不同的代码片段,例如 exceptionfor 循环片段等。

自定义代码片段

Visual Studio 还允许您创建自定义代码片段,这样您可以设计适合自己需求的片段。在 Visual Studio 的代码片段管理器中,您可以进一步探索创建代码片段的方法。在 Visual Studio Productivity Masterclass 课程中,我提供了如何创建自定义代码片段的详细教程。


总结

代码片段可以帮助您减少重复输入,提高效率。以下是一些快捷片段的总结:

代码片段 用途
cw Console.WriteLine
ctor 默认构造函数
if if 语句
while while 循环
try try-catch 语句
prop 自动属性
propg getprivate set 属性

通过熟练使用这些代码片段,您可以更高效地编写代码。在接下来的课程中,您将看到代码片段在不同场景下的具体应用。

85 - 理解方法与变量的私有与公共

私有与公共访问修饰符

欢迎回来!在本视频中,我们将讨论 私有 (private)公共 (public) 访问修饰符,它们是控制类成员访问权限的关键。我们将理解如何在 C# 中利用这些修饰符进行数据封装,确保数据安全,同时保持类的易用性。


私有 (private) 访问修饰符

在对象的成员变量(字段)前使用 private 关键字,就表示这个变量只能在该类内部访问,无法从外部直接修改或访问。例如,我们在类 Car 中定义了一个私有变量 _name

private string _name;

在类的外部,例如在主程序文件中,我们不能直接访问 _name。如果尝试修改 myCar._name,编译器会报错:“此变量由于保护级别不可访问”。

使用私有成员变量的原因

  1. 数据完整性:通过限制直接访问,我们可以确保数据不会被随意修改。通过在 gettersetter 中添加数据校验,我们能确保数据始终保持有效状态。
  2. 抽象性:私有变量隐藏了类的内部实现,外部代码无需关心类的内部状态,只需要关注类公开的功能。
  3. 复杂性降低:只公开必要的成员方法或变量,可以减少类的复杂度,使得类的使用变得简单。

示例:如何让变量在类外部可访问

一种方法是将 _name 设为公共 (public),但这样会违背数据封装的原则。另一种更优雅的方法是使用 属性 (property),这将在下一节中介绍。


公共 (public) 访问修饰符

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,汽车仍会启动。


公共方法与私有方法的使用场景

总结

在下一节中,我们将探讨 gettersetter 的概念,这将帮助我们在保持成员变量私有的同时,安全地提供访问权限。

86 - C#中的设置器

私有成员变量的Setter方法

欢迎回来!在本视频中,我们将讨论Setter方法的使用,这是一种通过方法来改变私有变量的值的方式。通过使用Setter方法,我们可以在保持变量私有的情况下,安全地从类外部设置变量值,从而确保数据的完整性和封装性。


什么是Setter方法?

Setter方法是一种公共方法,允许我们从类外部更改私有成员变量的值。例如,假设我们有一个private修饰的成员变量_name,我们可以通过一个publicSetName方法来更改它。这个方法不会直接返回变量值,而是用于设置值。

代码示例

假设我们有以下的私有变量:

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方法的优势

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方法,帮助我们在保持变量私有的情况下安全地读取它的值。

87 - C#中的获取器

理解Getter方法

欢迎回来!在上一个视频中,我们了解了Setter方法,现在我们将探讨Getter方法,或称“获取方法”。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方法的优势

Getter方法不仅允许我们安全地从类外部获取私有变量的值,而且还能让我们在返回值之前执行特定的逻辑。例如,我们可以添加一个后缀到汽车名称中:

public string GetName()
{
    return _name + " Car";  // 在名称后添加“ Car”
}

现在,当调用GetName方法时,返回的将是带有“ Car”后缀的名字。


Getter的实际应用

Getter方法的灵活性使我们能够控制返回的值格式,这在需要对外展示数据时非常有用。例如,在一个应用程序中,当获取用户信息时,我们可能希望格式化返回的数据以适应界面要求。


创建一个用于获取马力的Getter方法

一个简单的练习:创建一个返回汽车马力的Getter方法。

public int GetHP()
{
    return _hp;
}

Program.cs中调用该方法:

Console.WriteLine("My car's horsepower is " + myCar.GetHP());

这将显示汽车的马力值。


总结

在下一节视频中,我们将进一步探索属性,它结合了Getter和Setter的概念,并简化了访问和设置私有变量的方法。

88 - C#中的属性

探索属性(Properties)的用法

欢迎回来!在本视频中,我们将深入了解属性(Properties),以及它们如何简化了私有成员变量的访问和控制。属性可以看作是结合了Getter和Setter方法的简便方式,它让我们能够更灵活地控制数据的访问和设置方式。


什么是属性?

属性是类(Class)的成员,可以被看作是一种特殊的方法,用于读写或计算私有字段(Private Field)的值。属性在外部代码看来就像是公开的数据成员,但实际上,它们是通过特殊的方法(称为访问器 Accessor)来访问的。

通过属性,我们可以:

  1. 读取数据 - 使用 get 访问器。
  2. 写入数据 - 使用 set 访问器。

使用属性代替Getter和Setter

在上个视频中,我们手动创建了Getter和Setter方法来访问和更改私有变量。使用属性,我们可以简化这个过程。来看具体步骤:

1. 定义属性

我们可以使用快捷代码 prop 并按下Tab键,Visual Studio将自动生成属性结构。例如:

public string Name
{
    get { return _name; }
    set { _name = value; }
}

这里的Name属性包含了:


示例:实现Name属性

假设我们有一个私有成员变量 _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;
    }
}

通过这种方式,我们可以在设置值时进行验证,确保不接受空字符串。


总结

属性在C#开发中非常常用,它们既能增强代码的可读性,又能保护数据的完整性。在接下来的章节中,我们将继续深入探讨更多面向对象编程的概念。

89 - 自动实现属性

自动实现属性(Auto-Implemented Properties)

欢迎回来!在本视频中,我们将探讨自动实现属性(Auto-Implemented Properties)。这是C#中的一个简洁特性,可以大大简化属性的创建。


什么是自动实现属性?

当我们不需要在 getset 方法中添加任何额外逻辑时,可以使用自动实现属性来简化代码。自动实现属性在内部会创建一个匿名的私有字段,我们不需要手动定义它。这个私有字段只能通过属性的 getset 访问器进行访问。

例如,我们之前创建的 Name 属性是一个自动实现属性:

public string Name { get; set; }

这个 Name 属性实际上创建了一个隐藏的私有字段来存储数据,但我们不需要显式地编写这个字段。接下来我们将添加一个新属性来展示这一功能。


创建自动实现属性

假设我们想为汽车添加一个 MaxSpeed 属性,表示最大速度。我们可以通过自动实现属性来实现:

public int MaxSpeed { get; set; }

这里的 MaxSpeed 属性是自动实现的,它会在内部自动创建一个私有的存储字段,我们可以通过 getset 访问器来读取和设置 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 打印出来。这一切都通过自动实现属性完成,无需额外的字段定义。


自动实现属性的优点

  1. 简洁:自动实现属性省去了显式定义私有字段的步骤。
  2. 安全性:它仍然保留了封装的优势,私有字段仅能通过 getset 访问器访问。
  3. 高效:在不需要复杂逻辑的情况下,简化了代码结构。

总结

自动实现属性非常适合不需要额外逻辑的简单属性。它既提供了对私有数据的封装,同时让代码更为简洁、清晰。只需一行代码,即可拥有 getset 访问器,自动创建的私有字段在内部处理数据存储。

接下来,我们将进一步探讨如何使用只读和只写属性,来满足不同的数据访问需求。

90 - 只读与只写属性

只读和只写属性

欢迎回来!在本视频中,我们将探讨 只读只写 属性。这些属性的用途在于控制如何访问类中的数据。通过只读或只写属性,我们可以限制属性的访问方式,从而增强代码的安全性和封装性。


什么是只读属性?

只读属性具有 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 成为只写属性,不能在外部获取该属性的值。


只读和只写属性的应用场景

只读属性的应用场景

  1. 不可变数据:例如出生日期。出生日期在设置后不应再更改,适合用只读属性。

  2. 计算属性:当属性值是从其他数据计算而来时,可用只读属性。例如,矩形的 Area(面积)属性可以是只读的,通过宽度和高度计算得出,而不允许手动设置。

只写属性的应用场景

  1. 敏感数据:例如密码。在用户凭据类中,可能需要将密码设置为只写,以防止应用程序读取密码。

  2. 触发操作:只写属性还可以用于触发某些操作。例如在日志类中,可以用一个只写属性 Message 来写入日志信息,但不允许直接读取它的值。


总结

只读和只写属性在数据保护和封装方面非常有用。它们确保了数据只能在符合要求的情况下被访问或修改。虽然只写属性在实际应用中较少见,但在某些特殊场景下,仍然可以发挥重要作用。

希望这些示例帮助您理解如何有效使用属性的不同访问权限!我们下节课再见。

91 - 成员与终结器(析构函数)

成员(Members)

欢迎回来!在本视频中,我们将介绍 成员(Members) 的概念,并通过创建一个新的 Members 类来展示所有与面向对象编程相关的成员类型。成员是类的一部分,可以包括字段、属性、方法、构造函数和析构函数等内容。了解这些不同的成员类型将帮助您更好地设计和使用类。


创建类和字段

首先,我们创建一个名为 Members 的类。该类包含多个不同类型的成员。

私有字段(Private Fields)

我们可以定义一些私有字段,用于存储类的内部数据。私有字段只能在类的内部访问,不能从类的外部直接访问。例如:

private string memberName = "Lucy";
private string jobTitle = "Developer";
private int age = 30;
private int salary = 60000;

这些字段表示成员的名字、职位、年龄和薪水。

公有字段(Public Fields)

在某些情况下,您可能需要将字段设为公有,以便可以从类的外部直接访问它。例如:

public int experienceYears = 5;

公有字段可以直接从类的外部访问,但通常不推荐将字段直接设为公有,因为这样会降低封装性。


属性(Properties)

属性是用于访问私有字段的成员。我们可以使用自动实现的属性,或者手动编写 getter 和 setter 方法。

自动实现的属性

public string JobTitle { get; set; }  // 自动实现的属性

手动实现的属性

public string JobTitle
{
    get { return jobTitle; }
    set { jobTitle = value; }
}

手动实现的属性允许我们在获取和设置属性值时添加自定义逻辑。属性通常用于安全地暴露类中的私有字段。


方法(Methods)

方法是类的行为,通常为公有,以便从类外部调用。例如:

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 方法允许成员进行自我介绍。如果调用者是朋友(即 isFriendtrue),则调用私有方法 SharePrivateInfo()

私有方法(Private Methods)

私有方法只能在类的内部使用,通常用于内部逻辑或辅助计算。例如:

private void SharePrivateInfo()
{
    Console.WriteLine($"My salary is {salary}.");
}

私有方法 SharePrivateInfo 会输出成员的薪水,但不会对外部公开。


构造函数(Constructors)

构造函数用于初始化对象,是类的一个特殊成员。构造函数在创建对象时自动调用。它们可以设置初始值或执行其他初始化任务。

public Members()
{
    Console.WriteLine("Object created");
    age = 30;
    memberName = "Lucy";
    jobTitle = "Developer";
    salary = 60000;
}

在这个构造函数中,我们设置了 agememberNamejobTitlesalary 的初始值,并输出 "Object created" 以指示对象的创建。


析构函数(Destructors)

析构函数(又称“终结器”)是在对象被垃圾回收时调用的特殊成员,用于清理资源。析构函数通常用于释放非托管资源。

~Members()
{
    Console.WriteLine("Destruction of members object");
}

在 C# 中,析构函数的定义以 ~ 开头。请注意,析构函数不应包含实际业务逻辑,而仅限于清理操作。如果析构函数为空,最好不要定义它,以免降低性能。


测试代码

Program.cs 文件中,我们可以创建一个 Members 对象并调用方法来查看效果:

Members member1 = new Members();
member1.Introduce(true);  // 调用带朋友标记的介绍方法,输出薪水信息

通过调用 Introduce 方法,我们可以让成员自我介绍并分享其私密信息(如薪水),这是因为我们设置了 isFriend 参数为 true


总结

在本节中,我们讨论了以下成员类型:

这些成员类型是 C# 中面向对象编程的核心内容。理解它们并合理使用它们,将帮助您编写更加清晰、可维护的代码。


现在您已经了解了成员的不同类型及其用途。我们将在后续课程中继续深入探讨面向对象编程的更多内容。下节课见!

92 - 对象总结

对象和面向对象编程总结

现在,您已经完成了面向对象编程(OOP)章节,并学习了如何创建类,这些类可以拥有属性和方法,还包含了不同类型的成员(如字段、构造函数等)。您还了解了如何使用构造函数,析构函数等重要的编程概念。

为什么要使用对象和OOP?

到目前为止,您可能会觉得 OOP 有些复杂或不必要,因为您可能在编写的小程序中没有真正感受到它的优势。确实,当我们编写一个简单的小程序时,确实不需要使用复杂的对象结构和类设计。然而,当我们进入更复杂的项目时,对象的力量将会显现出来。

通过 OOP,您可以更好地管理代码结构,实现代码复用,简化维护和修改,并使程序更易于扩展。当应用规模扩大并引入更多复杂的功能和逻辑时,OOP 的设计思想和原则可以显著提升程序的组织性和可读性。

接下来要学习的内容

接下来,我们将进入 数组 的学习章节。数组是一种将多个对象或多个变量存储在一起的数据结构。与对象类似,数组也可以帮助您更好地组织和处理数据,特别是在需要存储和操作大量数据时。

在下一章中,您将学到如何使用数组和列表。这些工具将为您提供有效管理数据的强大手段,特别是当您需要对一组数据进行操作时,数组和列表将变得非常有用。

下一步

请继续保持耐心,不要担心现在是否完全理解了 OOP。随着编程经验的积累,这些概念将会逐渐变得清晰,您也会更灵活地运用它们。接下来,让我们继续学习数组,这将为您的编程技能带来更多的提升!

WangShuXian6 commented 2 weeks ago

7 - C中的集合

93 - 数组简介

数组章节介绍

在本章中,您将学习数组(Arrays)和列表(Lists)的工作原理。具体来说,我们将探索:

数组和列表的实际应用

在编程中,我们经常需要管理和操作较大数量的数据。假设在一个数据库中有 100 个用户,并且您需要为每位用户更改某些信息。这时,将这些用户放入数组或列表中将是非常高效的做法。不同于单独处理每个用户,通过数组或列表,您可以对整个数据集进行批量操作。

数组与列表的区别

在学习中,您会发现数组和列表在某些方面有所不同,每种都有自己的优势:

在接下来的章节中,您将更深入地理解何时应该使用数组,何时适合使用列表,以及如何使用它们来高效地处理数据。

接下来

在本章,我们将提供相关练习和测验,帮助您巩固对数组和列表的理解。准备好了吗?让我们深入探讨这些重要的数据结构,揭开它们的强大之处!

94 - 数组基础

数组理论概述

在本视频中,我们将介绍 C# 中数组的理论部分。在下一个视频中,我们会通过实际操作和示例更深入地理解数组的使用。首先,让我们来了解数组的基本概念和作用。

数组的定义与特点

数组是一种固定大小的顺序集合,用于存储相同类型的元素。需要特别注意,数组只能存储相同类型的数据元素,这意味着在同一个数组中,不能既有字符串(string)又有整数(int)等不同的数据类型。

数组的类型和灵活性

数组可以是任意类型的数据集合。例如,您可以创建一个仅包含整数的数组、一个仅包含字符串的数组,甚至可以存储对象的数组。几乎任何类型都可以作为数组的元素类型,只需保证数组中的所有元素都为同一类型。

数组的结构和索引

可以将数组想象成一个具有多个存储单元的存储结构,这些存储单元用于存放相同类型的数据。例如,以下图所示是一个长度为6的整数数组:

[13, 15, 5, 7, 8, 10]

在这个数组中,每个数据都有其索引,用于定位特定的数据元素。数组的索引从0开始,并逐步增加。因此,在上图中:

这意味着在一个长度为 n 的数组中,索引范围为 0n-1

声明和初始化数组

在 C# 中声明数组时,需要指定数据类型和数组名称,例如:

int[] grades;

上面的代码表示声明了一个 int 类型的数组,名为 grades,用来存储学生的成绩信息。

初始化数组需要以下步骤:

  1. 声明数组:数据类型 + 方括号 [] + 数组名称。
  2. 分配空间:使用 new 关键字分配指定长度的空间。

例如,下面的代码定义了一个包含 5 个整数的数组:

int[] grades = new int[5];

这表示数组 grades 可以存储 5int 类型的整数。

给数组赋值

赋值时,通过数组名称加方括号中的索引来指定要赋值的位置。如下所示:

grades[0] = 15;
grades[1] = 12;

这段代码为 grades 数组的第一个元素(索引为 0)赋值为 15,第二个元素(索引为 1)赋值为 12

总结

在本视频中,我们了解了数组的基础理论:

  1. 数组用于存储相同类型的元素集合。
  2. 数组具有固定的大小,并通过索引定位元素。
  3. 数组声明和初始化的基本语法。
  4. 使用索引来为数组元素赋值。

数组是编程中用于存储和管理一组数据的重要工具。在接下来的视频中,我们会进行实操,探索数组的实际应用及其在编程中的重要性。

95 - 声明与初始化数组及长度属性

数组的创建和使用

在本视频中,我们将学习如何在 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 的每个元素赋值。索引从 04,正好对应数组的五个元素。

访问数组元素

可以通过索引来访问数组中的某个元素。例如,要访问 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]

不同的数组初始化方法

除了使用索引分配值之外,还可以通过其他方式初始化数组。

  1. 直接初始化数组(指定元素):

    int[] gradesOfMathStudentsA = { 20, 13, 12, 8, 8 };

    这段代码创建并初始化了一个名为 gradesOfMathStudentsA 的数组,其元素为 {20, 13, 12, 8, 8}

  2. 使用 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 循环来遍历数组中的每个元素。

96 - Foreach循环

使用 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] 被赋值为 10nums[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 循环会自动处理数组的起点和终点,避免了因索引错误导致的越界异常。

foreachfor 的区别和优势

  1. 数据类型:在 foreach 循环中,变量的数据类型会自动匹配数组元素的数据类型,而 for 循环的索引通常是整数类型。
  2. 边界检查foreach 循环不需要指定开始或结束条件,避免了手动错误,直接遍历数组的所有元素。
  3. 性能:在一些情况下,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 循环能够简化代码编写和避免越界错误,适合在需要遍历整个数组的场景中使用。在接下来的视频中,我们将进一步探索数组的高级功能和操作。

97 - 为什么使用Foreach

深入理解 foreachforwhile 循环的用法及适用场景

在本节中,我们将深入了解 foreach 循环与其他循环(如 forwhile)之间的区别,并讨论每种循环的适用场景。每种循环在编写代码时都有其独特的优势,因此理解它们的用法将帮助你选择最适合的循环类型。

使用 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 循环的每次迭代中,都会打印当前元素 numberforeach 循环自动为我们处理迭代,因此代码更加简洁明了。

foreachfor 循环的比较

如果你需要操作索引,例如跳过某些元素或倒序遍历,那么传统的 for 循环更为合适。for 循环可以让你更加灵活地控制循环的开始点、结束点以及步长。

例如,如果我们想跳过每隔一个元素打印一次数组内容,那么可以使用 for 循环实现:

int[] numbers = {1, 2, 3, 4, 5};

for (int i = 0; i < numbers.Length; i += 2)
{
    Console.WriteLine(numbers[i]);
}

在这个例子中,我们使用了 for 循环,并初始化变量 i0,设置循环条件为 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,循环结束。

这种方法让我们可以根据条件控制循环的继续与否,而无需提前知道循环的次数。

总结:何时使用不同类型的循环

接下来,我们将进行一个实践练习,以巩固目前学习到的内容。

98 - 多维数组

多维数组的介绍与使用

在本视频中,我们将深入探讨多维数组,特别是二维数组(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)游戏。确保你熟练掌握二维数组的基本操作,为下一节做好准备。

99 - 嵌套For循环与二维数组

合并二维数组与循环的概念

现在你已经了解了数组、二维数组和 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();
}

在此代码中:

运行结果如下:

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 循环的高级应用和不同的遍历技巧。

100 - 嵌套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]);
}

在此代码中:

运行结果如下:

3
5
7

总结

通过这几种方法,我们可以灵活地处理多维数组中的不同元素。希望这些方法能够帮助您更好地理解二维数组的操作。下一个视频中,我们将继续深入探讨数组与循环的高级应用!

101 - 挑战:井字棋

创建井字棋游戏:综合应用多维数组、嵌套循环与输入验证

欢迎回来!在这个视频中,我们将接受一个小挑战,创建一个简单的井字棋(Tic Tac Toe)控制台游戏。这一项目将整合之前学习的二维数组、循环、条件语句以及输入验证等内容。我们将从基础的游戏界面开始,逐步实现玩家选择、获胜检测、错误提示以及游戏重置等功能。以下是实现的完整步骤。


1. 创建井字棋的游戏界面

首先,我们将定义一个 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("     |     |     ");
}

2. 定义游戏状态的二维数组

为了管理游戏状态,我们将使用一个二维字符数组来表示井字棋的九个格子。初始时,每个格子用对应的数字字符表示。

static char[,] playField = 
{
    {'1', '2', '3'},
    {'4', '5', '6'},
    {'7', '8', '9'}
};

3. 用户输入与输入验证

接下来,我们实现用户输入,使用 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);

4. 更新玩家选择并切换玩家

我们将使用方法 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;
    }
}

5. 实现胜利条件检测

胜利的条件是玩家的符号在行、列或对角线上连续出现。我们使用多个 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;
}

6. 检查平局

当所有格子都被占用而未分出胜负时,游戏判定为平局。我们可以通过一个计数器来统计已用格子。

static bool CheckDraw()
{
    foreach (var item in playField)
    {
        if (item != 'X' && item != 'O') return false;
    }
    return true;
}

7. 游戏重置功能

当有胜利者或平局出现时,我们提示用户按任意键重新开始,并重置 playField 为初始状态。

static void ResetField()
{
    playField = new char[,] {
        { '1', '2', '3' },
        { '4', '5', '6' },
        { '7', '8', '9' }
    };
    Console.Clear();
    SetField();
}

8. 主程序循环

在主程序中,我们将以上功能整合在 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);
}

总结

通过这个项目,我们完成了一个简单的井字棋控制台游戏,实现了二维数组的使用、循环、输入验证和游戏逻辑检测。这个挑战不仅复习了之前学过的概念,同时也增强了编程逻辑的应用能力。希望你在练习的过程中掌握了新技能!

在下一节中,我们将进入更深入的编程主题。再见!

102 - 锯齿数组

锯齿数组(Jagged Array)简介

欢迎回来!在这个视频中,我们将讨论锯齿数组的概念。不同于二维数组,锯齿数组中的每一行可以包含不同数量的元素。锯齿数组实际上是“数组中的数组”,这让它们非常灵活且适用于许多需要不规则数据结构的场景。


1. 创建锯齿数组

我们可以在 Main 方法中创建一个简单的锯齿数组。以下是声明和初始化一个包含整型值的锯齿数组的步骤:

int[][] jaggedArray = new int[3][];  // 声明一个包含 3 个数组的锯齿数组

在这行代码中,我们声明了一个包含三个数组的锯齿数组,但这些数组目前都是空的。我们可以为每个数组指定不同的长度。


2. 初始化锯齿数组

接下来,我们将为 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 };

在这种情况下,我们在初始化时直接提供了数组的值,省去了单独分配数组长度的步骤。


3. 使用内联初始化方法

我们还可以在声明时直接为锯齿数组赋值,这样代码更加简洁:

int[][] jaggedArray = new int[][]
{
    new int[] { 2, 3, 5, 7, 11 },
    new int[] { 1, 2, 3 },
    new int[] { 13, 21 }
};

在这种方式下,锯齿数组和其中的子数组都在同一行代码中初始化。


4. 访问锯齿数组的元素

要访问锯齿数组中的具体元素,我们可以通过“数组的索引 + 子数组的索引”来定位。例如,如果我们想访问第一个子数组的第三个元素,可以这样做:

Console.WriteLine($"The value at jaggedArray[0][2] is {jaggedArray[0][2]}");

在上面的示例中,jaggedArray[0][2] 代表第一个数组中的第三个元素(索引从 0 开始),因此它输出的是 5


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 循环遍历每个子数组的元素,依次打印出每个元素的值。


6. 使用嵌套 foreach 循环

为了让代码更简单清晰,我们还可以使用嵌套的 foreach 循环来遍历锯齿数组:

foreach (int[] array in jaggedArray)
{
    foreach (int value in array)
    {
        Console.Write($"{value} ");
    }
    Console.WriteLine();
}

这里,外层 foreach 循环依次遍历 jaggedArray 中的每个子数组,内层 foreach 循环则遍历当前子数组中的每个元素。这样可以更直观地查看数组内容。


总结

锯齿数组是一种强大的数据结构,能够存储不规则长度的数据,可以灵活适应许多编程场景。通过创建、初始化、访问以及遍历锯齿数组的不同方式,我们可以处理和管理复杂的数据结构。

在接下来的视频中,我们将学习如何将数组作为参数传递到方法中,以便更高效地处理数据。

103 - 锯齿数组或多维数组

锯齿数组 vs 多维数组

在我们开始练习之前,确实有必要深入了解锯齿数组和多维数组的区别以及使用场景。这样可以帮助我们更好地理解如何在不同的情况下使用它们。


锯齿数组(Jagged Array)

锯齿数组,也称为“数组的数组”,是一个数组,其中的元素也是数组。这种结构的关键特点是每个子数组的大小可以不同,从而提供了高度的灵活性。

示例:用锯齿数组表示一个三角形

假设我们需要用数字来表示一个三角形。锯齿数组是完成这项任务的理想选择,因为三角形的每一行可以包含不同数量的元素。

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 循环则遍历当前行的每个元素,并将其打印出来。最终,我们获得了一个三角形结构,其中每行的元素数量逐渐增加。

锯齿数组的这种结构特性使其适用于表示形状不规则的数据结构,例如稀疏矩阵、阶梯状数据等。


多维数组(Multidimensional Array)

与锯齿数组不同,多维数组在所有维度上大小固定且一致。这意味着每一行中的元素数量相同,适合需要行列对称的情况。

示例:用多维数组表示一个2x2矩阵

我们来看看如何使用多维数组创建一个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 循环遍历列,并打印出每个位置的值。因为多维数组在所有维度上大小一致,它特别适合用于需要执行数学运算的网格结构,例如矩阵乘法、图像处理等。


选择何时使用哪种数组


总结

锯齿数组和多维数组各有优缺点,取决于具体需求。锯齿数组提供更大的灵活性,适合不同大小的数据结构;而多维数组则结构统一,更适合数学运算。

希望通过这些讲解,你对锯齿数组和多维数组的选择有了更清晰的认识。现在我们可以继续进行练习,进一步巩固这些知识。

104 - 挑战:锯齿数组

任务解析和锯齿数组示例

在这个视频中,我们会通过一个小挑战,进一步巩固对锯齿数组的理解和使用。目标是创建一个包含不同朋友及其家庭成员的锯齿数组,并让其中的家庭成员互相介绍。


任务要求

  1. 创建一个锯齿数组,其中包含三个朋友的数组,每个朋友数组中有两个家庭成员。
  2. 展示家庭成员之间的介绍,比如让不同朋友的家庭成员互相认识,打印介绍信息到控制台。

例如,如果有一个朋友叫 Marta,她的兄弟分别是 Joe 和 Michael,那么数组中会有一项是包含 JoeMichael 的数组。


解决方案示例

首先,我们来创建这个锯齿数组:

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();
    }
}

代码解释

  1. 声明和初始化锯齿数组:使用嵌套的 new string[] 创建多个朋友的家庭成员数组。
  2. 输出介绍信息:通过 Console.WriteLine 将不同家庭的成员互相介绍,每行代码指定一个朋友的某个家庭成员与另一位朋友的家庭成员见面。
  3. 使用占位符:通过 {0}{1} 实现字符串插值,使代码更简洁并便于复用。

可选改进

可以将每个朋友的家庭单独定义为数组,并在 friendsAndFamily 中引用这些数组,这样更具可读性。例如:

string[] michaelsFamily = { "Michael", "Sandy" };
string[] franksFamily = { "Frank", "Claudia" };
string[] andrewsFamily = { "Andrew", "Michelle" };

string[][] friendsAndFamily = { michaelsFamily, franksFamily, andrewsFamily };

这种方式将每个朋友的家庭单独存储,有助于理解哪些家庭成员属于哪个朋友。


总结

本次练习展示了如何使用锯齿数组存储和处理不规则数据结构,并通过简单的字符串插值实现家庭成员之间的介绍。这不仅让我们熟悉了锯齿数组的基本操作,也增强了对复杂数据存储的理解。

105 - 将数组作为参数使用

结合数组与方法参数的使用

在本视频中,我们介绍了如何将数组作为参数传递给方法。这使我们能够对数组中的数据进行集中处理,例如计算平均值、修改数组中的值等。以下是示例代码和练习,帮助你更深入地理解和掌握该技巧。


示例:计算成绩的平均值

我们将创建一个方法 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;
    }
}

代码解释

  1. 创建成绩数组:用一个整数数组存储多个成绩。
  2. 计算平均值GetAverage 方法接收成绩数组,计算其平均值,并返回给主方法。
  3. 输出结果:在控制台打印所有成绩值以及计算出的平均值。

挑战:提高幸福值

编写一个方法 SunIsShining,通过将数组中的每个元素增加 2 来提高幸福值。然后输出修改后的数组值。

任务要求

  1. 创建一个 int 类型的数组 happiness,包含五个值。
  2. 编写一个 SunIsShining 方法,接收 int 数组作为参数,并将每个值增加 2。
  3. 在主方法中调用 SunIsShining 方法,并将 happiness 数组传入。
  4. 使用 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
        }
    }
}

代码解释

  1. 创建幸福值数组:数组 happiness 用于存储五个不同的幸福值。
  2. 编写 SunIsShining 方法:遍历数组,将每个元素增加 2。
  3. 输出修改后的值:使用 foreach 循环,输出更新后的幸福值。

小结

本视频中,我们学习了如何将数组作为参数传递给方法,以及如何在方法中处理数组数据。这种技巧在处理大量数据、需要集中计算或批量修改时非常有用。

请确保熟练掌握数组和方法参数的结合使用,这将让你能够编写更简洁、更模块化的代码。

107 - params关键字

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(); // 换行
    }
}

代码解释

  1. 调用 ParamsMethod:传递不同数量的字符串参数,params 关键字允许我们传递任意数量的参数。
  2. ParamsMethod 方法:该方法接收一个 string 类型的数组参数 sentence,并使用 for 循环逐个输出数组中的元素。
  3. 处理无参数情况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(); // 换行
    }
}

代码解释

  1. 定义变量:不同类型的变量包括整数、浮点数、字符和字符串。
  2. 调用 ParamsMethod2:使用 params 关键字和 object 数组,这样可以接受任意类型和数量的参数。
  3. 循环输出参数:通过 foreach 循环逐个输出传入的每个参数值。

运行结果:

50 3.14 @ The Hobbit
Another Example 55.3 $

params 关键字的优势

  1. 灵活性:允许方法接收可变数量的参数,不必提前确定参数数量。
  2. 兼容多种数据类型:使用 object 类型的 params 参数时,可以接收任意类型的参数。
  3. 提升代码简洁性:省去多重方法重载的需求,可直接传递参数数组,方便实现动态调用。

总结

params 关键字在处理不确定数量和类型的参数时非常实用。例如,当需要打印日志、动态输出或接收用户输入的多个值时,params 能有效简化代码结构。希望通过本视频的讲解,你能够更灵活地运用 params 关键字来编写更加动态、健壮的程序。在下一视频中,我们将深入探讨 params 关键字在实际应用中的高级用法。

108 - 为什么使用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 关键字为 C# 提供了灵活性,允许方法接受不确定数量的参数。然而,由于它只能用于方法的最后一个参数,并且可能会影响性能,因此在使用时需要谨慎。选择 params 的关键在于是否能显著简化代码以及满足实际的业务需求。

在接下来的视频中,我们会继续探讨一些 params 的实际应用场景,以帮助你更好地掌握这一关键字的应用。

109 - 使用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

执行流程

  1. 初始化最小值min 被初始化为 int.MaxValue,即一个非常大的数。
  2. 遍历数组:通过 foreach 遍历传入的参数。
  3. 比较并更新:在每次遍历中,比较 number 和当前 min,如果 number 较小,则更新 min
  4. 返回结果:返回遍历后的 min 值,即所有参数中的最小值。

何时使用 params

此示例展示了在以下情况中使用 params 的优势:

总结

params 是 C# 中的一个强大工具,可以让方法更加灵活,适用于需要处理可变参数数量的场景。虽然在某些情况下可能带来性能损耗(如传递大量数据时),但在大多数场景中,它极大地简化了代码,使方法的定义和调用更具灵活性。


在接下来的视频中,我们会探讨 params 在更多实际项目中的应用,让你在编写代码时更清楚何时使用这一功能。

110 - 概述:通用与非通用集合

集合:C#中的泛型与非泛型集合简介

在本课中,我们将学习 泛型集合非泛型集合,这是 C# 编程中的重要主题之一。在此,我们将探索集合的基本概念,并介绍何时以及如何使用这些集合类型。


什么是集合?

集合(Collection) 是一种数据结构,与数组相似,可以一次存储多个对象,形成对象的集合,因而称为“集合”。与数组不同,集合具有以下特点:

为什么需要集合?

设想以下场景:我们正在为学校开发一个考勤系统,需要动态添加学生。虽然可以使用数组来存储学生对象,但数组存在以下局限性:

  1. 固定大小:一旦定义了数组大小,就不能动态增加。因此,如果我们创建一个 500 个学生的数组,就无法存储更多学生。
  2. 类型限制:数组只能存储单一类型的对象,而在系统中,我们可能还需要添加教师、员工等不同类型的对象。

集合 允许我们更高效地存储、管理和操作多个对象,可以用来执行添加、删除、替换、搜索特定对象以及复制对象等操作。


集合的两大类型

C# 提供了两种集合类型:

  1. 非泛型集合:可以存储任意类型的对象,位于 System.Collections 命名空间中。
  2. 泛型集合:限制为单一类型的对象,用于存储相同类型的数据,位于 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 是一个非泛型集合,允许我们向其中添加不同类型的数据,包括 intfloatstring。遍历时使用 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# 中集合的基本概念以及泛型和非泛型集合的区别。在接下来的课程中,我们将详细探索不同的集合类型,并了解它们的具体应用场景。

111 - ArrayLists

ArrayList 和对象的用法入门

本节将详细介绍 C# 中的 ArrayList 集合,包括其常用方法和属性。我们将了解如何声明、添加和操作 ArrayList 中的元素,同时探索为何使用对象 (object) 数据类型来处理多种类型的数据。


什么是 ArrayList?

ArrayList 是一种非泛型集合,可以存储不同类型的对象,且不受大小限制。这使得 ArrayList 特别适合存储多种类型数据。要使用 ArrayList,我们需要引入 System.Collections 命名空间,因为它属于此命名空间。

using System.Collections;

声明 ArrayList

ArrayList 可以声明为不限制大小的,也可以指定初始大小。

// 不限制大小的 ArrayList
ArrayList myArrayList = new ArrayList();

// 指定初始大小为 100 的 ArrayList
ArrayList myArrayListWithCapacity = new ArrayList(100);

向 ArrayList 添加元素

在 ArrayList 中,可以使用 .Add() 方法添加元素,并且可以存储不同类型的对象,例如 intstringdouble 等:

myArrayList.Add(25);         // 添加整数
myArrayList.Add("Hello");    // 添加字符串
myArrayList.Add(13.37);      // 添加浮点数
myArrayList.Add(13);
myArrayList.Add(128);
myArrayList.Add(25.3);

ArrayList 的常用方法

  1. 删除指定值的元素:使用 .Remove() 方法删除第一个匹配的元素。

    myArrayList.Remove(13);  // 删除第一个值为 13 的元素
  2. 删除指定位置的元素:使用 .RemoveAt() 方法删除指定索引处的元素。

    myArrayList.RemoveAt(0);  // 删除索引 0 处的元素
  3. 计算元素个数:使用 .Count 属性获取 ArrayList 中的元素数量。

    int count = myArrayList.Count;
    Console.WriteLine("元素数量: " + count);

遍历 ArrayList 并进行类型判断

我们可以使用 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 和其他集合类型的用法及其特定方法。

112 - 列表

列表 (List) 介绍

在 C# 中,列表 (List) 是一种非常灵活的集合类型,相比数组,它允许我们动态地增减元素。因此列表在需要存储动态对象集合时非常实用。我们将了解如何声明、添加和移除元素、访问特定索引位置的元素、清空列表以及遍历列表中的元素。


列表的基本知识

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 eachfor 循环遍历列表:

使用 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 属性非常有用,它返回列表中的元素个数,帮助我们避免索引越界错误。


列表 (List) 方法和属性

除了上述方法外,列表还包含其他有用的属性和方法,适合进一步探索。通过使用不同的方法和属性,您可以对列表进行查找、排序、复制等操作。建议多实践,创建并操作不同的列表,熟悉其用法和特点。


小结

列表是一种灵活、易用的集合类型,适合动态数据存储需求。通过掌握列表的基本方法和属性,您将能够高效地管理对象集合并在项目中实现各种操作。在后续课程中,我们将更多地使用列表并探索其他高级特性。

113 - 哈希表

哈希表 (Hash Table) 与字典 (Dictionary)

在本课中,我们将学习如何使用哈希表和字典来存储键值对数据。它们在 C# 中是非常有用的集合类型。哈希表与字典都允许使用键 (key) 来快速查找对应的值 (value)。通过对比实际应用场景,我们可以更好地理解它们的作用和区别。


哈希表 (Hash Table) 的概念

哈希表的主要特点是键值对的存储方式。每个键 (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 是专门用于哈希表的结构,包含 KeyValue 属性。通过 entry.Value 获取学生对象,并转换为 Student 类型后,就可以访问每个学生的详细信息。


简化遍历

我们可以直接使用 studentsTable.Values 来遍历所有的值,而不需要 DictionaryEntry

foreach (Student student in studentsTable.Values)
{
    Console.WriteLine($"Student ID: {student.ID}, Name: {student.Name}, GPA: {student.GPA}");
}

在这种方式下,代码更简洁,并且可以直接访问学生对象的属性。


哈希表和字典的区别


总结

哈希表是一种非常灵活的集合类型,可以用来高效地存储和查找键值对数据。在后续的课程中,我们将探索字典的用法以及更多实际场景中的应用。

在下一节中,我们将有一个小练习,帮助你进一步理解哈希表的使用。

114 - 哈希表挑战

哈希表挑战任务的解决方案

在上一个视频中,我们学习了哈希表的基本操作,现在让我们完成一个小挑战任务。在本任务中,你将编写一个程序,遍历学生数组的每一个元素并将其插入到哈希表中。如果哈希表中已经存在具有相同 ID 的学生,则跳过插入,并显示错误消息:"抱歉,具有相同 ID 的学生已经存在"。

挑战任务要求:

解决方案实现:

  1. 定义哈希表
    首先,我们需要声明一个哈希表来存储学生数据,并引入 System.Collections 命名空间:

    using System.Collections;
    
    Hashtable studentsTable = new Hashtable();
  2. 创建 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 用于测试
    };
  3. 遍历学生数组并插入哈希表
    使用 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 已存在,则打印错误消息。
  4. 运行代码并测试
    运行程序,你应该会看到类似的输出,其中会显示哪些学生成功添加,哪些学生因 ID 重复而无法添加。

输出示例:

学生已添加:ID = 1, Name = Maria
学生已添加:ID = 2, Name = Jason
学生已添加:ID = 6, Name = Steve
学生已添加:ID = 3, Name = Clara
抱歉,具有相同 ID (1) 的学生已经存在。

任务扩展

你可以进一步扩展此任务,确保如果 ID 重复,系统会为学生自动生成一个新的 ID,然后继续将其添加到哈希表中。尝试编写代码来实现这一功能。

小结

通过这项任务,你熟悉了哈希表的使用,包括如何插入数据、检测重复项以及有效利用 ContainsKey 方法。掌握这些技能后,哈希表可以帮助你在实际项目中高效地管理和查询数据。希望你完成任务并理解哈希表的应用!

115 - 字典

字典 (Dictionary) 简介与使用

在前几节课中,我们讨论了哈希表的使用。本节课中,我们将讨论哈希表的泛型版本——字典(Dictionary)。字典与哈希表类似,通过键值对的形式存储数据,但字典是泛型集合,这意味着我们需要在声明时定义键和值的类型,以确保数据类型的一致性和安全性。

1. 定义字典

在 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 类型。

2. 字典的灵活性

字典不仅限于基本数据类型,还可以存储更复杂的对象。例如,我们可以创建一个 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>();

3. 向字典中添加数据

假设我们有一个员工数据库,可以用 for 循环遍历数据库,并将每个员工添加到 employeesDirectory 字典中:

foreach (Employee emp in employeesDatabase)
{
    employeesDirectory.Add(emp.Role, emp);
}

每个角色都是唯一的键,用于识别特定的员工对象。

4. 从字典中检索数据

可以使用键来获取字典中的特定项。例如,假设我们想获取职位为 "CEO" 的员工信息:

if (employeesDirectory.ContainsKey("CEO"))
{
    Employee ceo = employeesDirectory["CEO"];
    Console.WriteLine($"CEO Name: {ceo.Name}, Salary: {ceo.Salary}");
}
else
{
    Console.WriteLine("CEO not found.");
}

这种方法有效防止了当键不存在时引发的异常。

5. 使用 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

6. 使用 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}");
}

这将按顺序输出字典中的所有键和值。

小结

在下一节课中,我们将深入了解如何更新字典中的现有数据以及如何删除数据。希望这节课帮助你更好地理解字典的使用。

116 - 编辑与删除字典中的条目

字典 (Dictionary) 的更新与删除操作

在上一节课中,我们学习了如何创建字典、向字典中添加值以及遍历字典。这次我们来学习如何更新字典中的数据以及如何从字典中删除数据。

1. 更新字典中的数据

首先,让我们来看一个例子,其中我们希望更新特定键对应的值。比如,我们想要更新 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.

这表明更新操作已成功。我们可以在控制台中检查员工的详细信息,确认更新是否成功。

2. 从字典中删除数据

接下来,我们看看如何从字典中删除特定的键值对。假设我们想要删除 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 键对应的员工信息已被删除。

注意事项

小结

希望这节课帮助你更好地理解如何操作字典中的数据。

117 - 队列与堆栈概述

栈和队列的概念

栈 (Stack) 和队列 (Queue) 是计算机科学中的两种常见数据结构。数据结构是一种数据组织、管理和存储的格式,使数据访问和修改更加高效。不同的数据结构有不同的存储和管理数据的方式,这取决于它们的设计目的。本节课将重点介绍栈和队列,它们在特定的编程场景中非常有用。


栈 (Stack)

栈的特点是遵循 "后进先出" (LIFO) 原则,即最后插入的数据最先被移除。我们可以把栈比作一堆叠放的石头,你可以很容易地拿走最上面的石头,但如果要取出中间的石头,则必须先移走上面的石头。

栈的数据管理方式在很多场景中都有应用,尤其是在数学算法和操作系统的数据管理中。以下是一些常见的栈的应用:

  1. 数据反转:栈可以很方便地将输入数据反转。将数据逐一放入栈中,然后依次从栈顶移除数据,就能得到反转的结果。
  2. 浏览器的后退按钮:浏览器会使用栈来存储用户访问的网页记录,点击后退按钮时会依次加载用户上一个访问的页面。
  3. 撤销与重做功能:许多应用程序中的撤销 (Undo) 和重做 (Redo) 功能使用栈来记录执行的操作,可以逐步撤销或重做操作。

栈也是一种集合 (Collection),像列表 (List) 和字典 (Dictionary) 一样,栈也有其特有的操作:


队列 (Queue)

队列遵循 "先进先出" (FIFO) 原则,即最先加入的数据最先被移除。队列的例子可以参考生活中的排队场景,例如机场登机、餐厅的驾车点餐等。队列通常用于处理数据的顺序非常重要的情况:

  1. 操作系统的队列管理:操作系统使用队列来排队处理消息、输入输出请求等,以确保按照到达顺序执行。
  2. 服务器的请求管理:服务器通过队列管理多个请求,按顺序处理,以避免拥堵。
  3. 游戏中的输入队列:在一些游戏中,例如格斗游戏,用户的按键输入会按顺序排队,以识别连击操作。

队列的特有操作:


栈和队列在 C# 中的实现

接下来,我们将介绍如何在 C# 中实现并使用栈和队列。栈和队列在 C# 中都有内置的类来支持这些数据结构的操作。

栈的实现

C# 中的 Stack 类支持基本的栈操作,如 PushPopPeek。可以如下创建和操作栈:

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 类支持队列操作,如 EnqueueDequeuePeek。可以如下创建和操作队列:

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());
        }
    }
}

总结

栈和队列是数据管理中非常有用的结构,掌握它们可以帮助我们更有效地组织和处理数据。在下一节课中,我们将进一步探讨如何在实际编程中灵活使用这些数据结构。

118 - C#中的堆栈

栈(Stack)使用教程

在本视频中,我们将学习如何在 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() + " ");
}

运行结果将显示原数组和反转后的数组。栈在这里的应用展示了其 “后进先出” 特性,非常适合这种需要倒序的场景。


总结

栈在需要倒序处理数据的场景中非常有用,例如浏览器的回退按钮、撤销操作等。在下一节中,我们将探讨另一个常见的数据结构:队列

119 - 队列

队列(Queue)使用教程

在本视频中,我们将学习如何在 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();
}

当我们运行代码时,程序将按订单添加的顺序输出每个订单的处理信息。


总结

队列在需要按顺序处理数据的场景中非常有用,例如订单处理、请求排队等。在下一节视频中,您将更深入了解如何在实际应用中使用队列。

120 - 数组总结

总结与展望

到这里,我们已经完成了关于数组和列表的章节。当然,在整个课程中我们还会不断使用数组和列表,所以不用担心,这些内容会在不同的上下文中反复出现,您将更深入地理解它们在编程中的实际应用。

到目前为止,您已经学会了如何使用数组,了解了数组的不同类型。虽然有些类型的数组我们可能不会频繁使用,但了解它们的存在和用法仍然是很有价值的。有时仅仅知道一种工具的存在,就能在面对问题时找到更多的解决方法。即使记不住所有细节,也可以在未来的需求中回顾之前的内容,复习相关知识。

我们还学习了列表的概念和用法,并完成了一个比较大型的挑战练习——井字棋(Tic-Tac-Toe)。这个练习比我们之前的任务要困难许多,但这正是它的意义所在。您已经经历了基础的操作,这次练习让您在编写代码时必须更多地思考和运用已学知识。挑战中,您需要独立地构建逻辑,而不仅仅是按照指示编写代码。

如果您独立完成了这个练习,恭喜您!这是很了不起的成就。如果您还未完成,别担心,我们未来还有许多其他的练习和项目可以帮助您提升。您甚至可以在未来回到这个项目,重新尝试解决。如果您有其他类似的项目想法,也完全可以动手去实现。


非常感谢您一路学习到这里!接下来让我们进入新的章节,探索更多的编程知识。继续保持学习的热情,您一定会在编程旅程中不断进步。

WangShuXian6 commented 2 weeks ago

8 - 调试

122 - 调试简介

Debugging 入门

欢迎进入调试章节。在这一章节中,您将学习如何使用集成开发环境(IDE)进行调试。这是一项至关重要的技能,因为无论代码经验多么丰富,程序员的工作中总免不了要面对各种 bug 和错误。有时错误只是一个简单的空指针异常,但您找不到问题的根源;有时错误更复杂,代码看似正确,但却在运行时出现了意外的异常。这正是调试过程的意义所在,调试可以帮助您理清思路,找到问题,并最终修复代码中的 bug。

调试过程不仅仅是编程工作的组成部分,它还往往是一个非常关键的部分,因为几乎每一个程序员在编写代码时都会遇到不可预期的问题。熟练掌握调试工具将使您的开发过程更加顺利,也将帮助您更高效地解决问题。

本章节将涵盖的内容

在这一章节中,您将学习到:

通过学习这些调试技能,您将能够更轻松地发现和解决代码中的问题,使您的开发效率更高。

那么,让我们马上开始吧!

123 - 调试基础

调试入门及断点使用

在本视频及接下来的几个视频中,我们将学习如何在 Visual Studio 中使用调试功能,帮助您定位并修复代码中的 bug。调试的核心目的是消除程序中的 bug,而这些 bug 有时很容易找到,例如代码在运行时崩溃;但有时会出现逻辑错误,这种错误不会被 IDE 报告,通常只能由开发人员或用户在运行中发现。

为了演示调试功能,我创建了一个简单的工具:派对好友邀请工具。这个小工具用于挑选一部分名字较短的好友,以便邀请他们参加派对。在这个示例中,我们假设要邀请名字最短的三位朋友,工具将根据名字长度进行排序并选择。

示例代码说明

这段代码包括几个关键部分:

由于这是逻辑错误,代码不会报错,但会返回错误结果。例如,输出了 "Michelle" 和 "Angelina" 等较长名字的朋友,而不是最短的几个名字。这种错误在列表较短时容易发现,但在实际开发中,我们可能从外部数据源(如 XML 文件或网络数据)获取好友列表,这时调试工具就显得尤为重要。

断点和调试功能

为帮助理解调试过程,我们可以设置断点,通过单步调试分析代码运行的每一步,观察变量的状态和变化。以下是一些基本的调试操作:

1. 设置断点

在代码行左侧点击灰色条,或使用快捷键 F9,设置断点。断点将停止代码的运行,使您可以逐行查看代码的执行。

2. 开始调试

点击 调试 > 开始调试,或按 F5 开始调试程序。程序将在断点处暂停,显示当前代码的执行位置。

3. 单步执行

4. 查看变量值

将鼠标悬停在变量上可以查看当前值。Visual Studio 还提供了“监视窗口”(Watch Window),允许您同时监视多个变量的值,便于对比观察。

示例:调试和单步执行

在调试过程中,我们可以:

  1. 查看变量的当前值:例如观察 friendsparty_friends 列表的内容。
  2. 进入方法:进入 get_party_friends()get_party_friend(),观察各变量在循环和条件语句中的状态。

这样逐步观察变量的值,可以发现 shortest_name 变量与期望值的偏差,从而找到问题所在。

结论

通过调试和设置断点,我们可以轻松追踪代码的执行情况,并在每一行代码执行后查看变量的状态。这种方式非常适合查找和解决逻辑错误。接下来的视频中,我们将进一步深入调试功能,探索如何高效管理多个变量状态,使调试过程更加简便和直观。

124 - 局部变量与自动变量

自动和本地变量窗口,以及断点的高级使用

在本视频中,我们会更深入地了解 自动变量(Autos)本地变量(Locals) 窗口的使用,以及如何有效地管理和操作断点。我们会复习如何设置断点,同时进一步分析断点的使用和管理方法,这对于大型项目和复杂代码尤为重要。

断点的管理

在上一个视频中,我们在代码中添加了一个断点,以便在调试过程中暂停程序并观察变量状态。在这个示例中,我们的代码量较少,仅使用了几个断点。如果代码变得复杂,并有多个文件和许多断点,可以使用 断点窗口 来更好地管理和查看所有断点。

如何打开断点窗口

  1. 快捷键:按下 Ctrl + Alt + B
  2. 菜单导航:依次点击 调试 > 窗口 > 断点

断点窗口会显示所有断点的位置,例如 Program.cs 文件的第 10 行和第 21 行的断点。通过这个窗口,你可以启用或禁用断点,也可以删除不需要的断点。

启用或禁用断点

点击断点图标,可以直接移除断点。如果只想暂时停用断点,可以右键点击断点并选择“禁用断点”,此时断点标志会变成白色,表示已禁用,但仍然保留在代码中,可以随时重新启用。

自动变量(Autos)窗口

自动变量窗口 会自动显示当前断点附近的变量,因此不需要手动监视变量。自动变量窗口会显示最相关的变量,例如在主方法中的 args 变量和 friends 变量。

当我们使用 逐过程执行(Step Over) 时,窗口会更新,显示新生成或修改的变量。例如,当 friends 列表被赋值后,它就会在自动变量窗口中显示其元素和属性。这让我们能清楚地观察变量的当前值和数据结构。

本地变量(Locals)窗口

本地变量窗口 显示当前方法中的所有变量,也就是当前作用域中的变量。与自动变量窗口不同,自动变量窗口只显示关键变量,而本地变量窗口则显示所有局部变量。

本地变量的使用场景

  1. 调试当前作用域内的变量:如果当前断点位于 get_party_friends() 方法中,本地变量窗口将显示 listparty_friends 等方法内部的变量。
  2. 理解作用域:本地变量窗口只显示当前作用域的变量,因此如果尝试在其他方法中查看 friendsparty_friends,它们将不可见。

监视(Watch)窗口

监视窗口 允许你手动添加需要观察的变量。可以随时添加变量并观察它们在执行过程中的变化。以下是操作步骤:

  1. 打开监视窗口(调试 > 窗口 > 监视)。
  2. 在监视窗口中手动添加变量,例如 party_friends
  3. 监视窗口会持续显示该变量的值,即使你进入其他作用域或方法。

变量值的修改

在调试过程中,我们还可以手动修改变量的值以观察程序如何响应。例如,可以将 count 变量的值从 3 改为 5,然后运行代码查看不同值对程序的影响。通过这种方式,你可以测试不同的假设,从而帮助发现 bug 或逻辑错误。

示例:使用自动变量、本地变量和监视窗口调试

假设当前断点在 get_party_friends() 方法中:

  1. 通过 逐过程执行 查看方法内部的变量。
  2. 本地变量窗口 中观察 listparty_friends 的值。
  3. 使用 监视窗口 持续监视 party_friends 的元素和长度。

这样可以帮助我们发现一些潜在的问题。例如,friends 列表的值在不断减少,因为在 get_party_friends() 方法中我们错误地直接从列表中移除元素,从而导致最终剩余的朋友数量不正确。

总结

通过使用 自动变量本地变量监视窗口,我们能够在调试过程中更直观地观察和控制代码中的变量,逐步查找并修复错误。这种方法在处理大型项目和复杂逻辑时尤为有用。

在下一个视频中,我们将修复代码中的 bug,并进一步探讨如何避免类似的问题。

125 - 调试:创建列表副本并解决一些bug

使用调试工具修复逻辑错误

在这个视频中,我们将修复代码中的一个 bug,并展示如何使用调试工具帮助我们发现和解决问题。这个示例是一个较为简单的程序,但它仍然包含一些常见的逻辑错误。我们通过逐步分析代码中的每个方法,了解它们的作用,然后利用调试工具一步步找到问题的根源。

确认问题所在的方法

  1. 分析代码结构:首先,我们知道程序的逻辑在于判断一个朋友是否是“派对朋友”,因此我们可以将断点放在执行这部分逻辑的 GetPartyFriend 方法中。
  2. 理解代码意图:在大型项目中,理解每个方法的作用尤为重要。可以为方法添加注释,说明方法的功能。例如,我们可以在 GetPartyFriend 方法前添加注释,解释它的功能,如“此方法用于确定谁是派对朋友”。

设置断点并使用调试工具

  1. 在关键位置设置断点:可以在 GetPartyFriend 方法中设置断点,并启动调试模式来检查逻辑。
  2. 观察变量值:调试过程中,查看变量 shortestName 和其他相关变量的值,因为这些值决定了程序的逻辑结果。例如,代码中有一个比较语句 if (list[i].Length > shortestName.Length),如果我们期望得到名字最短的朋友,这个判断条件可能需要反转。
  3. 修改错误的条件:我们通过调试工具观察到错误的比较条件,将 > 改为 <,从而正确地选择名字较短的朋友。

解决其他潜在问题

  1. 手动输入列表带来的问题:当前的朋友列表是手动输入的,如果尝试邀请多于列表中现有朋友数量的朋友(例如邀请 10 个朋友),程序会报错 ArgumentOutOfRangeException,因为试图访问超出列表范围的元素。
  2. 分析数据的变化:通过断点和调试工具,我们可以观察到列表的元素逐步被删除,从而导致列表在某些情况下为空。出现这个问题的原因是我们直接对传入的 friends 列表进行了删除操作,而不是操作副本。

修复方法:使用列表副本

  1. 创建列表副本:为了避免删除原始列表中的元素,我们创建一个 buffer 副本列表。这可以确保原始 friends 列表在其他地方使用时不受影响。
  2. 修改引用:将 list.Remove(...) 改为 buffer.Remove(...),以便删除的是 buffer 中的元素,而不是原始列表中的元素。
  3. 测试并验证:去除断点并运行程序,确保程序能够正确地筛选出符合条件的朋友,并且不会影响原始的 friends 列表。

进一步修复:防止越界错误

  1. 处理越界异常:在调用 GetPartyFriends 方法时,如果我们指定的邀请人数超过列表中的朋友数量,将导致程序崩溃。可以在代码中添加逻辑,确保不尝试访问列表之外的元素。
  2. 下一步:在下一个视频中,我们将修复这个越界问题并处理可能导致崩溃的其他问题。

总结

通过本视频的内容,我们学习了如何使用调试工具定位和解决代码中的逻辑错误,并如何在程序中合理使用副本来保护原始数据。调试和防止越界错误都是编写健壮代码的重要步骤。在下一节视频中,我们将继续优化代码并确保程序能够在各种情况下正常运行。

126 - 调试:调用栈抛出错误与防御性编程

解决程序中的问题并理解防御式编程

在本视频中,我们将最终解决之前程序中的问题,并讨论防御式编程的概念。防御式编程旨在编写更加健壮和安全的代码,以提前预测并防止潜在问题的发生。特别是在我们不确定数据来源或数据格式时,例如数据可能来自数据库或网络,这种编程方式显得尤为重要。通过提前验证数据的完整性,可以有效避免运行时异常,确保代码更稳定。


识别并捕获潜在的错误

  1. 清空列表或邀请超出数量:在我们的示例中,朋友列表的数量少于请求的邀请数量。通过运行代码并观察会触发什么样的异常,我们可以在适当位置设置断点来跟踪异常。
  2. 检查变量值:当异常出现时,调试工具会帮助我们识别导致错误的变量。我们发现当列表为空时尝试访问列表的第一个元素会抛出异常。
  3. 在更早的阶段捕获异常:我们可以在 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)分析错误

调试过程中,我们可以使用调用堆栈(Call Stack)窗口来跟踪方法调用顺序:

  1. 查看调用链:在调用堆栈窗口中,我们可以看到程序的执行流程,包括从主方法到每个嵌套方法的调用顺序。这可以帮助我们找到错误的起始位置。
  2. 回溯错误源:当异常被捕获时,调用堆栈会指向当前方法,并展示其上层调用链。通过查看调用链,我们可以快速定位是哪个方法触发了异常,以及在什么情况下被调用。

防御式编程的重要性

防御式编程的核心在于提前预见潜在问题,确保程序在遇到非预期输入或数据时能够安全退出,而不会导致系统崩溃或产生不可预期的行为。通过这种方式,程序在各种边界条件下都会更具稳定性,特别是在与外部数据源(如数据库、网络请求等)交互时。


总结与反馈

在本视频中,我们学习了如何通过有效使用调试工具和防御式编程来使代码更具鲁棒性和稳定性。这些技巧不仅适用于小型示例程序,更适用于大型项目中的复杂逻辑。在调试和编码过程中,如果您有任何问题,欢迎提供反馈,因为这将帮助改进教程内容并提升其质量。

继续保持良好的编程习惯,并在下一节视频中学习更多 C# 编程技巧。

WangShuXian6 commented 2 weeks ago

9 - 继承与更多关于OOP

127 - 欢迎来到继承

继承(Inheritance)简介

在本章中,我们将学习继承的概念。继承是一种面向对象编程的核心机制,它允许一个类(子类)从另一个类(父类或基类)继承属性和方法。通过继承,子类可以直接获得父类中的功能,同时也可以进行扩展和定制。这种机制使得代码复用变得更加高效,减少了重复代码,提高了代码的可维护性。


继承的概念

继承可以类比为我们在人类基因中遗传的特性——我们会从父母那里继承一些基因,甚至是行为特征。类似地,继承指的是一个类从另一个类继承功能和属性。父类将自身的属性和方法传递给子类,子类则可以在继承这些功能的基础上进行扩展或重写。

继承通常用于以下几个目的:


基类和派生类

在继承中,有以下几个重要的概念:

在代码中,继承的语法通常如下:

class ParentClass
{
    public void ShowMessage()
    {
        Console.WriteLine("Hello from Parent Class");
    }
}

class ChildClass : ParentClass
{
    // ChildClass inherits from ParentClass
}

在这个例子中,ChildClass继承了ParentClass。这意味着ChildClass可以直接调用ParentClass中的ShowMessage方法,而无需再次定义该方法。


继承的优势

继承的主要优势包括:

  1. 代码复用:父类中的代码可以直接在子类中复用,这样可以避免重复代码。
  2. 代码组织更清晰:通过将通用功能放在基类中,并将特定功能放在派生类中,代码结构更加清晰,便于维护。
  3. 支持多态性(Polymorphism):继承使得多态性成为可能,父类引用可以指向子类对象,这使得代码在处理不同子类对象时具有一致性。

接口(Interface)

在继承章节中,我们还将了解接口。接口定义了一组不包含实现的功能,这些功能将由实现接口的类来提供具体实现。接口通常用于确保类具备某些特定功能,而不关心其具体实现细节。

接口的使用通常如下:

interface IPrintable
{
    void Print();
}

class Document : IPrintable
{
    public void Print()
    {
        Console.WriteLine("Printing document...");
    }
}

在这个例子中,IPrintable接口定义了一个Print方法。实现了IPrintable接口的Document类必须提供Print方法的具体实现。


挑战和练习

在本章学习过程中,我们将设置两个挑战任务,帮助您巩固所学内容。通过这些挑战,您将实践如何定义和使用继承、基类、派生类和接口。


希望您在接下来的视频中学到关于继承的更多内容并享受学习的过程!

128 - 继承介绍

继承的概述

欢迎回来!在本视频中,我们将继续深入学习继承,并且先了解其定义和用途。在后续视频中,我们将通过演示进一步探讨其实际应用。


继承的定义

继承允许我们基于已有的类来定义新类,这使得应用的创建和维护变得更加简单。继承提供了代码复用的机会,加快了实现速度,因为:

  1. 复用代码:我们不需要重复编写相同的代码或手动复制粘贴,而是可以直接从已有类继承功能并进行扩展。
  2. 简化维护:继承使代码结构更加清晰,可维护性大大增强,特别是在应用规模较大时。

此外,继承不仅仅可以用于我们自己创建的类,还可以对导入的库中的类进行继承,从而更广泛地复用功能。


继承的示例

示例 1:汽车类

假设我们有一个Car(汽车)基类,它具有通用的属性和方法:

基于这个Car类,我们可以派生出具体的汽车类型,如RaceCar(赛车)和StreetCar(街车):

通过继承,每种汽车类型可以共用通用的汽车属性和方法,同时根据特性添加新的属性和方法。


示例 2:员工类

在企业中,我们可以定义一个Employee(员工)基类:

基于Employee类,可以派生出不同类型的员工,如Designer(设计师)和Engineer(工程师):

尽管设计(Design)方法在DesignerEngineer类中都有定义,但它们的实现方式可以不同,并且这种方法对于每个员工都不是必须的。只有设计师和工程师需要这个方法,但普通员工(Employee)类本身不需要设计方法。


总结与下一步

这些示例展示了继承的基本概念和应用方式。接下来,我们将在演示中进一步深入了解继承的实际应用,探讨基类、派生类的实现,以及如何利用继承来构建灵活的程序架构。

让我们在下一个视频中开始演示!

129 - 简单的继承示例

C# 继承基础讲解

欢迎回来!在本视频中,我们将学习 C# 中的继承,并使用简单的例子来介绍其概念。继承是编程中的一个重要概念,为我们提供了代码复用的强大功能。通过继承,我们可以减少重复代码,实现更加高效的开发。我们会尽量保持内容简洁明了,以帮助你更好地理解。


继承的定义

继承是面向对象编程(OOP)的核心概念之一,它允许我们定义一个类(子类),以重用、扩展或修改另一个类(父类)的行为。具体来说:

为什么使用继承?

如果我们有多个类具有相似的代码或功能,但在细节上略有不同,我们可以将这些重复的代码提取到一个单独的类中,然后让各个类继承它。这可以是方法、功能或属性,从而让代码更加简洁,便于维护。


继承示例:创建电器设备类

假设我们要实现一个电器设备管理系统,并开始有两个类 Radio(收音机) 和 TV(电视) ,每个类中都包含一些属性和方法。比如:

  1. Radio 类:表示收音机,包含表示开关状态的布尔属性 isOn,表示品牌的 brand,以及方法 SwitchOnSwitchOffListenRadio
  2. TV 类:表示电视机,也包含 isOnbrand 属性,以及类似的 SwitchOnSwitchOff 方法。

这两个类中包含了许多相同的属性和方法,比如开关状态和品牌属性、打开和关闭方法等。


利用继承优化代码

要避免重复代码,我们可以创建一个新的基类,比如 ElectricalDevice(电器设备),将通用属性和方法放入这个基类中。

创建基类 ElectricalDevice

  1. 定义通用属性isOnbrand,表示设备状态和品牌。
  2. 实现通用方法SwitchOnSwitchOff 方法,用来控制设备的开关状态。
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 基类

接下来,让 RadioTV 类继承自 ElectricalDevice,从而共享通用属性和方法。

  1. 使用冒号 : 表示继承关系。
  2. 移除 RadioTV 中的重复代码,只保留它们独特的功能。
  3. 使用基类构造函数来初始化通用属性。

Radio 类实现继承

以下是 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,因此可以直接使用 SwitchOnSwitchOff 方法,以及 isOnbrand 属性。此外,我们在 ListenRadio 方法中检查 isOn 属性的状态,以确定是否可以收听收音机。

TV 类实现继承

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,并可以使用基类的属性和方法。


测试继承

在主程序中创建 RadioTV 对象,并测试它们的功能。

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();
    }
}

执行后,你会看到输出内容表示收音机和电视分别被打开,并可以使用各自的独特方法 ListenRadioWatchTV


总结

通过继承,我们可以避免重复代码,提高代码的可维护性。继承使我们能够在基类中定义通用功能和属性,然后在派生类中实现特定功能。这种方式不仅使得代码更简洁,也便于后续扩展和维护。

130 - 虚拟与重写关键字

C# 继承深入探讨:virtualoverride 关键字

欢迎回来!在本视频中,我们将深入探讨 C# 中的继承,重点讲解 virtualoverride 关键字的使用。理解这些概念对掌握面向对象编程中的继承功能至关重要。为了更好地演示继承的细节,我们会实现一个动物类 Animal,并从中继承出具体的动物类(例如 Dog),以便展示如何通过重写方法来定制子类的行为。


virtualoverride 关键字概述

通过这两个关键字,我们可以确保基类的代码既可以被子类复用,也可以被子类自定义或改写。


例子概述:实现动物类和狗类

在这个例子中,我们会创建一个基本的 Animal 类,赋予它一些通用的属性和方法,比如:

我们会在 Animal 类中将这些方法定义为虚方法(即 virtual 方法),从而允许子类(例如 Dog)根据自身特性对这些方法进行自定义,实现特有的行为。


步骤一:创建基类 Animal

1. 定义属性和构造函数

首先,我们创建 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; // 默认情况下动物是饥饿的
    }
}

2. 定义虚方法

我们会定义一些虚方法,以便子类可以选择性地进行重写。

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 方法中,我们使用了空的实现,允许子类完全自定义这个方法。而 EatPlay 方法具有一些默认行为,可以在子类中选择性地重写或保持不变。


步骤二:创建派生类 Dog

1. 定义 Dog 类并继承 Animal

public class Dog : Animal
{
    public bool IsHappy { get; set; }

    public Dog(string name, int age) : base(name, age)
    {
        IsHappy = true; // 默认狗是开心的
    }
}

我们使用 base 关键字来调用 Animal 类的构造函数,以初始化 NameAge 属性。此外,我们为狗类添加了一个新的属性 IsHappy

2. 重写方法

Dog 类中,我们可以选择性地重写 Animal 类中的方法,例如 EatMakeSound

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.");
    }
}

步骤三:测试继承和重写的效果

在主程序 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 类的通用特性。


总结

通过本节内容,我们了解到:

  1. virtual 关键字:用于定义可被子类重写的方法和属性。
  2. override 关键字:子类用它来重写父类的虚方法。
  3. base 关键字:子类调用父类方法时使用。

继承和多态性(通过 virtualoverride)是面向对象编程的重要组成部分,使我们能够创建结构清晰、扩展性强的代码。在之后的视频中,我们将继续深入探讨 C# 继承的更多特性。

131 - 继承演示

C# 继承与 virtualoverride 的应用:实现社交媒体 Post

欢迎回来!本视频将继续讲解 C# 中的继承,并通过创建一个 Post 类和它的派生类 ImagePost 来展示继承的应用。我们会以社交媒体的场景为例,比如推文、Facebook 帖子等,并创建一个基础类 Post,然后扩展出 ImagePost 和其他类型的帖子。这将帮助我们更深入理解继承的概念和优势。


目标

  1. 创建一个 Post 基类,其中包含所有帖子通用的属性和方法。
  2. 使用 ImagePost 类继承自 Post 类,展示如何通过继承来扩展功能。
  3. 通过实现 virtualoverride 方法,进一步理解如何自定义和覆盖继承的方法。

步骤一:创建基类 Post

1. 定义属性

一个社交媒体帖子通常包含以下属性:

我们使用 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; }
}

2. 构造函数

为了给帖子分配唯一 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;
}

3. 生成唯一 ID 的方法

GetNextID() 方法用于生成唯一的帖子 ID。

protected int GetNextID()
{
    return ++currentPostID;
}

4. 更新帖子的方法

创建 Update 方法允许更新帖子标题和公开状态。

public void Update(string title, bool isPublic)
{
    Title = title;
    IsPublic = isPublic;
}

5. 重写 ToString 方法

我们覆盖了 System.Object 中的 ToString() 方法,以便可以格式化输出帖子的详细信息。

public override string ToString()
{
    return string.Format("{0} - {1} by {2}", ID, Title, SentByUsername);
}

步骤二:创建派生类 ImagePost

ImagePost 类继承自 Post 类,添加一个特有属性 ImageURL,用于存储图片链接。

1. 定义属性和构造函数

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;
    }
}

2. 重写 ToString 方法

我们重写 ToString() 方法,以显示 ImagePost 的图片链接。

public override string ToString()
{
    return string.Format("{0} - {1} by {2}, Image: {3}", ID, Title, SentByUsername, ImageURL);
}

步骤三:在主程序中测试 PostImagePost

Program.cs 中创建 PostImagePost 的实例,测试它们的功能。

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

总结

通过这个例子,我们学习了:

  1. 继承ImagePost 类继承自 Post 类,获得了 Post 类的所有功能。
  2. 虚方法和覆盖ToString 方法通过 override 被重写,从而为 ImagePost 提供了更详细的输出。
  3. protected 访问修饰符:使属性和方法在派生类中可访问,但在外部类中不可访问。

在下一节,我们将继续深入探索继承的更多高级功能,以及如何应用到其他场景中。

132 - 继承挑战:视频发布与带回调的计时器

继承挑战:实现 VideoPost 类及计时功能

欢迎回来!本视频我们将继续深入探讨继承的概念。这次的挑战是创建一个新的派生类 VideoPost,并实现其特有的属性和方法,比如 VideoURL 和视频长度 Length。我们还将添加播放和停止视频的功能,进一步理解定时器(Timer)和回调(Callback)在 C# 中的应用。


任务概述

  1. 创建 VideoPost 类,继承自 Post 基类,增加视频特有的属性。
  2. VideoPost 创建构造函数。
  3. 覆盖 ToString 方法,以展示视频相关信息。
  4. 实现一个高级功能:通过计时器显示视频播放进度,并在用户按键时停止视频。

任务 1:创建 VideoPost

1.1 定义 VideoPost 类的特有属性

VideoPost 类继承自 Post 类,并且增加了两个新属性:

public class VideoPost : Post
{
    protected string VideoURL { get; set; }
    protected int Length { get; set; }
}

1.2 创建构造函数

VideoPost 类中,添加一个带有 titlesentByUsernamevideoURLisPubliclength 参数的构造函数。

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;
}

任务 2:覆盖 ToString 方法

覆盖 ToString 方法,以便在控制台输出中显示视频链接和长度信息。

public override string ToString()
{
    return string.Format("{0} - {1} by {2}, Video: {3}, Length: {4}s", ID, Title, SentByUsername, VideoURL, Length);
}

任务 3:实现 PlayStop 方法并添加计时功能

VideoPost 类中创建 PlayStop 方法,使用 Timer 类每秒更新视频播放进度。

3.1 定义字段和 Timer 对象

我们将定义以下字段:

protected bool isPlaying;
protected int curDuration;
private Timer timer;

3.2 创建 Play 方法

Play 方法初始化 Timer,并每秒触发一次回调方法 TimerCallback,用于更新播放进度。

public void Play()
{
    if (!isPlaying)
    {
        isPlaying = true;
        Console.WriteLine("Playing video...");
        timer = new Timer(TimerCallback, null, 0, 1000);
    }
}

3.3 实现 TimerCallback 方法

TimerCallback 方法用于更新播放进度,并在达到视频长度时自动停止。

private void TimerCallback(Object o)
{
    if (curDuration < Length)
    {
        curDuration++;
        Console.WriteLine("Video at {0}s", curDuration);
        GC.Collect(); // 强制垃圾回收
    }
    else
    {
        Stop();
    }
}

3.4 创建 Stop 方法

Stop 方法停止计时器并重置播放状态。

public void Stop()
{
    if (isPlaying)
    {
        isPlaying = false;
        Console.WriteLine("Stopped at {0}s", curDuration);
        curDuration = 0;
        timer.Dispose();
    }
}

任务 4:在 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

总结

在本次挑战中,我们学习了:

  1. 继承和覆盖方法:通过 VideoPost 类继承 Post 类,并使用 override 关键字覆盖 ToString 方法。
  2. 定时器和回调:使用 Timer 类创建视频播放进度更新器。
  3. 控制播放状态:使用布尔字段控制视频播放和停止逻辑。

在下一个视频中,我们将继续探讨继承和面向对象编程的其他高级概念。继续加油!

134 - 继承挑战2:员工、老板与实习生的解决方案

继承挑战:实现 EmployeeBossTrainee

欢迎回来!希望您已完成此挑战并尝试创建多个派生类。本次挑战中,我们将实现一个员工系统,其中包含一个 Employee 基类和两个派生类 BossTrainee。通过这个示例,您将更深入地理解类继承及如何扩展和覆盖基类的方法和属性。


任务概述

  1. 创建 Employee 基类,并定义基本属性和方法。
  2. 创建 Boss 派生类,为老板定义特有属性和方法。
  3. 创建 Trainee 派生类,为实习生定义特有属性和方法。
  4. 通过多态性(Polymorphism)预览覆盖和隐藏基类方法。

任务 1:创建 Employee 基类

1.1 定义基本属性

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.");
    }
}

任务 2:创建 Boss 派生类

Boss 类派生自 Employee,并添加一个新属性 CompanyCar 和一个 Lead 方法。

2.1 创建 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);
    }
}

任务 3:创建 Trainee 派生类

Trainee 类派生自 Employee,添加 WorkingHoursSchoolHours 属性,并覆盖 Work 方法。

3.1 创建 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);
    }
}

任务 4:在 Program.cs 中测试类的功能

通过在 Program.cs 中创建 EmployeeBossTrainee 的实例来测试类的功能。我们将展示如何使用继承的属性和方法,以及如何调用子类的特有方法。

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.

关键知识点

  1. 继承BossTrainee 类继承自 Employee 类,从而获得其属性和方法。
  2. 构造函数和基类调用:使用 base 关键字调用基类构造函数来初始化继承的属性。
  3. 方法覆盖与隐藏Trainee 中使用 new 关键字隐藏 EmployeeWork 方法。隐藏和重写(override)方法的细节将在下一章多态性中进一步探讨。

总结

在本次挑战中,我们学习了:

在下一章中,我们将更深入探讨多态性,通过 virtualoverride 等关键字来灵活控制类的行为,继续加油!

135 - 接口简介

接口简介与实现

欢迎回来!在这段视频中,我们将深入了解接口,并学习如何在 C# 中使用接口来扩展面向对象编程的功能。


什么是接口?

接口可以被理解为一种“契约”。实现某个接口的类必须提供该接口所定义的所有方法和属性的实现。接口只定义需要实现的方法和属性的签名,而不包含实际的实现逻辑。这为代码提供了一种标准,允许类在实现中有灵活性,但必须满足接口的要求。通常,接口的命名以 I 开头,例如 IEquatable,这帮助我们快速识别哪些是接口。

接口的关键特点:

代码示例:实现 IEquatable 接口

IEquatable 是一个用于比较对象的接口。在接下来的代码中,我们将实现一个类 Ticket,它包含一个 durationInHours 属性。我们将通过 IEquatable 接口使得该类可以基于 durationInHours 属性来比较两个 Ticket 对象。


代码实现步骤

1. 创建 Ticket

  1. 新建一个 Ticket 类。
  2. 定义一个 durationInHours 属性和构造函数。
public class Ticket
{
    public int DurationInHours { get; set; }

    public Ticket(int durationInHours)
    {
        this.DurationInHours = durationInHours;
    }
}

2. 实现 IEquatable<Ticket> 接口

  1. Ticket 类中使用冒号 : 实现 IEquatable<Ticket> 接口。
  2. Visual Studio 会提示 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;
    }
}

3. 测试 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();
    }
}

在这里:

  1. 我们创建了两个 Ticket 实例 ticket1ticket2,并将 durationInHours 属性设为 10。
  2. 使用 Equals 方法比较两个对象,输出为 True,因为 durationInHours 属性相等。
  3. 更改 ticket2durationInHours 属性为 6 后,再次比较,输出为 False

解释


总结

本视频中,我们学习了:

  1. 接口的概念及其在面向对象编程中的重要性。
  2. 使用 IEquatable<T> 接口在 C# 中实现自定义的对象比较方法。
  3. 接口允许我们定义比较标准,避免对象引用比较的局限性。

在下一视频中,我们将学习如何创建自定义接口,进一步探索接口在编程中的应用。

136 - 创建并使用自己的接口

创建和实现自定义接口的示例

欢迎回来!在本节课程中,您将学习如何创建并实现自己的接口。我们将通过一个具体的游戏示例来理解接口的使用,尤其是在类之间没有直接关系,无法共享基类的情况下,接口的重要性就显得尤为突出。

场景示例

假设我们在开发一款游戏,玩家可以破坏不同的物体,而每个物体在被破坏时会有不同的效果。例如:

我们有以下类:

这些类没有直接关系(车和椅子没有继承关系)。因此,我们无法通过继承共享一个基类来实现破坏功能。

类定义

我们先看一下 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 类类似,也有自己的属性 SpeedColor,并继承自 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";
    }
}

破坏功能的需求

为了实现破坏功能,我们不能让 CarChair 类继承同一个破坏基类,因为 C# 是单继承语言,我们已经让 Car 继承了 VehicleChair 继承了 Furniture。此外,车和椅子之间也没有直接关系。

接口:解决破坏功能的问题

最好的方法是使用接口。我们可以创建一个 IDestroyable 接口,让所有可破坏的类都实现它,这样就可以强制这些类遵循破坏的要求。每个类可以根据自己的需要实现接口中的方法和属性。

创建 IDestroyable 接口

  1. 添加一个新接口,命名为 IDestroyable,命名以 I 开头以便于识别。
  2. 接口包含一个用于存储破坏声音的属性 DestructionSound,以及一个用于执行破坏的 Destroy 方法。
public interface IDestroyable
{
    string DestructionSound { get; set; }
    void Destroy();
}

让类实现 IDestroyable 接口

1. Car 类实现 IDestroyable

  1. Car 类定义中添加 IDestroyable 接口。
  2. DestructionSound 属性分配一个爆炸音频文件。
  3. 编写 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 方法。

2. 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 中,我们创建 CarChair 实例,模拟破坏效果。

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...

小结

通过创建并实现接口,我们可以在不同的类中实现相同的方法和属性,而无需使用多重继承。这一方式提供了以下优点:

  1. 代码可读性:接口让代码结构清晰明了。
  2. 代码语义清晰:接口定义了行为模型,明确了类可以执行的操作。
  3. 代码维护性:接口减少了耦合性,便于替换实现。
  4. 设计模式支持:接口为面向对象编程中许多设计模式提供了支持。
  5. 多重继承的替代方案:接口可以作为实现多重继承的替代方式。

希望通过本课程的内容,您对接口的概念和使用有了更深入的理解。感谢观看,我们下节课再见!

137 - IEnumerator与IEnumerable

AIEnumerable 接口和 AIEnumerator 接口

在本节视频中,我们将深入学习一些关键的接口,这些接口对于希望成为高级C#开发人员的您非常重要。尤其是 AIEnumerable 接口和 AIEnumerator 接口。这些接口通常用于 C# 的集合,使我们能够迭代遍历集合中的元素。

AIEnumerable 接口概述

AIEnumerable 是 C# 中许多集合的基础接口,旨在提供一种迭代集合的方式。这也是我们可以使用 foreach 循环遍历列表(List)或字典(Dictionary)的原因,因为它们都实现了 AIEnumerable 接口。

简单来说,当一个集合类实现了 AIEnumerable 接口后,它就变得“可计数”,允许我们逐个访问集合中的每个元素。AIEnumerable 接口有两个版本:

  1. 泛型版本(AIEnumerable<T>),适用于特定类型的集合。
  2. 非泛型版本(AIEnumerable),适用于非特定类型的集合。

通常建议使用泛型版本,因为它的效率更高,而非泛型版本可能需要进行装箱和拆箱(boxing 和 unboxing),这会影响性能。

AIEnumerable 和 AIEnumerator 的区别

接下来,我们将通过一个具体示例来演示 AIEnumerableAIEnumerator 的用法。


示例:创建可迭代的 Dog 类集合

假设我们创建一个 Dog 类,并且我们要根据狗是否听话来奖励它不同数量的零食。然后,我们创建一个 DogShelter 类,该类包含一个 Dog 列表,我们希望能够迭代遍历这个列表。

1. 创建 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 的值输出狗叫声。

2. 创建 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 类中,我们:

3. 使用 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 的概念及用法。在后续视频中,我们将进一步探讨它们在更复杂场景中的应用。

138 - IEnumerable示例1

使用 AIEnumerable 接口的实际应用

在上一节中,我们从技术角度了解了 AIEnumerable 的工作原理。在这一节中,我们将探讨如何利用 AIEnumerable 接口提供的灵活性。因为在 C# 中,像 ListQueueArray 等集合类型都实现了 AIEnumerable 接口,因此它给了我们很大的自由度。

示例:创建一个可以返回不同类型集合的方法

我们将创建一个名为 GetCollection 的方法。此方法根据不同的选项返回不同类型的集合(ListQueueArray)。这是 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();
    }
}

代码解释

  1. GetCollection 方法:此方法接受一个整数参数 option,根据不同的选项返回不同类型的集合。

    • option == 1 返回 List<int>
    • option == 2 返回 Queue<int>
    • 其他情况返回 int[] 数组。
  2. DisplayCollection 方法:此方法接收一个 IEnumerable<int> 类型的集合参数。通过 foreach 循环迭代集合中的元素,并将其打印到控制台。

  3. 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 接口,我们能够:

总结

使用 AIEnumerable 提供了极大的灵活性,可以让我们在代码中使用不同类型的集合,而无需对集合的具体实现类型进行关心。这使代码更加通用和可复用。在下一节视频中,我们将探讨如何将 AIEnumerable 集合作为方法参数传递,以进一步扩展其应用。

139 - IEnumerable示例2

使用 IEnumerable 作为方法参数的示例

在本节视频中,我们将再次探索 IEnumerable,并通过一个新的示例展示如何将 IEnumerable 集合作为方法参数传递。通过这种方式,我们可以在方法中灵活处理多种类型的集合,就像在面向对象编程中可以将子类实例传递给需要父类的参数一样。

创建一个接收 IEnumerable 集合的方法

我们首先创建一个方法 CollectionSum,它将接受一个 IEnumerable<int> 类型的集合,并计算集合中所有数字的总和。这个方法的特点是,它接受任何实现了 IEnumerable 接口的集合类型,这样我们就能传入 ListArray 等多种集合类型。

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);
    }
}

代码解释

  1. CollectionSum 方法:此方法接收一个 IEnumerable<int> 类型的集合参数 anyCollection

    • 我们定义了一个 sum 变量,用于存储集合中所有元素的总和。
    • 通过 foreach 循环遍历 anyCollection,将每个元素的值加到 sum 中。
    • 最后将总和输出到控制台。
  2. Main 方法:在主方法中,我们创建了一个 List<int> 和一个 int[] 数组。

    • 使用 CollectionSum 方法分别传入 numberListnumberArray,验证该方法可以处理不同类型的集合。

运行结果

当运行程序时,输出结果如下:

Sum of numberList:
Sum is 16

Sum of numberArray:
Sum is 12

关键点

通过将 IEnumerable 作为参数传递,我们实现了以下灵活性:

总结

使用 IEnumerable 接口可以让方法具有更强的灵活性和扩展性,尤其是在处理各种集合时。无论是 ListArray 还是其他实现 IEnumerable 的集合类型,都可以直接传入 CollectionSum 方法。这种通用性的实现使代码更简洁、可维护性更高。

140 - 继承结束语

继承章节总结与展望

恭喜完成继承章节的学习!需要说明的是,这并不是继承知识的终点,未来的课程中我们还会进一步深入探讨继承的更多内容。继承是比我们之前讨论的主题更复杂的概念,因为它涉及面向对象编程(OOP)的核心思想之一。

在这一章节中,我们回到了面向对象编程的基础,深入学习了继承这一核心概念。面向对象编程是基于几个核心概念构建的,而继承正是其中重要的一环。继承允许我们在编程中实现代码的复用和扩展,创建更具组织性的代码结构。

通过本章节的学习,你已经掌握了继承的基础概念。接下来,我们将进入另一个重要的面向对象编程核心概念——多态性(Polymorphism)。多态性是继承的延伸,它允许我们在继承的基础上,通过不同方式实现不同的行为。

展望下一个章节:多态性

在下一章节中,我们将深入探讨多态性,它是面向对象编程中非常重要的特性。通过多态性,我们可以在父类和子类之间实现更灵活的操作,让程序在处理对象时表现出不同的行为。这个概念在现实应用中非常强大,能够使代码更加模块化和易于维护。

那么,让我们一起进入下一章节,开始学习多态性吧!

WangShuXian6 commented 2 weeks ago

10 - 多态与更多OOP文本文件

141 - 多态简介

多态性:面向对象编程的第三个核心支柱

在本章节中,你将学习面向对象编程的第三个也是最后一个核心概念——多态性(Polymorphism)。多态性在继承的基础上进一步扩展了对象的使用方式,使代码更加灵活和高效。以下是本章节的学习要点:

了解多态性的重要性

多态性是使继承真正灵活并广泛应用的关键概念。它使我们能够以统一的方式对不同类型的对象进行操作,而无需了解每个对象的具体类型。例如,在父类中定义的一个方法可以在多个子类中被重写,每个子类都可以提供自己的实现。这使得程序能够根据实际对象的类型来动态地调用合适的方法实现。

通过学习多态性,你将学会如何在代码中实现更高的抽象,并让你的程序具备良好的可扩展性和可维护性。你会发现,使用多态性不仅能够减少代码的重复,还能让代码结构更清晰,功能更强大。

本章目标

  1. 理解多态性的基本概念及其在面向对象编程中的作用。
  2. 掌握如何通过 virtualoverride 实现方法的多态性。
  3. 学会识别 is-ahas-a 关系,并用其优化类设计。
  4. 掌握基础的文件输入与输出操作,学会从文本文件读取和向文件写入数据。

开始学习

让我们开始这个关于多态性的章节!在接下来的内容中,我们将逐步实现这些概念,并通过实际的代码示例,帮助你更好地理解和运用多态性。准备好迎接面向对象编程的更高层次吧!

142 - 多态参数

多态性示例及新关键词的应用

在本视频中,我们通过一个多态性示例来深入了解多态的实现,同时还会讲解一些重要的关键词,如 newoverride。下面是该示例的逐步实现及其背后的概念:

创建一个通用的 Car 类

首先,我们创建一个 Car 类作为基类,这个类包括以下几个内容:

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 类

接下来,创建 BMWAudi 类,这两个类分别继承自 Car 类,并添加自己特有的属性和方法。

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.");
    }
}
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 关键词

多态性允许我们使用基类引用来操作派生类对象。以下示例展示了多态性的不同用法:

  1. 创建基类类型的列表并存储派生类对象

    • 我们可以在 List<Car> 中存储 BMWAudi 的对象,因为它们都继承自 Car
  2. 在循环中调用多态方法

    • 使用 foreach 遍历 cars 列表,并调用每个对象的 Repair() 方法。通过 virtualoverride,可以让不同的子类实现自己的 Repair() 方法,显示特定的信息。
  3. 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()
    }
}

总结:多态性与 newvirtualoverride

以上就是多态性及其相关关键词的基本应用。

143 - 密封关键字

使用 sealed 关键字——方法与类的封闭

在本视频中,我们会探讨 sealed 关键字的用法。sealed 关键字可以防止类或方法被进一步继承或重写。具体来说,当我们想要停止对某个方法的重写,或者不允许某个类被继承时,就可以使用 sealed 关键字。

1. 基本示例——防止方法被进一步重写

让我们从一个简单的类层次结构开始,构建以下类:

我们在 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,并没有阻止进一步的重写。

2. 使用 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 类中被重写。

3. 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 关键字在保护类结构和防止意外重写或继承时非常有用,尤其是在大型代码库中,可以增强代码的稳定性。

144 - 关联关系

Has-A 关系简介

在本视频中,我们将探讨 "Has-A" 关系,它不同于继承中的 "Is-A" 关系。在前面的例子中,我们看到 BMW 是一种汽车 (Car),也就是说,BMW 继承自 Car 类,因此是一种 "Is-A" 关系。例如,BMW 是一种 CarAudi 也是一种 CarM3 是一种 BMW,等等。

现在我们要探索的 "Has-A" 关系代表着对象之间的一种组合关系。例如,我们希望为每一辆车都增加一些特定信息,比如车的唯一 ID 和车主。这种情况下,我们可以通过创建一个新的类来包含这些信息,而不是直接在 Car 类中增加新的属性。这样一来,每辆车 拥有 (Has) 一个 CarIDInfo 对象。

步骤 1:创建 CarIDInfo

首先,我们来创建一个专门存储车 ID 和车主的类 CarIDInfo

public class CarIDInfo {
    public int IDNum { get; set; } = 0; // 车的唯一 ID
    public string Owner { get; set; } = "No Owner"; // 车主,默认为 "No Owner"
}

CarIDInfo 中,我们定义了两个属性:

  1. IDNum:一个整数,用于存储每辆车的唯一 ID。
  2. Owner:一个字符串,用于存储车主的名字。如果车辆还没有主人,默认值为 "No Owner"。

步骤 2:将 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 类中:

  1. 我们创建了一个 CarIDInfo 属性,用于存储车的 ID 和车主信息。
  2. 定义了 SetCarIDInfo 方法,用于设置 CarIDInfo 对象的属性。
  3. 定义了 GetCarIDInfo 方法,用于显示 CarIDInfo 中的信息。

步骤 3:创建具体的 BMWAudi

然后我们定义 BMWAudi 类,让它们继承自 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 属性,并通过构造函数初始化它们各自的特定属性。

步骤 4:在主程序中使用 CarIDInfo

最后,我们在主程序中创建 BMWAudi 对象,并为每辆车设置 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" 关系允许我们将一个类作为另一个类的属性,从而更灵活地管理对象之间的关系。这种方法可以在不改变继承结构的情况下为类添加额外的属性和方法,非常适用于对象包含其他对象的情况。

145 - 抽象

抽象类概述

在本视频中,我们将探讨 抽象类 的概念。这是面向对象编程的重要组成部分,理解了它可以更好地掌握继承和多态的概念。我们会创建几个新的类,并展示抽象类在代码中的应用。

什么是抽象类?

抽象类是我们不打算直接实例化的类,而是作为一种模板,供子类继承和实现。例如,我们创建一个 Shape 类,它代表一个几何形状,但我们不打算直接创建一个 Shape 对象。相反,我们会创建具体的形状(如立方体和球体)的类,这些类继承自 Shape 类,并实现它的特定属性和方法。

步骤 1:创建抽象类 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 类中,我们定义了以下内容:

  1. 属性 Name:存储形状的名称。
  2. 虚方法 GetInfo():打印形状的信息,可以在子类中重写。
  3. 抽象方法 Volume():定义了一个计算体积的方法,但没有具体实现。所有继承 Shape 的子类必须实现 Volume() 方法。

步骤 2:创建具体类 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 类中,我们定义了:

  1. 属性 Length:用于存储立方体的边长。
  2. 构造函数:初始化 NameLength 属性。
  3. 重写方法 Volume():计算立方体的体积。
  4. 重写方法 GetInfo():调用父类的 GetInfo(),并额外输出立方体的边长。

步骤 3:创建具体类 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 类中,我们定义了:

  1. 属性 Radius:用于存储球体的半径。
  2. 构造函数:初始化 NameRadius 属性。
  3. 重写方法 Volume():计算球体的体积。
  4. 重写方法 GetInfo():调用父类的 GetInfo(),并额外输出球体的半径。

步骤 4:在主程序中使用抽象类和子类

现在我们可以在主程序中创建 CubeSphere 的对象,来测试抽象类和子类的功能:

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

总结

通过这个视频,你学习了以下内容:

  1. 抽象类的定义和用途:抽象类不能直接实例化,只能被继承。
  2. 抽象方法的使用:抽象方法没有方法体,必须在子类中实现。
  3. 虚方法和重写:虚方法可以在子类中使用 override 关键字重写。

在下一个视频中,我们将继续探讨更多与抽象类相关的关键字和功能,比如 sealed 等。

146 - 抽象与多态的关键字

抽象类进阶及类型转换的应用

在本视频中,我们将深入探讨 抽象类 的用法,特别是如何使用 类型转换(casting)来处理继承的对象。了解这些概念将帮助我们更好地运用 多态性(polymorphism)以及更灵活地管理类和对象。


使用 asis 关键字进行类型检查和转换

我们可以使用 asis 关键字来确定一个对象是否是特定类型,以及安全地将对象转换成该类型。

1. 创建一个实例并使用 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.");
}

2. 使用 is 关键字判断类型

is 关键字可以用来检查一个对象是否属于某个类型。如果属于该类型,它会返回 true

if (shape is Cube) {
    Console.WriteLine("This shape is a cube.");
} else {
    Console.WriteLine("This shape is not a cube.");
}

这两个检查方法可以帮助我们安全地判断和处理对象的类型,以便在运行时执行不同的操作。


类型转换(Casting)示例

当我们确定某个对象的类型后,可以使用显式转换将其转为特定类型,并调用类型特有的属性或方法。

示例

我们创建一个 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 类包含一些通用方法,例如 ToStringGetHashCodeEquals 等。这些方法对于每个类都是可用的,即使我们没有显式定义它们。

示例

Shape shape = new Cube(3);
Console.WriteLine(shape.ToString()); // 输出对象的字符串表示形式

object 类的这些基础方法可以被任何对象使用。比如,我们可以调用 shape.ToString() 来获取 shape 对象的字符串表示形式。


总结

在本视频中,我们学习了以下内容:

  1. asis 关键字:用于类型检查和类型转换。

    • as 用于尝试将对象转换为指定类型。
    • is 用于检查对象是否属于某个类型。
  2. 类型转换(Casting):用于将对象显式转换为特定类型,以便调用特定的属性和方法。

  3. object 类的通用方法:所有类默认继承自 object,因此可以使用 ToStringEquals 等通用方法。

通过这些技术,我们可以更灵活地在多态性场景中处理对象的类型。在下一个视频中,我们将继续深入探讨抽象类和多态性的高级用法。

147 - 接口与抽象类

抽象类和接口的关键区别

在本节课中,我们将讨论 抽象类接口 的关键区别,并理解在设计应用程序时,何时使用抽象类、何时使用接口以及何时都不使用。理解这两者的区别对于设计 松耦合可扩展 的应用程序非常重要,这样的设计可以让应用程序更易于修改而不破坏现有功能。


抽象类概述

抽象类的使用场景

假设我们有一个 Motorcycle 类和一个 Car 类,它们有一些相似的功能。为了减少代码冗余,可以创建一个基本类 Vehicle。为了防止直接创建 Vehicle 实例,我们将它标记为抽象类。这样可以确保派生类如 MotorcycleCar 必须实现 Vehicle 中定义的通用方法,同时保持无法直接实例化 Vehicle


接口概述

接口的使用场景

假设我们有三个类 BicycleMotorcycleCar,它们都继承自 Vehicle。如果我们想为 Car 类添加自动驾驶功能,不需要在 Vehicle 基类中添加该功能,因为并不是所有的派生类都需要它。更好的方式是创建一个接口 ISelfDriving,并让需要自动驾驶功能的类实现该接口。


抽象类和接口的相似之处

  1. 无法实例化:我们不能从抽象类或接口直接创建对象。
  2. 支持多态性:可以将具体对象存储在抽象类或接口类型的变量中。例如,可以将 Cat 类型的对象存储在 abstract class Animal 类型的变量中,类似地,可以将 ListStack 类型的对象存储在 IEnumerable 类型的变量中。

抽象类和接口的关键区别

特点 抽象类 接口
实现与否 抽象类可以包含部分实现或没有实现的方法 接口完全没有实现,只是方法声明
构造函数 可以包含构造函数和字段 不能包含构造函数或字段
多继承支持 C# 支持类实现多个接口,提供多重继承的能力 C# 只允许单继承,但可以通过实现多个接口实现类似的多继承
方法实现 子类必须实现抽象方法,但可以直接继承非抽象方法 实现接口的类必须实现接口的所有成员

使用抽象类或接口的时机

  1. 使用抽象类:如果需要派生类拥有一些通用功能,可以选择抽象类。抽象类适用于共享核心特性和行为的场景,并定义了派生类的基本特征。
  2. 使用接口:如果只需要定义一个通用契约,用接口更合适。接口适合定义不一定属于类核心特性但能增强类功能的功能。例如,为多种类提供可编辑的能力,可以定义一个 IEditable 接口,而不需要创建一个共同的基类。

类比:抽象类 vs. 接口

  1. 抽象类:定义对象的核心特性,说明了“对象是什么”(例如:所有动物类都具备的基本特性)。
  2. 接口:定义对象的功能或行为契约,说明了“对象可以做什么”(例如:IEditable 表示对象可以编辑,而 IPrintable 表示对象可以打印)。

总结

通过理解这些概念,我们可以在接下来的课程和例子中更好地应用这些原则来编写灵活且可维护的代码。

148 - 从文本文件读取

如何从文本文件读取内容

在本视频中,我们将学习如何从文本文件中读取内容。我们创建了一个简单的 .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();

在上述代码中:

  1. ReadAllText 方法会读取指定文件的全部内容并返回一个字符串。
  2. 路径前的 @ 符号确保文件路径中的反斜杠不被解释为转义字符。
  3. 将文件内容存储到 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();

在上述代码中:

  1. ReadAllLines 方法会读取文件的每一行内容,并将其存储到字符串数组 lines 中。
  2. 使用 foreach 循环逐行输出每行内容,并在输出行前加入一个制表符 \t,使输出更加清晰。

运行结果

Contents of text file:
    Hello world
    Dennis here

此方法逐行读取文件内容,并在控制台中显示。


应用示例

文件读取功能可用于各种应用场景,比如:


总结

在 C# 中可以通过 System.IO.File 类中的 ReadAllText 方法读取整个文件内容,也可以使用 ReadAllLines 方法逐行读取内容。选择哪种方法取决于需求:若需要一次性读取整个文件,可以使用 ReadAllText;若需要逐行处理,则使用 ReadAllLines

这两种方法简洁且实用,适合在日常的文件操作中使用。

149 - 写入文本文件

如何将内容写入文本文件

在本视频中,我们将学习如何将内容写入文件。之前我们已经学会了如何读取文件内容,现在我们来看看如何将数据写入文件,有多种不同的写入方法可供选择。


方法一:按行写入文件

首先,我们可以使用字符串数组 string[],将每一行内容分别存储,然后写入文件。这个方法适用于我们希望在文件中逐行写入内容的情况。

示例代码

// 创建字符串数组,每一行存储为一个元素
string[] lines = { "First line", "Second line", "Third line" };

// 使用 File.WriteAllLines 方法写入文件
System.IO.File.WriteAllLines(@"C:\YourPath\Assets\textfile2.txt", lines);

在上面的代码中:

运行结果

在文件 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);

在上面的代码中:

运行结果

如果用户输入了以下内容:

testfile.txt 文件中,内容将显示为:

Hi there, this is a test.

方法三:使用 StreamWriter 类写入文件

使用 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);
        }
    }
}

在上面的代码中:

运行结果

在文件 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");
}

在上面的代码中:

运行结果

mytext2.txt 文件的末尾,内容将显示为:

Additional line

总结

在 C# 中,您可以使用多种方法将内容写入文件:

每种方法都适用于不同的使用场景,具体选择取决于需求。例如,可以使用 WriteAllLines 写入高分排行榜,或使用 StreamWriter 追加日志条目。

150 - 多态总结

接下来,进入高级 C# 主题

恭喜你完成了多态性的学习!现在,你已经理解了多态性的工作原理、它的概念、如何确保类不能从其他类继承,以及如何重写方法等等这些重要的内容。通过这些知识,你可以编写更复杂的程序,并具备了在团队中合作的能力。

在团队中编程比单独编程更具挑战性,因为不同的代码片段可能相互影响,导致程序无法顺利运行。而多态性是一个很好的工具,帮助我们在多人协作中避免代码冲突,确保程序更具可扩展性和可维护性。

展望下一个章节

接下来我们将进入 高级 C# 主题。在这一章节中,我们将学习一系列不同的知识点,这些知识可能难以归类到特定的章节或主题中,但它们都是你在 C# 编程生涯中会频繁用到的重要技能。

这章内容包含各种实用的高级技术,希望能为你打下坚实的 C# 基础,提升编程技能,使你的代码更高效、更灵活。

所以,让我们一起进入下一章吧!

WangShuXian6 commented 2 weeks ago

11 - 高级C话题

151 - 高级话题简介

本章介绍

欢迎回来!在本章中,你将学习许多不同的主题,虽然它们彼此之间并没有直接联系,但仍然是编程中的重要组成部分。具体来说,你将了解以下内容:

结构体 (Structs)

首先,你将学习什么是结构体。结构体是编程中用来组织相关数据的一种方式。你会明白如何定义结构体以及它们在编程中的作用和用法。

敌人 (Enemies)

在游戏开发或仿真等领域,理解“敌人”如何在系统中表示和操作非常重要。本节将介绍如何定义和管理“敌人”对象或实体。

访问修饰符 (Access Modifiers)

接下来,你会学习访问修饰符。访问修饰符用于控制程序中不同代码段之间的可见性和访问权限。了解如何使用访问修饰符可以帮助你保护数据,确保只有必要的部分可以访问或修改特定数据。

数学类 (Math Class)

数学类提供了各种数学运算的功能,例如三角函数、指数、对数等。本章会介绍如何使用数学类进行这些常用的计算操作。

随机类 (Random Class)

在许多应用中,生成随机数是常见需求。随机类为此提供了方法,可以用来生成随机整数、浮点数等。本章会演示如何正确使用随机类生成所需的随机数据。

状态类 (State Class)

状态类用于管理程序中状态的变化。它在游戏开发或复杂应用中尤为重要,因为它可以帮助你组织状态的不同变化并对其进行控制。

正则表达式 (Regular Expressions)

正则表达式是匹配字符串模式的强大工具,对于文本处理和数据验证非常有用。虽然学习正则表达式可能有些困难,但它在编程中是非常重要的。本节会详细解释如何编写和使用正则表达式。

数字 (Numbers)

你还会看到编程语言中“数字”的概念。不同编程语言对数字的定义和使用有所不同,但它们在基础概念上有相似之处。本节将帮助你理解各种编程语言对数字的不同处理方式。

垃圾回收器 (Garbage Collector)

垃圾回收器是编程语言用于管理内存的一种机制。它会自动回收不再使用的对象或数据以释放内存。本节会解释垃圾回收器的工作原理及其重要性。

抽象 (Abstract)

抽象是面向对象编程中的一个重要概念。你将了解如何定义抽象类和抽象方法,并在实际开发中灵活运用这些概念来实现代码的重用和扩展。

其他内容

本章还包含许多其他重要的概念和主题,这些知识将帮助你进一步理解编程的基本原理和高级功能。

结语

现在,我们开始深入学习这些内容,享受这一章节带来的知识吧!

153 - 访问修饰符

访问修饰符介绍

欢迎回来!在本视频中,我将介绍“访问修饰符”,它们允许你授予或限制代码的访问权限。为了更好地理解访问修饰符的作用以及它们背后的原理,我们需要了解一些相关的概念。

面向对象编程中的访问修饰符和封装

将字段和方法标记为特定的访问修饰符是面向对象编程 (OOP) 的一部分,它提高了代码的安全性,是封装的核心内容。封装是 OOP 的一个重要概念,在面向对象编程语言(如 C#)中,封装指的是两个相互关联但略有不同的概念,有时也指这两者的结合:

  1. 语言机制:用于限制对某些对象组件(如变量和方法)的直接访问。
  2. 语言结构:便于将数据和操作数据的方法(或函数)打包在一起。

使用Setter和Getter

例如,我们可以使用setter和getter方法来提高程序的安全性。在接下来的课程中,当我们学习属性时,我们将深入了解如何通过属性执行许多类似的操作。

常用的访问修饰符

接下来,我们将依次介绍几个常用的访问修饰符,并通过示例进行说明。

私有访问修饰符 (Private)

private 修饰符只允许在类或结构体内部访问。当你创建一个私有变量或私有方法时,它只能在定义它的类或结构体内部使用,无法在其他地方访问。例如:

class ClassOne {
    private int age = 18;
    private void Walk() {
        // 方法代码
    }
}

class ClassTwo {
    void AccessExample() {
        ClassOne firstClass = new ClassOne();
        // 无法访问 firstClass.age 或 firstClass.Walk(),因为它们是私有的
    }
}

在上面的例子中,ageWalk 方法在 ClassTwo 中是不可访问的,因为它们在 ClassOne 中被设为私有。

公共访问修饰符 (Public)

public 修饰符允许从项目的任何地方访问。使用 public 修饰符声明的变量或方法可以在整个项目中的每个类中使用,没有任何限制。例如:

class ClassOne {
    public int age = 18;
    public void Walk() {
        // 方法代码
    }
}

class ClassTwo {
    void AccessExample() {
        ClassOne firstClass = new ClassOne();
        // 可以访问 firstClass.age 和 firstClass.Walk()
    }
}

在这个例子中,由于 ageWalk 都被声明为 public,所以在 ClassTwo 中可以直接访问它们。

受保护的访问修饰符 (Protected)

protected 修饰符允许从当前类及其派生类中访问。要理解 protected 的作用,必须理解面向对象编程的继承概念。使用 protected 修饰的变量和方法在派生类中可以直接访问,不需要创建父类的对象。例如:

class ClassOne {
    protected int age = 18;
    protected void Walk() {
        // 方法代码
    }
}

class ClassTwo : ClassOne {
    void AccessExample() {
        // 可以直接访问 age 和 Walk 方法
    }
}

在这个例子中,ClassTwo 继承自 ClassOne,因此它可以直接访问 ageWalk,就像它们是 public 一样。

内部访问修饰符 (Internal)

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()
        }
    }
}

在这个例子中,ageWalk 可以在 ProjectNamespace 中的任何类中访问,因为它们是 internal 的。

访问修饰符的最佳实践

如何正确地使用访问修饰符?通常,声明一个新的类成员或方法时,建议使用最严格的访问修饰符来确保代码的安全性。通常从 private 开始,当需要更广泛的访问权限时,再逐步放宽权限到 internalprotectedpublic。这种方式可以确保代码的可控性和安全性。

使用访问修饰符的原因

访问修饰符为你的方法和变量提供了完全的控制。一个简单的例子是年龄变量 (age) 的使用:

假设你希望可以设置年龄,但如果将 age 设置为 public,那么任何类都可以随意更改它,甚至设置一个无意义的值,比如 -20-25。然而,如果 ageprivate,并且通过setter和getter来访问,你可以确保该变量的值有效。例如,当传入负值时,可以将其乘以 -1 或设置为 0;当值超过合理范围(如 150130)时,可以抛出错误或请求用户输入有效值。

总结

访问修饰符不仅提供了代码的安全性和控制力,还帮助你实现数据封装,使得代码更健壮、灵活。在后续视频中,我们将进一步探讨访问修饰符、封装以及更多相关概念。

154 - 结构体

结构体 (Structs) 简介

欢迎回来!在本视频中,我们将讨论结构体 (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 信息。

结构体与类的区别

结构体和类的相似性显而易见,例如,它们都可以包含变量和方法,但两者有一些重要的区别:

  1. 引用类型 vs 值类型

    • 类是引用类型,这意味着类的实例存储在堆 (heap) 上。
    • 结构体是值类型,存储在栈 (stack) 上,操作它们时传递的是值副本而非引用。
  2. 无参数的构造函数

    • 结构体不能包含显式的无参数构造函数。结构体必须在创建时被赋值。
  3. 继承

    • 结构体不支持继承,但可以实现接口。类可以继承其他类,但结构体不具备这种功能。
  4. 抽象、虚方法、受保护成员

    • 结构体的成员不能被声明为 abstractvirtualprotected,只能是 publicprivate

示例方法 - Display

可以在结构体中创建方法来显示信息,例如:

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");

但是,请注意,必须初始化所有字段,否则会报错,因为结构体中的每个成员都需要被赋值。

结构体 vs 类的简要总结

结构体和类都可以用于组合具有逻辑关系的多个变量,可以包含方法和事件,并且可以支持接口的实现。尽管一般情况下,类的实例存储在堆上,而结构体的实例存储在栈上,但也有一些例外情况,需要根据具体需求和性能考量来决定选择结构体还是类。

进一步学习资源

建议查看官方文档或参考 StackOverflow 等平台,获取更多关于结构体和类的深入信息,以便更好地理解两者的差异和应用场景。

155 - 枚举

枚举 (Enum) 简介

欢迎回来!在本视频中,我们将讨论枚举 (enum) 的概念。枚举基本上是一组常量,具有不可变的特性。它通常被放置在命名空间的级别上,以便整个库可以访问它。枚举适用于固定的一组值,比如一周的七天。

定义枚举 - 以“Day”为例

假设我们创建一个代表“天”的枚举,因为一周只有七天,所以这个枚举将只包含以下值:

enum Day {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
}

这个 Day 枚举定义了一周中的每一天,这七个值是固定的,不会发生变化。

使用枚举

现在我们定义了一个 Day 枚举,可以在代码中使用它。让我们在主方法中创建 Day 枚举的对象:

Day fr = Day.Friday;

这里,fr 被设为 Day 枚举中的 Friday。每个枚举值也都有一个索引,比如 Monday0Tuesday1,依此类推。让我们创建一个示例:

Day a = Day.Friday;
Console.WriteLine(fr == a); // 输出: True

在这里,fra 都是 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 的值为 12August 将是 13,以此类推。我们可以检查:

Console.WriteLine((int)Month.August); // 输出: 13

总结

枚举 (enum) 的关键要点在于它们提供了一组共享的常量,使库或应用程序保持一致性。枚举在整个命名空间中共享同一组常量值,这样不必在每个类中重复定义这些常量。使用枚举可以让代码更具可读性,简化了固定值的管理。

156 - 数学类

数学类 (Math Class) 介绍

欢迎回来!在本视频中,我们将讨论数学类 (Math Class)。数学类不仅仅是我们在学校学习的数学,而是提供了一些常量和静态方法,用于执行各种数学计算,比如三角函数、对数函数等。你无需自己编写这些方法,它们已经在 Math 类中准备好了。

使用 Math 类

要使用 Math 类,只需输入 MathMath 类提供了一些常量和静态方法,可以用于三角运算、对数运算及其他常见数学操作。例如,如果想要向上取整,可以使用 Math.Ceiling 方法。

向上取整 - Math.Ceiling

double result = Math.Ceiling(15.3);
Console.WriteLine($"Ceiling: {result}"); // 输出: 16

即使 15.3 小于 15.5Math.Ceiling 仍然会将其取整到下一个整数,即 16

向下取整 - Math.Floor

Math.Floor 用于向下取整到最接近的整数。例如:

double result = Math.Floor(15.3);
Console.WriteLine($"Floor: {result}"); // 输出: 15

15.3 会向下取整为 15

获取较小或较大值 - Math.MinMath.Max

可以使用 Math.MinMath.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 用于计算一个数的幂。例如,计算 35 次幂:

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 类提供了一个丰富的数学函数库,适用于许多数学计算。无论是简单的取整操作、基本的加减乘除,还是复杂的幂运算和对数运算,都可以通过 Math 类实现。在编写数学密集型代码时,建议多利用 Math 类中的方法以提高代码的简洁性和效率。

我们在后续视频中会继续探讨更多编程概念。

157 - 随机类

随机数生成 (Random Class) 介绍

欢迎回来!在本视频中,我们将讨论随机数生成器 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) 方法生成一个介于 16 之间的随机整数。7 是不包含的上限。

运行代码后,你将看到一组随机数,例如 6, 1, 4, 5, 2 等,这些数字是每次掷骰子的结果。

小挑战 - 简单的“占卜”程序

现在,给你一个小挑战:创建一个简单的“占卜”程序,该程序会随机回答问题,用“是”、“可能”、“否”三个答案来回答。具体步骤如下:

  1. 创建一个随机数生成器。
  2. 根据生成的随机数,提供不同的答案。
  3. 让用户输入一个问题并随机获得一个答案。

代码示例

以下是这个程序的实现:

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("不");
}

运行该程序时,你会被提示输入一个问题,然后程序会随机回答“是的”、“可能”或“不”。

代码说明

提高互动性 - 循环询问

如果想要继续询问,可以将程序放入一个 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 方法可以生成指定范围内的随机数,适用于多种场景。希望你能从这个小练习中获得一些乐趣!我们在后续视频中会继续探索更多内容。

159 - 正则表达式

正则表达式 (Regular Expressions) 简介

欢迎回来!本视频我们将讨论正则表达式 (Regex) 的概念。正则表达式是一种模式语言,正则表达式引擎会尝试在输入的文本中匹配这些模式。它非常强大,广泛用于各类编程语言中,比如用于检查用户输入是否为有效的电子邮件地址或链接等。

正则表达式的使用场景

正则表达式可以用来在文本中查找特定模式,或者验证输入内容。常见的应用场景包括:

基本字符转义和字符类

在编写正则表达式时,我们可以使用各种特殊字符和字符类。以下是一些常用的正则表达式符号及其含义:

在 Visual Studio 中使用正则表达式

我们可以在 Visual Studio 的查找工具中使用正则表达式来高亮文本中的特定模式。步骤如下:

  1. Ctrl + F 打开查找工具。
  2. 启用“使用正则表达式”选项。
  3. 输入正则表达式来匹配文本模式。

例如,查找所有数字可以输入 \d,查找所有空白字符可以输入 \s。这些字符类可以帮助你快速定位特定类型的内容。

常见的匹配模式示例

数字匹配

\d        // 匹配任意数字
\d{3}     // 匹配三个连续的数字
\d{3,5}   // 匹配三到五个连续的数字

字符匹配

边界匹配

组合字符和逻辑操作

示例 - 匹配特定的电话号码格式

例如,我们可以使用正则表达式匹配一个德国电话号码格式 +49-123-4567890

^\+49-\d{3}-\d{7}$

挑战 - 创建一个简单的占卜程序

使用随机数生成器,创建一个简单的占卜程序,该程序会随机回答问题,用“是”、“可能”、“否”三个答案来回答。

160 - 日期时间

DateTime 类简介

欢迎回来!在本视频中,我们将讨论 DateTime 类。DateTime 类可以帮助我们获取当前日期和时间,计算两个日期之间的差异等。使用 DateTime 类可以轻松进行日期和时间的操作。

创建一个 DateTime 对象

可以通过多种方式来创建 DateTime 对象,例如:

DateTime myBirthday = new DateTime(1988, 5, 31); // 年、月、日
Console.WriteLine("我的生日是: " + myBirthday);

输出示例:

我的生日是: 1988-05-31 00:00:00

获取当前日期和时间

示例:

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.HourMinuteSecond 来获取当前时间的各个部分。例如:

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 类提供了非常丰富的日期和时间操作功能,包括创建特定日期、获取当前日期和时间、计算日期差异等。掌握这些操作可以帮助你轻松应对项目中涉及时间的需求。希望这些示例对你有所帮助!

161 - 可空类型

可空类型 (Nullable Types) 简介

欢迎回来!在本视频中,我们将讨论可空类型 (Nullable Types),即一种变量可以有一个值或者没有值(即为 null)。在其他编程语言中,比如 Swift 中称之为选项 (Optional),这种概念被广泛使用。可空类型适用于可能未被赋值的变量,并确保未赋值时程序不会崩溃。

创建可空类型变量

要定义可空类型的变量,只需在数据类型后添加一个问号 (?)。例如,要创建一个可为空的 int

int? num1 = null;
int? num2 = 1337;

在以上代码中,num1 没有赋值(为 null),而 num2 有一个具体值。

可空类型的其他数据类型

可空类型适用于多种数据类型,如 doublebool

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("未指定性别");
}

如果 isMalenull,则输出 "未指定性别"。

从可空类型转换为非可空类型

可以通过显示转换 (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

总结

希望这些内容有助于你理解并使用可空类型,在处理数据不完整或缺失的情况下非常有用。

162 - 垃圾回收器

垃圾收集器 (Garbage Collector) 简介

欢迎回来!在本视频中,我们将讨论垃圾收集器 (Garbage Collector, GC) 及其如何在 C# 中自动管理内存。垃圾收集是一种用于内存管理的工具,适用于多种编程语言。在一些语言中,垃圾收集需要手动触发 (如 C、C++),而在 .NET 框架中,它是自动完成的。

C# 的垃圾收集器会自动回收不再使用的对象所占用的内存空间,因此一般来说,你不需要手动调用垃圾收集器。只有在少数情况或非常复杂的程序中,才可能需要手动触发 GC。

垃圾收集器的工作原理

当我们在程序中创建对象时,C# 会在内存中分配一块空间来存储该对象。例如:

Human dennis = new Human();

在这里,我们创建了一个 Human 类型的对象 dennis,并分配了一块内存来存储它。一旦对象被创建,我们的程序便可以通过引用 (dennis) 来访问该对象。

垃圾收集的流程

当一个对象不再被使用时(例如程序中不再有指向它的引用),它占用的内存会被标记为垃圾。这时,垃圾收集器会识别并回收这块内存,使其可以被其他对象重新使用。例如:

  1. 对象创建:当创建对象 dennis 时,系统分配内存并创建引用。
  2. 引用断开:如果我们将 dennis 设为 null,即 dennis = null;,那么对象在程序中不再有引用。
  3. 内存回收:垃圾收集器发现该对象无引用后,便会回收其内存。

垃圾收集的示例

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() 并不一定会立刻触发垃圾回收。这只是对垃圾收集器的一个“建议”,系统会根据当前内存情况决定是否执行垃圾回收。

何时触发垃圾收集

垃圾收集器一般在以下情况下运行:

  1. 系统内存不足。
  2. 分配的内存超过了预设的阈值。
  3. 调用了 GC.Collect() 方法(虽然这只是一个建议)。

使用 Finalize 方法

可以通过 Finalize 方法在对象被垃圾收集器回收前执行一些代码:

class Human {
    ~Human() {
        // 清理代码,如释放非托管资源
    }
}

Finalize 方法允许你在对象被回收前释放一些资源,例如关闭文件或数据库连接。

总结

垃圾收集器是 .NET 框架自动内存管理的重要组成部分。在 C# 中,垃圾收集器负责回收不再使用的对象所占用的内存,减少内存泄漏的风险。

通常情况下,你不需要手动干预垃圾收集器的工作,但理解它的原理有助于更高效地管理内存,尤其是当程序涉及大量对象或大内存分配时。

163 - Main参数解释(第一部分)

命令行参数简介

欢迎回来!在本视频中,我们将讨论 Main 方法中的 args 参数。args 是一个字符串数组,用来在启动应用程序时传递输入。了解 args 的用法对任何控制台应用程序来说都是很重要的,尽管在许多教程中往往被忽略。

什么是 args

args 是一个字符串数组,包含启动应用程序时传递的所有参数。通过它可以在不暂停应用程序的情况下传递所需的输入,而不是通过 Console.ReadLine 来询问用户输入。

如何在 Visual Studio 中设置命令行参数

  1. 右键点击项目,选择“属性”。
  2. 在“调试”选项卡中找到“应用程序参数”或“命令行参数”。
  3. 在输入框中输入参数,用空格分隔每个参数。例如,输入 "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;
}

在命令行中运行应用程序

  1. 打开 cmd 命令提示符。
  2. 导航到应用程序的文件路径(可以在 Visual Studio 的解决方案资源管理器中右键点击项目 > 打开文件夹路径 来找到应用程序的路径)。
  3. 输入以下命令来运行应用程序,并传递参数:

    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]);
        }
    }
}
  1. 如果没有参数,程序输出提示信息。
  2. 如果参数为 help,程序显示帮助信息。
  3. 如果有参数,程序输出 Hello 和第一个参数。

总结

通过这些步骤,你可以更好地理解如何使用命令行参数来增强控制台应用程序的功能。在下个视频中,我们将进一步探讨如何处理用户输入并使应用程序执行基本操作。

164 - 使用用户输入创建CMD应用的Main参数解释

命令行参数和用户输入处理

欢迎回来!在本视频中,我们将讨论如何使用命令行参数 (Command Line Arguments) 创建一个基本的操作程序。我们将学习如何处理用户输入,确保程序能够识别有效的输入,同时提供帮助手册,指导用户如何正确使用程序。

基本需求

我们的应用程序不直接通过控制台向用户询问输入,而是通过命令行参数传递数据。用户需要在启动应用程序时提供这些参数。我们将实现以下功能:

  1. 检查命令行参数:如果用户输入了 help,我们将显示帮助手册。
  2. 处理用户输入错误:确保程序能够处理无效输入。
  3. 执行基本运算:实现两个数的加法和减法操作。
  4. 显示结果或错误信息

编写程序的步骤

步骤 1:检查帮助参数

首先,当用户输入 help 参数时,我们将显示帮助信息,例如:

if (args.Length == 1 && args[0].ToLower() == "help") {
    Console.WriteLine("使用以下命令之一并跟随两个数字:");
    Console.WriteLine("add - 执行两个数的加法");
    Console.WriteLine("sub - 执行两个数的减法");
    return;
}

如果用户输入了 help,程序将显示支持的命令选项,并终止运行。

步骤 2:验证参数数量

接下来,确保用户提供了正确数量的参数。我们的应用程序需要三个参数:

  1. 第一个参数是命令(如 addsub)。
  2. 第二和第三个参数是要操作的两个数字。

如果参数数量不正确,显示错误信息并终止程序:

if (args.Length != 3) {
    Console.WriteLine("无效参数,请使用 'help' 命令查看指令。");
    return;
}

步骤 3:检查数字格式

检查用户输入的数字是否为有效的浮点数。使用 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;
}

如果解析失败,程序将显示错误信息并终止运行。

步骤 4:执行操作并显示结果

使用 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;
        }
    }
}

测试程序

编译程序后,使用以下命令在命令行中测试不同的输入情况:

  1. 显示帮助信息:

    yourprogram.exe help
  2. 执行加法操作:

    yourprogram.exe add 15 13
  3. 执行减法操作:

    yourprogram.exe sub 5 6
  4. 输入无效参数:

    yourprogram.exe invalid 5 6
  5. 输入无效数字格式:

    yourprogram.exe add five six

总结

通过这节课程,你学会了如何:

  1. 使用命令行参数来获取用户输入。
  2. 验证参数数量和格式。
  3. 使用 switch 语句执行基本的加法和减法操作。
  4. 显示帮助信息,指导用户正确使用程序。

希望通过这些内容,你能够创建出自己的命令行应用程序,处理用户输入并执行基本操作。下次见!

WangShuXian6 commented 2 weeks ago

12 - 事件与委托

165 - 委托简介

事件和委托 (Events and Delegates) 简介

欢迎回来!本章中,我们将深入探讨 事件 (Events)委托 (Delegates),这是理解之后内容的重要基础。这是一个相对复杂的主题,但在学习之后,你将会更好地理解 WPF 中的代码运作,例如按钮点击时的机制。

虽然事件和委托涉及的技术细节比较复杂,但它们的核心概念对于编写灵活、动态的代码至关重要。通过学习这些内容,你不仅可以像驾驶员一样使用代码,还可以像机械师一样了解代码内部的工作原理

为什么需要学习事件和委托?

在 WPF 应用程序中,事件和委托用于处理用户交互。比如,当用户点击按钮时触发某个事件。这个事件并不是简单的执行一个操作,而是依赖于委托来定义要执行的具体方法,类似于将任务交给一个“代理”来完成。

为了理解 WPF 中的事件驱动编程,深入掌握事件和委托将帮助你在复杂情况下灵活控制代码行为。掌握这些知识也让你在处理代码中的事件响应、数据传递和操作时更具自由度。

事件和委托的基本概念

  1. 委托 (Delegate):委托是一种引用类型,用于定义方法的签名。它可以被视为一种类型安全的函数指针,用于引用特定签名的函数。例如,一个接受两个整数并返回整数的委托,只能引用这种类型的函数。

  2. 事件 (Event):事件是基于委托的,专门用于响应特定动作(如按钮点击)的机制。当一个事件被触发时,它会通知所有注册的委托执行对应的方法。这一过程类似于广播信号,所有订阅了事件的监听器都会收到通知并执行对应的代码。

本章内容预览

总结

理解事件和委托将帮助你更好地驾驭代码,构建出灵活、动态的应用程序。在之后的 WPF 中,这些知识将成为创建交互式用户界面的基础。本章的学习内容可能会稍显复杂,但掌握它们将使你的编程能力迈上新台阶。

让我们开始本章的学习!

166 - 委托介绍

什么是委托 (Delegate)?

在简单的术语中,委托 是一种可以存储对方法的引用的类型。当你调用该委托时,它引用的方法将被调用。让我们通过一个示例来说明这个概念。

假设我们正在开发一个 UI 库,供其他开发人员用于构建移动应用。我们提供了一个按钮类 (Button Class),它具有以下几个属性:

现在,按钮还缺少一个关键功能:定义点击时执行的代码。

挑战:点击事件的实现

作为这个 UI 系统的开发者,我们不知道使用我们系统的其他开发人员希望按钮点击后执行的具体逻辑。因此,我们无法在按钮类中直接提供一个点击方法来满足所有需求。这时,委托成为了解决这一问题的理想选择。

通过提供一个委托来存储点击事件的方法引用,我们可以让其他开发人员定义自己的方法并将其分配给按钮的点击事件。这样,每个按钮可以在被点击时执行不同的逻辑。

定义点击事件的委托

我们可以将点击事件定义为委托。下面是如何定义这个点击事件的委托类型:

  1. 访问修饰符:首先定义访问修饰符,例如 private
  2. delegate 关键字:使用 delegate 关键字告诉编译器我们正在定义一个新的类型。
  3. 返回类型和参数:指定委托可以存储的方法类型,也就是方法的返回类型和参数。比如,在这个例子中,我们希望被存储的方法返回 void 且无参数。

代码如下:

private delegate void OnClickDelegate();

这里我们定义了一个名为 OnClickDelegate 的委托类型,用于存储返回类型为 void、无参数的方法。

创建委托变量

接下来,我们将创建一个该委托类型的变量来存储具体方法的引用:

public OnClickDelegate onClick;

在这里,我们定义了一个公共变量 onClick,它的类型是 OnClickDelegate。它可以存储任何符合 OnClickDelegate 签名(即返回 void 且无参数)的方法的引用。

如果尝试将不符合该委托签名的方法赋值给 onClick,例如带参数或返回值类型不为 void 的方法,编译器将抛出错误。

使用委托

假设我们在设计器中添加了一个按钮(例如名为 sendButton),并希望点击按钮后触发以下逻辑:

  1. 连接到网络
  2. 发送消息
  3. 显示发送成功的对话框

我们可以编写如下代码:

void SendButtonClick() {
    // 执行发送消息的逻辑
    ConnectToNetwork();
    SendMessage();
    ShowMessageSentDialog();
}

现在我们有了一个方法 SendButtonClick,但是还没有将它连接到按钮的 onClick 事件。为了让按钮在点击时调用此方法,我们可以将 SendButtonClick 方法的引用赋值给 onClick 变量:

sendButton.onClick = SendButtonClick;

注意,这里我们没有写 SendButtonClick(),而是直接写 SendButtonClick,因为我们只是将方法的引用赋给 onClick,并没有真正调用方法。

委托的运行机制

在底层,如果鼠标悬停在按钮上并点击了它,我们的 UI 系统会调用该按钮的 onClick 委托,而 onClick 委托会进一步调用被分配的方法。在这个例子中,当按钮被点击时,SendButtonClick 方法会被调用,并显示消息发送成功的对话框。

通过这种方式,使用我们 UI 系统的开发者可以创建他们自己的方法,并将这些方法分配给我们的委托,而我们可以在满足条件时调用它们。

委托的更多用法

除了点击事件,委托还可以用于处理其他事件。例如,我们可以创建应用启动或关闭时的委托,并触发相应的方法。

总结

委托的概念和应用不仅限于事件处理,它是 C# 中一种强大而灵活的功能。通过它,程序可以在运行时决定调用的具体方法,从而实现高度的动态性。

在接下来的视频中,我们将学习如何使用 C# 中的内置委托类型,并逐步创建自己的委托类型,类似于本例中实现的点击事件委托。希望你享受本章的学习内容,我们下个视频见!

167 - 委托基础

使用现有委托:Predicate 委托示例

欢迎回来!现在我们已经了解了委托的基本概念,让我们实际操作一下。在 C# 中,有许多内置的委托可以用来简化代码。我们将以 Predicate 委托为例,通过一个实际应用来理解委托的功能和用途。

示例场景

假设我们有一个字符串列表 names,其中包含一些名字,比如:Aidan、Sif、Walter 和 Anatoly。我们希望移除列表中包含字母 “I” 的所有名字。为了实现这一目标,我们将使用 List<T> 类中的 RemoveAll 方法。这个方法允许我们传递一个 Predicate 委托,以定义移除列表项的规则。

什么是 Predicate 委托?

Predicate 是一个委托类型,用于表示某个条件。它接收一个参数并返回一个 bool 值:

对于 RemoveAll 方法来说,它会对列表中的每个元素调用 Predicate,如果 Predicate 返回 true,则该元素将被移除。

代码实现

  1. 定义字符串列表:首先,创建一个包含名字的列表 names
  2. 定义过滤条件方法:接下来,我们创建一个与 Predicate<string> 匹配的方法 Filter,用于检查字符串是否包含字母 “I”。
  3. 调用 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");
    }
}

代码解析

  1. Filter 方法:此方法用于检查字符串中是否包含字母 "I"。它返回一个布尔值:

    • 如果包含 "I",则返回 true(表示该元素符合移除条件)。
    • 如果不包含 "I",则返回 false
  2. RemoveAll 方法RemoveAll 方法接收 Predicate 委托作为参数,在这里传递了 Filter 方法。因为 Filter 的签名与 Predicate<string> 匹配,所以可以直接传递给 RemoveAll 方法。

  3. 无括号传递方法:注意在传递 Filter 方法时没有使用括号 ()。这是因为我们传递的是 Filter 方法的引用,而不是调用 Filter 方法的结果。

运行结果

执行代码后,输出结果为:

移除前的名字列表:
Aidan
Sif
Walter
Anatoly

移除后的名字列表:
Walter

小结

通过这个示例,我们看到了委托的实际应用,以及如何利用 C# 内置的 Predicate 委托来实现灵活的条件筛选。RemoveAll 方法的灵活性在于它接收一个委托,因此可以传入任意符合委托签名的过滤条件方法。

后续学习:如果你还对委托的概念感到疑惑,不用担心!在接下来的内容中,我们将逐步创建自定义的委托类型,从而更加深入地理解委托的核心原理。

168 - 创建自己的委托

创建自定义委托:筛选年龄的示例

在这节课中,我们将创建自己的委托来筛选一组人的列表。通过这个例子,你将更深入地了解委托在实际代码中的应用。

假设我们有一个简单的 Person 类,它包含 NameAge 两个属性。现在我们希望筛选这组人,过滤出成年人(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 方法,这个方法将接受以下参数:

方法会根据筛选条件输出符合条件的人的名字和年龄。

// 用于显示符合条件的人的方法
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

代码解析

小结

通过这个示例,我们不仅了解了如何创建自己的委托,还学习了如何将委托用于筛选和操作数据的情境中。这种模式让代码更加灵活,使我们可以随时更改或添加筛选条件,而不必修改主代码逻辑。

在下一节中,我们将进一步优化代码,学习如何使用匿名方法来消除重复的筛选方法,从而使代码更加简洁。

169 - 匿名方法

匿名方法的概念和使用

在本节课中,你将学习如何使用匿名方法(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;
};

上面的代码解释如下:

  1. 使用 FilterDelegate 委托类型定义一个名为 filter 的变量。
  2. 通过 delegate 关键字定义一个匿名方法,并将它赋值给 filter
  3. 在匿名方法中,接收 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 表达式,这是匿名方法的简洁语法,能够进一步提高代码的可读性和简洁性。

170 - Lambda表达式

Lambda 表达式简介

在上节课中,我们了解了匿名方法如何帮助我们直接在代码中传递委托。在 C# 3.0 中,引入了 Lambda 表达式,这提供了一种更加简洁和功能性的语法来编写匿名方法。Lambda 表达式的灵感来自于 Lambda 演算,在这种计算模型中,一切都可以通过函数来表达。使用 Lambda 表达式,我们可以更方便地创建匿名函数和方法。

Lambda 表达式的基本形式

Lambda 表达式有两种主要形式:

  1. 表达式 Lambda:只包含一个表达式的 Lambda 表达式,语法简洁,一行代码即是整个表达式。

    输入参数 => 表达式
  2. 语句 Lambda:包含多个语句的 Lambda 表达式,通常在需要写多行逻辑时使用。

    输入参数 => { 语句块 }

Lambda 表达式实例

让我们使用 Lambda 表达式来改进之前的代码示例。假设我们已经定义了一个 Person 类,并且有一个 DisplayPeople 方法可以接受一个过滤条件(即 Lambda 表达式)来筛选符合条件的人员。现在我们来创建一些新的过滤条件,尝试使用 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;
});

代码解析:

  1. p => { ... } 表示我们正在使用 Lambda 表达式,其中 p 是输入参数,后面跟着一个语句块。
  2. p.Name.Contains(searchKeyword) && p.Age > 20 检查 p 对象的名字是否包含特定字母并且年龄大于 20。
  3. 如果条件满足,则返回 true,否则返回 false

这种用法中,因为逻辑较为复杂,使用了语句 Lambda。Lambda 表达式的参数和条件可以灵活地更改,不需要再额外创建一个方法。

表达式 Lambda 示例

现在,让我们尝试一个更加简单的筛选条件,只显示年龄为 25 的人员。对于简单的逻辑判断,可以使用更简洁的表达式 Lambda。

DisplayPeople("年龄等于25的人员", people, p => p.Age == 25);

代码解析:

  1. p => p.Age == 25 表达式 Lambda 表达式,其中 p 是输入参数,p.Age == 25 是直接返回的布尔表达式。
  2. 因为只需要一行代码即可完成筛选,所以表达式 Lambda 是最佳选择,极大地简化了代码。

运行结果

运行以上代码,我们可以看到控制台输出:

小结

Lambda 表达式的优势在于其简洁性和灵活性,它让代码更易读,更易维护。与匿名方法相比,Lambda 表达式使用 => 符号进一步简化了代码结构,并使表达式的意图更加清晰。Lambda 表达式在只需一行代码的情况下尤其适用,比如简单的过滤条件。

在接下来的课程中,我们将进一步学习事件多播委托,这将帮助我们更深入地理解委托的实际应用。

172 - 事件与多播委托

多播委托与事件

在本节课程中,我们将学习多播委托和事件。我们将用一个简单的游戏示例来探索这些概念,以便理解如何在代码中使用这些特性。

多播委托的问题背景

假设我们正在从头开发一个视频游戏。我们的代码由图形、音频库和玩家的逻辑组成。我们有以下简单的类:

  1. 渲染类Rendering):负责启动和停止渲染引擎。
  2. 音频系统类AudioSystem):负责启动和停止音频系统。
  3. 玩家类Player):有一个玩家名称属性,可以生成和移除玩家。

在游戏主逻辑中,我们需要在游戏开始时调用各个类的 StartGame 方法,在游戏结束时调用各个类的 GameOver 方法。

使用多播委托改进代码

在初始代码中,我们需要手动依次调用每个系统的 StartGameGameOver 方法。随着系统增多,这样的调用会越来越繁琐且容易出错。为了解决这个问题,我们可以使用多播委托

多播委托是一个可以存储多个方法引用的委托。通过它,我们可以用一个委托调用多个方法。

实现多播委托的游戏事件管理器

首先,我们创建一个 GameEventManager 静态类,其中定义一个 GameEvent 委托和两个事件变量 OnGameStartOnGameOver

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 中,我们定义了 TriggerGameStartTriggerGameOver 静态方法,用于触发 OnGameStartOnGameOver 事件。

在系统类中订阅事件

现在我们可以通过构造函数订阅这些事件:

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("渲染引擎停止...");
    }
}

AudioSystemRendering 的构造函数中,我们使用 += 操作符将 StartGameGameOver 方法添加到 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 中,使得代码更加清晰、简洁,且减少了出错的可能性。

使用事件的优点

总结

通过本节课程,我们学到了:

这些概念为我们接下来使用用户界面库中的事件处理机制提供了重要的基础,帮助我们更好地理解如何使用事件和委托来构建复杂的应用程序逻辑。

173 - 委托结束语

总结:事件与委托

好了,我们终于完成了事件委托的学习。这是整个课程中较为复杂的一个主题,希望你能够顺利地完成学习,并且理解了课程中的所有内容。如果你有些困惑也没关系,因为我们会在后续章节中逐步应用这些知识点,到时候应该会更加清晰。

学习建议

如果你在学习过程中遇到不解之处,我建议你深入研究文档。文档是开发者的好帮手,无论是类的详细信息还是特定方法的用法,都可以在文档中找到详细的解释。在这个阶段,你已经具备了开发者的基本技能,应该可以自由地开始编写自己的代码项目。

在实际应用中,你会遇到各种陌生的代码和方法。这种时候,就好比面对未知的“野生环境”——只有了解这些“生物”并理解如何“猎捕”它们,才能将代码管理得当,而阅读文档就是你成为熟练“猎手”的关键技能。

接下来:WPF

在下一章中,我们将进入Windows Presentation Foundation (WPF) 的学习,开始学习如何创建用户界面。WPF 允许我们创建美观、互动的用户界面,相较于之前的命令行应用程序,这将大大增强用户体验。现在,终于不再是单调的控制台,而是能够构建一个真正的 GUI 界面啦!

所以,准备好进入下一章吧!在那里,我们将利用 WPF 实现更多的功能,构建一个更为有趣的应用程序界面。期待在下一章中见到你!

WangShuXian6 commented 2 weeks ago

13 - WPF(Windows Presentation Foundation)替代品(2023年8月底前)

175 - WPF简介

欢迎来到 WPF 章节

在本章节中,你将学习如何使用Windows Presentation Foundation (WPF) 命名空间或库来编写美观的用户界面 (UI)。在这里,你将通过 XAMLC# 代码两种方式来创建用户界面。

两种方式的优缺点

你可以通过拖拽控件来快速构建用户界面,这是最便捷的方法,但同时也限制了你对界面细节的控制。因此,如果你想要完全掌控界面样式和行为,理解如何通过代码来操作控件将非常有帮助。在本章节中,你将学习到:

  1. XAML 基础:如何使用 XAML 定义基本的界面布局和样式。
  2. C# 控制 UI:如何通过代码来操作 UI 元素。
  3. 拖拽设计 VS 代码实现:理解拖拽设计的便捷性与代码实现的灵活性之间的权衡。
  4. 数据绑定和事件控制:更复杂的主题,包括如何将数据绑定到界面以及处理按钮点击事件等用户操作。

从控制台到 GUI 界面

经过前面章节的学习,我们一直在使用灰色(甚至是黑色)的命令行界面,这种界面虽然实用,但对用户的吸引力不强。而现在,我们将学习如何构建更具视觉效果的图形用户界面 (GUI),从而让应用程序不再单调。你将能够控制点击按钮的效果,定义界面上的响应逻辑,并将代码直接影响到用户体验上。

让我们开始吧!

本章节将带领你一步步深入,逐渐掌握 WPF 的基础知识与高级概念。希望你和我一样兴奋,准备好创建自己的用户界面!让我们立即开始吧!

176 - WPF及其使用时机介绍

欢迎来到 WPF 章节!

在本视频中,我们将为 Hello World WPF 应用程序设置环境。按照以下步骤操作,以确保你能够顺利创建并运行你的第一个 WPF 应用程序。

第一步:安装 .NET 桌面开发工具包

  1. 打开 Visual Studio Installer
  2. 找到你的 Visual Studio Community 2017 安装,然后点击 更多修改
  3. 在修改界面中找到 工作负载 部分,确保已安装 .NET 桌面开发 工作负载。此工具包允许你构建 WPF 和 Windows 窗体应用程序。
  4. 安装完成后,关闭安装器并重新打开 Visual Studio

第二步:创建新项目

  1. 打开 Visual Studio,选择 创建新项目
  2. 选择 WPF 应用程序 (.NET Framework)
  3. 命名项目为 WPF01 并创建项目。
  4. 项目加载后,你会看到一个名为 MainWindow.xaml 的文件,这个文件是我们用户界面的核心。

认识 XAML 和设计界面

XAML 和设计视图

使用工具箱添加控件

  1. 打开工具箱:按 Ctrl + Alt + X 或通过 视图 > 工具箱 找到工具箱。
  2. 在工具箱中找到 TextBlock 控件,将其拖入主窗口中。
  3. 当你拖动控件时,可以看到 XAML 代码会自动更新,显示 TextBlock 控件的属性。

编辑 TextBlock

运行你的 Hello World 应用程序

  1. 点击 运行 按钮或按 F5 启动应用程序。
  2. 你会看到一个带有标题“Main Window”和文本“Hello World”的窗口。

挑战练习

  1. 将 TextBlock 的文本改为 “Hello, [你的名字]”。
  2. 将窗口的标题改为 “My First GUI”。

示例

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 的可能性吧!

177 - XAML基础与代码后置

欢迎回来 WPF 章节

在本视频中,我们将深入探讨 XAML(发音为“Zemo”)。XAML 是一种基于 XML 的语言,允许我们用代码来编写用户界面(UI),从而不必通过拖放来构建界面元素和调整属性。

你可能会想:“我更喜欢视觉化的操作,直接拖放就可以。”确实,拖放是一种便捷的方法,但用 XAML 可以获得更强的灵活性和控制能力,并可以实现更复杂的功能,例如数据绑定、依赖属性等,这些功能在创建可维护的 UI 时非常重要。

XAML 基础

XAML 和 HTML 类似,使用开闭标签的结构。如果你熟悉 HTML,你会对它比较熟悉:

示例:创建一个按钮

<Button Content="Click Me" Height="50" Width="100"/>

在上面的 XAML 代码中:

XAML 的命名空间

在 XAML 文件顶部,你会看到诸如 xmlnsxmlns:x 之类的定义:

添加注释

使用以下方式在 XAML 中添加注释,便于说明代码:

<!-- 这是一个 XAML 注释,便于说明代码功能 -->

创建多个按钮

可以在 XAML 中创建多个按钮:

<Button Content="Click Me" Height="50" Width="100" />
<Button Content="Hi There" Height="50" Width="100" />

不过要注意,如果按钮在同一个网格(Grid)中,它们会彼此覆盖。我们将来会深入讲解网格布局如何使用来定位元素。

运行应用程序

运行应用程序,你会看到按钮可以被点击、显示动画效果等。这些简单的功能都是 WPF 提供的默认行为。

XAML 中的其他属性

你可以设置按钮的 字体大小 等更多属性:

<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

我们可以通过 C# 代码生成相同的 UI 结构,这就是所谓的 Code Behind(代码隐藏)。每个 XAML 文件都有一个对应的 .xaml.cs 文件,即它的代码隐藏文件。在这里,我们可以用 C# 代码来创建和设置 UI 元素。

示例:在代码隐藏中创建按钮

  1. 打开 MainWindow.xaml.cs 文件。
  2. 你会看到一个构造函数 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 代码相同的效果。

总结

本视频我们探讨了 XAML 和代码隐藏文件中的设置 UI 方法。接下来的视频我们将深入学习更多 WPF 的控件和布局技术,敬请期待!

178 - StackPanel、ListBox的可视与逻辑树

欢迎回来

在本视频中,我们将介绍 StackPanelListBox逻辑树视觉树的概念。这些是 WPF 中管理布局和界面层次结构的关键概念。

1. StackPanel

首先,我们将删除之前的 Grid,因为还没有深入使用 Grid。取而代之,我们将使用 StackPanel,它可以将元素垂直或水平堆叠在一起。以下是如何实现的:

<StackPanel>
    <TextBlock Text="Hello, World" HorizontalAlignment="Center" Margin="20"/>
</StackPanel>

在上述代码中,我们使用 HorizontalAlignment="Center" 将文本块居中显示,同时设置了 Margin="20",为四个方向都添加了 20 像素的边距。

2. ListBox

接下来,我们添加一个 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 中的项目可以选择,但没有任何交互事件绑定。

3. 添加 Button 和事件处理

我们可以添加一个按钮并在点击时触发一个事件。例如,添加一个 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!”。

4. 逻辑树与视觉树

逻辑树

逻辑树代表了 XAML 布局中的元素结构。例如,以下是我们界面布局的逻辑树结构:

逻辑树提供了 WPF UI 结构的简单视图,帮助我们了解布局层次。

视觉树

视觉树则更为详细,展示了所有在应用程序中渲染的可视元素,包括 XAML 中没有显式定义的元素。我们可以通过设置断点进入调试模式,并打开 WPF Tree Visualizer 来查看视觉树。

视觉树不仅包含了逻辑树中的元素,还包括在 UI 中呈现它们所需要的附加元素。例如,ListBox 在视觉树中可能会包含 BorderScrollViewer 等附加元素,用于增强显示效果。以下是视觉树的部分结构示例:

总结

理解这些概念可以帮助我们更好地组织和调试 WPF 应用程序。在下一个视频中,我们将深入研究事件的处理。

179 - 路由事件:直接冒泡与隧道

欢迎回来

在本视频中,我们将深入探讨 XAML 中的事件,特别是 WPF 的路由事件(Routed Events)。WPF 提供了三种路由事件:直接事件冒泡事件隧道事件。我们将详细介绍每种事件的工作方式,并通过实际示例来演示它们的使用方法。

1. 路由事件概述

路由事件是 WPF 中的事件类型,允许事件沿着视觉树逻辑树传播,以便其他元素处理它们。以下是三种主要的路由事件类型:

2. 直接事件

直接事件只在事件源元素上触发,不会在树结构中传播。以下是创建一个按钮的示例,点击该按钮将触发直接事件。

<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”。直接事件只会在按钮本身触发,不会向其他元素传播。

3. 冒泡事件

冒泡事件会从事件源开始,向上沿着视觉树传播。如果事件未被处理,它将继续向上传播,直到根元素。例如,我们可以使用 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");
}

当我们点击并释放鼠标按钮时,该事件触发并显示消息框。如果在树中的其他元素中未处理该事件,事件会继续向上传播。这就是冒泡事件的特性。

4. 隧道事件

隧道事件从根元素开始向下传播到事件源。这种事件的常见用法是预览事件(例如 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");
}

此事件在按钮内触发,但它会沿着视觉树向下传播,在抵达事件源之前可能被上层元素拦截。运行程序并点击按钮时,该事件会在释放鼠标按钮时立即触发。

5. 更多隧道事件和冒泡事件示例

为了展示隧道和冒泡事件的差异,我们可以添加更多的事件处理器,例如 PreviewMouseLeftButtonDownPreviewMouseRightButtonUp

<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");
}

通过这些处理器,我们可以观察到不同类型事件的行为:

总结

WPF 中的路由事件提供了灵活的事件处理机制,允许事件在视觉树中传播。理解不同类型的事件有助于我们更有效地控制事件流。以下是这三种事件的关键区别:

掌握这些事件类型,我们可以在 WPF 应用程序中更灵活地处理用户交互。

181 - 网格

欢迎回来

在本视频中,我们将学习如何使用 Grid 布局。我们之前讨论过 StackPanelWrapPanel,它们分别允许我们将元素垂直堆叠或按行排列。而 Grid 则提供了更强大的布局控制,允许我们将元素定位在一个网格中。我们将从简单的 2x2 网格开始,并逐步深入。

1. 创建基本的网格布局

首先,我们需要在 XAML 文件中定义列和行。可以使用 ColumnDefinitionsRowDefinitions 来定义网格的列和行。

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="100"/>
        <ColumnDefinition Width="100"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
</Grid>

上面的代码创建了一个网格,它包含 2 列,每列宽度为 100 像素,和 1 行,行高根据内容自动调整。

2. 向网格添加元素

接下来,我们可以在网格中添加按钮,并使用 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 像素。

3. 使用自动(Auto)和星号(*)宽度

除了固定宽度之外,我们还可以使用 Auto* 符号来控制列的宽度:

例如,将第一列的宽度设置为 Auto,第二列的宽度设置为 *

<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>

4. 控制宽度的比例

如果我们希望列之间的宽度按比例分配,可以使用多倍的星号。例如,我们希望第一列的宽度是第二列的两倍,可以设置如下:

<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="2*"/>

这会将总宽度分为 5 等份,第一个列占 3/5,第二个列占 2/5。

5. 使用行定义

我们还可以定义多行,并控制每行的高度。可以使用类似的方式定义 RowDefinitions

<Grid.RowDefinitions>
    <RowDefinition Height="*"/>
    <RowDefinition Height="*"/>
</Grid.RowDefinitions>

上面的代码会创建 2 行,且高度均等。

6. 定位到不同的单元格

我们可以通过指定 Grid.RowGrid.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"/>

7. 练习:创建 3x3 网格布局

练习:请尝试创建一个 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 个按钮和一个居中的文本块。

8. 组合布局

通过结合使用 GridStackPanel 等布局容器,我们可以实现灵活的布局控制。例如,可以将 Grid 放在 StackPanel 中,或者将 StackPanel 放在 Grid 的某个单元格中。

<StackPanel>
    <Grid>
        <!-- 网格内容 -->
    </Grid>
    <Button Content="Extra Button" Height="100"/>
</StackPanel>

总结

通过学习 Grid 布局,我们可以轻松地将控件精确地定位到窗口中的不同位置。这种布局方式使得用户界面设计变得灵活且易于控制。在后续视频中,我们将探索更多的 WPF 控件和布局,创建更加美观和复杂的界面。

182 - 数据绑定

欢迎回来

现在您已经了解了依赖属性,接下来是数据绑定的部分,因为它们是相互关联的。在本视频中,我们将介绍四种不同的数据绑定模式:

  1. OneWay:数据从绑定源(Source)单向流向绑定目标(Target)。
  2. TwoWay:数据在绑定源和绑定目标之间双向流动。
  3. OneWayToSource:数据从绑定目标流向绑定源。
  4. OneTime:数据只在初始化或构造时从源到目标单次绑定。

让我们通过示例来详细了解这些绑定模式的使用。


基础示例:连接 TextBox 和 Slider

我们将创建一个 TextBox 和一个 Slider,并使用不同的绑定模式连接它们。首先,让我们在 XAML 中添加以下控件:

<StackPanel>
    <TextBox Width="100" Margin="50"/>
    <Slider Minimum="0" Maximum="100"/>
</StackPanel>

运行这段代码后,您会看到一个可以输入文本的 TextBox 和一个 Slider。接下来,我们将通过不同的绑定模式连接这两个控件。

1. OneWay 单向绑定

首先,让 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 绑定属性,使其与 SliderValue 属性绑定,并指定 Mode=OneWay。现在当滑块的值改变时,文本框的值也会更新,但无法通过修改文本框来改变滑块的值。

2. TwoWay 双向绑定

如果我们希望 TextBoxSlider 之间能够双向同步,即文本框改变时滑块值也跟随改变,可以使用 TwoWay 模式:

<TextBox Width="100" Margin="50" Text="{Binding ElementName=mySlider, Path=Value, Mode=TwoWay}"/>
<Slider x:Name="mySlider" Minimum="0" Maximum="100"/>

运行后,您会发现无论是滑动滑块还是在文本框中输入新值,两个控件的值都会实时同步更新。

3. OneWayToSource 单向绑定至源

使用 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 中输入值时,滑块会跟随更新,但滑动滑块不会改变文本框中的值。

4. OneTime 单次绑定

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 和数据绑定的高级功能。

183 - INotifyPropertyChanged接口

欢迎回来

在本视频中,我们将讨论 INotifyPropertyChanged 接口,它允许我们在属性更改时触发某些操作。我们在上一篇视频中已经见过 PropertyChanged,当我们使用数据绑定时会涉及到它。今天我们将以稍微不同的方式来使用这个接口,并且你将看到如何自己使用它。

使用 StackPanel 而非 Grid

这次,我打算使用一个 StackPanel,因为我希望将元素堆叠起来。我将首先堆叠一个 Label,并将其内容设置为“number one”。然后我将关闭这个标签。

接下来,在标签下面,我将添加一个文本框,用于输入数值。我不会使用 TextBlock,而是使用 TextBox。设置好之后,我直接关闭它。

TextBox 中,我设置它的宽度为 100 像素,高度为 30 像素。接着我想设置文本内容,文本应该绑定到数据源,因此我将使用数据绑定。绑定的路径将是 number one,绑定模式设置为双向。

复制并修改代码

我们已经了解了绑定路径和模式的含义。现在我们复制这段代码并在此处粘贴两次。第一个文本框将绑定到 number two,第二个将绑定到 result,分别对应“number two”和“result”。

变量命名

你可能会问,为什么我使用 number onenumber tworesult,因为这些只是显示内容,并不是实际的变量名。为了连接这些内容,我们需要进入后台代码,因为我要在这里创建这些变量。

创建新类并实现接口

为了使用 INotifyPropertyChanged 接口,我们需要创建一个继承自它的新类。我们将创建一个名为 Some 的类,因为我们希望在输入 number onenumber two 的值后,底部的 result 会自动显示这两个数字的和。

为了使用 INotifyPropertyChanged,我们需要引入 System.ComponentModel 命名空间。所以我们在文件顶部添加 using System.ComponentModel

现在可以看到接口变成了绿色,这是 Visual Studio 中接口的标准颜色。接下来,我需要实现接口中的事件 PropertyChanged

实现属性更改事件

我们需要创建一个名为 OnPropertyChanged 的方法,接收一个 string 类型的参数 property。我们首先检查 PropertyChanged 事件是否为空。如果不为空,则调用 PropertyChanged 事件,将属性名称作为参数传递给它。

创建私有字段和属性

为了处理这些数据,我们首先创建三个私有字段:numOnenumTworesult。接下来,我们为这些字段创建相应的属性。

numOnenumTwo 属性采用稍微不同的定义方式,我不使用默认的 getset,而是自定义 getter 和 setter。在设置属性时,我们会尝试解析用户输入的值,以防用户输入的是非数字字符。如果解析成功,则更新字段值,并触发 PropertyChanged 事件,通知属性已经更改。

result 属性返回 numOnenumTwo 的和,并且每次它的值变化时,都会触发 PropertyChanged 事件。

更新界面并测试

为了测试这些更改,我们需要在 .xaml 文件中创建一个 Some 类的对象,并将其设置为数据上下文。这样我们就能通过绑定来更新 UI。

总结

在这次实现中,我们创建了一个类 Some,它继承自 INotifyPropertyChanged 接口,通过属性变更事件来自动更新结果。界面中的 TextBox 控件绑定到这些属性,确保每次用户输入新的数值时,result 会实时更新,显示两个数字的和。

通过这种方式,我们可以实现数据驱动的 UI,确保 UI 和数据始终保持同步。当用户输入数据时,result 会自动计算并显示新的值。这种实现方式适用于需要实时更新显示的场景,比如表单计算器等。

你可以根据需要进一步扩展这个例子,例如增加更多的输入框,或者将其修改为进行乘法等其他操作。

184 - ListBox与当前匹配列表

欢迎回来!在这个视频中,我们将创建一个列表框(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)将这些数据展示在界面上。我们将把 lbMatchesItemSource 设置为 matches,这样列表框就会显示所有比赛的信息。

接下来,我在界面上添加了一个按钮,用于显示选择的比赛详情。按钮的点击事件会触发 ShowSelected 方法,这个方法会检查是否有选中的比赛项,如果有,它就会弹出一个消息框,显示所选比赛的详细信息,例如:球队名称、得分等。

最后,我挑战你去尝试添加更多的比赛,你可以选择其他种类的比赛,进度条可以显示比赛进行的时间。你只需简单地复制粘贴比赛对象,并修改球队名称和得分。

在此之后,你可以运行程序,看到界面上显示的所有比赛以及它们的进度信息。你还可以进一步改进界面,例如调整得分显示的位置或为进度条添加动画效果等。

视频的最后,我们还可以扩展这个类,加入更多的信息,比如球员名单等。你可以创建一个新的属性 LineUp,用来存储球员的名称,并且在用户点击某个比赛时显示更多的球员信息。

这是我们视频的总结,涵盖了如何使用 ListBoxGrid 布局,还学习了如何使用数据绑定,将数据绑定到控件上显示。这个方法非常适合创建灵活且易于扩展的应用程序。希望你喜欢这个视频!

185 - ComboBox

欢迎回来。在这个视频中,我们将介绍组合框(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。这样,我们就能获取到这个组合框控件了。

接下来,我会设置这个组合框的 ItemSourceItemSource 将会是我们的颜色属性(Colors.GetProperties)。但是,我们不能直接这样使用它。因此,我们需要指定 Colors.GetProperties 的类型。这样我们就可以获取到所有可用的颜色。

这个类是 System.Windows.Media.Colors,其中有一个方法叫做 GetProperties,它会返回所有颜色的列表。这就是我们所需要做的。我们把 ItemSource 设置为这个颜色属性。我们当然可以使用一个对象列表来填充这些元素,但是这样做的方式非常简洁。正如你所看到的,只有一行代码,就能提供多种可选值。

因此,我们只需要一个包含所有这些颜色的组合框,你可以选择像巧克力色(Chocolate),深灰色(Dark Slate Gray)等等颜色。正如你所看到的,你可以将 ItemSource 设置为一个很棒的内置列表,或者你也可以创建自己的 ItemSource,它可以是来自数据库的数据,或者你自己创建的列表。

186 - CheckBox

欢迎回来。在这个视频中,我们将创建一个披萨配料应用程序,它允许我们在想要披萨送达时随时添加配料。所以,当我们点披萨时,我们可以选择添加配料,比如萨拉米香肠、蘑菇或者额外的马苏里拉奶酪。如果我们将所有这些配料都添加上,正如你所看到的,顶部的“添加所有”选项会被激活,并且它有三种状态,如你所见,它有一个“是”(True)状态,一个空状态,和一个“否”(False)状态。

好了,这就是我们要创建的内容。接下来,让我们开始吧。首先,我们需要为界面腾出更多空间,我将使用 Stack Panel 代替 Grid。再次强调,Stack Panel 的使用是因为 Grid 设置起来比较麻烦,特别是当你需要一个比较简单的布局时,Stack Panel 会更加方便。

接下来,我想要添加一个标签(Label),只是一些文本,文本的字体粗细为加粗(bold)。文本内容将是“披萨配料”。然后,我需要一个复选框(CheckBox)。我将创建一个名为 KB 的复选框,代表“所有配料”的选择框,这个复选框将会支持三种状态。什么是三种状态呢?就是它与其他复选框连接,当它被选中时,它表示所有的配料都被选中了。它的状态包括:

  1. 选中(True)
  2. 未选中(False)
  3. 空状态(即介于选中和未选中之间)

因此,这就是为什么我们需要三种状态。然后,我们可以添加选中和未选中的事件处理方法(checked/unchecked)。所以我将手动创建一个新的事件处理程序。你们已经知道如何创建事件处理程序,可以自己试着做一下。接下来,让我们创建这个事件处理程序。我们将其命名为 CBAllCheckedChanged,并且需要传入对象 sender 和事件参数 e

当复选框被选中时,事件 CBAllCheckedChanged 就会被调用,未选中时也是如此。所以,我需要为这两种情况编写相应的方法,确保它们能够同时处理。

接下来,我们为复选框添加文本:“添加所有”。现在,我们已经设置了一个大复选框,接下来在它下面创建一个 Stack Panel,其中包含另外三个复选框。我们为每个配料(如萨拉米、蘑菇、马苏里拉)都创建一个复选框。每个复选框都会有自己的事件处理方法。我为每个复选框创建一个单独的事件处理方法,命名为 SingleCheckedChanged,每个复选框的名称分别为 CheckBoxSalamiCheckBoxMushroomsCheckBoxMozzarella

复选框文本的显示会通过嵌套的 TextBlock 来实现。例如,萨拉米复选框会显示为“辣味”(spicy),并且通过设置 ForegroundFontWeight 来使文本更加美观。

接着,其他两个复选框(蘑菇和马苏里拉)也按照相同的方式创建,但没有像萨拉米那样增加额外的文本效果。复选框文本分别为“蘑菇”和“马苏里拉”。

为了使界面更加整齐,我为“添加所有”复选框和下面的三个复选框之间增加了一个边距(例如 5 或 10 像素),使它们看起来属于同一组。这样一来,“添加所有”复选框就与下面的配料复选框紧密关联。

现在,XAML 文件已经设置好了。接下来,我们需要进入后台代码(code-behind)并实现这些方法。我们已经声明了这些方法,但是它们目前还没有执行任何操作。我们可以通过以下方式修改它们:

  1. 添加所有配料的逻辑:首先,我们定义一个布尔值 newVal,它的值取决于“所有配料”复选框是否被选中。如果被选中,则 newValtrue,否则为 false。然后,我们将这个值应用到每个配料复选框中。

  2. 单个配料复选框的状态:当用户选中或取消选中单个配料复选框时,我们会检查所有配料复选框的状态。如果所有配料都被选中,那么“添加所有”复选框也会被选中;如果任何一个配料未被选中,那么“添加所有”复选框会被取消选中。

下面是实现这个逻辑的代码示例:

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
    }
}

通过这种方式,我们就能够控制复选框的三种状态——选中、未选中以及空状态。

现在,你可以运行这个应用,当你点击“添加所有”复选框时,所有配料都会被自动选择或者取消选择。如果你手动选择或取消某个配料,其他配料和“添加所有”复选框的状态也会相应改变。

这就是如何使用复选框和三种状态来创建一个披萨配料应用程序。当然,你也可以像我这样,给所有复选框使用相同的事件处理方法,或者你可以为每个复选框创建独特的方法,甚至使用消息框来提示用户他们选择了哪些配料。

非常简单明了的实现方式。如果你有任何问题,欢迎留言!下个视频我们将继续学习下一个主题。

187 - ToolTip

欢迎回来。在这个视频中,我想展示一个简单而快速的功能——如何为控件添加提示框(Tooltip)。为了演示这个功能,我创建了一个新的 WPF 项目,并在 Grid 中添加了一个按钮(Button)。按钮有一个名为 Tooltip 的属性,我们可以在这个属性中设置当用户悬停在按钮上时显示的提示内容。我将在提示框中输入一些文本,比如:“我是一个提示框,我很有用”这样简单有用的内容。

然后,我还可以给按钮添加文本,文本内容是“悬停查看更多信息”。同时,我将按钮的宽度设置为 150 高度设置为 100,使其稍微小一点。

现在,我们来看一下效果。你可以看到,当我们将鼠标悬停在按钮上时,提示框显示了“我是一个提示框,我很有用”。这就是如何使用提示框(Tooltip),它可以应用于多种不同的控件,不仅仅是按钮,也适用于其他控件,比如文本块(TextBlock)。

接下来,我创建了一个文本块(TextBlock),并为其添加了一个提示框(Tooltip)。当我将鼠标悬停在文本块上时,提示框会显示“请输入下面的年龄”这样的信息。这可以非常有效地帮助用户理解控件的功能。

当然,文本块本身不能接收文本输入,但如果我们在它下面添加一个文本框(TextBox),我们同样可以为文本框添加提示框。所以,提示框非常适用于为用户提供额外的信息,特别是在某些地方可能不够清楚时。

好了,这就是提示框的基本用法。在下一个视频中,我们将学习如何使用单选按钮(Radio Buttons)。敬请期待!

188 - 单选按钮与图像

欢迎回来。在这个视频中,我们将学习如何使用单选按钮(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; 来设置默认选中的项。

总结

通过这个示例,你学会了如何使用单选按钮以及如何为它们添加图像和事件处理程序。你还了解了如何设置默认选中项,并且学会了如何在事件发生时响应用户的选择。

189 - 属性数据与事件触发

在这个视频中,我想讨论一下属性数据和事件触发器,因为它们在动态调整界面时非常重要。举个例子,我将调整文本内容。当我将鼠标悬停在文本上时,文本的颜色和外观都会发生变化。接着是这个内容,“你好,伙伴”。你可以看到它的大小变得更大了,这里有一个动画效果。然后我们有一个复选框,旁边有一个文本显示“没有”,当我点击它时,文本会显示“哦,当然有人在这里,那就是我”。是的,我可以点击它。这些是我们将在本视频中讨论的内容,它们可以帮助你在制作用户界面时更加灵活,让界面在运行时自适应变化,并且提升用户体验。所以,让我们先创建一个新项目,我将它命名为“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,它们会在IsMouseOvertrue时执行。我们来运行代码,看看实际效果。你会发现,当我将鼠标悬停在“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中创建更复杂的界面逻辑。

190 - PasswordBox

欢迎回来。在本视频中,我们将结合使用文本框和密码框,来创建一个简单的登录界面。让我们开始吧。

首先,我将放大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个字符的最大长度会更为合理。

总结

通过结合使用 TextBoxPasswordBox 控件,我们可以创建一个简单的登录界面。通过密码框,我们可以保护用户的密码信息,并且还可以通过设置属性来自定义密码框的显示字符、限制最大字符长度等功能。

191 - WPF总结

好的,现在我们完成了这一章节,虽然这章节内容比较长、复杂,但也非常吸引人。因为我们终于能够看到一个用户界面,终于可以看到我们编写的代码实际呈现出来了。在这部分内容中,我们学习了一些相对复杂的知识点,比如数据绑定、依赖属性、INotifyPropertyChanged 接口等。虽然这些概念看起来比较复杂,但一旦你亲自创建了一些用户界面,或者多次亲自动手实现这些界面后,这些知识点会变得如同第二天性一样。

所以即使现在看起来有些困难,但只要你不断地练习,自己多做几次,最终你会掌握这些内容的,别担心。

好了,这就是本章节的内容。我们学习了如何创建用户界面,如何将这些内容串联起来,看到我们到目前为止所学的所有代码如何汇聚在一起形成一个较为复杂的部分。但这并不是结束。在课程的后续部分,我们还会再次使用 WPF 来创建更多的用户界面。所以,我们下一个章节再见!

WangShuXian6 commented 2 weeks ago

14 - WPF(Windows Presentation Foundation)

192 - 安装WPF工作负载

欢迎来到本节课程。在这一节中,我们将学习如何创建一个 WPF 项目。首先,你需要打开 Visual Studio 安装程序。在左下角搜索框中输入 Visual Studio,然后选择你的 Visual Studio 版本。现在,我选择的是 2022 版,然后点击“修改”。

接下来,给它一点时间来加载。我们将进入工作负载页面。请注意,如果你是 Linux 或 macOS 用户,你将无法找到或安装 "Dotnet Desktop Development" 工作负载(即用于构建 WPF 和 Windows Forms 的工作负载)。因此,如果你使用的是 Windows 电脑,请勾选这个框,然后点击“安装”。在下载并安装该工作负载后,你就能开始使用 WPF。

然而,如果你是 Linux 或 macOS 用户,你将无法运行和构建 WPF 应用程序。原因是 WPF 是专门为 Windows 电脑设计的。因此,你有三种选择:

  1. 你可以仅观看课程视频,而不参与实际操作;
  2. 或者,你可以使用 Windows 电脑,直接在其上跟随课程;
  3. 另外,你还可以安装虚拟机,例如安装 Windows 10 或 Windows 11,然后在虚拟机中跟随课程进行操作。

这点需要特别提醒。如果你使用的是 Windows 电脑,勾选此框并点击“安装”,等待它安装完成。接下来,我们将在下一个视频中设置项目。

193 - 创建WPF项目

现在我们来看看如何设置一个新的 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),你可以直接使用它来创建用户界面元素。

好了,我们在下一节课中继续学习。

194 - WPF项目结构与代码后置文件

你刚刚创建了你的第一个 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 创建你的第一个图形用户界面元素了。让我们在下一节课中开始吧。

195 - 创建我们的第一个GUI元素

欢迎回来。现在我们来看看我们当前的应用程序。正如我在上一节视频中提到的,当前应用程序的图形用户界面的 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 表示中作为一个新元素被写下。每个元素都有许多可以调整的属性,这一点非常重要。

196 - 创建带有列和行的网格

在本节课中,我们将探讨 Grid 控件。首先,我想提到一点,在第八行(例如,我的代码中)你可以看到标题是 Main Window,这正是你在图形用户界面中看到的 Main Window。我们可以为其设置一个高度和宽度,比如150和800。如果我们想调整这些值,只需在这里修改数字即可。现在,应用程序的大小被固定为500像素。我只是想展示一下,你完全可以调整这些属性。

接下来,我们有一个 Grid,但目前它看起来不像一个网格。Grid 是由列和行组成的。可以把它想象成 Excel 表格,例如,你有三列,然后有三行或者五行。如果我们想要在 Grid 中创建行,我们需要添加 Column DefinitionsRow 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像素等)。这样做在某些场景下有用,比如创建边框,但在大多数情况下,我们希望布局尽可能动态。接下来的课程中,我们将深入探讨如何使这个布局更灵活,这一点非常重要,请一定要关注!

197 - 固定、自动与相对大小

让我们深入探讨一下 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 系统来动态定位和调整元素,而不需要依赖硬编码的定位、填充或边距等数值。通过这种方法,你可以创建更灵活、响应式的布局,适应不同的屏幕大小和内容变化。

198 - 创建一个完美的网格

使用相对大小创建完美的网格

如果我们想要创建一个完美的网格布局,实际上非常简单。我们可以对每一行和每一列使用相对尺寸来实现。现在,我将为所有的列和行分配相对大小(使用星号 *),这样每一行和每一列都会平分剩余空间,使得网格看起来更加均匀。

设置列的相对大小

首先,我们为每一列设置相对大小,方法是为每一列的宽度使用 *``**,这样每列都会根据剩余空间自动调整宽度。如下所示:

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>

通过这种方式,我们已经为每一列分配了相同的空间,每列的宽度会根据总宽度均匀分配。

设置行的相对大小

接下来,我们对每一行进行相同的设置,使用 *``** 来为每一行设置相对高度。这会让所有的行在父容器内均匀地分配垂直空间。例如:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="*" />
        <RowDefinition Height="*" />
        <RowDefinition Height="*" />
    </Grid.RowDefinitions>

结果

现在,所有的列和行都使用了相对大小 *,使得网格中的每一列和每一行都占据均等的空间。你会看到在设计视图中,整个网格被完美地分配和对齐,每个单元格的大小相同,确保了视觉上的一致性。

总结

通过将 *``** 用于列和行的宽度或高度,你可以非常方便地创建一个均匀分布、动态适应的网格布局,而不需要担心硬编码的尺寸。无论屏幕大小如何变化,网格都会根据剩余空间自动调整,使得布局保持响应式且灵活。这是 WPF 布局系统中非常强大和常用的特性之一。

199 - WPF挑战:重建此GUI

WPF 挑战:创建布局

在这一课中,我们将继续专注于布局,特别是网格布局。你将有一个挑战,尝试自己重现一个简单的 WPF 应用程序布局。这主要是为了让你对使用 XAML 创建图形用户界面变得更加熟悉。

挑战描述

你需要创建一个界面,其外观与我所创建的布局类似。这个练习的重点是让你习惯在 WPF 中使用网格布局(Grid)。我们暂时不关注功能实现,而是专注于布局的设计。

布局要求

  1. 外部行和列:添加一些行和列来创建间距。那些外部的行和列的宽度应该设置为 10像素

  2. 中间列:在中间增加一个新的列,并且设置这个列的宽度为 10像素

  3. 控件添加

    • 标签(Label):在适当的位置添加两个标签(Label)。
    • 按钮(Button):在某些区域放置两个按钮,分别标记为 AB

提示

创建步骤

  1. 定义外部行列:

    <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>
  2. 添加控件:

    • 标签1:放置在第二行第二列。
    • 标签2:放置在第三行第二列。
    • 按钮A:放置在第二行第一列。
    • 按钮B:放置在第三行第一列。

    示例代码:

    <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 中定义的行和列位置进行自动排列。通过使用相对布局方式,你的界面将在不同的屏幕尺寸下自适应调整,保持响应式。

如果在完成这个任务时遇到困难,可以查看下一节课,我们将从头开始一起做一遍。

200 - WPF挑战解决方案:重建此GUI

本节讲解:布局挑战及列和行跨越

在本节课程中,我们将继续探讨上一个视频中的挑战,并一起重现之前创建的布局。你将学到如何通过 XAML 使用网格布局来控制元素的位置,以及如何利用网格的属性来使布局更具响应性。

布局要求

首先,我们需要通过增加更多的行和列来创建外部间距。这是为了确保我们的布局在视觉上更加整洁,并且在需要时能为控件提供足够的空间。

  1. 添加列:我们在两侧添加了新的列,并将宽度设置为 10 像素。这样可以为布局提供额外的边距。

    • 第一个列:宽度设置为 10 像素(左侧)。
    • 最后一个列:宽度设置为 10 像素(右侧)。
    • 中间的列:宽度设置为 10 像素,这样按钮可以放在这个列中。
  2. 添加行:为了创建垂直的间距,我们也在顶部和底部添加了新的行,设置高度为 10 像素。

步骤说明

  1. 添加列和行

    在你的 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>
  2. 放置控件

    • 第一个标签:将标题标签 Title of App 移动到中间列。
    • 第二个标签:添加一个新的标签并设置内容为 Sample Text,放置在合适的位置。
    • 按钮 A 和 B:通过拖拽或双击工具箱中的按钮控件,分别添加两个按钮。按钮 A 放在第三行第二列,按钮 B 放在第三行第三列。

    以下是放置控件的 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>
  3. 调整行高度

    你可能会注意到按钮的高度较小。如果你希望按钮所在的行具有更高的高度,可以在 RowDefinition 中设置一个固定高度,如 50 像素。

    <Grid.RowDefinitions>
       <RowDefinition Height="10" />
       <RowDefinition Height="*" />
       <RowDefinition Height="50" /> <!-- 为按钮所在的行设置更大的高度 -->
    </Grid.RowDefinitions>

实现布局

通过以上步骤,你应该能创建出一个具有外部边距的网格布局,内部包括两个标签和两个按钮。你可以看到标题和样本文本标签自动适应列宽,并且按钮在底部对齐。

列和行跨越

接下来,你将学到如何通过 ColumnSpanRowSpan 来让控件跨越多列或多行。这样,当你增加内容时,控件能够自动扩展其宽度或高度,而不被限制在单个列或行内。

例如,如果你希望标题标签跨越多个列以适应更长的文本,可以使用 ColumnSpan 属性来实现这一点:

<Label Content="Title of App" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" HorizontalAlignment="Center" VerticalAlignment="Center" />

通过 ColumnSpan="3",标题标签将跨越三列,确保它能根据内容自动扩展。

总结

通过这个练习,你应该对使用 XAML 来设计布局变得更加熟悉。你学会了如何:

在下节课中,我们将继续深入探讨如何使用 ColumnSpanRowSpan 来进一步提升布局的灵活性。

201 - 列跨越与行跨越

本节讲解:列跨越和行跨越

在本节课程中,我们将继续深入学习 Grid 系统,并探讨如何使用 ColumnSpanRowSpan 来让控件跨越多列或多行。这样做可以使控件在网格中占据更多的空间,适应不同大小的内容。

什么是 ColumnSpan 和 RowSpan?

Grid 布局中,控件默认仅占据一个单一的行和列。然而,如果我们希望某些控件(如文本)在视觉上跨越多个列或行,我们可以使用 ColumnSpanRowSpan 属性。

实现 ColumnSpan 和 RowSpan

我们以示例文本 Sample Text 为例来说明如何设置跨越。

  1. 计算列和行的跨度

    • 假设我们希望 Sample Text 标签跨越从第二列到第四列(即从中间列到右侧列),我们就需要设置它的跨度。
    • 我们首先需要确认元素所在的列。假设我们的网格有四列,列编号分别是:0123
    • Sample Text 所在的列是第二列(Column 2),并且我们希望它横跨到第四列。
    • 因此,ColumnSpan 的值应该设置为 3(即跨越三列:从列 2 到列 4)。
  2. 设置 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":表示该标签跨越三列,从第二列到第四列。
  3. 增加文本内容来观察效果: 如果我们将 Sample Text 的内容增加,文本长度变长,跨越的列也会相应增加。例如,如果你复制并增加文本内容,Sample Text 标签将继续跨越更多的列,直至达到网格的边缘。

    例如,增加文本后,跨越的列范围可能变为从第三列到第五列,这样就会占据更多空间。

观察结果

在 XAML 代码中应用 ColumnSpan 后,你会发现 Sample Text 标签已经跨越了多列。通过增加内容并调整标签的宽度,你可以看到该标签的宽度和跨度自动调整。

当你运行应用时,界面会呈现一个干净的布局,去除了辅助线,最终呈现的应用看起来如下:

这就是你使用 Grid 系统创建的布局,效果非常不错!

总结

通过本节课程,你已经学会了如何使用 ColumnSpanRowSpan 来调整控件的布局,使其能够跨越多个列或行。你也学会了如何通过这些属性来动态调整控件的大小和位置,确保它们适应不同的内容大小。

接下来,我们将进入下一阶段,开始学习如何结合 C# 编程来为你的应用程序添加功能。

祝贺你完成了网格系统的学习!这是一个非常好的起步,接下来让我们继续探索更多精彩的内容。

202 - 用C#创建GUI元素

在 WPF 中使用 C# 动态创建元素

在 WPF 中,你不仅可以使用 XAML 来定义界面元素,还可以通过 C# 代码动态地创建和操作这些元素。今天我们将介绍如何在 C# 中使用代码创建、设置位置并将元素添加到布局中。

创建按钮并将其添加到网格中

在本节中,我们将展示如何在 C# 中创建一个按钮并将其动态添加到 Grid 中。假设我们已经在 XAML 中有一个网格布局,现在我们需要通过 C# 动态创建按钮,并将其定位到特定的行和列。

1. 重置布局

首先,我们将重置窗口布局,确保我们可以重新开始,并且能够访问 MainWindow 中的所有文件。

this.ResetLayout();

通过此步骤,我们将移除所有按钮,并仅保留 row 3, column 4 中的内容。

2. 定义按钮并设置属性

接下来,我们将在 MainWindow 的代码后文件中创建一个按钮。首先进入代码后文件(即 MainWindow.xaml.cs),并在构造函数中开始编写代码。

在 C# 中,我们首先需要创建一个按钮实例。每当我们创建一个控件时,都需要使用 C# 的面向对象方式进行实例化。例如:

Button myButton = new Button();

这样,我们就创建了一个新的按钮对象 myButton

3. 设置按钮的内容

按钮的内容可以通过设置 Content 属性来进行定义。这个属性定义了按钮上显示的文本或控件内容。例如:

myButton.Content = "A";

这样,按钮上就会显示文本 "A"。

4. 设置按钮的位置

接下来,我们需要将按钮添加到 Grid 布局中的特定位置。在 XAML 中,我们通常使用 Grid.RowGrid.Column 来指定行和列,而在 C# 中,我们可以通过调用 SetRowSetColumn 方法来进行相同的操作:

Grid.SetRow(myButton, 3);  // 将按钮放置在第三行
Grid.SetColumn(myButton, 4); // 将按钮放置在第四列
5. 查找并访问 Grid 控件

为了将按钮添加到布局中,我们需要获取到 Grid 控件的引用。在 XAML 中,我们可以给控件指定一个名称,这样我们就能通过该名称在 C# 中访问它。例如,我们可以为 Grid 添加一个 x:Name 属性:

<Grid x:Name="myGrid">
    <!-- 网格内容 -->
</Grid>

然后,在 C# 中使用 FindName 方法来查找这个控件,并将其转换为 Grid 类型:

Grid myGrid = (Grid)this.FindName("myGrid");

FindName 方法返回一个 object 类型,所以我们需要将其转换为 Grid 类型,以便能够使用它的特定方法。

6. 将按钮添加到 Grid

现在,我们有了 Grid 的引用,可以使用 Children.Add 方法将按钮添加到 Grid 的子控件集合中:

myGrid.Children.Add(myButton);

通过这一步,按钮就被成功添加到 Grid 中,并且位于我们之前设置的行和列位置。

完整的 C# 代码示例

将上述步骤结合起来,完整的 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 中定义的那样。

总结

通过这节课,你已经学会了如何在 C# 中动态创建和管理 WPF 元素,并将它们添加到布局中。这为你将来开发更复杂的动态应用提供了强大的工具。

203 - 元素属性:样式与定位

使用属性窗口查看和调整元素属性

在 WPF 中,我们可以通过 XAML 或 C# 来创建和调整 UI 元素。例如,按钮、标签和其他控件都有许多可以自定义的属性,比如字体大小、颜色、文本样式等。如果我们想要快速查看这些属性并进行调整,最简单的方法之一是使用 属性窗口

打开属性窗口

  1. 重置窗口布局
    如果你没有看到属性窗口,可以通过点击 Window 菜单中的 Reset Window Layout 来恢复默认的布局。

  2. 选择元素
    选择你想查看或调整的控件,比如按钮。你可以点击控件,确保它被选中。

  3. 查看属性
    在右侧的属性窗口中,你将看到当前选中控件的所有属性。例如,如果你选中了一个按钮,你可以看到它的属性,如字体大小、文本样式、颜色等。

示例:调整按钮的字体大小

示例:设置按钮的字体加粗

其他可调整的属性

  1. 对齐方式
    你可以设置按钮文本的水平和垂直对齐方式,比如 HorizontalAlignmentVerticalAlignment。这将决定文本在按钮内的位置。例如,设置 HorizontalAlignmentCenter 可以使文本水平居中。

  2. 颜色设置
    属性窗口允许你轻松地设置按钮的背景色。例如,选择 Background 属性,你可以设置按钮的颜色,像是 RedBlue。这在 UI 设计中非常有用。

  3. 布局设置
    你还可以设置控件的 宽度高度。如果设置为 Auto,控件的大小将自动调整以适应其内容。同时,你还可以设置 MarginPadding 来控制控件周围的空间。

  4. 透明度
    你可以通过 Opacity 属性调整控件的透明度,使控件变得更透明或完全不透明。

  5. 行列设置
    对于布局在 Grid 中的控件,属性窗口会显示该控件所在的 RowColumn 位置,甚至可以设置 RowSpanColumnSpan 属性,来让控件跨越多个行或列。

XAML 和 C# 的关联

无论是通过 XAML 编写还是通过属性窗口调整,所有的这些属性最终都会映射到 XAML 或 C# 代码中。例如,在属性窗口中调整了按钮的 FontSizeBackground 后,XAML 文件中相应的属性会自动更新。因此,了解如何在属性窗口中调整这些属性,可以帮助你更快地理解和操作 XAML 或 C# 代码中的相应属性。

小贴士

通过使用属性窗口,你可以更直观地了解 WPF 元素的可调整属性,从而提高开发效率并更好地控制界面设计。

204 - 按钮点击与事件处理

创建按钮事件处理程序

在本视频中,我们将学习如何创建一个 按钮事件处理程序,即当用户点击按钮时,执行某些操作。我们还将看到如何使用 MessageBox 显示信息。

步骤 1:创建按钮并移除不需要的内容

我们首先移除代码后端文件中的第二个按钮,以简化操作。这是一个简单的测试项目,我们只需要一个按钮来演示。

步骤 2:定义点击事件

每个 WPF 控件(如按钮)都可以触发一些事件。在这个例子中,我们使用的是按钮的 Click 事件。当用户点击按钮时,这个事件会被触发,并执行相应的代码。

步骤 3:处理点击事件

  1. 自动生成事件处理方法
    当你双击事件图标时,Visual Studio 会自动为你创建一个新的方法。该方法通常会命名为 Button_Click 或类似的名称,这个方法会在按钮被点击时执行。

  2. 查看事件处理方法
    事件处理方法的代码会如下所示:

    private void Button_Click(object sender, RoutedEventArgs e)
    {
       MessageBox.Show("Hello World");
    }
    • sender 参数指向触发事件的对象(在这个例子中是按钮)。
    • RoutedEventArgs 是与事件相关的其他信息,在这里不需要特别关注。

    在事件处理方法中,我们通过 MessageBox.Show("Hello World") 来显示一个消息框,通知用户按钮已被点击。

步骤 4:测试事件

  1. 启动应用程序
    现在,我们启动应用程序并点击按钮,应该能看到一个消息框弹出,显示 "Hello World"。

  2. 修改事件名称
    如果我们将事件处理程序的方法名称修改为 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" />

    然后,重新启动应用程序,这时点击按钮应该会正确显示消息框。

步骤 5:事件驱动编程

总结

通过这种方式,我们可以让用户与应用程序进行交互,并通过按钮点击等事件执行相应的代码。通过为控件(如按钮)添加事件处理程序,我们能够使我们的应用程序具有更强的交互性。这是构建任何应用程序的基本要素之一。

现在,我们已经掌握了如何处理按钮的点击事件,接下来我们可以开始着手创建我们的第一个简单应用程序,开始实现更有趣的功能。

205 - 待办事项列表应用简介与项目设置

创建待办事项应用程序

在这一课中,我们将开始构建一个简单的 待办事项应用程序,用来巩固你在 WPF 中学到的知识,同时也会学习一些新的概念。应用的功能很简单,我们可以输入待办事项,并将它们添加到列表中,显示在一个可滚动的文本框里。

步骤 1:创建新项目

首先,创建一个新的 WPF 应用程序。步骤如下:

  1. 打开 Visual Studio,点击 创建新项目
  2. 选择 WPF 应用程序,然后点击 下一步
  3. 为项目命名为 TodoApp
  4. 选择你想使用的 .NET 版本(建议选择 .NET 7)。
  5. 点击 创建,稍等片刻,项目将会加载完成。

步骤 2:设置窗口大小和标题

  1. 调整窗口大小
    打开 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。

  2. 设置应用程序名称
    在同一文件中,修改 Title 属性为 Todo app,以便窗口显示正确的名称。

步骤 3:设置窗口尺寸和大小调整

  1. 禁用窗口调整大小
    如果你不希望用户调整窗口大小,可以在 XAML 文件中设置 ResizeModeNoResize,这样用户就无法拖动窗口边缘来改变大小:

    <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">
  2. 测试应用程序
    启动应用程序,你会发现窗口不能调整大小、最大化或最小化。

步骤 4:准备开始布局

现在你已经设置好了应用的基础,可以开始创建待办事项应用的布局。在下一课中,我们将继续创建 UI 组件,比如输入框、按钮和显示待办事项的区域。

这就是我们开始构建待办事项应用的第一步,接下来我们会创建更详细的布局和交互功能,继续你的 WPF 学习之旅!

206 - 创建网格按钮与文本框

设置应用程序的布局

在这一课中,我们将继续构建我们的 待办事项应用程序,并重点关注布局的设置。接下来,我们会利用你之前学到的知识,创建网格系统,并在其上添加各种界面元素。

步骤 1:创建网格布局

首先,我们有一个空的 Grid 元素。通过设置 Grid 的列和行定义,我们可以构建应用的布局。

  1. 定义列和行
    在 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>
    • 列定义

      • 第一列宽度为 10 像素,用作边距。
      • 第二列宽度为自适应(*),占用剩余空间。
      • 第三列宽度为 10 像素,用作边距。
    • 行定义

      • 第一行和第六行的高度为 10 像素,用作边距。
      • 第二行高度为自适应,用来显示待办事项列表。
      • 第三行的高度设置为 75 像素,作为文本框的空间。
      • 第四行的高度为 10 像素,再次作为边距。
      • 第五行的高度设置为 50 像素,用来显示按钮。

步骤 2:添加按钮和文本框

接下来,我们将添加按钮和文本框到布局中。

  1. 添加按钮
    我们创建一个按钮,让用户可以点击添加新的待办事项。将其放置在第一列和第五行中:

    <Button Grid.Row="4" Grid.Column="0" Content="Create Todo" />

    这会创建一个位于左上角的按钮,标签为 "Create Todo"。我们稍后将为此按钮添加事件处理。

  2. 添加文本框
    然后我们添加一个文本框,让用户可以输入待办事项的内容。将其放置在第一列和第三行中:

    <TextBox Grid.Row="2" Grid.Column="0" />

    这样文本框就会出现在中间的区域,用户可以在其中输入待办事项。

步骤 3:调整颜色和样式

为了确保界面友好和可用,我们需要调整文本框的背景色和前景色(文本颜色):

  1. 设置背景色
    在右侧的属性窗口中,选择 TextBox,然后设置背景色。我们可以设置为深色背景,以便与文本框的白色文本区分开:

    <TextBox Grid.Row="2" Grid.Column="0" Background="#333" />
  2. 设置前景色
    为了确保文本可见,我们将前景色设置为白色:

    <TextBox Grid.Row="2" Grid.Column="0" Background="#333" Foreground="White" />

    现在,文本框有了深色的背景和白色的文本,用户可以清晰地看到他们输入的内容。

步骤 4:添加滚动视图和堆叠面板

为了显示待办事项列表,我们需要使用 ScrollViewerStackPanel

  1. 添加滚动视图
    使用 ScrollViewer 来包装待办事项列表,允许用户滚动查看所有待办事项。如果待办事项过多,它们将超出视窗时,用户可以滚动查看:

    <ScrollViewer Grid.Row="1" Grid.Column="0">
       <StackPanel>
           <!-- 待办事项项将放在这里 -->
       </StackPanel>
    </ScrollViewer>

    这里,ScrollViewer 使得内部的 StackPanel 可滚动。待办事项将以垂直排列的方式显示在 StackPanel 中。

  2. 添加待办事项
    我们会在后续使用 C# 代码动态地将待办事项添加到 StackPanel 中。在此之前,我们只需确保布局设置正确。

步骤 5:测试布局

现在,应用程序的主要布局已经完成。你可以启动应用程序,检查界面布局是否符合预期:

结论

到此为止,我们已经完成了应用的基本布局:包括网格定义、按钮、文本框、滚动视图和堆叠面板。接下来,我们将继续添加事件处理程序,并编写 C# 代码来处理待办事项的增删改查。

207 - 创建滚动视图与堆栈面板

添加滚动视图和堆叠面板

在这一课中,我们将继续构建待办事项应用程序,专注于添加滚动视图和堆叠面板。这两个元素将使我们能够展示动态生成的待办事项列表。

步骤 1:添加滚动视图(ScrollViewer)

我们需要一个滚动视图来确保当待办事项超过显示区域时,用户可以滚动查看所有的待办事项。首先,打开左侧的工具箱,查看可用的控件。你会找到 ScrollViewer(滚动视图),这是我们需要的元素。

  1. 添加 ScrollViewer
    双击工具箱中的 ScrollViewer,它会自动添加到 XAML 文件中。你也可以直接手动输入来添加它。

    <ScrollViewer Grid.Row="1" Grid.Column="1" VerticalScrollBarVisibility="Auto">
       <StackPanel>
           <!-- 待办事项将添加到这里 -->
       </StackPanel>
    </ScrollViewer>
    • Grid.Row="1"Grid.Column="1":将滚动视图放置在网格的第二行和第二列(索引从 0 开始计数)。
    • VerticalScrollBarVisibility="Auto":根据内容的大小,自动显示垂直滚动条。如果内容过多,滚动条会出现,允许用户滚动查看。

步骤 2:添加 StackPanel

StackPanel 是一个非常适合垂直排列元素的容器,正好适用于我们的待办事项列表。我们将其放在 ScrollViewer 内部,这样它就能在滚动视图中垂直排列所有待办事项。

<StackPanel>
    <TextBlock Text="待办事项 1" />
    <TextBlock Text="待办事项 2" />
    <!-- 更多待办事项 -->
</StackPanel>

每个待办事项都将是一个 TextBlock 元素,自动堆叠在一起,形成一个垂直列表。你可以进一步设置样式、背景颜色等,但目前我们先保持简单。

步骤 3:设置背景颜色(可选)

如果你希望对滚动视图和堆叠面板进行样式调整,可以通过设置背景颜色来改变它们的外观。例如,可以为 StackPanel 设置一个浅灰色的背景,或者使用其他颜色来符合你的设计风格。

  1. 设置 StackPanel 背景色
    选择 StackPanel 元素,然后在属性窗口中选择 Background。你可以选择一种颜色,比如浅灰色:

    <StackPanel Background="#f0f0f0">
       <!-- 待办事项列表 -->
    </StackPanel>
  2. 选择合适的颜色
    你可以选择不同的颜色,例如深色、蓝色或其他喜欢的颜色。重要的是要确保颜色与其他界面元素区分开来,避免用户混淆。

步骤 4:测试布局

完成布局后,我们可以启动应用程序,查看界面的效果。此时,滚动视图和堆叠面板应该会显示在应用窗口中,虽然它们还没有实际的内容。

  1. 运行应用程序
    启动应用程序时,你会看到滚动视图和堆叠面板已经添加到界面中,但目前没有待办事项显示在其中。接下来,我们需要为它们添加功能。

步骤 5:为元素命名

为了能够在后台代码中访问这些元素并添加实际的功能,我们需要为 TextBoxStackPanel 元素设置 Name 属性。这样,我们就能从 C# 代码中访问它们,并动态地向界面添加待办事项。

  1. 为 TextBox 设置名称
    在 XAML 文件中,为 TextBox 添加一个名称属性,这样我们就可以在代码中访问它:

    <TextBox Name="todoInput" Grid.Row="2" Grid.Column="1" />
  2. 为 StackPanel 设置名称
    同样地,我们为 StackPanel 添加名称,以便在 C# 中访问它:

    <StackPanel Name="todoListPanel" Background="#f0f0f0">
       <!-- 待办事项项 -->
    </StackPanel>

步骤 6:下一步

在接下来的课程中,我们将开始编写 C# 代码,处理按钮点击事件,从 TextBox 中读取内容,并将新的待办事项作为 TextBlock 添加到 StackPanel 中。

总结

到目前为止,我们已经成功添加了滚动视图和堆叠面板,并配置了它们的基本样式和布局。接下来,我们将通过 C# 代码实现待办事项的动态添加功能,进一步完善我们的应用程序。

208 - 设置x名称属性以便访问

赋予元素名称属性并设置事件处理器

现在我们已经创建了整个布局,并包含了所有必要的元素,接下来我们将进入下一步:为这些元素设置 Name 属性,以便能够在后端代码(C# 文件)中访问它们。这一操作对于后续实现应用功能非常关键,特别是当我们希望通过用户点击按钮时,读取输入框的内容并动态更新待办事项列表。

步骤 1:设置按钮事件处理器

我们首先需要为 Add To Do 按钮添加一个点击事件处理器。每当用户点击按钮时,我们希望从文本框中读取内容,并将新的待办事项作为 TextBlock 添加到堆叠面板中。

  1. 创建事件处理器
    在按钮的 XAML 中,我们不需要手动设置 X:Name 属性来访问它,而是直接为其创建一个点击事件处理器。在按钮的 Click 事件中,添加一个自定义的事件处理函数名称。

    在 XAML 中设置按钮点击事件:

    <Button Content="Add To Do" Click="AddToDoButton_Click" />
  2. 在 C# 后台代码中创建事件处理器
    接着,我们需要在后台的 C# 代码中实现这个事件处理器。在 C# 文件中,创建一个方法 AddToDoButton_Click,该方法将会在按钮被点击时执行。

    private void AddToDoButton_Click(object sender, RoutedEventArgs e)
    {
       // 在这里处理按钮点击后的逻辑
    }

步骤 2:为文本框设置名称属性

为了在后台代码中访问 TextBox,我们需要为其设置一个 X:Name 属性,这样就可以通过该名称从 C# 代码中访问到它。

  1. 设置 TextBox 名称
    我们为文本框设置一个易于识别的名称,像 ToDoInput 这样。这样,在后台代码中,我们就可以通过该名称来获取文本框的内容。

    <TextBox x:Name="ToDoInput" />
  2. 访问 TextBox 内容
    在 C# 代码中,点击按钮时,我们可以通过 ToDoInput 来访问文本框的内容:

    string todoText = ToDoInput.Text;

步骤 3:为堆叠面板设置名称属性

为了将新的待办事项(TextBlock 元素)添加到堆叠面板中,我们需要为堆叠面板设置一个 X:Name 属性。这样,我们就可以在后台代码中引用并修改该堆叠面板。

  1. 设置 StackPanel 名称
    给堆叠面板设置一个名称,比如 ToDoList,以便我们能够从后台代码中访问它。

    <StackPanel x:Name="ToDoList" />
  2. 访问 StackPanel
    在 C# 代码中,我们可以通过 ToDoList 直接访问堆叠面板,并将新的 TextBlock 添加到其中:

    ToDoList.Children.Add(new TextBlock { Text = todoText });

步骤 4:总结

到目前为止,我们已经完成了以下工作:

步骤 5:下一步

在下一节课中,我们将实现主要的功能:当用户点击按钮时,我们将读取文本框中的内容,并创建一个新的 TextBlock,然后将其添加到堆叠面板中。这样,我们就能动态展示用户输入的待办事项了。

通过这一过程,我们可以逐步完善待办事项应用程序的核心功能,最终实现一个可以输入、显示和管理待办事项的应用程序。

209 - 添加待办事项创建逻辑

实现应用程序的主要逻辑

现在我们已经完成了布局和基本的设置,接下来让我们开始实现应用程序的主要功能。首先,我想指出,我的开发方式是先设计应用程序的外观和功能,然后实现它们。在设计过程中,我会考虑应用程序的需求和功能,再决定如何实现。对于我来说,这种方式很有效,因为我可以清楚地知道每个元素的作用并确保每个功能正确实现。当然,你也可以采用不同的方法,找到最适合你的开发方式。

步骤 1:处理按钮点击事件

我们首先要实现的功能是,当用户点击“创建待办事项”按钮时,从文本框中获取用户输入的内容,并将其添加到堆叠面板(StackPanel)中。

1.1 获取文本框中的内容

当用户在 TextBox 中输入内容并点击按钮时,我们需要从文本框中获取文本。假设我们已经在 XAML 中为文本框命名为 ToDoInput,在 C# 后台代码中,可以通过以下方式读取文本框的内容:

string todoText = ToDoInput.Text;

1.2 验证输入内容

接下来,我们需要确保只有当文本框中有内容时,才会添加新的待办事项。如果文本框为空,或者用户没有输入任何内容,我们将不会添加任何待办事项。

为了处理这种情况,我们可以使用以下代码检查文本框内容是否为空:

if (string.IsNullOrEmpty(todoText))
{
    // 如果文本框为空,直接返回,不做任何操作
    return;
}

步骤 2:创建待办事项并添加到堆叠面板

如果文本框中有内容,我们就可以创建一个新的 TextBlock 元素,并将其添加到 StackPanel 中,堆叠显示在页面上。

2.1 创建一个新的 TextBlock

首先,我们创建一个新的 TextBlock,并将其 Text 属性设置为从文本框获取的内容:

TextBlock todoItem = new TextBlock();
todoItem.Text = todoText;

2.2 添加到堆叠面板

创建了待办事项之后,我们需要将其添加到 StackPanel 中。在 StackPanel 中,所有的元素都会被添加到 Children 集合中,所以我们可以直接将 TextBlock 添加到 Children 集合中:

ToDoList.Children.Add(todoItem);

步骤 3:清空文本框

为了让用户体验更流畅,我们在每次添加待办事项之后,需要清空文本框,以便用户可以继续输入下一个待办事项:

ToDoInput.Clear();

步骤 4:验证功能

到目前为止,我们已经实现了获取输入、创建待办事项并将其添加到堆叠面板的功能。为了验证这一点,我们可以先启动应用程序,尝试输入待办事项并点击“创建待办事项”按钮。每当我们点击按钮时,待办事项应该会被添加到堆叠面板中,且文本框会被清空。

示例操作:

  1. 输入待办事项“去购物”。
  2. 点击“创建待办事项”按钮,待办事项会显示在堆叠面板中,文本框被清空。
  3. 输入下一个待办事项“遛狗”,点击按钮后,堆叠面板中会显示新的待办事项。

步骤 5:改善 UI 和样式

在应用程序功能基本完成后,我们可以进行一些样式和布局上的调整。比如,添加一些 Margin 来增加文本的间距,确保界面看起来不那么拥挤,或者调整 TextBlock 的颜色,使得文字更加可读。

5.1 添加边距

为了让每个待办事项之间有一定的间隔,我们可以为 TextBlock 设置边距。我们可以使用 Thickness 结构体来定义边距:

todoItem.Margin = new Thickness(10);

5.2 设置文本颜色

为了提高可读性,我们可以将文本颜色设置为白色。可以通过 Foreground 属性来实现:

todoItem.Foreground = new SolidColorBrush(Colors.White);

步骤 6:处理滚动条

随着待办事项数量的增加,可能会超出可视区域。为了处理这种情况,我们需要确保 ScrollViewer 能够在内容超出时显示滚动条。

6.1 测试滚动条

我们可以输入足够多的待办事项,检查滚动条是否出现。当内容超出界面时,滚动条应该会自动出现,用户可以通过滚动条查看所有待办事项。

步骤 7:总结

到目前为止,我们已经成功实现了一个简单的待办事项列表应用,具有以下功能:

步骤 8:下一步

现在你已经成功实现了基本的待办事项列表应用,接下来可以进行一些个性化的修改,比如改变界面的配色、添加标题或者描述,甚至可以进一步扩展应用,增加更多功能,例如删除待办事项或保存待办事项到文件中。

在下一课中,我们将继续深入探索 WPF,学习更多关于布局、控件和事件处理的技巧。

希望你在实现这个应用的过程中获得了乐趣,继续尝试并实验不同的功能和界面设计,提升你的开发技能!

210 - ContentControl与UserControl简介

构建更复杂的应用:登录功能和内容切换

你已经成功构建了一个简单的待办事项列表应用,这是一个很好的起点。但在现实开发中,大多数应用程序并不那么简单,不论是 Web 应用、WPF 应用,还是移动应用,通常都需要更多的功能。例如,大多数网站在你访问时都需要你先登录,才能使用其服务。比如,使用 YouTube 或 Facebook 时,你需要先登录才能访问你的账户和个性化内容。

多视图应用的需求

这意味着,在很多情况下,我们的应用会有多个视图。例如:

因此,如何在应用程序中切换视图和管理不同的用户界面变得非常重要。今天,我们将开始探讨如何在 WPF 中实现这一功能,尤其是通过 ContentControlUserControl 来实现视图切换。

内容控制和用户控制

1. ContentControl 的作用

在 WPF 中,ContentControl 是一种可以容纳其他元素的控件。它允许我们将不同的视图嵌入到应用程序的同一个窗口中,并根据需要动态切换它们。通过使用 ContentControl,你可以灵活地改变显示的内容,这对于处理登录、主页或其他视图非常有用。

2. UserControl 的作用

UserControl 是另一种非常重要的控件,它可以帮助我们将复杂的界面分解为更小、更可重用的组件。通过使用 UserControl,你可以将某一部分界面独立出来,进行模块化设计。比如,你可以将登录界面和主页分别设计为两个 UserControl,然后通过切换 ContentControl 来切换显示哪个 UserControl

创建视图和切换视图

接下来,我们将创建两个视图——登录视图和主页视图,并实现视图的切换。

3. 创建登录视图

首先,我们创建一个新的 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>

4. 创建主页视图

接下来,我们再创建一个新的 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>

5. 使用 ContentControl 切换视图

在主窗口中,我们使用 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();
    }
}

总结

通过使用 ContentControlUserControl,你可以轻松地在 WPF 中实现视图切换,进而实现类似于登录、主页等不同功能页面的展示。这个方法使得你的应用程序更加模块化和灵活,同时也为未来增加更多功能奠定了基础。

在接下来的课程中,我们将继续探索 WPF 的更多功能,学习如何更深入地定制界面和处理复杂的用户交互。

211 - 创建用于登录的ContentControl与UserControl

开始使用 ContentControl 和 UserControl

首先,我们回到 WPF 演示项目。项目中目前没有内容,可以将之前的内容全部删除,保留 Grid 控件就可以了。你可以返回你的旧项目,删除其中的所有内容,保持 Grid 即可。记住要删除代码后台文件中的所有内容。这个项目的目的是创建一个能够在不同视图之间切换的应用程序。

目标设计

在我们的 WPF 应用程序中,我们的目标是实现一个视图切换的功能。具体来说,当用户点击“登录”按钮后,切换到另一个视图,并显示相应的内容。我们关注的是如何通过 ContentControlUserControl 来动态切换显示的内容,而不仅仅是单纯的更改某个控件的内容。例如,你可以在界面上显示一个 TextBoxStackPanel 或其他任何控件,重点是在于如何实现视图之间的切换。

使用 ContentControl

我们首先创建一个新的控件,称为 ContentControl,它能够控制和显示应用程序的实际内容。为了便于后续操作,给这个控件命名为 mainContent

<ContentControl x:Name="MainContent" />

ContentControl 的用途

ContentControl 用于容纳不同的内容。你可以根据应用的复杂性,使用多个 ContentControl 来分别显示不同的内容。例如,假设你需要收集用户的个人信息和教育历史等数据,你可以将这两个部分分别放在不同的 ContentControl 中。这样你就能够更灵活地管理和展示内容。

对于简单的应用,可以仅使用一个 ContentControl 来管理内容。通过动态设置其 Content 属性,可以控制显示什么内容。

设置 ContentControl 内容

一旦定义了 ContentControl,我们可以在代码后台访问它并设置其内容。你可以在 MainWindow.xaml.cs 中访问 MainContent 控件并设置其内容:

MainContent.Content = new LoginView();

这里的 LoginView 是我们将要创建的第一个 UserControl

创建第一个 UserControl:LoginView

接下来,我们将创建一个新的 UserControl,用于显示登录界面。按照以下步骤操作:

  1. 右键点击项目,选择 Add -> New Item
  2. 在右上角的搜索框中输入 UserControl,选择 WPF UserControl
  3. 将其命名为 LoginView.xaml,然后点击添加。

创建完成后,你会看到一个新的文件 LoginView.xaml,它已经继承了 UserControl 类。现在我们可以在这个 UserControl 中添加登录表单或任何其他控件。

定义 LoginView

下面是 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 中显示 LoginView

最后,我们将在 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(); // 显示登录视图
    }
}

总结

通过使用 ContentControlUserControl,我们可以轻松地在 WPF 中实现视图切换,从而显示不同的用户界面内容。在这个例子中,我们展示了如何实现登录视图,并通过 ContentControl 动态切换不同的视图。在接下来的课程中,我们将继续深入探讨如何设计更加复杂的视图,并处理更多的用户交互。

212 - 设计LoginView

设计 LoginView 用户控件

我们已经创建了第一个用户控件 LoginView,现在我们来设计它的界面。当前如果你直接启动应用程序,你会发现 MainWindow 中并没有显示任何内容,尽管我们已经创建了 ContentControlUserControl。这意味着我们需要为 LoginView 用户控件添加界面元素,并通过设计来优化它的显示效果。

设计视图的尺寸说明

LoginView.xaml 中,你会看到与 MainWindow.xaml 不同的属性。在 MainWindow.xaml 中,我们有 HeightWidth,而在 LoginView.xaml 中,则有 DesignHeightDesignWidth 属性。这些属性仅在设计时起作用,帮助我们在设计界面时进行布局调整,但不会影响应用程序的实际运行尺寸。例如,如果我们设置设计宽度为 450 和设计高度为 800,运行时应用的大小会根据窗口的实际设置进行调整。

创建 Grid 布局

接下来我们使用 Grid 布局来设计我们的登录视图。首先,我们需要定义列和行,设置它们的尺寸。

  1. 列定义: 我们首先定义列,设置其中一个列的宽度为 Auto,另外两个使用相对大小(*)。
  2. 行定义: 我们会创建大约 5 到 6 行,适应不同的 UI 元素。

代码如下:

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

创建界面元素

  1. Label: 我们首先需要创建一个 Label 来显示标题 "Login"。
  2. Button: 然后创建一个 Button,它将显示 "Login" 按钮,用户点击后会触发登录事件。

接下来,我们在 LoginView.xaml 中实现这些界面元素。

Label 设置

Grid 中添加一个 Label 控件,设置其文本为 "Login":

<Label Content="Login"
       Grid.Row="0" Grid.Column="1"
       HorizontalAlignment="Center" VerticalAlignment="Center"
       FontSize="20" />

Button 设置

Grid 中添加一个 Button,设置其内容为 "Login":

<Button Content="Login"
        Grid.Row="2" Grid.Column="1"
        HorizontalAlignment="Center" VerticalAlignment="Center"
        Click="LoginButton_Click" />

在这里,Grid.RowGrid.Column 用于确定控件的位置。我们将 Label 放置在第一行和第二列,Button 放置在第三行和第二列。HorizontalAlignmentVerticalAlignment 用来将控件居中对齐。

调整行高

为了使按钮不显得过大,我们可以调整 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 中的内容,切换到不同的视图。在接下来的课程中,我们将继续深入探讨如何处理这些交互,并完成应用的功能实现。

213 - 显示LoginView UserControl

登录按钮点击事件处理

在之前的步骤中,我们已经设计了登录视图(LoginView)并添加了登录按钮。接下来,我们将为该按钮添加一个点击事件处理程序。虽然可以直接在 XAML 中添加事件处理器,但我们将手动设置它,这样可以更加灵活。

手动设置按钮点击事件

LoginView.xaml 中,找到登录按钮并为其添加 Click 事件。这里,我们不通过 XAML 中的事件属性来添加事件处理器,而是直接在代码中设置它:

  1. LoginView.xaml 中,我们将按钮的 Click 事件与事件处理器关联:
<Button Content="Login" 
        Grid.Row="2" Grid.Column="1" 
        HorizontalAlignment="Center" VerticalAlignment="Center" 
        Click="LoginButton_Click"/>
  1. 然后,在 LoginView.xaml.cs 中,我们定义事件处理器:
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
    // 在这里处理登录按钮的点击事件
}

通过这种方式,我们将事件处理程序 LoginButton_Click 与按钮的点击事件进行了绑定。

显示 LoginView 用户控件

接下来,我们希望当应用程序启动时,显示 LoginView 作为起始界面。我们已经创建了一个 ContentControl,现在我们将通过 MainWindow.xaml 来显示 LoginView

  1. 在 MainWindow.xaml 中使用 ContentControl:

MainWindow.xaml 中,我们有一个 ContentControl,它用于显示不同的用户控件。我们可以通过访问该控件的 Content 属性来改变显示的内容。

  1. 在 MainWindow.xaml.cs 中设置内容:

MainWindow.xaml.cs 的代码后面,我们通过设置 MainContentContent 属性来显示 LoginView。具体操作如下:

public MainWindow()
{
    InitializeComponent();

    // 设置 ContentControl 的内容为 LoginView
    MainContent.Content = new LoginView();
}

这段代码会在应用程序启动时,创建一个 LoginView 的新实例,并将其作为内容添加到 MainWindowContentControl 中。因此,用户启动应用程序时会看到 LoginView 界面。

登录按钮点击后的视图切换

现在,我们需要为登录按钮的点击事件添加逻辑。点击登录按钮后,我们希望切换到另一个视图,这个视图将展示发票数据(InvoiceView)。

创建 InvoiceView 用户控件

  1. 创建新的用户控件: 右键点击项目,在“添加”菜单中选择“新建项”,然后选择 User Control,将其命名为 InvoiceView.xaml

  2. 设计 InvoiceView 界面: 类似于 LoginView,我们为 InvoiceView 设计所需的控件。假设我们展示发票数据,可以包含一些数据绑定和样式设置。

  3. 修改 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;
}

修改登录视图中的 Label 文本

为了使登录界面看起来更加友好,我们可以稍微调整 LoginView 中的 Label,将其文本从 "Login" 更改为 "Please Login",让它与按钮文本区分开来。这样可以使界面更加美观。

修改后的代码如下:

<Label Content="Please Login" 
       Grid.Row="0" Grid.Column="1"
       HorizontalAlignment="Center" VerticalAlignment="Center"
       FontSize="20" />

总结

  1. 我们手动为 LoginButton 添加了点击事件处理程序。
  2. MainWindow.xaml 中设置 ContentControl 来显示 LoginView
  3. LoginButton_Click 事件添加了逻辑,使得点击登录按钮后,可以切换到新的视图(例如 InvoiceView)。
  4. 修改了 LoginView 中的 Label 文本,以改善界面的外观。

通过这些步骤,我们完成了用户从登录视图到发票视图的基本流程,并为将来的功能扩展打下了基础。接下来,我们将继续完善发票视图和其他逻辑。

214 - 创建与显示InvoiceView UserControl

设置第二个用户控件:发票视图

现在,我们将开始设置第二个用户控件,这个控件名为 InvoiceView,用于展示一些假数据。在此示例中,我们将仅展示一个简单的“Hello World”文本,以便演示如何在应用程序中切换视图。稍后,你可以根据需要扩展这个控件的功能。

创建 InvoiceView 用户控件

  1. 添加新的用户控件
    右键单击项目,选择 Add -> New Item,然后搜索 User Control,选择 User Control,并将其命名为 InvoiceView。点击 Add,它将生成一个名为 InvoiceView.xaml 的文件和相应的代码后文件 InvoiceView.xaml.cs

  2. 复制设计结构
    我们不需要重新开始设计布局,而是可以复用之前在 LoginView 中定义的布局。复制 LoginView.xaml 中的 Grid,并将其粘贴到 InvoiceView.xaml 中的相应位置。

  3. 修改内容
    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 事件处理程序

  1. 访问主窗口并更改内容
    LoginView.xaml.cs 中的 LoginButton_Click 事件处理程序中,我们将访问主窗口(MainWindow)并更新其 ContentControl 的内容为 InvoiceView

  2. 代码实现
    LoginButton_Click 事件处理程序中,获取主窗口并将其 ContentControl 的内容切换为 InvoiceView

private void LoginButton_Click(object sender, RoutedEventArgs e)
{
    // 获取当前主窗口
    var window = Application.Current.MainWindow;

    // 将主窗口的内容更改为 InvoiceView
    window.Content = new InvoiceView();
}

总结

  1. 创建 InvoiceView 用户控件
    我们首先创建了一个新的用户控件 InvoiceView,它展示了一个简单的标签和一个 TextBlock,用于显示占位符数据(如 "Hello World")。

  2. 更改 LoginButton_Click 事件处理程序
    LoginView 中,我们为登录按钮添加了一个点击事件处理程序。该事件处理程序在点击按钮后将主窗口的内容切换为 InvoiceView

  3. 切换视图
    在应用程序启动时,主窗口会显示 LoginView,而点击登录按钮后,主窗口的内容会切换到 InvoiceView

设计总结

接下来,你可以根据需求继续扩展这个应用程序,增加更多的视图和功能。

215 - 数据绑定简介

理解数据绑定在 WPF 应用程序中的应用

理解如何在 WPF 中绑定数据对于构建动态应用程序非常重要。在这个演示项目中,我创建了一个非常简单的应用程序,使用了我们迄今为止所学的知识。现在我将简要解释这个演示项目的结构和如何实现数据绑定。

演示项目概述

  1. 项目结构
    在这个项目中,我使用了一个基本的布局,包含了 GridRowDefinitions 等基本元素。我们有一个文本框、标签和按钮。通过这些简单的控件,我展示了如何使用数据绑定来动态更新 UI。

  2. 项目源代码
    为了更好地理解,我已经将源代码上传到了 GitHub 页面,你可以下载它并按照步骤操作。你可以删除之前在演示项目中创建的所有内容,重新下载这个项目,或者简单地进入 WPF 文件夹,找到 MainWindow.xamlMainWindow.xaml.cs 文件,然后将其中的内容复制到你的项目中。这样,你就能获得一个与你看到的一模一样的界面。

  3. 步骤说明

    • 打开 GitHub 页面后,你可以下载整个项目并在 Visual Studio 中打开。
    • 如果你不希望下载整个项目,你也可以手动复制 MainWindow.xaml 中的布局和 MainWindow.xaml.cs 中的代码。请确保在复制时,代码后端文件也一并复制,这样你就能避免手动编写代码时出现错误。
    • 代码中所使用的控件(如 GridLabelTextBox 等)在之前的 WPF 章节中都已经讲解过,所以你应该对这些控件不陌生。
  4. 确保程序正常运行
    一旦你成功设置了项目并运行应用程序,你应该看到一个简单的界面,允许你输入姓名和年龄,并通过点击按钮来显示 "Hello World"。如果你没有遇到任何错误,那么你可以继续跟着接下来的课程一起学习数据绑定和更多的功能。

数据绑定简介

在本节中,我们将开始讲解 WPF 中的数据绑定。数据绑定是 WPF 的一个核心特性,它可以帮助我们将 UI 元素与数据源连接起来,使得 UI 和数据之间的更新变得自动化和高效。WPF 提供了多种绑定方式,包括:

  1. 单向绑定
    这是最常见的一种绑定方式,数据从数据源流向 UI 控件。通常用于显示数据,例如将一个文本框绑定到某个数据模型的属性。

  2. 双向绑定
    双向绑定允许数据在 UI 控件和数据源之间进行双向更新。当 UI 控件的内容发生变化时,数据源会自动更新,反之亦然。这对于表单输入和其他需要同步的 UI 元素非常有用。

  3. 命令绑定
    在 WPF 中,命令绑定用于将按钮的点击事件等与某些命令处理程序绑定。命令可以是简单的动作,例如显示消息或更新数据。

开始数据绑定

让我们继续深入了解如何实现数据绑定,并探索 WPF 中不同的数据绑定方式。


这样你就能够在项目中应用数据绑定,进一步增强 UI 和数据交互的功能。

216 - 设置要绑定的数据

数据绑定概述

一般来说,数据绑定是 UI 元素和我们应用程序中的数据之间的流动。我们有多种数据绑定方式,例如单向数据绑定和双向数据绑定。在本节中,我们将从单向数据绑定开始,然后逐步深入双向数据绑定。

创建一个类来表示数据

  1. 创建类
    在当前的示例项目中,我们已经使用了简单的变量(如字符串和整数)。但在实际的应用程序中,通常我们会使用 C# 的面向对象编程方法来管理数据。因此,我们需要为 nameage 创建一个类。在本示例中,我们将创建一个 Person 类,它包含 nameage 属性。

  2. 创建 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

  1. 创建实例
    现在我们已经创建了 Person 类,但这只是一个模板,还没有实际的对象。接下来,我们将在 MainWindow 中创建 Person 的一个实例,并初始化它的属性,例如 Age 设置为 30,Name 设置为某个默认值。

    Person person = new Person
    {
       Name = "John Doe",
       Age = 30
    };
  2. 设置数据上下文
    在 WPF 中,数据上下文(DataContext)指示了数据绑定的源。在 MainWindow 的构造函数中,我们将 DataContext 设置为我们刚刚创建的 person 实例。

    public MainWindow()
    {
       InitializeComponent();
       this.DataContext = person;  // 设置数据上下文为 person
    }

    DataContext 是一个非常关键的概念,它将 UI 元素与数据源连接起来,让 WPF 知道哪些数据需要绑定到哪些 UI 元素上。

准备绑定数据

  1. 绑定数据
    一旦设置了数据上下文,下一步就是将 UI 元素(如文本框)绑定到数据类的属性上。在 WPF 中,数据绑定通常是通过 XAML 中的绑定语法实现的。我们将通过绑定 TextBox 元素的 Text 属性到 Person 类中的 NameAge 属性来展示数据。

  2. 下一步
    在下一个视频中,我们将继续演示如何在 XAML 中实现数据绑定,让 UI 元素自动与 Person 对象的属性同步。


通过这种方式,我们成功地将数据类与 UI 元素进行了连接,接下来将进一步实现绑定,确保数据能在 UI 和数据源之间流动。

217 - 单向数据绑定

实现单向数据绑定

在本节中,我们将实现单向数据绑定。我们已经有了一个 Person 类,它包含 nameage 属性,现在我们要在 UI 元素中显示这些属性的值。

1. 绑定数据到 UI

在我们的应用程序中,我们有两个文本框,一个用于显示 Name,另一个用于显示 Age。当启动应用程序时,我们希望能够自动填充这两个文本框,其中 Name 显示 "Yannick",Age 显示 "30"。

为了实现这个目标,我们将使用单向数据绑定:将数据从应用程序的 Person 对象绑定到 UI 元素(文本框)。单向数据绑定意味着数据从源(Person 对象)流向目标(UI 元素),但不会反向更新数据。

绑定步骤:
  1. 设置数据绑定:
    在 XAML 中,使用 Binding 元素来将文本框的 Text 属性绑定到 Person 类中的 NameAge 属性。我们通过设置绑定的 ModeOneWay 来实现单向数据绑定。

    <TextBox Text="{Binding Name, Mode=OneWay}" />
    <TextBox Text="{Binding Age, Mode=OneWay}" />
  2. 设置数据上下文:
    通过在 MainWindow 中设置 DataContext 来让 UI 元素知道数据源。我们将 DataContext 设置为 Person 对象实例。

    this.DataContext = person;  // 绑定数据源
  3. 效果:
    启动应用程序时,文本框会自动填充为 "Yannick" 和 "30",这就是单向数据绑定的效果。

2. 单向数据绑定的应用场景

在实际应用中,单向数据绑定特别适用于从外部源(如 Web 服务器或本地文件)加载数据并将其显示在 UI 上的场景。例如,假设从 Web 服务器加载了一个人的数据,包括姓名和年龄。使用单向数据绑定,您可以将这些数据展示给用户,而无需用户进行修改。

3. 为什么需要双向数据绑定?

到目前为止,使用的是单向数据绑定。也就是说,当我们修改文本框中的数据时,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",因为数据并没有反向更新。这个现象正是单向数据绑定的表现。

4. 下一步:双向数据绑定

为了让文本框的更改能够反映到 Person 对象中,我们需要实现双向数据绑定。双向数据绑定意味着 UI 元素的改变会自动更新数据对象,反之亦然。

例如,如果用户将文本框中的 "Yannick" 改为 "Peter",并点击按钮,数据源中的 Person 对象应更新为新的 NameAge,显示 Peter 和新的年龄。

在 XAML 中启用双向绑定:
<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Age, Mode=TwoWay}" />

通过将绑定模式设置为 TwoWay,我们可以确保 UI 元素和数据对象之间的双向数据流动。

5. 总结

通过双向数据绑定,我们可以实现更强大的用户交互,确保 UI 元素和数据源保持同步。接下来,我们将继续探索如何实现双向数据绑定。

218 - 双向数据绑定

实现双向数据绑定

现在我们来实现双向数据绑定。与单向数据绑定不同,双向数据绑定允许数据在 UI 元素和数据源(例如 Person 对象)之间双向流动。这意味着,当 UI 元素的值发生变化时,数据源也会同步更新;同样,数据源的变化会实时反映在 UI 元素中。

1. 修改绑定模式

在我们的应用中,我们之前使用了单向数据绑定,绑定模式为 OneWay。现在,我们将绑定模式改为 TwoWay,以便能够从 UI 元素更新数据源,反之亦然。

更新绑定代码:

我们在 XAML 中将 TextBoxText 属性绑定到 Person 类的 NameAge 属性。原先的绑定模式是 OneWay,现在我们将其改为 TwoWay

<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Age, Mode=TwoWay}" />

这意味着,当用户修改文本框中的值时,它会自动更新 Person 对象中的相应属性,反之亦然。

2. 使用 Path 指定属性

Binding 中,我们还可以使用 Path 来指定要绑定的属性。如果没有特别声明,Path 默认为目标属性(例如 NameAge)。但是,如果需要,我们可以显式指定 Path

<TextBox Text="{Binding Path=Name, Mode=TwoWay}" />
<TextBox Text="{Binding Path=Age, Mode=TwoWay}" />

这样做的效果与前面的绑定方式相同,都是双向绑定,只是通过 Path 显式指定属性名。

3. 测试双向数据绑定

在启动应用程序后,文本框中的 Name 会显示为 "Yannick",Age 显示为 "30"。当你修改文本框中的内容时(例如将 Name 改为 "Peter",将 Age 改为 "20"),Person 对象中的数据也会同步更新。

测试过程:
  1. 启动应用程序。
  2. Name 文本框中输入 "Peter"。
  3. Age 文本框中输入 "20"。
  4. 点击 "Info" 按钮,会显示 "Peter is 20 years old"。

这证明了双向数据绑定的工作原理。UI 元素的更改直接反映到数据对象中,我们也能通过代码访问到最新的数据。

4. 总结

通过双向数据绑定,我们能够实现 UI 元素和数据源之间的双向数据流动。用户在 UI 元素中输入的新值会实时更新到数据对象,而数据对象的变化也会反映到 UI 上。这种方式特别适用于需要在 UI 和数据源之间进行同步的应用场景。

这样,我们就完成了双向数据绑定的实现。在下一节中,我们将继续探索其他 WPF 中的数据绑定技巧。

219 - 单向到源的数据绑定

最终的数据绑定模式:单向到源数据绑定

在这节中,我们将探索数据绑定的最后一种模式——单向到源数据绑定(OneWayToSource)。这是数据绑定的一种特殊形式,它的工作方式与前面讨论的单向数据绑定和双向数据绑定有所不同。

1. 单向到源数据绑定(OneWayToSource)

之前我们讨论了单向数据绑定(OneWay),它是数据从源到目标的流动,即从数据源(例如 Person 对象)到 UI 元素。现在,我们要讨论的是单向到源数据绑定,它的工作方式是将数据从 UI 元素(目标)流动回数据源(源)。这种方式通常用于将用户在 UI 上输入的数据更新到后台的数据源中。

绑定模式修改:

为了实现单向到源数据绑定,我们将绑定模式从 OneWay 改为 OneWayToSource。这样,数据流将从 UI 元素(如文本框)流回数据源。

<TextBox Text="{Binding Name, Mode=OneWayToSource}" />
<TextBox Text="{Binding Age, Mode=OneWayToSource}" />
测试单向到源数据绑定:
  1. 启动应用程序,初始数据显示的是通过代码初始化的 Yannick30
  2. 在文本框中输入新的数据,例如 Peter50
  3. 点击 "Info" 按钮,我们会看到 "Peter is 50 years old" 的输出,尽管在后台代码中,数据初始化时仍然是 Yannick30

这是因为数据流动的方向发生了改变,现在是从 UI 元素流回到数据源。在这个模式下,UI 元素的内容(如文本框中的名称和年龄)会覆盖后台的数据。

2. 何时使用单向到源数据绑定

单向到源数据绑定并不是最常用的模式,它的应用场景相对较少。它适用于那些需要从 UI 元素收集数据并将其传递回数据源的情况。例如,用户填写表单时,可以使用这种模式将表单字段的输入值传回后台数据源。

在大多数应用中,单向数据绑定(从源到 UI 元素)和双向数据绑定(UI 元素和数据源之间双向更新)更常用。然而,单向到源数据绑定也有它的特殊用途,尤其是在需要更新数据源而不需要显示回数据的场合。

3. 总结

虽然 OneWayTwoWay 是最常用的绑定模式,OneTimeOneWayToSource 在特定场景下也非常有用,例如配置加载或从 UI 元素到数据源的流动。

至此,我们已经覆盖了 WPF 数据绑定的各种模式,了解了它们的适用场景和实现方式。希望你在实际开发中能灵活运用这些知识!

220 - 一次性数据绑定

单次数据绑定(OneTime Data Binding)

在我们之前讨论了单向数据绑定和双向数据绑定之后,现在介绍另外一种数据绑定模式——单次数据绑定(OneTime Data Binding)。这种方式比单向和双向数据绑定要简单一些,适用于一些特定的场景。

1. 什么是单次数据绑定?

正如名字所示,单次数据绑定只会在数据初始化时更新一次,并且之后不会再进行任何更新。它非常适用于那些配置数据或常量数据,这些数据在程序运行期间不会改变。

2. 如何实现单次数据绑定?

实现单次数据绑定时,你需要将绑定模式设置为 OneTime,这表示数据只会绑定并显示一次。

<TextBox Text="{Binding Name, Mode=OneTime}" />
<TextBox Text="{Binding Age, Mode=OneTime}" />

在这种模式下,数据只会在应用程序启动时加载一次并显示在 UI 元素中。如果后续修改了数据,UI 元素将不会自动更新,因为绑定只发生了一次。

3. 适用场景

单次数据绑定常用于以下场景:

4. 单次数据绑定与单向数据绑定的区别

虽然单向数据绑定(OneWay)和单次数据绑定(OneTime)都涉及到从数据源到 UI 元素的数据流,但它们之间有一个显著的区别:

5. 示例

假设我们有一个配置文件或常量需要显示在界面上:

<TextBox Text="{Binding AppVersion, Mode=OneTime}" />

这里,AppVersion 是一个静态的配置信息,使用单次数据绑定,文本框的值会被设置为该版本号,并且该值不会在运行时更新。

6. 总结

单次数据绑定常用于配置加载和常量值展示,不会随着数据源的变化而更新,适用于不需要动态更新的数据。

221 - ListBox简介

现实世界中的数据集合和 ListBox 控件

在现实世界的应用程序中,我们经常处理的数据并不是单一条目,比如“嘿,我有一个发票”或“嘿,我有一个人”。实际上,我们通常需要处理的是大量的条目,如成千上万的发票,或者大学里成千上万的学生数据。对于这些场景,我们的应用程序必须能够处理和显示数据集合,比如列表(List)、数组(Array)等。

1. 现实应用中的数据集合

例如,如果你正在构建一个发票管理应用,通常不仅仅是编辑单个发票,更多时候你需要从多个发票中选择一个进行编辑。所以,大多数应用程序都涉及到数据集合的处理,而不仅仅是单一的数据实体。这些数据可以通过多种方式加载,例如:

在这类应用中,你通常会面对一个数据集合,像是一个包含成千上万条数据的列表。

2. 使用 ListBox 控件展示数据集合

在 WPF 中,ListBox 是一个非常有用的控件,它能够帮助我们展示多个条目的数据集合,并提供很多功能。让我们快速查看一下这个控件的实现和用法。

2.1 创建 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,这样我们就能在代码背后访问和操作它。

2.2 在代码中绑定数据

在代码背后,我们可以将一个数据集合绑定到 ListBox。例如,我们可以创建一个字符串列表,里面包含几个名字,并将其作为数据源绑定到 ListBox

public MainWindow()
{
    InitializeComponent();

    // 创建一个包含名字的字符串列表
    List<string> names = new List<string> { "Yannick", "Peter", "Maria", "Mark" };

    // 将列表绑定到 ListBox 的 ItemSource 属性
    listBoxNames.ItemsSource = names;
}

在这个示例中,我们创建了一个 List<string> 类型的集合,包含了一些名字,并将其绑定到 listBoxNamesItemsSource 属性上。ItemsSourceListBox 用来显示数据的属性。

2.3 显示和交互

启动应用程序后,你将看到一个包含这些名字的 ListBox。你可以点击列表中的任何项,例如选择 YannickMariaPeter,每个选择都会变为选中的状态。

这样,ListBox 控件不仅可以显示多个数据项,还可以使用户进行交互,例如选择一个条目进行编辑。

3. ListBox 的更多功能

ListBox 是一个非常强大的控件,它有许多额外的功能,比如:

4. 总结

在现实世界的应用程序中,数据集合的处理是非常常见的。无论是加载发票列表、学生名册,还是任何包含多个条目的数据,我们都需要在 UI 中展示这些数据。ListBox 控件是一个非常适合用来显示数据集合的控件,它不仅可以显示静态数据,还可以与用户进行交互,允许选择或编辑数据项。

在接下来的课程中,我们将继续深入探讨 ListBox 的更多功能和如何在应用程序中更高效地使用它。

222 - ListBox项源

使用复杂数据类型在 ListBox 中显示数据

在之前的视频中,我们展示了如何将一个简单的字符串列表绑定到 ListBox 控件,并在界面中显示这些字符串,如 YannickPeterMariaMark。但这些只是简单的字符串数据类型,而在现实应用中,我们往往需要处理更复杂的数据类型,比如一个包含多个属性的 Person 类。

1. 数据绑定到 ListBox 的更复杂数据类型

假设我们有一个包含多个属性的 Person 类,它可能包括 nameage。现在我们希望在 ListBox 中显示这些 Person 对象,而不仅仅是显示它们的名字。为了做到这一点,我们需要做一些额外的设置。

2. 创建 Person

首先,确保你的 Person 类是公开的,这样它才能被外部访问并用于数据绑定。如果类没有设置为 public,你将无法访问它,会遇到访问错误。

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

在这个类中,我们定义了两个属性:NameAge,分别代表一个人的姓名和年龄。

3. 创建 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;
}

在上面的代码中,我们首先创建了一个名为 PeopleList<Person>,并用一些示例数据初始化它。然后我们将这个 List 绑定到 ListBoxItemSource 属性。

4. 显示复杂数据类型

然而,ListBox 默认只能显示简单数据类型(如字符串)。因为我们绑定的是 Person 类型的对象,所以 ListBox 默认调用这些对象的 ToString() 方法,通常会得到类似 Person 或其内存地址的结果。这显然不是我们想要的。

为了正确显示 Person 对象的属性(如 NameAge),我们需要提供一个数据模板(ItemTemplate),定义如何展示每个条目的内容。

5. 定义 ItemTemplate

ListBox 中,我们使用 ItemTemplate 来指定如何展示数据。具体来说,我们可以定义一个 DataTemplate,该模板指明了如何显示每个 Person 对象的 NameAge

<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 控件来显示 PersonNameAge 属性。{Binding Name}{Binding Age} 分别表示绑定到 Person 对象的 NameAge 属性。

6. 调整 ListBox 名称和数据绑定

为了保持代码的整洁和一致性,我们可以将 ListBox 控件的名称修改为 listBoxPeople,并确保代码背后的 ItemSource 属性绑定到正确的 People 列表:

listBoxPeople.ItemsSource = People;

确保控件的名称与数据绑定匹配,这样代码会更加清晰和一致。

7. 总结

通过上述步骤,我们成功地将一个复杂的数据类型 Person 显示在 ListBox 中。通过使用 ItemTemplate,我们能够定制每个条目的显示方式,使得 ListBox 能够显示 Person 对象的多个属性,而不仅仅是对象的默认字符串表示。

在接下来的视频中,我们将进一步探讨如何使用 ItemTemplate 来进行更多的自定义,提升 ListBox 的功能和表现。

223 - ListBox项模板

创建 ListBox 的 ItemTemplate

在这节课中,我们将深入探讨如何为 ListBox 创建一个 ItemTemplate。通过定义这个模板,我们可以指定 ListBox 中每个项的布局和样式,从而使我们的数据展示更加清晰和有意义。

1. 配置 ListBoxItemTemplate

首先,我们需要将 ListBox 的自闭合标签修改为标准标签,因为在定义 ItemTemplate 时需要在 ListBox 标签内包含一些内容。

<ListBox x:Name="listBoxPeople" Grid.Row="1" Grid.Column="1">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <!-- 在这里定义每个项的布局 -->
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

2. 使用 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 对象的 NameAge 属性。通过 {Binding Name}{Binding Age},我们绑定了每个条目的名称和年龄属性。

3. 为 ListBox 项添加更多上下文信息

虽然我们已经成功绑定了 NameAge 属性,但是仅仅显示这些值对用户来说可能并不够清晰。为了提供更多的上下文信息,我们可以使用 字符串格式化 来为每个字段添加说明性文字。

<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",从而更容易理解。

4. 查看最终效果

修改后,当你运行程序时,每个 ListBox 项将显示为 Name: YannickAge: 30 这样的格式。用户看到的内容更加直观,能清楚地知道 "30" 代表的是年龄,而不是其他不明确的数字。

5. 总结

通过定义 ItemTemplate 和使用 DataTemplate,我们可以灵活地展示复杂数据类型(如 Person 类)在 ListBox 中。通过绑定数据属性并使用 StringFormat,我们还可以为用户提供更多的上下文信息,使界面更加友好和易于理解。

在接下来的课程中,我们将进一步探索如何使用 ListBox 实现更多的功能,比如响应用户的选择和交互。

224 - ListBox访问选中的数据

ListBox 的选择功能与获取选中项

在这节课中,我们将探讨如何在 ListBox 中选择多个项,并且如何获取所选的项进行操作。这是 ListBox 的一个强大特性,允许用户选择一个或多个项,并通过编程来处理这些选中的项。

1. 配置 ListBox 的选择模式

首先,ListBox 支持多种选择模式,可以通过设置 SelectionMode 属性来选择不同的模式。以下是三种选择模式的说明:

例如,设置 SelectionModeMultiple,用户可以通过按住 Ctrl 键来选择多个项:

<ListBox x:Name="listBoxPeople" SelectionMode="Multiple" />

2. 添加按钮以显示选中项

为了能够处理选中的项,我们可以添加一个按钮,点击按钮时显示所有被选中的项。首先,我们在界面上添加一个按钮,并为其设置点击事件:

<Button x:Name="btnShowSelected" Content="Show Selected" Click="Button_Click" />

接着,在按钮的点击事件中,我们可以获取 ListBox 中所有被选中的项。

3. 获取选中的项

要获取 ListBox 中所有被选中的项,我们可以使用 SelectedItemSelectedItems 属性:

由于我们设置的是 Multiple 选择模式,因此我们使用 SelectedItems 来获取选中的项。代码如下:

var selectedItems = listBoxPeople.SelectedItems;

这里的 selectedItems 是一个包含所有被选中项的集合。接下来,我们可以通过循环遍历这个集合,并获取每个选中的项。

4. 显示选中项的信息

我们可以使用 foreach 循环遍历 selectedItems 集合,然后获取每个选中项的详细信息。由于选中的项是 Person 对象的实例,我们需要将它们从 object 类型转换(强制类型转换)为 Person 类型,以便访问其属性(如 NameAge)。

foreach (var item in selectedItems)
{
    // 将 item 转换为 Person 类型
    var person = (Person)item;

    // 显示选中项的名称
    MessageBox.Show($"Name: {person.Name}, Age: {person.Age}");
}

通过这种方式,我们可以轻松获取并展示用户选择的每个 Person 对象的名称和年龄等属性。

5. 测试功能

当用户点击 "Show Selected" 按钮时,应用程序会显示每个选中项的详细信息。例如,选择了 Mark 和 Scott 后,点击按钮,程序会显示:

Name: Mark, Age: 35
Name: Scott, Age: 28

6. 总结

通过配置 ListBoxSelectionMode 属性和使用 SelectedItems,我们可以方便地处理多个选中的项。使用强制类型转换,将 object 转换为实际的数据类型(如 Person),我们可以访问这些项的具体属性。通过这种方式,用户与 ListBox 的交互不仅能够选择项,还能对这些选中的项进行进一步的操作。

在接下来的课程中,我们将继续探索如何利用 ListBox 实现更多的功能,比如响应用户选择的变化并进行实时更新。

225 - 下一个应用:登录功能

创建登录应用程序与环境变量

在这一部分,我们将构建一个登录界面,用户可以输入密码进行验证。如果密码正确,登录按钮将被激活并显示相关信息。除了字符串比较外,我们还将使用 环境变量 来存储敏感数据,例如密码、API 密钥等。这种做法在实际软件开发中非常常见,因为它有助于保护敏感信息,避免将其硬编码在应用程序中。

1. 创建基本的登录界面

首先,我们需要一个基本的界面,其中包含一个文本框用于输入密码,一个按钮用于登录验证。密码验证成功后,按钮可以激活,并显示相应的消息。

<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"),直到用户输入正确的密码才会启用。

2. 处理密码输入和按钮启用

我们需要在后台代码中处理密码输入并根据密码的正确性启用按钮。为此,我们将监听 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 事件处理程序会在用户输入时检查密码。如果密码正确,按钮会被启用,用户可以点击登录按钮,显示相关提示。

3. 使用环境变量存储密码

在实际应用中,密码等敏感信息不应直接在代码中硬编码。我们可以通过 环境变量 来存储密码,并在运行时进行读取。这样可以增强应用的安全性,并避免将敏感信息暴露在代码中。

首先,我们需要在操作系统的环境变量中设置一个名为 APP_PASSWORD 的变量(你可以在系统的环境变量设置中手动添加,或者在代码中进行设置)。然后,在应用程序中读取该环境变量进行密码验证。

设置环境变量

在 Windows 中,你可以通过以下步骤设置环境变量:

  1. 打开“控制面板”。
  2. 点击“系统和安全”,然后点击“系统”。
  3. 点击“高级系统设置”。
  4. 在“系统属性”窗口中,点击“环境变量”按钮。
  5. 在“用户变量”下,点击“新建”,输入变量名 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") 从操作系统的环境变量中获取密码,并在用户输入密码时进行验证。

4. 测试功能

通过这种方式,我们不仅完成了简单的密码验证,还学会了如何利用环境变量来存储和读取敏感数据,确保应用程序的安全性。

5. 总结

在这节课中,我们了解了如何创建一个简单的登录界面并实现密码验证。同时,我们还学习了如何使用环境变量存储密码和其他敏感数据,这种做法在实际应用中非常重要,可以有效提高应用的安全性。在下一步中,你可以考虑将更多的敏感数据(如 API 密钥)存储在环境变量中,进一步增强安全性。

226 - 创建项目与登录用户控件

开始创建“发票管理”应用

我们将从一个全新的 WPF 项目开始,这个项目的名称是 Invoice Management。在 Visual Studio 中创建项目时,选择 WPF 模板并启动新项目,最终你将看到一个空白的界面,包含一个默认的 Grid 控件。

1. 创建 ContentControl

我们首先要做的就是在主窗口中创建一个 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 提供内容。

2. 创建 LoginView 用户控件

接下来,我们需要创建一个 LoginView 用户控件,这是我们登录界面的一部分。为了创建这个用户控件,右击项目并选择“添加” → “用户控件(WPF)”,然后命名为 LoginView

这将创建一个包含 LoginView.xaml 和其代码后端 LoginView.xaml.cs 的文件。我们可以在此文件中定义我们的登录界面布局。

3. 在 MainWindow 中加载 LoginView

一旦创建了 LoginView,我们可以通过代码在 MainWindow 中加载它。打开 MainWindow.xaml.cs 文件,在构造函数中添加如下代码来将 mainContent 的内容设置为 LoginView

public MainWindow()
{
    InitializeComponent();
    // 将主窗口的内容设置为 LoginView
    mainContent.Content = new LoginView();
}

此时,启动应用程序后,mainContent 会显示 LoginView,但该控件是空的,我们还没有设计界面。

4. 设计 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>

在这个布局中:

5. 设置视图内容

我们在 MainWindow.xaml 中已经创建了 ContentControl。此时 LoginView 应该被正确显示。如果启动应用程序,用户将首先看到登录界面。

6. 添加登录逻辑

接下来,我们要在 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"/>

7. 总结

我们已经成功创建了一个简单的登录界面,并将其集成到主窗口中。通过这种方式,我们可以通过 ContentControl 动态地加载不同的视图(如登录视图)。这种方法对于实现灵活的界面和用户体验非常有用。

接下来,我们可以继续扩展这个应用程序,加入更多功能,如错误提示、加载动画、以及实际的发票管理界面。

227 - 添加密码框

登录界面设计与实现

我们刚刚完成了登录视图的网格布局设计,现在我们将继续添加标签、密码框和登录按钮等控件,并完善它们的功能。

1. 添加“Login”标签

首先,我们要为登录界面添加一个标签,显示“Login”。该标签将位于第一行(grid.row=1),第一列(grid.column=1)。

<Label Grid.Row="1" Grid.Column="1" Content="Login" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="18" FontWeight="Bold"/>

这样,我们就创建了一个用于显示登录标题的标签。

2. 添加“Enter Password”标签

接下来,我们需要添加一个标签,提示用户输入密码。该标签位于第三行(grid.row=3)。

<Label Grid.Row="3" Grid.Column="1" Content="Enter Password" HorizontalAlignment="Center" FontSize="14"/>

3. 添加密码框(PasswordBox)

接下来,添加一个密码框(PasswordBox),用于接收用户输入的密码。它将位于与“Enter Password”标签相同的位置,即第三行(grid.row=3),并且与标签对齐(grid.column=1)。

<PasswordBox Grid.Row="4" Grid.Column="1" Width="300" Height="30"/>

4. 添加登录按钮

最后,我们需要一个按钮,用户点击后将尝试登录。这个按钮将位于第五行(grid.row=5),并且列也设置为第一列(grid.column=1)。

<Button Grid.Row="5" Grid.Column="1" Content="Login" Width="200" Height="50" HorizontalAlignment="Center"/>

5. 完整的登录视图 XAML 布局

下面是完整的 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>

6. 启动应用并查看效果

完成布局后,我们可以启动应用程序,查看是否正确显示登录界面以及所有控件。启动时,用户将看到一个包含“Login”标题、密码输入框和登录按钮的界面。

此时,用户可以输入密码并点击登录按钮。然而,目前我们尚未实现实际的登录功能,因此需要进一步处理用户输入。

7. 实现登录功能

我们需要在 LoginView.xaml.cs 中处理登录逻辑。当用户点击

228 - 环境变量

创建环境变量以存储敏感数据

在开发应用程序时,我们常常需要存储一些敏感数据,如密码、API 密钥、文件路径等。为此,使用环境变量是一种安全的做法。接下来,我们将讲解如何在 Windows 系统中创建和使用环境变量来存储这些敏感数据。

1. 打开环境变量设置

首先,我们需要打开 Windows 的环境变量设置窗口。操作步骤如下:

2. 添加新的系统环境变量

在环境变量设置窗口中,我们有两类环境变量:

我们可以在 系统变量 中创建一个新的环境变量来存储我们的敏感数据。操作步骤如下:

3. 保存环境变量并重启计算机

重要提示:在创建或修改环境变量后,系统不会立即加载这些变化。如果不重启计算机,你的程序将无法读取到这些新的环境变量,它们的值会是 null。因此,务必重启计算机以确保环境变量生效。

4. 如何使用环境变量

在应用程序中,我们可以通过 System.Environment.GetEnvironmentVariable 方法读取已设置的环境变量。例如:

string password = Environment.GetEnvironmentVariable("InvoiceManagement");

这段代码会读取名为 InvoiceManagement 的环境变量,并将其值赋给 password 变量。这样,我们就可以安全地使用存储在环境变量中的敏感数据,而不必将它们硬编码在应用程序中。

5. 小贴士

6. 后续操作

完成环境变量的创建并重启计算机后,我们就可以在应用程序中开始使用它了。记得使用正确的键名来访问和验证密码或其他敏感数据。

下一个步骤,我们将继续开发登录功能,利用刚刚设置的环境变量来验证用户输入的密码。

229 - 使用环境变量登录

为程序添加登录验证功能

在本节中,我们将为程序添加登录验证的功能。当用户输入密码时,点击登录按钮后,程序将读取密码框中的内容,检查我们之前设置的环境变量,并与用户输入的密码进行比较,确认是否输入了正确的密码。

1. 创建按钮点击事件处理器

首先,我们需要为登录按钮添加点击事件的处理器。当按钮被点击时,我们将触发一个方法,该方法将处理密码验证逻辑。

例如,在 XAML 中为按钮设置事件:

<Button Content="Login" Click="OnLoginButtonClicked"/>

2. 编写事件处理器代码

接下来,在 Code-behind 文件中实现 OnLoginButtonClicked 方法。我们需要做以下几件事:

  1. 获取用户输入的密码。
  2. 获取环境变量中存储的正确密码。
  3. 比较用户输入的密码和环境变量中的密码。
  4. 根据比较结果显示相应的消息(例如“登录成功”或“登录失败”)。
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");
    }
}

3. 代码解释

4. 测试功能

现在,运行应用程序,测试登录功能。操作步骤如下:

  1. 在登录框中输入密码。
  2. 点击 登录 按钮,检查是否能正确显示登录结果。
    • 如果密码正确,显示“Entered correct password”。
    • 如果密码错误,显示“Entered wrong password”。
    • 如果找不到环境变量,显示“Environment variable not found”。

5. 提示和建议

6. 小结

通过添加密码验证功能,我们使得应用程序能够从环境变量中读取存储的密码,并与用户输入的密码进行比较。这种方法避免了硬编码密码,提高了安全性。在实际开发中,环境变量常用于存储敏感数据,如API 密钥和数据库密码等。

接下来,我们将进一步提升应用的功能。在下一节中,我们将继续完善应用,增加更多的功能。

230 - 密码更改事件

为登录按钮添加启用/禁用逻辑

在本节中,我们将通过监听密码框内容的变化来启用或禁用登录按钮。我们将使用密码框的 PasswordChanged 事件来触发相应的逻辑,确保在密码框为空时禁用登录按钮,输入密码后才启用按钮。

1. 使用 PasswordChanged 事件

首先,我们需要为密码框添加一个事件监听器。当密码框的内容发生变化时,我们将检查密码是否为空,并根据结果启用或禁用登录按钮。

Login View 中,为密码框添加 PasswordChanged 事件:

<PasswordBox Name="passwordBox" PasswordChanged="OnPasswordChanged"/>

2. 编写 OnPasswordChanged 事件处理器

接下来,在 Code-behind 文件中实现 OnPasswordChanged 方法。在这个方法中,我们将检查密码框中的内容,并根据密码是否为空来启用或禁用登录按钮。

public void OnPasswordChanged(object sender, RoutedEventArgs e)
{
    // 获取登录按钮
    var loginButton = this.FindName("loginButton") as Button;

    // 检查密码框是否为空
    loginButton.IsEnabled = !string.IsNullOrEmpty(passwordBox.Password);
}

3. 启用/禁用登录按钮的逻辑

4. 设置登录按钮的初始状态

在应用程序启动时,我们希望登录按钮初始时是禁用的,直到用户输入密码。为了实现这一点,我们可以在 XAML 中设置按钮的 IsEnabled 属性为 false

<Button Name="loginButton" Content="Login" IsEnabled="False" Click="OnLoginButtonClicked"/>

这样,即使密码框最初为空,登录按钮也会保持禁用状态。然后,当用户输入密码时,PasswordChanged 事件会触发,检查密码框是否为空,并启用或禁用按钮。

5. 测试功能

现在,运行应用程序并测试以下场景:

  1. 打开应用程序时,登录按钮应该是禁用的。
  2. 输入任何内容(例如“test”)后,登录按钮应该被启用。
  3. 如果删除密码框中的内容,登录按钮应该再次被禁用。

6. 总结

这种方法提供了一种更好的用户体验设计,因为它确保只有在用户输入密码时才能点击登录按钮。通过监听 PasswordChanged 事件,我们能够实时地更新按钮的状态,增强了交互的流畅性和安全性。

在下一节中,我们将继续完善程序,进一步提升用户体验和功能。

231 - 如何继续

结语:扩展你的应用程序

我们已经完成了本章的内容,您现在掌握了足够的知识,可以开始扩展您的应用程序。下面是您可以尝试的一些方向:

1. 创建 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;
    }
}

2. 扩展应用程序:显示用户控制

不仅仅是在密码正确时显示消息框,您还可以进一步增强应用程序的功能。例如,在密码正确后,您可以显示另一个用户控制(UserControl),如 PersonViewInvoiceView,来展示用户的相关数据。您可以使用类似 ListBoxDataGrid 控件来显示和管理数据。

<UserControl x:Class="YourApp.InvoiceView" ...>
    <ListBox Name="invoiceList" />
</UserControl>

3. 在正确输入密码后显示新的视图

当用户成功登录并输入正确密码时,您可以在现有视图中切换到新的 InvoiceView 视图,展示发票数据。例如,您可以在密码验证成功后通过代码打开新视图:

if (passwordEntered == correctPassword)
{
    // 密码正确,显示发票视图
    InvoiceView invoiceView = new InvoiceView();
    invoiceView.Show();
}

4. 进一步扩展应用程序

您现在已经掌握了如何创建和操作数据,如何处理事件和用户输入,以及如何在 WPF 中使用视图和用户控制来展示数据。您可以根据自己的需求进一步扩展应用程序,例如:

祝您学有所成

现在,您已经掌握了开发 WPF 应用程序的基本技能,并且有足够的知识来创建更复杂的应用程序。我希望您在学习本章内容的过程中感到愉快,并且在接下来的章节中继续深入探索更多的 WPF 和 C# 开发技巧。祝您在后续的学习中收获更多,享受编程的乐趣!

WangShuXian6 commented 2 weeks ago

15 - WPF项目:货币转换器第一部分

232 - WPF货币转换器项目概述与设置

欢迎回来。那么,我们来看看在这个视频中我们将构建的内容。我们将使用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属性、水平对齐属性、边距以及垂直对齐属性。显然,除了这些属性外,还有很多其他的属性你可以修改。你可以在这里输入空白字符,看到它会列出一些你可以直接修改的属性。并且不仅是属性,还有一些命令可以在这里执行,某些事件也可以在这里处理,比如上下文菜单的打开、关闭等。所以有很多不同的属性和命令,你可以直接使用它们,玩一玩看看它们是做什么的。但当然,我们会关注在这个应用程序中最重要的那些。

好了,让我们开始吧。

233 - WPF货币转换器:矩形与渐变

欢迎回来

首先,我将进行一些更改,我要修改这个窗口的标题。我将它从“main window”改为“currency converter”(货币转换器)。接下来,我将调整窗口大小,设置为size to content(适应内容),并为窗口设置width(宽度)和height(高度),窗口的启动位置设为center owner(居中显示)。如果你想要始终在屏幕中央打开窗口,也可以选择center screen。让我们先测试一下,运行应用程序,你会看到它直接在屏幕中央打开,尽管如你所见,窗口内目前没有任何内容。

现在,由于设置了size to content,窗口的大小会根据内容自动调整。即便我已经在这里硬编码了窗口的高度和宽度,但由于size to content的设置,它会覆盖这些值。因此,编译器会忽略我在上面定义的高度和宽度。如果我将它们删除,再次运行应用程序,你会看到窗口的尺寸会恢复为我定义的默认值。但如果你希望窗口的大小与窗口内的内容大小一致,可以使用size to content设置。如果我没有任何内容,窗口会非常小,但一旦加入内容,它的大小会随着内容的增加而自动调整,避免出现看起来很奇怪的情况。

了解窗口的 XML 名称空间和布局

接下来,让我们来看看窗口的结构。我们正在使用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),这决定了渐变的过渡方式。

使用BorderBrush创建边框效果

为了让设计看起来更有层次,我在border中还添加了一个border brush(边框画刷),这将使边框更加突出。你可以看到,虽然边框的颜色很细微,但它增强了整个视觉效果。

设计自由度

尽管这个矩形可能看起来比较简单,但它的设计方式其实提供了很大的自由度。你可以根据设计需求调整颜色、边框厚度、圆角半径等属性。对于界面的每个元素,你都有大量的属性可以进行自定义,以便创造出理想的UI效果。

了解StackPanel

接下来,我将开始使用StackPanel来添加更多的内容。StackPanel是一种常用的布局容器,它允许你按照垂直或水平方向排列子元素。在这个应用中,我将利用它来组织我们需要的各类控件。

234 - WPF货币转换器:设置堆栈面板与标签

创建窗口和UI布局

首先,改变窗口的标题为“Currency Converter”,并设置窗口的大小为“适应内容”(Size to Content)。我们将窗口的启动位置设置为居中显示(Center Owner)。在测试时,你会发现窗口会自动居中显示,尽管此时窗口内没有任何内容。这是因为“Size to Content”会覆盖你设定的窗口宽高,即使你已经在代码中明确设置了窗口的宽度和高度。

了解Grid布局

接下来,我们开始创建Grid布局。在WPF中,Grid是用来排列和定位UI元素的容器。Grid由多个行和列组成,在使用Grid时,我们需要定义这些行和列的结构。我们可以为Grid添加“行定义”(RowDefinitions)。例如,定义三行的高度分别为62、83和150像素。你可以看到Grid中的每一行的高度已经设置,但还没有列定义。接下来,我们可以添加内容到Grid中并在特定的行中定义位置。

添加Border和Rectangle

我们开始在Grid中添加内容,首先是一个边框(Border)。在Grid的第二行(Grid Row 2)中添加一个边框,并在其中创建一个矩形(Rectangle)。矩形的背景使用渐变色(Linear Gradient Brush),从左到右有一个渐变效果,起始点为0,0,结束点为1,0。我们定义了渐变的颜色起止:起点是粉红色(#EC2075),终点是红色。使用这种渐变效果使得背景颜色看起来更加丰富。

StackPanel的使用

接着,我们开始使用StackPanelStackPanel是一个垂直或水平排列其子元素的容器。在StackPanel中,我首先添加一个标签(Label),其内容为“Hello World”。你可以通过设置StackPanelWidth属性,使其占据整个窗口宽度,或者为其设置一定的Height来控制其显示。默认情况下,StackPanel会自动占据Grid中的第一个可用位置,但我们也可以显式指定它在Grid中的位置,比如Grid.Row="0"

调整StackPanel内容和对齐

在此基础上,我将StackPanelOrientation属性设置为Horizontal,使得标签水平排列。接下来,我修改标签的内容为“Currency Converter”,并调整标签的宽度为1000,填充整个宽度,同时将文本内容居中对齐。你可以使用HorizontalContentAlignmentVerticalContentAlignment来控制文本的对齐方式。

设置字体和颜色

标签的字体大小被设置为25,并使用之前定义的粉红色(#EC2075)作为字体颜色。需要注意的是,标签的颜色属性使用的是Foreground,而不是Color。这样我们能够通过为Foreground设置颜色来改变文本的显示颜色。

错误处理与调试

在UI设计过程中,如果你遇到错误,XAML编辑器会显示出错误信息。例如,缺少某些引用的资源,或者某些控件的资源没有定义。在这个例子中,我们遇到了一个关于FontAwesome图标的问题。解决此问题的步骤是打开NuGet Package Manager,安装FontAwesome WPF包,并在XAML中添加对应的命名空间。

按钮样式的修改

为了让按钮变得圆角化,我们需要在App.xaml文件中定义一个按钮样式。通过设置Button控件的ControlTemplate,我们可以为按钮设置圆角边框。使用TemplateBinding可以将按钮的背景属性与控件模板中的背景关联,最终实现我们希望的圆角按钮效果。

总结

通过这些步骤,我们逐步构建了一个简洁的UI布局。首先,定义了窗口的基本属性,然后使用GridStackPanel来安排UI元素,接着通过设置各种对齐和样式属性来调整布局和外观,最终实现了一个功能完整且美观的界面。

235 - WPF货币转换器:自定义按钮与实现点击事件

介绍

在这段代码中,我们继续讲解如何通过在 WPF (Windows Presentation Foundation) 中使用 StackPanel 来构建用户界面,并通过不同的控件和布局属性调整元素的位置和样式。我们主要讨论了如何使用 StackPanel 进行布局,如何实现按钮的圆角样式,以及如何通过 XAML 和后端 C# 代码交互来动态更新界面。

创建圆角按钮样式

我们首先介绍了如何创建一个圆角按钮样式。通过在 App.xaml 文件中定义一个全局样式,可以使应用程序中的所有按钮都变为圆角按钮。具体步骤如下:

  1. 定义样式:通过在 App.xaml 中添加样式,设置 Button 控件的 ControlTemplate,使其具有圆角和特定的背景色。
  2. 使用样式:在 XAML 中,使用 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 布局

在这个示例中,使用了多个 StackPanel 来布局 UI 元素。每个 StackPanel 用来垂直或水平堆叠子元素,并通过设置 Orientation 属性来决定堆叠方向。

水平布局

我们首先通过 StackPanelOrientation 属性设置为 Horizontal,将多个 Label 水平排列。

<StackPanel Orientation="Horizontal">
    <Label Content="Hello World"/>
    <Label Content="Currency Converter"/>
</StackPanel>

此时,Hello WorldCurrency Converter 将会并排显示在同一行中。

垂直布局

如果将 Orientation 设置为 Vertical,元素将会垂直排列在一起。下面是修改后的代码:

<StackPanel Orientation="Vertical">
    <Label Content="Converted Currency"/>
    <Label Content="Enter Amount"/>
</StackPanel>

这会将两个标签垂直显示。

使用 Grid 和 StackPanel 控制位置

我们将 StackPanel 放置在 Grid 的特定行中,并为其设置宽度和高度。通过定义 Grid.RowGrid.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>

动态更新 UI 元素

通过 C# 代码,我们可以动态地修改界面元素的内容。例如,在按钮点击事件中,我们可以更新 Label 的文本。

  1. 获取控件引用:通过设置控件的 x:Name 属性,在 C# 代码中引用该控件。
  2. 修改控件内容:在按钮的事件处理方法中,修改 LabelContent 属性。

以下是一个按钮点击事件的处理方法:

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 控件中,我们可以验证用户输入的文本,确保它符合特定的格式。

  1. 设置事件处理程序:在 XAML 中通过 PreviewTextInputTextChanged 等事件,绑定到 C# 代码中的方法。
  2. 实现输入验证:在事件处理方法中编写逻辑,检查用户输入的内容是否合法。

例如,以下代码将设置一个文本框的输入验证事件:

<TextBox Name="AmountTextBox" PreviewTextInput="AmountTextBox_PreviewTextInput"/>
private void AmountTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    // 只允许输入数字
    if (!char.IsDigit(e.Text, 0))
    {
        e.Handled = true; // 阻止非法字符输入
    }
}

总结

在这个教程中,我们展示了如何通过 StackPanelGrid 控件创建灵活的布局,并通过 C# 代码动态更新 UI 元素。我们还学习了如何创建圆角按钮样式,如何处理用户输入事件,以及如何在 WPF 应用程序中使用图片。通过这种方式,我们可以将 UI 和逻辑分离,从而使开发更加高效和模块化。

236 - WPF货币转换器:创建带有文本框与下拉框的输入字段

欢迎回来。接下来我们有另一个 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 的方法。为了使它能够正常工作,事件处理方法必须包括 senderRoutedEventArgs 参数。但我们通常直接使用自动生成的 ConvertClick 方法,它已经能够完成大部分的工作。

每个按钮的样式都可以通过 XAML 来定义,像按钮的背景色、前景色、字体大小等。对于 Convert 按钮,我们设置了 FontSize 为 20,并且给它设置了一个 LinearGradientBrush 背景,用于显示渐变色。你也可以使用简单的颜色,如 AliceBlue,并通过 ButtonBackground 属性来设置颜色。如果想要用渐变色,就需要像我们之前一样使用 LinearGradientBrush

对于 Clear 按钮的设置和 Convert 按钮类似,我们也设置了 FontSizeBackground,并且同样指定了点击时触发的事件处理函数。

最后,我们有一个 StackPanel,它位于第四行,用于显示我们的项目logo。这个 StackPanel 包含一个图像元素,并且该图像的源(Source)设置为 images/logo.png,这是我们在 images 文件夹中存放的logo文件。为了让这个图像显示正确,我们必须确保文件已经被正确添加到项目中。在这里,我们指定了图像的高度和宽度,并且设置了它在界面中的垂直对齐方式。

通过这种方式,我们已经完成了整个UI的设置和布局,确保它在窗口中显示正确,并且能够与后端逻辑进行交互。

237 - WPF货币转换器:将值绑定到下拉框

欢迎回来

好的,现在我们来讨论一下逻辑部分。正如你之前所见,我们可以直接在我们的代码背后文件中添加逻辑,这个文件就是 MainWindow.xaml.cs 文件。这个文件中包含了程序的主逻辑,所有的功能都从这里开始。

绑定ComboBox的值

首先,我们需要为我们的两个 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,并为它添加了两列:TextValue。这些列分别用于显示文本和存储选定的值。然后,我们为每个货币类型添加了一些行。

接下来,我们将数据表的默认视图设置为 CNBFromCurrencyItemsSourceDisplayMemberPath 被设置为 Text,以确保显示货币名称,而 SelectedValuePath 则设置为 Value,确保每个选项背后有对应的值。

调用绑定方法

为了执行这个方法,我们需要在窗口加载时调用它。例如,可以在窗口的构造函数中调用:

public MainWindow()
{
    InitializeComponent();
    BindCurrency(); // 调用绑定方法
}

另一个ComboBox的绑定

对于第二个 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;
}

详细解析

测试和调试

当我们运行应用程序时,首先会看到两个下拉框,它们显示了可选的货币类型。如果我们没有输入任何金额并点击“Convert”按钮,程序会提示用户输入金额。输入有效数字后,程序会计算并显示转换结果。

小结

这个过程中,我们实现了:

  1. ComboBox 绑定货币数据。
  2. 通过按钮点击事件处理货币转换。
  3. 实现了输入验证和汇率转换逻辑。

这些都是基础的逻辑部分,之后我们还可以进一步扩展和优化,比如处理不同的货币汇率,或者将转换的逻辑通过调用外部API来实现。

238 - WPF货币转换器:输入验证与逻辑完成

代码逻辑分析与扩展

接下来,让我们分析并扩展这个应用的代码,理解如何处理用户输入、验证以及界面交互。

输入验证与错误提示

首先,我们对输入进行验证,确保用户输入的数据有效。如果用户没有输入货币值,或者选择的货币类型为空,我们会通过消息框提示用户:

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;
}

如果 comboBoxFromCurrencycomboBoxToCurrency 的选择是“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);
}

这个正则表达式限制了用户只能输入数字,不允许任何非数字字符(如字母或符号)。如果用户输入了非法字符,这个字符将被忽略。

最终的用户界面与限制

为了提高用户体验,我们还为窗口设置了最小尺寸,以防止窗口缩得过小影响布局。通过设置 MinHeightMinWidth,我们可以确保窗口始终保持足够的大小:

this.MinHeight = 400;
this.MinWidth = 1000;

这些设置确保了即使用户尝试手动缩小窗口,界面布局仍能正常显示。

程序运行效果

一切设置完成后,用户可以输入金额,选择源货币和目标货币,点击“转换”按钮后,界面会显示转换后的金额。若选择相同的源货币和目标货币,则会直接显示原始金额。清除按钮可以重置所有输入字段,方便用户重新输入。

通过这些操作,用户不仅能获得实时的货币转换结果,还能得到友好的错误提示和操作反馈。

总结

WangShuXian6 commented 2 weeks ago

16 - 使用数据库与C

240 - 数据库简介

数据库章节介绍

欢迎来到数据库章节。在这一章中,我们将学习如何使用数据库,或者说“数据库”(根据你的发音习惯而定,两个都可以)。在这里,我们将深入了解如何在程序中使用大量数据、如何创建数据,甚至如何建立一个数据库,以及与之相关的各个组成部分。这是编程中非常重要的一部分。如果你想成为一名全职的 C# 开发者,那么了解如何创建和使用数据库非常关键。更重要的是,你将学会如何从数据库中获取数据,并对这些数据进行处理。

在本章结束时,你将具备如何与数据库交互的基本能力,并能够在你的程序中高效地使用这些数据。接下来,我们还将通过 LINQ 来提升效率,这部分内容将在下一章中介绍。但问题是,在掌握 LINQ 之前,你需要先了解数据库的基本知识。因此,让我们从数据库的基础知识开始,然后再进入更高级的部分。

学习目标

  1. 如何使用数据库:如何在你的程序中使用和处理大量数据。
  2. 如何创建数据库:如何构建一个数据库以及它所需的各个组成部分。
  3. 如何获取数据:你将学习如何从数据库中提取数据,并根据需要对其进行处理。
  4. LINQ 的高效使用:尽管这部分将在下一章讲解,但你需要掌握数据库的基础知识,才能有效地使用 LINQ。

我们将在接下来的内容中深入探讨这些概念,并通过实践帮助你掌握数据库的使用方法。

241 - 设置MS SQL Server和VS进行数据库工作

数据库设置与连接讲解

欢迎来到这节课程。我是 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 Management Studio

现在,我们已经安装了 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 中设置 SQL Server 连接

接下来,我们将在 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 实例的安装、配置和数据库创建过程。接下来,你可以开始在项目中使用这个数据库进行开发和管理了。感谢观看!

242 - 数据集和表的介绍与设置

欢迎回来。在本视频中,我们将创建我们的第一个表格。在我们开始之前,我想向你展示一下本章节的最终成果。正如你所看到的,我这里有一个名为“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 部分相关的应用程序开发了。

243 - 关系或关联表

欢迎回来!在本视频中,我们将创建两个表,并设置它们,以便能够编写我们在上一个视频中看到的那个小程序。好了,让我们开始吧,首先在这里创建一个新表。我打开服务器资源管理器中的“表”部分,选择正确的数据连接,然后创建一个新表。点击“添加新表”。新表应该命名为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表被选中了,我还需要选中AnimalZoo_Animal这两个表,选择它们后点击“完成”。这样,它们就被添加到我们的数据源中了。现在,我们的数据源包含了所有的表数据。如果没有这些配置,它们就会为空。现在,我们可以用设计器来编辑我们的数据集了。我将打开它,可以看到我已经有了三张表:Zoo表、Animal表和Zoo_Animal表,这非常棒。虽然这些表还没有彼此连接,但我们稍后会做这个操作。首先,Zoo表有一个ID和一个位置,Animal表有ID和名称,而Zoo_Animal表包含了zoo_idanimal_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查询语句。在这个查询中,我可以选择所有来自ZooAnimal的表数据。例如,我可以运行SELECT * FROM Animal,这将返回所有动物的记录。如果你想检查所有动物园的数据,可以使用SELECT * FROM Zoo。如果你想查看某个特定动物园中的动物,比如纽约的动物园(ID = 1),你可以使用INNER JOIN连接Animal表和Zoo_Animal表,通过Zoo_Animalzoo_id连接到Zoo表,查看纽约动物园中有哪些动物。通过这种方式,我们可以快速地检索到每个动物园中都有哪些动物。

总之,这就是我们如何通过外键和关系表来管理动物和动物园之间的关系。在下一个视频中,我们将使用C#编写相应的代码来操作这些数据。

244 - 在ListBox中显示数据

开始构建用户界面(UI)

欢迎回来。现在你已经知道如何创建你的第一个查询、如何设置数据库等,接下来我们就可以开始处理UI并做一些疯狂的事情了。所以我们先来看看主窗口的XAML文件。我们可以像这样保持文件打开,稍微关闭一下其它部分,这样可以获得更多空间,实际上这样就好了。好,首先我们需要什么?我们需要一个列表框。在这里我使用了一个ListBox,我们来看看它的效果。我们可以看到有标签(label),然后是ListBox、按钮等。我们先从标签和列表框开始,后续再添加其他内容。虽然我们可以通过非常规范的方式创建所有这些元素,使用网格布局(grid)以及不同的列和行等,但我决定直接将所有东西拖放到窗口中,让它们自动完成工作。因为这一章教程并不主要讲解WPF,它更多的是关于数据库组件。所以我不会花太多时间在XAML上。

为了在主窗口中添加内容,我先放大一点。实际上我们不需要太大的缩放,因为我们只需要查看代码是如何添加的。接下来,我将开始拖放所需的组件。首先,我需要一个标签(label),所以我拖放一个标签并将它放置在左上角,标签内容修改为"Zoo List"。然后,我将添加一个ListBox,它将包含所有不同的列表项。我把它拖到“Zoo List”标签下面,并适当调整一下大小,设置为500 x 725。现在,我可以稍微让它更宽一些。正如你所看到的,所有布局设置都已经自动完成了。标签有一定的边距,垂直对齐在顶部,水平对齐在左边,而列表框水平对齐在左侧,并设置了特定的高度,还有顶部的边距。这样,所有元素都已经准备好了。

设置ListBox并为其命名

接下来,我希望能看到列表项或列表元素出现在“Zoo List”中。为此,我需要给它设置一个名称。我将为ListBox添加名称属性,命名为ListZeus(因为这是一个包含所有动物园的列表)。

配置SQL连接并显示Zoo数据

现在,让我们回到代码部分,因为我想将所有动物园展示在这个列表中。我们可以运行查询等,但这还不够,我们需要使用特定的C#方法,并且可以通过LINQ来更简洁地实现(不过LINQ将在下一个章节讲解)。为了保持简单,我们先采用传统的方法。首先,我们需要建立SQL连接。我们来创建一个SQL连接对象,并为此添加所需的命名空间:

using System.Data.SqlClient;

接下来,我们在MainWindow的构造函数中初始化SQLConnection对象:

SQLConnection connection = new SQLConnection(connectionString);

此时,程序已经通过connectionString连接到数据库了。

查询Zoo数据

接下来,我需要创建一个方法来展示动物园数据。为了实现这一点,我将创建一个私有方法,命名为ShowZoos,只在当前类中使用。我们首先要准备SQL查询:

string query = "SELECT * FROM Zoo";

然后,使用SQL适配器(SQLDataAdapter)来运行查询,并填充一个数据表:

SqlDataAdapter adapter = new SqlDataAdapter(query, connection);
DataTable zooTable = new DataTable();
adapter.Fill(zooTable);

SQL适配器会管理SQL连接的打开与关闭,这样我们就不需要手动管理连接的生命周期。接下来,我将通过设置ListBoxItemSource属性来展示数据:

ListZeus.DisplayMemberPath = "Location";
ListZeus.SelectedValuePath = "ID";
ListZeus.ItemsSource = zooTable.DefaultView;

显示Zoo列表

以上代码的作用是将从数据库中检索到的动物园地点作为ListBox的显示内容,并将ID作为选中项的值。ItemsSource被设置为数据表的默认视图,这样就将数据库中的数据填充到ListBox中了。

错误处理

最后,我添加了一个简单的错误处理机制。在数据库操作时,异常时有发生,因此我使用try-catch来捕获异常并显示错误信息:

try
{
    ShowZoos();
}
catch (Exception e)
{
    MessageBox.Show(e.ToString());
}

这样,如果出现任何异常,程序会显示一个消息框,告知用户发生了什么错误。

运行应用并验证

现在,如果我们运行这个应用程序,应该可以看到在ListBox中列出了所有动物园。如果没有显示数据,请确保在数据库表中确实有数据,并且数据库连接是正确的。

下一步

下一节将讲解如何根据用户点击某个动物园,显示该动物园的相关动物信息。

245 - 显示关联数据

欢迎回来。在本视频中,我们将添加另一个列表,用于显示与特定动物园相关的动物列表。正如你所知道的,我们有一个动物园列表,还有一个动物列表。我们需要一个列表,显示在特定动物园内的动物。例如,如果我点击纽约,我希望在这个附加的列表视图中看到纽约动物园内的动物。所以让我们开始吧,首先我们需要进入我们的 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 查询、SQLCommandSQLDataAdapter 来结合数据库和 C# 代码,处理数据库查询,并将结果显示在界面上。

至此,我们已经实现了关联动物的列表,接下来在下一个视频中,我们将添加一个显示所有动物的列表,并加入一些额外的功能。所以,下次视频见。

246 - 在ListBox中显示所有动物

欢迎回来。现在你已经知道了如何创建动物园表格以及与动物相关的动物表格,是时候来创建我们的动物表格了。这个表格将包含数据库中的所有动物,并将显示在这个列表框中。因此,我们将创建一个动物表格,并将所有的数据展示在此列表框中。请按照视频的步骤尝试实现,应该不会太难。你可以将之前视频中学到的知识应用到这个任务上。

接下来,复制现有的列表框,粘贴并拖动到适当位置,确保两个列表框之间的距离差不多。然后稍微调整列表框的大小,延长它的长度。之后,我将在这里添加一个按钮,虽然现在数字并没有完美对齐,但这可以在稍后调整窗口大小时解决。现在我们还不添加标签,因为我稍后会添加一个按钮,它包含“添加动物”和“删除动物”按钮。

显示动物列表

现在,我们已经有了这个列表框。接下来,我们将创建一个方法来显示所有动物。我们可以参考之前的 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 绑定到这个列表框,并设置正确的 DisplayMemberPathSelectedValuePathDisplayMemberPath 将用于显示动物的名称,而 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 表来获取数据。接下来,我们将在下一个视频中添加按钮功能,展示如何添加和删除动物,并完成其他相关功能。下次视频见。

247 - 通过点击从表中删除

欢迎回来。在本视频中,我们将添加剩余的用户界面,并且现在实现一个功能,即删除动物园(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)。具体步骤如下:

  1. 创建一个新的SQLCommand对象。
  2. 将查询语句和数据库连接传入该命令。
  3. 打开数据库连接。
  4. 添加ZooID参数。
  5. 执行删除命令。

在命令执行之后,我们要确保关闭数据库连接,以避免连接泄漏。

SQLConnection.Open();
SQLCommand.Parameters.AddWithValue("@ZooID", ListZeus.SelectedValue);
SQLCommand.ExecuteScalar();
SQLConnection.Close();

处理级联删除

在删除动物园时,我们还需要删除动物园与动物之间的关系记录。在数据库中,可能存在一个连接表(例如ZooAnimal),它存储了哪些动物属于哪些动物园。如果我们删除一个动物园,我们也需要删除这些关系记录。为了简化操作,可以在数据库设计时为外键添加“级联删除”(ON DELETE CASCADE)。这样,当删除动物园时,关联的动物记录会自动被删除。

我们可以通过更新表约束来实现这一点:

  1. 打开表定义。
  2. 为相关的外键约束添加“ON DELETE CASCADE”选项。

这将确保在删除动物园时,相关的动物记录自动删除。

处理异常

在删除操作过程中,可能会发生异常(例如外键约束错误)。为了更好地处理这些异常,我们可以使用try-catch-finally块:

try
{
    SQLConnection.Open();
    // 执行删除操作
    SQLCommand.ExecuteScalar();
}
catch (Exception e)
{
    MessageBox.Show(e.ToString());
}
finally
{
    SQLConnection.Close();
}

通过这种方式,即使出现错误,程序也能继续运行,并显示详细的错误信息。

更新UI

在删除动物园或动物时,我们需要更新界面上的数据。在删除动物园后,我们调用显示所有动物园的方法来刷新列表,以便用户看到最新的状态。我们可以在适当的地方调用ShowZeus方法:

ShowZeus();

这将确保删除操作后,界面显示的是最新的数据。

总结

现在,你已经学会了如何在应用程序中删除动物园,并处理级联删除和异常。接下来的内容,我们将学习如何向列表中添加元素。在下一节视频中,我们将实现这个功能。

249 - 删除动物、移除动物和添加动物功能

删除和添加功能

现在我们已经学会了如何从数据库或表中删除条目,但由于目前的表格比较空,我们需要向其中添加一些内容。因此,我们需要添加一个文本框,并且需要有一个“添加动物园”的按钮。让我们开始构建这些功能并添加相应的逻辑。

添加“添加动物园”按钮

首先,我们需要添加一个新方法,命名为 private void add_zoo_click,并且它将接收一个 object senderrouted_event_args。当用户点击“添加动物园”按钮时,此方法会被触发。为了实现这一点,我们需要在按钮的 Click 事件中绑定这个方法。因此,在按钮的事件绑定中,我们会将 add_zoo_clickClick 事件关联,确保用户点击按钮时执行我们的方法。

插入新数据到数据库

接下来,我们将实现与删除功能类似的代码,但需要插入新数据。具体来说,我们将使用 INSERT INTO SQL 语句向特定表中添加一行数据。这里我们添加的内容是“位置”,即添加动物园的位置。我们还需要将文本框的输入作为值插入到数据库中。为了做到这一点,首先我们给文本框命名为 myTextBox,然后通过 myTextBox.Text 获取用户输入的文本内容。

完成插入操作

我们将创建 SQL 命令,执行 INSERT INTO 查询,将用户输入的内容插入到数据库中。然后,打开数据库连接,执行 SQL 命令并添加相应的参数。若没有出现错误,插入操作成功后,我们需要刷新页面显示新增的动物园。

显示更新的动物园列表

为了让用户看到新增的动物园,操作完成后,我们需要调用一个显示动物园的函数。否则,页面上不会显示出新增的条目。

添加动物到动物园

接下来,我们需要添加一个新的按钮,用来将动物添加到指定的动物园。我们首先实现了一个 add_animal_to_zoo_click 方法,这个方法将在按钮点击时被触发。此时,按钮会显示一个消息框,以确认按钮点击事件被成功触发。

动物和动物园的关系

在数据库中,zoo_animal 表用于关联动物和动物园。我们需要执行一个 INSERT INTO zoo_animal 操作,将选定的动物 ID 和动物园 ID 插入到该表中。通过这种方式,每个动物和动物园之间建立了关联。之后,我们也会显示动物园和它们相关的动物。

更新和删除动物

我们还需要实现删除动物的功能。当用户点击“删除动物”按钮时,系统将删除选中的动物。同时,我们要刷新动物列表,确保用户看到删除操作后的更新结果。

完善界面交互

在完成所有按钮功能后,我们还需要确保所有按钮绑定了正确的事件,避免操作时出现错误。例如,删除动物时,需要确保删除的是当前选中的动物,并且更新列表显示最新的动物数据。

更新动物园和动物信息

目前我们的功能中缺少了“更新动物园”和“更新动物”功能。虽然用户可以添加新的动物园和动物,但如果想要更新已有的动物园或动物信息,这部分功能还未实现。未来,我们会针对这部分进行补充,确保数据能够实时更新。

结语

到目前为止,我们已经完成了很多功能的实现,包括删除、添加动物园和动物的功能,并且通过按钮绑定事件完成了交互操作。接下来,我们将实现更新动物园和动物的功能,并完善界面,以便用户能够更新动物园和动物的相关信息。如果你在开发过程中遇到问题,可以参考以上步骤并根据具体需求调整代码。

250 - 更新我们表中的条目

在这个视频中,我们将添加“更新动物园”和“更新动物”按钮。首先,我希望更新文本框中的内容,根据我选择的动物园或动物显示相应的信息。所以,让我们创建这个功能并进入后台代码。首先,我会创建一个新方法,命名为“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查询。

251 - 数据库总结

好的,现在我们已经完成了数据库章节。这一章是最复杂的章节之一。我们不仅使用了用户界面,还处理了大量的数据,并且我们已经学会了如何使用这些数据,甚至自己创建数据。我希望你喜欢这一章的内容。

接下来,我们将学习如何使用一个名为LINQ的第三方库,它将使我们的工作变得更加轻松。LINQ允许我们以一种更流畅的方式来处理数据,从我的角度来看,它更加直观和吸引人。让我们继续进入下一个章节,了解如何使用LINQ。

WangShuXian6 commented 2 weeks ago

17 - WPF项目货币转换器第二部分

252 - WPF货币转换器:构建一个带数据库集成的货币转换器

欢迎回来。好的,让我们开始吧。今天视频中我们要构建的是一个货币转换器,正如你所看到的那样。在第一部分视频中,我们已经构建了一个基本的货币转换器,大家应该已经看过了。在第一部分中,我们使用了静态数据,而这一次我们将使用数据库。所以我们会扩展这个功能,添加两个不同的标签页。一个标签页用于货币转换器,另一个标签页用来输入不同的汇率或特定货币的汇率值。

例如,这里欧元是基本货币,€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 中创建不同的标签页,非常简单。

253 - WPF货币转换器:设计货币转换的用户界面

欢迎回来!如您所见,我们现在要创建这个界面。它与之前的界面非常相似。我们有一个红色的框,并且顶部有粉色的文字。我们不仅仅有一个文本编辑框,也没有组合框,而是有两个输入框,按钮位置和之前的类似,底部有两个按钮,按钮的文本略有不同。此外,我们还没有图标,实际上,底部是有图标的。接下来就是一个数据网格。好,那么我们如何实现这些呢?

我将从我的网站上直接复制代码,因为我会逐行解释这段代码。您当然可以自己手动构建一切,因为实际上您可以从中学到很多东西。所以,我将给出一些构建步骤的指导。我们现在先来分析这段代码。

1. 网格布局

在代码中,您可以看到我们使用了一个有五行的网格布局。换句话说,界面被划分成了五个不同的部分:

  1. 第一行是“货币转换器”文本。
  2. 第二行是货币转换器的内容,包括其他相关文本。
  3. 接下来的几行分别包含文本框、按钮和图标。

为了实现这一点,您需要定义一个网格,并为每一行设置不同的行高。例如,定义行的大小如下:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="*" />
        <RowDefinition Height="*" />
        <RowDefinition Height="*" />
        <RowDefinition Height="*" />
        <RowDefinition Height="*" />
    </Grid.RowDefinitions>
</Grid>

这样一来,网格就会有五行,每行的高度相等,当然您可以根据需要调整每一行的高度值。

2. 复制和粘贴代码

接下来,我将直接粘贴代码,并逐一解释每一部分。首先,我们需要创建一个 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"/>

这两行代码就是实现图标按钮的部分。这些按钮会有相应的点击事件,允许您编辑或删除数据。确保在后台代码中定义相应的事件处理方法。

3. 处理资源文件

在UI代码中,您看到有几个资源被引用,例如按钮的图标。您需要将这些图标添加到项目中的资源文件夹下。例如,您可以将图标图片添加到 Images 文件夹,并且在代码中使用如下路径:

<Image Source="Images/edit_button.png" />
<Image Source="Images/delete_button.png" />

确保在项目中正确引用了这些文件,并且在代码中使用了正确的文件路径。如果没有正确设置资源文件,可能会导致程序无法找到图标。

4. 处理事件和逻辑

在前端UI部分中,我们已经创建了布局和按钮,但这些按钮并不具备实际的功能。为了让它们有效,您需要在代码后台定义按钮点击事件。例如:

private void SaveButton_Click(object sender, RoutedEventArgs e)
{
    // 处理保存逻辑
}

private void CancelButton_Click(object sender, RoutedEventArgs e)
{
    // 处理取消逻辑
}

您可以通过右键点击事件触发器,选择“转到定义”来自动生成这些方法。如果自动生成没有生效,您可以手动添加方法并链接到按钮的事件。

5. 编写数据网格相关的事件

如果您有一个数据网格来显示数据,您还需要为数据网格设置选中行变化的事件处理函数。例如:

<DataGrid Name="CurrencyGrid" SelectionChanged="CurrencyGrid_SelectionChanged"/>

在后台代码中,您需要为 SelectionChanged 事件定义处理方法:

private void CurrencyGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    // 处理选择变化
}

6. 调试和测试

在您实现了上述步骤后,运行程序看看它是否能够正常显示。您可能会遇到一些错误,比如找不到资源文件或未实现的事件方法。在这种情况下,检查您的文件路径,确保所有资源都正确添加到项目中。

7. 总结

通过这些步骤,您可以创建一个类似的界面,并且在后台实现相关逻辑。您可能需要逐步进行调试,以确保所有功能正常工作。如果有任何疑问,建议再次查看第一部分的内容,确保您理解了UI和后台逻辑的搭建方式。

希望这些指导能帮助您顺利构建自己的货币转换器界面!

254 - WPF货币转换器:理解数据网格功能和属性

介绍数据网格

让我们深入了解这个数据网格(DataGrid),因为这是我们在上一个视频中没有涉及到的一个新功能。数据网格基本上是这个整个框架——从上到下的矩形框。数据网格内包含列(Columns),每列有不同的定义。在我们当前的例子中,我们有四列:一个数据网格文本列(DataGridTextColumn),两个模板列(TemplateColumn),以及另一个数据网格文本列。

数据网格的结构

为了创建这样的数据网格,我们可以为其设置多个属性。例如,我们可以设置高度、宽度、边距等。在此示例中,宽度设置为480像素,背景被设定为透明。我们还设置了一个属性 CanUserAddRowsfalse,这意味着我们不允许用户直接通过UI添加行,而是希望通过代码手动控制这一操作。

数据网格也需要一个名称,便于在代码中访问。在这里,我们将数据网格命名为 DgvCurrency(表示货币数据网格)。

选择单元格变化事件(SelectedCellsChanged)

一个重要的事件是 SelectedCellsChanged。这个事件会在用户选择不同单元格时触发。具体来说,当我们在数据网格中的某一行进行更改时,事件会触发,并且会自动更新显示在该行的数据。

例如,当你点击某一行并更改金额后,所选行中的数据将自动更新。虽然没有点击“添加”按钮,但系统仍然会知道是哪个单元格被选中了,这就是通过 SelectedCellsChanged 事件实现的。

选择单元格的单位

在这个事件中,你还可以定义选择单元格的单位。比如,可以选择“单个单元格”,或者选择“整行”。在我们当前的设置中,我们选择了单个单元格,因此每次选择一个单元格时,只会选中该单元格。如果你设置为“整行”,那么点击任何单元格都会选中整行。

滚动条的设置

滚动条(VerticalScrollbar)设置为 Auto,这意味着滚动条将根据需要显示——只有当数据量超出视图范围时,滚动条才会自动出现。如果设置为 Visible,滚动条将始终可见;如果设置为 Hidden,则始终隐藏滚动条。这样,Auto 设置确保了界面的整洁,仅在必要时显示滚动条。

列的定义

数据网格内的列有不同的定义方式。我们有一些列没有显示名称,例如显示图标的列,另一些列则显示内容,如“金额”和“货币名称”。

数据绑定与显示

数据网格支持数据绑定,我们将列绑定到相应的字段。例如,“金额”和“货币名称”列绑定到数据中的 AmountCurrencyName 字段。我们将数据源(ItemSource)设置为数据库或其他数据源,以确保每一行显示正确的数据。

例如,我们将数据绑定到一个数据库,这样每一行会显示不同的货币金额和货币名称。通过设置 Binding,我们将数据库中的字段与数据网格的列关联起来。

设置列属性

我们设置了以下几个列属性:

编辑与删除按钮

数据网格中包含两个图标按钮:一个是“编辑”按钮,另一个是“删除”按钮。这些按钮通过 DataGridTemplateColumn 进行处理,意味着每个单元格都将包含这两个按钮,并且它们的显示是固定的。点击这些按钮时,会触发相应的事件,如编辑或删除某一行数据。

数据网格的功能

这些列和绑定配置确保数据网格的功能性和可用性。虽然目前的数据网格界面已经基本构建完成,但数据的具体操作还没有完全实现。接下来,我们将使用数据库进行数据填充,并通过代码来控制数据的显示与修改。

通过这些配置,数据网格不仅提供了显示数据的能力,还支持用户与数据的交互,如选择、编辑、删除操作。所有这些功能通过 BindingTemplateColumn 和相关事件实现,使得数据网格既强大又灵活。

255 - WPF货币转换器:为货币转换设置数据库

创建新文件夹和数据库

首先,我们需要创建一个新的文件夹。由于我的程序正在运行,因此我无法直接创建该文件夹,所以我首先停止程序,然后在 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个字符,可以为空
);

在 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>

连接字符串解析

总结

目前,我们已经完成了以下任务:

  1. Solution Explorer 中创建了一个 database 文件夹。
  2. 在文件夹内创建了 currency converter.mdf 数据库。
  3. 使用 SQL 代码创建了一个名为 Currency_Master 的表,表内包含 ID, Amount, 和 Currency_Name 列。
  4. 配置了连接字符串,使应用程序能够访问本地数据库。

下一步,我们将继续编写代码,利用这些设置连接数据库并操作数据。

256 - WPF货币转换器:实现数据库的SQL连接

欢迎回来!我们现在继续我们的项目,首先让我们进入 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 数据

接下来,我们需要从数据库中获取数据并将其绑定到 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 数据和数据表

在这里,我们执行 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();
}

数据库操作总结

  1. SQL 连接:首先通过连接字符串连接到数据库。
  2. SQL 命令:使用 SqlCommand 对象来执行查询(SELECT)命令。
  3. 数据适配器:使用 SqlDataAdapter 将查询结果填充到 DataTable 中。
  4. ComboBox 绑定:将数据表作为 ComboBox 的数据源绑定显示。

通过以上步骤,我们成功地将数据库中的数据绑定到应用程序的 ComboBox 中,这就是数据库交互的基础。在接下来的步骤中,我们将实现更多的功能,比如对数据库的修改、更新、删除等操作。

257 - WPF货币转换器:实现保存按钮功能

欢迎回来!现在到了关键的一步,我们需要确保点击保存按钮时能够执行相应的功能。虽然我已经运行了应用程序,并且它似乎没有问题,但是如果我们查看“Currency Master”部分,它并不会做任何事情。如果点击保存或取消按钮,什么也不会发生,因为我们还没有实现相关的功能。

在你的主窗口 (MainWindow) 文件中,你会看到有一个包含按钮的 StackPanel。这部分位于第二个标签页 TabItem Master 内。让我们打开这个部分,你可以看到里面有所有的 StackPanelTextBox,还有按钮。现在,按钮的点击事件还没有实现。

添加保存按钮的点击事件

我们需要为 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;  // 如果用户选择"否",则退出
    }
}

执行 SQL 更新操作

如果用户确认更新,我们继续执行数据库操作。我们需要通过 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);

完整代码总结

  1. 检查 TextAmountTextCurrencyName 是否为空;
  2. 如果 CurrencyID 大于零,弹出确认框;
  3. 执行 SQL 更新操作;
  4. 关闭数据库连接;
  5. 显示成功消息框。

以上就是当点击保存按钮时执行的完整功能。

258 - WPF货币转换器:添加新的货币条目

继续保存逻辑:添加新行

新行插入逻辑

currencyID 大于零时,我们进行更新操作;如果 currencyID 小于或等于零,说明是新增一行数据。在这种情况下,我们需要执行插入操作。插入数据时,我们首先需要确认用户是否真的想要保存。如果用户选择“是”,则执行以下代码。

首先,我们建立数据库连接。与更新操作相似,我们通过 command 创建一个 SQL 命令。这次,我们使用 INSERT INTO 语句将新的数据插入到数据库中。这里的 SQL 命令是:

INSERT INTO currency_master (amount, currency_name) 
VALUES (@amount, @currency_name);

SQL 命令中使用了 @amount@currency_name 作为占位符,这些占位符稍后将通过参数的方式提供实际的值。我们通过 AddWithValue 方法为这些参数赋值,确保数据传递给 SQL 查询。

command.Parameters.AddWithValue("@amount", textAmount.Text);
command.Parameters.AddWithValue("@currency_name", textCurrencyName.Text);

执行 SQL 命令后,我们关闭数据库连接,确保连接得到正确释放。关闭连接是很重要的,如果不关闭连接可能会导致资源泄漏或者错误。

清除输入字段

成功插入新数据后,我们调用 ClearMaster 方法清除输入字段。这个方法会执行以下操作:

代码实现如下:

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;  // 没有数据则清空
        }
    }
}

数据源绑定:dgvCurrency

在这里,dgvCurrency 代表我们 UI 中的 DataGridView 控件,用于展示数据库中的数据。如果数据库返回了有效数据,我们将其显示在 UI 上。否则,DataGridView 将显示为空。

总结

  1. 插入数据:当 currencyID 为零时,我们通过 INSERT INTO 将数据插入到数据库中。
  2. 清除输入:在插入成功后,调用 ClearMaster 方法清除用户输入,并重置相关控件和状态。
  3. 获取并显示数据GetData 方法负责从数据库获取并显示所有数据,确保 UI 始终展示最新的内容。

通过这些步骤,我们能够有效地管理数据库中的货币数据,无论是插入新数据还是更新现有数据。

259 - WPF货币转换器:在数据库中插入和编辑数据

欢迎回来

到目前为止,我们已经能够运行这个应用并将数据存入数据库。通过输入金额并点击保存,我们可以成功保存数据,并且在数据网格视图中显示它。现在,我们已经具备了将数据添加到数据库、从数据库获取数据的能力,接下来我们要实现的是能够编辑数据库中的数据。

首先,让我们测试一下货币转换器是否仍然有效。我将随便添加一些值进行测试:

保存这些值后,你会看到它们出现在这里。这是因为我们将这些数据框的 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 应用中处理数据库操作,并通过事件驱动的方式来更新界面。希望这对你有所帮助!

WangShuXian6 commented 2 weeks ago

18 - Linq

261 - Linq简介

欢迎来到 Linq 章节

在本章节中,您将学习如何使用一个名为 Linq 的第三方库。这个库将使您能够更高效地使用数据库,并且我们将探讨如何使用您可以在线找到的第三方库。

第三方库的使用

每当您需要使用第三方库时,首先要做的就是查阅该库的文档,深入了解它的功能和用法。如今有许多第三方库,每个库都为编程带来了不同的特色,它们能够在您开发程序时提供更简便的功能,从而让您的工作变得更加轻松。

使用 Linq 库

本章的重点是 Linq 库,它可以帮助我们将之前学到的知识以更加简洁和高效的方式结合在一起。通过 Linq ,您将能够更加高效地操作数据库,减少代码量,使您的编程工作更加流畅。

总结

接下来,我们将深入了解如何使用 Linq 来简化数据库操作,并结合您到目前为止学到的内容,提升编程效率。敬请期待下一视频的讲解。

262 - Linq温和介绍

欢迎回来

在本视频中,我将向您简要介绍 Link。Link 是一种用于从数据源中检索数据的工具,通过使用 Link 查询操作,您可以从不同类型的数据源中获取数据,这正是 Link 强大的地方。无论是数组、数据库、XML 文件,还是其他多种数据源,都可以用于查询操作。

查询操作的三部分

查询操作包括三个步骤:

  1. 获取数据源
  2. 创建查询
  3. 执行查询

这三个步骤是获取或调整数据所必需的基本步骤。

示例用法

我们来看一个简单的示例:打印出一个字符串数组中按名称排序的条目。假设我们有一个名为 names 的数组,里面有 Berta、Clause 和 Atom。

步骤 1:数据源

数据源是我们要查询的数据,示例中是 names 数组。

步骤 2:查询创建

查询的创建使用 Link 查询语法:

var query = from name in names
            orderby name ascending
            select name;

我们遍历所有的名字,按字母顺序对它们进行升序排序,然后选择每个名字。

步骤 3:查询执行

接下来,我们可以使用 foreach 循环来执行查询,并将结果打印到控制台:

foreach (var i in query)
{
    Console.WriteLine(i);
}

输出将会是:

Adam
Berta
Clause

在这个例子中,我们仅仅是重新排序了数据,而没有改变原始字符串的内容。我们以不同的顺序打印出来而已。

另一个示例

现在,我们来看另一个示例:打印出整个数组中的条目,按大小排序,但忽略小于 5 的值。假设我们有一个数字数组 numbers,它包含 7、5、13、125 和 4。

步骤 1:数据源

数据源是包含数字的数组 numbers

步骤 2:查询创建

查询创建如下所示:

var query = from number in numbers
            where number > 5
            orderby number descending
            select number;

我们首先筛选出大于 5 的数字,然后按降序排序(从大到小),最后选择这些数字。

步骤 3:查询执行

查询执行使用 foreach 循环来输出结果:

foreach (var i in query)
{
    Console.WriteLine(i);
}

输出将会是:

125
13
7

其中,数字 4 被排除在外,因为它不满足大于 5 的条件。

总结

这只是 Link 的一个非常基础的介绍,展示了如何从数组中获取和操作数据。但实际上,您可以从各种数据源中获取数据,Link 在这些数据源之间的表现是一致的,这就是 Link 的真正强大之处。无论数据源是数组、XML 文件还是数据库,Link 都能以相同的方式工作。

接下来,我们将深入探讨如何在数据库和其他数据源中使用 Link。希望您喜欢这个内容,并且准备好学习更高级的技术,您将能够将这些技能应用到更实际的编程中。

期待在下一个视频中与您见面!

263 - Linq演示

欢迎回来

在本次演示中,我们将开始使用 Link。所以,让我们先创建一个新的控制台应用程序,我将它命名为 LinkOne。创建完成后,接下来我们将创建一个简单的整数数组,命名为 numbers。这是一个 new int 类型的数组,我将提前在其中加入一些值:1、2、3、4、5,直到9。这个数组就是我们的 numbers

目标

我们的目标是从这个数组中提取出所有的奇数。实现这一功能的方法有很多种。例如,我们可以使用取余操作符(modulo),通过检查每个数字是否能被 2 整除且余数不为 0 来判断它是否为奇数。这样的方法是有效的,但我们将采用一种更为精妙的方式。

步骤

  1. 创建一个新方法
    我将创建一个名为 oddNumbers 的静态方法,该方法需要一个整数数组作为参数。这里,我们的参数将是 numbers 数组。

  2. 输出提示
    在方法中,首先在控制台上输出一个提示,告诉我们将要打印所有的奇数。

    Console.WriteLine("Odd numbers:");
  3. 使用 IEnumerable 获取奇数
    为了提取奇数,我们将使用 IEnumerableIEnumerable 是一个集合类型,类似于列表,但它具有一些附加功能。我们会使用 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 列表中。
  4. 打印奇数列表
    我们可以使用 foreach 循环打印 oddNumbers 列表中的每个奇数:

    foreach (var odd in oddNumbers)
    {
       Console.WriteLine(odd);
    }
  5. 打印所有原始数字
    为了比较,我们还可以打印出 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 的更多功能,敬请期待!

264 - Linq与列表和我们的大学管理者第一部分

欢迎回来

这是第二个 Link 演示。在这个演示中,我们将准备一些稍后会用到的内容。首先,我们将创建两个额外的类,然后再创建第三个类来管理这两个类。这两个类将用于创建有关大学和学生的对象。让我们开始吧。

创建大学类

我们首先创建一个 University 类,表示大学。每个大学有一些基本信息,比如大学的 IDName。然后我们创建一个 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 类,表示学生。每个学生有 IDNameGender(性别)和 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;
    }
}

使用 Link 查询列出男性和女性学生

接下来,我们将使用创建的 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 类,添加更多的功能,比如根据不同信息进行筛选或排序,敬请期待!

265 - 使用Linq进行排序和过滤

欢迎回来

在本视频中,我们将对学生进行排序,并且还会查看如何查找特定大学的所有学生。让我们先创建一个方法。我在我的 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 操作符来连接 studentsuniversities。我们通过学生的 UniversityID 与大学的 ID 进行匹配,并过滤出大学名称为 “Beijing Tech” 的学生。

输出北京理工大学的所有学生

运行程序时,控制台会显示来自北京理工大学的所有学生的信息。例如,学生 Frank 和 Tony(在北京理工大学,性别男,年龄分别为22岁和21岁)会显示在控制台上。

挑战:根据用户输入的大学 ID 查找学生

现在的挑战是创建一个方法,接受一个 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 和其他集合操作,我们能够灵活地处理和排序数据。

希望本视频对你有所帮助,下一步我们将在接下来的课程中扩展更多功能,敬请期待!

266 - 基于其他集合创建集合

欢迎回来!现在你已经了解了如何反转、排序数组和列表,及其他一些与 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}");
    }
}

代码解析

  1. join 操作:我们使用 join 通过学生的 UniversityID 与大学的 ID 进行关联,将每个学生与其所在的大学数据连接起来。
  2. orderby 操作:对连接后的数据按学生的姓名进行排序。
  3. select 操作:我们选择一个新的匿名类型,其中包括学生姓名和大学名称。这是我们想要在新集合中存储的信息。
  4. 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 的高级用法。敬请期待!

267 - Linq与XML

回到视频内容

在这一视频中,我们将学习如何将 LINQ 与 XML 结合使用。我创建了一个名为 Link with XML 的项目,并且编写了一个字符串,叫做 Students XML,它包含了一些 XML 数据。接下来我们将展示如何使用 XML 数据与 LINQ 进行操作。

XML 数据结构

在这个示例中,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 文件

为了开始使用 XML 数据,我们首先需要添加命名空间 System.Xml.Linq,这个命名空间包含了我们需要的 XDocument 类,能够帮助我们解析 XML 字符串。接下来,我们可以通过 XDocument.Parse() 方法将字符串转换为 XML 文档对象。

using System.Xml.Linq;

string studentsXml = "<students>...</students>";  // 你的 XML 数据
XDocument xDoc = XDocument.Parse(studentsXml);

使用 LINQ 解析 XML

通过 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 变量将包含一个学生信息的集合,每个学生的信息包括姓名、年龄和大学。

输出 XML 数据

我们可以遍历 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 提供了一个强大而简便的工具来处理这些数据。

268 - 设置LinqToSQL项目

在这个视频中,我们将使用 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_idnvarchar(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 再次打开数据源管理器。选择 “添加新的数据源”,然后选择数据库类型。在连接数据库时,可以选择你之前设置的连接。

安装 SQL Tools

为了使用 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 上下文的数据。

269 - 向我们的数据库插入对象

现在我们一切都已经设置好了,接下来我们可以创建我们的第一个代码了。其实我们已经有了一些代码,但它并没有像我们预期的那样起作用,对吧?所以让我们创建一个方法,将大学插入到我们的“大学”表中。我们有这个表格“大学”,如果我们查看数据……打开表格定义,它并不是我们想要的内容,但如果我们打开或显示表格数据,我们会看到它是空的。所以现在还没有任何数据,也没有任何名称,但我们会通过代码来改变这一点。接下来让我们创建一个新的方法,方法是公共的 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没有重置,它还是继续增长。所有之前的条目都已删除,但 ID 仍然没有回到起始值。需要记住这一点。

插入学生

现在你知道如何插入数据了。接下来是一个小挑战,插入学生。事实上,我认为这个稍微有点困难,所以我将展示如何做。让我们创建一个新的方法,叫做 InsertStudents。我打算使用 Lambda 表达式。我们之前还没有用到 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();

这将会插入所有学生并提交更改。然后,我们可以将 MainDataGridItemSource 设置为 DataContext.Students,这样就可以看到这些学生了。

最终结果

运行代码后,我们可以看到学生数据和他们对应的大学。每个学生都关联了一个大学,并且我们看到学生的性别、姓名以及大学ID。


这就是我们目前的操作,通过 Lambda 表达式和数据上下文操作数据库,成功插入了大学和学生数据。

270 - 使用Linq与关联表

在这个视频中,我们将介绍如何添加和管理课程。首先,我们将向数据库中插入课程数据,并创建一个关联表来管理学生与课程之间的关系。接下来,我将演示如何通过方法来插入课程数据,并通过关联表将学生和课程连接起来。

插入课程

我们首先创建一个新方法 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 所有的课程,并可以进一步处理这些课程信息,比如显示他的成绩或其他相关信息。

总结

本视频介绍了如何在数据库中插入课程和学生数据,并通过关联表管理学生与课程之间的关系。我们还演示了如何获取学生的大学和课程信息。通过这些基本操作,您可以更好地管理和查询学生及其课程信息。

在下一个视频中,我们将继续扩展功能,演示如何更新和删除数据,以及如何查询特定大学的所有课程等操作。敬请期待!

271 - 连接表的更高级别

获取特定大学的所有学生

首先,我们来实现一个新的方法,用于获取特定大学(例如:耶鲁大学)中的所有学生。你可以尝试自己实现这个方法,暂停视频来完成。

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;
}

在这个方法中,我们首先通过 joinStudents 表和 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;
}

在这个方法中,我们通过多次 joinStudentLecturesStudentsLectures 三个表连接起来。我们通过 student.University.Name == "Beijing Tech" 来过滤出北京科技大学的学生,然后返回他们所参加的课程。

测试代码

在主方法中调用这个方法:

GetLecturesFromBeijingTech();

运行代码后,结果显示只有“历史”课程在北京科技大学教授。问题在于我们只为 Layla 指定了历史课,而没有为 Jane 分配任何课程。要解决这个问题,我们需要确保所有学生都分配了课程。


结语

通过这些例子,我们展示了如何在 C# 中使用 LINQ 查询进行数据库操作,无需直接编写 SQL 语句。接下来,我们将学习如何更新和删除数据,进一步掌握数据操作的技巧。

在下一期视频中,我们将展示如何更新数据和删除数据。

272 - 删除和更新

更新和删除数据

在本视频中,我们将使用 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 操作,并能够在实际项目中熟练应用。

273 - Linq总结

完成 LINQ 章节

好的,现在我们已经完成了 LINQ 章节,这也是另一个非常重要的章节。我认为它很棒,希望你也喜欢。通过这个章节,你学到了很多与数据库相关的内容,包括如何筛选数据、如何编写小型软件等。我们还通过一个实际示例帮助你加深了理解。所以希望你能够充分吸收这些知识。

接下来,我们将进入下一个章节——线程,我们将讨论如何基于开发需求来实现并行运行的任务。线程允许我们同时处理多个任务,极大地提高程序的效率和响应速度。

在下一个章节中,我们将探索更多关于线程的内容,包括如何创建、管理和同步线程。希望你能够继续跟上进度并享受接下来的学习内容!

WangShuXian6 commented 2 weeks ago

19 - WPF项目货币转换器与GUI数据库和API第三部分

275 - WPF货币转换器:使用API和JSON获取实时货币值

大家好!在这个视频中,我将向你们展示如何使用我们在前两个视频中用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获取的数据。

异常处理

由于在网络请求过程中可能会发生错误,我们需要使用 trycatch 语句来防止应用程序崩溃。这样,即使发生异常,也能保证程序的稳定性。在 try 语句中,我们进行数据获取,如果数据获取失败,catch 块将会处理异常。

HTTP 客户端和超时设置

我们使用 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 转换

接下来,我们需要将字符串格式的响应数据转换为 JSON 对象,以便我们能够在程序中使用它。为此,我们使用 JsonConvert 类,它是 Newtonsoft.Json 库的一部分。JsonConvert 提供了方法,可以将字符串反序列化为特定类型的对象。我们将响应字符串反序列化为我们预先定义的 root 类。

Root rootObject = JsonConvert.DeserializeObject<Root>(responseString);

使用 JSON 对象

一旦将 JSON 数据转换为 C# 对象,我们可以像访问普通对象属性一样,访问这些数据。比如,我们可以轻松地获取 timestamprates 等字段:

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];

结论

通过这个过程,我们学会了如何:

这些步骤展示了如何从网络获取数据并在程序中使用它,无论是处理货币汇率数据,还是其他类型的 API 数据。

WangShuXian6 commented 2 weeks ago

20 - 编程面试练习

WangShuXian6 commented 2 weeks ago

21 - 线程

278 - 线程简介

欢迎回来

在本章节中,我们将学习线程。这个章节是在我完成这门课程后添加的,因为我收到了很多反馈,大家都提到线程的相关内容。就像委托章节一样,我决定将新章节添加到课程中。因此,如果你觉得课程中缺少了某些非常重要的内容,请随时告诉我,我会将其添加到课程中。

本章节目标

在这个章节中,我们将学习线程的基础知识,包括如何确保线程不会重叠以及如何确保线程能够重叠。我们还将探讨线程池的概念,如何在后台运行线程,如何连接线程,并确保线程仍然存活,而不是已经完成并“死掉”。

学习目标

小结

本章节为课程增添了线程管理的内容,接下来我们会进一步讲解每个部分的具体实现。我希望你能喜欢这个章节!在下一段视频中,我们将开始深入讨论线程的相关概念。

279 - 线程基础

欢迎回来

在本视频中,我们将深入探讨线程。我们将从线程的基本概念开始,学习如何创建线程以及如何创建多个线程。让我们通过控制台的输出开始。

基础线程操作

首先,我将创建多个输出,例如四个不同的“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 核心上并行执行,从而提高程序的效率。例如,四核处理器可以同时运行四个线程。但如果你的计算机只有一个处理器,所有线程仍然会在同一个核心上轮流执行。

结论

通过本视频,我们了解了如何创建和使用线程。线程可以让我们并行执行任务,提高程序的效率。但是,线程的执行顺序和行为可能会有一定的随机性,因此在使用线程时需要小心,确保线程之间的协调和同步。

280 - 线程开始和结束完成

欢迎回来

在上一期视频中,你学习了如何使用线程,以及如何设置线程并简单地启动它们。在本期视频中,我们将重点介绍任务的完成。我们希望使用线程做一些事情,只有当某个特定任务完成后,才继续执行后续的操作。让我们来看一下如何实现这一点。

创建任务完成源

我将创建一个新的变量,称为 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

在这个过程中,我们可以查看线程的 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。通过这些基础知识,你能够更好地理解线程如何在后台执行任务,并且能够控制任务的完成状态。

在下一期视频中,我们将继续深入探讨如何同时创建多个线程,并研究操作系统如何调度这些线程的执行。

281 - 线程池和后台线程

欢迎回来

在本期视频中,我们将讨论线程池(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();
});

引入线程池(Thread Pool)

为了优化线程的使用,我们可以使用线程池。线程池会根据需要动态地管理线程数量,并尽量避免不必要的线程创建。线程池会重用已完成的线程,而不是每次都创建新的线程,这样可以提高效率并减少资源消耗。

我们可以使用 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);

线程池的优势

线程池有许多优势:

  1. 线程复用:线程池不会为每个工作创建一个新线程,而是复用现有的线程,这样可以减少系统的负担。
  2. 有限线程数:线程池限制了同时运行的线程数,因此系统不会因为过多的线程创建而崩溃。它只会创建和管理当前需要的线程。
  3. 背景线程:线程池中的线程默认是后台线程,这意味着当所有前台线程(如主线程)结束时,后台线程会自动终止,而无需显式地清理线程。

线程池的工作原理

线程池通过队列管理工作项,只有当有空闲线程时,工作项才会被执行。这样,当系统的 CPU 正在空闲时,线程池可以开始执行新的工作项,而在 CPU 繁忙时,线程池则会等待直到有空闲线程。

ThreadPool.QueueUserWorkItem((state) => {
    // 执行任务
});

线程池的局限性

线程池的管理是有限的,它只能处理一定数量的线程。如果任务非常繁重,可能会导致线程池的线程无法及时处理所有任务。此时,可能需要根据具体情况调整线程池的大小或使用其他异步方法来处理任务。

注意事项

  1. 线程池中的线程是后台线程:当你创建线程时,如果你将其设置为后台线程(isBackground = true),那么它将不会阻止应用程序退出。
  2. 线程局部存储:线程池中的线程是重复使用的,这意味着它们可能会在不同的工作项之间传递数据。如果你的任务使用了线程局部存储(ThreadLocal),则在使用线程池时需要特别小心,因为线程池的线程可能会从先前的任务中遗留数据。

总结

本期视频讲解了如何使用线程池来处理多个并发任务,而不是创建大量的单独线程。通过使用线程池,可以高效地管理线程,避免不必要的资源消耗。线程池的工作原理和线程管理的方式使得它在处理大量并发任务时更为高效和安全。

在实际开发中,线程池常常用于处理后台任务,比如网络请求、文件操作等,以避免主线程(UI线程)被阻塞。如果需要进行长时间运行的任务,线程池是一个非常合适的选择。

282 - Join和IsAlive

欢迎回来。在本视频中,我将讨论 joinis alivejoin 是线程类的方法,而 is alive 是它的一个属性。接下来,我们将创建两个线程,并讨论它们的使用。

首先,我们创建两个线程,并且分别为每个线程创建了两个方法 threadOneFunctionthreadTwoFunction。这两个方法的作用仅仅是打印线程开始的信息。代码如下:

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 后,主线程将等待 thread1thread2 完成后才会继续执行,因此 "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

如你所见,主线程在 thread1thread2 完成之前不会结束。

超时机制

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 属性检查线程是否仍在运行。这些操作对线程管理和同步非常有用,尤其是在涉及到多个线程执行时。

283 - 任务和WPF

在本视频中,我们将介绍任务(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)任务来处理下载操作。在这种方法中,我们将使用asyncawait关键字来确保代码在后台线程中异步执行,而不会阻塞UI线程。通过这种方式,我们可以避免使用Dispatcher,直接在UI线程中更新控件的内容。

通过这些方法,我们可以确保WPF应用程序中的UI不会被耗时任务卡住,同时能够顺利更新界面。这就是在多线程编程中使用任务和线程的关键概念。

最后,我们还演示了如何在WPF中使用Web浏览器控件。通过下载HTML内容并将其显示在Web浏览器中,我们可以将下载的网页内容呈现给用户,进一步增强了UI的互动性。

285 - 线程总结

总结

在本章中,您了解了如何使用任务(tasks)、线程(threads),以及它们之间的关系。您还学会了如何检查线程是否仍然处于活动状态,如何组合线程等重要概念。

至此,本课程的核心C#特性部分已经完成。如果您觉得有任何重要的内容遗漏了,请随时告诉我。除此之外,我们将进入下一章,开始学习Unity,Unity是一个非常出色的游戏引擎,它能够帮助我们创建几乎任何类型的游戏。接下来我们将深入探讨Unity的使用。

感谢您的学习,下一章见!

WangShuXian6 commented 2 weeks ago

22 - 单元测试和测试驱动开发TDD

286 - TDD介绍

欢迎参加我们的测试驱动开发课程

恭喜你加入了这个课程,这意味着你对软件工程职业有着认真的态度。在这里,我要祝贺你开始这门课程,并祝愿你在软件工程行业的未来一切顺利。

在这门课程中,你将学习自动化测试与手动测试之间的差异。你将学习如何设置测试项目,并且我们将讨论多种技术,如“给定-当-然后(Given-When-Then)”、红绿重构模式(Red-Green-Refactor)、测试的可信度、"魔鬼代言人"游戏、参数化测试、在编写测试后发现替代路径、遵循TDD(测试驱动开发)法则、以及测试应用层等内容。

课程内容

我们从回顾如何手动测试应用程序开始。你将学习手动测试与自动化测试的区别。同时,我们会立刻开始,创建一个项目并编写第一个测试。

在第一章结束时,你将了解什么是测试优先开发(Test-First Development),以及如何编写良好的断言消息。最后,我们将安装一些用于编写单元测试的库。

通过本课程的学习,你将掌握测试驱动开发的核心概念,并能够应用到实际的开发工作中。

287 - 什么是测试驱动开发

测试驱动开发的简介

如果你在谷歌搜索什么是测试驱动开发(TDD),你会看到许多简短的描述,但你可能并不会从中获得真正有价值的信息。定义是没有错的,你也足够聪明,但问题在于,TDD与其他方法不同。你之前可能学过一些软件开发的内容,而TDD并不是通过几个简单的句子就能解释清楚的。因此,与其从理论的介绍开始,我会通过一个实际的例子来引导你了解TDD的基本概念。

手动测试是什么?

我们先从一个熟悉的话题开始:什么是手动测试?我们如何测试我们的应用程序?

首先,我们写一些基础代码,然后运行它并进行测试,检查它是否按预期工作。如果没有,可能会发现一些bug,或者需求发生了变化。那么我们就需要修改代码,再次运行进行测试,依此类推。

问题是,每次你修改代码时,可能会破坏一些已经正常工作的功能。为了确保程序仍然按预期工作,你必须每次修改代码时都对所有受影响的功能进行测试。这个过程非常耗时,并且不可靠。每次修改代码,你对程序的信心就会降低,信心的下降会导致更差的开发体验和更低的生产力。另一方面,测试所需的时间呈指数增长,每次运行程序时,你可能会忘记测试某些重要的内容。

那么,是否有更好的方法呢?

你可能会问,是否有更好的方法来解决这些问题?答案是肯定的!这就是我们在这里学习的原因。与其每次修改代码后都进行手动测试,我们可以编写一些代码来测试程序中最关键的部分,这样每次修改代码时,只需要运行这段测试代码。这不仅速度更快,而且更可靠,解决了很多手动测试中存在的问题。

自动化测试的例子

我来通过一个例子再解释一次。假设我们有一个应用程序,它的功能是求两个数字的和。我们该如何测试这个功能呢?

当我们运行程序时,输入两个数字,点击求和按钮,我们应该看到的结果是什么?两个数字相加的结果当然是 4,对吧?如果你不想每次都手动测试这个功能,我们可以为此编写代码进行测试。

假设我们有一个处理这个用户界面背后逻辑的求和函数,不论它的技术如何,我们可以编写一些代码来测试这个求和函数。

我们先忘掉界面技术,只考虑应用程序的核心逻辑部分——也就是这个求和函数。我们要测试的是这个函数是否按预期工作。我们可以编写代码来检查求和函数是否能正确计算出 2 和 2 的和。

编写测试代码

这个测试实际上和我们手动测试时做的测试是一样的,只不过是自动化的。每次我修改求和函数时,我都可以运行这个测试方法,自动检查它是否按预期工作。如果我破坏了这个功能,测试就会抛出异常。

接下来,我们把测试代码移到一个方法中,这个方法就叫做测试方法。我们称求和方法为系统,而在这个上下文中,测试方法就是用来验证系统是否成功满足期望的。

我们期望 2 加 2 的结果是 4。你可以看到方法的名字准确地描述了我们要测试的内容。现在,忘掉复杂的术语,我们有一个简单的测试方法,它告诉我们要在这个方法的逻辑中测试什么。然后我们只需调用求和方法,传入参数并检查结果。这样,每次调用这个自动化的测试方法时,它就会自动进行测试。

通过这种方式,我们可以在每次修改代码后,自动验证功能是否仍然按预期工作,极大地提高了开发效率和信心。

288 - 创建项目并编写第一个测试

创建第一个测试方法

现在我们进入 Visual Studio 2022,接下来我将创建一个新项目。如果你正在跟着课程一起学习,请立即打开 Visual Studio 并创建一个项目。我强烈建议你这样做。不要只是坐在那里看视频,打开 Visual Studio,创建一个项目,并通过实际操作来跟上课程的进度。我们的目标是为你提供最好的学习体验,而这需要你积极参与。

创建 XUnit 测试项目

首先,创建一个新的项目,我们将使用一个名为 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 的方法,它接受两个参数:leftright,然后返回这两个参数的和。

编写测试方法

现在,让我们来看一下你在测试类中可能看到的 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,测试抛出了异常,导致测试失败。

改进命名和代码结构

我们刚刚创建了第一个测试方法,并看到了通过或失败的结果。现在我们需要改进一下命名。我们将以更加专业的方式重构项目,确保命名符合最佳实践。

小结

恭喜你,你刚刚创建了第一个测试方法!你学会了如何编写一个简单的测试方法、如何运行测试,并理解了测试结果的两种可能性:成功或失败。接下来,我们会继续探索如何在项目中应用更加专业的测试实践。

289 - 重构和添加域

引入真实的计算器项目

现在,请注意,我们刚才创建的项目仅用于测试场景。这个项目包含了我们刚刚定义的 Sum 方法,然而,如果我们将计算器功能集成到测试项目中,那就不太符合实际开发中的做法了。理想情况下,我们应该把计算器的功能方法放到一个独立的项目中,而不是在测试项目中。我们将这个功能代码提取到一个 领域层 中,即一个包含实际业务逻辑的项目。

创建领域层项目

首先,右键点击你的解决方案,而不是项目本身,然后添加一个新的项目到解决方案中。这种做法在很多真实世界的应用中都会遇到,通常会有一个测试项目和一个包含算法和业务逻辑代码的项目。接下来,搜索 Class Library(类库),然后选择它。

点击 下一步,选择 .NET 6,然后点击 创建

修改类文件

接下来,我们需要重命名 Class1.cs 文件。在 Domain 项目中右键点击文件并选择 重命名。将其命名为 Calculator,点击 应用。这样做会自动更新所有引用。

移动求和方法

UnitTest1 类中,你会看到我们之前创建的 Sum 方法。这个方法应该在 Calculator 类中,而不是在测试项目中。因此,我们将方法从测试项目中移到 Calculator 类中。

在测试项目中添加引用

现在,我们已经将 Sum 方法移到 Calculator 类中,接下来需要确保测试项目可以引用到 Domain 项目中的 Calculator 类。

修改命名空间和类的访问权限

接下来,我们需要调整命名空间和方法的访问权限,以确保测试项目能够访问到计算器的逻辑代码。

修改代码引用

修改完命名空间后,我们需要确保在测试项目中正确引用 Calculator 类。

var calculator = new Calculator();
var result = calculator.Sum(2, 2);

运行测试

现在,我们已经将所有的类和命名空间整理好了,接下来可以运行测试了。记住,在测试驱动开发(TDD)中,每次修改代码后,都要运行测试以确保代码仍然正常工作。

你会看到测试通过了。即使我们做了很多改动,添加了新项目,更新了类名和命名空间,只要运行测试,最终都能确保我们的代码是正确的。

小结

通过这一步,我们已经成功地将计算器的业务逻辑从测试项目中提取到了 Domain 项目中,并通过创建项目引用和调整命名空间,使得测试项目能够访问到计算器的方法。最重要的是,我们运行了测试并验证了修改后的代码仍然正确。

现在,你已经掌握了如何组织代码、管理项目之间的引用以及如何在 TDD 中进行验证,确保代码在进行修改后仍然能够正常工作。

290 - 添加Web API

介绍 Web 项目

目前,我们有两个项目:一个是测试项目,另一个是包含计算器业务逻辑的 领域层 项目(Domain)。这些项目的结构符合专业应用程序的常见做法。在接下来的步骤中,我们将进一步扩展应用程序,添加一个新的 ASP.NET Core Web API 项目。这样,应用程序就可以通过网络与外部系统进行交互,这也是专业软件开发的常见做法。

创建 ASP.NET Core Web API 项目

首先,右键点击解决方案并添加一个新的项目,这次我们选择 ASP.NET Core Web API。如果你之前没有接触过 ASP.NET Core,那么你真的错过了很多东西。在这里,我们只是想展示如何将计算器功能暴露为 Web API 接口,而不是涉及部署和托管。

Web API 简介

简单来说,Web API 允许我们通过 HTTP 协议在 Web 上公开功能。我们将计算器的功能暴露为 API,这样用户就可以通过 Web 请求来使用我们的计算器方法。虽然我们不会在这里深入探讨部署和托管的问题,但要理解,创建 Web API 的主要目的是让外部应用能够通过请求访问计算器服务。

添加对领域层的引用

接下来,我们需要将 Domain 项目添加为 Web 项目的引用。这个步骤非常重要,因为 Web 项目需要访问 Domain 项目的代码,才能调用计算器的业务逻辑。

Web 项目不需要引用 Test 项目,因为 TestDomain 之间是唯一的联系,Web 项目只需要连接到 Domain 项目。

修改 Web API 控制器

Web 项目中,默认有一个 WeatherForecastController。我们需要将其修改为与我们的计算器逻辑相关的控制器。首先,右键点击 WeatherForecastController 并重命名为 CalculateController。同时,重命名文件为 CalculateController.cs

然后,删除原有的天气预报代码,并将 Get 方法更改为处理计算任务的逻辑。新方法应该能够接收两个参数(即加数),并返回它们的和。我们将设置 API 路径为 /add/{left}/{right},这样用户可以通过 URL 提供两个数字,然后返回它们的和。

创建 Web API 方法

我们将通过如下的代码创建一个 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 参数:leftright。该方法将使用我们在 Domain 项目中定义的 Calculator 类来执行加法操作,并将结果作为响应返回给调用者。

运行 Web API

现在,我们已经创建了一个 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,并实现了以下功能:

  1. 创建了一个 Web 项目来暴露计算器的功能。
  2. Domain 项目(包含业务逻辑)连接到 Web 项目。
  3. 修改了 Web API 控制器来处理用户请求,并通过 API 调用计算器方法来返回计算结果。
  4. 演示了如何根据需要更改功能代码,并且更改会自动反映在测试和 Web 项目中。

这个过程展示了如何构建一个现代化、模块化的应用程序,其中包含了领域层、测试层和 Web 层的分离。这是构建专业应用程序的推荐方式。

291 - 测试优先的方法

写代码的顺序与测试驱动开发 (TDD)

我们刚才编写了 sum 函数,并且写了一个简单的自动化测试。但是,我们没有遵循测试驱动开发(TDD)的方法。根据 TDD 的方法论,你必须先编写测试,再编写实际的生产代码。接下来,我将向你展示什么是先写测试再编写生产代码,并稍后讨论为什么要这样做。

重写 sum 函数——遵循测试优先的方法

可能现在这个过程感觉有点奇怪,但我们将按照 TDD 的方法先删除 calculator 类。因为正如我之前所说,TDD 的方法就是先编写测试,再编写生产代码。所以我们现在就这么做。不要为删除 calculator 觉得难过,它会在之后重新回来。

步骤 1:清空项目

首先,删除 calculator 项目。然后在我们的 calculator controller 中,去掉 using domain,删除 var calculator = new Calculator(),删除相关代码,并抛出 NotImplementedException。这一步也很重要:当你写测试时,可能会遇到你需要抛出 NotImplementedException 的情况。

步骤 2:保持测试项目为空

现在,转到我们的单元测试项目(Unit Test Project),保持它为空。这样,当前我们有一个 Web API 项目什么都不做,单元测试项目也没有任何内容,领域层(Domain Layer)也不包含任何元素。现在我们准备好了,可以按照 TDD 的方法开始编写代码。

步骤 3:定义需求并编写测试

首先,我们应该问自己:我们到底想要什么?这是每个开发者都应该经常问自己的问题。很多人忽视了这一点,但你应该问自己:我的程序应该做什么?在这个特定场景下,我们想要一个 sum 函数。

想象一下,sum 函数应该能做什么?很简单,比如 2 + 2 应该等于 4。虽然这是一个非常简单的场景,但在 TDD 中,更重要的是实际的工作流和方法论,而不是具体的算法。

步骤 4:编写测试代码

我们先删除自动生成的测试代码,或者你也可以重命名它,但我建议从头开始编写。首先,我们定义一个测试方法,给它一个非常清晰的名称:sum of two and two should be four。这个方法的作用是:给 sum 函数传入 22,期望返回值是 4。现在我们编写这个测试方法:

[Fact]
public void SumOfTwoAndTwoShouldBeFour()
{
    var result = sum(2, 2);
    Assert.Equal(4, result);
}

这段代码描述了测试的场景——我们将 22 传入 sum 函数,期望返回的结果是 4

步骤 5:编写实际代码

接下来,编写实际的 sum 函数。这个时候我们并没有实际的 sum 函数实现,编译器会报错,提示 sum 方法不存在。这时候,我们需要创建一个 sum 方法来通过这个测试。

public int sum(int left, int right)
{
    throw new NotImplementedException();
}

虽然此时 sum 函数没有实现实际逻辑,但我们让它编译通过,移除错误,并确保项目能编译。这样我们就能逐步通过编写测试来消除错误。

步骤 6:编写逻辑并修复错误

编译通过后,运行测试。测试应该会失败,因为我们还没有实现 sum 函数的逻辑。看到失败是很正常的,这是 TDD 流程的一部分。此时,我们根据错误信息去修复代码。

错误提示说方法没有实现,那么我们应该实现 sum 方法的逻辑。算法非常简单,我们让它返回 left + right

public int sum(int left, int right)
{
    return left + right;
}

重新运行测试,确保它们通过。现在,我们的测试和代码都通过了。

步骤 7:将代码移动到领域层

虽然我们的 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);

步骤 8:完成 Web API 项目

最后,我们回到 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 的流程:先编写测试,然后逐步编写代码并修复错误,直到所有测试都通过。通过这种方式,我们确保了代码的正确性并提高了可维护性。

292 - 断言消息

错误信息的处理与调试

当测试失败时,错误信息应该明确指出出错的原因。因为想象一下,如果你的应用程序中有成百上千个测试,并且这是非常常见的做法,那么某些测试会因最近修改的代码而失败。你应该能够快速地找出原因,对吧?现在,为了做一个快速的测试,我们可以简单地修改 sum 方法,让它返回 5,这显然是错误的。

步骤 1:修改代码

Calculator 类的 sum 方法中,我们暂时注释掉原本的逻辑,并让方法总是返回 5

public int Sum(int left, int right)
{
    // return left + right;
    return 5; // 错误的返回值
}

这样,无论传入什么参数,sum 方法都会返回 5。我们再去运行测试,结果会失败,因为我们期望的结果是 4,但返回的是 5

步骤 2:查看错误信息

运行测试后,我们看到测试失败了,错误信息显示:

System.Exception: Exception of type 'System.Exception' was thrown.

这个错误信息并没有帮助我们明确了解出错的原因。它只告诉我们发生了异常,但没有告诉我们具体出了什么问题。所以,我们需要改进这个错误信息,使它更具可读性,能帮助我们更快地找到问题。

步骤 3:改进错误信息

为了让错误信息更有用,我们可以在抛出异常时传递一个格式化的字符串,包含预期值和实际返回值:

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.

步骤 4:修复逻辑错误

现在,我们的错误信息更清晰了。它直接告诉我们哪里出了问题:2 + 2 应该是 4,但是返回了 5。所以,我们可以立即定位到 sum 方法的错误,并修正逻辑:

public int Sum(int left, int right)
{
    return left + right; // 正确的逻辑
}

保存并重新运行测试,测试应该会通过:

Test passed successfully.

步骤 5:改善测试代码

尽管我们已经修复了错误并使测试通过,但测试代码中的 if 语句使得代码看起来有点复杂。为了使测试代码更简洁,我们可以使用一个叫做 Fluent Assertions 的库,它可以帮助我们简化断言,并使代码更清晰。

安装 Fluent Assertions

首先,我们需要在项目中安装 FluentAssertions 库。你可以通过 NuGet 包管理器安装它,或者在包管理控制台中运行以下命令:

Install-Package FluentAssertions

步骤 6:使用 Fluent Assertions 简化测试

安装完 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");
}

结果分析

在这里,我们使用了 FluentAssertionsShould().Be() 方法来断言结果。这种写法不仅简洁,而且语义明确,帮助我们清晰地表达预期和实际结果的比较。

总结

通过改进错误信息,我们能够更快地定位和修复测试失败的原因。同时,通过引入 Fluent Assertions,我们简化了测试代码,提高了可读性。整个过程展示了如何更高效地进行单元测试,并且通过清晰的错误信息和简洁的代码结构提升开发效率。

293 - 流畅的断言

安装 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 改进测试代码

安装完 Fluent Assertions 后,我们回到 Calculator Test 中。之前的测试方法使用了 if 语句,代码看起来有些冗长,我们希望移除这些冗余部分。

步骤 1:移除 if 语句

我们可以通过 Fluent Assertions 来简化这部分代码。移除原有的 if 语句,改为以下语法:

result.Should().Be(4);

这比 if 语句更加简洁、易读,且更符合人类的阅读习惯。比如:result.Should().Be(4) 直接表明了测试的期望结果是 4。

步骤 2:运行测试并查看错误信息

接下来,我们让 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 方法的实现存在逻辑问题。

步骤 3:修复错误

我们回到 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 中,我们首先编写测试,明确代码应该如何工作,然后编写生产代码使其通过这些测试。

TDD 的第一条规则

TDD 的第一条规则是:只有通过失败的测试后,才编写生产代码。写测试而不是生产代码的过程被称为 先写测试(Test First Approach)。

第一章总结

在第一章中,我们回顾了手动测试过程,创建了一个测试项目并编写了第一个自动化测试。然后,我们根据测试驱动开发(TDD)重写了代码,去除了不必要的部分,写了更好的断言信息,并安装并使用了 Fluent Assertions 库,使测试代码更加简洁。

接下来的步骤

恭喜你已经完成了第一章的学习!现在你已经能够编写自动化测试了。接下来的章节将继续深入理论并提供一些实际案例。如果你现在不是通过 All Access Subscription 来观看课程,你可以考虑订阅 All Access,这样你可以获得个人任务和练习,同时也能获取课程的详细资料。你可以免费试用七天,立即开始学习。如果你已经订阅了 All Access,那么继续保持努力,学习更深的知识吧!

294 - 测试条件和前提

第二章:理论与实践

在上一章中,我们对测试驱动开发(TDD)进行了一个实用的介绍。本章将开始更深入的理论部分,并结合实践来进行讲解。通过本章的学习,你将了解以下几个内容:

软件开发与解决方案

软件开发的目的是为利益相关者的问题提供解决方案。在开发高质量的软件之前,你必须考虑以下几点:

  1. 在编写任何代码之前,你应该先了解清楚问题的本质。
  2. 设计一个针对问题的解决方案。
  3. 在开始实现函数主体之前,你需要知道函数应该接收哪些参数以及调用该函数会有什么后果。

换句话说,你应该知道期望函数完成什么任务,而不是它是如何完成这些任务的。

TDD 专家倾向于尽可能明确地表达预期需求,然后开始编写生产代码,直到生产代码满足预期需求。

先写测试的意义

这可能听起来有些混乱,不过别担心,我们会逐步解释清楚。假设你想编写一个除法函数,在开始编写函数主体之前,你应该首先考虑:

例如,四除以二应该等于二。所有客户端都期望函数具有这样的行为,这种期望就是一个测试场景。每当你编写生产代码时,你应该考虑以下三个问题:

  1. 你要实现的代码的输入是什么?
  2. 预期的输出是什么?
  3. 哪个函数、方法或类应该接收输入并得出输出?

测试场景和前置条件

假设你想测试汽车的点火是否正常。你按下点火按钮后,应该能听到汽车发动机的声音。测试的输入是启动点火,输出是发动机的声音。如果汽车没有启动,因为它没有油,这是否意味着点火系统不工作呢?显然不是。为了测试点火系统,汽车应该有油。加油就是该测试场景的前置条件。

前置条件 是测试场景的一部分。任何测试场景可以有零个或多个前置条件。有些测试场景可能没有前置条件,但每个测试场景都应该包括输入、系统运行和输出。

真实世界的例子

为了更容易理解这一点,我们来看一个真实世界的例子。假设我们正在开发一个航班预订应用,类似于我们在 21 天 ASP.Net Core 和 ASP 课程中构建的应用。当我预订一个座位时,航班的剩余座位数量应该减少。

作为乘客,如果我预定了一个座位,航班上应该还剩多少座位呢?这取决于我预定时航班的座位总数。因此,这个测试场景缺少了一些内容,它需要一个前置条件。我们需要在预定座位之前定义航班的初始座位数,以便检查预订方法是否正确减少了航班对象的座位数。

这时,场景就完整了:我们需要首先设置航班的座位数作为前置条件,然后进行预定操作。

总结

本章介绍了先写测试的原因,以及如何使用前置条件来完善测试场景。理解了这些概念后,你会更清楚在编写生产代码之前,测试的期望结果是什么,以及如何通过测试来验证代码的正确性。接下来,我们将通过一个新的项目来实践这些概念,进一步加深对 TDD 的理解。

295 - 设置航班项目

创建一个新的项目

现在我们将创建一个新的项目,名为 flight,用于测试我们在前一部分演示中的航班预定场景。这个项目将专注于航班预订的功能,确保我们的代码按预期执行。

步骤 1: 创建单元测试项目

首先,我们将在 Visual Studio 中创建一个新的 单元测试项目。你可以选择将项目命名为 flight,或者你可以根据自己的习惯命名为 flight test 或其他名称。这个项目将用于测试你的代码,因此你需要选择 单元测试项目模板

  1. 打开 Visual Studio。
  2. 创建一个新的项目。
  3. 选择 单元测试项目 模板(通常选择 .NET 6.NET 7)。
  4. 命名为 flight,然后点击 下一步
  5. 选择 .NET 6 作为框架(或其他版本),然后点击 创建

步骤 2: 安装 Fluent Assertions 库

一旦项目创建完成,我们需要安装 Fluent Assertions 包。Fluent Assertions 是一个用于编写更具可读性的断言的库,它可以让我们更加简洁和易读地表达期望结果。

  1. 在 Visual Studio 中,点击 工具 > NuGet 包管理器 > 管理解决方案的 NuGet 包
  2. 在左侧栏点击 浏览,搜索 Fluent Assertions
  3. 选择 Fluent Assertions,并点击 安装
  4. 确保仅为 flight 测试项目安装该包。

步骤 3: 准备进行下一步工作

安装完成后,你将准备好进行后续的开发和测试工作。此时,我们已经创建好了一个测试项目并安装了所需的依赖项,接下来我们可以开始编写实际的测试案例,并实现预定航班的功能。

你可以在接下来的任务中继续实现具体的功能,并通过编写测试用例来验证你的代码是否按照预期工作。

296 - 将场景转化为测试

开始使用 TDD 开发航班预定功能

现在,我们将继续使用 TDD(测试驱动开发)方法来实现一个航班预定的功能。我们会按照以下三个步骤进行:

  1. 创建一个航班对象,并设置初始座位容量为 3。
  2. 预定一个座位
  3. 验证剩余座位数,应该为 2,因为我们从 3 个座位中预定了 1 个。

步骤 1: 创建航班对象

我们开始编写测试,首先创建一个容量为 3 的航班对象:

var flight = new Flight(3);

然后,我们模拟预定一个座位的操作:

flight.Book("Yannick@tutorials.newcom");

接下来,我们验证剩余座位是否正确:

flight.RemainingNumberOfSeats.Should().Be(2);

到此为止,我们并没有实际实现代码,而是通过 TDD 方法,先定义了我们期望的行为和测试场景。

步骤 2: 实现 Flight 类

接下来,我们通过 TDD 的方式逐步解决错误,最终实现 Flight 类的功能。

创建 Flight 类

由于我们遇到了 flight 是命名空间而不是类型 的错误,我们首先需要创建一个 Flight 类。我们通过以下步骤来创建它:

  1. 右击解决方案,选择 添加新项目
  2. 选择 类库,命名为 Domain.Test,这是专门用于测试的类库。
  3. 添加一个 Flight 类,初步定义构造函数:
public class Flight
{
    public int RemainingNumberOfSeats { get; private set; }

    public Flight(int seatCapacity)
    {
        RemainingNumberOfSeats = seatCapacity;
    }

    public void Book(string email)
    {
        // 暂时不实现,稍后处理
        throw new NotImplementedException();
    }
}

在测试项目中引用 Domain.Test

  1. flight 测试项目中,右击项目选择 添加引用,并选择刚才创建的 Domain.Test 项目。

更新命名空间

由于出现了命名冲突,Flight 在不同命名空间中,我们可以选择直接修改命名空间,避免命名冲突。可以选择将测试项目中的 flight 类命名为 FlightTest,或者直接在 Flight 类中使用完全限定的命名空间。

步骤 3: 实现 Book 方法和 RemainingNumberOfSeats 属性

在测试运行时,我们遇到了 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 方法的逻辑,也没有调整剩余座位数。

步骤 4: 实现业务逻辑

为了让测试通过,我们现在需要实现实际的业务逻辑:

  1. Flight 类中,初始化时设置剩余座位数:
public Flight(int seatCapacity)
{
    RemainingNumberOfSeats = seatCapacity;
}
  1. 实现 Book 方法,减少剩余座位数:
public void Book(string email)
{
    if (RemainingNumberOfSeats > 0)
    {
        RemainingNumberOfSeats--;
    }
    else
    {
        throw new InvalidOperationException("No remaining seats.");
    }
}
  1. 重新运行测试。现在测试应该通过了。

步骤 5: 反思和总结

我们按照 TDD 的步骤进行开发:

  1. 编写测试:首先编写了我们期望的功能,模拟了预定航班的情境。
  2. 解决错误:随着测试失败,我们逐步实现了 Flight 类的各个方法和属性。
  3. 实现功能:最终,我们实现了航班预定的业务逻辑,确保测试通过。

总结

通过 TDD 方法,我们首先明确了测试的需求,逐步编写了测试并实现了相应的生产代码。虽然测试一开始失败,但通过不断修复代码,我们确保了每个功能都符合预期。这种方法不仅帮助我们更好地理解了需求,也确保了代码的正确性和可维护性。

接下来,你可以继续扩展这个功能,处理更多的业务场景,增加例如座位剩余为负数的错误处理,或者是取消预定等功能。

297 - 红绿重构

了解红绿重构 (Red-Green-Refactor) 过程

在 TDD(测试驱动开发)中,红绿重构 是一个关键的开发过程。这个过程分为三个阶段:

  1. 红色阶段(Red):我们从将需求转换为一个失败的测试开始。这意味着我们编写测试代码,使得当前系统无法通过它,因此测试失败。
  2. 绿色阶段(Green):在这个阶段,我们只写足够的生产代码来让测试通过,不能编写多余的代码。目标是让测试通过,而不是解决所有潜在的问题。
  3. 重构阶段(Refactor):当测试通过时,我们不应该立刻开始编写下一个测试,而是应该集中精力改善现有代码的设计。在这个阶段,我们重构代码,改进其结构,但不改变代码的外部行为。重构的目的是提升代码的可读性、可维护性、性能等方面。

Red-Green-Refactor 的实际操作

我们通过具体操作,来一步步讲解如何进行 红绿重构

第一步:重命名测试方法

首先,我们对测试方法进行重命名,使其更加描述其功能。我们将原来的测试方法名称改为更具体的 BookingReducesTheNumberOfSeats,这个名字清晰地反映了测试的目标——验证预定操作是否减少了座位数量。

public void BookingReducesTheNumberOfSeats()
{
    // 代码...
}

第二步:重命名测试类

接下来,我们将测试类 UnitTest1 重命名为 FlightSpecifications,这样测试类的名称更加符合其功能,能够清晰地表达其测试的是航班相关的功能。

public class FlightSpecifications
{
    // 代码...
}

第三步:迁移到生产代码

现在,我们已经完成了测试方面的初步工作,接下来我们会将生产代码从 domain.test 移动到正式的 domain 类库中。我们需要创建一个新的生产类库并将 Flight 类移到生产环境中。

  1. 创建生产类库

    • 右键点击解决方案,选择 添加新项目,并选择 类库,命名为 Domain
    • 点击 下一步,选择 .NET 6,创建新项目。
  2. Flight 类移到生产库

    • 右键点击 Flight 类文件,选择 快速操作和重构,然后选择 移动到命名空间
    • 选择 Domain 命名空间,点击 确定,将 Flight 类移到新的生产命名空间中。
  3. 删除原测试库中的 Flight

    • domain.test 中删除原来的 Flight 类,确保项目中只保留一个 Flight 类实例。

第四步:更新项目引用

完成了类的迁移后,接下来需要更新项目引用。在 FlightSpecifications 测试类中,引用的 Flight 类现在已经被移到 Domain 项目中,因此我们需要更新引用:

  1. 添加项目引用

    • 右键点击 FlightSpecifications 测试项目,选择 添加引用,并添加对 Domain 项目的引用。
    • 如果不再需要对 Domain.Test 的引用,可以删除它。
  2. 确保 FlightSpecifications 中引用了 Domain 项目

    • 确保我们在 FlightSpecifications 测试类中正确引用了 Domain 命名空间,现在就能使用我们刚刚迁移到生产代码中的 Flight 类了。

第五步:重命名方法参数

Flight 类中,我们重命名了 Book 方法的参数,以使代码更加清晰易懂:

public void Book(string passengerEmail, int numberOfSeats)
{
    // 代码实现
}

这一步确保了方法参数更加具描述性,代码的可读性大大增强。

第六步:运行测试

完成上述重构后,我们回到测试类 FlightSpecifications 中,右键点击测试方法,选择 运行测试,确保测试能够顺利通过。

此时,测试再次通过,表明我们没有改变代码的外部行为,仅仅是对代码进行了重构,改进了其结构和可维护性。

重构总结

在完成 红绿重构 过程后,我们主要达到了以下目标:

  1. 改进了测试方法和类的命名,使其更具描述性,易于理解。
  2. 迁移了生产代码,从测试类库迁移到正式的生产类库 Domain
  3. 优化了方法参数的命名,使其更具语义,提升代码可读性。
  4. 确保代码行为未改变,我们通过运行测试,验证了重构过程中没有改变代码的外部行为。

结论

红绿重构 是 TDD 中至关重要的一部分,它帮助开发人员在写完测试并通过后,确保代码的质量不断得到提升。在开发过程中,我们应始终专注于重构现有代码,并避免一次性编写过多的代码。重构的核心目标是改善代码的设计,而不改变其外部行为。这使得我们的代码更加清晰、易于维护,同时能确保功能的正确性。

298 - Given When Then模式和避免超额预订场景发现

理解避免超额预定的场景

在 TDD(测试驱动开发)中,我们首先通过思考和可视化测试场景来规划我们的测试。接着,我们将这些场景翻译成测试代码,然后逐步实现。这个过程帮助我们确保代码按照预期运行。

步骤1:可视化测试场景

我们首先使用便签(sticky notes)来可视化我们的测试场景。通过这种方式,我们可以明确测试的每个步骤。测试场景通常包括几个部分,常见的结构是 给定(Given)当(When)然后(Then)

  1. 给定(Given):这个步骤定义了测试的前提条件,设置了测试所需的环境。
  2. 当(When):此步骤描述了触发某个行为的动作或操作。
  3. 然后(Then):此步骤描述了预期的结果或行为。

步骤2:分解测试场景

在我们的航班预定场景中,测试分为以下几个步骤:

这些步骤采用了 给定-当-然后 格式,这种格式使得开发人员和业务人员之间的沟通更加顺畅。通过清晰地分隔 给定然后 的步骤,代码也更易于理解。

步骤3:避免超额预定的需求

在我们的航班预定系统中,我们需要确保系统能够避免超额预定的情况。如果航班的剩余座位不足以满足乘客的预定需求,系统应该返回一个错误,而不是继续进行预定。

步骤4:规划测试

我们要先思考如何测试这个场景。避免超额预定的规则是:当剩余座位不足时,应该返回一个错误,而不是完成预定。具体来说,如果剩余座位大于乘客想要预定的座位数,系统应该返回一个 “超额预定错误”。

测试场景:

我们首先考虑如何测试这个场景。测试的参数需要传递给 Flight 实体,而测试的结果则是一个错误提示。我们可以按照以下步骤来测试:

  1. 给定(Given):创建一个座位数为3的航班。
  2. 当(When):我尝试预定4个座位。
  3. 然后(Then):应该返回一个 “超额预定” 错误。

通过这种方式,我们可以模拟预定超出座位数的场景,并检查系统是否正确返回了错误。

步骤5:编写测试代码

根据上述测试场景,我们的测试代码可以写成以下形式:

[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);
}

步骤6:实现避免超额预定的逻辑

现在我们已经定义了测试,并且有了明确的预期行为,我们可以开始编写代码来实现避免超额预定的功能。

1. 更新 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 对象。

2. 测试验证

一旦我们实现了上述逻辑,就可以运行我们的测试,确保代码能够按照预期工作。如果测试通过了,意味着我们成功地避免了超额预定的情况。

步骤7:总结

在整个过程中,我们首先通过 给定-当-然后 模式规划了测试场景,然后将这个场景翻译为代码。我们通过先思考如何测试需求,再编写代码来实现需求,遵循了 TDD 的实践。

  1. 可视化测试场景:通过便签等方式规划测试步骤。
  2. 避免超额预定:我们设计了防止超额预定的逻辑,并验证了其正确性。
  3. 逐步实现:通过先写测试,再实现代码,最终确保代码的行为符合预期。

这种方法确保了我们在开发过程中始终保持对需求的明确理解,并通过 TDD 确保代码的正确性和可维护性。

299 - 避免超额预订场景

开始创建避免超额预定的测试方法

在这一部分,我们将开始实现避免超额预定的测试方法。我们将按照以下步骤来编写测试,并逐步修复可能出现的错误,直到测试通过。

步骤1:创建测试方法

首先,我们需要使用 [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) 的顺序编写了测试:

步骤2:解决编译错误

在运行测试时,我们会遇到一个编译错误: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

步骤3:创建 OverbookingError

我们需要为超额预定创建一个专门的错误类。可以在 Domain 层创建一个新的类,也可以将现有的类重命名。这里我们选择创建一个新的 OverbookingError 类。

public class OverbookingError
{
    // 可以在这里添加与错误相关的额外信息
}

步骤4:运行测试

接下来,我们运行测试。你可以看到,在 Flight 类中的 Book 方法修改后,我们已经没有编译错误了。但是,测试仍然会失败,错误信息显示:“期望错误类型为 OverbookingError,但返回的是 null”。

步骤5:实现逻辑

我们需要在 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;
    }
}

步骤6:再次运行测试

现在,重新运行测试,确保 AvoidsOverbooking 测试通过。如果逻辑实现正确,测试将成功通过,并且 OverbookingError 会在发生超额预定时被正确返回。

步骤7:总结

通过这个过程,我们展示了 TDD(测试驱动开发)的力量。首先,我们定义了一个测试场景,然后将其翻译成测试代码。每当测试失败时,我们修复错误,直到测试通过。通过这种方式,我们能够在不启动应用程序的情况下验证系统行为的正确性。

  1. 发现测试场景:我们首先通过分析需求来定义一个清晰的测试场景。
  2. 编写测试代码:使用 Given-When-Then 结构来编写测试代码。
  3. 实现代码:逐步实现代码,确保每个步骤都能通过测试。

通过这种方法,我们能够确保软件的行为符合预期,且保持代码的可维护性。TDD 的核心是 红-绿-重构 循环:

TDD 的核心原则

通过不断循环这三步,TDD 帮助我们创建更加可靠和高质量的软件。

300 - 测试可信度和恶魔代言人

第三章:编写可靠的测试与发现新场景

在上一章中,我们介绍了 红-绿-重构(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(魔鬼代言人)技术。

魔鬼代言人技术

魔鬼代言人是一种测试策略,它通过让生产代码故意不按预期工作,帮助我们发现现有测试的弱点。这个过程有两个角色:

  1. Tester(测试员):确保编写的测试覆盖了所有边界情况,防止生产代码出错。
  2. Devil's Advocate(魔鬼代言人):尝试修改生产代码,使其行为不符合预期,但不能修改测试代码。魔鬼代言人的目标是使代码出现问题,同时让现有的测试通过。

Devil's Advocate 阶段,魔鬼代言人会试图修改生产代码,使其行为不正确,同时不让现有测试失败。如果成功,测试员需要分析并添加新的测试来防御这种攻击,确保生产代码再次按预期工作。

实践魔鬼代言人技术

假设我们已经编写了一个测试并使其通过,然后进行重构。这时,作为测试员,我们要确保我们所编写的测试覆盖了所有可能的边界情况。接下来,我们转换角色,成为魔鬼代言人,试图修改生产代码,看看现有的测试是否能够捕捉到新的问题。

如果魔鬼代言人成功修改了生产代码并且测试仍然通过,我们作为测试员就需要添加新的测试,以防止这种问题再次发生。

总结

通过使用 Devil's Advocate 技术,我们可以确保测试覆盖到所有的边界情况,并且增加测试的可靠性。每次魔鬼代言人进行攻击时,测试员都会增加新的测试,确保生产代码不再出现错误。

这种方法不仅帮助我们发现代码中的潜在缺陷,还促进了测试的完善,使我们能够编写出更强大、可靠的测试用例,从而提高软件的质量和稳定性。

在本章中,我们学习了如何通过增加新的测试来发现新场景,如何使用魔鬼代言人技术来提升测试的信任度,并且了解了 TDD 中红-绿-重构模式的实际应用。

301 - 实践恶魔代言人以获得剩余座位数

扮演魔鬼的辩护人游戏

我们来玩一下魔鬼辩护人游戏。假设我们有一个测试,测试内容是:预订会减少座位数。那么我们现在有一个航班,预订了一个座位。最初我们有三个座位,如果预订一个座位,剩下的座位数应该减少1。所以剩余座位数应该是2,对吧?从魔鬼辩护人角度来看,完全可以直接进入 book 方法,在其中将剩余座位数直接设置为2,这样我们就不需要从参数中减去座位数了,对吧?我们可以直接设置剩余座位数为2,而不必使用参数。这样测试就会通过。

所以我现在复制那行代码,把它注释掉。然后把剩余座位数直接设置为2。这样测试将会通过。让我们再检查一下测试内容:测试期望剩余座位数应该为2。因此,如果直接设置为2,测试就会通过。让我们右键点击,运行测试。结果如你所见,所有的测试都通过了,这确实是魔鬼辩护人的一个例子。

如何从TDD的角度修复

从TDD(测试驱动开发)的角度来看,解决这个问题非常简单。我们只需复制整个测试代码,重新编写一个类似的测试,使用不同的值。并不是说重复写一些基础功能代码,而是在编写测试时使用不同的值,测试不同的场景。这与编写功能代码时的“不要重复自己”(DRY,Don't Repeat Yourself)原则略有不同,因为测试是可以重复的,只要它们测试的是不同的场景。

现在我们来修改测试,我们创建一个包含6个座位的航班,然后预订3个座位。剩余的座位数应该是3。让我们看看,如果我们右键点击运行测试,结果会发生什么。现在我们有四个测试,其中一个还没有运行。稍等片刻,你会看到其中一个测试失败了。测试失败了。

我们想要确保在应用程序部署之前,所有的测试都通过了。对吧?如果有一个测试失败了,我们肯定不想部署应用程序,因为有些功能没有按照预期工作。

返回到航班类并修复代码

现在让我们回到航班类中,使用我们刚才的参数。剩余座位数应该根据预订的座位数减少,这样就能解决问题了。让我们再切换回去,重新运行测试。现在,你可以看到所有的测试都成功通过了。

开发者常见问题:使用魔术数字

现在,让我解释一下,也许一些开发者已经遇到过类似的情况。假设我们不使用上面的方式,而是采用这样的代码:如果座位数等于1,则剩余座位数设置为2;如果座位数等于3,则剩余座位数设置为3。我们在代码中使用了“魔术数字”和硬编码的值。

这种做法在某些情况下可能可以用,但很多时候初学者容易犯这种错误。我们在代码中硬编码了魔术数字,而这种做法显然不是灵活的。如果我们这样做,测试仍然会通过,但这并不意味着代码是正确的。

提高测试的可靠性

为了提高我们测试的可靠性,我们可以再次创建不同的测试场景,比如:创建一个包含10个座位的航班,预订6个座位,剩余座位数应该是4。如果我们保存这样的代码,测试会失败,因为这些值并未正确设置。虽然现在不太可能再次使用类似的 if 语句,但实际上是有可能的。因此,为了增加测试的可信度,我们应该为不同的场景编写更多的测试。

多值测试的重要性

你现在应该记住的一点是,进行多值测试非常重要,而不是像我们最初的测试那样只测试一个单一的值。比如,我们可以使用6个座位和3个座位、10个座位和6个座位、3个座位和1个座位等不同的组合来测试。通过这种方式,我们可以确保程序员编写的算法不会无意中破坏测试。

这就是魔鬼辩护人的游戏。一个方面试图破坏测试,使其变得不可靠,而另一个方面则是通过编写更多、更好的测试来提高测试的可信度。

302 - 参数化测试

参数化测试的优化

现在我们有了所有测试,用于验证预订是否减少座位数。其实,进行多次测试是完全可以接受的,因为这些测试只是验证不同的场景,而不涉及逻辑实现,因此它们是可以维护的。然而,和简单的逻辑代码一样,如果代码不重复,那么维护起来会更加方便。所以,虽然在测试中允许重复,但从测试的角度来看,很多时候将重复的部分优化成参数化测试会更加高效。现在我们来看看如何通过参数化测试来提高可维护性,并简化航班测试规范。

创建参数化测试

首先,我们将从第一个测试开始,并在稍后从其他测试中复制相应的值。因此,我们首先专注于第一个测试:预订是否减少座位数。我们将不再使用 Fact 属性,而是改为使用 Theory。这是 XUnit 提供的功能,表示某些测试数据来自方法外部。接下来,我们需要使用 InlineData 属性,这个属性提供了内联数据源,为数据理论提供数据。现在我们为测试添加参数。

步骤 1:定义测试参数

我们首先定义几个参数:

接下来,我们使用这些参数来进行测试。

步骤 2:编写参数化测试代码

[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,它接受三个参数:seatCapacitynumberOfSeatsremainingSeats。我们通过 InlineData 提供测试数据,例如:创建一个包含3个座位的航班,预订1个座位,剩余座位数为2。

步骤 3:运行测试

运行测试后,我们可以看到它成功通过了,并且所有测试都通过了。测试的结果表明,预订座位后,剩余座位数符合预期。

扩展测试场景

为了测试更多场景,我们只需复制 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个

这就是魔鬼辩护人测试的优势:可以轻松地增加新的测试场景,而不需要修改核心逻辑或重复编写测试代码。

关于“魔术数字”与“虚拟数据”

在测试中,我们不会将像电子邮件这样的虚拟数据作为参数传递,因为电子邮件在此场景中只是一个占位符,它并不影响座位数的逻辑。作为测试的参数,应该仅传递对测试有实际意义的变量,如座位数和剩余座位数,而像电子邮件这样的数据是“虚拟的”,它不应参与到测试的主要逻辑中。如果我们将电子邮件地址作为参数传递,那么这个测试的目的就会变得不明确。就像常量一样,虚拟数据(如电子邮件)只是为了填充而存在,而测试参数才是测试逻辑的关键变量。记住,不要将“虚拟数据”作为测试参数传递。

303 - 通过检查生产代码的完整性发现新场景

记住预定

在前面的视频中,我们已经实现了避免超售的测试场景。现在,我们需要处理的是记住实际的预订记录。也就是说,我们必须追踪航班的预定记录,了解谁预定了哪一班航班以及预定了多少座位。简而言之,我们需要“记住所有的预定”。

场景分析

让我们从“当”步骤开始考虑。当我预定航班时,航班的预定列表应该包括我的预定。当然,航班应该在预定之前已经存在。这个场景的测试代码应该非常简洁且易于理解。我们可以使用类似 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)过程:首先编写测试,再根据测试来编写和修改生产代码。通过这种方式,我们不仅确保了代码的正确性,还提高了代码的可维护性和可扩展性。

304 - 重构rememberbookings

重构记住预定

现在我们已经完成了记住预定的实现,接下来我们可以对代码进行重构,提升其质量和可维护性。在这一部分,我们将重点改进 Flight 类中的 BookingList 的访问权限。

问题分析

当前的 BookingList 是公开的,这意味着任何能访问我们代码的人都可以向 BookingList 添加预定,显然我们并不希望这样。因此,我们需要将 BookingList 设置为私有,只能在 Flight 类内部进行修改。

解决方案

为了使 BookingList 只能在 Flight 类内部修改,我们需要将它从公开的列表改为私有的列表,并提供一个只读的公开接口供外部读取。

  1. BookingList 设置为私有:

我们首先将 BookingList 声明为私有,这样它只能在 Flight 类中被访问和修改。

private List<Booking> bookingList = new List<Booking>();
  1. 提供一个只读接口:

为了让外部能够读取预定列表,我们提供一个只读的 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));
    }
}

重要的变化

优点

通过这种方式,我们不仅增强了代码的封装性,还避免了外部代码直接修改预定列表的风险。只有 Flight 类的内部逻辑可以控制对 BookingList 的修改。

小结

在这一部分中,我们通过对 BookingList 的访问权限进行重构,改进了代码的封装性。通过使用私有字段和公开只读接口,我们确保了 BookingList 只能在 Flight 类内进行修改,从而提升了代码的安全性和可维护性。

本章总结

在本章中,我们不仅学习了如何通过测试驱动开发(TDD)来实现和改进预定系统,还通过重构提升了代码的封装性和安全性。我们使用了“魔鬼代言人”技巧和参数化测试,进一步提高了测试的可信度。通过这些改进,我们的代码变得更加健壮、可扩展。

在下一章中,我们将进一步探索更多的测试场景,并深入学习 TDD 的原则和实践。敬请期待,我们很快就会进入下一个激动人心的阶段。

305 - TDD规则

第四章:测试驱动开发流程与三大法则

欢迎来到《测试驱动开发》课程的第四章。在上一章中,我们学习了如何为待测试系统编写一个可信的测试集合。在这一章中,我们将把测试驱动开发(TDD)流程以检查清单的形式展示,帮助你更容易地遵循并编写测试。我们还将学习 TDD 的三大法则,并在实践中应用它们。最后,我们将学习一种发现新场景的技巧。

回顾我们的工作流程

在开始编写代码之前,让我们先回顾一下到目前为止的工作流程,而不是直接跳入代码中。我们首先思考测试场景,然后将场景转化为代码,进入红绿重构(Red-Green-Refactor)过程。当最终重构完成后,我们通过“魔鬼代言人”技巧来检查测试是否足够可信。接着,我们检查待测试系统是否缺少任何其他行为。

如何发现缺失的行为?

你可以通过查看生产代码来判断待测试系统是否缺少其他行为来完成它的工作。例如,在我们的示例中,我们查看了 Flight 类,发现它没有记住预定,因此我们新增了一个“航班记住预定”场景。通过这种方法,如果待测试系统能够完全执行它的功能而不需要其他行为,则可以开始寻找新的场景。

如果系统中还缺少行为,你可以指定一个新的单元测试来添加缺失的行为。

TDD 检查清单

你可以使用以下流程图作为练习 TDD 的检查清单。这将帮助你在编写测试时保持对测试驱动开发规则的关注。

  1. 思考场景
    我们的第一个步骤是思考待测试系统应该具备的场景。

  2. 转化为测试代码
    将场景翻译成代码并编写相应的测试。

  3. 红绿重构
    执行红绿重构过程,先让测试失败,再让其通过,最后进行重构。

  4. 魔鬼代言人技术
    使用魔鬼代言人技术检查测试是否可信。

  5. 检查是否缺少行为
    查看生产代码,判断待测试系统是否缺少任何行为。

测试驱动开发的三大法则

TDD 有三条基本法则,遵循这些法则将帮助你更有效地进行测试驱动开发。

  1. 只编写生产代码以通过一个失败的测试
    在你编写任何生产代码之前,必须有一个失败的测试。只有在一个测试失败时,才开始编写代码来解决这个问题。

  2. 只写足够的单元测试以使其失败
    编写单元测试时,只需确保测试能够失败。当你编写单元测试时,不要为任何未来的实现细节做多余的准备,避免过度设计。

  3. 只编写足够的生产代码以通过一个失败的单元测试
    在写生产代码时,要避免过度编写。只写足够的代码,使得测试能够通过。如果在通过一个测试时发现生产代码中缺少某个行为,应该为这个行为编写新的单元测试,而不是为其他行为提前编写代码。

实践中的第二条法则

假设你有一个失败的测试,并且你正在编写足够的生产代码来通过这个测试。根据 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);
}

解释

  1. Arrange(安排): 我们创建了一个航班对象,假设其座位容量为 3。然后我们通过调用 Book 方法为乘客预定了 1 个座位。
  2. Act(执行): 我们调用 CancelBooking 方法来取消预定。
  3. Assert(验证): 最后,我们验证航班的可用座位数是否已恢复为 3,表示取消预定操作已成功。

结语

本章中,我们学习了 TDD 的三大法则并应用它们,编写了取消预定的测试,并设计了相应的功能。在下一章中,我们将继续深入探索更多 TDD 的技术和策略,进一步提升我们的测试能力。

306 - 使用测试驱动开发规则取消预订场景

测试取消预定并释放座位

在前面的演示中,我们发现了一个场景,即取消预定能够释放座位。现在我们来创建一个新的测试方法,验证这一场景的实现。

创建取消预定的测试方法

首先,我们创建一个测试方法来验证取消预定是否正确释放座位。我们将按照以下步骤编写测试:

[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);
}

解释每个步骤

  1. Given(给定): 我们创建了一个初始座位数为 3 的航班实例 flight
  2. When(执行): 然后我们通过调用 flight.Book 方法为某个乘客预定了 1 个座位。
  3. Then(验证): 接着,我们调用 flight.CancelBooking 方法来取消该乘客的预定。最后,我们检查航班上剩余的座位是否恢复为 3。

编写测试时遵循的第二条法则

根据 TDD 的第二条法则:“编写足够的单元测试以使其失败”,我们在这里编写了测试方法。当前编译会因为 CancelBooking 方法未定义而失败,这实际上是 TDD 的一个重要提示,我们应该先修复这个问题,再继续编写其他代码。

修复编译错误

为了遵循第二条法则,我们修复这个编译错误。首先,我们需要为 Flight 类添加 CancelBooking 方法。你可以通过 IDE 中的“显示潜在修复”或直接按 Ctrl + . 来自动生成该方法:

public void CancelBooking(string passengerEmail, int numberOfSeats)
{
    // 尚未实现
}

此时,我们已经解决了编译错误,但该方法还没有具体的实现逻辑。

继续应用 TDD 的法则

根据 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 的三大法则,逐步编写了取消预定的功能,并通过测试验证了其正确性。最终,我们成功实现了取消座位的业务逻辑,并使用参数化测试覆盖了多种不同的情况。这种方式确保了我们编写的代码符合预期,且能够应对不同的业务需求。

307 - 处理取消预订未找到预订

取消预定的完整性和处理未预定的乘客

第一步:检查取消预定方法的完整性

在我们检查取消预定方法的完整性之前,我们首先回顾一下现有的测试。我们已经通过测试确保取消预定会恢复座位的可用性,接下来的任务是确保我们的 cancelBooking 方法能够处理所有相关的场景,尤其是当试图取消一个未预定航班时。

第二步:编写新的测试用例

我们先来思考一个新场景:如果乘客未预定该航班,取消预定应该不成功。我们可以通过以下的测试来验证这一点:

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 方法目前没有处理这种未预定乘客的情况。因此,我们首先需要处理编译错误。

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 表示成功取消预定。

代码如下:

public object CancelBooking(string passengerEmail, int numberOfSeats)
{
    if (!bookingList.Any(booking => booking.Email == passengerEmail))
    {
        return new BookingNotFoundError();
    }

    // 这里可以添加取消预定的逻辑
    return null; // 成功取消预定
}

第七步:通过所有测试

此时,我们可以重新运行所有的测试,确保所有的测试用例都通过。通过这一步,我们确认了我们的 CancelBooking 方法已经正确地处理了未预定的乘客情况,并且业务逻辑符合预期。

总结

通过这一步骤,我们完成了以下任务:

通过这些步骤,我们不仅确保了方法的正确性,还进一步提高了测试的可信度和可靠性。

308 - 你是如何发现新场景的

继续增强测试覆盖性

下一步:检查 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");
}

处理其他可能的场景

除了上述测试,我们还可以考虑一些其他可能的场景:

  1. 预定两个座位,但只取消一个

    • 如果乘客预定了两个座位,但只取消了一个,剩下的座位应该继续保留在 bookingList 中。
  2. 预定一个座位,但取消两个

    • 如果乘客预定了一个座位,却尝试取消两个座位,应该返回一个错误,因为没有足够的预定进行取消。

这些场景可以通过简单的修改现有的测试代码来实现。

进一步的测试和思考

在分析 CancelBooking 方法时,我们不仅仅可以通过查看生产代码来发现新的场景。更重要的是,我们可以利用 “what-if” 分析法(假设分析),通过思考可能的场景来发现新的测试用例。通过将这些假设写在便签上,可以帮助我们全面考虑每种可能的情况,确保测试涵盖所有边界条件。

总结:第三阶段的学习和实践

恭喜你完成了课程的第四章!在这一章中,你不仅学会了如何发现和定义新的测试场景,还深入了解了 TDD(测试驱动开发)中的三条基本法则,并通过实践解决了其中的挑战。

到目前为止,我们主要测试了单一的类,但在实际开发中,应用程序需要在更高层次上进行全面测试。下一章,我们将开始实现 FlightService,并根据测试来指导我们的开发过程。

继续保持关注,我们将在下一章中开始新的挑战!

309 - 应用层测试

第五章:测试应用层

什么是应用层服务?

在本章中,我们将学习如何测试应用程序服务。那么,什么是应用服务呢?我们迄今为止已经学习了如何测试单个类。这个类通常是实体类,而测试实体类是非常重要的。然而,单独测试实体类并不足够。在实际的应用程序中,我们从数据库中加载实体,对其进行修改,并最终将其保存回数据库。加载和保存数据到数据库是应用层的职责,因此我们也需要测试应用层的逻辑。

应用层负责协调其他组件,通常的任务是从数据库中加载实体,调用它们的API进行修改,然后再将它们保存回数据库。如果你熟悉Web开发或ASP.Net Core,你一定知道如何通过像Entity Framework这样的ORM(对象关系映射)工具来加载和保存数据库中的数据。

在本章中,我们将学习如何实际测试应用层,以确保它能够正确地协调不同的组件。

创建项目

首先,我们将为应用层和应用层的测试创建两个新的项目:

  1. 右键点击解决方案,选择 Add(添加) > New Project(新建项目)。
  2. 创建第一个测试项目:选择 xUnit Test Project,命名为 application.tests,选择 .NET 6,然后点击创建。

完成后,创建第二个项目:

  1. 右键点击解决方案,选择 Add(添加) > New Project(新建项目)。
  2. 选择 Class Library(类库),命名为 application,然后点击创建。

这时,我们已经有了两个新的项目:

命名测试类

接下来,我们会进入 application.tests 项目中。首先,我们修改 UnitTest1 类的名称。将类名修改为 FlightApplicationSpecifications,并且将测试方法命名为 BooksFlightsSaving,表示测试的业务逻辑是关于航班预定的保存。

public class FlightApplicationSpecifications
{
    public void BooksFlightsSaving()
    {
        // 这里我们将编写关于航班预定保存的测试逻辑
    }
}

创建数据层

应用层负责保存数据,但实际的数据对象(如航班实体)应当位于一个新的 数据库 项目中。此数据项目将包含实际的数据模型,便于应用层与数据库交互。

虽然你可能没有接触过多层架构的开发,但不要担心,随着项目的进展,你会对这种架构变得更加熟悉。在大多数企业级应用中,通常会有很多子项目,主项目会协调这些子项目进行不同的任务。我们接下来将为数据层创建一个新的类库:

  1. 右键点击解决方案,选择 Add(添加) > New Project(新建项目)。
  2. 选择 Class Library(类库),命名为 data,然后点击创建。

此时,我们已经为应用程序的各个部分准备好了相应的项目:

准备工作完成

我们已经完成了本章的准备工作,接下来,我们将开始编写应用层逻辑的测试,探索一个新的场景,并根据该场景编写测试代码。

在接下来的内容中,我们将深入学习如何通过测试驱动开发(TDD)的方法来编写和验证应用层服务的功能。


小结

在下一章节中,我们将通过测试应用层的逻辑来进一步理解如何在实际项目中使用TDD进行开发。

310 - 应用层预订场景第一部分

新的测试场景:预定航班时检查航班是否包含预订

场景概述

我们接下来的测试场景是,当我预定一个航班时,这个航班应该包含预定记录,并且在能够预定之前,必须确保航班存在。你可能会问,这个场景不是我们之前已经覆盖过了吗?是的,之前我们确实涉及过类似的场景,但这次的场景不同,因为我们是在 服务层 进行操作。

服务层与实体层的区别

与直接调用实体的 book 方法不同,我们现在调用的是 预定服务(booking service)的 book 方法。调用这个方法时,它会从数据库加载实体,调用实体的 book 方法,最后再将结果保存回数据库。因此,我们现在处在一个完全不同的层次上,目的是检查应用层的状态是否发生了变化。

我们需要验证以下内容:

步骤:编写测试

让我们开始编写相关的测试代码。在 应用层测试项目 中,首先我们要创建一个新的 预定服务(Booking Service)。

  1. 创建预定服务类
public class BookingService
{
    public void Book(BookDTO bookDTO)
    {
        // 方法逻辑会在后面逐步完善
    }

    public IEnumerable<BookingM> FindBookings()
    {
        // 返回预定列表的逻辑
    }
}

此时,我们会遇到错误,因为 BookingService 类中的 Book 方法和 FindBookings 方法还没有实现。这是 TDD 的常见情况:编写测试之前,先要确保测试能够找出代码中缺失的部分。

  1. 定义 DTO(数据传输对象)

为了向 Book 方法传递必要的信息,我们需要定义一个 BookDTO(预定数据传输对象),通常这个类会包含一些简单的属性,如航班信息、预定人信息等。

public class BookDTO
{
    public string FlightId { get; set; }
    public int NumberOfSeats { get; set; }
    public string Email { get; set; }
}

此时,我们进入了 红色状态,因为 BookingService 中的 Book 方法需要一个 BookDTO 类型的参数,但我们还没有创建这个类。

  1. 解决错误并继续

创建好 BookDTO 后,继续实现 BookingService 中的 Book 方法,并修改方法签名以接受 BookDTO 参数。

public void Book(BookDTO bookDTO)
{
    // 在这里将逻辑添加到Book方法
}
  1. 定义读取模型(Read Model)

为了返回已预定的航班信息,我们需要定义一个 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)则用于展示或查询数据。

  1. 实现查询预定的方法

接下来,我们实现 BookingService 中的 FindBookings 方法,返回一个包含所有预定记录的列表。

public IEnumerable<BookingM> FindBookings()
{
    // 这里模拟查询数据库的逻辑,返回一个包含所有预定的集合
    return new List<BookingM>
    {
        new BookingM { FlightId = "A123", NumberOfSeats = 2, Email = "test@example.com" }
    };
}
  1. 编写测试逻辑

应用层的测试类 中,使用 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

通过使用 Fluent Assertions,我们可以方便地验证 FindBookings 方法返回的列表中是否包含预定对象。上面的代码中的断言:

bookings.Should().ContainEquivalentOf(new BookingM
{
    FlightId = "A123",
    NumberOfSeats = 2,
    Email = "test@example.com"
});

这一行代码将验证 FindBookings 返回的预定列表中是否包含一个与我们创建的 BookingM 对象等效的预定。

运行测试

运行测试时,当前测试会失败,因为 FindBookings 方法并没有真正查询数据库,它只是返回了一个硬编码的结果。为了使测试通过,我们需要在后续的开发中实现实际的数据库查询逻辑。

总结

在下一个步骤中,我们将继续完善这个服务,处理实际的数据库操作以及其他业务逻辑。

311 - 应用层预订场景第二部分

1. 问题:NotImplementedException 异常

当前测试失败的原因是 findBookings 方法抛出了一个 NotImplementedException 异常。为了解决这个问题,我们将方法修改为返回一个包含新 Booking 对象的数组,这样就可以模拟一个预期的返回,而不会导致异常抛出。这样可以让测试顺利进行。

代码修改:

public IEnumerable<Booking> FindBookings()
{
    return new Booking[] { new Booking() };  // 返回一个模拟的预定数据
}

2. 空的 Booking 对象问题

当测试运行时,它依然会失败,因为 Booking 对象是空的,并且没有任何属性。这样 Fluent Assertions 在进行对象比较时,找不到任何可以比较的成员。为了让比较有效,我们需要为 Booking 模型添加属性。

解决方法:

public class Booking
{
    public string PassengerEmail { get; set; }
    public int NumberOfSeats { get; set; }

    public Booking(string passengerEmail, int numberOfSeats)
    {
        PassengerEmail = passengerEmail;
        NumberOfSeats = numberOfSeats;
    }
}

3. 更新 BookingDTO

我们还需要更新 BookingDTO 类,添加构造函数和属性。这样我们就可以在测试中创建有效的实例,进行比较。

代码修改:

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;
    }
}

4. 修复比较测试

一旦属性和构造函数添加完成,我们就可以修改测试,创建匹配的数据,以便进行比较。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));

5. 测试结果

经过必要的调整后,测试应该通过,因为:

Test Explorer 中运行测试,应该能看到测试通过。


总结:

通过这个过程,我们演示了在 TDD 中写单元测试的更复杂场景,我们模拟了与数据库的交互(数据的存储和提取),并确保正确的数据在应用程序内部传递。

312 - 应用层预订场景第三部分

1. 创建 Entities

首先,我们在数据层的项目中创建了一个新的 Entities 类,它将模拟数据库的行为。这个类将用于存储与业务逻辑相关的数据,比如航班和预定信息。

步骤:

public class Entities
{
    public List<Flight> Flights { get; set; } = new List<Flight>();
    public List<Booking> Bookings { get; set; } = new List<Booking>();
}

2. 添加引用

为了使应用层能够访问 Entities 类,我们需要将数据项目的引用添加到应用程序的测试项目中。通过这种方式,应用层可以使用数据层的命名空间。

步骤:

3. 更新 BookingService 构造函数

接下来,我们将修改 BookingService,让它能够接受并使用 Entities 实例,表示数据源(数据库)。

步骤:

public class BookingService
{
    private Entities _entities;

    public BookingService(Entities entities)
    {
        _entities = entities;
    }

    // 其他方法,例如预定航班、查找航班等
}

4. 配置 DBContext 和 Entity Framework Core

为了让 Entities 类能够与数据库交互,我们将 Entities 类变成一个 DbContext。这意味着我们将使用 Entity Framework Core 来模拟数据库的行为。

步骤:

public class Entities : DbContext
{
    public DbSet<Flight> Flights { get; set; }
    public DbSet<Booking> Bookings { get; set; }

    // DBContext 配置,例如连接字符串等
}

5. 使用内存数据库进行测试

为了避免在测试中连接到真正的数据库,我们可以使用一个内存数据库。这将允许我们在运行时模拟数据库操作,而不需要实际的 SQL 数据库。

步骤:

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");
    }
}

6. 更新航班 Flight

为了将航班与预定进行关联,我们在 Flight 类中添加了 FlightId 属性,这样每个航班就有了一个唯一标识符。

步骤:

public class Flight
{
    public Guid FlightId { get; set; } = Guid.NewGuid();
    public int Capacity { get; set; }
}

7. 预定航班和查找航班

我们接着将逻辑添加到 BookingService 中,以便可以预定航班并在内存数据库中查找预定。

步骤:

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);
    }
}

8. 总结

这些步骤展示了如何在测试中使用内存数据库,并通过模拟真实的数据库操作来测试应用程序逻辑,而不需要依赖外部数据库。在这个过程中,我们保持了 TDD(测试驱动开发)的原则,先编写测试,解决编译错误,最后实现功能。

313 - 配置内存数据库

1. 安装 Entity Framework Core In-Memory 数据库提供程序

在进行测试之前,我们需要确保应用程序配置了数据库提供程序。此时,我们遇到的错误是“没有为 DbContext 配置数据库提供程序”。为了修复这个问题,我们需要安装一个内存数据库提供程序,这样我们可以模拟一个数据库,而不需要使用真实的数据库。

步骤:

这将允许我们在内存中模拟一个数据库,而不需要实际的 SQL 数据库。

2. 配置 DbContext 和内存数据库

在应用程序中,我们需要创建一个 DbContext 实例,并配置它使用内存数据库。我们将通过 DbContextOptionsBuilder 来配置 Entities 类,使其使用内存数据库。

步骤:

var options = new DbContextOptionsBuilder<Entities>()
    .UseInMemoryDatabase(databaseName: "Flights")
    .Options;

var entities = new Entities(options);

3. 修改 Entities 类的构造函数

为了使 Entities 类能够接收并使用 DbContextOptions,我们需要添加一个构造函数。这个构造函数将调用 DbContext 的基类构造函数。

步骤:

public class Entities : DbContext
{
    public Entities(DbContextOptions<Entities> options) : base(options) { }

    public DbSet<Flight> Flights { get; set; }
    public DbSet<Booking> Bookings { get; set; }
}

4. 配置 Flight 类的无参构造函数

为了让 Entity Framework 正确处理 Flight 类的实例,我们需要为 Flight 类提供一个无参构造函数。Entity Framework 需要能够创建 Flight 类的实例,才能将数据映射到数据库中。

步骤:

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() { }
}

5. 配置 OnModelCreating 方法

为了定义 Flight 类和数据库之间的映射关系,我们需要重写 DbContext 类中的 OnModelCreating 方法。在这个方法中,我们可以设置实体的映射规则,例如将 Flight 类的 ID 属性设置为主键。

步骤:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    modelBuilder.Entity<Flight>(entity =>
    {
        entity.HasKey(f => f.Id);  // 设置 ID 为主键
    });
}

6. 设置一对多关系

由于一个 Flight 可以有多个 Booking,我们需要在 OnModelCreating 方法中配置这种一对多的关系。我们可以使用 modelBuilder 来定义这个关系。

步骤:

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 之间的一对多关系
    });
}

7. 运行测试

一旦我们完成了这些更改,就可以回到测试中运行我们的代码。现在,我们配置了内存数据库,并确保 FlightBooking 正确映射到数据库中。

步骤:

[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");
}

8. 参数化测试

为了提高测试的可靠性和可重复性,我们可以将测试参数化。通过不同的测试数据,我们可以验证预定是否正确工作。

步骤:

[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);
}

9. 总结

这些步骤使我们能够通过模拟的数据库来验证应用程序的业务逻辑,而不依赖于外部的数据库。我们遵循了 TDD(测试驱动开发)的原则,逐步修复编译错误,最终实现了功能。

314 - 参数化预订航班测试

1. 参数化 BooksFlights 测试

我们已经对 BooksFlights 测试进行了参数化,以便使用不同的输入数据进行测试。现在,我们将使用 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);
}

2. 更新 BooksFlight 方法使用参数

在测试方法中,我们现在使用传递的参数 passengerEmailnumberOfSeats 替代了硬编码的值。这样可以确保测试是基于不同的输入数据进行的,而不是固定的值。

步骤:

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);
}

3. 运行并验证测试失败

当我们运行测试时,测试失败是正常的,因为当前的 FlightBooking 实现始终返回相同的值。比如,我们可能只返回了一个硬编码的电子邮件(例如 "abc.com"),而我们实际传入的电子邮件是 "m@m.com""a@a.com"

步骤:

Expected value: m@m.com
Actual value: abc.com

这意味着我们的 FlightBooking 方法没有正确处理每个预定,而是总是返回相同的预定。我们需要在 Book 方法中进行改进,确保每次预定时返回正确的电子邮件地址。

4. 修复 FlightBooking 方法

当前的实现总是返回一个相同的预定数据,而不是根据不同的输入创建新的预定。我们需要确保每次预定时都会返回正确的乘客电子邮件和座位数量。

步骤:

public void Book(Booking booking)
{
    // 确保每次预定都被正确保存到数据库
    _entities.Bookings.Add(booking);
    _entities.SaveChanges();
}

5. 运行测试并验证修复

修复完 Book 方法后,我们可以重新运行测试。现在,我们传入的每个电子邮件地址和座位数应该能够正确匹配数据库中的预定数据。

步骤:

6. 总结

通过这种方式,我们不仅改进了测试的可维护性,还确保了代码逻辑能够处理不同的输入情况。这是 TDD 中常见的一个实践,我们通过不断修改代码来使其通过所有的测试。

315 - 实现预订服务

1. 配置预定服务实现 FindBookings 方法

我们现在要在 BookingService 中配置 FindBookings 方法,使其不仅返回一个硬编码的 imdb.com,而是根据给定的 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();
    }
}

2. 添加 FlightID 属性到 BookingDTO

为了使 Book 方法能够处理传入的 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;
    }
}

3. 查询数据库中的预定信息

我们现在需要在 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>(); // 如果没有找到航班,返回空列表
}

4. 使用 LINQ 查询数据库

在查询数据库时,我们使用了 LINQ(语言集成查询)来从航班的预定列表中选取数据,并将其转换为 BookingRemodel 类型。BookingRemodel 类用于封装预定信息,并仅包含我们需要的字段(例如 PassengerEmailNumberOfSeats)。

步骤:

using System.Linq;

5. 修复 entities 对象为 null 的问题

在测试过程中,如果 entities 对象为 null,我们需要确保正确实例化它,以便数据库操作能够正常进行。

步骤:

public BookingService(DbContext entities)
{
    _entities = entities ?? throw new ArgumentNullException(nameof(entities));
}

6. 运行测试并验证修复

最后,我们运行之前的 BooksFlights 测试,确认是否能够成功执行查询并返回正确的预定数据。现在,BookingService 已经配置为实际查询数据库,并根据 flightID 返回正确的预定信息。

步骤:

7. 总结

这个过程展示了如何使用 TDD 开发一个涉及数据库操作的功能,并且如何通过不断的重构和调试,最终实现一个稳定且有效的服务。

316 - 重构预订服务

1. 重构和拆分 C# 文件

现在,我们已经完成了初步的开发,并且现在是时候对代码进行重构了。我们有一个单一的 C# 文件,其中包含了多个类,为了使代码更加模块化、可维护,我们将把这些类移到不同的文件和命名空间中。

步骤:

namespace Application
{
    public class BookingService
    {
        // 这里是 BookingService 类的实现
    }
}

2. 移动和重命名 DTO 类

为了使代码更加清晰,我们将 DTO(数据传输对象)类也移到应用层,并确保它们在正确的命名空间中。

步骤:

示例:

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;
        }
    }
}

3. 解决缺少的引用

在文件结构变更后,我们需要确保所有相关类之间的引用已经正确更新。如果遇到缺少引用的错误,可以通过以下方式解决:

using Application;  // 确保引用了 Application 命名空间

4. 运行所有测试并确保功能正常

重构和修改代码结构后,最重要的一步是确保所有功能依然正确工作。为此,我们可以运行所有的单元测试,确保项目没有出现任何回归错误。

步骤:

5. 结果验证

如果测试结果显示一切正常且没有错误,则说明我们成功地完成了重构。此时代码更加清晰、模块化,便于维护和扩展。

6. 总结

这就是如何在保持应用功能完整的情况下,进行结构重构和模块化的过程。

317 - 创建取消预订的测试

1. 添加取消预定的测试

在 Flight Application 规格中,我们将添加一个新的测试方法用于测试取消预定功能。我们已经测试了 BookFlights 方法,现在我们将创建一个新的测试方法 CancelsBooking

步骤:

[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);
}

2. 创建 CancelBookingDTO

为了支持取消预定的功能,我们需要创建一个 CancelBookingDTO 类,用于传递取消预定所需的信息,例如航班 ID、乘客邮箱以及座位数。

步骤:

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# 文件来保存这个类。

步骤:

3. 更新 BookingService 以支持取消预定

BookingService 中,我们需要添加一个新的方法 CancelBooking,该方法接收一个 CancelBookingDTO 对象并执行取消操作。

步骤:

public void CancelBooking(CancelBookingDTO cancelBookingDTO)
{
    // 实现取消预定的逻辑
}

4. 添加获取剩余座位的方法

为了验证取消预定是否成功,我们需要在 BookingService 中添加一个方法 GetRemainingNumberOfSeats,该方法接收一个航班 ID,返回剩余的座位数。

步骤:

public int GetRemainingNumberOfSeats(int flightId)
{
    // 返回航班剩余座位数的逻辑
}

5. 更新测试中的数据库和预定流程

为了确保取消预定测试的有效性,我们需要做一些数据初始化工作:

步骤:

// 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);

6. 优化代码重用和重构

为了避免在多个测试方法中重复相同的数据库初始化和 BookingService 创建逻辑,我们可以将这些通用操作提取到共享的代码中。使用只读字段来存储 EntitiesBookingService 实例,并通过构造函数进行初始化。

步骤:

readonly Entities _entities = new Entities();
readonly BookingService _bookingService;

public FlightTests()
{
    _entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
    _bookingService = new BookingService(_entities);
}

然后,在每个测试方法中,直接使用这些共享的字段,避免重复初始化代码。

7. 总结

通过这些步骤,我们为应用添加了取消预定的功能,并确保了它的正确性。

318 - 最终确定取消预订

1. 回顾:模拟返回 3 的情况

GetRemainingNumberOfSeats 方法中,我们模拟返回 3 来让测试通过。虽然这种做法暂时使得测试通过,但它并未实现真正的逻辑。为了继续开发,我们需要实现真正的取消预定逻辑。

步骤:

public int GetRemainingNumberOfSeats(int flightId)
{
    return 3; // 固定返回 3
}

public void CancelBooking(CancelBookingDTO cancelBookingDTO)
{
    throw new NotImplementedException();
}

在测试中,我们能够看到,尽管没有实现取消预定的功能,但测试仍然通过。这是因为返回了一个固定值,符合期望结果。

2. 参数化测试:增加可测试性

为了提高测试的可靠性,我们将使用参数化测试,确保测试覆盖更多场景。通过将 Theory 属性和 InlineData 一起使用,我们可以为不同的初始座位容量提供多种测试数据。

步骤:

[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 为 10 的测试将会失败,因为我们尚未实现真正的取消逻辑。

3. 实现取消预定的逻辑

接下来,我们将为 CancelBooking 方法实现取消预定的功能。在这个方法中,我们将根据 CancelBookingDTO 查找航班,并更新其剩余座位数。更新后,我们需要保存数据库的更改。

步骤:

public void CancelBooking(CancelBookingDTO cancelBookingDTO)
{
    // 查找航班
    var flight = entities.Flights.Find(cancelBookingDTO.FlightId);

    if (flight != null)
    {
        // 取消预定,恢复座位数
        flight.SeatsAvailable += cancelBookingDTO.NumberOfSeats;

        // 保存更改
        entities.SaveChanges();
    }
}

4. 修改 GetRemainingNumberOfSeats 方法

我们将修改 GetRemainingNumberOfSeats 方法,不再返回固定值,而是根据航班 ID 查找实际剩余座位数。

步骤:

public int GetRemainingNumberOfSeats(int flightId)
{
    var flight = entities.Flights.Find(flightId);
    return flight?.SeatsAvailable ?? 0;  // 返回座位数,若找不到航班则返回 0
}

5. 修改 CancelBookingDTO

为了确保我们能够传递必要的信息,我们需要确保 CancelBookingDTO 类的属性设置正确。我们将为 flightIdpassengerEmailnumberOfSeats 提供合适的构造函数和属性。

步骤:

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;
    }
}

6. 验证取消预定功能

完成所有修改后,重新运行测试。现在我们已经实现了 CancelBooking 方法,并确保其与实际数据库交互。测试应该通过:

步骤:

7. 优化和重构

为了避免在多个测试方法中重复初始化数据库和 BookingService,我们将这些公共代码提取到共享的字段中。这样,每个测试方法可以直接使用这些字段,而不需要每次都初始化它们。

步骤:

readonly Entities _entities = new Entities();
readonly BookingService _bookingService;

public FlightTests()
{
    _entities.UseInMemoryDatabase().ConfigureDatabase("Flights").SubmitOptions();
    _bookingService = new BookingService(_entities);
}

现在,每个测试方法可以直接使用 _entities_bookingService,而不需要重复初始化代码。

8. 总结

通过这些步骤,我们成功实现了一个可用的航班预定和取消系统,并通过 TDD 确保其功能的正确性。

319 - 命名约定

1. 命名约定:清晰优先于严格遵守规则

在命名测试方法时,最重要的是确保名称清晰地表达了测试的行为,而不是死板地遵守命名规则。虽然存在一些常见的命名约定,如 GWT(Given-When-Then)模式,但我更倾向于关注测试方法名称的清晰度和简洁性。接下来,我们将讨论如何根据 GWT 模式调整现有测试方法的名称,并提供更具可读性的示例。

2. GWT 命名模式

GWT(Given-When-Then)模式是一种常见的命名约定,它有助于明确表示测试的各个步骤。每个部分都用简短的描述来表示:

例如,现有的 books flights 测试方法可以根据 GWT 模式进行重命名:

原方法名:

public void booksFlights() { ... }

根据 GWT 模式,方法名应改为:

public void givenAFlight_whenIBookTheFlight_thenTheFlightShouldContainMyBooking() { ... }

分解:

这个命名方法很详细,但显然非常冗长,尤其是在测试方法数量增多时,长长的名称会影响代码的可读性。

3. 优化命名:保持清晰但简洁

为了提高代码的可读性,我们需要创造性地改进方法名称,确保简洁且依然能够清晰表达行为。可以对命名进行优化,使得方法名既简短又直观。

优化后的命名:

public void remembersBookings() { ... }

解释:

通过这样的命名,我们保留了行为的清晰度,同时让方法名变得更加简洁易懂。

4. 应用于取消预定的测试方法

对于取消预定的测试方法,使用 GWT 模式也同样可以进行调整。在这个测试中,我们实际上不是测试“取消”操作本身,而是测试“取消后座位是否被释放”。因此,方法名应更好地描述测试的目标——是否能释放座位。

原方法名:

public void cancelBooking() { ... }

根据 GWT 模式,方法名可能是:

public void givenABookedFlight_whenICancelTheBooking_thenSeatsShouldBeFreedUp() { ... }

优化后的命名:

public void freesUpSeatsAfterBooking() { ... }

解释:

5. 总结

命名测试方法时,关键是要确保名称:

虽然 GWT 模式是一种有效的命名方式,但我们可以根据具体情况进行调整,使得方法名称既简洁又准确,方便后续维护和阅读。

320 - 测试套件作为文档

1. 测试驱动开发(TDD)的最大优势之一:作为文档的测试套件

在测试驱动开发(TDD)中,测试不仅仅是用来验证代码是否按预期工作,还能作为项目的文档。通过测试,任何开发人员都可以清楚地了解一个方法的行为和预期效果,无需查看源代码本身。这些测试方法就像是应用程序的“活文档”,它们会随着项目的变化而自动更新,始终与代码保持同步。

如何利用测试套件作为文档:

当你打开 Test Explorer 时,可以快速看到所有的测试方法及其目标。在这里,测试方法清楚地描述了每个功能的行为和期望结果,因此,测试本身可以充当文档来帮助你理解系统的功能。例如,测试方法名称通常直接表达了测试的目的,且方法内的代码展示了如何实现这些行为。

2. 测试就是文档:展示行为和验证功能

测试方法清晰地展示了应用程序的行为,并且通过测试验证了它是否真的做到了它所声称的内容。例如,你可以在测试中看到:

通过这些测试用例,开发人员可以一目了然地看到系统当前的行为和功能实现。

3. 测试套件作为活文档的价值

通过测试,我们不仅能够验证功能,还能确保功能的正确性,且这些测试本身就构成了项目文档。每当项目发生变化时,测试方法都会更新,从而确保文档始终是最新的。

举个例子,假设你查看了一个叫做 flight specifications 的测试类,你会看到以下几个方面的测试:

这种做法不仅验证了功能,还直接提供了功能的描述,让其他开发者可以通过阅读这些测试来理解项目的业务逻辑。

4. 如何帮助其他开发人员理解代码

测试套件的最大优势之一是它可以成为其他开发人员快速了解项目的途径。假设新的开发人员加入到你的项目中,他们可以通过快速浏览测试文件,理解系统的核心功能和行为,而无需深入查阅每一行代码。

例如,在 application test 中,你可以看到以下几个测试方法:

通过这些测试,任何新加入的开发人员都可以清晰地知道系统是如何管理预定和座位的。这种文档化的方式使得项目更加透明,团队协作更加高效。

5. 总结

总的来说,测试驱动开发不仅帮助你验证代码的正确性,而且能够使测试代码成为“活文档”,这一点对整个团队非常有价值。通过测试,开发人员不仅能够理解代码如何实现业务需求,还能够确保这些需求在代码变更时得到保持。因此,测试是项目中不可忽视的重要组成部分,它同时担任了验证工具和文档的角色,提升了项目的可维护性和可扩展性。

321 - 应用层

1. 使用内存数据库进行测试:避免真实数据库的复杂性

在进行测试时,访问真实的资源(如数据库)通常会使测试代码变得复杂,并且会增加测试执行的时间。每次执行测试时都需要连接到数据库并进行数据操作,既费时又容易导致不必要的依赖关系。因此,为了提高测试效率并避免复杂的数据库设置,我们可以选择使用内存数据库进行测试。

内存数据库的优势:

2. 应用服务与I/O技术分离:提高软件可维护性

为了提高软件的可维护性和可测试性,通常会将应用服务与具体的I/O技术(如MVC、Web API、WPF等)分离。这样做的好处在于:

如何实现分离:

3. 结合TDD方法:真实世界中的应用

通过将内存数据库与TDD(测试驱动开发)结合使用,你可以在不依赖真实数据库的情况下,测试应用程序的业务逻辑。TDD方法强调先写测试,再实现功能,从而保证代码的质量和功能的正确性。

如何进行TDD测试:

4. 总结:

恭喜你!现在你已经掌握了如何使用内存数据库来测试软件的应用服务,并结合TDD方法进行实际开发。这不仅能提升代码的可测试性、可维护性,还能确保系统能够正确实现预期的业务逻辑,同时避免了真实数据库带来的复杂性和性能问题。

WangShuXian6 commented 2 weeks ago

23 - UNITY基础

322 - Unity基础简介

欢迎回来

在本章中,您将学习 Unity 的基础知识。首先,我想说几句关于接下来内容的事情。接下来的两章内容最初并不是这门课程的一部分,而是我 Unity 课程中的内容。所以我为您精心准备了一些额外的内容,作为一个额外的奖励,提供给您更多有趣的小项目,帮助您在编程方面取得更好的进展。

Unity 和 C# 编程

Unity 是一个游戏开发引擎,它使用 C# 作为核心编程语言。因此,在接下来的章节中,我们将编写大量的代码,使用我们在之前章节中学到的所有知识来制作游戏。如果您对游戏开发不感兴趣,当然也可以跳过这些内容,继续做自己的项目,因为您已经有能力开始自己的创作了。

但如果您希望从不同的角度更深入地学习 C#,并看到它在游戏开发中的应用,这将有助于您成为一个更好的程序员,并且可以帮助您更好地理解到目前为止所学的一切。因为我们将以一种不同的视角去看待它,这种不同的视角能帮助您更清晰地理解之前学到的知识。

Unity 的基本概念

在这一章中,我们将介绍 Unity 的基本概念,这些基础知识将帮助您创建自己的游戏。当然,我们还会一起开发一些游戏。如果您想制作更多的游戏,您可以随时访问我的 YouTube 频道,我有一个频道叫做 Tutorials EU,网址也是 tutorials.eu,在这里您可以找到更多的项目、更多的游戏开发内容,以及一些特别的项目。如果您想去那里学习更多内容,帮助您成为一个更好的开发者,那就赶紧去看看吧。

注意:过渡不会很平滑

但请注意,这一部分内容和之前的 C# 课程有所不同。由于这一部分并非最初课程的一部分,因此在风格和方向上会有所不同。过渡可能不会那么平滑,所以请您做好准备。

感谢

非常感谢您坚持到现在。我很高兴看到您仍然跟随到这个阶段,您可能是少数(大约 5% 或 10%)能够坚持到这里的人。做得好!期待在下一节视频中与您见面。

324 - Unity界面概述

欢迎

在本视频中,我们将一起了解 Unity 的用户界面。首先,打开 Unity,然后点击 新建 按钮创建一个新项目。接着为项目命名,我将其命名为 Unity Basics,并选择 3D 作为项目类型。如果我们已经有一些想要添加的资源包,比如图形对象或已有的功能代码,可以在这里添加它们。接着设置保存路径,选择一个文件夹来存储你的项目。你还可以启用 Unity Analytics,它会收集项目数据,并提供与类似项目的对比基准以及玩家行为的分析。然而,考虑到我们当前并不需要发布游戏,所以我们暂时不启用它。

接下来,点击 创建 按钮,等待几秒钟,Unity 将会启动。启动后,您会看到一个包含多个区域和屏幕部件的界面。接下来,我们将逐一介绍这些界面部分,帮助你更好地理解它们。

Unity 界面组成

  1. 工具栏(Toolbar):
    在界面顶部是工具栏,它让我们能够改变与场景交互的方式。比如:

    • 第一个按钮(手形图标):用于在场景中移动视角。
    • 第二个按钮(十字架图标):用于改变对象在游戏中的位置。
    • 第三个按钮(旋转图标):用于改变对象的旋转,譬如旋转摄像机。
    • 第四个按钮(缩放图标):用于改变对象的大小,我们通常会在 Inspector(检查器) 中进行调整。
    • 第五个按钮:用于处理 UI 元素。

    这些按钮都属于 Transform 工具,用于改变游戏对象的变换组件。关于这些变换组件的内容,我们会在之后的课程中详细讲解。

  2. 层级视图(Hierarchy View):
    层级视图位于左侧,显示当前场景中的所有游戏对象(即您的游戏或关卡中的元素)。例如,我们可以在这里看到当前场景中的所有元素,包括 Main Camera(主摄像机)Directional Light(方向光) 和其他对象。

  3. 场景视图(Scene View):
    场景视图展示了开发者视角下的游戏场景,你可以在这里看到并编辑所有的游戏对象。比如,我们可以在这里快速创建一个立方体,并且看到它出现在层级视图中。场景视图也允许你使用 WASD 键或箭头键移动视角,并通过按住右键来旋转视角。

  4. 游戏视图(Game View):
    游戏视图展示了玩家在游戏中看到的内容,实际上是场景视图的运行时版本。在这里,你可以看到游戏的实时渲染效果。比如,如果我们点击 Play(播放) 按钮,游戏视图将显示正在运行的游戏画面。

  5. 检查器视图(Inspector View):
    检查器视图位于右侧,展示了所选游戏对象的详细信息。例如,主摄像机会有一个 Camera 组件,而我们创建的立方体则拥有 Transform 组件Cube 组件Box Collider 组件Mesh Renderer 组件 等信息。每个游戏对象的检查器视图显示了它的各类组件,并允许我们对这些组件进行编辑。

  6. 资产商店(Asset Store):
    在屏幕中央,您可以访问 Unity 的资产商店。这里提供了各种可以免费或付费的游戏资源,包括图形、音效、模型等。如果你需要特定的图形或功能,可以在这里找到并导入到项目中。

  7. 项目视图(Project View):
    项目视图位于屏幕底部,显示了项目中所有的资源和文件。所有的资源(例如脚本、模型、纹理等)都会在项目视图中列出,并且它们在硬盘上的物理存储位置也可以通过右键点击 Reveal in Finder 来查看。

  8. 控制台视图(Console View):
    控制台视图用于显示与代码相关的调试信息。例如,我们可以在这里查看代码的执行状态,查看输出的错误信息或日志。这对于开发和调试是非常重要的。

更多功能

  1. 窗口(Window):
    如果您误关闭了某些窗口,您可以通过 Window 菜单重新打开它们。例如,如果您关闭了 Game 视图,可以通过 Window > Game 来重新打开。

  2. 变换 Gizmo 开关(Transform Gizmo Toggle):
    这些按钮帮助我们调整游戏对象的变换工具,如位置、旋转和缩放的显示方式。通过这些按钮,我们可以选择对象的全局位置或局部位置。

  3. 播放、暂停、逐帧按钮

    • 播放按钮:开始游戏。
    • 暂停按钮:暂停游戏。
    • 逐帧按钮:按下后,游戏将逐帧执行,便于调试。

总结

这些组成部分让我们可以有效地管理和开发游戏项目。但这只是基本界面,我们将会在后续的视频中介绍如何自定义界面,调整它以便更好地适应你的开发需求。

在下一个视频中,我们将继续深入了解如何自定义 Unity 的界面。感谢您的收看,我们下次见!

325 - 创建自己的布局

自定义 Unity 界面布局

在本视频中,我们将学习如何更改 Unity 的界面布局。你可以通过调整布局来根据需要优化你的开发环境。方法很简单,首先在界面的右上角,你会看到一个按钮,默认情况下会显示为 Default。点击这个按钮,你会看到多种布局选项。

可用的布局选项

  1. 2x3 布局
    在这种布局中,场景视图游戏视图 位于左侧,并且是上下排列的。右侧分别显示 层级视图项目视图检查器视图

  2. 默认布局(Default):
    这是我们目前使用的布局,界面中所有视图都是按常规方式分隔的,你可以看到每个面板的位置。

  3. 四分视图布局(Four Split View):
    这个布局将场景分为四个视角,你可以从不同的角度查看场景。比如:

    • 从右侧视角查看。
    • 从顶部视角查看。
    • 从正面视角查看。
    • 从右上角角度查看。
  4. 高视图布局(Tall View):
    这个布局让场景视图占据更大的空间,而 层级视图项目视图 则位于场景视图上方。

  5. 宽视图布局(Wide View):
    这种布局则使场景视图更宽,其他视图(如检查器视图等)则相对较小。

创建和保存自定义布局

你可以根据自己的工作习惯创建并保存自定义的布局。比如,我创建了一个叫做 DPI Layout 的布局,它的结构是:

如果你想尝试自定义布局,可以按照以下步骤操作:

  1. 首先,调整窗口的位置,比如将 层级视图 移动到底部,并把 场景视图游戏视图 放在上下。
  2. 之后,点击右侧的按钮并选择 保存布局,为其命名,例如 “Test”。
  3. 保存后,你可以随时切换到该布局。比如,切换到 高视图布局,然后再切回 Test 布局

修改和删除布局

额外的窗口

你还可以通过 窗口 菜单添加更多的视图。例如,你可能需要一直打开 动画编辑器(Animator),在这种情况下,你可以把它作为一个单独的窗口添加并固定在界面上。同样,如果你希望随时访问 资产商店,也可以把它添加到界面中。

不过请注意,某些窗口可能会占用较多的系统资源,所以在设计布局时需要考虑到性能。

总结

你可以随时创建、保存并调整你自己的布局,确保你的开发环境符合个人习惯。随着你使用 Unity 的深入,你会逐步了解哪种布局最适合自己的开发工作流。

感谢你的收看,我们下个视频再见!

326 - 玩家移动

创建玩家移动脚本

在本视频中,我们将学习如何让一个物体在游戏中移动,并且这个物体会由玩家控制。你将学会如何使用用户输入以及基本的物理学。我们将会深入探讨物理学的内容,但这将在接下来的几期视频中详细讲解。

设置场景对象

  1. 删除现有的立方体
    首先,我们删除现有的立方体对象。接着,我们将创建两个新的 3D 对象:

    • 一个是 球体
    • 另一个是 平面,用作地面。
  2. 添加平面和球体
    层级视图 中右键点击,选择 3D 对象,然后选择 平面(Plane)。你会看到平面的位置为 (0, 0, 0),即 X、Y、Z 坐标都为零。

    你可以通过 变换组件(Transform) 来调整物体的位置。例如,改变 Y 轴的位置来将球体移到平面上方。你可以手动输入数值,或者通过拖动 Y 轴的滑块来调整。

  3. 调整球体位置
    我们把球体的位置调整为 (0, 0.37, 0),确保它不与地面相交。

修改物体颜色

  1. 创建材质并更改颜色
    为了让球体和地面看起来不那么单调,我们可以给它们添加材质。创建一个新的材质,命名为 Ground,然后将颜色设置为绿色。接着,将这个材质拖动到平面上,你会看到平面变成了绿色。

  2. 为球体添加红色材质
    创建另一个材质,命名为 Player,并设置为红色。将这个材质拖到球体上,球体的颜色就会变成红色。

创建玩家移动脚本

  1. 删除旧脚本并创建新脚本
    我们之前创建的 test 脚本不符合要求,所以我们将删除它。接着创建一个新的脚本,命名为 PlayerMovement,用于控制玩家(球体)的移动。

  2. 编写脚本
    在脚本中,我们首先需要创建两个变量:

    • 一个 public float speed 用于控制球体的移动速度,设置为 5。
    • 一个 RigidBody 类型的变量,用来控制球体的物理行为。

    Start 方法中,我们通过 GetComponent<Rigidbody>() 初始化刚体组件。

    接下来,在 Update 方法中,我们获取玩家的输入(即上下左右的按键),并将这些输入转换为水平和垂直方向的值。我们将这两个值组合成一个 Vector3 向量,表示球体的移动方向。

  3. 移动球体
    使用刚体的物理引擎来移动球体,我们将 Vector3 向量与速度相乘来控制球体的速度。每当玩家按下键盘上的移动键时,球体就会根据方向和速度移动。

  4. 添加脚本到球体
    PlayerMovement 脚本拖到球体对象上,或者通过 添加组件 找到并添加脚本。

调整摄像机

  1. 修改摄像机视角
    游戏默认的摄像机角度不太理想,我们可以通过调整摄像机的位置和旋转来获得更好的视角。将摄像机稍微移动,并旋转到一个合适的位置,确保我们能够看到球体的移动。

  2. 调整旋转角度
    我们将摄像机的旋转角度调整为 45 度,确保游戏画面显示得更加合适。

为球体添加物理属性

  1. 添加刚体组件
    在球体上添加 RigidBody 组件,这使得球体成为一个物理物体,具有质量、重力、阻力等属性。你可以在 物理 中找到 RigidBody 选项。

  2. 控制重力
    RigidBody 组件中,确保勾选了 Use Gravity 选项,这样球体会受到重力的影响。如果你取消勾选,球体就不会掉落。

运行游戏

  1. 测试玩家移动
    当一切设置好后,点击运行按钮,游戏开始运行。你会看到,通过按键盘上的方向键,球体可以在场景中移动。

  2. 观察物理效果
    如果球体移动到场景边缘,它会因为重力的作用而掉落到地下。这就是刚体组件和重力起作用的表现。

总结

我们创建了一个简单的玩家控制脚本,通过用户输入来控制球体的移动,同时使用物理引擎使得球体具有真实的物理行为。接下来,我们会进一步探讨物理学和脚本编写,帮助你更好地理解游戏对象的行为并创建自己的游戏。

在后续的课程中,我们会详细讲解如何使用 RigidBody 和其他物理组件,帮助你构建更复杂的游戏机制。

327 - 确保我们正确更改

视频概述

在本视频中,您将学习如何确保在游戏播放模式下进行的更改是安全的。因为在播放模式下所做的任何更改,如果不小心保存下来,可能会对场景产生不可预期的影响。

播放模式下的更改

让我们首先开始游戏并进入场景视图。在游戏视图中,我们可以看到当前对象的状态。比如,我们将一个立方体的缩放调整为每个方向放大5倍,并且旋转它,使得X和Y轴的旋转角度为55度。此时,立方体变得非常大。

当我们停止游戏时,您会看到立方体返回到它的初始状态,这就是播放模式的特点之一。所有在播放模式下所做的更改都会在停止游戏后丢失,恢复为之前的状态。

更改播放模式的提示颜色

Unity提供了一个非常有用的功能来提醒您当前是否处于播放模式。通过在播放模式下改变编辑器界面的颜色,您可以清楚地看到哪些更改仅会影响游戏播放时的状态,而不会影响场景本身。

  1. 进入设置:首先,您需要进入Unity的Preferences(偏好设置)
  2. 设置颜色:在偏好设置中找到Colors(颜色)选项,您可以在这里为播放模式添加提示颜色。可以将颜色设置为红色、绿色等任意颜色,帮助您区分播放模式下的操作。
  3. 选择颜色:例如,您可以将颜色设置为红色,或者选择一个更柔和的绿色,以减少视觉上的侵入感。

颜色效果

当您启动游戏时,编辑器的顶部和层级面板的颜色会发生变化,从而帮助您确认当前是否处于播放模式。这确保了您不会不小心在播放模式下修改不应更改的场景内容。

通过这种方式,您可以避免误操作,比如在播放模式下修改物体的属性,而这些修改在停止游戏时会被丢弃。

小结

设置播放模式提示颜色是一个非常简单但又非常有效的工具,可以帮助您清晰地意识到当前游戏的状态。这样,您就能避免在不该修改的情况下对场景进行更改。

328 - 物理基础

视频概述

在本视频中,您将学习Unity 3D中的物理基础。我们将首先快速回顾几个最重要的概念:刚体(Rigid Body)碰撞器(Collider)触发器(Trigger),并分别详细讲解它们的作用和使用方法。

刚体(Rigid Body)

刚体是一个物理组件,它包含了关于物体质量的信息。通过刚体,您可以使物体受到物理引擎的影响,进而实现与其他物体的交互。刚体的属性包括:

碰撞器(Collider)

碰撞器用于确定物体与其他物体是否发生碰撞。Unity中有多种类型的碰撞器,每种都适用于不同类型的物体:

触发器(Trigger)

触发器是一种特殊的碰撞器,它不会阻挡物体,而是允许物体通过。当您启用一个碰撞器的 Is Trigger 属性时,该碰撞器就会成为一个触发器。触发器的特点是:

小结

通过本视频,您应该对Unity 3D中的物理系统有了基本的了解,特别是刚体、碰撞器和触发器的使用。在接下来的教程中,我们将深入讲解各种碰撞器以及触发器的使用方法。

329 - RigidBody物理体

刚体(Rigid Body)讲解

在本视频中,我们将深入探讨刚体(Rigid Body)。首先,我将保存我的场景,以确保我们在进行任何操作之前都有备份。保存场景非常重要,您应该定期进行保存。

保存场景

  1. 点击“保存场景”按钮,并将其命名为 Main
  2. 之后,每当进行更改时,都应该按 Ctrl + S 或点击 保存场景,以确保不会丢失任何进度。

设置场景

接下来,我将选择我们的玩家角色,因为它已经包含了一个刚体组件。我们会逐一查看刚体的多个属性。

  1. 我首先调整地下平台的大小,将其设置为 5, 5, 5(其中Y值保持为1,因为地面本身没有高度)。这样可以更好地展示物体的行为。

调整场景布局

为了更好地查看游戏效果,我将使用自定义布局,这样就可以同时看到 场景视图游戏视图。当前,2D视图已激活,所以我看不到3D场景。我们可以禁用2D模式,切换到3D视图,方便观察物体的行为。

修改刚体的质量(Mass)

在游戏模式下,我将按下 播放按钮,并开始查看球体的行为。首先,我会打开 层级视图,并选择玩家对象。

接下来,我们来看一下刚体的 质量(Mass) 属性:

速度与质量的关系

为了让球体重新开始移动,我将 速度(Speed) 设置为 200。现在,球体开始缓慢移动,但仍然比较慢。接着,我将质量调整回 1,并将速度设置为 50。此时,球体的移动速度变得非常快,甚至有时会从屏幕上飞出。

添加阻力(Drag)

为了限制球体的最大速度,我将 阻力(Drag) 属性设置为 10。这样即使我继续按住键盘,球体的速度也不会无限增加,而会受到限制。阻力的作用是逐渐减缓物体的速度,直到它达到某个固定的速度。

启用重力(Gravity)

如果我启用 重力(Gravity),球体会受重力影响开始下落。如果禁用重力,球体将悬浮在空中,不会再向下掉落。

启用运动学(Kinematic)

如果将 运动学(Is Kinematic) 设置为启用,球体将不会再受物理引擎的影响,无法移动。这意味着即使我们在玩家控制脚本中添加了施加力的代码,球体也不会响应这些物理操作。这种功能可以用于墙壁等物体上,让它们不会受物理引擎的影响,但仍然起到阻挡作用。

插值(Interpolate)

如果角色动画依赖于物理系统,使用 插值(Interpolate) 可以使物体的动画和物理计算更加平滑,这样角色的动作不会因为物理引擎的计算而出现不协调的情况。

碰撞检测(Collision Detection)

默认情况下,刚体的碰撞检测为 离散(Discrete)。这对于大多数情况来说已经足够了。如果您的物体移动非常快,可能需要使用 连续动态(Continuous Dynamic) 碰撞检测来确保即使物体快速运动,也不会错过碰撞事件。大多数情况下,离散模式已经足够,而且对资源的消耗也较少。

约束(Constraints)

我们可以通过约束来冻结物体的某些位置或旋转。例如,如果我不希望球体在Y轴方向上下落,可以通过冻结 Y轴位置 来实现。这时,即使启用了重力,球体也不会向下掉落。

如果冻结了 X轴位置,球体就不能再向左右移动,只能沿Z轴方向前进或后退。

同样,我们也可以冻结物体的旋转。例如,冻结 X轴旋转,那么球体就只能绕Z轴旋转,不会再绕其他轴旋转。

示例与实验

接下来,我将为玩家添加一个立方体对象。通过调整立方体的尺寸(如设置为1x1x1),可以观察到当立方体与球体交互时,球体的行为发生了变化。

如果将旋转冻结,只允许沿某个方向旋转,我们就可以防止球体过度旋转,保持物体在物理世界中的稳定性。这对于不希望物体做出过多旋转的场景非常有用。

结论

刚体是任何需要受到物理引擎影响的物体必不可少的组件。通过调整刚体的属性,您可以精确控制物体的运动、旋转、重力影响以及其他物理行为。在接下来的教程中,我们将进一步探讨 碰撞器(Colliders) 和它们如何与刚体一起工作,帮助您实现更精确的物理交互。

小贴士

不要忘记经常保存您的场景,以确保您的工作不丢失!

330 - 碰撞体及其不同类型

在本视频中,您将学习 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 时,就像是非常粗糙的地面,物体会被减速。

总结一下,当您想给物体添加碰撞体时,可以选择不同类型的碰撞体。碰撞体的添加会显著改变物体的行为和物理反应。最后,别忘了保存您的场景,期待在下一个视频中,我们将研究触发器。

331 - 触发器

欢迎回来!在本视频中,我们将探讨触发器(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组件,使得它能够有效地撞击并推动这些立方体。

小结

到目前为止,我们已经展示了如何使用触发器来改变游戏对象的行为。通过触发器,你可以让游戏对象在被触发时消失、改变状态,或者触发其他事件。触发器非常适合用来实现如门的开关、物品收集或障碍消除等效果。

在以后的视频中,我们将会更多地使用碰撞器、刚体和触发器,帮助你深入理解这些物理概念,并在实际游戏中使用它们。

希望这些内容对你有所帮助,我们将在下一个视频中继续深入探讨!

332 - 预制件和游戏对象

欢迎回来!在本视频中,我想和大家谈论一个非常重要的概念,这不仅是 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对象。

使用预制体(Prefabs)

如果你希望让某些游戏对象在不同的场景中共享或者想要在程序中重复使用某些对象,那么你可以将它们转换为预制体。例如,我们想把当前的立方体(Cube)作为预制体,这样我们就可以在以后任何时候重复使用它。

方法很简单:将立方体拖放到“Assets”文件夹中,这时它就会变成一个名为“Cube.prefab”的预制体。你可以随时将它拖回场景中,调整位置,并且可以重复使用多个立方体。

例如,现在我删除了场景中的所有立方体,但我可以随时将预制体拖入场景中,并且不必每次都重新创建它。这使得我们可以方便地在不同的场景中使用这些预制体,甚至在程序中动态地实例化它们。

预制体的作用

通常,我们会将很多对象创建成预制体,尤其是那些你希望在多个场景中重复使用的对象。例如,玩家对象就是一个必须作为预制体存在的对象。因为当你切换到不同的关卡时,你可能希望在每个关卡中都能重新生成玩家,并且保持玩家的状态和能力。通过预制体,你可以方便地在不同场景中复用同一个玩家对象。

创建并整理预制体文件夹

现在,我们已经有了这个立方体的预制体,并且我们想将所有的预制体整理到一个文件夹里。我们可以创建一个“Prefabs”文件夹,然后将所有的预制体拖放到该文件夹中。这样,我们的项目就变得更加整洁,也能更好地管理和使用这些预制体。

总结

预制体(Prefabs)是 Unity 游戏开发中非常重要的工具,能够帮助你将游戏对象复用到多个场景中,甚至在程序中动态生成和控制这些对象。因此,理解和正确使用预制体是开发过程中不可忽视的一部分。

通过本视频,你不仅了解了什么是游戏对象,还学会了如何创建和使用预制体。接下来的视频中,我们将讨论组件(Components)这一概念,敬请期待!

333 - 组件和更多关于预制件的内容

在 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)的管理和修改也非常重要,通过理解如何应用更改,你可以保持项目的一致性和可维护性。

334 - 保持层次结构整洁

在 Unity 中,随着游戏中对象的数量不断增加,尤其是在一些游戏中,你可能会遇到这种情况:你的层级视图(Hierarchy)中会有成百上千个不同的对象。例如,当你通过编程创建子弹时,游戏中会有很多子弹。你射出子弹,敌人也会射出子弹,甚至所有人都会射出子弹,到处都是子弹。最终,你的层级视图就会被这些子弹填满,而你将无法看到其他重要的游戏对象了。那么,我们该如何解决这个问题呢?

使用文件夹来管理对象

一个简单的解决方案就是使用文件夹来组织对象。文件夹可以帮助你更好地管理和组织大量的对象,让你更容易找到需要的游戏对象。

创建文件夹

例如,在之前的视频中,我创建了一个游戏对象,现在我将删除它,并重新创建一个新的空游戏对象。我们可以重置该对象的 Transform 组件,使其位于原点(0,0,0)。现在,我们可以将该空游戏对象作为文件夹使用。可以将它重命名为“Bullets”,然后将所有的子弹对象拖放到这个文件夹内。

示例:使用文件夹来管理多实例对象

如果我们有多个相同的对象实例,使用文件夹也能帮助我们保持层级视图的清晰。例如,我们将一个名为 "Cube" 的对象重命名为 "Domino"(多米诺骨牌),然后将所有的多米诺骨牌对象放入 "Dominos" 文件夹。通过这种方式,如果我们复制这个对象,新的实例也会自动被放入文件夹中。

当你创建了文件夹后,你可以像操作普通对象一样操作它。你可以展开和折叠文件夹,在层级视图中隐藏不需要看的部分,只显示文件夹名称,这样可以大大减少混乱。

文件夹的其他应用

文件夹的使用不仅限于相同类型的对象。它们也适用于以下几种情况:

总之,使用文件夹是一种非常有效的管理方法,尤其是在项目变得复杂时。只要在层级视图中看到大量对象时,就可以考虑使用文件夹来提高可读性和可维护性。

总结

通过创建和使用文件夹,你可以将相关的游戏对象组织在一起,使你的层级视图更加整洁,操作更加方便。在本课程中,我们也将频繁使用文件夹来组织我们的游戏对象。所以,不要忘记在项目中合理使用文件夹。

335 - 类结构

在本视频中,我们将来看一下我们新创建的行为脚本,这个脚本就是我们在上一个视频中创建的。如果你自己想创建一个脚本,你可以右键点击你的资源(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 类的一个子类,在这个类中,StartUpdate 方法已经存在,我们无需手动编写它们是何时被调用的,Unity 引擎会自动处理这些。这个特性让我们编程变得更加简单,我们可以在 Start 方法被调用时做某些事情,在 Update 方法被调用时做其他事情。StartUpdate 方法是类的一部分,类通常由变量和方法构成。我们可以在这个类中创建自己的变量,在下一个视频中我们会介绍变量的使用,并且我们还可以创建自己的方法,或者使用 Unity 提供的内置方法,我们不必重新编写它们,我们只需要调用它们,或者我们也可以重写这些方法来定制它们的功能。所有这些内容我们都会在后续的课程中逐步学习。

方法的实际应用

接下来,我们可以简单地在 StartUpdate 方法中分别加入一个 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 中测试脚本

现在让我们保存这个脚本,并回到 Unity 编辑器。如果我们此时运行游戏,我们会发现控制台没有任何输出。这是因为我们还没有将脚本添加到任何游戏对象上。为了测试它,我们需要将 NewBehaviorScript 添加到我们的 Cube 对象中。

我们可以通过以下三种方式之一来添加脚本:

  1. 选择 Cube,然后点击 Add Component,在 Scripts 中找到并添加 NewBehaviorScript
  2. 直接将脚本拖拽到 Cube 上。
  3. 或者,选择 Cube,点击 Add Component,然后在 Scripts 中找到并添加这个脚本。

添加脚本后,再次运行代码,你会看到控制台中的输出发生了变化。控制台会显示 "Start method was called" 只被调用了一次,而 "Update method was called" 已经被调用了很多次。这样,你就可以直观地看到 StartUpdate 方法的执行情况。

注意性能优化

你可以看到 Update 方法会随着每一帧的调用而频繁执行。如果你在 Update 方法中放置了大量复杂的逻辑,它可能会影响游戏的性能。因此,在 Update 方法中尽量避免运行重的操作,保持它轻量化,并把初始化操作放到 Start 方法中,这样可以确保程序运行的效率。

注释和代码结构

另外,代码中的双斜杠 // 表示注释。所有跟在双斜杠后的内容都会被忽略,不会在游戏中执行。注释的作用是帮助我们更好地理解代码,特别是当代码量增加时,注释非常有用,可以帮助我们记住代码的功能。

所有的代码块,如类和方法,都被大括号 {} 包裹。大括号表示代码块的开始和结束,每个语句后面需要有分号 ;,表示语句的结束。

总结

在本视频中,我们介绍了如何创建并使用 Unity 脚本,以及 StartUpdate 方法的工作原理。我们还了解了如何将脚本添加到游戏对象中并查看输出信息。接下来,我们将在下一个视频中深入探讨变量的使用。如果你现在没有完全理解这些内容,不用担心,随着课程的深入,你会逐渐掌握这些概念的。

336 - Mathf和随机类

在本视频中,我想向你介绍一些我们还没有使用过的类,这些类可以帮助我们实现很多功能,而我们无需自己编写这些功能。总的来说,我想向你展示的是,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 类。它是一个包含常见数学功能的结构体类。你可以使用它来进行各种数学运算,尤其是在你需要处理角度、指数、三角函数等时非常方便。

例如,如果你想得到某个数值的绝对值,可以使用 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 类,掌握这些工具后,你会发现编程变得更加轻松。

337 - Unity基础总结

好的,到了这个阶段,你已经掌握了 Unity 的基础知识。现在,你了解了 Unity 的工作原理以及其核心功能,至少是一些基础功能。接下来,你需要做的就是将这些知识应用到实际的项目中。这就是我们接下来几章的内容,因为到目前为止,你学到的所有东西如果不应用到实际的游戏中,那它们就是没有用的。只有在实际开发中,你才会真正理解这些概念的意义,因为你可能已经发现,它们和你之前学到的东西非常不同。游戏开发是非常直观的,且比我们之前的编码学习要少很多,更多的是在用户界面中操作,实际编程的部分相对较少,但这也是很有趣的。我个人很喜欢这种编程与编辑器操作相结合的方式,个人认为这是一个很好的混合。

目标:制作一个 Pong 游戏

接下来我们将制作一个 Pong 游戏,这是一个非常基础的游戏,但与此同时,它将帮助你运用在上一章和本章中学到的功能,并加深你对它们的理解。每增加一个新的游戏元素,你都会学到一些新的东西,这些东西可以在你以后开发自己的游戏时使用。

应用与实践

其实,无论你学习任何编程的部分,都是如此。你学到一个新概念时,刚开始可能会感到有些困难,因为你并不完全理解它。但是,真正的学习是在你开始应用这些知识时发生的。只有通过实践,它们才会变得自然而然,成为你自己掌握的知识。

所以我建议我们继续前进,进入下一个章节,开始制作游戏吧!

WangShuXian6 commented 2 weeks ago

24 - UNITY使用Unity构建Pong游戏

338 - Pong介绍

欢迎来到 Pong 游戏章节!在这一章中,我们将制作一个 Pong 游戏 的克隆版本,这款游戏最早的版本可以追溯到上世纪七十年代,是当时的巨大成功,几乎是我所知道的第一款电子游戏。我们将重新制作这款游戏,基本上复现它的玩法。

游戏内容简介

如你所见,这个游戏包含了玩家 1 和玩家 2,每个玩家都有一个 球拍 和一个 飞来的球。我们将在游戏中使用音效,并且设有 计分系统,每次球被击打后,球的速度都会加快,这使得游戏变得越来越有挑战性,因为球拍的速度不会变化,玩家需要更加迅速地反应。

这是我们在本章中最终要实现的目标,你可以看到:

本章学习内容

我们将使用多个场景来实现游戏的不同部分:

  1. 主页面:玩家可以从这里开始游戏,点击播放按钮进入游戏。
  2. 游戏结束页面:当游戏结束时,会显示一个简单的“游戏结束”的文本,告诉玩家游戏已结束。

在这个过程中,你将学到很多不同的技能:

开始动手

这章的内容会非常有趣,涵盖了很多不同的技术。我们将一步步走过这个过程,最终完成一个完整的 Pong 游戏。顺便提一下,本章还配有文本材料,你可以在学习过程中通过阅读这些材料来帮助理解。

好啦,准备好了吗?让我们进入下一个视频,开始制作 Pong 游戏吧!

339 - 基础UI元素

欢迎回来。在本视频中,我们将看看如何使用 UI 元素,特别是 文本。我创建了一个新的项目,叫做 Pong Basics。在这里,我想要创建一个 UI 元素。首先,在 层级视图 中右键点击,选择 UI,你会看到多个选项,包括文本、图像、原始图像、按钮等等。现在我们先使用 文本。正如你所见,新的文本已经出现在屏幕的左下角。如果我们缩小视图到合适的角度,就能看到文本的位置。

Canvas 渲染模式

这是我们当前的 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 下载,或者从你电脑上导入任何字体文件。下载字体时要小心,确保你拥有该字体的使用权,因为一些字体是收费的。你可以搜索免费的或免版权的字体来使用。

小结

以上就是关于 文本 元素的基本操作。接下来,在下一个视频中,我们将展示如何通过代码来动态更改文本内容。

340 - 基础通过代码访问文本

欢迎回来。在本视频中,我们将展示如何通过编程更改文本元素的值。首先,让我们创建一个空对象,并命名为 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 中测试

回到 Unity 编辑器,我们的 GameManager 对象现在需要绑定 Text 元素。你只需要将 Canvas 中的文本对象拖动到 TextBehavior 脚本的 myText 字段中。

接着,运行游戏并按下 Space 键,文本内容应该会随着每次按下 Space 键而增加。你还可以尝试按下 S 键,查看文本是否正确更新为 "S was pressed"。

调整文本框大小

如果你发现文本框不能完全显示文本,可以调整文本框的宽度和高度。例如,你可以将文本框的宽度调整为 300,这样可以显示更多的文本内容。

保存场景

在进行这些更改时,记得保存你的场景。可以选择文件菜单中的 保存场景,并给场景命名为 Main 或其他你喜欢的名字。

结语

通过这些步骤,你现在已经学会了如何在 Unity 中操作文本元素并通过编程动态更改其内容。在接下来的 Pong 游戏 中,我们将继续使用文本元素,并且在大多数游戏中,UI 元素(如文本、按钮等)都是必不可少的。

341 - 基础按钮

欢迎回来。在本视频中,我们将了解按钮的使用。按钮非常重要,因为它们允许我们在场景之间切换,例如可以用来选择游戏中的关卡、设置菜单等。按钮在 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 事件中。

  1. ButtonBehavior 脚本添加到按钮(或其他游戏对象)。
  2. 选择按钮,在 On Click 事件列表中点击 +,然后拖动按钮对象到该列表中。
  3. 选择 ButtonBehavior 脚本,并在下拉菜单中选择 OnButtonPressed 方法。

这样,每次按下按钮时,OnButtonPressed 方法就会被触发,从而更新文本。

测试按钮功能

现在,我们可以运行游戏并测试按钮功能。点击按钮时,文本应更新为 “Button was pressed”。

如果你按下其他按键,比如 SpaceS,文本将相应地更新为不同的内容(例如 “S was pressed” 或 “Button was pressed”)。

结语

通过这些步骤,你学会了如何在 Unity 中使用按钮及其交互功能。下一节视频我们将介绍如何使用按钮来切换不同的场景。

342 - 基础切换场景

欢迎回来。在本视频中,我将向你展示如何从一个场景跳转到另一个场景。首先,我们来创建一个新的场景。我们可以通过多种方式来创建场景,例如创建一个新场景,或者复制我们的主场景。我们这次直接创建一个新的场景,命名为 Level One

创建新场景

  1. 创建一个新场景,命名为 Level One
  2. 然后我们需要保存当前的场景,并进入 Level One 场景。
  3. Level One 场景中,添加一些 UI 元素。首先添加一个 Text,显示 "Level Two" 并将其居中。
  4. 接着,添加一个 Button,按钮的文本显示 "Move to Main Scene"(返回主场景)。

场景之间的跳转

现在,我们希望在 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 的场景。

场景添加到构建设置

当你尝试从一个场景加载另一个场景时,可能会遇到一个错误,提示场景没有添加到构建设置中。解决这个问题的方法是:

  1. 打开 File > Build Settings
  2. Scenes In Build 中添加 Main SceneLevel One 场景。

记住每个场景的索引位置,比如 Main Scene 是 0,Level One 是 1。

测试场景跳转

  1. 现在保存所有修改,并运行游戏。
  2. Main Scene 中,点击按钮后应该会跳转到 Level One 场景。
  3. Level One 中,点击按钮后,应该会跳转回主场景。

按钮跳转功能的挑战

现在你已经能从主场景跳转到 Level One 场景,但如果你希望从 Level One 场景返回主场景,你需要在 Level One 中也添加一个按钮,并为其编写相应的跳转功能。

你可以通过以下步骤来完成:

  1. Level One 场景中添加一个按钮,文本为 "Move to Main Scene"(返回主场景)。
  2. 创建一个新的方法来处理场景的跳转,方法名为 MoveToScene,接受一个整数作为场景的索引,并加载指定的场景。
using UnityEngine.SceneManagement;

public class ButtonBehavior : MonoBehaviour {
    public void MoveToScene(int sceneID) {
        SceneManager.LoadScene(sceneID);
    }
}
  1. 现在将这个 ButtonBehavior 脚本绑定到 Level One 的按钮上,并设置按钮的 On Click 事件。

完成场景跳转

  1. Main Scene 中,设置按钮的 On Click 事件为 MoveToScene,并传入场景索引 1(即 Level One)。
  2. Level One 中,设置按钮的 On Click 事件为 MoveToScene,并传入场景索引 0(即 Main Scene)。
  3. 测试跳转功能,确保你可以在不同的场景之间自由切换。

动态场景跳转

除了通过按钮触发场景跳转外,你还可以在游戏逻辑中根据需要动态加载场景。例如,当玩家死亡时,自动跳转到 Game Over 场景,或当玩家完成某个关卡后跳转到下一个关卡。你可以在任何需要的地方调用 SceneManager.LoadScene 来实现这一功能。

总结

在本视频中,你学会了如何使用按钮和方法在不同的场景之间跳转。你还学到了如何通过 SceneManager 动态加载场景,并根据按钮点击来触发场景切换。这种功能非常适用于游戏中的场景切换,如从主菜单进入游戏、从一个关卡进入下一个关卡等。

希望你能将这些知识应用到你的项目中,祝你编程愉快!

343 - 基础播放声音

欢迎回来。在本视频中,我们将讨论如何在游戏中使用声音及其功能。你可以通过 Google 搜索“免版税游戏音效”,找到许多提供免费声音资源的网站,比如 1000 多个免费的音效、音乐曲目和游戏循环音效等资源。

免费声音资源

例如,你可以访问一些网站,如 Free Game Music,它提供了非常适合游戏的背景音乐。你还可以尝试 Free Sound,这是一个由 Carsten Frosch 创建的网站,提供了多种音效资源,如太空音效、神秘的声音等。如果你需要一个光束的声音,只需要在这些网站中下载相应的音效即可。

Unity 中使用声音

我们来看看如何将声音导入到 Unity 并在游戏中播放。首先,你可以通过 Unity 的资源商店下载免费的音乐包。例如,可以下载由 Vertex Studio 提供的 Absolutely Free Music 资源包。你可以选择下载整个包,或者只导入你需要的音乐文件。

导入音效

下载并解压资源包后,你会看到一个包含音频文件的文件夹。在 Unity 中,你可以将该音频文件拖拽到你的场景中。接下来,我们要将声音添加到一个对象上,比如一个立方体(Cube):

  1. 选择立方体对象。
  2. 在 Inspector 窗口中,点击 Add Component,然后选择 Audio Source 组件。
  3. Audio Source 组件中,拖入刚才导入的音频文件。

控制音量和播放设置

现在,音效已经被添加到立方体上。你可以调整以下属性:

通过代码控制声音

你还可以通过代码来控制声音的播放。让我们创建一个新的脚本,命名为 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 类的其他方法和属性。

344 - Pong项目大纲

欢迎来到本项目大纲和本视频。在这一部分,我将展示本章的最终结果,并告诉你如何构建这个项目。我会快速介绍一下,不想深入细节,因为你现在应该已经学到了大部分所需的知识。如果有些内容缺失,你可以参考文档,或者如果卡住了,可以观看接下来的视频,我会逐步讲解。如果你不想自己尝试构建游戏,也可以跟随视频一起做。

游戏场景介绍

首先,让我们快速看一下我们正在构建的游戏。在我们的项目中,有多个不同的场景:游戏场景、游戏结束场景和主菜单。我们现在所打开的场景是 游戏场景,它包含了以下几个部分:

玩法设计

  1. 得分机制:当球触碰到右侧的边墙时,玩家 1 得分;如果触碰到左侧边墙,则玩家 2 得分。
  2. 球速增加:每当球击中球拍时,球速都会增加,直到球的速度变得无法击打。设置了一个最大速度,球的速度不会超过这个最大值,但依然会非常快。
  3. 球拍操作:球拍的速度是固定的,不会增加。玩家可以通过击球的方式来改变球的反弹方向,例如击打球拍的顶部会将球弹向上,击打底部则会将球弹向下,从而迷惑对手。

游戏实现细节

我创建了一个 Canvas 画布,里面有两个得分标签:一个是玩家 1 的得分,另一个是玩家 2 的得分。你需要编写脚本来更新得分,根据球的运动轨迹来改变得分。

游戏过程演示

玩家与 AI

主菜单

游戏的主菜单包括一个标题 "Pong",以及一个“Play”按钮,点击后会进入游戏场景。

球的控制方式

建议与总结

我强烈建议你尝试自己动手构建这个游戏,因为通过实际构建,你能学到更多的知识。即使你只是跟随视频教程学习,也会有所收获。如果你发现视频中没有解释某些功能,或者在文档中找不到你需要的信息,请告诉我,我会尽量补充。但是如果你能自己解决问题,会更有助于你理解如何寻找解决方案,这对你未来构建自己的项目非常有帮助。

继续尝试构建游戏,或者跟着视频一起学习。希望在下一个章节见到你!

345 - 创建主菜单

创建 Pong 游戏主菜单

在本视频中,我们将开始为我们的游戏创建主菜单。首先,我们需要创建一个新项目。打开 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 元素

接下来,我们将为游戏添加一些 UI 元素。首先,我们将添加一个 Text,作为游戏的标题。在左侧的层级视图中,右击 Main Menu 并选择 UI > Text,这时 Unity 会自动为我们创建一个 Canvas 和一个事件系统。UI 元素会被放置在 Canvas 上,这意味着 Canvas 与游戏中的对象无关,它只负责显示 UI 元素。

我们将 Canvas 的名称更改为 Main Menu,然后在 Text 组件中,将文本颜色更改为白色。

调整 Canvas 和 Text

可能你会发现,Canvas 的大小相对于游戏视图来说非常小。这是因为游戏视图的大小被调整得很大。通常情况下,Canvas 会比游戏视图大很多。如果我们希望 Canvas 与背景和摄像头的大小一致,可以在 Canvas 组件中将 Render Mode 设置为 Screen Space - Camera,然后将主摄像头拖入 Camera 属性中。接下来,调整 Canvas 的 Scale 设置为 Scale With Screen,并将参考分辨率设置为 1600x900。这样,无论屏幕尺寸如何变化,UI 都会根据屏幕大小进行扩展。

修改文本位置与字体

由于文本太小且位置不合适,我们将调整其大小。将 Text 的宽度设置为 500,高度设置为 200,字体大小设置为 150。接着,为了让文本居中显示,我们将其 XY 坐标都设置为 0,这样它就会出现在屏幕的正中心。如果需要将其向上移动,可以调整 Y 坐标为 300。

添加“Play”按钮

接下来,我们将添加一个 Play 按钮。在 Canvas 下,右击并选择 UI > Button,这时会自动创建一个按钮。将按钮的宽度设置为 200,高度设置为 100。然后,我们将按钮的颜色改为黑色,并将其文本颜色设置为白色。为了使按钮文本更加清晰,我们还将按钮的 Font Size 设置为 120。

调整按钮位置

为了使按钮能够正常显示,我们需要调整它的尺寸。将按钮的高度设置为 130,这时你会看到按钮的文本溢出了。为了避免溢出,可以将按钮的高度调整为 300,然后将其位置设置为 Y = -200,使按钮位于屏幕底部。

改善文本和按钮的字体

Unity 默认的字体非常普通,并不适合游戏风格。我们可以在网上寻找一些有趣的游戏字体。我找到了一款叫 Monroe 的字体,它非常适合我们的游戏。下载并导入字体文件后,我们可以将其应用到标题文本和按钮文本中,立即看到效果更为精致。

总结

到此为止,我们已经完成了主菜单的创建,包含了游戏标题和一个播放按钮。下一步,我们将添加播放按钮的功能,使玩家能够点击按钮进入游戏。在下一视频中,我们将通过编写脚本实现这一功能。

记得保存你的场景,这样你的更改才会生效。保存好场景后,我们就可以进入下一个视频了。

346 - 切换场景和使用按钮

欢迎回来。在这段视频中,我们将创建游戏场景的第一部分,并通过使用播放按钮从当前场景切换到下一个场景。为了使播放按钮能够执行功能,我们需要给它添加一个脚本,否则它不会有任何作用。所以,我们需要创建一个新的脚本。我右键点击,创建一个新的脚本,并将其命名为 "PlayButton"(播放按钮),因为在本视频中我将脚本保持简单,因为最终创建多个只完成一件事的脚本会更容易管理,每个脚本只需要专注于完成它必须执行的一个重要任务,像这个播放按钮脚本也是如此。所以,这个脚本将只负责这个按钮,唯一的任务就是将我们从当前场景带到下一个场景。

创建播放按钮的基本功能

首先,我们需要为这个播放按钮赋予功能。我们如何做到这一点呢?我们将创建一个新的方法,这个方法是 public void,我将使用 PlayGame 作为方法名称。方法的名称首字母大写,因为在 C# 中方法名称通常是以大写字母开头的。接下来,我将使用 Debug 功能来进行简单的调试输出,即在控制台中打印 "Play Game was pressed"(播放按钮被按下)。目前,代码就这么简单,唯一的作用是创建一个调试日志。

public void PlayGame()
{
    Debug.Log("Play Game was pressed");
}

这段代码的作用是,当按下播放按钮时,它会在控制台输出一条消息。接下来,保存这个脚本(按下 Ctrl + SCmd + S),然后回到 Unity,选择我们的播放按钮。在主菜单中,你可以看到播放按钮右侧的按钮脚本组件,里面有一个 OnClick 区域,目前这个列表是空的。我们需要往这个列表中添加内容,点击加号按钮。在这里,我们可以选择一个对象,现在可以选择场景中的对象或者资产中的对象。最佳的做法是将刚刚创建的脚本拖入按钮中。

现在你可以看到,播放按钮脚本已经添加到按钮中了。接着,我们选择播放按钮本身作为 OnClick 的响应对象。在下拉菜单中选择我们刚才创建的 PlayGame 方法,如此一来,每当按钮被点击时,就会触发我们的方法。现在,保存并测试游戏,点击播放按钮,你可以在控制台中看到 "Play Game was pressed",这就表示按钮正常工作了。

从一个场景切换到另一个场景

现在我们知道按钮能够正常工作了,接下来我们要做的是让这个按钮实现从当前场景切换到下一个场景的功能。首先,我们需要创建一个新的场景。我们可以右键点击资产区,选择 "Create" -> "Scene",并命名为 "GameScene"(游戏场景),这是我们后续将实现游戏功能的场景。

进入新创建的游戏场景,你会看到这个场景是空的,没有任何东西。在这里,我们需要设置一个与主菜单场景相同的主相机和背景。你可以猜到,如何将这些设置从主菜单场景复制到游戏场景中,我来给你演示。

使用预制体(Prefab)共享主相机

回到主菜单场景,选中主相机,将其拖动到资产区,从而创建一个预制体。你会看到在资产区中出现了一个新的主相机预制体。接下来,切换到游戏场景,删除原有的主相机(此时场景中的摄像头已经消失)。然后将刚才的主相机预制体拖到游戏场景中,你会发现它继承了主菜单场景中的所有设置,例如相机的大小(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" 列表中。完成后,重新运行游戏,你就能顺利切换场景了。

整理资产文件夹

在项目中,我们应该保持资产的整洁。尽管目前只有几个文件,但随着游戏开发的进展,组织结构会变得非常重要。我们可以创建多个文件夹来管理不同的资产,例如:

对于场景,你可以选择将所有场景文件保存在 "Assets" 文件夹的根目录下,或者你也可以创建一个专门的场景文件夹来存放这些场景文件。

总结

现在,我们已经学会了如何创建和使用按钮,如何从一个场景跳转到另一个场景,以及如何组织和管理我们的游戏资产。下一步,在下一段视频中,我们将开始填充游戏场景,并进行一些整理工作。

347 - 构建我们的游戏场景

游戏场景设置

现在我们已经有了主菜单,接下来我们将进入游戏场景。在这个场景中,我们需要添加一些元素:墙壁、两个球拍、玩家一和玩家二的名字、分数以及非常重要的中间的球。让我们开始吧。

创建墙壁

首先,我们需要为游戏创建墙壁。我们将使用立方体来做这些墙壁。首先创建一个新的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)。

创建UI HUD(显示玩家信息)

我们接下来将创建HUD(头部显示区域),显示玩家的名字和分数。首先,创建一个UI文本,命名为“玩家一标签”,并将其拖拽到一个新的空游戏对象下,这样玩家一的名字和分数就会集中在同一个游戏对象中,方便管理。

我们还需要调整Canvas的设置。选择“画布”对象,在“画布”设置中,将其模式设置为“屏幕空间-摄像机”,并指定主摄像机。然后将画布缩放模式设置为“按屏幕缩放”,以便在不同分辨率下,UI能够自适应。

调整玩家一标签的位置,将其放置到左上角,位置为X轴125,Y轴54。接着调整“玩家一”的Canvas,设置X轴为-350,Y轴为300,宽度为700,高度为200。

设置字体和文本样式

接下来,调整字体。我们使用Monroe字体,并将字体颜色改为白色。调整字体大小,确保它能够清晰可见。然后,修改玩家一的分数标签。复制“玩家一标签”,并将其命名为“玩家一分数”。将分数标签的位置调整到右下角,设置X轴为-100,Y轴为50。将文本内容设置为0,并居中对齐。

设置玩家二的UI

为了为玩家二设置UI,复制玩家一的UI元素。改变玩家二的标签为“玩家二标签”和“玩家二分数”。调整玩家二标签的位置,将其放置到右上角,分数标签放置到左下角。记得调整相应的位置和对齐方式。

完成游戏场景

现在,我们已经完成了游戏场景的布局。我们有了墙壁、球拍、球和中间的虚线分隔。HUD显示了玩家的名字和分数。

接下来,我们将实现这些元素的功能,在下一个视频中将编写相关的代码来使游戏场景开始工作。此时,您可以试着重新构建这个场景,根据自己的喜好调整值、颜色和字体,享受创建游戏的过程。

348 - 2D与3D碰撞体和刚体

在这段视频中,我们将处理我们的球。到目前为止,我们的球只是场景中间的一个小方块,我们需要为它添加功能,使它能够左右飞行,来回反弹,每当它碰到球拍时都会反弹。当然,我们还希望它能够反弹到顶部和底部的墙壁。现在我们暂时不让它与侧墙发生碰撞,但我们将为此添加一些功能,以便进行测试。接下来我们开始修改场景中的球。

首先,我们的球需要有一个碰撞器(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。我们将逐一修改:

  1. 顶部墙壁:移除现有的Box Collider,添加Box Collider 2D。
  2. 底部墙壁:同样,移除3D碰撞器,添加2D碰撞器。
  3. 左右墙壁:同样的操作,确保它们也有2D碰撞器。

修改球拍的碰撞器

同样地,我们还需要修改球拍的碰撞器,将它们的碰撞器从Box Collider(3D)改为Box Collider 2D。然后,我们将偏移量(Offset)设置为零,并将大小(Size)设置为1x1

测试球拍的反弹

现在,球应该能反弹到球拍上了。为了让球从左到右飞行,我们还需要为球添加一个脚本。目前,球只是受重力影响,并没有其他运动。我们将在下一段视频中为球添加脚本,使它能够从左到右飞行。

349 - 左右移动我们的球

欢迎回来。在本视频中,我们将为我们的球添加功能,使其能够左右移动并与球拍碰撞,这是我们预期的功能。接下来,我们当然还需要让我们的球拍也能移动,这是我们将在后续视频中做的事情。完成这些后,游戏就差不多完成了。我们现在开始为球创建一个新的脚本,命名为“BallMovement”。接着,我们可以创建并在 Visual Studio 中打开这个脚本。

创建和设置脚本

在 Visual Studio 中,我们将看到 StartUpdate 方法。由于我们不需要 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 中测试

现在我们已经完成了球的基础移动逻辑。接下来,在 Unity 中测试时,我们需要调整球的质量和重力比例。为了让球变得非常轻,我们将质量设置为 0.01,并且将重力比例(Gravity Scale)设置为 0,因为球不需要受到重力影响。

Rigidbody2D rb = this.gameObject.GetComponent<Rigidbody2D>();
rb.mass = 0.01f;  // 设置质量
rb.gravityScale = 0f;  // 去除重力影响

设置参数

在 Unity 中,我们将为 movementSpeedextraSpeedPerHitmaxExtraSpeed 设置初始值。我们可以将 movementSpeed 设置为 400,extraSpeedPerHit 设置为 50,maxExtraSpeed 设置为 1000。这些值是在测试后得出的合理数值。

movementSpeed = 400f;
extraSpeedPerHit = 50f;
maxExtraSpeed = 1000f;

测试游戏

当我们点击播放按钮时,球将等待 2 秒后开始向左移动(因为是玩家1开始),然后来回反弹。在开始时,球的速度应该是恒定的,不会变化,直到我们碰撞球拍时,球的速度才会增加。

增加碰撞计数

为了让球在每次碰撞后加速,我们需要创建一个方法 IncreaseHitCounter,它会增加碰撞计数器的值。每次碰撞后,我们会调用此方法。

public void IncreaseHitCounter()
{
    if (hitCounter * extraSpeedPerHit < maxExtraSpeed)
    {
        hitCounter++;  // 增加碰撞计数器
    }
}

这个方法将被球拍脚本调用,以便在每次碰撞时增加球的速度。我们将在后续的视频中详细讲解如何实现球拍的脚本。

结束语

到目前为止,我们已经完成了球的基础移动逻辑,并通过调整碰撞计数来增加游戏的难度。我们还在 Unity 中进行了一些测试,并调整了球的运动参数。下一步,我们将继续完善球拍的控制逻辑,确保游戏可以正常进行。

感谢收看,我们在下一个视频中继续!

350 - 球拍移动

欢迎回来。在这个视频中,我们将处理我们的球拍。到目前为止,球拍无法移动,这是我们要在本视频中解决的问题。为此,我们需要两个脚本,一个用于玩家一的球拍,另一个用于玩家二的球拍。我们先添加一个新组件,创建一个新的脚本,命名为 RecordPlayerOne。至于 RecordPlayerTwo 脚本,您可以自己尝试编写。接下来,我们先处理 RecordPlayerOne 脚本。

记录玩家一的脚本

RecordPlayerOne 脚本中,我们需要做的非常简单,主要就是一件事:创建一个 FixedUpdate 方法。FixedUpdate 是 Unity 中的一个内部方法,它会以固定的频率(通常为每秒 60 次)被调用。我们需要在这个方法中检查用户是否按下了某个按钮,如果用户按下了特定按钮,那么球拍就应该移动,并且应该按照特定的速度移动。

我们需要一个 public float 类型的 movementSpeed 变量。一个合理的值可能是 200,意味着球拍的移动速度是 200。接下来我们需要创建 FixedUpdate 方法。这个方法会被 Unity 自动调用,并且会不断检查是否有按钮按下。

获取输入

在方法内部,我们需要检查用户是否按下了控制球拍移动的按钮,并把结果保存为一个浮动的 v 值。我们可以通过 Unity 的 Input.GetAxis 方法来获取用户输入。在这里,我们使用的是 "Vertical" 轴,这个轴会根据玩家按下的按钮返回一个值:

这样,我们就能根据输入的值来控制球拍的移动。

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" 键用于垂直方向的控制。如果要为玩家二创建类似的设置,我们需要做一些调整。可以按照以下步骤操作:

  1. 创建一个新的输入轴,命名为 Vertical2
  2. 修改它的负值和正值分别对应 "DownArrow""UpArrow",这样就能使玩家二使用上下箭头键来控制球拍的移动。

完成这些步骤后,Vertical 轴就对应玩家一,Vertical2 轴就对应玩家二。

物理设置

为了让球拍不受重力的影响,我们需要为球拍添加 Rigidbody2D 组件,并设置重力值为 0。同时,为了避免球拍因物理引擎的影响发生旋转,我们需要冻结球拍在 Z 轴上的旋转。

球拍移动的调试

在 Unity 中,我们需要做一些调试来确保球拍能按预期移动。首先,我们为 RecordPlayerOne 脚本设置一个合理的 movementSpeed,比如 200。然后检查球拍是否能够根据用户的输入(使用 "W""S" 键)在屏幕上移动。

处理玩家二的球拍

现在,我们需要为玩家二添加 RecordPlayerTwo 脚本。你可以参考 RecordPlayerOne 脚本,只需要调整 Input.GetAxis("Vertical2") 来控制玩家二的球拍移动。其余代码和设置基本相同。

确保玩家二的球拍也有 Rigidbody2D 组件,并且同样禁用重力,冻结 Z 轴旋转。然后,设置 movementSpeed 使得玩家二的球拍移动速度合理。

调整难度

为了让游戏的难度不平衡,你可以调整玩家一和玩家二的球拍速度。例如,给玩家二的球拍一个更高的移动速度,这样玩家二就会更容易击中球。反之,你也可以降低玩家一的球拍速度,以增加游戏的挑战性。

清理工作

一旦完成了所有设置,我们需要做一些清理工作。将 RecordPlayerOneRecordPlayerTwo 脚本拖入 Scripts 文件夹,并保存场景。这样,我们的球拍就可以根据玩家的输入进行移动了。

下一步

在下一个视频中,我们将进一步优化球拍的物理效果。目前,球只有在与球拍碰撞时沿水平方向(X 轴)移动,而我们希望在碰撞的不同位置(球拍的顶部或底部)产生不同的反弹效果。这样可以让 Pong 游戏更具挑战性和趣味性。

351 - 正确反弹

欢迎回来。在本视频中,我们将处理球的反弹逻辑,使得游戏更加互动并且有趣。你可以看到,球从左侧飞来并撞击到球拍时,如果它撞到球拍的底部,球应该朝下反弹。所以 Y 值应该是负数。如果它撞到球拍的顶部,球应该朝上反弹,所以 Y 值应该是正数。为了实现这一点,我们需要添加一个脚本,这个脚本将被添加到球体上。我们将创建一个新的脚本,命名为 Collision Controller,它将控制我们的碰撞检测,检查碰撞的位置以及碰撞的对象等。我们不仅需要检测球拍的碰撞,还要检测上下墙壁的碰撞。

步骤 1:创建 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 的分数
        }
    }
}

步骤 2:设置球的碰撞逻辑

在上面的代码中,我们做了以下几件事:

  1. 计算反弹方向: 我们首先获取球和球拍的位置,并计算球与球拍顶部或底部的碰撞点。这是通过 ballPosition.y - racketPosition.y 计算的。然后,计算结果除以球拍的高度(racketHeight),以得到 Y 轴的反弹强度。

  2. 判断玩家: 根据碰撞的是玩家 1 还是玩家 2,分别设置 X 轴的反弹方向。如果是玩家 1,X 方向是正值(从左往右);如果是玩家 2,X 方向是负值(从右往左)。

  3. 增加击球计数: 调用 ballMovement.IncreaseHitCounter() 方法,每次击球时增加计数,从而加速球的速度。

  4. 处理碰撞:OnCollisionEnter2D 中,我们根据碰撞对象的名称判断是与球拍还是墙壁碰撞。如果是墙壁,我们会进行相应的分数处理。

步骤 3:调整球的移动

接下来,我们需要更新 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);  // 增加速度
    }
}

步骤 4:设置 Unity 中的物理和输入

  1. 给球体添加刚体: 为了使球能够根据物理引擎反弹,我们需要给球体添加一个 Rigidbody2D 组件。确保 Gravity Scale 设置为 0,防止重力影响球的运动。

  2. 连接脚本: 在 Unity 中,确保 CollisionController 脚本已经正确连接到球体,并且将 BallMovement 脚本作为公共变量关联到 CollisionController 脚本中。

步骤 5:测试

在 Unity 中运行游戏,球应该能够根据球拍的顶部或底部位置反弹。如果球碰到球拍的顶部,应该朝上反弹;如果碰到底部,应该朝下反弹。每次击球后,球的速度会逐渐加快,这样游戏会变得更加具有挑战性。

总结

到目前为止,我们已经实现了球的反弹逻辑,确保球会根据它与球拍的碰撞位置反弹。接下来,我们将继续完善分数系统,并让球重新回到场地中央。感谢观看,下一集我们将开始实现这些功能。

352 - 计分系统

欢迎回来。在本视频中,我们将添加一个得分系统。我们需要做的是创建一个新的脚本,名为 ScoreController。让我们开始吧,首先我们需要将这个脚本添加到场地对象中,因为场地将充当我们的得分控制器(ScoreController)游戏对象。那么我们来创建一个新的脚本,命名为 ScoreController,然后编辑它。

步骤 1:创建 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!");
            // 在这里我们通常会切换到下一个场景
        }
    }
}

步骤 2:修改 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得分
        }
    }
}

步骤 3:在 Unity 中设置

  1. 连接 ScoreController 脚本: 确保将 ScoreController 脚本添加到场地游戏对象上,并将对应的 UI 文本对象(例如显示得分的文本)拖放到 scoreTextPlayer1scoreTextPlayer2 变量中。

  2. 设置获胜目标:ScoreController 脚本的 goalsToWin 变量中设置获胜所需的得分,比如设定为 5 分。

  3. 连接 CollisionController 脚本:ScoreController 脚本引用到 CollisionController 中,并确保正确连接。

步骤 4:测试游戏

  1. 运行游戏并测试得分系统。当球碰到左右墙壁时,玩家的得分应该会增加,并且 UI 上的得分文本会实时更新。

  2. 当其中一位玩家的得分达到设定的获胜分数时,控制台会打印出该玩家获胜的消息。

步骤 5:冻结旋转

为了防止球体旋转,我们可以冻结球的 Z 轴旋转。在 Unity 编辑器中,选择球体并勾选 Rigidbody2D 组件中的 Z 轴冻结旋转选项。

步骤 6:重置游戏

目前的代码只会记录得分并显示获胜者的消息。接下来,我们需要添加一个功能,用于在游戏结束时重置游戏状态,重新开始一个新的回合。这个功能将在下一个视频中实现,我们还将添加一个“游戏结束”屏幕。

总结

到目前为止,我们已经实现了简单的得分系统,每当球击中左右墙壁时,玩家的得分就会增加。UI 也会实时更新,并且当某个玩家获得足够的分数时,游戏会显示获胜消息。接下来,我们将继续完善游戏,添加游戏结束画面并重置游戏。感谢观看,我们下次见!

353 - 重新开始一轮

欢迎回来。在本视频中,我们将创建重置功能。也就是说,每当有玩家得分时,球将根据当前玩家的回合被重置到特定位置。如果是玩家 1 的回合,球将在左侧;如果是玩家 2 的回合,球将在右侧。除此之外,我们还将添加游戏结束界面,在达到某个得分时,玩家将进入游戏结束画面。好,现在让我们开始吧!

步骤 1:创建重置球的位置功能

首先,我们需要在 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);  // 球位置在右侧
        }
    }
}

步骤 2:在碰撞控制器中调用重置功能

接下来,我们要在 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得分后,重置球在右侧
        }
    }
}

步骤 3:测试重置功能

  1. 运行游戏,测试得分系统。每当一个玩家得分,球都会重置到对方的起始位置。
  2. 例如,如果玩家 1 得分,球会重置到左侧;如果玩家 2 得分,球会重置到右侧。

步骤 4:添加游戏结束画面

我们已经成功地实现了得分和球重置的功能,接下来我们需要添加游戏结束画面。我们将在下一个视频中实现这个功能,同时还将添加音效来增强游戏体验。

总结

本视频中,我们做了以下几项工作:

在下一个视频中,我们将实现游戏结束界面并为游戏添加音效。感谢观看,我们下次见!

354 - 游戏结束屏幕

欢迎回来。在本视频中,我们将为游戏添加游戏结束场景。首先,我们将复制并修改主菜单场景,改成游戏结束场景。接着,我们需要实现从游戏场景到游戏结束场景的跳转。最后,我们还会讨论如何处理胜者的显示,尽管这部分我们将在后续的项目中进一步探讨。

步骤 1:复制主菜单并创建游戏结束场景

首先,在 Unity 编辑器中,我们复制主菜单场景。可以使用 Mac 上的 Command + D 或 Windows 上的 Ctrl + D 来复制场景。

  1. 将复制的场景命名为 GameOver
  2. 打开 GameOver 场景。
  3. 将场景中的 Pong 文本修改为 Game Over
  4. 修改文本的大小,将宽度调整为 700,并使文本居中。
  5. Play 按钮文本修改为 Replay

步骤 2:修改按钮的功能

接下来,查看按钮的功能。当前的按钮使用的是 PlayGame 脚本,我们需要检查这个脚本。这个脚本的功能是加载下一个场景:

public class PlayButton : MonoBehaviour
{
    public void PlayGame()
    {
        SceneManager.LoadScene("Game");  // 加载游戏场景
    }
}

由于我们想在游戏结束时跳转到 GameOver 场景,接下来要调整现有的脚本或在需要的时候使用它。

步骤 3:在得分时跳转到游戏结束场景

现在,我们需要从游戏场景跳转到游戏结束场景。打开 ScoreController 脚本,找到显示 "Game Won"Debug.Log,并在此时实现场景的跳转。

  1. 导入 SceneManagement 命名空间:
using UnityEngine.SceneManagement;
  1. Game Won 的地方添加代码跳转到 GameOver 场景:
if (this.scorePlayer1 > this.goalsToWin || this.scorePlayer2 > this.goalsToWin)
{
    Debug.Log("Game Won");
    SceneManager.LoadScene("GameOver");  // 加载游戏结束场景
}

步骤 4:将游戏结束场景添加到构建设置

在 Unity 中,确保所有场景都已添加到构建设置中。否则,无法加载场景。按照以下步骤进行操作:

  1. 转到 Unity 编辑器中的 File -> Build Settings
  2. GameOver 场景拖动到场景列表中。

步骤 5:测试功能

  1. 设置 GoalsToWin 为 1(便于测试)。
  2. 运行游戏并尝试得分,看看是否成功跳转到 GameOver 场景。
  3. GameOver 场景中,点击 Replay 按钮,看看是否返回到游戏场景。

步骤 6:总结

在本视频中,我们完成了以下任务:

如果你想进一步显示哪个玩家获胜,可以使用静态变量来存储获胜玩家的信息,或者使用 DontDestroyOnLoad 来跨场景共享数据。我们将在后续的项目中详细讨论这些内容。

感谢观看,我们下次见!

355 - 为游戏添加声音

欢迎回来!在本视频中,我们将为我们的游戏添加音效。我们将介绍如何添加碰撞音效,如球击中墙壁或球拍时的声音。接下来,我们还将创建一个 SoundController 脚本来管理这些音效,并将它们正确地绑定到球对象上。

步骤 1:下载和准备音效文件

首先,访问 OpenGameArt.org,在该网站上你可以找到很多免费的游戏音效资源。例如,你可以搜索“ball”音效,并找到类似乒乓球的撞击声。这些声音可以用于当球击中墙壁时播放。

你可以下载这些音效文件,或者如果你使用的是本项目中提供的音效文件,可以直接将其添加到 Unity 项目中。

步骤 2:创建资源文件夹

为了管理和存放这些资源,我们先创建一个 Resources 文件夹。在 Resources 文件夹中,我们将放置所有的音效文件、预制件、材质和脚本:

  1. 在 Unity 的 Assets 目录下,创建一个名为 Resources 的新文件夹。
  2. 将音效、预制件、材质和脚本拖动到这个文件夹中。

步骤 3:创建 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();    // 播放墙壁声音
        }
    }
}

步骤 4:创建并配置音效组件

SoundController 脚本中,我们有两个 AudioSource 组件:一个用于球拍的音效,另一个用于墙壁的音效。接下来,我们需要为球创建这些音频源,并将音频文件分配给它们:

  1. 选择 Ball 游戏对象。
  2. 右键点击 Ball 游戏对象,选择 Create Empty 创建空对象,并命名为 AudioSource
  3. Ball 对象下创建两个 AudioSource 组件,分别命名为 RacketSoundWallSound
  4. 将下载的音效文件拖入这两个音频源的 AudioClip 属性中。
  5. 取消勾选 Play On Awake,以防音效在游戏开始时自动播放。

步骤 5:绑定音效组件

返回 SoundController 脚本,确保它正确地引用了两个 AudioSource 组件:

  1. SoundController 脚本添加到 Ball 游戏对象。
  2. BallInspector 窗口中,看到 SoundController 脚本的 RacketSoundWallSound 字段。
  3. RacketSound 音效拖到 RacketSound 字段,将 WallSound 音效拖到 WallSound 字段。

步骤 6:测试音效

现在,播放游戏并测试音效:

  1. 当球撞击球拍时,你应该听到球拍的音效。
  2. 当球撞击墙壁时,你应该听到墙壁的音效。

步骤 7:其他改进和挑战

在添加音效后,我们可以考虑其他的改进:

步骤 8:总结

在本视频中,我们完成了以下任务:

你还可以继续改进和扩展这个游戏,添加更多功能并优化游戏体验。挑战之一是为 Player 2 实现 AI 控制,这需要你思考如何通过编程控制球拍的移动,确保 AI 能够根据球的坐标进行判断。

祝你在接下来的章节中学到更多有用的知识,并享受改进游戏的过程!

356 - 添加基本AI

欢迎回来!在本视频中,我们将为 Player 2 添加一个基本的 AI 控制,来让它自动跟随球的运动。这个任务是一个小挑战,我强烈建议你自己动手尝试。通过这个练习,你可以学习如何通过代码控制物体的移动。你之前已经做过类似的操作,但这次我们会以不同的方式进行,AI 将会跟随球的运动。你可以暂停视频,自己尝试一下,完成后再查看我如何做。如果你不想自己尝试,你也可以直接观看我演示的实现过程。

步骤 1:创建 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;
        }
    }
}

步骤 2:为 Player 2 绑定脚本和配置参数

  1. 将新创建的 RecordPlayer2AI 脚本添加到 Player 2 的球拍对象上。
  2. Inspector 中,你会看到 movementSpeedball 两个公共字段。
  3. 设置 movementSpeed 为 200。
  4. Ball 对象拖到 ball 字段中,以便 AI 能够跟踪球的位置。

步骤 3:测试 AI

保存场景并开始播放游戏,看看 Player 2 是否能够自动跟随球:

步骤 4:总结与挑战

在这个任务中,你学会了如何为一个玩家(Player 2)创建基本的 AI 控制,来使其自动跟随球的运动。尽管这是一个非常简单的 AI 控制,但它已经可以根据球的位置做出反应,这为你以后创建更复杂的 AI 系统打下了基础。

挑战:尽管我们已经为 Player 2 实现了一个简单的跟随 AI,你可以尝试进一步改进它,例如:

无论如何,这个 AI 只是一个起点,随着你对游戏开发的理解加深,你可以不断优化它。

在接下来的章节中,你将学习更多的技巧,并能够将这些技巧应用到你的游戏中。希望你能享受这个过程,并尝试实现这些挑战!我们下个视频再见!

357 - 章节总结

恭喜你完成了 Pong 游戏的构建!当然,虽然你是跟着特定的教程一步步进行的,但现在的关键是,你可能还不能完全独立地从零开始重建这个游戏。不过,这其实是很正常的,因为游戏开发从来就不是一蹴而就的。

研究与学习的过程

如果你遇到一个功能不懂如何实现,通常的做法是去 Google 搜索、查看相关的教程或示例代码,再根据这些资源进行实验和测试。实际上,开发过程中很多时候是通过查找资料、参考其他开发者的示例和经验来解决问题的。这是开发者日常生活中的一部分。你不必担心自己现在不能完全独立完成一个游戏,随着你逐渐积累经验,这种能力会自然提高。

别急,积累经验是关键

游戏开发就像是一个不断 积累和实验 的过程。只要你不断构建更多的原型、不断尝试新的功能,逐步你会变得更加熟练,掌握更复杂的技巧。每次完成一个项目,无论是简单还是复杂,都会让你离成为一个 优秀的游戏开发者 更近一步。

继续前行,迈向更高级的技能

当你完成了这些基础的项目之后,就可以开始挑战更高级的内容了。掌握了基本的原理和技能后,你可以更自信地面对更复杂的开发任务。这样,你会逐渐深入理解如何高效地开发游戏,并且开始具备开发复杂游戏的能力。

感谢你一路跟随

感谢你坚持到最后,继续跟着我一起学习。希望你在本章节中学到的内容能够帮助你顺利进阶,掌握更多游戏开发的技能。下一章将带来更高级的挑战,我们一起继续前行!

希望你保持热情,玩得开心,学得更多!

WangShuXian6 commented 2 weeks ago

25 - UNITY使用Unity构建Zig Zag克隆

358 - 章节介绍

欢迎来到 Zig Zag 克隆游戏章节

在这个章节中,我们将一起制作 Zig Zag 游戏。Zig Zag 是一个非常基础的 3D 游戏,操作也非常简单。游戏唯一的输入就是 点击屏幕,这意味着玩家只需要在正确的时机点击屏幕,就可以玩得非常开心。尽管如此,Zig Zag 依然是一个非常上瘾且有趣的游戏,我真的很佩服那些设计出这种简单却如此吸引人的游戏的人。

游戏特点

本章内容

在制作这个游戏的过程中,你将学到以下内容:

我们将会详细讲解如何从头开始构建这个游戏,包括关卡设计、物理效果、动画控制、粒子系统的实现等内容。你将能够通过这次练习学到很多关于 3D 游戏开发的技巧。

准备开始

所以,准备好开始制作这个简单但又极具魅力的 Zig Zag 游戏了吗?让我们一起深入探索游戏开发的奥秘,看看如何用最少的输入打造出让玩家欲罢不能的游戏体验!我希望你已经迫不及待了,快来加入我们吧!

下一段视频我们将正式开始创建这个游戏,敬请期待!

359 - Zig Zag介绍

欢迎来到 Zig Zag 克隆游戏章节

在这个章节中,你将学习如何构建一款成功的 Zig Zag 克隆游戏。这款游戏类似于目前非常流行的 无尽跑酷游戏,其玩法简单但充满挑战,玩家可以享受几乎 无尽的游戏体验。你将学习如何使用 3D 角色,如何根据自己的需求自定义玩家的运动方式,而不是依赖预设的行为,如何创建 可收集物品 来增加得分,还会学习如何创建 3D 世界,并且更重要的是,如何让这个世界通过 程序化生成 扩展。最后,你还会学习如何在游戏中使用声音效果以及如何通过重置场景来重置游戏。

本章内容概览

本章目标

这个章节的目标是让你能够从零开始构建一个功能齐全的 Zig Zag 风格游戏,并且在过程中学习游戏开发的许多关键概念和技巧。无论你是刚接触 3D 游戏开发还是已经有一定经验,本章都会给你带来宝贵的实践经验,帮助你在游戏开发的路上走得更远。

享受这个过程

我希望你能享受这个章节,学到新的技能,并在构建游戏的过程中获得乐趣!在接下来的章节里,我们将深入探讨每一个环节,帮助你完成这款令人上瘾的游戏。

准备好开始了吗?让我们一起动手创建这个精彩的游戏吧!

360 - 基础通过代码实例化创建对象

欢迎回来

在这个视频中,我们将学习如何实例化对象。为了演示这个概念,我们首先创建一个新的 3D 对象。我将使用一个立方体,并希望通过代码将这个立方体实例化多次。

步骤 1:创建立方体对象

首先,我们创建一个立方体并稍微旋转它,以便更好地查看。你可以为它添加一个材质,这样更容易区分它。或者,我们可以将背景颜色设置为纯色,这样立方体就会更清晰地显示出来。

步骤 2:保存立方体为 Prefab

接下来,我们将立方体保存为一个 Prefab,这样可以在代码中实例化多个立方体。

  1. 保存立方体为 Prefab:选中立方体并将其拖动到项目窗口中,创建一个 Prefab。
  2. 创建空对象:创建一个空对象,命名为 Script
  3. 删除立方体:删除场景中的立方体,仅保留 Script

步骤 3:创建代码脚本

现在,我们要在空对象上添加一个脚本,命名为 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);
        }
    }
}

步骤 4:连接 Prefab 和运行代码

  1. InstantiateCubes 脚本附加到 Script 对象上。
  2. 将之前保存的 Cube Prefab 拖动到脚本的 Prefab 属性上。
  3. 运行场景,你将看到 10 个立方体按照 X 轴方向排布,每个立方体之间的间距为 3 单位。

步骤 5:修改实例化位置

如果我们希望立方体从不同的起始位置开始,可以调整实例化的位置。例如,将 X 坐标设置为 -15 + i * 3.0f,这样立方体就不会从 0 开始,而是从负值开始,形成更加清晰的排列。

Instantiate(prefab, new Vector3(-15 + i * 3.0f, 0, 0), Quaternion.identity);

步骤 6:通过用户输入实例化对象

除了在 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 坐标上按顺序排列,并且随着每次实例化,计数器都会递增。

步骤 7:运行并测试

保存脚本并运行场景。每次按下空格键时,新的立方体就会出现在屏幕上,每个立方体之间的间距为 3 单位。通过这种方式,我们可以动态地创建对象。

小结

现在你已经学会了如何通过代码实例化对象。无论是自动在开始时创建多个对象,还是通过用户输入动态生成对象,这个技巧都会在后续的项目中非常有用。希望你在下一个项目中能够使用这个技巧!

玩得开心,期待在下一个视频见到你!

361 - 基础Invoke和InvokeRepeating用于延迟调用和重复调用

欢迎回来

在这个视频中,我们将学习如何使用 InvokeRepeating,它可以让我们在一段时间后重复执行某个方法,按照设定的时间间隔反复执行。让我们回到之前创建的 InstantiateCubes 脚本,并使用 InvokeRepeating 方法来不断创建立方体。

步骤 1:创建 CreateNewCube 方法

我们首先定义一个新的方法,CreateNewCube,这个方法将负责创建新的立方体。

public void CreateNewCube()
{
    // 在指定位置实例化新的立方体
    Instantiate(prefab, new Vector3(-10 + counter * 3.0f, 0, 0), Quaternion.identity);
    counter++;  // 每次创建新的立方体时,增加计数器
}

在这个方法中,我们创建了一个新的立方体,并将它放置在指定的位置。每次调用这个方法时,counter 都会增加,这样新创建的立方体会在 X 轴上按顺序排列。

步骤 2:使用 InvokeRepeating 调用 CreateNewCube 方法

接下来,我们在 Start 方法中使用 InvokeRepeating 来定期调用 CreateNewCube 方法。

void Start()
{
    // 使用 InvokeRepeating 方法调用 CreateNewCube 方法
    InvokeRepeating("CreateNewCube", 3.0f, 1.0f);  // 第一个参数是方法名称,第二个是开始延迟时间,第三个是重复时间间隔
}

步骤 3:测试重复调用

当我们运行场景时,CreateNewCube 方法将在 3 秒后首次被调用,并且每隔 1 秒就会重复调用一次。这意味着每秒钟都会创建一个新的立方体。你会看到所有的立方体沿 X 轴逐渐排开,直到无限创建新的对象,直到内存耗尽或游戏崩溃。

步骤 4:取消 InvokeRepeating

我们可以使用 CancelInvoke 来停止重复调用。例如,如果我们希望在创建 5 个立方体后停止重复创建,我们可以添加以下代码来取消调用:

if (counter >= 5)
{
    CancelInvoke("CreateNewCube");  // 停止调用 CreateNewCube 方法
}

步骤 5:测试取消调用

如果我们按照上面的代码进行设置,立方体的创建将在创建 5 个后停止。运行场景后,你将看到只会创建 5 个立方体。

步骤 6:使用 Invoke 调用单次方法

如果你希望延迟调用某个方法一次而不是重复调用,可以使用 Invoke 方法。Invoke 方法在指定的时间后只会执行一次指定的方法。

例如,我们可以在 5 秒后调用 CreateNewCube 方法一次:

void Start()
{
    Invoke("CreateNewCube", 5.0f);  // 延迟 5 秒后调用 CreateNewCube 方法
}

小结

通过使用这些方法,你可以控制游戏中的行为,并在指定的时间间隔内执行重复任务或延迟执行任务。希望你掌握了如何使用这些工具,并能在你的项目中灵活应用它们!

362 - 基础玩家偏好设置保存数据

欢迎回来

在这个视频中,我们将学习如何使用玩家偏好设置(Player Preferences)来保存数据,如最高分、玩家名字等等。这样,你可以在游戏中保存并加载这些数据,提供更好的用户体验。

步骤 1:创建脚本

首先,我们创建一个新的脚本,命名为 SavingData。在这个脚本中,我们将实现一个功能,当按下“空格”键时,数字会增加,并将其保存到玩家偏好设置中。

public class SavingData : MonoBehaviour
{
    int number = 0;  // 初始化数字
}

步骤 2:增加数字

接下来,我们将通过检测按下“空格”键来增加数字。

void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))  // 如果按下空格键
    {
        number++;  // 数字加1
    }
}

步骤 3:保存数据

我们希望在每次数字增加时保存当前的最大数字。所以,我们需要定义一个方法来获取存储的数字,并比较当前的数字。如果当前数字更大,我们就覆盖保存的数字。

int GetNumber()
{
    // 从 PlayerPrefs 获取存储的数字
    int myNumber = PlayerPrefs.GetInt("MyNumber", 0);  // 默认值为 0
    return myNumber;
}

PlayerPrefs.GetInt 方法用于获取存储在玩家偏好设置中的整数值。如果没有找到对应的值,将使用默认值(在这里是 0)。

步骤 4:更新和保存最高分

接下来,在 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);  // 在控制台输出新的最高分
        }
    }
}

步骤 5:显示存储的数字

Start 方法中,我们可以输出存储的数字(例如,显示最高分)。这样每次启动游戏时,玩家就可以看到他们的最高分。

void Start()
{
    // 获取存储的数字并显示
    int storedNumber = GetNumber();
    Debug.Log("Stored number is: " + storedNumber);
}

步骤 6:测试功能

现在,当你运行游戏时,每按下“空格”键,数字会增加并更新存储的最高分。停止游戏后重新启动,你将看到之前保存的最高分。

控制台输出示例:

小结

通过使用 PlayerPrefs,我们可以轻松保存简单的数据,如最高分或玩家名称。需要注意的是,PlayerPrefs 更适合保存简单的、少量的数据,如整数、浮点数或字符串。如果你需要保存更复杂的数据(例如,整个游戏状态或多个对象),你将需要学习如何使用序列化技术。

希望你能跟随这个视频的步骤完成,并尝试自己实现这一功能。接下来,我们将在下一个视频中进行更有趣的操作!

363 - 基础射线检测

欢迎回来

在本视频中,你将学习如何使用射线(Raycast),它是一个强大的工具,可以用于许多不同的应用场景。例如,你可以检查自己前面、后面、上方、下方是否有物体,或者检查鼠标所在的点是否有物体。我们将在两个不同的游戏中使用射线,来演示它的应用。

步骤 1:设置游戏场景

首先,我们需要创建一个立方体(Cube)并放入游戏场景中。同时,我们还需要创建一个平面(Plane),作为地下物体。我们将检查立方体下方是否有物体。

步骤 2:添加脚本检查物体

接下来,我们将为立方体添加一个新的脚本,检查它是否与地下的物体发生碰撞。我们将使用射线检测(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.");
        }
    }
}

步骤 3:测试射线检测

将这个脚本添加到立方体对象上,并运行游戏。你将在控制台看到以下输出:

你可以移动立方体并查看控制台中的变化,确保射线检测正常工作。

步骤 4:鼠标射线检测

除了检测物体下方,我们还可以使用鼠标来检测是否有物体被射线击中。我们将演示如何使用鼠标位置来射线检测场景中的物体。

修改脚本使用鼠标位置进行射线检测

回到 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);
        }
    }
}

步骤 5:测试鼠标射线检测

现在运行游戏,并移动鼠标到不同的位置。你将在控制台看到类似以下的输出:

通过这种方式,你可以轻松检测鼠标指向的位置,并判断是否与场景中的物体发生了碰撞。

应用场景:构建建筑物

这个射线检测的方法可以应用于很多不同的场景,尤其是在像《Farmville》这种模拟建设类游戏中。你可以使用鼠标射线来确定玩家放置建筑物的位置,确保建筑物只会放置在可用的区域上。

总结

通过本视频,你学到了如何使用射线检测来判断物体之间的碰撞,不论是检测物体下方的碰撞,还是根据鼠标位置射线检测。射线检测是一个非常实用的功能,可以在多个场景中派上用场,尤其是在需要用户交互的游戏中。

在接下来的章节中,我们将使用这个技术来构建更复杂的游戏元素,比如模拟建造和放置建筑物。希望你能将这个技巧运用到自己的项目中!

364 - Zig Zag的设置

欢迎回来

在这一章中,我们将创建一个“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角色的移动,以及更多的内容。同时,我们还将创建一个无限的程序化地图,玩家可以在其中收集水晶,创建一个既有趣又有吸引力的手机游戏。

在下一个视频中,我们将继续设置正确的视角。

365 - 设置视角

欢迎回来。在这个视频中,我们将继续调整游戏的视角,并进一步设置项目。首先,我们将把我们的玩家(或角色)重命名为 "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键不再起作用,玩家也不再有动画效果了。这是因为我们的角色没有动画控制器,也没有可以控制移动的组件。

好的,接下来我们保存当前的场景,并在下一个视频中继续进行游戏开发。

366 - 移动角色

欢迎回来。在这个视频中,我们将让我们的角色能够移动。这将是一个非常简单的左右移动,角色只会左右走。每当我们点击或按下按钮时,角色就会朝左或朝右移动。所以,接下来我们就来实现这一功能。

首先,我们选中我们的角色对象,然后为其添加一个组件。我将使用脚本,创建一个新的脚本,并命名为 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);
    }
}

如果 walkingRighttrue,角色将旋转45度;如果为 false,角色将旋转-45度。这段代码完成了角色方向的切换。

完成以上代码后,我们保存脚本,返回 Unity 编辑器。在 Unity 中,我们将脚本放到一个新的文件夹里,命名为 Scripts,然后将 CharController 脚本拖到角色对象上。

在测试之前,我们还需要确保在 Update 方法中调用了 SwitchDirection 方法,这样每当按下空格键时,角色就会切换朝向。

接下来,我们运行游戏,观察角色是否可以根据空格键的输入切换方向。玩家角色应该能够正常地向左和向右移动。然而,由于目前没有动画,角色看起来只是左右摆动的手臂,这样的效果可能有些怪异。我们将在接下来的视频中改进这一点,给角色添加动画。

此外,还有一个问题:目前摄像机并没有跟随角色移动。因此,当角色向前走时,可能会跑出视野看不见它。我们将在下一个视频中解决这个问题,调整摄像机以跟随玩家。

367 - 使相机跟随玩家

欢迎回来。在这个视频中,我们将处理摄像机,使其能够跟随我们的玩家。我们将为此创建一个新的脚本,命名为 FollowCam(跟随摄像机),这个脚本的作用是让摄像机跟随玩家角色。接下来,我们将把这个脚本直接分配给我们的主摄像机。

首先,在 Unity 中创建一个新的脚本,命名为 FollowCam,然后将其拖动到主摄像机的组件列表中,作为组件附加到摄像机上。接下来,打开脚本文件,进行编辑。

在脚本中,我们将使用 Awake 方法来初始化设置,而不是使用 Update 方法。我们需要两个主要的变量:

  1. Transform target:这是我们需要跟随的目标位置,也就是玩家的位置。我们将其设置为 public,这样我们可以在 Unity 中进行赋值。
  2. Private Vector3 offset:这是摄像机相对于玩家的偏移量,表示摄像机与玩家之间的距离。

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 轴差异是 1Y 轴差异是 4.5Z 轴差异是 -5。这意味着摄像机将始终保持这个距离,跟随玩家的位置。

保存场景并运行代码后,观察摄像机是否正确跟随玩家。运行时你会发现摄像机会始终保持与玩家的相对位置,并跟随玩家移动。

现在,我们已经成功地让摄像机跟随玩家了,这对于制作一个无尽奔跑类的游戏非常合适。在接下来的视频中,我们将为玩家添加动画效果,所以敬请期待!

368 - 动画角色

在这个视频中,我们将处理玩家角色的动画。首先,让我们创建一个角色。在 Unity 中,进入 Assets 文件夹,创建一个新文件夹,命名为 Character。然后,将你的角色拖入该文件夹,因为这个角色和我们从 Asset Store 下载的版本不同,因为我们已经对其做了一些修改。例如,我们删除了控制器、动画控制器并创建了新的脚本等。

创建角色动画控制器

接下来,我们进入 Character 文件夹,创建一个新的动画控制器。每当你想为物体添加动画时,都需要一个动画控制器。我们将其命名为 char_anim_controller。你可以根据个人喜好命名,但 char_anim_controller 这个名称已经很清楚地表明了它的功能。

打开该动画控制器后,进入 Animator 窗口。你会看到几个状态:Any StateEntryExit,这些是创建动画控制器时自动生成的。我们需要添加一个新的状态,因此右键点击空白区域,选择 Create State,然后选择 Empty。你会看到 EntryAny 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 是我们需要的掉落动画。

为了让角色在掉落时切换到掉落动画,我们需要设置从 RunFalling 的过渡。点击 Run 状态的过渡箭头,创建一个从 RunFalling 的过渡。然后我们需要为过渡设置条件,决定什么时候从跑步状态切换到掉落状态。

添加动画过渡条件

为了添加过渡条件,我们首先需要在动画控制器中创建一个新的参数。点击左侧的 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 变量中。

测试动画

最后,我们运行游戏并查看动画效果。角色跑步时,应该播放跑步动画,而当角色离开地面时,应该播放掉落动画。我们可以通过调整动画片段,或者试验不同的动画来确保效果更好。

接下来,我们将继续改进游戏的启动方式,确保游戏的启动体验更好。

保存场景并进入下一个视频。

369 - 开始游戏

游戏管理器和启动流程

创建游戏管理器脚本

为了让玩家能够在游戏内启动游戏,我们需要创建一个新的脚本来管理游戏状态。首先,创建一个名为 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() 方法,用于设置 gameStartedtrue,表示游戏已启动。

在控制器中访问 GameManager

在玩家控制器脚本中,我们需要访问 GameManager 以便在玩家按下空格键时,游戏开始。首先,创建一个私有的 GameManager 变量,然后在 Awake() 方法中初始化它。

private GameManager gameManager;

void Awake()
{
    gameManager = FindObjectOfType<GameManager>(); // 查找场景中的 GameManager 对象
}

接着,在 FixedUpdate() 方法中,检查游戏是否已启动:

void FixedUpdate()
{
    if (!gameManager.gameStarted)
    {
        return; // 如果游戏未启动,则不做任何处理
    }

    // 其他控制逻辑
}

在 Unity 中测试游戏

保存并返回 Unity,运行游戏后,我们可以看到玩家不会移动,因为 gameStartedfalse。玩家的动画仍然会播放,但他会假装在跑步(实际上并未开始游戏)。为了避免这种情况,我们需要创建一个新的状态,称为“Idle”(空闲状态),表示玩家没有开始游戏时的状态。

添加“Idle”状态

Animator 中,创建一个新的空状态并命名为 Idle。然后,设置该状态为默认状态,这意味着当游戏未开始时,玩家处于该状态。我们还需要为此状态添加一个名为 Idle 的动画(可以在已下载的资源中找到)。

设置“Idle”到“Run”的过渡

创建 IdleRun 的过渡。在过渡条件中,我们需要添加一个新的触发器(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 中,为 IdleRun 的过渡添加条件,并禁用 Exit Time,这样动画会立即从 Idle 过渡到 Run,而不是等到 Idle 动画播放完成。

添加掉落动画

为了在玩家掉落时触发掉落动画,我们还需要创建一个从 RunFalling 的过渡,并在合适的时机添加条件来启动掉落动画。类似地,我们创建一个触发器 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 键后,游戏应该能正常启动,玩家的动画也会根据不同状态(跑步、空闲、掉落)进行过渡。如果玩家从空中掉落,应该触发掉落动画。

在接下来的视频中,我们将讨论如何重新启动游戏。

370 - 重新开始游戏

重启游戏流程

创建重启游戏的方法

在这个视频中,我们将实现游戏重启的功能。目前我们只是在按下回车键时启动了游戏,但现在我们希望在玩家掉落时重新启动游戏。

首先,我们需要检测玩家是否掉落。我们可以通过检查玩家的 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 时,游戏将结束并重新加载场景。你可以通过以下步骤进行测试:

  1. 按下 Enter 启动游戏。
  2. 玩家走动并掉落,游戏将重新加载。

构建和运行游戏

构建游戏并运行时,你可能会发现游戏的场景看起来有所不同。这是因为 Unity 在构建游戏时可能会呈现不同的效果。为了测试这个效果,可以通过以下步骤来构建并运行游戏:

  1. 在 Unity 中选择 File > Build and Run
  2. 在弹出的窗口中选择保存的文件夹,命名并保存你的游戏(例如,命名为 ZigZagClone)。
  3. 构建完成后,打开保存的文件夹并启动构建的游戏。

当你在构建的游戏中测试时,你会看到和 Unity 编辑器中类似的效果。玩家按下 Enter 启动游戏,走动并掉落时,游戏会重新加载,并且场景的视觉效果与编辑模式中保持一致。

下一步

在下一个视频中,我们将讨论如何添加水晶收集系统以及如何为玩家添加分数功能。

371 - 收集水晶并增加分数

导入并设置水晶

从资产商店导入水晶模型

欢迎回来!现在,我们需要去资产商店导入水晶模型,因为游戏中有水晶需要玩家收集。一个很棒的水晶资产是 Stylized Crystal。你可以在资产商店中找到它,搜索关键词“Stylized Crystal”。它是一个低多边形风格的免费资产,非常适合用作游戏中的水晶。

  1. 在资产商店中搜索并找到 Stylized Crystal
  2. 下载并导入到你的游戏项目中。

创建水晶预设

接下来,我们将使用导入的水晶模型创建一个预设。在项目视图中,找到 Stylized Crystal,然后将它拖到场景中。

  1. 在层级视图中找到 Stylized Crystal,拖到场景中。
  2. 删除水晶上的 Animator 组件,因为水晶不需要动画。
  3. 将对象名称修改为 Crystal
  4. 添加一个 Box Collider 组件,并将其设置为触发器 (Trigger)。

设置水晶的属性

为了让水晶能够触发事件,我们需要设置它的触发器。将 Box Collider 组件的 Is Trigger 属性设置为 True。这样当玩家与水晶碰撞时,它会触发事件,并增加分数。

  1. Crystal 对象上添加 Box Collider 组件。
  2. 勾选 Is Trigger

接下来,我们为水晶添加一个新的标签。点击 Tag,选择 Add Tag,然后创建一个名为 Crystal 的标签。

  1. Crystal 对象设置刚才创建的 Crystal 标签。

将水晶对象转为预设

为了方便管理,我们需要将水晶对象转化为预设:

  1. 创建一个名为 Prefabs 的文件夹。
  2. Crystal 拖动到 Prefabs 文件夹中。

设置水晶位置和旋转

现在,我们将水晶放置在场景中,并调整它的位置和旋转:

  1. 选中 Crystal 对象,将其位置设置为 (0, 0, 0)。
  2. Y 坐标设置为 0.5,这样它就会出现在道路上方。
  3. 旋转水晶,使其看起来更自然,将旋转值设置为 45

你可以根据需要调整水晶的位置,甚至在场景中复制多个水晶,增加挑战性。通过将水晶对象移动到不同的位置,可以增加游戏的难度,让玩家更容易掉落。

移动水晶到 "Crystals" 文件夹

为了保持层级结构的清晰,我们将水晶对象移到一个专门的文件夹中:

  1. 创建一个名为 Crystals 的文件夹。
  2. 将所有水晶对象拖动到 Crystals 文件夹中。

测试水晶的碰撞

现在,我们来测试水晶是否能正确地与玩家发生碰撞。运行游戏后,玩家应该能够穿过水晶,并且水晶应该在碰撞后消失。

添加分数系统

接下来,我们为游戏添加一个分数系统。每次玩家收集到一个水晶时,分数应该增加。

  1. GameManager 中创建一个公共的整型变量 score 来记录分数。
  2. 创建一个公共方法 IncreaseScore,每次调用时增加分数。
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 元素。我们将在屏幕的左上角显示分数。

  1. Hierarchy 中创建一个新的 Text 对象。Unity 会自动为你创建一个 Canvas
  2. 设置 Text 位置,选择 Top Left 锚点,调整位置为 (100, -50)。
  3. 设置字体大小为 100,字体颜色为 白色,并将 Overflow 设置为 Overflow,以便文本可以溢出。

更新 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 字段中。

测试分数显示

运行游戏并测试分数系统。玩家收集水晶时,分数应该增加,并且在屏幕左上角显示。

下一步

在下一个视频中,我们将讨论如何设置 高分系统,记录玩家的最高分数,并在游戏结束后显示最高分。

372 - 添加高分

添加高分功能

创建高分显示文本

在游戏中添加一个 高分 显示功能。首先,我们需要复制分数文本(score text)并将其命名为 high score text,这样我们就可以在屏幕上同时显示当前分数和高分。

  1. GameManager 中,创建一个公共的 Text 变量来存储高分文本:
public Text highScoreText;

获取和设置高分

为了获取和设置高分,我们将使用玩家的本地存储(PlayerPrefs)。本地存储可以存储数据,在游戏关闭后仍然保存下来。

  1. GameManager 中,我们添加一个 Awake 方法,在游戏开始时从本地存储中获取高分:
private void Awake()
{
    highScoreText.text = "Best: " + GetHighScore().ToString();
}
  1. 创建一个 GetHighScore 方法来从 PlayerPrefs 获取高分:
public int GetHighScore()
{
    return PlayerPrefs.GetInt("highScore", 0);  // 如果没有高分,默认为0
}

更新高分

每当玩家的分数超过当前高分时,我们就更新高分并将其显示在屏幕上。我们将在增加分数时进行检查,确保 current score 大于 high score

  1. 修改 IncreaseScore 方法,在每次增加分数时检查是否超过了当前高分。如果是,就更新高分:
public void IncreaseScore()
{
    score += 1;

    // 检查是否超过当前高分
    if (score > GetHighScore())
    {
        PlayerPrefs.SetInt("highScore", score);  // 更新本地存储中的高分
        highScoreText.text = "Best: " + score.ToString();  // 更新高分显示
    }
}

调整高分文本的位置和显示

我们需要在 UI 中正确显示高分文本。创建一个文本框来显示当前的高分,并将其放置在屏幕的合适位置。

  1. 创建一个新的 Text 元素,并将其位置调整到左上角的合适位置。根据前面的步骤,将其 Anchor 设置为 Top Left,然后设置合适的坐标。你可以将 Y 位置 设置为 -120,并将 字体大小 调整为 70

在游戏开始时获取高分

当游戏启动时,我们需要显示保存的高分。在 Awake 方法中,我们获取并设置显示的高分:

private void Awake()
{
    highScoreText.text = "Best: " + GetHighScore().ToString();  // 显示高分
}

测试高分功能

  1. 运行游戏并收集几个水晶,看看分数是否正确增加。
  2. 每当玩家的分数超过当前高分时,highScoreText 会更新显示新的高分。

修改显示的高分格式

为了让文本更清晰,我们可以修改显示格式。确保 highScoreText 始终以 "Best: x" 的格式显示,更新方法如下:

highScoreText.text = "Best: " + score.ToString();  // 始终更新高分

测试高分功能

你可以通过重新运行游戏并尝试得分来测试高分功能,确保每次玩家超过当前最高分时,都会更新并显示新的高分。

下一步

在下一个视频中,我们将为水晶添加特效,使得玩家收集水晶时能够看到一些视觉反馈。

373 - 添加粒子效果

添加水晶特效

创建粒子效果

为了增加游戏的视觉效果,当玩家收集到水晶时,我们将添加一个粒子效果。具体操作如下:

  1. Hierarchy 中右击,选择 Effects,然后选择 Particle System,以创建一个新的粒子系统。

  2. 默认情况下,粒子系统会被添加到场景中。我们需要将其移动到合适的位置,以便它能显示在玩家角色的附近。

  3. 将粒子系统的位置设置为 (0, 0, 0),然后将其拖动到水晶的位置。假设水晶的位置是 (0.5, 0, 0),将粒子系统移到水晶的位置。

配置粒子效果

粒子系统生成的效果是持续不断的,我们可以通过修改其属性来让其更符合需求。

  1. 颜色设置:在 Start Color 选项中,可以更改粒子的起始颜色。我们可以选择与水晶颜色匹配的颜色。为了更有动感,可以让颜色在两种颜色之间随机变化。右键点击 Start Color,选择 Random Between Two Colors,然后设置两种颜色之间的随机变化。

  2. 发射模式:默认情况下,粒子系统会持续发射粒子。我们可以修改为 Burst 模式,使粒子在短时间内爆发出来。这样,粒子将在瞬间生成,然后消失。

  3. 持续时间:调整粒子的 Duration(持续时间)和 Lifetime(生命周期)。例如,将粒子的 Lifetime 设置为 2 秒,让粒子效果在短时间内消失。

  4. 旋转设置:可以设置粒子的旋转角度,使其朝着不同的方向发射。这里我们将 X 轴旋转 设置为 -90,让粒子朝上发射。

  5. 发射形状:粒子的发射形状可以选择 Sphere,使粒子从一个球体中发射。我们还可以调整球体的大小,来改变粒子扩散的范围。

  6. 渐变效果:为了让粒子逐渐消失,我们可以在 Color Over Lifetime 中设置渐变效果。通过编辑 Alpha 值,可以让粒子从不透明逐渐变为透明,从而产生逐渐消失的效果。

  7. 大小变化:通过 Size Over Lifetime 可以控制粒子的大小变化。可以选择让粒子逐渐变大或逐渐变小。

  8. 噪声效果:如果想让粒子在不同的方向上随机浮动,可以启用 Noise 并调整噪声强度,这样粒子的运动会更加随机,模拟风的效果。

保存并使用粒子预制件

  1. 配置好粒子效果后,我们将其保存为一个预制件。首先,右键点击粒子系统,选择 Create Prefab,将其命名为 CrystalEffect

  2. 拖动 CrystalEffect 预制件到 Prefabs 文件夹中,这样就可以在其他地方重复使用这个粒子效果。

在角色脚本中添加粒子效果

  1. 在角色控制器脚本中,我们需要创建一个公共的 GameObject 变量,用来引用我们的粒子效果预制件:
public GameObject crystalEffect;
  1. 在角色碰撞到水晶时,我们希望创建一个粒子效果实例,并将其显示在玩家的位置。通过 OnTriggerEnter 方法来检测角色是否与水晶碰撞,然后创建粒子效果:
void OnTriggerEnter(Collider other)
{
    if (other.CompareTag("Crystal"))
    {
        // 在玩家的位置生成粒子效果
        GameObject effect = Instantiate(crystalEffect, transform.position, Quaternion.identity);
        // 粒子效果持续 2 秒后销毁
        Destroy(effect, 2f);
        // 销毁水晶
        Destroy(other.gameObject);
    }
}
  1. Inspector 中,设置粒子效果对象为之前创建的 CrystalEffect 预制件。

调整粒子效果的位置

为了让粒子效果显示在玩家的中心位置(而不是脚下),我们可以将粒子效果的生成位置设置为玩家胸部的位置。具体实现如下:

// 获取玩家胸部的位置,通常使用模型中的 "RayStart" 作为参考
GameObject effect = Instantiate(crystalEffect, rayStart.transform.position, Quaternion.identity);

通过这样设置,粒子效果将在玩家胸部附近发射,而不是脚部。

测试效果

  1. 保存脚本并运行游戏,收集水晶时你应该能看到粒子效果从玩家的胸部位置爆发出来。
  2. 如果效果看起来太大或不合适,可以返回 Particle System 设置,调整大小、速度、颜色等参数,直到达到理想的效果。

总结

通过添加粒子效果,我们为水晶收集行为增加了视觉反馈,增强了游戏的表现力。你可以根据需要进一步调整效果参数,尝试不同的视觉风格和粒子行为,使其更符合游戏的主题。

下一步

在下一个视频中,我们将添加背景音乐,为游戏增添更多的氛围。

374 - 背景音乐循环

好的,让我们给你的游戏加入一些动感十足的背景音乐吧,因为拥有循环播放的背景音乐非常棒,我们正是要添加这样的音乐。首先,前往 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 AwakeLoop 选项,因为我们希望这段音乐从游戏开始时就播放并且循环。接着,将音量设置为 0.3,避免音量过大。完成后,运行游戏并检查效果。

保持背景音乐不被销毁

默认情况下,当场景重新加载时,音频播放器会被销毁,导致背景音乐停止。为了让背景音乐在场景之间持续播放,我们使用 DontDestroyOnLoad 方法。

  1. 打开 Background Loop 脚本,删除 StartUpdate 方法。
  2. 使用 Awake 方法来确保背景音乐对象在场景切换时不会被销毁。代码如下:
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 就非常合适。如果觉得太大,随时可以进一步调低音量。

完整效果

现在,当游戏开始时,背景音乐会持续播放,且在切换场景时不会被销毁。你可以继续进行游戏,背景音乐会始终在背后播放,带给玩家一个连贯的体验。

小结

到这里,我们已经成功地为游戏添加了背景音乐循环,并确保它在场景切换时不断播放。接下来,我们将在下一视频中展示如何根据程序动态生成游戏世界。

375 - 程序生成我们的地图

游戏的无限生成和程序化地图

如果有一个对无限奔跑游戏最重要的元素,那就是无限性。为了创建一个无限的世界,我们需要使用程序化生成。也就是说,我们必须通过代码来实现它,而不是手动创建数十亿个道路元素或路段。我们想要的是通过程序化的方式来创建这些元素。那么现在我想做的是,首先设置我的水晶,并且将水晶的放置位置改为在道路路径上,而不是随机的世界中的某个位置。接下来,我们将水晶放置到道路路径上。

设置水晶并删除无关元素

首先,我们需要把所有其他的水晶删除。接下来,删除我们所有的道路部分。然后我们将水晶拖到游戏中。大概放置在这个位置,确保水晶在道路上方。如果是放置正确的话,就放在那里,接下来我要将水晶对象停用掉,这样水晶就不再显示了。现在,这个道路部分就会成为一个新的预制件。我们将这个道路部分拖动到预制件文件夹中,之后我们将使用这个预制件来程序化地创建更多的道路部分。

道路的基础结构

在我们继续之前,我们有一个游戏的基础元素将始终保持不变,那就是道路。为了让程序化生成更方便,我们首先去掉水晶,然后复制水晶并按照需求移动它。游戏的前几个步骤是固定的,然后路段开始向一边扩展。我们可以查看游戏视图,确保道路部分一直保持相同。道路的下一部分也会有相同的规则。现在这就是我们地图的起点。

创建程序化道路生成

接下来,我们将创建一个脚本来实现道路的程序化生成。在Unity中,我们为道路部分添加一个新的组件,并创建一个新的脚本,命名为Road。打开这个脚本,我们需要一个公共的游戏对象类型的变量roadPrefab,用于存储我们前面创建的道路预制件。在Unity编辑器中,我们将这个预制件赋值给roadPrefab。接下来,我们还需要知道从哪个位置开始创建新的道路部分。

设置道路生成的起始位置

我们需要知道从哪个点开始创建新的道路部分。通过检查道路部分的位置,我们发现它的初始位置是0, 7, 9。所以我们将在脚本中创建一个公共的Vector3变量lastPost,并将其设置为0, 7, 9。接下来,我们需要计算每个道路部分之间的偏移量。在Unity中,选中一个道路部分,按住ControlShift键进行复制并移动,可以看到每次移动的偏移量是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秒。

解决生成过程中的问题

然而,我们发现生成道路时,某些地方可能会出现小的间隙,导致角色动画出现问题。为了解决这个问题,我们可以增加一个检查,确保在生成新道路时没有间隙,或者在角色掉落时调整动画参数。

我们通过给角色的动画状态添加一个新的过渡条件,例如“没有再掉落”来解决这个问题。通过这种方式,当角色从空中回到地面时,可以触发相应的动画。

总结

到目前为止,我们已经成功地创建了一个通过程序化生成的无限世界。游戏的基本功能都已经实现,玩家可以收集分数,查看高分,并且不断地向前奔跑。接下来,玩家可以根据需要调整游戏的难度,例如通过增加角色的速度,或者更改道路生成的速度。

现在你已经掌握了如何制作一个程序化生成的无限奔跑游戏,接下来可以根据自己的想法和需求,加入更多的功能,使游戏更加丰富和有趣。

WangShuXian6 commented 2 weeks ago

26 - UNITY使用Unity构建水果忍者克隆

376 - 章节介绍

开始构建《水果忍者》

在本章中,我们将创建一款经典的游戏——水果忍者。这是一个非常好玩的游戏,我曾经在香港时玩了无数小时,甚至记得我曾通过火车从香港去中国。我的朋友,一位英国人,玩这款游戏非常厉害,打破了很多纪录,这让我非常有动力去打破他的记录。我也开始疯狂玩这款游戏,虽然这款游戏非常简单,但却极其有趣。你只需要滑动屏幕,切割水果,避免炸弹。这就是游戏的全部内容。

游戏构建目标

本章的目标是帮助你创建一个类似的游戏,水果忍者,并且让你掌握以下内容:

个人经历分享

回想起当时在香港的日子,我的朋友真的是水果忍者的高手,而我为了赶上他的记录,几乎每天都在玩这款游戏。虽然这款游戏玩法简单,但是它的游戏机制非常有趣。通过切割水果而避免炸弹,简单的游戏机制却能让人沉浸其中。

你将学到什么?

通过构建这款游戏,你不仅可以学到游戏开发的基本概念,还可以:

  1. 生成和管理游戏元素:例如水果和炸弹等。
  2. 触摸屏交互:你将实现滑动效果,让玩家能够切割屏幕上的水果。
  3. 3D建模与实现:你将学会如何使用3D模型来展示水果和炸弹。
  4. 跨设备测试:在完成基本的游戏功能后,你将学会如何将游戏导出到Android设备,进行真实环境下的测试。

分享与进步

完成这个游戏后,我希望你能把它分享出去,无论是上传到Google Play商店,还是其他平台。如果你愿意,欢迎分享你上传的链接,看看你是如何将这个游戏做得更好的。我非常期待看到你根据这次教程所做的游戏,并且能够与你的朋友分享。

无论是水果忍者还是其他游戏,我希望你在开发过程中不断学习和进步,最终成为一名优秀的开发者。

感谢与鼓励

感谢你跟随我一起完成本章内容,我期待在接下来的课程中与你共同探索更多有趣的游戏开发技巧。无论你将来想做什么样的游戏,保持对编程和开发的热情,一定能够实现你的目标。

祝你好运,期待在下个视频中见到你!

377 - 创建水果并将其爆炸

构建水果忍者克隆游戏

在本章中,我们将创建一个水果忍者的克隆游戏。如果你不熟悉这款游戏,可以去YouTube查找一些相关视频。它是一个非常基础的游戏,你需要切割水果。只需要简单地滑动屏幕,然后切割水果,水果会掉下来,你将获得相应的分数。游戏的目标是尽可能地获得更多的分数。当然,游戏中有一些东西会阻碍你的得分,那就是炸弹。一旦你切到炸弹,你就会输掉游戏。基本上就是这些内容,我们将构建这些基础功能。

创建项目

首先,我们需要创建一个新的项目,并将其命名为Fruit Ninja clone(水果忍者克隆)。在我的例子中,我将其命名为V1,但你不需要这样做,除非你已经在项目中有了一个克隆版本。

导入资源

接下来,我们将导入一些资源,这些资源可以从本章的讲座附件中下载。包括:

将所有这些资源拖到Unity的资源管理器中,并创建一个文件夹,命名为models,然后将这些资源和材质文件移入其中。

创建和调整橙子模型

我们首先来处理橙子模型:

  1. 将橙子模型拖入场景中。
  2. 调整橙子的大小。选择橙子的模型,修改其缩放因子0.3,然后点击应用,这样橙子的大小就合适了。如果不调整,橙子会显得太大。
  3. 对切割后的橙子(orange cut)也进行相同的调整。
  4. 删除场景中的橙子切割模型,作为演示用途后,我们不再需要它。

添加物理组件

  1. 橙子模型已经有了动画器,但没有控制器。我们现在不需要控制器,所以可以删除它。
  2. 为橙子添加2D刚体组件,因为我们只希望橙子在2D空间中移动,不需要它在3D空间中进行深度移动。
  3. 添加2D圆形碰撞器,使其适应橙子的圆形外观。你可以调整圆形碰撞器的半径,0.3是一个合适的值。
  4. 设置碰撞检测模式连续,以确保碰撞检测非常快速,这是因为水果忍者的游戏节奏非常快。

创建Prefab和脚本

  1. 将橙子保存为一个Prefab,创建一个新的Prefab文件夹并将橙子拖入其中。
  2. 保存场景为Main
  3. 创建一个Scripts文件夹并在其中创建一个新的C#脚本,命名为Fruit

创建切割水果的功能

Fruit脚本中,我们需要实现一个切割水果的方法,使得水果在被切割时分成两部分:

public GameObject slicedFruitPrefab;

void CreateSlicedFruit() {
    // 在当前水果的位置和旋转下实例化切割后的水果
    Instantiate(slicedFruitPrefab, transform.position, transform.rotation);

    // 销毁当前的水果对象
    Destroy(gameObject);
}
  1. 在Unity中,切割后的橙子orange cut)包含两个部分。我们需要将这两个部分添加刚体,并让它们稍微旋转,以便看起来更加真实。
  2. 为每个切割的橙子部分添加一个3D刚体组件(而不是2D刚体),因为我们希望它们有一定的旋转,并且从橙子的中心爆炸到不同方向。
  3. 使用Box Collider 3D来处理切割后部分的碰撞。

处理切割后效果

  1. Prefab中,选中切割后的橙子,确保它包含两个切割后的部分,并将它们拖入Prefabs文件夹中。
  2. Fruit脚本中,为每个切割后的橙子设置切割后的水果Prefab
  3. 添加切割后效果的动画:当水果被切割时,我们要让它们随机旋转,并通过爆炸效果将它们向不同的方向发射。
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);
}

测试切割效果

  1. 场景中拖入橙子对象,并确保橙子有Fruit脚本。
  2. Update()方法中,我们通过空格键来触发切割水果的效果:
void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
        CreateSlicedFruit();
    }
}
  1. 启动游戏并按空格键,你会看到橙子被切割并分成两部分,旋转并爆炸到不同的方向。

结果和总结

当你按下空格键时,你会看到橙子被切割成两部分,并且这两部分会以随机的旋转和爆炸力向不同方向飞出。这是一个基本的切割水果的实现,接下来,我们将实现水果的生成机制,使得水果可以自动生成并飞向空中,等待玩家去切割。

不要忘记保存场景,准备好进入下一章,继续构建你的游戏!

378 - 水果生成器

项目目标

在本视频中,我们的目标是生成一些水果,这些水果将从屏幕底部生成并快速飞向游戏场景。我们可以在后续阶段切割这些水果。目前我们无法切割它们,但以后会实现这一功能。此外,我们还希望水果生成的间隔时间是随机的,且每个水果生成的时间间隔不相同。

创建脚本

  1. 创建 Spanner 脚本 我们将为水果生成创建一个新的脚本,命名为 Spanner,因为它的功能是生成水果。打开脚本并做如下设置:

    • 不需要 Update 方法。
    • 我们需要几个变量:
      • 一个 GameObject 类型的公共变量,命名为 fruitToSpawn,用于指定要生成的水果类型。
      • 两个浮动变量来控制水果生成的时间间隔,分别为 minWaitmaxWait。例如,minWait 设置为 0.3 秒,maxWait 设置为 1.5 秒。
  2. 启动协程生成水果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");
    }
}

配置 Unity 场景

  1. 创建 Spanner 对象

    • 在 Unity 中创建一个空对象,并命名为 Spanner,然后为其添加刚才编写的脚本。
    • 通过 Unity 编辑器,您可以设置 minWaitmaxWait 的值,选择 fruitToSpawn(例如 Orange)来进行测试。
  2. 观察水果生成

    • 脚本会在控制台中打印“Fruit spawned”,表示水果已经被成功生成。
    • 为了观察位置,我们给 Spanner 添加了一个 Gizmo,使用紫色来标识其位置。
  3. 多点生成水果

    • 创建多个生成点,每个生成点会在不同的位置生成水果。我们设置了三个生成位置,并用随机值选择生成的点。
    • 生成的水果将根据选定的生成点位置进行生成。

添加旋转和力的作用

  1. 添加水果的旋转

    • 水果不仅需要在垂直方向上飞行,还应根据生成位置的角度稍微偏移。
    • 修改生成点的旋转角度,确保水果朝不同方向飞行。
  2. 应用力使水果飞向空中

    • 给水果添加一个刚体 (Rigidbody),并通过 AddForce 方法向水果施加力。
    • 我们使用 transform.up * 10 来为水果施加向上的力,创建飞向空中的效果。
  3. 销毁生成的水果

    • 为了避免场景中不断堆积水果,我们设置了一个定时销毁机制。生成的水果将在 5 秒后自动销毁。
    • SpawnFruits 协程中,添加了如下代码:
Destroy(fruit, 5f);

测试和调整

  1. 调整生成的力和角度

    • 可以通过编辑器直接调整生成的最小和最大力值(例如 minForcemaxForce),以确保水果飞行的方向和力度符合需求。
    • 随着调整的进行,您会看到水果的飞行轨迹发生变化,最终达到预期的效果。
  2. 查看和调整生成点位置

    • 为了确保水果从合适的地方生成,我们调整了生成点的位置,使其落在正确的高度。

下一步计划

下一步,我们将实现切割水果的功能,并将切割操作绑定到鼠标操作上。

379 - 创建我们的刀刃

游戏开发 - 切割水果与刀刃实现

欢迎回来!现在如果我们开始游戏并检查一个行为,那就是如果我们切割橙子,它们将永远存在于游戏中。我们已经确保橙子会在一定时间后被删除,但切割后的橙子却不会。因此,我们需要修改这一点。让我们进入脚本,具体来说是进入我们的 Fruit 脚本。

1. 切割后的水果删除

Fruit 脚本中,我们可以加入一个方法,使切割后的水果在 5 秒后销毁。可以在以下代码行进行修改:

Destroy(insert.gameObject, 5f);

这将使被实例化的切割水果(切割后的橙子)在 5 秒后被删除。现在我们来测试一下这个修改是否生效。我将切割水果并看看这些橙子切割后的物体是否会在 5 秒后被删除。正如你所看到的,它们确实消失了。这是非常重要的,如果不处理这些物体,可能会导致性能问题。虽然 PC 的硬件比较强大,问题不大,但在智能手机上,这可能会造成问题。所以一定要小心创建和删除游戏物体。

2. 创建刀刃

接下来,我们需要创建刀刃。首先,我们创建一个空的游戏物体并命名为 Blade。然后,重置其位置。接下来,给它添加一个组件——Trail Renderer(拖尾渲染器)。拖尾渲染器会产生一个视觉效果,模拟刀刃移动时产生的尾迹。

现在,保存脚本并测试它。移动刀刃,调整 X 和 Y 的值,你会看到它创建了一个品红色的尾迹。由于我们尚未为刀刃设置正确的材质,所以它默认显示为品红色。接下来,我们需要为刀刃创建一个材质。

3. 创建刀刃的材质

首先,进入 Assets 文件夹,创建一个新文件夹,命名为 Materials。然后,在该文件夹中创建一个新的材质,并将其命名为 Blade。我们选择一个淡蓝色作为颜色,或者你可以选择任何你喜欢的颜色。我们将颜色设置为稍微明亮一些的蓝色。然后,将该材质拖拽到刀刃的材质槽中,测试一下效果。如果你将刀刃移动来回,你就会看到蓝色的尾迹。

4. 让刀刃跟随鼠标

接下来,我们要让刀刃的拖尾渲染器跟随鼠标。拖尾渲染器基于鼠标的世界坐标来移动。我们的游戏是 2D 游戏,但鼠标实际上位于 3D 世界中,所以我们将使用适合 2D 游戏的组件。

查看拖尾渲染器时,发现它保持存在的时间太长,这可能是由于默认的时间设置所致。将其修改为 0.2 秒,这样拖尾就会只存在 0.2 秒,生成短暂的拖尾效果。测试后你会发现拖尾更短,更符合预期。

5. 刀刃的宽度调整

为了使效果更好,我们还需要调整拖尾的宽度。你可以通过调整拖尾的 Key 来设置不同的宽度。在拖尾渲染器的曲线编辑器中,创建新的关键帧并调整它们的位置。这样,刀刃的拖尾就会呈现出更像刀刃的形态。

6. 刀刃的物理设置

为了让刀刃能够与物体发生物理互动,我们需要给刀刃添加 RigidBody2D 组件,并设置为 Kinematic,这样它就不会受到其他物体的影响,但仍然可以影响其他物体。我们还需要设置碰撞检测为 Continuous,以确保刀刃与物体的碰撞更加流畅。

7. 刀刃跟随鼠标的脚本

为了让刀刃能够随着鼠标移动,我们需要编写一个脚本来实现这一功能。首先,我们在脚本中创建一个 RigidBody2D 变量,并在 Awake 方法中初始化它。接着,我们编写一个名为 SetBladeToMouse 的方法,使用 Camera.main.ScreenToWorldPoint 将鼠标的屏幕位置转换为世界空间位置,进而使刀刃跟随鼠标。

private void SetBladeToMouse() {
    Vector3 mousePosition = Input.mousePosition;
    mousePosition.z = 10; // 设置 Z 坐标以确保正确的位置
    rb.position = Camera.main.ScreenToWorldPoint(mousePosition);
}

然后在 Update 方法中调用该函数,确保刀刃每帧都能根据鼠标位置更新。

8. 解决鼠标位置的 3D 坐标问题

有时候,直接使用 Input.mousePosition 会导致问题,因为鼠标位置是一个 3D 坐标,但我们的游戏是 2D 游戏。因此,我们需要手动调整 Z 坐标来确保它在正确的 2D 平面上。通过设置 Z 值为 10(这实际上是向摄像机推远 10 个单位),就可以确保刀刃能够正确跟随鼠标。

9. 触发器与水果切割

接下来,我们需要让刀刃切割水果。在 Fruit 脚本中,我们添加一个 OnTriggerEnter2D 方法。当刀刃与水果发生碰撞时,我们就切割水果。具体来说,当刀刃的碰撞器与水果的碰撞器相遇时,我们创建一个新的切割水果对象。

private void OnTriggerEnter2D(Collider2D collision) {
    Blade b = collision.GetComponent<Blade>();
    if (b != null) {
        // 切割水果的逻辑
        CreateSlicedFruit();
    }
}

同时,我们需要给刀刃添加碰撞器,确保它是触发器,并且水果的碰撞器也设置为触发器。

10. 调整刀刃的碰撞器大小

我们需要为刀刃设置适当的碰撞器大小,以便在切割水果时准确地检测到碰撞。通过调整刀刃的 Collider2D 的大小,使其与水果的大小匹配。测试时,如果刀刃与水果碰撞,水果就会被切割。

11. 切割水果

现在,当刀刃碰到水果时,水果就会被切割成两部分。你可以测试一下,确认水果是否能够正确切割。

下一步

在下一个视频中,我们将继续完善游戏,添加更多功能,优化游戏体验,提升游戏的视觉效果。

380 - GUI和炸弹

创建游戏管理器

我们首先要在游戏中添加一些UI元素,比如得分、最高分和一个计时器。为了实现这些功能,我们需要创建一个新的对象,命名为game manager,并为它添加一个脚本。

  1. 创建脚本
    在“scripts”文件夹中创建一个新的脚本,并将其命名为game manager。接着将该脚本拖到游戏管理器对象上。

  2. 编辑游戏管理器脚本
    打开并编辑game manager脚本,我们将先让它处理分数的计数。首先,我们需要一个整数来存储分数值,并且还需要一个文本对象来显示分数。因此,需要在脚本中添加Unity的UI命名空间:

    using UnityEngine.UI;

    然后声明一个public文本变量来显示得分:

    public Text scoreText;
  3. 增加分数的逻辑
    接着,我们创建一个方法来增加分数,每次调用时增加2分,并更新显示的文本:

    public void IncreaseScore()
    {
       score += 2;
       scoreText.text = score.ToString();
    }
  4. 回到Unity编辑器
    在Unity中创建一个空对象,命名为UI,并为其添加一个Canvas。在Canvas下,添加一个UI文本组件,并将scoreText拖到脚本中对应的位置。调整文本的大小和位置,将其设置为在左上角显示。

添加得分文本和最高分文本

接下来,我们设置一个显示得分和最高分的UI:

  1. 设置得分文本
    创建一个新的UI文本,命名为scoreText,并调整其大小、位置和颜色,例如将颜色设置为橙色。确保文本大小适合显示分数。

  2. 添加最高分文本
    复制scoreText并将其命名为highScoreText,然后将其放置在屏幕的底部。可以修改文本大小为36并显示“Best: 0”。

  3. 添加文本边框
    为文本添加一个轮廓效果,使其在不同背景色下更加清晰。在Unity编辑器中,选择UI组件,添加一个UI效果并选择Outline

将分数功能添加到代码中

现在,我们已经设置了UI,接下来要将分数更新逻辑连接到游戏中:

  1. 链接UI文本
    scoreText拖到game manager脚本中的scoreText字段上,并确保初始得分为0。

  2. 增加分数的调用
    在切水果的逻辑中,我们每次切到水果时,调用gameManager中的IncreaseScore()方法来增加分数。例如,在水果的脚本中,我们可以这样调用:

    GameManager gameManager = FindObjectOfType<GameManager>();
    gameManager.IncreaseScore();

    这样,每当切到水果时,分数就会增加。

添加游戏结束机制

为了使游戏更具挑战性,我们需要添加一些机制来停止游戏,例如计时器到期或切到炸弹时游戏结束。

  1. 添加炸弹
    我们需要将炸弹添加到游戏中。首先,找到炸弹模型并将其缩小(例如,设置其scale为0.3)。然后给炸弹添加物理组件,如Rigidbody2DCircleCollider2D

  2. 炸弹脚本
    为炸弹创建一个脚本Bomb,在其中添加OnTriggerEnter2D方法,当炸弹与刀片碰撞时,停止游戏:

    void OnTriggerEnter2D(Collider2D collision)
    {
       Blade blade = collision.GetComponent<Blade>();
       if (blade != null)
       {
           GameManager gameManager = FindObjectOfType<GameManager>();
           gameManager.OnBombHit();
       }
    }
  3. 暂停游戏
    gameManager脚本中创建OnBombHit()方法,当炸弹被切到时,我们停止游戏:

    public void OnBombHit()
    {
       Time.timeScale = 0;  // 停止游戏
       Debug.Log("Bomb Hit!");
    }

    使用Time.timeScale控制游戏的时间流速,0表示暂停。

随机生成炸弹和水果

为了让炸弹出现得更有挑战性,我们需要在游戏中随机生成炸弹和水果。修改水果生成逻辑:

  1. 修改物体生成器
    我们将物体生成器中的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)];  // 生成水果
    }
  2. 在Unity中设置生成物体
    将水果和炸弹的预制体拖到objectsToSpawn数组中,并调整炸弹出现的概率(例如,设为10%)。

  3. 测试游戏
    运行游戏,确保炸弹和水果按预期生成。如果切到炸弹,游戏应暂停并显示“游戏结束”。

总结

到这里,我们已经完成了游戏中得分、最高分显示、炸弹生成、以及游戏暂停等基本功能。在接下来的步骤中,我们可以进一步优化游戏的UI,添加更多的游戏玩法,例如计时器和重新开始游戏的功能。

381 - 游戏结束和重新开始

欢迎回来。在我们游戏的当前行为中,每当我们碰到一个炸弹时,游戏就会停止。显然,这不是我们想要的最佳行为。我们期望的是,在炸弹触发时,能够展示一个“游戏结束”界面,或者类似的东西。对于我们的情况,我认为最好的选择是暂停背景中的游戏,同时弹出一个面板,显示“游戏结束”,并允许我们重新开始游戏。今天我们就要实现这一点。

创建游戏结束面板

首先,让我们创建一个面板。我不想让它拉伸,只希望它居中,宽度设置为 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 方法中,我们需要做几件事:

  1. 将分数重置为 0。
  2. 更新游戏结束面板上的分数文本为 Score: 0
  3. 将游戏结束面板隐藏。
  4. 销毁所有当前存在的游戏对象(例如,炸弹、橙子等)。

我们可以通过查找带有标签“Interactable”的所有游戏对象,然后销毁它们。确保所有互动对象(如炸弹、橙子和切割橙子)都被赋予该标签。在 RestartGame 方法中,我们使用 FindGameObjectsWithTag 来找到所有带有这个标签的对象,并使用 Destroy 销毁它们。

完整实现

现在,我们可以测试游戏。在游戏过程中,获取分数后,碰到炸弹时,游戏会暂停,并显示游戏结束界面。点击重启按钮时,游戏会重置,分数清零,并销毁所有当前的游戏对象。

总结

通过这一系列的步骤,我们实现了以下功能:

  1. 创建了游戏结束面板,并在游戏结束时激活它。
  2. 显示当前分数,并在游戏结束时更新。
  3. 添加了一个重启按钮,可以重置游戏,清除游戏对象,并恢复初始状态。

在接下来的视频中,我们将为游戏添加高分功能,目前虽然显示了分数,但我们还没有保存任何分数数据。我们将在之后的教程中完成这个部分。

382 - 添加高分

欢迎回来!在本视频中,我们将添加高分功能。正如你在这里看到的,当前显示的“最佳得分”是零。接下来,我们将实现一个功能,使其能够真正记录和显示最高得分。

创建高分文本变量

首先,我们需要在 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() 事件中。

测试

一旦完成了上述所有步骤,就可以测试这个功能。你应该能看到以下行为:

  1. 游戏开始时,显示当前的高分。
  2. 游戏中得分增加时,检查是否超过了当前的高分,并更新高分。
  3. 游戏结束时,显示当前得分和高分。
  4. 点击重启按钮时,重置得分,并且高分保持不变。

这样,我们就成功地实现了高分系统!在下一个视频中,我们将学习如何在 Blender 中创建自己的水果模型,并将其添加到游戏中。

383 - 扩展游戏

欢迎回来!在本视频中,我们将把之前创建的所有不同模型添加到游戏中。你可以将它们与我们已经创建的橙子 prefab 进行比较,按照同样的方法构建西瓜、香蕉等其他水果的 prefab,并使它们能够在游戏中随机生成并正常工作。

1. 添加西瓜模型

首先,我们将添加西瓜模型。将西瓜模型拖入场景中,看到它很大,因此我们需要将它缩小。将西瓜的缩放值设置为 0.4 并应用。

同样,我们对切开的西瓜模型进行相同的操作,缩放至 0.4 并应用。

接下来,为西瓜添加所需的组件:

2. 添加切开的西瓜模型

接下来,我们处理西瓜切块的部分。我们给每一块切开的西瓜添加 RigidbodyBox Collider。这部分我们可以简化,使用 Box Collider 来代替 Circle Collider。记得,切开的西瓜模型不需要触发器。

完成这些后,将切开的西瓜对象拖入 Prefabs 文件夹中。然后,将切开的西瓜 prefab 拖入西瓜水果的 Sliced Fruit Prefab 变量中。

3. 添加香蕉模型

然后,我们进行香蕉的设置。将香蕉模型拖入场景中,同样需要缩放至 0.3 并应用。接着,进行同样的操作,为切开的香蕉添加缩放设置。

为香蕉添加以下组件:

4. 添加切开的香蕉模型

对于切开的香蕉模型,我们需要为每一块添加 RigidbodyBox Collider。使用两个 Box Collider 来为两块香蕉建模。完成后,我们将切开的香蕉拖入 Prefabs 文件夹,并将切割后的香蕉 prefab 拖入香蕉的 Sliced Fruit Prefab 变量中。

5. 配置水果的随机生成

现在,我们将水果添加到游戏中的随机生成器中。在 Spawner 脚本中,目前只有橙子作为生成的水果。我们要为香蕉和西瓜添加随机生成的选项。将 香蕉 拖到 Spawner 脚本的第二个位置,将 西瓜 拖到第三个位置。

如果你有其他的水果模型,比如苹果、椰子等,可以继续将它们添加到这里。

6. 测试游戏

现在,我们开始测试游戏。启动游戏并看看是否能够随机生成橙子、香蕉和西瓜。当你切割这些水果时,它们应该会被销毁,并且当游戏结束时,所有水果都会正确消失。

通过这一系列步骤,你已经成功地将多个水果模型添加到了游戏中,并实现了它们的随机生成与切割功能。

7. 游戏的进一步完善

至此,我们的游戏已经接近完成,类似于 Fruit Ninja 的游戏已经有了基本的水果切割功能。接下来的步骤将是准备游戏的 Android 版本。在下一个视频中,我们将一起看如何准备 Android 版本的游戏。

384 - 为Android准备代码

欢迎回来!在本视频中,我们将准备代码,使得游戏不仅可以在电脑上运行,还可以在 Android 上运行。目前,我们的游戏使用鼠标进行操作,但是在 Android 设备上,我们需要使用触摸屏。因此,我们需要做一些调整,以确保只有在鼠标或触摸屏发生移动时,刀刃才会激活,水果才会被切割。

1. 目标

2. 代码修改

为了实现这一目标,我们需要检查鼠标是否在移动,而不是仅仅检测鼠标的当前位置。具体步骤如下:

2.1. 新增变量

我们需要添加以下变量:

public float MinVelo = 0.1f; // 最小速度,只有当鼠标移动的速度超过这个值时,才认为是有效的移动
private Vector3 lastMousePosition;
private Vector3 mouseVelocity;
private Collider2D call;  // 刀刃的碰撞器

2.2. 初始化碰撞器

Awake 方法中初始化刀刃的碰撞器:

void Awake()
{
    call = GetComponent<Collider2D>(); // 获取刀刃的 2D 碰撞器
}

2.3. 判断鼠标是否在移动

接下来,我们需要创建一个方法来判断鼠标是否在移动。这将通过比较当前鼠标位置与上次鼠标位置的差异来计算移动距离。如果移动距离大于最小速度值,则认为鼠标在移动。

bool IsMouseMoving()
{
    Vector3 currentMousePosition = transform.position; // 获取当前鼠标位置
    float traveled = (lastMousePosition - currentMousePosition).magnitude; // 计算鼠标移动的距离

    lastMousePosition = currentMousePosition; // 更新最后的鼠标位置

    if (traveled > MinVelo) // 如果移动的距离大于最小速度,则返回 true,表示鼠标在移动
        return true;
    else
        return false;
}

2.4. 更新刀刃碰撞器的激活状态

Update 方法中,我们检查鼠标是否在移动,并据此激活或禁用刀刃的碰撞器。

void Update()
{
    call.enabled = IsMouseMoving(); // 只有当鼠标移动时,刀刃的碰撞器才会被启用
}

3. 测试代码

保存脚本并回到游戏中进行测试。在游戏运行时,如果你没有移动鼠标或触摸屏,水果将不会被切割;只有当鼠标或触摸屏发生明显移动时,刀刃才会激活并切割水果。

通过这种方式,我们确保了只有在用户实际进行移动时,刀刃才会起作用,从而模拟了触摸屏的行为。

4. 下一步

现在,代码已经准备好进行 Android 版本的移植。我们将在下一个视频中配置 Android Studio,为 Android 设备准备游戏。


这段代码和测试确保了游戏能够根据设备的输入方式(鼠标或触摸屏)来正确激活刀刃,避免无效的切割事件。

385 - 在Android设备上测试

1. 介绍

在本视频中,我们将介绍如何将游戏导出并在 Android 设备上运行。由于我自己没有 Android 设备,所以下面会展示一段朋友录制的视频,演示如何设置你的 Windows 机器来运行游戏。

2. 安装 Android Studio

首先,你需要安装 Android Studio,并在 developer.android.com 网站上下载它。

步骤:

  1. 访问 Android Studio 官网
  2. 下载并安装 Android Studio(大约 1.9GB)。
  3. 在安装过程中,选择 Android Studio, Android SDK, 和 Android Version,然后点击 Next 完成安装。

3. 配置 Android SDK 路径

安装完成后,你不需要启动 Android Studio,只需要进行以下配置:

步骤:

  1. 打开 文件资源管理器,并搜索 文件夹选项
  2. 在文件夹选项中,启用 显示所有文件和文件夹,因为我们需要访问隐藏的文件夹。
  3. 进入以下路径:C:\Users\<你的用户名>\AppData\Local\Android\SDK,复制该路径。

4. 配置 Unity 与 Android Studio

现在,打开 Unity,并将 Android SDK 路径配置到 Unity 中:

步骤:

  1. 打开 Unity,进入 Edit > Preferences
  2. External Tools 中找到 Android SDK 设置。
  3. 将刚刚复制的 SDK 路径 粘贴到该设置中,然后保存并关闭。

5. 连接 Android 设备

通过 USB 将 Android 设备连接到计算机。

步骤:

  1. 在 Unity 中,进入 Build Settings,选择 Android 平台。
  2. 选择你想要的场景,点击 Switch Platform,Unity 将切换到 Android 平台。
  3. 进入 Player Settings,确保游戏始终以横屏模式显示,选择 Resolution and Presentation,将 Default Orientation 设置为 Landscape Left

6. 配置 Android 构建设置

接下来,配置 Android 的其他设置:

步骤:

  1. Player Settings 中,进入 Other Settings
  2. Package Name 处,输入如 com.<你的公司名称>.test
  3. Product Name 处,输入类似 Fruit Ninja Clone
  4. 设置版本号。

7. 构建和运行游戏

步骤:

  1. 保存场景和项目。
  2. Build Settings 中点击 Build and Run
  3. Unity 将生成 APK 文件,并将其安装到 Android 设备上。

8. 在 Android 设备上测试游戏

安装完毕后,游戏将直接在你的设备上运行。你可以测试游戏是否正常工作,切割水果和碰到炸弹时的效果。

9. 下一步

虽然游戏已经可以运行在 Android 设备上,但显示可能有些小。接下来我们将在下一视频中进行一些界面调整,确保游戏在 Android 上显示正常。


通过以上步骤,你可以将游戏导出为 APK 文件并在 Android 设备上运行,确保游戏可以在 Android 手机上直接体验。

386 - 做一些调整

1. 修改背景颜色和界面调整

在本视频中,我们将对游戏的界面做一些调整,包括更改相机的背景颜色和优化 UI 设置。

步骤:

  1. 更改相机背景颜色:

    • 打开 Unity 中的相机设置。
    • 将相机的背景颜色修改为深灰色,这样可以使游戏界面看起来更加清晰。
  2. UI 画布设置:

    • 在 UI 画布(Canvas)中,选择 Canvas 组件。
    • Canvas Scaler 选项中,设置 UI Scale ModeScale With Screen Size
    • 这里我们使用的参考分辨率为 800x800,并将 Match Width Or Height 设置为 0.5。这样 UI 会根据屏幕的分辨率自动进行缩放。
    • 通过调整这些设置,UI 会在不同尺寸的屏幕上自动适应。

2. 处理游戏结束面板

接下来,我们将对游戏结束面板(Game Over Panel)进行一些调整,以提升界面的视觉效果。

步骤:

  1. 更改游戏结束面板背景颜色:

    • 选择 Game Over Panel,并将其背景颜色调整为一种较深的灰色,稍微比背景色深一些。
  2. 调整相机背景颜色:

    • 将相机背景颜色设置为稍微浅一点的灰色,以提供更好的对比度。
  3. 调整重启按钮和文本:

    • 选择 Reset Button,并将其稍微向下移动,同时增大其尺寸。
    • 修改 Restart ButtonReset Text 的字体大小,使其更易读。
  4. 调整分数文本:

    • 修改 Score TextGame Over Text 的位置,使其更加协调。
    • Game Over Text 的颜色更改为白色,以提高可读性。

3. 保存并测试

完成这些调整后,保存场景,并构建和运行游戏,确保它在 Android 设备上能够正常显示。

步骤:

  1. 在 Unity 中,保存场景和项目。
  2. 构建并运行游戏,测试在 Android 设备上的效果。

4. 最终效果

通过这些调整,游戏的界面看起来更加协调,颜色对比度也有了显著提升。尤其是 重启按钮,虽然还可以进一步优化,但已经明显比之前更大并且适应得更好。

这些修改为用户提供了更友好的界面,使游戏体验更加顺畅。

387 - 为你的游戏添加Unity广告

1. 添加广告到游戏中

在本视频中,我们将学习如何为游戏添加广告。通过添加广告,你可以监控游戏的表现并通过广告获得收益。我们将使用 Unity Ads 来实现这一功能。

步骤:

  1. 创建 Unity 账户:

    • 首先,你需要一个 Unity 账户。前往 Unity 官网(unity.com),并创建一个 Unity ID。如果你没有账户,请注册一个。
  2. 启用 Unity 广告服务:

    • 登录到 Unity 账户后,打开 Unity 编辑器,并进入 Window > General > Services 选项。
    • 在这里,选择 Ads 选项,点击 Activate 来启用广告服务。
    • 你可以选择是否将游戏设置为针对 13 岁以下的儿童。如果不是,跳过这一步即可。
    • 激活后,你将看到支持的平台,包括 Android、iOS 等。继续设置。
  3. 获取游戏 ID:

    • 在 Unity 编辑器中,前往 Services > Dashboard,你可以找到你的游戏 ID(例如 Android 和 iOS 的不同 ID)。复制 Android 平台的 ID。
  4. 在代码中添加广告:

    • 打开 GameManager 脚本,在脚本中添加广告所需的命名空间:
      using UnityEngine.Advertisements;
    • Awake() 方法中,初始化广告:
      Advertisement.Initialize("your_game_id");

      这里,"your_game_id" 是你从 Unity 服务中获取的 ID。

  5. 显示广告:

    • 你可以选择在特定事件中展示广告,例如玩家点击炸弹时:
      if (Advertisement.IsReady())
      {
       Advertisement.Show();
      }
  6. 测试广告:

    • 在游戏中测试广告时,建议启用 Test Mode,这样你可以在开发过程中避免产生实际费用。
    • 保存并构建游戏,然后在设备上测试广告的展示效果。每当你触发广告条件(如点击炸弹),广告将会展示。

2. 在游戏中集成广告的最佳实践

通常,仅仅展示广告并不是最赚钱的方式。为提高广告的盈利效果,最好结合奖励机制。例如,当玩家观看完 30 秒的广告后,可以给玩家一些游戏内奖励,如金币或其他资源。

步骤:

  1. 设置奖励广告:

    • 使用 Unity Ads 的奖励广告类型(Rewarded Video)。当玩家观看广告时,他们可以获得奖励,这样可以提高广告观看的频率和效果。
  2. 激励系统设计:

    • 在你的游戏设计中,考虑如何通过广告来为玩家提供激励。比如,玩家观看广告后获得额外的生命、道具或其他游戏资源。这不仅能增加广告的观看率,还能提高用户的满意度和参与度。
  3. 避免自己点击广告:

    • 如果你经常点击自己的广告,Unity 可能会对你进行惩罚,甚至禁用广告服务。所以,在开发和测试期间,一定要避免点击自己的广告。可以使用测试账户来模拟广告观看。

3. 完整的广告设置流程

以下是广告集成的总结步骤:

  1. 创建 Unity 账户并登录。
  2. 在 Unity 编辑器中启用广告服务。
  3. 获取游戏 ID,并将其添加到代码中初始化广告。
  4. 选择适当的时机展示广告,例如触发事件时(如点击炸弹)。
  5. 启用测试模式,在开发过程中测试广告效果。
  6. 考虑增加奖励广告和激励机制,以提高广告的盈利效果。

4. 结语

通过这些步骤,你现在可以在 Unity 中成功集成广告,并通过广告获得收入。同时,合理设计奖励系统,可以让玩家更愿意观看广告,从而提高游戏的盈利潜力。在下一视频中,我们将讨论如何避免因点击自己的广告而导致的处罚问题。

388 - 将你的设备设置为开发者设备

1. 确保广告不会为你带来收益

在上一步中,你已经学习了如何为游戏添加广告,并且通过 Unity Ads 将其连接到 Play Store。现在,我们需要确保你的设备不会因测试广告而为你带来收益。为此,我们将注册测试设备,并确保在测试过程中你不会从点击广告中获得任何收入。

步骤:

  1. 登录到 Unity Dashboard:

    • 进入 Unity Dashboard,登录到你的 Unity 账户。你可以在 Services 页面看到与游戏相关的统计数据,包括未支付的收入。
    • 如果你还没有收入,可以看到类似 未支付的收益(unpaid earnings) 这样的提示信息。
  2. 注册测试设备:

    • Dashboard 中,选择 Add Test Device 来注册你的设备。这是为了确保在测试广告时,你的点击不会产生任何实际的收入。
  3. 查找广告 ID:

    • 在 Android 设备上,前往 Google 设置 > 其他服务 中,找到 我的广告 ID(My Ad ID)。复制这个广告 ID。
    • 将此 ID 输入到 Unity Dashboard 中注册设备时的 Ad ID 栏目中。
  4. 完成设备注册:

    • 输入设备名称和广告 ID 后,点击注册。这样,你的设备就会被标记为测试设备。
  5. 测试时的效果:

    • 现在,你可以在设备上观看广告并进行测试,但由于这是测试设备,你点击广告时不会为你产生任何收入。这样,你可以安全地进行广告调试和测试,而不必担心产生不必要的收入。

2. 结语

通过将设备注册为测试设备,你可以在不产生实际收入的情况下进行广告的测试。这对于开发和调试广告功能非常重要,尤其是在你还在开发阶段时,确保你的广告收入不会因为自己点击广告而受到影响。在发布到 Play Store 或其他平台之前,确保完成了这些设置,以避免因误操作而被处罚。

389 - 添加声音

1. 创建自己的声音效果并导入游戏

在这段视频中,我们将介绍如何创建并使用你自己的声音效果。这个过程非常简单,我们将使用嘴巴模拟一些基本的声音,然后将其用于我们的游戏。为了录制和编辑声音,我们将使用 Audacity,这是一款免费的录音和编辑软件,非常适合制作声音效果。

步骤:

  1. 下载并安装 Audacity:

    • 首先,前往 Audacity 官网 下载并安装软件。支持 Windows 和 Mac 系统。最新版本为 2.2。
    • 下载并安装完成后,打开 Audacity。
  2. 录制声音:

    • 在 Audacity 中,按下 录音按钮 开始录制声音。例如,我们要模拟一个刀切水果的声音。
    • 你可以选择使用的麦克风(例如外接麦克风)。尝试录制不同的声音,直到你满意为止。
  3. 编辑录制的音频:

    • 删除你不需要的部分。选中不需要的区域并删除它们。
    • 例如,去除声音开头的空白部分,确保声音没有延迟开始。
    • 放大音频,精确调整开始和结束位置,确保声音效果是平滑的。
  4. 保存和导出音频文件:

    • 在完成录制和编辑后,选择 另存为 并选择 WAV 格式
    • 将文件命名为例如 "slash.wav",并保存在你希望的位置。
  5. 将声音导入到 Unity 中:

    • 打开 Unity,进入 Assets 文件夹,将刚才创建的音频文件拖放到项目中。
    • 为了更好地管理,可以在 Assets 中创建一个 Sounds 文件夹,并将声音文件放入其中。

2. 在 Unity 中使用声音

现在,我们已经将声音文件导入到 Unity 项目中,接下来我们将在游戏中播放该声音。

步骤:

  1. 在游戏管理器中添加声音变量:

    • 打开 GameManager 脚本,并添加以下变量:
      • AudioClip[] sliceSounds:这是一个数组,用于存放多个切割声音效果。
      • AudioSource audioSource:这是一个私有的音频源,用于控制声音播放。
  2. 初始化音频源:

    • Awake 方法 中,初始化 audioSource
      audioSource = GetComponent<AudioSource>();
    • 确保 GameManager 上有一个 AudioSource 组件,这样才能播放声音。
  3. 为切割声音创建播放方法:

    • 创建一个方法,名为 PlayRandomSliceSound,用于随机播放切割声音:
      public void PlayRandomSliceSound() {
       AudioClip randomSound = sliceSounds[Random.Range(0, sliceSounds.Length)];
       audioSource.PlayOneShot(randomSound);
      }
    • Random.Range 用来从 sliceSounds 数组中随机选择一个声音。
  4. 调用声音播放方法:

    • 在游戏中,当发生切割水果的事件时(例如玩家点击切水果的地方),调用 PlayRandomSliceSound 方法:
      // 例如,在切水果的代码中添加以下代码
      GameManager.instance.PlayRandomSliceSound();
  5. 测试声音效果:

    • 回到 Unity 编辑器,确保 AudioSource 组件已经正确添加到 GameManager 上。
    • GameManager 的 Inspector 中,找到 sliceSounds 数组并拖放音频文件(例如之前的 "slash.wav")到数组元素中。
    • 禁用 Play On Awake 选项,因为我们将手动控制声音播放。
  6. 运行游戏进行测试:

    • 按下 Play 按钮开始游戏。当玩家进行切割操作时,应该会听到声音效果。

3. 扩展功能

4. 结语

现在,你已经知道如何创建简单的音效并将其导入 Unity 游戏中。通过这些基本步骤,你可以轻松地为游戏中的各种事件添加音效,提升游戏体验。如果你有其他功能需要添加,或者觉得有缺失的部分,可以自己尝试添加或者向我们反馈,我们将很高兴看到你如何改进和扩展这个项目。

WangShuXian6 commented 2 weeks ago

27 - 感谢您完成课程

390 - 感谢您完成课程

1. 课程结束感言

好了,你已经完成了这门课程。在这两年的时间里,我意识到自己没有在课程的结尾留下最终的感言,而现在是时候补上了。你可能注意到,我看起来比两年前好了一些,我希望你喜欢我现在的模样,但这并不是重点。这段视频的重点是感谢你。

我非常感激你依然陪伴着我走到这一步,感谢你在这段超过 30 小时的课程中一路走来。我知道要花费大量时间来完成这门课程并掌握其中的知识,但如果你认真且持续地学习,那么你已经成为了一名真正的开发者。我由衷地感激你做到了这一点,也感激能有机会教你这些内容。

2. 我的愿景与目标

我的愿景是教授 1000 万人如何编程,听起来这个目标或许很疯狂,但它一直在我的心中。虽然你不能看到我写在纸上的愿景,但我可以告诉你,这个目标我已经为自己设定,并且深信不疑。

你可能会问,为什么是 1000 万人?我的想法是,如果我能帮助 1000 万人学会编程,那么其中的一部分人将会开发出改变我们所有人生活的软件。试想一下,如果像扎克伯格这样的天才程序员从我这里学习编程,虽然现在他已经是一个了不起的开发者,并且拥有像 Facebook 这样改变世界的巨大公司,但假设在未来有某个新的软件创新,可能我无法预见它的样貌,但有一个从我这里学习的开发者,或许就是你,能创造出改变全球的应用软件。

我相信从这 1000 万人当中,哪怕只有 1000 人成为杰出的开发者,那么他们的成功将是我教导的一部分。那就是我的目标,也就是我所坚持的理念。为了实现这一点,我一直在制作这些视频,去帮助更多的人实现梦想。

3. 鼓励与祝福

因此,我非常感谢你坚持走完了这门课程,感谢你从我这里学习了编程。我希望你现在能够创造出一些伟大的软件,用它来改变世界,或者至少改变你自己的世界。其实改变世界不一定意味着影响全球,有时候,仅仅是改善你自己的生活、为你的家庭带来更好的未来,获得一份更好的工作,甚至创办自己的公司,开始创业,这些也是非常值得的目标。

甚至,如果你只是将编程当作一种爱好,做一个简单的游戏,带给几个人快乐,或者让一小部分人感到愉悦,那也是非常美好的。其实能做到这些,就已经很了不起了。

我衷心希望你能在这条编程的路上走得更远,取得更大的成就。我非常感谢你和我一起走过这段旅程,并祝你一切顺利!

4. 未来展望

我还会继续开设更多课程,我会让这些课程越来越好,因为在这过程中,我自己也在不断学习、不断进步,不仅作为一名开发者,也作为一名讲师。所以,我希望你能继续关注我的课程,未来会有更多有用的内容和教学资源提供给你。

此外,我也将推出一个网站 tutorials.eu,这是我发布教程和编程相关博客文章的平台。今天我们将首次发布博客文章,未来,当你观看这段视频时,网站上可能已经有成百上千篇文章可以供你学习了。而且,我们将制作每篇博客的配套视频,让你不仅能通过文字学习,还能通过视频进一步理解。我们绝不会仅仅满足于写文章,而是会结合视频内容提供更深入的学习体验。

5. 结语

说实话,我可能有点语无伦次了,但我真的非常感谢你与我一起完成这门课程,祝愿你在未来的编程之旅中一切顺利。