android实现日历

日历的实现

近期由于工作需要,对以前的日历控件进行重构和加强。
日历总体使用PopupWindow实现,日历view通过popupWindow.setContentView实现。

原有日历使用的view为单一的view(FrameLayout:calendarView),在功能上不支持滑动,仅支持左右button点击切换月份,支持设置可选范围、支持点击选中日期。
基于此,实现上为calendarView中包含GridView,更改月份时直接更换adapter中的月份数据在同一页面进行内容的更换,该实现体验较差,生硬死板。

目标日历需要新增实现手势滑动、主题更换、设置展现动画、标记当日日期等效果。
使用了ViewPager实现滑动效果,使用PageAdapter作为适配器。ViewPager容器中的每一个页面为一个CalendarPageView类(LinearLayout),承载日历滑动部分的view显示。

其中需要解决的主要问题为:viewPager在滑动过程中的动态加载和数据预加载问题。
由于正常情况下,viewPager的滑动所显示的内容是相对固定的,比如3到4个界面,负责展示即可。
但是对于日历来说,事情要复杂得多;具体问题有以下几点。

日历的页数数量处理

日历,可以理解为页面是无穷的,按照当今的日历,早可到1970年,晚可到无穷光年之外。为了解决这种无穷的问题,需要换个角度思考:用户的滑动是有限的。即时实现了加载无数页面的情况,用户也不可能全都滑一遍,因此,在用户所能用到的范围内进行设定即可。这里设定1000页(够你滑的了吧。。。),并且将第500页设置为当前日期所在的月份(这样,当月永远在第500页,前后永远对称)。

日历的页面缓存

使用过ViewPager及FragmentPagerAdapter的都知道,在Adapter内默认的是缓存3个页面,这三个页面分别是当前显示的页面,当前左边的页面和当前右面的页面,这是为了实现滑动时候的流畅。这个缓存机制是在基类中已经存在的。

在FragmentPagerAdapter中暴露了getItem()方法用于加载需要的界面,以及getCount()用于设置总页数(上面提到的1000),还可以重写destroyItem()进行页面销毁的控制。除此之外还要实现基类的构造函数,需要传入FragmentManager fm;这也成为了使用FragmentPagerAdapter(包括后面提到的FragmentPagerStatAdapter)的致命弱点。

而如果使用的是PagerAdapter,则可重写instantiateItem()设置界面的加载,destroyItem()设置界面的删除过程,当然还有getCount()设置总页数和isViewFramObject()

对比以上两种(加上FragmentPagerStatAdapter,共三种),最终选择PagerAdapter。原因就是,前两种都需要构造函数传入FragmentManager对view进行管理,这就使得,能够使用该viewpager的Activity只能是FragmentActivity,因为只有这样才能从该Activity中通过getSupportFragmentManager()获得fragmentManager对象。如果我要将viewPager放在一个PopupWindow中就无法获得了,因此是有局限性的。

敲定Adapter之后,我们来讨论如何做到页面缓存。首先需要明确,adapter时刻都需要保持三个页面的引用,那我们必须在任何一个时刻都有至少4个页面。为什么是四个,我们看一下viewPager如何通过Adapter获取每一个page。

android实现日历_第1张图片

如上图所示,在开始左滑时,首先调用instantiateItem()将4号页面设置好,再调用destroyItem()删除最左侧的1号页面,然后动画将2,3,4这三个页面滑动到正确的位置。这也就是为何需要至少4个页面的原因。新增和删除页面的执行不是同时的,有先有后,就必然有个顶上和换下的过程,因此需要留下一个buffer。
在日历中我使用List < CalendarViewPage >作为这四个页面的缓存,同时使用position%4获取此时应该使用的缓存对象。页面缓存问题不复存在。

日历数据的缓存

说完页面的缓存,来说一下日历数据的缓存。每一个page页面中都包含了日历的小格子,即使用GridView进行实现。对于日历的数据,涉及到计算问题,即使用一个日历Helper类计算某年某月某日的所有日子和位置。幸好有MonthDisplayHelper的支持,能够获得日历页面中的所有日期数字,包括当月页面的上月日期和下月日期。因此每个月的日期数据使用List 存储即可。

