Register-ArgumentCompleter で 入力補完機能を自作しよう!
これは、PowerShell Advent Calendar 2015 9日目の記事です。
目次
はじめに
以前、TabExpansion++ というモジュールを使うと、自前の入力補完機能を簡単に作ることが出来る、という内容の記事を書きました。
この入力補完のカスタマイズ機能ですが、PowerShell v5.0 から標準で使えるようになり、より手軽に使用できるようになっていました。
それに伴い、Register-ArgumentCompleterというコマンドレットが追加されていて、このコマンドレットを実行するだけで、自前の入力補完機能が登録できるようになったようです。
今回は、このRegister-ArgumentCompleterコマンドレットを使って、自前の入力補完機能を実装してみたいと思います。
Register-ArgumentCompleter コマンドレット
まずは、Register-ArgumentCompleter コマンドレットに渡すパラメータを確認してみましょう。
Register-ArgumentCompleter ` -CommandName (ターゲットコマンド名) ` -ParameterName (ターゲットパラメータ名) ` -ScriptBlock (入力候補一覧を作成するスクリプトブロック)
CommandName
パラメータに、補完機能のを登録するコマンドの名前を指定します。ParameterName
パラメータに、補完対象となるパラメータの名前を指定します。ScriptBlock
パラメータに、独自の入力候補一覧を作成する処理を記述したスクリプトブロックを指定します。(詳細は後述します)
入力候補の一覧を生成するスクリプトブロック
ScriptBlock
パラメータには、System.Management.Automation.CompletionResult
オブジェクトをコレクションで返すスクリプトブロックを指定します。
また、スクリプトブロックには、5つのパラメータが渡ってきて、入力途中の文字列などが取得できるようになっています。
スクリプトブロックの概要をざっくりと書くと以下の様になります。
$createCompletionResults = { # 以下の5つのパラメータを受け取る。入力中の文字列は $wordToComplete に入ってくる。 param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) $items = (入力候補のソースを取得) # 入力候補の数だけ値を出力する foreach($item in $items) { $completionText = (決定時にパラメータ値として挿入される文字列); $listItemText = (入力候補の一覧に表示される文字列); $resultType = (入力候補のタイプ。候補の左側に表示されるアイコンを指定できる); $toolTip = (ツールチップに表示される文字列); # CompletionResult オブジェクトとして出力する [System.Management.Automation.CompletionResult]::new( $completionText, $listItemText, $resultType, $toolTip); } }
それでは、これを踏まえて実際に入力補完機能を作ってみましょう。
Invoke-Item コマンドレットの入力候補に「最近使った項目」の一覧を出す
Invoke-Item コマンドレットのPath
パラメータ入力時に「最近使った項目」の一覧を出して、最近使ったファイルに素早くアクセスできるようにしたいと思います。
Invoke-Item
コマンドレットは、指定したファイルを実行したり、フォルダを開いたりするのに使用するコマンドレットです。
エクスプローラ上で任意のファイルやフォルダをダブルクリックした時と同様の動作をします。「最近使った項目」は、
%AppData%\Microsoft\Windows\Recent
フォルダにショートカットとして保存されます。スタートメニューやタスクバーアイコンを右クリックすると表示される「最近使ったもの」一覧で利用されます。
実装
実装したコードは、以下の通りです。
細かな部分はコード内のコメントと、後述の補足説明をご確認ください。
「最近使った項目」フォルダからショートカットファイルの一覧を取得する
「最近使った項目」(Recent)フォルダは、[System.Environment]::GetFolderPath()
メソッドで取得できます。
取得したフォルダ以下のファイルは Get-ChildItem コマンドで取得しています。フォルダ名の下に\*.lnk
を付けることで、ショートカットファイル(.lnk)のみを取得するようにしています。
取得したファイルの一覧は、更新日時の新しい順にソート後、先頭から表示したい件数分(ここでは50件)を取得しています。
# Recentフォルダから、最新50件のショートカット(*.lnk)ファイルを取得 $recentFolder = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::Recent); $items = Get-ChildItem "$($recentFolder)\*.lnk" | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 50;
Pathを直接入力する場合にも対応する
「最近使った項目」の一覧から選択だけでなく、Pathを直接入力したい場合にも対応するため、スクリプトブロックの冒頭で以下のコードを入れています。
# "\" が含まれる場合は、通常のPath入力とみなし、独自の入力補完は行わない # 例えば"C:\" と入力した場合など if(([string]$wordToComplete).Contains("\") ) { return; }
こうすることで、".\" や、"C:\" などと入力した場合は 通常のPathを補完するダイアログが表示されるようになります。
(スクリプトブロックが、なにも返さなかった場合は、通常の補完機能が動作することを利用しています。)
入力済みの文字列がマッチする項目だけを一覧に含める
パラメータに入力途中の文字がある状態で、補完機能が実行された場合、「補完一覧に表示する文字列」に「入力済みの文字列」がマッチする項目のみを一覧に含めるようにしています。
# 一覧に表示する文字列 $listItemText = Split-Path $shortcut.TargetPath -Leaf; # 入力中の文字列$wordToCompleteがマッチしない項目はスキップ if($listItemText -notmatch $wordToComplete) { continue; }
ショートカットの情報はWSHのCreateShortCut()で取得
どうやらショートカットファイルを操作するPowerShellのコマンドはないようなので ショートカットの参照元を取得するために、WSHの機能を使用しています。
$wsh = new-object -comobject WScript.Shell $shortcut = $Wsh.CreateShortcut("path\to\shortcut"); # 参照元のパス $shortcut.TargetPath;
実行結果
Invoke-Item の第1引数(-Path)で [Ctrl]+[Space](「最近使った項目」の一覧を表示)
"ab" を入力後 [Ctrl]+ [Space] (「最近使った項目」で名前に "ab" を含むファイルのみ表示)
"c:\" を入力 (c ドライブのアイテム一覧を表示)
ちゃんと、期待通りの動きになっているようです。
まとめ
Register-ArgumentCompleterコマンドレットを使う事で、自前の入力補完機能が簡単に実装できることが分かりました。
この機能が必要となるケースはそれほど多くはないかもしれませんが、使い方によっては非常に有用な機能となり得るのではないでしょうか。
おまけ
Invoke-Item でファイルを開いても、「最近使った項目」が更新されない問題が発覚しました。
どうやら、エクスプローラや標準のファイル選択ダイアログを使わないと「最近使った項目」は更新されないようです。
これはあまり嬉しくないので、Invoke-Item コマンドを実行後に、「最近使った項目」を更新する処理を追加したコマンドレットを作って、これを使う様にしました。
function Invoke-ItemEx { [CmdletBinding()] [Alias("iix")] Param ( [Parameter(Mandatory=$true, Position=0)] [string] $Path ) if(-not(Test-Path $Path)) { Write-Error -Exception ([System.ArgumentException]::new( "存在するファイル名またはフォルダ名を指定してください","Path")); return; } Invoke-Item -Path $Path; # ファイルのみ if(Test-Path $Path -PathType Leaf) { $Shell = New-Object -ComObject Shell.Application; $Shell.AddToRecent($Path); } } Register-ArgumentCompleter -CommandName Invoke-ItemEx -ParameterName Path ` -ScriptBlock $recentItemsCompletion
- 「最近使った項目」への登録は Shell.Application の AddToRecent()を使います。
- AddToRecent()に、アクセスしたファイルのPathを渡して実行すると、Recentフォルダに以下のショートカットが登録されます。
- 引数に渡したファイルのショートカット
- そのファイルを格納しているフォルダのショートカット
- AddToRecent()にフォルダのPathを渡すと、その1階層上のフォルダのショートカットが登録されてしまいます。
- これは期待する動作ではないので、AddToRecent()にはファイルのPathのみを渡すようにしました。
UWPのContentDialog や Pageの イベントを {x:Bind } でバインドするとXAML内部エラーになる
UWP のプログラミング中に、以下のエラーに遭遇したのでメモしておきます。
1>C:\Program Files (x86)\MSBuild\Microsoft\WindowsXaml\v14.0\8.2\Microsoft.Windows.UI.Xaml.Common.targets (263,5): Xaml 内部エラー error WMC9999: オブジェクト参照がオブジェクト インスタンスに設定されていません。
ContentDialog の PrimaryButtonClick イベントを{x:Bind }でバインドしたらビルドエラーになった。
次のXAMLで、PrimaryButtonClick="ContentDialog_PrimaryButtonClick"
の部分を以下のどちらかに置き換えると、ビルドエラーで上記メッセージが表示されます。
PrimaryButtonClick="{x:Bind ViewModel.Execute}"
(ViewModel のメソッド)PrimaryButtonClick="{x:Bind ContentDialog_PrimaryButtonClick}"
(コードビハインドのイベントハンドラ)
また、これと同じメソッドのバインディングを Dialog内に配置したボタンで行った場合は問題ありませんでした。
Button Click="{x:Bind ViewModel.Execute}"
<ContentDialog x:Class="TestApp1.Views.ContentDialog1" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:TestApp1" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" Title="TITLE" PrimaryButtonText="Button1" SecondaryButtonText="Button2" PrimaryButtonClick="ContentDialog_PrimaryButtonClick"> <!-- {x:Bind} を使うと Xaml 内部エラー WMC9999 PrimaryButtonClick="{x:Bind ViewModel.Execute}" PrimaryButtonClick="{x:Bind ContentDialog_PrimaryButtonClick}" --> <!-- コマンドのバインディングはOK PrimaryButtonCommand="{x:Bind ViewModel.TestCommand}" --> <Grid> <!-- こちらのボタンでは {x:Bind}でも問題なし--> <Button Click="{x:Bind ViewModel.Execute}">Content Button</Button> </Grid> </ContentDialog>
また、ContentDialog だけでなく、Pageでも同様のエラーが発生しました。
<Page x:Class="TestApp1.Views.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:TestApp1" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" PointerPressed="Page_PointerPressed" > <!-- {x:Bind} を使うと Xaml 内部エラー WMC9999 PointerPressed="{x:Bind Page_PointerPressed}" --> <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <!-- こっちの{x:Bind} はOK --> <Button Content="Show Dialog" HorizontalAlignment="Center" Click="{x:Bind Button_Click}"/> </Grid> </Page>
試した環境
- Windows 10 Pro 64bit
- Microsoft Visual Studio Enterprise 2015 Version 14.0.23107.0 D14REL
- Microsoft .NET Framework Version 4.6.00079
問題の起きるサンプルコードを以下に置いておきます
https://github.com/pierre3/UWPTestApp
PowerShellでEtwStream
EtwStream.PowerShell
neuecc/EtwStream をPowerShellから使えるようにしてみました。
Get-TraceEventStream Cmdlet
現在は、このコマンドのみ実装されています。
Get-TraceEventStream [-NameOrGuid] <string> [-DumpWithColor] [-TraceLevel <TraceEventLevel> { Always | Critical | Error | Warning | Informational | Verbose }] [<CommonParameters>] Get-TraceEventStream [-WellKnownEventSource] <string> { AspNetEventSource | ConcurrentCollectionsEventSource | FrameworkEventSource | PinnableBufferCacheEventSource | PlinqEventSource | SqlEventSource | SynchronizationEventSource | TplEventSource} [-DumpWithColor] [-TraceLevel <TraceEventLevel> { Always | Critical | Error | Warning | Informational | Verbose}] [<CommonParameters>]
Object pipeline
出力は、オブジェクトで流れてきますので、PowerShellのコマンドを使ったフィルタリングや加工も可能です。
出力されるオブジェクトは、PSTraceEvent というMicrosoft.Diagnostics.Tracing.TraceEvent
をラップした独自クラスとしています。
EtwStream で実装されている TraceEventの拡張メソッドが使えるようになっています。
public string Dump(bool includePrettyPrint = false, bool truncateDump = false); public void DumpPayload(); public string DumpPayloadOrMessage(); public ConsoleColor? GetColorMap(bool isBackgroundWhite); public void DumpWithColor(bool withProviderName = false, bool withProcesName = false);
DumpWithColor Switch
DumpWithColor スイッチで、色付きの簡易ビューアになります。
PS C:\> Get-TraceEventStream -NameOrGuid SampleEventSource -DumpWithColor
WellKnownEventSource
WellKownEventSource パラメータで、既知のEventSource のGUID を一覧から指定できます。
View in GridView-Window
Out-GridView に流すと、GUI上でソートやフィルタリングが自在にできるリッチなEventビューワにもなります。
PS C:\> Get-TraceEventStream SampleEventSource | Out-GridView
実装
コマンドレットの実装は、こちらの記事を参考にさせて頂きました。
参考というか、コードのベース部分は、ほぼそのまま流用させて頂きました。
変更したのは、ソースのIObservableをEtwStream.ObservableEventListener.FromTraceEvent()
で取得するようにした事と、
StopProcessing() メソッドをオーバーライドして Ctrl+Break による中断に対応した事位です。
まとめ
ブログのネタになればと、試しに作ってみましたが、予想以上に使えそうな予感がします。
LINQPadのライセンスが無かったり、そもそも職場でフリーソフトのインストールが制限されていて使えない!といったような方の代替手段として使えるかも??
ETW/EventSource によるロギングを試してみる
最近、以下の記事を拝見しました。
neue cc - EtwStream - ETW/EventSourceのRx化 + ビューアーとしてのLINQPad統合
正直、EtwやEventSourceというものに馴染みが無かったのですが、これからは必須の技術となりそうですね。
さて、EventSourceですが、いきなり構造化ログってのもかなりダルいので、まずは非構造化ログをEventSourceで実現するところから初めてみましょう。
という事で、前回の記事で書いた、ScheduledNotifierをベースとした自作のLogger、LogMessageNotifierをEventSourceに置き換えることから初めてみたいと思います。
LogMessageNotifierをEventSourceに変更する
今回は、Logging用のEventSourceに 冒頭の記事の中で掲載されていたLoggerEventSource クラスのコードをそのまま利用させて頂く事とします。
LoggerEventSourceクラスのメソッドをそのまま呼び出しても良いのですが、既存のコードでは、Model側はILoggerインターフェースを通じてLogの書き込みを行っていました。
ひとまずはこの部分は変えずに、LoggerEventSource をILoggger でラップすることで対応します。
using ReactiveBingViewer.Diagnostics; using System; namespace ReactiveBingViewer.Notifiers { /// <summary> /// Logメッセージ通知オブジェクト /// </summary> public class EventSourceLogger : ILogger { public LoggerEventSource EventSource { get; private set; } public EventSourceLogger(LoggerEventSource eventSource =null) { EventSource = eventSource?? LoggerEventSource.Log; } public void Trace(string message) => EventSource.Verbose(message); public void Debug(string message) => EventSource.Debug(message); public void Info(string message) => EventSource.Informational(message); public void Warn(string message) => EventSource.Warning(message); public void Error(string message) => EventSource.Error(message); public void Fatal(string message) => EventSource.Critical(message); public void Trace(string message, Exception e) { Trace(message); LogException(e); } public void Debug(string message, Exception e) { Debug(message); LogException(e); } public void Info(string message, Exception e) { Info(message); LogException(e); } public void Warn(string message, Exception e) { Warn(message); LogException(e); } public void Error(string message, Exception e) { Error(message); LogException(e); } public void Fatal(string message, Exception e) { Fatal(message); LogException(e); } private void LogException(Exception e) { EventSource.Exception(e.GetType().FullName, e.StackTrace.ToString(), e.Message); } } }
ILoggerをそのまま温存したので Model内でのlogの書き込み部分は変更する必要がありません。
後は、ViewModel のコンストラクタで行っている ReactiveProperty を初期化している部分をEventSourceの型に合わせてほんの少し修正するだけで済みそうです。
public class MainWindowViewModel { //private LogMessageNotifier logger = new LogMessageNotifier(); private EventSourceLogger logger = new EventSourceLogger(); public MainWindowViewModel() { //Modelのコンストラクタに logger を渡す webImageStore = new WebImageStore(App.BingApiAccountKey, App.VisionApiSubscriptionKey, logger).AddTo(disposables); var logListner = EtwStream.ObservableEventListener.FromEventSource(logger.EventSource); ////Logファイルに書き出し System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.TextWriterTraceListener("log.txt")); //logger.Subscribe(log => logListner.Subscribe(log => { System.Diagnostics.Trace.WriteLine(log); System.Diagnostics.Trace.Flush(); }).AddTo(disposables); ////Infoレベルのログはステータスバーに表示 //StatusMessage = logger.Where(x => x.Level == LogLevel.Info) // .Select(x => x.Message) // .ToReadOnlyReactiveProperty(); StatusMessage = logListner.Where(x => x.Level == System.Diagnostics.Tracing.EventLevel.Informational) .Select(x => x.Payload.First().ToString()) .ToReadOnlyReactiveProperty(); ////Warn レベル以上 は エラーリストに表示 ClearErrorLogsCommand = new ReactiveCommand(); //リストクリア用 //ErrorLogs = logger // .Where(x => x.Level >= LogLevel.Warn) // .ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit()); ErrorLogs = logListner.Where(x => (int)x.Level <= (int)System.Diagnostics.Tracing.EventLevel.Warning) .Select(x=>$"[{x.Level}]{x.Payload[0]} ({x.Payload[2]})") .ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit()); ////エラーリスト表示切替。 ErrorLogs にアイテムがある場合のみ表示する ErrorLogsVisibility = ErrorLogs .CollectionChangedAsObservable() .Select(_ => (ErrorLogs.Count > 0) ? Visibility.Visible : Visibility.Collapsed) .ToReactiveProperty(Visibility.Collapsed); } }
これで実行してみます。
おお、ちゃんと動きました。
EtwStream で(超簡易版)Etwビューアを作ってみる
後は、EventSourceとしたことでEtwStream を使えばプロセス外からも参照できるはず。早速LINQPadで...
と思ったのですが無償版ではEtwStream.LingPad をNuGetできないのですね。
幸い、ベースライブラリである EtwStream.Core は 通常の.Netプロジェクトに入れられるようですので、これを利用して簡易ビューアを作ってみたいと思います。
- WPFアプリケーションで作ります。
- NuGetでEtwStream とReactiveProperty をインストールします。
PM> Install-Package EtwStream
PM> Install-Package ReactiveProperty
- ViewModel を定義します。
ObservableEventListener.FromTraceEvent() で 取得したIObservable<TraceEvent>
をReactiveCollection に変換します。
using EtwStream; using Reactive.Bindings; using System.Linq; using System.Reactive.Linq; namespace EtwStreamViewer { public class MainWindowViewModel { public ReadOnlyReactiveCollection<string> LogEvents { private set; get; } public MainWindowViewModel() { LogEvents = ObservableEventListener.FromTraceEvent("LoggerEventSource") .Select(x =>$"[{x.TimeStamp}][{x.Level}]: {x.FormattedMessage}" ) .ToReadOnlyReactiveCollection(); } } }
- XAML を定義します。
WindowにListBoxを追加して、ReactiveCollection をバインドします。
<Window x:Class="EtwStreamViewer.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:EtwStreamViewer" mc:Ignorable="d" Title="MainWindow" Height="350" Width="525"> <Grid> <ListBox ItemsSource="{Binding LogEvents}"></ListBox> </Grid> </Window>
これで準備OK!さっそく実行してみましょう。
ちゃんと 先ほど修正した ReactiveBingViewer のEvent が補足されています。(すごい!)
ちゃんと作り込んだら本格的なeventビユーワも作れそうですね。
まとめ
いつもにも増してまとまりのない記事になってしまいましたが、取り急ぎお試ししてみました!ということでご容赦下さい。
WPFでReactiveProperty入門 ~アプリケーションのステータスやエラー情報をIObservable で通知する
この記事は、WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ の続編です。
関連記事
- WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ
- WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る (1. 検索バーの実装) - pierre3のブログ
- WPFでReactiveProperty入門 ~ Rxを使って検索結果のサムネイル画像を一括ダウンロードする - pierre3のブログ
(番外編)
- WPFで「UriSouceプロパティに画像のURLを入れてBitmapImageを初期化する処理」を非同期で実行するとしぬ - pierre3のブログ
- [WPF] BitmapImage の生成・初期化を非同期で行う際のメモリの問題について - pierre3のブログ
サンプルプロジェクト github.com
目次 ReactiveBingViewer(この記事のサンプルアプリ)では、アプリケーション内のステータス情報の通知と記録を以下の通りに行います。
Model側で発生したステータスおよびエラー情報(以降まとめてLog情報と呼びます)は、LogMessage オブジェクトに格納されて LogMessage の定義は以下の通りです。 実際にLog情報の通知を行う、 ViewModelでは、LogMessageNotifier を用途に合わせて以下の様に使用します。 エラー通知メッセージのクリアは、専用のReactiveCommand を実装して行います。 ReactiveCollection 内のアイテムをクリアする場合、IObservableから ReactiveCollection への変換を行う また、エラー通知パネルに表示するメッセージが無い場合に、通知パネル自体を非表示とするために、以下の処理を実装しています。 該当部分のXAMLを以下に示します。 今回の記事に関連するソースコードの一覧です。 Notifier による通知と、ReactiveProperty を組み合わせることで、Modelから通知されるLog情報を、目的に合わせて柔軟かつスッキリと記述できるようになったと思います。
アプリケーションのステータスやエラーの情報の通知と記録
アプリケーションの現在の状況(「検索中...」や「検索が完了しました」などのメッセージ)をWindow下部のステータスバーに表示します。
アプリケーション実行中の例外を補足した場合、以下のような通知パネルに例外の内容を表示します。
この通知パネルは、表示する例外メッセージが存在する場合のみ表示されます。右上の[×]ボタンをクリックするとパネル内のメッセージがクリアされてパネル自体も非表示となります。
上記通知を含め、アプリケーション内で発生したイベントはLog情報としてファイルへ記録します。ModelからViewModelへ ~
IObservable<LogMessage>
による通知LogMessageクラス
IObservable<LogMessage>
に乗ってViewModelへ通知されます。public class LogMessage
{
//作成時刻
public DateTime CreatedAt { get; }
//メッセージ
public string Message { get; }
//Logレベル(Trace < Debug < Info < Warn < Error < Fatal )
public LogLevel Level { get; }
//例外オブジェクト
public Exception Exception { get; }
//例外オブジェクトの有無
public bool HasError { get { return Exception != null; } }
public LogMessage(string message, LogLevel level, Exception e = null)
{
CreatedAt = DateTime.Now;
Message = message;
Level = level;
Exception = e;
}
public override string ToString()
{
return (HasError)?
string.Format("{0}:[{1}] {2} [{3}]", Level.Name, CreatedAt.ToString("G"), Message, Exception.Message) :
string.Format("{0}:[{1}] {2}", Level.Name, CreatedAt.ToString("G"), Message);
}
}
LogMessageNotifierクラス
LogMessageNotifier
クラスを定義します
ScheduledNotifier<T>
を継承して、IObservable<T>
による通知を行いますScheduledNotifier<T>
は Reactive.Bingings.Notifiers 名前空間に含まれるReactiveProperty のクラスの1つで、IObservable<T>
による値の通知を行います。ScheduledNotifier<T>
を含む Notifier系クラスの詳細は ReactivePropertyのNotifier系クラス in C# for Visual Studio 2013 を参照ください。
public class LogMessageNotifier : ScheduledNotifier<LogMessage>, ILogger
{
public LogMessageNotifier(IScheduler scheduler) : base(scheduler) { }
public LogMessageNotifier() : base() { }
public void Trace(string message) => Report(LogMessage.CreateTraceLog(message));
public void Debug(string message) => Report(LogMessage.CreateDebugLog(message));
public void Info(string message) => Report(LogMessage.CreateInfoLog(message));
public void Warn(string message) => Report(LogMessage.CreateWarnLog(message));
public void Error(string message) => Report(LogMessage.CreateErrorLog(message));
public void Fatal(string message) => Report(LogMessage.CreateFatalLog(message));
public void Trace(string message, Exception e) => Report(LogMessage.CreateTraceLog(message, e));
public void Debug(string message, Exception e) => Report(LogMessage.CreateDebugLog(message, e));
public void Info(string message, Exception e) => Report(LogMessage.CreateInfoLog(message, e));
public void Warn(string message, Exception e) => Report(LogMessage.CreateWarnLog(message, e));
public void Error(string message, Exception e) => Report(LogMessage.CreateErrorLog(message, e));
public void Fatal(string message, Exception e) => Report(LogMessage.CreateFatalLog(message, e));}
}
public interface ILogger
{
void Trace(string message);
void Debug(string message);
void Info(string message);
void Warn(string message);
void Error(string message);
void Fatal(string message);
void Trace(string message,Exception e);
void Debug(string message, Exception e);
void Info(string message, Exception e);
void Warn(string message, Exception e);
void Error(string message, Exception e);
void Fatal(string message, Exception e);
}
public class MainWindowViewModel : IDisposable
{
private LogMessageNotifier logger = new LogMessageNotifier();
private WebImageStore webImageStore;
//...
public MainWindowViewModel()
{
//ModelはILoggerインターフェースを受け付ける
webImageStore = new WebImageStore(logger);
}
}
ViewModelからViewへ ~
IObservable<LogMessage>
をReactiveProperty に変換
LogMessageNotifier を、 LogLevel.Info のメッセージのみを通すReactiveProperty に変換します
LogMessageNotifier を、 LogLevel.Warn 以上のメッセージを保持する ReactiveCollection に変換します
LogMessageNotifier を購読(Subscribe) して、OnNext()でLogファイルへの書き込みを行いますpublic class MainWindowViewModel : IDisposable
{
private CompositeDisposable disposables = new CompositeDisposable();
private LogMessageNotifier logger = new LogMessageNotifier();
private WebImageStore webImageStore;
// ステータスバー・メッセージ
public ReadOnlyReactiveProperty<LogMessage> StatusMessage { get; private set; }
// エラー・メッセージ
public ReadOnlyReactiveCollection<LogMessage> ErrorLogs { get; private set; }
// エラー表示状態
public ReactiveProperty<Visibility> ErrorLogsVisibility { get; private set; }
// エラー表示クリア用コマンド
public ReactiveCommand ClearErrorLogsCommand { get; private set; }
public MainWindowViewModel()
{
//WebImageStoreのコンストラクタにLogMessageNotifierのインスタンスを渡す
webImageStore = new WebImageStore(logger);
//Logファイルの設定。今回はTextWriterTraceListenerで書き込む
System.Diagnostics.Trace.Listeners.Add(
new System.Diagnostics.TextWriterTraceListener("log.txt"));
//Logファイルに書き込み
logger.Subscribe(log =>
{
System.Diagnostics.Trace.WriteLine(log);
System.Diagnostics.Trace.Flush();
}).AddTo(disposables);
//Infoレベルのログはステータスバーに表示
StatusMessage = logger.Where(x => x.Level == LogLevel.Info)
.Select(x => x.Message)
.ToReadOnlyReactiveProperty();
//エラー通知クリア用コマンド
ClearErrorLogsCommand = new ReactiveCommand();
//Warn レベル以上のLogメッセージをReactiveCollectionに格納する
ErrorLogs = logger
.Where(x => x.Level >= LogLevel.Warn)
.ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit());
//エラーリスト表示切替。 ErrorLogs にアイテムがある場合のみ表示する
ErrorLogsVisibility = ErrorLogs
.CollectionChangedAsObservable()
.Select(_ => (ErrorLogs.Count > 0) ? Visibility.Visible : Visibility.Collapsed)
.ToReactiveProperty(Visibility.Collapsed);
}
}
エラー通知パネルのクリアと表示切替
ToReactiveCollection()
または ToReadonlyReactiveCollection()
の引数に ReactiveCommand.ToUnit()
を渡すだけでクリア処理を実装することが出来ます。//エラー通知クリア用コマンド
ClearErrorLogsCommand = new ReactiveCommand();
//Warn レベル以上のLogメッセージをReactiveCollectionに格納する
ErrorLogs = logger
.Where(x => x.Level >= LogLevel.Warn)
.ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit());
//エラーリスト表示切替。 ErrorLogs にアイテムがある場合のみ表示する
ErrorLogsVisibility = ErrorLogs
.CollectionChangedAsObservable()
.Select(_ => (ErrorLogs.Count > 0) ? Visibility.Visible : Visibility.Collapsed)
.ToReactiveProperty(Visibility.Collapsed);
View の定義
<!-- ステータスバー -->
<StatusBar DockPanel.Dock="Bottom">
<StatusBarItem DockPanel.Dock="Right">
<!-- プログレスパー (省略) -->
</StatusBarItem>
<Separator DockPanel.Dock="Right" />
<StatusBarItem>
<!-- ステータスメッセージ -->
<TextBlock Text="{Binding StatusMessage.Value,Mode=OneWay}"/>
</StatusBarItem>
</StatusBar>
<!-- エラー通知 -->
<Grid DockPanel.Dock="Bottom" Margin="4" Visibility="{Binding ErrorLogsVisibility.Value}">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Rectangle Grid.ColumnSpan="2" Stroke="Chocolate" Fill="LightYellow"/>
<!-- エラーメッセージ一覧 -->
<ListBox Grid.Row="3" ItemsSource="{Binding ErrorLogs}" MaxHeight="120" Margin="0,0,4,0"
Background="Transparent" BorderBrush="{x:Null}"/>
<Button Grid.Column="1" Margin="2" VerticalAlignment="Top" HorizontalAlignment="Right"
Command="{Binding ClearErrorLogsCommand}" Background="Transparent"
FontFamily="Segoe UI Symbol"></Button>
</Grid>
ソースコード
処理の詳細は、こちらをご参照ください。
まとめ
[WPF] BitmapImage の生成・初期化を非同期で行う際のメモリの問題について
この記事は、前回のエントリ WPFで「UriSouceプロパティに画像のURLを入れてBitmapImageを初期化する処理」を非同期で実行するとしぬ - pierre3のブログ
の続きになります。
目次
前回は、Web画像のURLからBitmapImageを、UIをブロックする事無く生成するには、以下の様に記述すると良さそう。 しかし、このコードにはメモリリークを引き起こす問題が2つあります。 上のコードでは、EndInit()での初期化が終わった時点でStreamSourceプロパティに設定したMemoryStreamは不要になり、using のスコープを抜けるとDispose()されます。 しかし、MemoryStream では、コンストラクタに渡した byte配列の参照を内部バッファとして保持しているのですが、それをDispose()後も抱えたまま離さないようなのです。
※ Dispose()後も_buffer が残ったまま! 画像データとしては、Bitmapにデコードされた表示用の画素データと、デコード前のbyte配列(内部バッファ)の両方を持つことになります。 対処法は、以下サイトにありました。 MemoryStreamをラップしたStreamクラスを作って Dispose時にMemoryStreamの参照を外すという方法でこの問題を回避するようです。 以下のようなStreamのラップクラスを用意して MemoryStream をこれでラップしたうえで StreamSourceプロパティに設定します。 以下、サムネイル画像50枚分のBitmapImageを作成した状態のスナップショットでヒープを比較したものです。効果は一目瞭然だと思います。 ・MemoryStreamのみの場合
・WrappingStreamを使用した場合
これで問題の1つは解決できました。 そして、もう1つの問題が以下になります。 この問題については、以下のエントリに詳しいので、そちらをご参照ください。 上記エントリで記述されている通り、 最も時間のかかるダウンロード部分のみバックグラウンドで非同期実行して、ダウンロードした結果を基にBitmapImageを生成・初期化する部分はUIスレッド上で行う様にします。 まずは、画像データのダウンロード部分。 (2015/10/26 訂正) 上記メソッドで取得したbyte配列を受け取って、MemoryStream を作ります。 上の2つのメソッドを組み合わせて呼び出します。 Rxを使って一度にダウンロードする場合は、前回のおさらい
というところまで書きました。public async Task<BitmapImage> DownloadImageAsync(string url)
{
using (var web = new System.Net.Http.HttpClient())
{
//HttpClientでbyte配列に先読みしておいて
var bytes = await web.GetByteArrayAsync(url).ConfigureAwait(false);
// そのbyte配列でMemoryStream を作成し、StreamSourceプロパティに設定
using (var stream = new MemoryStream(bytes))
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = stream;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
}
}
まずは、そのうちの1つからMemoryStream はDisposeしても内部バッファを離さない
この状態のBitmapImageは、単純計算で必要な量の2倍のメモリ空間を占有しているってことです。
特に大きな画像を読み込んだ場合は、なかなか無視できない容量になるのではないでしょうか。Dispose時にMemoryStreamの参照を外すストリームクラスでラップする
/// <summary>
/// ストリームのWrapperクラス
/// </summary>
/// <remarks>
/// Dispose 時に、内部ストリームの参照を外します
/// </remarks>
public class WrappingStream : Stream
{
Stream m_streamBase;
public WrappingStream(Stream streamBase)
{
if (streamBase == null)
{
throw new ArgumentNullException("streamBase");
}
m_streamBase = streamBase; //渡したStreamを内部ストリームとして保持
}
//Streamクラスのメソッドをオーバーライドして、内部ストリームの同じメソッドをそのまま呼ぶだけ
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken)
{
ThrowIfDisposed();
return m_streamBase.ReadAsync(buffer, offset, count, cancellationToken);
}
public new Task<int> ReadAsync(byte[] buffer, int offset, int count)
{
ThrowIfDisposed();
return m_streamBase.ReadAsync(buffer, offset, count);
}
//...(中略)...
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_streamBase.Dispose();
m_streamBase = null; //disposeしたら内部ストリームをnullにして参照を外す
}
base.Dispose(disposing);
}
private void ThrowIfDisposed()
{
if (m_streamBase == null)
{
throw new ObjectDisposedException(GetType().Name);
}
}
}
public async Task<BitmapImage> DownloadImageAsync(string url)
{
using (var web = new HttpClient())
{
var bytes = await web.GetByteArrayAsync(url).ConfigureAwait(false);
using (var stream = new WrappingStream(new MemoryStream(bytes)))
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = stream;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
}
}
DispatcherObjectをバックグラウンドスレッドで作成するとメモリリークする
BeginInvokeShutdown()
と Dispatcher.Run
を使って回避する方法もあるようなのですが、
今回はバックグラウンドスレッド上でBItmapImageを生成しないようにすることで、対処したいと思います。ダウンロード処理とBitmapImage生成処理を分離する
画像データを非同期ダウンロードしてbyte配列で返す
System.Net.Http.HttpClient
の GetByteArrayAsync() の結果をそのまま返すだけです。
System.Net.Http.HttpClient
の GetByteArrayAsync() を await してその結果を返します。// このままではDownload完了前に HttpClient がDispose()されてしまう
//public static Task<byte[]> DownLoadImageBytesAsync(string url)
//{
// using (var web = new HttpClient())
// {
// return web.GetByteArrayAsync(url);
// }
//}
public static async Task<byte[]> DownLoadImageBytesAsync(string url)
{
using (var web = new HttpClient())
{
return await web.GetByteArrayAsync(url);
}
}
受け取ったbyte配列からBitmapImageを作成する
さらに、WrappingStreamにラップしたものをStreamSourceプロパティに設定してBitmapImageを初期化します。public static BitmapImage CreateBitmap(byte[] bytes, bool freezing = true)
{
using (var stream = new WrappingStream(new MemoryStream(bytes)))
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = stream;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
if (freezing && bitmap.CanFreeze)
{ bitmap.Freeze(); }
return bitmap;
}
}
DownLoadImageBytesAsync()をバックグラウンドで実行後、指定したディスパッチャ上でCreateBitmap()を実行する
CreateBitmap() を実行するDispatcher を引数で指定できるようにしています。
今回はUIスレッド上で処理を行いたいので、App.Current.Dispatcher
を指定します。public static async Task<BitmapImage> DownloadImageAsync(string url,
Dispatcher dispatcherForBitmapCreation)
{
var bytes = await DownLoadImageBytesAsync(url).ConfigureAwait(false);
return await dispatcherForBitmapCreation.InvokeAsync(() => CreateBitmap(bytes));
}
// 使用例
// var bitmap = await DownloadImageAsync(downloadUrl, App.Current.Dispatcher);
おまけ
DownloadImageAsync()
にまとめず、個別に実行する方が分かりやすいかもしれません。private ObservableCollection imageSource;
public void DownloadSearchResult(IEnumerable<Uri> imageUrlList)
{
//IEnumerable<T>を、スレッドプール上で動作するIObservable<T> に変換
disposable = imageUrlList.ToObservable(ThreadPoolScheduler.Instance)
//画像データをまとめてダウンロード
.SelectMany(async uri => await DownLoadImageBytesAsync(uri))
//UIスレッドに処理を移す
.ObserveOn(Reactive.Bindings.UIDispatcherScheduler.Default)
//BitmapImageを作成してコレクションに追加
.Subscribe( bytes => imagesSource.Add(CreateBitmap(bytes));
}
//ここで使用しているReactive.Bindings.UIDispatcherScheduler は
//ReactiveProperty が用意するクラスです。App.xaml.cs でAppクラスのOnStartup()あたりで
// Reactive.Bindings.UIDispatcherScheduler.Initialize();
//としておくと、UIDispatcherScheduler.Default で UIスレッドのDispatcherが取得できます
WPFで「UriSouceプロパティに画像のURLを入れてBitmapImageを初期化する処理」を非同期で実行するとしぬ
この記事は、WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ の続きです。
関連記事
- WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ
- WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る (1. 検索バーの実装) - pierre3のブログ
- WPFでReactiveProperty入門 ~ Rxを使って検索結果のサムネイル画像を一括ダウンロードする - pierre3のブログ
サンプルプロジェクト github.com
目次
今回は、検索結果として取得した画像のURLから画像データを読み込んでBitmapImageを生成する部分について書くのですが、
先に言ってしまうと、試してみたけど結果うまく行きませんでした。というお話です。 BitmapImageにWeb上の画像を読み込ませるには、UriSourceプロパティを使用するのが最も簡単です。 非常に簡単で便利なのですが、この処理はUIをブロックするようなのです。 そこで、Taskにツッコんで別スレッドで実行するようにしてみます。 しかし、上記コードではImageコントロール等で表示しようとした時点で以下のエラーが発生します。 型 'System.Windows.Markup.XamlParseException' のハンドルされていない例外が PresentationFramework.dll で発生しました UIスレッド以外で生成されたUIオブジェクトはFreeze()してからでないとUIスレッドで使用できないのでした。 UIで使用する前に Freeze しておく必要があるのですが、そのタイミングも問題になります。 ダウンロードの完了を待ってからFreezeすればどうでしょうか? 試してみます。 これでうまく行きそうな気がしますが、今度はまた別の問題が発生します。 しかも、例外で落ちることもなく沈黙する最悪のパターンです。 何か手掛かりは無いか、VisualStudio(2015)の診断ツールで「イベント」を確認すると、何やら謎の例外がスローされて直後にキャッチされているのが分かります。 どうやら、 この EndInig()メソッドの内部で 色々試してみましたが、今回のようなアプローチの仕方ではうまく行きそうにありません。 「UriSource を使った一連の処理は、UIスレッド以外で実行しないようにしましょう。」 では、非同期で処理できないの?というわけではなく、別の方法があります。 以下の様に、画像データを先にダウンロードしておき、MemoryStream でBitmapImageに流してやればOKです。 Web検索等で調べると、このようなやり方が一般的のようですね。 しかし、これにもまだ問題があります。
上のはじめに
BitmapImageの初期化にUriSourceを使うとUIがブロックされる
UriSourceプロパティに画像のURLを指定するだけで、画像データのダウンロードとBitmapへのデコードをBitmapImage自身がやってくれます。public BitmapImage CreateBitmapImage(Uri sourceUri)
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = sourceUri;
bitmap.EndInit();
return bitmap;
}
// 以下の様にコンストラクタにURIを渡すのと同じ
// var bitmap = new BitmapImage(sourceUri);
実際に、上記のコードを使って複数の画像を連続してダウンロードすると、すべての画像が生成されるまでUIの表示が更新されず、操作もできない状態になります。
(ダウンロード完了を待たずにEndInit()を抜けて、DownloadCompletedイベントで完了通知を受け取るようになっているので、ダウンロード自体はバックグラウンドで行われているように見えるのですが)非同期にしてみる
public Task<BitmapImage> CreateBitmapImageAsync(Uri sourceUri)
{
return Task.Run(()=> {
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = sourceUri;
bitmap.EndInit();
return bitmap;
}
}
DependencySource は、DependencyObject と同じ Thread 上で作成する必要があります。
例えば、以下の様にEndInit() の後に Freeze しようとしても、この時点では画像のダウンロードが完了していないので、Freezeできません。public Task<BitmapImage> CreateBitmapImageAsync(Uri sourceUri)
{
return Task.Run(()=> {
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = sourceUri;
bitmap.EndInit();
bitmap.Freeze(); //初期化が終わっていないのでFreezeできません!!
return bitmap;
}
}
ダウンロード完了を待ってからFreezeする
ダウンロードの完了は DownloadCompleted イベントで受け取れます。
また、ダウンロード中に発生した例外は、DownloadFailed イベントで補足できます。private ObservableCollection images = new ObservableCollection();
private Task CreateBitmapImage(Uri sourceUri)
{
return Task.Run(()=> {
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = sourceUri;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.DownloadCompleted += (sender,args) => {
var image =(BitmapImage)sender;
image.Freeze(); //Freezeしてから
images.Add(image); //Binding Sourceのコレクションに追加
};
bitmap.DownloadFailed += (sender,args) => {
//失敗したらここに来る(はず)
Trace.WriteLine($"DL Failed. [{args.ErrorException}]");
};
bitmap.EndInit();
}
}
UIスレッド以外で実行すると謎の沈黙
System.Deployment.Application.InvalidDeploymentException
なる例外が発生して、尚且つそれが握りつぶされているのが原因のようです。InvalidDeploymentException
ってなんなんでしょう? 例外発生時の呼び出し履歴を見てみます。System.Deployment.dll!System.Deployment.Application.ApplicationDeployment.CurrentDeployment.get()
System.Deployment.dll!System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed.get()
PresentationCore.dll!MS.Internal.AppModel.SiteOfOriginContainer.SiteOfOriginForClickOnceApp.get()
PresentationCore.dll!MS.Internal.SecurityHelper.ExtractUriForClickOnceDeployedApp()
PresentationCore.dll!MS.Internal.SecurityHelper.BlockCrossDomainForHttpsApps(System.Uri uri = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.BitmapDownload.BeginDownload(System.Windows.Media.Imaging.BitmapDecoder decoder = {不明}, System.Uri uri = {不明}, System.Net.Cache.RequestCachePolicy uriCachePolicy = {不明}, System.IO.Stream stream = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.LateBoundBitmapDecoder..ctor(System.Uri baseUri = {不明}, System.Uri uri = {不明}, System.IO.Stream stream = {不明}, System.Windows.Media.Imaging.BitmapCreateOptions createOptions = {不明}, System.Windows.Media.Imaging.BitmapCacheOption cacheOption = {不明}, System.Net.Cache.RequestCachePolicy requestCachePolicy = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.BitmapDecoder.CreateFromUriOrStream(System.Uri baseUri = {不明}, System.Uri uri = {不明}, System.IO.Stream stream = {不明}, System.Windows.Media.Imaging.BitmapCreateOptions createOptions = {不明}, System.Windows.Media.Imaging.BitmapCacheOption cacheOption = {不明}, System.Net.Cache.RequestCachePolicy uriCachePolicy = {不明}, bool insertInDecoderCache = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.BitmapImage.FinalizeCreation()
PresentationCore.dll!System.Windows.Media.Imaging.BitmapImage.EndInit()
LateBoundBitmapDecoder
が生成されて、そのコンストラクタ内でBitmapDownload.BeginDownload()
が実行され、 SecurityHelper.BlockCrossDomainForHttpsApps()
メソッド内でClickOnce Deployed がなんちゃら・・・・
うーん、よくわかりません。
これ以上の深追いはやめておきます。UriSourceを使ったBitmapImageの初期化はUIスレッドで
別のアプローチ
public async Task<BitmapImage> DownloadImageAsync(string url)
{
using (var web = new HttpClient())
{
var bytes = await web.GetByteArrayAsync(url).ConfigureAwait(false);
using (var stream = new MemoryStream(bytes))
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = stream;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
}
}
(だったら最初からそうしろってことですが、結果がダメでもいろいろ試してみることに意義があるという事で)DownloadImageAsync()
を実行するとメモリリークを引き起こすのですが、長くなってきたので続きは次回!