65.9K
CodeProject 正在变化。 阅读更多。
Home

一次回答多个问题:带有窗体的交互式动画图形

2017 年 3 月 11 日

CPOL

44分钟阅读

viewsIcon

48191

downloadIcon

1067

解答关于图形、带 UI 的多线程、窗体开发、打印等方面的问题

引言

世界上有三样东西可以让你看一辈子:炉火跳动的火焰,溪水流淌的流水,还有一个金发女郎在倒车。

V. Pupkin, 工程师

目录

动机

与地球生物多样性保护问题相关的学科,就存在“热点地区”的概念。如果我们只保留地球上 36 个有限区域内的所有生物物种,就可以保留地球上约 60% 的物种。

我回答 CodeProject 提问者问题的经验(撰写时已有 19,614 个回答)表明,大多数问题可以通过对 3-5 个领域的技术进行全面解释来解答。因此,我想到可以用一篇文章来解答我在这里遇到的大约 ⅕-⅓ 的初学者问题。此外,我还发现我可以用一个应用程序来演示这些技术和答案。

这个名为“弹跳球”的应用程序代表了一个简单的动画二维物理模型:一个在均匀引力场中弹跳的球,它会撞到四壁,并且存在一些能量损失,等等

那么,我们开始吧……

解决的问题

  1. 我在窗体上使用 Graphics 进行绘制,但当我最小化窗体并再次显示时,所有图形都消失了。我哪里做错了? [★]
  2. 如何在 PictureBox 中移动图像? [★]
  3. 我需要打印窗体中显示的图形并将其保存为位图。如果不用 PictureBox,我该如何做到? [★]
  4. 我想用代表图形的线条连接窗体上的控件,但在匹配窗体坐标和控件坐标时遇到了许多问题。我该如何做到? [★]
  5. 在尝试显示窗体时,我突然收到“为了防止加载设计器时可能丢失数据,必须解决以下错误”错误。项目编译并运行正常,但我无法修改窗体。我无法使用调试器找出并修复问题,因为当我执行应用程序时,我从未遇到异常。我该怎么办? [★]
  6. 我开发了一个带有图形的自定义控件。当我点击它或使用 Tab 键导航到它时,它不会获得焦点。怎么了? [★]
  7. 我正在使用线程进行计算,需要将计算结果输出到窗体上的文本框中,但当我尝试为其 Text 属性赋值时,会收到“无效的跨线程操作”异常。出了什么问题? [★]
  8. 当我运行应用程序并关闭它时,Visual Studio 会显示进程状态为“正在运行”,直到我点击“停止调试”菜单项。如果我在独立模式下运行应用程序,Windows 任务管理器会显示进程在我关闭主窗口后仍在执行。如果我启动并关闭同一个应用程序几次,任务管理器会显示多个应用程序实例。我该如何避免这种情况? [★]
  9. 我有一个动画控件,用于模拟简单的机器人运动:绕一个轴旋转。整个运动的时间是根据实际机器人速度计算的。该计算计算定时器滴答之间的时间间隔、滴答之间角运动的量以及完成运动所需的滴答数。不仅动画运动与机械机器人不同步,而且看起来也不流畅。有时对象会随机冻结一段时间,有时会突然跳跃。如何实现流畅的动画? [★]
  10. 我如何避免应用程序因未处理的异常而终止? [★]
  11. 如何定义应用程序版本?如何使用它? [★]
  12. 我正尝试将数据保存在“C:\Windows\MyApplication”文件夹中,但不起作用。如何做到? [★]
  13. 如何找到位于我的应用程序所在目录中的数据文件? [★]
  14. 我如何在一个不同的线程中调用我的方法? [★]
  15. 我如何打印我的窗体? [★]

答案单独放在此处,在解释所有技术的示例应用程序的描述之后。点击 [★] 查看相应问题的答案。

演示应用程序:弹跳球

我选择了一个相当简单的机械运动模型,它涵盖了计算机动画和交互式图形的几乎所有方面。它产生了逼真的机械运动印象,唤起了作为引言的笑话所描绘的情绪。

同时,这可能是最原始的真实机械运动示例 — 使用仅两个自由度的单个对象的长时间运动。它允许我以非常简单、明确的方式演示动画和交互式图形的所有编程方面,并涵盖所有潜在问题。

应用程序描述

应用程序的典型外观如图顶部图片所示。该图片显示了运动开始前的一刻,代表了定格的概念。

该应用程序表示一个球在二维空间中移动,处于某个均匀引力场中,它可以碰撞一个矩形区域的墙壁;碰撞不是完美弹性非弹性的;相反,每次碰撞都会损失一部分球的动能;在当前模型中,可以指定能量损失的固定百分比。此机制,以指定的阻尼百分比形式表示,是运动阻尼机制之一。

阻尼的第二种机制,在本运动模型中被考虑的是表面摩擦力,当球停止弹跳并接触到底面时,摩擦力开始起作用。从弹跳到摩擦的过渡时刻是模型中最微妙的方面。在最简单的模型中,球会无限弹跳。高级模型应考虑碰撞过程中球的变形以及表面微观缺陷的尺度特征,这些缺陷的范围是负责摩擦力模型中摩擦力的分子力。此外,还应考虑球的旋转。是的,像从弹跳球过渡到表面接触运动这样简单的行为,实际上取决于这些微妙的因素。同时,这种过渡的微妙细节在看似真实的运动中并不真正显眼,而这在一些简化的模型中是可以显示的。我将在“物理”部分解释这个细节。

对我们来说,更重要的是动画运动取决于两个因素:根据拉普拉斯确定论,力学定律完全决定了任何机械系统的终身行为,以及用户的干预。用户可以根据“移步换景”原则,停止和重新启动运动,改变房间的几何形状/尺寸,球的速度和环境的物理参数,并最终终止应用程序或启动它。因此,这两种因果关系源的协作和交互将是我们这个小研究的主要课题。

渲染图形

开发中的第一个自然步骤应该是图形渲染屏幕,这取决于系统的当前状态,而无需考虑导致该状态的原因。最终,该状态将取决于用户在窗体中输入的运动参数集、主窗体的大小以及运动的完整历史记录,从初始坐标和球的速度向量开始。

图形渲染的主要思想直接体现在应用程序的片段中

partial class BallScene : Control {

    protected override void OnPaint(PaintEventArgs e) {
        Draw(e.Graphics, null);
    } //OnPaint

    // ...

} //class BallScene

在这里,我们创建了一个自定义控件类 BallScene,它继承自 System.Windows.Forms.Control,并通过重写其虚拟方法 OnPaint 来定义其图形如何渲染。

