Parse
mBaas
Swift
XCode7
Swift2.0

Swift2系でParse.comを利用したサンプルアプリ(Chapter3: 登録データをUITableViewに一覧&詳細表示と追加・変更・削除に関する処理)

More than 3 years have passed since last update.

※こちらは自分の備忘録も兼ねて作成していますので、もし紛らわしい表現や記述の誤り等を発見した場合にはコメント欄等でお申し付け頂ければと思います。


前回からの復習:Parse.comでのログイン・ログアウトの実装ができたので実際のコンテンツ部分を作成する

前回の内容はこちらになりますので、まだParseの導入&疎通試験等〜ParseUIを利用した簡易ログイン・ログアウト機能の実装がお済みでない方は下記をご参考にして導入をしてみてください。

前回までは会員専用アプリの土台部分を作成しましたので、今回は実際のコンテンツ部分の作成をしていく形になります。レイアウトの作成に関しては割愛していますが、できるだけ実際のアプリに近しい形にしてみましたので、データ処理部分の実装と併せて余力がありましたら是非ともご参考にして頂ければ幸いです。

(参考)Storyboardの画面遷移図その1:

story_board_capture1.png

(参考)Storyboardの画面遷移図その2:

story_board_capture2.png

今回のサンプルコードの実装でポイントになる部分に関しては、


  • Parseのデータベースから登録データをUITableViewへ一覧表示する部分

  • Parse.comで追加・変更・削除の処理をコメントデータの部分で実現する部分

  • PFFileクラスを使用した画像データの扱い方に関する部分

  • UITableViewCellの高さを可変にするTips

の4点になりますので、メインはこの部分に関して解説していきます。


準備

Parse.comの公式ページにも詳細なリファレンスがありますので、そのリンクを掲載しておきます。かなり詳細に書かれているのですが、SwiftではなくObjective-Cで書かれいますので、適宜Swiftに置き換えて読まなければいけないので少し手間かもしれません。


★実装にあたっての参考資料

ですので今回のサンプルを作るにあたり、追加・変更・削除処理に関しての技術TIPSの記事資料は下記になります。下記の4つはSwiftで書かれていますので、実際に実務等で活用したい場合にも参考にできるかと思います。

僕自身がParse.comを使用してみた雑感ですが、若干クロージャーの部分の記述には最初は戸惑った部分はありましたが、慣れると一度CoreDataやRealm等を使用した経験のある方であればさほど導入はさほど難しくないかと思います。


★画面の設計図

サンプルアプリの概要としましては、カフェの一覧&詳細表示とお気に入りのカフェにコメントをつけられるようなコミュニティーサイトのようなサンプルアプリを作成してみることにしました。

capture_sample_base.png

今回はメイン部分に関する解説になりますが、この他にもチュートリアルを進める中で機能を適宜追加していくような感じにしたいと思います。


★Parse.comのテーブル設計

Parse.comの管理画面でDBの設計は管理画面カラムやデータからできますので、phpMyAdmin等のDB管理ツールに馴染みのある方であれば似たような感覚で操作ができるかと思います。

今回はCafeMasterとCafeTransactionCommentが「1対多」の関係で紐づくような仕様を想定しています。またアソシエーションは特に利用していません。

(参考)CafeMasterテーブルの画面:

cafe_master_capture.png

(参考)CafeMasterテーブル定義:

◎ CafeMaster:カフェ情報マスタ

カラム名
データ型
説明

objectId
String
一意なID

name
String
カフェ名

catchCopy
String
カフェのキャッチコピー

IntroductionDetail
String
カフェの紹介文

CafeImage
File
カフェの画像

commentSum
Number
コメント総数

commentAmount
Number
コメント総評価数

ACL
ACL
アクセスコントロール

createAt
Date
登録日

updateAt
Date
更新日

※ objectId, ACL, createAt, updateAtに関してはあらかじめ設定されています。

(参考)CafeTransactionCommentテーブルの画面:

cafe_transaction_comment_capture.png

(参考)CafeTransactionCommentテーブル定義:

◎ CafeTransactionComment:カフェ情報に紐づくコメント情報

カラム名
データ型
説明

objectId
String
一意なID

CafeMasterObjectId
String
CafeMasterテーブルのID

CafeCommentDetail
String
カフェに関するコメント

