20201208のC#に関する記事は7件です。

Unity 超初心者が Firebase でアプリ開発する際に必要になるスキル

こちらは Unity #2 Advent Calendar 2020 の 8日目の記事です

また今回は、解説が長くなってしまうため前編と後編に分けることにしますmm
後編では、Firebaseの実装関連を全て Firebase Advent Calendar 2020 の16日目に投稿予定の記事『UnityとFirebaseでアプリ開発する際にユーザーデータの取り扱い方まとめ(仮)』でまとめようと思います。

概要

今回は、タイトルの通り超初心者がUnity/C#を学び、さらにFriebaseを使ってアプリ開発したい人向けとなります。
プログラミング初心者でもUnityは、書籍が豊富なだけでなく、Asset StoreUnity LearnUnity Japan YouTubeチャンネル などUnityコンテンツの制作・開発においてとてもサポートが豊富なので、熱意があれば小学生から高齢者まで幅広くもの作りができる、あらゆる可能性を持ったツールだと思います。

私は、とあるサービスでUnityのインストラクターをやっているのですが、担当している生徒さんはほとんどプログラミング初心者でUnityを学びつつC#/プログラミングも学んで、その上で目的であるゲーム作りやVR/AR作りなども学んでいるため、学ぶことが多くお手上げ状態になってしまいます。
どんなに学びやすい環境でもスキルを身に付ける量が多いと、どうしてもモチベーションが下がってしまいますよね。

そこで今回は、初心者がUnityでiOS/Androidのアプリ開発を目指している方にだけターゲットになってしまいますが、「どうぶつタワーバトル」や「飛べゴリラ」などのインディー/カジュアルゲームの開発でコスパ最強な mBaaSFirebase について、活用方法や必要になってくる知識のサポートについて解説していきたいと思います!

定義

各用語について本記事の定義をさせてください。

  • 超初心者
    • UnityとC#を技術書やネット記事などを参考にすでに何か作っている
  • プログラミング初心者
    • ”入門”や”初心者”などが書かれている技術書をもとに何らかの言語を学んで何となく使える
  • アプリ開発
    • 本記事ではiOS/Androidのアプリ開発のことで扱います

ターゲット

改めてターゲットはこちらになります。

  • Unity超初心者
  • UnityでiOS/Androidのアプリ開発をやりたい方
  • FirebaseをUnityでどう使うのかわからない方

前提

本記事を参考にするには次の項目が問題ないことをご確認ください。

  • ボルドプラットフォームは、iOS/Androidに向けたアプリ開発が前提です。
  • 各ツールのインストールや環境設定など済んでいる。
  • C#の学習が、入門書の後半(例えば 独習 C# だと「第7~9章 オブジェクト指向構文」)まで進んでいる。
  • Unityがテーマなので、プログラミングの話はオブジェクト指向言語ベースです。
  • Firebaseの前提知識は、公式ドキュメントで一部割愛させていただきます。

やってほしいこと

今回は、Firebase Authentication (Auth)Firebase Realtime Database (Database) を活用するので、私が過去に書いた FirebaseとUnityでアプリ開発(ハンズオンみたいなやつ)を参考に FirebaseAuth.unitypackageFirebaseDatabase.unitypackage をUnityにインポートし、Firebaseの導入を済ませておいてください。

キャッチアップ

また、公式ドキュメントをもとに一度Firebaseを実際に使ってどんなものなのか知ってもらいたいです。
次の項目を一通りやってみて、途中でわからなくて止まってしまっても全部確認してやってください?

Firebase 活用方法

 

今回、AuthとDatabaseを活用する理由ですが、カジュアルなゲームや2D/3Dのコンテンツの開発でユーザーデータの登録/取得が必要になる場面が多いため、他にもFirebaseの導入が必要かもしれませんが、まずは最低限の導入から進めることをオススメします。

アプリの要件次第ですが、例えばゲームアプリの開発でランキングシステムを実装するとします。
一端末で完結するアプリ(ノベルゲームや人狼ゲームなど)であれば PlayerPrefs などを用いて、ローカルにデータを保持するだけで事足ります。
ですが、オンラインで様々なユーザーがランキングを争う要件となると、全ユーザーのゲームデータを管理する サーバー が必要となります。

そこで、FirebaseのDatabaseでゲームデータを保持させ、アプリを使用するユーザーのみDatabaseへアクセスできるようにセキュリティの担保、およびユーザーを管理するAuthを使うことで、サーバーの開発と運用をやらなくて済みます。

Auth

 

Databaseを使用するならAuth必須だと思っていいでしょう。
Firebaseはある一定から従量課金制になるので、使った分だけ請求が発生します。そのため、誰でもアクセスでき(".read": true)誰でも書き込みでき(".write": true)る脆弱なDatabaseのルールで運用してしまうと、Databaseが攻撃されて多額の請求が発生してしまう可能性が考えられます。

そのため、作るアプリを使用するユーザーのみアクセスと書き込みができる設定にしておけば不正アクセスを特定できるし、後からさらにセキュリティを強化することが可能です。

認証方式

 

本来は、SNS連携でスマートにユーザー管理したいのですが、Unityの場合はiOS/Androidともに個別対応が必要で大変なので、今回も前回同様パスワードでの認証を扱います。
 

※実装関連は後半へ続く

Database

 

ユーザーデータをDatabaseに保存することで作る機能の幅がグッと広がります。
ゲームならランキング機能やゲームデータの共有機能、ゲーム以外でもフレンド機能などを開発できるようになります。

私が過去に開催したイベント Unity Developer Jobs #1 でご登壇していただいた @tktrvr さんの 発表内容 で、アバターライブ配信プラットフォーム「トピア」 のチャット・ギフトの実装は、Firebase Realtime Databaseで実装されているとのことでした。

 

※実装関連は後半へ続く

公式ドキュメントにある各サンプルコード

さて、実際にAuthとDatabaseを扱って、Unity/C#の初心者さんはサンプルコードや公式ドキュメントの解説についてレベルが高く感じ、全然頭に入らないしどうしたらいいのかわからない状態になった方は少なくないんじゃないでしょうか。

ここからが本記事の本題でして、これから次の項目を一つ一つFirebaseに合わせて解説していきます。
Unity/C#の初心者さんは、どうキャッチアップすればUnityでFirebaseを使ってアプリ開発できるのか一緒に抑えていきましょう!

