Android深色模式适配指南

Android 深色模式(夜间模式)适配指南

Android 10 (API 级别 29) 及更高版本中提供深色主题背景。深色主题背景具有诸多优势:

  • 可大幅减少耗电量(具体取决于设备的屏幕技术)。
  • 为弱视以及对强光敏感的用户提高可视性。
  • 让所有人都可以在光线较暗的环境中更轻松地使用设备。

0x01 DayNight 主题适配深色模式

将应用的主题背景(通常可在 res/values/styles.xml 中找到)设置为继承 DayNight 主题背景。

1
<style name="AppTheme" parent="Theme.AppCompat.DayNight">

用到的资源和颜色需要在 -night 目录中重新配置一份。

在暗黑模式下,系统会优先从 -night 后缀的目录下找到对应的资源配置。

0x02 Force Dark 自动适配深色模式

如果您的应用采用浅色主题背景,则 Force Dark 会分析应用的每个视图,并在相应视图在屏幕上显示之前,自动应用深色主题背景。

Force Dark 应用需要满足以下三个条件

  • 使用系统及 AndroidX 提供的浅色主题背景(例如 Theme.Material.Light
  • 在其主题背景中设置 android:forceDarkAllowed="true"
  • 手机系统启用深色模式,Android 10 (API 级别 29)以上

如果您的应用使用深色主题(例如 Theme.Material),或者继承自 DayNight 主题背景,则系统不会应用 Force Dark。

在特定 View 上停用 Force Dark,可以通过 android:forceDarkAllowed 布局属性或 setForceDarkAllowed()

0x03 动态设置深色模式

如要切换主题背景,请调用 AppCompatDelegate.setDefaultNightMode()。

每个选项直接映射到以下某个 AppCompat.DayNight 模式:

  • 浅色 - MODE_NIGHT_NO
  • 深色 - MODE_NIGHT_YES
  • 由省电模式设置 - MODE_NIGHT_AUTO_BATTERY ( Android 9 或更低版本的设备上)
  • 系统默认 - MODE_NIGHT_FOLLOW_SYSTEM ( Android 10 (API 级别 29) 及更高版本上)

注意:从 AppCompat v1.1.0 开始,setDefaultNightMode() 会自动重新创建任何已启动的 Activity。

0x04 切换深色模式不重建 Activity

当应用的主题背景发生更改(无论是通过系统设置还是 AppCompat)时,会触发 uiMode 配置变更。这意味着系统会自动重新创建 Activity。

在某些情况下,您可能希望应用处理配置变更。例如,您可能希望延迟配置变更时间,因为设备正在播放视频。

应用可以声明,每个 Activity 都可以处理 uiMode 配置变更,以自行处理深色主题背景的实现:

1
2
3
<activity
android:name=".MyActivity"
android:configChanges="uiMode" />

当某个 Activity 声明它会处理配置变更时,系统会在出现主题背景变更时调用该 Activity 的 onConfigurationChanged() 方法。

0x05 判断是否是深色模式

0x0501 判断当前 APP 是否是深色模式

如要检查当前采用的是哪种主题背景,应用可以运行如下代码:

1
2
3
4
5
val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
when (currentNightMode) {
Configuration.UI_MODE_NIGHT_NO -> {} // Night mode is not active, we're using the light theme
Configuration.UI_MODE_NIGHT_YES -> {} // Night mode is active, we're using dark theme
}

0x0502 判断当前手机系统是否是深色模式

如果要判断当前手机系统是否是深色模式,可以使用以下代码:

1
2
3
4
private fun isSystemNightMode(activity: Activity): Boolean {
val uiModeManager = activity.getSystemService(Context.UI_MODE_SERVICE)
return if (uiModeManager is UiModeManager) uiModeManager.nightMode == UiModeManager.MODE_NIGHT_YES else false
}

如何在 APP 内判断手机是否切换了系统深色模式?

前提是当前 APP 没有使用 android:configChanges="uiMode" 方式。
由于直接通过手机系统快捷设置切换深色模式时,会触发 activity 的 recreate()方法,导致 APP 的 activity 重建,但是在 activity 的 onPause()方法或者 APP 切换到后台时,获取到的深色模式状态已经是切换之后的了。所以,如果要判断 APP 使用过程中,系统是否切换深色模式,目前使用的方式是在进入 activity 时就记录一下当前的系统深色模式状态,然后,onStop 方法去检查系统的设置是否改变,并且把是否切换的值缓存在 APP 全局缓存(注意不能是 Activity 中)里。然后在 App 切换到前台的时候,再去获取缓存中的值,来判断上一次切换到后台时是否是因为系统切换了深色模式。

0x06 老项目深色模式适配指南

  1. compileSdkVersion 升级到 29 以上
  2. 使用 Force Dark 自动适配深色模式
  3. 针对有问题布局建立-night资源文件夹,配置对应的 color,drawable 和 layout 等
  4. APP 中的关键页面使用 DayNight 主题适配,以追求极致的 UI 体验

特别感谢

官网深色主题背景: https://developer.android.google.cn/guide/topics/ui/look-and-feel/darktheme?hl=zh-cn

TargetSdkVersion升级到30后文件存储

TargetSdkVersion升级到30后文件存储

Android 存储目录

  • 私有存储 (Private Storage) : 每个应用在都拥有自己的私有目录,其它应用看不到,彼此也无法访问到该目录。
  • 内部存储私有目录 (/data/data/packageName) ;
  • 外部存储私有目录 (/sdcard/Android/data/packageName);
  • 共享存储 (Shared Storage) : 存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms、Music、Notifications、Podcasts、Ringtones、Movies、Download等目录。
  • 外部存储:Environment.getExternalStorageDirectory()获取sdcard下的任意文件夹,在SDK29以上已经过期、失效。

外部存储和TargetSdkVersion兼容情况:

  • targetSdkVersion = 28,运行后正常读写所有文件,如果不是必须的需求并且是新创建的项目的话,建议把文件按照规范存储在外部存储私有目录 (/sdcard/Android/data/packageName)
  • targetSdkVersion = 29,targetSdkVersion 由 低版本 修改到 29,覆盖安装,运行后正常读写。
  • targetSdkVersion = 29,卸载旧应用,重新安装新应用,如果读写外部存储,程序崩溃 (open failed: EACCES (Permission denied))
  • targetSdkVersion = 29,添加android:requestLegacyExternalStorage=”true”(不启用分区存储),读写正常不报错
  • targetSdkVersion = 30,targetSdkVersion 由 低版本 修改到 30,覆盖安装,读写报错,程序崩溃 (open failed: EACCES (Permission denied))
  • targetSdkVersion = 30,targetSdkVersion 由 低版本 修改到 30,覆盖安装,增加 android:preserveLegacyExternalStorage=”true”,读写正常不报错
  • targetSdkVersion = 30,卸载旧应用,重新安装新应用,不管设置任何配置,如果读写外部存储,程序崩溃 (open failed: EACCES (Permission denied))

特别感谢

特别感谢以下博文

targetSdkVersion 升级到29、30文件处理:https://www.jianshu.com/p/892a2ca5c41e

Android获取文件MD5

Android获取文件MD5

通过 java.security 包下的MessageDigest工具,可以简单快捷的直接计算出文件的MD5值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun getFileMd5(path: String?): String? {
if (path == null || path.isEmpty()) {
return ""
}
try {
val messagedigest = MessageDigest.getInstance("MD5")
val buf = ByteArray(4096)
var n: Int
val fis = FileInputStream(path)
while (fis.read(buf, 0, 4096).also { n = it } > 0) {
messagedigest.update(buf, 0, n)
}
fis.close()
return HexUtils.toHexString(messagedigest.digest())
} catch (e: Exception) {
e.printStackTrace()
}
return ""
}
1
2
3
4
5
6
7
8
9
10
11
12
13
private static char[] hexDigits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};

