CodeProject 新问题跟踪器 - 一个使用 API 跟踪 CodeProject 最新问题的应用程序





5.00/5 (6投票s)
当 CodeProject 上发布新问题时显示通知的应用程序
引言
CodeProject 新问题跟踪器是一个使用 CodeProject API 获取最新问题并在有新问题发布时显示通知气球的应用程序。如果您想使用该应用程序,请注册一个 CodeProject API 客户端 ID 和客户端密钥,并将其输入到新问题跟踪器主窗口的“设置”选项卡中。该应用程序需要 .NET 4.0 或更高版本。
注意:CodeProject 新问题跟踪器与 The Code Project 无关,我 (ProgramFOX) 创建了这个工具并将其命名为“CodeProject 新问题跟踪器”,因为它跟踪 CodeProject 上的新问题。
当跟踪到新问题时,您会在通知区域看到一个气球弹出,并且新问题会添加到主窗口的网格中(参见屏幕截图)。窗口默认是隐藏的;要显示它,请单击通知区域图标。关闭窗口会隐藏它,但不会关闭应用程序。使用“设置”选项卡上的“退出跟踪器”来关闭它。
有时,作者是 [unknown]
。这是由这个 bug 引起的。
新问题跟踪器使用以下依赖项
辅助类
该跟踪器使用了一些帮助器类,其中包含常用方法。
Storage 类 - 用于在 AppData 文件夹中存储数据
其中一个帮助器类是 Storage
。这个类用于在新问题跟踪器存储数据的 AppData 文件夹中存储文件数据和读取文件数据。它包含一个 StoreBytes
方法用于将字节存储到文件中,一个 LoadBytes
方法用于从文件中读取字节,一个 StoreInt
方法用于将整数存储到文件中,以及一个 LoadInt
方法用于从文件中加载整数。
StoreBytes
方法有一个 string
参数用于指示文件名,以及一个包含要存储在文件中的字节的字节数组参数。首先,它检查 %AppData%\ProgramFOX\CodeProjectNewQuestionsTracker 文件夹是否存在,如果不存在,则创建该文件夹。然后,它使用 File.WriteAllBytes
将字节数组存储到文件中。
LoadBytes
方法有一个 string
参数用于指示文件名,以及一个 out byte[] data
参数用于写入文件内容。如果文件存在,它会读取文件,将其内容放入 data
,并返回 true
。如果文件不存在,则返回 false
。
StoreInt
方法接受一个 string
和一个 int
作为参数。string
指示存储 int
的文件名,int
是要存储的数据。int
被转换为字节数组(使用 BitConverter
),然后该数组使用 StoreBytes
存储。
LoadInt
方法接受一个 string
参数用于指示文件名,以及一个 out int data
参数用于写入文件内容。如果文件存在,它使用 LoadBytes
读取其字节,将这些字节转换为 int
,将 data
的值设置为该 int
并返回 true
。如果文件不存在,则返回 false
。
class Storage
{
public static void StoreBytes(string filename, byte[] data)
{
string dir = Path.Combine(Environment.GetFolderPath
(Environment.SpecialFolder.ApplicationData),
"ProgramFOX", "CodeProjectNewQuestionsTracker");
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
string fullPath = Path.Combine(dir, filename);
File.WriteAllBytes(fullPath, data);
}
public static bool LoadBytes(string filename, out byte[] data)
{
string fullPath = Path.Combine(Environment.GetFolderPath
(Environment.SpecialFolder.ApplicationData),
"ProgramFOX", "CodeProjectNewQuestionsTracker", filename);
if (!File.Exists(fullPath))
{
data = null;
return false;
}
data = File.ReadAllBytes(fullPath);
return true;
}
public static void StoreInt(string filename, int data)
{
Storage.StoreBytes(filename, BitConverter.GetBytes(data));
}
public static bool LoadInt(string filename, out int data)
{
byte[] b;
bool bytesLoaded = Storage.LoadBytes(filename, out b);
data = bytesLoaded ? BitConverter.ToInt32(b, 0) : 0;
return bytesLoaded;
}
}
EncryptDecryptData - 使用 CryptProtectData 进行加密和解密
CryptProtectData 是一个用于加密和解密的本地包装器。它使用一个唯一的密钥来加密/解密数据,该密钥可以从用户配置文件中获取。要使用此包装器,我们需要使用 P/Invoke。该类包含两个类:DATA_BLOB
和 CRYPTPROTECT_PROMPTSTRUCT
。
[StructLayout(LayoutKind.Sequential)]
private class CRYPTPROTECT_PROMPTSTRUCT
{
public int cbSize;
public int dwPromptFlags;
public IntPtr hwndApp;
public String szPrompt;
}
[StructLayout(LayoutKind.Sequential)]
private class DATA_BLOB
{
public int cbData;
public IntPtr pbData;
}
这些类用于 CryptProtectData
和 CryptUnprotectData
方法的参数。这些方法用于加密和解密。
[DllImport("Crypt32.dll")]
private static extern bool CryptProtectData(
DATA_BLOB pDataIn,
String szDataDescr,
DATA_BLOB pOptionalEntropy,
IntPtr pvReserved,
CRYPTPROTECT_PROMPTSTRUCT pPromptStruct,
int dwFlags,
DATA_BLOB pDataOut
);
[DllImport("Crypt32.dll")]
private static extern bool CryptUnprotectData(
DATA_BLOB pDataIn,
StringBuilder szDataDescr,
DATA_BLOB pOptionalEntropy,
IntPtr pvReserved,
CRYPTPROTECT_PROMPTSTRUCT pPromptStruct,
int dwFlags,
DATA_BLOB pDataOut
);
这些方法使用起来并不简单和简短,因此类中还有两个方法:EncryptData
和 DecryptData
。这些方法使用上述代码块中定义的本地方法。
EncryptData
方法有两个 string
参数,一个包含数据,一个包含描述。该方法返回一个 Tuple<bool, byte[]>
,其中包含一个字节指示操作是否成功,以及一个包含加密数据的字节数组。为了加密数据,EncryptData
将数据转换为字节数组,将此数组复制到 IntPtr
,并使用此 IntPtr
作为 CryptProtectData
方法的参数。此方法返回一个 DATA_BLOB
,其中包含一个 int
指示加密字节数组的长度,以及一个保存该数组的 IntPtr
。然后,IntPtr
数据被复制到字节数组中,并返回该数组。
public static Tuple<bool, byte[]> EncryptData(string data, string description)
{
byte[] bytesData = Encoding.Default.GetBytes(data);
int length = bytesData.Length;
IntPtr pointer = Marshal.AllocHGlobal(length);
Marshal.Copy(bytesData, 0, pointer, length);
DATA_BLOB data_in = new DATA_BLOB();
data_in.cbData = length;
data_in.pbData = pointer;
DATA_BLOB data_out = new DATA_BLOB();
bool success = CryptProtectData
(data_in, description, null, IntPtr.Zero, null, 0, data_out);
Marshal.FreeHGlobal(pointer);
if (!success)
{
return new Tuple<bool, byte[]>(false, null);
}
byte[] outBytes = new byte[data_out.cbData];
Marshal.Copy(data_out.pbData, outBytes, 0, data_out.cbData);
return new Tuple<bool, byte[]>(true, outBytes);
}
DecryptData
方法有一个字节数组作为参数,其中包含加密数据。此方法将字节数组复制到 IntPtr
并将其作为参数传递给 CryptUnprotectData
方法。CryptUnprotectData
返回一个 DATA_BLOB
,其中包含一个 int
指示解密数据的长度,以及一个保存数据的 IntPtr
。然后,IntPtr
数据被复制到字节数组中,该字节数组被转换为 string
,并返回该 string
。
public static Tuple<bool, string> DecryptData(byte[] data)
{
int length = data.Length;
IntPtr pointer = Marshal.AllocHGlobal(length);
Marshal.Copy(data, 0, pointer, length);
DATA_BLOB data_in = new DATA_BLOB();
data_in.cbData = length;
data_in.pbData = pointer;
DATA_BLOB data_out = new DATA_BLOB();
StringBuilder description = new StringBuilder();
bool success = CryptUnprotectData
(data_in, description, null, IntPtr.Zero, null, 0, data_out);
Marshal.FreeHGlobal(pointer);
if (!success)
{
return new Tuple<bool, string>(false, null);
}
byte[] outBytes = new byte[data_out.cbData];
Marshal.Copy(data_out.pbData, outBytes, 0, data_out.cbData);
string strData = Encoding.Default.GetString(outBytes);
return new Tuple<bool, string>(true, strData);
}
ItemSummaryListViewModel、PaginationInfo、ItemSummary 和 NameIdPair - 用于保存 API 返回数据的类
在 ResponseData.cs 文件中,有四个类:ItemSummaryListViewModel
、PaginationInfo
、ItemSummary
和 NameIdPair
。关于这些类没有太多可说的,它们旨在保存 CodeProject API 返回的数据。
public class ItemSummaryListViewModel
{
public PaginationInfo Pagination { get; set; }
public ItemSummary[] Items { get; set; }
}
public class PaginationInfo
{
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages { get; set; }
public int TotalItems { get; set; }
}
public class ItemSummary
{
public string Id { get; set; }
public string Title { get; set; }
public NameIdPair[] Authors { get; set; }
public string Summary { get; set; }
public NameIdPair Doctype { get; set; }
public NameIdPair[] Categories { get; set; }
public NameIdPair[] Tags { get; set; }
public NameIdPair License { get; set; }
public string CreatedDate { get; set; }
public string ModifiedDate { get; set; }
public NameIdPair ThreadEditor { get; set; }
public string ThreadModifiedDate { get; set; }
public float Rating { get; set; }
public int Votes { get; set; }
public float Popularity { get; set; }
public string WebsiteLink { get; set; }
public string ApiLink { get; set; }
public int ParentId { get; set; }
public int ThreadId { get; set; }
public int IndentLevel { get; set; }
}
public class NameIdPair
{
public string Name { get; set; }
public int Id { get; set; }
}
AccessTokenData - 用于在获取访问令牌时保存数据
帮助器类 AccessTokenData
用于保存新问题跟踪器获取 API 访问令牌时返回的数据。
class AccessTokenData
{
public string access_token { get; set; }
public string token_type { get; set; }
public int expires_in { get; set; }
}
QuestionData - 一个保存问题最重要数据的类
QuestionData
类保存问题最重要的数据。它在问题数据传递给用户界面进行显示时使用。它具有以下属性:
AuthorName
- 问题作者的姓名AuthorUriStr
- 一个包含问题作者 URI 的string
AuthorUri
- 作者的 URI,存储为Uri
QuestionTitle
- 问题的标题QuestionUriStr
- 一个包含问题 URI 的string
QuestionUri
- 问题的 URI,存储为Uri
public class QuestionData
{
public string AuthorName { get; set; }
public string AuthorUriStr { get; set; }
public Uri AuthorUri
{
get
{
return new Uri(this.AuthorUriStr);
}
}
public string QuestionTitle { get; set; }
public string QuestionUriStr { get; set; }
public Uri QuestionUri
{
get
{
return new Uri(this.QuestionUriStr);
}
}
}
NewQuestionTrackedEventHandler 和 NewQuestionTrackedEventArgs
执行跟踪工作的 NewQuestionsTracker
类有一个事件 NewQuestionTracked
,类型为 NewQuestionTrackedEventHandler
,它接受一个 NewQuestionTrackedEventArgs
作为参数。NewQuestionTrackedEventArgs
类包含一个 QuestionData
数组。
public delegate void NewQuestionTrackedEventHandler
(object sender, NewQuestionTrackedEventArgs newQuestions);
public class NewQuestionTrackedEventArgs : EventArgs
{
public QuestionData[] QuestionInformation { get; set; }
}
NewQuestionsTracker 类 - 新问题跟踪器
变量声明
在类的顶部,首先声明了一些由类使用的变量
string accessToken = null;
ManualResetEvent cancelEvent = new ManualResetEvent(false);
bool running = false;
Queue<string> postIds = new Queue<string>();
Thread currThread;
accessToken
保存 API 返回的访问令牌。cancelEvent
是一个 ManualResetEvent,用于取消跟踪。每次跟踪器拉取新问题后,它都会休眠一段时间。使用cancelEvent
,可以中止此休眠。running
是一个布尔值,指示跟踪器是否正在运行。postIds
是一个Queue
,保存最新问题的 ID。此队列用于检查问题是否已发布,以查看是否有任何新问题。currThread
保存当前运行跟踪新问题方法的线程。
NewQuestionsTracker 的事件
NewQuestionsTracker
类有几个事件,用于在 API 连接失败、无法获取访问令牌或跟踪到新问题时发送通知。
- 当跟踪器无法连接到 API 时,会触发
ConnectionFailed
。 - 当无法获取 API 访问令牌时,会触发
AccessTokenNotFetched
。 - 当跟踪到新问题时,会触发
NewQuestionTracked
。
Action _onConnectionFailed;
public event Action ConnectionFailed
{
add
{
_onConnectionFailed += value;
}
remove
{
_onConnectionFailed -= value;
}
}
Action _onAccessTokenNotFetched;
public event Action AccessTokenNotFetched
{
add
{
_onAccessTokenNotFetched += value;
}
remove
{
_onAccessTokenNotFetched -= value;
}
}
NewQuestionTrackedEventHandler _onNewQuestionTracked;
public event NewQuestionTrackedEventHandler NewQuestionTracked
{
add
{
_onNewQuestionTracked += value;
}
remove
{
_onNewQuestionTracked -= value;
}
}
检查与 API 的连接
当跟踪器启动时(参见接下来的段落中的此过程),它做的第一件事是检查是否与 API 有连接。它为此使用了 CheckApiConnection
方法。
bool CheckApiConnection()
{
HttpWebRequest req = (HttpWebRequest)HttpWebRequest.Create("https://api.codeproject.com/");
bool canConnect;
try
{
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
{
canConnect = resp != null && resp.StatusCode == HttpStatusCode.OK;
}
}
catch (WebException)
{
canConnect = false;
}
return canConnect;
}
为了检查跟踪器是否可以连接到 API,它创建了一个 HttpWebRequest
,向 https://api.codeproject.com/
发送请求。如果状态码为 200 OK,则可以正常连接。如果状态码不是 200 OK,或者抛出 WebException
,则无法连接。
获取 API 访问令牌
跟踪器做的第二件事是获取 API 访问令牌。它将 CodeProject API 客户端 ID 和客户端密钥传递给服务器,服务器返回一个 API 访问令牌,授予您访问 API 的权限。该方法解密客户端 ID 和客户端密钥,将它们传递给 CodeProject API 服务器,并接收包含访问令牌的 JSON 响应。该方法使用 HttpClient
与服务器交互。GetAccessToken
方法是 NewQuestionsTracker
类的一部分,该方法返回一个布尔值(表示成功/失败)并将访问令牌存储在 AccessToken
属性中。
bool GetAccessToken(string clientIdFile, string clientSecretFile)
{
byte[] clientIdEncrypted;
bool gotClientIdEnc = Storage.LoadBytes(clientIdFile, out clientIdEncrypted);
byte[] clientSecretEncrypted;
bool gotClientSecretEnc = Storage.LoadBytes(clientSecretFile, out clientSecretEncrypted);
if (!(gotClientIdEnc && gotClientSecretEnc))
{
return false;
}
Tuple<bool, string> clientIdDecrypted = EncryptDecryptData.DecryptData(clientIdEncrypted);
Tuple<bool, string> clientSecretDecrypted =
EncryptDecryptData.DecryptData(clientSecretEncrypted);
if (!(clientIdDecrypted.Item1 && clientSecretDecrypted.Item1))
{
return false;
}
string clientId = clientIdDecrypted.Item2;
string clientSecret = clientSecretDecrypted.Item2;
clientIdDecrypted = null;
clientSecretDecrypted = null;
string json = null;
string requestData = String.Format
("grant_type=client_credentials&client_id={0}&client_secret={1}",
Uri.EscapeDataString(clientId), Uri.EscapeDataString(clientSecret));
using (WebClient client = new WebClient())
{
client.Headers[HttpRequestHeader.ContentType] = "application/x-www-form-urlencoded";
try
{
json = client.UploadString("https://api.codeproject.com/Token", requestData);
}
catch (WebException)
{
return false;
}
}
AccessTokenData access_token_data = JsonConvert.DeserializeObject<AccessTokenData>(json);
this.accessToken = access_token_data.access_token;
return true;
}
上述方法首先解密您的客户端 ID 和客户端密钥。它使用 EncryptDecryptData
类来完成此操作,其解释可以在本文前面的一段中找到。然后,它创建一个 WebClient
向 api.codeproject.com/Token
发送请求,参数为 grant_type
、client_id
和 client_secret
。在发送此请求之前,它将 Content-Type 标头设置为 application/x-www-form-urlencoded
。WebClient.UploadString
方法返回 JSON 数据,该数据使用 JSON.NET 的 JsonConvert.DeserializeObject
方法进行反序列化。
获取新问题
NewQuestionsTracker
类有一个 FetchNewestQuestions
方法,用于获取最新问题并返回一个 ItemSummaryListViewModel
。它也像 GetAccessToken
一样使用了 WebClient
。
ItemSummaryListViewModel FetchNewestQuestions()
{
ItemSummaryListViewModel respData;
using (WebClient client = new WebClient())
{
client.Headers[HttpRequestHeader.Accept] = "application/json";
client.Headers[HttpRequestHeader.Pragma] = "no-cache";
client.Headers[HttpRequestHeader.Authorization] =
String.Concat("Bearer ", this.accessToken);
string json = client.DownloadString("https://api.codeproject.com/v1/Questions/new");
respData = JsonConvert.DeserializeObject<ItemSummaryListViewModel>(json);
}
return respData;
}
创建 WebClient
后,设置标头。Accept
标头设置为 application/json
以获取 JSON 响应,Pragma
设置为防止缓存干扰请求或响应,Authorization
标头设置为传递访问令牌。设置标头后,请求发送到 https://api.codeproject.com/v1/Questions/new
以获取最新问题。
启动和取消方法
实际工作在 DoWork
方法中执行(参见下一段)。Start
方法为 DoWork
方法创建一个新线程并运行该线程。Cancel
方法使用 cancelEvent
(ManualResetEvent
)来取消跟踪器的执行。
public void Start(int millisecondsDelay)
{
running = true;
cancelEvent.Reset();
Thread thr = new Thread(DoWork);
thr.IsBackground = true;
currThread = thr;
thr.Start(millisecondsDelay);
}
public void Cancel()
{
running = false;
cancelEvent.Set();
currThread.Join();
}
Start
方法还将 cancelEvent
的状态设置为非信号。Cancel
方法使用 ManualResetEvent.Set()
方法将事件的状态设置为“信号”。当事件状态被信号时,这将在 DoWork
方法中处理。
DoWork 方法
在这个方法中,实际的工作发生了
- 使用
CheckApiConnection
方法检查 API 连接。 - 使用
GetAccessToken
方法获取访问令牌。 - 使用
FetchNewestQuestions
方法获取最新问题。 - 检查是否有新发布的问题。如果是,则调用
NewQuestionTracked
事件。 - 等待一段时间。等待多长时间由
Start
方法的millisecondsDelay
参数指定。
void DoWork(object m)
{
bool canConnect = CheckApiConnection();
if (!canConnect)
{
if (_onConnectionFailed != null)
{
Application.Current.Dispatcher.BeginInvoke(_onConnectionFailed);
}
return;
}
int millisecondsDelay = (int)m;
bool gotAccessToken = GetAccessToken("clientId", "clientSecret");
if (!gotAccessToken)
{
if (_onAccessTokenNotFetched != null)
{
Application.Current.Dispatcher.BeginInvoke(_onAccessTokenNotFetched);
}
return;
}
if (!gotAccessToken)
{
if (_onAccessTokenNotFetched != null)
{
Application.Current.Dispatcher.BeginInvoke(_onAccessTokenNotFetched);
}
return;
}
while (running)
{
ItemSummaryListViewModel respData = FetchNewestQuestions();
List<QuestionData> newQuestions = new List<QuestionData>();
for (int i = 0; i < respData.Items.Length; i++)
{
ItemSummary item = respData.Items[i];
if (!postIds.Contains(item.Id))
{
postIds.Enqueue(item.Id);
QuestionData newQData = new QuestionData();
if (item.WebsiteLink.StartsWith("//"))
{
item.WebsiteLink = "http:" + item.WebsiteLink;
}
newQData.QuestionUriStr = item.WebsiteLink;
newQData.QuestionTitle = item.Title;
int authorId = item.Authors[0].Id;
newQData.AuthorName = authorId != 0 ? item.Authors[0].Name :
"[unknown]"; // https://codeproject.org.cn/Messages/4971799/
// Sometimes-author-name-is-empty-and-ID-is.aspx
newQData.AuthorUriStr = String.Concat
("https://codeproject.org.cn/script/Membership/View.aspx?mid=", authorId);
newQuestions.Add(newQData);
}
else
{
break;
}
}
if (postIds.Count > 50)
{
for (int i = postIds.Count; i > 50; i--)
{
postIds.Dequeue();
}
}
if (newQuestions.Count > 0 && this._onNewQuestionTracked != null)
{
Application.Current.Dispatcher.BeginInvoke(this._onNewQuestionTracked,
new object[] { this, new NewQuestionTrackedEventArgs
{ QuestionInformation = newQuestions.ToArray() } });
}
if (cancelEvent.WaitOne(millisecondsDelay))
{
break;
}
}
}
调用 CheckApiConnection
后,它会检查其返回值。如果为 false
,则在调度线程上调用 _onConnectionFailed
。这也是 UI 运行的线程。如果跟踪器可以连接到 API,它会获取访问令牌。"clientId"
和 "clientSecret"
string
指定加密的客户端 ID/密钥存储在哪个 AppData 文件中。如果无法获取访问令牌,则该方法在调度线程上调用 _onAccessTokenNotFetched
。
然后,它进入一个 while
循环。这个循环中发生了什么?
- 使用
FetchNewestQuestions
获取最新问题。 - 一个
for
循环遍历所有问题。 - 如果当前问题不在
postIds
队列中,则- 将当前问题的 ID 添加到
postIds
中。 - 使用当前
ItemSummary
的信息创建一个QuestionData
对象。如果问题作者的 ID 是0
,则名称为空,因此将其替换为[unknown]
。ID 有时为 0 的事实是由此 bug 引起的。 - 将新创建的
QuestionData
添加到newQuestions
列表中。
for
循环。 - 将当前问题的 ID 添加到
- 我们只在队列中存储最新的 50 个问题,所以如果超过 50 个,则删除所有多余的项目。
- 如果跟踪到新问题,则在调度线程上调用
_onNewQuestionTracked
。 - 然后,我们使用
cancelEvent.WaitOne
方法等待指定的毫秒数。当调用Cancel
方法(因此也调用cancelEvent.Set
)时,WaitOne
将被中断并返回true
。如果中断,我们将跳出while
语句。如果未中断,我们将继续。
应用程序
单实例应用程序
因为 New Questions Tracker 应该只运行一个实例,所以我将应用程序设为单实例应用程序。我创建了一个包含 Main
方法的 Program.cs 文件来处理这个问题。它尝试创建一个名为 CodeProjectNewQuestionsTracker
的 Mutex
,并检查是否已存在一个。如果存在,它会显示一个错误消息,说明已有一个正在运行的实例,然后退出。在 main 方法的末尾,在 Mutex 上调用 GC.KeepAlive
方法,以确保它不会被垃圾回收。如果它被垃圾回收,则可以启动另一个应用程序,因为互斥量不再存在。
注意:因为我们创建了自己的 Main
方法,所以 App.xaml 的生成操作应设置为 Page
而不是 ApplicationDefinition
。在 Visual Studio 中,您可以通过在解决方案资源管理器中单击 App.xaml 并转到属性选项卡来更改生成操作。
class Program
{
[STAThread]
static void Main()
{
bool isNewInstance;
Mutex singleInstanceMutex =
new Mutex(true, "CodeProjectNewQuestionsTracker", out isNewInstance);
if (isNewInstance)
{
App applic = new App();
applic.InitializeComponent();
applic.Run();
}
else
{
MessageBox.Show("An instance is already running.",
"Running instance", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
GC.KeepAlive(singleInstanceMutex);
}
}
App
类将在下一段中解释。
App 类
App
类启动跟踪器和 UI。在该类的第一行(在 App.xaml.cs 中),声明了一些变量
MainWindow _mainWindow = new MainWindow();
NewQuestionsTracker tracker = new NewQuestionsTracker();
int delayTime;
_mainWindow
是 UI 窗口(见下一段),tracker
跟踪最新问题,delayTime
存储延迟时间(毫秒)。
跟踪器在 Application_Startup
方法中启动。此方法在 Main
方法中执行 Run
方法后调用。
private void Application_Startup(object sender, StartupEventArgs e)
{
int _delayTime;
bool dtLoaded = Storage.LoadInt("delayTime", out _delayTime);
this.delayTime = dtLoaded ? _delayTime : 60000; // default delay time: 60 seconds
tracker.ConnectionFailed += this._mainWindow.tracker_ConnectionFailed;
tracker.AccessTokenNotFetched += this._mainWindow.tracker_AccessTokenNotFetched;
tracker.NewQuestionTracked += this._mainWindow.tracker_NewQuestionTracked;
tracker.Start(delayTime);
this.MainWindow = this._mainWindow;
this._mainWindow.delayTime = delayTime;
this._mainWindow.TrackerRestartRequested += RestartTracker;
this._mainWindow.TrackingStartRequested += StartTracking;
this._mainWindow.TrackingStopRequested += StopTracking;
this._mainWindow.DelayTimeChanged += ChangeDelayTime;
this._mainWindow.ClientIdSecretChanged += ChangeClientIdSecret;
}
首先,它从文件中加载延迟时间。如果该文件不存在,则将延迟时间设置为默认值,即 60 秒。然后,它将 NewQuestionsTracker
的某些事件绑定到 MainWindow
类的方法,因为它们需要 UI 交互。它还将 MainWindow
的某些事件绑定到 App
类的方法。
当单击 UI 上的按钮时,会调用此类中的其他方法
RestartTracker
调用跟踪器上的Cancel
和Start
以使其重新启动。StartTracking
调用跟踪器上的Start
以启动它。StopTracking
调用跟踪器上的Cancel
以停止它。ChangeDelayTime
使用Storage
类更改延迟时间。ChangeClientIdSecret
使用EncryptDecryptData
和Storage
更改客户端 ID 和客户端密钥。
private void RestartTracker(object sender, EventArgs e)
{
tracker.Cancel();
tracker.Start(delayTime);
}
private void StartTracking(object sender, EventArgs e)
{
tracker.Start(delayTime);
}
private void StopTracking(object sender, EventArgs e)
{
tracker.Cancel();
}
private void ChangeDelayTime(int newDelayTime)
{
Storage.StoreInt("delayTime", newDelayTime);
}
private void ChangeClientIdSecret(string newClientId, string newClientSecret)
{
byte[] cie = EncryptDecryptData.EncryptData
(newClientId, "CodeProject API Client ID").Item2;
byte[] cse = EncryptDecryptData.EncryptData
(newClientSecret, "CodeProject API Client Secret").Item2;
Storage.StoreBytes("clientId", cie);
Storage.StoreBytes("clientSecret", cse);
}
用户界面
UI 由一个选项卡控件组成,包含两个页面:显示最新问题的页面,以及更改/显示设置的页面。问题页面包含一个数据网格,其中包含行以超链接形式显示问题标题和问题发布者,单击它们时,页面将在您的默认网络浏览器中打开。
MainWindow.xaml - XAML 设计
MainWindow.xaml 包含窗体设计的 XAML 代码。第一行创建 Window
<Window x:Class="CodeProjectNewQuestionsTracker.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="CodeProject New Questions Tracker" Height="700" Width="950"
xmlns:tb="http://www.hardcodet.net/taskbar"
Closing="Window_Closing"
WindowState="Maximized"
Icon="/Icons/CodeProjectNewQuestionsTracker.ico">
root
元素的属性将窗口标题设置为“CodeProject 新问题跟踪器
”,默认最大化窗口(如果未最大化,则为 950x700),设置图标,将 Window_Closing
(请参见 MainWindow.xaml.cs,下一节)绑定到 Closing
事件,并创建 tb
命名空间。此命名空间用于通知区域图标。
在根元素中,有一个 Window.Resources
元素。这里,它只用于定义一些样式
<Window.Resources>
<Style TargetType="TextBlock">
<Setter Property="Padding" Value="5" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style TargetType="TextBlock" x:Key="dataGridTextBlockStyle"
BasedOn="{StaticResource {x:Type TextBlock}}" />
<Style TargetType="TextBox">
<Setter Property="Padding" Value="5" />
<Setter Property="FontSize" Value="13" />
</Style>
</Window.Resources>
上述 Style
元素确保 TextBlock
和 TextBox
中的字体大小为 13,并且它们的填充为 5px。数据网格中的 TextBlock
需要一个带有键的单独样式规则,并且在将 TextBox
放入数据网格时,您必须为其指定 dataGridTextBlockStyle
样式。如果您不这样做,尽管有一个“全局”样式规则,它也不会获得不同的字体大小和填充。我不确定原因。
在 Window.Resources
元素之后,是 TabControl
。在页面内容隐藏的情况下,它看起来像这样
<TabControl>
<TabItem Header="Recently posted questions">
<!-- DataGrid will go here -->
</TabItem>
<TabItem Header="Settings">
<!-- Settings grid will go here -->
</TabItem>
</TabControl>
TabControl
元素包含 TabItem
元素,这些元素定义了页面。这些元素有一个 Header
属性,用于设置选项卡页面的标题。
在第一个 TabItem
中,是 DataGrid
。网格的数据源在 MainWindow.xaml.cs 中创建。
不带子元素的 DataGrid
元素看起来像这样
<DataGrid AutoGenerateColumns="False"
CanUserReorderColumns="True"
CanUserResizeColumns="True"
CanUserAddRows="False"
RowBackground="White"
AlternatingRowBackground="LightYellow"
IsReadOnly="True"
x:Name="recentQsGrid">
...
</DataGrid>
属性确保列不能自动生成,用户可以重新排序和调整列大小但不能添加列,行背景为白色,交替行背景为浅黄色(这意味着一行白色,另一行黄色,然后又是白色...),并且数据网格是 readonly
。它的名称是 recentQsGrid
。
然后必须在 DataGrid
标签内定义列。这可以使用 DataGrid.Columns
、DataGridTemplateColumn
和 DataTemplate
来完成
<DataGrid.Columns>
<DataGridTemplateColumn Header="Post">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Style="{StaticResource dataGridTextBlockStyle}">
<Hyperlink ToolTip="{Binding QuestionLink}"
NavigateUri="{Binding QuestionUri}"
RequestNavigate="hyperlink_RequestNavigate"
TextDecorations="None">
<Run Text="{Binding QuestionTitle}" />
</Hyperlink>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Author">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Style="{StaticResource dataGridTextBlockStyle}">
<Hyperlink ToolTip="{Binding AuthorLink}"
NavigateUri="{Binding AuthorUri}"
RequestNavigate="hyperlink_RequestNavigate"
TextDecorations="None">
<Run Text="{Binding AuthorName}" />
</Hyperlink>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
在 DataGrid.Columns
中,我们放置了两个 DataGridTemplateColumn
:一个用于帖子标题,一个用于帖子作者。在 DataTemplateColumn
的 CellTemplate
属性中,我们放置了一个 DataTemplate
。此元素包含我们放入 DataGrid
中的实际控件。模板包含一个带有 dataGridTextBlockStyle
样式的 TextBlock
。此 TextBlock
包含一个 HyperLink
,其工具提示和 URI 来自数据源,数据源在代码隐藏中设置。超链接没有任何文本装饰,并在单击超链接时执行 hyperlink_RequestNavigate
。超链接内部有一个 Run
,其文本绑定到问题标题或作者姓名。
在另一个选项卡页面中,我们有一个 WPF Grid
来显示 TextBlock
、TextBox
和 Button
。不带子元素时,TabItem
和 Grid
看起来像这样
<TabItem>
<Grid>
...
</Grid>
</TabItem>
Grid
的第一个子元素是行定义和列定义。有两列:一列的 Width
为“Auto
”,另一列填充行的其余部分。有 9 行,所有行的 Height
都为“Auto
”。
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
在这些定义之后,是一些 TextBlock
和 TextBox
<TextBlock Text="Client ID:" Grid.Column="0" Grid.Row="0" />
<TextBox Text="New Client ID here" Grid.Column="1" Grid.Row="0" x:Name="clientIdTxt" />
<TextBlock Text="Client Secret:" Grid.Column="0" Grid.Row="1" />
<TextBox Text="New Client Secret here" Grid.Column="1" Grid.Row="1" x:Name="clientSecretTxt" />
<TextBlock Text="Delay time (milliseconds, 1s = 1000ms):" Grid.Column="0" Grid.Row="2" />
<TextBox Grid.Column="1" Grid.Row="2" x:Name="delayTimeTxt" Loaded="delayTimeTxt_Loaded" />
<TextBlock Text="Original Client ID and Client Secret
are not exposed here for privacy/security reasons"
Grid.ColumnSpan="2" Grid.Row="3" />
第一个 TextBlock
显示“Client ID:
”,位于填写客户端 ID 的 TextBox
左侧。第二个 TextBlock
显示“Client Secret:
”,位于填写客户端密钥的 TextBox
左侧。第三个 TextBlock
提供了关于延迟时间的一些信息,位于输入延迟时间的 TextBox
左侧。然后,还有一个 TextBlock
说明原始客户端 ID 和客户端密钥未在文本框中公开。出于隐私/安全原因,它们被保密。您只能使用文本框输入新的。
在 TextBlock
和 TextBox
下方,有一些 Button
,用于保存更改的信息或执行诸如重新启动跟踪器之类的操作。
<Button Grid.Column="0" Grid.Row="4" x:Name="updateClientIdSecretBtn"
Click="updateClientIdSecretBtn_Click">
<TextBlock>
Update Client ID and Client Secret
</TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="5" x:Name="updateDelayBtn"
Click="updateDelayBtn_Click">
<TextBlock>
Update delay time
</TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="6" x:Name="stopStartTrackingBtn"
Click="stopStartTrackingBtn_Click">
<TextBlock>
Stop tracking
</TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="7" x:Name="restartTrackerBtn"
Click="restartTrackerBtn_Click">
<TextBlock>
Restart tracker
</TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="8" x:Name="exitTrackerBtn"
Click="exitTrackerBtn_Click">
<TextBlock>
Exit tracker
</TextBlock>
</Button>
所有按钮都有一个内部 TextBlock
,以使它们具有样式中指定的填充和字体大小。所有 Click
事件都引用代码隐藏中的方法。
“设置”选项卡上的最后一个元素是通知区域图标。它为此使用了 WPF NotifyIcon
库。
<tb:TaskbarIcon x:Name="questionsTaskbarIcon"
ToolTipText="CodeProject New Questions Tracker"
IconSource="/Icons/CodeProjectNewQuestionsTracker.ico"
Visibility="Visible" />
MainWindow.xaml.cs - 代码隐藏
MainWindow.xaml.cs 包含与 UI 相关的方法:处理按钮单击,在网格上显示新问题等。
首先,定义了一些字段。这些字段稍后将在类中使用
bool shown = false;
bool tracking = true;
public int delayTime;
bool shouldExit = false;
然后,定义了 RecentQuestions
属性。这是一个 ObservableCollection
,将用于绑定到 DataGrid
。
ObservableCollection<QuestionData> _recentQuestions = new ObservableCollection<QuestionData>();
ObservableCollection<QuestionData> RecentQuestions
{
get
{
return _recentQuestions;
}
set
{
_recentQuestions = value;
}
}
在该属性之后,定义了一些事件。当单击其中一个按钮时,MainWindow
将使用这些事件通知 App
,App
将采取相应的操作。
Action<int> _delayTimeChanged;
public event Action<int> DelayTimeChanged
{
add
{
_delayTimeChanged += value;
}
remove
{
_delayTimeChanged -= value;
}
}
Action<string, string> _clientIdSecretChanged;
public event Action<string, string> ClientIdSecretChanged
{
add
{
_clientIdSecretChanged += value;
}
remove
{
_clientIdSecretChanged -= value;
}
}
EventHandler _trackerRestartRequested;
public event EventHandler TrackerRestartRequested
{
add
{
_trackerRestartRequested += value;
}
remove
{
_trackerRestartRequested -= value;
}
}
EventHandler _trackingStartRequested;
public event EventHandler TrackingStartRequested
{
add
{
_trackingStartRequested += value;
}
remove
{
_trackingStartRequested -= value;
}
}
EventHandler _trackingStopRequested;
public event EventHandler TrackingStopRequested
{
add
{
_trackingStopRequested += value;
}
remove
{
_trackingStopRequested -= value;
}
}
- 当用户更改延迟时间时,会调用
DelayTimeChanged
。 - 当用户更改客户端 ID 和客户端密钥时,会调用
ClientIdSecretChanged
。 - 当用户单击按钮重新启动跟踪器时,会调用
TrackerRestartRequested
。 - 当用户单击按钮启动跟踪器时,会调用
TrackingStartRequested
。这与停止跟踪器的按钮是同一个按钮,因此是调用此事件还是TrackingStopRequested
取决于跟踪器状态。
在这些事件之后,是构造函数
public MainWindow()
{
InitializeComponent();
recentQsGrid.ItemsSource = RecentQuestions;
ActionCommand leftClickCmd = new ActionCommand();
leftClickCmd.ActionToExecute += leftClickCmd_ActionToExecute;
questionsTaskbarIcon.LeftClickCommand = leftClickCmd;
}
构造函数设置 DataGrid
的项目源。然后,它创建一个新的 ActionCommand
(它派生自 ICommand
,请参阅下一节),并将其 ActionToExecute
设置为 leftClickCmd_ActionToExecute
,这是一个我们稍后将创建的方法。然后它将通知区域图标的 LeftClickCommand
设置为新创建的 ActionCommand
。因此,如果您左键单击通知区域中的图标,将调用 leftClickCmd_ActionToExecute
。
在构造函数之后,创建 leftClickCmd_ActionToExecute
。如果窗口显示,此方法会将其隐藏。如果窗口隐藏,则此方法会将其显示。它使用 shown
变量存储窗口是隐藏还是显示。
void leftClickCmd_ActionToExecute(object obj)
{
if (shown)
{
this.Hide();
}
else
{
this.Show();
}
shown = !shown;
}
然后是处理跟踪器事件的方法。这些方法在 App.xaml.cs 的 Application_Startup
中绑定到跟踪器事件。
public void tracker_ConnectionFailed()
{
MessageBox.Show("Could not connect to the CodeProject API.
Please check your internet connection.
If you have internet connection, check whether the API site is up.",
"Could not connect",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
public void tracker_AccessTokenNotFetched()
{
MessageBox.Show("Access token could not be fetched.
Please re-enter your Client ID and Client Secret on the Settings tab.",
"Could not fetch access token",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
public void tracker_NewQuestionTracked(object sender, NewQuestionTrackedEventArgs e)
{
QuestionData[] newQuestions = e.QuestionInformation;
for (int i = newQuestions.Length - 1; i >= 0; i--)
{
RecentQuestions.Insert(0, newQuestions[i]);
}
if (newQuestions.Length > 1)
{
questionsTaskbarIcon.ShowBalloonTip("New questions",
String.Format("{0} new questions tracked.", newQuestions.Length), BalloonIcon.None);
}
else if (newQuestions.Length == 1)
{
questionsTaskbarIcon.ShowBalloonTip("New question", String.Format("{0} asked: {1}",
newQuestions[0].AuthorName, newQuestions[0].QuestionTitle), BalloonIcon.None);
}
}
tracker_ConnectionFailed
显示一个消息框,告知跟踪器无法连接到 API。tracker_AccessTokenNotFetched
显示一个消息框,告知无法获取访问令牌。tracker_NewQuestionTracked
将新问题添加到网格,并使通知区域图标显示一个气球。如果有一个以上的新问题,它会显示“N 个新问题已跟踪
”。如果只有一个问题,它会显示“用户提问:标题
”。
之后,是处理窗口事件的方法。这里的第一个是 Window_Closing
。此方法隐藏窗口,但不会关闭它,因为那会关闭应用程序。只有当单击“退出跟踪器”按钮时才应该关闭它。
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
this.Hide();
shown = false;
e.Cancel = !shouldExit;
}
shouldExit
为 false
;当用户点击“退出跟踪器”按钮时,它被设置为 true
,然后应用程序退出。
在上述方法之后,我们有 hyperlink_RequestNavigate
方法。当点击超链接时,此方法使用 Process.Start
在您的默认浏览器中打开页面
private void hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
Process.Start(e.Uri.AbsoluteUri);
}
下一个方法是 updateDelayBtn_Click
。当用户更新延迟时间时,此方法会检查输入时间是否有效,然后调用 DelayTimeChanged
事件告知 App
,然后 App
将更改设置(请参阅前面章节之一)。
private void updateDelayBtn_Click(object sender, RoutedEventArgs e)
{
int newDt;
if (Int32.TryParse(delayTimeTxt.Text, out newDt))
{
delayTime = newDt;
if (_delayTimeChanged != null)
{
_delayTimeChanged(newDt);
MessageBox.Show("Delay time changed. The change will be applied
when you restart the tracker.", "Delay time changed",
MessageBoxButton.OK, MessageBoxImage.Information);
}
}
else
{
MessageBox.Show("Entered delay time is not valid.",
"Invalid delay time", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
下一个方法是 delayTimeTxt_Loaded
。当文本框加载时,此方法会将延迟时间放入其中。
private void delayTimeTxt_Loaded(object sender, RoutedEventArgs e)
{
delayTimeTxt.Text = delayTime.ToString();
}
delayTime
字段由 App
类设置。
接着,我们有处理点击“停止跟踪”/“开始跟踪”按钮的方法。
private void stopStartTrackingBtn_Click(object sender, RoutedEventArgs e)
{
if (tracking && _trackingStopRequested != null)
{
_trackingStopRequested(this, new EventArgs());
}
else if (!tracking && _trackingStartRequested != null)
{
_trackingStartRequested(this, new EventArgs());
}
stopStartTrackingBtn.Content = new TextBlock()
{ Text = tracking ? "Start tracking" : "Stop tracking" };
tracking = !tracking;
}
如果应用程序正在跟踪,它会调用 _trackingStopRequested
事件;否则,它会调用 _trackingStartRequest
事件。然后它会更改按钮的文本,并更改 tracking
字段的值。
停止/启动按钮下方的按钮是重新启动按钮。当单击它时,它会检查跟踪器是否正在运行:如果未运行,它会显示一个对话框,告知您应该改用“开始跟踪”按钮。如果正在运行,它会调用 _trackerRestartRequested
事件。
private void restartTrackerBtn_Click(object sender, RoutedEventArgs e)
{
if (!tracking)
{
MessageBox.Show("Cannot restart tracker because it is not running;
click the 'Start tracking' button instead.");
}
else if (_trackerRestartRequested != null)
{
_trackerRestartRequested(this, new EventArgs());
}
}
MainWindow
类中的最后一个方法是处理点击“退出跟踪器”按钮的方法。如果跟踪器正在运行,它会调用事件请求停止它。然后它将 shouldExit
设置为 true
,以避免取消窗体关闭,它会处置通知区域图标并关闭窗口。然后应用程序将退出。
private void exitTrackerBtn_Click(object sender, RoutedEventArgs e)
{
if (tracking && _trackingStopRequested != null)
{
_trackingStopRequested(this, new EventArgs());
}
shouldExit = true;
questionsTaskbarIcon.Dispose();
this.Close();
}
ActionCommand 类
我已经在上一节的构造函数中提到了这个类。它派生自 ICommand
,我们需要这个类,因为我们必须将它的一个实例传递给通知区域图标的 LeftClickCommand
。
派生自 ICommand
的类需要两个方法和一个事件:Execute
、CanExecute
和 CanExecuteChanged
(后者是事件)。Execute
执行命令,CanExecute
返回一个布尔值,指示命令是否可以执行,CanExecuteChanged
在 CanExecute
的返回值更改时调用。
ActionCommand
实现了以上所有功能,但它还有一个事件:ActionToExecute
。这个方法由主窗口使用;当调用 Execute
时,ActionCommand
会调用 ActionToExecute
。
ActionToExecute
的实现如下
Action<object> _actionToExecute;
public event Action<object> ActionToExecute
{
add
{
bool canExecuteBefore = this.CanExecute(null);
_actionToExecute += value;
bool canExecuteAfter = this.CanExecute(null);
if (canExecuteBefore != canExecuteAfter)
{
if (_canExecuteChanged != null)
{
_canExecuteChanged(this, new EventArgs());
}
}
}
remove
{
bool canExecuteBefore = this.CanExecute(null);
_actionToExecute -= value;
bool canExecuteAfter = this.CanExecute(null);
if (canExecuteBefore != canExecuteAfter)
{
if (_canExecuteChanged != null)
{
_canExecuteChanged(this, new EventArgs());
}
}
}
}
在向 ActionToExecute
添加/删除事件处理程序之前,它会存储 CanExecute
的当前值。然后它会向 _actionToExecute
添加/删除事件处理程序。之后,它会再次检查 CanExecute
的值。如果值发生变化,则会调用 _canExecuteChanged
。
Execute
首先检查 CanExecute
,如果该方法返回 true
,则调用 _actionToExecute
。
public void Execute(object parameter)
{
if (this.CanExecute(parameter))
{
_actionToExecute(parameter);
}
}
CanExecute
检查 _actionToExecute
是否不为 null
:在这种情况下,它返回 true
,否则返回 false
。
public bool CanExecute(object parameter)
{
return _actionToExecute != null;
}
CanExecuteChanged
的实现如下
EventHandler _canExecuteChanged;
public event EventHandler CanExecuteChanged
{
add
{
_canExecuteChanged += value;
}
remove
{
_canExecuteChanged -= value;
}
}
历史
- 2015 年 9 月 27 日
- 网格中的问题链接已损坏,因为 API 开始返回协议相对 URL。此错误现已修复,链接应能正常工作。
- 2015 年 4 月 6 日
- 第一版