言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

StateListAnimatorを使ってXMLだけでAnimationをつける

こちらの記事を読んで「<selector> の中にobject animator埋め込めるの知らなかった!!すげー!!!!」となったので遊んでみたメモ。

android.jlelse.eu

StateListAnimator

AndroidにはStateListAnimatorというクラスがあって、Viewのdrawable stateによってAnimationを書き分けることができる。何が最高かってこのAnimationはXMLでお手軽にかけるってところだ。1

ドキュメントはこちら

遊んでみた

アイコンに触っている間だけ大きくなるアニメーションを書いてみた。 xmlはこんな感じ

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <set>
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleX"
                android:valueTo="1.5" />
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleY"
                android:valueTo="1.5" />
            <objectAnimator
                android:duration="200"
                android:propertyName="transitionZ"
                android:valueTo="10dp" />
        </set>
    </item>
    <item>
        <set>
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleX"
                android:valueTo="1" />
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleY"
                android:valueTo="1" />
            <objectAnimator
                android:duration="200"
                android:propertyName="transitionZ"
                android:valueTo="0dp" />
        </set>
    </item>
</selector>

android:state_pressed="true" の時とそうでない場合でObjectAnimatorの振る舞いを変えることができる。上記公式ドキュメントのリンク先を参照するとpress以外のstateもたくさんある。

xmlobjectAnimatorタグで使えるattribute一覧はこちら

Animation resources  |  Android Developers

    <objectAnimator
        android:propertyName="string"
        android:duration="int"
        android:valueFrom="float | int | color"
        android:valueTo="float | int | color"
        android:startOffset="int"
        android:repeatCount="int"
        android:repeatMode=["repeat" | "reverse"]
        android:valueType=["intType" | "floatType"]/>

このanimationとviewを紐づける時はこんな感じでandroid:stateListAnimatoを使う。

android:stateListAnimator="@animator/fav_animator"

こんな感じでハートがドキドキする。

f:id:muumuumuumuu:20180718234247g:plain

サンプルコードはこちら。

github.com


  1. 個人的には最高だと思うけど、Viewの振る舞いをXMLに書くことに対して反対派の人はいると思うので、そこはもうお好みで :pray:

KotlinのRegexとDestructuredで文字列からdata classに変換する

Androidでアプリを書いていて、正規表現を扱うときにjava.util.regex.Matcherを使うこと多いが、あれは個人的には好きではない。もっとスッキリかけるんじゃないかなぁといつも思ってしまう。

例えば、URLからprotocolとdomainを正規表現を使って取り出すコードを書こうと思うとこんな感じになるかと思う。

    companion object {
        private const val REGEX: String = "(.*)://(.*)"
    }

    fun hoge() {
        val urlString = "http://www.com"
        val url = generateUrlFromString(urlString)
    }

    private fun generateUrlFromString(url: String): Url? {
        val matcher = Pattern.compile(REGEX).matcher(url)
        return if (matcher.find()) {
            val protocol = matcher.group(1)
            val domain = matcher.group(2)
            return Url(protocol, domain)
        } else {
            null
        }
    }

data class Url(val protocol: String, val domain: String)

