自定义BannerViewPager

虽然网上这种自定义ViewPager实现的banner挺多,但还是自己动手写下会好很多。权当练手吧。
一、效果预览

image.png

二、需求分析

1.重写ViewPager控件实现bannerView
2.需要定时轮播
3.需要无限轮播
4.内存优化
5.ViewPager自身切换速率太快,需要重新设置

三、自定义View套路代码
直接继承自ViewPager

public class BannerViewPager extends ViewPager {
    //内存优化,复用的View.
    private SparseArray<View> mConvertViews;

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

    public BannerViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
        //内存优化,缓存页面数据,下面会讲到
        mConvertViews = new SparseArray<>();
    }
}

四、采用Adapter设计模式
原始的ViewPager就是采用了adapter设计模式,方便用户使用,所以这里我们也仿照源码思想,采用adapter设计模式。
新建一个BannerAdapter类:

public abstract class BannerAdapter {
    //Observable,观察者模式,下面讲到
    private BannerViewPager.AdapterDataObservable mObservable = new BannerViewPager.AdapterDataObservable();
    //返回页面的View
    public abstract View getView(int position);
    //返回BannerView的页面数量,如果是无限轮播,记住要返回真是页面数量。
    public abstract int getCount();
    //ViewPager页面切换特效,方便自定义,返回null采用默认特效
    public Transformer getTransformer() {
        return null;
    }

    //************************** 观察者设计模式 **************************
    public void registerAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.registerObserver(observer);
    }

    public void unregisterAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.unregisterObserver(observer);
    }

    public final void notifyDataSetChanged() {
        mObservable.notifyChanged();
    }
}

BannerViewPager中定义setAdapter方法:

public void setAdapter(BannerAdapter adapter) {
        if (mBannerAdapter != null) {
            mBannerAdapter.unregisterAdapterDataObserver(mObserver);
        }
        this.mBannerAdapter = adapter;
        if (mBannerAdapter == null) {
            throw new IllegalArgumentException("BannerAdapter不能为null");
        }
        mBannerAdapter.registerAdapterDataObserver(mObserver);
        //设置切换动画
        if (mBannerAdapter.getTransformer() != null) {
            mTransformer = mBannerAdapter.getTransformer();
            mTransformer.bind(this);
        }
        mBannerPagerAdapter = new BannerPagerAdapter();
        setAdapter(mBannerPagerAdapter);
        setCurrentItem(mBannerAdapter.getCount());
    }

在这个方法中,我们主要做了这几件事:

1.观察者设计模式的处理。
2.bannerViewPager翻页动画。
3.给ViewPager设置真实的adapter:BannerPagerAdapter,下面就看下这个Adapter内部类。

在BannerViewPager中定义BannerPagerAdapter类:

private class BannerPagerAdapter extends PagerAdapter {
        @Override
        public int getCount() {
            return mBannerAdapter.getCount() == 1 ? 1 : Integer.MAX_VALUE;
        }
        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }
        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            //先转化为实际position
            final int realPosition = position % mBannerAdapter.getCount();
            View bannerItemView = mBannerAdapter.getView(realPosition);
            container.addView(bannerItemView);
            //点击事件
            bannerItemView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mItemClickListener != null) {
                        mItemClickListener.onBannerItemClick(realPosition);
                    }
                }
            });
            return bannerItemView;
        }
        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            container.removeView((View) object);
        }
    }

其中有页面点击事件处理,很简单的回调

    public BannerItemClickListener mItemClickListener;
    public interface BannerItemClickListener {
        void onBannerItemClick(int position);
    }
    /**
     * 条目点击事件
     */
    public void setOnItemClickListener(BannerItemClickListener listener) {
        this.mItemClickListener = listener;
    }

这里说下无限轮播的思路,奥妙都在BannerPagerAdapter 这个类中,
getCount方法返回Integer.MAX_VALUE,代表ViewPager有2^31-1个页面
instantiateItem方法中根据postion返回每个页面的View,postion最大为Integer.MAX_VALUE-1,而实际页面数只有有限的几个,所以需要做下转换final int realPosition = position % mBannerAdapter.getCount();
这样处理后,假设有实际页面ABC,最终的效果就是[position=0,页面A] [position=1,页面B] [position=2,页面C] [position=3,页面A] [position=4,页面B] [position=5,页面C] [position=6,页面A] [position=7,页面B] ...

