4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity+ARCore】位置情報から飲食店に空席状況を表示させるには | 後編

Last updated at Posted at 2024-11-13

作ったサービス

目次
1.本記事でできること
2.動作環境
3.システム構成図
4.サーバー設定
5.飲食店検索
6.空席状況をスクレイピング
7.特定の位置にオブジェクトを表示させる
8.飲食店アイコン表示
9.Unityで位置情報を取得
10.まとめ

本記事でできること

こんにちは。位置情報から飲食店に空席状況をAR空間に表示させるサービスを作ってます。後編では前編でUnityとARCore Geospatial APIを使い表示させたアイコンに飲食店の空席状況を反映させる方法を紹介します。

動作環境

AR環境

・Unity 2022.3.49f1
・ARFoundation 5.1.5
・Google ARCore XR Plugin
・ARCore Extensions: 1.46.0

実行デバイス

Galaxy S22 SC-51C

サーバー環境

Flask フレームワーク
ngrok ローカルサーバー

システム構成図

構成図.png

サーバー設定

Flaskセットアップ

Flaskサーバーを簡単にセットアップする方法を紹介します。Flaskは、軽量で使いやすいフレームワークであり、簡単にAPIエンドポイントを定義できます。

必要な環境

1. Pythonのインストール

FlaskはPython 3.xで動作します。もしまだPythonがインストールされていない場合は、以下のリンクから最新版をダウンロードしてください。

2. Flaskのインストール
以下のコマンドでFlaskをインストール。

pip install flask

3.CORSの設定

他のドメインからAPIを利用する場合、CORSを設定する必要があります。

pip install flask-cors

4.サーバー実装

from flask import Flask, request, jsonify
from flask_cors import CORS

# Flaskアプリケーションのインスタンスを作成
app = Flask(__name__)

# CORSを有効にする
CORS(app)  # これで全てのエンドポイントにCORSを設定できます

@app.route('/api/get_restaurants', methods=['POST'])
def get_restaurants():
    # リクエストのJSONデータを取得
    data = request.get_json()
    latitude = data.get("latitude")  # 緯度
    longitude = data.get("longitude")  # 経度
    range_value = data.get("range_value")  # 検索範囲

    # 飲食店情報を取得する処理(現在は緯度、経度に関係なく仮のデータを返します)
    restaurant_data = search_restaurants(latitude, longitude, range_value)

    # JSON形式でレスポンスを返す
    return jsonify(restaurant_data)

def search_restaurants(latitude, longitude, range_value):
    # 今は仮のデータを返す
    return {
        "restaurants": [
            {"name": "Sushi Bar", "availability": "空席あり"},
            {"name": "Ramen House", "availability": "空席なし"},
            {"name": "Cafe", "availability": "空席あり"}
        ]
    }

if __name__ == '__main__':
    app.run(debug=True)

説明
search_restaurants関数は、飲食店の空席情報を取得するためのロジックです。ここでは仮のデータを使用していますが、後ほど実際のAPIやデータベースに置き換えることで、リアルな情報を取得することができます。range_valueは、飲食店の検索範囲を決定するために使用します。これはグルメサーチAPIのクエリパラメータに組み込むことができます。

HTTP通信

UnityからFlaskサーバーにデータを送信するには、UnityWebRequestを使用します。以下は、Unity側のサンプルスクリプトです。

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class LocationSender : MonoBehaviour
{
    public string serverUrl = "http://localhost:5000/api/get_restaurants";
    public double latitude = 35.6895; //現在位置の緯度
    public double longitude = 139.6917;//現在位置の経度
    public int rangeValue = 1;

    void Start()
    {
        // コルーチンを開始
        StartCoroutine(SendLocationToServer(latitude, longitude, rangeValue));
    }

    public IEnumerator SendLocationToServer(double latitude, double longitude, int rangeValue = 1)
    {

            string jsonData = $"{{\"latitude\": {latitude}, \"longitude\": {longitude}, \"range_value\": {rangeValue}}}";

            UnityWebRequest request = new UnityWebRequest(serverUrl, "POST");
            byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonData);
            request.uploadHandler = new UploadHandlerRaw(bodyRaw);
            request.downloadHandler = new DownloadHandlerBuffer();
            request.SetRequestHeader("Content-Type", "application/json");


            // サーバーにリクエストを送信
            yield return request.SendWebRequest();

            // エラーチェック
            if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
            {
                Debug.LogError("Error sending location data: " + request.error);
            }
            else
            {
                Debug.Log("Location data sent successfully: " + request.downloadHandler.text);
                // レスポンスデータを処理する
                ProcessRestaurantData(request.downloadHandler.text);
            }

        }
    }

    private void ProcessRestaurantData(string jsonResponse)
    {
        // サーバーからのレスポンスをパース
        Debug.Log("Received restaurant data: " + jsonResponse);
    }
}

