1. 程式人生 > >子視窗(child window)應用實戰

子視窗(child window)應用實戰

1 在頁面任意位置展示一個漂浮view

1.1 需求背景

前幾天接到一個小需求,要在app某頁面中顯示一個漂浮的文字提示。本來想就彈個Toast的,但PM還要求文字提示支援手動關閉(比如觸控式螢幕幕任意位置關閉,或者點選文字提示後關閉),那麼系統的Toast就不能用了。另一方面,因為需要彈出文字提示的頁面無論頁面展示邏輯還是業務邏輯都異常複雜,所以不太希望去它的佈局檔案中新增view,也不希望對其程式碼邏輯有過多的修改——最好是能夠一行程式碼展示文字提示,一行程式碼移除文字提示。

最終確定的方案是使用子視窗。為什麼使用子視窗而不是頂級視窗(系統級視窗)呢?因為子視窗有與父視窗相同的生命週期。當文字提示以頁面的子視窗的形式存在時,當用戶按下返回鍵關閉頁面,文字提示也會跟著頁面一起消失,而不是繼續留在螢幕上。

寫了一個單獨的Tip類來實現這個功能,效果如下:

1.2 使用方式

基本使用方式:

//展示tip
Tip tip = new Tip(view) //view:需要展示tip的頁面中的任意一個view,用來獲取父視窗的windowToken
        .content("文字提示")
        .show();

//關閉tip
tip.remove();

可選項:

Tip tip = new Tip(view)
        .content("文字提示")
        .location(Gravity.LEFT | Gravity.TOP, 0, 0
) //指定tip在父視窗中的位置,若不指定,則居中顯示 .clickToRemove(true) //點選tip時tip消失 .show(); tip.updateContent("新文字提示"); //更新tip的顯示內容 tip.updateLocation(Gravity.CENTER, 0, 0); //更新tip的位置

自定義tip的檢視:

View contentView = View.inflate(MainActivity.this, R.layout.view_tip, null);
Tip tip = new Tip(view)
        .content(contentView)
        .show();

1.3 程式碼實現

Tip類的實現很簡單,僅有200來行程式碼,對WindowManager稍有了解的朋友應該很容易理解。有兩個地方需要稍微解釋一下:

1 獲取不到windowToken

要展示一個子視窗,需要獲取父視窗的windowToken。我們獲取windowToken的方式是,在show()方法中呼叫viewInParentWindow的getWindowToken方法(viewInParentWindow就是建立Tip物件時傳遞給構造方法的那個view)。這裡可能會出現一種情況,就是如果show()方法被呼叫得過早的話,比如在Activity的onCreate方法中,此時viewInParentWindow尚未被attach到父視窗中,那麼viewInParentWindow.getWindowToken()會返回null,使用這個空的windowToken來顯示tip會導致程式丟擲異常並崩潰。如何解決這個問題呢?這裡採用了一種簡單粗暴的方式,就是使用一個定時器去檢查viewInParentWindow.getWindowToken()的返回值,如果為null的話,就0.5s後再去檢查,如果不為null,則使用這個windowToken來展示tip:

//如果show()呼叫得太早,比如在Activity的onCreate方法中,此時viewInParentWindow尚未被attach到父視窗中,
//那麼viewInParentWindow.getWindowToken()會返回null
//因此在後面操作開始之前必須確保getWindowToken()已有非null值,每0.5s檢查一次
final Timer timer = new Timer();
timer.scheduleAtFixedRate(new TimerTask() {
    @Override
    public void run() {
        if (viewInParentWindow.getWindowToken() != null) {
            timer.cancel();

            new Handler(applicationContext.getMainLooper()).post(new Runnable() {//切換到主執行緒
                @Override
                public void run() {
                    //tip展示邏輯
                }
            });
        }
    }
}, 0, 500);

2 窗體洩露(WindowLeaked)