五、自动轮播
自动轮播我们采用handler,handler.postDelayed(Runnable r, long delayMillis),同时传入的runable的run方法里,再次postDelayed调用自身runnable方法,形成一个递归。

//初始化handler
@SuppressLint("HandlerLeak")
    private void initHandler() {
        mHandler = new Handler();
    }
//定义一个runnable,并在run方法内再次mHandler.postDelayed自身
    private final Runnable task = new Runnable() {
        @Override
        public void run() {
            setCurrentItem(getCurrentItem() + 1);
            mHandler.postDelayed(task, mCutDownTime);
        }
    };

接着定义开始和停止滚动的两个方法,其实就是控制handler的执行和停止任务:

    public void startScroll() {
        if (mBannerAdapter == null) {
            return;
        }

        boolean scrollable = mBannerAdapter.getCount() != 1;
        if (scrollable && mHandler != null) {
            mHandler.postDelayed(task, mCutDownTime);
        }
    }

    public void stopScroll() {
        if (mBannerAdapter == null) {
            return;
        }
        if (mHandler != null) {
            mHandler.removeCallbacks(task);
        }
    }

mCutDownTime就是轮播间隔时间,当然也得要提供给用户api控制这个间隔

   private int mCutDownTime = 5000;
    /**
     * 设置每个条目切换时间
     */
    public void setCutDownTime(int millis) {
        this.mCutDownTime = millis;
    }

到此,自动轮播处理完毕。

六、自定义Scroller改变ViewPager默认切换速率
通过查看源码我们发现,ViewPager的滚动内部采用scroller控制,那么我们自己定义一个scroller将他自带的替换掉即可。

public class BannerScroller extends Scroller {

    private int mDuration = 1000;

    public BannerScroller(Context context) {
        super(context);
    }

    public void setScrollerDuration(int duration) {
        this.mDuration = duration;
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy) {
        super.startScroll(startX, startY, dx, dy,mDuration);
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        super.startScroll(startX, startY, dx, dy, mDuration);
    }
}

mDuration即控制滑动时间的变量。
在BannerViewPager的构造方法中,替换默认mScroller为我们自定义的Scroller,不过遗憾的是ViewPager并没有提供相关api,且mScroller为私有的,我们这里只能采取一些手段了:反射。

    public BannerViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);

        mConvertViews = new SparseArray<>();
        //反射修改ViewPager默认的滚动速率
        try {
            final Field field = ViewPager.class.getDeclaredField("mScroller");
            mBannerScroller = new BannerScroller(context);
            field.setAccessible(true);
            field.set(this, mBannerScroller);
        } catch (Exception e) {
            e.printStackTrace();
        }
        initHandler();
    }

到这里,自定义ViewPager也算勉强可以用了,不过还有不少值得优化的地方。

七、内存优化
回到BannerPagerAdapter这个类中,instantiateItem方法,每次获取页面,直接调用mBannerAdapter.getView(realPosition);,我们的轮播图条目数可以有Integer.MAX_VALUE-1个的,这个方法就要调用无数次,页面就要初始化无数次,而实际的页面只有有限的几个,能不能优化呢,将初始化后的页面缓存起来即可!

