标签:android自定义view android翻页效果 翻页
尊重原创转载请注明:From AigeStudio(http://blog.csdn.net/aigestudio)Power by Aige 侵权必究!
炮兵镇楼
在《自定义控件其实很简单》系列的前半部分中我们用了整整六节近两万字两百多张配图讲了Android图形的绘制,虽然篇幅很巨大但仍然只是图形绘制的冰山一角,旨在领大家入门,至于修行成果就看各位的了……那么这个些列主要是通过前面学习到的一些方法来尝试完成一个翻页的效果。
对于我个人来说,我是不太建议大家在没自己去尝试前看本文的,因为你看了别人的思路就会有个惯性思维朝着别人的思路去靠,实际上如果你自己尝试去分析去做的话不见得做不出来,甚至可能方法更简捷效率更高。分析这个效果的时候我还找妹子要了个新的笔记本翻了两三个小时,后来又在PS里模拟了一下然后通过绘图计算最终才有了一个简短的思路。
翻页虽然几乎每个人都试过,除非你没看过书……没碰过本子……甚至没碰过纸……一个看似简单的动作其实隐藏了巨量的信息,如果我们要把翻页的过程模拟得真实,涉及面会相当广,这里我假定我们所做的翻页效果类似于课本翻页,从右下角掀开纸张翻至左边,而我们的控件就假定为课本的右边部分而左边部分呢在我们控件左侧外看不到,那么控件的左端即可看成我们课本的装订线,这样的假定我们可以简化问题,如之前我所说,控件必定都是不完美的,如果存在完美的控件就不需要我们Custom了~那么这个控件实现的是一个怎么样的效果呢?效果很简单,往控件中传入多张图片,我们以翻页的形式依次展示这些图片。整个翻页的原理都是想通的,虽然这个效果我模拟得很简单,但是你完全可以照我的思路定义ViewGroup或者ValueAnimation等等……
为了进一步简化问题,我们将整个翻页效果的实现分为四部分,第一部分为翻页的尝试实现,第二部分呢则是折线翻页的实现,第三部分我们尝试引入曲线翻页,第四部分则为一些后续效果的处理以及效率的优化,如果有必要还会增加一些章节继续完善效果。这样我们的流程就很清晰了,这一节我们首先来尝试实现翻页,如果大家能哪个本子或者书来跟着我的思路走就更好了,本来是打算拍些Photo作为展示的,但是我发现现实的翻页不好控制,算了,一些理论上的东西只好靠各位自行动手+脑补了。
首先,我们要进行一些约定,上面说到我们模拟的翻页效果是以控件左侧为纸张装订线使其能够实现从右下角翻开的效果,这是约定之一,其次,我们规定忽略掀起部分相对于本页的弧线使之始终与本页平行,再次规定视点与光源方向均位于纸张正上方并且光源为单光源聚光灯光锥刚好罩住纸张,这些约定不理解不要紧,在涉及到的时候我会具体说明。
我们知道View并非像ViewGroup那样是个容器可以容纳其他的控件,那么要将一些图片依次“放入”View中并依次呈现该如何办呢?通过前面对Canvas的学习,我们可以尝试使用Canvas的“图层”功能来实现这一效果,将Bitmap依次至于不同的“图层”再通过Canvas的clipXXX方法裁剪呈现不同Bitmap,理论上是可行的,那实际如何呢?我们来试试,新建一个View的子类PageCurlView:
public class PageTurnView extends View { private List<Bitmap> mBitmaps;// 位图数据列表 public PageTurnView(Context context, AttributeSet attrs) { super(context, attrs); } }同样,PageCurlView是与数据有关的,我们对外提供一个方法来为PageCurlView设置数据:
/** * 设置位图数据 * * @param mBitmaps * 位图数据列表 */ public synchronized void setBitmaps(List<Bitmap> mBitmaps) { /* * 如果数据为空则抛出异常 */ if (null == mBitmaps || mBitmaps.size() == 0) throw new IllegalArgumentException("no bitmap to display"); /* * 如果数据长度小于2则GG思密达 */ if (mBitmaps.size() < 2) throw new IllegalArgumentException("fuck you and fuck to use imageview"); this.mBitmaps = mBitmaps; invalidate(); }这里要注意,如果图片小于两张,那就没必要去做翻页效果了,当然你也可以将其绘制出来然后在用户实行“翻页”的时候提示“已是最后一页”也可以,这里我就直接不允许图片张数小于2张了。
在《自定义控件其实很简单5/12》中我们自定义了一个折线视图,在该例中我们为PolylineView设置了一个初始化数据,即当用户没有设置数据时默认显示了一组随机值数据。那在这里呢我不再做初始化数据而是当绘制时如果数据为空那么我们就显示一组文本信息提示用户设置数据:
/** * 默认显示 * * @param canvas * Canvas对象 */ private void defaultDisplay(Canvas canvas) { // 绘制底色 canvas.drawColor(Color.WHITE); // 绘制标题文本 mTextPaint.setTextSize(mTextSizeLarger); mTextPaint.setColor(Color.RED); canvas.drawText("FBI WARNING", mViewWidth / 2, mViewHeight / 4, mTextPaint); // 绘制提示文本 mTextPaint.setTextSize(mTextSizeNormal); mTextPaint.setColor(Color.BLACK); canvas.drawText("Please set data use setBitmaps method", mViewWidth / 2, mViewHeight / 3, mTextPaint); }
如果没有设置数据,那么PageCurlView的默认显示效果如下:
如果有数据,那么我们在绘制这些位图之前要对其大小进行调整,这里我就直接将位图的大小矫正与控件一致,当然实际应用当中你可以根据比例来缩放图片使其保持宽高比,这里我就直接舍弃宽高比了:
/** * 初始化位图数据 * 缩放位图尺寸与屏幕匹配 */ private void initBitmaps() { List<Bitmap> temp = new ArrayList<Bitmap>(); for (int i = 0; i < mBitmaps.size(); i++) { Bitmap bitmap = Bitmap.createScaledBitmap(mBitmaps.get(i), mViewWidth, mViewHeight, true); temp.add(bitmap); } mBitmaps = temp; }那么数据有了,我们尝试将其绘制出来看看:
/** * 绘制位图 * * @param canvas * Canvas对象 */ private void drawBtimaps(Canvas canvas) { for (int i = 0; i < mBitmaps.size(); i++) { canvas.save(); canvas.drawBitmap(mBitmaps.get(i), 0, 0, null); canvas.restore(); } }如上代码所示,每一次绘制位图我们都锁定还原Canvas使每一个Bitmap在绘制时都独立开来,方便我们操作:
非常壮观的建筑物~虽然我们是把Bitmap绘制出来了,但是细心的朋友会发现,绘制顺序是颠倒的,位于列表末端的Bitmap被绘制在了最顶层,很简单,我们在initBitmaps的时候掉个头不就是了么:
private void initBitmaps() { List<Bitmap> temp = new ArrayList<Bitmap>(); for (int i = mBitmaps.size() - 1; i >= 0; i--) { Bitmap bitmap = Bitmap.createScaledBitmap(mBitmaps.get(i), mViewWidth, mViewHeight, true); temp.add(bitmap); } mBitmaps = temp; }这时候运行就会显示第一张图片了:
古典欧式建筑~~
很多细心的朋友可能会有这样的疑问问什么不在drawBtimaps里面翻转顺序呢?原因有二,其一是既然是初始化数据那么我们希望在initBitmaps之后拿到的数据是直接能用的而不是在draw的时候还要执行没必要计算影响效率,其二是我们会在drawBtimaps执行一些计算,需要的一些参数包括循环的一些参数,如果我们的循环还要自行计算必定会增加逻辑的复杂度。
图片是画出来了,但是要如何去“遮住”上一张同时显示下一张图片呢?我们可以利用《自定义控件其实很简单5/12》中讲到的clipXXX裁剪方法去做,通过控制clipRect的right坐标来显示图片,好我们修改一下drawBtimaps方法加入clip:
/** * 绘制位图 * * @param canvas * Canvas对象 */ private void drawBtimaps(Canvas canvas) { for (int i = 0; i < mBitmaps.size(); i++) { canvas.save(); canvas.clipRect(0, 0, mClipX, mViewHeight); canvas.drawBitmap(mBitmaps.get(i), 0, 0, null); canvas.restore(); } }mClipX为裁剪区域右端的坐标值,我们在onSizeChanged中为其赋值使其等于控件宽度:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { // 省去一些代码…… // 初始化裁剪右端点坐标 mClipX = mViewWidth; }并且重写View的onTouchEvent方法获取事件,将当前触摸点的X坐标赋予mClipX并重绘视图:
@Override public boolean onTouchEvent(MotionEvent event) { // 获取触摸点的x坐标 mClipX = event.getX(); invalidate(); return true; }此时运行效果如下:
大家可以看到虽然我们可以通过手指的滑动来clip裁剪图片,但目测并没有达到我们理想的效果,clip裁剪了所有的图片而我们其实只想裁剪第一张并使第二张显示出来……OK,那我们再改下drawBtimaps方法:
/** * 绘制位图 * * @param canvas * Canvas对象 */ private void drawBtimaps(Canvas canvas) { for (int i = 0; i < mBitmaps.size(); i++) { canvas.save(); /* * 仅裁剪位于最顶层的画布区域 */ if (i == mBitmaps.size() - 1) { canvas.clipRect(0, 0, mClipX, mViewHeight); } canvas.drawBitmap(mBitmaps.get(i), 0, 0, null); canvas.restore(); } }我们只针对位于最顶层的画布区域进行裁剪而其他的则保持不变,这样我们就可以得到一个“翻”的效果:
现在想想每次我们去滑动都要从至右滑到至左对吧,可是我们的手指是有宽度的,想精确地一次性从至右滑到至左太麻烦,我们可以在左右两端设定一个区域,当当前触摸点在该区域时让我们的图片自动滑至或者说吸附到至左或至右:
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction() & MotionEvent.ACTION_MASK) { default: // 获取触摸点的x坐标 mClipX = event.getX(); invalidate(); break; case MotionEvent.ACTION_UP:// 触点抬起时 // 判断是否需要自动滑动 judgeSlideAuto(); break; } return true; }那么这个事件我们在手指抬起时触发,手指抬起后判断当前点的位置:
/** * 判断是否需要自动滑动 * 根据参数的当前值判断绘制 */ private void judgeSlideAuto() { /* * 如果裁剪的右端点坐标在控件左端五分之一的区域内,那么我们直接让其自动滑到控件左端 */ if (mClipX < mViewWidth * 1 / 5F) { while (mClipX > 0) { mClipX--; invalidate(); } } /* * 如果裁剪的右端点坐标在控件右端五分之一的区域内,那么我们直接让其自动滑到控件右端 */ if (mClipX > mViewWidth * 4 / 5F) { while (mClipX < mViewWidth) { mClipX++; invalidate(); } } }如图所示,当我们的触摸点在距控件左端1/5的区域内时抬起手指后图片自动吸附到了左端,同样当我们的触摸点在距控件右端4/5-5/5的区域内时抬起手指后图片自动吸附到了右端:
OK,好像没什么问题是么?哈哈哈哈哈啊哈哈哈哈哈哈哈哈如果你真要这么认为你就上当了,大家可以认真地看看是不是真没问题,这里其实我给大家挖了个坑,看似没啥问题,其实涉及到一个很重要的信息,下一节我们会讲。这里要注意,因为我们会不断地触发触摸事件,也就是说onTouchEvent会不断地被调用,而在onTouchEvent中我们会不断重复地去计算mViewWidth * 1 / 5F和 mViewWidth * 4 / 5F的值,这对我们控件的效率来说是相当不利的,我们考虑将其封装成成员变量并在onSizeChanged中赋予初始值:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { // 省去一些代码…… // 计算控件左侧和右侧自动吸附的区域 autoAreaLeft = mViewWidth * 1 / 5F; autoAreaRight = mViewWidth * 4 / 5F; }再在judgeSlideAuto中调用:
private void judgeSlideAuto() { /* * 如果裁剪的右端点坐标在控件左端五分之一的区域内,那么我们直接让其自动滑到控件左端 */ if (mClipX < autoAreaLeft) { while (mClipX > 0) { mClipX--; invalidate(); } } /* * 如果裁剪的右端点坐标在控件右端五分之一的区域内,那么我们直接让其自动滑到控件右端 */ if (mClipX > autoAreaRight) { while (mClipX < mViewWidth) { mClipX++; invalidate(); } } }对象和参数的复用一定要运用得炉火纯青,特别是在像onDraw、onTouchEvent这类有可能被不断重复调用的方法中,尽量避免不必要的计算(特别是浮点值的计算)和对象生成,这样对提高View运行效率有着举足轻重的意义!
到目前为之我们成功翻起了第一张图片,但是如何能够连续不断地翻起剩下的图片呢?先别急,在这之前我们先来看一个浪费资源影响效率的东西。我在setBitmaps的时候传了五张图片进来,也就是说mBitmaps数据列表的size长度为5,而在drawBtimaps中呢我们直接通过循环将五张图片依次绘制在了Canvas中:
for (int i = 0; i < mBitmaps.size(); i++) { canvas.save(); // 省略一些代码…… canvas.drawBitmap(mBitmaps.get(i), 0, 0, null); canvas.restore(); }现在想想看我们是否有这样的必要呢?这里是五张图片,如果是10张、100张、1000张、10000张……呢?这样直接一次性地全部draw绝逼要崩~而事实上我们也没有必要去一次性绘制这么多图片,因为我们每次最多只会显示两张:上一张翻和下一张显示,也就是说我们仅需显示当前最顶层的两张图片即可~这样在有大量图片的时候可以大大提高我们绘图的效率,然后通过一些参数的判断来不断地在滑动过程中绘制往后的两张图片直至最后一张图片绘制完成,so~我们更改一下drawBtimaps:
/** * 绘制位图 * * @param canvas * Canvas对象 */ private void drawBtimaps(Canvas canvas) { // 绘制位图前重置isLastPage为false isLastPage = false; // 限制pageIndex的值范围 pageIndex = pageIndex < 0 ? 0 : pageIndex; pageIndex = pageIndex > mBitmaps.size() ? mBitmaps.size() : pageIndex; // 计算数据起始位置 int start = mBitmaps.size() - 2 - pageIndex; int end = mBitmaps.size() - pageIndex; /* * 如果数据起点位置小于0则表示当前已经到了最后一张图片 */ if (start < 0) { // 此时设置isLastPage为true isLastPage = true; // 并显示提示信息 showToast("This is fucking lastest page"); // 强制重置起始位置 start = 0; end = 1; } for (int i = start; i < end; i++) { canvas.save(); /* * 仅裁剪位于最顶层的画布区域 * 如果到了末页则不在执行裁剪 */ if (!isLastPage && i == end - 1) { canvas.clipRect(0, 0, mClipX, mViewHeight); } canvas.drawBitmap(mBitmaps.get(i), 0, 0, null); canvas.restore(); } }我们增加了一个int类型的成员变量pageIndex,用来作为计算读取数据列表的参考值。在这里我们约定控件左侧小于autoAreaLeft的区域为“回滚区域”,什么意思呢?如果我们的手指触摸点在该区域,那么我们就认为用户的操作为“返回上一页”(当然你也可以去计算滑动起始点之差的正负来判断用户的行为,事件不是本系列重点就不讲了)。pageIndex的作用可以用简单用下图表示:
五种颜色代表五张图片,左上角的序号表示其在列表中的下标位置,当pageIndex为0时start为3而end为5,那么意味着列表最后的图片会被绘制在最上层,接着绘制倒数第二张,如果pageIndex为1时start为2而end为4,那么意味着列表倒数第二张的图片会被绘制在最上层,接着绘制倒数第三张……以此类推,那么我们该如何控制pageIndex的值呢?由上可知,pageIndex++表示显示下一页而pageIndex--则表示上一页,那么故事就很简单了,当我们的mClipX值为0时意味着图片已被裁剪完了,那么这时候我们就可以使pageIndex++,而当用户的手指触碰回滚区域的时候则让pageIndex--显示回上一页,既然需要判断事件,那我们只好修改onTouchEvent咯:
@Override public boolean onTouchEvent(MotionEvent event) { // 每次触发TouchEvent重置isNextPage为true isNextPage = true; /* * 判断当前事件类型 */ switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN:// 触摸屏幕时 // 获取当前事件点x坐标 mCurPointX = event.getX(); /* * 如果事件点位于回滚区域 */ if (mCurPointX < mAutoAreaLeft) { // 那就不翻下一页了而是上一页 isNextPage = false; pageIndex--; mClipX = mCurPointX; invalidate(); } break; case MotionEvent.ACTION_MOVE:// 滑动时 float SlideDis = mCurPointX - event.getX(); if (Math.abs(SlideDis) > mMoveValid) { // 获取触摸点的x坐标 mClipX = event.getX(); invalidate(); } break; case MotionEvent.ACTION_UP:// 触点抬起时 // 判断是否需要自动滑动 judgeSlideAuto(); /* * 如果当前页不是最后一页 * 如果是需要翻下一页 * 并且上一页已被clip掉 */ if (!isLastPage && isNextPage && mClipX <= 0) { pageIndex++; mClipX = mViewWidth; invalidate(); } break; } return true; }在ACTION_MOVE事件中我们重新定义了事件的执行标准,如果MOVE的距离小于mMoveValid = mViewWidth * 1 / 100F即控件宽度的百分之一则无效,上面代码的注释和上面的分析过程一致就不多扯了,具体效果如下:
以下是这一部分PageTurnView的全部代码,下一节我们将尝试引入折线,将单纯的由右至左切换变成一个折页翻转的效果
public class PageTurnView extends View { private static final float TEXT_SIZE_NORMAL = 1 / 40F, TEXT_SIZE_LARGER = 1 / 20F;// 标准文字尺寸和大号文字尺寸的占比 private TextPaint mTextPaint;// 文本画笔 private Context mContext;// 上下文环境引用 private List<Bitmap> mBitmaps;// 位图数据列表 private int pageIndex;// 当前显示mBitmaps数据的下标 private int mViewWidth, mViewHeight;// 控件宽高 private float mTextSizeNormal, mTextSizeLarger;// 标准文字尺寸和大号文字尺寸 private float mClipX;// 裁剪右端点坐标 private float mAutoAreaLeft, mAutoAreaRight;// 控件左侧和右侧自动吸附的区域 private float mCurPointX;// 指尖触碰屏幕时点X的坐标值 private float mMoveValid;// 移动事件的有效距离 private boolean isNextPage, isLastPage;// 是否该显示下一页、是否最后一页的标识值 public PageTurnView(Context context, AttributeSet attrs) { super(context, attrs); mContext = context; /* * 实例化文本画笔并设置参数 */ mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG | Paint.LINEAR_TEXT_FLAG); mTextPaint.setTextAlign(Paint.Align.CENTER); } @Override public boolean onTouchEvent(MotionEvent event) { // 每次触发TouchEvent重置isNextPage为true isNextPage = true; /* * 判断当前事件类型 */ switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN:// 触摸屏幕时 // 获取当前事件点x坐标 mCurPointX = event.getX(); /* * 如果事件点位于回滚区域 */ if (mCurPointX < mAutoAreaLeft) { // 那就不翻下一页了而是上一页 isNextPage = false; pageIndex--; mClipX = mCurPointX; invalidate(); } break; case MotionEvent.ACTION_MOVE:// 滑动时 float SlideDis = mCurPointX - event.getX(); if (Math.abs(SlideDis) > mMoveValid) { // 获取触摸点的x坐标 mClipX = event.getX(); invalidate(); } break; case MotionEvent.ACTION_UP:// 触点抬起时 // 判断是否需要自动滑动 judgeSlideAuto(); /* * 如果当前页不是最后一页 * 如果是需要翻下一页 * 并且上一页已被clip掉 */ if (!isLastPage && isNextPage && mClipX <= 0) { pageIndex++; mClipX = mViewWidth; invalidate(); } break; } return true; } /** * 判断是否需要自动滑动 * 根据参数的当前值判断自动滑动 */ private void judgeSlideAuto() { /* * 如果裁剪的右端点坐标在控件左端十分之一的区域内,那么我们直接让其自动滑到控件左端 */ if (mClipX < mAutoAreaLeft) { while (mClipX > 0) { mClipX--; invalidate(); } } /* * 如果裁剪的右端点坐标在控件右端十分之一的区域内,那么我们直接让其自动滑到控件右端 */ if (mClipX > mAutoAreaRight) { while (mClipX < mViewWidth) { mClipX++; invalidate(); } } } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { // 获取控件宽高 mViewWidth = w; mViewHeight = h; // 初始化位图数据 initBitmaps(); // 计算文字尺寸 mTextSizeNormal = TEXT_SIZE_NORMAL * mViewHeight; mTextSizeLarger = TEXT_SIZE_LARGER * mViewHeight; // 初始化裁剪右端点坐标 mClipX = mViewWidth; // 计算控件左侧和右侧自动吸附的区域 mAutoAreaLeft = mViewWidth * 1 / 5F; mAutoAreaRight = mViewWidth * 4 / 5F; // 计算一度的有效距离 mMoveValid = mViewWidth * 1 / 100F; } /** * 初始化位图数据 * 缩放位图尺寸与屏幕匹配 */ private void initBitmaps() { List<Bitmap> temp = new ArrayList<Bitmap>(); for (int i = mBitmaps.size() - 1; i >= 0; i--) { Bitmap bitmap = Bitmap.createScaledBitmap(mBitmaps.get(i), mViewWidth, mViewHeight, true); temp.add(bitmap); } mBitmaps = temp; } @Override protected void onDraw(Canvas canvas) { /* * 如果数据为空则显示默认提示文本 */ if (null == mBitmaps || mBitmaps.size() == 0) { defaultDisplay(canvas); return; } // 绘制位图 drawBtimaps(canvas); } /** * 默认显示 * * @param canvas * Canvas对象 */ private void defaultDisplay(Canvas canvas) { // 绘制底色 canvas.drawColor(Color.WHITE); // 绘制标题文本 mTextPaint.setTextSize(mTextSizeLarger); mTextPaint.setColor(Color.RED); canvas.drawText("FBI WARNING", mViewWidth / 2, mViewHeight / 4, mTextPaint); // 绘制提示文本 mTextPaint.setTextSize(mTextSizeNormal); mTextPaint.setColor(Color.BLACK); canvas.drawText("Please set data use setBitmaps method", mViewWidth / 2, mViewHeight / 3, mTextPaint); } /** * 绘制位图 * * @param canvas * Canvas对象 */ private void drawBtimaps(Canvas canvas) { // 绘制位图前重置isLastPage为false isLastPage = false; // 限制pageIndex的值范围 pageIndex = pageIndex < 0 ? 0 : pageIndex; pageIndex = pageIndex > mBitmaps.size() ? mBitmaps.size() : pageIndex; // 计算数据起始位置 int start = mBitmaps.size() - 2 - pageIndex; int end = mBitmaps.size() - pageIndex; /* * 如果数据起点位置小于0则表示当前已经到了最后一张图片 */ if (start < 0) { // 此时设置isLastPage为true isLastPage = true; // 并显示提示信息 showToast("This is fucking lastest page"); // 强制重置起始位置 start = 0; end = 1; } for (int i = start; i < end; i++) { canvas.save(); /* * 仅裁剪位于最顶层的画布区域 * 如果到了末页则不在执行裁剪 */ if (!isLastPage && i == end - 1) { canvas.clipRect(0, 0, mClipX, mViewHeight); } canvas.drawBitmap(mBitmaps.get(i), 0, 0, null); canvas.restore(); } } /** * 设置位图数据 * * @param bitmaps * 位图数据列表 */ public synchronized void setBitmaps(List<Bitmap> bitmaps) { /* * 如果数据为空则抛出异常 */ if (null == bitmaps || bitmaps.size() == 0) throw new IllegalArgumentException("no bitmap to display"); /* * 如果数据长度小于2则GG思密达 */ if (bitmaps.size() < 2) throw new IllegalArgumentException("fuck you and fuck to use imageview"); mBitmaps = bitmaps; invalidate(); } /** * Toast显示 * * @param msg * Toast显示文本 */ private void showToast(Object msg) { Toast.makeText(mContext, msg.toString(), Toast.LENGTH_SHORT).show(); } }该部分源码下载:传送门
标签:android自定义view android翻页效果 翻页
原文地址:http://blog.csdn.net/aigestudio/article/details/42678541