再帰の場合に注意が必要で、上記の場合だとmatcher.group()が0ではなく1と2だったり何かとやらかしてしまったりする。(matcher.group(0)にはhttp://www.comが入ってくる)

そんなときに、この記事見て最高では????と思ったので自分でも試してみることにした。一行でまとめると Destructuredクラスが最高では?という話です。やっぱりKotlinは可愛い。

medium.com

上記のgenerateUrlFromString()Java正規表現からKotlinの正規表現に書き換えたコードがこちら。

    private fun generateUrlFromString(url: String): Url? =
        REGEX.toRegex().matchEntire(url)
            ?.destructured
            ?.let { (protocol, domain) ->
                Url(protocol, domain)
            }

これだけで十分可愛さが伝わる気がするが、蛇足ながら可愛いポイントを書いていく。

  1. 文字列REGEXtoRegex()Regexクラスに変換
  2. matchEntire()で引数url stringを渡すことによりMatchResultを取得
  3. 2で正規表現にマッチしなかった場合はnullになるので、?.destructuredを取得。これは正規表現にマッチしたgroupをDestructuredクラスで返してくれる
  4. 3ですでにnullの可能性があるので引き続き?.でletを呼ぶ。ポイントはここでラベルをつけることができるという点。サンプルコードだとprotocoldomainをすぐに使っているが、let blockのなかで複雑なことをしている場合にわざわざ名前をつけるために変数に代入しなくてもよくて可読性がグッと上がる。

これらをワインラインでスッキリかけるところがまた可愛い!

当然ながらKotlinのRegexJavaのMatcherとかをwrapしているので、めんどくさいところは全てやってくれていて最高。

言いたいことは以上です。

kotlin.concurrent.threadは新規threadが作成されるけどcurrent threadのgroupで動く

[2018.7.15 ご指摘いただき記事の最後に追記しました]

あらまし

先日おもしろいTweetを見つけたのでちょっと調べてみたメモ。

残念ながら手元で上記の現象は再現しなくて、MashmallowでもCrashが発生しなかったんだけど、

Kotlinのthreadってmain threadじゃない別threadで動くんじゃなかったっけ…?🤔

と思ったので調査。
ちなみにMashmallowの端末が手元になかったのでEmulator環境でやってます。

まずは調査用の簡単なコードを準備。Activityの onResume() でUI操作を行う。(textという名前のTextViewに"hogehoge"というtextをセットしています。)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_thread)
        Timber.plant(Timber.DebugTree())
        // main threadのログ出力結果を確認するためにログを入れる
        Timber.d("main thread: ${Looper.getMainLooper().thread}")
    }

    override fun onResume() {
        super.onResume()
        thread {
            Timber.d("current thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }
    }

ThreadのtoString()で表示しているのは下記の三つ。 thread nameprioritygroup name

    public String toString() {
        ThreadGroup group = getThreadGroup();
        if (group != null) {
            return "Thread[" + getName() + "," + getPriority() + "," +
                           group.getName() + "]";
        } else {
            return "Thread[" + getName() + "," + getPriority() + "," +
                            "" + "]";
        }
    }

ログとるときの操作は、単純に画面を表示させるだけ。結果はこんな感じ。

07-14 08:31:01.772 5497-5497/? D/ThreadActivity: main thread: Thread[main,5,main]
07-14 08:31:01.777 5497-5513/? D/ThreadActivity$onResume: current thread: Thread[Thread-266,5,main]

thread blockの中でログを出力した方は確かにthread nameを見ると別のthreadになっている。しかし3つ目のgroup nameはmainになっている。

kotlin.concurrent.threadを読んでみよう

Kotlinのthreadは気軽にサンプルコードのように書いている人が多いと思うが、じつはたくさんのdefault引数が用意されている

public fun thread(start: Boolean = true, isDaemon: Boolean = false, contextClassLoader: ClassLoader? = null, name: String? = null, priority: Int = -1, block: () -> Unit): Thread {
    val thread = object : Thread() {
        public override fun run() {
            block()
        }
    }
    if (isDaemon)
        thread.isDaemon = true
    if (priority > 0)
        thread.priority = priority
    if (name != null)
        thread.name = name
    if (contextClassLoader != null)
        thread.contextClassLoader = contextClassLoader
    if (start)
        thread.start()
    return thread
}

で、そんなdefault引数を置いておいて、メソッドの頭でJavajava.lang.Threadをnewしているんだけど、このときcurrent threadをparentにとるようになっているので、main threadからthread()を呼び出すとmain threadのgroupで新規threadが作られるのだ。

    private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
        Thread parent = currentThread();
        if (g == null) {
            g = parent.getThreadGroup();
        }

        g.addUnstarted();
        this.group = g;

UI操作できるのは一つのthreadからだけ

ちなみに上記のサンプルコードで、一度backgroundに行って再びonResume()に帰ってくるとアプリがcrashする。

    android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

backgroundから復帰した時にonResume()でもう一度threadから新規threadを作成しているので最初にさわったthreadではない別のthreadからUIを触ろうとしてcrashする。 ログを見てもthread nameが変わっていることがわかる

// Activity起動
07-14 08:31:01.772 5497-5497/? D/ThreadActivity: main thread: Thread[main,5,main]
07-14 08:31:01.777 5497-5513/? D/ThreadActivity$onResume: current thread: Thread[Thread-266,5,main]

// Backgroundから復帰(2回目のonResume)
07-14 08:31:06.983 5497-5518/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[Thread-269,5,main]

ここでthreadを新規作成するのではなくmain threadに固定することでこのcrashは避けられる。

    override fun onResume() {
        super.onResume()
// この部分を
//        thread {
//            Timber.d("current thread: ${Thread.currentThread()}")
//            text.text = "hogehoge"
//        }

// こう変える
        runOnUiThread {
            Timber.d("current thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }
    }

ログを見たら全てmain threadで固定されていることがわかる。

// Activity起動
07-14 08:07:18.328 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity: main thread: Thread[main,5,main]
07-14 08:07:18.329 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[main,5,main]

// Backgroundから復帰(2回目のonResume)
07-14 08:07:27.811 5242-5242/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread: Thread[main,5,main]

ちなみにもう一つおもしろい挙動を見つけたのでメモ。 こんな感じでthreadとrunOnUiThread両方書いてやるとCrashは発生しない。

    override fun onResume() {
        super.onResume()
        thread {
            Timber.d("current thread from thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }

        runOnUiThread {
            Timber.d("current thread from ui thread: ${Thread.currentThread()}")
            text.text = "hogehoge"
        }
    }

ログを見ると別threadよりも先に runOnUiThread の中が実行されているのがわかる。先にmain threadでさわっているので以降main threadのgroupで動いている別threadでさわってもcrashしないということなのかな。 // この部分間違ってそうなので追記で捕捉しました(2018.7.15)

// 1回目のonResume()
07-14 08:40:11.749 5693-5693/? D/ThreadActivity$onResume: current thread from ui thread: Thread[main,5,main]
07-14 08:40:11.749 5693-5709/? D/ThreadActivity$onResume: current threa from thread: Thread[Thread-278,5,main]

// 2回目のonResume()
07-14 08:40:49.488 5693-5693/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current thread from ui thread: Thread[main,5,main]
07-14 08:40:49.489 5693-5717/com.example.muumuu.playgroundapp D/ThreadActivity$onResume: current threa from thread: Thread[Thread-280,5,main]

[2018.7.15ここから追記]

ブログ公開ごにhydrakecatさんにご指摘いただいたのでさらに追加調査。

確かに thread { } の中で Thread.sleep(1000)を入れるとcrashするようになった。 (emulator環境Mashmallow / 実機環境Oreo共にcrashすることを確認 )

    override fun onResume() {
        super.onResume()
        thread {
            text.text = "hogehoge" // sleep以前にtextをさわってもcrashしないが、
            Thread.sleep(1000) // 1000msecの遅延後
            text.text = "hogehoge" // textをさわるとcrashする
        }

例外を投げているのはViewRootImpl.javaのこの部分。

Cross Reference: /frameworks/base/core/java/android/view/ViewRootImpl.java

   7311     void checkThread() {
   7312         if (mThread != Thread.currentThread()) {
   7313             throw new CalledFromWrongThreadException(
   7314                     "Only the original thread that created a view hierarchy can touch its views.");
   7315         }
   7316     }

mThreadに何が入るのかとういうと、constructorでThread.currentThread()を代入している。mThreadはfinalなのでsleep前後でも変わらない。

    235     final Thread mThread;

    478     public ViewRootImpl(Context context, Display display) {
    479         mContext = context;
    480         mWindowSession = WindowManagerGlobal.getWindowSession();
    481         mDisplay = display;
    482         mBasePackageName = context.getBasePackageName();
    483         mThread = Thread.currentThread();

mThreadThread.currentThread() もsleep前後で変化しないはずなのでおかしいなぁと思っていたら

というわけでcheckThread()が呼ばれない可能性を考える。

ViewRootImplmHandlingLayoutInLayoutRequestというフラグを持っていて、Layout実行時にこのフラグがtrueになる。

   1158     @Override
   1159     public void requestLayout() {
   1160         if (!mHandlingLayoutInLayoutRequest) {
   1161             checkThread();
   1162             mLayoutRequested = true;
   1163             scheduleTraversals();
   1164         }
   1165     }

で、requestLayout()にこのフラグが立っていたら checkThread() が実行されない(というか何もしない)ので、threadのcheckも行われず例外も投げられないということのようだ。sleep後はlayoutが完了してフラグがfalseになっているのでthreadのcheckが行われて例外が発生している。

runOnUiThread()thread 両方書いたケースでも、runOnUiThread()の方でlayout要求が出ているのでmHandlingLayoutInLayoutRequestがtrueになり、そのタイミングでthreadでUIをさわったのでcheckThread()が呼ばれず例外が発生しなかっただけだと思われる。

ViewRootImplAndroid FrameworkのクラスなのでDebuggerが効かず本当にそうか?というのが確認できないので歯がゆいが、多分あってるんじゃ無いかなぁ。多分。

[2018.7.15 追記ここまで]

RxTextView#textChanges()でdistinctUntilChangedする時にeventが流れない時がある

みんな大好きRxBinding1 ですが、ちょっとハマることがあったのでメモ。

RxTextView#textChanges()EditText の入力イベントを監視する。その時に重複した入力をdistinctUntilChanged()で削ろうとしたら以降何もeventが流れない現象に遭遇した。

RxTextView.textChanges(editText)
        .distinctUntilChanged() //  ここでevnetが止められるから
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe {
            Log.d("SampleApp", it.toString()) // ここは実行されない
        }

textChanges()は内部ではTextWatcherを使っていて、これがTextの変更を検出するとCharSequenceを返してくれるのだが、こいつはmutableで毎度同じinstanceを返すのだ。同じinstanceなのでchangeしたと見なされずいつまでたっても次のeventが流れてこない。

なので、CharSequenceからStringを取り出してやれば想定していた挙動になる。

RxTextView.textChanges(editText)
        .map { it.toString() } //  ここでStringに変換すると
        .distinctUntilChanged() 
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe {
            Log.d("SampleApp", it.toString()) // ここが実行される
        }

そもそもなんで RxTextView#textChanges() で入力の重複を防ぎたかったかというと、機種依存でSoftware Keyboardのsearch buttonをtapした時にtextChangesのeventがemitされちゃう場合があって、これを防ぎたかったから。これはRxTextViewというよりは、内部で使われているTextWatcherでeventが発火しているからなんだけど、keyboardの機種依存周りつらすぎてなんか仕様とか作って統一して欲しい…


  1. AndroidでReactive Programmingする時に、Viewのイベントをstreamで扱えるのが最大の利点だと思ってしまいそうになるくらいに大好き

@JvmOverloadsなfunctionをmockできないケースがある

@JvmOverloadsを付与したKotlinのfunctionを、Javaから呼び出すコードを書いた。ここのテストを書こうと思ってmockito-kotlinでmockしようとしてできなかったときのメモ。ちなみにmockしたいクラスはDagger2でDIしている前提。

やりたかったこと

例えばこんなKotlinで書かれたfunctionがあったとして、

// Hoge.kt
class Hoge {
    @JvmOverloads
    fun equal(a: String, b: String = "0"): Boolean = a == b
}

それをJavaから呼び出しをするコードに対して

// Fuga.java
public class Fuga {

    @Inject
    Hoge hoge = new Hoge();

    @VisibleForTesting
    public Fuga(Hoge hoge) {
        this.hoge = hoge;
    }

    boolean callHogeEquals() {
        return hoge.equal("1");
    }
}

mockするようなテストコードを書こうと思ったらfailする

class FugaTest {

    @Mock
    lateinit var hoge: Hoge

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun testCallHogeEquals() {
        val fuga = Fuga(hoge)
        whenever(hoge.equal("1")).thenReturn(true)

        val result = fuga.callHogeEquals()

        Assert.assertEquals(true, result)
    }
}
junit.framework.AssertionFailedError: 
Expected :true
Actual   :false

mockできずに本体コード通っちゃったのかな?と思ったが、こういうコードに対して同じようなテストを書いたところ

    @JvmOverloads
    fun returnConcatString(a: String, b: String = "0") = a + b
    @Test
    fun testCallHogeConcatString() {
        val fuga = Fuga(hoge)
        whenever(hoge.returnConcatString("1")).thenReturn("hoge")

        val result = fuga.callHogeCancatString()

        Assert.assertEquals("hoge", result)
    }
junit.framework.ComparisonFailure: 
Expected :hoge
Actual   :null

という結果になったので、どうやら本体コードを通っているのではなく、その型のdefault値が返って来ているように見える。

どうやって回避する?

1. 全てKotlinで書く

今回のようにTest対象のクラスだけがJavaで、mock対象とTestコードがKotlinで書かれた場合にこの問題が発生する。Test対象のクラスもKotlinで書いた場合には再現しないので、Test対象のクラスをKotlinで書き直すと回避できる。

2. 全てのParameterを指定する

別の方法として、Javaで書かれたTest対象のクラスからmock対象のコードを呼び出す時に、全てのParameterを指定するように変更するとこの問題は回避できる。

先ほどの例でいうと、Test対象のJavaをこんな感じに変更して、

    boolean callHogeEquals() {
        return hoge.equal("1", "0"); // default parameterと同じものを2nd parameterに指定
    }

Testコードも2nd paramterをつけてやると

    @Test
    fun testCallHogeEquals() {
        val fuga = Fuga(hoge)
        whenever(hoge.equal("1", "0")).thenReturn(true)

        val result = fuga.callHogeEquals()

        Assert.assertEquals(true, result)
    }

無事にtestがpassする。ちなみにmock対象のコードは@JvmOverloads をつけたままで問題ない。

なぜmockできないのか?

@JvmOverloads がついたfunctionをJavaのbyte codeで見てみると、それぞれのシグネチャのパターン分のメソッドだけでなく、ブリッジメソッドも作られていた。

  // access flags 0x1049
  public static synthetic bridge equal$default(Lcom/example/muumuu/playgroundapp/Hoge;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z
  @Lkotlin/jvm/JvmOverloads;() // invisible
    ILOAD 3
    ICONST_2
    IAND
    IFEQ L0
   L1
    LINENUMBER 8 L1
    LDC "0"
    ASTORE 2
   L0
    ALOAD 0
    ALOAD 1
    ALOAD 2
    INVOKEVIRTUAL com/example/muumuu/playgroundapp/Hoge.equal (Ljava/lang/String;Ljava/lang/String;)Z
    IRETURN
    MAXSTACK = 3
    MAXLOCALS = 5

で、mockする箇所で引数一つで呼び出すとそのブリッジメソッドをINVOKESTATICしている。

   L3
    LDC "1"
    ACONST_NULL
    ICONST_2
    ACONST_NULL
    INVOKESTATIC com/example/muumuu/playgroundapp/Hoge.equal$default (Lcom/example/muumuu/playgroundapp/Hoge;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKESTATIC com/nhaarman/mockito_kotlin/MockitoKt.whenever (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    ICONST_1
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKEINTERFACE org/mockito/stubbing/OngoingStubbing.thenReturn (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    POP

自分の理解ではmockitoは@Mockを付与したインスタンスはdummy objectを生成して、when等で定めた振る舞いをstubとして生成している。そのため、今回のようにブリッジメソッドをstubとして生成しても実際にJavaのコードからコールされるのはこのブリッジメソッドではないのでstubが存在しないため戻り値のクラスのデフォルト値が返ってしまったようだ。

それでは上記回避策をとった場合は、どのようなbyte codeが生成されていて、なぜ問題が起きないのか見ていこう。

まず、全てKotlinで書かれた場合、mockする時だけでなく本体側も@JvmOverloadsなfunctionを同じシグネチャでコールするとブリッジメソッドが呼ばれるので、stubする対象が一致するので正常にmockできる。

次にstub生成時に引数をフルで指定する場合は、ブリッジメソッドではなく引数がフルに揃ったシグネチャのメソッドのスタブが作成されるため正常にmockできる。

   L3
    LDC "1"
    LDC "1"
    INVOKEVIRTUAL com/example/muumuu/playgroundapp/Hoge.equal (Ljava/lang/String;Ljava/lang/String;)Z
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKESTATIC com/nhaarman/mockito_kotlin/MockitoKt.whenever (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    ICONST_1
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKEINTERFACE org/mockito/stubbing/OngoingStubbing.thenReturn (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    POP




はー、フルKotlinな世界はよ〜〜〜〜〜

アメリカでUberにiPhone置き忘れて無事に回収した。SIMは通話付きを買おうな!

[2018/06/13 追記しました]

事の顛末

一週間くらい出張でUS(CA, Palo Alto)に来ている。Palo AltoはSan Fransiscoと異なり移動は車が基本になる。なので出勤はUberを利用しているのだが、車内にiPhoneを置き忘れる事件が発生。先日のGoogle I/OiPhoneを置き忘れた話を見かけたがまさか自分が同じ事になるとは思わなかった 😇

azihsoyn.hatenablog.com

私がラッキーだったのは、メインで使っていた端末が無くしたiPhoneではなく、別のAndroid端末だった事。日本から持って来た端末は2台だが、現地でSIMを1枚だけ買ってAndroidの方に挿して使っていた。つまり、iPhoneを無くしても常にネットワークに接続できるし、帰りのUberも呼べるしで緊急で困ることはなかった。あとiPhoneはプライベートで使っていただけだったので会社の情報とかも入っていなかったので一安心。とはいえまたiPhone買い直して$800とか払うのもバカバカしいので取り戻す事にした。ちなみにiPhoneはSIMさしてないとオフラインなので「iPhoneを探す」が使えない😇😇

Uberアプリは無くしたものを取り戻すのも便利。ただし電話番号とSMSが必須。

ドライバーとなんとか連絡をとりたくてUberアプリを色々さわる。乗車情報より「持ち物を紛失した」のメニューを発見。ここまではいいんだけど、Uberはプライバシーとかの観点からかドライバーと乗客が直接やりとりするのを極力減らす設計になっており、Uberを経由した電話で連絡を取る事になる。

どういうことかと言うと、こちらが連絡してほしい番号をUberにアプリ経由で送信し、Uberがドライバーに連絡するのだが、ドライバーはUberから連絡された番号にかけても直接乗客の番号に繋がるのではなくUberの番号に繋がり、そこから自動で通話が転送される。通話終了後、どちらかが掛け直しても相手に繋がらないような仕組みになっていた。

私の場合は電話番号を送信したらすぐにかかって来た。1 "Are you an Uber driver?"って聞いたらそうだって言われたので、「自分が今朝の乗客であること 」と「iPhoneを無くしたらしいこと」を伝えると、「今日は何人か乗せたから誰かわかんないけど、とりあえずiPhoneには気がつかなかった。でも調べてまた折り返し連絡するね」って言ってくれて通話終了。この時点では気がついてなかったけど、向こうから折り返すことがUberの仕組み上できなかった。

ずっと待ってても連絡が来ないのでどうしたもんかなぁと次の日の朝周りに相談したら「アメリカはグイグイpushしないと無くしたものは戻って来ない」と言われたのでPushすることにする。

もう一度同じメニューから連絡をとってみたけど今度は繋がらず。別の問い合わせ先を探していたら "I couldn't reach my driver about a lost item" 2 があったのでこちらからも連絡してみる事にする。先ほどと同様電話番号を送信する仕組みになっていた。今度はサポートからメッセージが来て、「ドライバーと連絡がついてiPhone見つかったらしい。向こうから連絡くると思うからちょっと待っててくれ」3 と言われたので待つ。

しばらくするとSMSがドライバーより届いて、「どこに住んでるの?」と聞かれて住所を教えると「$20でそこまで持っていくか、San JoseのUberのオフィスに預けるかどっちがいいか選んで」と言われたので持って来てもらう事にした。そこから時間とかのやりとりを全てSMS上で行い、その日の晩4にドライバーが来てくれて無事iPhoneを受け取った。彼曰く、「本当はもっと早く(最初の電話で約束した通り)掛け直してあげたかったんだけど繋がらなかったんだよねー」とのこと。ちなみに受け取る時に$20をキャッシュで支払ったのだが、どうやらUberを通じて返すと$15がアカウントに課金されるので$5で私はSan Joseにいく手間と時間を買って、ドライバーは$20をポケットに入れることができたっぽい。5

[2018/06/13 追記 ここから]
後日Uberのサポートから「落し物見つかったって聞いたから$15がライド料金に含まれるように調整しておいたよ、$15は全額ドライバーに支払われるよ」という連絡が来ました。
[2018/06/13 追記 ここまで]

タイムライン

  • 6/6 9:00
    • 出勤途中のUberiPhoneを置き忘れる。多分上記のブログと同じくポケットから落ちたのに気がつかないまま降りてしまったと思われる。
    • 出勤してiPhoneがない事に気がつくも、部屋に置き忘れたのかも?と思って特に何もせず
  • 6/6 18:30
    • 帰宅。iPhoneがないか部屋中探すが見つからず。Paring済みのBluetooth earphoneで端末を探すの結構便利だった。
  • 6/6 19:00
    • AndroidUberのアプリを立ち上げヘルプメニューになんかないか探し始める。乗車情報より「持ち物を紛失した」のメニューを発見
    • ドライバーと電話で話す。車内をチェックして折り返し連絡をくれることに
  • 6/7 9:00
    • 一晩明けてもドライバーから連絡がないので再度連絡を試みるが繋がらない
    • Uberサポートに連絡
    • ドライバーからSMSが来て$20で持って来てもらうことを約束する
  • 6/7 20:00
    • ドライバーからiPhoneを受け取る

雑感

  • 今回の出張でたまたまSIMは通話付きのもの6を買っていたのでとてもスムーズにいった。通話なしのSIMを買っていたら相当面倒だったと思う。
    • 日本だとSMSはMFAくらいにしか使っていないけど意外と他の国では使われるのだろうか。
  • Uberのドライバーと乗客を直接繋がないプライバシーに配慮したサービス設計は参考になった。
  • 今回ドライバーに私た$20が出張中支払った唯一のキャッシュだった。念のため空港で換金しておいてよかった


言いたいことは以上です。


  1. 相手もどういう感じでかけてるかわからないので、"Hello?“ "Hello?"ってお互い何度か言う羽目になってしまった。コントか。

  2. この辺なぜか英語で表示されている。ローカライズ頑張れ!とどうしてもアプリ開発者目線でみてしまう。

  3. 実際はおきまりの「Sorry to hear about your phone」で始まる丁寧でながーい文章が送られて来た。

  4. 晩といってもサマータイムで20:00でも明るくてびっくりした。

  5. Uberが受け取る$15のうち、いくらドライバーに渡るかは不明。

  6. San FransiscoのAT&Tの店舗で通話付きSIMを買った。店員のお姉さんがすごいネイルで器用にSIMを扱っていたのが印象的だった。

TransitionのShowcaseアプリを作って公開しました

はじめに

先日droid girls meetupに参加し、Animationのハンズオンを楽しんできた。 GoogleのオフィスでGooglerの方(Support libraryを作っている方)が講師というなんとも贅沢な回だった。

droidgirls.connpass.com

そういえば業務でがっつりtransitionをさわる機会がないなぁと思っていたので折角なので色々試してみた。 ついでに、

そういえばこんなことをつぶやいていたのでShowcase的なアプリにして公開することにした。

Source code

コードはこちら。

github.com

サンプルの中にソースコードgithubページに飛べるボタンを置いておいたので、ここどうやって実装してるのかな?って思ったらすぐコードを確認できるようにしておいたのでよければみて見て下さい。

とはいうもののあまりまだサンプルを作れていなくて、作ったのは下記の3つ。

  • ObjectAnimator(Scale)
  • Arc Motion Transition
  • Shared element Activity Transition

それぞれについて気になったポイントとかハマったポイントを書き残しておく。

ObjectAnimator(Scale)

連続するanimationを記述するときに ktxのAnimatorクラスの拡張関数が便利だった。ただしsupport libraryのtransitionとかに対する拡張関数は生えてないようなので実はそんなに使い所は多くないかもしれない。

Arc Motion Transition

最初 ArcMotion() を一次元上での移動に対して設定しており、カーブを描くanimationにならずにハマった。ArcMotionのコードを読んでいるとどうやらベジエ曲線を軌跡とするため、一次元だと曲線にならないっぽい。(一次ベジエ曲線は単なる線分なので。)
余談だけどこれのおかげで TransitionManager 周りのコードを読んで`TransitionManager#beginDelayedTransition()`が何をやっているか理解できた。sceneChangeRunTransition() のなかで対象のViewGroupのViewTreeObserverを登録しておき、view parameterが変わったりしてそのViewGroupにlayoutが走った時とかに onPreDraw() 等を検知して登録したtransitionに対して playTransition() をコールする仕組みだった。へー。

Shared element Activity Transition

Shared element Activity Transition自体は思ったよりかなり簡単にできたが、全く関係のない別の箇所でハマった。 CoordinatorLayoutの中に入れるScrollできるViewはRecyclerViewNestedScrollVIewしかダメっぽい。普通のScrollViewを入れてもAppBarLayoutはcollapsedされないし、AppBarLayoutScrollViewが重なってしまう。これ毎回忘れて毎回ハマる気がする…




もっといろんなanimation作ってどんどん足して行きたいなー