//用SparseArray缓存,键为postion,真实的postion;值为页面数据。
private SparseArray<View> mConvertViews;
public View getConvertView(int position) {
        final View convertView = mConvertViews.get(position, null);
        if (convertView == null || convertView.getParent() != null) {
            //健壮性判断,如果缓存的convertView有它的parent,那么返回null。
            return null;
        }
        return convertView;
    }
    //内存优化后的BannerPagerAdapter 
    private class BannerPagerAdapter extends PagerAdapter {

        @Override
        public int getCount() {
            return mBannerAdapter.getCount() == 1 ? 1 : Integer.MAX_VALUE;
        }

        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            //先转化为实际position
            final int realPosition = position % mBannerAdapter.getCount();
            View bannerItemView = getConvertView(realPosition);
            //先从缓存中拿,如果拿不到,那么就重新初始化
            if (bannerItemView == null) {
                bannerItemView = mBannerAdapter.getView(realPosition);
            }
            container.addView(bannerItemView);
            bannerItemView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mItemClickListener != null) {
                        mItemClickListener.onBannerItemClick(realPosition);
                    }
                }
            });
            return bannerItemView;
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            container.removeView((View) object);
            //页面从viewpager移除的同时,缓存到mConvertViews中
            mConvertViews.put(position % mBannerAdapter.getCount(), (View) object);
        }
    }

八、bug修复
在使用中,存在这样一个现象,比如嵌套在RecyclerView中使用,如果该bannerViewPager被滑出屏幕再滑进屏幕,ViewPager的第一次切换没有动画,很生硬。
查看源码:

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        mFirstLayout = true;
    }

这个onAttachedToWindow方法中,将mFirstLayout 置为true,在切换时,会先判断该字段,只有mFirstLayout =false时才启用滑动动画,所以重写该方法即可,同样mFirstLayout 为私有,采取反射

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        try {
            //缓存反射字段
            if (mFirstLayoutField == null) {
                //反射耗时,缓存一下
                mFirstLayoutField = ViewPager.class.getDeclaredField("mFirstLayout");
            }
            mFirstLayoutField.setAccessible(true);
            mFirstLayoutField.set(this,false);
        } catch (Exception e) {
            e.printStackTrace();
        }
        startScroll();
    }
    //离开屏幕,暂停轮播。
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        stopScroll();
    }

九、处理点击暂停
手指在触摸或滑动bannerView的时候不应自动轮播,需要停止。
开始我想重写onTouchEvent或onInterceptTouchEvent,都没能达到效果,于是我就翻看了其他BannerView的处理,发现他们都是在dispatchTouchEvent中处理的。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int action = ev.getAction();
        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL
                || action == MotionEvent.ACTION_OUTSIDE) {
            startScroll();
        } else if (action == MotionEvent.ACTION_DOWN) {
            stopScroll();
        }
        return super.dispatchTouchEvent(ev);
    }

后来我分析了一下事件分发:

对于onTouchEvent,
ACTION_DOWN 不经过此方法,向下传递到它的子view时,子view的onTouchEvent返回true(bannerViewPager的子view设置了点击事件),因此事件不再回传给该BannerViewPager。
ACTION_MOVE,ACTION_UP经过此方法,子view不处理这两种事件,交给该BannerViewPager处理拖拽滑动事件。
对于onInterceptTouchEvent,
ACTION_DOWN 经过此方法
ACTION_MOVE ACTION_UP不经过此方法,因为在move和up的时候,onTouchEvent处理了拖拽滑动事件,此时mFirstTouchTarget被置为null, dispatchTouchEvent处理分发事件时,就不走onInterceptTouchEvent方法了。

十、加入切换动画
我使用了仿魅族商店的切换动画,具体原理看这里
https://www.jianshu.com/p/e67aa68d2766
算法什么的我就无耻的直接拿过来用了
定义接口:

public interface Transformer {
    void bind(BannerViewPager viewPager);
    int getChildDrawingOrder(int childCount, int n);
}

仿魅族切换动画

public class MZTransformImpl implements Transformer{
    //中间放大系数
    private float mScaleMax = 1.0f;
    //两边缩小系数
    private float mScaleMin = 0.9f;
    //重叠部分
    private float mCoverWidth = 80f;

    private ArrayList<Integer> childCenterXAbs = new ArrayList<>();
    private SparseArray<Integer> childIndex = new SparseArray<>();

    private BannerViewPager mBannerViewPager;

    @Override
    public void bind(BannerViewPager viewPager) {
        this.mBannerViewPager = viewPager;
        mBannerViewPager.setPageTransformer(true, new SPageTransformer());//默认调用了 setChildrenDrawingOrderEnabledCompat(true);使得getChildDrawingOrder起作用
        mBannerViewPager.setClipToPadding(false);
        mBannerViewPager.setOverScrollMode(ViewPager.OVER_SCROLL_NEVER);
    }