説明
serverUrl FlaskサーバーのURLです。localhostを使用してローカル環境で動作させていますが、デプロイ後はサーバーのURLに変更します。(後ほどngrokを使用してホスト)
UnityWebRequestUnityからサーバーへHTTPリクエストを送信するために使用します。POSTメソッドを使用し、JSON形式でデータを送信しています。
ProcessRestaurantDataサーバーから受け取った飲食店情報を処理する関数です。ここで、レスポンスデータを解析してUnityのUIに表示したりできます。

Unityでの実行結果

スクリーンショット 2024-11-11 133731.png

APIを使って飲食店検索

1.グルメサーチAPI
ホットペッパーグルメが提供する近くの飲食店を検索できるAPIです。

2.Yahoo!ジオコーダAPI
住所から緯度・経度を取得するために、Yahoo!のジオコーダAPIを使用します。このAPIを使うことで、グルメサーチAPIから取得した住所を基に、飲食店の緯度・経度を特定できます。

# グルメサーチAPIからレストラン情報を取得する関数
import requests
import urllib.parse

# グルメサーチAPIからレストラン情報を取得
async def search_restaurants(current_latitude, current_longitude, range_value):
    api_key = 'YOUR_API_KEY'  # ここにAPIキーを入力
    range_values = {1: (0.3, 30), 2: (0.5, 60), 3: (1.0, 100)}  # 範囲ごとの設定

    threshold, count = range_values.get(range_value, (1.0, 30))

    query = {
        'key': api_key,
        'lat': current_latitude,
        'lng': current_longitude,
        'range': range_value,
        'start': 0,
        'count': count,
        'format': 'json'
    }
    
    url = 'https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?' + requests.compat.urlencode(query)

    try:
        response = requests.get(url)
        response.raise_for_status()  # リクエストエラーがあれば例外を発生させる
        json_response = response.json()

        restaurants_info = []
        for shop in json_response.get('results', {}).get('shop', []):
            restaurant_name = shop.get("name")
            address = shop.get("address")
            lat, lng = get_lat_lng_by_address(address)  # Yahoo!ジオコーダAPIで座標取得

            restaurant = {
                "name": restaurant_name,
                "lat": lat,
                "lng": lng,
                "genre": shop.get("genre", {}).get("name"),
            }

            restaurants_info.append(restaurant)

        return {"status": "success", "data": restaurants_info}

    except requests.exceptions.RequestException as e:
        print(f"Error fetching data: {e}")
        return {"status": "error", "message": str(e)}

# Yahoo!ジオコーダAPIを使って住所から緯度経度を取得
def get_lat_lng_by_address(address):
    url = "https://map.yahooapis.jp/geocode/V1/geoCoder"
    encoded_address = urllib.parse.quote(address)
    client_id = "YOUR_CLIENT_ID"  # Yahoo!のClient IDを挿入

    request_url = f"{url}?appid={client_id}&query={encoded_address}&output=json&level=4"

    try:
        response = requests.get(request_url)
        response.raise_for_status()
        json_response = response.json()
        coordinates = json_response.get('Feature', [{}])[0].get('Geometry', {}).get('Coordinates', '')

        if coordinates:
            lng, lat = map(float, coordinates.split(','))
            return lat, lng
        else:
            return None, None  # 座標が取得できなかった場合

    except requests.exceptions.RequestException as e:
        print(f"Error fetching coordinates: {e}")
        return None, None