CafeCommentStar
Number
カフェの評価(1〜3)

CafeComment
File
コメントの添付画像

commentUsername
String
コメントをつけたユーザー名

ACL
ACL
アクセスコントロール

createAt
Date
登録日

updateAt
Date
更新日

※ objectId, ACL, createAt, updateAtに関してはあらかじめ設定されています。

※ CafeCommentImageの名前は「cafe_comment.png」で統一しています。


★Parse.comの画面操作のおさらい

ここではParse.comの画面の使い方をざっくりと管理画面のキャプチャを元にまとめておきました。

今回のサンプルでは、先ほどのテーブル定義を参考にしてParse.comのCafeMasterへあらかじめCafeマスタデータを登録しておきます。

(参考1)新規テーブル(Class)の追加:

parse_add_operation1.png

新たにテーブルを追加する際は左側にある「Create a class」のボタンを押して、


  • 「What type of class do you need?」にはCustomを選択

  • 「What should we call it?」には任意のクラス名を追加

という手順で簡単に作成できます。

(参考2)新規カラムの追加:

parse_add_operation2.png

新規のカラムを追加する際は画面の一番右にある「Add a new column」のボタンを押すと、上の図のような画面が現れます。


  • 「What type of data do you want to store?」で任意のデータ型を選択(数値や文字列だけでなくファイルや日付等も選択可能です)

  • 「What should we call it?」には任意のカラム名を追加

という流れで追加できます。

(参考3)作成したカラムに値を追加する:

parse_add_operation3.png

追加したカラムに値を追加する場合は上記のように空白の対象カラムの部分をクリックすると、編集可能の状態になるのでそこに値を追加していきます。

parse_add_operation4.png

※上記のように日付や画像アップロードもできます。

このようにWeb上で、DBの管理ツールと同じような感覚でデータの操作が柔軟に行うことができますので、操作に慣れておくと良いかと思います。


1. Cafeマスタ情報を表示する画面(自分で登録したデータを表示する)

ログイン画面後の遷移した先は紹介しているCafeの一覧が表示される形になります。また表示されたセルをタップすると、該当のカフェの詳細情報とそのカフェのコメント一覧を見ることができるような形にしてあります。


★Cafeマスタから情報を取得する

上記の作業が完了しましたら、登録したデータをUITableViewへ表示する処理を書いていきます。今回は日付の新しい順に一覧表示させてなおかつ一番新しい新着情報に関してはUITableViewのセルのデザインを変更するような形にしています。

まずは先ほど登録したCafeマスタ情報をviewWillApperのタイミングで取得します。ここで読んでいるloadParseData()メソッドでは処理が完了したらテーブルを再読み込みしています。

また上の検索バーに検索したい文言を入れると、カフェの紹介文にそのキーワードを含むものだけを選ぶロジックも実装しています。


DisplayController.swift

--- (省略) ---

//データのリロード
func loadParseData() {

//いったん空っぽにしておく
self.cafeDataArray.removeAllObjects()

//parse.comのデータベースからデータを取得する
let query:PFQuery = PFQuery(className: "CafeMaster")

//whereKeyメソッドで検索条件を指定
if !self.targetString.isEmpty {
query.whereKey("IntroduceDetail", containsString: self.targetString)
}

//orderByAscendingでカラムに対して昇順で並べる指定
query.orderByDescending("createdAt")

//制限をかける
query.limit = 20

//クロージャーの中で上記の検索条件に該当するオブジェクトを取得する
query.findObjectsInBackgroundWithBlock {
(objects:[PFObject]?, error:NSError?) -> Void in

//------ クロージャー内の処理:ここから↓ -----
//エラーがないかの確認
if error == nil {

//データが存在する場合はNSMutableArrayへデータを格納
if let objects = objects {

for object in objects {

//取得したオブジェクトをメンバ変数へ格納
self.cafeDataArray.addObject(object)
}

//テーブルビューをリロードする
self.cafeTableView.reloadData()
}

} else {

//異常処理の際にはエラー内容の表示
print("Error: \(error!) \(error!.userInfo)")
}
//------ クロージャー内の処理:ここまで↑ -----
}

}