    public int getChildDrawingOrder(int childCount, int n) {
        if (n == 0 || childIndex.size() != childCount) {
            childCenterXAbs.clear();
            childIndex.clear();
            int viewCenterX = getViewCenterX(mBannerViewPager);
            for (int i = 0; i < childCount; ++i) {
                int indexAbs = Math.abs(viewCenterX - getViewCenterX(mBannerViewPager.getChildAt(i)));
                //两个距离相同,后来的那个做自增,从而保持abs不同
                if (childIndex.get(indexAbs) != null) {
                    ++indexAbs;
                }
                childCenterXAbs.add(indexAbs);
                childIndex.append(indexAbs, i);
            }
            Collections.sort(childCenterXAbs);//1,0,2  0,1,2
        }
        //那个item距离中心点远一些,就先draw它。(最近的就是中间放大的item,最后draw)
        return childIndex.get(childCenterXAbs.get(childCount - 1 - n));
    }

    private int getViewCenterX(View view) {
        int[] array = new int[2];
        view.getLocationOnScreen(array);
        return array[0] + view.getWidth() / 2;
    }

    class SPageTransformer implements ViewPager.PageTransformer {
        private float reduceX = 0.0f;
        private float itemWidth = 0;
        private float offsetPosition = 0f;

        @Override
        public void transformPage(View view, float position) {
            if (offsetPosition == 0f) {
                float paddingLeft = mBannerViewPager.getPaddingLeft();
                float paddingRight = mBannerViewPager.getPaddingRight();
                float width = mBannerViewPager.getMeasuredWidth();
                offsetPosition = paddingLeft / (width - paddingLeft - paddingRight);
            }
            float currentPos = position - offsetPosition;
            if (itemWidth == 0) {
                itemWidth = view.getWidth();
                //由于左右边的缩小而减小的x的大小的一半
                reduceX = (2.0f - mScaleMax - mScaleMin) * itemWidth / 2.0f;
            }
            if (currentPos <= -1.0f) {
                view.setTranslationX(reduceX + mCoverWidth);
                view.setScaleX(mScaleMin);
                view.setScaleY(mScaleMin);
            } else if (currentPos <= 1.0) {
                float scale = (mScaleMax - mScaleMin) * Math.abs(1.0f - Math.abs(currentPos));
                float translationX = currentPos * -reduceX;
                if (currentPos <= -0.5) {//两个view中间的临界,这时两个view在同一层,左侧View需要往X轴正方向移动覆盖的值()
                    view.setTranslationX(translationX + mCoverWidth * Math.abs(Math.abs(currentPos) - 0.5f) / 0.5f);
                } else if (currentPos <= 0.0f) {
                    view.setTranslationX(translationX);
                } else if (currentPos >= 0.5) {//两个view中间的临界,这时两个view在同一层
                    view.setTranslationX(translationX - mCoverWidth * Math.abs(Math.abs(currentPos) - 0.5f) / 0.5f);
                } else {
                    view.setTranslationX(translationX);
                }
                view.setScaleX(scale + mScaleMin);
                view.setScaleY(scale + mScaleMin);
            } else {
                view.setScaleX(mScaleMin);
                view.setScaleY(mScaleMin);
                view.setTranslationX(-reduceX - mCoverWidth);
            }
        }
    }
}

回到BannerViewPager类中,setAdapter方法内设置切换动画

    public void setAdapter(BannerAdapter adapter) {
        ```
        //设置切换动画
        if (mBannerAdapter.getTransformer() != null) {
            mTransformer = mBannerAdapter.getTransformer();
            mTransformer.bind(this);
        }
        ```
    }

重写getChildDrawingOrder控制绘制顺序

    /**
     * 控制子View的绘制顺序
     */
    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        if (mTransformer != null) {
            return mTransformer.getChildDrawingOrder(childCount, i);
        }
        return super.getChildDrawingOrder(childCount, i);
    }

