每个月收到信用卡账单时,我总会又惊又惑。上个月怎么又花了那么多钱?看着每一笔出帐流水,猛抓头皮却怎么也记不起来这钱是用在了哪儿。痛定思痛,采取行动,我要记账。作为一个信奉技术能改变世界的IT人,我理所当然的在网上搜索各种电子记账本。在线的记账功能不敢用(怕被骚扰),一些单机记账软件提供的功能又不是我想要的。
与此同时,最近空下来的时候,我在看SQLite方面的资料。SQLite的简洁、小巧让我有些爱不释手。就此决定给自己做个记账本,用SQLite作为本地数据引擎。
功能概述
我需要的记账功能比较简单:
第一、记录每一笔消费,并可以添加需要的标签。当我查看明细时,能知道自己买了啥。
第二、对我来说,消费只需要分成两种:‘生活必需消费’和‘享受消费’。每周、每月可以看到这两种消费所占的比例、金额。
第三、能查看自己近6个月的消费走势。
根据这3点需求,我为自己度身定制了这款记账工具。
图1是记账本的启动框。
程序将启动一个工作线程来检查记账程序路径下是否已存在账本数据库,若没有则创建该数据库和所需的表结构。同时定时器将轮询检查结果。
(图1)
图2是记账本的主界面。
很多其他记账软件把消费分成餐饮,交通,买衣服……或者更细。一笔账到底归为哪一类要想个半天,同时出的图表复杂但又意义不大。
为自己做的账本只有两种消费类别,对应两个大按钮,点击即可进入记账界面。这两种消费所占的比例和总额是我每月的关注点。
主界面的最下方还有3个按钮,分别对应‘返回主界面’、‘退出程序’、‘查看报表’。在任何其它界面中,这三个按钮的图案、功能都保持一致。
(图2)
点击主界面上的绿色或红色按钮就会进入到记账界面。如图3所示
标题、图标、主色调区分了不同的消费。该界面的设计也是希望最简化,省去了消费时间选择框,默认为当前记录时间。
该界面的一个亮点是‘标签选择框’。框中的标签是动态生成的。系统会取近一个月时间,使用最频繁的10个标签来显示。(代码分析部分还会展开)
这里记录的标签,会出现在后面的明细报表中,这是我用来对账的。
(图3)
最后来看一下这个小工具能生成的图表与报表,如图4所示
该工具能输出3种报表,分别是消费比例图,近6月消费走势图,消费对账明细。对于图表,鼠标至于色块上方时将显示消费金额。
这3个报表也本着减少操作,降低复杂度,简洁好用为宗旨,所以只提供了最必要的功能。
(图4)
程序结构
看了工具的界面设计后,让我们来看一下程序结构,如图5所示
(图5)
整个Solution最主要由3个Project组成。
1. DataAccessLayer.SQLite包装了对SQLite访问的方法
2. ForSingle 主程序
3. UserControls 自定义用户控件
需要说明的是:
这个工具所有界面最下方的3个按钮保持统一,所以我在UserControls中画了一个BaseForm(图中橙色框标出),让主界面继承自BaseForm。
其他的每一个界面都做成UserControl,在主程序中控制它们的创建与显示。如图中绿色框标出。
SQLite对于本地应用是个不错的选择,我使用的是一个包装成ADO.NET接口的SQLite引擎。以下链接供参考:
我使用的类库:http://sqlite.phxsoftware.com/
SQLite官方网站:http://www.sqlite.org/
代码分析
1. 程序启动
当程序启动时,需要做一下检查和初始化工作。我把这些工作都放在启动框中完成。
Program.cs:
04 |
Application.EnableVisualStyles(); |
05 |
Application.SetCompatibleTextRenderingDefault( false ); |
06 |
if (Splash.Instance.ShowDialog() == DialogResult.OK) |
08 |
Application.Run( new MainFrame()); |
以上代码中的Splash就是启动对话框。只有当返回DialogResult.OK时,才会启动主程序。
Splash对话框是一个简单单例模式的实现。
Splash.cs:
01 |
private static Splash _instance; |
02 |
public static Splash Instance |
06 |
if (_instance == null ) |
08 |
_instance = new Splash(); |
在Splash的构造过程中,会启动一个定时器,再会启动一个工作线程运行初始化程序。
Splash.cs:
6 |
Worker.RunWorkerAsync(); |
工作线程与定时器之间由标志DBState联系起来的,工作线程置标志,定时器轮询标志。
Splash.cs:
01 |
private Timer _ticker; |
08 |
_ticker = new Timer( this .components); |
09 |
_ticker.Interval = 2000; |
10 |
_ticker.Tick += new System.EventHandler(_ticker_Tick); |
16 |
private enum DBStateEnum |
23 |
private DBStateEnum _dbState = DBStateEnum.Undefined; |
24 |
private DBStateEnum DBState |
26 |
get { return _dbState; } |
27 |
set { _dbState = value; } |
30 |
private void _ticker_Tick( object sender, System.EventArgs e) |
32 |
if (DBState == DBStateEnum.Ready) |
34 |
this .DialogResult = DialogResult.OK; |
37 |
else if (DBState == DBStateEnum.Failed) |
39 |
if ( string .IsNullOrEmpty( this .lblMessage.Text)) |
41 |
this .lblMessage.Text = ErrorMessage; |
45 |
this .DialogResult = DialogResult.Cancel; |
2. 标签选择框的绘制
图3下半部分中有一系列动态标签,这些标签的显示逻辑为:
从本地SQLite数据库中,查询出指定消费类别(‘生活必需’或‘奢侈享受’)近一个月中不重复的标签,按出现频率倒序排列,并取出前10个
FeeRecorderControl.cs:
01 |
private static readonly string getRecentMonthTop10SubCategorySql = |
09 |
ConsumeDate >= date('now', 'localtime', '-1 month') |
11 |
ConsumeDate <= datetime('now', 'localtime') |
13 |
ifnull(SubCategory, '') <> '' |
界面上的绘制标签区域其实是一个Panel,每一个标签是一个Label。
每次添加Label时,需检查当前将绘制的Label是否会超出Panel的边界,并相应的进行换行处理或退出循环。
FeeRecorderControl.cs:
01 |
private void InitalizeSubCategoryPanel( string strCategory, Color backColor) |
03 |
using (SQLiteConnection conn = new SQLiteConnection(SqliteConnString)) |
06 |
using (SQLiteCommand cmd = new SQLiteCommand( string .Format(getRecentMonthTop10SubCategorySql, strCategory), conn)) |
08 |
using (SQLiteDataReader reader = cmd.ExecuteReader()) |
10 |
Point subCategoryLocation = new Point(0, 0); |
11 |
SubCategoryList.Clear(); |
12 |
plSubCategory.Controls.Clear(); |
15 |
string strSubCategory = reader[ "SubCategory" ].ToString(); |
16 |
Label lblSubCategory = new Label(); |
17 |
lblSubCategory.Text = strSubCategory; |
18 |
lblSubCategory.Font = new Font( "Microsoft Sans Serif" , 12F, System.Drawing.FontStyle.Bold, |
19 |
System.Drawing.GraphicsUnit.Point, (( byte ) (0))); |
20 |
lblSubCategory.Width = lblSubCategory.Text.Length*25 + 10; |
21 |
lblSubCategory.Height = 35; |
22 |
lblSubCategory.TextAlign = ContentAlignment.MiddleCenter; |
23 |
lblSubCategory.BackColor = backColor; |
24 |
lblSubCategory.Click += new EventHandler(lblSubCategory_Click); |
25 |
if (subCategoryLocation.X + lblSubCategory.Width <= plSubCategory.Width |
26 |
&& subCategoryLocation.Y + lblSubCategory.Height <= plSubCategory.Height) |
28 |
lblSubCategory.Location = subCategoryLocation; |
30 |
else if (subCategoryLocation.X + lblSubCategory.Width > plSubCategory.Width |
31 |
&& subCategoryLocation.Y + lblSubCategory.Height + 5 + lblSubCategory.Height <= plSubCategory.Height) |
33 |
subCategoryLocation.X = 0; |
34 |
subCategoryLocation.Y = subCategoryLocation.Y + lblSubCategory.Height + 5; |
35 |
lblSubCategory.Location = subCategoryLocation; |
41 |
subCategoryLocation.X = subCategoryLocation.X + lblSubCategory.Width + 5; |
42 |
SubCategoryList.Add(lblSubCategory); |
44 |
plSubCategory.Controls.AddRange(SubCategoryList.ToArray()); |
总结与思考
1. 我对WinForm的开发远没有对数据库开发熟悉,大家若发现纰漏之处,请温柔指出。
2. 最近用户体验是一个热门词汇,做软件除了考虑技术问题之外,更要站在用户的角度去考虑他们的使用习惯。
3. 我自己非常想把这个记账工具做成手机版的,但对于移动开发知之甚少,大家可以进行尝试与讨论,欢迎和我邮件交流。