MVP模式在攜程酒店的應(yīng)用和擴(kuò)展

前言

筆者所在的酒店業(yè)務(wù)部門是攜程旅行的幾大業(yè)務(wù)之一,其業(yè)務(wù)邏輯復(fù)雜,業(yè)務(wù)需求變動(dòng)快,經(jīng)過多年的研發(fā),已經(jīng)是一個(gè)代碼規(guī)模龐大的工程,如何規(guī)范代碼,將代碼按照其功能進(jìn)行分類,將代碼寫到合適的地方對(duì)項(xiàng)目的迭代起著重要的作用。

MVP模式是目前客戶端比較流行的框架模式,攜程在很早之前就開始探索使用該模式進(jìn)行相關(guān)的業(yè)務(wù)功能開發(fā),以提升代碼的規(guī)范性和可維護(hù)性,積累了一定的經(jīng)驗(yàn)。本文將探討一下該模式在實(shí)際工程中的優(yōu)點(diǎn)和缺陷,并介紹攜程面對(duì)這些問題時(shí)的思考,解決方案以及在實(shí)踐經(jīng)驗(yàn)基礎(chǔ)上對(duì)該模式的擴(kuò)展模式MVCPI。

一、從MVC說起

MVC已經(jīng)是非常成熟的框架模式,甚至不少人認(rèn)為它過時(shí)陳舊老氣,在實(shí)踐中,很多同事會(huì)抱怨,MVC會(huì)使得代碼非常臃腫,尤其是Controller很容易變成大雜燴,預(yù)期的可維護(hù)性變得很脆弱,由此導(dǎo)致一方面希望有新框架模式可以解決現(xiàn)在的問題,但同時(shí)對(duì)框架模式又有些懷疑,新的框架模式是否能真正解決現(xiàn)在的問題?會(huì)不會(huì)重蹈覆轍?會(huì)不會(huì)過度設(shè)計(jì)?會(huì)不會(huì)掉進(jìn)一個(gè)更深的坑?總之,這些類似“一朝被蛇咬,十年怕井繩”的擔(dān)憂顯得不無道理。但不管如何,我們需要仔細(xì)耐心的做工作。

1.1、被誤解的MVC

在MVP模式逐漸流行之前,不管我們有意識(shí)或無意識(shí)地,我們使用的就是MVC模式。以Android為例,我們來看看MVC是什么樣子。

public class HotelActivity extends Activity {??    
      private TextView mNameView;? 
      private TextView mAddressView;?  
      private TextView mStarView;?? 
      @Override?    
      protected void onCreate(Bundle savedInstanceState) {?             
          super.onCreate(savedInstanceState);?            
          setContentView(R.layout.activity_main2);??    

          mNameView = (TextView) findViewById(R.id.hotel_view);?              
          mAddressView = (TextView) findViewById(R.id.address_view);?          
          mStarView = (TextView) findViewById(R.id.star_view);??
        
          HotelModel hotel = HotelLoader.loadHotelById(1000);?
?        
          mHotelNameView.setText(hotel.hotelName);?       
          mHotelAddressView.setText(hotel.hotelAdress);?        
          mHotelStarView.setText(hotel.hotelStar);?
      }
?}

上面的代碼,概括了Android MVC的基本結(jié)構(gòu),從筆者的經(jīng)驗(yàn)來看,很多應(yīng)用都存在這樣的代碼風(fēng)格,也就是大部分人認(rèn)為的MVC:

  • Model :
     Hotel,HotelLoader
    
  • Controller:
    HotelActivity
    