我想让读者注意一个小事实:inherited OnPaint没有被调用。为什么?这一点很重要。继承的 Control.Paint 方法执行事件 System.Windows.Forms.Control.OnPaint 的调用。如果 inherited OnPaint 没有被调用,事件处理程序将不会被调用。或者,我们可以选择不重写 OnPaint。相反,我们可以处理 Paint 事件并在处理程序中执行相同操作。在这种情况下,这没有多大意义。我们无论如何都需要创建一个控件类(显然,因为它包含了所有渲染逻辑),所以重写 OnPaint 方法将是最简单的选择。此外,如果我们不需要调用继承的方法,这对性能来说会更好一些。

两个机制选择哪个?这取决于,而且差别不大。处理 Paint 事件可能很有用,如果我们只是使用现有类,而不是派生任何类并编写自己的类。在这种情况下,我们将没有任何用于重写的 OnPaint 方法的内容。另一方面,支持 Paint 事件可能至关重要,如果我们必须为其他用户提供控件类库。在极少数情况下,我们可能希望阻止类的用户进行任何自定义渲染。在所有其他情况下,这就是用户所期望的。

此时,我省略了 Draw 函数的细节。它只是使用 System.Drawing.Graphics 方法提供的图形图元来根据整个系统的当前状态绘制场景。从这个 Draw 方法中抽象出来非常重要;我将在“打印、图形导出和图形渲染是同一回事”部分进行解释。

交互式图形

作为第一步,我选择反映用户输入的场景参数的变化。这不仅是最自然和最简单的下一步,也是我推荐用于开发许多类似应用程序的步骤。事情是这样的:这是中间测试最方便的点,以确保问题的纯渲染部分已正确完成。当我们引入动画时,验证会更难。在此步骤中,我通过交互式鼠标单击和键盘事件对球的运动进行了原型化。从这个开发阶段开始,球的位置就可以由用户手动控制。稍后,当添加动画时,手动控制球的位置允许用户暂时拦截球的运动,将其位置更改为当前房间空间内的任意位置。

交互式行为的本质非常简单。我们需要物理模型的、完全抽象于图形或 UI 的数据层。Draw 方法的实现(在上面的代码片段中调用)获取所有数据并据此绘制场景。那么,当用户使用 UI 更改其中一些参数时会发生什么?显然,场景应该被重新渲染,全部或部分。这将在响应任何此类事件时完成,然后会以某种方式触发对 OnPaint 方法的调用。这是通过其中一个方法 Control.Invalidate 完成的。这样,整个控件都可以被无效化,或者无效化可能只触及场景上的某个区域。

无效化机制正是我们所需要的。它在 Windows 中得到了高度优化。它基于 Windows API ON_PAINT消息。理解这个特定消息由 Windows 不同于正常的​​消息分派机制来处理是很重要的。特别是,如果几个无效化请求发生在第一个无效化在渲染中得到体现之前,Windows 会将无效化请求累积在一个控件客户区点集的子集中,显然,通过执行集合析取(OR)操作。这样,就消除了对 OnPaint 方法的冗余调用。

事实上,我们已经涵盖了动画和交互式图形所需的主要技术。对于动画,一切都以相同的方式进行。我们只需要更改数据并逐帧重新渲染场景。我们只需要为问题添加时间维度,并弄清楚系统的状态应该如何随时间修改。

动画图形

要概览动画,如果它主要是动画,就足以将以下部分组合在一起:1)我们需要根据先前状态和时间来编程系统状态的变化,同时考虑系统的机械参数,可能由用户动态修改;例如,在计算机游戏中,这被称为“物理”;2)创建更改的事件序列(读作:“无效化”);3)对于每一帧,从系统时钟获取当前时间,并根据时间值,将“物理”应用于当前状态,以获得下一帧要渲染的系统状态。

此时,下一步是处理多线程。顺便问一下,为什么不用定时器?让我们看看…

为什么不用定时器?

为什么不用定时器呢?嗯,只是因为定时器对于此目的来说确实很糟糕,处理它们非常复杂且不可靠,尽管很容易获得最初看似有效的结果。讨论定时器会让我们离本文主题太远,但我必须解释一下。主要的误解是定时器可以提供周期性事件的想法。理想情况下,这会很好,但实际上并非如此。首先,.NET BCL 中有许多不同的定时器,我必须警告读者一种特别不应使用的定时器:System.Windows.Forms.Timer。由于其极其糟糕的质量,它根本不应用于任何几乎是周期性的事件。为什么会有这种定时器?它有什么好处吗?是的,它的优点是易于使用:它的 Tick 事件的处理程序在UI 线程中被调用,这消除了与其他线程需要进行的跨线程调用的需求。但它如何能以如此差的精度使用呢?一个简单的例子是延迟显示,例如,一个闪屏窗口,其中确切的时间并不关键。

另一个,也许更大的问题,与所有定时器相关的问题是代码可靠性的复杂性。大多数能够运行 .NET 应用程序的操作系统都不是实时系统,就像世界上大多数操作系统一样。在实践中,周期性事件序列是一个神话。尽管时间精度很高,但在所有非实时多线程系统中,硬件定时器事件与实际的(例如)渲染之间的时间延迟是非确定性的值。更重要的是,事件处理的时间也是非确定性的。你是否曾经试图处理这种情况,即在处理上一个事件的调用仍在进行时,下一个定时器事件就被调用了?我认为,这就足够了,但最糟糕的是这个问题的非确定性。应用程序可能工作很长时间,最终在最尴尬的情况下失败。

真正的问题是:我们是否真的需要严格周期性的事件序列?答案是:不需要,对于动画来说不是。在渲染帧的那一刻知道当前准确时间就足够了。通过一个在某个循环中运行的线程,我们不仅可以提供更好的精度,而且循环迭代之间的一些时间差异也不会被注意到。下面,我将解释线程应如何处理时间。

话虽如此,我们可以开始设计多线程了。

线程包装器

首先,此应用程序和广泛的应用程序的多线程设计的主要思想是:我们只需要两个线程:一个运行 UI 的主线程,或者说UI 线程,另一个应该代表运动的场景并逐帧实现所有“物理”。我称第二个线程为动画线程。关键方面是动画线程的生命周期。它应该在应用程序生命周期的非常开始时(尽管主窗体已经渲染)被创建,只创建一次,并在结束时终止,也只终止一次。在整个应用程序期间,该线程应被重用,即使运动被中止或停止。此外,可以通过节流机制有效地暂停/恢复它。这种设计带来了很多好处,首先是可靠性。而且,对于其目的来说,它并没有过度简化。

线程包装器的概念值得专门写一篇文章。在当前实现中,我们不需要开发这种类的最通用的形式,所以它可以是特定于动画的。这个类非常重要,以至于最好显示其完整的代码。

namespace BouncingBall.Thread {
    using System;
    using System.Drawing;
    using System.Threading;

    partial class ThreadWrapper {

