Android運行時權限終極方案,用PermissionX吧

本文同步發表於我的微信公衆號,掃一掃文章底部的二維碼或在微信搜索 郭霖 即可關注,每個工作日都有文章更新。

各位小夥伴們大家早上好,不知道你的《第三行代碼》已經讀到哪裏了?

有些朋友的閱讀速度真是令人印象深刻,我記得在《第三行代碼》剛剛發售一週不到的時間裏,竟然就有人已經讀到第9章了(因爲公衆號後臺有人回覆第9章裏隱藏的關鍵字)。現在,《第三行代碼》已經出版一個月有餘了,相信已經有不少朋友將全本書都看完了。

全書都看完的朋友一定知道,《第三行代碼》的最後一章是帶着大家一起開發了一個開源庫:PermissionX。這一章的主旨是爲了讓你瞭解一個開源庫整體的開發與發佈過程,爲了更好地演示這個過程,我想到了去寫PermissionX這樣一個庫。

不過,書中PermissionX庫的整體功能還是比較簡單的,因爲這一章的重點不在於如何將開源庫做得完善與強大,而是強調的一個開發與發佈的過程。

但是後來,我覺得PermissionX確實可以做成一個真正用於簡化Android運行時權限處理的庫,它所存在的意義應該不僅限於書中的教學目的,而是可以真的應用到實際的項目當中,幫助大家解決處理運行時權限的痛點。

所以,後期我又對PermissionX進行了諸多功能拓展,現在已經達到對外發布的標準了,那麼今天正式向大家宣佈:PermissionX已經上線!

源碼庫地址是:https://github.com/guolindev/PermissionX


痛點在哪裏?

沒有人願意編寫處理Android運行時權限的代碼,因爲它真的太繁瑣了。

這是一項沒有什麼技術含量,但是你又不得不去處理的工作,因爲不處理它程序就會崩潰。但如果處理起來比較簡單也就算了,可事實上,Android提供給我們的運行時權限API並不友好。

以一個撥打電話的功能爲例,因爲CALL_PHONE權限是危險權限,所以在我們除了要在AndroidManifest.xml中聲明權限之外,還要在執行撥打電話操作之前進行運行時權限處理才行。

權限聲明如下:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.permissionx.app">

    <uses-permission android:name="android.permission.CALL_PHONE" />
	...
	
</manifest>

