從 Android 靜音看正確的查bug的姿勢?

騰訊bugly發表於2016-02-23

0、寫在前面

沒搶到小馬哥的紅包,無心回家了,回公司寫篇文章安慰下自己TT。。話說年關難過,bug多多,時間久了難免頭昏腦熱,不辨朝暮,難識乾坤。。。艾瑪,扯遠了,話說誰沒踩過坑,可視大家都是如何從坑裡爬出來的呢?

1、實現個靜音的功能

話說,有那麼一天,

PM:『我這裡有個需求,很簡單很簡單那種』

RD:『哦,需要做三天』

PM:『真的很簡單很簡單那種』

RD:『哦,你又說了一遍很簡單,那麼現在需要做六天了』

對呀,靜音功能多簡單,點一下,欸,靜音了;再點一下,欸,不靜音了;再點一下,欸。。。

我一看API,是挺簡單的:

private void setMuteEnabled(boolean enabled){
    AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
    mAudioManager.setStreamMute(AudioManager.STREAM_MUSIC, enabled);
}

是吧,多簡單,三分鐘搞定。不過說真的,這並不是什麼好兆頭,太簡單了,簡單到令人窒息啊!

2、『您好,我是京東快遞,您有一個bug簽收一下』

話說,過了幾天,

QA:『如果我先開啟靜音,然後退出我們的app再進來,儘管頁面顯示靜音狀態,但我無法取消靜音啊』

RD:『一定是你的用法有問題!』

當然,我也挺心虛的啊,因為這段程式碼我總共花了三分鐘,說有bug,我也不敢不信吶。我們再來細細把剛才的場景理一遍:

  1. 開啟app,開啟靜音
  2. 點選返回鍵,直到app進入後臺執行
  3. 重新點選app的icon,啟動app,此時期望app中的靜音按鈕顯示為靜音開啟的狀態,並且點選可以取消靜音。當然,實際上並不是這樣 (|_|)

有個問題需要交代一下,Android api並沒有提供獲取當前音訊通道是否靜音的api(為什麼沒有?你。。你居然問我為什麼?你為什麼這麼著急?往後看就知道啦),所以我在進入app載入view時,要根據本地儲存的靜音狀態來初始化view的狀態:

boolean persistedMute = mute.getContext().getSharedPreferences("volume", Context.MODE_PRIVATE).getBoolean("Volume.Mute", false);
muteButton.setChecked(persistedMute);

而這個欄位是在使用者點選了muteButton之後被存入SharedPreference當中的。

不可能啊,到這裡毫無懸念可言啊,肯定是沒有問題的呀。

接著看,這時候我們要取消靜音了,呼叫的程式碼就是下面這段程式碼:

private void setMuteEnabled(boolean enabled){
    AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
    mAudioManager.setStreamMute(AudioManager.STREAM_MUSIC, enabled);
}

然後,app一臉不屑的看都不看灑家一眼,依舊不吱聲。

坑爹呢吧!!自行腦補我摔手機的場景

正是:自古bug多簡單,惹得騷年盡難眠。?

3、『你可以告訴我該靜音或者不靜音,但聽不聽那是我的事兒』

我這麼無辜,寥寥幾行程式碼,能犯什麼錯誤呢?所以問題一定出在官方的API上。

AudioManager.java


    /**
     * Mute or unmute an audio stream.
     * <p>
     * The mute command is protected against client process death: if a process
     * with an active mute request on a stream dies, this stream will be unmuted
     * automatically.
     * <p>
     * The mute requests for a given stream are cumulative: the AudioManager
     * can receive several mute requests from one or more clients and the stream
     * will be unmuted only when the same number of unmute requests are received.
     * <p>
     * For a better user experience, applications MUST unmute a muted stream
     * in onPause() and mute is again in onResume() if appropriate.
     * <p>
     * This method should only be used by applications that replace the platform-wide
     * management of audio settings or the main telephony application.
     * <p>This method has no effect if the device implements a fixed volume policy
     * as indicated by {@link #isVolumeFixed()}.
     *
     * @param streamType The stream to be muted/unmuted.
     * @param state The required mute state: true for mute ON, false for mute OFF
     *
     * @see #isVolumeFixed()
     */
    public void setStreamMute(int streamType, boolean state) {
        IAudioService service = getService();
        try {
            service.setStreamMute(streamType, state, mICallBack);
        } catch (RemoteException e) {
            Log.e(TAG, "Dead object in setStreamMute", e);
        }
    }

