2019-02-13

Android实现一个功能完善的聊天页面

​ 前言:在APP中经常需要使用到聊天页面,尤其是一些涉及社交和社区类的APP。本次我对自己做过的聊天页面的一些模块进行抽取归纳和总结,也希望能够帮助他人快速完成聊天页面的开发,所以写了这篇博客。实现了表情页面、礼物页面和一键发送页面, 主要解决了键盘跟表情和礼物框切换时的跳闪、礼物的切换选择、动画等问题,因为UI是从网上找的,动画效果文件是直接使用的YY开源库里面Demo所提供的,大家将就下,关键是了解实现的思路和流程即可,好,废话不多说,具体效果如下所示:


1、解决键盘与表情和礼物框的切换跳闪问题

我们先来看一下activity_main.xml的布局效果图:



​ 现在我们来分析下上图中的蓝色框中的布局,代码如下:

<LinearLayout
        android:id="@+id/ll_chatControl"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/white"
        android:orientation="vertical"
        android:paddingTop="8dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="bottom"
            android:orientation="horizontal"
            android:paddingLeft="20dp"
            android:paddingRight="10dp">

            <LinearLayout
                android:id="@+id/ll_chatMsg"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:background="@drawable/chat_input_box_shape"
                android:gravity="center_vertical"
                android:paddingLeft="9dp"
                android:paddingTop="11dp"
                android:paddingRight="9dp"
                android:paddingBottom="11dp">

                <com.lin.clay.emojikeyboard.utils.CustomPasteEditText
                    android:id="@+id/edit_chatMsg"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:background="@null"
                    android:gravity="center_vertical"
                    android:hint="请输入内容"
                    android:includeFontPadding="false"
                    android:maxLines="5"
                    android:textColor="@color/black"
                    android:textColorHint="@color/chat_input_box_hint"
                    android:textCursorDrawable="@drawable/cursor_shape"
                    android:textSize="14dp" />

            </LinearLayout>

            <ImageView
                android:id="@+id/img_emoji"
                android:layout_width="28dp"
                android:layout_height="28dp"
                android:layout_marginLeft="6dp"
                android:layout_marginRight="6dp"
                android:layout_marginBottom="4dp"
                android:src="@drawable/chat_page_emoji" />

            <RelativeLayout
                android:layout_width="45dp"
                android:layout_height="match_parent"
                android:gravity="bottom">

                <TextView
                    android:id="@+id/tv_send"
                    android:layout_width="45dp"
                    android:layout_height="28dp"
                    android:layout_centerHorizontal="true"
                    android:layout_marginBottom="4dp"
                    android:background="@drawable/chat_send_button_bg"
                    android:gravity="center"
                    android:text="发送"
                    android:textColor="@color/white"
                    android:textSize="13dp" />

            </RelativeLayout>

        </LinearLayout>

        <LinearLayout
            android:id="@+id/ll_moreOperate"
            android:layout_width="match_parent"
            android:layout_height="49dp"
            android:layout_gravity="bottom"
            android:background="@color/white"
            android:gravity="center_vertical"
            android:paddingLeft="20dp"
            android:paddingRight="20dp">

            <RelativeLayout
                android:id="@+id/rl_oneKeySend"
                android:layout_width="65dp"
                android:layout_height="wrap_content"
                android:layout_weight="1">

                <ImageView
                    android:id="@+id/img_oneKeySend"
                    android:layout_width="32dp"
                    android:layout_height="32dp"
                    android:layout_centerInParent="true"
                    android:src="@drawable/chat_one_key_send" />

            </RelativeLayout>

            <View
                android:layout_width="1dp"
                android:layout_height="match_parent"
                android:layout_marginTop="9dp"
                android:layout_marginBottom="9dp"
                android:background="@color/app_main_color" />

            <RelativeLayout
                android:id="@+id/rl_giftCommon"
                android:layout_width="65dp"
                android:layout_height="wrap_content"
                android:layout_marginLeft="20dp"
                android:layout_weight="1">

                <ImageView
                    android:id="@+id/img_giftCommon"
                    android:layout_width="65dp"
                    android:layout_height="65dp"
                    android:layout_centerInParent="true"
                    android:src="@drawable/chat_page_gifts_common" />

            </RelativeLayout>

        </LinearLayout>

    </LinearLayout>

    <LinearLayout
        android:id="@+id/ll_emotionGifts"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:visibility="gone">

        <View
            android:layout_width="match_parent"
            android:layout_height="1px"
            android:background="#e5e5e5" />

        <com.lin.clay.emojikeyboard.utils.NoHorizontalScrollerViewPager
            android:id="@+id/vp_emotionGifts"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </LinearLayout>

</LinearLayout>

​ 我们可以看到最下面有个NoHorizontalScrollerViewPager布局,这个是设置了不能滑动的viewpager,用来加载表情fragment、礼物fragment和一键发送fragment的。

public class NoHorizontalScrollerViewPager extends ViewPager
{
    public NoHorizontalScrollerViewPager(Context context) {
        super(context);
    }