この処理サンプルに関しての大まかな流れとしては、


  • 取得対象のPFQueryクラスのインスタンスを作成する

  • whereKeyやorderByDescending等の検索条件を指定するメソッド等を用いて絞り込みを行う

  • findObjectsInBackgroundWithBlockメソッド内のクロージャーでUITableViewで使用するNSMutableArray(ほかにも分解してよしなにする用)へオブジェクトを格納する

という流れになります。またUITableViewに表示する際はオブジェクトのキーに該当する値をダウンキャストや型変換を駆使して表示処理をしてあげてください。セルのインデックス値に該当するオブジェクトの型はAnyObject型です。


★セルのIndexPath.rowの値によって読み込むセルを変更する

最近のメディア系の情報アプリ等でもよく見かけるようなスタイルのUIとして、最新のN個の情報を目立たせる為に目立たせたいセルとそれ以外のセルを別なレイアウトにしているアプリもよく見かけるかと思います。

今回は少しそのままでは平凡なので、一番最新の1件だけ目立たせ(FirstDisplayCell:大きな表示のセルを読み込む)、その他は従来に近いオーソドックスなテーブルビューのセルで表示させることをします。

最初に読み込む用とそれ以降に読み込む用のXibファイルとUITableViewCellを継承したクラスを用意して、下記のように実装すればOKです。


DisplayController.swift

//viewDidLoad内のXibの読み込み宣言をする

override func viewDidLoad() {
super.viewDidLoad()

--- (省略) ---

//Xibのクラスを読み込む宣言を行う
let nibFirst:UINib = UINib(nibName: "FirstDisplayCell", bundle: nil)
self.cafeTableView.registerNib(nibFirst, forCellReuseIdentifier: "FirstDisplayCell")

let nibSecond:UINib = UINib(nibName: "SecondDisplayCell", bundle: nil)
self.cafeTableView.registerNib(nibSecond, forCellReuseIdentifier: "SecondDisplayCell")
}

--- (省略) ---

//関連する部分のTableViewの処理
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

if indexPath.row == 0 {

let cell = tableView.dequeueReusableCellWithIdentifier("FirstDisplayCell") as? FirstDisplayCell

//各値をセルに入れる
let cafe : AnyObject = self.cafeDataArray.objectAtIndex(indexPath.row)

-- (データを取得してセルに表示) --

//画像のセルなので特に飾りはなし
cell!.accessoryType = UITableViewCellAccessoryType.None
return cell!

} else {

let cell = tableView.dequeueReusableCellWithIdentifier("SecondDisplayCell") as? SecondDisplayCell

//各値をセルに入れる
let cafe : AnyObject = self.cafeDataArray.objectAtIndex(indexPath.row)

-- (データを取得してセルに表示) --

//セルの右に矢印をつけてあげる
cell!.accessoryType = UITableViewCellAccessoryType.DisclosureIndicator
return cell!
}

}


私は個人的にUITableViewやUICollectionViewを使用するアプリ開発やサンプルを作成する際は、できるだけセルのクラスを細かな単位でひとまとめにしておいてカスタマイズをすることが多いです。

(特にアプリの規模が大きくなったり、大きな人数で開発するような場合にはこちらの方が都合が良い場合もあるかと思います)

またセル単位で細かなカスタマイズが必要になる場合にもこのように分割するような方法は重宝するのではないかと思います。


★PFFileからUIImageへの変換して画像を表示する

このサンプルを作成する上でかなりハマりやすいポイントの一つとして「画像の表示」部分です。CoreDataやRealmで画像データを保存する際は(UIImage型 ⇄ NSData型)の変換をして保存 or 表示処理を行います。

しかしParse.comを使用した場合のファイルはPFFile型の扱いとなっているので、愚直にそのまま変換することができません。まずはセルの画像表示の処理については下記の様になります。


DisplayController.swift

//まずはセルのインデックスに該当する値を取得

let cafe : AnyObject = self.cafeDataArray.objectAtIndex(indexPath.row)

//PFFlie型のデータを該当のカラムから取得する
let dispImgFile: PFFile = (cafe.valueForKey("CafeImage") as? PFFile)!

//getDataInBackgroundWithBlockのクロージャー内でimageDataを抜き出して表示させる
dispImgFile.getDataInBackgroundWithBlock {
(imageData: NSData?, error: NSError?) -> Void in
if error == nil {
let image = UIImage(data: imageData!)
cell!.firstCafeImageView.image = image
}
}


