なにも わからぬ

パソコンとプログラミング関係をメモっていきたい

kotlinで複数回献血クラブのスクレイピングライブラリ&Serializableでハマった話

例のごとく初心者が書いた記事です。突っ込み歓迎します

kotlinで書いた複数回献血クラブ用のスクレイピングライブラリが一応動く形になったので、キリもないしgistに貼って一旦公開してみる。ほんとは献血クラブ非公式クライアントとしてサクッとアプリにして公開したかったけど、Android固有の知識がいろいろ求められてしんどい。自分以外誰得な内容だし習作のテーマ間違った気がする……!!

今のところ

val scraper = Scraper(ID, PASS1, PASS2)

インスタンスを作るとログイン後の1ページ分(最大10回分)の献血データをダウンロードしscraper.recordIndexscraper.bloodDataに書き込まれ、2ページ目以降のデータも取得したければscraper.next()を繰り返す……だけのもの。自分以外使わないだろうけど少しずつ修正していきます。

長いのでリンクのみ
A scraper for www.kenketsu.jp

Serializableやtransientでハマった話

AndroidのActivityをまたいでデータをやりとりするにはintent.putExtra()を使うようだけど、文字列型や整数型などのプリミティブ型以外の自作クラスのインスタンスなどを送る場合は、バイト列などに変換できることを保証するSerializableであることを示さなければならないそう。Serializableはメソッドも何もないマーカークラスというやつで、これを継承しているクラスのみがSerializeできるものとしてActivityをまたぐことができ、インスタンス変数なども含めてSerializableを継承していないクラスが混じっているものをまたがせようとするとエラーが出る。

で、自作クラスには全部Serializableをおまじないのように継承させたが、外部ライブラリであるOkHttpは継承クラスを作ろうとしてもなんかうまく行かず?どーすっぺと適当にぐぐっていたら見つけた@transientを頭に付けてインスタンス変数を宣言すればいい、というのを意味もわからず実行した。

class Scraper(...) : Serializable {
    @transient var client:OkHttpClient

そのときのコードは、Activityをまたぐ前にclientを使ったメソッドを実行し、またいだ後にインスタンス変数の一部を参照するだけだったので問題が露呈しなかったが、作り続けていくうちに当然Activityをまたいだ先でもメソッドを実行させるようになり、そこでclientへのアクセスがあったときにNullPointerExceptionが起こるようになった。困っていろいろググったところ、transientはSerializeの対象外にするという宣言であり、Serializeしてデータを送るときにtransientの宣言されたデータは送られない=nullとなる?というようなことがわかった。

参考:Java直列化メモ(Hishidama's Java Serializable Memo)
【Java】Serializableの基本(シリアライズ・直列化) - TASK NOTES

あーやっちまった全部作り直しかーと思っていたら、static変数はもともとSerializeの対象外であり、kotlinではcompanion objectの中に突っ込むことで実現できるとわかった。

class Scraper(...) : Serializable {
    companion object {
        lateinit var client:OkHttpClient
    }

これでthis.clientScraper.clientに変わったくらいで事なきを得た。HTTPライブラリのインスタンスをクラス変数に持たせるのはどうなのよと思うし、そもそもインターネットアクセスを全部外に出すべきなんじゃ、とも思うのだけど……。

【KotlinでAndroid】 ガチ初心者がネットワークアクセスで四苦八苦

始めたばかりのガチ初心者が書いた記事なのでコードの品質は保証できません。参考にする場合は自分で調べ直してください。突っ込みを頂けたら追記・訂正します

ようやくAndroid Studioに手を出してみた。JavaもKotlinもAndroid開発もなにもかも未経験だったので、メインスレッドでネットワーク通信すると例外とか別スレッドでUIいじろうとすると例外とか、おそらく先人がみな経験したような地雷を丁寧に一つずつ踏み抜いていくことになった。ので調べたことをメモしていくことにする。

はじめの一歩

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

5行目のsetContentView()activity_main.xmlの内容が表示される、と同時にここで初めてviewの内容にコード側からアクセスできるようになる。javascriptで言えばここでhtmlを展開するからそれより前でDOMノード取得しようとしてもできないようなもんね。ここでsetContentViewより前でfindViewById()しようとして
f:id:htkb:20161106221157j:plain
となって悩んだ。

ネットワーク通信

setContentView()の後ろにtextview.setText("hogehoge")などとして(kotlin-android-extensions使用)viewの内容が書き換えられるのを確認後、OkHttpライブラリを使いhtmlをgetし表示しようと試みる。まずネットワークの権限がないとエラーが出るので事前にAndroidManifest.xml<manifest...の下の行に<uses-permission android:name="android.permission.INTERNET" />を追加しておく。んで

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)
        textview.setText(getHtml())
    }
}

