技术联盟

【安卓】从源码的角度深入分析Scroller

这篇文章我将从源码的角度深入分析Scroller类。在阅读的时候,建议大家打开源码对照着看,否则可能看的云里雾里。

一.Scroller的用途

熟悉android的同学必然对Scroller不陌生,Scroller是一个弹性滑动对象,可以制作很多酷炫的滑动效果,Lancher中的滑屏效果就有使用到Scroller。
我们知道,View类中的scrollTo和scrollBy方法提供了滑动操作,但是这种滑动操作是瞬间完成的,就是说你为scrollTo提供终点坐标,该方法只要一调用,我们就会发现已经滚动到目的地了,这种方式很显然用户体验是不好的,因而android工程师为我们封装了Scroller类,这个类可以为View带来缓慢移动的效果
具体使用方式通常是通过在你自定义的View中调用Scroller的startScroll并刷新视图,另外需重写computeScroll方法(刷新视图过程中会调用此方法),在该方法中调用Scroller的computeScrollOffset计算应该滚动到的位置,然后使用scrollTo滚动到该位置,再调用invalidate刷新,就可以实现滚动效果了(不了解的同学建议先去熟悉Scroller用法)。
Scroller使用示例(代码片段):

  1. public class MyView extends LinearLayout  
  2. {  
  3.     private Scroller mScroller;  
  4.     … …   
  5.     private void smoothScrollTo(int destX,int destY)  
  6.     {  
  7.         int scrollX = getScrollX();  
  8.         int scrollY = getScrollY();  
  9.   
  10.         int deltaX = destX-scrollX;  
  11.         int deltaY = destY-scrollY;  
  12.         mScroller.startScroll(scrollX,scrollY,deltaX, deltaY, 1000);  
  13.         invalidate();  
  14.     }  
  15.     @Override  
  16.     public void computeScroll()  
  17.     {  
  18.         if(mScroller != null)  
  19.         {  
  20.             if(mScroller.computeScrollOffset())  
  21.             {  
  22.                 scrollTo(mScroller.getCurrX(),mScroller.getCurrY());  
  23.                 Log.d(TAG,“scrollX=”+getScrollX()+“,scrollY=”+getScrollY());  
  24.                 postInvalidate();  
  25.             }  
  26.         }  
  27.     }  
  28.       
  29.       
  30. }  
二.ScrollTo、ScrollBy、getScrollX、getScrollY方法分析
 
在分析Scroller源码之前,我们必须先了解ScrollTo,ScrollBy,getScrollX,getScrollY这几个方法的作用。这四个方法都是View类提供的,这点先明确。
我们先分析getScrollX和getScrollY方法,查看源码实现:
  1. /** 
  2.      * Return the scrolled left position of this view. This is the left edge of 
  3.      * the displayed part of your view. You do not need to draw any pixels 
  4.      * farther left, since those are outside of the frame of your view on 
  5.      * screen. 
  6.      * 
  7.      * @return The left edge of the displayed part of your view, in pixels. 
  8.      */  
  9.     public final int getScrollX() {  
  10.         return mScrollX;  
  11.     }  
  12.     /** 
  13.      * Return the scrolled top position of this view. This is the top edge of 
  14.      * the displayed part of your view. You do not need to draw any pixels above 
  15.      * it, since those are outside of the frame of your view on screen. 
  16.      * 
  17.      * @return The top edge of the displayed part of your view, in pixels. 
  18.      */  
  19.     public final int getScrollY() {  
  20.         return mScrollY;  
  21.     }  