search_restaurants関数
この関数は、指定した緯度・経度を中心に、range_value(1: 0.3km, 2: 0.5km, 3: 1km)の範囲内でレストランを検索します。requests.get()でグルメサーチAPIにリクエストを送り、レストラン情報を取得します。詳しくはグルメサーチAPIの公式情報をみてください。

get_lat_lng_by_address関数
この関数は、Yahoo!ジオコーダAPIを使って住所から緯度・経度を取得します。住所をエンコードし、APIにリクエストを送ることで、座標情報を取得します。

これでAPIの使用により取得したデータは、名前、住所、料理ジャンル、緯度・経度を含む飲食店リストとしてUnityに返すことができます。

空席状況をスクレイピング(食べログ)

スクレイピング(Webスクレイピング)と呼ばれるデータ収集法のこと

食べログの利用規約では、「スクレイピングを禁止する」と明記されていませんが負荷をかける行為や収集したデータの取扱には注意してください。

食べログの転用・転売の禁止
[1]お客様は、当社が提供する食べログについて、その全部あるいは一部を問わず、営業活動その他の営利を目的とした行為又はそれに準ずる行為やそのための準備行為を目的として、利用又はアクセスしてはならないものとします。また、その他、宗教活動、政治活動などの目的での利用又はアクセスも行ってはならないものとします。
[2]食べログへ投稿された口コミを無断転載・無断利用することは禁止します。ただし、当該投稿をした本人は除きます。
[3]口コミを投稿した本人による当該口コミの利用等本規約が特に認めた場合を除き、食べログに掲載されている口コミを利用して利益を得た場合には、当社はその利益相当額の金員を請求できる権利を有するものとします。
引用:食べログ利用規約

※現在は食べログからのスクレイピングをやめ、別の方法を検討中です。理由は以下に記載します。

主な使用ライブラリ

  1. playwright (Playwright Async API)
    Playwrightは、Webブラウザの自動化ツールで、ページの操作、データの取得、スクリーンショットのキャプチャなどを効率的に行うことができます。今回のスクレイピングはこのライブラリを使います。

  2. romkan (特に to_katakana, to_hiragana)
    romkanライブラリは、ローマ字をカタカナやひらがなに変換するためのライブラリ。
    2024年10月の時点では、Python3.xではインストールできないのでライブラリをコピーして使用することを推奨します。
    https://github.com/soimort/python-romkan/tree/master

  3. max 関数
    複数の一致率(元の名前、カタカナ、ひらがな)を計算した後、最も一致率が高いものを選択するためにmax関数を使用しています。これにより、最も適切な一致を選ぶことができます。


import asyncio
import urllib.parse
from datetime import datetime
from playwright.async_api import async_playwright
from romkan.common import to_katakana, to_hiragana  # カタカナとひらがな変換をインポート

# 食べログの店舗URLを取得する関数
async def get_restaurant_url(restaurant_name):
    encoded_name = urllib.parse.quote(restaurant_name)
    search_url = f"https://tabelog.com/rst/rstsearch/?sk={encoded_name}"

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        await page.goto(search_url)
        await page.wait_for_timeout(1000)

        restaurant_elements = await page.query_selector_all('.list-rst__rst-name-target.cpy-rst-name')
        
        if len(restaurant_elements) == 1:
            restaurant_url = await restaurant_elements[0].get_attribute('href')
        else:
            restaurant_url = await get_best_match_url(restaurant_elements, restaurant_name)

        await browser.close()
    return restaurant_url