fun getHtml(): String {
    val client = OkHttpClient()
    val req = Request.Builder().url("http://www.google.co.jp").get().build()
    val resp = client.newCall(req).execute()
    return if(resp != null && resp.body() != null) resp.body().string() else "error"
}

とすると
f:id:htkb:20161106221157j:plain
ときたもんだ。Android Monitorを見るとandroid.os.NetworkOnMainThreadExceptionとのこと、これは超頻出問題らしく、ネットワーク通信はメインスレッドで行ってはならず非同期通信しなければならないらしい。で、この非同期通信のための最小コードがよくわからずぐぬぬぬしていたのだが、とりあえず問題なく動いたっぽいのが以下:

open class MyAsyncTask : AsyncTask<Void, Void, String>() {

    override fun doInBackground(vararg params: Void): String? {
        return null
    }
}

を頭に追加し、

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)

        object : MyAsyncTask() {
            override fun doInBackground(vararg params: Void): String? {
                return getHtml()
            }
        }.execute()
    }
}

とする。AsyncTaskを継承したMyAsynkTaskを即時実効しているようで、doInBackground()メソッドが実際に裏で動くタスクのよう?
参照:Kotlinを使ってみよう!(2) : OkHttp編 - Takahiro Octopress Blog

非同期スレッドでUIはいじれない

以上のコードはdoInBackground()がhtmlを返しているだけなのでまだ画面に反映されない。そこでreturnするまえにtextview.setText(getHtml())などとやってみると、
f:id:htkb:20161106221157j:plain
でログにはCaused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.と出る。でググってみるとやはり頻出のようで、整合性などの問題のためメインスレッド以外からUIの要素を変更したりできないようだ。そのかわり、AsyncTaskonPostExecute()メソッドはdoInBackground()が終わった後に返り値を受け取って自動的に呼ばれるようで、onPostExecute()ではUIの変更が可能なよう。そこで最終的に

open class MyAsyncTask : AsyncTask<Void, Void, String>() {

    override fun doInBackground(vararg params: Void): String? {
        return null
    }

    override fun onPostExecute(text: String) {}
}

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)

        object : MyAsyncTask() {
            override fun doInBackground(vararg params: Void): String? {
                return getHtml()
            }
            override fun onPostExecute(text: String) {
                textview.setText(text)
            }
        }.execute()
    }
}

fun getHtml(): String {
    val client = OkHttpClient()
    val req = Request.Builder().url("http://www.google.co.jp").get().build()
    val resp = client.newCall(req).execute()
    return if(resp != null && resp.body() != null) resp.body().string() else "error"
}

こうなってようやく、
f:id:htkb:20161106224347j:plain
生のhtmlをtextviewに垂れ流すまでに至った。ここまできてようやくkotlinでロジックを書き始められそう。難しいぜAndroid

Hello, Kotlin

Javaも書けないのにbetter Javaとして評判のKotlinに手を出してみた。とりあえず定番っぽい以下の本を買う。

もちろんJava経験者がターゲットなので、「第3章:ガイドツアー」からなかなか厳しい。けど「第4章:基本的な文法」からはよくある言語仕様の紹介になり、ずぶの素人でもついていけそうな感じになっている。

最終的にはAndroidアプリを作るのが目標だけど、とりあえずはIntelliJ IDEAで純粋にKotlinをいじってみよう。Hello worldを言わせるだけじゃ面白くないので、昔やったことのある穴掘り法による迷路作成を適当に実装してみた。

import java.util.*

fun createMap(xLength: Int, yLength: Int): Array<Array<Int>> {
    var map = Array(yLength, {Array(xLength, {0})})

    fun buildRoad(posX: Int, posY: Int) {
        val directions: List<Int> = mutableListOf(0,1,2,3)
        Collections.shuffle(directions)
        for (i in directions) {
            val distance:Array<Int> = when(i) {
                0 -> arrayOf(0, -1)
                1 -> arrayOf(1, 0)
                2 -> arrayOf(0, 1)
                3 -> arrayOf(-1, 0)
                else -> arrayOf(0, 0)
            }
            val dest:Array<Int> = arrayOf(posX+distance[0]*2, posY+distance[1]*2)
            if (
                dest[0] in 0..xLength-1
                && dest[1] in 0..yLength-1
                && map[dest[1]][dest[0]] == 0
            ) {
                map[posY+distance[1]][posX+distance[0]] = 1
                map[dest[1]][dest[0]] = 1
                buildRoad(dest[0], dest[1])
                break
            }
        }
    }

    map[1][1] = 1
    buildRoad(1, 1)
    for (i in 0..100) {
        val r = Random()
        while(true) {
            val x = r.nextInt(xLength)
            val y = r.nextInt(yLength)
            if (x%2 == 1 && y%2 == 1 && map[y][x] == 1) {
                buildRoad(x, y)
                break
            }
        }
    }
    return map
}

