欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

仿微信朋友圈【九宫格的实现】

程序员文章站 2022-03-09 18:14:32
...

最近有个想法,想用环信的sdk去做个社交类的小demo玩。在此之前,先来模仿下微信的朋友圈九宫格效果。同时也兼容了QQ的做法,如果数据集大于九张时,就在最后一张图片上显示一层遮罩效果,并显示剩余图片的数量。之后的计划是仿微信的朋友圈评论、回复这方面的效果,在实际开发中还是比较实用的。

老规矩,先来张效果图(录制的图片太大满足不了神经的CSDN上传要求,压缩又不清晰,所以还是放几张静态图吧) 
仿微信朋友圈【九宫格的实现】

仿微信朋友圈【九宫格的实现】

仿微信朋友圈【九宫格的实现】

需求分析

  1. 单张的情况,我们需要考虑图片的宽度与我们自定义九宫格控件的宽度,如果图片的宽度大于控件的宽度,那么我们就用控件的宽度作为图片的宽度
  2. 2 X 2的情况,微信朋友圈对于4张的图片,采用的是2 X 2的布局方式
  3. 当图片集大于9张时,我们需要在最后一张图片上显示一层遮罩,并显示出剩余的图片数量
  4. 最后一点需要特别注意,就是关于我们自定义控件的复用。比如说我们滑出屏幕的一个item的布局是7张图片,这时滑进屏幕的item是6张图片的布局,难道我们还需要再重新new出来六个imageview?当然不是,我们完全可以复用滑出屏幕的那个布局,同时移除一个imageview即可。反之如果当前滑入的item是9张,那么复用后就只需要再new出来两个imageview控件即可。其实是跟ListView的复用机制思想差不多。

根据上面的分析,实现起来应该相对有些思路了。下面就开启自定义模式了

自定义属性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="NineGridView">
        <attr name="nine_gv_spacing" format="dimension"/>
        <attr name="nine_maxImageNum" format="integer"/>
        <attr name="nine_single_image" format="dimension"/>
    </declare-styleable>
</resources>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

在我们自定义类的构造方法中去获取我们的自定义属性

public NineGridView(Context context) {
        this(context, null);
    }

    public NineGridView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public NineGridView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //单位转换
        mNineGridViewSpacing = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mNineGridViewSpacing, context.getResources().getDisplayMetrics());
        mSingleImageSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mSingleImageSize, context.getResources().getDisplayMetrics());
        //获取自定义属性
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.NineGridView, defStyleAttr, 0);
        int count = typedArray.getIndexCount();
        for (int i=0; i<count; i++){
            int attr = typedArray.getIndex(i);
            switch (attr){
                case R.styleable.NineGridView_nine_gv_spacing:
                    mNineGridViewSpacing = (int) typedArray.getDimension(attr, mNineGridViewSpacing);
                    break;
                case R.styleable.NineGridView_nine_maxImageNum:
                    mMaxImageNum = typedArray.getInt(attr, mMaxImageNum);
                    break;
                case R.styleable.NineGridView_nine_single_image:
                    mSingleImageSize = typedArray.getDimensionPixelSize(attr, mSingleImageSize);
                    break;
            }
        }
        typedArray.recycle();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

测量 onMeasure