    public NoHorizontalScrollerViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    /**
     * 重写拦截事件,返回值设置为false,这时便不会横向滑动了。
     * @param ev
     * @return
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return false;
    }

    /**
     * 重写拦截事件,返回值设置为false,这时便不会横向滑动了。
     * @param ev
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        return false;
    }
}

​ 我们再来看下在MainActivity中涉及键盘和表情、礼物切换的主要代码:

//表情面板
private EmotionKeyboard mEmotionKeyboard;
List<Fragment> emotionFragments = new ArrayList<>();
private GlobalOnItemClickManagerUtils GlobalOnItemClickManager;        //表情和编辑框的绑定

private void initKEG() {
    mEmotionKeyboard = EmotionKeyboard.with(this)
            .setEmotionView(llEmotionGifts)//绑定表情面板
            .setEmotionGiftsView(vpEmotionGifts)//绑定礼物表情选择
            .bindToContent(rvMatchMsg)//绑定内容view
            .bindToEditText(editChatMsg)//判断绑定那种EditView
            .bindToEmotionButton(imgEmoji)//绑定表情按钮
            .bindToGiftsButton(rlGiftCommon)//绑定礼物按钮
            .bindToOneKeySendButton(rlOneKeySend)//绑定一键发送按钮
            .bindToChatControl(llChatControl)   //绑定整个底部表情礼物布局
            .bindKeyboardListener() //绑定键盘弹出隐藏监听
            .build();

    //创建全局监听
    GlobalOnItemClickManager = GlobalOnItemClickManagerUtils.getInstance(this);
    GlobalOnItemClickManager.attachToEditText(editChatMsg);

    emotionFragments.add(new EmotionFragment());
    emotionFragments.add(new GiftsFragment());
    emotionFragments.add(new OneKeySendFragment());
    vpEmotionGifts.setAdapter(new EmotionGiftsFragmentAdapter(getSupportFragmentManager(), emotionFragments));
    //设置缓存View的个数
    vpEmotionGifts.setOffscreenPageLimit(emotionFragments.size() - 1);
}

​ 其中EmotionKeyboard类就是我们解决切换过程中跳闪问题的关键类,代码也比较长,所以这边就不全部贴出来了,主要挑比较重要的来讲,具体代码大家可以前往最下面进入GitHub中查看。

状态栏:首先,因为在这里状态栏设置了白底黑字,因为手机系统默认时白色字体的,但是在开发中我所需要使用的沉浸式是白底黑字,所以需要修改状态栏的字体颜色,Andorra6.0及以上Android系统提供了API可以改变状态栏的字体颜色,6.0以下的只有小米和魅族手机官方提供了方法,大家可以查看这个//TODO 博客,设置了白底黑字后在底部编辑框不会被键盘顶起,以前只要设置了状态栏透明也会出现这个问题,这个是Android本身的问题,网上有一个类AndroidBug5497Workaround 可以解决这个问题,好像这个现在已经被Android官方给修复了。

获取软键盘的高度:我们可以使用绑定监听,获取软键盘的高度,然后用SharedPreferences保存下来,下次可直接使用

public EmotionKeyboard bindKeyboardListener() {
    //检查,用来处理一些隐藏的导航栏导致表情布局弹出高度不一致,为了适配一些隐藏了虚拟按键的手机
    //保证监听获取的键盘高度正确
    checkHasHintNavigationBar();
    virtualBarHeigh = Utils.getVirtualBarHeigh(mActivity);
    // TODO: 2018/8/14  适应首页动画,改变了6.0以下的状态栏,解除限制
    //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        SoftKeyBoardListener.setListener(mActivity, new SoftKeyBoardListener.OnSoftKeyBoardChangeListener() {
            @Override
            public void keyBoardShow(int height) {
                sp.edit().putInt(SHARE_PREFERENCE_SOFT_INPUT_HEIGHT, height).apply();
                if (virtualBarHeigh != 0 && screenHeight != rootViewHeight) {
                    height = height - virtualBarHeigh;
                }
                setChatControlViewParams(height);
                ((MainActivity) mActivity).chatListScroolDelay(200);
            }

            @Override
            public void keyBoardHide(int height) {
                if (!mEmotionLayout.isShown()) {
                    setChatControlViewParams(0);
                }
            }
        });
    //}
    return this;
}

RecyclerView:也就是在类中的mContentView,在操作时主要是操作mContentView的权重weight,通过改变权重获得需要的效果。

操作编辑框:即是弹出和隐藏键盘时,获取保存了软键盘的高度,通过监听键盘的弹起与收起,然后根据键盘的变化相应操作绑定整个底部表情礼物布局llChatControl的bottomMargin,使键盘弹出的时候位于键盘的上方,键盘退出的时候设置bottomMargin为0。

private void setChatControlViewParams(int marginButton) {
        ((LinearLayout.LayoutParams) mContentView.getLayoutParams()).weight = 1.0F;
        LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) mChatControlView.getLayoutParams();
        layoutParams.bottomMargin = marginButton;
        mChatControlView.setLayoutParams(layoutParams);
}

操作表情按钮:有两种情况:

/**
 * 绑定表情按钮
 *
 * @param emotionButton
 * @return
 */
public EmotionKeyboard bindToEmotionButton(final View emotionButton) {
    emojiIcon = (ImageView)emotionButton;
    emotionButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            emojiIcon.setImageResource(R.drawable.chat_page_keyboard);
            if (mEmotionLayout.isShown()) {
                if (vpEmotion.getCurrentItem() == 1) {
                    vpEmotion.setCurrentItem(0);
                    return;
                }
                if (vpEmotion.getCurrentItem() == 2) {
                    vpEmotion.setCurrentItem(0);
                    return;
                }
                if(vpEmotion.getCurrentItem() == 0) {
                    emojiIcon.setImageResource(R.drawable.chat_page_emoji);
                     //TODO: 2018/5/23 表情已经弹出,再点击时不变化,不让其弹出软键盘
                    lockContentHeight();//显示软件盘时,锁定内容高度,防止跳闪。
                    hideEmotionLayout(true);//隐藏表情布局,显示软件盘
                    unlockContentHeightDelayed();//软件盘显示后,释放内容 高度
                    return;
                }
            } else {
                vpEmotion.setCurrentItem(0);
                if (isSoftInputShown()) {//同上
                    setChatControlViewParams(0);
                    lockContentHeight();
                    showEmotionLayout();
                    unlockContentHeightDelayed();
                } else {
                    showEmotionLayout();//两者都没显示,直接显示表情布局
                }
            }
            ((MainActivity) mActivity).chatListScroolDelay(200);
        }
    });
    return this;
}

