Android艺术开发探索第四章——View的工作原理(下)

Android艺术开发探索第四章——View的工作原理(下)


我们上篇BB了这么多,这篇就多多少少要来点实战了,上篇主席叫我多点自己的理解,那我就多点真诚,少点套路了,老司机,开车吧!

我们这一篇就扯一个内容,那就是自定义View

  • 自定义View

    • 自定义View的分类
    • 自定义View的须知
    • 自定义View的实例
    • 自定义View的思想

一.自定义View的分类

自定义View百花齐放,没有什么具体的分类,不过可以从特性大致的分为4类,其实在我看来,就三类,继承原生View,继承View和继承ViewGroup。

  • 1.继承View重写onDraw方法

    重写了绘制,一般就是想自己实现某些图形了,因为原生控件已经满足不了你了,很显然这需要绘制的方式来完成,采用这个方式需要自身支=warp_content,并且pading也要自己处理,比较考验你的功底了

  • 2.继承ViewGroup派生出来的Layout

    这个相当于重写容器了,当某些效果看起来像是View的组合的时候,就是他上场的时候了,不过这个很复杂,需要合理的使用测量和布局这两个过程,还要兼顾子元素的这两个过程

  • 3.继承特定的View

    比如TextView,就是重写原生的View嘛,比如你想让TextView默认有颜色之类的,有一些小改动,这个就可以用它的,他相对来说比较简单,这个就不需要自己支持包裹内容和pading了

  • 4.继承特定的ViewGroup

    这个和上述一样,只不过是重写容器而已,这个也比较常见,事件分发的时候用的也多

二.自定义View的须知

这节大致的说一下注意事项

  • 1.让View支持warp_content

    这个在之前将测量的时候说过,如果你不特殊处理一下是达不到满意的效果的,这里就不重复了

  • 2.如果有有必要,让你的View支持padding

    这是因为如果你不处理下的话,那么该属性是不会生效的,在ViewGroup也是一样

  • 3.尽量不要在View中使用Handler

    为什么不能用,是因为没有必要,View本身就有一系列的post方法,当然,你想用也没人拦着你,我倒是觉得handler写起来代码简洁很多

  • 4.View中如果有线程或者动画,需要及时停止,参考View#onDetachedFromWindow

    这个问题那就更好理解了,你要是不停止这个线程或者动画,容易导致内存溢出的,所以你要在一个合适的机会销毁这些资源,在Activity有生命周期,而在View中,当View被remove的时候,onDetachedFromWindow会被调用,,和此方法对应的是onAttachedToWindow

  • 5.View带有滑动嵌套时,需要处理好滑动冲突

    滑动冲突之前就BB过,这里就不讲了

三.自定义View的实例

  • 1.继承View重写onDraw方法

我们来实现一个很简单的图形:圆。尽管如此,还是有很多细节需要注意的,实现的过程中需要考虑warp_content和padding,OK,我们先来看代码

public class CircleView extends View {

    //颜色
    private int mColor = Color.RED;
    //画笔样式
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public CircleView(Context context) {
        super(context);
        init();
    }

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

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    //初始化
    private void init() {
        //设置颜色
        mPaint.setColor(mColor);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //View的宽
        int width = getWidth();
        //View的高
        int height = getHeight();
        //圆的半径 = 宽和高比较出的数 / 2
        int radiu = Math.min(width, height) / 2;
        //绘制圆
        canvas.drawCircle(width / 2, height / 2, radiu, mPaint);
    }
}

上面的代码就绘制出了一个圆,运行看下效果

上面的代码很简单,估摸着会点自定义的完全能写出来,我们写这个案例就是要抛砖引玉,不信,我们接着看下去,我们把布局改成这个样子

<?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">

    <com.liuguilin.viewwork.view.CircleView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#000000" />

</LinearLayout>

现在我们来运行一下你就会看到不一样的效果了

接下来我们再调整一下,只给他增加一个

    android:layout_margin="20dp"

这样会是什么效果呢?

