天天看點

Android富文本開發

基礎概念目錄介紹

  • 01.業務需求簡單介紹
  • 02.實作的方案介紹
  • 03.異常狀态下儲存狀态資訊
  • 04.處理軟鍵盤回删按鈕邏輯
  • 05.在指定位置插入圖檔
  • 06.在指定位置插入輸入文字
  • 07.如果對選中文字加粗
  • 08.利用Span對文字屬性處理
  • 09.如何設定插入多張圖檔
  • 10.如何設定插入網絡圖檔
  • 11.如何避免插入圖檔OOM
  • 12.如何删除圖檔或者文字
  • 13.删除和插入圖檔添加動畫
  • 14.點選圖檔可以檢視大圖
  • 15.如何暴露設定文字屬性方法
  • 16.文字中間添加圖檔注意事項
  • 17.鍵盤彈出和收縮優化
  • 18.前背景切換編輯富文本優化
  • 19.生成html片段上傳伺服器
  • 20.生成json片段上傳伺服器
  • 21.圖檔上傳政策問題思考

00.該控件介紹

1.1 富文本介紹

  • 自定義文本控件,支援富文本,包含兩種狀态:編輯狀态和預覽狀态。編輯狀态中,可以對插入本地或者網絡圖檔,可以同時插入多張有序圖檔和删除圖檔,支援圖文混排,并且可以對文字内容簡單操作加粗字型,設定字型下劃線,支援設定文字超連結(超連結支援跳轉),還可以統計富文本中的字數,功能正在開發中和完善中……

1.2 富文本效果圖

Android富文本開發
Android富文本開發
Android富文本開發
Android富文本開發
Android富文本開發

1.3 富文本開源庫

  • 富文本控件支援動态插入文字,圖檔等圖文混排内容。圖檔可以支援本地圖檔,也支援插入網絡連結圖檔;
  • 富文本又兩種狀态:編輯狀态 + 預覽狀态 。兩種狀态可以互相進行切換;
  • 富文本在編輯狀态,可以同時選擇插入超過一張以上的多張圖檔,并且可以動态設定圖檔之間的top間距;
  • 在編輯狀态,支援利用光标删除文字内容,同時也支援用光标删除圖檔;
  • 在編輯狀态,插入圖檔後,圖檔的寬度填充滿手機螢幕的寬度,然後高度可以動态設定,圖檔是劇中裁剪顯示;
  • 在編輯狀态,插入圖檔後,如果本地圖檔過大,要求對圖檔進行品質壓縮,大小壓縮;
  • 在編輯狀态,插入多張圖檔時,添加插入過渡動畫,避免顯示圖檔生硬。結束後,光标移到插入圖檔中的最後一行顯示;
  • 編輯狀态中,圖檔點選暴露點選事件接口,可以在4個邊角位置動态設定一個删除圖檔的功能,點選删除按鈕則删除圖檔;
  • 連續插入多張圖檔時,比如順序1,2,3,注意避免出現圖檔插入順序混亂的問題(異步插入多張圖檔可能出現順序錯亂問題);
  • 在編輯富文本狀态的時候,連續多張圖檔之間插入輸入框,友善在圖檔間輸入文本内容;
  • 在編輯狀态中,可以設定文字大小和顔色,同時做好拓展需求,後期可能添加文本加粗,下劃線,插入超連結,對齊方式等功能;
  • 編輯狀态,連續插入多張圖檔,如果想在圖檔中間插入文字内容,則需要靠譜在圖檔之間預留編輯文本控件,友善操作;
  • 支援對文字選中的内容進行設定加粗,添加下劃線,改變顔色,設定對齊方式等等;
  • 關于富文本字數統計,由于富文本中包括文字和圖檔,是以圖檔和文字數量統計分開。參考易車是:共n個文字,共n個圖檔顯示

2.0 頁面構成分析

  • 整個界面的要求
    • 整體界面可滾動,可以編輯,也可以預覽
    • 内容可編輯可以插入文字、圖檔等。圖檔提供按鈕操作
    • 軟鍵盤删除鍵可删除圖檔,也可以删除文字内容
    • 文字可以修改屬性,比如加粗,對齊,下劃線
  • 根據富文本作出以下分析
    • 使用原生控件,可插入圖檔、文字界面不能用一個EditText來做,需要使用LinearLayout添加不同的控件,圖檔部分用ImageView,界面可滑動最外層使用ScrollView。
    • 使用WebView+js+css方式,富文本格式用html方式展現,比較複雜,對标簽要非常熟悉才可以嘗試使用
  • 使用原生控件多焦點問題分析
    • 界面是由多個輸入區域拼接而成,暫且把輸入區域稱為EditText,圖檔區域稱為ImageView,外層是LinearLayout。
    • 如果一個富文本是:文字1+圖檔1+文字2+文字3+圖檔3+圖檔4;那麼使用LinearLayout包含多個EditText實作的難點:
      • 如何處理記錄目前的焦點區域
      • 如何處理在文字區域的中間位置插入ImageView樣式的拆分和合并
      • 如何處理輸入區域的删除鍵處理

2.2 第一種方案

  • 使用ScrollView作為最外層,布局包含LineaLayout,圖文混排内容,則是用TextView/EditText和ImageView去填充。
  • 富文本編輯狀态:ScrollView + LineaLayout + n個EditText+Span + n個ImageView
  • 富文本預覽狀态:ScrollView + LineaLayout + n個TextView+Span + n個ImageView
  • 删除的時候,根據光标的位置,如果光标遇到是圖檔,則可以用光标删除圖檔;如果光标遇到是文字,則可以用光标删除文字
  • 當插入或者删除圖檔的時候,可以添加一個過渡動畫效果,避免直接生硬的顯示。如何在ViewGroup中添加view,删除view時給相應view和受影響的其他view添加動畫,不太容易做。如果隻是對受到影響的view添加動畫,可以通過設定view的高度使之顯示和隐藏,還可以利用ScrollView通過滾動隐藏和顯示動畫,但其他受影響的view則比較難處理,最終選擇布局動畫LayoutTransition 就可以很好地完成這個功能。