- View : 
```java
     mHotelNameView
     mHotelAddressView
     mHotelStarView

可以試想一下如果這個(gè)界面展示的數(shù)據(jù)非常的多話,MainActivity必然會(huì)變得非常龐大,就像大部分人所抱怨的那樣。誠然,上面的demo是MVC模式,但是,它僅是從系統(tǒng)框架的角度來看,如果從應(yīng)用框架來看,它不是。下面來看一下,從應(yīng)用框架來看一下MVC正確的結(jié)構(gòu):

1.2、MVC的正確姿勢(shì)

應(yīng)用中的MVC應(yīng)該在系統(tǒng)的MVC框架上根據(jù)業(yè)務(wù)的自身的需要進(jìn)行進(jìn)一步封裝,也就是說,如果在我們宣稱我們是使用MVC框架模式的時(shí)候,代表我們的主要工作是封裝自己的MVC組件。它看起來應(yīng)該是像下面的風(fēng)格:

public class HotelActivity extends Activity {?

    private HotelView mHotelView;?

    @Override?   
    protected void onCreate(Bundle savedInstanceState) {?        
        super.onCreate(savedInstanceState);?         
        setContentView(R.layout.activity_main2);?
        mHotelView = (HotelView) findViewById(R.id.hotel_view);
        HotelModel hotel = HotelLoader.loadHotelById(1000);
        mHotelView.setHotel(hotel);?   
     }
?}

跟之前的代碼相比,基本結(jié)構(gòu)是相似的,如下:

  • Model :
     Hotel,HotelLoader
    
  • Controller:
    HotelActivity
    
- View : 
```java
     mHotelView

僅僅View層發(fā)生了變化,這是因?yàn)?,Model和Controller相對(duì)是大家容易理解的概念,在面臨任何一個(gè)業(yè)務(wù)需求的時(shí)候,自然就能產(chǎn)生的近乎本能的封裝(盡管Model的基本封裝大部分工程師都可完成,但不可否認(rèn)Model的設(shè)計(jì)是至關(guān)重要而有難度的);而對(duì)View的看法,可能就是“能正確布局和展示就行”。但這正是關(guān)鍵所在:我們需要對(duì)界面進(jìn)行全方位的封裝,包括View。具體來說,一個(gè)真正的MVC框架應(yīng)該具備下面的特點(diǎn):

  • 數(shù)據(jù)都由Model進(jìn)行封裝
  • View綁定業(yè)務(wù)實(shí)體,view.setXXX
  • Controller不管理與業(yè)務(wù)無關(guān)的View

1.3 MVC模式的問題所在

前面說到,很多人抱怨采用MVC模式使得Controller變得很臃腫,我相信,Controller變得臃腫是事實(shí),但其歸結(jié)于采用MVC模式是不正確的,這個(gè)鍋不應(yīng)該由MVC來背,因?yàn)?,這個(gè)論點(diǎn)會(huì)導(dǎo)致我們走向錯(cuò)誤的方向從而無法發(fā)現(xiàn)MVC真正的問題所在。為什么這么說呢,那是因?yàn)樵诒救肆私獾降暮芏嗲闆r下,大家并沒有正確理解MVC框架模式,如采用前文中第一種模式,自然會(huì)使得Controller臃腫,但是如果采用第二種模式,Controller的代碼和邏輯也會(huì)非常清晰,至少不至于如此多的抱怨。因此如果只是想解決Controller臃腫的話,MVC就夠了,毋庸質(zhì)疑。那MVC的問題是什么呢?我想只有深刻的理解了這個(gè)問題,我們才有必要考慮是否需要引入新的框架模式,以及避免新的模式中可能出現(xiàn)的問題。

View強(qiáng)依賴于Model是MVC的主要問題。由此導(dǎo)致很多控件都是根據(jù)業(yè)務(wù)定制,從Android的角度來看,原本可以由一個(gè)通用的layout就能實(shí)現(xiàn)的控件,由于要綁定實(shí)體模型,現(xiàn)在必須要自定義控件,這導(dǎo)致出現(xiàn)大量不必要的重復(fù)代碼。因此有必要將View和Model進(jìn)行解耦,而MVP的主要思想就是解耦View和Model。由此引入MVP就顯得很自然。

二、 Android MVP

2.1、參考實(shí)現(xiàn)

Android 官方提供的MVP參考實(shí)現(xiàn),大致思想如下:

1、抽象出IView接口,規(guī)范控件訪問方法,而不限View具體來源

public interface IHotelView {
     public TextView getNameView();
     public TextView getAddressView();
     public TextView getStarView();
}

2、抽象出IPresenter接口,定義IView 和 Model的綁定接口

public interface IHotelPresenter {
     public void setView(IHotelView hotelView);
     public void setData(HotelMotel hotel); 
}

3、IPresenter的實(shí)現(xiàn)類,實(shí)施數(shù)據(jù)和IView的綁定,并負(fù)責(zé)相關(guān)的業(yè)務(wù)處理

