##ことのあらまし
Android開発者の手元に日々届くクラッシュレポート、しかしここのところずっとクラッシュ原因のトップを占めていたのはまったく身に覚えのないものでした。
java.lang.NullPointerException
at android.webkit.HTML5VideoView.release(HTML5VideoView.java:255)
at android.webkit.HTML5VideoViewProxy$VideoPlayer.suspend(HTML5VideoViewProxy.java:165)
at android.webkit.HTML5VideoViewProxy.suspend(HTML5VideoViewProxy.java:874)
at android.webkit.HTML5VideoViewManager.suspend(HTML5VideoViewManager.java:75)
at android.webkit.WebViewClassic.loadUrlImpl(WebViewClassic.java:2764)
at android.webkit.WebViewClassic.loadUrlImpl(WebViewClassic.java:2786)
at android.webkit.WebViewClassic.loadUrl(WebViewClassic.java:2779)
at android.webkit.WebView.loadUrl(WebView.java:807)
java.lang.NullPointerException
at android.webkit.HTML5VideoView.release(HTML5VideoView.java:256)
at android.webkit.HTML5VideoViewProxy$VideoPlayer.suspend(HTML5VideoViewProxy.java:173)
at android.webkit.HTML5VideoViewProxy.handleMessage(HTML5VideoViewProxy.java:467)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:213)
at android.app.ActivityThread.main(ActivityThread.java:4786)
at java.lang.reflect.Method.invokeNative(Method.java)
at java.lang.reflect.Method.invoke(Method.java:511)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:789)
なんだこれ android.webkit.HTML5VideoView.release
調査
このクラッシュはAndroidのOSバージョン4.1.2でしか発生していない。
クラスとメソッド名を見る限り、動画が含まれるWebページを見た後に、それを消すタイミングでNPEが出ているもよう。
だけどもいくら該当端末をいじっても再現が出来ない……
AndroidのJavaライブラリは公式のgitリポジトリで公開されている。
https://github.com/android/platform_frameworks_base
なので該当するソースコードを直接見てしまえば原因がわかる!はずなのだが……release
なんてメソッドがどこにも見当たらない……
ところがスタックトレースの中にあるHTML5VideoViewManager
でググった結果、謎のgitリポジトリが見つかった。
如何にも怪しい……が、確かにHTML5VideoView.release
というメソッドがある!
https://github.com/xdtianyu/android_04.01.01_msm7627a/blob/master/frameworks/base/core/java/android/webkit/HTML5VideoView.java#L253
これさえあればこのファイルが公式gitリポジトリの中でどのコミットに含まれているか調べられるぞ。
$ cd platform-frameworks-base
$ curl -O https://raw.githubusercontent.com/xdtianyu/android_04.01.01_msm7627a/master/frameworks/base/core/java/android/webkit/HTML5VideoView.java
$ git hash-object HTML5VideoView.java
cbda6c6052009ae424fc59118ed8d78eb2b74caa
$ git cat-file -p cbda6c6052009ae424fc59118ed8d78eb2b74caa
fatal: Not a valid object name cbda6c6052009ae424fc59118ed8d78eb2b74caa
なんだよ……何なんだよお前……
解析
おそらくファイルが見つからない原因は、このファイルを保持していた開発ブランチが削除され、git gc
でコミットごと消されてしまったためでしょう。何でそんなのが市場に出回っているのかはともかく……
こいつ誰だよといった疑問はさておき、ソースが存在しようがしまいが、クラッシュは存在しているのでとにかく調べる。
Handler
から非同期的に呼ばれている方はちょっと手を出すのが難しそうなので、明示的にこちらがWebView.loadUrl
を読んでいるところを追っていきましょう。
public void loadUrl(String url) {
checkThread();
mProvider.loadUrl(url);
}
public void loadUrl(String url) {
loadUrlImpl(url);
}
private void loadUrlImpl(String url) {
if (url == null) {
return;
}
loadUrlImpl(url, null);
}
private void loadUrlImpl(String url, Map<String, String> extraHeaders) {
switchOutDrawHistory();
if (mHTML5VideoViewManager != null)
mHTML5VideoViewManager.suspend();
WebViewCore.GetUrlData arg = new WebViewCore.GetUrlData();
arg.mUrl = url;
arg.mExtraHeaders = extraHeaders;
mWebViewCore.sendMessage(EventHub.LOAD_URL, arg);
clearHelpers();
}
public void suspend() {
assert (mUiThread == Thread.currentThread());
Iterator<HTML5VideoViewProxy> iter = mProxyList.iterator();
while (iter.hasNext()) {
HTML5VideoViewProxy proxy = iter.next();
proxy.suspend();
}
}
public void suspend() {
mVideoPlayer.suspend();
}
private class VideoPlayer {
public void suspend() {
if (mHTML5VideoView != null) {
mHTML5VideoView.pause();
mCachedPosition = getCurrentPosition();
mHTML5VideoView.release();
setBaseLayer(0);
mHTML5VideoView = null;
isVideoSelfEnded = false;
end();
}
}
}
public void release() {
if (mCurrentState != STATE_RELEASED) {
stopPlayback();
mPlayer.release();
mSurfaceTexture.release();
}
mCurrentState = STATE_RELEASED;
}
mSurfaceTexture
がnull
の場合に、ここでNullPointerException
が発生している!
でなんでこいつがnull
なのか。原因は後述しますが、直接の理由は以下の通り。
// SurfaceTexture will be created lazily here
public SurfaceTexture getSurfaceTexture() {
// Create the surface texture.
if (mSurfaceTexture == null || mTextureNames == null) {
mTextureNames = new int[1];
GLES20.glGenTextures(1, mTextureNames, 0);
mSurfaceTexture = new SurfaceTexture(mTextureNames[0]);
}
return mSurfaceTexture;
}
mSurfaceTexture
はコンストラクタで初期化されずに、必要になった際にlazy initializationされているよう。あの、release
でも必要みたいなんですが……
原因
さて、なぜmSurfaceTexture
がnull
になるのか。まずHTML5VideoView
を作っている部分はHTML5VideoViewProxy.VideoPlayer
のこのメソッド。
private boolean ensureHTML5VideoView(String url, int time, boolean willPlay) {
if (mHTML5VideoView == null) {
mHTML5VideoView = new HTML5VideoView(mProxy, time);
mHTML5VideoView.setStartWhenPrepared(willPlay);
mHTML5VideoView.setVideoURI(url);
return true;
}
return false;
}
そしてensureHTML5VideoView
を呼んでいるのはこやつら。
public void enterFullscreenVideo(String url, float x, float y, float w, float h) {
if (ensureHTML5VideoView(url, mCachedPosition, false)) {
mHTML5VideoView.prepareDataAndDisplayMode();
}
mHTML5VideoView.enterFullscreenVideoState(mWebView, x, y, w, h);
}
public void loadMetadata(String url) {
if (ensureHTML5VideoView(url, 0, false)) {
mHTML5VideoView.retrieveMetadata(mProxy);
}
}
public void load(String url) {
if (ensureHTML5VideoView(url, 0, false)) {
mHTML5VideoView.prepareDataAndDisplayMode();
}
}
public void play(String url, int time) {
if (ensureHTML5VideoView(url, time, true)) {
mHTML5VideoView.prepareDataAndDisplayMode();
mHTML5VideoView.seekTo(time);
} else {
// Here, we handle the case when we keep playing with one video
if (!mHTML5VideoView.isPlaying()) {
mHTML5VideoView.start();
setBaseLayer(0);
}
}
}
HTML5VideoView#prepareDataAndDisplayMode
の中身はこうなっている。
public void prepareDataAndDisplayMode() {
decideDisplayMode();
...
}
public void decideDisplayMode() {
SurfaceTexture surfaceTexture = getSurfaceTexture();
Surface surface = new Surface(surfaceTexture);
mPlayer.setSurface(surface);
surface.release();
}
この中ではちゃんとmSurfaceTexture
が初期化されている……つまり唯一これを呼んでいないloadMetaData
! おまえか! おまえか、このやろう!
でこのloadMetaData
がどういうときに呼び出されているかというと、どうやらHTMLで
<video src="movie.mp4" preload="metadata"></video>
として動画のサイズや長さなどの情報をプリロードしているときのよう。しかしこのようなHTMLを用意しても結局クラッシュは再現できなかった。タスケテ……タスケテ……
対策
以上の処理を見る限り、このNPEをナントカ倒してしまえば他の部分へ影響が出ることは無いだろう。オーバーライドかなにかでHTML5VideoView.release
処理を差し替えることができればよかったのだが、どうにも難しそう……なので、次のような対策を考えた。
-
release
処理でmSurfaceTexture
がnull
になっているのが落ちる原因である。 - こいつは
getSurfaceTexture
で遅延初期化されるフィールド。 - 従って
release
直前にgetSurfaceTexture
を呼んでしまえば、無駄にSurfaceTextureを生成/破棄してしまうがともかくクラッシュは解消される。 - 正常な処理では
mSurfaceTexture != null
のはずだから、余分にgetSurfaceTexture
を呼んでも何も問題ない。
つまり、WebView.loadUrl
を呼ぶ手前で次のような処理をあらかじめ行えばよい!
HTML5VideoViewManager manager = webView.mProvider.mHTML5VideoViewManager;
if (manager != null) {
for (HTML5VideoViewProxy proxy : manager.mProxyList) {
HTML5VideoView videoView = proxy.mVideoPlayer.mHTML5VideoView;
if (videoView != null) {
videoView.getSurfaceTexture();
}
}
}
ただ問題なのは、このコードに出てくる変数やらクラスやらは当然privateなのでまともにアクセスすることができません。どうする? そうだね、リフレクションだね。
できあがったものがこちらです
public class MyWebView extends WebView {
@Override
public void loadUrl(String url) {
fixHTML5VideoViewCrash();
super.loadUrl(url);
}
private void fixHTML5VideoViewCrash() {
if (!Build.VERSION.RELEASE.equals("4.1.2")) {
return;
}
HTML5VideoViewReflectionObjects r = HTML5VideoViewReflectionObjects.INSTANCE;
if (r == null) {
return;
}
Object provider = r.getWebViewProviderMethod.invoke(this);
Object manager = r.mHTML5VideoViewManagerField.get(provider);
if (manager != null) {
List<?> proxyList = (List<?>) r.mProxyListField.get(manager);
for (Object proxy : proxyList) {
Object videoPlayer = r.mVideoPlayerField.get(proxy);
Object videoView = r.mHTML5VideoViewField.get(videoPlayer);
if (videoView != null) {
r.getSurfaceTextureMethod.invoke(videoView);
}
}
}
}
private static class HTML5VideoViewReflectionObjects {
public static final HTML5VideoViewReflectionObjects INSTANCE = create();
public final Class<?> WebViewClassicClass;
public final Class<?> HTML5VideoViewManagerClass;
public final Class<?> HTML5VideoViewProxyClass;
public final Class<?> HTML5VideoViewProxy$VideoPlayerClass;
public final Class<?> HTML5VideoViewClass;
public final Method getWebViewProviderMethod;
public final Field mHTML5VideoViewManagerField;
public final Field mProxyListField;
public final Field mVideoPlayerField;
public final Field mHTML5VideoViewField;
public final Method getSurfaceTextureMethod;
public HTML5VideoViewReflectionObjects() throws ClassNotFoundException, NoSuchFieldException, NoSuchMethodException {
WebViewClassicClass = Class.forName("android.webkit.WebViewClassic");
HTML5VideoViewManagerClass = Class.forName("android.webkit.HTML5VideoViewManager");
HTML5VideoViewProxyClass = Class.forName("android.webkit.HTML5VideoViewProxy");
HTML5VideoViewProxy$VideoPlayerClass = Class.forName("android.webkit.HTML5VideoViewProxy$VideoPlayer");
HTML5VideoViewClass = Class.forName("android.webkit.HTML5VideoView");
getWebViewProviderMethod = WebView.class.getDeclaredMethod("getWebViewProvider");
mHTML5VideoViewManagerField = WebViewClassicClass.getDeclaredField("mHTML5VideoViewManager");
mProxyListField = HTML5VideoViewManagerClass.getDeclaredField("mProxyList");
mVideoPlayerField = HTML5VideoViewProxyClass.getDeclaredField("mVideoPlayer");
mHTML5VideoViewField = HTML5VideoViewProxy$VideoPlayerClass.getDeclaredField("mHTML5VideoView");
getSurfaceTextureMethod = HTML5VideoViewClass.getDeclaredMethod("getSurfaceTexture");
AccessibleObject.setAccessible(new AccessibleObject[]{
mHTML5VideoViewManagerField, mProxyListField, mVideoPlayerField, mHTML5VideoViewField
}, true);
}
public static HTML5VideoViewReflectionObjects create() {
try {
return new HTML5VideoViewReflectionObjects();
} catch (Exception e) {
return null;
}
}
}
}
結果
これを組み込んだ結果、loadUrl
からのクラッシュはとりあえず発生しなくなったはず。しかしもう一方のHandler
から呼ばれている方は未だに解決できていない……。この手記を見つけた誰かが屍を乗り越えて跡を継いでくれると信じ、ここに筆を置きます。