Json(JavaScript Object Notation)

Jsonは、1要素をKeyとValueで取り扱います。このValueでは、文字列や数値を取り扱える他に新たに要素を増やしたり、配列でもあたりを管理することができる上に人間がとても管理しやすいデータ型でもあります。

例えば、以下のようなテーブル情報があったとして、次のようなJsonフォーマットを文字列で管理することができます。
Json形式はフォーマットが存在するため、JSON Pretty Linter Ver3のようなWebツールを使って確認すると作業しやすいですね。

Usersテーブル

id name point
1 Tanaka 90
2 Sato 80
3 Yamada 70
4 Inoue 60

{
  "Users": {
    "1": {
      "name": "Tanaka",
      "point": 90
    },
    "2": {
      "name": "Sato",
      "point": 80
    },
    "3": {
      "name": "Yamada",
      "point": 70
    },
    "4": {
      "name": "Inoue",
      "point": 60
    }
  }
}

 
FirebaseのDatabaseでは、JsonのようなKeyとValueでやり取りします。一般的なデータベース(MySQLやPostgreSQL)は、SQL(Structured Query Language)をベースに設計されているため、SQLを学ぶ必要があります。

SQLの学習はとても難しいですが、JSONであればKeyとValueの設定さえ把握してしまえばいいので、JsonのWebツールなどを活用してサクッと身に付けましょう!

非同期

非同期は、以下のように『通常同期処理とは違って、メイン処理(メインスレッド)が終わっても別で処理(サブスレッド)が進んでいる。』だけ知り、それ以外で非同期を理解しようとしなくて大丈夫です。

// 同期処理のサンプル
void Start() {
    Debug.Log("【メッセージ表示開始】");
    Debug.Log( Message() );
    Debug.Log("【メッセージ表示終了】");
}

string Message()
{
    var stg = "■-□-■-□-■-□-■-□-■-□\n";
    stg += " +。♪*。#+。♪*。#+。\n";
    stg += "Hello World!!\n";
    stg += " +。♪*。#+。♪*。#+。\n";
    stg += "■-□-■-□-■-□-■-□-■-□\n";
    return stg;
}

/**
 * [表示結果]
 * 
 * 【メッセージ表示開始】
 * ■-□-■-□-■-□-■-□-■-□
 *  +。♪*。#+。♪*。#+。
 * Hello World!!
 *  +。♪*。#+。♪*。#+。
 * ■-□-■-□-■-□-■-□-■-□
 * 【メッセージ表示終了】
 */

// 非同期処理のサンプル
void Start() {
    Debug.Log("【メッセージ表示開始】");

    // 別処理(別スレッド)を作成
    Thread thread = new Thread(new ThreadStart(() =>
    {
        Debug.Log( Message() );
    }));
    // 別処理実行
    thread.Start();

    Debug.Log("【メッセージ表示終了】");
}

/**
 * [表示結果]
 * 
 * 【メッセージ表示開始】
 * 【メッセージ表示終了】
 * ■-□-■-□-■-□-■-□-■-□
 *  +。♪*。#+。♪*。#+。
 * Hello World!!
 *  +。♪*。#+。♪*。#+。
 * ■-□-■-□-■-□-■-□-■-□
 */

 
非同期処理の場合、上記のように処理の結果が変わります。
そのため、いつものようにメソッドを扱う感覚でプログラミングしてしまうと、タイミングによって処理結果がバラバラになってしまって不具合の原因を作りかねません。

非同期の場合、処理がどのタイミングで結果を出してくれるのか意識するために、フローチャートシーケンス図 を書きながらプログラミングするとよいでしょう!

どこで使うのか

非同期処理が必要になる箇所ですが、Firebaseが提供している *****Async( ... ).ContinueWith( task => { 【ここの処理全て非同期処理】 } ) とAsyncが付いているメソッド全てになります。

Authでは、アカウントの作成( CreateUserWithEmailAndPasswordAsync )とログイン( SignInWithEmailAndPasswordAsync )などで非同期処理が求められ、Databaseでは、データの取得( GetValueAsync )で非同期処理が求められます。

難しいので使い方がわかればいい

  

各プログラミング言語で非同期をテーマにした書籍が出ているぐらい、初心者ベテラン関係なく、非同期はとても難しいです。なので、最低限扱えるレベル学習するだけで大丈夫ですー

Unityでの非同期の歴史

Unityの非同期には歴史がありまして、その辺は @4_mio_11 さんの 古来よりUnity非同期を実現していたコルーチンとは何者か? を参考にされてください。

ラムダ式(Lambda Expression)

先ほど解説で出た ContinueWith メソッドの引数=ラムダ式(無名関数)になります。
これも非同期同様に難しい分類なので、書き方と扱い方さえ把握すれば理解しようとしなくていいです。

ラムダ式は以下のような書き方で、ソースコードを簡略化することができます。

// 通常のメソッド
int Add(int a, int b) {
    return a + b;
}

// ラムダ式
(int a, int b) => { return a + b; };

 
ラムダが無名関数と言われているように、ラムダ式になるとメソッド名と返り値の型を明示しなくてもプログラミングとして動きます。

さらに知りたい方は、@toRisouP さんの 【C#】わかった"つもり"になれる「ラムダ式」解説 を参考にされてください。

さいごに

いかがだったでしょうか、個人的にFirebaseを扱うことでキャッチアップの量が増えてしまいますが、ここまで解説した最低限の範囲を学んで、Firebaseを用いて作っているアプリにユーザーデータを保存して取得できる機能が作れたら、Unity超初心者から卒業してるレベルじゃないでしょうか。

実装内容は、全て後編の Firebase Advent Calendar 2020 の16日目に投稿予定の記事『UnityとFirebaseでアプリ開発する際にユーザーデータの取り扱い方まとめ(仮)』でまとめさせていただきますmm

あくまでも参考程度

 

ちなみに公式ドキュメントのサンプルコードは、そのままでは扱えないコードを記載していることがためにあります。
上記のように変なところに閉じブロックがあったり、Unityではないですが、過去にiOSのネイティブアプリでFirebaseを導入した際にサンプルコードだけではエラーになって調査しながら組み込んだことがありました。