        internal class MotionEventArgs : EventArgs {
            internal MotionEventArgs(PointF coordinates) { Coordinates = coordinates; }
            internal PointF Coordinates { get; set; }
        } //class MotionEventArgs
        internal class ExceptionEventArgs : EventArgs {
            internal ExceptionEventArgs(Exception exception) { Exception = exception; }
            internal Exception Exception { get; set; }
        } //class ExceptionEventArgs

        internal ThreadWrapper() {
            Thread = new Thread(Body);
        } //ThreadWrapper

        internal void Start(bool letGo) {
            Thread.Start();
            if (!letGo) return;
            InnerLoopEvent.Set();
            OuterLoopEvent.Set();
        } //Start
        internal void Abort() {
            Thread.Abort();
        } //Abort
        internal void Pause() {
            InnerLoopEvent.Reset();
            OuterLoopEvent.Reset();
        } //Pause
        internal void Resume() {
            InnerLoopEvent.Set();
            OuterLoopEvent.Set();
        } //Resume

        internal event EventHandler<MotionEventArgs> CoordinatesChanged;
        internal event EventHandler<ExceptionEventArgs> ExceptionThrown;
        internal event EventHandler Paused;

        void Body() {
            while (true) {
                OuterLoopEvent.WaitOne();
                try {
                    InnerLoop();
                } catch (ThreadAbortException) {
                } catch (Exception exception) {
                    if (ExceptionThrown != null)
                        ExceptionThrown.Invoke(this, new ExceptionEventArgs(exception));
                } //exception
            } //do
        } //Body

        void SelfPause() {
            Pause();
            if (Paused != null)
                Paused.Invoke(this, new EventArgs());
        } //SelfPause

        ThreadExchangeData exchangeData;
        internal ThreadExchangeData ExchangeData {
            get { lock (SyncObject) return exchangeData; }
            set { lock (SyncObject) exchangeData = value; }
        } //ExchangeData

        PointF ballPosition;
        internal PointF BallPosition {
            get { lock (PositionSyncObject) return ballPosition; }
            set {
                lock (PositionSyncObject) {
                    isBallPositionDirty = true;
                    ballPosition = value;
                } //lock
            } //set BallPosition
        } //BallPosition

        Thread Thread;
        ManualResetEvent InnerLoopEvent = new ManualResetEvent(false);
        ManualResetEvent OuterLoopEvent = new ManualResetEvent(false);
        object SyncObject = new object();
        object PositionSyncObject = new object();
        bool isBallPositionDirty;

    } //class ThreadWrapper

} //namespace BouncingBall.Thread

请注意,该类是部分的,因此这里未显示某些成员(CurrentStateStateInnerLoopInvertAndDampVelocity;请参阅提供的完整源代码,“ThreadWrapper.InnerLoop.cs”文件)。

线程包装器最重要的方面是它提供了数据交换和事件调用的封装,因此也提供了线程同步

一个关键概念是使用带有虚拟方法 BodySystem.Threading.Thread 构造函数。想法是这样的:传递给线程构造函数的方法本身可以有一个参数。这可以通过使用 ParameterizedThreadStart类型的参数的构造函数来完成。好还是坏?这很糟糕!问题在于:此委托类型接受一个非类型化参数,更确切地说,是 System.Object 类型。这种类型丢失在实践中需要类型转换,这是危险、容易出错且不易维护的事情。同时,重要的是要注意,线程构造函数接受委托实例,这意味着静态和非静态(实例)方法。当我们传递包装器实例本身的实例方法时,它的所有成员都作为一个(隐式)调用参数传递给委托:this。它全面解决了在线程实例构造期间传递参数的问题。ParameterizedThreadStart 将变得毫无意义。

另一个重要方面是使用事件处理对象进行线程节流System.Threading.EventWaitHandle 或其两个专用形式,System.Threading.AutoResetEventSystem.Threading.ManualResetEvent。请注意,现代线程类没有 PauseResume 等方法;它们已被弃用并删除,因为它们极其危险。它们的危险性显而易见,但解释这一点将超出本文的主题。与这些方法相反,EventWaitHandle 的 wait 方法仅在代码中一些精确选择的点将调用线程置于等待状态,这些点非常适合这样做。在等待状态下,调用线程不花费任何 CPU 时间。当它调用非信号事件句柄对象上的 wait 方法时,调用线程会被其他线程抢占,并且不会安排执行,直到它被某些事件(由任何其他线程调用的 AbortInterrupt 方法,超时,以及重要的是,相应 EventWaitHandle 实例的信号事件)唤醒

“实时”

已经解释了主要思想:我们实际上不需要严格周期性的动画帧之间的时间间隔。

最适合动画的计时工具是 System.Diagnostics.Stopwatch 类。它提供了最佳精度。请注意,我们只需要相对时间,而不是绝对日历时间。

现在,我将提出一个不那么常用甚至不那么显而易见的想法,该想法适用于我们的特定问题:使用该类的两个独立实例:一个用于 X 运动,另一个用于 Y 运动。是的,我有单独的“X 时间”和“Y 时间”。关键是:在均匀引力场(在我们的情况下是 Y 方向)中的机械运动,以及在许多其他简单情况下,对于两个方向是完全独立的,所以整个运动是两个独立任务。

这是使用 Stopwatch 的基本场景。

using Stopwatch = System.Diagnostics.Stopwatch;

// ...

partial class ThreadWrapper {

// ...