然後,編寫如下代碼來進行運行時權限處理:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        makeCallBtn.setOnClickListener {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
                call()
            } else {
                ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CALL_PHONE), 1)
            }
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            1 -> {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    call()
                } else {
                    Toast.makeText(this, "You denied CALL_PHONE permission", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun call() {
        try {
            val intent = Intent(Intent.ACTION_CALL)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        } catch (e: SecurityException) {
            e.printStackTrace()
        }
    }

}

這段代碼中真有正意義的功能邏輯就是call()方法中的內容,可是如果直接調用call()方法是無法實現撥打電話功能的,因爲我們還沒有申請CALL_PHONE權限。

那麼整段代碼其他的部分就都是在處理CALL_PHONE權限申請。可以看到,這裏需要先判斷用戶是否已授權我們撥打電話的權限,如果沒有的話則要進行權限申請,然後還要在onRequestPermissionsResult()回調中處理權限申請的結果,最後才能去執行撥打電話的操作。

你可能覺得,這也不算是很繁瑣呀,代碼量並不是很多。那是因爲,目前我們還只是處理了運行時權限最簡單的場景,而實際的項目環境中有着更加複雜的場景在等着我們。

比如說,你的App可能並不只是單單申請一個權限,而是需要同時申請多個權限。雖然ActivityCompat.requestPermissions()方法允許一次性傳入多個權限名,但是你在onRequestPermissionsResult()回調中就需要判斷哪些權限被允許了,哪些權限被拒絕了,被拒絕的權限是否影響到應用程序的核心功能,以及是否要再次申請權限。

而一旦牽扯到再次申請權限,就引出了一個更加複雜的問題。你申請的權限被用戶拒絕過了一次,那麼再次申請將很有可能再次被拒絕。爲此,Android提供了一個shouldShowRequestPermissionRationale()方法,用於判斷是否需要向用戶解釋申請這個權限的原因,一旦shouldShowRequestPermissionRationale()方法返回true,那麼我們最好彈出一個對話框來向用戶闡明爲什麼我們是需要這個權限的,這樣可以增加用戶同意授權的機率。

是不是已經覺得很複雜了?不過還沒完,Android系統還提供了一個“拒絕,不要再詢問”的選項,如下圖所示:

只要用戶選擇了這個選項,那麼我們以後每次執行權限申請的代碼都將會直接被拒絕。

可是如果我的某項功能就是必須要依賴這個權限才行呢?沒有辦法,你只能提示用戶去應用程序設置當中手動打開權限,程序方面已無法進行操作。

可以看出,如果想要在項目中對運行時權限做出非常全面的處理,是一件相當複雜的事情。事實上,大部分的項目都沒有將權限申請這塊處理得十分恰當,這也是我編寫PermissionX的理由。


PermissionX的實現原理

在開始介紹PermissionX的具體用法之前,我們先來討論一下它的實現原理。

其實之前並不是沒有人嘗試過對運行時權限處理進行封裝,我之前在做直播公開課的時候也向大家演示過一種運行時權限API的封裝過程。

但是,想要對運行時權限的API進行封裝並不是一件容易的事,因爲這個操作是有特定的上下文依賴的,一般需要在Activity中接收onRequestPermissionsResult()方法的回調才行,所以不能簡單地將整個操作封裝到一個獨立的類中。

爲此,也衍生出了一系列特殊的封裝方案,比如將運行時權限的操作封裝到BaseActivity中,或者提供一個透明的Activity來處理運行時權限等。

不過上述兩種方案都不夠輕量,因爲改變Activity的繼承結構這可是大事情,而提供一個透明的Activty則需要在AndroidManifest.xml中進行額外的聲明。

現在,業內普遍比較認可使用另外一種小技巧來進行實現。是什麼小技巧呢?回想一下,之前所有申請運行時權限的操作都是在Activity中進行的,事實上,Android在Fragment中也提供了一份相同的API,使得我們在Fragment中也能申請運行時權限。

但不同的是,Fragment並不像Activity那樣必須有界面,我們完全可以向Activity中添加一個隱藏的Fragment,然後在這個隱藏的Fragment中對運行時權限的API進行封裝。這是一種非常輕量級的做法,不用擔心隱藏Fragment會對Activity的性能造成什麼影響。

這就是PermissionX的實現原理了,書中其實也已經介紹過了這部分內容。但是,在其實現原理的基礎之上,後期我又增加了很多新功能,讓PermissionX變得更加強大和好用,下面我們就來學習一下PermissionX的具體用法。


基本用法

要使用PermissionX之前,首先需要將其引入到項目當中,如下所示:

dependencies {
	...
	implementation 'com.permissionx.guolindev:permissionx:1.1.1'
} 

我在寫本篇文章時PermissionX的最新版本是1.1.1,想要查看它的當前最新版本,請訪問PermissionX的主頁:https://github.com/guolindev/PermissionX

PermissionX的目的是爲了讓運行時權限處理儘可能的容易,因此怎麼讓API變得簡單好用就是我優先要考慮的問題。

比如同樣實現撥打電話的功能,使用PermissionX只需要這樣寫:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        makeCallBtn.setOnClickListener {
            PermissionX.init(this)
                .permissions(Manifest.permission.CALL_PHONE)
                .request { allGranted, grantedList, deniedList ->
                    if (allGranted) {
                        call()
                    } else {
                        Toast.makeText(this, "您拒絕了撥打電話權限", Toast.LENGTH_SHORT).show()
                    }
                }
        }
    }

    ...

}

是的,PermissionX的基本用法就這麼簡單。首先調用init()方法來進行初始化,並在初始化的時候傳入一個FragmentActivity參數。由於AppCompatActivity是FragmentActivity的子類,所以只要你的Activity是繼承自AppCompatActivity的,那麼直接傳入this就可以了。

接下來調用permissions()方法傳入你要申請的權限名,這裏傳入CALL_PHONE權限。你也可以在permissions()方法中傳入任意多個權限名,中間用逗號隔開即可。

最後調用request()方法來執行權限申請,並在Lambda表達式中處理申請結果。可以看到,Lambda表達式中有3個參數:allGranted表示是否所有申請的權限都已被授權,grantedList用於記錄所有已被授權的權限,deniedList用於記錄所有被拒絕的權限。

