前言
現在很多應用都使用到懸浮窗,例如微信在視頻的時候,點擊Home鍵,視頻小窗口仍然會在屏幕上顯示。這個功能在很多情況下都非常有用。那麼今天我們就來實現一下Android懸浮窗,以及探索一下實現懸浮窗時的易錯點。
實現原理
懸浮窗插入介面
在實現懸浮窗之前,我們需要知道通過什麼介面,能夠將一個控制項放入到屏幕中去。 Android的界面繪製,都是通過WindowMananger的服務來實現的。那麼,既然要實現一個能夠在自身應用以外的界面上的懸浮窗,我們就要利用WindowManager來「做手腳」。
(frameworks/base/core/java/android/view/WindowMananger.java)
@SystemService(Context.WINDOW_SERVICE)
public interface WindowManager extends ViewManager {
…
}
WindowManager實現了ViewManager介面,可以通過獲取WINDOW_SERVICE系統服務得到。而ViewManager介面有addView方法,我們就是通過這個方法將懸浮窗控制項加入到屏幕中去。
許可權設置及請求
懸浮窗需要在別的應用之上顯示控制項,很顯然,這需要某些許可權才可以。 在API Level >= 23的時候,需要在AndroidManefest.xml文件中聲明許可權SYSTEM_ALERT_WINDOW才能在其他應用上繪製控制項。
除了這個許可權外,我們還需要在系統設置裡面對本應用進行設置懸浮窗許可權。該許可權在應用中需要啟動Settings.ACTION_MANAGE_OVERLAY_PERMISSION來讓用戶手動設置許可權。
startActivityForResult(new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse(“package:” + getPackageName())), REQUEST_CODE);
LayoutParam設置
WindowManager的addView方法有兩個參數,一個是需要加入的控制項對象,另一個參數是WindowManager.LayoutParam對象。
這裡需要著重說明的是LayoutParam里的type變數。這個變數是用來指定窗口類型的。在設置這個變數時,需要注意一個坑,那就是需要對不同版本的Android系統進行適配。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
layoutParams.type = WindowManager.LayoutParams.TYPE_PHONE;
}
在Android 8.0之前,懸浮窗口設置可以為TYPE_PHONE,這種類型是用於提供用戶交互操作的非應用窗口。
而Android 8.0對系統和API行為做了修改,包括使用SYSTEM_ALERT_WINDOW許可權的應用無法再使用一下窗口類型來在其他應用和窗口上方顯示提醒窗口:
- TYPE_PHONE
- TYPE_PRIORITY_PHONE
- TYPE_SYSTEM_ALERT
- TYPE_SYSTEM_OVERLAY
- TYPE_SYSTEM_ERROR
如果需要實現在其他應用和窗口上方顯示提醒窗口,那麼必須該為TYPE_APPLICATION_OVERLAY的新類型。 如果在Android 8.0以上版本仍然使用TYPE_PHONE類型的懸浮窗口,則會出現如下異常信息:
android.view.WindowManager$BadTokenException: Unable to add window android.view.ViewRootImpl$W@f8ec928 — permission denied for window type 2002
具體實現
下面來講解一下懸浮窗的具體實現方式。為了讓懸浮窗與Activity脫離,使其在應用處於後台時懸浮窗仍然可以正常運行,這裡使用Service來啟動懸浮窗並做為其背後邏輯支撐。 在啟動服務之前,需要先判斷一下當前是否允許開啟懸浮窗。
(MainActivity.java)
public void startFloatingService(View view) {
…
if (!Settings.canDrawOverlays(this)) {
Toast.makeText(this, “當前無許可權,請授權”, Toast.LENGTH_SHORT);
startActivityForResult(new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse(“package:” + getPackageName())), 0);
} else {
startService(new Intent(MainActivity.this, FloatingService.class));
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == 0) {
if (!Settings.canDrawOverlays(this)) {
Toast.makeText(this, “授權失敗”, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, “授權成功”, Toast.LENGTH_SHORT).show();
startService(new Intent(MainActivity.this, FloatingService.class));
}
}
}
懸浮窗控制項可以是任意的View的子類類型。這裡先以一個最簡單的Button來做示例。
(FloatingService.java)
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
showFloatingWindow();
return super.onStartCommand(intent, flags, startId);
}
private void showFloatingWindow() {
if (Settings.canDrawOverlays(this)) {
// 獲取WindowManager服務
WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
// 新建懸浮窗控制項
Button button = new Button(getApplicationContext());
button.setText(“Floating Window”);
button.setBackgroundColor(Color.BLUE);
// 設置LayoutParam
WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
layoutParams.type = WindowManager.LayoutParams.TYPE_PHONE;
}
layoutParams.format = PixelFormat.RGBA_8888;
layoutParams.width = 500;
layoutParams.height = 100;
layoutParams.x = 300;
layoutParams.y = 300;
// 將懸浮窗控制項添加到WindowManager
windowManager.addView(button, layoutParams);
}
}
好了,完成了! 對,沒看錯,最簡單的懸浮窗這就實現了。是不是很簡單?來看看效果吧。
當然了,這個懸浮窗的效果僅僅是顯示出來,離真正想要的效果還相差甚遠。不過基礎的原理是已經實現了,剩下的就是要在這上面一點點的添加功能啦。
增加小功能
拖動功能
首先想要增加的功能就是能夠拖動這個懸浮窗。因為懸浮窗顯示的位置也許會擋住背後我們想要看到的信息,如果能夠把懸浮窗拖走那就最好了。 在Android中,觸摸事件的處理算是一個最基本操作了,直接上代碼。
(FloatingService.java)
private void showFloatingWindow() {
…
button.setOnTouchListener(new FloatingOnTouchListener());
…
}
private class FloatingOnTouchListener implements View.OnTouchListener {
private int x;
private int y;
@Override
public boolean onTouch(View view, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
x = (int) event.getRawX();
y = (int) event.getRawY();
break;
case MotionEvent.ACTION_MOVE:
int nowX = (int) event.getRawX();
int nowY = (int) event.getRawY();
int movedX = nowX – x;
int movedY = nowY – y;
x = nowX;
y = nowY;
layoutParams.x = layoutParams.x + movedX;
layoutParams.y = layoutParams.y + movedY;
// 更新懸浮窗控制項布局
windowManager.updateViewLayout(view, layoutParams);
break;
default:
break;
}
return false;
}
}
這裡需要注意的是,在代碼注釋處的更新懸浮窗控制項布局的方法。只有調用了這個方法,懸浮窗的位置才會發生改變。看看效果吧。
圖片自動播放
下面我們對懸浮窗做一些小變動,來演示一下略微複雜一丟丟的界面。這裡的懸浮窗界面我們不再單純的使用一個Button控制項,而是在一個LinearLayout內加一個ImageView,布局文件如下。
(image_display.xml)
android:layout_width=”match_parent”
android:layout_height=”match_parent”
android:orientation=”vertical” >
android:layout_width=”wrap_content”
android:layout_height=”wrap_content” />
在創建懸浮窗布局的地方做一些修改。
(FloatingService.java)
private void showFloatingWindow() {
…
LayoutInflater layoutInflater = LayoutInflater.from(this);
displayView = layoutInflater.inflate(R.layout.image_display, null);
displayView.setOnTouchListener(new FloatingOnTouchListener());
ImageView imageView = displayView.findViewById(R.id.image_display_imageview);
imageView.setImageResource(images[imageIndex]);
windowManager.addView(displayView, layoutParams);
…
}
我們還想讓圖片隔兩秒就切換一張,那麼就再做一個定時切換圖片的機制吧。
(FloatingService.java)
@Override
public void onCreate() {
…
changeImageHandler = new Handler(this.getMainLooper(), changeImageCallback);
}
private Handler.Callback changeImageCallback = new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
if (msg.what == 0) {
imageIndex++;
if (imageIndex >= 5) {
imageIndex = 0;
}
if (displayView != null) {
((ImageView) displayView.findViewById(R.id.image_display_imageview)).setImageResource(images[imageIndex]);
}
changeImageHandler.sendEmptyMessageDelayed(0, 2000);
}
return false;
}
};
private void showFloatingWindow() {
…
windowManager.addView(displayView, layoutParams);
changeImageHandler.sendEmptyMessageDelayed(0, 2000);
}
來看一下懸浮窗自動播放圖片的效果吧。
視頻小窗口
下面我們就來看看懸浮窗最常用的功能:視頻小窗口。例如微信在視頻過程中退出界面,就會以小窗口的形式來顯示視頻。在這裡,我先以MediaPlay和SurfaceView播放一個網路視頻來模擬一下效果。實現起來與上面的圖片播放器基本相同,只是改變了控制項和相應的播放邏輯。布局文件類似上面的圖片播放器,只是把ImageView替換成了SurfaceView。 創建懸浮窗控制項。
(FloatingService.java)
private void showFloatingWindow() {
…
MediaPlayer mediaPlayer = new MediaPlayer();
mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
SurfaceView surfaceView = displayView.findViewById(R.id.video_display_surfaceview);
final SurfaceHolder surfaceHolder = surfaceView.getHolder();
surfaceHolder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
mediaPlayer.setDisplay(surfaceHolder);
}
…
);
mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mp) {
mediaPlayer.start();
}
});
try {
mediaPlayer.setDataSource(this, Uri.parse(“https://raw.githubusercontent.com/dongzhong/ImageAndVideoStore/master/Bruno%20Mars%20-%20Treasure.mp4”));
mediaPlayer.prepareAsync();
}
catch (IOException e) {
Toast.makeText(this, “無法打開視頻源”, Toast.LENGTH_LONG).show();
}
windowManager.addView(displayView, layoutParams);
}
好啦,下面就來一曲火星哥騷氣的《Treasure》吧。
總結
以上就是Android懸浮窗的實現方式,以及一些小小的簡單應用。 可以總結為以下幾個步驟:
- 聲明及申請許可權
- 構建懸浮窗需要的控制項
- 將控制項添加到`WindowManager`
- 必要時更新`WindowManager`的布局
需要注意的容易掉的坑就是 LayoutParams.type的版本適配問題。