    void InnerLoop() {
        Stopwatch watchX = new Stopwatch();
        Stopwatch watchY = new Stopwatch();
        while (true) {
            InnerLoopEvent.WaitOne();
            ThreadExchangeData data = ExchangeData;
            lock (SyncObject) {
                // ...
                watchX.Stop(); watchY.Stop();
                // ...
            } 
            lock (PositionSyncObject)
                // ...
                        watchX.Reset(); watchY.Reset();
                        watchX.Start(); watchY.Start();
                // ...
                double timeX = watchX.Elapsed.TotalSeconds;
                double timeY = watchY.Elapsed.TotalSeconds;
                // ...
        // use timeX to calculate new X coordinate
        // use timeY to calculate new Y coordinate
                watchX.Reset(); watchX.Start();     
    } //InnerLoop

} //class ThreadWrapper

这样,我们使用实际经过的时间从上一动画帧和系统配置空间(在此问题中是坐标和速度)的当前坐标来计算下一帧的坐标。

实际经过时间的某些变化不会破坏流畅运动的印象;对于观众来说,重要的是球在每一动画帧的当前时间点都处于正确的空间中渲染。

UI 线程调用

已经描述了图形渲染以及通过 System.Drawing.Graphics.Invalidate 机制显示运动的机制。这对于交互式运动来说已经足够了。

对于动画,即由一个单独的线程(不是 UI 线程,而是动画线程)控制的运动,还有一个额外的问题:不能从非 UI 线程调用任何与 UI 相关的东西。相反,需要使用 InvokeBeginInvoke 方法,该方法在 System.Windows.Threading.Dispatcher(用于 Forms 和 WPF)或 System.Windows.Forms.Control(仅限 Forms)中。

但是什么定义了 UI 线程?另请参阅此答案

Control.InvokeControl.BeginInvoke 在任何线程中都有效。其功能完全不受线程来源的影响:它可以是 UI 线程、通过 System.Threading.Thread 构造函数创建的线程、线程池中的线程或由 System.ComponentModel.BackgroundWorker 创建的线程。

此 API 的目的是在 UI 线程上分派委托实例的调用。如果调用 Control.InvokeControl.BeginInvoke 是在 UI 线程中完成的,则委托将立即被调用。由于在此情况下调用机制是冗余的,可以通过检查谓词属性 Control.InvokeRequired 来避免这种情况。如果不需要调用机制,则首选常规调用控件的方法或属性。此谓词仅用于可以从不同线程调用的某些调用泛化:有时在 UI 线程,有时不在。在许多情况下,开发人员确切地知道委托将仅从非 UI 线程调用;在这种情况下,不需要 Control.InvokeRequired,因为无论如何都应使用调用。

现在,UI 库(System.Windows.Forms 和 WPF)的设计方式是,所有对所有 UI 控件和应用程序的调用都不应直接进行,而应分派到 UI 线程。对于 Forms 和 FPW,还有一个通用的接口,其功能得到了极大的扩展:System.Windows.Threading.Dispatcher,它也有 InvokeBeginInvoke 方法,以及几个重载。Dispatcher 实例可以通过静态属性 System.Windows.Threading.Dispatcher.CurrentDispatcher 获取(此属性要么实例化新实例,要么使用先前创建的 Dispatcher 实例)。

现在,Dispatcher 方法、Control.InvokeControl.BeginInvoke 使用相同的机制将委托分派到 UI 线程:委托实例和实际调用参数的实例被放入某个队列,该队列由 WPF 或 System.Windows.Forms UI 线程读取。在主 UI 循环中,这些数据从队列中移除并用于实际调用。每个线程实例都有一个调用列表;调用列表中的每个元素都封装了委托入口点、用于访问方法声明类实例(如果方法是非静态的)的 this 参数以及所有调用参数的实例。所有这些数据都用于实际调用。

现在我们来区分 InvokeBeginInvoke。对于 Invoke,从调用中获得的返回值被返回给 Invoke 的调用者。这意味着同步线程对调用它的非 UI 线程是阻塞的。

相反,BeginInvoke 会立即返回类型为 System.IAsyncResult 的结果,因此此调用本身是非阻塞的。在大多数情况下,应使用此调用方法,特别是在不需要返回结果时;典型示例:从非 UI 线程状态获取 UI 线程显示通知。

如果需要 BeginInvoke 的返回结果,情况会很复杂,因为结果不是立即准备好的,所以无论如何都需要某种等待。标准帮助文档中对此的文档记录不佳。基本上,有三种方法可以稍后获取调用结果;其中一种是调用阻塞的 EndInvoke

有一个非常普遍的误解,即可以通过 Dispacher 将调用机制用于任何线程。事实并非如此。Dispatcher 确实适用于非 UI 应用程序,但如果使用 InvokeBeginInvoke;如果不存在活动的 UI 线程,它就什么也做不了!对这些方法的调用等同于在同一线程上常规调用委托。

是否可以在任何线程上使用类似的机制?不行,但可以通过专门编写的自定义线程使用阻塞队列来完成,队列的元素是委托实例。完整的代码及用法示例可在我的提示/技巧文章中找到:用于线程通信和线程间调用的简单阻塞队列。查看源代码可以很好地了解 UI 线程调用机制的工作原理。

对于当前的弹跳球应用程序,我添加了 WPF System.Windows.Threading.Dispatcher 用法的演示,其唯一目的是演示技术,在功能上等同于使用 System.Windows.Forms.Control.InvokeSystem.Windows.Forms.Control.BeginInvoke,因为它不太明显,而且在 System.Windows.Forms 应用程序中使用某些 WPF 程序集的可行性也是如此。这是显示如何完成此操作的步骤链。首先,应用程序需要引用唯一的 WPF 程序集 WindowsBase。然后,在主应用程序窗体中,我使用 System.Windows.Threading.Dispatcher 类,添加对当前调度程序实例的引用并使用它。

using Dispatcher = System.Windows.Threading.Dispatcher;

// ...

Dispatcher Dispatcher;

// ...

Dispatcher = Dispatcher.CurrentDispatcher;

// ...

Thread.ThreadWrapper Thread = new Thread.ThreadWrapper();

// ...
// in some forms initialization method ImplementSetup,
// which is called after the form's InitializeComponent() is called:

Thread.CoordinatesChanged += (sender, eventArgs) => {
    System.Action action = () => {
        Scene.Position =
            new System.Drawing.PointF(eventArgs.Coordinates.X, eventArgs.Coordinates.Y);
    };
    //with System.Windows.Forms.Control.Invoke:
    //Invoke(action);
    //with System.Windows.Threading.Dispatcher:
    Dispatcher.Invoke(action);
}; //Thread.CoordinatesChanged

请注意注释掉的 Invoke(action),它是对 System.Windows.Forms.Control.Invoke 的调用,可以代替 Dispatcher.Invoke(action) 使用。

现在,在由动画线程运行的 ThreadWrapper Thread 的正文中,球的位置是逐帧重新计算的。在一个循环中,会调用 CoordinatesChanged 事件(如果至少有一个处理程序)。该事件在主窗体类(它扮演了动画线程的线程包装器单例的角色)中添加。此处理程序修改 Scene.Position 属性。此属性的setter 调用 BallScene 类实例 SceneInvalidate 方法。

partial class BallScene : Control {

    // ...
    
