58
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[iOS] [Swift] [Watson] DialogとNatural Language Classifier(自然言語分類)を組み合わせてチャットボットアプリを作ってみた

Last updated at Posted at 2016-07-06

追記(2016.09.10)

[Watson] Dialog Serviceの廃止について
http://qiita.com/y_some/items/de4cdea1b60d36243c0d

概要

曖昧な問いかけに対して、映画のタイトル、または、出演者を答えてくれるチャットボットを作ってみました。

WatsonDialog.gif

動画はこちら

長くなりますが、複数の技術要素を寄せ集めた成果ですので、あえて記事を分割せず一気に解説します。

技術要素と開発環境

Watson

iOS

  • Xcode7.3
  • Swift2.2
  • Carthage
    • JSQMessagesViewController
    • Alamofire
    • SwiftyJSON
    • SVProgressHUD

その他

DBpedia

シーケンス図

スクリーンショット 2016-07-05 6.49.52.png サーバーサイド機能を実装して、iOSはUI部分のみにした方がベターかと思います。

Watson Dialogについて

ネットに日本語情報が少ないので、私も試行錯誤中です。

Dialogの事始めは以下の記事を参照。
[Watson] Dialogの利用開始手順と日本語化の設定

Dialogシナリオ

XML Notepad などのXMLエディタがあると捗ります。