このように、公式ドキュメントであっても全て正しいわけではなかったりするので、『公式のサンプルコードなのにエラーになるなあ、日本語記事は見つからないけど海外の記事に似たようなエラーの人がいるみたい。翻訳してもわからないから直接公式に問い合わせしよー』ぐらいの感覚で気楽にやらないとやっていけませんので笑

 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【C#】Azure CosmosDBのデータ更新についてとdynamicでハマった話

こんにちは!GxP2年目の笠井です。
この記事はグロースエクスパートナーズ アドベントカレンダーの12日目の記事となります。

今回は業務でAzure CosmosDB(以下CosmosDB)のデータメンテナンスツールを作っている際にC#のdynamicで悩んだ話です。
テーマが「今年の学び」ということで、基礎的なところで躓いた話ではありますが自分への戒めとして書いていきます。

状況

CosmosDBから取得したデータ内の年月日等の日付に相当する値を結合し、新たにISO 8601形式の日付データとして要素を追加するツールを作る必要がありました。

CosmosDBJson.png

今回CosmosDBから取得するデータはJSONになっており、1つのドキュメントごとに要素数が異なる半構造化データに対応するためにdynamicで取得することにしました。

Azure CosmosDB

CosmosDBとは

CosmosDBはさまざまなタイプのデータモデルに対応可能なNoSQLデータベースで、Key-Value型、Graph型、Document型などのデータモデルとそれぞれのモデルに対応したAPIが提供されています。
今回はそのなかでもSQL API(Document型)を使用しました。

Cosmosアカウントの要素

Cosmosアカウントの要素は以下のような階層になっています。
CosmosDBArchi.png

dynamic とは

dynamicがどういったものなのかざっくり説明します。

動的型付け変数

実行時に型が決まります。
ですので、以下のようなクラスを定義したとして

public class Hoge {
   public string A { get; set; } 
   public string B { get; set; } 
}

以下のように存在しないプロパティを参照するようなコードを書いた場合、コンパイルは通りますがRuntimeBinderExceptionが投げられます。

dynamic hoge = new Hoge();

// コンソールにAの値が出力される
System.Console.Writeline(hoge.A);

// コンソールにBの値が出力される
System.Console.Writeline(hoge.B);

// コンパイルは通るが実行時エラーとなる
System.Console.Writeline(hoge.C);

また、dynamic というものではなく、実体はObjectです。
なので以下のようなコードはコンパイルエラーになります。

dynamic A;
if (typeof(A) == typeof(dynamic))
{
    hoge();
}

CosmosDBデータメンテナンスツール

今回は単純に「CosmosDBに接続、全件取得し更新する」というコードです。

using Microsoft.Azure.Cosmos;
using System;
using System.Threading.Tasks;

namespace Hoge
{
    public class Hoge
    {
        // CosmosDB エンドポイント
        private static readonly string EndpointUri = "todo";
        // プライマリキー 
        private static readonly string PrimaryKey = "todo";

        // CosmosDB クライアント
        public CosmosClient cosmosClient;

        // DB
        public Database database;

        // コンテナ 
        public Container container;

        // DB名 
        private string databaseName = "todo";

        // コンテナ名 
        private string containerName = "todo";


        public static async Task Main(string[] args)
        {
            try
            {
                Console.WriteLine("開始しています...\n");
                Hoge app = new Hoge();

                app.cosmosClient = new CosmosClient(EndpointUri, PrimaryKey);

                // DBへ接続する、存在しなければDBを作成する
                await app.CreateDatabaseAsync();
                // コンテナへ接続する、存在しなければコンテナを作成する
                await app.CreateContainerAsync();
                // SQLを実行し、検索結果の取得と各UPDATEを実行する
                await app.QueryAndUpdateItemsAsync();
            }
            catch (CosmosException de)
            {
                Exception baseException = de.GetBaseException();
                Console.WriteLine("{0} error occurred: {1}", de.StatusCode, de);
            }
            catch (Exception e)
            {
                Console.WriteLine("Error: {0}", e);
            }
            finally
            {
                Console.WriteLine("終了しました、何かキーを押してください。");
                Console.ReadKey();
            }
        }

        /// <summary>
        /// DBの情報を取得、存在しなければDBを作成する
        /// </summary>
        public async Task CreateDatabaseAsync()
        {
            this.database = await this.cosmosClient.CreateDatabaseIfNotExistsAsync(databaseName);
            Console.WriteLine("Created Database: {0}\n", this.database.Id);
        }

        /// <summary>
        /// コンテナの情報を取得、存在しなければコンテナを作成する
        /// </summary>
        /// <returns></returns>
        public async Task CreateContainerAsync()
        {
            // コンテナ名とパーティションキーを指定する。
            // 存在しなければ指定したコンテナ名とパーティションキーでコンテナが作成される
            this.container = await this.database.CreateContainerIfNotExistsAsync(containerName, "/partitionKey");
            Console.WriteLine("Created Container: {0}\n", this.container.Id);
        }

        /// <summary>
        /// SQLを実行し、検索結果の取得と各UPDATEを実行する
        /// </summary>
        /// <returns></returns>
        public async Task QueryAndUpdateItemsAsync()
        {
            // クエリ、今回は全件検索
            var sqlQueryText = "SELECT * FROM c ";

            // クエリを実行する
            QueryDefinition queryDefinition = new QueryDefinition(sqlQueryText);
            FeedIterator<dynamic> queryResultSetIterator = 
                                     this.container.GetItemQueryIterator<dynamic>(queryDefinition);


            while (queryResultSetIterator.HasMoreResults)
            {
                FeedResponse<dynamic> currentResultSet = await queryResultSetIterator.ReadNextAsync();
                foreach (var item in currentResultSet)
                {
                   // データを整形する
                   item.Date = example(item);                    

                   // ドキュメントを更新する
                   await this.container.ReplaceItemAsync<dynamic>(
                        item, 
                        item.id.ToString(), 
                        new PartitionKey(item.partitionKey.ToString())
                   );
                }
            }
        }
    }
}

DB、コンテナの情報を取得

  • CosmosClient.CreateDatabaseIfNotExistsAsync()で指定したDBが存在するかを確認し、存在すればDBの情報(Task<DatabaseResponse>)を返却します。存在しない場合はDBを作成します。
  • CosmosDatabase.CreateContainerIfNotExistsAsync()で指定したコンテナが存在するかを確認し、存在すればコンテナの情報(Task<ContainerResponse>)を返却します。存在しない場合はコンテナを作成します。
