這幾天在寫 WinForm 程式,遇到有意思的需求。小程式透過 Registry Shell/Open/Command 註冊方式啟動(類似在 Chrome/Edge 網頁用 IE 開啟超連結所介紹的技巧),以 MyApp.exe %1 方式接收參數執行任務。

這裡用個簡化範例模擬我遇到的挑戰。

Program.cs Main() 從 string[] args 取得外部傳入字串,稍後交給 WinForm 處理。 除了呼叫時傳入參數,程式還有其他方式取得待處理字串,故我用 Queue<string> 儲存待處理字串。 不管啟動時傳入或後續由其他管道接收,一律放入 Queue 中,WinForm 再以 Timer 定期檢查統一由 Queue 取出字串進行處理:

static class Program
{
    public static Queue<string> Messages = new Queue<string>();

    [STAThread]
    static void Main(string[] args)
    {
        if (args.Any())
        {
            Messages.Enqueue(args[0]);
        }
        Application.EnableVisualStyles();
        Application.SetCompatibleTextRenderingDefault(false);
        Application.Run(new Form1());
    }
}

Form1 只簡單放了 ListBox lbMessages 跟 Timer Timer1,Timer1 每 0.1 秒檢查一次 Queue, 只要其中有字串,就取出加入 ListBox 顯示出來:

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private void Timer1_Tick(object sender, EventArgs e)
    {
        if (Program.Messages.Any())
        {
            lbMessages.Items.Add(Program.Messages.Dequeue());
        }
    }
}

簡單寫完,實測也 Work。但有個困擾,就是重複呼叫時會跑出多個視窗:

使用者每點一下網頁連結就跑一個新視窗,感覺不太優。 幸好,之前學過用 Mutex 限定程式只執行一份的技巧(延伸閱讀:防止程式同時執行多份,比檢查 Process 清單更好的方法), 要限定不准重複啟動難不倒我。Program.cs 修改如下:

static class Program
{
    public static Queue<string> Messages = new Queue<string>();

    static string appGuid = "{8604F243-A7AE-45C9-AB4C-D129FEA9FEE9}";
    [STAThread]
    static void Main(string[] args)
    {
        using (Mutex m = new Mutex(false, "Global\\" + appGuid))
        {
            //另一份已執行
            if (!m.WaitOne(0, false))
            {
                MessageBox.Show("程式已在執行中");
                return;
            }
            else
            {
                if (args.Any())
                {
                    Messages.Enqueue(args[0]);
                }
                Application.EnableVisualStyles();
                Application.SetCompatibleTextRenderingDefault(false);
                Application.Run(new Form1());
            }
        }
    }
}

很好,這樣就能阻止多個視窗產生。

但問題只解決了一半,第二次呼叫時傳入的參數應該要移交已在執行中的程序處理,而不是隨著後啟動程序關閉消失無蹤。 因此,後起程序偵測到已有相同程式在執行,關閉前應設法通知執行中的程序,將工作移交過去,這種跨程序溝通是標準的 Interprocess Communictaion(IPC) 情境。

關於 Shared Memory 的兩三事一文列舉過 IPC 有多種選擇,如 Data Copy、DDE、File Mapping、Pipes、RPC、Socket 等。 由於本案例屬低頻率微流量的資訊交換,不想殺雞用牛刀,我希望做法愈省事愈好。評估後,決定試試之前沒玩過且特別適合 WinForm 前景程式的 Data Copy (WM_COPYDATA)。

經過好一陣摸索,最後試出來最簡潔的可行寫法是引用一個封裝好的 SendMessage 字串傳遞共用函式(來源:C# Win32 messaging with SendMessage and WM_COPYDATA),配合我的應用改寫並簡化:

public class MessageHelper
{
    [DllImport("User32.dll", EntryPoint = "FindWindow")]
    public static extern Int32 FindWindow(String lpClassName, String lpWindowName);
    [DllImport("User32.dll", EntryPoint = "SendMessage")]
    public static extern int SendMessage(int hWnd, int Msg, int wParam, ref COPYDATASTRUCT lParam);
    [DllImport("User32.dll", EntryPoint = "SetForegroundWindow")]
    public static extern bool SetForegroundWindow(int hWnd);

    public const int WM_COPYDATA = 0x4A;
    public struct COPYDATASTRUCT
    {
        public IntPtr dwData;
        public int cbData;
        [MarshalAs(UnmanagedType.LPStr)]
        public string lpData;
    }

    public bool BringAppToFront(int hWnd)
    {
        return SetForegroundWindow(hWnd);
    }

    public int SendWindowsStringMessage(int hWnd, int wParam, string msg)
    {
        int result = 0;
        if (hWnd > 0)
        {
            byte[] sarr = System.Text.Encoding.Default.GetBytes(msg);
            int len = sarr.Length;
            COPYDATASTRUCT cds;
            cds.dwData = (IntPtr)100;
            cds.lpData = msg;
            cds.cbData = len + 1;
            result = SendMessage(hWnd, WM_COPYDATA, wParam, ref cds);
        }
        return result;
    }

    public int GetWindowId(string className, string windowName)
    {
        return FindWindow(className, windowName);
    }

    public static void WndProc(ref System.Windows.Forms.Message m, 
        Action<string> callback)
    {
        if (m.Msg == WM_COPYDATA)
        {
            var cds = (COPYDATASTRUCT)m.GetLParam(typeof(COPYDATASTRUCT));
            callback(cds.lpData);
        }

    }
}

工具就緒,來看看程式要如何整合應用 SendMessage 達成溝通。

Program.cs 發現另有一份執行時,要利用 Form1 的名稱(Form1.Text,定義成常數確保 Program 與 Form1 一致)找到 Form1 的 hWnd。 再透過 SendWindowsStringMessage 將 args[0] 傳送給 Form1:


public const string FormName = "WMCopyDataForm\t";

[STAThread]
static void Main(string[] args)
{
    //REF: https://blog.darkthread.net/blog/9952/
    using (Mutex m = new Mutex(false, "Global\\" + appGuid))
    {
        //另一份已執行
        if (!m.WaitOne(0, false))
        {
            if (args.Any())
            {
                //透過SendMessage將訊息傳給執行中的視窗
                var mh = new MessageHelper();
                var hwnd = mh.GetWindowId(null, FormName);
                mh.BringAppToFront(hwnd);
                var res = mh.SendWindowsStringMessage(hwnd, 0, args[0]);
            }
            return;
        }
        else
        {
            if (args.Any())
            {
                Messages.Enqueue(args[0]);
            }
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new Form1());
        }
    }
}

Form1.cs 則加上 WinProc(ref Message m) 方法處理外部傳入資訊, 當接收到 WM_COPYDATA 時,將其還原成原本的資料結構取出字串,將其填入 Queue。

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
        this.Text = Program.FormName;
    }


    protected override void WndProc(ref Message m)
    {
        MessageHelper.WndProc(ref m, s =>
        {
            Program.Messages.Enqueue(s);
        });
        base.WndProc(ref m);
    }

    private void Timer1_Tick(object sender, EventArgs e)
    {
        if (Program.Messages.Any())
        {
            lbMessages.Items.Add(Program.Messages.Dequeue());
        }
    }
}

實測一下。第二次呼叫不會產生新視窗,傳入字串顯示在已存在的視窗,成功! (灑花)

A example showing how to use Mutex to prevent duplicated process instances and pass the arguemt to the existing process with WM_COPYDATA.


Comments

# by Berlin

謝謝大大,簡單易懂,給讚!

Post a comment