2014/09/30

Samsung SBrowserの小さな小さな工夫


ほんとうは、仕組みが解明できてから公開したかったのですが、もったいぶるメリットもないので、しょうもない内容なの承知でわかっている範囲だけ公開。

といっても、わかってるのは・・・

同じマルチタッチイベントを与えているにもかかわらず、SBrowserは中心軸がずれずに拡大縮小がされる!

以上。・・・分かり次第ちょびちょび付け足しで書いていきます…。

どんなタッチイベントを送ったか

マルチタッチと、その中心軸の遷移

点の座標をログに出してMatplotlibで可視化すると、こんなかんじで中心軸がぶれています。

そもそもAndroidってどうやってマルチタッチを解釈しているのか(書いてる途中・・・)

ScaleGestureDetectorというコンポーネントがAndroidのフレームワークに有ります。
マルチタッチズームの基本をちらっと図解します。

ScaleGestureDetectorのキモは3つです。
 ・[ScaleStart] 親指と人差指の間の距離をSpanといい、その距離が初期距離から16dp以上ずれるとズームを開始
 ・[ScaleBy] ズームの中心は、親指と人差指の中点(リアルタイムに更新)。倍率は、16ms前のSpanと現在のSpanの比率(リアルタイムに更新)
 ・[ScaleEnd] 指が2点じゃなくなるとズーム終了

中心の座標は、開始時のもの固定ではなくてリアルタイムにタッチの点の中心座標に更新するのがAndroid標準です。なので、冒頭で書いたとおりChromeはバカ正直にタッチの中心座標を更新するため、マルチタッチの中心軸がブレブレになって見えたりするのです。

SBrowserはこのようなタッチ中心の座標ブレブレ問題を解決しているようです。

SBrowserの工夫とは・・・(まだわからず)

オープンソースでその実装箇所を探ってる最中です。分かり次第続報をお届けします。



と、長く時間が空きましたが、結局実装箇所はわかりませんでした。
Zoomのジェスチャを判別する部分は、他の機種との差分は(ゼロではないですが)多分ない。 で、気になってSBrowser.apkを引っこ抜いて、apktool d SBrowser.apk とかなんとか探ってみようかなと思いましたが、答えっぽいものにはたどり着けませんでした。

org/chromium/content/browser/ZoomManager.smaliとかを見る限りだとAndroid標準のScaleGestureDetector使ってるし、 多分、MultiTabPinchUtilのperformPinchZoomあたりでなにかゴニョっとやってるんだろうなぁと思いつつ…
若干悔しいながら、今回はこれまで。。

Android標準のScaleGestureDetectorの動作とカスタマイズポイントが見えたくらいでよしとしておきましょう。完全に自己満足ですが。

2014/09/23

"Androidのブラウザをカスタマイズする"ということ

