LoginSignup
39
35

More than 5 years have passed since last update.

Android 4.1.2 WebView クラッシュ問題

Last updated at Posted at 2015-03-28

ことのあらまし

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を読んでいるところを追っていきましょう。

WebView.java
    public void loadUrl(String url) {
        checkThread();
        mProvider.loadUrl(url);
    }
WebViewClassic.java
    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();
    }
Html5VideoViewManager.java
    public void suspend() {
        assert (mUiThread == Thread.currentThread());
        Iterator<HTML5VideoViewProxy> iter = mProxyList.iterator();
        while (iter.hasNext()) {
            HTML5VideoViewProxy proxy = iter.next();
            proxy.suspend();
        }
    }
HTML5VideoViewProxy.java
    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();
            }
        }
    }
HTML5VideoView.java
    public void release() {
        if (mCurrentState != STATE_RELEASED) {
            stopPlayback();
            mPlayer.release();
            mSurfaceTexture.release();
        }
        mCurrentState = STATE_RELEASED;
    }

mSurfaceTexturenullの場合に、ここでNullPointerExceptionが発生している!

でなんでこいつがnullなのか。原因は後述しますが、直接の理由は以下の通り。

HTML5VideoView.java
    // 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でも必要みたいなんですが……

原因

さて、なぜmSurfaceTexturenullになるのか。まずHTML5VideoViewを作っている部分はHTML5VideoViewProxy.VideoPlayerのこのメソッド。

HTML5VideoViewProxy.java
        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を呼んでいるのはこやつら。

HTML5VideoViewProxy.java
        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の中身はこうなっている。

HTML5VideoView.java
    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処理でmSurfaceTexturenullになっているのが落ちる原因である。
  • こいつは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なのでまともにアクセスすることができません。どうする? そうだね、リフレクションだね。

できあがったものがこちらです

MyWebView.java
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から呼ばれている方は未だに解決できていない……。この手記を見つけた誰かが屍を乗り越えて跡を継いでくれると信じ、ここに筆を置きます。

39
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
35