那么问题来了,每个月的日期都需要MonthDisplayHelper计算获得,计算过程还是比较费时的。因此考虑使用List 对每个月份的日期数据进行存储,在涉及到点击事件或切换页面引发的UI变化时,及时刷新对应的MonthData状态位即可。

思路出来了,在缓存中,其实使用了SparseArray这一个类似HashMap的结构进行缓存,它的key只能是数字,并且比HashMap更高效。这里key为viewPager的position的值。前面提到过,position一共1000,中点为500,也就是说,当月数据在SparseArray中对应的key=500.

当在加载page页面的时候需要某个月的MonthData时,先检查SparseArray中是否有缓存,如果有则直接获取根据当前状态刷新一遍;否则重新计算MonthData,在计算的过程中根据当前状态值进行设定,然后存入缓存。

自此,日历数据缓存搞定。

ViewPager的position如何关联缓存数据

前面制定的规则是,1000个页面滚动容量,当月放在中间点,即500处。那么,使用Helper类是可以计算出当前月之前的某年某月的距离数,或者今后某年某月距现在的月份差,使用500去计算吧,你懂得。

说是坑,其实也不是,只能是对于工具类的掌握不够熟练造成的吧。例如:
计算当前年月日的时候,使用Calendar类,通过getInstance().get()获取当前年,月,日,都是int类型,但没料到,获得的月刚好比正常月少1,坑吧,年和日都不差,只有月份差1。还有使用GregorianCalendar获取13位时间戳的时候,传入的年月日竟然也要求月份为减1以后的值。。
在这上面被坑调了半天。。

还有一个大坑,由于业务需要,在打开popupWindow的时候需要设置某年某月某日,这就需要打开的时候,viewPager.setCurrentItem(position)到指定的position。那么问题来了,当我初次打开日历时,可能position刚好设置为当前月,或者其他月都行,暂且当做a月,(注意初次这个词),当我翻到几个月之前,当做b月,然后关闭popupWindow。由于popupWindow不是每次打开都会new一个新的(太浪费),因此之前的状态都还在内存,下次打开理论上是应该正常显示为a月的界面。但是,当我再次打开时,发现有的时候a月的页面竟然全无数据成为空白,而且这种情况不是必现,而是时而有时而无。而且该情况只发生在我向前滑动的时候,从来不会发生在向后滑动日历的情况中。

聪明的你想到什么原因了吗?

原因请看下图:

android实现日历_第2张图片

图中方框内为月份数字,上面的数字表示缓存view的index。
如果当前是6月份,上一次popwindow关闭的时候我停留在2月份。当我再次打开pageView时,设置到6月的位置,因此adapter会通过instentiaItem()方法加载1,2,3号缓存页面,加载完毕后,需要执行deleteItem()方法删除原有存留页面,而此时,删除的将是1月,2月和3月份的页面,而他们刚好又对应的1,2,3页面,因此他们的view内数据都会被取消关联,这是展现的6月份就是没有数据的。正是因为这种先加载再删除的adapter机制导致了这个错误。如果你尝试将上一次的滑动结果停留在3月或者一月,总之只要是index=2的页面被加载进来过,第二次打开直接到6月份都会出现这个空白问题

介于这个问题,在关闭的时候又没有找到能够清除已加载页面的方式,我做了以下两步:

  1. 删除Adapter
  2. new 一个新的Adapter
  3. 将状态设置到该新的adapter中
  4. setAdapter
    这样就完全避免受到过时状态的影响了。

实现中的不足

实现过程中,我意识到,数据的缓存会成为一个消耗内存的问题。因为我只有添加缓存,并没有删除缓存的机制,除非用户关闭日历,我就可以通过删除Adapter来将内存释放,否则如果日历一直开着,用户一直朝着一个方向猛滑,早晚得OOM(这种人就2B)。

我的想法是使用LRU结构代替现有SparseArray进行定期清理。但介于2B还是少,我还木有替换。
以上是本次日历增强过程中的重点,希望读到它的人今后遇到问题能收到启发,少走弯路。

写了这么多,该下班了。各位晚安。

你可能感兴趣的:(随笔)