注意,如果要采用仿魅族切换样式,在BannerViewpager需要设置paddingLeft和paddingRight,在xml中定义即可。

十一、最后讲下观察者设计模式
源码中Adaper里很多到用到了观察者模式,Adapter中数据变化,调用notifyDataSetChanged,那么对应的View也会变化,这都是有一定模板套路的,也可参考上一篇https://www.jianshu.com/p/d75edebb6c8f的讲解。
这里就总结下套路:
首先要弄明白,谁是观察者(observer),谁是被观察者(observable)
记住一点:被观察者发生变化,观察者就会响应变化。
举个例子,手机是被观察者吧,人就是观察者,手机来微信了,就是被观察者发生变化,我们会很自然的去打开微信查看,这就是观察者响应变化。
类比一下,adapter就是被观察者,BannerViewpager就是观察者,因为adapter数据变化,BannerViewpager得要响应改变ui。
在代码世界里,这种响应是如何做到的呢,为什么observer会响应observable的变化,说白了就是observable中持有了observer的引用,observable发生变化后,再主动调用observer相关变化的方法即可。
开始上代码,BannerViewPager中:

    private final BannerDataObserver mObserver = new BannerDataObserver();
    static class AdapterDataObservable extends Observable<AdapterDataObserver> {
        public void notifyChanged() {
            // since onChanged() is implemented by the app, it could do anything, including
            // removing itself from {@link mObservers} - and that could cause problems if
            // an iterator is used on the ArrayList {@link mObservers}.
            // to avoid such problems, just march thru the list in the reverse order.
            for (int i = mObservers.size() - 1; i >= 0; i--) {
                mObservers.get(i).onChanged();
            }
        }
    }

    public static abstract class AdapterDataObserver {
        public void onChanged() {

        }
    }

    private class BannerDataObserver extends AdapterDataObserver{
        @Override
        public void onChanged() {
            mBannerPagerAdapter.notifyDataSetChanged();
        }
    }

BannerAdapter中:

    private BannerViewPager.AdapterDataObservable mObservable = new BannerViewPager.AdapterDataObservable();
    //************************** 观察者设计模式 **************************
    public void registerAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.registerObserver(observer);
    }

    public void unregisterAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.unregisterObserver(observer);
    }

    public final void notifyDataSetChanged() {
        mObservable.notifyChanged();
    }

在setAdapter(BannerAdapter adapter)中,将观察者注册给被观察者就完事了,实质就是依赖注入。

public void setAdapter(BannerAdapter adapter) {
        if (mBannerAdapter != null) {
            mBannerAdapter.unregisterAdapterDataObserver(mObserver);
        }
        this.mBannerAdapter = adapter;
        if (mBannerAdapter == null) {
            throw new IllegalArgumentException("BannerAdapter不能为null");
        }
        mBannerAdapter.registerAdapterDataObserver(mObserver);
       ...
    }

这样,调用BannerAdapter的notifyDataSetChanged方法就是调用 mObservable.notifyChanged();->AdapterDataObserver.onChanged()->mBannerPagerAdapter.notifyDataSetChanged();最终ui发生改变。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 1.概述 这其实是我第一篇想写的博客,可能是因为我遇到了太多的坑,那个时候刚入行下了很多Demo发现怎么也改不动,...
    红橙Darren阅读 8,428评论 21 19
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,391评论 25 707
  • 一、实验环境:centOS 二、步骤 1、使用yum安装ffmpeg的依赖文件 此步骤一般不会有什么问题,就算以前...
    RookieSky阅读 3,535评论 0 2
  • 明天你有空吗? 小园是我初中同学了,是个喜欢披着头发的女孩子,我对她的印象也仅止于此了,高中曾经见过几次面,大学之...
    折舍阅读 251评论 0 0
  • 寓言大意 讲了一个走出迷茫,恐惧,舒适区的寓言,在一个迷宫中,有两只小鼠嗅嗅和匆匆和两个小矮人哼哼和唧唧。他们日出...
    小逸电影说阅读 539评论 0 0