當用戶關閉頁面時,如果此頁面中有子視窗(也就是tip)正在展示的話,那麼子視窗會被自動關閉,同時會丟擲一個WindowLeaked異常。雖然這個異常並不會造成程式崩潰之類的嚴重後果,但logcat裡鮮紅的異常日誌看著也是很不爽的。解決窗體洩露的辦法是在頁面關閉之前,先將頁面中展示的tip移除,比如在activity的onDestroy方法中呼叫tip的remove方法。但實際開發中的情況是比較複雜的,有時候要想確保在頁面關閉前呼叫tip的remove方法並不太容易。那麼不妨換個角度:能不能讓tip自己去監聽頁面的關閉,並在頁面關閉前將自己移除呢?其實這很容易做到:

viewInParentWindow.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
    @Override
    public void onViewAttachedToWindow(View v) {

    }

    @Override
    public void onViewDetachedFromWindow(View v) {
        remove();
    }
});

當頁面關閉時,頁面中所有的view(包括viewInParentWindow)都會被從頁面所在的視窗中detach掉,那麼onViewDetachedFromWindow就會被呼叫,此時呼叫tip的remove方法將其移除就可以避免窗體洩露(windowLeaked)。

完整程式碼

/**
 * 作為子視窗存在的tip,依附於父視窗,當父視窗不可見時,tip也不可見。
 */
public class Tip {
    private static final String TAG = "Tip";

    private Context applicationContext;
    private WindowManager wm;
    private View viewInParentWindow;//父視窗中的一個view,通過此view來獲取父視窗的各種資訊

    private View content;
    private Size size;
    private Location location;
    private boolean clickToRemove = false;

    private static class Size {
        int width, height;

        public Size(int width, int height) {
            this.width = width;
            this.height = height;
        }
    }

    private static class Location {
        int gravity, offsetX, offsetY;

        Location(int gravity, int offsetX, int offsetY) {
            this.gravity = gravity;
            this.offsetX = offsetX;
            this.offsetY = offsetY;
        }
    }

    /**
     * @param viewInParentWindow:父視窗中的一個view。因為tip是作為子視窗而存在的,因此必須傳入一個父視窗中的view,用以獲取父視窗的各種資訊。
     */
    public Tip(@NonNull View viewInParentWindow) {
        applicationContext = viewInParentWindow.getContext().getApplicationContext();
        wm = (WindowManager) applicationContext.getSystemService(Context.WINDOW_SERVICE);
        this.viewInParentWindow = viewInParentWindow;

        this.viewInParentWindow.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
            @Override
            public void onViewAttachedToWindow(View v) {

            }

            //當父視窗被關閉時,父視窗中的view會被detach,那麼此方法就會被呼叫,
            //此時需要移除tip,防止視窗洩露(windowLeaked)。
            @Override
            public void onViewDetachedFromWindow(View v) {
                remove();
            }
        });
    }

    /**
     * 以預設的textView作為tip的內容
     */
    public Tip content(@NonNull String message) {
        this.content = buildDefaultView(message);
        return this;
    }

    private View buildDefaultView(@NonNull String message) {
        TextView textView = new TextView(applicationContext);
        textView.setText(message);
        textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16);
        textView.setTextColor(Color.parseColor("#FFFFFF"));
        textView.setBackgroundColor(Color.parseColor("#000000"));
        float density = applicationContext.getResources().getDisplayMetrics().density;
        textView.setPadding((int) (20 * density), (int) (10 * density), (int) (20 * density), (int) (10 * density));
        return textView;
    }

    /**
     * 以引數view作為tip的內容
     */
    public Tip content(@NonNull View view) {
        this.content = view;
        return this;
    }

    /**
     * 設定Tip的尺寸
     * <p>
     * 注意:
     * 1.一般情況下不需要呼叫此方法,Tip的預設尺寸為wrap_content
     * 2.極少數情況下,當通過content(View)傳入的view尺寸過大時,可能會出現顯示異常(原因暫不明確,可能與系統對子視窗的限制有關),此時呼叫此方法可能會解決問題
     */
    public Tip size(int width, int height) {
        this.size = new Size(width, height);
        return this;
    }

    /**
     * 設定tip的顯示位置,若不呼叫此方法,那tip的預設位置就是居中
     *
     * @param gravity:對齊方式,如"Gravity.LEFT|Gravity.TOP"
     * @param offsetX:在對齊方式基礎上的橫向偏移量,單位為畫素
     * @param offsetY:在對齊方式基礎上的縱向偏移量,單位為畫素
     */
    public Tip location(int gravity, int offsetX, int offsetY) {
        this.location = new Location(gravity, offsetX, offsetY);
        return this;
    }

    /**
     * 是否要點選移除(預設否)
     */
    public Tip clickToRemove(boolean clickToRemove) {
        this.clickToRemove = clickToRemove;
        return this;
    }