getScrollX和getScrollY方法返回的是mScrollX和mScrollY变量。这两个变量是什么呢?
这里我直接告诉大家,mScrollX和mScrollY指的是视图内容相对于视图原始起始坐标的偏移量,mScrollX和mScrollY的默认值为0,因为默认是没有偏移的。另外需注意偏移量的正负问题,因为是相对视图起始坐标的,所以如果你是向右偏移那么mScrollX应该是负数,而向左偏移mScrollX为正数。再举个例子,比如你定义了一个ImageView,其左上角的坐标为(100,80),此时mScrollX和mScrollY值都为0(没有偏移),现在你要把该ImageView移到(120,100)处,也就是右下方,那么你的mScrollX应该是100-120=-20,mScrollY应该是80-100=-20,这下你该明白了吧。
明白了这个问题,scrollBy和scrollTo也就不在话下了,我们查看源码:
  1. public void scrollTo(int x, int y) {  
  2.        if (mScrollX != x || mScrollY != y) {  
  3.            int oldX = mScrollX;  
  4.            int oldY = mScrollY;  
  5.            mScrollX = x;  
  6.            mScrollY = y;  
  7.            invalidateParentCaches();  
  8.            onScrollChanged(mScrollX, mScrollY, oldX, oldY);  
  9.            if (!awakenScrollBars()) {  
  10.                postInvalidateOnAnimation();  
  11.            }  
  12.        }  
  13.    }  
  14.   
  15.    public void scrollBy(int x, int y) {  
  16.        scrollTo(mScrollX + x, mScrollY + y);  
  17.    }  
先看scrollTo方法,它首先判断x,y方向的偏移量(即mScrollX,mScrollY)是否和传进来的偏移量(即x,y)相同,如果相同那么直接返回,否则我们将更新mScrollX和mScrollY,并且通知界面发生改变请求重绘。通过这种方式就可以实现滚动效果了,只是是瞬间移动的。再看这个scrollBy,直接调用的是scrollTo,根据参数很容易发现,这个方法的作用是在当前偏移基础上,再继续偏移(x,y)单位。需要注意的是这两个方法的参数是偏移量而不是实际位置哦!
这里还有一点需要注意,那就是你调用一个View的scrollTo方法进行滚动时,滚动的并不是该View本身,而是该View的内容。比如你要对一个Button进行滚动的话,应该在Button外面包一个ViewGroup,然后调用ViewGroup的scrollTo方法。这一点也很重要,大家需要注意下!
三.Scroller源码分析
 
Scroller的精华在于computeScrollOffset和startScroll方法,所以我们重点分析这两个方法。
分析之前,先看这个类中定义的一些变量:
  1. private int mMode;//模式,有SCROLL_MODE和FLING_MODE  
  2.   private int mStartX;//起始x方向偏移  
  3.   private int mStartY;//起始y方向偏移  
  4.   private int mFinalX;//终点x方向偏移  
  5.   private int mFinalY;//终点y方向偏移  
  6.   private int mCurrX;//当前x方向偏移  
  7.   private int mCurrY;//当前y方向偏移  
  8.   private long mStartTime;//起始时间  
  9.   private int mDuration;//滚动持续时间  
  10.   private float mDurationReciprocal;//持续时间的倒数  
  11.   private float mDeltaX;//x方向应该滚动的距离,mDeltaX=mFinalX-mStartX  
  12.   private float mDeltaY;//y方向应该滚动的距离,mDeltaY=mFinalY-mStartY  
  13.   private boolean mFinished;//是否结束  

对这些变量有个大致印象之后,我们就开始看startScroll源码了:

  1. public void startScroll(int startX, int startY, int dx, int dy) {  
  2.         startScroll(startX, startY, dx, dy, DEFAULT_DURATION);  
  3.     }  
  4.   
  5.     public void startScroll(int startX, int startY, int dx, int dy, int duration) {  
  6.         mMode = SCROLL_MODE;  
  7.         mFinished = false;  
  8.         mDuration = duration;  
  9.         mStartTime = AnimationUtils.currentAnimationTimeMillis();  
  10.         mStartX = startX;  
  11.         mStartY = startY;  
  12.         mFinalX = startX + dx;  
  13.         mFinalY = startY + dy;  
  14.         mDeltaX = dx;  
  15.         mDeltaY = dy;  
  16.         mDurationReciprocal = 1.0f / (float) mDuration;  
  17.     }  
这个所谓的startScroll方法里面全部是对上面那些变量赋值的,比如将当前模式置为SCROLL_MODE,设置持续时间,起点终点偏移,起始时间等等。这个方法似乎并没有触发start scroll,恩,的确是这样的。那么到底怎么触发scroll呢?我们将这个问题留到下一个部分分析。
为了让大家理解这些变量的作用,我画了一张图。