因爲我們只申請了一個CALL_PHONE權限,因此這裏直接判斷:如果allGranted爲true,那麼就調用call()方法,否則彈出一個Toast提示。

運行結果如下:

怎麼樣?對比之前的寫法,是不是覺得運行時權限處理沒那麼繁瑣了?


核心用法

然而我們目前還只是處理了最普通的場景,剛纔提到的,假如用戶拒絕了某個權限,在下次申請之前,我們最好彈出一個對話框來向用戶解釋申請這個權限的原因,這個又該怎麼實現呢?

別擔心,PermissionX對這些情況進行了充分的考慮。

onExplainRequestReason()方法可以用於監聽那些被用戶拒絕,而又可以再次去申請的權限。從方法名上也可以看出來了,應該在這個方法中解釋申請這些權限的原因。

而我們只需要將onExplainRequestReason()方法串接到request()方法之前即可,如下所示:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
    .onExplainRequestReason { deniedList ->
    }
    .request { allGranted, grantedList, deniedList ->
        if (allGranted) {
            Toast.makeText(this, "所有申請的權限都已通過", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "您拒絕瞭如下權限:$deniedList", Toast.LENGTH_SHORT).show()
        }
    }

這種情況下,所有被用戶拒絕的權限會優先進入onExplainRequestReason()方法進行處理,拒絕的權限都記錄在deniedList參數當中。接下來,我們只需要在這個方法中調用showRequestReasonDialog()方法,即可彈出解釋權限申請原因的對話框,如下所示:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
    .onExplainRequestReason { deniedList ->
        showRequestReasonDialog(deniedList, "即將重新申請的權限是程序必須依賴的權限", "我已明白", "取消")
    }
    .request { allGranted, grantedList, deniedList ->
        if (allGranted) {
            Toast.makeText(this, "所有申請的權限都已通過", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "您拒絕瞭如下權限:$deniedList", Toast.LENGTH_SHORT).show()
        }
    }

showRequestReasonDialog()方法接受4個參數:第一個參數是要重新申請的權限列表,這裏直接將deniedList參數傳入。第二個參數則是要向用戶解釋的原因,我只是隨便寫了一句話,這個參數描述的越詳細越好。第三個參數是對話框上確定按鈕的文字,點擊該按鈕後將會重新執行權限申請操作。第四個參數是一個可選參數,如果不傳的話相當於用戶必須同意申請的這些權限,否則對話框無法關閉,而如果傳入的話,對話框上會有一個取消按鈕,點擊取消後不會重新進行權限申請,而是會把當前的申請結果回調到request()方法當中。

另外始終要記得將所有申請的權限都在AndroidManifest.xml中進行聲明:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.permissionx.app">

    <uses-permission android:name="android.permission.READ_CONTACTS" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.CALL_PHONE" />
	...
	
</manifest>

重新運行一下程序,效果如下圖所示:

目前解釋權限申請原因對話框的樣式暫時還無法自定義,下個版本當中,我會加入自定義對話框樣式的功能。

當然,我們也可以指定要對哪些權限重新申請,比如上述申請的3個權限中,我認爲CAMERA權限是必不可少的,而其他兩個權限則可有可無,那麼在重新申請的時候也可以只申請CAMERA權限:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.ACCESS_FINE_LOCATION)
    .onExplainRequestReason { deniedList ->
        val filteredList = deniedList.filter {
            it == Manifest.permission.CAMERA
        }
        showRequestReasonDialog(filteredList, "攝像機權限是程序必須依賴的權限", "我已明白", "取消")
    }
    .request { allGranted, grantedList, deniedList ->
        if (allGranted) {
            Toast.makeText(this, "所有申請的權限都已通過", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "您拒絕瞭如下權限:$deniedList", Toast.LENGTH_SHORT).show()
        }
    }

這樣當再次申請權限的時候就只會申請CAMERA權限,剩下的兩個權限最終會被傳入到request()方法的deniedList參數當中。

解決了向用戶解釋權限申請原因的問題,接下來還有一個頭疼的問題要解決:如果用戶不理會我們的解釋,仍然執意拒絕權限申請,並且還選擇了拒絕且不再詢問的選項,這該怎麼辦?通常這種情況下,程序層面已經無法再次做出權限申請,唯一能做的就是提示用戶到應用程序設置當中手動打開權限。