/// <summary>
/// DBの情報を取得、存在しなければDBを作成する
/// </summary>
public async Task CreateDatabaseAsync()
{
    this.database = await this.cosmosClient.CreateDatabaseIfNotExistsAsync(databaseName);
    Console.WriteLine("Created Database: {0}\n", this.database.Id);
}

/// <summary>
/// コンテナの情報を取得、存在しなければコンテナを作成する
/// </summary>
/// <returns></returns>
public async Task CreateContainerAsync()
{
    // コンテナ名とパーティションキーを指定する。
    // 存在しなければ指定したコンテナ名とパーティションキーでコンテナが作成される
    this.container = await this.database.CreateContainerIfNotExistsAsync(containerName, "/partitionKey");
    Console.WriteLine("Created Container: {0}\n", this.container.Id);
}

クエリの実行

  • stringのクエリ文字列をQueryDefinitionクラスに定義します。 定義したクエリをContainer.GetItemQueryIterator()で実行することで、結果がFeedIterator<T>で返却されます。
// クエリ、今回は全件検索
var sqlQueryText = "SELECT * FROM c ";

// クエリを実行する
QueryDefinition queryDefinition = new QueryDefinition(sqlQueryText);
FeedIterator<dynamic> queryResultSetIterator = this.container.GetItemQueryIterator<dynamic>(queryDefinition);

データを更新

  • FeedIterator.ReadNextAsync() で取得してきた FeedResponse<T>にはクエリで取得してきたデータが入っているのでforeachを使って一つ一つ更新していきます。
  • Container.ReplaceItemAsync<T>()に内容を更新したitem、itemのidパーティションキーを渡して更新します。
while (queryResultSetIterator.HasMoreResults)
{
    FeedResponse<dynamic> currentResultSet = await queryResultSetIterator.ReadNextAsync();
    foreach (var item in currentResultSet)
    {
        // データを整形する
        item.Date = example(item);                    

        // ドキュメントを更新する
        await this.container.ReplaceItemAsync<dynamic>(
            item, 
            item.id.ToString(), 
            new PartitionKey(item.partitionKey.ToString())
        );
    }
}

今回のハマりポイント

ちゃんと型を把握しなければいけない

実行時に型が決まるのでコンパイルエラーが出ません。なのでちゃんと型を把握して使う必要があります。
コンパイルは通ってしまうのであまり意識が出来ておらず苦戦しました。

以下のitem.idNewtonsoft.Json.Linq.JTokenが想定されるのでToString()メソッドでstringに変換して引数に入れています。

await this.container.ReplaceItemAsync<dynamic>(
    item, 
    item.id.ToString(), 
    new PartitionKey(item.partitionKey.ToString())
);

今回事前調査が足りず、CosmosDBから取得できるJSONがNewtonsoft.Json.Linq.JObjectであることが実装後にわかったので、結果としてdynamicを使う必要がありませんでした。
以下は修正後のコードです。

public async Task QueryAndUpdateItemsAsync()
{
    // クエリ、今回は全件検索
    var sqlQueryText = "SELECT * FROM c ";

    // クエリを実行する
    QueryDefinition queryDefinition = new QueryDefinition(sqlQueryText);
    FeedIterator<JObject> queryResultSetIterator = 
                                     this.container.GetItemQueryIterator<JObject>(queryDefinition);


    while (queryResultSetIterator.HasMoreResults)
    {
        FeedResponse<JObject> currentResultSet = await queryResultSetIterator.ReadNextAsync();
        foreach (var item in currentResultSet)
        {
            // データを整形する
            item["Date"] = example(item);                    

            // ドキュメントを更新する
            await this.container.ReplaceItemAsync<dynamic>(
                item, 
                item["id"], 
                new PartitionKey(item["partitionKey"])
            );
        }
    }
}

まとめ

本記事の件で自分の経験の浅さを思い知りました。
実行時に型が決まるという仕様上コンパイルしただけではエラーなのかが把握しきれないので、動的型付けや型推論を使う際はちゃんと型を把握して実装するよう注意したいと思いました。
どんな型のデータが入っているかを知る方法としてObject.GetType()がありますが、一番手っ取り早いのはちゃんと公式のドキュメントを読むことだと思います。