    public PointF Position {
        get { return position; }
        set {
            if (position == value) return;
            position = FixPosition(value);
            Invalidate();
        } //set Position
    } //Position

} //class BallScene

第一个代码示例中的示例显示了同一类的另一个片段。这样,我们就有了完整的帧更新周期;它从动画线程中的模型数据更改开始;该更改通过 UI 线程调用机制调用图形无效化方法。

物理

再次,我所说的“物理”是指在计算机游戏开发中使用的术语。

如我上文所述,物理模型的唯一难点是弹跳和球在受摩擦力影响的地面表面上运动之间的过渡,这通常可以看作是一个固定力。在我们用户编辑的数据中,它以 G(重力加速度)的单位表示。如果我们试图构建一个真正详细和准确的物理模型,我们会发现这种过渡不是一个单一的点。在一段时间内,弹跳运动会与地面和球之间的摩擦结合,滑动程度不同。最终,摩擦会达到零滑动,并且弹跳可以被认为是完全阻尼的。此外,摩擦会使球滚动,因此摩擦可能是滚动摩擦和滑动摩擦的组合,所以摩擦不能被认为是恒定的;并且球的转动惯量会发挥作用。

对于一个简单的模型来说,这种复杂的细节看起来有些过度。但是,试图简化模型会遇到这个困难:弹跳模式和“摩擦模式”结束的标准,我称之为代码中的 frictionMode

为了解决这个问题,我首先简化了与墙壁的碰撞计算。让我们利用这样的事实:墙壁(首先是地面)会简单地反转垂直坐标和速度,如果我们考虑地面坐标为零。计算的问题在于,配置空间中模型的状态仅由帧定义。换句话说,与地面的碰撞时间可能发生在动画帧之间的某个时间,因此应该引入一个额外的时间点,即碰撞时间。此时,让我们稍微作弊一下。让我们简单地计算下一帧的状态,就好像地面不存在一样。很自然,最终它会给我们一个在地面以下的球坐标。对于任何墙壁都可能发生同样的情况,但我们需要特别注意地面,在那里可能会出现摩擦模式。如果检测到这样的地下位置,我们可以简单地反转垂直坐标和速度,并将球对称地显示在地面下方。很容易看出,在微小的微小弹跳(接近摩擦模式转换的某种条件)的情况下,这种近似的不准确性可能正式给我们负的动能。我们可以使用此条件作为转换为摩擦模式的条件。在摩擦模式下,我们认为球的运动是纯水平的;它在恒定摩擦力下继续运动,直到速度在当前运动方向上降至零或以下(请注意,在摩擦模式下球仍然可以弹跳过垂直墙壁)。

有趣的是,这种相当粗糙的技巧给人一种非常平滑和逼真的运动印象。

再次,我们单独考虑垂直和水平运动,所以我们可以有垂直动能和势能,以及水平动能,分别考虑。摩擦模式的检查如下所示。

initialY = 2d * physicsMaxY - y;
y = initialY;
double potential = localData.Gravity * (physicsMaxY - y);
yEnergy *= 1d - localData.Damping;
double kinetic = (yEnergy - potential);
if (kinetic < 0 && localData.Friction > 0) {
    kinetic = 0;
    frictionMode = true;
    // ...
} //if

(参见“ThreadWrapper.InnerLoop.cs”)。

一个警告:线程终止

让我们看看“弹跳球”应用程序进程的生命周期结束。这是其主窗体重写的 OnFormClosing 方法。

protected override void OnFormClosing(FormClosingEventArgs e) {
    Thread.Abort();
    base.OnFormClosing(e);
} //OnFormClosing

在这里,Thread.Abort() 是对上面显示的线程包装器方法的调用。它只是调用底层线程的 System.Threading.Thread.Abort 方法,该线程在我们的应用程序中是动画线程。

这种技术经常会引发一些争论。许多人认为 Thread.Abort 极其危险。不幸的是,这种信念通常基于对其工作原理的缺乏理解;这些人将其与旧的、糟糕的 Windows API TerminateThread 联系起来。实际上,Thread.AbortTerminateThread 完全无关。它使用非常安全的方法播种异常AbortException 被播种到线程状态中,因此执行会跳转到抛出该异常的代码,该代码可以被应用程序用户捕获和处理,从而减轻异步终止的所有潜在负面影响。毕竟,真正危险的 PauseResume 方法已被从 .NET 线程中删除,但 Abort 已被引入。基本上,它非常安全,但仅限于……那些非常了解发生了什么的开发人员。

尽管如此,我同意这种方法可能非常危险。最严重的论点与在构造和析构链期间中止的可能性有关:原则上,进程可能以某种半构造的状态结束,这种状态很难恢复。然而,我已经为这些问题开发了完全安全的解决方案,它大致基于将 Abort 封装在一个线程包装器中,该包装器用于安全地推迟中止。所有这些问题都应该单独写一篇文章。

那么,为什么在此特定应用程序中使用 Abort 是完全安全的呢?首先,这是因为它发生在应用程序生命周期的末尾。在这种特定情况下,情况恰恰相反:不在主窗口关闭前终止线程反而危险。此外,这个特定线程什么也不做,以免破坏任何东西。它只是访问内存中的共享对象,该对象代表物理模型的当前状态,并执行 UI 线程调用以更新图片。

会有什么替代方案?一种流行的替代方案是使用后台线程。我建议仅在一些极少数特殊情况下使用后台线程。后台线程唯一的区别是它在终止时不会持有其父进程;换句话说,后台线程会自动终止。这可能非常危险,因为(与 Abort 相反)线程终止的时间完全不受应用程序开发人员的控制。想象一下,当主窗体已经关闭时,线程试图更新某个窗体。后台线程的行为在此进行了说明。

另一种替代方案是线程的协作(非异步)终止。确实,这是最安全的选择。给线程一个标志(与一个或多个其他线程共享),该标志由另一个线程设置并告诉线程:“现在终止你自己”。这是安全的,因为线程可以在其代码中的某个安全点检查此标志并执行终止步骤。许多开发人员认为这是唯一可接受的方法。不幸的是,在某些问题中根本无法使用这种方法。对这些情况的讨论将远远超出本文的主题,但我很乐意与那些希望开启此类讨论的读者进行讨论。此外,这种方法可能会带来固有的性能问题。

异常

基本上,所有异常最终都应该得到处理,至少在每个线程的最顶层堆栈帧上。动画线程所有异常的处理显示在线程包装器的代码中。请注意,Abort 异常也得到了处理,但其处理程序什么也不做。正如我在上面描述的那样,这只发生在应用程序生命周期的末尾。

显然,异常不应该被处理得太局部化。一些初学者最糟糕的错误是在所有方法中捕获异常。相反,异常应该只在代码中几个精心选择的点被捕获,我称之为“能力点”;在那里,上下文知道如何处理某些特定类型的异常。顶层堆栈帧是与异常类型无关的特殊情况。

但是 UI 中的异常是一个特殊情况。最好不要在顶层,而是在应用程序的主事件循环的顶层捕获和处理所有异常。然后,所有异常都可以以类型无关的方式处理,将异常信息呈现给用户,然后应用程序可以继续。所有非废话 UI 框架都有这种机制。通过窗体可以这样做。

// first, entry point: 
static void Main() {
    Application.EnableVisualStyles();
    // this is how the mechanism is enabled:
    Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new FormMain());
} //Main

启用该机制后,我们需要提供通用异常处理程序。

public partial class FormMain {

    // ...

    void ImplementSetup() {

        // ...

        Thread.ExceptionThrown += (sender, eventArgs) => {
            Invoke(new Action(() => {
                Pause();
                ShowException(Resources.UiText.FormatAnimationException, eventArgs.Exception);
            }));
        }; //Thread.ExceptionThrown

    } //ImplementSetup

    // very simplified type-agnostic exception handler: 
    void ShowException(string titleFormat, Exception exception) {
        if (exception == null) return;
        MessageBox.Show(
            string.Format(
                Resources.UiText.FormatException,
                exception.GetType().Name, exception.Message),
            string.Format(titleFormat, Application.ProductName),
            MessageBoxButtons.OK,
            MessageBoxIcon.Error);
     } //ShowException

