アプリを作るとき、アプリの状態を保存しておき、次回起動時に読み込むという機能はほぼ必須の機能です。
今回は、そんな機能を実現するために考えなればならないことや、どんな方法で実現するかについて書きます。
ちなみに、プラットフォームなどによって使用できる方法が異なる場合がありますが、今回はWindowsプログラム動作するデスクトップアプリを想定しています。
保存先
- レジストリ
- レジストリのメリットはファイルを作らずに済む点です。
- レジストリを使用する例としては、アンインストールされても残しておきたいライセンス情報などを格納するのに適しています。
- そのユーザーのみであれば、HEKY_CURENT_USE\Software、PC共通であれば、HKEY_LOCAL_MACHINE\SOFTWAREの下にキーを作り値を保存します。
- HKEY_LOCAL_MACHINEに書き込みを行う場合は、管理者権限が必要です。
- ファイル
- ファイルのメリットは何といってもシンプルで分かりやすいところです。
- 上記のレジストリに保存する以外のケースでは、ファイルに保存することになるでしょう。
- よく利用されるフォルダーは以下のフォルダーです。
環境変数 説明 環境変数が示す場所又は値の例 %ALLUSERSPROFILE% All Usersのプロファイルフォルダ C:\ProgramData %APPDATA% アプリケーションデータ C:\Users\【ユーザ名】\AppData\Roaming
Windows10の環境変数の一覧と調べ方。便利な使い方も解説
- データベース
- 保存しておくデータがメモリに保持できないくらい大きかったり、動作中、頻繁に読み書きを行いたい場合は、データベースに保存という手があります。
- データベースとしては、SQLiteなどがよく採用されます。
- クラウド
- 今時のアプリだと、クラウド上にデータを保持しておくという手もあります。
- ブラウザでアクセスするWebアプリなどでは当然データはサーバー側に保存することになりますが、デスクトップアプリでもサーバーにデータがあれば、PCが故障したときなどでも、別PCからすぐ復旧することができます。
実装例
たとえば、こんなデータを読み書きしたい場合を考えてみます。
internal class 保存するデータ
{
public Dictionary<string, Rectangle> フォーム情報 { get; set; } = new();
public string ユーザー名 { get; set; } = string.Empty;
}
・読み書きの例(INIファイル)
元々は、Windows 3.1時代に考えられたフォーマットで、ファイルサイズなど制限も厳しいため新規に採用するメリットはあまりありませんが、現在でも使われているアプリを時々見かけることがあります。
また、INIファイルは手書きしやすいので、設定画面を持たないアプリで使われている場合があります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
internal partial class INIファイル
{
private static readonly string FilePath = @$"{Application.StartupPath}\setting.ini";
/// <summary>
/// INIファイルから文字列を読み込む
/// </summary>
/// <param name="lpAppName">セクション名</param>
/// <param name="lpKeyName">キー名</param>
/// <param name="lpDefault">デフォルト値</param>
/// <param name="lpReturnedString">取得した値</param>
/// <param name="nSize">lpReturnedStringのサイズ</param>
/// <param name="lpFileName">INIファイルファイルパス</param>
/// <returns>lpReturnedStringにコピーされた文字数</returns>
/// <remarks>https://learn.microsoft.com/ja-jp/windows/win32/api/winbase/nf-winbase-getprivateprofilestring</remarks>
[LibraryImport("KERNEL32.DLL", EntryPoint = "GetPrivateProfileStringW", StringMarshalling = StringMarshalling.Utf16)]
private static partial uint GetPrivateProfileString(string? lpAppName, string? lpKeyName, string? lpDefault, char[] lpReturnedString, uint nSize, string lpFileName);
/// <summary>
/// INIファイルから整数を読み込む
/// </summary>
/// <param name="lpAppName">セクション名</param>
/// <param name="lpKeyName">キー名</param>
/// <param name="lpDefault">デフォルト値</param>
/// <param name="lpFileName">INIファイルファイルパス</param>
/// <returns>取得した整数値</returns>
/// <remarks>https://learn.microsoft.com/ja-jp/windows/win32/api/winbase/nf-winbase-getprivateprofileintw</remarks>
[LibraryImport("KERNEL32.DLL", EntryPoint = "GetPrivateProfileIntW", StringMarshalling = StringMarshalling.Utf16)]
private static partial uint GetPrivateProfileInt(string lpAppName, string lpKeyName, int nDefault, string lpFileName);
/// <summary>
/// INIファイルに文字列を書き込む
/// </summary>
/// <param name="lpAppName">セクション名</param>
/// <param name="lpKeyName">キー名</param>
/// <param name="lpString">書き込む文字列</param>
/// <param name="lpFileName">INIファイルファイルパス</param>
/// <returns></returns>
/// <remarks>https://learn.microsoft.com/ja-jp/windows/win32/api/winbase/nf-winbase-writeprivateprofilestringw</remarks>
[LibraryImport("kernel32.dll", EntryPoint = "WritePrivateProfileStringW", StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool WritePrivateProfileString(string lpAppName, string lpKeyName, string lpString, string lpFileName);
private static string GetValue(string section, string key, string defaultValue)
{
var buffer = new char[1024];
var length = GetPrivateProfileString(section, key, defaultValue, buffer, (uint)buffer.Length, FilePath);
return buffer.AsSpan(0, (int)length).ToString();
}
private static int GetValue(string section, string key, int defaultValue) => (int)GetPrivateProfileInt(section, key, defaultValue, FilePath);
private static string[] GetSections()
{
var buffer = new char[1024];
var length = GetPrivateProfileString(null, null, null, buffer, (uint)buffer.Length, FilePath);
return buffer.AsSpan(0, (int)length - 1).ToString().Split('\0');
}
private static void WriteValue(string section, string key, string value) => WritePrivateProfileString(section, key, value, FilePath);
private static void WriteValue(string section, string key, int value) => WritePrivateProfileString(section, key, value.ToString(), FilePath);
public static 保存するデータ Load()
{
var result = new 保存するデータ()
{
ユーザー名 = GetValue("UserInfo", nameof(保存するデータ.ユーザー名), string.Empty)
};
var sections = GetSections();
foreach (var item in sections.Where(x => x.StartsWith(nameof(保存するデータ.フォーム情報))))
{
var key = item.Split('_')[1];
if (int.TryParse(GetValue(item, nameof(Rectangle.X), string.Empty), out var x)
&& int.TryParse(GetValue(item, nameof(Rectangle.Y), string.Empty), out var y)
&& int.TryParse(GetValue(item, nameof(Rectangle.Width), string.Empty), out var width)
&& int.TryParse(GetValue(item, nameof(Rectangle.Height), string.Empty), out var height))
{
result.フォーム情報.Add(key, new Rectangle(x, y, width, height));
}
}
return result;
}
public static void Save(保存するデータ value)
{
foreach (var item in value.フォーム情報)
{
var section = $"{nameof(保存するデータ.フォーム情報)}_{item.Key}";
WriteValue(section, nameof(Rectangle.X), item.Value.X);
WriteValue(section, nameof(Rectangle.Y), item.Value.Y);
WriteValue(section, nameof(Rectangle.Width), item.Value.Width);
WriteValue(section, nameof(Rectangle.Height), item.Value.Height);
}
WriteValue("UserInfo", nameof(保存するデータ.ユーザー名), value.ユーザー名);
}
}
こんな風になります。
[フォーム情報_FormMain]
X=52
Y=52
Width=816
Height=489
[UserInfo]
ユーザー名=mania3bb
・読み書きの例(レジストリ)
using Microsoft.Win32;
internal class レジストリ
{
private const string Path = $@"Software\mania3bb\レジストリサンプル";
public static 保存するデータ Load()
{
var result = new 保存するデータ();
var key = Registry.CurrentUser.OpenSubKey(Path);
if (key == null) return result;
{ if (key.GetValue(nameof(保存するデータ.ユーザー名)) is string value) result.ユーザー名 = value; }
{
if (key.OpenSubKey(nameof(保存するデータ.フォーム情報)) is RegistryKey value)
foreach (var keyItem in value.GetSubKeyNames())
{
if (value.OpenSubKey(keyItem) is not RegistryKey subkey) continue;
if (int.TryParse(subkey.GetValue(nameof(Rectangle.X))?.ToString(), out var x)
&& int.TryParse(subkey.GetValue(nameof(Rectangle.X))?.ToString(), out var y)
&& int.TryParse(subkey.GetValue(nameof(Rectangle.X))?.ToString(), out var width)
&& int.TryParse(subkey.GetValue(nameof(Rectangle.X))?.ToString(), out var height))
{
result.フォーム情報.Add(keyItem, new Rectangle(x, y, width, height));
}
}
}
return result;
}
public static void Save(保存するデータ value)
{
var key = Registry.CurrentUser.CreateSubKey(Path);
key.SetValue(nameof(保存するデータ.ユーザー名), value.ユーザー名);
var フォーム情報Key = key.CreateSubKey(nameof(保存するデータ.フォーム情報));
foreach (var item in value.フォーム情報)
{
var itemKey = フォーム情報Key.CreateSubKey(item.Key);
itemKey.SetValue(nameof(Rectangle.X), item.Value.X);
itemKey.SetValue(nameof(Rectangle.Y), item.Value.Y);
itemKey.SetValue(nameof(Rectangle.Width), item.Value.Width);
itemKey.SetValue(nameof(Rectangle.Height), item.Value.Height);
}
}
}
↓はレジストリをエクスポートしたものです。
[HKEY_CURRENT_USER\Software\mania3bb]
[HKEY_CURRENT_USER\Software\mania3bb\レジストリサンプル]
"ユーザー名"="mania3bb"
[HKEY_CURRENT_USER\Software\mania3bb\レジストリサンプル\フォーム情報]
[HKEY_CURRENT_USER\Software\mania3bb\レジストリサンプル\フォーム情報\FormMain]
"X"=dword:000001ee
"Y"=dword:000001ee
"Width"=dword:00000330
"Height"=dword:000001e9
・読み書きの例(XMLファイル)
ここまで見てきたように、フォーム情報のような入れ子になっている値を保存するのは結構面倒です。
シリアライザーを使えば、たったこれだけで済みます。
しかも、保存する項目が増減しても問題ありません。
.NETの標準シリアライザ(XML/JSON)の使い分けまとめ
using System.Runtime.Serialization;
internal class XMLファイル
{
private static readonly string FilePath = @$"{Application.StartupPath}\setting.xml";
public static 保存するデータ Load()
{
var serializer = new DataContractSerializer(typeof(保存するデータ));
using var st = new FileStream(FilePath, FileMode.Open);
return serializer?.ReadObject(st) is 保存するデータ result ? result : new 保存するデータ();
}
public static void Save(保存するデータ value)
{
var serializer = new DataContractSerializer(typeof(保存するデータ));
using var st = new FileStream(FilePath, FileMode.OpenOrCreate);
serializer.WriteObject(st, value);
}
}
DataContractSerializerを使う場合、予めこのような属性で読み書きするクラスやメンバーを指定しておく必要があります。
using System.Runtime.Serialization;
[DataContract]
internal class 保存するデータ
{
[DataMember]
public Dictionary<string, Rectangle> フォーム情報 { get; set; } = new();
[DataMember]
public string ユーザー名 { get; set; } = string.Empty;
}
<保存するデータ xmlns="http://schemas.datacontract.org/2004/07/%E4%BF%9D%E5%AD%98%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<フォーム情報 xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
<a:KeyValueOfstringRectangle3odnvp_PE>
<a:Key>FormMain</a:Key>
<a:Value xmlns:b="http://schemas.datacontract.org/2004/07/System.Drawing">
<b:height>489</b:height>
<b:width>816</b:width>
<b:x>442</b:x>
<b:y>442</b:y>
</a:Value>
</a:KeyValueOfstringRectangle3odnvp_PE>
</フォーム情報>
<ユーザー名>mania3bb</ユーザー名>
</保存するデータ>
応用編(インスタンスのコピーを作る)
あるインスタンスとまったく同じ内容のコピー(所謂ディープコピー)を作りたいときに、上記のシリアライザーを使うと、簡単にコピーを作ることができます。
using System.Runtime.Serialization;
public static partial class Extentions
{
public static T DeepCopy<T>(this T value) where T : class
{
using var st = new MemoryStream();
var serializer = new DataContractSerializer(typeof(T));
//メモリストリームを使ってシリアライズ
serializer.WriteObject(st, value);
//ストリームの位置を先頭に戻す
st.Position = 0;
//デシリアライズすると中身が全く同じインスタンスが得られる
return (T)(serializer?.ReadObject(st) ?? throw new NullReferenceException());
}
}
private void ButtonDeepCopy_Click(object sender, EventArgs e)
{
var コピー元 = new 保存するデータ
{
ユーザー名 = "mania3bb"
};
コピー元.フォーム情報.Add(Name, Bounds);
var コピー先 = コピー元.DeepCopy();
//参照は違うが、中身の値は同じ
Debug.Assert(!object.ReferenceEquals(コピー先, コピー元));
Debug.Assert(コピー先.ユーザー名 == コピー元.ユーザー名);
var コピー元フォーム = コピー元.フォーム情報[Name];
var コピー先フォーム = コピー先.フォーム情報[Name];
Debug.Assert(コピー先フォーム.X == コピー元フォーム.X);
Debug.Assert(コピー先フォーム.Y == コピー元フォーム.Y);
Debug.Assert(コピー先フォーム.Width == コピー元フォーム.Width);
Debug.Assert(コピー先フォーム.Height == コピー元フォーム.Height);
}
まとめ
データの保存にはシリアライズを活用しよう