解释下,图中我们假设从A点滚动到B点,那么,如果知道mStartX,mStartY(由startScroll的startX和startY参数得到)和mDeltaX,mDeltaY(由startScroll的dx和dy参数得到)。那么,mFinalX和mFinalY就很容易得到了。此外在加上duration持续时间,那么我们就可以根据(当前时间-mStartTime)占duration的比例来算出当前位置,也即mCurrX和mCurrY。我不会告诉你,这就是computeScrollOffset的作用!通过上面分析我们发现,startScroll方法其实相当于一个预处理,为computeScrollOffset提供数据。
下面我们查看computeScrollOffset源码:
  1. public boolean computeScrollOffset() {  
  2.         if (mFinished) {  
  3.             return false;  
  4.         }  
  5.        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() – mStartTime);  
  6.       
  7.         if (timePassed < mDuration) {  
  8.             switch (mMode) {  
  9.             case SCROLL_MODE:  
  10.        final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);  
  11.                 mCurrX = mStartX + Math.round(x * mDeltaX);  
  12.                 mCurrY = mStartY + Math.round(x * mDeltaY);  
  13.                 break;  
  14.             case FLING_MODE:  
  15.                  … …  
  16.                 break;  
  17.             }  
  18.         }  
  19.         else {  
  20.             mCurrX = mFinalX;  
  21.             mCurrY = mFinalY;  
  22.             mFinished = true;  
  23.         }  
  24.         return true;  
  25.     }  
看,是不是很好理解了。首先判断是否结束(mFinished==true?),如果没有结束,那么程序继续向下跑,计算timePassed,即当前时间减去开始时间,如果小于mDuration,那么就可以根据比例计算出当前位置,当然了,这里使用了Interpolator来控制动画的效果,加速或者减速等等。如果已经超过了mDuration了,那么滚动应该停止了,所以将mFinished置为ture,下次调用computeScrollOffset就会返回false了。
 
到这里,我们把Scroller中的重要部分都分析完了,但是我们发现这两个方法都没有涉及具体滚动。那么滚动到底是如何触发的呢?下面我们将对整个流程进行源码分析。
四.滚动流程分析
 
在第一部分中,我给出了一个Scroller的使用示例,在smoothScrollTo方法中我们调用了Scroller的startScroll,然后调用了invalidate方法刷新视图,这个滚动效果就是由invalidate触发的!我们知道,调用了invalidate方法将会引起整个view系统的重绘,所以我们得从View的绘制说起。有经验的同学都应该知道,View的绘制包括三个主要过程,分别是measure,layout和draw。下面我们看View类的draw方法源码:
  1. public void draw(Canvas canvas) {  
  2.          … …  
  3.        /* 
  4.         * Draw traversal performs several drawing steps which must be executed 
  5.         * in the appropriate order: 
  6.         * 
  7.         *      1. Draw the background 
  8.         *      2. If necessary, save the canvas’ layers to prepare for fading 
  9.         *      3. Draw view’s content 
  10.         *      4. Draw children 
  11.         *      5. If necessary, draw the fading edges and restore layers 
  12.         *      6. Draw decorations (scrollbars for instance) 
  13.         */  
  14.        // Step 1, draw the background, if needed  
  15.        int saveCount;  
  16.        if (!dirtyOpaque) {  
  17.            drawBackground(canvas);  
  18.        }  
  19.          … …  
  20.        // Step 2, save the canvas’ layers  
  21.          … …  
  22.        // Step 3, draw the content  
  23.        if (!dirtyOpaque) onDraw(canvas);  
  24.        // Step 4, draw the children  
  25.        dispatchDraw(canvas);  
  26.        // Step 5, draw the fade effect and restore layers  
  27.          … …  
  28.    }  

draw方法将绘制分成了六步,其中第三步是调用onDraw去绘制内容,第四步是调用dispatchDraw去绘制子视图。我们看下dispatchDraw方法:

  1. /** 
  2.     * Called by draw to draw the child views. This may be overridden 
  3.     * by derived classes to gain control just before its children are drawn 
  4.     * (but after its own view has been drawn). 
  5.     * @param canvas the canvas on which to draw the view 
  6.     */  
  7.    protected void dispatchDraw(Canvas canvas) {  
  8.    }  

