ios 8 的时间滚轮控件实现了扁平化,带来很好用户体验,android没有现成控件,小弟不才,数学与算法知识不过关,顾十分苦恼,幸好在github上找到sai大神实现代码,甚为欣喜,顾把学习这个控件点滴记录下来,分享给大家。项目原地址https://github.com/saiwu-bigkoo/Android-PickerView。

  ios 8 滚轮的效果:

  而sai大神控件的效果:

  哎,妈呀是不是效果95%相识啊。

  好了,废话少说,谈谈我从这个控件中收获的心得。

  首先,我们要高瞻远瞩看一下他的整体架构图,让我们对这个控件有一个整体感觉。

  通过这一个简单架构图,我们可以清晰看出:

  这虽然是一个很小控件,但是麻雀虽小五脏俱全,采用典型的mvc模式将页面展示,逻辑控制,数据加载实现了有效的分离,接下来,我就浅尝辄止将这几个类分析一下吧。

  万事开头难,我们当然是从最容易的开始,看看wheeladapter吧。

  wheeladapter负责将数据填充进来,这是一个经典adapter模式,对于adapter的作用于好处,我想没有必要在这里赘述。我们看看它的全貌吧:

public interface WheelAdapter<T> {/*** Gets items count*/public int getItemsCount();/*** Gets a wheel item by index.* * @param index the item index* @return the wheel item text or null*/public T getItem(int index);/*** Gets maximum item length. It is used to determine the wheel width. * If -1 is returned there will be used the default wheel width.* * @return the maximum item length or -1*/public int indexOf(T o);
}

  这是一个接口,它三个方法分别能够获取数据条数,当前索引下所对应条目,以及当前条目所对应索引。这都是对数据频繁操作。

  趁热打铁,我们更进一步,看看负责展示view又是长成什么样子。

