借助 android databinding 框架,逃離 adapter 和 viewholder 的噩夢

jhrm9352 7年前發布 | 6K 次閱讀 安卓開發 Android開發 移動開發

前言

理解這篇文章最好有 databinding 的基礎知識,如果之前沒有了解過,推薦下面三篇文章:

  1. 官方文檔

  2. connorlin 的中文翻譯

  3. 棉花糖給 Android 帶來的 Data Bindings

但你也可以先看完這篇文章,大致了解 databinding 可以怎么幫助到我們后,再回去看這些基本介紹。

對于 android 開發者來說,諸多噩夢中,adapter 和 viewholder 絕對算一個,倒不是難,而是煩,千遍一率的相似代碼,對于程序員來說,重復是最令人討厭的。所以這才有這么多仁人志士,前俯后繼地進行通用型,萬能型 adapter 的抽象 。

但是,由于受限于傳統的 android 開發,即使他們已經做得很極致了,但用起來還是不夠爽,我們還是要不停地去實現 ViewHolder。

直到我開始學習 databinding 框架 (我現在有一種趕腳,它簡直就是人民,哦,不對,android 開發者的大救星啊),在 棉花糖給 Android 帶來的 Data Bindings 這篇文章中說到,databinding 可以輕松地實現多類型的 viewholder 時,我在想,我干嘛不用它來解決這個世界性的大難題呢(呵呵)。當下就立馬開始嘗試,實際,只要你寫起來,就會發現,實現起來是非常 easy 和自然的 (但我不知道為什么沒有人這么去做,我 google/github 了也沒看到類似的,讓我陷入了深深地懷疑,難道是我做錯了,還懇請各位大神來 review 我的代碼并打臉我)。

使用 databinding 框架實現的 adapter 只有 100 行左右的代碼,單一 java 文件,理解起來非常容易。從此你就可以忘記 viewholder 了。

當然,根據代碼守恒定律,代碼總量總體是恒定的,我們能使用這么少的代碼,背后是 databinding 框架為我們生成的大量代碼,眼不見,心不煩,更何況這是 google 為我們生成的代碼。

是不是已經按捺不住激動的心情啦。好吧,我們言歸正轉。其實在之前我已經寫 一篇文章 來闡述這種思想,并寫了一個簡陋的例子,但后來我還是覺得這個例子太簡單了,說服力不夠,沒有解決很多人關心的問題,在每個通用型 adapter 的文章下面,都有一堆人在問,如果用你這個 adapter,怎么刷新,怎么加載更多,怎么實現 header item,怎么實現 footer item。那么,我就決定,寫一個完整的例子,一次性解決你們所有的疑問。并且,我把這個過程寫下來,如何一步步實現這些功能,如何一步步重構代碼,使代碼優雅。最后,即使沒有打動你投入 databinding 的懷抱,我覺得你也可以學習到很多。

先來看一下最后的效果,UI 就別吐槽了,畢竟對于工程師來說,獨立開發一個應用,最大的問題就是選顏色了(其實是我啦),本來想用一個 GIF 來展示所有效果的,但一個 GIF 體積太大了,所以就拆成了三個:

  1. 演示 HeaderItem,EmptyItem,ErrorItem,刷新,加載更多,加載出錯并重試:

  2. 演示 沒有更多數據:

  3. 稍微潤色了一下 UI,為數據 Item 增加各種點擊事件:

這個完整的例子包括了以下功能,我想應該能滿足 90% 的需求吧:

  1. 刷新

  2. 加載更多

  3. 支持 header item

  4. 支持 empty item,并允許再次刷新

  5. 支持 error item,并允許再次刷新

  6. 支持 footer item,包括 3 種狀態:加載中,出錯并允許重試,沒有更多數據

  7. 支持多種數據類型的 item,我們在這個例子中只展示了 ImageItem 和 TextItem 兩種類型

這個項目中有兩個文件夾,MultiTyepAdapterSample 和 MultiTypeAdapterTutorial,兩者代碼是幾乎相同的,后者是我為了寫這篇文章重新創建的,每一個關鍵步驟我都打好了 tag,以方便讀者進行對照。