fun main(args: Array<String>) {
    val scanner = Scanner(System.`in`)
    var input: String
    var length: Int
    while (true) {
        println("迷路の1辺のマス数を10以上で入力(偶数は奇数に繰り上げ)")
        input = scanner.nextLine()
        if (Regex("""\d{2}""").matches(input)) {
            length = if(input.toInt()%2 == 1) input.toInt() else input.toInt()+1
            break
        }
    }
    var map: Array<Array<Int>> = createMap(length, length)

    for (y: Int in map.indices) {
        var line:String = ""
        for (x: Int in map[y].indices)
            line += if (map[y][x] == 0) "#" else "."
        println(line)
    }
}

実行するとこんな感じに f:id:htkb:20161101235416j:plain

難しいけど少しずつやっていこう。

複数回献血クラブにログインしデータを取得するスクリプト

日本赤十字社の複数回献血クラブに登録すると、ネット上でこれまでの献血時のデータを閲覧できたり、
f:id:htkb:20161027001127j:plain
こんな風にグラフにして見せてくれたりするため、ちょくちょく献血している人には便利。しかし献血後にデータ反映まで数日のラグがあったり、データ閲覧のページにたどり着くまで2回ほどログイン作業が必要だったりとチェックが面倒だったりする。そこで、python+requestsでログインを試みてみる。

firefoxで複数回献血クラブにログインしつつLive HTTP headersを覗きながら試してみると、

  1. 会員ログインページ(https://www.kenketsu.jp/nskc/user/login.asp)に {"mode":"login", "user_id":数字10桁の会員ID, "password":パスワード}をPOSTしクッキーを得る
  2. 会員メニュー内の献血記録のパス入力ページ(https://www.kenketsu.jp/nskc/user/done/passwdexe.asp)に 1. で得たクッキーと {"mode":"login", "passwd":4桁のパスワード, "dummy":""}をPOSTする

のみで良いことがわかった。以下はとりあえず最終の記録の日時を得るだけのスクリプト

import requests
import re

def get_latest_date():
    # 以下3つを自分の情報に変更する
    user_id = "0000000000"
    login_password = "password"
    record_password = "0000"

    login_url = "https://www.kenketsu.jp/nskc/user/login.asp"
    login_data = {"mode": "login", "user_id": user_id, "password": login_password}
    record_url = "https://www.kenketsu.jp/nskc/user/done/passwdexe.asp"
    record_data = {"mode": "login", "passwd": record_password, "dummy": ""}

    # まずは会員ログインしてログイン状態のクッキーをもらう
    resp = requests.post(url=login_url, data=login_data)

    # もらったクッキーを使って献血記録にアクセス
    resp2 = requests.post(url=record_url, data=record_data, cookies=resp.cookies)

    # あとはresp2.textを煮るなり焼くなり
    html = resp2.text
    latest_date = re.search(r"H\d+\.\d+\.\d+", html)
    if latest_date is not None:
        return latest_date.group()
    else:
        return None

Xperia XA Ultraの気になったところ・イマイチなところ

ファーストインプレッション からネガティブな感想を並べてしまったが、総合的に満足とはいえミドルロー程度の性能なので割り切る必要はいろいろと出てくる。使っていて気づいた点を。

続きを読む

Xperia XA Ultraでデレステ


音楽が著作権違反と言われ即消されてたので音消した。音ゲーなのに。

SoCの性能良ければ快適ってわけじゃないデレステXperia XA Ultraの100万倍くらいのGPU性能を誇るShield Tabletでは2D軽量でカクついたり指が画面に3本以上触れるとロングノーツが即座に切れるなどの怪現象に見舞われて忍耐力が養われたが、XA Ultraは2D軽量ならスムーズに動き発熱もあまりなく快適そのもの。

ただ設定→音と通知→オーディオ設定→ClearAudio+にチェックが入ってると音ズレがえらいことになるので必ずオフ。
f:id:htkb:20161019213324j:plain

また、イヤホンの有無でタイミング調節が異なったり微妙な癖はあるようだ。今のところタイミング調節はイヤホンありで27前後、スピーカーで29~30のよう。タップ音も付けるとまた違ってきて、最適値を見つけるのはやや骨が折れそう。

f:id:htkb:20161019220935j:plain もちろん3Dはダメダメです。

追記:タップ音ありにすると音楽に対して譜面と判定がどんどん後ろにずれていって音ゲーにならない。多分性能の問題なのでタップ音ありでやりたい人にはダメそう。

こんにちは Xperia XA Ultra

f:id:htkb:20161018210200j:plain

続きを読む