apk怎么拆包修改(如何拆apk安裝包)
安卓進階漲薪訓練營,讓一部分人先進大廠
大家好,我是皇叔,最近開了一個安卓進階漲薪訓練營,可以幫助大家突破技術職場瓶頸,從而度過難關,進入心儀的公司。
詳情見文章: 沒錯!皇叔開了個訓練營
前言
在 Android 中,有個非常強大的功能,那就是輔助功能。
輔助功能本是用于服務殘障人士的。比如對于視障人士來說,輔助功能可以幫助他們讀出屏幕上的文字或圖片(閱讀圖片時會播放其 ContentDeion 屬性)。
除此之外,輔助功能還可以模擬點擊,模擬手勢等等,對于我這樣的懶癌人士,輔助功能可以幫助我做一些重復、機械的點擊操作。
模擬點擊功能非常強大,它不局限于本應用內(nèi),它就像模擬出了一只手,可以在任何時刻幫助我們點擊屏幕的任何位置。
比如我們可以開啟一個循環(huán),不斷地點擊某個位置,這在某些場景中可以解放我們的手指細胞。還可以實現(xiàn)類似這樣的點擊序列:等待 3s 點擊位置 A,然后等待 2s 點擊兩次位置 B,等待 500ms 再點擊 5 次位置 C 等等。以此完成一些日常的簽到打卡等功能。
缺點是它不知道當前頁面顯示的內(nèi)容是什么,這一點可以通過截圖 + 圖片識別來解決。
所以想要實現(xiàn)一個簡單的外掛,可以分三步走:
模擬點擊
應用外截屏
圖片識別
模擬點擊
應用外截屏
圖片識別
接下來我們就來一步步地攻克這三個技術點。
模擬點擊
新建 MyAccessibilityService 類
首先,新建一個 MyAccessibilityService 類,繼承自系統(tǒng)的 AccessibilityService 類:
classMyAccessibilityService: AccessibilityService{
展開全文
override fun onAccessibilityEvent(accessibilityEvent: AccessibilityEvent?){
}
override fun onInterrupt{
}
}
繼承 AccessibilityService 后,需要實現(xiàn)兩個方法 onAccessibilityEvent 和 onInterrupt。
onAccessibilityEvent 方法中,帶有一個參數(shù) AccessibilityEvent,當界面發(fā)生改變時,這個方法就會被調用,界面改變的具體信息就會包含在這個參數(shù)中。onInterrupt 方法輔助服務被中斷了。
我們暫時先在這兩個方法中簡單地打印一行日志,待會再在其中添加具體的功能。
注冊 Service
寫好 MyAccessibilityService 類后,需要在 AndroidManifest 中注冊。注冊輔助服務和注冊一般的服務略有區(qū)別:
service
android:name= ".MyAccessibilityService"
android:deion= "@string/deion_in_manifest"
android:exported= "true"
android:label= "@string/label_in_manifest"
android:permission= "android.permission.BIND_ACCESSIBILITY_SERVICE"
intent-filter
action android:name= "android.accessibilityservice.AccessibilityService"/
/intent-filter
meta-data
android:name= "android.accessibilityservice"
android:resource= "@xml/accessibility_config"/
首先是需要聲明一個 label,這個 label 是在系統(tǒng)的輔助功能設置中顯示的名字
deion 屬性可以不寫,指的是在輔助功能設置中顯示的該輔助功能的描述
permission 屬性必須寫,表示這個服務需要綁定 AccssibilityService
在這個 service 中,有一個 inter-filter,這個也是必須寫的,不妨記作固定格式
還有一個 meta-data,其中的 resource 屬性指向一個 xml 文件,這個文件中可以配置允許這個輔助功能做哪些事
首先是需要聲明一個 label,這個 label 是在系統(tǒng)的輔助功能設置中顯示的名字
deion 屬性可以不寫,指的是在輔助功能設置中顯示的該輔助功能的描述
permission 屬性必須寫,表示這個服務需要綁定 AccssibilityService
在這個 service 中,有一個 inter-filter,這個也是必須寫的,不妨記作固定格式
還有一個 meta-data,其中的 resource 屬性指向一個 xml 文件,這個文件中可以配置允許這個輔助功能做哪些事
xml 文件如下:
?xml version= "1.0"encoding= "utf-8"?
accessibility-service xmlns:android= "http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes= "typeAllMask"
android:accessibilityFeedbackType= "feedbackGeneric"
android:canPerformGestures= "true"
android:canRetrieveWindowContent= "true"
android:deion= "@string/deion_in_xml"
android:notificationTimeout= "100"/
AndroidManifest 和 xml 中,用到的字符串資源文件如下:
string name= "label_in_manifest"Label in manifest/string
string name= "deion_in_manifest"Deion in manifest/string
string name= "deion_in_xml"Deion in xml/string
這些都設置好之后,這個 Service 就注冊成功了。
現(xiàn)在就可以運行一下看看效果了。
開啟輔助服務
此時運行程序,會發(fā)現(xiàn)沒有任何 onAccessibilityEvent 事件打出。這是因為輔助功能是一項比較危險的功能,默認是關閉的。需要到系統(tǒng)設置中手動打開才可以使用。
通過圖中的三個步驟,確保 Use Label in manifest 的開關是打開的,我們的輔助功能就被正式啟用了。從圖中我們也可以看出注冊 service 時寫的字符串各自的使用場景。在程序中,也可以通過代碼到達輔助功能設置頁面,代碼如下:
object AccessibilitySettingUtils {
fun jumpToAccessibilitySetting(context: Context){
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
context.startActivity(intent)
}
}
開啟輔助功能后,點擊桌面就會在 Log 控制臺收到以下消息:
D/~~~: accessibilityEvent: EventType: TYPE_WINDOW_CONTENT_CHANGED; EventTime: 101990739; PackageName: com.google.android.apps.nexuslauncher; MovementGranularity: 0; Action: 0; ContentChangeTypes: [CONTENT_CHANGE_TYPE_SUBTREE]; WindowChangeTypes: [] [ ClassName: android.widget.FrameLayout; Text: []; ContentDeion: null; ItemCount: - 1; CurrentItemIndex: - 1; Enabled: true; Password: false; Checked: false; FullScreen: false; Scrollable: false; BeforeText: null; FromIndex: - 1; ToIndex: - 1; ScrollX: 0; ScrollY: 0; MaxScrollX: 0; MaxScrollY: 0; ScrollDeltaX: - 1; ScrollDeltaY: - 1; AddedCount: - 1; RemovedCount: - 1; ParcelableData: null]; recordCount: 0
這表示我們接收到了一個 accessibilityEvent 消息,他的類型是 TYPE_WINDOW_CONTENT_CHANGED,意思是窗口內(nèi)容發(fā)生了變化,PackageName 中表示這個變化的內(nèi)容所在的包名。
說明我們的輔助功能已經(jīng)開始工作了。
點擊對應坐標
想要查看屏幕上的坐標,可以在開發(fā)人員選項中打開顯示坐標的設置:
打開這個設置后,每次點擊屏幕,都會在頂部顯示當前點擊的位置坐標。點擊對應坐標的代碼如下:
object ClickUtils {
fun click(accessibilityService: AccessibilityService, x: Float, y: Float){
Log.d( "~~~", "click: ($x, $y)")
val builder = GestureDeion.Builder
val path = Path
path.moveTo(x, y)
path.lineTo(x, y)
builder.addStroke(GestureDeion.StrokeDeion(path, 0, 1))
val gesture = builder.build
accessibilityService.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback {
override fun onCancelled(gestureDeion: GestureDeion){
super.onCancelled(gestureDeion)
}
override fun onCompleted(gestureDeion: GestureDeion){
super.onCompleted(gestureDeion)
}
}, null)
}
}
在這個工具類中,我們將 AccessibilityService 和坐標傳入。
通過 GestureDeion 的 Builder 構建一個手勢,通過 Builder 的 addStoke 方法傳入一條 path,這條 path 我們設置為從 (x, y) 坐標移動到 (x, y) 坐標。StrokeDeion 的后兩個參數(shù)表示 startTime 和 duration,分別表示手勢的開始時間以及持續(xù)時間,以毫秒為單位。我將其設置為 0 和 1,也就是 1ms 以內(nèi)完成從 (x, y) 坐標移動到 (x, y) 坐標。
這樣就模擬出了一個點擊事件。通過 accessibilityService 的 dispatchGesture 方法觸發(fā)這個手勢,這個方法接收兩個參數(shù),第一個參數(shù)是手勢的具體配置,第二個參數(shù)表示手勢執(zhí)行的結果,包含執(zhí)行完成和取消兩種結果。
測試
我們不妨寫個簡單的頁面來測試一下。先寫一個頁面,包含兩個按鈕:
?xml version= "1.0"encoding= "utf-8"?
androidx.constraintlayout.widget.ConstraintLayout xmlns:android= "http://schemas.android.com/apk/res/android"
xmlns:app= "http://schemas.android.com/apk/res-auto"
xmlns:tools= "http://schemas.android.com/tools"
android:layout_width= "match_parent"
android:layout_height= "match_parent"
tools:context= ".MainActivity"
Button
android:id= "@+id/btn_jump_to_settings"
android:layout_width= "match_parent"
android:layout_height= "wrap_content"
android:text= "Jump to Settings"
android:textAllCaps= "false"
app:layout_constraintTop_toTopOf= "parent"/
Button
android:id= "@+id/btn_test"
android:layout_width= "match_parent"
android:layout_height= "wrap_content"
android:text= "Test"
app:layout_constraintTop_toBottomOf= "@id/btn_jump_to_settings"/
/androidx.constraintlayout.widget.ConstraintLayout
這個頁面的效果圖:
在 app/build.gradle 中,開啟 ViewBinding,目的是使用這些按鈕更方便:
buildFeatures {
viewBinding true
}
在 MainActivity 中,設置按鈕的點擊事件:
classMainActivity: AppCompatActivity{
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnJumpToSettings.setOnClickListener {
AccessibilitySettingUtils.jumpToAccessibilitySetting( this)
}
binding.btnTest.setOnClickListener {
Toast.makeText( this, "I'm clicked", Toast.LENGTH_SHORT).show
}
}
}
第一個按鈕 btnJumpToSettings 的作用是點擊跳轉到輔助服務設置頁
第二個按鈕用來做測試,點擊時會彈出 Toast:"I'm clicked"。待會我們就模擬點擊這個按鈕。
第一個按鈕 btnJumpToSettings 的作用是點擊跳轉到輔助服務設置頁
第二個按鈕用來做測試,點擊時會彈出 Toast:"I'm clicked"。待會我們就模擬點擊這個按鈕。
查看一下第二個按鈕的坐標位置:
從圖中可以看出,第三個按鈕的坐標大約是 (622,406)。在 MyAccessibilityService 的 onServiceConnected 方法中,模擬點擊此坐標:
override fun onServiceConnected{
super.onServiceConnected
Log.d( "~~~", "onServiceConnected")
thread {
Thread.sleep( 5000)
ClickUtils.click( this, 622f, 406f)
}
}
可以看到,我們在 onServiceConnected 方法中,開啟了一個線程,先睡眠 5s,再調用 ClickUtils.click(this, 622f, 406f) 方法點擊 (622,406)。之所以要睡眠 5s,是因為在設置中開啟了輔助服務后,onServiceConnected 方法就會立刻回調,而我們要從設置頁面返回到此頁面才能看到這個按鈕被點擊的效果,返回過程需要一點時間。
開測
可以看到,我先點擊了第一個按鈕到達輔助服務設置頁面,在開啟輔助服務后,我立即返回了 MainActivity,等待幾秒后,Test 按鈕被自動點擊了。說明我們的輔助點擊功能已經(jīng)正常工作了。
注:實際上這里的點擊并不局限于本應用內(nèi),之所以要返回這個頁面再點擊,只是為了講解時更方便,讓大家能更清楚地看到效果。
應用外截屏
應用內(nèi)截屏
在講解 Android 應用外截屏之前,我們先看一下 Android 應用內(nèi)截屏。在 Android 應用內(nèi)截屏非常簡單,只需要獲取 View 的緩存即可:
fun screenShot(activity: Activity): Bitmap {
returnview2Bitmap(activity.window.decorView)
}
fun view2Bitmap(view: View): Bitmap {
view.isDrawingCacheEnabled = true
returnview.drawingCache
}
本文重點講述應用外截屏。應用外截屏其實也不復雜,只需要兩步:
通過 MediaProjectionManager 的 getMediaProjection 方法獲取到 MediaProjection 對象。
再通過 MediaProjection 的 createVirtualDisplay 方法就能截取屏幕了。
通過 MediaProjectionManager 的 getMediaProjection 方法獲取到 MediaProjection 對象。
再通過 MediaProjection 的 createVirtualDisplay 方法就能截取屏幕了。
應用外截屏
構建 MediaProjectionManager 對象的方式非常簡單,調用 getSystemService(MEDIA_PROJECTION_SERVICE) 方法就可以了:
privateval mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
構建 MediaProjection 稍微復雜一點,構建 MediaProjection 對象需要兩個參數(shù),一個 resultCode,一個 resultData。
這兩個參數(shù)什么意思呢,為什么需要它們呢?
這是因為截取應用外屏幕有侵犯用戶隱私的風險,所以截屏之前需要獲得用戶的同意。所以在截屏前需要調用 startActivityForResult 方法詢問用戶:這個應用準備截屏了,你同意嗎?
在用戶同意后,onActivityResult 方法中就會攜帶 resultCode 和 resultData 參數(shù)。有了這兩個參數(shù),我們就可以構建 MediaProjection 對象了。
Talk is cheap, show me the code. 我們來一起寫個 Demo。
首先是布局文件:
?xml version= "1.0"encoding= "utf-8"?
androidx.constraintlayout.widget.ConstraintLayout xmlns:android= "http://schemas.android.com/apk/res/android"
xmlns:app= "http://schemas.android.com/apk/res-auto"
xmlns:tools= "http://schemas.android.com/tools"
android:layout_width= "match_parent"
android:layout_height= "match_parent"
tools:context= ".MainActivity"
SurfaceView
android:id= "@+id/surfaceView"
android:layout_width= "match_parent"
android:layout_height= "0dp"
app:layout_constraintBottom_toTopOf= "@id/btnStart"
app:layout_constraintTop_toTopOf= "parent"/
Button
android:id= "@+id/btnStart"
android:layout_width= "match_parent"
android:layout_height= "wrap_content"
android:text= "Start Screen Capture"
android:textAllCaps= "false"
app:layout_constraintBottom_toTopOf= "@id/btnStop"
app:layout_constraintTop_toBottomOf= "@id/surfaceView"/
Button
android:id= "@+id/btnStop"
android:layout_width= "match_parent"
android:layout_height= "wrap_content"
android:text= "Stop Screen Capture"
android:textAllCaps= "false"
app:layout_constraintBottom_toBottomOf= "parent"/
/androidx.constraintlayout.widget.ConstraintLayout
效果圖:
布局文件中,有一個 SurfaceView,待會我們將用它來展示截圖內(nèi)容。底部有兩個按鈕,一個 Start Screen Capture,一個 Stop Screen Capture,分別表示開始截圖和停止截圖。在 build.gradle 中開啟 ViewBinding,使得引用控件更加方便:
buildFeatures {
viewBinding true
}
在 MainActivity 中:
constval REQUEST_MEDIA_PROJECTION = 1
classMainActivity: AppCompatActivity{
privateval binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
privateval mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
privatevar mediaProjection: MediaProjection? = null
privatevar virtualDisplay: VirtualDisplay? = null
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.btnStart.setOnClickListener {
Log.d( "~~~", "Requesting confirmation")
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent, REQUEST_MEDIA_PROJECTION)
}
binding.btnStop.setOnClickListener {
Log.d( "~~~", "Stop screen capture")
stopScreenCapture
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?){
super.onActivityResult(requestCode, resultCode, data)
if(requestCode == REQUEST_MEDIA_PROJECTION) {
if(resultCode != RESULT_OK) {
Log.d( "~~~", "User cancelled")
return
}
Log.d( "~~~", "Starting screen capture")
mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)
virtualDisplay = mediaProjection!!.createVirtualDisplay(
"ScreenCapture",
ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, ScreenUtils.getScreenDensityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
binding.surfaceView.holder.surface, null, null
)
}
}
privatefun stopScreenCapture{
Log.d( "~~~", "stopScreenCapture, virtualDisplay = $virtualDisplay")
virtualDisplay?.release
virtualDisplay = null
}
}
其中,用到的 ScreenUtils 的作用是獲取屏幕的寬高和密度。代碼如下:
object ScreenUtils {
fun getScreenWidth: Int {
returnResources.getSystem.displayMetrics.widthPixels
}
fun getScreenHeight: Int {
returnResources.getSystem.displayMetrics.heightPixels
}
fun getScreenDensityDpi: Int {
returnResources.getSystem.displayMetrics.densityDpi
}
}
當點擊 Start 按鈕時,調用 startActivityForResult 詢問用戶是否同意截屏,這個方法中傳入的 Intent 是 mediaProjectionManager.createScreenCaptureIntent,這是專門用于詢問用戶是否同意截屏的 Intent,調用這行代碼后,會彈出這樣一個彈窗:
如果用戶點了確認,也就是上圖中的 “Start now” 按鈕,onActivityResult 就會收到 resultCode == RESULT_OK,以及用戶確認后的 data,通過這兩個參數(shù),我們就能構建出 mediaProjection 對象了。
獲取到 mediaProjection 對象后,通過 createVirtualDisplay 方法開始截屏。這個方法接收多個參數(shù),第一個參數(shù)表示 VirtualDisplay 的名字,隨意傳入一個字符串即可。
緊跟著的三個參數(shù)表示屏幕的寬高和密度。下一個參數(shù) DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR 表示 VirtualDisplay 的 flag,有多種值可選,我暫時不清楚幾種 flag 的區(qū)別,不妨先記做固定寫法。下一個參數(shù)表示展示截圖結果的 Surface,這里傳入 binding.surfaceView.holder.surface,截圖結果就會展示到 SurfaceView 上了。最后兩個參數(shù)一個是 callback,一個是 handler,是用來處理截圖的回調的,我們暫時用不上,都傳入 null 即可。
需要注意的是,當 createVirtualDisplay 方法調用后,設備就會不斷地獲取當前屏幕,直到 createVirtualDisplay 創(chuàng)建的 virtualDisplay 對象被 release 才會停止截屏。所以我們在 Stop 按鈕的點擊事件中,調用了 virtualDisplay 的 release 方法。
整體來說代碼還是很簡單的,我們運行一下試試:
可以看到,直接 crash 了...查看 Logcat 控制臺:
java.lang.SecurityException: Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
報了一個 SecurityException,Media projections 需要一個帶有 ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION 類型的前臺 Service。
前臺 Service
我在編寫這個 Demo 時,targetSdk 設置的是最新的版本:31,事實上,如果讀者在編寫此 Demo 時,targetSdk 的版本在 28 或以下,就不會遇到這個錯誤,此時就已經(jīng)能正常截屏了。
只有 targetSdk 在 28 以上時,才會出現(xiàn)這個錯誤。SDK 28 代表 Android 9.0,在 Android 9.0 以后,才要求截屏時必須運行一個前臺 Service。
所以修復這個 crash 有兩種方案:
把 targetSdk 改成 28,
創(chuàng)建前臺 Service,適配 Android 9.0 以上版本。
把 targetSdk 改成 28,
創(chuàng)建前臺 Service,適配 Android 9.0 以上版本。
我更傾向于第二種方案,因為這個項目是我寫給自己練手的,我希望用最新的 API;并且將截圖功能放到 Service 中其實也更符合我的需求。
首先新建一個 Service:
classCaptureService: Service{
override fun onBind(intent: Intent?): IBinder? {
returnnull
}
}
在 AndroidManifest 中,添加 FOREGROUND_SERVICE 權限,注冊此 Service:
uses-permission android:name= "android.permission.FOREGROUND_SERVICE"/
application
...
...
service
android:name= ".CaptureService"
android:foregroundServiceType= "mediaProjection"/
/application
此 Service 需要添加 android:foregroundServiceType="mediaProjection" 屬性,表示這是用于截屏的 Service。
新建 MyApplication,注冊前臺 Notification Channel:
constval SCREEN_CAPTURE_CHANNEL_ID = "Screen Capture ID"
constval SCREEN_CAPTURE_CHANNEL_NAME = "Screen Capture"
classMyApplication: Application{
override fun onCreate{
super.onCreate
createScreenCaptureNotificationChannel
}
privatefun createScreenCaptureNotificationChannel{
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Create the channel for the notification
val screenCaptureChannel = NotificationChannel(SCREEN_CAPTURE_CHANNEL_ID, SCREEN_CAPTURE_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(screenCaptureChannel)
}
}
不要忘了在 AndroidManifest 中聲明此 Application:
application
android:name= ".MyApplication"
.../
然后,在 CaptureService 中,啟用前臺通知:
classCaptureService: Service{
override fun onCreate{
super.onCreate
startForeground( 1, NotificationCompat.Builder( this, SCREEN_CAPTURE_CHANNEL_ID).build)
}
override fun onBind(intent: Intent?): IBinder? {
returnnull
}
}
這樣就寫好了一個前臺 Service。
修改 MainActivity 中的代碼,點擊 Start 后,先啟動 Service,再調用截屏:
binding.btnStart.setOnClickListener {
startForegroundService(Intent( this, CaptureService:: class. java))
Log. d("~~~", " Requestingconfirmation")
startActivityForResult( mediaProjectionManager. createScreenCaptureIntent, REQUEST_MEDIA_PROJECTION)
}
此時運行就不會報錯了,效果如下:
可以看到,已經(jīng)可以成功截圖了,前文說過,當 createVirtualDisplay 方法調用后,設備就會不斷地獲取當前屏幕,所以才會看到截圖畫面層層疊疊的效果。
在 Google 官方提供的截圖 Demo 中,運行效果也是類似的,感興趣的讀者可以在 github 上查看 Google 官方的 Demo:
https://github.com/android/media-samples/tree/main/ScreenCapture
https://github.com/android/media-samples/tree/main/ScreenCapture
注:只要啟動了這樣一個前臺 Service,即使沒有把截屏邏輯移到 Service 中,也已經(jīng)可以正常截屏了。但更好的做法是把截圖邏輯移到 Service 中,感興趣的讀者可以自行實現(xiàn)。
截圖一次并取其 Bitmap
雖然現(xiàn)在截圖成功了,但運行效果并不是我們想要的。一般我們想要的效果是,截圖一次并取其 Bitmap。
為了實現(xiàn)這個效果,我們需要使用一個新的類:ImageReader。ImageReader 中包含一個 Surface 對象,在 createVirtualDisplay 方法中,將 binding.surfaceView.holder.surface 替換成 ImageReader 的 Surface 對象,就可以將截圖結果記錄到 ImageReader 中了。
創(chuàng)建 ImageReader:
privateval imageReader by lazy { ImageReader.newInstance(ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, PixelFormat.RGBA_8888, 1) }
創(chuàng)建時需要傳入屏幕的寬高,第三個參數(shù)表示圖片的格式,這里傳入的是 PixelFormat.RGBA_8888。
注:實際上寫 PixelFormat.RGBA_8888 時,Android Studio 會報錯,因為它預期的是傳入一個 ImageFormat。PixelFormat.RGBA_8888 對應的常量是 1,但 ImageFormat 中沒有對應常量 1 的格式。我嘗試過換成 ImageFormat 中的其他格式,但換了之后始終運行不了。而這里的報錯卻并不影響程序運行,所以我就任由它報紅了。如果讀者有更好的方案,望不吝賜教:
最后一個參數(shù)表示最多保存幾張圖片,我們傳入 1 就可以了。
創(chuàng)建好 ImageReader 后,接下來替換掉 createVirtualDisplay 方法中的參數(shù),并獲取 imageReader 中的截圖結果:
virtualDisplay = mediaProjection!!.createVirtualDisplay(
"ScreenCapture",
ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, ScreenUtils.getScreenDensityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader.surface, null, null
)
Handler(Looper.getMainLooper).postDelayed({
val image = imageReader.acquireLatestImage
if(image != null) {
Log.d( "~~~", "get image: $image")
} else{
Log.d( "~~~", "image == null")
}
stopScreenCapture
}, 1000)
可以看到,代碼中先是將 imageReader.surface 傳入了 createVirtualDisplay 方法中,使得截圖結果記錄到 ImageReader 中。再等待了 1s 鐘,然后調用 imageReader.acquireLatestImage 獲取 imageReader 中記錄的截圖結果,它是一個 Image 對象。之所以等待 1s 是因為截圖需要一定的時間,并且在獲取到截圖結果后,我們需要調用 stopScreenCapture 將 virtualDisplay 對象釋放掉,否則這里會一直截圖。并且如果不釋放的話,在下一次截圖時會報以下錯誤:
java.lang.IllegalStateException: maxImages ( 1) has already been acquired, call #close before acquiring more.
獲取到 Image 對象后,可以將其轉換成 Bitmap 對象,轉換工具類如下:
object ImageUtils {
fun imageToBitmap(image: Image): Bitmap {
val bitmap = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(image.planes[ 0].buffer)
image.close
returnbitmap
}
}
這樣我們就實現(xiàn)了截圖一次并取其 Bitmap。不妨將這個 Bitmap 設置到 ImageView 上,看看效果。首先修改布局文件:
?xml version= "1.0"encoding= "utf-8"?
androidx.constraintlayout.widget.ConstraintLayout xmlns:android= "http://schemas.android.com/apk/res/android"
xmlns:app= "http://schemas.android.com/apk/res-auto"
xmlns:tools= "http://schemas.android.com/tools"
android:layout_width= "match_parent"
android:layout_height= "match_parent"
tools:context= ".MainActivity"
ImageView
android:id= "@+id/iv"
android:layout_width= "match_parent"
android:layout_height= "0dp"
app:layout_constraintBottom_toTopOf= "@id/btnStart"
app:layout_constraintTop_toTopOf= "parent"/
Button
android:id= "@+id/btnStart"
android:layout_width= "match_parent"
android:layout_height= "wrap_content"
android:text= "Start Screen Capture"
android:textAllCaps= "false"
app:layout_constraintBottom_toTopOf= "@id/btnStop"
app:layout_constraintTop_toBottomOf= "@id/iv"/
Button
android:id= "@+id/btnStop"
android:layout_width= "match_parent"
android:layout_height= "wrap_content"
android:text= "Stop Screen Capture"
android:textAllCaps= "false"
app:layout_constraintBottom_toBottomOf= "parent"/
/androidx.constraintlayout.widget.ConstraintLayout
唯一的修改是把之前布局文件中的 SurfaceView 換成了 ImageView,id 也對應換成了 iv。然后將獲取到的 Image 轉成 Bitmap,并設置到 ImageView 上:
binding.iv.setImageBitmap(ImageUtils.imageToBitmap(image))
運行效果如下:
可以看到,點擊 Start 按鈕后,等待 1s 后,就完成了截圖,并且展示到了 ImageView 上。這里的截圖并不局限于本應用內(nèi),不妨看一個截取應用外屏幕的效果:(注:我在錄制這個效果時將截圖等待時間延長到了 3s,以保證截圖時完全退到了桌面)
可以看到,確實可以截取到應用外的屏幕。
只讓用戶同意一次
現(xiàn)在的截圖還有一個問題,每次截圖前都會詢問用戶是否同意截圖。雖然我們可以通過上文介紹的模擬點擊幫用戶點同意,但更好的做法是將用戶同意的結果保存起來,下次截圖前直接使用即可。我們修改一下 Demo 看看效果。
MainActivity 修改如下:
constval REQUEST_MEDIA_PROJECTION = 1
classMainActivity: AppCompatActivity{
privateval binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
privateval mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
privatevar mediaProjection: MediaProjection? = null
privatevar virtualDisplay: VirtualDisplay? = null
privateval handler by lazy { Handler(Looper.getMainLooper) }
privateval imageReader by lazy { ImageReader.newInstance(ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, PixelFormat.RGBA_8888, 1) }
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.btnStart.setOnClickListener {
startForegroundService(Intent( this, CaptureService:: class. java))
startScreenCapture
}
binding. btnStop. setOnClickListener{
Log.d( "~~~", "Stop screen capture")
stopScreenCapture
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?){
super.onActivityResult(requestCode, resultCode, data)
if(requestCode == REQUEST_MEDIA_PROJECTION) {
if(resultCode != RESULT_OK) {
Log.d( "~~~", "User cancelled")
return
}
Log.d( "~~~", "Starting screen capture")
mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)
setUpVirtualDisplay
}
}
privatefun startScreenCapture{
if(mediaProjection == null) {
Log.d( "~~~", "Requesting confirmation")
// This initiates a prompt dialog for the user to confirm screen projection.
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent, REQUEST_MEDIA_PROJECTION)
} else{
Log.d( "~~~", "mediaProjection != null")
setUpVirtualDisplay
}
}
privatefun setUpVirtualDisplay{
Log.d( "~~~", "setUpVirtualDisplay")
virtualDisplay = mediaProjection!!.createVirtualDisplay(
"ScreenCapture",
ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, ScreenUtils.getScreenDensityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader.surface, null, null
)
handler.postDelayed({
val image = imageReader.acquireLatestImage
if(image != null) {
Log.d( "~~~", "get image: $image")
binding.iv.setImageBitmap(ImageUtils.imageToBitmap(image))
} else{
Log.d( "~~~", "image == null")
}
stopScreenCapture
}, 1000)
}
privatefun stopScreenCapture{
Log.d( "~~~", "stopScreenCapture, virtualDisplay = $virtualDisplay")
virtualDisplay?.release
virtualDisplay = null
}
}
主要修改在于多了一個 startScreenCapture 方法,在這個方法中,先判斷 mediaProjection 是否已經(jīng)存在,如果不存在,則執(zhí)行剛才的邏輯,調用 startActivityForResult 請求用戶同意截屏。如果已經(jīng)存在,則直接調用 createVirtualDisplay 截屏即可。
運行效果:
這樣就實現(xiàn)了用戶只需同意一次截屏權限,應用就能多次截屏的功能。
通過上文介紹的模擬點擊,在獲取截屏權限時,可以實現(xiàn)自動點擊同意。然后就可以愉快地多次截屏了。
由于這種截屏方式不局限于本應用內(nèi),所以可以在后臺默默地不斷截取屏幕。接下來我們再學習一點基本的圖像識別技術,把截取到的屏幕利用起來。
圖片識別
我采用的方式是對比圖片的相似度,以達到知道當前在哪一屏的效果,然后就能通過輔助功能點擊這一屏中設定好的坐標了
第一種對比方式
第一種對比方式是:取出兩張 bitmap 中的所有像素,然后一一進行對比。匹配的點除以總點數(shù)就能得到一個相似度。代碼如下:
object SimilarityUtils {
fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {
// 獲取圖片所有的像素
val pixels1 = getPixels(bitmap1)
val pixels2 = getPixels(bitmap2)
// 總的像素點數(shù)以較大圖片為準
val totalCount = pixels1.size.coerceAtLeast(pixels2.size)
if(totalCount == 0) return0.00
var matchCount = 0
var i = 0
while(i pixels1.size i pixels2.size) {
if(pixels1[i] == pixels2[i]) {
// 統(tǒng)計相同的像素點數(shù)量
matchCount++
}
i++
}
// 相同的像素點數(shù)量除以總的像素點數(shù),得到相似比例。
returnString.format( "%.2f", matchCount.toDouble / totalCount).toDouble
}
privatefun getPixels(bitmap: Bitmap): IntArray {
val pixels = IntArray(bitmap.width * bitmap.height)
// 獲取每個像素的 RGB 值
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
returnpixels
}
}
可以看到,similarity 函數(shù)接收兩個 Bitmap,返回一個 Double 值,這個值的取值范圍是 0.00~1.00,表示相似度。
首先通過 bitmap.getPixels 取出所有的像素點,以其中較多的像素點作為總點數(shù)。
然后通過 pixels1[i] == pixels2[i] 對比每個像素點,如果相同則 matchCount 加一,最后用 matchCount / totalCount 計算出相似度。
這種比較方式特別直觀,容易理解,通過每個像素點依次比較得出相似度。我們也很容易想到它的缺點:如果第二張圖片是由第一張圖片縮放、變形、旋轉等變換得來的,那么每個像素點可能都無法匹配上,所以相似度會很低很低。
也就是說,這個算法幾乎只能用于比較圖片是否一模一樣,只要兩張圖的像素點有細微的錯位,比較結果就會完全不準確。
不過其實這種算法已經(jīng)能夠滿足我們的需求了,只要我們每次都取一樣的 Bitmap 進行比較就可以了。只要保證整張圖都一樣,或者從 Bitmap 裁剪出的固定區(qū)域一樣就可以了。此時比較結果可以供我們正常使用。
但更好的做法是通過 SIFT 算法計算相似度。
通過 SIFT 算法計算相似度
SIFT 算法指的是尺度不變特征轉換 (Scale Invariant Feature Transform)。它是計算機視覺領域中描述圖片特征的一種算法,應用非常廣泛。
這個算法是由一些大神們研究出來的,由于本文不是在寫論文,所以我也不會對這個算法進行深究,簡單介紹一下它的大概原理:
先將圖片映射為空間中的坐標:
再從所有坐標中過濾出其中的特征點:
再為特征點分配一個方向值,使得圖片變形后仍然能夠正確匹配:
將這些信息轉換成數(shù)學描述:
注:算法原理的這段內(nèi)容,只是我個人一點粗淺的理解,可能和算法的實際實現(xiàn)有出入。但這個算法的實現(xiàn)不是本文的重點,重點在于這個算法可以用于對比兩張圖片的相似度。所以于我而言,我愿將其稱之為魔法。
這個算法被封裝在 OpenCV 庫中,所以使用前需要導入 OpenCV 庫。
OpenCV 官方?jīng)]有提供 gradle 導入的方式,所以網(wǎng)上有許多導入 OpenCV 庫的教程,講的都是去下載 OpenCV 的源碼,再通過 Module 的方式加入項目中。
但國外有民間大佬為我們封裝了 gradle 導入的方式,大佬封裝的 github 地址:
https://github.com/quickbirdstudios/opencv-android
https://github.com/quickbirdstudios/opencv-android
所以現(xiàn)在我們可以直接在 build.gradle 中直接導入 OpenCV 庫:
implementation 'com.quickbirdstudios:opencv:4.5.3.0'
需要注意的是,OpenCV 庫非常大,導入這個庫會讓 apk 的體積增加 100 多 M,所以要慎用。
有了 OpenCV 庫,就可以編寫圖片相似度對比工具類了:
object SIFTUtils {
// SIFT detector
privateval siftDetector by lazy { SIFT.create }
fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {
// 計算每張圖片的特征點
val deors1 = computeDeors(bitmap1)
val deors2 = computeDeors(bitmap2)
// 比較兩張圖片的特征點
val deorMatcher = DeorMatcher.create(DeorMatcher.FLANNBASED)
val matches: ListMatOfDMatch = ArrayList
// 計算大圖中包含多少小圖的特征點。
// 如果計算小圖中包含多少大圖的特征點,結果會不準確。
// 比如:若小圖中的 50 個點都包含在大圖中的 100 個特征點中,則計算出的相似度為 100%,顯然不符合我們的預期
if(bitmap1.byteCount bitmap2.byteCount) {
deorMatcher.knnMatch(deors1, deors2, matches, 2)
} else{
deorMatcher.knnMatch(deors2, deors1, matches, 2)
}
Log.i( "~~~", "matches.size: ${matches.size}")
if(matches.isEmpty) return0.00
// 獲取匹配的特征點數(shù)量
var matchCount = 0
// 鄰近距離閥值,這里設置為 0.7,該值可自行調整
val nndrRatio = 0.7f
matches.forEach { match -
val array = match.toArray
// 用鄰近距離比值法(NNDR)計算匹配點數(shù)
if(array[ 0].distance = array[ 1].distance * nndrRatio) {
matchCount++
}
}
Log.i( "~~~", "matchCount: $matchCount")
returnString.format( "%.2f", matchCount.toDouble / matches.size).toDouble
}
privatefun computeDeors(bitmap: Bitmap): MatOfKeyPoint {
val mat = Mat
Utils.bitmapToMat(bitmap, mat)
val keyPoints = MatOfKeyPoint
siftDetector.detect(mat, keyPoints)
val deors = MatOfKeyPoint
// 計算圖片的特征點
siftDetector.compute(mat, keyPoints, deors)
returndeors
}
}
在這個類中,同樣有一個 similarity 方法,接收兩個 Bitmap,返回一個 0.00~1.00 的 Double 型數(shù)據(jù),表示圖片的相似度。
首先通過 SIFT.create 構建出用 SIFT 算法實現(xiàn)的圖片檢測器 siftDetector,再通過 siftDetector.compute 計算出圖片的特征點。
再通過 DeorMatcher.create 構建出 deorMatcher 對象,通過 deorMatcher.knnMatch 方法比較出兩張圖片相似的特征點數(shù)量。
這里比較時有一個 if 條件判斷,它的作用是保證比較的是大圖中包含多少小圖中的特征點。因為如果計算小圖中包含多少大圖的特征點,結果會不準確。
比如:若小圖中的 50 個點都包含在大圖中的 100 個特征點中,則計算出的相似度為 100%,顯然不符合我們的預期。
最后通過 array[0].distance = array[1].distance * nndrRatio 判斷特征點是否相似,統(tǒng)計出相似的特征點數(shù)量后,通過 matchCount / matches.size 計算出相似度。
測試
先在 res/drawable 文件夾下放一張圖片,比如我放了一張我的頭像,命名為 img.png:
然后修改 MainActivity 中的代碼:
classMainActivity: AppCompatActivity{
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)
val bitmap2 = Bitmap.createBitmap(bitmap1, 0, 0, bitmap1.width / 2, bitmap1.height / 2)
Log.d( "~~~", "similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")
}
}
首先通過 BitmapFactory.decodeResource 將 res/drawable 文件夾中的圖片取出來,轉換成 Bitmap,構建出 bitmap1。bitmap2 由 bitmap1 裁剪而來,通過 Bitmap.createBitmap 方法,從 bitmap1 的 (0, 0) 位置開始,裁剪出寬為原圖一半、高為原圖一半的 Bitmap。然后調用 SIFTUtils.similarity(bitmap1, bitmap2) 比較兩張圖片的相似度。
非常完美!
運行代碼,立馬 crash:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example.imagesimilarity, PID: 21924
java.lang.UnsatisfiedLinkError: No implementation found forlongorg.opencv.core.Mat.n_Mat (tried Java_org_opencv_core_Mat_n_1Mat and Java_org_opencv_core_Mat_n_1Mat__)
at org.opencv.core.Mat.n_Mat(Native Method)
at org.opencv.core.Mat.init(Mat.java: 23)
at com.example.imagesimilarity.SIFTUtils.computeDeors(SIFTUtils.kt: 50)
at com.example.imagesimilarity.SIFTUtils.similarity(SIFTUtils.kt: 19)
at com.example.imagesimilarity.MainActivity.onCreate(MainActivity.kt: 38)
at android.app.Activity.performCreate(Activity.java: 8000)
果然凡事都沒有一帆風順的。這個報錯大致意思是沒有找到 OpenCV 中的某個方法的具體實現(xiàn)。奇了怪了,我們明明已經(jīng)導入過 OpenCV 庫了。
查詢一番后,在 StackOverflow 上找到了答案,原因是 OpenCV 使用前需要先初始化。
MainActivity 代碼修改如下:
classMainActivity: AppCompatActivity{
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val loaded = OpenCVLoader.initDebug
Log.d( "~~~", "loaded: $loaded")
if(loaded) {
val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)
val bitmap2 = Bitmap.createBitmap(bitmap1, 0, 0, bitmap1.width / 2, bitmap1.height / 2)
Log.d( "~~~", "similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")
}
}
}
在 onCreate 方法中,先調用 OpenCVLoader.initDebug 方法初始化 OpenCV,通過其返回值判斷是否加載成功,當加載成功后再執(zhí)行我們剛才的比較相似度邏輯。
運行程序,Logcat 控制臺輸出如下:
D/~~~: loaded: true
I/~~~: matches.size: 190
I/~~~: matchCount: 88
D/~~~: similarity: 0.46
表示兩張圖片的相似度為 46%,說明我們的程序已經(jīng)正常工作了。
后記
到這里,我們的外掛三部曲就完結了。這三章講述了三個獨立的技術點:模擬點擊、應用外截屏、圖像識別。這些技術對用戶而言有些風險,所以通常都需要用戶手動授權。比如模擬點擊前需要用戶開啟輔助功能,截取屏幕前需要用戶同意應用讀取屏幕。
為什么沒有講他們的綜合運用呢?這實際上是我無奈之舉。這些技術綜合運用起來像是黑魔法,有些黑科技成分,不便細講,我平時也只運用在自己的個人手機上,讓它們幫我做一些機械的重復工作。這幾篇文章只是給大家介紹錘子、釘子、板子,如何用它們制作桌椅板凳還需要讀者親自動手。
為了防止失聯(lián),歡迎關注我防備的小號
微信改了推送機制,真愛請星標本公號??
掃描二維碼推送至手機訪問。
版權聲明:本文由飛速云SEO網(wǎng)絡優(yōu)化推廣發(fā)布,如需轉載請注明出處。