那麼PermissionX是如何處理這種情況的呢?我相信絕對會給你帶來驚喜。PermissionX中還提供了一個onForwardToSettings()方法,專門用於監聽那些被用戶永久拒絕的權限。另外從方法名上就可以看出,我們可以在這裏提醒用戶手動去應用程序設置當中打開權限。代碼如下所示:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
    .onExplainRequestReason { deniedList ->
        showRequestReasonDialog(deniedList, "即將重新申請的權限是程序必須依賴的權限", "我已明白", "取消")
    }
    .onForwardToSettings { deniedList ->
        showForwardToSettingsDialog(deniedList, "您需要去應用程序設置當中手動開啓權限", "我已明白", "取消")
    }
    .request { allGranted, grantedList, deniedList ->
        if (allGranted) {
            Toast.makeText(this, "所有申請的權限都已通過", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "您拒絕瞭如下權限:$deniedList", Toast.LENGTH_SHORT).show()
        }
    }

可以看到,這裏又串接了一個onForwardToSettings()方法,所有被用戶選擇了拒絕且不再詢問的權限都會進行到這個方法中處理,拒絕的權限都記錄在deniedList參數當中。

接下來,你並不需要自己彈出一個Toast或是對話框來提醒用戶手動去應用程序設置當中打開權限,而是直接調用showForwardToSettingsDialog()方法即可。類似地,showForwardToSettingsDialog()方法也接收4個參數,每個參數的作用和剛纔的showRequestReasonDialog()方法完全一致,我這裏就不再重複解釋了。

showForwardToSettingsDialog()方法將會彈出一個對話框,當用戶點擊對話框上的我已明白按鈕時,將會自動跳轉到當前應用程序的設置界面,從而不需要用戶自己慢慢進入設置當中尋找當前應用了。另外,當用戶從設置中返回時,PermissionX將會自動重新請求相應的權限,並將最終的授權結果回調到request()方法當中。效果如下圖所示:

同樣,下個版本當中,我也會加入自定義這個對話框樣式的功能。


更多用法

PermissionX最主要的功能大概就是這些,不過我在使用一些App的時候發現,有些App喜歡在第一次請求權限之前就先彈出一個對話框向用戶解釋自己需要哪些權限,然後纔會進行權限申請。這種做法是比較提倡的,因爲用戶同意授權的概率會更高。

那麼PermissionX中要如何實現這樣的功能呢?

其實非常簡單,PermissionX還提供了一個explainReasonBeforeRequest()方法,只需要將它也串接到request()方法之前就可以了,代碼如下所示:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
	.explainReasonBeforeRequest()
    .onExplainRequestReason { deniedList ->
        showRequestReasonDialog(deniedList, "即將申請的權限是程序必須依賴的權限", "我已明白")
    }
    .onForwardToSettings { deniedList ->
        showForwardToSettingsDialog(deniedList, "您需要去應用程序設置當中手動開啓權限", "我已明白")
    }
    .request { allGranted, grantedList, deniedList ->
        if (allGranted) {
            Toast.makeText(this, "所有申請的權限都已通過", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "您拒絕瞭如下權限:$deniedList", Toast.LENGTH_SHORT).show()
        }
    }

這樣,當每次請求權限時,會優先進入onExplainRequestReason()方法,彈出解釋權限申請原因的對話框,用戶點擊我已明白按鈕之後纔會執行權限申請。效果如下圖所示:

不過,你在使用explainReasonBeforeRequest()方法時,其實還有一些關鍵的點需要注意。

第一,單獨使用explainReasonBeforeRequest()方法是無效的,必須配合onExplainRequestReason()方法一起使用才能起作用。這個很好理解,因爲沒有配置onExplainRequestReason()方法,我們怎麼向用戶解釋權限申請原因呢?

第二,在使用explainReasonBeforeRequest()方法時,如果onExplainRequestReason()方法中編寫了權限過濾的邏輯,最終的運行結果可能和你期望的會不一致。這一點可能會稍微有點難理解,我用一個具體的示例來解釋一下。