    // ...

} //class FormMain

我们已经讨论过 ImplementSetup 方法:这是窗体 InitializeComponent() 调用后调用的一个主窗体方法。

顺便说一句,WPF 提供了一个非常相似的机制。

UI 和布局:不要滥用设计器

滥用设计器是一个非常大的话题,太大以至于无法在此处涵盖。目前,我只想涵盖一个方面。我的许多同事和 CodeProject 提问者都抱怨过一个非常典型的情况:在开发过程中,即使在运行时一切正常,他们也发现自己无法在窗体设计器模式下打开某个窗体。如果窗体能在设计器中显示,他们可以轻松修复问题,但设计器会报告一些异常并且不显示窗体。如何摆脱这个死胡同?

解决方案非常简单。可以使窗体再次加载,而不会破坏任何已实现的现有功能。首先,重要的是要认识到这种情况是由于再次滥用设计器造成的。设计器模式和运行时模式是不同的。最好在设计器模式下运行尽可能少的代码。

快速修复在于 System.Windows.Forms.Component.DesignMode 属性,该属性在此处进行了描述。

解决方案是将大部分运行时代码放在 System.Windows.Forms.Form 和/或 System.Windows.Forms.UserControl 的此属性检查之下。

partial class MyForm : Form {

    // ...

    void DoSomething() {
        if (DesignMode) return;
        // now really do something :-)
    } // DoSomething

} // class MyForm

添加此类检查不会改变任何运行时功能,但可以修复在设计器中加载窗体的问题。

我还强烈建议避免使用设计器创建任何事件处理程序,并在代码中编写所有处理程序,通常以匿名方法的形式,如我所有示例所示,但这又是另一个故事了,还有过度使用设计器的负面角色的许多其他方面。

持久化配置和数据合同

首先,应用程序不附带安装程序。它代表了所谓的便携式应用程序的概念。我很高兴微软已经开始鼓励这种部署模型。确实,太多的应用程序带来了运行安装的麻烦,却没有任何回报,而且仍有大量应用程序在已严重污染的 Windows 注册表中留下无情的垃圾,而且似乎没有提供删除它们的机制。

这并不意味着本应用程序不需要安装和卸载。它实际上会在用户关闭应用程序或更改某些物理模型参数时“安装”自己。它可以通过菜单命令“清理系统中的持久化配置并退出”来删除所有痕迹(“卸载”)。我希望大多数软件产品都能做到这一点。注册表清理确实很烦人。

持久化是使用 Microsoft 数据合同实现的。

另一种方法,通常使用的方法是 .NET 应用程序设置,但对我来说,它看起来过于陈旧和笨拙。

.NET 中的数据合同是最好也是唯一健壮的持久化(序列化)技术。它的主要特点是它完全非侵入性。如果有一组数据类型构成任何任意的对象图(对于 XML 格式,不一定是),将其转换为数据合同不会破坏其行为,因为开发人员只为类型及其成员添加属性。不需要像继承自特殊类或实现特殊接口那样进行操作。持久化是完全类型无关的。数据合同通过 .NET反射在运行时发现。本质上,对象图存储在任何流中,而不会向该设施提供任何特定类型的信​​息。加载数据时,它会在内存中恢复逻辑上相同的对象图。这样,该技术不仅最健壮,而且最易于使用。

另一个好处是性能。反射很昂贵,但它只使用一次。正如 .NET序列化技术所做的那样,在第一次运行时,序列化会即时创建序列化程序集,使用 System.Reflection.Emit,该程序集稍后会被重用。

在我们的应用程序中,合同在“MetadataSchema.cs”文件中描述。现在,我们需要选择一个用于持久化存储的文件。这是如何完成的。

static string PersistentConfigurationFileName {
    get {
        string applicationDataDirectory =
            System.Environment.GetFolderPath(
                System.Environment.SpecialFolder.LocalApplicationData);
        return string.Format(Configuration.DefinitionSet.FmtUniqueFileName,
            applicationDataDirectory,
            System.IO.Path.DirectorySeparatorChar,
            Configuration.DefinitionSet.UniqueFileNameGuid);
    } //PersistentConfigurationFileName
} //PersistentConfigurationFileName

System.Environment.GetFolderPath 方法,与 System.Environment.SpecialFolder 枚举类型结合使用,可以计算并返回不同的特殊文件夹,特别是,用于每个用户配置数据的文件夹。

这是如何加载元数据模式实例。

internal static MetadataSchema Load(string fileName) {
    DataContractSerializer serializer = new DataContractSerializer(typeof(MetadataSchema));
    using (XmlReader reader = XmlReader.Create(fileName, FastestXmlReaderSettings)) {
        return (MetadataSchema)serializer.ReadObject(reader);
    } //using
} //Load

这是如何存储的。

internal void Store(string fileName) {
    DataContractSerializer serializer = new DataContractSerializer(GetType());
    using (XmlWriter writer = XmlWriter.Create(fileName, HumanReadableXmlWriterSettings)) {
        serializer.WriteObject(writer, this);
    } //using
} //Store

注意 `using` 语句(不要与 `using` 指令混淆)。这是如何自动调用 ISerializable.Dispose 的,在此例中是针对 XmlReaderXmlWriter

使用资源和 AssemblyInfo 属性

应用程序的“关于”框显示应用程序徽标、版权、应用程序名称、版本和其他有用信息,特别是本文的 URL,它作为应用程序配置,可以通过系统默认的 Web 浏览器显示。所有这些信息从何而来,如何收集?

首先,让我们看看“AssemblyInfo.cs”文件。其大部分内容非常常见,但有两行在通常自动生成的“AssemblyInfo.cs”文件中找不到。

[assembly: AssemblyDocumentation(
    Uri = "https://www.codeproject... Many-Questions-Answered-At-Once-Graphics-WinForms")]
[assembly: ApplicationDocumentation(
    Uri = "https://www.codeproject... Many-Questions-Answered-At-Once-Graphics-WinForms")]

为了引入这些附加属性,需要做一些工作。属性应该针对整个程序集。这是如何做的。

namespace System {

    [AttributeUsage(AttributeTargets.Assembly)]
    public class AssemblyDocumentationAttribute : System.Attribute {
        public string Uri { get { return uri; } set { this.uri = value; } }
        internal Uri Value { get { return new Uri(uri); } }
        string uri;
    } //class AssemblyDocumentationAttribute

    [AttributeUsage(AttributeTargets.Assembly)]
    public class ApplicationDocumentationAttribute : System.Attribute {
        public string Uri { get { return uri; } set { this.uri = value; } }
        internal Uri Value { get { return new Uri(uri); } }
        string uri;
    } //class ApplicationDocumentationAttribute

} //System

现在,我们需要使用 .NET反射来检索属性存储的元数据