我們摘出最關鍵的一句,大家一起來樂呵樂呵。。。。

The mute requests for a given stream are cumulative: the AudioManager
can receive several mute requests from one or more clients and the stream
will be unmuted only when the same number of unmute requests are received.

就是說,我們可以傳送任意次靜音請求,而想要取消靜音,還得發出同樣次數的取消靜音請求才可以真正取消靜音。

好像找到答案了。不對呀,我以你的人格擔保,我只發了一次靜音請求啊,怎麼取消靜音就這麼費勁呢!

4、『這是我的名片』

突然,嗯,就是在這時,我想起前幾天我那本被茶水泡了的《深入理解Android》卷③提到,其實每個app都可以傳送靜音請求,而且各自都是單獨計數的。那麼問題來了,每個app發靜音請求的唯一身份標識是啥嘞?

還是要看設定靜音的介面方法:

AudioManager.java

   public void setStreamMute(int streamType, boolean state) {
        IAudioService service = getService();
        try {
            service.setStreamMute(streamType, state, mICallBack);
        } catch (RemoteException e) {
            Log.e(TAG, "Dead object in setStreamMute", e);
        }
    }

這個service其實是AudioService的一個例項,當然,其實AudioManager本身所有操作都是轉發給AudioService的。

AudioService.java

    /** @see AudioManager#setStreamMute(int, boolean) */
    public void setStreamMute(int streamType, boolean state, IBinder cb) {
        if (mUseFixedVolume) {
            return;
        }

        if (isStreamAffectedByMute(streamType)) {
            if (mHdmiManager != null) {
                synchronized (mHdmiManager) {
                    if (streamType == AudioSystem.STREAM_MUSIC && mHdmiTvClient != null) {
                        synchronized (mHdmiTvClient) {
                            if (mHdmiSystemAudioSupported) {
                                mHdmiTvClient.setSystemAudioMute(state);
                            }
                        }
                    }
                }
            }
            mStreamStates[streamType].mute(cb, state);
        }
    }

最後一行我們看到實際上設定靜音需要傳入cb也就是AudioManager傳入的mICallBack,以及是靜音還是取消靜音的操作state,而這個mute方法本質上也是呼叫了VolumeDeathHandler的mute方法,我們直接看這個方法的原始碼:

AudioService.VolumeDeathHandler

public void mute(boolean state) {
    boolean updateVolume = false;
    if (state) {
        if (mMuteCount == 0) {
            // Register for client death notification
            try {
                // mICallback can be 0 if muted by AudioService
                if (mICallback != null) {
                    mICallback.linkToDeath(this, 0);
                }
                VolumeStreamState.this.mDeathHandlers.add(this);
                // If the stream is not yet muted by any client, set level to 0
                if (!VolumeStreamState.this.isMuted()) {
                    updateVolume = true;
                }
            } catch (RemoteException e) {
                // Client has died!
                binderDied();
                return;
            }
        } else {
            Log.w(TAG, "stream: "+mStreamType+" was already muted by this client");
        }
        mMuteCount++;
    } else {
        if (mMuteCount == 0) {
            Log.e(TAG, "unexpected unmute for stream: "+mStreamType);
        } else {
            mMuteCount--;
            if (mMuteCount == 0) {
                // Unregister from client death notification
                VolumeStreamState.this.mDeathHandlers.remove(this);
                // mICallback can be 0 if muted by AudioService
                if (mICallback != null) {
                    mICallback.unlinkToDeath(this, 0);
                }
                if (!VolumeStreamState.this.isMuted()) {
                    updateVolume = true;
                }
            }
        }
    }
    if (updateVolume) {
        sendMsg(mAudioHandler,
        MSG_SET_ALL_VOLUMES,
        SENDMSG_QUEUE,
        0,
        0,
        VolumeStreamState.this, 0);
    }
}

