"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のオープンソースを覗いてみてください。


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


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

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
ここに詳しく載ってるのでそちらを・・・。


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

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

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

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

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

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

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

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


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

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

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


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



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

  


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

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

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


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


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

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

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

AndroidiPhone端末は、ブラウザ画面でダブルタップすると拡大しますね。
もう少し専門用語で言うと、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の挙動を調べてみて、自分も同じ開発者として、そういう一員でありたいなと思った次第でした。