C#でIEを操作する

今回やりたいことは、ざっくり言うとC#で指定したURLをIEで立ち上げたり、閉じたりという操作です。
以下、ざっくり要件で少々詳しい要件を書いてます。

今回の内容を応用すれば、DOM操作をC#経由でしたりできます。
ただ、今回は別にそこまでしません。

なんで今更IEなのかというと、例の如く仕事で必要になったからというやつです。

ざっくり要件

今回実現したい内容は

  • WinFormで配置したボタンでIEを起動する。(二つの異なるページがある)
  • 上記ボタンから起動したIEを親とし、親から開かれたページは子とする。
  • 親子関係を定義して、親に紐づく子を再帰的に閉じれるようにする。
  • 開いた先のリンクを閉じれるようにする(再帰的)
  • WinFormを閉じる際に起動しているすべてのIEを閉じる。
  • WinFormで起動したものでなく、ユーザが手動で開いたIEは制御してはいけない。(閉じたらだめ)

Processではなく、COMを使った理由

最初はProcess.startでIEを操作しようと思いました。
起動だけなら別にそれでもよかったのですが、閉じるという挙動が入ることで少し複雑になります。
というか、実験しているうちに閉じるという挙動をイイ感じにできないんじゃないかと思い至りました。

なぜProcessでIEの閉じるをうまく制御できないのか

まず、上記要件を見ると、ボタンでIEを閉じれるようにしないといけないわけです。
実験用のソースを今回も例の如くgithubにあげてます。
フォームに三つボタンを配置しています。
一つ目がyahooを開くボタン
二つ目がIEを閉じるボタン
三つめがIEのプロセスをkillするボタン

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace HandlingIe
{
    public partial class ProcessSample : Form
    {
        private List<Process> pList = new List<Process>();

        public ProcessSample()
        {
            InitializeComponent();
        }

        private void btnStart_Click(object sender, EventArgs e)
        {
            var p = Process.Start("IExplore.exe", "https://www.yahoo.co.jp/");
            pList.Add(p);

        }

        private void btnClose_Click(object sender, EventArgs e)
        {
            var p = pList.Last();
            p.CloseMainWindow();
        }

        private void btnKill_Click(object sender, EventArgs e)
        {
            var p = pList.Last();
            p.Kill();
        }
    }
}

実際に動作させたい場合は、program.csで以下の部分を変更する

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace HandlingIe
{
    static class Program
    {
        /// <summary>
        /// アプリケーションのメイン エントリ ポイントです。
        /// </summary>
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            //ここをコメント化
            Application.Run(new ComSample());
       //ここをコメント解除
            //Application.Run(new ProcessSample());
        }
    }
}

開いているIEが一つのうちはProcessでうまく動作します。
①すでにIEを開いている場合
②二つ以上IEを開く場合
このような場合は例外が発生します。

System.InvalidOperationException: ‘プロセスは終了しているため、要求された情報は利用できません。’

なぜこのようなことが起こるのか。どこかに書いてあったか忘れたけど、
すでにIEのプロセスが存在していると、二つ目以降のプロセスが一つ目のプロセスに統合され、そのプロセスが消えるらしい。
ただ、それをどこで見たか忘れたので、もしかしたら勘違いかもしれない。
なので、気になる方はご自身で調査してください。
(会社で色々ググって見つけたけど、再度自宅でググってみたけど、すぐに見つからなかった。。。)
っで、上記状態であるのなら、たぶん上記要件を満たすのは厳しいかなと思ったので、COMを使うことにしました。

COMサンプル用にプロジェクトを作成

今回はHandlingIeという名前のプロジェクトを作成して解説します。(COMサンプル用と言いつつ、あとからProcessでうまくいかないフォームも追加しちゃった。)
実験ソースなので、おなじみWinFormsアプリで作っていきます。
github のソース

プロジェクトの作成が終わったら、IEのCOMオブジェクトを扱うために、参照の追加からCOMのMicrosoft Internet Controlsをチェックします。

COMオブジェクトを扱うために、 SHDocVw が必要になります。
using SHDocVw;