public class HotelPresenter implements IHotelPresenter {
     private IHotelView hotelView;
     public void setView(IHotelView hotelView) {
            this.hotelView  = hotelView; 
     }
     public void setData(HotelModel hotel) {
       hotelView.getNameView().setText(hotel.hotelName);
       hotelView.getAddressView().setText(hotel.hotelAddress);
       hotelView.getStarView().setText(hotel.hotelStart);
     }
}

4、Activity實(shí)現(xiàn)IView,角色轉(zhuǎn)變?yōu)閂iew,弱化Controller的功能

public class HotelActivity extends Activity  implements IHotelView {?
    @Override?   
    protected void onCreate(Bundle savedInstanceState) {?       
        super.onCreate(savedInstanceState);?       
        setContentView(R.layout.activity_main2);??      

        HotelModel hotel = HotelLoader.loadHotelById(1000);?       
        IPresenter presenter = new Presenter();?          
        presenter.setView(this);? 
        presenter.setData(hotel);

?    }? 
    @Override?     
       public TextView getNameView() {
            return (TextView)findViewById(R.id.hotel_name_view);? 
    }
    @Override?   
    public TextView getAddressView() {
        return (TextView)findViewById(R.id.hotel_address_view);?  
    }
    @Override?    
    public TextView getStarView() {
        return (TextView)findViewById(R.id.hotel_address_view);? 
    }?
}

上述代碼,主要的特點(diǎn)可以概括為:

  • 面向接口
  • View - Model 解耦
  • Activity角色轉(zhuǎn)換

就目前了解到的情況來看,很多采用MVP模式的應(yīng)用基本上和android參考實(shí)現(xiàn)方案差別不大,說明該模式的應(yīng)用場景也是很廣泛的。

2.2 Android MVP存在的問題

盡管已經(jīng)有了大量的應(yīng)用,但不可否認(rèn)該模式的還是存在一些問題,這些問題在攜程的使用過程中也得到了體現(xiàn)。比如,上下文丟失問題,生命周期問題,內(nèi)存泄露問題以及大量的自定義接口,回調(diào)鏈變長等問題。可以歸納為:

  • 業(yè)務(wù)復(fù)雜時(shí),可能使得Activity變成更加復(fù)雜,比如要實(shí)現(xiàn)N個(gè)IView,然后寫更多個(gè)模版方法。

  • 業(yè)務(wù)復(fù)雜時(shí),各個(gè)角色之間通信會(huì)變得很冗長和復(fù)雜,回調(diào)鏈過長。

  • Presenter處理業(yè)務(wù),讓業(yè)務(wù)變得很分散,不能全局掌握業(yè)務(wù),很難去回答某個(gè)業(yè)務(wù)究竟是在哪里處理的。

  • 用Presenter替代Controller是一個(gè)危險(xiǎn)的做法,可能出現(xiàn)內(nèi)存泄漏,生命周期不同步,上下文丟失等問題。

以下面的這個(gè)需求來看幾個(gè)具體的示例:

詳情按鈕的展示需要服務(wù)端下發(fā)標(biāo)記位控制,展示時(shí)點(diǎn)擊需要請(qǐng)求一個(gè)服務(wù),服務(wù)返回時(shí)toast提示用戶

public class HotelPresenter {? 
    private IHotelView mHotelView;?
    private Handler handler = new  Handler(getMainLooper());?  
    public void setData(HotelModel hotelModel) {?   
       View button = mHotelView.getButtonView();?   
       int visibility = hotelModel.showButton ? .VISIBLE :GONE;                
       button.setVisibility(visibility);?  
       if (hotelModel.showButton) {?            
           button.setOnClickListener(new View.OnClickListener() {?                
           @Override?                
           public void onClick(View v) {?               
               sendRequest();?             
           }?       
        });?    
     }? 

     private void sendRequest() {?       
        new Thread() {?       
            public void run() {?
                Thread.sleep(15*1000);?
                handler.post(new Runnable() {?         
                        public void run() {?               
                              Toast.makeText(???)? //Where is Context?
                        }? 
                });?   
         }? 
       }.start();?  
     }?
}

上述代碼表明,HotelPresenter可以處理大部分的業(yè)務(wù),但是在最后需要使用上下文的時(shí)候,出現(xiàn)了困難,因?yàn)槊撾x了上下文,展示一個(gè)Toast都不能實(shí)現(xiàn)