static String toHexString(byte[] bytes) {
StringBuilder sb = new StringBuilder(2 * bytes.length);
for (int l = 0; l < bytes.length; l++) {
char c0 = hexDigits[(bytes[l] & 0xf0) >> 4];
char c1 = hexDigits[bytes[l] & 0xf];
sb.append(c0);
sb.append(c1);
}
return sb.toString();
}

Retrofit动态切换域名(BaseUrl)

Retrofit动态切换域名(BaseUrl)

Retrofit只有在创建Retrofit对象时能设置BaseUrl,没有提供动态修改的Api。

1
2
3
4
5
6
val retrofit = Retrofit.Builder()
.baseUrl(url)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(ResultCallAdapterFactory.create())
.client(okHttpClient)
.build()

根据不同的使用场景,动态修改url主要有一下几种方式

0x01 @Get,@Post注解配置全路径

熟悉 Retrofit 的开发者应该知道 @Get , @Post 这些标注到每个接口方法上的注解不仅可以传相对路径,还可以传全路径,这样我们就可以做到不同的接口使用不同的 BaseUrl ,从而达到使用多个 BaseUrl 的需求,但是注解上的值只能是 Final 的常量,不能动态改变,所以我称这个解决方案为静态解决方案

1
2
3
4
5
@GET("https://developer.android.com/login")
fun login(
@Query("mobile") mobile: String,
@Query("code") code: String
): Call<ResponseBody>

