资讯动态

Android日期时间选择器实战:DatePicker、TimePicker与CalendarView用法详解

发布时间:2026/10/9 7:38:56 来源:尧图企业网站定制
1. 从场景出发日期和时间功能为什么值得单独学做Android开发这几年我见过太多新手在个人资料页、订单页面、预约功能里用最原始的方式处理日期——摆一个EditText让用户手动输入1995-08-20或者14:30。短时间看确实省事但真正测试的时候问题就来了用户输入格式五花八门有的是1995.8.20有的干脆19950820后台一接数据直接报错更别提日期合法性了用户输个2025-13-45你都不知道该不该拦。这就是Date Time组件存在的意义。Android从早期版本就内置了一套完整的日期时间选择控件体系DatePicker、TimePicker、CalendarView配合TextView、Button这些基础组件可以在几行代码之内实现一个体验标准、数据规范的选择器。用户不用敲键盘点几下就完成输入前端拿到的数据也是标准的年月日、时分秒结构大大降低了解析和校验的成本。这篇教程是Date Time组件系列的上半部分我打算把最核心的三件套讲透DatePicker日期选择器、TimePicker时间选择器、CalendarView日历视图。内容会覆盖XML布局里的常用属性、代码里的监听回调、日期格式化的基础知识最后用一个生日选择保存回显的综合Demo把前面所有知识点串起来。不管你是刚学完Android四大组件、正准备做界面交互的新手还是已经写了几个小项目但一直在用EditText硬凑日期输入的初级开发者这篇都适合你。我会把每个控件的原理、参数、坑都讲明白代码可以直接抄理解了之后也能自己改。2. 核心组件全景DatePicker、TimePicker、CalendarView三件套先把三个组件的关系理清楚。它们不是三个功能完全独立的东西而是围绕日期时间数据这一件事从不同角度提供服务。组件本质作用典型使用场景输出数据DatePicker年、月、日三级选择器选择生日、选择出发日期、选择到期日年、月、日三个整数TimePicker时、分两级选择器支持24小时制/AM-PM制选择闹钟时间、预约时间、开始结束时间小时、分钟两个整数CalendarView按月显示的日历网格可标记和监听选中日期日程管理、打卡记录、日历选择选中日期对应的毫秒时间戳或年月日你会发现DatePicker和CalendarView做的事情其实是有重叠的——都能选日期。区别在于交互形式DatePicker是三个滚轮或两个下拉框分开选年月日精确、快速手指不用大幅度滑动CalendarView是完整的月历网格用户能直观看到今天、周末、前后月份适合日历感强的业务场景。但CalendarView有个比较明显的问题——性能和自定义性都在中等偏下水平很多项目最终选择自己画日历这个我后面单独讲。TimePicker在Android 5.0之后分成了两种显示模式时钟模式和输入模式。时钟模式就是那个圆形的表盘你手指转动指针选时间输入模式则是直接显示小时和分钟的输入框。两种模式各有好处时钟模式直观输入模式快速。不过要注意的是系统会根据设备版本和主题自动决定显示哪种你可以用setIs24HourView()来强制切换24小时制但无法直接指定我要时钟模式还是输入模式。2.1 各个组件的继承关系与API定位从类关系上看DatePicker继承自FrameLayoutTimePicker也是FrameLayout的子类CalendarView则直接继承自ViewGroup。这意味着它们本质上都是View可以被放进任何布局里也可以动态addView。它们都通过init()方法或构造参数初始化状态然后通过setOnDateChangedListener()、setOnTimeChangedListener()这类回调对外通知数据变化。一个应该记住的点DatePicker和TimePicker都是被动输出型控件——用户操作后通过listener通知你你必须在回调里主动取值。它们不会自动保存状态如果你的Activity因为旋转屏幕等原因被重建你需要自己在onSaveInstanceState()里保存当前选中的年月日时分然后在新实例里恢复。2.2 一个核心选择XML直出还是Dialog封装很多新手第一次接触DatePicker直接就在布局文件里写了一个DatePicker标签放在页面底部然后发现这玩意儿占了半个屏幕——DatePicker在滚轮模式下高度接近500dp。这在平板或者专门的设置页里还能接受但在手机端的普通页面里就非常不协调。所以实际开发中DatePicker和TimePicker最常见的用法是被封装在Dialog对话框里用户点击EditText或者Button的时候弹出来选完就收回去不占用常驻页面空间。这个封装逻辑我后面在Demo部分会手把手写一遍现在你只需要记住布局文件里直接用DatePicker不是不行但95%的业务场景用Dialog封装才是正解。3. DatePicker实战日期选择从布局到监听DatePicker是这一篇里最值得花时间研究的组件因为它的属性比较多而且不同Android版本下外观差异非常大。3.1 XML布局里的关键属性DatePicker android:idid/datePicker android:layout_widthwrap_content android:layout_heightwrap_content android:datePickerModespinner android:calendarViewShownfalse android:firstDayOfWeek1 android:maxDate1735689599999 android:minDate0 android:headerBackgroundcolor/black android:headerDayOfMonthTextColorcolor/white android:headerMonthTextColorcolor/white android:headerYearTextColorcolor/white android:calendarTextColorcolor/white android:dayOfWeekBackgroundcolor/black android:dayOfWeekTextColorcolor/white android:endYear2100 android:startYear1900 /我来逐个拆解这些属性的含义datePickerMode这个最关键控制显示样式。spinner模式是三个并排的滚轮或者下拉列表紧凑小巧calendar模式是日历网格月份标题calendar模式下通常还会自带一个可展开的日历视图。calendarViewShown只有spinner模式下才有效设为false就是不显示下面的日历只看滚轮。一般都会关掉因为日历会大幅增加占用的高度。firstDayOfWeek设置每周第一天周一传1周日传7。默认跟随系统区域设置。minDate/maxDate限制可选日期范围注意单位是毫秒时间戳不是年或者天。startYear/endYear滚轮模式下年份滚轮的起止范围2000~2100这样。header*和dayOfWeek*、calendarTextColor这些是控制头部和日历文字颜色的不同主题下默认值可能不一致需要调试时微调。注意minDate和maxDate如果不设置默认大约从1900年1月到2100年12月基本能覆盖绝大多数业务需求不用特意改。3.2 代码里初始化与监听回调XML里定义好了还不够你需要拿到用户选中的日期。标准写法是这样的DatePicker datePicker findViewById(R.id.datePicker); // 初始化状态第二个参数到第四个参数分别是初始年、月、日 // 注意月从0开始0代表1月 datePicker.init(2025, 0, 20, new DatePicker.OnDateChangedListener() { Override public void onDateChanged(DatePicker view, int year, int monthOfYear, int dayOfMonth) { // 用户滚动任何一个滚轮都会触发这个回调 // monthOfYear是0~11如果你要拼接成1月~12月一定要加1 Log.d(DatePicker, 选择了: year - (monthOfYear 1) - dayOfMonth); } });这里有几个特别容易踩的坑第一个坑月份从0开始计数。DatePicker和CalendarView返回的月份都是0~11而日历上显示的和后台存数据的通常是1~12。忘了加1的话1月会变成0月12月会变成11月数据一入库就是错的。这个问题我见过太多人栽过。第二个坑init()方法只能调用一次。如果你在代码里想动态修改日期范围或者初始值不要再次调用init()而是要用updateDate(year, month, day)方法。重复调用init()在某些Android版本上会导致滚轮不刷新、回调不触发的诡异问题。第三个坑监听器注册的时间。init()执行的时候就会回调一次onDateChanged如果你在监听器里写了依赖数据的代码比如刷新其他控件就意味着初始化时也会执行一次。这未必是坏事但也可能造成不必要的重复刷新操作。有一个小技巧用一个boolean标志位来区分初始化回调和用户操作回调这样就能避免多余逻辑。3.3 代码动态设置日期范围XML里的minDate和maxDate可以直接写但更常见的是代码里动态设置比如做抢票功能只能选未来30天DatePicker datePicker findViewById(R.id.datePicker); // 获取当前日历 Calendar calendar Calendar.getInstance(); long now calendar.getTimeInMillis(); // 设置最小日期为今天 datePicker.setMinDate(now); // 设置最大日期为30天之后 calendar.add(Calendar.DAY_OF_MONTH, 30); datePicker.setMaxDate(calendar.getTimeInMillis());有人会问setMinDate()传的是毫秒值为什么不直接传302460601000这里有个隐蔽问题——如果把now 30天毫秒数直接设置进去看起来没问题但如果你在跨天、跨月、跨年的边界场景下计算容易出现偏差。最稳妥的做法还是用Calendar.add()去加天数让系统帮我们处理大小月、闰年的逻辑。另外setMinDate()和setMaxDate()设置完之后DatePicker内部会自动校验当前选中的日期是否还能落在合法区间里。如果之前选中的日期超出范围它会自动跳到范围内的最近值这个行为在部分国产ROM上可能会有兼容性问题实测中遇到过几次闪一下的情况但属于少见现象。4. TimePicker实战时间选择与模式细节TimePicker相对DatePicker来说简单不少就两个值小时和分钟。但越是简单越容易在细节上翻车。4.1 基础XML和代码用法TimePicker android:idid/timePicker android:layout_widthwrap_content android:layout_heightwrap_content android:timePickerModespinner /代码里的初始化和监听TimePicker timePicker findViewById(R.id.timePicker); // 设置24小时制显示注意这个方法的参数写死的是true/false // 如果你不调用系统会根据系统设置决定 timePicker.setIs24HourView(true); // 设置初始时间 timePicker.setHour(9); timePicker.setMinute(30); timePicker.setOnTimeChangedListener(new TimePicker.OnTimeChangedListener() { Override public void onTimeChanged(TimePicker view, int hourOfDay, int minute) { Log.d(TimePicker, 选择了: hourOfDay : minute); } });4.2 setHour和setMinute不是随便用的在Android 5.0之前TimePicker没有setHour()和setMinute()这两个方法是用setCurrentHour()和setCurrentMinute()。从API 23开始就废弃了旧方法改用新的setHour()和setMinute()。如果你还在用老写法编译期虽然能通过有Deprecated注解只是警告但运行时在Android 7.0以上可能出现兼容问题。setIs24HourView()这个方法也挺有意思。官方文档说它是设置是否以24小时制显示但实际效果是如果你传true会显示0~23点传false则显示1~12点并带AM/PM的切换按钮。这个设置只影响显示方式不管哪种显示方式回调里返回的hourOfDay都是0~23的小时值这一点很多人会误判——以为选择AM/PM模式后返回的是1~12。并不是系统在回调前已经自动转换好了。4.3 监听器触发时序的问题TimePicker的setOnTimeChangedListener比DatePicker的回调更敏感——不仅用户滚动滚轮会触发你用setHour()、setMinute()这些方法也会触发回调。所以在某些初始化场景下你可能会遇到回调多次执行的尴尬。举个例子你给TimePicker初始化一个值timePicker.setIs24HourView(true); timePicker.setHour(9); // 触发一次onTimeChanged timePicker.setMinute(30); // 又触发一次onTimeChanged实测结果是setHour()和setMinute()各触发一次回调也就是说连续执行两次刷新操作而用户最后看到的实际值是9:30中间那个9:xx的中间状态其实没人关心。如果回调里执行的是操作数据库、HTTP请求这类耗时操作就会出现两次请求。解法很简单初始化时用一个标志位屏蔽回调等初始化完成了再解除屏蔽。boolean isInitializing true; timePicker.setHour(9); timePicker.setMinute(30); isInitializing false; timePicker.setOnTimeChangedListener((view, hourOfDay, minute) - { if (isInitializing) { return; } // 处理用户真正的时间选择 });4.4 时钟模式下模拟表盘操作的经验如果你的设备显示的是时钟模式圆形表盘不是spinner模式需要注意几个操作上的反馈差异表盘上外圈是小时内圈是分钟中间有个白色小圆点用于切换。很多第一次用的用户会以为那个圆点是装饰实际上要拖动它才能选分钟。如果你选择了AM/PM制表盘上会单独显示AM/PM按钮需要用那个按钮切换上午下午转动指针不会自动切换AM/PM。时钟模式下选择小时松手后会自动切换到分钟选择不需要手动切换这个交互逻辑倒是不用担心。如果你想强制用spinner模式可以在XML里写android:timePickerModespinner。但要注意这个属性在Android 5.0API 21以上才有效老版本只有一种样式没法切换。5. CalendarView的取舍与真实定位CalendarView不是每个项目都用得上但只要涉及日历二字的界面很多人第一反应就是用它。我先给结论CalendarView适合快速验证和简单场景不适合做深度定制的商业项目。5.1 XML和代码基本用法CalendarView android:idid/calendarView android:layout_widthmatch_parent android:layout_heightmatch_parent android:firstDayOfWeek1 android:maxDate1700000000000 android:minDate1600000000000 android:selectedDayVerticalBarColorcolor/purple_500 android:weekNumberColorcolor/purple_500 android:selectedDateVerticalBardrawable/vertical_bar_selector /代码初始化CalendarView calendarView findViewById(R.id.calendarView); // 设置监听 calendarView.setOnDateChangeListener(new CalendarView.OnDateChangeListener() { Override public void onSelectedDayChange(CalendarView view, int year, int month, int dayOfMonth) { // 注意这个回调里只有年月日没有时分秒 // 月份同样是0~11 Log.d(CalendarView, 选中了: year - (month 1) - dayOfMonth); } }); // 获取当前选中日期毫秒时间戳 long selectedDate calendarView.getDate(); Calendar calendar Calendar.getInstance(); calendar.setTimeInMillis(selectedDate); int year calendar.get(Calendar.YEAR); int month calendar.get(Calendar.MONTH); // 0~11 int day calendar.get(Calendar.DAY_OF_MONTH);CalendarView有个特点它没有滚轮模式这种紧凑布局天生就是要占一大块面积所以一般只能放在全屏页面或者大卡片里。5.2 为什么很多项目不直接用CalendarView从我的经验看CalendarView在真实项目中的使用率远低于DatePicker原因有几方面外观定制困难。默认的灰色网格、默认的橙色选中圆角、固定的行高列宽这些都很难通过XML彻底改变。你想改成红色的选中日、圆角的日期格子、加底部今天标记CalendarView基本给不了你。硬改背景色很容易出现半透明错位、文字颜色看不清的问题。性能表现平平。当月份跨度大时CalendarView内部加载每个月的视图有不少开销滑动切换月份时会感觉到明显的卡顿特别是老机型上掉帧很常见。无法灵活标记特殊日期。比如休息日、假期、已预约、已抢购这些常见需求CalendarView原生都不支持你得自己画装饰。这其实是它最致命的地方——现代日历应用几乎都要求日期上有额外信息不给就不够用。所以到后期我做项目日历类需求大多直接选型第三方库比如MaterialCalendarView或者用RecyclerView自绘网格。但这种自绘方案复杂度比较高涉及每月的天数计算、首日是星期几的推算、选中态的绘制等等不太适合刚入门的朋友。如果你想练手也可以先拿CalendarView做一版粗糙的理解日历数据结构之后再考虑自绘。5.3 自己封装日历的成本评估如果决定自绘日历你需要提前想清楚这些模块每个月的天数自己写一个方法处理闰年2月或者直接用Calendar类的getActualMaximum(Calendar.DAY_OF_MONTH)这个API返回当月最大天数可以避开手写闰年逻辑的坑。当月第一天是星期几Calendar.getInstance()set(year, month, 1)get(Calendar.DAY_OF_WEEK)。网格布局用GridLayout或RecyclerView的GridLayoutManager7列固定。选中状态一个状态列表记录选中的年月日刷新ItemView的背景。月份切换动画ViewPager或ViewFlipper。根据我的经验初次做这个至少要两天期间还容易遇到各种状态不同步的bug。所以我的建议是项目要赶进度就用现成的开源库想学原理就花一个周末自己边写边调但不要在一开始就指望CalendarView能直接搬到生产环境。6. 格式化和时区日期时间最容易翻车的部分光会用组件还不够你选完日期之后总要转成字符串存起来或者把后台返回的字符串解析回组件。这块涉及SimpleDateFormat和时区问题是基础教程里容易被忽略但实战里当炸弹用的一环。6.1 SimpleDateFormat的正确打开方式import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; // 获取当前时间 Date now new Date(); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()); String formatted sdf.format(now); Log.d(Format, formatted); // 输出如 2025-01-20 14:35:09新手容易犯的错是漏掉第二个参数Locale。如果漏了它默认使用系统默认locale在某些语言环境下日期格式里的月份、星期的英文/本地化表达会变成语言文字比如January、二月而不是数字。尤其是用户把手机语言设成阿拉伯语等RTL语言时连数字都可能变成阿拉伯文数字解析会完全错乱。所以永远显式指定Locale这个习惯能避免90%的日期格式化兼容性问题。还有SimpleDateFormat是线程不安全的这是个老生常谈的问题。但很多新手还没到多线程那步先用不上太深的理论只要记住不要在多个线程里共享同一个SimpleDateFormat实例。最省心的做法是每次需要时new一个或者用ThreadLocal包一层看你的项目复杂度来定。6.2 时间戳、UTC与本地的三角关系服务器返回的日期通常是毫秒时间戳比如1737345600000这种长整数。这个数字代表的是从1970年1月1日00:00:00 UTC到那个时刻所经过的毫秒数它是一个绝对时刻和你在哪个时区无关。问题出现在把它转成可读字符串的环节。如果你在代码里这样写SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()); String timeString sdf.format(new Date(timestamp));它默认是用当前系统时区来转的。也就是说同一个时间戳在中国手机上是北京时间在美国手机上是纽约时间显示出来的字符串不一样。这在绝大多数业务里是正确的——你应该显示用户本地时间而不是服务器时间。但有一种情况会比较坑如果你们的后端存储的是日期字符串比如2025-01-20 10:00:00而不是时间戳那你解析的时候一定要跟后端确认清楚这个字符串是哪个时区的。很多后端程序员为了省事直接把服务器的本地时间比如UTC8的字符串塞给你你拿SimpleDateFormat解析默认会认为它是用户本地时区的一个时刻那么在不同时区的用户手机上显示出来的时间就错了几个小时。我的处理习惯是客户端和服务器统一用时间戳传输字符串只用于展示。这个约定能省去大量时区纠纷。6.3 给用户显示友好的相对时间拿到日期之后让用户看到2025-01-20 10:00:00这种精确时间在IM消息、评论列表这类场景并不友好。通常大家喜欢刚刚、5分钟前、3小时前、昨天 14:30、2024年12月30日这样分层次的人性化表达。实现思路不复杂public static String getFriendlyTime(long targetTime, long nowTime) { long diff nowTime - targetTime; if (diff 0) { return 时间错误; } if (diff 60 * 1000) { return 刚刚; } if (diff 60 * 60 * 1000) { return diff / (60 * 1000) 分钟前; } if (diff 24 * 60 * 60 * 1000) { return diff / (60 * 60 * 1000) 小时前; } // 超过24小时进一步判断是否属于昨天 Calendar targetCal Calendar.getInstance(); targetCal.setTimeInMillis(targetTime); Calendar nowCal Calendar.getInstance(); nowCal.setTimeInMillis(nowTime); if (targetCal.get(Calendar.YEAR) nowCal.get(Calendar.YEAR) targetCal.get(Calendar.DAY_OF_YEAR) nowCal.get(Calendar.DAY_OF_YEAR) - 1) { return 昨天 new SimpleDateFormat(HH:mm, Locale.getDefault()).format(new Date(targetTime)); } if (targetCal.get(Calendar.YEAR) ! nowCal.get(Calendar.YEAR)) { return new SimpleDateFormat(yyyy年M月d日, Locale.getDefault()).format(new Date(targetTime)); } return new SimpleDateFormat(M月d日 HH:mm, Locale.getDefault()).format(new Date(targetTime)); }这里注意昨天的判断逻辑直接用减法判断diff 48小时是不太对的因为昨天23点和今天凌晨1点之间的diff远小于48小时但语义上是昨天而非1小时前。用日历字段判断当前自然日与目标自然日相差1天才是正确姿势。7. 组合实战一个完整的选择生日并保存回显Demo光看API不过瘾我现在把整套流程串起来做一个点击按钮弹出日期时间对话框选完在TextView上回显的案例。这个案例几乎覆盖了我上面讲的所有知识点做完你就能直接套用到自己的项目里。7.1 需求拆解页面就三个元素一个EditText或者TextView点击后弹出选择框一个Button点击后把当前EditText里的值保存到SharedPreferences一个TextView启动时从SharedPreferences读上次保存的值并显示选择框需要同时选日期和时间也就是说要用到DatePicker和TimePicker两个组件放在同一个对话框里上下排列。7.2 用DialogFragment封装DatePicker TimePicker我推荐用DialogFragment而不是Dialog因为DialogFragment在手机旋转屏幕时能帮你管理系统回收和重建Dialog则完全依赖你手动处理状态。实际项目里这也是主流做法。public class DateTimePickerFragment extends DialogFragment { public interface DateTimeSetListener { void onDateTimeSet(int year, int month, int day, int hour, int minute); } private DateTimeSetListener listener; private Calendar initialDateTime; public static DateTimePickerFragment newInstance(Calendar initialDateTime) { DateTimePickerFragment fragment new DateTimePickerFragment(); Bundle args new Bundle(); args.putSerializable(initial_date_time, initialDateTime); fragment.setArguments(args); return fragment; } Override public void onAttach(Context context) { super.onAttach(context); if (context instanceof DateTimeSetListener) { listener (DateTimeSetListener) context; } else { throw new RuntimeException(宿主必须实现DateTimeSetListener); } } NonNull Override public Dialog onCreateDialog(Bundle savedInstanceState) { if (getArguments() ! null) { initialDateTime (Calendar) getArguments().getSerializable(initial_date_time); } if (initialDateTime null) { initialDateTime Calendar.getInstance(); } // 构建包含DatePicker和TimePicker的容器布局 LinearLayout container new LinearLayout(requireContext()); container.setOrientation(LinearLayout.VERTICAL); container.setPadding(30, 10, 30, 0); DatePicker datePicker new DatePicker(requireContext()); datePicker.setCalendarViewShown(false); datePicker.init( initialDateTime.get(Calendar.YEAR), initialDateTime.get(Calendar.MONTH), initialDateTime.get(Calendar.DAY_OF_MONTH), null ); TimePicker timePicker new TimePicker(requireContext()); timePicker.setIs24HourView(true); timePicker.setHour(initialDateTime.get(Calendar.HOUR_OF_DAY)); timePicker.setMinute(initialDateTime.get(Calendar.MINUTE)); container.addView(datePicker); container.addView(timePicker); return new AlertDialog.Builder(requireContext()) .setTitle(请选择日期和时间) .setView(container) .setPositiveButton(确定, new DialogInterface.OnClickListener() { Override public void onClick(DialogInterface dialog, int which) { int year datePicker.getYear(); int month datePicker.getMonth(); int day datePicker.getDayOfMonth(); int hour timePicker.getHour(); int minute timePicker.getMinute(); if (listener ! null) { listener.onDateTimeSet(year, month, day, hour, minute); } } }) .setNegativeButton(取消, null) .create(); } }这里有几个细节值得说DialogFragment的传输数据不要直接塞对象我用Serializable其实是一种偷懒做法更规范的是传year、month、day三个int分开塞Bundle。如果你传的是Calendar对象虽然Serializable接口能跑通但在跨进程或低内存场景下可能有隐患。实际生产项目我更推荐传long型时间戳恢复时再转Calendar。DatePicker在代码里new出来也能直接用不一定要在XML里声明。你可以用setCalendarViewShown(false)关掉内置日历保持滚轮模式。TimePicker先setHour再setMinute这个顺序没强制要求但因为setHour()和setMinute()都会触发一次回调如果你设置了监听器所以要尽量避免在同一个作用域里设置多个监听器导致重复回调。7.3 宿主的调用与回显public class MainActivity extends AppCompatActivity implements DateTimePickerFragment.DateTimeSetListener { private static final String PREFS_NAME my_prefs; private static final String KEY_BIRTHDAY birthday; private EditText editBirthday; private TextView textSaved; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); editBirthday findViewById(R.id.edit_birthday); textSaved findViewById(R.id.text_saved); editBirthday.setOnClickListener(v - { // 从EditText当前文本解析出初始时间初始化默认值是当前时间 DateTimePickerFragment fragment DateTimePickerFragment.newInstance(Calendar.getInstance()); fragment.show(getSupportFragmentManager(), DateTimePicker); }); // 回显上次保存的值 SharedPreferences prefs getSharedPreferences(PREFS_NAME, MODE_PRIVATE); String saved prefs.getString(KEY_BIRTHDAY, null); if (saved ! null) { textSaved.setText(已保存的生日: saved); } } Override public void onDateTimeSet(int year, int month, int day, int hour, int minute) { month month 1; // 转成1~12月 String formatted String.format(Locale.getDefault(), %d年%d月%d日 %02d:%02d, year, month, day, hour, minute); editBirthday.setText(formatted); editBirthday.setSelection(editBirthday.getText().length()); } }这个Demo里我用的是最简单的String.format没有引入SimpleDateFormat主要原因是这些字段本来就是整数没必要再包一层Date对象。但如果你要做更复杂的显示例如排序、比较、转时间戳建议还是把年/月/日/时/分组合成Calendar对象再统一转换为时间戳或格式化字符串数据处理更规范。还需要一个隐藏技巧如果EditText不仅仅是想当显示控件还希望用户能手动清除内容而不是只靠对话框填写你可以在点击事件里判断一下TextUtils.isEmpty(editBirthday.getText())为空时直接把初始Calendar设为当前时间不为空时可以从当前文本解析出上次时间。不过解析文本属于进阶内容涉及格式检测这篇先不展开。7.4 SharedPreferences保存与读取的注意点我把保存放在一个独立的保存按钮里这样逻辑更清晰Button btnSave findViewById(R.id.btn_save); btnSave.setOnClickListener(v - { String birthday editBirthday.getText().toString().trim(); if (birthday.isEmpty()) { Toast.makeText(this, 请先选择日期, Toast.LENGTH_SHORT).show(); return; } getSharedPreferences(PREFS_NAME, MODE_PRIVATE) .edit() .putString(KEY_BIRTHDAY, birthday) .apply(); Toast.makeText(this, 保存成功, Toast.LENGTH_SHORT).show(); textSaved.setText(已保存的生日: birthday); });这里有意选择存字符串而不是存mm/dd/yyyy这几个字段是因为在这个Demo里我们只需要展示给用户看没有后续复杂的逻辑运算。如果后续真的要算年龄、星座之类的存储时建议存标准时间戳因为字符串格式容易混入中文、斜杠、短横线解析时还得来回试探。8. 我踩过的坑和写组件时的习惯最后这一部分我把自己这几年在日期时间组件上积累的问题和处理习惯列一下作为整个教程的收尾。这里面有些坑你可能現在还碰不到但遇到了再回来看能省不少事。8.1 高频Bug清单与解决方案问题现象根因解决办法月份显示总比实际少1个月DatePicker/CalendarView返回的month是0~11展示时1存入后台时先-1设置了minDate但日期仍可往前选XML里的minDate用了2025-01-01这种字符串格式必须使用毫秒时间戳TimePicker的AM/PM切换后回调值不变系统已经自动转换hourOfDay为0~23不要自己再处理AM/PM逻辑直接用回调值对话框弹出时DatePicker没选中今天没调用init()或updateDate()设置初始值初始化时传入Calendar实例手机旋转屏幕后对话框消失或状态丢失直接用了AlertDialog没有用DialogFragment统一改用DialogFragment持有并恢复状态SimpleDateFormat在多线程环境下格式化乱掉SimpleDateFormat线程不安全每次新建实例或用ThreadLocal包装时间戳转字符串总差8小时服务器返回字符串但没带时区信息客户端按本地时区解析了让后端改用时间戳传输不要传裸字符串8.2 我的几个编码习惯第一所有跟日期时间有关的工具方法统一放进一个DateUtils类不要散落在各个Activity里。项目大了之后这类方法会频繁复用集中管理能让你在改某个逻辑时不用到处找调用点。我一般会在类顶上写注释说明本类所有方法均以毫秒时间戳为基准单位输出字符串统一使用yyyy-MM-dd HH:mm:ss格式。第二调用init()和设置监听的顺序要固定。我习惯先初始化数据再注册监听器避免初始化状态被当作用户操作处理。有的版本上初始化时机选错了还会导致第一次滚动失效要小心。第三时间戳永远用long类型接收。后端返回的有时是字符串数字比如1737345600000你Integer.parseInt()会直接崩报NumberFormatException因为超出了int的最大范围。用Long.parseLong()就不会有这个问题。这两个类型转换错误在整个Android开发里出现的频率高得惊人不止在日期组件这一块。第四测试时要特意覆盖几个边界日期2月29日闰年、12月31日跨年、每月1号、星期日和星期一分界、23:59到00:00的切换。不要只看正常日子很多日期组件的Bug都是在边界值上崩出来的。最后提醒一句Android不同机型、不同系统版本对DatePicker、TimePicker的默认视觉风格差异非常大同一套代码在原生安卓上可能是一个样子在国产定制ROM上又是另一个样子。在做UI走查的时候日期时间类控件要多找几台设备看看效果不要只盯着你自己的测试机。如果项目对UI一致性要求特别高那就需要考虑自定义View或者开源库了这部分我会留在下篇继续写——包括定时任务AlarmManager、倒计时CountDownTimer、以及各种第三方日期时间库的选型对比。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