<?xml version="1.0" encoding="utf-8"?>
<dialog xsi:noNamespaceSchemaLocation="WatsonDialogDocument_1.1.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <flow>
    <folder label="Main">
      <!--対話の入口-->
      <output id="output_main">
        <prompt selectionType="RANDOM">
          <!--itemからランダムに出力-->
          <item>知りたいのは映画作品ですか?それとも出演者ですか?</item>
        </prompt>
        <getUserInput>
          <!--ユーザの入力を受け付ける-->
          <search ref="folder_menu_top">
            <!--folder_menu_topを検索する-->
          </search>
          <default>
            <!--ユーザ入力を解釈できなかった場合の処理-->
            <output>
              <prompt selectionType="RANDOM">
                <item>すみません。違う表現でもう一度おねがいします。</item>
              </prompt>
              <goto ref="##special_DNR_GET_USER_INPUT_NODE_ID"></goto>
            </output>
          </default>
        </getUserInput>
      </output>
      <output id="output_main_end">
        <prompt selectionType="RANDOM">
          <item>お役に立てたら嬉しいです。</item>
        </prompt>
        <action varName="TITLE" operator="SET_TO_BLANK">
          <!--変数にBLANKをセットする-->
        </action>
        <action varName="STARRING" operator="SET_TO_BLANK" />
        <action varName="WORK" operator="SET_TO_BLANK" />
      </output>
    </folder>
    <folder label="Library">
      <!--対話の主要部分-->
      <folder id="folder_menu_top">
        <input>
          <grammar>
            <!--語彙を解釈する-->
            <!-- *: 任意の文字列の場合にヒット -->
            <!-- $: 文字列を含む場合にヒット -->
            <item>$映画作品</item>
          </grammar>
          <output>
            <goto ref="folder_title">
              <!--folder_titleに飛ぶ-->
            </goto>
          </output>
        </input>
        <input>
          <grammar>
            <item>$出演者</item>
          </grammar>
          <output>
            <goto ref="folder_starring" />
          </output>
        </input>
      </folder>
      <folder id="folder_title">
        <output>
          <prompt selectionType="RANDOM">
            <item>どんな映画でしたか?</item>
          </prompt>
          <getUserInput>
            <output>
              <!--変数に値をセットする(他にもいろいろなactionノードの定義の仕方あり)-->
              <action varName="WORK" operator="SET_TO_USER_INPUT"></action>
              <!--{変数}という書き方で変数値を取得できる-->
              <action varName="TITLE" operator="APPEND">{WORK} </action>
              <prompt selectionType="RANDOM">
                <!--%NAME%をiOS側でNLCのClassに置換する-->
                <item>それは%NAME%ではありませんか?</item>
              </prompt>
              <getUserInput>
                <input>
                  <grammar>
                    <item>$はい</item>
                    <item>$ありがとう</item>
                  </grammar>
                  <output>
                    <goto ref="output_main_end" />
                  </output>
                </input>
                <input>
                  <grammar>
                    <item>$いいえ</item>
                    <item>$他の</item>
                  </grammar>
                  <output>
                    <goto ref="folder_title" />
                  </output>
                </input>
              </getUserInput>
            </output>
          </getUserInput>
        </output>
      </folder>
      <folder id="folder_starring">
        <output>
          <prompt selectionType="RANDOM">
            <item>どんな映画に出演していましたか?</item>
          </prompt>
          <getUserInput>
            <output>
              <action varName="WORK" operator="SET_TO_USER_INPUT"></action>
              <action varName="STARRING" operator="APPEND">{WORK} </action>
              <prompt selectionType="RANDOM">
                <item>それは%NAME%ではありませんか?</item>
              </prompt>
              <getUserInput>
                <input>
                  <grammar>
                    <item>$はい</item>
                    <item>$ありがとう</item>
                  </grammar>
                  <output>
                    <goto ref="output_main_end" />
                  </output>
                </input>
                <input>
                  <grammar>
                    <item>$いいえ</item>
                    <item>$他の</item>
                  </grammar>
                  <output>
                    <goto ref="folder_starring" />
                  </output>
                </input>
              </getUserInput>
            </output>
          </getUserInput>
        </output>
      </folder>
    </folder>
    <folder label="Global" />
    <folder label="Concepts">
      <concept>
        <!--同じ語彙を定義-->
        <grammar>
          <item>出演者</item>
          <item>俳優</item>
          <item>女優</item>
          <item>役者</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>映画作品</item>
          <item>タイトル</item>
          <item>作品</item>
          <item>名前</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>ありがとう</item>
          <item>どうも</item>
          <item>サンキュー</item>
          <item>さようなら</item>
          <item>じゃあね</item>
          <item>バイバイ</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>はい</item>
          <item>YES</item>
          <item>OK</item>
          <item>大丈夫</item>
          <item>いいね</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>いいえ</item>
          <item>NO</item>
          <item>NG</item>
          <item>ダメ</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>他の</item>
          <item>違う</item>
        </grammar>
      </concept>
    </folder>
  </flow>
  <constants>
    <var_folder name="Home" />
  </constants>
  <variables>
    <var_folder name="Home">
      <!--変数を定義(TEXT以外にも様々なtypeがある)-->
      <var name="TITLE" type="TEXT" />
      <var name="STARRING" type="TEXT" />
      <var name="WORK" type="TEXT" />
    </var_folder>
  </variables>
  <settings>
    <setting name="AUTOLEARN" type="USER">false</setting>
    <setting name="LANGUAGE" type="USER">ja-JP</setting>
    <setting name="RESPONSETIME" type="USER">-2</setting>
    <setting name="MAXAUTOLEARNITEMS" type="USER">4</setting>
    <setting name="NUMAUTOSETRELATED" type="USER">4</setting>
    <setting name="TIMEZONEID" type="USER">Australia/Sydney</setting>
    <setting name="AUTOSETRELATEDNODEID" type="USER">0</setting>
    <setting name="INPUTMASKTYPE" type="USER">0</setting>
    <setting name="CONCEPTMATCHING" type="USER">1</setting>
    <setting name="PARENT_ACCOUNT">en-us-legacy</setting>
    <setting name="PLATFORM_VERSION">10.1</setting>
    <setting name="USE_TRANSLATIONS">2</setting>
    <setting name="USE_SPELLING_CORRECTIONS">2</setting>
    <setting name="USE_STOP_WORDS">2</setting>
    <setting name="USE_CONCEPTS">3</setting>
    <setting name="ENTITIES_SCOPE">3</setting>
    <setting name="DNR_NODE_ID">-15</setting>
    <setting name="MULTISENT">0</setting>
    <setting name="USER_LOGGING">2</setting>
    <setting name="USE_AUTOMATIC_STOPWORDS_DETECTION">0</setting>
  </settings>
  <specialSettings>
    <specialSetting label="DNR Join Statement">
      <variations />
    </specialSetting>
    <specialSetting label="AutoLearn Statement">
      <variations />
    </specialSetting>
  </specialSettings>