这样按理说也是我们预期的效果,对吧,这样的话margin属性是生效的,这是因为margin由父容器所控制的,所以不需要View去动,我们进一步实验,我现在给他继续增加,加上一个padding

    android:padding="20dp"

这里是重头戏了,我们运行后会发现,他没什么反应呀,我们之前说过,如果你直接继承View,在测量的时候需要做点处理的,不然的话,你的warp_content就和match_parent是一样的了。

为了解决这几个问题,我们需要做如下的处理

首先,关于warp_content的问题,我们只需要指定一个warp_content模式宽/高即可,比如设置200px作为默认的宽高

其次,针对padding的问题,我们再绘制的时候考虑进去就好了,修改后的onDraw如下

   @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //padding值
        int left = getPaddingLeft();
        int right = getPaddingRight();
        int top = getPaddingTop();
        int bottom = getPaddingBottom();
        //View的宽
        int width = getWidth() - left - right;
        //View的高
        int height = getHeight() - top - bottom;
        //圆的半径 = 宽和高比较出的数 / 2
        int radiu = Math.min(width, height) / 2;
        //绘制圆
        canvas.drawCircle(left + width / 2, top + height / 2, radiu, mPaint);
    }

这样就解决了,主要的逻辑就是绘制的时候考虑到View四周的空白即可,圆心和半径都会考虑到,现在我们来运行下,就有效果了

最后,为了让View更加容易应用,我们需要提供一些自定义的属性,这些怎么玩呢,我们继续看

第一步实在values目录下面创建自定义属性的xml,比如attrs.xml,也可以其他名字,名字没什么限制,不过为了规范,还是…你懂的,我们就来写一个

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleView">
        <attr name="circle_color" format="color" />
    </declare-styleable>
</resources>

这个很简单吧,我们只定义了一个颜色的属性,这里面有个format是类型,看下就懂了,然后呢

第二步,在View的构造方法里解析到我们这个属性,仔细看代码:

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray type = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
        //没有指定颜色的话默认红色
        mColor = type.getColor(R.styleable.CircleView_circle_color, Color.RED);
        type.recycle();
        init();
    }

这段代码就是加载一个资源文件,拿到里面的属性,如果没有指定的话,默认就是红色了,那我们要使用的话,写一个命名空间,然后…:

<?xml version="1.0" encoding="utf-8"?>
<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="match_parent"
    android:orientation="vertical">

    <com.liuguilin.viewwork.view.CircleView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_margin="20dp"
        android:background="#000000"
        android:padding="20dp"
        app:circle_color="@color/colorPrimary" />

</LinearLayout>

上门的布局唯一要注意的就是这个命名空间了 xmlns:app=”http://schemas.android.com/apk/res-auto”,然后就可以使用app:属性的方式添加了,那我们运行一下,效果也很明显,来看下全部的代码吧:

public class CircleView extends View {

    //颜色
    private int mColor = Color.RED;
    //画笔样式
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public CircleView(Context context) {
        super(context);
        init();
    }

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

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray type = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
        //没有指定颜色的话默认红色
        mColor = type.getColor(R.styleable.CircleView_circle_color, Color.RED);
        type.recycle();
        init();
    }

    //初始化
    private void init() {
        //设置颜色
        mPaint.setColor(mColor);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, 200);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, 200);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //padding值
        int left = getPaddingLeft();
        int right = getPaddingRight();
        int top = getPaddingTop();
        int bottom = getPaddingBottom();
        //View的宽
        int width = getWidth() - left - right;
        //View的高
        int height = getHeight() - top - bottom;
        //圆的半径 = 宽和高比较出的数 / 2
        int radiu = Math.min(width, height) / 2;
        //绘制圆
        canvas.drawCircle(left + width / 2, top + height / 2, radiu, mPaint);
    }
}

这代码清晰脱俗吧,简单好记,就是这样

  • 2.继承ViewGroup派生出来的Layout

这个同等于自定义布局,在之前介绍滑动的时候,有过类似的例子,主席就偷懒的搬上来了,当时分析滑动冲突的两种自定义View:HorizontalScrollViewEx和StickyLayout,其中HorizontalScrollViewEx就是通过继承ViewGroup来实现的,我们再次来分析他的测量和布局过程