//-----------------------------------------------------------------------------

    /**
     * 展示tip
     */
    public Tip show() {
        if (content == null) {
            Log.e(TAG, "尚未設定顯示內容,請先呼叫content(String)或content(View)");
            return this;
        }

        //如果show()呼叫得太早,比如在Activity的onCreate方法中,此時viewInParentWindow尚未被attach到父視窗中,
        //那麼viewInParentWindow.getWindowToken()會返回null
        //因此在後面操作開始之前必須確保getWindowToken()已有非null值,每0.5s檢查一次
        final Timer timer = new Timer();
        timer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                if (viewInParentWindow.getWindowToken() != null) {
                    timer.cancel();

                    new Handler(applicationContext.getMainLooper()).post(new Runnable() {//切換到主執行緒
                        @Override
                        public void run() {
                            WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
                                    WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
                                    0, 0, PixelFormat.TRANSPARENT);
                            layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
                            layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;//子視窗
                            layoutParams.token = viewInParentWindow.getWindowToken();//必須給子視窗設定token,即其父視窗的token
                            if (size != null) {
                                layoutParams.width = size.width;
                                layoutParams.height = size.height;
                            }
                            if (location != null) {
                                layoutParams.gravity = location.gravity;
                                layoutParams.x = location.offsetX;
                                layoutParams.y = location.offsetY;
                            }
                            wm.addView(content, layoutParams);

                            if (clickToRemove) {
                                content.setOnClickListener(new View.OnClickListener() {
                                    @Override
                                    public void onClick(View v) {
                                        remove();
                                    }
                                });
                            }
                        }
                    });
                }
            }
        }, 0, 500);

        return this;
    }

    /**
     * tip是否正在被顯示
     */
    public boolean isShowing() {
        return ViewCompat.isAttachedToWindow(content);
    }

    /**
     * 更新tip的內容
     * <p>
     * 僅適用於通過content(String)建立的tip
     */
    public void updateContent(@NonNull String message) {
        if (content == null) {
            Log.e(TAG, "content為null");
            return;
        }

        if (!ViewCompat.isAttachedToWindow(content)) {
            Log.e(TAG, "content並未顯示在任何視窗中");
            return;
        }

        if (!(content instanceof TextView)) {
            Log.e(TAG, "content不是TextView");
            return;
        }

        TextView tv = (TextView) content;
        tv.setText(message);
    }

    /**
     * 更新tip的顯示位置
     *
     * @param gravity:對齊方式,如"Gravity.LEFT|Gravity.TOP"
     * @param offsetX:在對齊方式基礎上的橫向偏移量,單位為畫素
     * @param offsetY:在對齊方式基礎上的縱向偏移量,單位為畫素
     */
    public void updateLocation(int gravity, int offsetX, int offsetY) {
        if (content == null) {
            Log.e(TAG, "content為null");
            return;
        }

        if (!ViewCompat.isAttachedToWindow(content)) {
            Log.e(TAG, "content並未顯示在任何視窗中");
            return;
        }

        WindowManager.LayoutParams layoutParams = (WindowManager.LayoutParams) content.getLayoutParams();
        layoutParams.gravity = gravity;
        layoutParams.x = offsetX;
        layoutParams.y = offsetY;
        wm.updateViewLayout(content, layoutParams);
    }

    /**
     * 移除tip
     */
    public void remove() {
        if (content != null && ViewCompat.isAttachedToWindow(content)) {
            wm.removeViewImmediate(content);
        }
    }
}