</dialog>

今回は使っていませんが、変数の値によって分岐をさせるための<if>ノードなど、他にも様々なノードがあります。
また、<var>ノードのtypeや、<action>ノードで指定できる操作も様々な種類があります。

現状は日本語のドキュメントがないので、詳細はDialog Service Documentation(英語)を参照するしかないと思います。

APIリクエストパラメータ

公式DocumentのAPI Reference(英語)を参照。

レスポンスサンプル(API Referenceからの転載)

Converse(対話)

{
  "response": [
    "Hi this is Watson"
  ],
  "input": "Hi Hello",
  "conversation_id": 11231,
  "confidence": 1,
  "client_id": 4435
}

conversation_id は対話を一意にするIDで、初回のレスポンスで返却されます。
以降のリクエスト時にその値をセットします。
client_id は conversation_id の中で発言者を一意にするためのID(らしい)です。

Get profile variables(変数の取得)

{
  "client_id": 4435,
  "name_values": [
    {
      "name": "topping",
      "value": "cheese"
    }, {
      "name": "size",
      "value": "large"
    }, {
      "name": "size",
      "value": "large"
    }
  ]
}

Watson Natural Language Classifier (NLC) について

私が参考にさせていただいたのは、以下の記事です。
NLCについては他にもネット上にいろいろ情報があると思います。

自然言語分類器 IBM Watson Natural Language Classifier(前編)
自然言語分類器 IBM Watson Natural Language Classifier(後編)

この記事では、トレーニングデータをcurlコマンドでアップロードするよう記述されていますが、現在はGUIでアップロードできる beta toolkit が提供されており、Bluemixダッシュボードから利用できます。

だたし、beta toolkit は米国南部リージョンのみで提供されていますので、NLCサービスの登録先リージョンに注意が必要です。
(2016年7月5日時点)

DialogのシナリオにNLCを統合することもできるらしいですが、今回はそこまで試している余裕がありませんでした。

トレーニングデータ

Wikipediaからトレーニングデータを作ろうと考えました。
Wikipediaからスクレピイングすることは結構な手間ですが、Wikipediaのデータを二次加工しやすい形で提供してくれるDBpediaというサービスがあります。

DBpediaでは、クエリを投げて、その結果をHTMLやXML、CSVなどのフォーマットで受け取ることができます。

DBpediaのクエリを実行するフォーム
http://ja.dbpedia.org/sparql

<出演者名と出演映画の概要を取得するクエリの例>

select distinct ?starring_name ?abstract where {
      <http://ja.dbpedia.org/resource/映画作品一覧> <http://dbpedia.org/ontology/wikiPageWikiLink> ?movie .
      ?movie <http://dbpedia.org/ontology/abstract> ?abstract .
      ?movie <http://ja.dbpedia.org/property/出演者> ?starring .
      ?starring rdfs:label ?starring_name.
}

<実行結果イメージ>
スクリーンショット 2016-07-04 21.16.37.png

NLCのトレーニングデータはCSVですので、フォーマットにCSVを指定すれば、ほぼそのまま使えます。
今回のアプリの例で言うと、上記クエリ結果の映画の概要をtext、出演者名をclassとして使用する訳です。
(Watsonに解釈させるためには、Wikipediaの概要部分だけでは少し情報不足でしたが)

DBpediaの使い方についてのもう少し詳しい情報は、こちらの記事を参照すればよろしいかと。
Wikipedia からスクレイピングして… とか言ってる人におすすめしたい,DBPedia からの情報抽出
DBpediaのある主語の述語プロパティwikiPageWikiLinkの目的語となる文字列を取得するためのSPARQLクエリ

APIリクエストパラメータ

公式DocumentのAPI Reference(英語)を参照。

レスポンスサンプル(API Referenceからの転載)

Classify