恩,没错,是个空方法,因为只有ViewGroup才有子视图,所以我们来到ViewGroup中,找到dispatchDraw:

  1. @Override  
  2.     protected void dispatchDraw(Canvas canvas) {  
  3.         boolean usingRenderNodeProperties = canvas.isRecordingFor(mRenderNode);  
  4.         final int childrenCount = mChildrenCount;  
  5.         final View[] children = mChildren;  
  6.          … …  
  7.         for (int i = 0; i < childrenCount; i++) {  
  8.             int childIndex = customOrder ? getChildDrawingOrder(childrenCount, i) : i;  
  9.             final View child = (preorderedList == null)  
  10.                     ? children[childIndex] : preorderedList.get(childIndex);  
  11.     if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {  
  12.                 more |= drawChild(canvas, child, drawingTime);  
  13.             }  
  14.         }    
  15.     }  

代码也是极其长的,这里截取了一部分。我们看到这个方法中调用了drawChild方法,从名字上就可以看出这个方法是用来绘制子视图的,找到其实现:

  1. protected boolean drawChild(Canvas canvas, View child, long drawingTime) {  
  2.         return child.draw(canvas, this, drawingTime);  
  3.     }  

这里调用了view的draw方法,当然,这个draw方法跟上面那个draw不一样,因为它有三个参数!那还等什么,我们回到View的代码中找到这个含有三个参数的draw方法:

  1. boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {  
  2.        boolean more = false;  
  3.        final boolean childHasIdentityMatrix = hasIdentityMatrix();  
  4.        final int flags = parent.mGroupFlags;  
  5.        … …  
  6.        int sx = 0;  
  7.        int sy = 0;  
  8.        if (!hasDisplayList) {  
  9.            computeScroll();//终于找到了computeScroll  
  10.            sx = mScrollX;  
  11.            sy = mScrollY;  
  12.        }  
  13.         … …  
  14.        return more;  
  15.    }  
在其中,我们找到了computeScroll方法!当然View中的computeScroll方法是空的,需要我们根据场景自己复写。
比如,我发现,TextView中就有复写这个方法:
  1. @Override  
  2.    public void computeScroll() {  
  3.        if (mScroller != null) {  
  4.            if (mScroller.computeScrollOffset()) {  
  5.                mScrollX = mScroller.getCurrX();  
  6.                mScrollY = mScroller.getCurrY();  
  7.                invalidateParentCaches();  
  8.                postInvalidate();  // So we draw again  
  9.            }  
  10.        }  
  11.    }  
好了,折腾了这么久,下面我们再回顾下这个过程:我们在自定义view中调用了startScroll方法为滚动设置了一些基本数据,然后通过invalidate由上而下刷新view视图,首先是根视图(通常是ViewGroup)的draw方法被调用,然后调用onDraw绘制视图内容,接着dispatchDraw方法被调用去绘制子视图,dispatchDraw方法会对每个子视图调用drawChild方法,而drawChild方法会调用该子View的draw方法(三个参数),在draw方法中会调用computeScroll方法进行滚动,而computeScroll方法是被复写的,视场景而定,通常是根据computeScrollOffset来判断是否需要滑动,如果需要的话,接着调用postInvalidate再由上至下重新绘制,如此一来便实现了平滑滚动的效果!
ok,到这里总算是把Scroller讲清楚了!
五.实例分析
 
为了便于理解,我这里写了一个小例子,通过打印日志的形式让大家理解上面所讲的内容。
首先是几个自定义的view:
MyLinearLayout:
  1. package com.example.scrollerdemo;  
  2. import android.content.Context;  
  3. import android.graphics.Canvas;  
  4. import android.util.AttributeSet;  
  5. import android.util.Log;  
  6. import android.widget.LinearLayout;  
  7. public class MyLinearLayout extends LinearLayout  
  8. {  
  9.     private static final String TAG = “TEST”;  
  10.     public MyLinearLayout(Context context)  
  11.     {  
  12.         super(context);  
  13.     }  
  14.     public MyLinearLayout(Context context, AttributeSet attrs)  
  15.     {  
  16.         super(context, attrs);  
  17.     }  
  18.     @Override  
  19.     public void draw(Canvas canvas)  
  20.     {  
  21.         Log.i(TAG, “MyLinearLayout—>draw”);  
  22.         super.draw(canvas);  
  23.     }  
  24.     @Override  
  25.     protected void onDraw(Canvas canvas)  
  26.     {  
  27.         Log.i(TAG, “MyLinearLayout—>onDraw”);  
  28.         super.onDraw(canvas);  
  29.     }  
  30.     @Override  
  31.     public void computeScroll()  
  32.     {  
  33.         Log.i(TAG, “MyLinearLayout—>computeScroll”);  
  34.         super.computeScroll();  
  35.     }  
  36. }  