0x02 @Url支持全路径地址

熟悉 Retrofit 的开发者也同样知道 @Url 这个标注到每个接口方法参数上的注解,它可以将全路径作为参数传进接口作为每次请求的 Url 地址,每次请求接口都可以将不同的全路径作为参数,从而达到支持多个 BaseUrl 以及在运行时动态改变 BaseUrl ,所以很多请求图片等资源的接口都是使用这个方案

1
2
3
4
5
6
@GET
fun login(
@Url url: String,
@Query("mobile") mobile: String,
@Query("code") code: String
): Call<ResponseBody>

0x03 多个Retrofit对象

即不同的 BaseUrl 使用不同的 Retrofit 对象。缺点是只要新增一个BaseUrl,就需要创建一个新的 Retrofit 对象

0x04 OkHttpClient添加域名切换拦截器

自定义域名切换拦截器HostInterceptor,动态切换BaseUrl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class HostInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val httpUrl: HttpUrl = originalRequest.url
val builder: Request.Builder = originalRequest.newBuilder()
val envList: List<String> = originalRequest.headers("Host-Env")
if (envList.isNotEmpty()) {
builder.removeHeader("Host-Env")
val env = envList[0]
var baseURL: HttpUrl? = null
//根据头信息中配置的value,来匹配新的base_url地址
if ("ANDROID" == env) {
baseURL = "https://developer.android.com/".toHttpUrlOrNull()
}
if (baseURL != null) {
//重建新的HttpUrl,需要重新设置的url部分
val newHttpUrl: HttpUrl = httpUrl.newBuilder()
.scheme(baseURL.scheme) //http协议如:http或者https
.host(baseURL.host) //主机地址
.port(baseURL.port) //端口
.build()
//获取处理后的新newRequest
val newRequest: Request = builder.url(newHttpUrl).build()
return chain.proceed(newRequest)
}
}
return chain.proceed(originalRequest)
}
}

创建OkHttpClient对象,并添加域名切换拦截器HostInterceptor

1
2
3
4
5
6
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(writeTimeout, TimeUnit.SECONDS)
.addInterceptor(HostInterceptor.create())
.build()

正常使用retrofit对象生成ApiService,请求服务走到拦截器并切换BaseUrl

1
2
3
4
5
6
@Header("Host-Env", "ANDROID")
@GET("login")
fun login(
@Query("mobile") phone: String,
@Query("code") code: String
): Call<ResponseBody>

PS:通过自定义Header标签,可以根据标签的类型仅动态切换指定类型的域名

特别感谢

特别感谢以下博文

解决Retrofit多BaseUrl及运行时动态改变BaseUrl:https://www.jianshu.com/p/2919bdb8d09a

自定义Scheme支持外部调用