测量我们控件的宽高等,这里根据上面的分析可知我们需要对单张图片以及非单张图片进行判断。如果是多张图片的话,我们需要根据行、列个数以及每行每列之间的间距值来算出最终的宽、高

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize;

        int totalWidth = widthSize - getPaddingLeft() - getPaddingRight();
        if(imagesDatasList != null && imagesDatasList.size() > 0){
            if(imagesDatasList.size() == 1){
                //说明是单张图片
                mWidth = mSingleImageSize > totalWidth ? (int)(totalWidth * 0.8) : mSingleImageSize;
                mHeight = mWidth;
                //进一步根据高度来调整显示,控制最大显示范围
                if(mHeight > mSingleImageSize){
                    float ratio = mSingleImageSize * 1.0f / mHeight;
                    mWidth = (int) (mWidth * ratio);
                    mHeight = mSingleImageSize;
                }
            }else{
                //说明不止一张
                mWidth = mHeight = (totalWidth - mNineGridViewSpacing*(columnCount - 1)) / columnCount;
            }
            widthSize = mWidth * columnCount + mNineGridViewSpacing * (columnCount - 1) + getPaddingLeft() + getPaddingRight();
            heightSize = mHeight * rowCount + mNineGridViewSpacing * (rowCount - 1) + getPaddingTop() + getPaddingBottom();
            setMeasuredDimension(widthSize, heightSize);
        }else{
            heightSize = widthSize;
            setMeasuredDimension(widthSize, heightSize);
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

其实,实际开发中可能服务器返回的还有图片的宽高比例,那么我们可以根据这个宽高比例还算出图片的高度等等,具体情况根据业务来定。

确定位置 onLayout

既然是自定义ViewGroup,那么onLayout()方法肯定少不了。它是用来确定子view的位置的

@Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        if(imagesDatasList == null) return;
        int childCount = imagesDatasList.size() > mMaxImageNum ? mMaxImageNum : imagesDatasList.size();
        for(int i = 0; i < childCount; i++){
            ImageView childView = (ImageView) getChildAt(i);
            if(mAdapter != null){
                mAdapter.onDisplayImage(getContext(), childView, imagesDatasList.get(i));//得到图片数组中的每一张图片
            }
            //通过此方式来确定宽高是否累加、换行,一并判断了
            int columnNum = i % columnCount;
            int rowNum = i / columnCount;
            left = (mWidth + mNineGridViewSpacing ) * columnNum + getPaddingLeft();//根据i来决定left, i=0 left=getPaddingLeft   i=1表示第二个childView的left=第一个child的宽+间距+内间距
            top = (mHeight + mNineGridViewSpacing) * rowNum + getPaddingTop();
            right = left + mWidth;
            bottom = top + mHeight;

            childView.layout(left, top, right, bottom);
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

接下来就是我们的adapter跟这个自定义控件的交互了

public void setData(List<String> mDataLists){
        //有无数据决定着九宫格控件的显示与隐藏
        if(mDataLists == null || mDataLists.isEmpty()){
            this.setVisibility(View.GONE);
            return;
        }else{
            this.setVisibility(View.VISIBLE);
        }
        //获取图片数量,图片的数量有可能大于规定的最大数量9张
        int newImgCount = mDataLists.size() > mMaxImageNum ? mMaxImageNum : mDataLists.size();
        //给rowCount、columnCount行列赋值。对图片的分布特殊处理,比如 四张  2 X 2 分布
        setRowAndColumn(newImgCount);
        //复用
        if(imagesDatasList == null){
            for(int i = 0; i < newImgCount; i++){
                ImageView iv = imageViewHolder(i);
                if(iv == null) return;
                addView(iv, generateDefaultLayoutParams());
            }
        } else {
            int oldImgCount = imagesDatasList.size() > mMaxImageNum ? mMaxImageNum : imagesDatasList.size();//原来的图片数据数量
            if(newImgCount < oldImgCount){
                //说明可以复用原来的imageview   移除后面多余的view(imageview)布局
                removeViews(newImgCount,oldImgCount - newImgCount);
            }else if(newImgCount > oldImgCount){
                //说明需要再新new几个imageview提供多余的数据使用
                for(int i=oldImgCount; i < newImgCount; i++){
                    ImageView iv = imageViewHolder(i);
                    if(iv == null) return;
                    addView(iv, generateDefaultLayoutParams());//将imageview添加到默认宽高的布局中
                }
            }
        }
       //如果是最后一张,并且图片的数据集总数大于九张,那么就在最后一张图片上展示还剩图片的数量
        if (mDataLists.size() > mMaxImageNum){
            View child = getChildAt(mMaxImageNum - 1);//九宫格的最后一张图片
            if(child instanceof MyGridViewItemImageView){
                MyGridViewItemImageView imageView = (MyGridViewItemImageView) child;
                imageView.setImagesCount(mDataLists.size());
            }
        }
        imagesDatasList = mDataLists;
        //当view布局内容发生改变后调用此方法会重新走onMeasure()和onLayout()方法,重新调整布局
        //requestLayout(); //因为addViews()方法内部已经有requestLayout()了
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45

我们需要在展示数据的adapter中去调用此方法。这里为了调用的简洁,我们额外定义了一个抽象类。

public abstract class NineGridViewAdapter {

    protected abstract void onDisplayImage(Context context, ImageView iv, String url);

    protected void onItemImageClick(Context context, ImageView iv, int position, List<String> list){

    }

    protected ImageView generateImageView(Context context){
        MyGridViewItemImageView imageView = new MyGridViewItemImageView(context);//设置图片的点击背景颜色变化效果
        imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
        return imageView;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

这里需要特别说明的是generateImageView()方法,这里面我们new出来我们的九宫格中的一张张图片。同时,还就点击图片变暗的点击效果以及超过9张后的效果处理。下面就具体看看

/**
 * 设置图片点击时有个背景色,松手后移除背景色   类似XML文件设置selector效果
 */

public class MyGridViewItemImageView extends ImageView{

    private int textColor = Color.parseColor("#FFFFFF");
    private int textSize;
    private int imageViewBg = 0x88000000;
    private int imagesCount;//总的数据集
    private String textDesc;//要绘制的文字

    private Paint paint;

    public MyGridViewItemImageView(Context context) {
        this(context, null);
    }

    public MyGridViewItemImageView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyGridViewItemImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        textSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 32, context.getResources().getDisplayMetrics());

        //初始化画笔
        iniPaint();
    }

    private void iniPaint() {
        paint = new Paint();
        paint.setAntiAlias(true);
        paint.setDither(true);
        paint.setColor(textColor);
        paint.setTextSize(textSize);
        paint.setTextAlign(Paint.Align.CENTER);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if(imagesCount > 9){
            canvas.drawColor(imageViewBg);//背景颜色
            Paint.FontMetricsInt fontMetrics = paint.getFontMetricsInt();
            float baseLine = getHeight() / 2 - (fontMetrics.bottom + fontMetrics.top) / 2;
            canvas.drawText(textDesc, getWidth() / 2, baseLine, paint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                Drawable drawable = getDrawable();
                if(drawable != null){
                    //drawable.mutate().setColorFilter(Color.GRAY, PorterDuff.Mode.MULTIPLY);
                    drawable.setColorFilter(Color.GRAY, PorterDuff.Mode.MULTIPLY);
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                Drawable drawableUp = getDrawable();
                if(drawableUp != null){
                    //drawableUp.mutate().clearColorFilter();
                    drawableUp.clearColorFilter();
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        //将drawable对象置空
        setImageDrawable(null);
    }

    public int getImagesCount() {
        return imagesCount;
    }

    public void setImagesCount(int imagesCount) {
        this.imagesCount = imagesCount;
        textDesc = "+"+(imagesCount - 9);
        invalidate();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101

需要特别说明的一点是drawable.mutate().setColorFilter(Color.GRAY, PorterDuff.Mode.MULTIPLY);这个方法。根据jeasonlzy大神的解释是,如果这样写的话在部分机型上会出问题,所以他给出了一个解决方案。由于现有测试机种类有限,目前还没有出现他说的这种问题。不管了,先给出两种实现方式。

接下来,再来看看我们的adapter是如何调用交互的

/**
 *  展示数据的适配器 adapter
 */

public class RecyclerViewDatasAdapter extends RecyclerView.Adapter<RecyclerViewDatasAdapter.ImageViewHolder>{

    private Context context;
    private List<ImagesBean> lists;
    private LayoutInflater inflater;

    public RecyclerViewDatasAdapter(Context context, List<ImagesBean> lists) {
        this.context = context;
        this.lists = lists;
        inflater = LayoutInflater.from(context);
    }

    @Override
    public ImageViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new ImageViewHolder(inflater.inflate(R.layout.item_layout, parent, false));
    }

    @Override
    public void onBindViewHolder(ImageViewHolder holder, int position) {
        holder.iv.setImageResource(R.mipmap.ic_launcher);
        holder.tvName.setText(lists.get(position).getName());
        holder.tvDesc.setText(lists.get(position).getDesc());
        holder.nineGridView.setData(lists.get(position).getImgsUrl());//将图片集合传到我们的自定义九宫格控件中
    }

    @Override
    public int getItemCount() {
        return null != lists ? lists.size() : 0;
    }

    public class ImageViewHolder extends RecyclerView.ViewHolder{

        private ImageView iv;
        private TextView tvName;
        private TextView tvDesc;
        private NineGridView nineGridView;

        private NineGridViewAdapter nineGridViewAdapter = new NineGridViewAdapter() {
            @Override
            protected void onDisplayImage(Context context, ImageView iv, String url) {
                //Glide.with(context).load(url).into(iv);
                Picasso.with(context).load(url).into(iv);
            }

            @Override
            protected ImageView generateImageView(Context context) {
                return super.generateImageView(context);
            }

            @Override
            protected void onItemImageClick(Context context, ImageView iv, int position, List<String> list) {
                Toast.makeText(context, "你点击了 position = " + position, Toast.LENGTH_SHORT).show();
                //super.onItemImageClick(context, iv, position, list);
            }
        };

        public ImageViewHolder(View itemView) {
            super(itemView);
            iv = (ImageView) itemView.findViewById(R.id.iv);
            tvName = (TextView) itemView.findViewById(R.id.tv_name);
            tvDesc = (TextView) itemView.findViewById(R.id.tv_desc);
            nineGridView = (NineGridView) itemView.findViewById(R.id.nineGridView);
            nineGridView.setDataAdapter(nineGridViewAdapter);
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71

这里需要特别说明一下,大家可以看到这里我采用的是Glide加载图片,在测试中发现当图片大于九张时会出现图片部分被放大(也就是所谓的变形),开始我以为是自定义控件哪写的有问题,但是经过反复测试,发现是Glide加载的问题。按照网上说的方式,比如关掉加载动画等,发现并不能解决。Glide的源码着实太复杂,所以目前并不能很好的解决这个问题。以后有时间再继续研究吧,目前我换用了其它的图片加载框架就没问题了。

顺便把我们的实体类也贴出来吧

/**
 * 实体类
 */

public class ImagesBean implements Serializable{
    private static final long serialVersionUID = 370114387259948705L;

    private int imgs;
    private String name;
    private String desc;
    private ArrayList<String> imgsUrl;//图片数组集合

    public ImagesBean(String name, String desc, ArrayList<String> imgsUrl) {
        this.name = name;
        this.desc = desc;
        this.imgsUrl = imgsUrl;
    }

    public int getImgs() {
        return imgs;
    }

    public void setImgs(int imgs) {
        this.imgs = imgs;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }

    public ArrayList<String> getImgsUrl() {
        return imgsUrl;
    }

    public void setImgsUrl(ArrayList<String> imgsUrl) {
        this.imgsUrl = imgsUrl;
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

最后是我们的MainActivity

 public class MainActivity extends AppCompatActivity {

    private RecyclerView recyclerView;
    private RecyclerViewDatasAdapter adapter;
    private List<ImagesBean> mDatas;
    private String[] imgsUrl = {
            "https://pic4.zhimg.com/02685b7a5f2d8cbf74e1fd1ae61d563b_xll.jpg",
            "https://pic4.zhimg.com/fc04224598878080115ba387846eabc3_xll.jpg",
            "https://pic3.zhimg.com/d1750bd47b514ad62af9497bbe5bb17e_xll.jpg",
            "https://pic4.zhimg.com/da52c865cb6a472c3624a78490d9a3b7_xll.jpg",
            "https://pic3.zhimg.com/0c149770fc2e16f4a89e6fc479272946_xll.jpg",
            "https://pic1.zhimg.com/76903410e4831571e19a10f39717988c_xll.png",
            "https://pic3.zhimg.com/33c6cf59163b3f17ca0c091a5c0d9272_xll.jpg",
            "https://pic4.zhimg.com/02685b7a5f2d8cbf74e1fd1ae61d563b_xll.jpg",
            "https://pic4.zhimg.com/fc04224598878080115ba387846eabc3_xll.jpg",
            "https://pic3.zhimg.com/d1750bd47b514ad62af9497bbe5bb17e_xll.jpg",
            "https://pic4.zhimg.com/da52c865cb6a472c3624a78490d9a3b7_xll.jpg",
            "https://pic3.zhimg.com/0c149770fc2e16f4a89e6fc479272946_xll.jpg",
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        recyclerView = (RecyclerView) findViewById(R.id.recyclerview);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        //测试数据
        mDatas = new ArrayList<>();
        for(int i=0; i < 12; i++){
            ArrayList<String> imgs = new ArrayList<>();
            imgs.addAll(Arrays.asList(imgsUrl).subList(0, i % 12 + 1));
            ImagesBean bean = new ImagesBean("我是bean", "测试九宫格图片,只是测试demo,只是测试demo",imgs);
            mDatas.add(bean);
        }
        adapter = new RecyclerViewDatasAdapter(this, mDatas);
        recyclerView.setAdapter(adapter);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

最后,非常感谢laobie大牛,此项目就是参考他的项目。如果大家觉得还有什么问题的话,欢迎留言交流。