MyView(实现了平滑滚动效果):

  1. package com.example.scrollerdemo;  
  2. import android.content.Context;  
  3. import android.graphics.Canvas;  
  4. import android.util.AttributeSet;  
  5. import android.util.Log;  
  6. import android.view.View;  
  7. import android.widget.LinearLayout;  
  8. import android.widget.Scroller;  
  9. /** 
  10.  * @author Rowandjj 
  11.  *  
  12.  */  
  13. public class MyView extends LinearLayout  
  14. {  
  15.     private static final String TAG = “TEST”;  
  16.     private Scroller mScroller;  
  17.       
  18.     public MyView(Context context)  
  19.     {  
  20.         super(context);  
  21.         init();  
  22.     }  
  23.     public MyView(Context context, AttributeSet attrs)  
  24.     {  
  25.         super(context, attrs);  
  26.         init();  
  27.     }  
  28.     private void init()  
  29.     {  
  30.         mScroller = new Scroller(getContext());   
  31.     }  
  32.     public void testSmoothScroll()  
  33.     {  
  34.         //向右下方平滑滚动(向右偏移100,向下偏移100)  
  35.         smoothScrollTo(-100,-100);  
  36.     }  
  37.       
  38.     private void smoothScrollTo(int destX,int destY)  
  39.     {  
  40.         int scrollX = getScrollX();  
  41.         int scrollY = getScrollY();  
  42.         Log.d(TAG,“scrollX=”+scrollX+“,scrollY=”+scrollY);  
  43.         int deltaX = destX-scrollX;  
  44.         int deltaY = destY-scrollY;  
  45.         mScroller.startScroll(scrollX,scrollY,deltaX, deltaY, 1000);  
  46.         invalidate();  
  47.     }  
  48.     @Override  
  49.     public void draw(Canvas canvas)  
  50.     {  
  51.         Log.i(TAG,“MyView——>draw run”);  
  52.         super.draw(canvas);  
  53.     }  
  54.     @Override  
  55.     protected void onDraw(Canvas canvas)  
  56.     {  
  57.         Log.i(TAG,“MyView——>onDraw run”);  
  58.         super.onDraw(canvas);  
  59.     }  
  60.       
  61.     @Override  
  62.     protected void dispatchDraw(Canvas canvas)  
  63.     {  
  64.         Log.i(TAG,“MyView——>dispatchDraw run”);  
  65.         super.dispatchDraw(canvas);  
  66.     }  
  67.       
  68.     @Override  
  69.     protected boolean drawChild(Canvas canvas, View child, long drawingTime)  
  70.     {  
  71.         Log.i(TAG,“MyView——>drawChild run”);  
  72.         return super.drawChild(canvas, child, drawingTime);  
  73.     }  
  74.       
  75.       
  76.     @Override  
  77.     public void computeScroll()  
  78.     {  
  79.         Log.i(TAG,“MyView——>computeScroll run”);  
  80.         if(mScroller != null)  
  81.         {  
  82.             if(mScroller.computeScrollOffset())  
  83.             {  
  84.                 scrollTo(mScroller.getCurrX(),mScroller.getCurrY());  
  85.                 Log.d(TAG,“scrollX=”+getScrollX()+“,scrollY=”+getScrollY());  
  86.                 postInvalidate();  
  87.             }  
  88.         }  
  89.     }  
  90. }  