SHDocVw.InternetExplorerを操作するために、ラッパークラスを作り
そのクラス経由でIEを操作していきます。
後述しますが、ラッパークラスにした理由は、【 開いた先のリンクを別ウィンドウで開き、それも制御対象としたい 】という要件を満たすためです。Processだと、そもそもこれできないですよね?たぶん。というわけで、ソースは以下。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using SHDocVw;

namespace HandlingIe
{
    /// <summary>
    /// SHDocVw.InternetExplorerをラップするためのクラス
    /// </summary>
    class IeManager : IDisposable
    {
        /// <summary>
        /// このクラスのGUIDを念のため用意
        /// </summary>
        public Guid Guid { get; }

        /// <summary>
        /// 親が閉じられているか
        /// </summary>
        public bool IsParentClosed { get; private set; } = false;

        /// <summary>
        /// 親から開かれた子IEのリスト
        /// </summary>
        private List<InternetExplorer> _childExplorers = new List<InternetExplorer>();

        private InternetExplorer _parentIe;

        /// <summary>
        /// SHDocVw.InternetExplorerをラップするためのクラス
        /// </summary>
        /// <param name="navUrl"></param>
        public IeManager(string navUrl)
        {
            Guid = Guid.NewGuid();
            _parentIe = new InternetExplorer();
            try
            {
                _parentIe.Navigate(navUrl);
                _parentIe.Visible = true;
                // 新しくIEを開くタイミングで発生するイベントにアタッチ
                _parentIe.NewWindow3 += IeOn_NewWindow3;
                _parentIe.OnQuit += IeOn_OnQuit;
            }
            catch (Exception e)
            {
                Marshal.ReleaseComObject(_parentIe);
                Console.WriteLine(e);
            }
        }

        /// <summary>
        /// リソースを解放
        /// </summary>
        public void Dispose()
        {
            CloseParentIe();

            CloseAllChildIe();
        }
        /// <summary>
        /// 最後に開いたブラウザを閉じる
        /// </summary>
        public void CloseLastChildIe()
        {
            // 子の解放
            if (_childExplorers != null && _childExplorers.Any())
            {
                var ie = _childExplorers.Last();
                try
                {
                    ie.NewWindow3 -= IeOn_NewWindow3;
                    _childExplorers.Remove(ie);
                    ie.Quit();
                }
                catch (Exception exception)
                {
                    CloseLastChildIe();
                    Console.WriteLine(exception);
                }
                finally
                {
                    // リソースを解放
                    Marshal.ReleaseComObject(ie);
                }
            }
        }
        /// <summary>
        /// すべての子を閉じる
        /// </summary>
        private void CloseAllChildIe()
        {
            // 子の解放
            if (_childExplorers != null && _childExplorers.Any())
            {
                foreach (var ie in _childExplorers)
                {
                    try
                    {
                        ie.NewWindow3 -= IeOn_NewWindow3;
                        ie.Quit();
                    }
                    catch (Exception exception)
                    {
                        Console.WriteLine(exception);
                    }
                    finally
                    {
                        // リソースを解放
                        Marshal.ReleaseComObject(ie);
                    }
                }

                _childExplorers = null;
            }
        }
        /// <summary>
        /// 親を閉じる
        /// </summary>
        private void CloseParentIe()
        {
            // 親の解放
            try
            {
                if (_parentIe != null)
                {
                    if (!IsParentClosed) _parentIe.Quit();
                    IsParentClosed = true;
                    _parentIe.NewWindow3 -= IeOn_NewWindow3;
                    _parentIe.OnQuit -= IeOn_OnQuit;
                }
            }
            catch (Exception exception)
            {
                Console.WriteLine(exception);
            }
            finally
            {
                // リソースを解放
                Marshal.ReleaseComObject(_parentIe);
            }
        }

        public Action onQuitParentAction;
        /// <summary>
		/// 親が閉じられた場合は、そのステータスを保持する
		/// </summary>
		private void IeOn_OnQuit()
        {
            IsParentClosed = true;
            onQuitParentAction?.Invoke();
        }