また、dynamicvarをむやみに乱用すると他の作業者や未来の自分がソースコードを見た時に理解しづらくなってしまうので型が容易に想定できる範囲で使うことを意識するといいと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[C#/WPF] WPFでアクションセンター内のトーストを押したときにアプリを起動する

もくじ
https://qiita.com/tera1707/items/4fda73d86eded283ec4f

やりたいこと

デスクトップアプリ(WPF)にトースト(toast)でユーザーに通知を行う機能を実装したい。
ざっくり動きとしては、こういうのが「ピローン」と画面右下に出てきて、
image.png
そいつを押すと、アプリが起動する。
押さずにほおっておくとWindowsの「アクションセンター」に入る。
image.png
アクションセンター内にあるトーストを押すと、押したトーストは消えて、アプリが起動する。
というようなイメージ。


トーストを実装すること自体初めてなのでよくしらなかったのだが、

  • トーストはもともとUWPの機能である。
  • デスクトップアプリ(WPFやWinForm)でトーストをするにはちょっと特殊なことをしないといけない。

っぽい。

で、その特殊なことをMicrosoftの公式ドキュメントやネットで調べながら実装したところ、トーストの仕組みを知らないせいでものすごく苦労してしまったので、その時に調べたことやノウハウをメモして残そうと思った次第。

成果物

まず下記に、作ったコード一式を置いている。

トースト実験プログラム本体
https://github.com/tera1707/WPF-/tree/master/040_ToastJikken

AUMID,CLSIDの入ったshortcutを作成するツール
https://github.com/tera1707/WPF-/tree/master/040_MakeShortcut

トーストのためのCLSIDをレジストリに登録するツール
https://github.com/tera1707/WPF-/tree/master/040_RegisterCLSIDtoRegistry

やり方概要

下記のページに、デスクトップアプリでトーストを実装するためのMicrosoft公式のやり方が書いてあるので、基本的にはこちらをもとに進める。
https://docs.microsoft.com/ja-jp/windows/uwp/design/shell/tiles-and-notifications/send-local-toast-desktop?tabs=msix-sparse

やること大項目 MSdocsの該当項目
①コードを書く Step 1: Install the Notifications library
Step 2: Implement the activator
Step 3.2: Register AUMID and COM server
Step 4: Register COM activator
Step 5: Send a notification
②AUMID,CLSIDの入ったshortcutを作成 Step 3.1: WiX Installer
③トーストのためのCLSIDをレジストリに登録 Step 3.1: WiX Installer

基本はMS公式ドキュメントをベースに進めるのだが、
この表で「①コードを書く」に該当する公式のStep 3.1: WiX Installerが、何をするための手順なのかが全然わからなかった。(というより、こちらの都合で、Wixインストーラーではなく別のインストーラー作成ソフトを使わないといけなかったので、そのソフトを使った場合ではこの項目に対して何をしたらよいのか?が全然読み取れなかった。)

そのわからなかった部分については、後ほど

の項目で対応策に触れる。

実際にトーストを作る

ここから、実際にトーストの機能を作っていく。
基本はMS公式の通りに進めるが、一部進める順番が違うので注意。

①コードを書く

Microsoft.Toolkit.Uwp.Notificationsパッケージをインストール

公式:Step 1: Install the Notifications library

UWPのSDKをええように参照して、デスクトップアプリからもトーストを使えるようにしてくれるMicrosoft.Toolkit.Uwp.Notifications のNuGetパッケージをインストールする。

image.png

CLSIDを持つNotificationActivatorを継承したクラスを作成

公式:Step 2: Implement the activator

MyNotificationActivator.csというファイルを追加して、そこにNotificationActivatorクラスを継承したクラスを作成し、そいつにCLSIDを振る。(中身は後でつくる)

下記のGuid("・・・・・")の部分がCLSID。

MyNotificationActivator.cs
[ClassInterface(ClassInterfaceType.None)]
[ComSourceInterfaces(typeof(INotificationActivationCallback))]
[Guid("EF608355-E10B-487C-BA55-AE7E400E4EC7"), ComVisible(true)]
class MyNotificationActivator : NotificationActivator
{
    // 中身は後でつくる
}

CLSIDは、いわゆる「GUID」なので、VisualStudioのGUIDの作成ツールを使ってGUIDを取得し、そいつを振ればOK。
image.png

下記で「コピー」を押すとクリップボードにコピーされるので、[Guid("replaced-with-your-guid-C173E6ADF0C3"), ComVisible(true)]のところに張り付ける。
(そのまま張り付けるとカッコとかついてるので、必要なGUIDの数字以外の余計なものは消す)
image.png

DesktopNotificationManagerCompat.RegisterAumidAndComServer でAUMIDを登録

公式:Step 3.2: Register AUMID and COM server

アプリ起動後一回、下記を実行してAUMIDを登録する。
今回は、メインのウインドウのコンストラクタで実施。

MainWindow.xaml.cs
DesktopNotificationManagerCompat.RegisterAumidAndComServer<MyNotificationActivator>("MyCompany.ToastJikken");

DesktopNotificationManagerCompat.RegisterActivator でCOMサーバーを登録する

Step 4: Register COM activator

アプリ起動後一回、下記を実行してCOMサーバーを登録する。
RegisterAumidAndComServerと同様に、メインのウインドウのコンストラクタで実施。

MainWindow.xaml.cs
DesktopNotificationManagerCompat.RegisterActivator<MyNotificationActivator>();

トーストを表示

トーストを表示させる部分をつくる。
(下記のコード。MSのサンプルそのまま)

Step 5: Send a notification

MainWIndow.xaml.cs
// トーストを組み立てる
ToastContent toastContent = new ToastContentBuilder()
    .AddToastActivationInfo("action=viewConversation&conversationId=5", ToastActivationType.Foreground)
    .AddText("Hello world!")
    .GetToastContent();

// 組み立てたやつをもとにToastNotificationを作成
var toast = new ToastNotification(toastContent.GetXml());

// トーストを表示
DesktopNotificationManagerCompat.CreateToastNotifier().Show(toast);

ここまでで、とりあえずトーストは出てくる。が、出てきたトーストを押してもなにも起きない。
DesktopNotificationManagerCompat.CreateToastNotifier().Show(toast);を実行しても、まだトーストはでない。(後に行うショートカット作成とCLSID登録が必要。)

次は、トーストが押されたときに実行される部分を作る。

押されたときの処理(OnActivated())を実装する

Step 6: Handling activation

下記のようなコードを書いて、トーストを押されたら、メイン画面上のリストboxに押された旨を表示させてみる。
MainWindow.mw.AddLog("ABC")が、その旨表示させるコードビハインドが持っているメソッド。

MyNotificationActivator.cs
[ClassInterface(ClassInterfaceType.None)]
[ComSourceInterfaces(typeof(INotificationActivationCallback))]
[Guid("EF608355-E10B-487C-BA55-AE7E400E4EC7"), ComVisible(true)]
class MyNotificationActivator : NotificationActivator
{
    public override void OnActivated(string invokedArgs, NotificationUserInput userInput, string appUserModelId)
    {
        Application.Current.Dispatcher.Invoke(delegate
        {
            OpenWindowIfNeeded();

            // ログ表示
            MainWindow.mw.AddLog("OnActivated()実行しました");
            MainWindow.mw.AddLog(invokedArgs);
        });
    }

    private void OpenWindowIfNeeded()
    {
        MainWindow.mw.AddLog("OpenWindowIfNeeded()");

        // ウインドウを開く (アプリが閉じている間にトーストが押されたとき(≒アクションセンターで押された時)等)
        if (App.Current.Windows.Count == 0)
        {
            MainWindow.mw.AddLog("メインウインドウ表示");
            new MainWindow().Show();
        }

        // ウインドウをActivateして、フォーカスをあてる
        MainWindow.mw.AddLog("メインウインドウにフォーカス当てました");
        App.Current.Windows[0].Activate();

        // 最小化してたら通常の大きさに戻す
        App.Current.Windows[0].WindowState = WindowState.Normal;
    }
}

これで、トーストが押されたときに、上の処理をしてくれるようになる。

【注意】
OnActivated()は、UIスレッドとは異なるスレッドで実行されている。
そのため上記サンプルでも画面を操作するようなコードはApplication.Current.Dispatcher.Invoke(()を使ってUIスレッドで実行するようにしている。

★次の「②AUMID,CLSIDの入ったshortcutを作成」と「③トーストのためのCLSIDをレジストリに登録」について

公式:Step 3.1: WiX Installer

この部分が、公式では「WiX」というMSおすすめ?のインストーラー作成ツールを使った手順説明になっているのだが、ここが、WiXを使った結果、何がoutputされるのか?何をしようとしている手順なのか?がわからなかった。

というより、私の場合、作るアプリをインストールするときに使うインストーラーが、WiXではなくほかのインストーラー作成ソフトを使うことに決まっていたため、WiXを使えない、どうしたらいい?となってしまった。(調べ始めた時点で「WiX」というツールの存在を知らなかったので輪をかけて???になった)

調べるうえで、先人が作成してくれているデスクトップアプリでのトーストを扱うOSSを利用する中で、やっていることを理解していこうという調査方法を取ったのだが、その時、調べを進めるうえでものすごく下記のページ/OSSにお世話になった。

〇デスクトップアプリからインタラクティブなトースト通知
https://8thway.blogspot.com/2016/05/desktop-interactive-toast.html

〇emoacht/DesktopToast
https://github.com/emoacht/DesktopToast

こちらのOSSを利用すると、簡単にデスクトップアプリでトーストを実装することができる。
そのコードを拝見する中で、そのよくわからないstep3で実際にやらないといけないのが、

  • NotificationActivatorを継承したクラスのCLSIDをレジストリに登録すること
  • アプリのAUMIDNotificationActivatorを継承したクラスのCLSIDを持ったショートカットをスタートメニューに置くこと

だということが分かった。
(大変勉強になりました、ありがとうございます。)


で、上記を行う場合、使っていたインストーラー作成ソフトでそういうことができるので、インストーラーにやってもらうことになった。(インストーラでインストールをする時に、ショートカットとCOMのCLSIDを登録してくれるようにした。)

ただ、仕事ではそういう風に対応したが、自分で勉強&趣味で作ってみるうえではそのようなインストーラー作成ソフト(有料)は使えないので、自前でその2つができるようなツールを作って対応した。(そのツールについても下に置き場所を書いておく)

②AUMID,CLSIDの入ったshortcutを作成

公式:Step 3.1: WiX Installer

以下の情報を持つショートカットを作成し、ユーザーのスタートメニューに配置する。
スタートメニューのパスは、Win+Rで出てくる「ファイル名を指定して実行」に「shell:start menu」と入力して出てくるC:\Users\ユーザー名\AppData\Roaming\Microsoft\Windows\Start Menuにする。

※「shell:common start menu」と入力して出てくるC:\ProgramData\Microsoft\Windows\Start Menu\Programsでも試した限りトーストはうまく動く。
複数ユーザーに共通のショートカットを作りたい時などはこちらにすればよいっぽい。

それぞれ、今回は実験用として、下記のような値にした。

※配置場所:C:\Users\ユーザー名\AppData\Roaming\Microsoft\Windows\Start Menu

使うもの 備考
AUMID MyCompany.ToastJikken 通常会社名.アプリ名の形式にする(参照)
CLSID EF608355-E10B-487C-BA55-AE7E400E4EC7 NotificationActivatorを継承したクラスに振ったGUID

※上記CLSIDは今回実験するときに作ったサンプル値なので、上にあげた手順で各自自分のGUIDを作って入れること。

ショートカット作成用のツールはこちら
https://github.com/tera1707/WPF-/tree/master/040_MakeShortcut
image.png
AUMIDの形式は公式ページに記載がある。
※ただ試した限りでは、別にこの形式でなくてもトーストは動くっぽい。
image.png

③トーストのためのCLSIDをレジストリに登録

公式:Step 3.1: WiX Installer

NotificationActivatorを継承したクラスに振ったCLSIDをレジストリに登録する。
アクションセンターに入ったトーストから、アプリを起動させるのに必要な手順。

登録先は、

`コンピューター\HKEY_CURRENT_USER\Software\Classes\CLSID\{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}\LocalServer32`

xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxは、今回使うCLSID。

regeditで手打ちで登録してもOK。
ただ面倒なので、レジストリ登録のためにツールを作った。
https://github.com/tera1707/WPF-/tree/master/040_RegisterCLSIDtoRegistry
image.png
実際に登録してregeditでみると、下記のような感じ。
image.png

動かしてみる

ここまで行えば、以下ができるようになる。

  • アプリからトーストを表示する。
  • 右下に出ている間にトーストを押したときに、OnActiateに書いた処理を実行する。
  • (アプリ実行中に)アクションセンターに入ったトーストを押したときに、アプリを起動してOnActiateに書いた処理を実行する。
  • (アプリ終了状態で)アクションセンターに入ったトーストを押したときに、アプリを起動してOnActiateに書いた処理を実行する。 image.png これで、基本のトーストのライフサイクル一周分はできたかと思う。

トーストをカスタムする

基本はここまででできたが、ただテキストを表示して、押したらアプリ起動する、というだけでは味気ないので、画像を出したりユーザーにテキストを入力させたりもできる様子。

その辺は、下記をみればできそう。(今回はやらない)
https://docs.microsoft.com/ja-jp/windows/uwp/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=builder-syntax
image.png

ポイント/ハマったところ

はじめ、トーストがどういう仕組みで動いているのかを全然わかっていなかったので、MSの公式手順のそれぞれ(特にstep3)が、何をしたいがための作業なのかがよくわかってなかった。

で、ハマったポイントというか、あ、たぶんそういうことなんだな、となったポイントは下記だった。

AUMIDとCLSIDを含んだショートカットがスタートメニューの中に置かれてないと、アクションセンターの中のトーストからアプリ起動できない

右下にトーストが「ピローン」といって表示されている間はアプリ本体(もしくはトーストを表示するためのexe)が起動している状態
image.png

そのトーストは、一定時間経過するとアクションセンターに入る。
アクションセンターに入った後のトーストを押したときは、アプリ本体が終了している状態であってもアプリを起動しないといけない。

トーストを押したときにどのアプリを起動しに行くか、の紐づけをしているのが、上で作成したショートカットレジストリに登録したCLSIDだった。(たぶん)

下の図は、公式にこういう流れで処理している、というドキュメントを見つけて書いたわけではなく、動かしてみてそういう動きをしてるんじゃないか、という私の理解を書いたもの。(なので、参考程度に...)

image.png

多分、UWPで作ったアプリのトースト機能では、こういうことを自前でショートカットとか作らずに、UWPの元々の仕組みでできるのだと思う。
(それを、元々の仕組みを使わずに、無理やり実現させようというのがデスクトップアプリのトースト、という理解。)

デバッグ中は動くのに、本番環境に入れたとたんにわけのわからない動きをする

NotificationActivatorを継承したクラスのCLSIDをレジストリに登録したのだが、実装中にCLSID(GUID)をいろいろ変えて動きを見てみたりしていたせいで、変な動きに悩まされた。

具体的には、
image.png
ここのLocalServer32の中の、トーストを押したときに起動したいexeのパスを変えてしまったり、VisualStudioのプロジェクトを別の場所に置いてしまったりすると、トーストを押したときに何も起動してくれなかったり、今は使ってない昔使っていたVSのプロジェクトの中のexeが起動してしまったりする。

私の場合は、デバッグ時はこの絵にあるようなVSのプロジェクトのフォルダ内のexeで動かしているが、本来のexeの場所はC:\Program Files\・・・の中だったので、本番環境にもっていくと、「デバッグしてるときはうまく動くのに、本番か環境に入れるとトーストを押してもアプリ起動してくれない」といったことに陥ってしまった。

今振り返ると、別のパスを見に行ってしまっているのだからそうなるのは当たり前に思えるが、実験し始めた当初はトーストの仕組みもよくわかってない状態だったのでそれはそれはハマった。

やってみて感じたこと

一応Microsoftの公式のやり方があるのだから「デスクトップアプリでもトーストは実装できる」と考えてよいものだと思うが、やっぱりUWPの機能はUWPで使った方がよいかもしれない、と今回感じた。
(appxなどにパッケージする形で出すのも良いかもしれない)

上で何度も書いていた「MSの資料のstep3がわからない」のように「デスクトップアプリで使えるようにするための部分」はあまり公式ドキュメントで優しく解説してくれないっぽいので、そのあたりでなにか問題があって説明を求められたときに、調査が難航しそうな気がする。

とはいえ、トーストの機能は今は一般的になってるので、仕様を決める側は「当然そういうことができる」ものだと思っている様子。

私の周りでは「トーストはデスクトップアプリではナシ」という選択肢は無さそうなので、開発しながら実験して触りまくって、開発終了までに枯らすしかないな、と思う。

参考

ショートカットリンクを作成する
https://www.wabiapp.com/WabiSampleSource/windows/create_short_cut.html

PCに登録されているAUMID(AppUserModelID)を確認する方法
https://docs.microsoft.com/ja-jp/windows/configuration/find-the-application-user-model-id-of-an-installed-app

AppUserModelIDをC#から操作する
https://8thway.blogspot.com/2012/11/csharp-appusermodelid.html

Handle shortcut with AppUserModelID in C#
https://emoacht.wordpress.com/2012/11/14/csharp-appusermodelid/

emoachtさんAUMID入りショートカット作成C#コード
https://emoacht.wordpress.com/2012/11/14/csharp-appusermodelid/

ショートカットファイル(.lnkファイル)を作成する
https://smdn.jp/programming/tips/createlnk/

Microsoft.Toolkit.Uwp.Notificationsパッケージを使ってデスクトップアプリからトーストを実装
https://docs.microsoft.com/ja-jp/windows/uwp/design/shell/tiles-and-notifications/send-local-toast-desktop?tabs=classic

登録されているAUMIDを見る
https://docs.microsoft.com/ja-jp/windows/configuration/find-the-application-user-model-id-of-an-installed-app

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ムービー再生中にFPSを落とさせない工夫

はじめに

本記事は、サムザップ #1 AdventCalendar 2020の12/9の記事です。
昨日の記事は@kanasaki_kenjiの「最高の振動をデザインするための下準備〜UnityからCore Haptic FrameWorkを使ってみた〜」です。

導入きっかけ

私は、Unityを使ったゲーム開発をしております。
インゲームなどの60FPSをキープしたい、そしてプレー中にムービーを流したいなどの要望を受けました。
私が担当したプロジェクトでは、ムービーを再生するのに利用しているプラグインとして
CRI Manaを利用していました。

CRI Manaにはムービーをシームレスで再生するという機能などいくつかありますが、
低スペックの端末だとシームレスの再生の切り替えのタイミングでコマ落ちすることが調査して判明しました。
そこで今回どうやって工夫したかを説明したいと思います。

対処方法

いくつか試してみてので、その時のことを書きます

  • アセットバンドルとして登録されているムービーを一つに連結してしまう

こちらであれば簡単だなと思って実際にやってみたのですが、
これだとファイルサイズのでかいムービーデータになってしまい
ダウンロードが遅すぎるという欠点がでました

  • アセットバンドルとして登録されているムービーを分割した状態で並列ダウンロードさせて連結させる
        /// <summary>
        /// Save(デバイスで読み書きできるパスにセーブする)
        /// </summary>
        /// <param name="index">index</param>
        /// <param name="totalbyte">分割されたファイルのトータルバイト数</param>
        /// <param name="usmFileName">ムービーファイル</param>
        /// <param name="outputFilePath">出力先ファイルパス</param>
        /// <returns></returns>
        private async UniTask Save(int index, long totalbyte, string usmFileName, string outputFilePath)
        {
            if(File.Exists(outputFilePath))
            {
                FileInfo fileInfo = new FileInfo(outputFilePath);
                // 端末のファイルとコピー元のサイズをチェック
                if(fileInfo.Length == totalbyte)
                {
                    //同じなので終了
                    return;
                }
                // 初回のチェックの場合ファイルを削除しておく
                if(index == 0)
                {
                    File.Delete(outputFilePath);
                }
            }
            TextAsset asset = assetLoader.LoadResource<TextAsset>(usmFileName);
            byte[] bytes = asset.bytes;
            // ファイル書き込み
            using (var stream = new FileStream(outputFilePath, FileMode.Append, FileAccess.Write))
            {
                stream.Write(bytes, 0, bytes.Length);
            }
        }

こちらのSaveメソッドをusmFileNameの数だけ回し
それらをファイルごとに書き込むという手法をとりました。
更に端末にセーブした元を比較するのにファイルサイズを比較しないとアセットの差し替えに対応できないので
チェックし初回のチェックがついているときには、元のファイルを削除しておくという対応を加えました。
変更がなければ、生成しないという手法を採用しました。

これによりムービーが再生されてしまえば、シームレスで切り替えるという処理がなくなり
60FPSをキープすることが可能なりました。

まとめ

意外にこれするだけで、効果がかなりありました。もしも困っていたら試してみてください。
明日の記事は、@norimatsu_yusukeです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity 2020から自動実装プロパティのバッキングフィールドにSerializeField属性をつけるとプロパティ名が表示される

PONOS Advent Calendar 2020の10日目の記事です。
昨日は@kerimekaさんの【Android】root判定によるチート対策でした。


Unity 2019以前

C# 7.3から自動実装プロパティのバッキングフィールドに属性をつけられるようになりましたが、SerializeField属性をつけるとInspectorの表示が残念なことになってました。

public class Sample : MonoBehaviour
{
    [field: SerializeField]
    public int ValueA { get; private set; }
}

こう書くと
property-backing-field-2019.png
こう表示されてしまう。

ラベルだけ上書きするPropertyDrawerを自作したら使い勝手が良くなりそうな気がしますが、実際はうまくいきません。
そのあたりは下記の記事で説明されています。
Unityで自動実装プロパティに表示名を指定する

Unity 2020

2020からは

public class Sample
{
    [field: SerializeField]
    public int ValueA { get; private set; }
}

と書くと
property-backing-field.png
こう表示されるようになりました。

ちなみに表示名はプロパティ名ですがyamlに保存されるのはバッキングフィールドの名前です。
sample-prefab-yaml.png

結論

Unity 2020から自動実装プロパティのバッキングフィールドにSerializeField属性をつけるとプロパティ名がいい感じに表示されるようになりました?


本当はInspectorでいい感じに表示する拡張を公開する予定だったのですが、2020でチェックしていたところ何もしなくてもプロパティ名が表示されたのでやめました。


明日は@kenta-sanです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

音ゲーのタイミング計算

音ゲーを作ったのでその時の知見をまとめておきます

前提として今回は一定の速度でノーツが移動します
beatmaniaIIDXのSOFT LANDING ON THE BODYのようなソフランは考慮していませんが、最初からノーツスピードが遅く流れてくる太鼓の達人のようなソフランは対応できます

ノーツの移動
update(){
 this.gameObject.transform.Translate(0, "ノーツの移動速度" * Time.deltaTime, 0);
}

//ノーツの移動速度の計算方法
speed  = "曲のノーツスピード" * "プレイヤーが設定したノーツスピード" * "曲のBPM" / 60;

原型
座標 = "曲のBPM" / 60 * "ノーツをタップする時間" * "曲のノーツスピード" + "曲のオフセット";
プレイヤーが設定をいじる場合
座標 = "曲のBPM" / 60 * "ノーツをタップする時間" * "曲のノーツスピード" * "プレイヤーが設定したノーツスピード" + "曲のオフセット" + "プレイヤーの設定したオフセット";
//この際、曲のノーツスピードは基本値を1にしておくこと

このように座標の計算ができる

おまけ

スコア計算について

最終的に1000000になるように1ノーツあたりのスコアを計算して足していく方法が最初に思いついたが、細かすぎる計算はあまりきれいに出ない
何なら1000000にならないことが多々あった

別の方法として割合で計算する方法がある

score = (int)((float)1000000 * (((((float)"最高判定の数" * (float)2) + (float)"ひとつ下の判定" )/ ((float)"すべてのノーツ数" * (float)2))));

参考元

小節数から再生時間を計算する

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

複数のInspectorウインドウを一斉に操作する機能を作成する

PONOS Advent Calendar 2020の7日目の記事です。

昨日は@nissy_gpさんのNode.js+Sequelize+MySQLでプライマリーキーをUUIDにするでした。

はじめに

前回の記事で複数のInspectorウインドウを一度に開く機能を実装しました。
今回は開かれているすべてのInspectorウインドウに対して一括で処理を実行する方法を紹介します。

実装

すべてのInspectorウインドウを取得する

現在Unityエディタ上で開かれているすべてのInspectorウインドウを取得するためにはInspectorWindow.GetAllInspectorWindows()メソッドを利用します。

UnityCsReference/InspectorWindow.cs at master · Unity-Technologies/UnityCsReference · GitHub

UnityEditorにinternalとして定義されている InspectorWindowクラスの機能を直接呼び出すことはできないため、前回の記事と同様にリフレクション機能を使ってメソッド実行します。

var inspectorWindowType = Assembly.Load("UnityEditor").GetType("UnityEditor.InspectorWindow");
var mathodInfo = inspectorWindowType.GetMethod("GetAllInspectorWindows", BindingFlags.NonPublic | BindingFlags.Static);
var inspectorWindows = mathodInfo.Invoke(null, null) as EditorWindow[];

これで、開いているInspectorウインドウの配列を取得することができました。
なお、その後のウインドウ操作を行いやすいように、EditorWindowの配列として取得しています。

あとはこの配列内の要素を操作をしていけばOKです。

すべてのInspectorウインドウを閉じるサンプル

サンプルとして「開いているすべてのInspectorウインドウを閉じる機能」のコードを掲載します。

static void CloseAllInspector()
{
    var inspectorWindowType = Assembly.Load("UnityEditor").GetType("UnityEditor.InspectorWindow");
    var mathodInfo = inspectorWindowType.GetMethod("GetAllInspectorWindows", BindingFlags.NonPublic | BindingFlags.Static);
    var inspectorWindows = mathodInfo.Invoke(null, null) as EditorWindow[];

    foreach (var inspectorWindow in inspectorWindows)
    {
        inspectorWindow.Close();
    }
}

まとめ

今回紹介したInspectorWindow.GetAllInspectorWindows()メソッドを利用することで、開いているInspectorウインドウを一括で操作する機能を容易に作成することができます。
開発中、オブジェクトの比較用に頻繁に追加 / 削除されるウインドウなので、こういった機能を利用して効率的に操作していきたいですね。

明日は@nissy_gpさんです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む