単に取得したデータの型を変換するだけはうまくいかないので、getDataInBackgroundWithBlockを使ってPFFileからNSData?型の画像データを取得してUIImageViewに表示するというフローをとります。

(この逆の処理に関しては追加・変更・削除処理の部分を参照して頂ければと思います)


2. Cafeマスタ情報に紐づくコメント一覧を表示する画面(ユーザーが登録したコメントデータの表示やCafeマスタの評価情報の更新)

次のCommentController.swiftの画面では、カフェの詳細情報とユーザーが投稿したコメントの一覧表示をする画面になります。また自分のコメントは編集ができるようになっています(この場合にはコメント一覧を表示しているテーブルビューのセルに矢印マークが付きます)。

またこちらの画面を表示した際にはコメント一覧テーブルを集計してコメントの総数やユーザー評価の合計値を算出して、カフェ情報マスタの情報の更新も一緒に行います。


★Cafeのコメント情報テーブルからコメント情報を取得する & コメントの総数や合計点数を更新する処理

カフェのコメント一覧を表示する際の処理自体は前述の「★Cafeマスタから情報を取得する

」の処理とほぼ同様ではありますが、この時のクロージャー内の処理の中に、コメントの総数や評価の合計値を算出して更新する処理を加えます。今回の書き方としては更新用の別メソッドを作成して、コメント表示一覧用のデータ処理が終わった後に更新用メソッドを呼び出すような実装をしています。


DisplayController.swift

//データのリロード

func loadParseCommentData() {

//いったん空っぽにしておく
self.cafeCommentArray.removeAllObjects()

//parse.comのデータベースからデータを取得する
let query:PFQuery = PFQuery(className: "CafeTransactionComment")
query.whereKey("CafeMasterObjectId", equalTo: self.targetCafeObjectId)

//orderByAscendingでカラムに対して昇順で並べる指定
query.orderByDescending("createdAt")

//制限をかける
query.limit = 100

//クロージャーの中で上記の検索条件に該当するオブジェクトを取得する
query.findObjectsInBackgroundWithBlock {
(objects:[PFObject]?, error:NSError?) -> Void in

//------ クロージャー内の処理:ここから↓ -----
//エラーがないかの確認
if error == nil {

//データが存在する場合はNSMutableArrayへデータを格納
if let objects = objects {

for object in objects {

//取得したオブジェクトをメンバ変数へ格納
self.cafeCommentArray.addObject(object)
let commentStar: Int = object.valueForKey("CafeCommentStar") as! Int
self.amountStar = self.amountStar + commentStar
}

//テーブルビューをリロードする
self.cafeDetailCommentTable.reloadData()
}

let commentAmount: Int = self.amountStar
let commentSum: Int = self.cafeCommentArray.count as Int

//Debug.
//print(コメント内の総ポイント数: \(commentAmount))
//print(コメント内の総数: \(commentSum))

self.updateCafeMasterAverage(
self.targetCafeObjectId,
targetAmount: commentAmount,
targetSum: commentSum
)

} else {

//異常処理の際にはエラー内容の表示
print("Error: \(error!) \(error!.userInfo)")
}
//------ クロージャー内の処理:ここまで↑ -----
}

}

//カフェマスタのデータを更新
func updateCafeMasterAverage(targetObjectId: String, targetAmount: Int, targetSum: Int) {

//parse.comのデータベースからデータを取得する
let query:PFQuery = PFQuery(className: "CafeMaster")
query.getObjectInBackgroundWithId(targetObjectId, block: { object, error in

object?["commentAmount"] = targetAmount
object?["commentSum"] = targetSum

object?.saveInBackgroundWithBlock { (success: Bool, error: NSError?) -> Void in

//Debug.
//print("CafeMaster Object id: \(targetObjectId) has been updateed.")
}
})
}


アソシエーションがないので若干冗長な処理になっているかもしれませんが、これでカフェ情報に紐づくコメントの一覧を表示する部分を実装することができました。

また今回は前の画面でカフェ情報のデータは前のControllerからSegueを使って渡していますのでこの部分の実装に関しても見ておくと良いかと思います。

※AnyObject型で強引に渡している感は若干ありますが...(^^;)


3. コメントフォーム画面(ユーザーが登録したコメントデータの追加・変更・削除を行う)

Parse.comと連携したアプリでの一番気になる部分になるかと思います。

