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

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2015 年 4 月 6 日

CPOL

20分钟阅读

viewsIcon

19893

downloadIcon

337

当 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_BLOBCRYPTPROTECT_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;
}

这些类用于 CryptProtectDataCryptUnprotectData 方法的参数。这些方法用于加密和解密。

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

这些方法使用起来并不简单和简短,因此类中还有两个方法:EncryptDataDecryptData。这些方法使用上述代码块中定义的本地方法。

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 文件中,有四个类:ItemSummaryListViewModelPaginationInfoItemSummaryNameIdPair。关于这些类没有太多可说的,它们旨在保存 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 类来完成此操作,其解释可以在本文前面的一段中找到。然后,它创建一个 WebClientapi.codeproject.com/Token 发送请求,参数为 grant_typeclient_idclient_secret。在发送此请求之前,它将 Content-Type 标头设置为 application/x-www-form-urlencodedWebClient.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 方法使用 cancelEventManualResetEvent)来取消跟踪器的执行。

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 方法

在这个方法中,实际的工作发生了

  1. 使用 CheckApiConnection 方法检查 API 连接。
  2. 使用 GetAccessToken 方法获取访问令牌。
  3. 使用 FetchNewestQuestions 方法获取最新问题。
  4. 检查是否有新发布的问题。如果是,则调用 NewQuestionTracked 事件。
  5. 等待一段时间。等待多长时间由 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 循环。这个循环中发生了什么?

  1. 使用 FetchNewestQuestions 获取最新问题。
  2. 一个 for 循环遍历所有问题。
  3. 如果当前问题不在 postIds 队列中,则
    1. 将当前问题的 ID 添加到 postIds 中。
    2. 使用当前 ItemSummary 的信息创建一个 QuestionData 对象。如果问题作者的 ID 是 0,则名称为空,因此将其替换为 [unknown]。ID 有时为 0 的事实是由此 bug 引起的。
    3. 将新创建的 QuestionData 添加到 newQuestions 列表中。
    如果当前问题已在队列中,那么此后将不会有新问题,我们将跳出 for 循环。
  4. 我们只在队列中存储最新的 50 个问题,所以如果超过 50 个,则删除所有多余的项目。
  5. 如果跟踪到新问题,则在调度线程上调用 _onNewQuestionTracked
  6. 然后,我们使用 cancelEvent.WaitOne 方法等待指定的毫秒数。当调用 Cancel 方法(因此也调用 cancelEvent.Set)时,WaitOne 将被中断并返回 true。如果中断,我们将跳出 while 语句。如果未中断,我们将继续。

应用程序

单实例应用程序

因为 New Questions Tracker 应该只运行一个实例,所以我将应用程序设为单实例应用程序。我创建了一个包含 Main 方法的 Program.cs 文件来处理这个问题。它尝试创建一个名为 CodeProjectNewQuestionsTrackerMutex,并检查是否已存在一个。如果存在,它会显示一个错误消息,说明已有一个正在运行的实例,然后退出。在 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 调用跟踪器上的 CancelStart 以使其重新启动。
  • StartTracking 调用跟踪器上的 Start 以启动它。
  • StopTracking 调用跟踪器上的 Cancel 以停止它。
  • ChangeDelayTime 使用 Storage 类更改延迟时间。
  • ChangeClientIdSecret 使用 EncryptDecryptDataStorage 更改客户端 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 元素确保 TextBlockTextBox 中的字体大小为 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.ColumnsDataGridTemplateColumnDataTemplate 来完成

<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:一个用于帖子标题,一个用于帖子作者。在 DataTemplateColumnCellTemplate 属性中,我们放置了一个 DataTemplate。此元素包含我们放入 DataGrid 中的实际控件。模板包含一个带有 dataGridTextBlockStyle 样式的 TextBlock。此 TextBlock 包含一个 HyperLink,其工具提示和 URI 来自数据源,数据源在代码隐藏中设置。超链接没有任何文本装饰,并在单击超链接时执行 hyperlink_RequestNavigate。超链接内部有一个 Run,其文本绑定到问题标题或作者姓名。

在另一个选项卡页面中,我们有一个 WPF Grid 来显示 TextBlockTextBoxButton。不带子元素时,TabItemGrid 看起来像这样

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

在这些定义之后,是一些 TextBlockTextBox

<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 和客户端密钥未在文本框中公开。出于隐私/安全原因,它们被保密。您只能使用文本框输入新的。

TextBlockTextBox 下方,有一些 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 将使用这些事件通知 AppApp 将采取相应的操作。

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;
    }
}
  1. 当用户更改延迟时间时,会调用 DelayTimeChanged
  2. 当用户更改客户端 ID 和客户端密钥时,会调用 ClientIdSecretChanged
  3. 当用户单击按钮重新启动跟踪器时,会调用 TrackerRestartRequested
  4. 当用户单击按钮启动跟踪器时,会调用 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.csApplication_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;
}

shouldExitfalse;当用户点击“退出跟踪器”按钮时,它被设置为 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 的类需要两个方法和一个事件:ExecuteCanExecuteCanExecuteChanged(后者是事件)。Execute 执行命令,CanExecute 返回一个布尔值,指示命令是否可以执行,CanExecuteChangedCanExecute 的返回值更改时调用。

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 日
    • 第一版
© . All rights reserved.