觀察如下代碼:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
	.explainReasonBeforeRequest()
    .onExplainRequestReason { deniedList ->
        val filteredList = deniedList.filter {
            it == Manifest.permission.CAMERA
        }
        showRequestReasonDialog(filteredList, "攝像機權限是程序必須依賴的權限", "我已明白")
    }
    ...

這裏在onExplainRequestReason()方法中編寫了剛纔用到的權限過濾邏輯,當有多個權限被拒絕時,我們只重新申請CAMERA權限。

在沒有加入explainReasonBeforeRequest()方法時,一切都可以按照我們所預期的那樣正常運行。但如果加上了explainReasonBeforeRequest()方法,在執行權限請求之前會先進入onExplainRequestReason()方法,而這裏將除了CAMERA之外的其他權限都過濾掉了,因此實際上PermissionX只會請求CAMERA這一個權限,剩下的權限將完全不會嘗試去請求,而是直接作爲被拒絕的權限回調到最終的request()方法當中。

效果如下圖所示:

針對於這種情況,PermissionX在onExplainRequestReason()方法中提供了一個額外的beforeRequest參數,用於標識當前上下文是在權限請求之前還是之後,藉助這個參數在onExplainRequestReason()方法中執行不同的邏輯,即可很好地解決這個問題,示例代碼如下:

PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
	.explainReasonBeforeRequest()
    .onExplainRequestReason { deniedList, beforeRequest ->
        if (beforeRequest) {
            showRequestReasonDialog(deniedList, "爲了保證程序正常工作,請您同意以下權限申請", "我已明白")
        } else {
            val filteredList = deniedList.filter {
                it == Manifest.permission.CAMERA
            }
            showRequestReasonDialog(filteredList, "攝像機權限是程序必須依賴的權限", "我已明白")
        }
    }
    ...

可以看到,當beforeRequest爲true時,說明此時還未執行權限申請,那麼我們將完整的deniedList傳入showRequestReasonDialog()方法當中。

而當beforeRequest爲false時,說明某些權限被用戶拒絕了,此時我們只重新申請CAMERA權限,因爲它是必不可少的,其他權限則可有可無。

最終運行效果如下:


Permission-Support

這個庫的名字叫PermissionX,因此不用多說,它肯定是與AndroidX兼容的。以防還有部分朋友不清楚AndroidX是什麼的,這裏有一篇我之前寫的科普文章 總是聽到有人說AndroidX,到底什麼是AndroidX?

但是,我相信現在仍然存在很多項目沒有使用AndroidX,而是在繼續使用着之前的Android Support Library。爲此,我又專門提供了一份面向Android Support Library的版本:Permission-Support。

在用法層面,兩個版本沒有任何區別,本文以上討論的所有內容在Permission-Support上都適用。只是在引用庫的時候,如果你準備使用Permission-Support,請使用以下依賴庫地址:

dependencies {
	...
	implementation 'com.permissionx.guolindev:permission-support:1.1.1'
} 

不過,Android Support Library註定將會在不久的將來被Google完全淘汰,因此Permission-Support我也不會維護太久的時間,只是暫時過渡一下。而PermissionX我是準備長期維護下去的,並會持續增加更多好用的新功能。


後記

最後,一定也會有朋友想要詢問,Java語言的項目能不能使用PermissionX呢?

其實早在最開始的時候,我是打算將PermissionX設計成Kotlin和Java都可以通用的一個庫。但是寫着寫着發現,如果想要兼容Java語言,需要放棄很多Kotlin的語法特性,這樣PermissionX用起來就不再是那麼簡潔了,最終只好選擇了放棄Java語言的支持。

不過等PermissionX整體功能穩定下來之後,我可能會專門再編寫一個Java版的PermissionX。語法層面肯定要比Kotlin版的複雜不少,但是一定比你自己去處理運行時權限簡單得多。

新庫剛剛發佈,可能還存在很多我自己沒能測出來的bug,也請大家幫忙多多測試,共同將這個庫變得更加完善。

再次貼上PermissionX的開源庫地址,歡迎大家star和fork。

https://github.com/guolindev/PermissionX

另外,如果你想學習Kotlin語言或Android 10、Jetpack等最新的Android知識,可以閱讀我的新書:《第一行代碼——Android 第3版》,詳情點擊這裏查看


關注我的技術公衆號,每天都有優質技術文章推送。

微信掃一掃下方二維碼即可關注:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章