public static DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");private View view;private WheelView wv_year;private WheelView wv_month;private WheelView wv_day;private WheelView wv_hours;private WheelView wv_mins;private Type type;private static int START_YEAR = 1990, END_YEAR = 2100;public View getView() {return view;}public void setView(View view) {this.view = view;}public static int getSTART_YEAR() {return START_YEAR;}public static void setSTART_YEAR(int sTART_YEAR) {START_YEAR = sTART_YEAR;}public static int getEND_YEAR() {return END_YEAR;}public static void setEND_YEAR(int eND_YEAR) {END_YEAR = eND_YEAR;}public WheelTime(View view) {super();this.view = view;type = Type.ALL;setView(view);}public WheelTime(View view,Type type) {super();this.view = view;this.type = type;setView(view);}public void setPicker(int year ,int month,int day){this.setPicker(year, month, day, 0, 0);}/*** @Description: TODO 弹出日期时间选择器*/public void setPicker(int year ,int month ,int day,int h,int m) {// 添加大小月月份并将其转换为list,方便之后的判断String[] months_big = { "1", "3", "5", "7", "8", "10", "12" };String[] months_little = { "4", "6", "9", "11" };final List<String> list_big = Arrays.asList(months_big);final List<String> list_little = Arrays.asList(months_little);Context context = view.getContext();// 年wv_year = (WheelView) view.findViewById(R.id.year);wv_year.setAdapter(new NumericWheelAdapter(START_YEAR, END_YEAR));// 设置"年"的显示数据wv_year.setLabel(context.getString(R.string.pickerview_year));// 添加文字wv_year.setCurrentItem(year - START_YEAR);// 初始化时显示的数据// 月wv_month = (WheelView) view.findViewById(R.id.month);wv_month.setAdapter(new NumericWheelAdapter(1, 12));wv_month.setLabel(context.getString(R.string.pickerview_month));wv_month.setCurrentItem(month);// 日wv_day = (WheelView) view.findViewById(R.id.day);// 判断大小月及是否闰年,用来确定"日"的数据if (list_big.contains(String.valueOf(month + 1))) {wv_day.setAdapter(new NumericWheelAdapter(1, 31));} else if (list_little.contains(String.valueOf(month + 1))) {wv_day.setAdapter(new NumericWheelAdapter(1, 30));} else {// 闰年if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0)wv_day.setAdapter(new NumericWheelAdapter(1, 29));elsewv_day.setAdapter(new NumericWheelAdapter(1, 28));}wv_day.setLabel(context.getString(R.string.pickerview_day));wv_day.setCurrentItem(day - 1);wv_hours = (WheelView)view.findViewById(R.id.hour);wv_hours.setAdapter(new NumericWheelAdapter(0, 23));wv_hours.setLabel(context.getString(R.string.pickerview_hours));// 添加文字wv_hours.setCurrentItem(h);wv_mins = (WheelView)view.findViewById(R.id.min);wv_mins.setAdapter(new NumericWheelAdapter(0, 59));wv_mins.setLabel(context.getString(R.string.pickerview_minutes));// 添加文字wv_mins.setCurrentItem(m);// 添加"年"监听OnItemSelectedListener wheelListener_year = new OnItemSelectedListener() {@Overridepublic void onItemSelected(int index) {int year_num = index + START_YEAR;// 判断大小月及是否闰年,用来确定"日"的数据int maxItem = 30;if (list_big.contains(String.valueOf(wv_month.getCurrentItem() + 1))) {wv_day.setAdapter(new NumericWheelAdapter(1, 31));maxItem = 31;} else if (list_little.contains(String.valueOf(wv_month.getCurrentItem() + 1))) {wv_day.setAdapter(new NumericWheelAdapter(1, 30));maxItem = 30;} else {if ((year_num % 4 == 0 && year_num % 100 != 0)|| year_num % 400 == 0){wv_day.setAdapter(new NumericWheelAdapter(1, 29));maxItem = 29;}else{wv_day.setAdapter(new NumericWheelAdapter(1, 28));maxItem = 28;}}if (wv_day.getCurrentItem() > maxItem - 1){wv_day.setCurrentItem(maxItem - 1);}}};// 添加"月"监听OnItemSelectedListener wheelListener_month = new OnItemSelectedListener() {@Overridepublic void onItemSelected(int index) {int month_num = index + 1;int maxItem = 30;// 判断大小月及是否闰年,用来确定"日"的数据if (list_big.contains(String.valueOf(month_num))) {wv_day.setAdapter(new NumericWheelAdapter(1, 31));maxItem = 31;} else if (list_little.contains(String.valueOf(month_num))) {wv_day.setAdapter(new NumericWheelAdapter(1, 30));maxItem = 30;} else {if (((wv_year.getCurrentItem() + START_YEAR) % 4 == 0 && (wv_year.getCurrentItem() + START_YEAR) % 100 != 0)|| (wv_year.getCurrentItem() + START_YEAR) % 400 == 0){wv_day.setAdapter(new NumericWheelAdapter(1, 29));maxItem = 29;}else{wv_day.setAdapter(new NumericWheelAdapter(1, 28));maxItem = 28;}}if (wv_day.getCurrentItem() > maxItem - 1){wv_day.setCurrentItem(maxItem - 1);}}};wv_year.setOnItemSelectedListener(wheelListener_year);wv_month.setOnItemSelectedListener(wheelListener_month);// 根据屏幕密度来指定选择器字体的大小(不同屏幕可能不同)int textSize = 6;switch(type){case ALL:textSize = textSize * 3;break;case YEAR_MONTH_DAY:textSize = textSize * 4;wv_hours.setVisibility(View.GONE);wv_mins.setVisibility(View.GONE);break;case HOURS_MINS:textSize = textSize * 4;wv_year.setVisibility(View.GONE);wv_month.setVisibility(View.GONE);wv_day.setVisibility(View.GONE);break;case MONTH_DAY_HOUR_MIN:textSize = textSize * 3;wv_year.setVisibility(View.GONE);break;case YEAR_MONTH:textSize = textSize * 4;wv_day.setVisibility(View.GONE);wv_hours.setVisibility(View.GONE);wv_mins.setVisibility(View.GONE);}wv_day.setTextSize(textSize);wv_month.setTextSize(textSize);wv_year.setTextSize(textSize);wv_hours.setTextSize(textSize);wv_mins.setTextSize(textSize);}/*** 设置是否循环滚动* @param cyclic*/public void setCyclic(boolean cyclic){wv_year.setCyclic(cyclic);wv_month.setCyclic(cyclic);wv_day.setCyclic(cyclic);wv_hours.setCyclic(cyclic);wv_mins.setCyclic(cyclic);}public String getTime() {StringBuffer sb = new StringBuffer();sb.append((wv_year.getCurrentItem() + START_YEAR)).append("-").append((wv_month.getCurrentItem() + 1)).append("-").append((wv_day.getCurrentItem() + 1)).append(" ").append(wv_hours.getCurrentItem()).append(":").append(wv_mins.getCurrentItem());return sb.toString();}

  这个控件主要负责数据展示,因此他需要加载一个view视图,加载年月日视图的时候,我们要对相应的大小月,平闰年进行逻辑的判断,另外,为了其他地方能够很方便的调用,我们还需要将选择时间数据进行封装,嗯,这个控件就负责数据的展示,就这么简单。

  既然是自定义控件,前面都应该是一些铺垫,下面,我们需要对重头戏——wheelview进行一下庖丁解牛般的剖析。

  前面说了wheelview控件是个controller,那么他一定有什么独特逻辑在里面,其实无论是自己自定义控件也好,还是瞻仰像sai大神也罢,无非对控件的onDraw方法,onMeasure方法,OnTouch事件搞定,一个控件也就弄明白了。我们这里也把握这个节奏来分析wheelView的代码。

  从那里开始了,万事开头难,还是从最简单开始吧!