​ 一种是mEmotionLayout已经显示的情况,mEmotionLayout即是那包括了显示表情和礼物fragment的上面提到的那个viewpager。如果显示的不是表情框,那么则是vpEmotion中的fragment切换,如果已经显示的是表情fragment,那么则需要隐藏mEmotionLayout,弹出软键盘,首先需要锁定内容高度,也就是RecyclerView的高度,把权重设为0,然后隐藏mEmotionLayout,显示软键盘,然后需要加个延迟把RecyclerView的权重再设为1,至于llChatControl,因为上面有键盘的监听,所以会自动回调设置bottomMargin的。

/**
 * 锁定内容高度,防止跳闪
 */
private void lockContentHeight() {
    LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) mContentView.getLayoutParams();
    params.height = mContentView.getHeight();
    params.weight = 0.0F;
}

/**
 * 隐藏表情布局
 *
 * @param showSoftInput 是否显示软件盘
 */
private void hideEmotionLayout(boolean showSoftInput) {
    if (mEmotionLayout.isShown()) {
        mEmotionLayout.setVisibility(View.GONE);
        if (showSoftInput) {
            showSoftInput();
        }
    }
}
    
/**
 * 释放被锁定的内容高度
 */
private void unlockContentHeightDelayed() {
    mEditText.postDelayed(new Runnable() {
        @Override
        public void run() {
            ((LinearLayout.LayoutParams) mContentView.getLayoutParams()).weight = 1.0F;
        }
    }, 200L);
}

​ 第二种是mEmotionLayouy没有显示的情况,如果键盘没有弹出,则直接显示表情布局;如果键盘已经弹出,则现将mChatControlView的bottomMargin设为0,然后锁定RecyclerView的高度,显示表情礼物mEmotionLayout,并设定mEmotionLayout高度等于键盘的高度,然后需要加个延迟把RecyclerView的权重再设为1。

private void setChatControlViewParams(int marginButton) {
        ((LinearLayout.LayoutParams) mContentView.getLayoutParams()).weight = 1.0F;
        LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) mChatControlView.getLayoutParams();
        layoutParams.bottomMargin = marginButton;
        mChatControlView.setLayoutParams(layoutParams);
}

/**
 * 锁定内容高度,防止跳闪
 */
private void lockContentHeight() {
    LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) mContentView.getLayoutParams();
    params.height = mContentView.getHeight();
    params.weight = 0.0F;
}
    
/**
 * 显示表情礼物mEmotionLayout,并设定高度等于键盘的高度
 */
    private void showEmotionLayout() {
        softInputHeight = getKeyBoardHeight();
        hideSoftInput();
        mEmotionLayout.getLayoutParams().height = softInputHeight;
        mEmotionLayout.setVisibility(View.VISIBLE);
}
    
/**
 * 释放被锁定的内容高度
 */
private void unlockContentHeightDelayed() {
    mEditText.postDelayed(new Runnable() {
        @Override
        public void run() {
            ((LinearLayout.LayoutParams) mContentView.getLayoutParams()).weight = 1.0F;
        }
    }, 200L);
}

操作礼物和一键发送按钮:实现和流程跟表情是一样的,无非是通过vpEmotion切换fragment和一些细节的不同,具体前往最下面进入查看源码。

参考:https://blog.csdn.net/javazejian/article/details/50542912

2、表情页面

​ 在最上面中我们提到了NoHorizontalScrollerViewPager,这个是用来加载三个fragment的,表情页面在EmotionFragment中实现,在此EmotionFragment中使用了工厂模式,可以生产各种表情,根据需要对各种表情进行添加和扩展,

​ 首先在EmotionFragment的布局如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical">

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/vp_emotion"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>

    <View
        android:layout_width="match_parent"
        android:layout_height="1px"
        android:background="@color/emotion_gifts_interval"/>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_emotion"
        android:layout_width="match_parent"
        android:layout_height="40dp"/>
</LinearLayout>

​ 可以看到这里同样使用了ViewPager,这里是用来加载各种表情fragment的,比如一些经典表情和一些GIF动画表情等,这里只添加了经典表情,下面的RecyclerView是用来实现条目横向布局的,就是与各种表情fragment相对应的,从效果图也能看得出来。

​ 下面这个方法是首先通过工厂类FragmentFactory根据传进去的参数生成各种表情的EmotionFactoryFragment。然后给ViewPager加载各种表情EmotionFactoryFragment。

private void setVPEmotion() {
        //创建fragment的工厂类
        FragmentFactory factory = FragmentFactory.getSingleFactoryInstance();

        //经典表情
        EmotionFactoryFragment classicEmotionFragment = (EmotionFactoryFragment) factory.getEmotiomFragment(EmotionManager.getInstance().EMOTION_CLASSIC_TYPE, Utils.Dp2Px(getActivity(), 25), 7, 0);
        emotionFragments.add(classicEmotionFragment);

        //经典表情2
//        EmotionFactoryFragment otherEmotionFragment = (EmotionFactoryFragment) factory.getEmotiomFragment(EmotionUtils.EMOTION_OTHER_TYPE, Utils.Dp2Px(getActivity(), 60), 4, 1);
//        emotionFragments.add(otherEmotionFragment);

        EmotionGiftsFragmentAdapter adapter = new EmotionGiftsFragmentAdapter(getChildFragmentManager(), emotionFragments);
        vpEmotion.setAdapter(adapter);
    }

​ 这个是工厂类,专门用来生成表情fragment和礼物fragment的

public class FragmentFactory {
    public static final String EMOTION_MAP_TYPE = "EMOTION_MAP_TYPE";
    public static final String ITEM_WIDTH_HEIGHT = "ITEM_WIDTH_HEIGHT";
    public static final String EMOTION_COLUMNS = "EMOTION_COLUMNS";
    public static final String EMOTION_TYPE = "EMOTION_TYPE";
    private static FragmentFactory factory;

    private FragmentFactory() {

    }

    /**
     * 双重检查锁定,获取工厂单例对象
     *
     * @return
     */
    public static FragmentFactory getSingleFactoryInstance() {
        if (factory == null) {
            synchronized (FragmentFactory.class) {
                if (factory == null) {
                    factory = new FragmentFactory();
                }
            }
        }
        return factory;
    }

