Android P的APP适配总结,让你快人一步
欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~
上篇:Android P 行为变更适配
Android P 这次有很多行为变更,其中不乏一些需要亟需适配的变更。
一、全面屏检测
在 Android 8.0 时代各个手机厂商就开始发布自己的全面屏手机,但是此时 Android 官方并未支持到该功能,所以各个厂商都各自实现了一套全面屏判断逻辑,对于开发者来说甚是麻烦。终于在 Android P 里官方收归了该功能的判断逻辑,Android P 和之后的版本完全可以使用官方 API 来判断全面屏,当然前提是第三方厂商按照 google 官方接口去实现。Android P 版本判断全面屏代码很简单,但是在适配过程中你可能会在网上发现如下判断代码:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
decorView.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
@RequiresApi(api = 28)
@Override
public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
if (windowInsets != null) {
DisplayCutout cutout = windowInsets.getDisplayCutout();
if (cutout != null) {
List<Rect> rects = cutout.getBoundingRects();
//通过判断是否存在rects来确定是否全面屏手机
if (rects != null && rects.size() > 0) {
isNotchScreen = true;
}
}
}
return windowInsets;
}
});
}
这段代码确实可以判断出全面屏与否,但是会造成一个很严重的后果,就是在某些手机(pixel 和 vivo x21 均出现该情况)上底部导航栏会透明,导致应用内容会透到导航栏从而被遮挡,大大影响内容展示。最后经过仔细排查发现仅仅因为在上面那段代码中调用了 setOnApplyWindowInsetsListener
函数,该函数在 Android 官网有详细介绍,是用来在 Android 21 版本之后代替 fitSystemWindows
函数,目的是让 View 根据 Window 的缩进进行相应处理,调用后会影响系统状态栏和导航栏对应用内容的展示,对此的介绍资料网上有很多,就不赘述了。真正完美判断全面屏的代码如下:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
WindowInsets windowInsets = decorView.getRootWindowInsets();
if (windowInsets != null) {
DisplayCutout displayCutout = windowInsets.getDisplayCutout();
if (displayCutout != null) {
List<Rect> rects = displayCutout.getBoundingRects();
//通过判断是否存在rects来确定是否刘海屏手机
if (rects != null && rects.size() > 0) {
isNotchScreen = true;
}
}
}
}
二、非 SDK API 适配详解
2.1 非 SDK API 名单介绍
Android P 版本最大最严格的特性变更应该非 SDK 接口限制莫属了。对于非 SDK API 里面的部分名单来说,就算在不修改 targetSdkVersion 的前提下,不管是直接、反射还是通过 JNI 调用都会造成调用失败、抛出 NoSuchFieldException
或 NoSuchMethodException
等严重后果,该行为影响范围波及所有调用此接口的应用。
非 SDK API 名单总共分为三类:light grey list (浅灰名单)、dark grey list (深灰名单)、dark list(黑名单),详情:
2.2 非 SDK API 名单扫描
所以对于我们应用开发者来说,当前首要任务是适配深灰名单和黑名单。目前 google 官方提供了一个可以实时查询三个名单里面 API 列表的网站:https://android.googlesource.com/platform/frameworks/base/+/master/config/。在之前 DP 版本时开发者如果遇到了不得不使用的黑名单或者深灰名单 API,需要向 google 官方及时提出反馈(反馈url:https://issuetracker.google.com/issues/new?component=328403&template=1027267),申请将其移动到浅灰名单中,但是目前正式版本已经发布,未得知该申请通道是否仍有效。
详细了解了非 SDK API 之后,下一步当然是将应用代码里面的深灰名单和黑名单 API 调用找出来一一修改。目前官方提供了一个非常实用的扫描工具,该工具可以把应用里面三个类型名单的 API 调用都扫描出来(但是可能会有遗漏),使用方法也很简单:
- 打包一个应用 APK,建议使用 release 包,排除一些未使用到的单元测试类或者其他因素的影响,将 APK 放到工具指定目录下;
- 执行命令
./appcompat.sh --dex-file=test.apk
,在终端上会输出三个名单每个 API 的详细调用处: #1: Linking dark greylist Landroid/os/SystemProperties;->get(Ljava/lang/String;)Ljava/lang/String; use(s): Ltmsdkobf/gv;->a(Ljava/lang/String;)Ljava/lang/String; #2: Linking dark greylist Landroid/os/SystemProperties;->get(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; use(s): Ltmsdkobf/gp;->b(Landroid/content/Context;)Ljava/lang/String; ....
2.3 非 SDK API 适配
经过上一步扫描出应用内非 SDK API 调用之后,接下来就可以直接开始适配。适配的原则是优先黑名单和深灰名单,浅灰名单在官方未有替代 API 之前可以暂时不适配,在 Android P 上运行也不会有任何问题。扫描完成之后,不出意外大家应该会有三类需要适配的 API 调用:
- 应用代码本身调用到了非 SDK API 接口; 针对应用代码本身调用到了非 SDK API 接口,用的比较频繁的例如
SystemProperties.get
,就需要去寻找另外一个可以替代的合法 API,如果找不到就只能认为该 API 调用失败从而走失败逻辑,如果实在必须要用到该 API 就尽早去向 google 申请移动到浅灰名单中。 - 第三方库调用到了非 SDK API 接口; 针对第三方库调用到了非 SDK API 接口,解决办法当然是直接查询相关资料或者联系库提供方,确认是否有适配 Android P 新版本的 SDK。还有需要提到的一点,就算更换适配完成的第三方 SDK 后,仍然可能会在同一地方扫描出非 SDK API 的调用,这是因为适配工程师只是在调用处加了一个 try-catch 保护逻辑,虽然这样也勉强叫做适配完成,但是还是强烈建议大家使用如下的适配方式: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // Android P or above } else { // below Android P } 严格按照上面的适配方案,扫描工具就不会再扫描出此处的非 SDK API 调用,我们也无需每次都去确认所有非 SDK API 调用处都加了保护逻辑。 当然如果第三方库没有适配也没有近期适配的意向,目前有两种方法:第一种是屏蔽入口;第二种是反编译 SDK,在关键地方加上适配代码;
- Android 官方库调用到了非 SDK API 接口; 没错!Android 官方库也会被扫描出非 SDK API 调用,针对这种情况,需要分情况讨论:
该 API 调用查看 v7 support 包源码可以发现已经被 try-catch 住了,测试了相关类也可以正常运行,而且在适配过程中升级 rc 版本的 support-v7 包会导致应用编译不过,所以目前 QQ 音乐暂时认定无需升级到最新版本的 support-v7。除上面介绍的特殊情况之外还是建议更换最新版本的官方 SDK。
三、电源管理改进
3.1 应用待机群组
Android P 上对电源管理又做了一系列的改进措施,不管应用 targetApi 版本是否已经升级到 P,系统都会依据应用最近的使用时间和频率来给应用进行待机分组,然后根据应用所属群组限制应用可以访问的资源,目前总共有五类分组:
- 活跃: 一般为正在使用或者在前台运行的应用,例如:
- 应用启动一个 Activity;
- 应用正在运行前台 Service;
- 应用的同步适配器关联上了一个前台应用;
- 用户点击了应用的一个通知; 系统不会对该类应用有任何的限制;
- 工作集: 应用经常运行,但是当前未属于活跃状态就会被归属于工作集,该群组的应用在运行作业和触发闹钟方面会被施加轻度的限制;
- 常用: 应用如果被定期使用,但不是每天的话就会被归到该工作群组。该群组的应用在运行作业和触发闹钟方面会被施加较强的限制,FCM 消息数量也会有相关限制;
- 极少使用: 应用如果不经常使用就会被归到该工作群组,系统会对该群组应用运行作业、触发闹钟和接收高优先级别 FCM 的消息能力方面有严格的限制;
- 从未使用: 安装但从未被使用过的应用会被归到该工作群组,该工作群组的应用会被施加极其严格的限制;
更加详细的表述可以参考官网:App Standby Buckets(https://developer.android.com/about/versions/pie/power),不同群组的限制的详细表现见:Power management restrictions(链接:https://developer.android.com/topic/performance/power/power-details)。系统会动态的将手机里面的应用分配到这五类群组里面,也会根据需要变化应用群组,同时借助了机器学习来将一个应用放到更合适的群组里。目前应用可以通过 UsageStatsManager.getAppStandbyBucket()
函数来获取当前所属的应用群组,借助这个结果来更好的提升自己的打开频率,同时可以借助此来模拟处于不同群组能否正常工作。另外,位于低电耗模式白名单中的应用不适用基于应用待机群组的限制。
3.2 省电模式改进
Android 9 对省电模式又做了很多改进,开启省电模式之后会有如下限制:
- 系统会更加积极的将应用置于待机模式,不管应用是否空闲;
- 后台执行限制将适用于所有应用,无论他们的 targetApi 是多少;
- 屏幕关闭时,位置服务可能被停用;
- 后台应用没有网络访问权限;
这里需要重点介绍一下后台执行限制,该限制于 Android O 版本引入,主要是为了优化 Android 在多应用多服务运行时,系统负载过大会杀死后台音乐播放等服务导致用户体验下降的问题,它默认只对 targetApi 大于等于 26 的应用生效。目前用户可以通过设置页面对任意应用施加后台执行限制,后台执行限制会对应用有两方面的影响:
- 后台服务限制: 处于前台(可见、具有前台服务或者关联到前台应用)或临时白名单(处理高优先级 FCM、接收短信等广播或者执行通知的
PendingIntent
)时,应用可以自由创建和运行前台与后台服务。 进入后台时,在一个持续数分钟的时间窗内,应用仍可以创建和使用服务,但是超过该时间之后再通过 startService 去启动一个服务就会抛出java.lang.IllegalStateException: Not allowed to start service Intent
的错误,解决办法是使用startForegroundService
或者JobIntentService
; - 广播限制: 针对 Android O 和之上的应用无法继续在其清单中为隐式广播注册广播接收器。
四、Apache HTTP client 相关类找不到
将 compileSdkVersion 升级到 28 之后,如果在项目中用到了 Apache HTTP client 的相关类,就会抛出找不到这些类的错误。这是因为官方已经在 Android P 的启动类加载器中将其移除,如果仍然需要使用 Apache HTTP client,可以在 Manifest 文件中加入:
<uses-library android:name="org.apache.http.legacy" android:required="false"/>
或者也可以直接将 Apache HTTP client 的相关类打包进 APK 中。
除上面两种适配方式外,QQ 音乐目前采用了另外一种方式。在音乐项目中,我们已经将使用 Apache HTTP client 的模块单独抽离到了一个 module 中,所以暂时只需要保持 module 中的 compileSdkVersion 在 28 以下即可正常编译运行。
五、其余适配
4.1 前台 Service
在 Android P 中,如果 targeSdkVersion 升级到 28,使用前台 Service 必须要申请 FOREGROUND_SERVICE
权限,如果没有申请该权限,系统会抛出 SecurityException
,该权限为普通权限,申请自动授予应用。
4.2 隐私安全保护
- Build.SERIAL 标识修改:在 Android P 中,对隐私保护又做了更加严格的要求。在某些应用中为了识别手机的唯一性可能会用到 Build.SERIAL 这个标识,但这个标识在 Android P 中已经被设置成了
UNKNOWN
,所以会直接导致该功能出现异常。 - 多进程 webview 信息访问限制:在 Android P 中为了提升系统的安全性,用户无法在多进程的 webview 中共享数据目录,该目录下存储的是一些 cookies、Http 缓存和其他一些永久、临时的缓存。当下不少应用会把 webview 放在另一个进程中打开以避免内存泄漏,但是他们 cookies 的设置往往还是在主进程中,所以开发者需要仔细排查自己的应用是否有这么使用,webview 相关运行是否正常等。
4.3 com.android.internal 包下某些类找不到
升级到 28 之后,应用编译后抛出 com.android.internal
包下面有些类找不到的异常,经过查找发现这些类已经从 SDK 中移除。针对这种情况目前有两种处理办法:
- 移除该类的调用逻辑;
- 在应用中新建一个同名类,将被移除类的所有代码逻辑复制到新建类中(必要时可能需要将被移除类相关类同时拷贝一份到应用中),然后将应用中所有相关 import 引用直接修改成新建类的包名引用即可;
下篇:Android P 实用新特性
Android P 这次当然也有很多丰富的特性,总结了两个对于第三方应用开发者比较实用的特性
。
一、HEIF 图片格式支持
HEIF(High Efficiency Image Format),高帧率图片格式,采用的是 HEVC 编码格式。苹果于 iOS11 版本开始支持该图片格式,而 Android 则是在 Android O MR1 版本开始支持 HEIF 静态图的软解码,在 P 版本上完全支持该格式的软编解码。HEIF 格式的压缩率是 JPEG 的 2.39 倍,同等大小质量的图片可节省 50% 的空间和网络传输流量,而且支持动图。HEIF 格式比起 GIF 格式来说有着更好的图片展示效果,所以 HEIF 格式图片的目标是用来代替 JPEG 成为主流的图片压缩格式。HEIF 格式图片的扩展名为 .heif 或者 .heic:
HEIF | WebP | JPEG | |
---|---|---|---|
最大尺寸 | 无上限 | 16383x16383 | 65535x65535 |
编码 | HEVC | VP8 | JPEG |
是否支持其他编码 | YES | NO | NO |
支持音频/文字 | YES | NO | NO |
支持多图片 | YES | YES | NO |
支持裁剪 | YES | NO | NO |
支持透明 | YES | YES | NO |
支持缩略图 | YES | NO | YES |
分块加载 | YES | NO | NO |
看上去很美好,但是目前还不是所有的 Android P 机型都会支持 HEIF 格式硬编解码,因为这需要特殊的硬件支持同时还需要缴纳一定的专利费,所以在编解码效率上就会有机型差异,同时 Android P 软编解码也只能支持静态 HEIF 格式图片。目前开发者可以通过版本来判断是否支持 HEIF 编解码,判断逻辑如下:
fun supportHEIF() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
解码代码也很简单,支持将 HEIF 格式图片解码成 Bitmap 和 Drawable:
@TargetApi(28)
fun decodeHEIFDrawable(filePath: String): Drawable? {
if (!supportHEIF()) {
return null
}
var source: ImageDecoder.Source = ImageDecoder.createSource(File(filePath))
return ImageDecoder.decodeDrawable(source)
}
@RequiresApi(28)
fun decodeHEIFBitmap(filePath: String): Bitmap? {
if (!supportHEIF()) {
return null
}
var source: ImageDecoder.Source = ImageDecoder.createSource(File(filePath))
return ImageDecoder.decodeBitmap(source)
}
另外扫描本地图片则继续使用 ContentResolver 即可,如果设备支持 HEIF 格式,系统会自动扫描上 HEIF 格式的图片:
var cursor : Cursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)
但是这样还远远没有适配完成,第三方应用适配 HEIF 格式图片有一个很困难的地方是本地虽然可以识别解码 HEIF 格式的图片,但是如果某个用户将其设置为头像上传到后台,后台将其下发给其他不支持 HEIF 图片格式解码的手机,这些手机就肯定有展示问题。解决这个问题目前有两种思路:
- 终端在上传之前将其转码成 JPEG 格式的图片,但是这样就根本没有充分利用到 HEIF 图片的高压缩率的优势;
- 在到达后端之后,后端将其转码成 JPEG 图片,同时保存一份 HEIF 和 JEPG,到时候根据用户是否可以解码 HEIF 下发不同格式图片。该方案可以充分利用 HEIF 的优点,但是大大增加了后端存储空间和开发工作量。
二、ImageDecoder
上面已经介绍到了 ImageDecoder
在解码 HEIF 图片中的应用,但是实际它的功能完全不仅于此,在 Android P 中它可以完全替代 BitmapFactory
和 BitmapFactory.Options
相关类。ImageDocoder
类可以通过字节数据、文件和 URI 来解码一张图片。用法和之前一样,首先通过 createSource
方法创建一个图片文件的 ImageDecoder.Source
对象,然后调用 decodeDrawable
或者 decodeBitmap
方法传入之前的 ImageDecoder.Source
对象就能生成图片的 Drawable 或者 Bitmap 对象引用。ImageDecoder
支持 PNG、JPEG、WEBP、GIF 和 HEIF 多种格式图片的解码,另外解码 GIF 或者 WEBP 格式图片得到的是一个 AnimatedImageDrawable
对象,AnimatedImageDrawable
类的工作原理和 AnimatedVectorDrawable
类似,都是使用一个工作线程来解码,所以解码线程和显示线程互不干扰。AnimatedImageDrawable
用法也很简单:
var drawable: Drawable = ImageDecoder.decodeDrawable(source);
if (drawable is AnimatedImageDrawable){
image.setImageDrawable(drawable)
drawable.start()
}
ImageDecoder
除了基础的解码功能之外,还有很多非常实用的方法,比如通过设置 OnHeaderDecodedListener
就可以在解析图片之前获取到图片的宽高等信息,同时还可以根据需要设置采样率:
val listener = object : OnHeaderDecodedListener {
fun onHeaderDecoded(decoder: ImageDecoder, info: ImageInfo, source: Source) {
decoder.setTargetSampleSize(2)
}
}
val drawable = ImageDecoder.decodeDrawable(source, listener)
另外还可以通过 setPostProcessor
方法来添加一些自定义的效果,比如最常用的切圆角:
var drawable = ImageDecoder.decodeDrawable(source) { decoder, info, src ->
decoder.setPostProcessor { canvas ->
val path = Path()
path.setFillType(Path.FillType.INVERSE_EVEN_ODD)
val width = canvas.getWidth()
val height = canvas.getHeight()
path.addRoundRect(0, 0, width, height, 20, 20, Path.Direction.CW)
val paint = Paint()
paint.setAntiAlias(true)
paint.setColor(Color.TRANSPARENT)
paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.SRC))
canvas.drawPath(path, paint)
PixelFormat.TRANSLUCENT
}
}
非常便捷。用法远不仅于此,有了 Canvas 对象,开发者完全可以发挥想象去实现自己想要的炫酷效果。另外如果解码的图片不完整或者包含错误,一般情况下会抛出 DecodeException
,但是如果这个时候通过 setOnPartialImageListener
函数传递一个 OnPartialImageListener
对象,并且在 onPartialImage
函数中返回 true,则图片就会只展示解析成功的一部分而不会抛出 DecodeException
:
var drawable = ImageDecoder.decodeDrawable(source) { decoder, info, src ->
decoder.setOnPartialImageListener { e: ImageDecoder.DecodeException ->
true
}
}
引用
https://developer.android.google.cn/about/versions/pie/android-9.0 https://mp.weixin.qq.com/s/03ospQEdY5HLdYqxEiDX1g https://blog.csdn.net/GenlanFeng/article/details/79496359 https://developer.android.com/about/versions/pie/power https://segmentfault.com/a/1190000015947004
问答
Android - 如何修复权限异常?
相关阅读
Android音频系统
Android 基本常识
Android全局异常处理
【每日课程推荐】机器学习实战!快速入门在线广告业务及CTR相应知识
此文已由作者授权腾讯云+社区发布,更多原文请点击
搜索关注公众号「云加社区」,第一时间获取技术干货,关注后回复1024 送你一份技术课程大礼包!
海量技术实践经验,尽在云加社区!
Android P的APP适配总结,让你快人一步的更多相关文章
- Android屏幕相关概念和适配方法
参考文档: 1.http://blog.csdn.net/carson_ho/article/details/51234308(略有修改) 2.http://www.cnblogs.com/cheng ...
- Android通知栏介绍与适配总结(上篇)
此文已由作者黎星授权网易云社区发布. 欢迎访问网易云社区,了解更多网易技术产品运营经验. 由于历史原因,Android在发布之初对通知栏Notification的设计相当简单,而如今面对各式各样的通知 ...
- Android通知栏介绍与适配总结
由于历史原因,Android在发布之初对通知栏Notification的设计相当简单,而如今面对各式各样的通知栏玩法,谷歌也不得不对其进行更新迭代调整,增加新功能的同时,也在不断地改变样式,试图迎合更 ...
- Android 7.1 - App Shortcuts
Android 7.1 - App Shortcuts 版权声明:本文为博主原创文章,未经博主允许不得转载. 微博:厉圣杰 源码:AndroidDemo/Shortcuts 文中如有纰漏,欢迎大家留言 ...
- Android中实现APP文本内容的分享发送与接收方法简述
谨记(指定选择器Intent.createChooser()) 开始今天的内容前,先闲聊一下: (1)突然有一天头脑风暴,对很多问题有了新的看法和见解,迫不及待的想要分享给大家,文档已经写好了,我需要 ...
- Android 7.1 App Shortcuts使用
Android 7.1 App Shortcuts使用 Android 7.1已经发了预览版, 这里是API Overview: API overview. 其中App Shortcuts是新提供的一 ...
- 在布局中使用android.support.v4.app.Fragment的注意事项
1.Activity必须继承android.support.v4.app.FragmentActivity 2.fragment标签的name属性必须是完全限定包名,如下: <LinearLay ...
- Android 中如何计算 App 的启动时间?
(转载) 已知的两种方法貌似可以获取,但是感觉结果不准确:一种是,adb shell am start -w packagename/activity,这个可以得到两个值,ThisTime和Total ...
- 如何让你的App适配iOS7?
随着苹果在2013年9月18日发布iOS7最新的系统以来,iOS各种设备升级到iOS7的数字就已经不断刷新记录.目前据外界统计iOS7设备装机量已经达到2.5亿部,已占iOS设备的64%.由此可见让自 ...
随机推荐
- [原创]K8Cscan插件之Cisco思科设备扫描(IP、设备型号、主机名、Boot、硬件版本)
[原创]K8 Cscan 大型内网渗透自定义扫描器 https://www.cnblogs.com/k8gege/p/10519321.html Cscan简介:何为自定义扫描器?其实也是插件化,但C ...
- yolov3中 预测的bbox如何从特征图映射到原图?
Anchor Box的边框 选取标准的k-means(欧式距离来衡量差异),在box的尺寸比较大的时候其误差也更大,而我们希望的是误差和box的尺寸没有太大关系.所以通过IOU定义了如下的距离函数,使 ...
- [学习笔记]修改关键跳无效且关键CALL又不存在的情况
先用DI查下壳,VC++写的,无壳. 然后,打开软件看一下软件注册的情况 有弹窗,那载入OD看看能不能搜索到字符串 回到反汇编窗口,发现有两个JE都跳过了注册成功的代码 似乎很简单的样子,只要NOP掉 ...
- Android--Menus
前言 本篇博客讲解一下菜单Menu的使用.菜单在windows应用中使用十分广泛,几乎所有的windows应用都有菜单,Android中也加入了菜单的支持.从官方文档了解到,从Android3.0(A ...
- 剑指offer-学习笔记
前言:18/06/06开始学习,每个程序都会用C写一遍,因书中用C++举例,也会换种思路写,供学习和参考!!!很推荐这本书很不错,准备入手,一般不买实体书,都用电子书,因一般都看一遍,但这本会看很多遍 ...
- 小程序开发--template模板
小程序的template模板可以说是我遇到的最简单的了,看看实例: <template name="article"> <view class='containe ...
- MongoDB分片详解
分片是MongoDB的扩展方式,通过分片能够增加更多的机器来用对不断增加的负载和数据,还不影响应用. 1.分片简介 分片是指将数据拆分,将其分散存在不同机器上的过程.有时也叫分区.将数据分散在不 ...
- 【SQL跟踪工具】SQL Profiler 跟踪器
什么是SQL Profiler SQL Server Profiler 是一个功能丰富的界面,用于创建和管理跟踪并分析和重播跟踪结果. 事件保存在一个跟踪文件中,稍后试图诊断问题时,可以对该文件进行分 ...
- [转]使用jenkins实现持续集成
本文转自:https://www.cnblogs.com/zishengY/p/7170656.html 一.jenkins 介绍 它是一个自动化的周期性的集成测试过程,从检出代码.编译构建.运行测试 ...
- [nodejs] nodejs开发个人博客(一)准备工作
前言 nodejs是运行在服务端的js,基于google的v8引擎.个人博客系统包含对数据库的增删查改,功能齐备,并且业务逻辑比较简单,是很多后台程序员为了检测学习成果,最先拿来练手的小网站程序.我也 ...