コメント画面の仕様としては、


  • 一番上の文言が削除の場合と更新の場合で変わる

  • カメラボタンを押すと追加or編集用の写真が表示される

  • 追加の際はゴミ箱のボタンは非活性

  • +ボタンを押すと対象のデータ1件の追加 or 編集が完了する

  • データ編集の場合は前のControllerから渡された値をそれぞれセットした状態にする

  • 編集の際にゴミ箱ボタンを押すと対象のデータが1件削除される

のようになります。今回はカフェ情報のobjectIdに紐づくコメントデータ1件に対する追加・変更・削除の処理に関してですが検索条件等を工夫することでさらに複雑な処理を実装することも可能です。


★コメントの追加&変更

コメントの追加&変更に関しては、下記のような書き方をしています。変更の場合には変更対象のCafeTransactionCommentのobjectIdをキーにして該当のデータを更新し、追加の場合には新規に作成したオブジェクトに入力したデータを渡してあげるだけででき、この場合にはobjectIdは自動で付与されます。追加ボタンのアクションに下記のような処理を記述します。


AddController.swift

--- (省略) ---

//編集モード時
if self.editCommentFlag == true {

//コメントのIDと対応する対象のデータを更新
let query = PFQuery(className: "CafeTransactionComment")
query.getObjectInBackgroundWithId(self.targetObjectID, block: { object, error in

object?["CafeMasterObjectId"] = self.cafeMasterObjectID
object?["CafeCommentDetail"] = self.saveTargetComment
object?["CafeCommentStar"] = self.saveTargetStar
object?["CafeCommentImage"] = self.uploadImageFile
object?["commentUsername"] = PFUser.currentUser()!.username!

object?.saveInBackgroundWithBlock { (success: Bool, error: NSError?) -> Void in

//Debug.
//print("Object id: \(self.targetObjectID) has been updateed.")

if success {
self.dismissViewControllerAnimated(true, completion: nil)
}

}
})

//追加モード時
} else {

//追加対象のデータを投入
let targetObject = PFObject(className: "CafeTransactionComment")

//追加対象のデータを投入
targetObject["CafeMasterObjectId"] = self.cafeMasterObjectID
targetObject["CafeCommentDetail"] = self.saveTargetComment
targetObject["CafeCommentStar"] = self.saveTargetStar
targetObject["CafeCommentImage"] = self.uploadImageFile
targetObject["commentUsername"] = PFUser.currentUser()!.username!

//追加処理
targetObject.saveInBackgroundWithBlock { (success: Bool, error: NSError?) -> Void in

//Debug.
//print("New Object has been added.")

if success {
self.dismissViewControllerAnimated(true, completion: nil)
}
}

}


編集時にはgetObjectInBackgroundWithIdメソッドで第1引数に変更対象のCafeTransactionCommentのobjectIdを入れてクロージャーの中で更新対象のデータを入れる処理を記載することと、新規追加の際にはPFObject(className: "CafeTransactionComment")で空のインスタンスを作った後に追加対象のデータを投入しsaveInBackgroundWithBlockメソッドを実行するという流れを押さえておきましょう。


★コメントの削除

コメントの削除に関しても、処理記述のイメージとしては前述の編集処理とほとんど似たようなイメージで実装できます。削除ボタンのアクションの中に下記のように記述をします。


AddController.swift

//コメントのIDと対応するデータを削除

let query = PFQuery(className: "CafeTransactionComment")
query.getObjectInBackgroundWithId(self.targetObjectID, block: { object, error in

if error == nil {
object?.deleteInBackground()
self.dismissViewControllerAnimated(true, completion: nil)
}
})


削除時にはgetObjectInBackgroundWithIdメソッドで第1引数に変更対象のCafeTransactionCommentのobjectIdを入れて該当のデータを取得し、エラーがない場合にdeleteInBackgroundメソッドを実行して該当のデータを削除するという流れになります。


★UIImageをPFFileへ変換する処理

ここでは1. Cafeマスタ情報を表示する画面(自分で登録したデータを表示する)の部分でも解説した処理とは逆の処理を行います。


AddController.swift

//(226行目の部分)

//UIImage型のデータをNSData型へ変換を行う
let imageData: NSData = UIImagePNGRepresentation(self.saveTargetImage!)!
//さらにNSData型をPFFile型に変換する
self.uploadImageFile = PFFile(name: "cafe_comment.png", data: imageData)!