internal static Uri ApplicationDocumentationUri {
    get {
        object[] attributes = Assembly.GetExecutingAssembly()
            .GetCustomAttributes(
                typeof(ApplicationDocumentationAttribute), false);
        if (attributes.Length == 0) {
            return default(Uri);
        }
        return ((ApplicationDocumentationAttribute)attributes[0]).Value;
    }
} //ApplicationDocumentationUri

一些元数据已包含在FCL 中;其他一些可以通过上述方式提取。

徽标、字符串格式和其他字符串信息应存储在应用程序资源中,不应硬编码。这是提取“关于”框的所有数据以及文档 URL 的点击如何工作的示例。

void Setup() {
    this.Text = String.Format(
        Resources.UiText.FormatTitleAbout, Application.ProductName);
    pictureBox.Image = Resources.Files.Logo;
    textBoxMainText.Text =
        string.Format(Resources.UiText.FormatAboutMain,
                      Application.ProductName,
                      AssemblyVersion.Major,
                      AssemblyVersion.Minor,
                      AssemblyCopyright);
    textBoxDescription.Text = AssemblyDescription;
    linkLabelArticle.Text = AssemblyDocumentationUri.ToString();
    linkLabelArticle.Click += (sender, eventArgs) => {
        System.Diagnostics.Process.Start(linkLabelArticle.Text);
    }; //linkLabelArticle.Click
} //Setup

打印、图形导出和图形渲染是同一回事

现在,让我们仔细看看 BallScene.Draw 方法;它的调用显示在第一个代码示例

internal void Draw(Graphics graphics, PrintPageEventArgs printPage) {
    int radius = Radius;
    graphics.SmoothingMode = SmoothingMode.HighQuality;
    graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
    graphics.TextRenderingHint = TextRenderingHint.AntiAlias;
    bool printing = printPage != null;
    Matrix textPrintTransform = default(Matrix);
    if (printing) { //indicate printer
        SetPrintPageLayout(graphics, printPage.MarginBounds);
        textPrintTransform = graphics.Transform;
        PrintFrame(graphics);
    } //if
    if (printPreview)
        PrintFrame(graphics);
    graphics.TranslateTransform(position.X + radius, position.Y + radius);
    BallRenderer.DrawBall(graphics, radius, ballColor);
    if (showVelocity) {
        float baseR = radius * 4;
        float x = velocity.X * baseR;
        float y = velocity.Y * baseR;
        float length = (float)Math.Sqrt(x * x + y * y);
        float angle;
        if (length > 0) {
            angle = (float)Math.Atan2(y, x);
            angle = (float)(angle * 180d / Math.PI);
            graphics.RotateTransform(angle);
            //graphics, vectorModule, arrowLength, arrowNotch, arrowRadius:
            BallRenderer.DrawVelocity(
                graphics, length,
                DefinitionSet.ArrowLength, DefinitionSet.ArrowNotch, DefinitionSet.ArrowRadius);
        } //if
    } //if showVelocity
    if (printing) {
        graphics.Transform = textPrintTransform;
        PrintText(graphics, false);
        return;
    } //if
    graphics.ResetTransform();
    if (PrintPreview)
        PrintText(graphics, true);
    Pen penFrame = SystemPens.ControlDark;
    if (Focused)
        penFrame = SystemPens.ControlText;
    graphics.DrawRectangle(penFrame, new Rectangle(
        ClientRectangle.Left, ClientRectangle.Top,
        ClientRectangle.Width - 1, ClientRectangle.Height - 1));
} //Draw

此方法已从 Graphics 对象特定实例的性质中抽象出来。在我们的例子中,它可以是屏幕(那么调用如第一个代码示例所示),位图图像或打印机页面。方法的第二个参数对于屏幕或图像为 null,仅用于打印。这样,同一方法用于在不同介质上渲染场景。打印到图像或打印机是通过主窗体的方法完成的,该窗体具有 Scene 对象作为成员字段。

这是如何将场景打印到位图图像。

void PrintToImage() {
    this.Scene.PrintPreview = true;
    try {
        if (PrintToImageDialog.ShowDialog() != DialogResult.OK) return;
        using (Bitmap bitmap =
            new Bitmap(Scene.ClientRectangle.Width, Scene.ClientRectangle.Height)) {
            using (Graphics graphics = Graphics.FromImage(bitmap))
                Scene.Draw(graphics, null);
            bitmap.Save(PrintToImageDialog.FileName);
        } //using bitmap
    } finally {
        this.Scene.PrintPreview = false;
    } //exception
} //PrintToImage

这是打印页面如何渲染。

void Print() {
    using (PrintDocument pd = new PrintDocument()) {
        this.Scene.PrintPreview = true;
        try {
            if (PrinterSettings != null) 
                PrintDialog.PrinterSettings = PrinterSettings;
            pd.PrintPage += (sender, eventArgs) => {
                this.Scene.Draw(eventArgs.Graphics, eventArgs);
            }; // pd.PrintPage
            DialogResult result = PrintDialog.ShowDialog();
            if (result != DialogResult.OK) return;
            PrinterSettings = PrintDialog.PrinterSettings;
            PrintDialog.Document = pd;
            pd.PrinterSettings = PrinterSettings;
            pd.Print();
        } finally {
            this.Scene.PrintPreview = false;
        } //exception
    } //using
} //Print

注意 BallScene.PrintPreview 标志。此标志通过 this 参数传递给 BallScene.Draw 方法,以告知该方法在渲染时显示不同的内容,正如方法实现中所示。这样,渲染可以与不同介质相同,但可以修改某些细节,具体取决于介质。对于打印机或位图图像,文本会显示模型参数。

这是打印结果。

解答

A1

很可能,从该类的一个工厂方法 System.Drawing.Graphics.From* 获取图形实例;这是最常见的错误之一。渲染不是这样工作的;这些工厂方法的目的完全不同(其中一些在“打印、图形导出和图形渲染是同一回事”部分进行了描述)。

即使在控件的客户区中写入了一些图形,在下一次无效化时它也会消失。

图形渲染的基本用法是通过重写 System.Windows.Forms.Control.OnPaint 方法或处理 System.Windows.Forms.Control.Paint 事件来完成的。另请参阅:渲染图形

A2

不要使用。System.Windows.Forms.PictureBox 类(叹气),到目前为止,并非最令人困惑的,但它比 .NET 的任何其他部分都让更多的初学者感到困惑。这个控件(是的,一个控件类,而不是“图片”)的使用非常有限,并且很容易在没有它的情况下实现FCL。所以,它已经给人们带来了比它有用更多的伤害。它只是提供了一种显示静态图像的简化方式。

尽管在 PictureBox 中可以实现一些动态、交互式或动画行为,但该类本身并没有提供帮助,只会带来很多额外的麻烦,只会消耗开发时间并消耗一些额外的内存和 CPU 时间,仅此而已。