    /**
     * 根据需求获取表情fragment的方法
     *
     * @param emotionType 表情类型,用于判断使用哪个map集合的表情
     * @param itemSize    表情大小
     * @param columns     表情列数
     * @param type        表情类型,0:表示经典表情  1:表示其它表情
     * @return
     */
    public Fragment getEmotiomFragment(int emotionType, int itemSize, int columns, int type) {
        Bundle bundle = new Bundle();
        bundle.putInt(FragmentFactory.EMOTION_MAP_TYPE, emotionType);
        bundle.putInt(FragmentFactory.ITEM_WIDTH_HEIGHT, itemSize);
        bundle.putInt(FragmentFactory.EMOTION_COLUMNS, columns);
        bundle.putInt(FragmentFactory.EMOTION_TYPE, type);

        EmotionFactoryFragment emotionFactoryFragment = EmotionFactoryFragment.newInstance(EmotionFactoryFragment.class, bundle);
        return emotionFactoryFragment;
    }

    /**
     * 根据需求获取礼物fragment的方法
     *
     * @return
     * @param data
     */
    public Fragment getGiftsFragment(ArrayList<IMChatGiftsModel.GiftDetail> data) {
        Bundle bundle = new Bundle();
        bundle.putSerializable("imchatgifts",data);
        Fragment giftsFactoryFragment = GiftsFactoryFragment.newInstance(GiftsFactoryFragment.class, bundle);
        return giftsFactoryFragment;
    }
}

​ 至于EmotionFactoryFragment,这个就是真正的每个表情页了,布局就是一个RecyclerView,这里主要介绍一下表情页里面的这个方法

private void setEmotionDatas() {
    List<String> emotionNames = new ArrayList<>();
    Collections.addAll(emotionNames, EmotionManager.getInstance().EMOJI_TEXT_ARRAY);
    EmotionFactoryAdapter emotionFactoryAdapter = new EmotionFactoryAdapter(getActivity(), emotionNames, emotion_map_type, item_width_height, emotion_columns, emotion_type);
    rvEmotion.setLayoutManager(new GridLayoutManager(getActivity(), emotion_columns, RecyclerView.VERTICAL, false));
    rvEmotion.setAdapter(emotionFactoryAdapter);
    if (emotion_type == 0) {
        emotionFactoryAdapter.setOnEmotionClickItemListener(GlobalOnItemClickManagerUtils.getInstance(getActivity()).getOnClassicEmotionClickItemListener(emotion_map_type));
    } else if (emotion_type == 1) {
        emotionFactoryAdapter.setOnEmotionClickItemListener(GlobalOnItemClickManagerUtils.getInstance(getActivity()).getOnOtherEmotionClickItemListener(emotion_map_type));
    }
}

​ 这里就是给页面设置adapter,重点关注方法最下面的点击事件,我们把这个是个点击事件交给了GlobalOnItemClickManagerUtils,这个类在最上面介绍的代码中也出现过,它是一个单例,主要就是绑定编辑框,然后在表情点击选择选择之后添加到标编辑框上,

public class GlobalOnItemClickManagerUtils {

   private static GlobalOnItemClickManagerUtils instance;
   private List<EditText> mEditTextList;

   public static GlobalOnItemClickManagerUtils getInstance(Context context)
   {
      if(instance == null)
      {
         synchronized(GlobalOnItemClickManagerUtils.class)
         {
            if(instance == null)
            {
               instance = new GlobalOnItemClickManagerUtils();
               instance.mEditTextList = new ArrayList<>();
            }
         }
      }
      return instance;
   }

   public void attachToEditText(EditText editText)
   {
      mEditTextList.add(editText);
   }

   /**
    * 经典表情点击事件
    *
    * @param emotion_map_type
    * @return
    */
   public OnEmotionClickItemListener getOnClassicEmotionClickItemListener(final int emotion_map_type)
   {
      return new OnEmotionClickItemListener()
      {
         @Override
         public void onItemClick(RecyclerView.Adapter adapter, View view, int position)
         {

            if(adapter instanceof EmotionFactoryAdapter)
            {
               if(mEditTextList == null || mEditTextList.size() == 0)
               {
                  return;
               }
               EditText mEditText = mEditTextList.get(mEditTextList.size() - 1);
               if(mEditText == null)
               {
                  return;
               }
               Context mContext = mEditText.getContext();
               if(mContext == null)
               {
                  return;
               }
               EmotionFactoryAdapter emotionFactoryAdapter = (EmotionFactoryAdapter)adapter;
               if(position == emotionFactoryAdapter.getItemCount() - 1)
               {
                  // 如果点击了最后一个回退按钮,则调用删除键事件
                  mEditText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
               }
               else
               {
                  // 如果点击了表情,则添加到输入框中
                  String emotionName = emotionFactoryAdapter.getItem(position);

                  // 获取当前光标位置,在指定位置上添加表情图片文本
                  int curPosition = mEditText.getSelectionStart();
                  StringBuilder sb = new StringBuilder(mEditText.getText().toString());
                  sb.insert(curPosition, emotionName);

                  // 特殊文字处理,将表情等转换一下
                  mEditText.setText(SpanStringUtils.getEmotionContent(emotion_map_type, mContext, (int)(mEditText.getTextSize() * 1.5), sb.toString()));

//                        mEditText.setText(sb.toString());
//                        ClickSpanBuilder.getInstance().setIsEmoji(true).build(mEditText);

                  // 将光标设置到新增完表情的右侧
                  mEditText.setSelection(curPosition + emotionName.length());
               }

            }
         }

         @Override
         public void onItemLongClick(RecyclerView.Adapter adapter, View view, int position)
         {

         }
      };
   }