# 店舗名との一致率に基づきURLを取得
async def get_best_match_url(restaurant_elements, restaurant_name):
    best_match_ratio = 0.0
    restaurant_url = None

    for restaurant in restaurant_elements:
        restaurant_text = await restaurant.inner_text()

        # 英語の店名に対してカタカナとひらがなに変換して一致率を計算
        katakana_name = to_katakana(restaurant_name)
        hiragana_name = to_hiragana(restaurant_name)

        # 一致率を計算
        match_ratio = max(
            get_match_ratio(restaurant_name, restaurant_text),
            get_match_ratio(katakana_name, restaurant_text),
            get_match_ratio(hiragana_name, restaurant_text)
        )

        if match_ratio > best_match_ratio:
            best_match_ratio = match_ratio
            restaurant_url = await restaurant.get_attribute('href')

    if best_match_ratio >= 0.3:
        print(f"[DEBUG] 最良一致率: {best_match_ratio:.2f} - URL: {restaurant_url}")
    else:
        print("[DEBUG] レストランが見つかりませんでした。")
    
    return restaurant_url

# 一致率を計算する関数
def get_match_ratio(name_variant, restaurant_text):
    common_chars = set(name_variant) & set(restaurant_text)
    return len(common_chars) / min(len(name_variant), len(restaurant_text))

# 空席情報を取得する関数
async def get_availability(restaurant_name):
    # 店舗URLを取得
    restaurant_url = await get_restaurant_url(restaurant_name)

    if not restaurant_url:
        return {"availability": "Unknown", "peoplefilter": []}

    # 食べログ店舗ページに移動して空席情報を取得
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        try:
            await page.goto(restaurant_url)

            # 日付と空席情報を取得
            date_elements = await page.query_selector_all('.p-booking-calendar__select-date-inner em')
            if len(date_elements) < 2:
                return {"availability": "Unknown", "peoplefilter": []}

            extracted_date = f"{(await date_elements[0].inner_text()).strip()} {(await date_elements[1].inner_text()).strip()}"
            if extracted_date != datetime.now().strftime('%m %d'):
                return {"availability": "Unknown", "peoplefilter": []}

            # 空席の選択肢を取得
            select_element = await page.wait_for_selector('.js-svt')
            options = await select_element.query_selector_all('option')
            first_option_text = await options[0].inner_text()

            if '' in first_option_text:
                return {"availability": "Kuuseki", "peoplefilter": [await opt.inner_text() for opt in options[:10]]}
            elif '×' in first_option_text:
                return {"availability": "Manseki", "peoplefilter": []}
            else:
                return {"availability": "Unknown", "peoplefilter": []}

        except Exception as e:
            print(f"Error: {e}")
            return {"availability": "Unknown", "peoplefilter": []}
        finally:
            await browser.close()  

店舗名に基づくURL取得
入力された店舗名をURLエンコードし、食べログの検索結果ページにアクセス。
店舗名と一致するリンクを取得。1件の場合は直接URLを、複数の場合は一致率を計算して最も一致するURLを選択。(グルメサーチの店舗名が英表記で食べログの店舗名が日本語の場合もあるのでromkanを使う)

空席情報の取得
店舗のURLに移動し、空席情報や日付情報を取得。
現在の日付と一致する空席情報を抽出し、「空席」や「満席」などのステータスを返す。

  • スクレイピングをやめた3つの主な理由:
    • サーバーにかなり負荷をかけてしまうため
    • 空席状況を公開している飲食店自体が少ないため、効果が薄い
    • このサービスではスクレイピングは短期的な解決策にしかならない

空席状況の取得方法については、飲食店に協力してもらい、独自のフォーマットで情報を提供してもらう方針が理想的だと考えていましたが、時間や金銭的な都合上、今回は実現できませんでした。

AR空間に飲食店アイコン表示

次にUnityで用意した飲食店アイコンのプレファブにサーバーから飲食店情報を持たせてAR空間にスポーンします。
スクリーンショット 2024-11-11 164542.png

スクリプト

RestaurantSpawner

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using Newtonsoft.Json;

public class RestaurantSpawner : MonoBehaviour
{
    // サーバーのURL
    public string serverUrl = "YOUR_SERVER_URL";
    
    // プレハブとして使う飲食店アイコンのオブジェクト
    public GameObject RestaurantIconPrefab;
    
    // Anchorや位置情報に基づくためのマネージャー
    public AnchorManager anchorManager;
    public EarthManager earthManager;
    
    // デバイスの現在の緯度と経度、検索範囲の変数
    private double latitude = 0.0;
    private double longitude = 0.0;
    private int rangeValue = 1;