其實這個方法的邏輯比較簡單,如果靜音,那麼mMuteCount++,否則—。這裡面還有一個邏輯處理了傳送了靜音請求的app因為crash而無法發出取消靜音的請求的情形,如果出現這樣的情況,系統會直接清除這個app發出的所有靜音請求來使系統音訊正常工作。

那麼,mMuteCount是VolumeDeathHandler的成員,而VolumeDeathHandler的唯一性主要體現在傳入的IBinder例項cb上。

AudioService.VolumeDeathHandler

private class VolumeDeathHandler implements IBinder.DeathRecipient {
    private IBinder mICallback; // To be notified of client's death
    private int mMuteCount; // Number of active mutes for this client

    VolumeDeathHandler(IBinder cb) {
        mICallback = cb;
    }

    ……
}

結論就是:AudioManager的mICallBack是靜音計數當中發起請求一方的唯一身份標識。

5、『其實,剛才不是我』

對呀,有名片啊,問題是我這是同一個app啊,同一個啊……問題出在哪裡了呢。

剛才我們知道了,其實靜音請求計數是以AudioManager當中的一個叫mICallBack的傢伙為唯一標識的,這個傢伙是哪裡來的呢?

AudioManager.java

private final IBinder mICallBack = new Binder();

我們發現,其實對於同一個AudioManager來說,這個mICallBack一定是同一個。反過來說,我們在操作靜音和取消靜音時沒有效果,應該就是因為我們的mICallBack不一樣,如果是這樣的話,那麼說明AudioManager也不一樣。。。

操曰:『天下英雄,唯使君與操耳』

玄德大驚曰:『操耳是哪個嘛?』

正當我收起我驚呆了的下巴的時候,我回過神來,準備對AudioManager的身世一探究竟。且說,AudioManager是怎麼來的?

AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);

那麼這個getSystemService又是什麼來頭??經過一番查證,我們發現,其實這個方法最終是在ContextImpl這個類當中得以實現:

ContextImpl.java

    @Override
    public Object getSystemService(String name) {
        ServiceFetcher fetcher = SYSTEM_SERVICE_MAP.get(name);
        return fetcher == null ? null : fetcher.getService(this);
    }

那麼問題的關鍵就在與我們拿到的這個ServiceFetcher例項了。且看它的get方法實現:

ContextImpl.ServiceFetcher

        public Object getService(ContextImpl ctx) {
            ArrayList<Object> cache = ctx.mServiceCache;
            Object service;
            synchronized (cache) {
                if (cache.size() == 0) {
                    // Initialize the cache vector on first access.
                    // At this point sNextPerContextServiceCacheIndex
                    // is the number of potential services that are
                    // cached per-Context.
                    for (int i = 0; i < sNextPerContextServiceCacheIndex; i++) {
                        cache.add(null);
                    }
                } else {
                    service = cache.get(mContextCacheIndex);
                    if (service != null) {
                        return service;
                    }
                }
                service = createService(ctx);
                cache.set(mContextCacheIndex, service);
                return service;
            }
        }

如果有快取的Service例項,就直接取出來返回;如果沒有,呼叫createService返回一個。再看看下面的片段,這個問題就很清楚了:

        registerService(AUDIO_SERVICE, new ServiceFetcher() {
                public Object createService(ContextImpl ctx) {
                    return new AudioManager(ctx);
                }});

這一句就實際上往SYSTEM_SERVICE_MAP.get當中新增了一個與AudioService有關的ServiceFetcher例項,而這個例項裡面居然直接new了一個AudioManager。

等會兒讓我想會兒靜靜。它在這裡new了一個AudioManager。它怎麼能new了一個AudioManager呢。

按照我們剛才的推斷,前後兩次操作AudioManager是不一樣的,而同一個Context返回的AudioManager只能是一個例項,換句話說,只要我們每次獲取AudioManager時使用的Context不是同一個例項,那麼AudioManager就不是同一個例項,繼而mICallBack也不是同一個,所以音訊服務會以為是兩個毫不相干的靜音和取消靜音的請求。

再來看看我們用的Context會有什麼問題。

AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);

這段程式碼是在View當中的,換句話說,getContext返回的是初始化View時傳入的Context。初始化這個View傳入的Context是我們唯一的Activity。這時,我不說,大家也會猜到下面的內容了:

靜音時的Activity例項和第二次進入引用時取消靜音時的Activity根本不可能是同一個例項,因此這兩個操作是不相干的。由於系統只要收到任意的靜音請求都會使對應的音訊通道進入靜音狀態,因此即使我們用另一個AudioManager發出了取消靜音的請求,不過然並卵。

6、『這事兒還是交給同一個人辦比較靠譜』

有了前面的分析,解決方法其實也就浮水而出了:

AudioManager mAudioManager = (AudioManager) getContext().getApplicationContext().getSystemService(Context.AUDIO_SERVICE);

我們只要使用Application全域性Context去獲取AudioManager不就沒有那麼多事兒了麼?其實儘可能地引用Application而不是Activity,在很多場合甚至會避免記憶體洩露。有朋友問起什麼時候應該用Application,什麼時候應該用Activity,答案很明顯,只要是Application可以做到的,就一律不要用Activity,除非引用方的生命週期跟Activity的生命週期一致。

再來回答,為什麼系統沒有提供獲取是否靜音的Api這個問題。如果系統確實提供了這個Api,它應該為你提供哪些資訊呢?是告訴你係統當前是否靜音嗎?它告訴你這個有啥意義呢,反正那些別人操作的結果,如果已經靜音,你也單方面做不到取消靜音;是告訴你你這個應用是否已經傳送過靜音請求?請求數量你自己完全可以自己記錄,為什麼還要官方Api提供給你?所以,獲取是否處於靜音狀態這個介面其實意義並不見得有多大。當然,實際上這個api是寫在程式碼中的,只不過被@hide了,我們就當做沒有看待好了。

7、 小結

靜音的故事講完了,這個小故事告訴我們一個道理:程式碼從來都不會騙我們

侯捷先生在《STL原始碼剖析》一書的扉頁上面寫道『原始碼之前,了無祕密』。寫程式的時候,我經常會因為執行結果與預期不一致而感到不悅,甚至抱怨這就是『命』,想想也是挺逗的。計算機總是會忠實地執行我們提供的程式,如果你發現它『不聽』指揮,顯然是你的指令有問題;除此之外,我們的指令還需要經過層層傳遞,才會成為計算機可以執行的機器碼,如果你對系統api的工作原理不熟悉,對系統的工作原理不熟悉,你在組織自己的程式碼的時候就難免一廂情願。

至於官方API文件,每次看到它都有看到『課本』一樣的感覺。中學的時候,老師最愛說的一句話就是,『課本要多讀,常讀常新』。官方API呢,顯然也是這樣。沒有頭緒的時候,它就是我們救星啊。

作為Android開發者,儘管我不需要做Framework開發,但這並不能說明我不需要對Framework有一定的認識和了解。我們應該在平時的開發和學習當中經常翻閱這些系統的原始碼,瞭解它們的工作機制有助於我們更好的思考系統api的應用場景。

關於Android系統原始碼,如果不是為了深入的研究,我比較建議直接在網上直接瀏覽:

  • Androidxref,該站點提供了一定程度上的程式碼跳轉支援,以及非常強大的檢索功能,是我們查詢系統原始碼的首選。
  • Grepcode也可以檢索Android系統原始碼,與前者不同的是,它只包含Java程式碼,不過也是尺有所長,grepcode在Java程式碼跳轉方面的支援已經非常厲害了。

    想了解更多幹貨,請搜尋關注公眾號:騰訊Bulgy,或搜尋微訊號:weixinBugly,關注我們

  •                                            


騰訊Bugly

Bugly是騰訊內部產品質量監控平臺的外發版本,支援iOS和Android兩大主流平臺,其主要功能是App釋出以後,對使用者側發生的crash以及卡頓現象進行監控並上報,讓開發同學可以第一時間瞭解到app的質量情況,及時修改。目前騰訊內部所有的產品,均在使用其進行線上產品的崩潰監控。

騰訊Bugly經過內部團隊4年打磨,目前騰訊內部所有的產品都在使用,基本覆蓋了中國市場的移動裝置以及網路環境,可靠性有保證。使用Bugly,你就使用了和手機QQ、QQ空間、手機管家相同的質量保障手段。

相關文章