UIImageViewに表示する時の手順とは異なりますが「UIImage → NSData → PFFile」の順番で処理を行っています。

上記の順番と画像の扱いの部分についてはParse.comを初めて扱った際によくハマった部分の一つです。

実際にParse.comを利用したアプリ開発をする上で、自分も思わずハマってしまったのが


  • UIImage ⇄ PFFileの相互変換処理の部分

  • クロージャーの概念の理解

の2つの部分でした。これから取り組む方は、この辺りのポイントに関しては書籍やParse.comのドキュメント等で事前にある程度予習をしておくと良いかもと思いました。


4. その他Chapter2の補足部分やその他のテクニックに関して

Chapter3では実際のコンテンツを作成したこともあり、上記のParse.comに関する処理の他にもちょっとしたテクニックや前の記事では紹介しなかった部分に関して解説をします。


★Parse.comのアカウントでのログアウト処理

カフェの一覧表示のページの右下にログアウトのボタンを追加しました(ドアの絵文字のボタンです)。ログアウトの処理に関しては下記のように実装します。


AddController.swift

//ログアウトアクション

@IBAction func logoutAction(sender: UIBarButtonItem) {

//ログアウトして戻す
PFUser.logOut()
self.dismissViewControllerAnimated(true, completion: nil)
}


ログイン時の処理を作成するのとは打って変わってこれだけでOKです。


★AutoLayoutを用いて高さが可変のUITableViewCellを作成する

コメント部分のセルに関してはコメントの高さを固定にしてしまうと、長すぎるとコメントの内容が途中で切れてしまったり、また短すぎると余白が必要以上に空いてしまったりとかなり不格好になってしまいます。

そこでAutoLayoutを活用すると、高さを可変にする処理がInterfaceBuilderのAutoLayoutの設定と若干のコードの記述で実現することができます。

Storyboardでの設定とコードのポイント:

table_view_auto_layout.png

Label部分の設定のポイントはAutoLayoutで上下左右の間だけを追加して幅と高さの設定を行わないことと右ペインのLinesの値を0にするところがポイントです。


AddController.swift

override func viewDidLoad() {

super.viewDidLoad()

--- (省略) ---

//自動計算の場合は必要
//※この場合は func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat は必要ない
self.cafeDetailCommentTable.estimatedRowHeight = 44.0
self.cafeDetailCommentTable.rowHeight = UITableViewAutomaticDimension
}


viewDidLoad内は上記のように記述します。

(参考)AutoLayoutに関する参考資料:

(参考)UITableViewのセルを可変にする際の参考資料:

AutoLayoutを使わないと結構実現するのは面倒くさい処理の一つでもあるので、個人的に今まで苦手意識があったけれどもAutoLayoutは積極的に活用していこうと感じました。


おわりに:次回の予告

今回の処理は結構レイアウト等も込みで作成していく部分になりますので、結構期間が空いてしまったのでかなり詰め込んだ内容になってしまいました(すみませんでした!)

mBaaSの要の機能である追加・変更・削除の処理を実際にありそうなサンプルに近しい形の基本形で実装してみましたので私自身もParse.comに関しての技術がより深まったので、次に個人アプリを作成する機会があれば是非とも活用してみようと感じる次第です。

次回は、


  • Chapter4: FacebookやTwitterでのSNSでのシェアや認証機能での連携

をする予定です。

(Parse.comがあと1年後に終了するというアナウンスがありましたので上記の部分に関しては形を変えて行う or 他のmBaaSで同様な解説をしている記事を新たに作成する等違った形でも展開できればと考えております)

※順番的にはとびとびになってゆるい感じになってしまうかもしれませんが何かしらの参考になれば幸いです。


追記とその他

Githubはこちら: PremiumCafeList

2016.01.19:


  • Chaper3までのサンプルを更新しました。Chapter毎のサンプルを確認する場合は以下のコマンドで確認をしてみて下さい。

$ git clone git@github.com:fumiyasac/PremiumCafeList.git

$ git checkout 8e46b4b9c0c23621b5cd74d8a8cf145c74bfcf92

このサンプルについて


  • GithubへのPull Requestならびに要望や改善に関する提案も受け付けていますのでお気軽にどうぞ!