    // デバイスの位置情報を使ってサーバーにリクエストを送信
    public IEnumerator SendLocationToServer(double latitude, double longitude, int rangeValue = 1)
    {
        // JSON形式で緯度、経度、検索範囲をまとめる
        string jsonData = $"{{\"latitude\": {latitude}, \"longitude\": {longitude}, \"range_value\": {rangeValue}}}";
        UnityWebRequest request = new UnityWebRequest(serverUrl, "POST");
        byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonData);
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        // サーバーにリクエスト送信
        yield return request.SendWebRequest();
        if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
        {
            Debug.LogError("Error sending location data: " + request.error);
        }
        else
        {
            ProcessRestaurantData(request.downloadHandler.text); // レスポンスデータ処理
        }
    }

    // サーバーから取得したレスポンスデータを処理して、飲食店の情報をログ出力
    private void ProcessRestaurantData(string jsonResponse)
    {
        var response = JsonConvert.DeserializeObject<RestaurantResponse>(jsonResponse);
        if (response?.Data != null)
        {
            response.Data.ForEach(restaurant =>
            {
                Debug.Log($"名前: {restaurant.Name}, ジャンル: {restaurant.Genre}, 空席状況: {restaurant.Availability}");
                SpawnRestaurantAtLocation(restaurant); // 飲食店情報をもとにアイコンを生成
            });
        }
        else
        {
            Debug.Log("データが見つかりませんでした。");
        }
    }

    // 飲食店情報をもとに指定位置にアイコンをスポーン
    private void SpawnRestaurantAtLocation(Restaurant restaurant)
    {
        if (!restaurant.Latitude.HasValue || !restaurant.Longitude.HasValue) return;

        // 位置情報をもとにAnchorを追加し、その位置にアイコンをスポーン
        var anchor = anchorManager.AddAnchor(restaurant.Latitude.Value, restaurant.Longitude.Value,
                                             earthManager.CameraGeospatialPose.Altitude + 1.5, Quaternion.identity);
        var spawnedObject = Instantiate(RestaurantIconPrefab, anchor.transform);
        spawnedObject.name = restaurant.Name;

        // 飲食店情報を持つRestaurantInfoコンポーネントを追加
        var restaurantInfo = spawnedObject.AddComponent<RestaurantInfo>();
        restaurantInfo.Name = restaurant.Name;
        restaurantInfo.Genre = restaurant.Genre;
        restaurantInfo.Availability = restaurant.Availability;
        restaurantInfo.PeopleFilter = restaurant.PeopleFilter;
    }
}

// 飲食店情報の構造体
[System.Serializable]
public class RestaurantInfo : MonoBehaviour
{
    public string Name, Genre, Availability;
    public List<string> PeopleFilter { get; set; }
}

// サーバーからのレスポンスデータ構造
public class RestaurantResponse
{
    public string Status { get; set; }
    public List<Restaurant> Data { get; set; }
}

// 飲食店データの構造
public class Restaurant
{
    public string Name, Genre, Availability;
    public double? Latitude, Longitude;
    public List<string> PeopleFilter { get; set; }
}

SendLocationToServer
デバイスの緯度・経度(latitudeとlongitude)および範囲(rangeValue)をJSON形式でサーバーに送信し、サーバーから飲食店情報を取得します。

ProcessRestaurantData
サーバーからのレスポンスをJSON形式で受け取り、各飲食店の情報(名前、ジャンル、空席状況など)をデシリアライズして出力します。その後、SpawnRestaurantAtLocationメソッドを使ってAR空間上にアイコンを配置します。

SpawnRestaurantAtLocation
飲食店の位置(緯度と経度)に基づいてAnchorを設定し、指定位置にRestaurantIconPrefabを生成して飲食店情報を表示します。

RestaurantIconPrefabにつけて飲食店情報を反映

IconScript
//サンプルスクリプト(自由に変えてください)
using TMPro;