这里BB一句,要规范的写View,需要一定的代价,这个,需要去看线性布局去了解了,他们的实现都很复杂,对于HorizontalScrollViewEx来说,就不这么精细了

回顾下HorizontalScrollViewEx的功能,他类似于ViewPager,或者说水平方向的线性布局,它内部的View可以竖直滑动,解决他的冲突的代码就不提了,我们主要还是看下他的测量

   @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measureHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec,heightMeasureSpec);

        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
        if(childCount ==0){
            setMeasuredDimension(0, 0);
        }else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measureHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measureWidth, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpecSize, measureHeight);
        }
    }

这里发现一点小bug,不过不碍事,这里的逻辑呢,可以这样理理,首先有没有子元素,没有就全部都是0,有的话再去判断是否是warp_content,,如果是包裹内容,那这个控件的宽度就是所以的总和了,如果高度采用包裹内容,那这个控件就是第一个子元素的高度,这样说应该好理解一点

再回来说说规范性,上面的代码可以说有两点吧,首先,是不应该直接设置为0,还有就是测量的时候没有考虑到padding和子元素的maggin,好的我们继续来看下onLayout

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildSize = childCount;
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            final int childWidth = childView.getMeasuredWidth();
            mChildWidth = childWidth;
            childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
            childLeft += childWidth;
        }
    }

这个布局的逻辑也没多少代码,我们拿到子元素之后将其放在合适的位置,位置是从左往右的,但是仍然没有考虑padding和子元素的maggin,这个也不是很规范,好的,那我们直接撸完整代码:

public class HorizontalScrollViewEx extends ViewGroup {

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;

    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

    //分别记录上次滑动的坐标
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    private Scroller mScroller;

    private VelocityTracker mVelocityTracker;

    public HorizontalScrollViewEx(Context context) {
        super(context);
        init();
    }

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

    public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        if (mScroller == null) {
            mScroller = new Scroller(getContext());
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int scrollX = getScrollX();
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();

                if (Math.abs(xVelocity) >= 50) {
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measureHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measureHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measureWidth, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpecSize, measureHeight);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            final int childWidth = childView.getMeasuredWidth();
            mChildWidth = childWidth;
            childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
            childLeft += childWidth;
        }
    }

    private void smoothScrollBy(int dx, int dy) {
        mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
        invalidate();
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}

OK,代码慢慢看

四.自定义View的思想

整体来讲,还是有点模糊,不过精髓都已经体现出来了,自定义算是一个综合体系,大多数情况下还是要灵活一点,而且有些可能需要公式计算,所以比较五花八门,那我们这里肯定不能一一去概括了,但是基本功大家应该都已经了解了,我在后续的章节中会挑一些好的View来介绍,这是我,不是书上的,最主要的是基本功然后就是实现思路了,这点我特别推荐去学习优秀的开源库了解一下,好了,我们第四章,View的工作原理到这里就GG了,下章再见!!!

PPT:http://download.csdn.net/detail/qq_26787115/9699388

MakeDown:http://pan.baidu.com/s/1o7Z4Djs 密码:xdgt

Sample:http://download.csdn.net/detail/qq_26787115/9699383

我正在参加2016博客之星,请投我一票吧!

时间: 2024-10-04 18:45:32

Android艺术开发探索第四章——View的工作原理(下)的相关文章

Android艺术开发探索第四章——View的工作原理(上)

这章就比较好玩了,主要介绍一下View的工作原理,还有自定义View的实现方法,在Android中,View是一个很重要的角色,简单来说,View是Android中视觉的呈现,在界面上Android提供了一套完整的GUI库,里面有很多控件,但是有时候往往并不能满足于需求,所以只有自定义View了,我们会简单的说下流程,然后再去实践除了View的三大流程之外,View常见的回调方法也是必须掌握的,比如构造方法,onAttach,onVisibilityChanged,onDetach,另外对于一些

Android开发艺术探索——第四章View的工作原理