1.4 GlobalTip

在最開始提供的demo程式碼中,除了通過子視窗實現漂浮view的Tip類之外,還有一個通過頂級視窗來實現漂浮view的GlobalTip類。頂級視窗和子視窗的區別在於,頂級視窗不依賴於app中的任何頁面,它的生命週期等於app的生命週期,也就是說只有當整個app關閉時,頂級窗口才會被自動關閉。GlobalTip類的實現與Tip類大同小異,就不另作說明了。

2 由伺服器靈活配置的浮動廣告

2.1 需求背景

這是幾個月以前team leader給的一個小任務,要求寫一個工具類,能夠在app中方便地展示浮動廣告(其實就是一些活動資訊,比如節日活動之類的),浮動廣告的展示邏輯要求與app自身的邏輯良好解耦,以便在多個app中進行復用,此外,浮動廣告的內容、尺寸、展示位置等資訊均通過伺服器來配置,這樣可以做到隨時更新,而無需app發版。

最終的效果:

2.2 程式碼實現

這大概只能算是一個草稿,沒有在專案中實際使用過(因為需求很快就被砍掉了),也沒有考慮效能、相容性等問題,僅供參考:

public abstract class BaseAdManager {
    private static final String TAG = "BaseAdManager";

    protected View viewInParentWindow;
    OnContentClickListener listener;
    ArrayList<AdInfo> adInfos;//從伺服器獲取的廣告配置資訊
    ArrayList<Tip> tips = new ArrayList<>();//正在展示的廣告列表

    /**
     * 展示廣告
     *
     * @param viewInParentWindow 展示廣告的頁面中的一個view
     * @param listener           廣告圖片的點選監聽
     */
    public void showAds(@NonNull final View viewInParentWindow, @Nullable OnContentClickListener listener) {
        this.viewInParentWindow = viewInParentWindow;
        this.listener = listener;

        new Thread(new Runnable() {
            @Override
            public void run() {
                adInfos = getAdInfos();//getAdInfos():同步從伺服器獲取廣告配置資訊,耗時操作
                new Handler(viewInParentWindow.getContext().getMainLooper()).post(new Runnable() {//切換到主執行緒
                    @Override
                    public void run() {
                        show();
                    }
                });
            }
        }).start();
    }

    private void show() {
        if (adInfos != null) {
            removeAll();//若已有正在展示的廣告,則先移除

            for (final AdInfo adInfo : adInfos) {
                View adView = View.inflate(viewInParentWindow.getContext().getApplicationContext(), R.layout.view_ad, null);

                //載入廣告圖片
                ImageView iv_content = (ImageView) adView.findViewById(R.id.iv_content);
                Picasso.with(viewInParentWindow.getContext().getApplicationContext())
                        .load(adInfo.imageUrl)
                        .resize(adInfo.width, adInfo.height)
                        .into(iv_content);

                //展示廣告
                final Tip tip = new Tip(viewInParentWindow)
                        .content(adView)
                        .size(adInfo.width, adInfo.height)
                        .location(adInfo.gravity, adInfo.offsetX, adInfo.offsetY)
                        .show();
                tips.add(tip);//將廣告加入廣告列表,removeAll()方法會遍歷這個廣告列表

                iv_content.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        if (listener != null) {
                            listener.onAdContentClick(adInfo.detail);
                        }
                    }
                });

                //廣告圖片右上角的x按鈕
                adView.findViewById(R.id.iv_close).setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        tip.remove();
                        tips.remove(tip);
                    }
                });
            }
        }
    }

    /**
     * 移除全部廣告
     */
    public void removeAll() {
        for (Tip tip : tips) {
            tip.remove();
        }
        tips.clear();
    }