めっちゃ会社でやってるネタなので企業秘密に触れるギリギリラインではありますが、
世の中、ブラウザはオープンソースの時代です( ー`дー´)キリッ ので、タイーホとならない程度に、書きたいことを書いてみようと思います。

そもそもなぜブラウザをカスタマイズするのか?

へたなカスタマイズはフラグメンテーション(改造によって互換性のないめちゃくちゃなものに・・・)のもとです。なので、やるべきではありません。
それなのに、なぜカスタマイズをするのか。

そこにはユーザの小さな小さな不満があるからです。

 ・タップしてからページが表示されるまでの時間が遅い。早くしたい。
 ・タッチスクロールの指に吸い付く度合いがヘボい。もっと指に気持ちよくついてきてほしい。
 ・電池めっちゃくう。ずっとブラウザ使ってても電池くわないようにしてほしい。

ほんとうに地味なことですが、こういうちょっとしたことがユーザの感覚に響くのです。

なくても困らないのだけども、あったほうが俄然いい

これがブラウザカスタマイズのモットーです。


で、ごちゃごちゃ論じるのはこのブログの趣旨には合いませんね。
オープンソースのブラウザコードを少しだけ読み解いてみましょう。


カスタマイズの説明の前に。

「ブラウザって、どうせタッチスクロールしたぶんだけ画面を動かしてるんでしょ?」というイメージをお持ちの方に、
ちょっとだけ認識を改めてほしいので、ブラウザのタッチスクロールの処理の仕組みを簡単に説明します。
まぁたしかにタッチスクロールした分だけ動かしてることには間違いないんですけど、登場人物が2人いて、それをここで明確にしておきたいのです。

登場人物1:タッチをひたすら監視する小人さん
Androidには、タッチイベントをリアルタイムに監視して、
「いまスクロールされたぞ!」「いまタップされたぞ!」って
都度言ってきてくれる小人さんがいます。


登場人物2:WebKit

WebKitの一番の役割は、
HTMLというフォーマットで書かれた文字列から
要求に応じてビットマップ画像を作り出すことです。




AndroidのWebViewは
前述のタッチイベント監視人さんとWebKitをうまく組み合わせて、
ユーザがタッチスクロールしたら画面を書きなおして
あたかも画面がスクロールしたかのように見せています。


ブラウザの「タッチスクロール」をすこしだけ改善してみよう

用意するもの:
 ・Android4.4.2か4.4.4の端末(Nexus 5)
 ・ページ内検索が速いPCブラウザ(IEはダメ、Google Chrome推奨)
 ・Androidのビルド環境


現在のAndroidのブラウザの多くに使われている"WebView"という部品は、じつは厳密にはスクロールした分だけ画面が移動してくれていません。
実際にスクロールされるのは、タッチスクロール量から数ミリメートルだけ引かれたぶんが、スクロールされます。

え?うそでしょ?とおもったあなた。実際にAndroid端末で以下のように動作を見てみてください。


"指に吸い付かないスクロール"を体感しよう


このブログをAndroidで見た時のスクリーンショットで説明してますが、YahooでもGoogleでもどんなページでも同じです。

どんなに頑張ってスクロールしても最終的に指の場所についてきてくれませんね?
今回はこれを、ちゃんと指についてきてくれるよう改善してみたいと思います。



 ・指に吸い付かない理由

正解から言ってしまうと、タッチイベントを監視している小人さんが"躊躇している"タイミングがあるからです。

専門用語で言うと、touch slopとよばれるもので、
「タップ」なのか「タッチスクロール」なのかを見分けるための、しきい値です。


どこまでがタップでどこからがスクロールか


ソースコードを見る前に、絵で説明します。

①は誰が見てもタップですね。これをスクロールだって言うと、手が震えがちな人はタップできませんね。
そして、③は誰が見てもスクロールですね。これをタップだって言われると、スクロールさせるにはどんだけ手を動かさんとダメなの?と思ってしまいますね。
じゃあ、②はどうでしょう?これは微妙ですね。

ただ、タッチ監視をしている小人さんは「微妙」という回答はできません。ユーザが画面に触れて何かアクションをした以上は、かならず「タップ」か「スクロール」かを決めてあげなければなりません。

そこでAndroidは1つバシッと指標を決めていて、
 ・8dp以上動いていない場合はタップ
 ・8dp以上動いている場合はタッチスクロール
としています。



ここでピンときたひとがいるかもしれません。

タッチスクロールしても8dp動いてない場合はタップと認識される。
タッチスクロールと認識され始めるのは、8dp目のスクロール部分から。

そう、まさにこの8dp捨てられているのがAndroidのWebViewの動作なのです。

さて、ソースを見てましょう。
http://tools.oesf.biz/android-4.4w_r1.0/xref/external/chromium_org/content/public/android/java/src/org/chromium/content/browser/third_party/GestureDetector.java

これがタッチを監視してる小人です。

    461     /**
    462      * Analyzes the given motion event and if applicable triggers the
    463      * appropriate callbacks on the {@link OnGestureListener} supplied.
    464      *
    465      * @param ev The current motion event.
    466      * @return true if the {@link OnGestureListener} consumed the event,
    467      *              else false.
    468      */
    469     public boolean onTouchEvent(MotionEvent ev) {

    535         case MotionEvent.ACTION_DOWN:

    553             mDownFocusX = mLastFocusX = focusX;
    554             mDownFocusY = mLastFocusY = focusY;


    558             mCurrentDownEvent = MotionEvent.obtain(ev);


    571             handled |= mListener.onDown(ev);
    572             break;


    574         case MotionEvent.ACTION_MOVE:
    578             final float scrollX = mLastFocusX - focusX;
    579             final float scrollY = mLastFocusY - focusY;


    583             } else if (mAlwaysInTapRegion) {
    584                 final int deltaX = (int) (focusX - mDownFocusX);
    585                 final int deltaY = (int) (focusY - mDownFocusY);
    586                 int distance = (deltaX * deltaX) + (deltaY * deltaY);
    587                 if (distance > mTouchSlopSquare) {
    588                     handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
    589                     mLastFocusX = focusX;
    590                     mLastFocusY = focusY;
    591                     mAlwaysInTapRegion = false;


    595                 }


    599             } else if ((Math.abs(scrollX) >= 1) || (Math.abs(scrollY) >= 1)) {
    600                 handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
    601                 mLastFocusX = focusX;
    602                 mLastFocusY = focusY;
    603             }
    604             break;


    606         case MotionEvent.ACTION_UP:


    615             } else if (mAlwaysInTapRegion) {

    616                 handled = mListener.onSingleTapUp(ev);
スクロール量の平方が、mTouchSlopSquareという値を超えているかどうかによって、
 ・onDown()→onScroll(),onScroll(),onScroll(),onScroll(),・・・
 ・onDown()→onSingleTapUp()
mListenerへのコールバックのされ方が2通りあります。

(いうまでもなく、前者はmTouchSlopを超えたタッチスクロール操作で、後者はmTouchSlopを超えないタップ操作。)


    413             final ViewConfiguration configuration = ViewConfiguration.get(context);
    414             touchSlop = configuration.getScaledTouchSlop();
    425         mTouchSlopSquare = touchSlop * touchSlop;
mTouchSlopSquareは↑で定義されていて、元をたどると http://tools.oesf.biz/android-4.4w_r1.0/search?q=config_viewConfigurationTouchSlop このへんから値を持ってきています。
Nexus 7 (2013)とかは8dpではなく、ちょっと大きめの12dpが指定されていますね。
# タッチパネルの精度が悪いからでしょうか…


ちなみに、ここまでのところ、「8dp捨てられている」という実装はありません。
タッチスクロール時、GestureDetector自体は
のように忠実に、判断結果と座標をmListenerへコールバックしています。
じゃあ、誰がonDown~初回onScrollのスクロール量を捨てているかというと、mListenerを使っている人です。
つまり、小人さんに監視を任せている、その司令塔が、捨てているのです。

http://tools.oesf.biz/android-4.4w_r1.0/xref/external/chromium_org/content/public/android/java/src/org/chromium/content/browser/ContentViewGestureHandler.java
司令塔はこいつです。

     30 class ContentViewGestureHandler implements LongPressDelegate {
     31 

     69     private GestureDetector mGestureDetector;
このとおり、タッチ監視の小人であるGestureDetectorを配下に持っています。
    297     private void initGestureDetectors(final Context context) {
のなかに小人による監視結果をさばくための実装があります。

そこには、このように書かれています。

    304                     @Override
    305                     public boolean onDown(MotionEvent e) {

    308                         mTouchScrolling = false;
    309                         mSeenFirstScrollEvent = false;

    316                         if (sendMotionEventAsGesture(GESTURE_TAP_DOWN, e, null)) {

    318                         }
    319                         // Return true to indicate that we want to handle touch
    320                         return true;
    321                     }
     323                     @Override
    324                     public boolean onScroll(MotionEvent e1, MotionEvent e2,
    325                             float distanceX, float distanceY) {

    327                         if (!mSeenFirstScrollEvent) {
    328                             // Remove the touch slop region from the first scroll event to avoid a
    329                             // jump.
    330                             mSeenFirstScrollEvent = true;
    331                             double distance = Math.sqrt(
    332                                     distanceX * distanceX + distanceY * distanceY);
    333                             double epsilon = 1e-3;
    334                             if (distance > epsilon) {
    335                                 double ratio = Math.max(0, distance - scaledTouchSlop) / distance ;
    336                                 distanceX *= ratio;
    337                                 distanceY *= ratio;
    338                             }
    339                         }
     359                         // distanceX and distanceY is the scrolling offset since last onScroll.
    360                         // Because we are passing integers to webkit, this could introduce
    361                         // rounding errors. The rounding errors will accumulate overtime.
    362                         // To solve this, we should be adding back the rounding errors each time
    363                         // when we calculate the new offset.
    364                         int x = (int) e2.getX();
    365                         int y = (int) e2.getY();
    366                         int dx = (int) (distanceX + mAccumulatedScrollErrorX);
    367                         int dy = (int) (distanceY + mAccumulatedScrollErrorY);
    368                         mAccumulatedScrollErrorX = distanceX + mAccumulatedScrollErrorX - dx;
    369                         mAccumulatedScrollErrorY = distanceY + mAccumulatedScrollErrorY - dy;
    370 
    371                         mExtraParamBundleScroll.putInt(DISTANCE_X, dx);
    372                         mExtraParamBundleScroll.putInt(DISTANCE_Y, dy);
    373                         assert mExtraParamBundleScroll.size() == 2;
    374 
    375                         if ((dx | dy) != 0) {
    376                             sendGesture(GESTURE_SCROLL_BY,
    377                                     e2.getEventTime(), x, y, mExtraParamBundleScroll);
    378                         }
上のコードででっかく太字にしたところが、「8dp捨てられている」実際のコードです。
初回にスクロールイベントを判定した時点で一気にばっこーーん!と8dpスクロールとんでスクロールされるのは不自然なので、滑らかに見せるために、8dp捨ててスクロール量を計算しているようです。

 ・指に吸い付かせる

まずは、馬鹿になって、AndroidのWebViewの心遣い(ばっこーーーん!とスクロールが飛んでしまうのを防ぐために)を無視してしまいましょう。
    335                                 double ratio = Math.max(0, distance - 0) / distance;
これで
mmm external/chromium_org/android_webview && mmm frameworks/webview/chromium/

cd out/target/product/hammerhead/
adb root
adb remount
adb shell stop
adb sync system
adb reboot
端末を焼き帰ると、ぱっこーーーーん!と飛びながらも追従するブラウザが出来上がりましたね?
(一気にステップとばしすぎ!と思われても仕方がない書き方w ビルドの説明まで書いてるとキリがないので・・・)

で・・・
こんなのが製品として成り立つわけがありませんね。

実際に製品にする人々はどういう工夫をしているかというと・・・(以下略

F-06Fのオープンソースを覗いてみてください。


歯切れ悪いですが、以上です(笑)


もっといい方法があるよ!という意見がありましたら、どしどしコメントください。

2014/02/04

MonkeyRunnerとuiautomator

最近のAndroidでは、標準で使える(楽しい)テストツールもだんだんと充実してきました。今回は、そのなかで自動試験に使えそうな、MonkeyRunnerとuiautomatorを簡単に紹介します。
私はPython使いなので、uiautomatorもPythonバインディングの方しか知りません・・・。(Javaでの使い方は他のページもあるし、そちらをみてください)

※重要な心得※
自動試験は手数を減らすのが目的です。
手数が減らないようなものを自動試験しても仕方がないですし、目的が不明瞭なものはかえって自動化しようとしてできなくてハマります。
私の経験上、「なにか定型的な作業やってるなぁ」と感じた時こそが自動化のはじまりです。


・MonkeyRunner
概念的には

 PC…adb接続…→[tcpport:5555 Android端末]

てなかんじで、ソケット通信で命令をうじゃうじゃ流し込んで端末をあれこれ操作します。

PC側主体で端末をうじゃうじゃ動かす形式ゆえ、後述するuiautomatorのように、画面の作りを意識したような試験(「OKボタンをおす」とか「メニューの2番めの項目を選ぶ」とか)には向いていません。

逆に、画面の作りに依存しない試験についてはuiautomatorより簡単にかけます。

ドキュメントはおもに↓を見ておけばよいでしょう。(MonkeyRunnerクラスは、かなり特殊なケースでしか使わないので)
http://developer.android.com/tools/help/MonkeyDevice.html

で、いきなりコードをかいちゃいましょう。
中身はそれなりに読めばわかるでしょう。

# -*- coding:utf-8 -*-

# 決まり文句
import time
from com.android.monkeyrunner import MonkeyRunner, MonkeyDevice
d = MonkeyRunner.waitForConnection(deviceId="EP7331C9G7")

# screen on
d.wake()
time.sleep(1)

# swipe up for unlock
x=int(d.getProperty("display.width"),10)/2
y1=int(d.getProperty("display.height"),10)/4
y0=y1*3
d.drag((x,y0),(x,y1),0.2,10)
time.sleep(1)

# launch google chrome
d.press("KEYCODE_HOME",MonkeyDevice.DOWN_AND_UP)
time.sleep(1)
d.shell("am start -n com.android.chrome/com.google.android.apps.chrome.Main")
time.sleep(1)

# browse yahoo.com
d.press("KEYCODE_SEARCH",MonkeyDevice.DOWN_AND_UP)
time.sleep(1)
for i in xrange(200):
 d.press("KEYCODE_DEL",MonkeyDevice.DOWN_AND_UP)
d.type("http://www.yahoo.com/")
time.sleep(1)
d.press("KEYCODE_ENTER",MonkeyDevice.DOWN_AND_UP)
time.sleep(0.2)
d.press("KEYCODE_ENTER",MonkeyDevice.DOWN_AND_UP)
time.sleep(5)

# swipe up 3 times
y0=y1*2
for i in xrange(3):
 d.drag((x,y0),(x,y1),0.2,10)
 time.sleep(0.5)
time.sleep(3)

# swipe up 3 times
y0=y1*2
for i in xrange(3):
 d.drag((x,y1),(x,y0),0.2,10)
 time.sleep(0.5)
time.sleep(3)

# return to HOME
d.press("KEYCODE_HOME",MonkeyDevice.DOWN_AND_UP)
time.sleep(2)

d.press("KEYCODE_POWER",MonkeyDevice.DOWN_AND_UP)
time.sleep(1)

フリックはswipeとか気の利いたメソッドはないので、dragメソッドで点の数をうまく調整して実行してやる必要があります。
VSYNCの周期が60Hzなので、それを意識して1点あたり16msくらいになるようにdurationとstepを決めてやらないと、美しいスワイプ動作にはなりません。

これとは別にadb logcatを監視するスレッドを作ってやれば、
logcatに変なエラーが出た時だけそのスクリーンショットを残す、とか気の利いたことができます。
logcat監視スレッドはこのあたりのソースを拝借して改変すれば割と簡単に作れます。

繰り返し試験をやるときの注意ですが、これ結構重要で、
間違っても
for i in xrange(1000):
    print i,"回目のテスト"
    doTest()
なんてやってはいけません。
17回目くらいの試験でたまたま友達からのメールがとどいて、思ったように操作ができずMonkeyRunnerがエラーになったりしたら、そこで自動試験が終わっちゃいます。
17回目の試験でコケても18回目、それでコケても19回目、とやっていってもらわないと困りますね。
import os,sys
if sys.argv==1:
  for i in xrange(1000):
    os.system("monkeyrunner %s %d"%(sys.argv[0],d))
else:
  i=int(sys.argv[1],10)
  print i,"回目のテスト"
  doTest()
荒業ではありますが、こう書いたほうがはるかに頑健な自動試験スクリプトとなります。


・uiautomator
概念的にはMonkeyRunnerとは対照的で

 PC…実行プログラムを転送→[Android端末]

てなかんじで、Android端末側が自主的に、書かれた命令をうじゃうじゃ実行します。
画面を意識した試験を行うための命令セットもたくさん用意されているので、UIを中心に試験するなら間違いなくこっちでしょう。

Pythonバインディングのインストールは
sudo apt-get install python-pip
sudo pip install uiautomator

ちなみに、ドキュメント(※1)はあまり充実していないので、ソースコード(※2)とAndroidのリファレンス(※3)をページ内検索したり行ったり来たりで読むのがいいと思います。
※1 https://github.com/xiaocong/uiautomator の下の方
※2 https://github.com/xiaocong/uiautomator/blob/master/uiautomator.py
※3 http://developer.android.com/tools/help/uiautomator/UiDevice.html

あ、ちなみにWindowsじゃMonkeyRunnnerは動かない(らしい)ですが、uiautomatorは動きます!私はLinux使うのであんまり気にしてないですが、会社とかだとWindowsオンリー!っていうところもあるでしょうから。

WindowsでnumpyとかMatPlotlibとかひと通り入れた状態でのPython3環境ではありますが、以下のようにすればuiautomatorをインストールせずとも、味見程度に動かすことはできます。


mkdir uiautomator_test
cd uiautomator_test

# urllib3をおとしてくる
git clone https://github.com/shazow/urllib3.git
mv urllib3 _urllib3
mv _urllib3\ullib3 urllib3

# uiautomatorを落としてくる
git clone https://github.com/xiaocong/uiautomator.git

touch mytest.py
explorer .

C:\Users\yi01\Desktop\uiautomator_test>ls
_urllib3  mytest.py  uiautomator  urllib3
こんなかんじのフォルダ構成になったらmytest.pyにスクリプトをゴニョゴニョっと書いていきます。あ、あと一応adb接続でいろいろ送り込むので、以下のようになってる前提です。
C:\Users\yi01>adb devices
List of devices attached
EP7331C9G7      device

こちらもいきなりコードからですが、設定画面を開いて「マップ」のデータ全削除未遂をするという糞シナリオです。
設定画面のなかで「アプリ」というのはどこにあるか、座標的にはわかりませんよね?そういうMonkeyRunnerでは痒いところに手が届かない!というのが、uiautomatorでは手が届いちゃうんです。
# -*-codoing:utf-8 -*-

# 決まり文句
from uiautomator.uiautomator import device as d

#デバイスID指定でやりたいときは↓
#from uiautomator.uiautomator import Device
#d=Device("EP7331C9G7")

import time

for i in range(3):
    print("画面よ、つけー!")
    d.screen.on()
    time.sleep(1)

    print("画面よ、消えろー!")
    d.screen.off()
    time.sleep(1)

print("画面よ、つけー!")
d.screen.on()
time.sleep(2)

print("スワイプして画面ロック解除するぞ")
x=d.displayWidth/2
y0=d.displayHeight/2
y1=y0/2
y0+=y1/2
d.swipe(x, y0, x, y1, steps=12)
time.sleep(1)

print("HOME")
d.press.home()
time.sleep(1)


#リファレンスには書いてないけどソースを見るとadbも使えるっぽい。
def adb_shell(device,*cmd):
    c=["shell"]
    c.extend(cmd)
    print (" ".join(c))
    return [s.decode("utf-8") for s in device.server.adb.cmd(*c).communicate()]

print("設定アプリを立ち上げよう")
adb_shell(d,"am start com.android.settings/.Settings")
time.sleep(2)


d(text="アプリ").click()
time.sleep(3)

d(text="マップ").click()
time.sleep(3)

d(text="データを削除").click()
time.sleep(2)

print("イッヒッヒ!!")

d(text="キャンセル").click()
time.sleep(2)

print("やさしいから消さないもーん")

d.press.home()
time.sleep(2)

print("ほな、さいなら~")
time.sleep(2)

むちゃくちゃ手抜きですけど、こんな具合になります。↓

これだけだと、はて何に使うんかいね?って感じがすると思いますが、
d(text="hogehoge") 以外にもセレクタの条件いろいろ使えるみたいです。

  • texttextContainstextMatchestextStartsWith
  • classNameclassNameMatches
  • descriptiondescriptionContainsdescriptionMatchesdescriptionStartsWith
  • checkablecheckedclickablelongClickable
  • scrollableenabled,focusablefocusedselected
  • packageNamepackageNameMatches
  • resourceIdresourceIdMatches
  • indexinstance
↑公式リファレンスからイタダキしました。

resourceIdとか、自分の作ったアプリ以外だとわかんないじゃん!と思うかもしれません。
そんな人のためにも、Androidはすばらしいツールを用意してくれています。

uiautomatorviewer

FirefoxでいうDOMインスペクタ的なものです。
画面のスクリーンショット上で、この要素のIDなぁに?パッケージ名は?などなど
けっこういろいろ見えます。

使い方は
http://developer.android.com/tools/testing/testing_ui.html
ここに詳しく載ってるのでそちらを・・・。


そんなわけで、
ともかくも、活かすも殺すも、発想次第!

自動試験は思い通りに行かないことも多く、作るのに結構時間はかかりますが、
一度作ってしまえば終夜試験で大幅に工数削減とか、繰り返し試験で耐久性向上!なんてことがいとも簡単にできるようになります。

私も、半年前くらいから、自動試験の虜になってしまいました^ ^;;
さあ、ぜひぜひおためしあれ。

2013/10/27

「1秒でも早く申し込みたい!」を可能にしたGoogle Chromeの工夫

近年スマホを使う理由として、「Webが見やすいから」というのがトップにランクインしている。
就活生たちをみていても「いち早くシューカツサイトで説明会の申し込むために」スマホを購入した、という学生も少なくない。

しかし、「いち早くシューカツサイトで説明会の申し込み」をするのに、
じつは利用するブラウザによって5秒ほど差をつけられてしまう、という事実に気づいているだろうか?おそらくほとんどの人は気がついていないだろう。

仕組みから説明してしまうと長くなってしまうので、結論から言うと、
・Androidの標準ブラウザ、iPhoneのSafariは、Google Chromeよりも「タップ」の判定時間が300ミリ秒遅い
のである。

以下、その仕掛けをひもといていこう。


①「300ミリ秒遅い」を体感しよう

普段使いしていると、通信遅延のほうが大きいし、はっきりいって300ミリ秒くらいの遅延は気が付かないと思う。
それでも、以下のような手順をやってみると「あれ、意外と300ミリ秒って長いかも」という実感が生まれてくると思う。まずはそれを自分の手で確かめていただきたい。
(いまのところ、私が確認しているのはiPhone 4SとAndroid 4.2だけです。その他の環境だとうまくいかないかもしれません・・・)

・まずはGoogleChromeをインストールしましょう。


・とりあえずYahoo! Japanのトップページへ…
べつにどこのサイトでもいいのですが、Yahooでも見ましょう。
で、てきとうにここ↓をいつものようにタップしてみてください。



・次に、「設定」→「ユーザ補助」→「強制的にズームを有効にする」にチェックをつけて、再度Yahooのリンクをタップしてみよう

  


さて、違いがわかりましたか?

わからない、という人は、もう一度チェックを外して/つけて、
タップする際に、じっくりタップするのではなく、パッと画面を叩く程度に軽いタップをするように意識してやってみてください。

もうめんどくさい、という方のために、動画をとってみました。(最初から見せろよ、というツッコミはナシでw)
(0:00付近と0:23付近をよーく見比べてみてください)


わかりましたか?リンクが青く反転するまでの時間(=タップの判定がされるまでの時間)が"なんとなく"違いますね?
その"なんとなく"の違いこそが、説明は後述しますが、300ミリ秒の違いなんです。


②なぜ300ミリ秒おそいの?

どうしてGoogle Chromeが300ミリ秒早いの?を知るには、
そもそもなぜ「300ミリ秒遅いの?」という理由を知る必要があります。
そして理由は意外と単純で、さきほど触っていただいた「強制的にズームを有効にする」と関係があります。

これも結論から言うと、ダブルタップかシングルタップかを判定するために300ミリ秒が必要なのです。

AndroidやiPhone端末は、ブラウザ画面でダブルタップすると拡大しますね。
もう少し専門用語で言うと、WebViewはブラウザに対して「シングルタップが2回」なのか「ダブルタップ」なのかを判別した上で、イベントハンドラをコールバックしないといけないわけです。


●ダブルタップ
-----------↓-↑----↓--↑-----------------

●シングルタップが2回
---↓---↑----------------------↓--↑-----


Android標準ブラウザでは、「シングルタップが2回」なのか「ダブルタップ」なのかは、
上の模式図でしめしたところの ↑(1回目のTouchUp)から ↓(2回目のTouchDown)まで
 300ミリ秒以内 →ダブルタップと認識する
 300ミリ秒以上 →シングルタップが2回と認識する
となっています。

こちらも専門用語で言うと、↑(1回目のTouchUp)が来て、300ミリ秒待ってみて
 その間に↓(TouchDown)が来れば「ダブルタップ」をコールバック
 その間に↓(TouchDown)が来なければ「シングルタップ」をコールバック
します。

さらに詳しく、ソースコードでいうと、

frameworks/base/core/java/android/webkit/WebViewInputDispatcher.java
    448 
    449     private void scheduleClickLocked() {
    450         unscheduleClickLocked();
    451         mPostClickScheduled = true;
    452         mUiHandler.sendEmptyMessageDelayed(UiHandler.MSG_CLICK, DOUBLE_TAP_TIMEOUT);
    453     }
    454 
    455     private void unscheduleClickLocked() {
    456         if (mPostClickScheduled) {
    457             mPostClickScheduled = false;
    458             mUiHandler.removeMessages(UiHandler.MSG_CLICK);
    459         }
    460     }

452行目のDOUBLE_TAP_TIMEOUTこそが、300ミリ秒という値です。

frameworks/base/core/java/android/view/ViewConfiguration.java
     92     /**
     93      * Defines the duration in milliseconds between the first tap's up event and
     94      * the second tap's down event for an interaction to be considered a
     95      * double-tap.
     96      */
     97     private static final int DOUBLE_TAP_TIMEOUT = 300;

scheduleClickLockedとunscheduleClickLockedの呼び元を見ると、ざっくり前述の説明のような条件分岐で、ダブルタップかシングルタップかを判定しているのがわかるかと思います。(気になる人は調べてみてください)


③なぜGoogle Chromeは300ミリ秒の待ちをなくすことができたの?

さて、Google Chromeの工夫を説明する準備が整いました。

前述の通り、普通の発想では
 300ミリ秒以内 →ダブルタップと認識する
 300ミリ秒以上 →シングルタップが2回と認識する
という条件分岐があることから、300ミリ秒待たないという選択肢は無いように見えます。

では、Google Chromeは一体どのようにしてこの300ミリ秒をなくしたのでしょうか?

実は答えはすでにここまでで書いています。

そう、Google Chromeは2つの点に着目しました。
(※あくまで私の予想なので、もっとあるかもしれません…他にも工夫点を見つけられたら教えてくださいw)

・そもそも拡大する必要のあるページなのか
  →拡大が禁止されているページでは、ダブルタップ操作は不要なので、すべてシングルタップ判定をさせればいい!そうすれば300ミリ秒またなくてもいい!
    →だから、「強制的にズームを有効にする」のOn/Offで300ミリ秒遅いかどうかの挙動が変わったのです。

・ダブルタップかどうかを↑(1回目のTouchUp)時点でできるだけ判定できないか
  → ↓(1回目のTouchDown)から↑(1回目のTouchUp)まで"じっくり"タップした場合は高確率でダブルタップではない。そうすれば、300ミリ秒またなくても、↑(1回目のTouchUp)ですぐにシングルタップ判定を出せる!
    →だから、「強制的にズームを有効にする」にチェックがついていても"じっくり"タップしている人にとっては300ミリ秒遅いことは体感しにくい、と前述していたのです。


意外と単純ですね。


●最後に・・・

ここまで読んでくださった読者には、少しだけいいことを教えましょう。

冒頭でAndroid標準ブラウザは300ミリ秒遅いって書いてしまいましたが、
実は遅くない端末がいくつか有ります。

私が確認した限りでは
・Xperia AX SC-01E
・REGZA Phone T-02D
・Arrows NX F-06E, F-01F

自分がFの中の人なので、Fの端末が多くてすみません。多分他にもあるでしょう。

これらの製品はAOSPそのものではなく、チップベンダーがカスタマイズしてきたAndroidソースをベースに作られているので、実は彼らがChromeの仕組みを知っていてこっそりと改良を入れて出してきているのかもしれません。

「タップが早くできるから端末を買おう」だなんて人はいないと思います。
でも、こういうカタログスペックに見えない所で「使っていて"なんとなく"気持ちがいい」というユーザの使用感の向上のために日々努力している人がいることも確かです。

今回、Chromeの挙動を調べてみて、自分も同じ開発者として、そういう一員でありたいなと思った次第でした。

2013/09/30

auのiPhone 4Sユーザー、3年目の悩み。

かれこれiPhone 4Sを買ってもうすぐ2年。

Jailbreakしてサーバにしてみたり、gccいれてネイティブでプログラミングしてみたり、
20回くらいおとしたり、水につけたり、まあ色々あったものの、
iOS 5.1.2のiPhone 4Sはいまのところこれと言って不満なく使っている。

しかし、3年を迎えるにあたり、大きな問題が見えてきた。利用料金である。
いろいろ不安だったので、auショップにいって聞いてきて、いろいろ教えてもらった。


もともと自分は「実質○○円」なんて考え方は無理で、いつケータイを変えても困らないために機種代は借金無く一括払いすることにしている。

iPhone 4S購入時には、その借金返済サポートとして2年間、料金が2140円ずつ割り引かれる素晴らしい特典があったのだ。そう、2年間だけは。

3年目からは、それがなくなり、一気に利用料金が跳ね上がる。具体的には



〜2年目 3年目〜
TEL基本料金 980 980
メール基本料金 315 315
パケット定額 4980 5460
毎月割 -2140 0

4135 6755

こんなに跳ね上がるのだ。

正直言うと、2500円増しの料金を払い続けるくらいならば、MNPで適当に他キャリアに乗り換えて30000円キャッシュバックとか受けつつ、毎月料金を5000円以下に抑えたほうが、総合的に安くつくように思えてしまう。

auショップでドコモの値段を聞くわけにも行かなかったので、
MNPつかわないで機種変でiPhone 5Sにしたらどうなるの?と聞いてみた。



〜2年目
TEL基本料金 980
メール基本料金 315
パケット定額 5460
毎月割 -2245

4510

毎月の値段はこんなもん。で、注目の頭金が、

本体68040円(16GBモデル) - 15750円(ネットクーポンを使った場合) = 52290円

これがいわゆる「実質負担0円」マジックなんだと思うが、iPhone 4Sを使い続けるのとかわらないわけだ。
(なぜなら、iPhone 4SもiPhone 5Sも16GBは「実質0円」を謳って販売しているから)


ドコモのほうはこれから聞いてこようと思うが、
正直、本体代金がバカ高いし、iPhoneに対する売り方?考え方?がおかしい気がするし、MNPで数千円安く変えたとしても、乗り換えないだろう。

やっぱり2台持ちかな・・・。