数独和智能客户端技术






4.79/5 (63投票s)
本文将基于一个Web服务开发一个数独游戏,并介绍智能客户端应用的开发技巧。
第一部分:数独
如果你不知道数独是什么,你可以回到你的宇宙飞船,然后回家。在回家的路上,你可以停这里,找出它是什么。
那么,为什么是数独呢?
我最近才开始玩数独,发现这个游戏非常有趣且令人上瘾。当你玩数独时,你最终会达到一个点,你需要找到一种方法来标记一个方格的可能值,因为你无法记住所有这些值。有些人将可能的数字写得很小,这种方法的缺点是,如果你用铅笔写,它不易读,如果你用钢笔写,它会变得混乱。另一种方法是使用点作为可用可能性的标记,每个数字都有自己的位置,例如,数字1表示为方格左上角的点,数字5表示为方格的中间,数字9表示为右下角的点。
记住这一点的一个简单方法是查看手机键盘。
所以,在完成所有纸质数独谜题后,我在网上搜索了更多。我找到了大量的谜题,但在那里我遇到了一个主要问题。你看,你不能在屏幕上放点!我实际上尝试过使用MS Paint,但我很快发现用鼠标写数字“2”和证明费马大定理一样难。
于是我就这样了,有大量的谜题但却无法玩。突然一封新邮件到来!“..Code Project Newsletter.. ..Smart Client competition..” 。
好吧,如果你阅读了这篇文章,你就知道接下来发生了什么。
第二部分:架构
因此,在决定编写数独智能客户端后,我开始考虑整体架构和设计。以下是我的见解。
- 需要一个自定义控件来负责绘制数独谜题,并与用户交互,以便他们可以标记可用的可能性为点。
- 应支持多种地图尺寸(6x6、9x9、16x16)和难度级别(简单、中等、困难)。
- 应用程序设置,如选定的地图尺寸和难度级别,应与高分信息一起保存到磁盘。
- 应使用Web服务来
- 获取数独谜题数据,
- 并提供互联网范围内的高分。
- 智能客户端应同时在Pocket PC和普通PC上工作。
- 智能客户端应快速运行(!)。这个见解实际上来得晚一些,在我意识到性能在开发智能设备时需要特别关注之后。本文中提到的许多技术都是出于性能原因而使用的。
以下各节将详细讨论这些问题。
作为一般说明,开发是使用.NET Compact Framework完成的,并在我的WinXP系统和Pocket PC模拟器上进行了测试。如果您发现任何错误或与您的智能设备不兼容,请随时评论。
第三部分:SudokuMap和SudokuCell
第三部分:一般
如果您打开源代码,您会看到项目名称是Sudoku2,以下是Sudoku项目发生的情况。
我首先编写了一个名为SudokuCell
的控件,它支持
- 绘制选定的数字(如果已选定)。
- 绘制半选定的点作为可能性。
- 允许用户单击单元格上的某个位置将其设为可能性。
- 允许双击单元格上的某个位置来选择值。
- 当单元格被设为只读时,不允许用户交互。
我将专注于有趣的部分,而将繁琐的部分留给其他地方,例如计算单元格的大小以及根据单击位置检查选择了哪个数字。如果您对此感兴趣,请查看源代码。
第三部分:最小化控件数量
我最初的设计是创建多个SudokuCell
实例,每个实例对应数独谜题中的一个单元格。所以在经典模式(9x9网格)下,我的主窗体上有81个控件。我甚至将它们包装在一个名为SudokuMap
的漂亮类中,这样我就可以根据选定的地图尺寸等来创建控件。
到目前为止一切顺利,直到我运行应用程序。应用程序在Pocket PC模拟器上加载花费了两个多分钟。当同一个可执行文件在我的普通PC上运行时,主窗体立即加载。
在查找了浪费的时间之后,我发现问题命令是Controls.Add
,而不是81个构造函数或其他任何东西;只是将SudokuCell
控件添加到窗体的Controls
集合中。我甚至尝试添加标准控件,如标签,但结果相同。Controls.AddRange
本可以工作得更好,但Microsoft选择不在.NET Compact Framework中包含此函数。
之后,我创建了一个新项目Sudoku2
,并重写了SudokuCell
和SudokuMap
类,以便SudokuMap
是添加到窗体的唯一控件,而SudokuCell
成为了一个不继承自Control
的类,但仍然负责处理鼠标单击和绘制单元格。
实现方式是SudokuMap
有一个SudokuCell
数组,当触发OnMouseDown
或OnPaint
函数时,它会将调用委托给正确的SudokuCell
。
第三部分:双击实现
单元格上的用户交互如下:
- 单击方格中的特定位置将在该位置标记一个点。这些点将表示该方格的选定可能性。
- 双击方格中的特定位置将选择与该位置相关的数字。选择数字后,点将消失,并显示选定的数字。
当我发现.NET Compact Framework中没有双击时,我感到多么惊讶!我花了好几天才放松下来,最终决定自己实现它。我将在智能设备上实现双击。
双击实现的基本思想是,我们需要记住上一次单击的时间和地点。因此,如果出现单击,并且它与前一次单击的位置相同(在一定阈值内),并且连续单击之间经过的时间很短(例如小于250毫秒),那么我们就有一个双击。
有一个小的不准确之处是,在这种情况下,我们无法从第一次单击判断它是否属于“双击”对。因此,我们总是执行单击操作,有时也执行双击操作。
如果您希望实现双击并想避免额外的单击操作,您应该在每次按下单击时调用一个计时器(250毫秒),并且仅当计时器结束时才执行单击操作(如果出现第二次单击,请不要忘记停止计时器)。
这是一个演示双击技术的示例代码
// return clicked number
int clickedNumber = GetCheckedNumber(e.X - CellLocation.X,
e.Y - CellLocation.Y);
int now = System.Environment.TickCount;
// check for double click activity
if ((mPreviousClickedNumber == clickedNumber) &&
(now - mPreviousClickTime < DoubleClickTime))
{
// double click
// ...
}
else
{
// single click
// ...
// update previous clicked number and time
mPreviousClickedNumber = clickedNumber;
mPreviousClickTime = now;
}
第三部分:双缓冲和绘图技术
另一个性能损失是由Paint
方法引起的。Paint
方法有三个问题。
Paint
方法直接在屏幕Graphics
对象上进行了大量绘制。- 每次地图更改时(例如,标记一个点),整个地图都会重绘。
- 昂贵的GDI对象在每次绘图时被反复创建。
第一个改进是使用双缓冲技术。与其直接在屏幕Graphics
对象上进行所有用户定义的绘制,不如在您自己的Graphics
对象上进行,该对象将作为缓冲区。这个Graphics
对象代表一个内存中的位图。在完成所有到内存位图的绘制后,您可以简单地将位图复制到屏幕Graphics
对象上进行实际绘制。这会带来更快的绘图和减少闪烁。
正常的.NET Framework在进行用户定义的绘制时具有内置的双缓冲支持,因此只需为您的控件SetStyle
即可。不幸的是,.NET Compact Framework中不存在此支持。
另一个改进是只绘制已更改的部分。这里我们使用前一段所述的相同的内存位图,当我们完成将其绘制到屏幕时,我们不销毁它,而是将其保存到下一次绘制。因此,如果没有任何更改,我们就不需要重新计算位图,并且可以直接将其复制到屏幕。此外,如果单个单元格发生更改,我们只需要重新计算该单个单元格的绘制。
以下是一个演示这些技术的示例代码。
我们需要在控件中声明三个成员变量:第一个是内存位图,第二个是内存位图上的Graphics
对象,第三个是一个布尔标志,指示是否需要重新计算位图。
/// <SUMMARY>
/// saves buffer in-memory bitmap
/// </SUMMARY>
private Bitmap mBufferBitmap;
/// <SUMMARY>
/// saves buffer in-memory graphics
/// </SUMMARY>
private Graphics mBufferGraphics;
/// <SUMMARY>
/// saves a flag that indicates whether a
/// recalculation of the bitmap is needed
/// </SUMMARY>
private bool mRecalculateNeeded = true;
这是OnPaint
方法的实现,您可以看到我们仅在recalculate
标志为真时执行繁重的绘制,大多数时候我们只是复制内存位图。在此示例中,您将看到SudokuMap
的OnPaint
绘制数独网格,然后将绘制委托给每个SudokuCell
。
/// <SUMMARY>
/// OnPaint - overrides paint to draw sudoku map
/// </SUMMARY>
/// <PARAM name="e">arguments</PARAM>
protected override void OnPaint(PaintEventArgs e)
{
if (mRecalculateNeeded)
{
// fill background in white
mBufferGraphics.FillRectangle(
CommonGraphicObjects.WhiteBrush, e.ClipRectangle);
int i,j;
// draw grid
for (i=0 ; i<=MapInfo.MapCellsNumber ; ++i)
{
// draw vertical line
if (i % MapInfo.ColsInSmallRect == 0)
mBufferGraphics.DrawLine(CommonGraphicObjects.BlackPen,
mOffset.X + i*mCellSize.Width, mOffset.Y,
mOffset.X + i*mCellSize.Width,
mOffset.Y + MapInfo.MapCellsNumber*mCellSize.Height);
else
mBufferGraphics.DrawLine(CommonGraphicObjects.GrayPen,
mOffset.X + i*mCellSize.Width, mOffset.Y,
mOffset.X + i*mCellSize.Width,
mOffset.Y + MapInfo.MapCellsNumber*mCellSize.Height);
// draw horizontal line
if (i % MapInfo.RowsInSmallRect == 0)
mBufferGraphics.DrawLine(CommonGraphicObjects.BlackPen,
mOffset.X, mOffset.Y + i*mCellSize.Height,
mOffset.X + MapInfo.MapCellsNumber*mCellSize.Width,
mOffset.Y + i*mCellSize.Height);
else
mBufferGraphics.DrawLine(CommonGraphicObjects.GrayPen,
mOffset.X, mOffset.Y + i*mCellSize.Height,
mOffset.X + MapInfo.MapCellsNumber*mCellSize.Width,
mOffset.Y + i*mCellSize.Height);
}
// draw cells internal
for (i=0 ; i < MapInfo.MapCellsNumber ; ++i)
{
for (j=0 ; j < MapInfo.MapCellsNumber ; ++j)
{
mSudokuCells[i,j].Paint(mBufferGraphics);
}
}
}
e.Graphics.DrawImage(mBufferBitmap, 0, 0);
mRecalculateNeeded = false;
}
最后,如果单元格已更改(例如,由于鼠标单击),它将更新内存位图并触发控件刷新以调用Paint
方法。
/// <SUMMARY>
/// causes image to recalculate and redraw
/// </SUMMARY>
internal void UpdateImage(SudokuCell updatedCell)
{
updatedCell.Paint(mBufferGraphics);
Invalidate();
}
关于绘图的最后一点是GDI对象的使用。这些对象是昂贵的资源,不应在每次绘制时重新创建,最好一次创建它们,然后使用static
变量在需要的地方使用它们。完成使用后(例如关闭应用程序时)不要忘记处理它们。
第四部分:地图信息类
第四部分:一般
地图信息类是一组提供不同地图类型信息的类。这些类包含的信息,如在一个小的数独矩形中有多少行和多少列,以及在绘制时应使用的符号类型(考虑16x16网格)。所有这些类都具有相同的接口,因此应用程序的其他部分不必了解它们的内部细节。
第四部分:工厂设计模式
地图信息类使用工厂设计模式构建。
在此设计模式中,我们有一个类知道如何构建地图信息类。该类具有某种Create
函数,该函数接收参数,并根据这些参数和一些内部逻辑,创建相关的地图信息类。然后,该类作为基类返回,这样没有人知道类的确切类型。这很好,所以如果我们想添加新的地图类型,我们只需要更改工厂,它就会构建它们,应用程序的其余部分甚至不知道细节,甚至不知道这些类是从哪里来的(我们可以从远程对象等构建类)。
这是地图信息类的类图
通过使用反射来创建类,可以使此设计更加有趣,这样就可以将其他地图作为DLL添加,而无需重新编译应用程序。然而,在我们的应用程序中,这将是过度设计。
第四部分:使用接口
您可能在工厂部分看到我没有使用任何接口来统一不同的地图信息类。使用公共接口将是一个更正确的设计,但在这种特定情况下,代价太大了。
即使使用接口在为智能设备编码时也有其自身的代价。如果我们看所需的接口,我们会看到它有三个成员变量,它们为不同的地图信息类返回不同的值,当然,接口没有成员变量,所以它们必须实现为接口的属性。
问题是,使用属性获取和设置值除了简单的整数赋值之外,还会产生函数调用。如果我们大量使用这些属性,那么函数调用的开销就会显现出来。简而言之,我通过删除接口的使用并切换回普通的基类,性能提高了30%。对本机C++有效的对我来说也有效。
第五部分:应用程序设置
第五部分:一般
每个人都需要应用程序设置。在我的智能客户端中,应用程序设置包括记住上次选择的地图类型和地图难度级别,当然还有本地高分。
我选择将我的应用程序设置序列化到XML文件中,因为.NET有如此方便的类可以从中读取和写入。幸运的是,.NET Compact Framework支持这一部分!给双击一个沉默的时刻。
第五部分:延迟IO
延迟IO是一个非常复杂的标题,但实际上它并没有什么特别的意思。基本思想是:不要在每次内存更改时写入文件。
我的实现:在应用程序退出时写入文件。这听起来像一个琐碎的概念,但我最初想在每次设置更改时都写入文件,这样应用程序在崩溃的情况下会更具容忍性。然后我记得Pocket PC不喜欢那些滥用它的人,所以我放手了。
第五部分:单例设计模式
单例是另一种设计模式,每个人都应该了解。
基本上,每个人在他们的应用程序中都有一个设置类。而这个设置类总是可以从应用程序的任何部分访问。但您通常不希望创建多个设置类实例。因此,单例设计模式就出现了,它强制创建最多一个实例。
实现细节是微不足道的。在您的设置类中:编写一个静态GetInstance
函数,该函数在第一次调用时创建您的类的一个实例。该函数将此实例保存到一个静态变量中。下次调用该函数时,它将返回先前创建的实例。一个额外的功能是将您的设置类的构造函数设为private
,以确保没有人会在您的GetInstance
函数之外创建您的类。
单例模式的示例代码
private static Settings mInstance;
/// <SUMMARY>
/// Get single instance of settings class
/// </SUMMARY>
public static Settings Instance
{
get
{
if (mInstance == null)
{
mInstance = new Settings();
}
return mInstance;
}
}
/// <SUMMARY>
/// Private ctor to enforce singelton
/// </SUMMARY>
private Settings()
{
}
第六部分:Web服务和高分
第六部分:一般
Web服务很有趣。将一个任何人都可以通过互联网访问的代码放在一起的想法为许多应用程序带来了活力。我的意思是,玩数独并在高分榜上取得最好的成绩很有趣,但在互联网上取得最好的成绩呢?想象一下!
我创建了一个具有两个功能的Web服务:获取数独谜题数据和托管互联网范围内的高分结果。
顺便说一下,关于Web服务最难的部分是找到一个免费的支持ASP.NET的主机。
第六部分:跨平台bug
如果您还记得,我的目标之一是创建一个在PC和Pocket PC上都能以相同方式运行的应用程序。事实证明,在.NET Compact Framework中消耗Web服务时存在一个bug。当您添加Web服务的引用时,Visual Studio会为您创建一个代理。这个代理用于隐藏Web服务连接的细节。Visual Studio在.NET Compact Framework应用程序的情况下生成的代码在使用普通.NET Framework时会失败。问题是,有两个属性不受支持;它们是Use
属性和ParameterStyle
属性。
这是原始生成代码的示例
[System.Web.Services.Protocols.SoapDocumentMethodAttribute(
"http://tempuri.org/GenerateMapData",
RequestNamespace="http://tempuri.org/",
ResponseNamespace="http://tempuri.org/",
Use=System.Web.Services.Description.SoapBindingUse.Literal,
ParameterStyle=
System.Web.Services.Protocols.SoapParameterStyle.Wrapped)]
[return: System.Xml.Serialization.XmlArrayItemAttribute(
IsNullable=false)]
public CellData[] GenerateMapData(int cellsNumber,
MapDifficultyLevel level) {
object[] results =
this.Invoke("GenerateMapData", new object[] {
cellsNumber,
level});
return ((CellData[])(results[0]));
}
解决方案是重新编译时将其删除
[System.Web.Services.Protocols.SoapDocumentMethodAttribute(
"http://tempuri.org/GenerateMapData",
RequestNamespace="http://tempuri.org/",
ResponseNamespace="http://tempuri.org/")]
[return: System.Xml.Serialization.XmlArrayItemAttribute(
IsNullable=false)]
public CellData[] GenerateMapData(int cellsNumber,
MapDifficultyLevel level) {
object[] results =
this.Invoke("GenerateMapData", new object[] {
cellsNumber,
level});
return ((CellData[])(results[0]));
}
请注意,如果您更新了Web引用,则应再次执行此操作,因为代码会被重新生成。
第六部分:高分实现
Web服务提供获取互联网高分列表、获取特定高分时间以及用新时间更新高分的功能。利用这些函数,我创建了显示互联网高分页面信息的标签。
注意:此特定Web服务不安全。它是为了学习目的而创建的。它可能比Internet Explorer不安全。如果您滥用此Web服务,您就有太多空闲时间了,否则为什么您想更改俄罗斯服务器上的文本文件?
在普通PC上运行应用程序时,互联网高分示例
第六部分:Web服务示例
我在源代码中包含了基本Web服务的代码。我说基本是因为基本示例中的生成函数为每种地图类型(迷你、经典、怪物)返回一个硬编码的游戏。
在服务器上运行的Web服务中,我从经典地图(9x9、简单、中等、困难)的本地数据库获取谜题数据,而在怪物和迷你地图类型中则是相同的硬编码地图。
尽管如此,这个示例还是展示了所涉及Web界面的接口和基本编码。
第七部分:最后
一如既往,在嵌入式系统中,您必须为性能而编码,不要在没有充分理由的情况下做任何额外的事情。现在和30年前唯一的区别是,那时您使用汇编进行性能编码,现在我们使用.NET进行性能编码。这是唯一的区别。还有Web服务。:)
就是这样。希望您喜欢。别忘了投票。
第八部分:更新
- 2005年8月5日 - 添加了保存和加载数独地图的支持,因此也支持离线播放。
- 2005年8月12日 - 添加了保存和加载游戏(包括时间和标记值)的支持+添加了颜色和一些小的bug修复。