正确的做法在“渲染图形”、“交互式图形”和“动画图形”部分进行了说明;本文的其余部分解释了所有相关技术。

A3

在“打印、图形导出和图形渲染是同一回事”部分解释了适用的技术。

A4

我看到过很多尝试这样做。尽管这样做是完全可能的,但我不会推荐这种方式,因为它的可维护性差且开发困难。错误在于认为如果某些组件已经可用,使用它们就可以节省开发时间。如果它们是为这种协作设计的,那将是真的。但它们不是这样设计的。

那么,该怎么办?我建议使用通常称为轻量级的方法。是的,从头开始开发组件会更容易。在当前的应用程序中,这类组件的例子是 BallSceneBallRenderer 类。

A5

UI 和布局:不要滥用设计器”部分提供了一个非常简单可靠的方案。问题修复后,重要的是要避免过度使用设计器。

A6

最常见的错误是忘记调用 Conrol.SetStyle 方法(该方法是受保护的),该方法在这里。控件应该被设置为 Selectable

当控件继承某个已功能正常的控件类时,通常不需要这样做,但自定义图形渲染类的基类通常是 System.Windows.Forms.Control。在这种情况下,通常会忘记。

另一件要记住的事情是设置 TabIndexTabStop 属性。

A7

查看您应用程序的入口点Main 方法)。它调用Application.Run(new SomeFormClass())。当发生这种情况时,传递给Application.Run 的该类的实例 SomeFormClass 会与调用线程绑定。这使得该线程成为UI 线程。不仅是这个窗体实例,后来添加到的此实例作为父级的所有 UI 组件,直接或间接,都会与该线程绑定。您无法在任何其他线程中调用所有这些对象的任何实例方法或属性,除了少数与UI 线程调用机制相关的方法。

相反,应该使用 UI 线程调用,这在“UI 线程调用”部分有详细描述。

A8

看起来有些线程阻止了进程终止。只有当这些进程的所有线程都完成后,进程才能终止。这不适用于后台线程

因此,当应用程序的主窗体关闭时,开发人员必须确保它导致所有线程完成/终止。主(UI)线程将简单地终止,因为关闭主窗体应该允许 Application.Run 方法返回。其他线程应该退出它们的线程方法或被中止;这个过程应该在主窗体关闭之前完成。执行此操作的适当点应该是重写的 System.Windows.Form.OnFormClosing 方法。

另一种方法是使用后台线程。这种方法的缺点是线程终止的时间不受开发人员的控制。如果这样的线程在主窗体关闭后对 UI 进行某些操作,可能会导致不可预测的麻烦,即使某些测试表明一切正常。

进一步的考虑,请参阅一个警告:线程终止

至于防止多个应用程序实例同时运行的问题,我在我的文章一次性完成单实例应用程序的所有三个功能,.NET 中提供了全面的解决方案。

A9

事实上,人类的感知对任何突然的移动都极其敏感,并且可以轻易地检测到从平滑运动到突然移动的最轻微的过渡。这种特质作为重要的生存因素得到了发展。同时,“平滑”并不意味着等时间间隔。人眼只检测坐标随时间变化的函数是否_平滑_;并且它做得非常好;但这完全是另一回事。我在“实时”部分解释了如何实现平滑。

很可能,导致问题的组件是 System.Windows.Forms.Timer 类。定时器的精度太差,以至于它永远不能用于任何几乎是周期性或平滑的事件。也许,这种类型的唯一好处是它最容易使用(通常,其他时间类型需要UI 线程调用来处理 UI)。

但定时器无论如何都不太适合此目的。我在“为什么不用定时器?”部分对此进行了说明。

A10

请参阅异常部分。

A11

请参阅资源和 AssemblyInfo 属性部分。

A12

这不是一个合法的地点。只是最近,我拥有一台根本没有 C:\ 驱动器的计算机,这是几次升级的结果。没有任何标准规定它应该存在。

此外,硬编码的文件或目录名称(不算一些临时实验代码)在任何情况下都没有用处。在所有情况下,这些名称都应该在运行时根据用户输入、配置文件或通过 OS API 合法检索的系统属性来计算。一种实现方法是由 System.Environment.GetFolderPath 类型与 System.Environment.SpecialFolder 枚举类型一起提供的。

另请参阅“持久化配置和数据合同”部分中的此代码片段

A13

有几种方法可以找到当前正在运行的应用程序的可执行目录。首先,最好使用不需要任何特殊程序集引用的方法(例如,使用 System.Windows.Forms.Application 的方法),并且无论应用程序如何托管,始终能正确工作。这就是我建议的。

string location = System.Reflection.Assembly.GetEntryAssembly().Location;
string executableDirectory = System.IO.Path.GetDirectoryName(location);

它的作用是:它查找主可执行模块的位置,即包含应用程序入口点Main)的入口程序集。当然,你可以找到任何其他程序集的位置,例如,调用程序集。

重要的是要理解,有几种查找此目录的方法,但有些方法取决于应用程序的托管方式(在 Windows 服务下执行或在调试器下执行时可能无法返回正确结果),但这里显示的方法始终能正确工作。

A14

通常,一个方法在任何方面都不会绑定到任何特定的线程。任何方法都可以从任何线程调用。然而,这一事实并没有消除线程同步的问题。

在某些特殊情况下,与同步不直接相关,某些方法或属性只能从特定线程调用,例如UI 线程。在这种情况下,应使用UI 线程调用机制,该机制在“UI 线程调用”部分有详细描述。

此机制的操作原理可以从我的文章用于线程通信和线程间调用的简单阻塞队列中理解。

A15

首先,请思考一下:你真的需要打印一个窗体吗?假设你可以打印它。但是为什么?通常,它会显示所有那些按钮、复选框、单选按钮和其他交互式控件……在纸上。在纸上点击所有这些项目效率不高,但这还不是最糟糕的潜在问题。可滚动控件中的某些信息可能会消失。屏幕渲染的原理与为纸张设计的原理相去甚远。

因此,最合理的方法是创建某种数据抽象层,并独立地在不同介质上渲染相同的数据。不要忘记要在此处应用的最重要原则:SPOT 或 SSOT

另一方面,您可能需要在不同媒介上呈现一些图形数据,并确保其外观一致。在这种情况下,请再次参阅 打印、导出图形和图形渲染是同一件事 部分。

如果您仍然认为需要打印表单,例如,作为一种快速且粗糙的解决方案,请在 CodeProject 或其他类似论坛上进行搜索。

最终注释

本应用程序设计用于快速适应其他类似任务,因此可以作为初学者的应用程序模板。

我会尝试在遇到新问题或回想起相关主题的其他问题时更新问答部分。

如果读者对这类文章表现出浓厚兴趣,我可能会撰写关于 WPF 的类似文章。这可以一次性涵盖另一大部分需要回答的问题。

© . All rights reserved.