        /// <summary>
        /// ie右クリックのタブを新しくor新しいwindowで開く場合に発生するイベント。COMの管理下におく
        /// </summary>
        /// <param name="ppdisp"></param>
        /// <param name="cancel"></param>
        /// <param name="dwflags"></param>
        /// <param name="bstrurlcontext"></param>
        /// <param name="bstrurl"></param>
        private void IeOn_NewWindow3(ref object ppdisp, ref bool cancel, uint dwflags, string bstrurlcontext, string bstrurl)
        {
            Type comType = Type.GetTypeFromProgID("InternetExplorer.Application");
            InternetExplorer ie = Activator.CreateInstance(comType) as InternetExplorer;
            _childExplorers.Add(ie);
            ie.RegisterAsBrowser = true;
            ie.NewWindow3 += IeOn_NewWindow3;
            ppdisp = ie.Application;
            ie.Visible = true;
        }
    }
}

ハマりポイント

親から起動した子の制御

親から起動した子を制御するのが難しかった。
NewWindow3というイベントを使って、新しく開くウィンドウをこのクラスの管理下におくという部分です。
ソースを見て頂けると不思議な気持ちになるかも。
開こうとしているURLは引数bstrurlに入ってくるので、こいつでインスタンス化して、cancel=trueする、というような流れな気がします。
しかし、これでは上手くいかず、二重に起動して、終了制御もうまくいかない。

正しくは、InternetExplorerをインスタンス化して、ppdispに入れる。
その後、インスタンス化したものを_childExplorersに入れて、それらをQuit()したりして操作する。

まだ不十分化もしれないけど、リソースの管理

これボタン起動して、ボタンで閉じるって流れを前提に考えてますけど、たぶん手動で閉じるパターンとかもあるよなと。
COMなので、リソースの管理がちょいとセンシティブなので、どうしたものか。
手動で閉じられ場合、これもイベントで補足してやればいいと思うけど、今回サンプルでゴリゴリ書くの大変だったので、なんとなくこんな感じでもいいんじゃない?くらいのソースを書いてます。
以下の部分ですね。

        /// <summary>
        /// 最後に開いたブラウザを閉じる
        /// </summary>
        public void CloseLastChildIe()
        {
            // 子の解放
            if (_childExplorers != null && _childExplorers.Any())
            {
                var ie = _childExplorers.Last();
                try
                {
                    ie.NewWindow3 -= IeOn_NewWindow3;
                    _childExplorers.Remove(ie);
                    ie.Quit();
                }
                catch (Exception exception)
                {
                    CloseLastChildIe();
                    Console.WriteLine(exception);
                }
                finally
                {
                    // リソースを解放
                    Marshal.ReleaseComObject(ie);
                }
            }
        }

手動で閉じられてる場合は、ie.Quit()で例外が発生するので、そのままcatchで次の子を閉じに行くという感じです。
っで、結局finallyでMarshal.ReleaseComObject呼ばれるので大丈夫かなぁという感じです。
本気でやるなら、ここで例外の発生をみて閉じた判断とするのではなく、ちゃんと閉じるイベントで何かしらしてやる。
ちなみに、親のwindowだけは、手動で閉じられたかという情報が業務上必要だったので、ここだけちょっと工夫した。
っで、この工夫を子window達にもうまいことやれれば。。。いけるんじゃないかな。念のため、このソースがそれ。

        public Action onQuitParentAction;
        /// <summary>
		/// 親が閉じられた場合は、そのステータスを保持する
		/// </summary>
		private void IeOn_OnQuit()
        {
            IsParentClosed = true;
            onQuitParentAction?.Invoke();
        }

親から起動した子もIeManagerとして管理する(未解決)

せっかくラッパークラス作ったのに、子はList<InternetExplorer>で管理してしまっているわけです。
これを本来は、List<IeManager>で管理したいわけです。
ただ、これがまだうまくいっていない。
業務上、すでに必要な機能はこのラッパークラスで良いわけですけど、ちょっと気持ち悪いというかなんというか。。。
なので、ちょっとハっとアイデアが出た時ようにブランチで作業しています。
なんかいいアイデアがあればPullRequestとかほしいです。
よろしくお願いします。

C#C#, COM

Posted by takumioda