//---------------------------------------------------------------------------

    /**
     * 廣告圖片的點選監聽
     */
    public interface OnContentClickListener {
        void onAdContentClick(String detail);
    }

    /**
     * 廣告配置資訊
     */
    public class AdInfo {
        String imageUrl;
        String detail;
        int width, height;
        int gravity, offsetX, offsetY;

        /**
         * 廣告配置資訊
         *
         * @param imageUrl:廣告圖片的url
         * @param detail:廣告詳情。通常是一個url,指向廣告詳情頁面
         * @param width:廣告圖片展示的寬度,單位為px
         * @param height:廣告圖片展示的高度,單位為px
         * @param gravity:廣告圖片在頁面中的位置,例如"Gravity.LEFT|Gravity.TOP"
         * @param offsetX:廣告圖片位置的橫向偏移量,單位為px
         * @param offsetY:廣告圖片位置的縱向偏移量,單位為px
         */
        public AdInfo(String imageUrl, String detail, int width, int height, int gravity, int offsetX, int offsetY) {
            this.imageUrl = imageUrl;
            this.detail = detail;
            this.width = width;
            this.height = height;
            this.gravity = gravity;
            this.offsetX = offsetX;
            this.offsetY = offsetY;
        }
    }

    /**
     * 同步從伺服器獲取廣告配置資訊
     * <p>
     * 以okhttp為例,使用call.execute(...)而不是call.enque(...)
     */
    public abstract ArrayList<AdInfo> getAdInfos();
}

BaseAdManager是一個抽象類,其中包含抽象方法getAdInfos(),子類需要覆寫這個方法並返回需要展示的廣告的配置資訊,即ArrayList<AdInfo>。通常的做法是,在getAdInfos()方法中直接使用網路框架的同步方法從伺服器獲取廣告配置資訊(比如okhttp的call.execute)。

廣告配置資訊AdInfo中,除了廣告的尺寸和位置資訊之外,還包含String imageUrlString detail這兩個欄位:imageUrl是廣告圖片的url;而detail是該廣告的詳細資訊,通常它是一個指向廣告詳情頁的url,當廣告圖片被使用者點選時,detail會被傳遞給BaseAdManager的使用者,BaseAdManager的使用者可以使用瀏覽器或webView開啟這個url,以便向用戶展示廣告的詳細資訊。

BaseAdManager的工作流程:當用戶通過BaseAdManager的子類例項呼叫其showAds(...)方法時,BaseAdManager會通過子類實現的getAdInfos()從伺服器獲取廣告配置資訊,然後根據廣告配置資訊建立一系列Tip物件並展示出來。

2.3 使用示例

首先是寫一個子類實現BaseAdManager。在下面的程式碼中,因為沒有伺服器支援,所以直接手寫了一個ArrayList<AdInfo>,而實際使用的話,ArrayList<AdInfo>應該是從伺服器同步獲取的:

public class AdManager1 extends BaseAdManager {
    private static final String TAG = "AdManager1";

    @Override
    public ArrayList<AdInfo> getAdInfos() {
        ArrayList<AdInfo> adInfos = new ArrayList<>();
        adInfos.add(new AdInfo(
                "https://s1.ax1x.com/2017/09/27/lyHYR.png",
                "跳轉到詳情頁1",
                300,
                300,
                Gravity.RIGHT, 0, 0
        ));
        adInfos.add(new AdInfo(
                "https://s1.ax1x.com/2017/09/27/ly7k9.jpg",
                "跳轉到詳情頁2",
                1080,
                567,
                Gravity.BOTTOM, 0, 0
        ));
        return adInfos;
    }
}

建立子類例項並呼叫showAds(...)方法:

//展示廣告
AdManager1 adManager1 = new AdManager1();
adManager1.showAds(anyView, new BaseAdManager.OnContentClickListener() {
    @Override
    public void onAdContentClick(String detail) {
        Toast.makeText(MainActivity.this, detail, Toast.LENGTH_SHORT).show();
    }
});

//隱藏廣告
adManager1.removeAll();