@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {this.widthMeasureSpec = widthMeasureSpec;remeasure();setMeasuredDimension(measuredWidth, measuredHeight);}private void remeasure() {if (adapter == null) {return;}measureTextWidthHeight();//最大Text的高度乘间距倍数得到 可见文字实际的总高度,半圆的周长halfCircumference = (int) (maxTextHeight * lineSpacingMultiplier * (itemsVisible - 1)) ;//整个圆的周长除以PI得到直径,这个直径用作控件的总高度measuredHeight = (int) ((halfCircumference * 2) / Math.PI);//求出半径radius = (int) (halfCircumference / Math.PI);//控件宽度,这里支持weightmeasuredWidth = MeasureSpec.getSize(widthMeasureSpec);//计算两条横线和控件中间点的Y位置firstLineY = (int) ((measuredHeight - lineSpacingMultiplier * maxTextHeight) / 2.0F);secondLineY = (int) ((measuredHeight + lineSpacingMultiplier * maxTextHeight) / 2.0F);centerY = (int) ((measuredHeight + maxTextHeight) / 2.0F - CENTERCONTENTOFFSET);//初始化显示的item的position,根据是否loopif (initPosition == -1) {if (isLoop) {initPosition = (adapter.getItemsCount() + 1) / 2;} else {initPosition = 0;}}preCurrentIndex = initPosition;}

 这个方法主要作用就是重新测量子控件位置,由于子控件是以半圆形式进行排列的,我们需要计算出半圆的周长,以及半圆半径,以及中间两条横线与中间点y位置,以及控件初始化位置,这些变量将在后面ondraw方法绘制时候将有很大作用。

  既然能够滚动,就需要监听触摸事件,那么ontouch事件代码是怎么样的了:

  @Overridepublic boolean onTouchEvent(MotionEvent event) {boolean eventConsumed = gestureDetector.onTouchEvent(event);float itemHeight = lineSpacingMultiplier * maxTextHeight;switch (event.getAction()) {case MotionEvent.ACTION_DOWN:startTime = System.currentTimeMillis();cancelFuture();previousY = event.getRawY();break;case MotionEvent.ACTION_MOVE:float dy = previousY - event.getRawY();previousY = event.getRawY();totalScrollY = (int) (totalScrollY + dy);// 边界处理。if (!isLoop) {float top = -initPosition * itemHeight;float bottom = (adapter.getItemsCount() - 1 - initPosition) * itemHeight;if (totalScrollY < top) {totalScrollY = (int) top;} else if (totalScrollY > bottom) {totalScrollY = (int) bottom;}}break;case MotionEvent.ACTION_UP:default:if (!eventConsumed) {float y = event.getY();double l = Math.acos((radius - y) / radius) * radius;int circlePosition = (int) ((l + itemHeight / 2) / itemHeight);float extraOffset = (totalScrollY % itemHeight + itemHeight) % itemHeight;mOffset = (int) ((circlePosition - itemsVisible / 2) * itemHeight - extraOffset);if ((System.currentTimeMillis() - startTime) > 120) {// 处理拖拽事件smoothScroll(ACTION.DAGGLE);} else {// 处理条目点击事件smoothScroll(ACTION.CLICK);}}break;}invalidate();return true;}

 在action_down中,我们需要记录用户按下坐标位置,再action_move中处理用户移动的边界值,这样做有什么好处了,就是移动条目最终都处于控件正中的位置,而在action_up,我们要确认用户到底使移动多大距离,这样子,调用重绘的方法,滚轮就会滚动相应距离,实现最终滚动效果。

  如果说wheelView是本控件的核心,那么ondraw方法就是wheelView代码核心,最高潮终于来了。

 protected void onDraw(Canvas canvas) {if (adapter == null) {return;}//可见的item数组Object visibles[] = new Object[itemsVisible];//更加滚动的Y值高度除去每行Item的高度,得到滚动了多少个item,即change数change = (int) (totalScrollY / (lineSpacingMultiplier * maxTextHeight));try {//滚动中实际的预选中的item(即经过了中间位置的item) = 滑动前的位置 + 滑动相对位置preCurrentIndex = initPosition + change % adapter.getItemsCount();}catch (ArithmeticException e){System.out.println("出错了!adapter.getItemsCount() == 0,联动数据不匹配");}if (!isLoop) {//不循环的情况if (preCurrentIndex < 0) {preCurrentIndex = 0;}if (preCurrentIndex > adapter.getItemsCount() - 1) {preCurrentIndex = adapter.getItemsCount() - 1;}} else {//循环if (preCurrentIndex < 0) {//举个例子:如果总数是5,preCurrentIndex = -1,那么preCurrentIndex按循环来说,其实是0的上面,也就是4的位置preCurrentIndex = adapter.getItemsCount() + preCurrentIndex;}if (preCurrentIndex > adapter.getItemsCount() - 1) {//同理上面,自己脑补一下preCurrentIndex = preCurrentIndex - adapter.getItemsCount();}}//跟滚动流畅度有关,总滑动距离与每个item高度取余,即并不是一格格的滚动,每个item不一定滚到对应Rect里的,这个item对应格子的偏移值int itemHeightOffset = (int) (totalScrollY % (lineSpacingMultiplier * maxTextHeight));// 设置数组中每个元素的值int counter = 0;while (counter < itemsVisible) {int index = preCurrentIndex - (itemsVisible / 2 - counter);//索引值,即当前在控件中间的item看作数据源的中间,计算出相对源数据源的index值//判断是否循环,如果是循环数据源也使用相对循环的position获取对应的item值,如果不是循环则超出数据源范围使用""空白字符串填充,在界面上形成空白无数据的item项if (isLoop) {if (index < 0) {index = index + adapter.getItemsCount();if(index < 0){index = 0;}}if (index > adapter.getItemsCount() - 1) {index = index - adapter.getItemsCount();if (index > adapter.getItemsCount() - 1){index = adapter.getItemsCount() - 1;}}visibles[counter] = adapter.getItem(index);} else if (index < 0) {visibles[counter] = "";} else if (index > adapter.getItemsCount() - 1) {visibles[counter] = "";} else {visibles[counter] = adapter.getItem(index);}counter++;}//中间两条横线canvas.drawLine(0.0F, firstLineY, measuredWidth, firstLineY, paintIndicator);canvas.drawLine(0.0F, secondLineY, measuredWidth, secondLineY, paintIndicator);//单位的Labelif(label != null) {int drawRightContentStart = measuredWidth - getTextWidth(paintCenterText,label);//靠右并留出空隙canvas.drawText(label, drawRightContentStart - CENTERCONTENTOFFSET, centerY, paintCenterText);}counter = 0;while (counter < itemsVisible) {canvas.save();// L(弧长)=α(弧度)* r(半径) (弧度制)// 求弧度--> (L * π ) / (π * r)   (弧长X派/半圆周长)float itemHeight = maxTextHeight * lineSpacingMultiplier;double radian = ((itemHeight * counter - itemHeightOffset) * Math.PI) / halfCircumference;// 弧度转换成角度(把半圆以Y轴为轴心向右转90度,使其处于第一象限及第四象限float angle = (float) (90D - (radian / Math.PI) * 180D);if (angle >= 90F || angle <= -90F) {canvas.restore();} else {String contentText = getContentText(visibles[counter]);//计算开始绘制的位置measuredCenterContentStart(contentText);measuredOutContentStart(contentText);int translateY = (int) (radius - Math.cos(radian) * radius - (Math.sin(radian) * maxTextHeight) / 2D);//根据Math.sin(radian)来更改canvas坐标系原点,然后缩放画布,使得文字高度进行缩放,形成弧形3d视觉差canvas.translate(0.0F, translateY);canvas.scale(1.0F, (float) Math.sin(radian));if (translateY <= firstLineY && maxTextHeight + translateY >= firstLineY) {// 条目经过第一条线canvas.save();canvas.clipRect(0, 0, measuredWidth, firstLineY - translateY);canvas.scale(1.0F, (float) Math.sin(radian) * SCALECONTENT);canvas.drawText(contentText, drawOutContentStart, maxTextHeight, paintOuterText);canvas.restore();canvas.save();canvas.clipRect(0, firstLineY - translateY, measuredWidth, (int) (itemHeight));canvas.scale(1.0F, (float) Math.sin(radian) * 1F);canvas.drawText(contentText, drawCenterContentStart, maxTextHeight - CENTERCONTENTOFFSET, paintCenterText);canvas.restore();} else if (translateY <= secondLineY && maxTextHeight + translateY >= secondLineY) {// 条目经过第二条线canvas.save();canvas.clipRect(0, 0, measuredWidth, secondLineY - translateY);canvas.scale(1.0F, (float) Math.sin(radian) * 1.0F);canvas.drawText(contentText, drawCenterContentStart, maxTextHeight - CENTERCONTENTOFFSET, paintCenterText);canvas.restore();canvas.save();canvas.clipRect(0, secondLineY - translateY, measuredWidth, (int) (itemHeight));canvas.scale(1.0F, (float) Math.sin(radian) * SCALECONTENT);canvas.drawText(contentText, drawOutContentStart, maxTextHeight, paintOuterText);canvas.restore();} else if (translateY >= firstLineY && maxTextHeight + translateY <= secondLineY) {// 中间条目canvas.clipRect(0, 0, measuredWidth, (int) (itemHeight));canvas.drawText(contentText, drawCenterContentStart, maxTextHeight - CENTERCONTENTOFFSET, paintCenterText);int preSelectedItem = adapter.indexOf(visibles[counter]);if(preSelectedItem != -1){selectedItem = preSelectedItem;}} else {// 其他条目canvas.save();canvas.clipRect(0, 0, measuredWidth, (int) (itemHeight));canvas.scale(1.0F, (float) Math.sin(radian) * SCALECONTENT);canvas.drawText(contentText, drawOutContentStart, maxTextHeight, paintOuterText);canvas.restore();}canvas.restore();}counter++;}}

 前文提到过,根据用户滚动的距离,计算出最终滚动到条目,然后把这条目周围一定数目的数据绘制出来,最难理解,就是到底如何绘制的了,我们可以这样理解根据位置来计算应该把画布哪部分进行缩放,上下就是压小高度,中间放大高度,然后下面判断这个文字位置到哪里了,超过第一条线就是说明进入中间位置,放大文字,超过第二条线说明出了中间位置,缩回去。也许,你还会问,他y轴是如何移动的,它移动距离其实就是你可以理解为一条线曲成弧形,然后根据在这条线上的位置计算出这个弧形上面的位置再折射出这个弧形对应一条竖的射影位置。示意图如下:

  这就是我对这个控件一点心得。

老猪带你玩转自定义控件三——sai大神带我实现ios 8 时间滚轮控件相关推荐

  1. 王者荣耀10连胜,竟然也有人不相信,猎游大神带菜鸡玩家10连胜

    如果一局游戏一个人头都没有获得,甚至还送了几个人头,这一局游戏大家知道是胜利还是惨败?很多玩家都知道遇到这样的情况,这一局游戏基本上没有戏了,那就是根本不可能有太大的可能再获得胜利,特别是前期敌人顺风 ...

  2. 大神带飞————动态生成对象并绑定父对象(绑定对象池中的对象使自己成为对象池中对象的子对象)

    实例代码 using System.Collections; using System.Collections.Generic; using UnityEngine; public class New ...

  3. SQL优化大神带你写有趣的SQL(6) SELF JOIN的应用

    大家好,我是知数堂SQL 优化班老师 网名:骑龟的兔子 今天给大家,带来的是 SELF JOIN的应用 下面是,表结构和,INSERT 语句脚本. create table t0718 (idx in ...

  4. 蒙文字体怎么安装_焘哥带你玩转字体(三)字体的安装及显示问题

    视频版 焘哥带你玩转字体(三)https://www.zhihu.com/video/1132957888620130304 上两篇文章我们共同了解了[衬线].[非衬线]字体,和如何识别字体的性格,以 ...

  5. 带你玩转Visual Studio(八)——带你跳出坑爹的Runtime Library坑

    在Windows下进行C++的开发,不可避免的要与Windows的底层库进行交互,然而VS下的一项设置MT.MTd.MD和MDd却经常让人搞迷糊,相信不少人都被他坑过,特别是你工程使用了很多第三库的时 ...

  6. 【AI好书】KK大神带你俯瞰未来20-30年的科技发展趋势,早阅读一天就让你在互联网时代先行一步!...

    欢迎大家来到<AI 好书>专栏,这一个专栏是面向所有对人工智能技术感兴趣的朋友.在这个专栏里,我们会给大家推荐人工智能相关的优质书籍. 今天要推荐的书籍是<必然--阐述12种必然的科 ...

  7. TOM带你玩充电 篇三:15款5号电池横评及选购建议——南孚金霸王小米宜家耐时品胜一个都逃不了...

    双鹿电池的几个版本 理论上来说性价比:绿骑士>金骑士>黑骑士>蓝骑士 绿骑士和金骑士都很不错.哪个便宜买哪个. 小米性价比虽然最高,但是超市买不到. 蓝骑士是普通碳性电池,黑骑士是高 ...

  8. python可以制作网站吗_Python大神带你用30行代码打造一个网站,爬虫+web不一样的玩法...

    首先,先把实际的效果图放上来: 用Python做的个性签名网站-效果图 在开始做之前,我们必须得知道这个用了那些模块: flask:一个轻量级的web开发框架,相信很多人也听说过这个牛逼加简洁的框架 ...

  9. 大神带你玩转异步编程,理论与实践齐飞,敢说是目前最全的讲解了

    要完全理解异步编程需要先理解几个概念 任务 我给任务的定义是完成某项功能的单元模块,任务有大有小,站在操作系统的角度,一个程序就是一个任务,每当运行一个程序就会创建一个新的任务,它在操作系统中还有一个 ...

最新文章

  1. 【VS实践】如何在vs中自动添加注释
  2. 使用MS VS的命令来编译C++程序
  3. 小程序中Cannot read property ‘setData‘ of undefined问题的解决
  4. 开源矿工README
  5. linux驱动的入口函数module_init的加载和释放
  6. Leetcode-88:合并两个有序数组
  7. C#异常--System.IO.FileLoadException:“混合模式程序集是针对“v2.0.50727”版的运行时生成的错误...
  8. nodejs + ts 配置
  9. FIFO的verilog代码
  10. 超越JUnit –测试框架的替代方案
  11. Android Retrofit 2.0 使用-补充篇
  12. php一句话跨域,php跨域怎么解决
  13. 软件测试周刊(第21期):不要告诉我你想干什么
  14. input内加小图标
  15. 美团旅行数据质量监管平台实践
  16. 在线预览文档 Office Online
  17. 解决蓝牙鼠标和电脑连接出现卡顿的情况
  18. [世界杯] 巴西 vs 日本 4:1
  19. mysql5.7内存占用_解决mysql升级到5.7内存占用过大问题
  20. 常见浏览器以及对应驱动的下载与使用

热门文章

  1. 脑洞大开的思维工具:六顶思考帽
  2. 【转】从“致加西亚的信”看自行管理
  3. 用Meta标签代码让360双核浏览器默认极速模式打开网站不是兼容模式
  4. 单片机长时间程序跑飞_单片机程序跑飞的三种现象、原因及解决方法
  5. Galois开始写的三个前端页面记录
  6. 写给你看的Python Web 岗位分析,求职必备
  7. 超大气友价商城仿互站源码
  8. Vue 利用后端的数据字典和Map对象实现表格列字段动态转义的处理方案
  9. Flink 异常 - 9.The heartbeat of TaskManager with id container timed out 分析与 Heartbeat 简介
  10. 如何查看SQL Server的索引碎片情况并进行整理