Android开发艺术探索--第四章View的工作原理 4.1 (一)初识ViewToot和DecorView 基本概念 ViewRoot对应于ViewRootImpl类,是连接WindowManager和DecorView的纽带,View的三大流程均是通过ViewRoot来完成的.在ActivityThread中,当Activity对象被创建完成后,会将DecorView添加到View中.同时,会创建ViewRootImpl对象,并将ViewTootImpl对象和DecorView建立关联.

Android艺术开发探索第三章————View的事件体系(下)

Android艺术开发探索第三章----View的事件体系(下) 在这里就能学习到很多,主要还是对View的事件分发做一个体系的了解 一.View的事件分发 上篇大致的说了一下View的基础知识和滑动,现在我们再来聊聊一个比较核心的知识点,那就是事件分发了,而且他还是一个难点,我们更加应该掌握,View的滑动冲突一直都是很苦恼的,这里,我们就来一起探索一下 1.点击事件的传递规则 我们分析的点击事件可不是View.OnClickListener,而是我们MotionEvent,即点击事件,关于M

Android艺术开发探索第三章——View的事件体系(上)

Android艺术开发探索第三章----View的事件体系(上) 我们继续来看这本书,因为有点长,所以又分了上下,你在本片中将学习到 View基础知识 什么是View View的位置参数 MotionEvent和TouchSlop VelocityTracker,GestureDetector和Scroller View的滑动 使用scrollTo/scrollBy 使用动画 改变布局参数 各种滑动方式的对比 弹性滑动 使用Scroller 通过动画' 使用延时策略 这章的概念偏自定义View方

第四章:View的工作原理

4.1 ViewRoot和DecorView ViewRoot对应于ViewRootImplement类,它是连接WindowManager和DecorView的纽带,View的三大流程均是通过ViewRoot来完成的. 在ActivityThread中,当Activity对象被创建完毕后,会将DecorView添加到Window中,同时会创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView建立关联 View的绘制流程是从ViewRoot的performTrav

Android艺术开发探索——第二章:IPC机制(下)

Android艺术开发探索--第二章:IPC机制(下) 我们继续来讲IPC机制,在本篇中你将会学习到 ContentProvider Socket Binder连接池 一.使用ContentProvider ContentProvider是Android中提供的专门用来不同应用之间数据共享的方式,从这一点来看,他天生就是适合进程间通信,和Messenger一样,ContentProvider的底层实现同样也是Binder,由此可见,Binder在Android系统中是何等的重要,虽然Conten

【读书笔记】【Android 开发艺术探索】第4章 View 的工作原理

一.基础知识 1.ViewRoot 和 DecorView ViewRoot 对应 ViewRootImpl 类,它是连接 WindowManager 和 DecorView 的纽带,View 的三大流程都是通过 ViewRoot 来完成的.在ActivityThread 中,当 Activity 对象被创建完毕后,会将 DecorView 添加到 Window 中,同时会创建 ViewRoot 对象. DecorView 添加到窗口 Window 的过程. 图片来自https://yq.ali

Android深度探索第四章总结

Android深度探索第四章主要是源代码的下载和编译.要下载Android源代码首先需要配置Android源代码下载环境(1.创建一个用于存放下载脚本文件的目录:2.下载repo脚本文件:3.创建用于存放Android晕代码的目录:4.初始化:5.开始下载Android源代码).介绍了Android源代码目录结构(/bootable这个目录下存放 android 部分启动相关代码,包括 android 的 recovery模式,一般用于进行 OTA 升级,由 C++ 编写,可以看到用于显示的ui

Android群英传笔记——第四章:ListView使用技巧

Android群英传笔记--第四章:ListView使用技巧 近期也是比較迷茫.可是有一点点还是要坚持的,就是学习了.近期离职了,今天也是继续温习第四章ListView,也拖了事实上也挺久的了,listview可谓是老牌大将了,非常多的应用场景都要使用它,他也是我们用得最多的控件之中的一个了,尽管如今出来了一个RecyclerView,可是ListView的地位一时半会儿还是撼动不了的.这就促使我们更加应该去把他掌握了 一.Listview经常使用优化技巧 我们一步步来把ListView学习好