2.3 第二種方法

  • 使用WebView實作編輯器,支援n多格式,例如常見的html或者markdown格式。利用html标簽對富文本處理,這種方式就需要專門處理标簽的樣式。
  • 注意這種方法的實作,需要深入研究js,css等,必須非常熟悉才可以用到實際開發中,可以當作學習一下。這種方式對于圖檔的顯示和上傳,相比原生要麻煩一些。

2.4 富文本支援功能

  • 支援加粗、斜體、删除線、下劃線行内樣式,一行代碼即可設定文本span屬性,十分友善
  • 支援添加單張或者多張圖檔,并且插入過渡動畫友好,同時可以保證插入圖檔順序
  • 支援富文本編輯狀态和預覽狀态的切換,支援富文本内容轉化為json内容輸出,轉化為html内容輸出
  • 支援設定富文本的文字大小,行間距,圖檔和文本間距,以及插入圖檔的寬和高的屬性
  • 圖檔支援點選預覽,支援點選叉号控件去除圖檔,暴露給外部開發者調用。同時加載圖檔的邏輯也是暴露給外部開發者,充分解耦

  • 對于自定義View,如果頁面出現異常導緻自定義View異常退出,則當然希望儲存一些重要的資訊。自定義儲存狀态類,繼承BaseSavedState,代碼如下所示
    public class TextEditorState extends View.BaseSavedState {
    
        public int rtImageHeight;
    
        public static final Creator<TextEditorState> CREATOR = new Creator<TextEditorState>() {
            @Override
            public TextEditorState createFromParcel(Parcel in) {
                return new TextEditorState(in);
            }
    
            @Override
            public TextEditorState[] newArray(int size) {
                return new TextEditorState[size];
            }
        };
    
        public TextEditorState(Parcelable superState) {
            super(superState);
        }
    
        public TextEditorState(Parcel source) {
            super(source);
            rtImageHeight = source.readInt();
        }
    
        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(rtImageHeight);
        }
    }           
  • 如何使用該儲存狀态欄,自定義View中,有兩個特别的方法,分别是onSaveInstanceState和onRestoreInstanceState,具體邏輯如下所示
    /**
     * 儲存重要資訊
     * @return
     */
    @Nullable
    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        TextEditorState viewState = new TextEditorState(superState);
        viewState.rtImageHeight = rtImageHeight;
        return viewState;
    }
    
    /**
     * 複現
     * @param state                                state
     */
    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        TextEditorState viewState = (TextEditorState) state;
        rtImageHeight = viewState.rtImageHeight;
        super.onRestoreInstanceState(viewState.getSuperState());
        requestLayout();
    }           

  • 想了一下,當富文本處于編輯的狀态,利用光标可以進行删除插入點之前的字元。删除的時候,根據光标的位置,如果光标遇到是圖檔,則可以用光标删除圖檔;如果光标遇到是文字,則可以用光标删除文字。
  • 更詳細的來說,監聽删除鍵的點選的邏輯需要注意,當光标在EditText 輸入中間,點選删除不進行處理正常删除;當光标在EditText首端,判斷前一個控件,如果是圖檔控件,删除圖檔控件,如果是輸入控件,删除目前控件并将輸入區域合并成一個輸入區域。
  • 建立一個鍵盤倒退監聽事件,代碼如下所示:
    // 初始化鍵盤倒退監聽,主要用來處理點選回删按鈕時,view的一些列合并操作
    keyListener = new OnKeyListener() {
        @Override
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            //KeyEvent.KEYCODE_DEL    删除插入點之前的字元
            if (event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
                EditText edit = (EditText) v;
                //處于倒退删除的邏輯
                onBackspacePress(edit);
            }
            return false;
        }
    };           
  • 然後針對倒退删除,分為兩種情況,第一種是删除圖檔,第二種是删除文字内容。具體代碼如下所示:
    /**
     * 處理軟鍵盤backSpace回退事件
     * @param editTxt                     光标所在的文本輸入框
     */
    private void onBackspacePress(EditText editTxt) {
        try {
            int startSelection = editTxt.getSelectionStart();
            // 隻有在光标已經頂到文本輸入框的最前方,在判定是否删除之前的圖檔,或兩個View合并
            if (startSelection == 0) {
                int editIndex = layout.indexOfChild(editTxt);
                // 如果editIndex-1<0,
                View preView = layout.getChildAt(editIndex - 1);
                if (null != preView) {
                    if (preView instanceof RelativeLayout) {
                        // 光标EditText的上一個view對應的是圖檔,删除圖檔操作
                        onImageCloseClick(preView);
                    } else if (preView instanceof EditText) {
                        // 光标EditText的上一個view對應的還是文本框EditText
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }           

  • 當點選插入圖檔的時候,需要思考兩個問題。第一個是在那個位置插入圖檔,是以需要定位到這個位置;第二個是插入圖檔後,什麼時候折行操作。
  • 對于上面兩個問題,這個位置可以取光标所在的位置,但是對于一個EditText輸入文本,插入圖檔這個位置可以分多種情況:
    • 如果光标已經頂在了editText的最前面,則直接插入圖檔,并且EditText下移即可
    • 如果光标已經頂在了editText的最末端,則需要添加新的imageView
    • 如果光标已經頂在了editText的最中間,則需要分割字元串,分割成兩個EditText,并在兩個EditText中間插入圖檔
    • 如果目前擷取焦點的EditText為空,直接在EditText下方插入圖檔,并且插入空的EditText
  • 代碼思路如下所示
    /**
     * 插入一張圖檔
     * @param imagePath                            圖檔路徑位址
     */
    public void insertImage(String imagePath) {
        if (TextUtils.isEmpty(imagePath)){
            return;
        }
        try {
            //lastFocusEdit擷取焦點的EditText
            String lastEditStr = lastFocusEdit.getText().toString();
            //擷取光标所在位置
            int cursorIndex = lastFocusEdit.getSelectionStart();
            //擷取光标前面的字元串
            String editStr1 = lastEditStr.substring(0, cursorIndex).trim();
            //擷取光标後的字元串
            String editStr2 = lastEditStr.substring(cursorIndex).trim();
            //擷取焦點的EditText所在位置
            int lastEditIndex = layout.indexOfChild(lastFocusEdit);
            if (lastEditStr.length() == 0) {
                //如果目前擷取焦點的EditText為空,直接在EditText下方插入圖檔,并且插入空的EditText
            } else if (editStr1.length() == 0) {
                //如果光标已經頂在了editText的最前面,則直接插入圖檔,并且EditText下移即可
            } else if (editStr2.length() == 0) {
                // 如果光标已經頂在了editText的最末端,則需要添加新的imageView和EditText
            } else {
                //如果光标已經頂在了editText的最中間,則需要分割字元串,分割成兩個EditText,并在兩個EditText中間插入圖檔
            }
            hideKeyBoard();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }           

  • 前面已經提到了,如果一個富文本是:文字1+圖檔1+文字2+文字3+圖檔3+圖檔4,那麼點選文字1控件則在此輸入文字,點選文字3控件則在此輸入文字。
  • 是以,這樣操作,确定處理記錄目前的焦點區域位置十分重要。目前的編輯器已經添加了多個輸入文本EditText,現在的問題在于需要記錄目前編輯的EditText,在應用樣式的時候定位到輸入的控件,在編輯器中添加一個變量lastFocusEdit。具體可以看代碼……
  • 既然可以記錄最後焦點輸入文本,那麼如何監聽目前的輸入控件呢,這就用到了OnFocusChangeListener,這個又是在哪裡用到,具體如下面所示。要先setOnFocusChangeListener(focusListener) 再 requestFocus。
    /**
     * 所有EditText的焦點監聽listener
     */
    private OnFocusChangeListener focusListener;
    
    
    focusListener = new OnFocusChangeListener() {
        @Override
        public void onFocusChange(View v, boolean hasFocus) {
            if (hasFocus) {
                lastFocusEdit = (EditText) v;
                HyperLogUtils.d("HyperTextEditor---onFocusChange--"+lastFocusEdit);
            }
        }
    };
    
    /**
     * 在特定位置插入EditText
     * @param index                            位置
     * @param editStr                        EditText顯示的文字
     */
    public void addEditTextAtIndex(final int index, CharSequence editStr) {
        //省略部分代碼
        try {
            EditText editText = createEditText("插入文字", EDIT_PADDING);
            editText.setOnFocusChangeListener(focusListener);
            layout.addView(editText, index);
            //插入新的EditText之後,修改lastFocusEdit的指向
            lastFocusEdit = editText;
            //擷取焦點
            lastFocusEdit.requestFocus();
            //将光标移至文字指定索引處
            lastFocusEdit.setSelection(editStr.length(), editStr.length());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }           

  • Span 的分類介紹
    • 字元外觀,這種類型修改字元的外形但是不影響字元的測量,會觸發文本重新繪制但是不觸發重新布局。
      • ForegroundColorSpan,BackgroundColorSpan,UnderlineSpan,StrikethrougnSpan
    • 字元大小布局,這種類型Span會更改文本的大小和布局,會觸發文本的重新測量繪制
      • StyleSpan,RelativeSizeSpan,AbsoluteSizeSpan
    • 影響段落級别,這種類型Span 在段落級别起作用,更改文本塊在段落級别的外觀,修改對齊方式,邊距等。
      • AlignmentSpan,BulletSpan,QuoteSpan
  • 實作基礎樣式 粗體、 斜體、 下劃線 、中劃線 的設定和取消。舉個例子,對文本加粗,文字設定span樣式注意要點,這裡需要區分幾種情況
  • 目前選中區域不存在 bold 樣式 這裡我們選中BB。兩種情況
    • 目前區域緊靠左側或者右側不存在粗體樣式: AABBCC 這時候直接設定 span即可
    • 目前區域緊靠左側或者右側存在粗體樣式如: AABBCC AABBCC AABBCC。這時候需要合并左右兩側的span,隻剩下一個 span
  • 目前選中區域存在了Bold 樣式 選中 ABBC。四種情況:
    • 選中樣式兩側不存在連續的bold樣式 AABBCC
    • 選中内部兩端存在連續的bold 樣式 AABBCC
    • 選中左側存在連續的bold 樣式 AABBCC
    • 選中右側存在連續的bold 樣式 AABBCC
    • 這時候需要合并左右兩側已經存在的span,隻剩下一個 span
  • 接下來逐漸分解,然後處理span的邏輯順序如下所示
    • 首先對選中文字内容樣式情況判斷
    • 邊界判斷與設定
    • 取消Span(當我們選中的區域在一段連續的 Bold 樣式裡面的時候,再次選擇Bold将會取消樣式)
  • 什麼時候取消span呢,這個邏輯是比較複雜的,具體看看下面的舉例。
    • 當我們選中的區域在一段連續的 Bold 樣式裡面的時候,再次選擇Bold将會取消樣式
    • 使用者可以随意的删除文本,在删除過程中可能會出現如下的情況:
      • 使用者輸入了 AABBCCDD
      • 使用者選擇了粗體樣式 AABBCCDD
      • 使用者删除了CC然後顯示如下 : AABB DD
      • 這個時候選中其中的BD 此時,在該區域中 存在兩個span ,并且沒有一個 span 完全包裹選中的 BD
      • 在這種情況下 仍需要進行左右側邊界判斷進行删除。這個具體可以看代碼邏輯。

  • 這裡僅僅是對字型加粗進行介紹,其實設定span可以找到規律。多個span樣式,考慮到後期的拓展性,肯定要進行封裝和抽象,具體該如何處理呢?
    • 設定文本選中内容加粗模式,代碼如下所示,可以看到這裡隻需要傳遞一個lastFocusEdit對象即可,這個對象是最近被聚焦的EditText。
    /**
     * 修改加粗樣式
     */
    public void bold(EditText lastFocusEdit) {
        //擷取editable對象
        Editable editable = lastFocusEdit.getEditableText();
        //擷取目前選中的起始位置
        int start = lastFocusEdit.getSelectionStart();
        //擷取目前選中的末尾位置
        int end = lastFocusEdit.getSelectionEnd();
        HyperLogUtils.i("bold select  Start:" + start + "   end:  " + end);
        if (checkNormalStyle(start, end)) {
            return;
        }
        new BoldStyle().applyStyle(editable, start, end);
    }           
    • 然後如何調用這個,在HyperTextEditor類中代碼如下所示。為何要這樣寫,可以把HyperTextEditor富文本類中設定span的邏輯放到SpanTextHelper類中處理,該類專門處理各種span屬性,這樣代碼結構更加清晰,也友善後期增加更多span屬性,避免一個類代碼太臃腫。
    /**
     * 修改加粗樣式
     */
    public void bold() {
        SpanTextHelper.getInstance().bold(lastFocusEdit);
    }           
  • 然後看一下new BoldStyle().applyStyle(editable, start, end)具體做了什麼?下面這段代碼邏輯,具體可以看07.如果對選中文字加粗的分析思路。
    public void applyStyle(Editable editable, int start, int end) {
        //擷取 從  start 到 end 位置上所有的指定 class 類型的 Span數組
        E[] spans = editable.getSpans(start, end, clazzE);
        E existingSpan = null;
        if (spans.length > 0) {
            existingSpan = spans[0];
        }
        if (existingSpan == null) {
            //目前選中内部無此樣式,開始設定span樣式
            checkAndMergeSpan(editable, start, end, clazzE);
        } else {
            //擷取 一個 span 的起始位置
            int existingSpanStart = editable.getSpanStart(existingSpan);
            //擷取一個span 的結束位置
            int existingSpanEnd = editable.getSpanEnd(existingSpan);
            if (existingSpanStart <= start && existingSpanEnd >= end) {
                //在一個 完整的 span 中
                //删除 樣式
                //
                removeStyle(editable, start, end, clazzE, true);
            } else {
                //目前選中區域存在了某某樣式,需要合并樣式
                checkAndMergeSpan(editable, start, end, clazzE);
            }
        }
    }           

  • 富文本當然支援插入多張圖檔,那麼插入多張圖檔是如何操作呢。插入1,2,3這三張圖檔,如何保證它們的插入順序,進而避免插入錯位,帶着這幾個問題看一下插入多張圖檔操作。
    Observable.create(new ObservableOnSubscribe<String>() {
    @Override
    public void subscribe(ObservableEmitter<String> emitter) {
    try{
        hte_content.measure(0, 0);
        List<Uri> mSelected = Matisse.obtainResult(data);
        // 可以同時插入多張圖檔
        for (Uri imageUri : mSelected) {
            String imagePath = HyperLibUtils.getFilePathFromUri(NewActivity.this,  imageUri);
            Bitmap bitmap = HyperLibUtils.getSmallBitmap(imagePath, screenWidth, screenHeight);
            //壓縮圖檔
            imagePath = SDCardUtil.saveToSdCard(bitmap);
            emitter.onNext(imagePath);
        }
        emitter.onComplete();
    }catch (Exception e){
        e.printStackTrace();
        emitter.onError(e);
    }
    }
    })
    .subscribeOn(Schedulers.io())//生産事件在io
    .observeOn(AndroidSchedulers.mainThread())//消費事件在UI線程
    .subscribe(new Observer<String>() {
        @Override
        public void onComplete() {
            ToastUtils.showRoundRectToast("圖檔插入成功");
        }
    
        @Override
        public void onError(Throwable e) {
            ToastUtils.showRoundRectToast("圖檔插入失敗:"+e.getMessage());
        }
    
        @Override
        public void onSubscribe(Disposable d) {
    
        }
    
        @Override
        public void onNext(String imagePath) {
            //插入圖檔
            hte_content.insertImage(imagePath);
        }
    });           

  • 插入圖檔有兩種情況,一種是本地圖檔,一種是網絡圖檔。由于富文本中對插入圖檔的寬高有限制,即可以動态設定圖檔的高度,這就要求請求網絡圖檔後,需要對圖檔進行處理。
  • 首先看一下插入圖檔的代碼,在HyperTextEditor類中,由于封裝lib,不建議在lib中使用某個圖檔加載庫加載圖檔,而應該是暴露給外部開發者去加載圖檔。
    /**
     * 在特定位置添加ImageView
     */
    public void addImageViewAtIndex(final int index, final String imagePath) {
        if (TextUtils.isEmpty(imagePath)){
            return;
        }
        try {
            imagePaths.add(imagePath);
            final RelativeLayout imageLayout = createImageLayout();
            HyperImageView imageView = imageLayout.findViewById(R.id.edit_imageView);
            imageView.setAbsolutePath(imagePath);
            HyperManager.getInstance().loadImage(imagePath, imageView, rtImageHeight);
            layout.addView(imageLayout, index);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }           
  • 那麼具體在那個地方去loadImage設定加載圖檔呢?可以發現這樣極大地提高了代碼的拓展性,原因是你可能用glide,他可能用Picasso,還有的用ImageLoader,是以最好暴露給外部。
    HyperManager.getInstance().setImageLoader(new ImageLoader() {
        @Override
        public void loadImage(final String imagePath, final ImageView imageView, final int imageHeight) {
            Log.e("---", "imageHeight: "+imageHeight);
            //如果是網絡圖檔
            if (imagePath.startsWith("http://") || imagePath.startsWith("https://")){
                //直接用圖檔加載架構加載圖檔即可
            } else { //如果是本地圖檔
                
            }
        }
    });           

  • 加載一個本地的大圖檔或者網絡圖檔,從加載到設定到View上,如何減下記憶體,避免加載圖檔OOM。
    • 在展示高分辨率圖檔的時候,最好先将圖檔進行壓縮。壓縮後的圖檔大小應該和用來展示它的控件大小相近,在一個很小的ImageView上顯示一張超大的圖檔不會帶來任何視覺上的好處,但卻會占用相當多寶貴的記憶體,而且在性能上還可能會帶來負面影響。
  • 加載圖檔的記憶體都去哪裡呢?
    • 其實我們的記憶體就是去bitmap裡了,BitmapFactory的每個decode函數都會生成一個bitmap對象,用于存放解碼後的圖像,然後傳回該引用。如果圖像資料較大就會造成bitmap對象申請的記憶體較多,如果圖像過多就會造成記憶體不夠用自然就會出現out of memory的現象。
  • 為何容易OOM?
    • 通過BitmapFactory的decode的這些方法會嘗試為已經建構的bitmap配置設定記憶體,這時就會很容易導緻OOM出現。為此每一種解析方法都提供了一個可選的BitmapFactory.Options參數,将這個參數的inJustDecodeBounds屬性設定為true就可以讓解析方法禁止為bitmap配置設定記憶體,傳回值也不再是一個Bitmap對象,而是null。
  • 如何對圖檔進行壓縮?
    • 1.解析圖檔,擷取圖檔資源的屬性
    • 2.計算圖檔的縮放值
    • 3.最後對圖檔進行品質壓縮
  • 具體設定圖檔壓縮的代碼如下所示
    public static Bitmap getSmallBitmap(String filePath, int newWidth, int newHeight) {
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(filePath, options);
        // Calculate inSampleSize
        // 計算圖檔的縮放值
        options.inSampleSize = calculateInSampleSize(options, newWidth, newHeight);
        // Decode bitmap with inSampleSize set
        options.inJustDecodeBounds = false;
        Bitmap bitmap = BitmapFactory.decodeFile(filePath, options);
        // 品質壓縮
        Bitmap newBitmap = compressImage(bitmap, 500);
        if (bitmap != null){
            //手動釋放資源
            bitmap.recycle();
        }
        return newBitmap;
    }           
  • 思考:inJustDecodeBounds這個參數是幹什麼的?
    • 如果設定為true則表示decode函數不會生成bitmap對象,僅是将圖像相關的參數填充到option對象裡,這樣我們就可以在不生成bitmap而擷取到圖像的相關參數了。
  • 為何設定兩次inJustDecodeBounds屬性?
    • 第一次:設定為true則表示decode函數不會生成bitmap對象,僅是将圖像相關的參數填充到option對象裡,這樣我們就可以在不生成bitmap而擷取到圖像的相關參數。
    • 第二次:将inJustDecodeBounds設定為false再次調用decode函數時就能生成bitmap了。而此時的bitmap已經壓縮減小很多了,是以加載到記憶體中并不會導緻OOM。

  • 當富文本處于編輯狀态時,點選删除圖檔是可以删除圖檔的,對于删除的邏輯,封裝的lib可以給開發者暴露一個删除的監聽事件。注意删除圖檔有兩種操作:第一種是利用光标删除,第二種是點選觸發删除。删除圖檔後,不僅僅是要删除圖檔資料,而且還要删除圖檔ImageView控件。
    /**
     * 處理圖檔上删除的點選事件
     * 删除類型 0代表backspace删除 1代表按紅叉按鈕删除
     * @param view                             整個image對應的relativeLayout view
     */
    private void onImageCloseClick(View view) {
        try {
            //判斷過渡動畫是否結束,隻能等到結束才可以操作
            if (!mTransition.isRunning()) {
                disappearingImageIndex = layout.indexOfChild(view);
                //删除檔案夾裡的圖檔
                List<HyperEditData> dataList = buildEditData();
                HyperEditData editData = dataList.get(disappearingImageIndex);
                if (editData.getImagePath() != null){
                    if (onHyperListener != null){
                        onHyperListener.onRtImageDelete(editData.getImagePath());
                    }
                    //SDCardUtil.deleteFile(editData.imagePath);
                    //從圖檔集合中移除圖檔連結
                    imagePaths.remove(editData.getImagePath());
                }
                //然後移除目前view
                layout.removeView(view);
                //合并上下EditText内容
                mergeEditText();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }           

  • 為什麼要添加插入圖檔的過渡動畫
    • 當向一個ViewGroup添加控件或者移除控件;這種場景雖然能夠實作效果,并沒有一點過度效果,直來直去的添加或者移除,顯得有點生硬。有沒有辦法添加一定的過度效果,讓實作的效果顯得圓滑呢?
  • LayoutTransition簡單介紹
    • LayoutTransition類實際上Android系統中的一個實用工具類。使用LayoutTransition類在一個ViewGroup中對布局更改進行動畫處理。
  • 如何運用到插入或者删除圖檔場景中
    • 向一個ViewGroup添加控件或者移除控件,這兩種效果的過程是應對應于控件的顯示、控件添加時其他控件的位置移動、控件的消失、控件移除時其他控件的位置移動等四種動畫效果。這些動畫效果在LayoutTransition中,由以下四個關鍵字做出了相關聲明:
      • APPEARING:元素在容器中顯現時需要動畫顯示。
      • CHANGE_APPEARING:由于容器中要顯現一個新的元素,其它元素的變化需要動畫顯示。
      • DISAPPEARING:元素在容器中消失時需要動畫顯示。
      • CHANGE_DISAPPEARING:由于容器中某個元素要消失,其它元素的變化需要動畫顯示。
    • 也就是說,ViewGroup中有多個ImageView對象,如果需要删除其中一個ImageView對象的話,該ImageView對象可以設定動畫(即DISAPPEARING 動畫形式),ViewGroup中的其它ImageView對象此時移動到新的位置的過程中也可以設定相關的動畫(即CHANGE_DISAPPEARING 動畫形式);
    • 若向ViewGroup中添加一個ImageView,ImageView對象可以設定動畫(即APPEARING 動畫形式),ViewGroup中的其它ImageView對象此時移動到新的位置的過程中也可以設定相關的動畫(即CHANGE_APPEARING 動畫形式)。
    • 給ViewGroup設定動畫很簡單,隻需要生成一個LayoutTransition執行個體,然後調用ViewGroup的setLayoutTransition(LayoutTransition)函數就可以了。當設定了布局動畫的ViewGroup添加或者删除内部view時就會觸發動畫。
  • 具體初始化動畫的代碼如下所示:
    mTransition = new LayoutTransition();
    mTransition.addTransitionListener(new LayoutTransition.TransitionListener() {
    
        @Override
        public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
        }
        
        @Override
        public void endTransition(LayoutTransition transition,
                ViewGroup container, View view, int transitionType) {
            if (!transition.isRunning() && transitionType == LayoutTransition.CHANGE_DISAPPEARING) {
                // transition動畫結束,合并EditText
                 mergeEditText();
            }
        }
    });
    mTransition.enableTransitionType(LayoutTransition.APPEARING);
    mTransition.setDuration(300);
    layout.setLayoutTransition(mTransition);           
  • 有個問題需要注意一下,當控件銷毀的時候,記得把監聽給移除一下更好,代碼如下所示
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (mTransition!=null){
            //移除Layout變化監聽
            mTransition.removeTransitionListener(transitionListener);
        }
    }           
  • 動畫執行先後的順序
    • 分析源碼可以知道,預設情況下DISAPPEARING和CHANGE_APPEARING類型動畫會立即執行,其他類型動畫則會有個延遲。也就是說如果删除view,被删除的view将先執行動畫消失,經過一些延遲受影響的view會進行動畫補上位置,如果添加view,受影響的view将會先給添加的view騰位置執行CHANGE_APPEARING動畫,經過一些時間的延遲才會執行APPEARING動畫。這裡就不貼分析源碼的思路呢!

  • 編輯狀态時,由于圖檔有空能比較大,在顯示在富文本的時候,會裁剪局中顯示,也就是圖檔會顯示不全。那麼後期如果是想添加點選圖檔檢視,則需要暴露給開發者監聽事件,需要考慮到後期拓展性,代碼如下所示:
    • 這樣做的目的是是暴露給外部開發者調用,點選圖檔的操作隻需要傳遞view還有圖檔即可。
    // 圖檔處理
    btnListener = new OnClickListener() {
        @Override
        public void onClick(View v) {
            if (v instanceof HyperImageView){
                HyperImageView imageView = (HyperImageView)v;
                // 開放圖檔點選接口
                if (onHyperListener != null){
                    onHyperListener.onImageClick(imageView, imageView.getAbsolutePath());
                }
            } 
        }
    };           

  • 針對設定文字加粗,下劃線,删除線等span屬性。同時設定span,有許多類似的地方,考慮到後期的添加和移除,如何封裝能夠提高代碼的擴充性。
    /**
     * 修改加粗樣式
     */
    public void bold() {
        SpanTextHelper.getInstance().bold(lastFocusEdit);
    }
    
    /**
     * 修改斜體樣式
     */
    public void italic() {
        SpanTextHelper.getInstance().italic(lastFocusEdit);
    }
    
    /**
     * 修改删除線樣式
     */
    public void strikeThrough() {
        SpanTextHelper.getInstance().strikeThrough(lastFocusEdit);
    }
    
    /**
     * 修改下劃線樣式
     */
    public void underline() {
        SpanTextHelper.getInstance().underline(lastFocusEdit);
    }           
  • 上面實作了選中文本加粗的功能,斜體、 下劃線 、中劃線等樣式的設定和取消與粗體樣式一緻,隻是建立 span 的差別而已,可以将代碼進行抽取。
    public abstract class NormalStyle<E> {
    
        private Class<E> clazzE;
    
        public NormalStyle() {
            //利用反射
            clazzE = (Class<E>) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
        }
    
        /**
         * 樣式情況判斷
         * @param editable                      editable
         * @param start                         start
         * @param end                           end
         */
        public void applyStyle(Editable editable, int start, int end) {
    
        }
    }           
  • 其他的設定span的屬性代碼即是如下所示,可以看到添加一種類型很容易,也容易看懂,便于拓展:
    public class ItalicStyle extends NormalStyle<ItalicStyleSpan> {
        @Override
        protected ItalicStyleSpan newSpan() {
            return new ItalicStyleSpan();
        }
    }
    
    public class UnderlineStyle extends NormalStyle<UnderLineSpan> {
        @Override
        protected UnderLineSpan newSpan() {
            return new UnderLineSpan();
        }
    }           

  • 在文字中添加圖檔比較特殊,是以這裡單獨拿出來說一下。在文字内容中間插入圖檔,則需要分割字元串,分割成兩個EditText,并在兩個EditText中間插入圖檔,那麼這個光标又定位在何處呢?
    • 對于光标前面的字元串保留,設定給目前獲得焦點的EditText(此為分割出來的第一個EditText)
    • 把光标後面的字元串放在新建立的EditText中(此為分割出來的第二個EditText)
    • 在第二個EditText的位置插入一個空的EditText,以便連續插入多張圖檔時,有空間寫文字,第二個EditText下移
    • 在空的EditText的位置插入圖檔布局,空的EditText下移。注意,這個過程添加動畫過渡一下插入的效果比較好,不然會比較生硬
    //擷取光标所在位置
    int cursorIndex = lastFocusEdit.getSelectionStart();
    //擷取光标前面的字元串
    String editStr1 = lastEditStr.substring(0, cursorIndex).trim();
    //擷取光标後的字元串
    String editStr2 = lastEditStr.substring(cursorIndex).trim();
    
    lastFocusEdit.setText(editStr1);
    addEditTextAtIndex(lastEditIndex + 1, editStr2);
    addEditTextAtIndex(lastEditIndex + 1, "");
    addImageViewAtIndex(lastEditIndex + 1, imagePath);           

  • 軟鍵盤彈出的時機
    • 如果不做任何處理,系統預設的是,進入頁面,第一個輸入框自動擷取焦點軟鍵盤自動彈出,這種使用者互動方式,往往不是産品想要的,往往會提出以下優化需求:
    • 需求1:editText擷取焦點,但是不彈出軟鍵盤(也就是說光标顯示第一個輸入框,不主動彈軟鍵盤)
      • 在第一個輸入框的最直接父布局加入:android:focusable="true";android:focusableInTouchMode="true"
        • (效果:軟鍵盤不彈出,光标不顯示,其他輸入框也不擷取焦點,ps非直接父布局沒有效果)
      • android:windowSoftInputMode="stateAlwaysHidden"
        • (效果:軟鍵盤不彈出,光标顯示在第一個輸入框中)
    • 需求2:editText不擷取焦點,當然軟鍵盤不會主動彈出(光标也不顯示)
      • 在父布局最頂部添加一個高度為0的EditText,搶了焦點但不展示;
  • 軟鍵盤遮擋界面的問題
    • 當界面中有輸入框,需要彈起軟鍵盤輸入資訊的時候,軟鍵盤可能遮擋部分布局,更有甚者,目前輸入框如果在螢幕下方,軟鍵盤也會直接遮擋輸入框,這種情況對使用者體驗是相當不友好的,是以要根據具體的情況作出相應的處理。
    • android定義了一個屬性,名字為windowSoftInputMode, 這個屬性用于設定Activity主視窗與軟鍵盤的互動模式,用于避免軟鍵盤遮擋内容的問題。我們可以在AndroidManifet.xml中對Activity進行設定。
    stateUnspecified-未指定狀态:軟體預設采用的互動方式,系統會根據目前界面自動調整軟鍵盤的顯示模式。
    stateUnchanged-不改變狀态:目前界面軟鍵盤狀态由上個界面軟鍵盤的狀态決定;
    stateHidden-隐藏狀态:進入頁面,無論是否有輸入需求,軟鍵盤是隐藏的,但是如果跳轉到下一個頁面軟鍵盤是展示的,回到這個頁面,軟鍵盤可能也是展示的,這個屬性差別下個屬性。
    stateAlwaysHidden-總是隐藏狀态:當設定該狀态時,軟鍵盤總是被隐藏,和stateHidden不同的是,當我們跳轉到下個界面,如果下個頁面的軟鍵盤是顯示的,而我們再次回來的時候,軟鍵盤就會隐藏起來。
    stateVisible-可見狀态:當設定為這個狀态時,軟鍵盤總是可見的,即使在界面上沒有輸入框的情況下也可以強制彈出來出來。
    stateAlwaysVisible-總是顯示狀态:當設定為這個狀态時,軟鍵盤總是可見的,和stateVisible不同的是,當我們跳轉到下個界面,如果下個頁面軟鍵盤是隐藏的,而我們再次回來的時候,軟鍵盤就會顯示出來。
    adjustUnspecified-未指定模式:設定軟鍵盤與軟體的顯示内容之間的顯示關系。當你跟我們沒有設定這個值的時候,這個選項也是預設的設定模式。在這中情況下,系統會根據界面選擇不同的模式。
    adjustResize-調整模式:當軟鍵盤顯示的時候,目前界面會自動重繪,會被壓縮,軟鍵盤消失之後,界面恢複正常(正常布局,非scrollView父布局);當父布局是scrollView的時候,軟鍵盤彈出,會将布局頂起(保證輸入框不被遮擋),不壓縮,而且可以軟鍵盤不消失的情況下,手動滑出被遮擋的布局;
    adjustPan-預設模式:軟鍵盤彈出,軟鍵盤會遮擋螢幕下半部分布局,當輸入框在螢幕下方布局,軟鍵盤彈起,會自動将目前布局頂起,保證,軟鍵盤不遮擋目前輸入框(正常布局,非scrollView父布局)。當父布局是scrollView的時候,感覺沒啥變化,還是自定将布局頂起,輸入框不被遮擋,不可以手動滑出被遮擋的布局(白瞎了scrollView);           
    • 看了上面的屬性,那麼該如何設定呢?具體效果可以看demo案例。
    <activity android:name=".NewArticleActivity"
        android:windowSoftInputMode="adjustResize|stateHidden"/>           
  • 軟鍵盤及時退出的問題
    • 當使用者輸入完成之後,必須手動點選軟鍵盤的收回鍵,軟鍵盤才收起。如果能通過代碼主動将軟鍵盤收起,這對于使用者體驗來說,是一個極大的提升,思前想後,參考網上的文檔,個人比較喜歡的實作方式是通過事件分發機制來解決這個問題。
  • 解決點選EditText彈出收起鍵盤時出現的黑屏閃現現象
    View rootView = hte_content.getRootView();
    rootView.setBackgroundColor(Color.WHITE);           

  • 由于富文本中,使用者會輸入很多的内容,當關閉頁面時候,需要提醒使用者是否儲存輸入内容。同時,切換到背景的時候,需要注意儲存輸入内容,避免長時間切換背景程序記憶體吃緊,在回到前台輸入的内容沒有呢,查閱了汽車之家,易車等app等手機上的富文本編輯器,都會有這個細節點的優化。

19.1 送出富文本

  • 用戶端生成html片段到伺服器
    • 在用戶端送出文章,文章。富文本包括圖檔,文字内容,還有文字span樣式,同時會選擇一些文章,文章的标簽。還有設定文章的類型,封面圖,作者等許多屬性。
    • 當點選送出的時候,用戶端把這些資料,轉化成html,還是轉化成json對象送出給伺服器呢?思考一下,會有哪些問題……
  • 轉化成html
    • 對于将單個富文本轉化成html相對來說是比較容易的,因為富文本中之存在文字,圖檔等。轉化成html細心就可以。
    • 但是對于設定富文本的标簽,類型,作者,封面圖,日期,其他關聯屬性怎麼合并到html中呢,這個相對麻煩。
  • 最後想說的是
    • 對于富文本寫文章,文章,如果寫完富文本送出,則可以使用轉化成html資料送出給伺服器;
    • 對于富文本寫完文章,文章,還有下一步,設定标簽,類型,封面圖,作者,時間,還有其他屬性,則可以使用轉化成json資料送出給伺服器;

19.2 編輯富文本

  • 伺服器傳回html給用戶端加載
    • 涉及到富文本的加載,背景管理端編輯器生成的一段html 代碼要渲染到移動端上面,一種方法是前端做成html頁面,放到伺服器上,移動端這邊直接webView 加載url即可。
    • 還有一種背景接口直接傳回這段html富文本的,String類型的,移動端直接加載的;具體的需求按實際情況而定。
  • 加載html檔案流暢問題
    • webView直接加載url體驗上沒那麼流暢,相對的加載html檔案會好點。但是對比原生,體驗上稍微弱點。
    • 如果不用WebView,使用TextView顯示html富文本,則會出現圖檔不顯示,以及格式問題。
    • 如果不用WebView,使用自定義富文本RichText,則需要解析html顯示,如果對html标簽,js不熟悉,也不太好處理。

  • 參考了易車釋出文章,送出資料到伺服器,針對富文本,是把它拼接成對象。将文字,圖檔按照富文本的順序拼接成json片段,然後送出給伺服器。

20.1 送出富文本

  • 用原生ScrollView + LineaLayout + n個EditText+Span + n個ImageView來實作富文本。可以先建立一個對象用來存儲資料,下面這個實體類比較簡單,開發中字段稍微多些。如下所示
    public class HyperEditData implements Serializable {
    
        /**
         * 富文本輸入文字内容
         */
        private String inputStr;
        /**
         * 富文本輸入圖檔位址
         */
        private String imagePath;
        /**
         * 類型:1,代表文字;2,代表圖檔
         */
        private int type;
    
        //省略很多set,get方法
    }           
  • 然後怎麼去把富文本資料按照有序去放到集合中呢?如下所示,具體可以看demo中的代碼……
    /**
     * 對外提供的接口, 生成編輯資料上傳
     */
    public List<HyperEditData> buildEditData() {
        List<HyperEditData> dataList = new ArrayList<>();
        try {
            int num = layout.getChildCount();
            for (int index = 0; index < num; index++) {
                View itemView = layout.getChildAt(index);
                HyperEditData hyperEditData = new HyperEditData();
                if (itemView instanceof EditText) {
                    //文本
                    EditText item = (EditText) itemView;
                    hyperEditData.setInputStr(item.getText().toString());
                    hyperEditData.setType(2);
                } else if (itemView instanceof RelativeLayout) {
                    //圖檔
                    HyperImageView item = itemView.findViewById(R.id.edit_imageView);
                    hyperEditData.setImagePath(item.getAbsolutePath());
                    hyperEditData.setType(1);
                }
                dataList.add(hyperEditData);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        HyperLogUtils.d("HyperTextEditor----buildEditData------dataList---"+dataList.size());
        return dataList;
    }           
  • 最後将富文本資料轉化為json送出到伺服器,伺服器拿到json後,結合富文本的後續資訊,比如,作者,時間,類型,标簽等建立可以用浏覽器打開的h5頁面,這個需要跟伺服器端配合。如下所示
    List<HyperEditData> editList = hte_content.buildEditData();
    //生成json
    Gson gson = new Gson();
    String content = gson.toJson(editList);
    //轉化成json字元串
    String string = HyperHtmlUtils.stringToJson(content);
    //送出伺服器省略           

20.2 編輯富文本

  • 當然,送出了文章肯定還有稽核功能,這個時候想去修改富文本怎麼辦。ok,需要伺服器把之前傳遞給它的json傳回給用戶端,然後解析填充到富文本中。這個就沒什麼好說的……

  • 大多數開發者會采用的方式:
    • 先在編輯器裡顯示本地圖檔,等待使用者編輯完成再上傳全部圖檔,然後用上傳傳回的url替換之前html中顯示本地圖檔的位置。
  • 這樣會遇到很多問題:
    • 如果圖檔很多,上傳的資料量會很大,手機的網絡狀态經常不穩定,很容易上傳失敗。另外等待時間會很長,體驗很差。
  • 解決辦法探讨:
    • 選圖完成即上傳,得到url之後直接插入,上傳是耗時操作,再加上圖檔壓縮的時間,這樣編輯器顯示圖檔會有可觀的延遲時間,實際項目中可以加一個預設的占位圖,另外加一個标記提醒使用者是否上傳完成,避免沒有上傳成功使用者即送出的問題。
  • 這種場景很容易想到:
    • 比如,在簡書,掘金上寫部落格。寫文章時,插入本地圖檔,即使你沒有送出文章,也會把圖檔上傳到伺服器,然後傳回一個圖檔連結給你,最後當你發表文章時,圖檔隻需要用連結替代即可。
  • 參考部落格

富文本開源庫:

你的star是我開源的動力,謝謝!