public class IconScript : MonoBehaviour
{
    void Start()
    {
        var restaurantInfo = GetComponent<RestaurantInfo>();
        var textMeshPro = GetComponentInChildren<TextMeshPro>();

        if (restaurantInfo != null && textMeshPro != null)
            textMeshPro.text = restaurantInfo.Name;  // 店舗名を表示
    }
}

プレファブにRestaurantInfoという構造体をつけスポーンしたのでその構造体に入っているアイコンの飲食店の名前をテキストに表示することができます。

iOS/Androidで位置情報を取得

このサービスでは、ユーザーの位置情報をサーバーに送信し、現在地周辺のお店を効率的に検索する仕組みが必要です。
Unityで位置情報を取得するには、LocationServiceを利用します。これはユーザーの許可を得てデバイスの現在位置を取得するためのものです。Androidでは、位置情報取得の許可をリクエストするためにパーミッションの設定も必要です。

LocationManager
using UnityEngine;
using System.Collections;
#if UNITY_ANDROID
using UnityEngine.Android;
#endif

public class LocationManager : MonoBehaviour
{
    IEnumerator StartLocationService()
    {
        // Androidの場合、パーミッションの確認とリクエスト
        if (Application.platform == RuntimePlatform.Android)
        {
            if (!Permission.HasUserAuthorizedPermission(Permission.FineLocation))
            {
                Permission.RequestUserPermission(Permission.FineLocation);
                while (!Permission.HasUserAuthorizedPermission(Permission.FineLocation))
                {
                    yield return null;
                }
            }
        }
        // iOSの場合
        else if (Application.platform == RuntimePlatform.IPhonePlayer)
        {
            // iOSでは位置情報アクセスが必要な際、システムが自動で許可を求めるため、ここでは追加処理は不要。
        }

        // 位置情報が有効か確認
        if (!Input.location.isEnabledByUser)
        {
            Debug.Log("ユーザーが位置情報サービスを無効にしています。");
            yield break;
        }

        // 位置情報サービスを開始
        Input.location.Start();

        // サービスが初期化されるのを最大10秒間待つ
        int maxWait = 10;
        while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0)
        {
            yield return new WaitForSeconds(1);
            maxWait--;
        }

        // タイムアウトした場合
        if (maxWait <= 0)
        {
            Debug.Log("位置情報サービスの初期化がタイムアウトしました。");
            yield break;
        }

        // 位置情報取得に失敗した場合
        if (Input.location.status == LocationServiceStatus.Failed)
        {
            Debug.Log("位置情報を取得できませんでした。");
            yield break;
        }

        // 位置情報取得が成功した場合
        StartCoroutine(UpdateLocationData());
    }

    IEnumerator UpdateLocationData()
    {
        while (Input.location.status == LocationServiceStatus.Running)
        {
            double currentLatitude = Input.location.lastData.latitude;
            double currentLongitude = Input.location.lastData.longitude;

            Debug.Log("Latitude: " + currentLatitude + ", Longitude: " + currentLongitude);
            yield return new WaitForSeconds(5f);
        }
    }

    void Start()
    {
        StartCoroutine(StartLocationService());
    }
}

RestaurantSpawnerスクリプトと組み合わせることで現在の位置情報を使い近くの飲食店のアイコンを表示できるようになります。

工夫するべきこと

ARでのアイコン表示に関しては、単純に位置情報をもとにアイコンを表示するだけでなく、アイコンが重ならないように工夫が必要です。例えば、複数の飲食店アイコンが近接して表示される場合、ユーザーが快適に閲覧できるように、自動でアイコンの配置を調整する処理が求められます。

さらに、オクルージョン(視界外のオブジェクトを隠す)設定も重要な要素です。近くに複数のアイコンが表示されると、遠くのアイコンが見えにくくなるため、視界に入る範囲だけを表示することで、見やすさを保つこと。
ARはUXやUIの部分にかなり工夫が必要です。

まとめ

自身のサービスでは、何よりも一目で空席状況が分かるというシンプルで直感的なユーザー体験を提供することを最優先にしました。
ARを用いたサービスを開発する上で、情報の整理とその効果的な見せ方が重要であると思います。
お読み頂き誠に有難うございました。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?