   /**
    * 其它表情点击事件
    *
    * @param emotion_map_type
    * @return
    */
   public OnEmotionClickItemListener getOnOtherEmotionClickItemListener(int emotion_map_type)
   {
      return new OnEmotionClickItemListener()
      {
         @Override
         public void onItemClick(RecyclerView.Adapter adapter, View view, int position)
         {

            if(adapter instanceof EmotionFactoryAdapter)
            {
               EmotionFactoryAdapter emotionFactoryAdapter = (EmotionFactoryAdapter)adapter;
            }
         }

         @Override
         public void onItemLongClick(RecyclerView.Adapter adapter, View view, int position)
         {

         }
      };
   }

   public void onDestrouGlobalOnItemClickManager()
   {
      if(mEditTextList == null || mEditTextList.size() == 0)
      {
         return;
      }
      EditText editText = mEditTextList.get(mEditTextList.size() - 1);
      mEditTextList.remove(mEditTextList.size() - 1);
      editText = null;
   }
}

​ 我们再来看下这个类中的getOnClassicEmotionClickItemListener方法,里面给编辑框setText中使用了SpanStringUtils,这个类就是根据正则将相应的文本替换成emoji表情图片。

/**
 * 根据文本替换成emoji表情图片
 *
 * @param text
 * @return
 */

