たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 4754
どうもこんばんわ。
アオナツラインを完走しました。
ことねちゃんが可愛かったです。続きが気になるから後日談みたいなのが欲しいと思った。
BGMモードでOPの曲が聞けないので初回限定版を買えばよかったかなってちょっと後悔してたりもする。
JetpackComposeでErogameScapeの情報を表示するアプリを作ってみます。
なまえ | あたい |
---|---|
Jetpack Compose | Beta05 |
ErogameScapeっていうエロゲ専門サイトがあるんですが、これSQLを書いてPOSTするとなんとデータが返ってきます。このサイトやばい
SELECT * FROM gamelist WHERE gamename = '彼女のセイイキ'
campaign_game
テーブルなんですが、なんかここのテーブル一覧には乗っていないのですが、他の人が書いたSQLではなぜかセール中のゲームが取得できるんですよね。
それでテーブル一覧を返すSQLを書いて見るとたしかにあるんですね。テーブル一覧のサイトが更新されていないだけかな。
SELECT * FROM pg_tables
JetpackComposeが本題なのに時間がかかってしまった。
ゲームの情報のほか、ブランド名、セール中ならセール情報を表示するようにしました。
SELECT DISTINCT g.id,
g.gamename,
c.content,
c.name,
c.end_timestamp,
g.furigana,
g.sellday,
g.brandname,
b.brandname,
g.model,
COALESCE(g.median, -1) AS median,
COALESCE(g.average2, -1) AS average2,
COALESCE(g.stdev, -1) AS stdev,
COALESCE(g.count2, -1) AS count2,
g.dmm,
COALESCE(g.max2, -1) AS max2,
COALESCE(g.min2, -1) AS min2,
g.shoukai
FROM gamelist g
INNER JOIN brandlist b ON g.brandname = b.id
LEFT OUTER JOIN (
SELECT g.content,
l.name,
g.game,
l.end_timestamp
FROM campaign_game g
INNER JOIN campaignlist l ON g.campaign = l.id
WHERE l.end_timestamp > now()
) c ON g.id = c.game
WHERE g.gamename = 'アオナツライン'
id | gamename | content | name | end_timestamp | furigana | sellday | brandname | brandname | model | median | average2 | stdev | count2 | dmm | max2 | min2 | shoukai |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
27418 | アオナツライン | アオナツライン | 2019-03-29 | 84 | 戯画 | PC | 82 | 80 | 13 | 348 | eg_0012 | 100 | 0 | http://products.web-giga.com/aonatsu/ | |||
29203 | アオナツライン | アオナツライン | 2020-04-23 | 781 | エンターグラム | PS4 | 78 | 78 | 18 | 2 | 90 | 65 | http://www.entergram.co.jp/aonatsu/ | ||||
29204 | アオナツライン | アオナツライン | 2020-04-23 | 781 | エンターグラム | PSV | 87 | 87 | 3 | 3 | 90 | 85 | http://www.entergram.co.jp/aonatsu/ |
セール中ならこうなります。
id | gamename | content | name | end_timestamp | furigana | sellday | brandname | brandname | model | median | average2 | stdev | count2 | dmm | max2 | min2 | shoukai |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
20228 | 彼女のセイイキ | 1,017円 50%OFF | スプリングセール 2021 | 2021-05-10 17:00:00 | カノジョノセイイキ | 2014-12-19 | 702 | feng | PC | 75 | 74 | 9 | 665 | feng_0003 | 100 | 10 | http://www.feng.jp/seiiki/ |
SELECT
は取り出す列を書き出していきます。
FROM
はどこのテーブルから取り出すかです。まずはゲームの情報の入ってるテーブルですね。
それから、INNER JOIN
を利用してブランド名テーブルを結合します。ON
のあとの条件式に当てはまれば結合されます。
複数のテーブルを扱う場合はテーブル名のあとに名前をつけることが出来ます(この例ではg
とかb
とか)
COALESCE
ってのはNULLのときに変わりに表示する値をセットできるやつです。今回はNULLの場合-1
を入れるようにしてあります。
AS 名前
は列の名前を変えるときに使うんですが今回はスクレイピングしちゃうのであんまり関係ない。
LEFT OUTER JOIN
っていうのは、ON
の条件式が当てはまらないときには代わりにNULLを入れてくれるやつです。
INNER JOIN
を使ってしまうとセールしてるときは表示されますが、セールしてないときは表示されなくなってしまいます。
で、LEFT OUTER JOIN
のあとのSELECT
ですが、これはサブクエリって呼ばれるやつでクエリ内で使う値をクエリを使って取り出すときに使います。
SELECT g.content,
l.name,
g.game,
l.end_timestamp
FROM campaign_game g
INNER JOIN campaignlist l ON g.campaign = l.id
WHERE l.end_timestamp > now()
このクエリを実行すると、以下のような内容が返ってきます。campaign_game
とcampaignlist
のテーブルを結合して、セール中であるものを取得するクエリです。
content | name | game | end_timestamp |
---|---|---|---|
1,017円 50%OFF | スプリングセール 2021 | 20228 | 2021-05-10 17:00:00 |
この結果を、LEFT OUTER JOIN
で結合します。上のサンプルではc
をクエリ結果の名前として指定しているため、c.content
でセールの値段を取得することが出来ます。
WHERE g.gamename = 'アオナツライン'
はゲーム名が「アオナツライン」と一致するものを取得するという意味です。
疲れたのであとは各自調べて。
その他使うものとしては
dependencies {
// OkHttp
implementation("com.squareup.okhttp3:okhttp:4.9.1")
// HTML Parser
implementation 'org.jsoup:jsoup:1.13.1'
// CoroutineとLifeCycle考えてくれるやつ
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"
// Glide
implementation 'com.github.bumptech.glide:glide:4.11.0'
// ComposeでLiveData
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
// 以下省略
}
インターネットパーミッションを忘れずに
GameData.kt
/**
* ゲーム情報
*
* https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/sql_for_erogamer_tablelist.php
*
* @param id ID
* @param gamename 名前
* @param brandname_id ブランドのID
* @param brandname ブランド名
* @param content セール情報
* @param endTimeStamp セール終了日時
* @param model PCとかPSVとか
* @param name セール名
* @param furigana 名前のふりがな
* @param sellday 発売日
* @param median 得点中央値
* @param average2 平均値
* @param stdev 標準偏差
* @param count2 得点データ数
* @param dmm DMM(FANZA)のURLの一部
* @param max2 最高得点
* @param min2 最低得点
* @param shoukai オフィシャルHPのURL
* */
data class GameData(
val id: Int,
val gamename: String,
val content: String,
val name: String,
val endTimeStamp: String,
val furigana: String,
val sellday: String,
val brandname_id: Int,
val brandname: String,
val model: String,
val median: Int,
val average2: Int,
val stdev: Int,
val count2: Int,
val dmm: String,
val max2: Int,
val min2: Int,
val shoukai: String,
)
ErogameScape.kt
/**
* https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/sql_for_erogamer_tablelist.php
* */
object ErogameScape {
private val okHttpClient = OkHttpClient()
/**
* ゲーム情報を取得する
*
* @param gameName ゲーム名。「彼女のセイイキ」など
* @return 成功すれば[GameData]の配列(コンシューマー移植などがあるため)
* */
suspend fun getGameInfo(gameName: String) = withContext(Dispatchers.Default) {
// ゲーム情報配列
val gameInfoList = arrayListOf<GameData>()
// POST内容
val sql = """
SELECT DISTINCT g.id,
g.gamename,
c.content,
c.name,
c.end_timestamp,
g.furigana,
g.sellday,
g.brandname,
b.brandname,
g.model,
COALESCE(g.median, -1) AS median,
COALESCE(g.average2, -1) AS average2,
COALESCE(g.stdev, -1) AS stdev,
COALESCE(g.count2, -1) AS count2,
g.dmm,
COALESCE(g.max2, -1) AS max2,
COALESCE(g.min2, -1) AS min2,
g.shoukai
FROM gamelist g
INNER JOIN brandlist b ON g.brandname = b.id
LEFT OUTER JOIN (
SELECT g.content,
l.name,
g.game,
l.end_timestamp
FROM campaign_game g
INNER JOIN campaignlist l ON g.campaign = l.id
WHERE l.end_timestamp > now()
) c ON g.id = c.game
WHERE g.gamename = '$gameName'
"""
val formData = FormBody.Builder().apply {
add("sql", sql)
}.build()
val request = Request.Builder().apply {
url("https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/sql_for_erogamer_form.php")
post(formData)
}.build()
// POSTリクエストを飛ばす
val response = okHttpClient.newCall(request).execute()
// スクレイピング
if (response.isSuccessful) {
val document = Jsoup.parse(response.body!!.string())
val trElementList = document.getElementsByTag("tr")
repeat(trElementList.size - 1) {
// テーブル一行目はいらない
val index = it + 1
val trElement = trElementList[index]
val tdList = trElement.getElementsByTag("td")
val id = tdList[0].text().toInt()
val gamename = tdList[1].text()
val content = tdList[2].text()
val name = tdList[3].text()
val end_time_stamp = tdList[4].text()
val furigana = tdList[5].text()
val sellday = tdList[6].text()
val brandname_id = tdList[7].text().toInt()
val brandname = tdList[8].text()
val model = tdList[9].text()
val median = tdList[10].text().toInt()
val average2 = tdList[11].text().toInt()
val stdev = tdList[12].text().toInt()
val count2 = tdList[13].text().toInt()
val dmm = tdList[14].text()
val max2 = tdList[15].text().toInt()
val min2 = tdList[16].text().toInt()
val shoukai = tdList[17].text()
// データクラスへ
val gameData = GameData(
id,
gamename,
content,
name,
end_time_stamp,
furigana,
sellday,
brandname_id,
brandname,
model,
median,
average2,
stdev,
count2,
dmm,
max2,
min2,
shoukai,
)
gameInfoList.add(gameData)
}
}
return@withContext gameInfoList
}
}
MainViewModel.kt
通信はここでやってる。
/**
* [MainActivity]で使うViewModel
* */
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val context = application.applicationContext
private val _gameInfoLiveData = MutableLiveData<GameData>()
/** 結果を送信するLiveData */
val gameInfoLiveData = _gameInfoLiveData as LiveData<GameData>
/**
* ゲームの情報を取得する関数
* @param gameName ゲーム名。「彼女のセイイキ」など
* */
fun getGameInfo(gameName: String) {
viewModelScope.launch {
val data = ErogameScape.getGameInfo(gameName)
if (data != null) {
_gameInfoLiveData.postValue(data!!)
}
}
}
}
ViewModelで取得したデータを表示するようにしましょう。
検索ボックスと、インターネット上の画像を表示するための部品を
GameInfoUI.kt
/**
* 検索ボックス
* */
@Composable
fun SearchBox(
searchText: String,
onChangeSearchText: (String) -> Unit,
onClickSearchButton: () -> Unit,
) {
Card(
modifier = Modifier.padding(5.dp),
elevation = 10.dp,
shape = RoundedCornerShape(10.dp)
) {
Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
TextField(
modifier = Modifier.weight(1f),
trailingIcon = {
IconButton(onClick = { onClickSearchButton() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_search_24),
contentDescription = "search"
)
}
},
value = searchText,
onValueChange = { text -> onChangeSearchText(text) },
placeholder = { Text(text = "ギャルゲ、エロゲのタイトルを入力") },
maxLines = 1,
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)
)
}
}
}
/**
* インターネット上の画像を表示する
* */
@Composable
fun InternetImage(url: String) {
val bitmap = remember { mutableStateOf<Bitmap?>(null) }
Glide.with(LocalContext.current).asBitmap().load(url).into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
bitmap.value = resource
}
override fun onLoadCleared(placeholder: Drawable?) {
}
})
if (bitmap.value != null) {
Image(
bitmap = bitmap.value!!.asImageBitmap(),
contentDescription = "写真",
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.width(200.dp)
.height(200.dp)
)
}
}
/**
* 二段のText。上の段のほうが文字が大きい
* */
@Composable
fun RankText(modifier: Modifier, title: String, value: String) {
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
textAlign = TextAlign.Center,
)
Text(text = title)
}
}
UI作るの難しい。
あとアイコンを https://fonts.google.com/icons から持ってきたので各自入れてね
GameInfoScreen.kt
/**
* ゲーム情報表示画面
* */
@Composable
fun GameInfoScreen(viewModel: MainViewModel) {
val context = LocalContext.current
// 検索ワード
val searchText = remember { mutableStateOf("") }
// ゲーム情報LiveDataを変換する
val gameInfoList = viewModel.gameInfoLiveDataList.observeAsState()
Scaffold(
topBar = {
SearchBox(searchText = searchText.value, onChangeSearchText = { searchText.value = it }) {
// 検索ボタン押したとき
viewModel.getGameInfo(searchText.value)
}
}
) {
if (gameInfoList.value != null) {
// 今回は一個だけ表示
val gameInfo = gameInfoList.value!![0]
// 写真URL
val imgUrl = "https://pics.dmm.co.jp/digital/pcgame/${gameInfo.dmm}/${gameInfo.dmm}ps.jpg"
Column {
// 情報
Row {
InternetImage(url = imgUrl)
Column {
Text(text = gameInfo.gamename, fontSize = 25.sp)
Text(text = gameInfo.furigana)
Text(text = gameInfo.brandname, fontSize = 20.sp)
Text(text = "発売日\n${gameInfo.sellday}")
}
}
// セール情報があれば
if (gameInfo.content.isNotEmpty()) {
Card(
border = BorderStroke(1.dp, MaterialTheme.colors.primaryVariant),
elevation = 0.dp,
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(text = "${gameInfo.name} (${gameInfo.end_time_stamp} まで)")
Text(text = gameInfo.content, fontSize = 20.sp)
}
}
}
// DMMで購入するボタン
Button(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, "https://dlsoft.dmm.co.jp/detail/${gameInfo.dmm}/".toUri()))
},
) {
Icon(painter = painterResource(id = R.drawable.ic_store_black_24dp), contentDescription = "shop")
Text(text = "DMMで購入する")
}
// 評価など
Divider()
Row {
RankText(
modifier = Modifier
.weight(1f)
.padding(5.dp),
"最高得点",
gameInfo.max2.toString()
)
RankText(
modifier = Modifier
.weight(1f)
.padding(5.dp),
"得点中央値",
gameInfo.median.toString()
)
RankText(
modifier = Modifier
.weight(1f)
.padding(5.dp),
"最低得点",
gameInfo.min2.toString()
)
}
Row {
RankText(
modifier = Modifier
.weight(1f)
.padding(5.dp),
"得点データ数",
gameInfo.count2.toString()
)
RankText(
modifier = Modifier
.weight(1f)
.padding(5.dp),
"平均値",
gameInfo.average2.toString()
)
RankText(
modifier = Modifier
.weight(1f)
.padding(5.dp),
"標準偏差",
gameInfo.stdev.toString()
)
}
Divider()
// ErogameScapeで開く
OutlinedButton(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, "https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/game.php?game=${gameInfo.id}".toUri()))
},
) {
Icon(painter = painterResource(id = R.drawable.ic_open_in_browser_black_24dp), contentDescription = "shop")
Text(text = "ErogameScapeで開く")
}
}
}
}
}
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ErogameScapeDroidTheme {
Surface(color = MaterialTheme.colors.background) {
// ゲーム詳細画面
GameInfoScreen(viewModel = viewModel)
}
}
}
}
}
動かすとこんな感じ
UI作るのって難しいよな
ソースコードあります。
https://github.com/takusan23/ErogameScapeDroid/tree/efa4137b777c4e069ebc7fa3eda5fcbccdba7500