{
  "classifier_id": "10D41B-nlc-1",
  "url": "https://gateway.watsonplatform.net/natural-language-classifier/api/v1/classifiers/10D41B-nlc-1/classify?text=How%20hot%20wil/10D41B-nlc-1",
  "text": "How hot will it be today?",
  "top_class": "temperature",
  "classes": [
    {
      "class_name": "temperature",
      "confidence": 0.9998201258549781
    },
    {
      "class_name": "conditions",
      "confidence": 0.00017987414502176904
    }
  ]
}

iOS

ライブラリ管理

Carthageを利用しました。
こちらの記事が参考になります。
Carthage導入

チャットUI(JSQMessagesViewController)

以下の記事を参考にさせていただきました。(ほぼコピペ)
Swiftで超簡単にチャットアプリのUIを作る

プロジェクト作成時、テンプレートに"Single View Application"を選択し、ViewControllerのBaseClassにJSQMessagesViewControllerを指定するだけです。(下記コード参照)
Storyboardはデフォルトのままです。

通信制御およびJSONパース

AlamofireとSwiftyJSONを使用しました。
こちらの記事がわかりやすいと思います。
AlamofireとSwiftyJSONでAPIを叩くチュートリアル

プログレスアニメーション

SVProgressHUDを利用しました。
こちらの記事が参考になります。
[iOS] 超簡単に処理中のUIを出せるSVProgressHUDについて

App Transport Security (ATS) 問題

どうやらWatson APIはiOS 9以降のATSの要件をクリアしていないようで、APIリクエストでエラーが発生しました。
ATSの概要、エラーおよび回避方法は、以下の記事を参照です。
[iOS,Swift] App Transport Security(ATS)を解除する方法

アイコン素材

こちらの素材を利用させていただきました。
ICOOON MONO

コード

あくまで自己学習用なのでエラーハンドリングは甘いです。
アーキテクチャデザインもあえて簡素にしています。

ViewController.swift
import UIKit
import JSQMessages
import Alamofire
import SwiftyJSON
import SVProgressHUD

class ViewController: JSQMessagesViewController {
    
    // MARK: Declaration
    
    var messages: [JSQMessage]?
    var incomingBubble: JSQMessagesBubbleImage!
    var outgoingBubble: JSQMessagesBubbleImage!
    var incomingAvatar: JSQMessagesAvatarImage!
    var outgoingAvatar: JSQMessagesAvatarImage!
    
    // APIリクエスト時に必要となるユニークなDialogID
    let DIALOG_ID = "(実際の値を設定)"
    // WatsonDialogサービスのサービス資格情報
    let DIALOG_USER = "(実際の値を設定)"
    let DIALOG_PASS = "(実際の値を設定)"
    var conversationId: String = ""
    var clientId: String = ""
    
    // Natural Language Classifier(NLC)APIリクエスト時に必要となるユニークなClassifierId
    let CLASSIFIER_ID_TITLE = "(実際の値を設定)"
    let CLASSIFIER_ID_STARRING = "(実際の値を設定)"
    // WatsonNLCサービスのサービス資格情報
    let NLC_USER = "(実際の値を設定)"
    let NLC_PASS = "(実際の値を設定)"
    
    // WatsonのResponse文字列の中で、名前を表示させる固定文字列(この文字列を置換する)
    let REPLACE_STRING = "%NAME%"
    // WatsonDialogの変数名
    let DIALOG_VAR_TITLE = "TITLE"
    let DIALOG_VAR_STARRING = "STARRING"
    
    // MARK: Event
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 自分のsenderId, senderDisokayNameを設定
        self.senderId = "user1"
        self.senderDisplayName = "hoge"
        
        // 吹き出しの設定
        let bubbleFactory = JSQMessagesBubbleImageFactory()
        self.incomingBubble = bubbleFactory.incomingMessagesBubbleImageWithColor(UIColor.jsq_messageBubbleBlueColor())
        self.outgoingBubble = bubbleFactory.outgoingMessagesBubbleImageWithColor(UIColor.jsq_messageBubbleGreenColor())
        