MyImageView:

  1. package com.example.scrollerdemo;  
  2. import android.content.Context;  
  3. import android.graphics.Canvas;  
  4. import android.util.AttributeSet;  
  5. import android.util.Log;  
  6. import android.widget.ImageView;  
  7. public class MyImageView extends ImageView  
  8. {  
  9.     private static final String TAG = “TEST”;  
  10.     public MyImageView(Context context)  
  11.     {  
  12.         super(context);  
  13.     }  
  14.     public MyImageView(Context context, AttributeSet attrs)  
  15.     {  
  16.         super(context, attrs);  
  17.     }     
  18.     @Override  
  19.     protected void onDraw(Canvas canvas)  
  20.     {  
  21.         Log.i(TAG,“myImageView—->onDraw run”);  
  22.         super.onDraw(canvas);  
  23.     }  
  24.       
  25.     @Override  
  26.     public void computeScroll()  
  27.     {  
  28.         Log.i(TAG,“myImageView—->computeScroll run”);  
  29.         super.computeScroll();  
  30.     }  
  31. }  
我们根据上面三个自定义view做一个布局,外层是MyLinearLayout,中间是MyView,最里面是一个MyImageView.
activity_main.xml:
  1. <LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android”  
  2.     android:layout_width=“match_parent”  
  3.     android:layout_height=“match_parent”  
  4.     android:orientation=“vertical” >  
  5.     <com.example.scrollerdemo.MyLinearLayout  
  6.         xmlns:tools=“http://schemas.android.com/tools”  
  7.         android:layout_width=“match_parent”  
  8.         android:layout_height=“match_parent”  
  9.         android:background=“#ffaaff”  
  10.         tools:context=“com.example.scrollerdemo.MainActivity” >  
  11.         <com.example.scrollerdemo.MyView  
  12.             android:id=“@+id/mv”  
  13.             android:layout_width=“200dp”  
  14.             android:layout_height=“200dp”  
  15.             android:layout_marginLeft=“50dp”  
  16.             android:layout_marginTop=“100dp”  
  17.             android:background=“@android:color/darker_gray”  
  18.             android:orientation=“vertical” >  
  19.             <com.example.scrollerdemo.MyImageView  
  20.                 android:layout_width=“wrap_content”  
  21.                 android:layout_height=“wrap_content”  
  22.                 android:src=“@drawable/ic_launcher” />  
  23.         </com.example.scrollerdemo.MyView>  
  24.     </com.example.scrollerdemo.MyLinearLayout>  
  25. </LinearLayout>  

下面是MainActivity的代码:

  1. package com.example.scrollerdemo;  
  2. import android.app.Activity;  
  3. import android.os.Bundle;  
  4. import android.view.View;  
  5. import android.view.View.OnClickListener;  
  6. public class MainActivity extends Activity implements OnClickListener  
  7. {  
  8.     private MyView mv = null;  
  9.       
  10.     @Override  
  11.     protected void onCreate(Bundle savedInstanceState)  
  12.     {  
  13.         super.onCreate(savedInstanceState);  
  14.         setContentView(R.layout.activity_main);  
  15.           
  16.         mv = (MyView) findViewById(R.id.mv);  
  17.         mv.setOnClickListener(this);  
  18.     }  
  19.     @Override  
  20.     public void onClick(View v)  
  21.     {  
  22.         switch (v.getId())  
  23.         {  
  24.         case R.id.mv://点击时触发滚动效果  
  25.             mv.testSmoothScroll();  
  26.             break;  
  27.         default:  
  28.             break;  
  29.         }  
  30.     }  
  31. }  

代码很简单,不必过多介绍,下面看显示效果:


打开logcat查看日志:
这是第一次绘制,跟上面分析过程一致,首先是MyLinearLayout的draw和onDraw方法被调用,然后通过dispatchDraw绘制孩子,所以MyView的computeScroll、draw、onDraw、dispatchDraw等方法被调用,接着MyImageView的computeScroll和onDraw方法会被调用,如此从上到下进行一次绘制。
下面我们点击安卓机器人图标,会调用testSmoothScroll方法,机器人会从左上角平滑滚动到右下角,此时我们查看logcat日志:

中间日志太多,省略了,下面这段是最后打印的

通过日志可以看出,上面分析是正确的,通过在MyView的computeScroll方法中调用postInvalidate,导致不断重绘,直到偏移量达到startScroll事先指定的(-100,-100)。
至此,本篇文章结束,希望对大家有所帮助!
转自:http://blog.csdn.net/chdjj/article/details/41678897

Share this:

码字很辛苦,转载请注明来自技术联盟《【安卓】从源码的角度深入分析Scroller》

评论