自定义Scheme支持外部调用

0x01 制定Scheme

为了使用户能够从其他APP直接跳到指定页面,开发者需要使用自定义Scheme。App自定义的Uri的格式:{scheme}://{host_path}

例如:

一个优酷的视频播放页可以被描述为:youku://play/video/12321;

一个多看的电子书详情页可以被描述为:duokan://detail/ebook/21312。

0x02 添加intent-filter

Android Manifest 文件所对应的的 activity 添加 intent-filter

对于一个可以展示 {app_name}://{page}/{type}/{id}

1
2
3
4
5
6
7
8
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<!-- 处理以"app_name://page/type"开头的 URI -->
<data android:scheme="app_name" />
<data android:host="detail" />
<data android:path="/type" />
</intent-filter>

0x03 使用 am 指令进行测试

通过如下指令测试调起,如果能够正确地调起页面展示数据则说明 intent-filter 设置成功。

1
adb shell am start -W -a "android.intent.action.VIEW" -d "yourUri" yourPackageName

打开手机应用商店的评论调研

打开手机应用商店的评论调研

目前国内主流的应用商店ov,华为,小米和应用宝中,只有oppo和vivo支持APP直接拉起应用商店评分

0x01 Oppo应用商店评分

Oppo应用评论调起的官方文档:

https://open.oppomobile.com/new/developmentDoc/info?id=11038

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private final static String PKG_MK_HEYTAP = "com.heytap.market";//Q之后的软件商店包名
private final static String PKG_MK_OPPO = "com.oppo.market";//Q之前的软件商店包名
private final static String COMMENT_DEEPLINK_PREFIX = "oaps://mk/developer/comment?pkg=";
private final static int SUPPORT_MK_VERSION = 84000; // 支持评论功能的软件商店版本

/**
* 拉起评论页面。
*/
public static boolean jumpToComment(Activity context) {
// 此处一定要传入调用方自己的包名,不能给其他应用拉起评论页。
String url = COMMENT_DEEPLINK_PREFIX + context.getPackageName();
// 优先判断heytap包
if (getVersionCode(context, PKG_MK_HEYTAP) >= SUPPORT_MK_VERSION) {
return jumpApp(context, Uri.parse(url), PKG_MK_HEYTAP);
}
if (getVersionCode(context, PKG_MK_OPPO) >= SUPPORT_MK_VERSION) {
return jumpApp(context, Uri.parse(url), PKG_MK_OPPO);
}
return false;
}

/**
* 获取目标app版本号~
*
* @param context
* @param packageName
* @return 返回版本号
*/
private static long getVersionCode(Activity context, String packageName) {
long versionCode = -1;
try {
PackageInfo info = context.getPackageManager().getPackageInfo(packageName, PackageManager.GET_META_DATA);
if (info != null) {
versionCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? info.getLongVersionCode() : info.versionCode;
}
} catch (PackageManager.NameNotFoundException e) {
}
return versionCode;
}

private static boolean jumpApp(Activity context, Uri uri, String targetPkgName) {
try {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.setPackage(targetPkgName);
intent.setData(uri);
// 建议采用startActivityForResult 方法启动商店页面,requestCode由调用方自定义且必须大于0,软件商店不关注
context.startActivityForResult(intent, 100);
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}

0x02 Vivo应用商店评分

Vivo应用评论调起的官方文档:

https://dev.vivo.com.cn/documentCenter/doc/257

1
2
3
4
5
6
String url = market://details?id=${pkg}&th_name=need_comment
Uri uri = Uri.parse(url);
Intent intent= new Intent(Intent.ACTION_VIEW,uri);
intent.setPackage("com.bbk.appstore");
startActivity(intent);

0x03 其他应用商店

直接跳转到应用商店的APP详情页,具体代码如下:

1
2
3
4
5
6
7
String url = market://details?id=${pkg}
Uri uri = Uri.parse(url);
Intent intent= new Intent(Intent.ACTION_VIEW,uri);
if (market_pkg != null)
intent.setPackage(market_pkg);
startActivity(intent);

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×