        // アバターの設定
        self.incomingAvatar = JSQMessagesAvatarImageFactory.avatarImageWithImage(UIImage(named: "watson.png")!, diameter: 64)
        self.outgoingAvatar = JSQMessagesAvatarImageFactory.avatarImageWithImage(UIImage(named: "you.png")!, diameter: 64)
        
        // メッセージデータの配列を初期化
        self.messages = []
        
        receiveAutoMessage()
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    // MARK: Override Method
    
    /// Sendボタンが押された時に呼ばれる
    override func didPressSendButton(button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: NSDate!) {
        
        // 新しいメッセージデータを追加する
        let message = JSQMessage(senderId: senderId, displayName: senderDisplayName, text: text)
        self.messages?.append(message)
        
        // メッセジの送信処理を完了する(画面上にメッセージが表示される)
        self.finishReceivingMessageAnimated(true)
        
        // メッセージを受信
        self.receiveAutoMessage()
        
    }
    
    /// アイテムごとに参照するメッセージデータを返す
    override func collectionView(collectionView: JSQMessagesCollectionView!, messageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageData! {
        return self.messages?[indexPath.item]
    }
    
    /// アイテムごとのMessageBubble(背景)を返す
    override func collectionView(collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageBubbleImageDataSource! {
        let message = self.messages?[indexPath.item]
        if message?.senderId == self.senderId {
            return self.outgoingBubble
        }
        return self.incomingBubble
    }
    
    /// アイテムごとにアバター画像を返す
    override func collectionView(collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageAvatarImageDataSource! {
        let message = self.messages?[indexPath.item]
        if message?.senderId == self.senderId {
            return self.outgoingAvatar
        }
        return self.incomingAvatar
    }
    
    /// アイテムの総数を返す
    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return (self.messages?.count)!
    }
    
    // MARK: Private Method
    
    /// 返信メッセージを受信する
    func receiveAutoMessage() {
        SVProgressHUD.show()
        if self.conversationId == "" {
            postDialog("")
        } else {
            let inputText = self.inputToolbar.contentView.textView.text
            self.inputToolbar.contentView.textView.text = ""
            postDialog(inputText)
        }
    }
    
    /// Watson Dialogと対話を行う
    /// - parameter input: 入力テキスト
    func postDialog(input: String) {
        // conversation_idとは複数クライアントから同じDialogにアクセスしても独立した対話になるように割り振られるID
        // conversation_idは初回のAPIアクセス時に返却され、2回目以降の対話ではconversation_idが必須となる
        let params = [
            "conversation_id": self.conversationId,
            "client_id": self.clientId,
            "input": input,
            ]
        Alamofire.request(.POST, "https://gateway.watsonplatform.net/dialog/api/v1/dialogs/\(self.DIALOG_ID)/conversation", parameters: params)
            .authenticate(user: self.DIALOG_USER, password: self.DIALOG_PASS)
            .responseJSON { response in
                if response.result.error == nil {
                    guard let object = response.result.value else {
                        return
                    }
                    let json = JSON(object)
                    print("\n--Debug postDialog json:")
                    print(json)
                    if self.conversationId == "" {
                        self.conversationId = json["conversation_id"].stringValue
                        self.clientId = json["client_id"].stringValue
                    }
                    let messageText = self.editResponse(json["response"].arrayValue)
                    if messageText.containsString(self.REPLACE_STRING) {
                        // 固定文字列が含まれていたらDialogの変数より絞り込み条件を取得しNLCからclassを取得し文字列置換する
                        self.getDialogVariables(messageText)
                    } else {
                        self.dispResponse(messageText)
                    }
                }
        }
    }
    
    /// Response配列のメッセージを編集する
    /// - parameter responseArray: WatsonのResponse要素(配列)
    /// - returns: 編集後の文字列
    func editResponse(responseArray: Array<SwiftyJSON.JSON>) -> String {
        var messageText: String = ""
        for text in responseArray {
            if messageText != "" {
                messageText += "\n"
            }
            messageText += text.string!
        }
        return messageText
    }
    
    /// Watson Dialogの変数(=絞り込み条件)を取得する
    /// - parameter messageText: 表示文字列
    func getDialogVariables(messageText: String) {
        Alamofire.request(.GET, "https://gateway.watsonplatform.net/dialog/api/v1/dialogs/\(self.DIALOG_ID)/profile?client_id=\(self.clientId)")
            .authenticate(user: self.DIALOG_USER, password: self.DIALOG_PASS)
            .responseJSON { response in
                print("\n--Debug getDialogVariables response:")
                print(response.result.value)
                if response.result.error == nil {
                    guard let object = response.result.value else {
                        return
                    }
                    let json = JSON(object)
                    print("\n--Debug getDialogVariables json:")
                    print(json)
                    // Dialogの変数名と値を配列化
                    let variables = json["name_values"].arrayValue
                    var classifierId = ""
                    var filterText = ""
                    for varText in variables {
                        guard let varName = varText["name"].string else {
                            return
                        }
                        if varName == self.DIALOG_VAR_TITLE {
                            // 作品名指定の場合
                            classifierId = self.CLASSIFIER_ID_TITLE
                            if varText["value"].stringValue != "" {
                                filterText = varText["value"].stringValue
                                break
                            }
                        }
                        if varName == self.DIALOG_VAR_STARRING {
                            // 出演者名指定の場合
                            classifierId = self.CLASSIFIER_ID_STARRING
                            if varText["value"].stringValue != "" {
                                filterText = varText["value"].stringValue
                                break
                            }
                        }
                    }
                    self.getClassFromNLC(classifierId, messageText: messageText, filterText: filterText)
                }
        }
    }
    
    /// NLCのResponseを取得する
    /// - parameter classifierId: ClassifierID
    /// - parameter messageText: 表示文字列
    /// - parameter filterText: フィルタ文字列
    func getClassFromNLC(classifierId: String, messageText: String, filterText: String) {
        let params = [
            "text": filterText,
            ]
        Alamofire.request(.GET, "https://gateway.watsonplatform.net/natural-language-classifier/api/v1/classifiers/\(classifierId)/classify", parameters: params)
            .authenticate(user: self.NLC_USER, password: self.NLC_PASS)
            .responseJSON { response in
                print("\n--Debug getClassFromNLC response:")
                print(response.result.value)
                if response.result.error == nil {
                    guard let object = response.result.value else {
                        return
                    }
                    let json = JSON(object)
                    print("\n--Debug getClassFromNLC json:")
                    print(json)
                    let classJson = json["classes"].arrayValue
                    var dispText = ""
                    if classJson.count > 0 {
                        for i in 0...classJson.count - 1 {
                            // classes配列は確信度の降順に並んでいる(らしい)のでそのまま使う
                            if i > 2 {
                                // 3件超は長くなりすぎるので
                                break
                            }
                            if dispText != "" {
                                dispText += "または"
                            }
                            dispText += ("「" + classJson[i]["class_name"].stringValue + "」")
                        }
                    }
                    let s = messageText.stringByReplacingOccurrencesOfString(self.REPLACE_STRING, withString: dispText)
                    self.dispResponse(s)
                }
        }
    }
    
    /// Responseをチャットに表示する
    /// - parameter responseText: 表示文字列
    func dispResponse (responseText: String) {
        let message = JSQMessage(senderId: "user2", displayName: "underscore", text: responseText)
        self.messages?.append(message)
        self.finishReceivingMessageAnimated(true)
        SVProgressHUD.dismiss()
    }   
}

Githubリポジトリ

まとめ

  • 今年日本語化対応が行われたWatsonですが、まだ日本語の解析精度は低いと感じます。今後の精度向上に期待します。
  • Dialogの日本語情報が相変わらず少なくて苦戦しました。こちらも公式ドキュメントの日本語化が待たれます。
  • 『映画のタイトル、または、出演者を答えてくれる』というのは、題材としてはイマイチかも知れません。(アイデア不足 :sweat_smile: )NLCの用途というのは自然言語の意図を汲み取ることであって、データベースのような使い方ではないのかも。
  • iOS部分の実装は各種ライブラリのおかげでカンタンでした♪
58
61
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
58
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?