さて、これだけ機能を追加しようと思うと、さすがに作り方をステップバイステップで解説するのは気が遠くなるので、わかりにくいクラスの概要を説明して、あとは
ソースコード、インストーラを一式つけておきます。
何が、大変だったかというと、やはり画像を取り出すための URL
に規則性がなく、それを個別に実装していくと、いたずらに同じようなコードが並ぶことになります。そこで、どこまで一般化できるか(するかどうか)が悩みました
。あまり凝った構成にすると全体が把握しづらくなるし、べたに書くとコードのメインテナンス性が落ちるし、ちょうどいい構成が難しいですね。
URLに関しては、たとえばアメダスの降水量であれば、
"http://www.jma.go.jp/jp/amedas/imgs/rain/{0}/{1}.png"
のように場所と時間がパラメータになります。時間は、過去から現在まで1時間おきです。
一方、警報は、次のように場所のみで、時間のパラメータは取りません。
"http://www.jma.go.jp/jp/warn/imgs/{0}/99.png"
また、予測: 紫外線はさらに複雑で、場所と時間のパラメータを取りますが、昼間と、18時〜翌日の6時の夜間では、与える時間のパラメータが違います。
"http://www.jma.go.jp/jp/uv/imgs/uv_color/forecast/{0}/{1}.png"
さらに、天気図では、24時間予報図、48時間予報図、実況天気図(アジア)では、過去3時間おき(3, 6,
9,・・・)であったり、過去6時間おき (3, 9, 15, 21)であったり、過去12時間おき (9,
21)であったり、と様々です。おまけに、これらの時間が、気象情報の表示種類が変わるごとに変更しなければなりません。
これらを単純に1つのメソッドで実装してしまうと、if...else if .... else if ...
みたいになるか、switch が延々と並び、さらにそれらをメニューに登録しなければならず、とんでもないコードになります。
URLの時間のパラメータを見てみると、最低限 メニューに表示する文字列、url に時間パラメータを渡すための文字列(TimeCommand)があればよいことがわかります。しかし、MenuItem
には、この時間パラメータを渡すための文字列(TimeCommand)を持たす場所がありません。そこで、MenuItemを継承して、MenuItemTime
クラスを作成し、そこにTimeCommandを保持するようにします。
public
class
MenuItemTime :
MenuItem
{
private
string
timeCommand;
public
string TimeCommand
{
get
{ return
timeCommand; }
set
{ timeCommand = value;
}
}
public
MenuItemTime(string
menuText, string
timeCmd)
{
Text = menuText;
TimeCommand = timeCmd;
}
}
このMenuItemTimeを MenuItemCollection クラスに追加して、Menu.MenuItems
に直接セットすることができるととても楽なのですが、このメソッドが用意されていません。しかたないので、MenuItemTimeの配列を用意し、それを順番にMenu.MenuItems
に Add してく必要があります。
そこで、MenuItemCollection のかわりに、List<MenuItemTime>
のジェネリックスを使用して、一度このリストに時間メニューの値を用意し、次にMenu.MenuItems
にセットするという方法を使います。そのために、まず List<MenuItemTime> のラッパークラスとして、ListMenuItemTime
クラスを次のように定義します。このメソッドとしては、時間経過に伴って、時間メニューを更新しなければならないので、そのためのUpdateメソッド、それからMenu
に値をセットするための、SetTimeMenu(Menu menu, EventHandler eh) を定義します。ClearMenuCheck(Menu
menuItem) は、メニューのチェックをまとめて外すための補助メソッドです。
public
class
ListMenuItemTime
{
protected
List<MenuItemTime>
timeList = new
List<MenuItemTime>();
public
void Set(List<MenuItemTime>
tList)
{
timeList = tList;
}
///
<summary>
///
時間のリストList<MenuItemTime>を更新する。
///
</summary>
virtual
public
void
Update()
{
timeList.Clear();
}
///
<summary>
///
メニューに時間をセットする
///
</summary>
///
<param name="menu">セットするメニュー</param>
///
<param name="eh">メニューのイベントハンドラ</param>
///
<returns></returns>
virtual
public
string SetTimeMenu(Menu
menu, EventHandler
eh)
{
menu.MenuItems.Clear();
foreach
(MenuItemTime
at in
timeList)
{
int
i = menu.MenuItems.Add(at);
menu.MenuItems[i].Click +=
new System.EventHandler(eh);
}
ClearMenuCheck(menu);
return
null;
}
virtual
public
string
GetDefaultTimeCommand() { return
null; }
protected
void
ClearMenuCheck(Menu
menuItem)
{
// メニューのチェックをクリアする
foreach (MenuItem
mi in
menuItem.MenuItems)
{
if
(mi.Checked) mi.Checked = false;
if
(mi.MenuItems.Count != 0)
ClearMenuCheck(mi);
}
}
}
あとは、それぞれの気象情報に合わせて、ListMenuItemTime
というリストを作成します。まとめると次のような構成になります。
public
class
TimeListNormal :
ListMenuItemTime
{
///
<summary>
///
レーダー・降水ナウキャスト以外の場合の時間メニューを作成
///
過去12時間まで1時間刻み
///
</summary>
public
override
void Update()
{
base.Update();
// 中略
for (int
i = 0; i < 12; i++)
{
// 過去12時間まで1時間刻みで追加する。
DateTime
t = baseHour.Subtract(new
TimeSpan(i,
0, 0));
string
timeCmd = t.ToString("yyyyMMddHHmm")
+ "-00";
timeList.Add(new
MenuItemTime(t.ToString("HH:mm"),timeCmd));
}
}
///
<summary>
/// メニューに時間をセットする
///
</summary>
public
override
void SetTimeMenu(Menu
menu, EventHandler
eh)
{
base.SetTimeMenu(menu,
eh);
timeList[0].Checked = true;
}
public
override
string
GetDefaultTimeCommand()
{
return
timeList[0].TimeCommand;
}
}
このように各時間メニューごとに ListMenuItemTimeから継承したクラスを作成します。(下図)
このクラスダイアグラムは一部で、全体としてはこのぐらい(下図)になります。
ファイルは、ClassMenuTime.cs にまとめてあり(下図)、500行程度です。
このままだと、これらのインスタンスがバラバラなので、MenuItemHanderクラスでインスタンスをまとめて管理します。(下図)
|
幸い、地域メニューは地方だけに絞り込んだので、シンプルにすみました。時間メニューと同様に、MenuItemを継承して、locationCode
を持たせた MenuItemLocation クラスを作成します(下図)。これをMenuLocation
クラスで、場所メニューに追加するだけです。
using System;
using
System.Windows.Forms;
namespace
Uchukamen.WZero3.WZero3DeAmedas
{
public
class
MenuItemLocation :
MenuItem
{
private
string
locationCode;
public
string
LocationCode
{
get
{ return
locationCode; }
set
{ locationCode = value;
}
}
public
MenuItemLocation(string
locCode, string
name, EventHandler
eh)
{
locationCode = locCode;
Text = name;
Click += new System.EventHandler(eh);
}
}
public
class
MenuLocation
{
public
void Set(MenuItem
mi, EventHandler
eh)
{
mi.MenuItems.Add(new
MenuItemLocation("000",
"全国", eh));
mi.MenuItems.Add(new
MenuItemLocation("201",
"北海道地方(北西部)",
eh));
中略
mi.MenuItems.Add(new
MenuItemLocation("219",
"宮古・八重山地方",
eh));
}
}
}
|
種類メニューも基本的に地域メニューと同じ考え方で、V1.2.0.0から大きな変更はありません。
各MenuItemは、画像をアクセスするための Url の文字列フォーマット UrlString と、その文字列フォーマットに位置情報が必要かどうか(bool
LocationMenu)、場所情報が必要かどうか(bool TimeMenu)を持たせます。
種類に関するメニューは、MenuForType クラスが管理します(下図)。ここで、SetAmedasMenu
で気象関連の種類のメニューを追加し、SetEarthQuakeMenu メソッドで地震関連の種類のメニューを追加します。
using System;
using System.Collections.Generic;
using
System.Windows.Forms;
namespace
Uchukamen.WZero3.WZero3DeAmedas
{
public
class
MenuItemType :
MenuItem
{
private
bool
timeMenu;
public
bool
TimeMenu
{
get
{ return
timeMenu; }
set
{ timeMenu = value;
}
}
中略
public
MenuItemType(string
name, bool
loc, bool
time,
ListMenuItemTime tList,
EventHandler eh,
string
urlStr)
{
Text = name;
locationMenu = loc;
timeMenu = time;
urlString = urlStr;
timeList = tList;
Click += new System.EventHandler(eh);
}
}
public
class
MenuForType
{
private
MenuTimeHander
menuTimeHander;
public
MenuForType(MenuTimeHander
mth)
{
menuTimeHander = mth;
}
//
public
void SetAmedasMenu(MenuItem
mi, EventHandler
eh)
{
// 天気予報のトップメニュー
int
iobs = mi.MenuItems.Add(new
MenuItemType(
"天気予報",
false,
false,
null, eh,
null));
// 天気予報のサブメニュー
mi.MenuItems[iobs].MenuItems.Add(new
MenuItemType(
"天気予報",
true,
false,
null, eh,
"http://www.jma.go.jp/jp/yoho/images/g1/{0}_telop_today.png"));
中略
mi.MenuItems[ietc].MenuItems.Add(new
MenuItemType(
"黄砂観測実況図",
false,
true,
menuTimeHander.timeListKosa, eh,
"http://www.jma.go.jp/jp/kosa/imgs/kosaobs/000/{0}.png"));
}
}
// 地震関連のメニューを追加
public
void
SetEarthQuakeMenu(MenuItem
mi, EventHandler
eh)
{
//地震のトップメニュー
int
ieq = mi.MenuItems.Add(new
MenuItemType(
"地震情報",
false,
false,
null, eh,
""));
// 地震のサブメニュー
mi.MenuItems[ieq].MenuItems.Add(new
MenuItemType(
"地震情報(震度速報)",
false,
false,
null, eh,
"http://www.jma.go.jp/jp/quake/quake_sindo_index.html"));
中略
mi.MenuItems[ieq].MenuItems.Add(new
MenuItemType(
"地震情報(遠地地震に関する情報)",
false,
false,
null, eh,
"http://www.jma.go.jp/jp/quake/quake_foreign_index.html"));
}
}
種類のメニューの初期設定は、次のように呼び出します。
private
void CreateMenuForType()
{
MenuForType menuForType = new
MenuForType(menuTypeHander);
// 天気関連の種類メニューを追加する
menuForType.SetAmedasMenu(menuItemType, menuItemType_Click);
// 地震関連の種類メニューを追加する
menuForType.SetEarthQuakeMenu(menuItemType,
menuItemEarthQuake_Click);
// デフォルトでは、トップメニューを選択する。
selectedTypeMenu = (MenuItem)(menuItemType.MenuItems[0]);
}
これで、種類メニュー関連をこの1つのクラスに集中させます。
|
さて、ここまででメニュー関連の実装はほぼ終わり、メニューを選択することにより、種類、地域、時間に応じたパラメータを扱うことができるようになりました。次に、これらのパラメータから、画像のURLを計算し、画像を読み込みます。画像を読み込む際には、メインフォームと同じスレッドで読み込むと、メインフォームが画像の読み込み中にフリーズしてしまいます。そこで、画像を読み込む処理はメインスレッドとは別のスレッドで実行する必要があります。
.NET Framework
2.0ではスレッド関連の機能も強化されていて、パラメータを渡してスレッドを実行することができます。詳細は、http://www.microsoft.com/japan/msdn/netframework/skillup/core/article6.aspx
を見てください。
しかし、.NET Compact Framework 2.0では、この機能がサポートされていません。したがって、次のような
ThreadImageLoader クラスを作成する必要がありました。実装している内容は簡単で、ThreadImageLoader
クラスにパラメータを渡しておき、その中でパラメータを使用しながらスレッドを実行して画像を読み込むという処理になります。
class
ThreadImageLoader
{
private
Thread
thread = null;
public
ThreadImageLoader(FormMain
fMain, PictureBoxWeb
pBox, ProgressBar
pBar, Uri
param, bool
save)
{
// パラメータをセット
}
// スレッドを実行
public
void Start()
{
thread = new
Thread(AsyncLoadImage);
thread.Start();
}
public
void Abort()
{
thread.Abort();
}
public
void Join()
{
thread.Join();
}
// 画像を読み込む
public void
AsyncLoadImage()
{
・・・
}
}
ここで注意しなければならないのが、メインフォームは別のスレッドで画像の読み込みを行い、その画像をメインスレッドの
PictureBox に書き込んでやる必要があります。しかし、メインスレッドと同一のスレッドでなければ、メインスレッドのPictureBoxに書き込みできません。このような別スレッドから、コントロールと同じスレッドに処理を渡す方法として、ほとんどのコントロールに
Invoke メソッドが用意されています。別スレッドに、コントロールのインスタンスを渡しておいて、そのコントロールから Invoke
メソッドを呼び出してあげると、コントロールと同一のスレッドで動作してくれます。同様に、非同期の BeginInvoke, EndInvoke
もサポートされています。ここでは、Invoke メソッドでコントロールと同じスレッドをキックしてあげます。.NET Framework
2.0からは、コントロールに対して別のスレッドから書き込みが行われると例外が上がるようになっています。
具体的な AsyncLoadImage() の骨格は、次のような流れになっています。
public
void
AsyncLoadImage()
{
try
{
WebRequest
request = HttpWebRequest.Create(urlAmedas.ToString());
中略
using (HttpWebResponse
response = (HttpWebResponse)request.GetResponse())
using
(BinaryReader
dataStream = new
BinaryReader(response.GetResponseStream()))
using
(MemoryStream
memStream = new
MemoryStream((int)(response.ContentLength)))
{
byte[] buff =
new
byte[response.ContentLength
+ 10000];
中略
while (true)
{
画像をメモリーストリームに読み込む
}
// Bitmap をメモリストリームから作成する。
bmp = new
Bitmap(memStream);
// 必要に応じて、メモリストリームからファイルに書き出す。
}
picBox.Invoke(new
DelegSetImage(picBox.SetImage),
new
object[] { bmp });
picBox.Invoke(new
DelegScale(picBox.scale),
new
object[] { 1.0f });
}
catch (WebException
exc)
{
中略
}
finally
{
中略
}
}
これに、あとはプログレスバーを表示したり、ファイルに書き出したりの処理が入るだけですので、1つ1つ追っていけば理解できると思います。
|
さて、画像読み込み用のスレッドで画像の読み込みが終わると、picBox.Invoke(new
DelegVoid(picBox.Invalidate)) が最後に呼び出されて、picBox
の再描画が必要になったことをメインフォームに教えます。メインフォームでは、Invalideate()が呼び出されると、OnPaint
イベントハンドらが呼び出されて、PictureBox の再描画処理が始まります。この OnPaint
で実際の描画処理を実装してあげればよいことになります。
ところで、今回は拡大・縮小、移動を行わせようとすると、メインフォームでいろいろ実装する必要が出てきてしまいます。しかし、このような拡大・縮小、移動であれば
PictureBox 内で閉じて実行することができるので、PictureBox
から継承したクラスを作成し、そこにそのような面倒な処理を隠ぺいすることにします。 そこで、次のような PictureBoxから継承した
PictureBoxWeb クラスを作成し、その中で拡大・縮小 ScaleImage メソッド、移動のための MoveUp/MoveDown/MoveLeft/MoveRight
メソッドを実装します。
水平・垂直への移動は、極めて簡単で描画位置を変更し、Invalidate() により、再描画を指示するだけです。
///
<summary>
/// 水平・垂直への移動
///
</summary>
public
void
MoveRight()
{ sx += (int)(20
/ sz); Invalidate(); }
public void
MoveLeft()
{ sx -= (int)(20
/ sz); Invalidate(); }
public void
MoveUp()
{ sy -= (int)(20
/ sz); Invalidate(); }
public
void
MoveDown()
{ sy += (int)(20
/ sz); Invalidate(); }
拡大縮小も同様に、スケールのパラメータからオリジナルの画像の座標と描画先の座標を計算して、再描画をかけるだけですが、ちょっとめんどうなのでPowerPointに拡大・縮小の説明をまとめておきました。
///
<summary>
/// 拡大縮小時のパラメータを計算する
///
</summary>
///
<param name="s">拡大縮小率</param>
public
void ScaleImage(float
s)
{
// オリジナルの画像の座標と描画先の座標を計算する。
この計算はわかりにくいけれど、図を書いて考えてね。
Invalidate(); // 再描画を指示する。
}
そして、最後にInvalidate を呼び出すことにより、次の OnPaint
イベントハンドらが呼び出されます。そこでは、基本的に、計算したオリジナルの画像の座標と描画先の座標をもとに、originalの画像をもとにDrawImage
で描画を行います。
protected
override
void OnPaint(PaintEventArgs
pe)
{
if (original ==
null)
return;
Rectangle destinationRect = new
Rectangle(
(Parent.Width - normalizedWidth) / 2, (Parent.Height -
normalizedHeight) / 2,
normalizedWidth, normalizedHeight);
pe.Graphics.DrawImage(original, destinationRect,
sx, sy, sWidth, sHeight,
GraphicsUnit.Pixel,
new System.Drawing.Imaging.ImageAttributes());
// 基本クラス OnPaint を呼び出しています
base.OnPaint(pe);
}
|
V1.x
では、地震情報は扱えませんでした。これは、Amedas などの画像アクセス方法と同じメカニズムが取れないためです。具体的に言うと、
http://www.jma.go.jp/jp/quake/quake_local_index.html
のページにあるように表形式で、最新の情報が表示されます。
このリストをクリックすると、次のようなHTMLにアクセスに行きます。
http://www.jma.go.jp/jp/quake/03110600391.html
このページで表示されている画像のURL は、次のようになっています。
http://www.jma.go.jp/jp/quake/images/japan/03110600391.png
要するにこれと同じことをプログラムで実行しないと、画像を取得することができません。そこで、EarthQuake というクラスを追加し、そこに地震関連の実装を集中させます。処理は、地震の表を読み込む→表を分解して、行ごとのデータにする→行ごとのデータを分解して、1つの行の情報のかたまり
class EarthQuake (発表日時、発生日時、震央地、マグニチュード、最大震度、地図へのURL)で管理するという流れになります。
class EarthQuake は次のような構成です。
これをジェネリクス List <EarthQuake> で管理しています。
メニューで地震関連が選択された場合、次のように呼び出します。
private
void
ShowEarthQuakeListView(MenuItemType
mItem)
{
中略
ListEarthQuake
leq = new
ListEarthQuake();
leq.GetInfo(mItem.UrlString.ToString());
listViewEarthQuake.Items.Clear();
foreach (EarthQuake
eqinfo in
leq)
{
listViewEarthQuake.Items.Add(new
ListViewItem(eqinfo.Get()));
}
リストビューを表示する。
}
これで、次のようにリストビューが表示されます。
次に、ユーザーがリストを選択すると、次のように画像を表示するメソッドを呼びます。
private
void
listViewEarthQuake_SelectedIndexChanged(object
sender, EventArgs
e)
{
中略
// 画像のURLを取得する
string
imageUrl = ((ListView)sender).Items[selectedIndex].SubItems[5].Text;
EnablePicBox();
// 画像を表示する
ShowEarthQuake(new
Uri(imageUrl));
}
画像表示のためのメソッド ShowEarthQuake() は、基本的にAmedas
の画像表示と同じロジックをURLを変えて次のように呼び出しているだけです。
private
void
ShowEarthQuake(Uri
url)
{
中略
loadImageThread = new
ThreadImageLoader(this,
picBox, progressBar1, url, configFile.Save);
loadImageThread.Start();
}
なお、どのようにHTML から情報を切り出しているかというと、正規表現(Regex) を使用しています。幸い、.NET
Compact Framework でも正規表現がサポートされているので助かりました。
一行分抜き出す正規表現
Regex reg =
new
Regex("(?<=<tr><td
nowrap><a href=).*(?=</td></tr>)");
その1行から各データを抜き出すための正規表現は次の通り(ちょっといい加減だけど)
private
Regex regAnnounce =
new
Regex("(?<=.html>).*分(?=</a>)");
private
Regex regUrl
= new
Regex("[0-9]+.html");
private
Regex
regTime = new
Regex("[ 0-9]+日[0-9]+時[0-9]+分頃");
private
Regex
regShindo = new
Regex("震度[0-9]");
private
Regex
regMagnitude = new
Regex("M[0-9][.]?[0-9]?");
private
Regex
regLocation = new
Regex("(?<=頃</td><td
nowrap>).*(?=</td><td nowrap>M)");
以上、数行で切り出すことができます。正規表現は、便利便利w。
おまけに、津波も同じように実装しました。地震と津波で ListView
を共通化することもできるのですが、かえってコードの見通しが悪くなりそうだったので、別々に持たせることにしました。
|
.NET Compact Framework では、WebClient がないなど、一部制約がありますが WebRequest/WebResponse
などの代替手段を使用できます。また、正規表現はひょっとしたらサポートされていないかもと思ったのですが、これも標準でサポートされており、実装上困ることはほとんどありませんでした。
それから、V1.0, V1.1 のときに、ネーミング規則もあまり考えずに進めてしまいましたが、例えばMenuItem
を継承した場合、AbcMenuItem としますか? それとも MenuItemAbc としますか?
最初 AbcMenuItem としていましたが、複数の同じようなクラスを作る必要があり、すべてMenuItemAbc
というネーミング規則にしました。今後もそうするでしょう。ちょっとした点ですけれど、意外とコードを読むうえで、重要です。皆さんどうしているのでしょうか・・・気になる。
それにしても、こんな名前の変更が Visual Studio のリファクタリングで簡単にできます。Visual Studio
のリファクリタイングは、機能としてはパッとしないですが、これがなかったら、死んでました。
処理的にも実際に実装した部分はコメント込みで1700行ちょっとかかりましたが、基本的な動作はシンプルですので、その他便利ツールなどを作ってみてはいかがでしょうか。
開発環境は、Visual
Studio 2008 Express Edtion が無償でダウンロードできます。
|