public static CharSequence getEmotionContent(int emotion_map_type, final Context mContext, int size, String text)
{
   if(TextUtils.isEmpty(text))
   {
      return "";
   }
   SpannableStringBuilder builder = new SpannableStringBuilder(text);
   Pattern mPattern2 = EmotionManager.getInstance().mPatternEmoji;
   Matcher matcher = mPattern2.matcher(text);
   while(matcher.find())
   {
      // 利用表情名字获取到对应的图片
      int resId = EmotionManager.getInstance().getImgByName(emotion_map_type, matcher.group());
      Drawable drawable = mContext.getResources().getDrawable(resId);
      drawable.setBounds(0, 0, size, size);//这里设置图片的大小
      MyImageSpan imageSpan = new MyImageSpan(drawable);
      builder.setSpan(imageSpan, matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
   }
   return builder;
}

​ 这里面可以看到使用了EmotionManager,这个就是表情的管理类,我们可以往这个类里面添加各种表情集合,还有相应的正则表达式,然后在EmotionFactoryFragment根据传递过来的表情类型显示出相应的表情页。

public class EmotionManager {

    /**
     * 表情类型标志符
     */
    public static final int EMOTION_CLASSIC_TYPE = 0x0001;//经典表情
    public static final int EMOTION_OTHER_TYPE = 0x0002;//经典表情

    /**
     * key-表情文字;
     * value-表情图片资源
     */
    public ArrayMap<String, Integer> EMOTION_CLASSIC_MAP;
    public ArrayMap<String, Integer> EMOTION_OTHER_MAP;
    public ArrayMap<String, Integer> EMPTY_MAP;

    public ArrayMap<String, Integer> mEmojiSmileyToRes;
    public Pattern mPatternEmoji;

    private static EmotionManager emotionManager;

    public static EmotionManager getInstance() {
        if (emotionManager == null) {
            synchronized (EmotionManager.class) {
                if (emotionManager == null) {
                    emotionManager = new EmotionManager();
                }
            }
        }
        return emotionManager;
    }

    private EmotionManager() {
        mPatternEmoji = buildPatternEmoji();
        EMOTION_CLASSIC_MAP = buildEmojiSmileyToRes();

        //其它表情
        EMOTION_OTHER_MAP = new ArrayMap<>();
//        EMOTION_OTHER_MAP.put("[clay呵呵]", R.drawable.lt_001_s);
//        EMOTION_OTHER_MAP.put("[clay嘻嘻]", R.drawable.lt_002_s);
//        EMOTION_OTHER_MAP.put("[clay哈哈]", R.drawable.lt_003_s);
        //空表情
        EMPTY_MAP = new ArrayMap<>();
    }

    private final int[] EMOJI_SMILEY_RES_IDS = {
            R.drawable.emoji_001, R.drawable.emoji_002, R.drawable.emoji_003, R.drawable.emoji_004,
            R.drawable.emoji_005, R.drawable.emoji_006, R.drawable.emoji_007, R.drawable.emoji_008, R.drawable.emoji_009, R.drawable.emoji_010,
            R.drawable.emoji_011, R.drawable.emoji_012, R.drawable.emoji_013, R.drawable.emoji_014, R.drawable.emoji_015, R.drawable.emoji_016,
            R.drawable.emoji_017, R.drawable.emoji_018, R.drawable.emoji_019, R.drawable.emoji_020, R.drawable.emoji_021, R.drawable.emoji_022,
            R.drawable.emoji_023, R.drawable.emoji_024, R.drawable.emoji_025, R.drawable.emoji_026, R.drawable.emoji_027, R.drawable.emoji_028,
            R.drawable.emoji_029, R.drawable.emoji_030, R.drawable.emoji_031, R.drawable.emoji_032, R.drawable.emoji_033, R.drawable.emoji_034,
            R.drawable.emoji_035, R.drawable.emoji_036, R.drawable.emoji_037, R.drawable.emoji_038, R.drawable.emoji_039, R.drawable.emoji_040,
            R.drawable.emoji_041, R.drawable.emoji_042, R.drawable.emoji_043, R.drawable.emoji_044, R.drawable.emoji_045, R.drawable.emoji_046,
            R.drawable.emoji_047, R.drawable.emoji_048, R.drawable.emoji_049, R.drawable.emoji_050, R.drawable.emoji_051, R.drawable.emoji_052,
            R.drawable.emoji_053, R.drawable.emoji_054, R.drawable.emoji_055, R.drawable.emoji_056, R.drawable.emoji_057, R.drawable.emoji_058,
            R.drawable.emoji_059, R.drawable.emoji_060, R.drawable.emoji_061, R.drawable.emoji_062, R.drawable.emoji_063, R.drawable.emoji_064,
            R.drawable.emoji_065, R.drawable.emoji_066, R.drawable.emoji_067, R.drawable.emoji_068, R.drawable.emoji_069, R.drawable.emoji_070,
            R.drawable.emoji_071, R.drawable.emoji_072, R.drawable.emoji_073, R.drawable.emoji_074, R.drawable.emoji_075, R.drawable.emoji_076,
            R.drawable.emoji_077, R.drawable.emoji_078, R.drawable.emoji_079, R.drawable.emoji_080, R.drawable.emoji_081, R.drawable.emoji_082,
            R.drawable.emoji_083, R.drawable.emoji_084, R.drawable.emoji_085, R.drawable.emoji_086, R.drawable.emoji_087, R.drawable.emoji_088,
            R.drawable.emoji_089, R.drawable.emoji_090, R.drawable.emoji_091, R.drawable.emoji_092, R.drawable.emoji_093, R.drawable.emoji_094,
            R.drawable.emoji_095, R.drawable.emoji_096, R.drawable.emoji_097, R.drawable.emoji_098, R.drawable.emoji_099, R.drawable.emoji_100,
            R.drawable.emoji_101, R.drawable.emoji_102, R.drawable.emoji_103, R.drawable.emoji_104, R.drawable.emoji_105, R.drawable.emoji_106,
            R.drawable.emoji_107, R.drawable.emoji_108, R.drawable.emoji_109, R.drawable.emoji_110, R.drawable.emoji_111, R.drawable.emoji_112,
            R.drawable.emoji_113, R.drawable.emoji_114, R.drawable.emoji_115, R.drawable.emoji_116, R.drawable.emoji_117, R.drawable.emoji_118,
            R.drawable.emoji_119, R.drawable.emoji_120, R.drawable.emoji_121, R.drawable.emoji_122, R.drawable.emoji_123, R.drawable.emoji_124,
    };

    public final String[] EMOJI_TEXT_ARRAY = new String[]{
            "\uD83D\uDE0C", "\uD83D\uDE28", "\uD83D\uDE37", "\uD83D\uDE33", "\uD83D\uDE12", "\uD83D\uDE30", "\uD83D\uDE0A", "\uD83D\uDE03", "\uD83D\uDE1E",
            "\uD83D\uDE20", "\uD83D\uDE1C", "\uD83D\uDE0D", "\uD83D\uDE31", "\uD83D\uDE13", "\uD83D\uDE25", "\uD83D\uDE0F", "\uD83D\uDE14", "\uD83D\uDE01",
            "\uD83D\uDE09", "\uD83D\uDE23", "\uD83D\uDE16", "\uD83D\uDE2A", "\uD83D\uDE1D", "\uD83D\uDE32", "\uD83D\uDE2D", "\uD83D\uDE02", "\uD83D\uDE22",
            "☺", "\uD83D\uDE04", "\uD83D\uDE21", "\uD83D\uDE1A", "\uD83D\uDE18", "\uD83D\uDC4F", "\uD83D\uDC4D", "\uD83D\uDC4C", "\uD83D\uDC4E", "\uD83D\uDCAA",
            "\uD83D\uDC4A", "\uD83D\uDC46", "✌", "✋", "\uD83D\uDCA1", "\uD83C\uDF39", "\uD83C\uDF84", "\uD83D\uDEA4", "\uD83D\uDC8A", "\uD83D\uDEC1", "⭕",
            "❌", "❓", "❗", "\uD83D\uDEB9", "\uD83D\uDEBA", "\uD83D\uDC8B", "❤", "\uD83D\uDC94", "\uD83D\uDC98", "\uD83C\uDF81", "\uD83C\uDF89", "\uD83D\uDCA4",
            "\uD83D\uDCA8", "\uD83D\uDD25", "\uD83D\uDCA6", "⭐", "\uD83C\uDFC0", "⚽", "\uD83C\uDFBE", "\uD83C\uDF74", "\uD83C\uDF5A", "\uD83C\uDF5C", "\uD83C\uDF70",
            "\uD83C\uDF54", "\uD83C\uDF82", "\uD83C\uDF59", "☕", "\uD83C\uDF7B", "\uD83C\uDF49", "\uD83C\uDF4E", "\uD83C\uDF4A", "\uD83C\uDF53", "☀", "☔", "\uD83C\uDF19",
            "⚡", "⛄", "☁", "\uD83C\uDFC3", "\uD83D\uDEB2", "\uD83D\uDE8C", "\uD83D\uDE85", "\uD83D\uDE95", "\uD83D\uDE99", "✈", "\uD83D\uDC78", "\uD83D\uDD31",
            "\uD83D\uDC51", "\uD83D\uDC8D", "\uD83D\uDC8E", "\uD83D\uDC84", "\uD83D\uDC85", "\uD83D\uDC60", "\uD83D\uDC62", "\uD83D\uDC52", "\uD83D\uDC57", "\uD83C\uDF80",
            "\uD83D\uDC5C", "\uD83C\uDF40", "\uD83D\uDC9D", "\uD83D\uDC36", "\uD83D\uDC2E", "\uD83D\uDC35", "\uD83D\uDC2F", "\uD83D\uDC3B", "\uD83D\uDC37", "\uD83D\uDC30",
            "\uD83D\uDC24", "\uD83D\uDC2C", "\uD83D\uDC33", "\uD83C\uDFB5", "\uD83D\uDCF7", "\uD83C\uDFA5", "\uD83D\uDCBB", "\uD83D\uDCF1", "\uD83D\uDD52"
    };

    //聊天emoji表情
    private ArrayMap<String, Integer> buildEmojiSmileyToRes() {
        if (EMOJI_SMILEY_RES_IDS.length != EMOJI_TEXT_ARRAY.length) {
            //表情的数量需要和数组定义的长度一致!
            throw new IllegalStateException("Smiley resource ID/text mismatch");
        }

        ArrayMap<String, Integer> smileyToRes = new ArrayMap<String, Integer>(EMOJI_TEXT_ARRAY.length);
        for (int i = 0; i < EMOJI_TEXT_ARRAY.length; i++) {
            smileyToRes.put(EMOJI_TEXT_ARRAY[i], EMOJI_SMILEY_RES_IDS[i]);
        }

        return smileyToRes;
    }

    //构建Emoji正则表达式
    private Pattern buildPatternEmoji() {
        StringBuilder patternString = new StringBuilder(EMOJI_TEXT_ARRAY.length * 3);
        patternString.append('(');
        for (String s : EMOJI_TEXT_ARRAY) {
            patternString.append(Pattern.quote(s));
            patternString.append('|');
        }
        patternString.replace(patternString.length() - 1, patternString.length(), ")");

        return Pattern.compile(patternString.toString());
    }

    /**
     * 根据文本替换成emoji表情图片
     *
     * @param text
     * @param size
     * @return
     */
    public CharSequence replaceEmoji(Context mContext, CharSequence text, int size) {
        SpannableStringBuilder builder = new SpannableStringBuilder(text);
        Matcher matcher = mPatternEmoji.matcher(text);
        while (matcher.find()) {
            int resId = mEmojiSmileyToRes.get(matcher.group());
            Drawable drawable = mContext.getResources().getDrawable(resId);
            drawable.setBounds(0, 0, Utils.getRealPixel(size), Utils.getRealPixel(size));//这里设置图片的大小
            MyImageSpan imageSpan = new MyImageSpan(drawable);
            builder.setSpan(imageSpan, matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        return builder;
    }

    /**
     * 根据名称获取当前表情图标R值
     *
     * @param EmotionType 表情类型标志符
     * @param imgName     名称
     * @return
     */
    public int getImgByName(int EmotionType, String imgName) {
        Integer integer = null;
        switch (EmotionType) {
            case EMOTION_CLASSIC_TYPE:
                integer = EMOTION_CLASSIC_MAP.get(imgName);
                break;
            case EMOTION_OTHER_TYPE:
                integer = EMOTION_OTHER_MAP.get(imgName);
                break;
            default:
                LogUtils.e("the emojiMap is null!! Handle Yourself ");
                break;
        }
        return integer == null ? -1 : integer;
    }

    /**
     * 根据类型获取表情数据
     *
     * @param EmotionType
     * @return
     */
    public ArrayMap<String, Integer> getEmojiMap(int EmotionType) {
        ArrayMap EmojiMap = null;
        switch (EmotionType) {
            case EMOTION_CLASSIC_TYPE:
                EmojiMap = EMOTION_CLASSIC_MAP;
                break;
            case EMOTION_OTHER_TYPE:
                EmojiMap = EMOTION_OTHER_MAP;
                break;
            default:
                EmojiMap = EMPTY_MAP;
                break;
        }
        return EmojiMap;
    }
}
3、礼物页面

​ 礼物页面的实现跟表情页其实差不多,搞清楚了表情页面的实现相信很快能搞定礼物的实现流程,礼物页面的根fragment是GiftsFragment,它的布局如下,可以看出跟表情差不多,同样是一个viewpager和一个RecyclerView,只是多了一个点击赠送。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical">

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/vp_gifts"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>

    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="@color/emotion_gifts_interval"/>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="40dp">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_gifts"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginRight="73dp"/>

        <RelativeLayout
            android:id="@+id/rl_gradualGifts"
            android:layout_width="84dp"
            android:layout_height="match_parent"
            android:layout_alignParentRight="true"
            android:background="@drawable/chat_gifts_gradual_button">

            <RelativeLayout android:layout_width="73dp"
                            android:layout_height="match_parent"
                            android:layout_alignParentRight="true"
                            android:background="@color/white">

                <TextView
                    android:layout_width="53dp"
                    android:layout_height="wrap_content"
                    android:layout_centerInParent="true"
                    android:background="@drawable/chat_gradual_gift_shape"
                    android:gravity="center"
                    android:includeFontPadding="false"
                    android:padding="6dp"
                    android:text="赠送"
                    android:textColor="@color/white"
                    android:textSize="13dp"/>

            </RelativeLayout>

        </RelativeLayout>
    </RelativeLayout>
</LinearLayout>

​ 我们再来看下这个方法,同样是使用工厂模式通过FragmentFactory创建礼物GiftsFactoryFragment,然后使用viewpager进行加载。

private void setVPGifts(ArrayList<IMChatGiftsModel> data)
{
   //创建fragment的工厂类
   FragmentFactory factory = FragmentFactory.getSingleFactoryInstance();
   for(int i = 0; i < data.size(); i++)
   {
      IMChatGiftsModel imChatGiftsModel = data.get(i);
      ArrayList<IMChatGiftsModel.GiftDetail> items = imChatGiftsModel.items;
      if(items != null && items.size() > 0)
      {
         GiftsFactoryFragment giftsFactoryFragment = (GiftsFactoryFragment)factory.getGiftsFragment(items);
         giftsFragments.add(giftsFactoryFragment);
      }
   }

   EmotionGiftsFragmentAdapter adapter = new EmotionGiftsFragmentAdapter(getChildFragmentManager(), giftsFragments);
   vpGifts.setAdapter(adapter);
   vpGifts.setOffscreenPageLimit(giftsFragments.size());
}

​ 下面这个方法是点击赠送按钮后调用的,通过giftsFactoryFragment.isChackedGift()判断页面中礼物是否选中,选中则获取礼物的信息然后调用disposeSelectedGift(checkGifts)将其传给MainActivity进行发送。

/**
 * 不默认选择礼物--赠送礼物
 */
private void sendGifts()
{
   List<Fragment> fragments = getChildFragmentManager().getFragments();
   if(fragments != null)
   {
      for(int i = 0; i < fragments.size(); i++)
      {
         GiftsFactoryFragment giftsFactoryFragment = (GiftsFactoryFragment)fragments.get(i);
         boolean chackedGift = giftsFactoryFragment.isChackedGift();
         if(chackedGift)
         {
            IMChatGiftsModel.GiftDetail checkGifts = giftsFactoryFragment.getCheckGifts();
            if(checkGifts == null)
            {
               return;
            }
            disposeSelectedGift(checkGifts);
            return;
         }
      }
   }
}

​ 点击选中礼物之后最好需要一些效果显示给用户,所以我在GiftsFactoryFragment布局中的RecyclerView的GiftsFactoryAdapter给选中的条目增加了一些属性动画。

private ScaleAnimation getmGiftGlobalScaleAnimation() {
    if (mGlobalGiftScaleAnimation == null) {
        mGlobalGiftScaleAnimation = new ScaleAnimation(1, 0.9f, 1, 0.9f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
        mGlobalGiftScaleAnimation.setDuration(250);
        mGlobalGiftScaleAnimation.setRepeatCount(1);
        mGlobalGiftScaleAnimation.setRepeatMode(Animation.REVERSE);
    }
    return mGlobalGiftScaleAnimation;
}
private AnimatorSet addAnimatorSet(int position, View view) {
    if (!animatorSetMap.containsKey(new Integer(position))) {
        AnimatorSet set = new AnimatorSet();
        final ObjectAnimator oa3 = ObjectAnimator.ofFloat(view, "scaleX", 1, 0.7f);
        oa3.setDuration(800);
        oa3.setRepeatCount(-1);
        oa3.setRepeatMode(ValueAnimator.REVERSE);

        final ObjectAnimator oa4 = ObjectAnimator.ofFloat(view, "scaleY", 1, 0.7f);
        oa4.setDuration(800);
        oa4.setRepeatCount(-1);
        oa4.setRepeatMode(ValueAnimator.REVERSE);
        //设置一起飞
        set.playTogether(oa3, oa4);
        set.setStartDelay(300);
        animatorSetMap.put(new Integer(position), set);
        return set;
    } else {
        return animatorSetMap.get(new Integer(position));
    }
}

接下来就是关键的礼物的播放了,在Android中,如果想要实现酷炫的动画效果,那么实现主要有这几种方式:

  • 帧动画:需要把图片带到安装包中,加大了安装包的大小,如果动画很多,这种方案明显不是那么现实,不过可以从网上下载到sd卡,然后播放的时候去加载,这样虽然可以,但下载量也有点大,太麻烦,不是好的选择。
  • 属性动画:属性动画实现的效果有限,而且开发代价太大。
  • GIF:GIF的性能不是最优选择,而且动画酷炫点GIF也会相应的变大。
  • Lottie:Lottie是Airbnb开源的一个面向 iOS、Android、React Native 的动画库,可实现非常复杂的动画,使用也及其简单,极大释放人力,值得一试。目前QQ礼物就是使用了这个。Lottie
  • SVGA:这是YY开源的一个动画框架,占用资源少,动画文件大小也比较小,集成很方便,目前虎牙直播等YY系在线上使用。SVGA

​ 经过上面的分析我们可以在礼物中可以使用的方案有两种,要么使用Airbnb的Lottie,要么使用YY的SVGA,这个看个人需求。我不会制作这些动画文件,所以我在这里选择使用的是YY的SVGA,因为YY开源库中提供了很多的动画效果文件,我直接拿来用了。

​ 好,回归主题,监听长按礼物的条目或者直接点击赠送礼物,即可使礼物播放,最终会调用到MainActivity的下面的这个方法进行播放。

/**
 * 长按播放礼物
 *
 * @param giftId
 * @param title
 * @param strSvga
 */
public void playFrameGift(String giftId, String title, String strSvga) {
    loadAnimation(strSvga);
}

private void loadAnimation(String strSvga) {
    SVGAParser parser = new SVGAParser(this);
    parser.parse(strSvga, new SVGAParser.ParseCompletion() {
        @Override
        public void onComplete(@NotNull SVGAVideoEntity videoItem) {
            svgaGift.setVisibility(View.VISIBLE);
            svgaGift.setVideoItem(videoItem);
            svgaGift.setLoops(1);
            svgaGift.startAnimation();
        }

        @Override
        public void onError() {

        }
    });
}

svgaGift在MainActivity上的布局如下:

<com.opensource.svgaplayer.SVGAImageView
    android:id="@+id/svga_gift"
    android:visibility="gone"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
4、一键发送

​ 发送开场白就简单多了,就是一个OneKeySendFragment,点击发送开场白直接把设置的开场白发送出去。

public class OneKeySendFragment extends Fragment {
    private RelativeLayout rlOnekeyMsg;
    private TextView tvOnekeySendMsg;
    private TextView tvOnekeySend;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.chat_onekey_send_fragment, container, false);
        rlOnekeyMsg = view.findViewById(R.id.rl_onekeyMsg);
        tvOnekeySendMsg = view.findViewById(R.id.tv_onekeySendMsg);
        tvOnekeySend = view.findViewById(R.id.tv_onekeySend);
        return view;
    }

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        tvOnekeySend.setOnTouchListener(Utils.getTouchBackListener(0.9f));
        tvOnekeySend.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ((MainActivity) getActivity()).sendOneKeyMsg(tvOnekeySendMsg.getText().toString());
            }
        });

        tvOnekeySendMsg.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                promptAnimator();
            }
        });

        rlOnekeyMsg.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                promptAnimator();
            }
        });
    }

    private void promptAnimator() {
        int offset = Utils.Dp2Px(getActivity(), 5);
        ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(tvOnekeySend, "translationX", offset, -offset, offset, -offset, offset, -offset, 0);
        objectAnimator.setDuration(800);
        objectAnimator.start();
        ToastUtils.showToast(getActivity(), "请点击发送按钮", Toast.LENGTH_SHORT, ToastUtils.DEFEAT);
    }
}
5、结束

​ 好了,这篇博客到此为止了,内容也比较多,可能有些没讲好的,或者没讲到的,欢迎指出。如果有需要,最好下载代码跟着博客介绍一起查看比较传送门

推荐阅读更多精彩内容