為了避免這樣的尷尬,因此改進(jìn)方案如下:

public class HotelPresenter {?  
     private IHotelView mHotelView;
?     private Fragment mFragment;?
     private HotelPresenter(Fragment fragment) {? 
       this.mFragment = fragment;? 
     }?  
     private Handler handler = new Handler(Looper.getMainLooper());?   

     public void setData(HotelModel hotelModel) {?  
         View button = mHotelView.getButtonView();?       
         button.setVisibility(hotelModel.showButton ? VISIBLE :GONE);? 
         if (hotelModel.showButton) {?            
               button.setOnClickListener(new View.OnClickListener() {?                 
                      @Override?   
                      public void onClick(View v) {? 
                             sendRequest();?
                    }
?            });
         }
?     }?

     private void sendRequest() {?
        new Thread() {
?            public void run() {?
                Thread.sleep(15*1000);?
                handler.post(new Runnable() {?
                    public void run() {
                         Context context = mFragment.getActivity();
                         int duration = LENGTH_SHORT;
                         //NullPointerException will occur
                         Toast.makeText(context,"成功”,duration).show();?                   
                    }? 
               });?
            }? 
       }.start();?
    }?
}

改進(jìn)的方案中,考慮到需要使用上下文,因此新增了接口傳入Fragment作為上下文,在Presenter需要時(shí)可以使用,但是,由于Fragment生命周期會(huì)了變化,可能會(huì)導(dǎo)致空指針問題。

于是新的問題又需要解決。主要是兩個(gè)思路,一個(gè)是為Presenter增加生命周期方法,在Fragment的生命周期方法里調(diào)用Presenter對(duì)應(yīng)的生命周期函數(shù),但這就讓Presenter看起來像Fragment的孫子;另外一個(gè)就是承認(rèn)Presenter其實(shí)不太合適承擔(dān)Controller的職責(zé),從而提供接口給外部處理;如下:

public class HotelPresenter {?
       private IHotelView mHotelView;
?       private Handler handler = new Handler(Looper.getMainLooper());
?       public void setData(HotelModel hotelModel) {
?           View button = mHotelView.getButtonView();?       
           button.setVisibility(hotelModel.showButton ? VISIBLE :GONE);?
           if (hotelModel.showButton) {?           
                button.setOnClickListener(new View.OnClickListener() {?               
                @Override? 
                public void onClick(View v) {
?                     if (mCallback != null) {
                          mCallback.onSendButtonClicked();
                    }?
                });
           }?
       }?

       public interface Callback {
          public void onSendButtonClicked();
       }

       private Callback mCallback;
       public  void setCallback(Callback  callback) {
            mCallack = callback;
       }
}

這個(gè)方案很穩(wěn)定,似乎成為了最佳的選擇。但是自定接口和回調(diào)始終有那么一點(diǎn)痛

三、MVP的擴(kuò)展模式MVCPI

由于前面的分析,MVP參考實(shí)現(xiàn)并不是萬能的,攜程酒店并沒有完全采用參考實(shí)現(xiàn)方案,而是結(jié)合自身的實(shí)踐經(jīng)驗(yàn)思考之后設(shè)計(jì)出來的擴(kuò)展方案。我們主要考慮了一下的幾個(gè)問題:

  • 如何定義View接口?
  • 如何定位Presenter ?
  • 如何對(duì)待Controller?
  • 如何解決長長的回調(diào)鏈?

通過對(duì)上述問題的思考,提出對(duì)應(yīng)的解決方法,規(guī)避前面論述的各種問題,形成了攜程酒店的MVCPI框架模式,并在多個(gè)業(yè)務(wù)場景運(yùn)行,取得了較為滿意的效果。下面,詳細(xì)介紹MVCPI模式。

3.1、 IView

和Android 參考實(shí)現(xiàn)不一樣的是,我們并沒有采用強(qiáng)類型的接口作為表達(dá)View的方式,而是采用弱類型的接口來定義View。具體定義方式如下:

public interface IView {?
    //用于展示酒店名稱的控件? 
    int NAME_VIEW = R.id.name_view;? 
    //用于展示酒店地址的控件
    int ADDRESS_VIEW = R.id.address_view;?  
    //用于展示酒店星級(jí)的控件? 
    int STAR_VIEW = R.id.star_view;
?    //用于展示酒店詳情入口的的控件
    int DETAIL_BUTTON = R.id.detail_button;
?}

上面的接口簡潔的描述了作為業(yè)務(wù)控件的View需要具備的子控間ID,并不需要具體的實(shí)現(xiàn)類。因此也不需要Activity去實(shí)現(xiàn)這個(gè)接口,只需要在layout中申明這幾個(gè)ID的即可,極大的簡化了代碼。

3.2、 Presenter

與參考實(shí)現(xiàn)的定位不一樣,我們認(rèn)為由Presenter取代Controller并不是一個(gè)好的做法,Presenter應(yīng)是Controller的補(bǔ)充,主要起到View和Model解耦和數(shù)據(jù)綁定的作用,所負(fù)責(zé)的控件的上的業(yè)務(wù)還是有Controller決定如何去處理。另外setView接受的參數(shù)是一般的View,而非一個(gè)接口類型,內(nèi)部根據(jù)IView定義的ID去查找子控件。如下:

public class CtripHotelPresenter {?
    TextView mNameView;? 
    TextView mAddressView;?
    TextView mStarView;? 
    Button mDetailButton;? 
    public void setView(View view) {     
        mNameView = (TextView)mView.findViewById(IView.NAME_VIEW);?        
        mAddressView = (TextView)mView.findViewById(IView.ADDRESS_VIEW);?  
        mStarView = (TextView) mView.findViewById(IView.STAR_VIEW);?          
        mDetailButton = (Button) mView.findViewById(IView.DETAIL_BUTTON);?   
   }? 
   public void setData(HotelModel hotel) {?       
        mNameView.setText(hotel.hotelName);?        
        mAddressView.setText(hotel.hotelAdress);?       
        mStarView.setText(hotel.hotelStar);? 
        int v = hotel.showButton ? View.VISIBLE : View.GONE;?       
        mDetailButton.setVisibility(v);?
    }
}

3.3、 Interactor

Interactor是我們定義出來的擴(kuò)展元素,在MVP和MVC中都沒有對(duì)應(yīng)的角色。為了闡述它的含義,我們先來看看兩個(gè)非常常見的場景。

回調(diào)鏈過長.png

在前面介紹過,Presenter自定義接口是很多候選方案中較為合理的選擇,但相比MVC而言,MVP更容易出現(xiàn)如上圖的一種調(diào)用和回調(diào)關(guān)系(甚至更長)。維護(hù)這種回調(diào)鏈通常來說是一件非常頭痛的事情,從View的角度來看,很難知道某個(gè)事件到最后究竟完成了什么業(yè)務(wù),Acitivity也不知道到要裝配哪些回調(diào)。某個(gè)未知的新需求可能需要將該鏈條上的每個(gè)環(huán)節(jié)都增加回調(diào)。

下面來是另外一種場景,大家可以腦補(bǔ)一下采用上面的回調(diào)方案,回調(diào)鏈會(huì)是什么情況。

交互集中型界面.png

在該界面有幾個(gè)特點(diǎn):

  • 幾十種動(dòng)態(tài)交互需求,
  • 分布于不同的模塊
  • 分布于不同深度的嵌套層次中

經(jīng)過大量版本迭代后,無論產(chǎn)品經(jīng)理,研發(fā)或者測(cè)試,都不清楚到底有哪些需求,業(yè)務(wù)邏輯是什么,寫在什么地方等等......

上述兩個(gè)場景可以得出兩個(gè)結(jié)論:

  • 排查問題非常耗時(shí)
  • 增加功能成本高,容易引致其他問題

為了解決上述兩個(gè)比較棘手的問題,我們引入了Interactor,用于描述整個(gè)界面的交互,一舉解決上述兩個(gè)問題。我們認(rèn)為交互模型是一個(gè)功能模塊的重要邏輯單元,相對(duì)于實(shí)體模型來說,交互模型更加抽象,在大多數(shù)的情況,并不能引起大家的注意,但它確實(shí)是如實(shí)體一樣的存在,正是因?yàn)闆]有對(duì)交互進(jìn)行系統(tǒng)的描述,才導(dǎo)致上面兩種突出的問題。盡管抽象,但是交互模型本質(zhì)非常簡單,它有著和實(shí)體模型有相似的結(jié)構(gòu),示例如下:

public class HotelOrderDetailListeners {?
    public View.OnClickListener mBackListener; // 返回按鈕點(diǎn)擊事件監(jiān)聽者?
    public View.OnClickListener mShareClickListener;//分享按鈕事件監(jiān)聽者?
    public View.OnClickListener mConsultClickListener;//咨詢按鈕事件監(jiān)聽者
    ……
}

通過對(duì)界面整體分析后,我們建立如上的交互模型,所有的交互都在交互模型進(jìn)行注冊(cè),由交互模型統(tǒng)一管理,進(jìn)而可以對(duì)整個(gè)界面的交互進(jìn)行宏觀把控;然后在頁面的所有元素中共享同一個(gè)交互模型,進(jìn)而各個(gè)元素不再需要自定義接口和避免建立回調(diào)鏈。最后由Controller負(fù)責(zé)組裝,進(jìn)一步加強(qiáng)Controller的控制能力。

3.4、 MVCPI全貌

最后,整體介紹一下MVCPI的代碼結(jié)構(gòu)

1、首先定義整個(gè)界面中有哪些用戶交互,本例中就一個(gè)詳情按鈕交互

public class HotelInteractor {
     //點(diǎn)擊詳情的事件處理器
     public View.OnClickListener mDetail;
}

2、Presenter構(gòu)造時(shí)需要傳入交互模型,內(nèi)部定義了IView接口,傳入的View中需要包含它定義的ID的控件,在bindData時(shí),詳情按鈕的點(diǎn)擊不是通過匿名內(nèi)部類去處理,而是直接引用交互模型中定義的mDetail

public class HotelPresenter {
   private View hotelView;
   private HotelInteractor mInteractor;
   private Button mDetailButton;

   public HotelPresenter(HotelInteractor interactor) {
         this.mInteractor = interactor;
   }

   private interface IView {?
         int DETAIL= R.id.detail_button;
          ……
   }
   public void setView(View hotelView) {
         this.hotelView  = hotelView; 
         mDetailButton= (Button)findViewById(IView. DETAIL );
   }
   public void setData(HotelModel hotel) {
        if (hotel.showButton) {
             mDetailButton.setVisibility(View.Visibile);
             mDetailButton.setOnClickListener(mInteractor.mDetail);
        }
   }
}

3、Controller負(fù)責(zé)界面各個(gè)元素(包括交互模型)的初始化和裝配

public class HotelActivity extends Activity {
?    @Override
?    protected void onCreate(Bundle savedInstanceState) {?
        super.onCreate(savedInstanceState);?
        setContentView(R.layout.activity_main2);?
        HotelInteractor interactor = new HotelInteractor();
        interactor.mDetail = new View.OnClickListener() {?
             public void onClick(View view) {
?                 viewHotelDetail();//處理詳情業(yè)務(wù);? 
            }? 
       };?
        HotelModel model= HotelLoader.loadHotelById(1000);?       
        HotelPresenter presenter = new HotelPresenter (interactor);?       
        View view= findViewById(R.id.hotel_view);?       
        presenter.setView(view);?
        presenter.setData(hotel);?
      }
?}

四、結(jié)論

通過對(duì)MVC,MVP的介紹和研究,我們發(fā)現(xiàn)二者的關(guān)系并不是相互取代的關(guān)系,而是一種演化和改進(jìn)的關(guān)系。經(jīng)實(shí)踐證明,MVC仍然具有強(qiáng)大的生命力,試圖用MVP取代MVC幾乎都會(huì)失敗。攜程在MVC模式基礎(chǔ)上,結(jié)合MVP思想,加入Interactor元素搭建的MVCPI框架模式,一方面將數(shù)據(jù)綁定邏輯從Controller(或者View)中分離出去,另一方面將交互模型的控制納入進(jìn)來,進(jìn)一步加強(qiáng)了Controller的控制能力。無論從代碼的簡潔性,維護(hù)性,擴(kuò)展性來看,都具有較大優(yōu)勢(shì),具有一定的實(shí)踐推廣價(jià)值。

當(dāng)然,任何框架模式都不是全能的,MVCPI也存在它不足,如果有好的意見和建議,歡迎加入,一起討論推進(jìn)框架模式的發(fā)展。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容