PS: 下面的內容完全是基于 databinding 思想來展開講的,所以如果你對它不感興趣,那么下面的內容對你就沒什么幫助。另外,文章比較長且啰索 (力爭講解到每一個細節),需要比較充裕的時間來閱讀和練習。如果你對 databinding 感興趣,歡迎提交 issue 探討。

大綱

    1. 一步一步實現極簡的 adapter

    </li>
  1. 使用篇

    1. 設置 RecyclerView 和 SwipeRefreshLayout

    2. 實現各種狀態類 item

    3. 實現刷新

    4. 實現加載更多

    5. 為 item 增加事件處理

    6. 獲取 item 的 position

    7. item 與 model 的關系

    8. </ol> </li>
    9. 優化篇

      1. 將 adapter 獨立成庫

      2. 使用 MVP 簡化 Activity 的邏輯

      3. </ol> </li>
      4. 總結篇

      5. </ol>

        實現篇

        一步一步實現極簡的 adapter

        首先創建一個新的 Android Studio 工程,在 app module 的 build.gradle 中加上 databinding 的支持,并導入 recyclerview support 庫:

        // build.gradle
        android {
            //...

        dataBinding {
            enabled = true
        }
        

        }

        dependencies { //... compile 'com.android.support:recyclerview-v7:25.1.0' }</code></pre>

        接著,我們來開始來實現這個 adapter,取名為 MultiTypeAdapter,因為我們要支持多類型,那么 adapter 里的 item 必然是抽象的,我們定義為 IItem:

        // MultiTypeAdapter.java
        public interface IItem {
        }

        private List<IItem> items = new ArrayList<>();</code></pre>

        我們先從簡單的入手,先來看看 getItemCount() ,我們用 showHeader 和 showFooter 兩個變量來控制是否顯示 header 或 footer,那么 getItemCount() 的實現如下:

        // MultiTypeAdapter.java
        @Override
        public int getItemCount() {
          int cnt = items.size();
          if (showHeader) {
            cnt++;
          }
          if (showFooter) {
            cnt++;
          }
          return cnt;
        }

        接著來實現 getItemViewType(int position) ,關于這個方法,一般的實現,我們要根據 position 和相應位置的 item 類型來返回不同的值,比如:

        // MultiTypeAdapter.java
        @Override
        public int getItemViewType(int position) {
            if (position == 0 && showHeader) {
              return ITEM_TYPE_HEADER;
            } else if (position == getItemCount() -1 && showFooter) {
              return ITEM_TYPE_FOOTER;
            } else {
              if (items.get(position) instanceof ImageItem) {
                return ITEM_TYPE_IMAGE;
              } else {
                return ITEM_TYPE_TEXT;
              }
            }
        }

        這樣的實現,很煩很丑是不是。關于這個方法的優化,我們很容易達成一種共識,首先,我們不再返回類似 ITEM_TYPE_IMAGE 這種常量類型,而是直接返回它的 xml layout,其次,我們直接從 item 自身得到這個 layout。因此,我們為 IItem 增加一個 getType() 的接口方法。代碼如下:

        public interface IItem {
          // should directly return layout
          int getType();
        }

        @Override public int getItemViewType(int position) { if (position == 0 && showHeader) { return R.layout.item_header; } else if (position == getItemCount() -1 && showFooter) { return R.layout.item_footer; } else { return items.get(position).getType(); } }</code></pre>

        (2017/2/15 Update: 由于 getType() 實際是應該返回一個 xml layout 的,為了讓這個方法名意義更明確,從 1.0.7 開始,這個方法重命名為 getLayout() ,但整個教程仍然保留為 getType() )

        因為 header 和 footer,尤其是 footer,只是單純地用來顯示 正在 loading 等一些狀態,我們很容易把它跟常規的數據 item 區別對待,但是,實際上我們可以把它看成一個偽 item,沒有數據,只有布局的 item。我們分別實現只有布局的 HeaerItem 和 FooterItem,并在合適的時機加到 items 里面或從 items 里移除,就可以控制 header 和 footer 的顯示與隱藏了。

        // HeaerItem.java
        public class HeaderItem implements MulitTypeAdapter.IItem {
            @Override
            public int getType() {
                return R.layout.item_header;
            }
        }

        // FooterItem.java public class FooterItem implements MulitTypeAdapter.IItem { @Override public int getType() { return R.layout.item_footer; } }</code></pre>

        這樣,我們的 getItemViewType() 終于可以簡化成一行代碼了,清爽!

        @Override
        public int getItemViewType(int position) {
            return items.get(position).getType();
        }

        這樣,我們也不需要 showHeader 和 showFooter 這樣的狀態變量了,那么 getItemCount() 也可以簡化成一行代碼了。

        public int getItemCount() {
            return items.size();
        }

        剛才說到我們要在合適的時機把 HeaerItem 或 FooterItem 加到 items 或從 items 中移除,所以我們給 adapter 加上一些操作 items 的方法。如下所示:

        // MultiTypeAdapter.java
        public void setItem(IItem item) {
            clearItems();
            addItem(item);
        }

        public void setItems(List<IItem> items) { clearItems(); addItems(items); }

        public void addItem(IItem item) { items.add(item); }

        public void addItem(IItem item, int index) { items.add(index, item); }

        public void addItems(List<IItem> items) { this.items.addAll(items); }

        public void removeItem(IItem item) { items.remove(item); }

        public void clearItems() { items.clear(); }</code></pre>

        你可能會想,誒,在這些操作函數里最后再加上 notifyDatasetChanged() 是不是會更方便點,這樣我在上層就不用再手動調用一下 adapter.notifyDatasetChanged() ,實際當你自己寫起來的時候,你就會發現這樣并不靈活。因為,我可能并不想每一次 addItem 都刷新一次 UI,我可能要多次 addItem 后才刷新一次 UI,這樣,在上層由調用者來決定何時刷新 UI 會更靈活,更何況,我可能并不想只調用 notifyDatasetChanged() ,我有時想調用 notifyItemRemoved() ,或是 notifyItemChaned() 。

        當然,你也可以給 adapter 加上一個 getItems() 的方法,然后把這些對 items 的操作邏輯都移動上層去處理,但我自己還是傾向于在 adapter 內封裝這些方法。

        // MulitTypeAdapter.java
        public List<IItem> getItems() {
            return items;
        }

        OK,至此,我們僅僅實現了 adapter 的 getItemCount() 和 getItemViewType() 方法,但是別著急。

        接下來,就該處理難啃的的 onCreateViewHolder() 和 onBindViewHolder() 了,先來看 onCreateViewHolder() 吧。

        在 onCreateViewHolder() 中,我們要根據 viewType 來生成不同的 ViewHolder,假設這里我們只顯示 ImageViewHolder 和 TextViewHolder。要顯示的 item 分別為 ImageItem 和 TextItem。我們先定義一個抽象基類 ItemViewHolder,代碼如下:

        // ItemViewHolder.java
        public abstract class ItemViewHolder extends RecyclerView.ViewHolder {
            public ItemViewHolder(View itemView) {
                super(itemView);
            }

        public abstract void bindTo(MulitTypeAdapter.IItem item);
        

        }</code></pre>

        分別實現 ImageViewHolder 和 TextViewHolder:

        // ImageViewHolder.java
        public class ImageViewHolder extends ItemViewHolder {
            public ImageViewHolder(View itemView) {
                super(itemView);
            }

        public void bindTo(MulitTypeAdapter.IItem item) {
            ImageItem imageItem = (ImageItem) item;
            // then do something
        }
        

        }

        // TextViewHolder.java public class TextViewHolder extends ItemViewHolder { public TextViewHolder(View itemView) { super(itemView); }

        public void bindTo(MulitTypeAdapter.IItem item) {
            TextItem textItem = (TextItem) item;
            // then do something
        }
        

        }</code></pre>

        然后實現 onCreateViewHolder() :

        // MulitTypeAdapter.java
        @Override
        public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            View itemView = LayoutInflater.from(parent.getContext())
                    .inflate(viewType, parent, false);
            if (viewType == R.layout.item_image) {
                return new ImageViewHolder(itemView);
            } else if (viewType == R.layout.item_text) {
                return new TextViewHolder(itemView);
            }
            return null;
        }

        實現 onBindViewHolder() :

        // MulitTypeAdapter.java
        @Override
        public void onBindViewHolder(ItemViewHolder holder, int position) {
            holder.bindTo(items.get(position));
        }

        可以看到, onBindViewHolder() 的實現也已經變得非常簡潔。那么就剩下 onCreateViewHolder() 了。一般來說,我們會把這一部分邏輯通過工廠方法來優化,代碼如下所示:

        // ViewHolderFactory.java
        public class ViewHolderFactory {
            public static ItemViewHolder create(ViewGroup parent, int viewType) {
                View itemView = LayoutInflater.from(parent.getContext())
                        .inflate(viewType, parent, false);
                switch (viewType) {
                    case R.layout.item_image:
                        return new ImageViewHolder(itemView);
                    case R.layout.item_text:
                        return new TextViewHolder(itemView);
                    default:
                        return null;
                }
            }
        }

        那么 onCreateViewHolder 就可以同樣簡化成一行代碼,如下所示:

        public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            return ViewHolderFactory.create(parent, viewType);
        }

        到目前為止,我們所做的和其它開發者所做的優化并沒有什么不同,但是別著急,因為我們都還沒有用上 databinding。接下來我們看看 databinding 的表現,看它是如何消除手動創建多個 ViewHolder 的。

        我們要把 ImageItem 顯示在 item_image.xml 上,把 TextItem 顯示在 item_text.xml 上,我們分別用 databinding 的方式實現這兩個 xml。在此之前,我們先來為 ImageItem 和 TextItem 填充一些數據。

        借助 unsplash 提供的 url 讓 ImageItem 產生隨機圖片 (別忘了在 AndroidManifest.xml 中加上網絡訪問權限),用當前日期時間作為 TextItem 的內容。

        // ImageItem.java
        public class ImageItem implements MulitTypeAdapter.IItem {
            @Override
            public int getType() {
                return R.layout.item_image;
            }

        ////////////////////////////////////////////////
        public final String url;
        
        public ImageItem() {
            url = "https://unsplash.it/200/200?random&" + new Random().nextInt(40);
        }
        

        }

        // TextItem.java public class TextItem implements MulitTypeAdapter.IItem { @Override public int getType() { return R.layout.item_text; }

        ///////////////////////////////////////////
        public final String content;
        
        public TextItem() {
            content = new Date().toString();
        }
        

        }</code></pre>

        item_image.xml :

        <layout>
            <data>
                <variable
                    name="item"
                    type="com.baurine.multitypeadaptertutorial.item.ImageItem"/>
            </data>

        <LinearLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:app="http://schemas.android.com/apk/res-auto"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="8dp">
        
            <ImageView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:error="@{@drawable/ic_launcher}"
                app:imageUrl="@{item.url}"
                app:placeholder="@{@drawable/ic_launcher}"/>
        </LinearLayout>
        

        </layout></code></pre>

        其中 ImageView 的 imageUrl/error/placeholder 屬性是使用 強大的 BindingAdapter 實現的,代碼如下:

        // BindingUtil.java
        public class BindingUtil {
            @BindingAdapter({"imageUrl", "error", "placeholder"})
            public static void loadImage(ImageView imgView,
                                        String url,
                                        Drawable error,
                                        Drawable placeholder) {
                Glide.with(imgView.getContext())
                        .load(url)
                        .error(error)
                        .placeholder(placeholder)
                        .into(imgView);
            }
        }

        item_text.xml :

        <layout>
            <data>
                <variable
                    name="item"
                    type="com.baurine.multitypeadaptertutorial.item.TextItem"/>
            </data>

        <LinearLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="8dp">
        
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@{item.content}"/>
        </LinearLayout>
        

        </layout></code></pre>

        在使用了 databinding 后,在創建 ViewHolder 時,ViewHolder 里需要保存就是不再是 itemView,而是 ViewDataBinding,每一個使用 <layout></layout> 形式的 xml 布局都會被 databinding 框架自動生成一個 ViewDataBinding 類的派生類,比如 item_image.xml 會生成 ItemImageBinding, item_text.xml 會生成 ItemTextBinding,而 ViewDataBinding 是它們的基類。因此我們改寫 ItemViewHolder/ImageViewHolder/TextViewHolder。

        public abstract class ItemViewHolder extends RecyclerView.ViewHolder {
            protected final ViewDataBinding binding;

        public ItemViewHolder(ViewDataBinding binding) {
            super(binding.getRoot());
            this.binding = binding;
        }
        
        public abstract void bindTo(MulitTypeAdapter.IItem item);
        

        }

        public class ImageViewHolder extends ItemViewHolder { public ImageViewHolder(ViewDataBinding binding) { super(binding); }

        public void bindTo(MulitTypeAdapter.IItem item) {
            ImageItem imageItem = (ImageItem) item;
            ((ItemImageBinding) binding).setItem(imageItem);
        }
        

        }

        public class TextViewHolder extends ItemViewHolder { public TextViewHolder(ViewDataBinding binding) { super(binding); }

        public void bindTo(MulitTypeAdapter.IItem item) {
            TextItem textItem = (TextItem) item;
            ((ItemTextBinding) binding).setItem(textItem);
        }
        

        }</code></pre>

        此時,ViewHolderFactory 中的代碼是這樣的,我們要 inflate 得到 ViewDataBinding,如下所示:

        public class ViewHolderFactory {
            public static ItemViewHolder create(ViewGroup parent, int viewType) {
                ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()),
                        viewType, parent, false);
                switch (viewType) {
                    case R.layout.item_image:
                        return new ImageViewHolder(binding);
                    case R.layout.item_text:
                        return new TextViewHolder(binding);
                    default:
                        return null;
                }
            }
        }

        接下來,終于到了最關鍵最核心的一步,下面注意啦,我要開始變形啦。

        在 ImageViewHolder 和 TextViewHolder 的 bindTo() 方法中,我們分別進行了兩次類型轉化,但是,實際上,ViewDataBinding 為我們提供了一個另外一個更通用的方法 setVariable(int variableId, Object obj) 來對 xml 中的變量進行賦值,注意,它的第二個參數是一個 Object。

        比如,在 ImageViewHolder 中,我們持有了 item_image.xml 對應的 ItemImageBinding 實例對象,我們可以用自動生成的 setItem((ImageItem)item) 方法來進行賦值,也可以使用 setVariable(BR.item, item) 來進行賦值,因為這個 ViewDataBinding 實例知道,這個 xml 中 BR.item 對應的類型是 ImageItem,所以它會自動把 item 轉化成 ImageItem 類型。我們直接來看一下 ItemImageBinding 內部是怎么來實現 setVariable() :

        public boolean setVariable(int variableId, Object variable) {
            switch(variableId) {
                case BR.item :
                    setItem((com.baurine.multitypeadaptertutorial.item.ImageItem) variable);
                    return true;
            }
            return false;
        }

        可見,這個方法就是對各種 setXyz 方法的一層封裝。而因為這個方法是由基類 ViewDataBinding 定義的,根據 OOP 的多態特性, 我們直接調用基類的 setVariable() 方法即可,因此,ImageViewHolder 中的 bindTo() 方法就可以簡化成一行代碼:

        public void bindTo(MulitTypeAdapter.IItem item) {
            binding.setVariable(BR.item, item);
        }

        而對于 TextViewHolder 來說,也是一樣的。

        如此一來,如果我們在不同的 item xml 中使用相同的 variable name,如上例中都使用了 name="item" ,那么 bindTo() 方法就可以統一成一種寫法了,如上面所示。

        ImageViewHolder 和 TextViewHolder 從形式上已經是一樣的了,那我們就沒有必要實現多個 ViewHolder 了,統一用一個 ItemViewHolder 來實現,并在 setVariable() 后執行 binding.executePendingBindings() 來讓 UI 馬上變化:

        public class ItemViewHolder extends RecyclerView.ViewHolder {
            private final ViewDataBinding binding;

        public ItemViewHolder(ViewDataBinding binding) {
            super(binding.getRoot());
            this.binding = binding;
        }
        
        public void bindTo(MulitTypeAdapter.IItem item) {
            binding.setVariable(BR.item, item);
            binding.executePendingBindings();
        }
        

        }</code></pre>

        但是我們一定要理解的是,單一 ViewHolder 的背后,是由 databinding 框架生成的多個 ViewDataBinding。總體上來說,代碼量并沒有減少,但對于我們開發者來說,要寫的代碼和邏輯確是大大減少了。

        此時,ViewHolderFactory 可以簡化成如下所示:

        public class ViewHolderFactory {
            public static ItemViewHolder create(ViewGroup parent, int viewType) {
                ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()),
                        viewType, parent, false);
                return new ItemViewHolder(binding);
            }
        }

        但是實際上,由于我們并不需要多個 ViewHolder 了,這個工廠類也就失去意義了,我們把 create() 這個方法移到 ItemViewHolder 中,刪除 ViewHolderFactory 類,并修改 adapter 的 onCreateViewHolder() 方法,如下所示:

        // ItemViewHolder.java
        public static ItemViewHolder create(ViewGroup parent, int viewType) {
            ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()),
                    viewType, parent, false);
            return new ItemViewHolder(binding);
        }

        // MulitTypeAdapter.java public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return ItemViewHolder.create(parent, viewType); }</code></pre>

        更進一步,由于我們只有一個 ItemViewHolder,而且不需要對外公開,因此我們把它整體移入到 MulitTypeAdapter 類中,作為內部靜態類。至此,整個 adapter 全部完成

        public class MultiTypeAdapter extends RecyclerView.Adapter<MultiTypeAdapter.ItemViewHolder> {

        public interface IItem {
            // should directly return layout
            int getType();
        }
        
        private List<IItem> items = new ArrayList<>();
        
        ///////////////////////////////////////////////////////
        
        @Override
        public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            return ItemViewHolder.create(parent, viewType);
        }
        
        @Override
        public void onBindViewHolder(ItemViewHolder holder, int position) {
            holder.bindTo(items.get(position));
        }
        
        @Override
        public int getItemCount() {
            return items.size();
        }
        
        @Override
        public int getItemViewType(int position) {
            return items.get(position).getType();
        }
        
        ///////////////////////////////////////////////////////
        // operate items
        
        public List<IItem> getItems() {
            return items;
        }
        
        public void setItem(IItem item) {
            clearItems();
            addItem(item);
        }
        
        public void setItems(List<IItem> items) {
            clearItems();
            addItems(items);
        }
        
        public void addItem(IItem item) {
            items.add(item);
        }
        
        public void addItem(IItem item, int index) {
            items.add(index, item);
        }
        
        public void addItems(List<IItem> items) {
            this.items.addAll(items);
        }
        
        public void removeItem(IItem item) {
            items.remove(item);
        }
        
        public void clearItems() {
            items.clear();
        }
        
        ///////////////////////////////////////////////////
        static class ItemViewHolder extends RecyclerView.ViewHolder {
            private final ViewDataBinding binding;
        
            static ItemViewHolder create(ViewGroup parent, int viewType) {
                ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()),
                        viewType, parent, false);
                return new ItemViewHolder(binding);
            }
        
            ItemViewHolder(ViewDataBinding binding) {
                super(binding.getRoot());
                this.binding = binding;
            }
        
            void bindTo(MultiTypeAdapter.IItem item) {
                binding.setVariable(BR.item, item);
                binding.executePendingBindings();
            }
        }
        

        }</code></pre>

        從此,我們就可以和 viewholder 說拜拜了,我們的重心轉移到實現一個又一個的 Item 上,而 Item 是極為輕量的。

        至此,我們一步一步地實現了這個目前還不到 100 行的極簡 adapter,那如何使用它來,來輕松地實現 header, footer 呢,且聽下回 分解。

         

        來自:https://github.com/baurine/multi-type-adapter/blob/master/note/multi-type-adapter-tutorial-1.md

         

 本文由用戶 jhrm9352 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!