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

TextView文字颜色渐变

TextView文字颜色渐变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import android.graphics.LinearGradient
import android.graphics.Shader
import android.widget.TextView

/**
* 左到右渐变
*/
fun TextView.setHorizontalGradientTextColor(startColor: Int, endColor: Int) {
val x1 = this.paint.measureText(this.text.toString())//测量文本 宽度
val shader = LinearGradient(0f, 0f, x1, 0f, startColor, endColor, Shader.TileMode.CLAMP)
this.paint.shader = shader
this.invalidate()
}

/**
* 上到下渐变
*/
fun TextView.setVerticalGradientTextColor(startColor: Int, endColor: Int) {
val y1 = this.paint.textSize//测量文本 高度
val shader = LinearGradient(0f, 0f, 0f, y1, startColor, endColor, Shader.TileMode.CLAMP)
this.paint.shader = shader
this.invalidate()
}

使用方式,调用扩展方法即可

textView.setHorizontalGradientTextColor(Color.RED, Color.GREEN)

Android之FileProvider详解

Android之FileProvider详解

原文地址:https://juejin.cn/post/7009204672225345549

Android 7.0之前,文件的Uri以 file:///形式提供给其他app访问。

Android 7.0之后,分享文件的Uri发生了变化。为了安全起见, file:///形式的Uri不能正常访问。官方提供了 FileProvider,FileProvider生成的Uri会以 content://的形式分享给其他app使用。

在7.0以前,为了访问 file:///形式的Uri,我们必须修改文件的权限。修改后的权限对所有app都是有效的,这样的行为是不安全的。 content://形式的Uri让Android的文件系统更安全,对于分享的文件,接收方app只拥有临时的权限,减少了我们app内部的文件被其他app恶意操作的行为。

0x01 创建FileProvider

在manifest文件 <application></application>标签中添加pvodier标签,配置如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
<manifest>
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.FileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

android:name指定Provider的类名,使用官方提供的androidx.core.content.FileProvider

android:authorities相当于一个用于认证的暗号,在分享文件生成Uri时,会通过它的值生成对应的Uri。。值是一个域名,一般格式为 <包名>.fileprovider</包名>

android:exported设置为false,FileProvider不需要公开。

android:grantUriPermissions设置为true,这样就能授权接收端的app临时访问权限了。

0x02 配置共享目录file_paths.xml

res/xml中创建一个资源文件(如果xml目录不存在,先创建),名字随便(一般叫file_paths.xml)。

1
2
3
4
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="my_logs" path="logs/"/>
...
</paths>

<paths></paths>必须有1个或多个子标签,每个子标签代表要请求的私有文件目录。不同的子标签代表不同的目录类型。

<provider></provider>标签中添加 <meta-data></meta-data>子标签。

设置 <meta-data></meta-data>的属性 android:name值为 android.support.FILE_PROVIDER_PATHS,属性 android:resouce的值为刚才我们创建的path文件名。

配置paths

<paths></paths>的每个子标签必须有 path属性,代表content Uris的路径。 name不需要和path保持一样,只是一个名称。

子标签有以下几种。

files-path
1
2
<files-path name="my_files" path="path" />

代表内部存储的files目录,与 Context.getFilesDir()获取的路径对应。

最终生成的Uri格式为:authorities/pathname/filename

示例:

1
2
content:

cache-path
1
2
<cache-path name="name" path="path" />

代表内部存储的cache目录,与 Context.getCacheDir()获取的路径对应。

external-path
1
2
<external-path name="name" path="path" />

代表外部存储(sdcard)的cache目录,与 Environment.getExternalStorageDirectory()获取的路径对应。

external-files-path
1
2
<external-files-path name="name" path="path" />

代表app的外部存储的根目录,与 Context#getExternalFilesDir(String) Context.getExternalFilesDir(null)获取的路径对应。

external-cache-path
1
2
<external-cache-path name="name" path="path" />

代表app外部缓存区域的根目录,与 Context.getExternalCacheDir()获取的路径对应。

external-media-path
1
2
<external-media-path name="name" path="path" />

代表app外部存储媒体区域的根目录,与 Context.getExternalMediaDirs()获取的路径对应。

注意: 这个目录只在API 21(也就是Android 5.0)以上的系统上才存在。

0x03 生成Content Uri文件

为了让其他app使用Content Uri,我们的app必须提前生成Uri。

1
2
3
4
5
6
7
File file = new File(Context.getFilesDir(), "my_log");
Uri uri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
uri = FileProvider.getUriForFile(getContext(), this.getPackageName() + ".FileProvider", file);
} else {
uri = Uri.fromFile(file);
}

这里注意获取目录,在配置paths时我们讲了,paths的子标签必须和获取目录的代码保持对应。这里我们用的是 Context.getFilesDir(),所以paths文件中必须包含 files-path子标签,不然别的app获取uri时会出现异常。

最终生成Uri是使用的 FileProvider.getUriForFile()。第一个参数就是 provider中设置的 authorities属性值。

0x04 Content Uri的几种使用场景

为邮箱app分享附件文件

1
intent.putExtra(Intent.EXTRA_STREAM, contentUri);

其他分享

使用 Intent.setDateIntent.setClipData()

1
intent.setClipDataClipData.newRawUri("", contentUri)

最后使用 startActivity(intent)启动分享操作。

0x05 授权临时权限

分享一般只有这读取和写入2种权限,根据需要传入 Intent.addFlags()中。

1
2
Intent intent = new Intent(Intent.ACTION_SEND);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

Problems专题之Gradle

Problems专题之Gradle

0x01 More than one file was found with OS independent path ‘META-INF/webview_release.kotlin_module’

这是因为第三方库中存在很多重名的META-INF文件,在打包的时候去除即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
android {
// ...
packagingOptions {
exclude 'META-INF/webview_release.kotlin_module'
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude 'META-INF/gradle/incremental.annotation.processors'
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/ASL2.0'
// ...
}
}

0x02 Certificate for <x.x.x> doesn’t match any of the subject alternative names

在执行gradlew命令打包时遇到这个错误,肯定是https证书有问题。

解决方案:如果支持http的话就使用http

1
2
3
4
5
6
7
8
9
> Could not resolve com.bytedanceapi:ttsdk-ttmp:1.36.2.25.pcdn.
Required by:
project :player > com.bytedanceapi:ttsdk-player_premium:1.36.2.25.pcdn > com.bytedanceapi:ttsdk-ttplayer:1.36.2.25.pcdn
> Could not resolve com.bytedanceapi:ttsdk-ttmp:1.36.2.25.pcdn.
> Could not get resource 'https://artifact.bytedance.com/repository/Volcengine/com/bytedanceapi/ttsdk-ttmp/1.36.2.25.pcdn/ttsdk-ttmp-1.36.2.25.pcdn.pom'.
> Could not GET 'https://artifact.bytedance.com/repository/Volcengine/com/bytedanceapi/ttsdk-ttmp/1.36.2.25.pcdn/ttsdk-ttmp-1.36.2.25.pcdn.pom'.
> Certificate for <artifact.bytedance.com> doesn't match any of the subject alternative names: [*.alicdn.com, *.cmos.greencompute.org, cmos.greencompute.org, m.intl.taob
ao.com, *.mobgslb.tbcache.com, alikunlun.com, *.alikunlun.com, s.tbcdn.cn, *.django.t.taobao.com, alicdn.com]

把项目根目录的build.gradle文件中所有的https://artifact.bytedance.com/替换为http://artifact.bytedance.com/即可

Ox03 module java.base does not “opens java.io” to unnamed module

升级到 Java 16 以上,AndroidStudio编译遇到错误。

Unable to make field private final java.lang.String java.io.File.path accessible: module java.base does not “opens java.io” to unnamed module @fb04536

解决方案一:

  1. gradle-wrapper 属性中的 gradle 版本更改为 7.1.1(6.x 不支持 java 16)
  2. gradle.properties 中添加以下行
1
2
3
4
5
6
org.gradle.jvmargs=-Xmx1536m \
--add-exports=java.base/sun.nio.ch=ALL-UNNAMED \
--add-opens=java.base/java.lang=ALL-UNNAMED \
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens=java.base/java.io=ALL-UNNAMED \
--add-exports=jdk.unsupported/sun.misc=ALL-UNNAMED

解决方案二:

升级build-gradlew版本。 将项目根目录的 build.gradle文件中
classpath 'com.android.tools.build:gradle:4.2.2'
升级为
classpath 'com.android.tools.build:gradle:7.2.1'

RecyclerView+SnapHelper实现ViewPager滑动效果

RecyclerView+SnapHelper实现ViewPager滑动效果

SnapHelper结合RecyclerView使用,能很方便的实现ViewPager滑动效果。SnapHelper是一个抽象类,Google内置了两个默认实现类,LinearSnapHelper和PagerSnapHelper。

LinearSnapHelper的使用方法

使当前Item居中显示,常用场景是横向的RecyclerView, 类似ViewPager效果,但是又可以快速滑动多个条目。

1
2
3
4
5
LinearLayoutManager manager = new LinearLayoutManager(getContext());
manager.setOrientation(LinearLayoutManager.HORIZONTAL);
recyclerView.setLayoutManager(manager);
LinearSnapHelper snapHelper = new LinearSnapHelper();
snapHelper.attachToRecyclerView(recyclerView);

PagerSnapHelper的使用方法

使RecyclerView像ViewPager一样的效果,每次只能滑动一页。

1
2
3
4
5
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
linearLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
recyclerView.setLayoutManager(linearLayoutManager);
PagerSnapHelper snapHelper = new PagerSnapHelper();
snapHelper.attachToRecyclerView(recyclerView);

原文地址:https://developer.aliyun.com/article/665537

Aria下载器源码分析

Aria下载器源码分析

Aria 中文文档: https://aria.laoyuyu.me/aria_doc/

版本:3.8.15

0x01 注册流程

在Activity的onCreate、fragment的onCreate、java的构造函数中使用Aria.download(this).register()便可以实现注册。

0x0101 Aria类,下载库的统一入口

Aria类仅一个私有的构造方法,无法实例化

1
private Aria() {}

0x0102 Aria类的download方法

Aria类中的2个主要静态方法 download, upload, 对应下载和上传两种类型

以下载方法为例,下载,在当前类中调用Aria方法,参数需要使用this,返回对象是 DownloadReceiver

1
2
3
4
5
6
public static DownloadReceiver download(Object obj) {
if (AriaManager.getInstance() != null) {
return AriaManager.getInstance().download(obj);
}
return get(convertContext(obj)).download(obj);
}
  1. AriaManager是个单例
  2. 首次会走get方法,最终执行AriaManager#init()方法进行初始化
  3. convertContext()方法会判断当前参数obj是否是Context对象,并返回Context
  4. 最后都会执行AriaManager单例对象的download()方法,返回一个DownloadReceiver对象

0x0103 AriaManager初始化

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
@SuppressLint("StaticFieldLeak") 
private static volatile AriaManager INSTANCE = null;

private AriaManager(Context context) {
APP = context.getApplicationContext();
}

public static AriaManager getInstance() {
return INSTANCE;
}

static AriaManager init(Context context) {
if (INSTANCE == null) {
synchronized (LOCK) {
if (INSTANCE == null) {
INSTANCE = new AriaManager(context);
INSTANCE.initData();
}
}
}
return INSTANCE;
}

private void initData() {
mConfig = AriaConfig.init(APP);
initDb(APP);
regAppLifeCallback(APP);
initAria();
}

  1. init()方法,双空判断加锁实现AriaManager单例
  2. 初始化调用 initData() 方法
  3. 初始化 AriaConfig
  4. 初始化DB
  5. 注册APP生命周期回调,Activity销毁自动移除当前对象的receiver
  6. Aria初始化,异常处理,日志,命令处理器 CommandManager 初始化

0x0104 AriaManager类的download

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private Map<String, AbsReceiver> mReceivers = new ConcurrentHashMap<>();
/**
* 处理下载操作
*/
DownloadReceiver download(Object obj) {
IReceiver receiver = mReceivers.get(getKey(ReceiverType.DOWNLOAD, obj));
if (receiver == null) {
receiver = putReceiver(ReceiverType.DOWNLOAD, obj);
}
return (receiver instanceof DownloadReceiver) ? (DownloadReceiver) receiver : null;
}

private IReceiver putReceiver(ReceiverType type, Object obj) {
final String key = getKey(type, obj);
IReceiver receiver = mReceivers.get(key);

if (receiver == null) {
AbsReceiver absReceiver =
type.equals(ReceiverType.DOWNLOAD) ? new DownloadReceiver(obj) : new UploadReceiver(obj);
mReceivers.put(key, absReceiver);
receiver = absReceiver;
}
return receiver;
}
  1. 调用download方法,根据obj和ReceiverType.DOWNLOAD类型生成key,查询mReceivers是否已经存在当前对象的下载功能接收器DownloadReceiver,存在直接返回
  2. 首次调用,会走到putReceiver方法,新生成一个下载功能接收器DownloadReceiver,并存储在mReceivers中

0x0105 将当前对象注册到Aria

  1. 调用DownloadReceiver#register()方法
  2. 通过DOWNLOAD注解或者实现DownloadTaskListener接口,调用TaskSchedulers.getInstance().register()将当前类注册到Aria

0x0106 TaskSchedulers注册(TODO)

TaskSchedulers 事件调度器,用于处理任务状态的调度

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
private Map<String, Map<TaskEnum, Object>> mObservers = new ConcurrentHashMap<>();
/**
* 将当前类注册到Aria
*
* @param obj 观察者类
* @param taskEnum 任务类型 {@link TaskEnum}
*/
public void register(Object obj, TaskEnum taskEnum) {
String targetName = obj.getClass().getName();
Map<TaskEnum, Object> listeners = mObservers.get(getKey(obj));

if (listeners == null) {
listeners = new ConcurrentHashMap<>();
mObservers.put(getKey(obj), listeners);
}

if (!hasProxyListener(listeners, taskEnum)) {
if (obj instanceof TaskInternalListenerInterface) {
listeners.put(taskEnum, obj);
return;
}
String proxyClassName = targetName + taskEnum.proxySuffix;
ISchedulerListener listener = createListener(proxyClassName);
if (listener != null) {
listener.setListener(obj);
listeners.put(taskEnum, listener);
} else {
ALog.e(TAG, "注册错误,没有【" + proxyClassName + "】观察者");
}
}
}

0x02 下载流程(TODO)

1
2
3
4
long taskId = Aria.download(this)
.load(DOWNLOAD_URL) //读取下载地址
.setFilePath(DOWNLOAD_PATH) //设置文件保存的完整路径
.create(); //启动下载

01 DownloadReceiver.load()
1 HttpBuilderTarget.create()
2 BuilderController.create()
3 CmdHelper.createNormalCmd()
4 NormalCmdFactory.createCmd() -> StartCmd
5 EventMsgUtil.getDefault().post(StartCmd) -> mEventQueue.take()
6 EventMsgUtil.sendEvent()
7 StartCmd.executeCmd() -> AbsNormalCmd.startTask()
8 DTaskQueue.createTask(DTaskWrapper wrapper)
9 TaskWrapperManager.getInstance().putTaskWrapper(wrapper)
10 AbsTaskQueue.startTask()
11 DLoadExecutePool.putTask()
12 AbsTask.start()
13 HttpDLoaderUtil.start() -> AbsNormalLoaderUtil.start()
14 NormalLoader.run() -> AbsNormalLoader.run()
15 AbsNormalLoader.startFlow() -> NormalLoader.handleTask()// 启动单线程任务
16 NormalLoader.startThreadTask()
17 NormalTTBuilder.buildThreadTask()
18 ThreadTaskManager.getInstance().startThread()
19 AbsNormalLoader.startTimer() // 启动进度获取定时器

21 ThreadTask.call() // 线程池执行任务回调
22 AbsThreadTaskAdapter.call()
23 HttpDThreadTaskAdapter.handlerThreadTask() // 正式建立Http连接,下载任务
24 HttpDThreadTaskAdapter.readNormal()

25 HttpDThreadTaskAdapter.handleComplete()
26 ThreadTask.updateCompleteState()
27 NormalThreadStateManager.callback -> STATE_COMPLETE
28 BaseListener.onComplete() // 对应的实体类是BaseDListener
29 BaseListener.sendInState2Target() // 将任务状态发送给下载器

0x03 完成事件逆行分析

  1. IDLoadListener.onComplete()
  2. NormalLoader.addComponent(IRecordHandler recordHandler) -> ILoaderVisitor.addComponent(IRecordHandler recordHandler) // 处理任务记录
  3. RecordHandler.checkTaskCompleted() // 检查任务是否已完成
  4. 遍历TaskRecord中所有的ThreadRecord.isComplete就认为下载完成

0x04 M3U8文件下载过程

M3U8ThreadTaskAdapter.readDynamicFile(InputStream is) // 动态长度文件读取方式
M3U8ThreadTaskAdapter.handleComplete() // 处理完成
ThreadTask.updateCompleteState() // 组装Message消息,发送给VodStateManager.callback -> Handler.Callback
VodStateManager.callback -> STATE_COMPLETE
BaseListener.onComplete() // 对应的实体类是M3U8Listener

RecyclerView滑动到指定Item并置顶

RecyclerView滑动到指定Item并置顶

0x01 TopLinearSmoothScroller

1
2
3
4
5
6
7
8
9
10
11
12
import android.content.Context
import androidx.recyclerview.widget.LinearSmoothScroller

class TopLinearSmoothScroller(context: Context?) : LinearSmoothScroller(context) {
public override fun getVerticalSnapPreference(): Int {
return SNAP_TO_START
}

override fun getHorizontalSnapPreference(): Int {
return SNAP_TO_START
}
}

0x02 TopScrollLinearLayoutManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class TopScrollLinearLayoutManager(context: Context?, orientation: Int, reverseLayout: Boolean) :
LinearLayoutManager(context, orientation, reverseLayout) {

override fun smoothScrollToPosition(
recyclerView: RecyclerView?,
state: RecyclerView.State?,
position: Int
) {
val linearSmoothScroller = TopLinearSmoothScroller(recyclerView?.context)
linearSmoothScroller.targetPosition = position
startSmoothScroll(linearSmoothScroller)
}
}

0x03 RecyclerView中使用

设置recyclerView的layoutManager为自定义的TopScrollLinearLayoutManager,然后直接调用 smoothScrollToPosition() 方法就可以滚动到指定的位置并且置顶了。

1
2
3
4
5
6
7
recyclerView.layoutManager = TopScrollLinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL,
false
)
// ...
recyclerView.smoothScrollToPosition(1)

ExoPlayer简易播放器

ExoPlayer简易播放器

一个简单的基于ExoPlayer的播放器。
ExoPlayer官网:https://exoplayer.dev/
使用ExoPlayer版本:2.18.2

实现功能:通过url播放视频,简易自定义controller,监听播放器状态变化,首帧时间打印,错误信息打印。

如果需要深层次的UI定制,建议不要用exo_ui库下面的布局,用SurfaceView和TextureView完全自定义。具体代码如下:

1.播放器Fragment

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129

import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Player.Listener
import com.google.android.exoplayer2.ui.StyledPlayerView

class ExoPlayerFragment : Fragment() {

companion object {
fun newInstance(url: String): ExoPlayerFragment {
val args = Bundle()
args.putString(EXO_URI, url)
val fragment = ExoPlayerFragment()
fragment.arguments = args
return fragment
}

private const val TAG = "ExoPlayerFragment"
private const val EXO_URI = "EXO_URI"
}

private var videoUri: String? = null
private var player: ExoPlayer? = null

private val listener = object : Listener {

override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
Log.d(TAG, "onEvents->${events}:")
}

override fun onPlaybackStateChanged(playbackState: Int) {
val stateString: String = when (playbackState) {
ExoPlayer.STATE_IDLE -> "ExoPlayer.STATE_IDLE"
ExoPlayer.STATE_BUFFERING -> "ExoPlayer.STATE_BUFFERING"
ExoPlayer.STATE_READY -> "ExoPlayer.STATE_READY"
ExoPlayer.STATE_ENDED -> "ExoPlayer.STATE_ENDED"
else -> "UNKNOWN_STATE"
}
Log.d(TAG, "onPlaybackStateChanged: state=$stateString")
super.onPlaybackStateChanged(playbackState)
printPlayerTimeLine("onPlaybackStateChanged:")
}

override fun onRenderedFirstFrame() {
super.onRenderedFirstFrame()
printPlayerTimeLine("onRenderedFirstFrame:")
}

override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
printPlayerTimeLine("onPlayerError:")
error.printStackTrace()
}
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
videoUri = arguments?.getString(EXO_URI)
val root = inflater.inflate(R.layout.exo_player_fragment, container, false)
val playerView = root.findViewById<StyledPlayerView>(R.id.styled_player_view)
initPlayerView(playerView)
return root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
startPlay()
}

private fun initPlayerView(playerView: StyledPlayerView) {
Log.d(TAG, "initPlayerView: ")
player = ExoPlayer.Builder(requireContext()).build()
player?.addListener(listener)
// Bind the player to the view.
playerView.player = player

// back
playerView.findViewById<View>(R.id.back).setOnClickListener {
fragmentManager?.popBackStack()
val ft = fragmentManager?.beginTransaction()
ft?.remove(this@ExoPlayerFragment)
ft?.commitAllowingStateLoss()
}
}

private var prepareTime: Long = 0L
private fun printPlayerTimeLine(method: String) {
val c = System.currentTimeMillis()
val duration = if (prepareTime == 0L) 0 else c - prepareTime
prepareTime = c
Log.i(TAG, "printPlayerTimeLine: method=$method, duration=$duration, uri=${videoUri}")
}

private fun startPlay() {
Log.d(TAG, "startPlay: ")
videoUri?.let {
// Build the media item.
val mediaItem: MediaItem = MediaItem.fromUri(it)

// Set the media item to be played.
player?.setMediaItem(mediaItem)

// Prepare the player.
player?.prepare()
printPlayerTimeLine("player->prepare:")

// Start the playback.
player?.play()
// calPlayerTimeLine("player->play:")
}
}

override fun onDestroy() {
player?.release()
super.onDestroy()
}
}

2.播放器布局

exo_player_fragment.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:clickable="true">

<com.google.android.exoplayer2.ui.StyledPlayerView
android:id="@+id/styled_player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
app:animation_enabled="false"
app:controller_layout_id="@layout/custom_player_control_view"
app:use_controller="true" />

</FrameLayout>

3.自定义的controller布局

custom_player_control_view.xml

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?xml version="1.0" encoding="utf-8"?>

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom"
android:background="#00000000"
android:layoutDirection="ltr"
android:orientation="vertical"
tools:targetApi="28">

<ImageView
android:id="@+id/back"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:background="#33000000"
android:scaleType="centerInside"
android:src="@mipmap/back"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<LinearLayout
android:id="@id/exo_bottom_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#33000000"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent">


<ImageButton
android:id="@id/exo_prev"
style="@style/ExoStyledControls.Button.Center.Previous"
android:padding="8dp" />

<ImageButton
android:id="@id/exo_play_pause"
style="@style/ExoStyledControls.Button.Center.PlayPause"
android:padding="8dp" />

<ImageButton
android:id="@id/exo_next"
style="@style/ExoStyledControls.Button.Center.Next"
android:padding="8dp" />

<TextView
android:id="@id/exo_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:textColor="#FFBEBEBE"
android:textSize="14sp"
android:textStyle="bold" />

<com.google.android.exoplayer2.ui.DefaultTimeBar
android:id="@id/exo_progress"
android:layout_width="0dp"
android:layout_height="26dp"
android:layout_weight="1" />

<TextView
android:id="@id/exo_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:textColor="#FFBEBEBE"
android:textSize="14sp"
android:textStyle="bold" />

</LinearLayout>


</androidx.constraintlayout.widget.ConstraintLayout>

SystemBarUtil 工具类

SystemBarUtil 工具类

工具类,提供了系统栏高度和屏幕宽高获取方法

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

import android.app.Activity
import android.content.Context
import android.graphics.Point
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager

/**
* 工具类,提供了系统栏高度和屏幕宽高获取方法
*/
object SystemBarUtil {

/**
* 获取状态栏高度
*/
@JvmStatic
fun getStatusBarHeight(context: Context): Int {
var height = 0
try {
val resourceId = context.applicationContext.resources.getIdentifier(
"status_bar_height",
"dimen",
"android"
)
if (resourceId > 0) {
height =
context.applicationContext.resources.getDimensionPixelSize(resourceId)
}
} catch (e: Exception) {
}
return height
}

/**
* 获取系统导航栏高度
*/
@JvmStatic
fun getNavigationBarHeight(context: Context): Int {
var height = 0
try {
val resourceId = context.applicationContext.resources.getIdentifier(
"navigation_bar_height",
"dimen",
"android"
)
if (resourceId > 0) {
height =
context.applicationContext.resources.getDimensionPixelSize(resourceId)
}
} catch (e: Exception) {
}
return height
}

private const val NAVIGATION = "navigationBarBackground"

// 该方法需要在View完全被绘制出来之后调用
@JvmStatic
private fun isNavigationBarVisible(activity: Activity): Boolean {
val vp = activity.window.decorView as ViewGroup?
if (vp != null) {
for (i in 0 until vp.childCount) {
vp.getChildAt(i).context.packageName
if (vp.getChildAt(i).id !== View.NO_ID &&
NAVIGATION == activity.resources.getResourceEntryName(vp.getChildAt(i).id)
) {
return true
}
}
}
return false
}

/**
* 获取屏幕的物理大小 px
*/
@JvmStatic
fun getDeviceScreenSize(context: Context): Point {
val appContext = context.applicationContext
val wm = appContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val point = Point(0, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
wm.defaultDisplay.getRealSize(point)
} else {
wm.defaultDisplay.getSize(point)
}
return point
}

/**
* 获取显示屏幕的宽高 px
*/
@JvmStatic
fun getDisplaySize(context: Context): Point {
val point = Point(0, 0)
val dm = context.applicationContext.resources.displayMetrics
point.x = dm.widthPixels
point.y = dm.heightPixels
return point
}
}

Android 13 监控网络连接状态

Android 13 监控网络连接状态

获取瞬时状态

1
2
3
4
5
6
val cm = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val currentNetwork = cm.activeNetwork
if (currentNetwork != null) {
val caps = cm.getNetworkCapabilities(currentNetwork)
val linkProperties = cm.getLinkProperties(currentNetwork)
}

监听网络事件

NetworkCallback 类与 ConnectivityManager.registerDefaultNetworkCallback(NetworkCallback)ConnectivityManager.registerNetworkCallback(NetworkCallback) 结合使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
val cm = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.e(TAG, "The default network is now: " + network)
}

override fun onLost(network: Network) {
Log.e(TAG, "The application no longer has a default network. The last default network was " + network)
handle(null)
}

override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
Log.d(TAG, "The default network changed capabilities: " + networkCapabilities)
handle(networkCapabilities)
}

override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
Log.i(TAG, "The default network changed link properties: " + linkProperties)
}
})

解析NetworkCapabilities的网络状态信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 private fun handle(caps: NetworkCapabilities?) {
if (caps != null) {
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
if (
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
) {
setResult(STATE_WIFI)
return
} else if (
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
) {
setResult(STATE_MOBILE)
return
}
}
}
setResult(STATE_UNKNOWN)
return
}

APK签名之jarsigner签名工具

APK签名之jarsigner签名工具

使用JDK签名工具jarsigner签名APK文件 jarsigner -verbose -keystore [签名文件路径] -signedjar [签名后的apk文件路径] [未签名的apk文件路径] [证书别名]

20230110180748

1
jarsigner -verbose -keystore D:\xxx\xxx.jks -signedjar D:\xxx\xxx_signed.apk D:\xxx\***.apk keyAlias

Android首页灰色实现方案

Activity设置灰色

使用ColorMatrix设置灰度

1
2
3
4
5
6
7
private fun setGrayPaint(view: View) {
val paint = Paint()
val cm = ColorMatrix()
cm.setSaturation(0f)
paint.colorFilter = ColorMatrixColorFilter(cm)
view.setLayerType(View.LAYER_TYPE_HARDWARE, paint)
}

给首页Activity的decorView设置灰度Paint

1
2
3
4
5
6
7
8
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContentView(...)

// 需要在 setContentView 之后
setGrayPaint(window.decorView)
}

需要特殊处理的控件

  • 弹框
  • WebView
  • SurfaceView

这些控件由于不是跟activity公用一个window,需要各自单独处理灰度Paint。调用 setGrayPaint(view) 即可。

相关资源

Android实现设置灰白模式效果

微信AndResGuard资源混淆工具

微信AndResGuard资源混淆工具

AndResGuard是一个帮助你缩小APK大小的工具,他的原理类似Java Proguard,但是只针对资源。他会将原本冗长的资源路径变短,例如将res/drawable/wechat变为r/d/a。

AndResGuard不涉及编译过程,只需输入一个apk(无论签名与否,debug版,release版均可,在处理过程中会直接将原签名删除),可得到一个实现资源混淆后的apk(若在配置文件中输入签名信息,可自动重签名并对齐,得到可直接发布的apk)以及对应资源ID的mapping文件。

0x01 原理介绍

根据Android的编译流程,所有资源ID已经被编译成32位int值。这说明我们并不需要去修改xml与java,因为在编译过程已经被R.java所替换,我们直接修改resources.arsc的二进制数据,不改变打包流程,只要在生成resources.arsc之后修改它,同时重命名资源文件。

0x02 使用场景

  • 缩小APK体积
  • 保护res资源文件的可读性
  • 皮应用中减少跟主应用代码的重复率

0x03 资源混淆配置

  1. Project根目录的build.gradle中,添加插件的依赖
1
2
3
4
5
buildscript {
dependencies {
classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.21'
}
}
  1. app模块的build.gradle中添加Res相关配置
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
plugins {
id 'AndResGuard'
}

android {...}

andResGuard {
mappingFile = file("./resource_mapping.txt")
use7zip = false
useSign = true
// 打开这个开关,会keep住所有资源的原始路径,只混淆资源的名字
keepRoot = false
// 设置这个值,会把arsc name列混淆成相同的名字,减少string常量池的大小
fixedResName = "arg"
// 打开这个开关会合并所有哈希值相同的资源,但请不要过度依赖这个功能去除去冗余资源
mergeDuplicatedRes = true
whiteList = [
"R.mipmap.ic_launcher",
"R.mipmap.ic_launcher_round",
"R.string.app_name",
]
compressFilePattern = [
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
]
sevenzip {
artifact = 'com.tencent.mm:SevenZip:1.2.21'
//path = "/usr/local/bin/7za"
}

/** * 可选: 如果不设置则会默认覆盖assemble输出的apk **/
// finalApkBackupPath = "${project.rootDir}/final.apk"

/** * 可选: 指定v1签名时生成jar文件的摘要算法 * 默认值为“SHA-1” **/
// digestalg = "SHA-256"
}

0x04 如何启动

使用Android Studio的同学可以在 andresguard 下找到相关的构建任务; 命令行可直接运行./gradlew resguard[BuildType | Flavor], 这里的任务命令规则和assemble一致。

0x05 配置7Zip压缩

在设置sevenzip时, 你只需设置artifact或path, 支持同时设置,总以path的值为优先。

0x06 配置apk输出

如果没有配置finalApkBackupPath,最终结果会覆盖assemble[BuildType | Flavor]的输出APK。如果配置则输出至finalApkBackupPath配置路径。

0x07 Font资源不支持混淆

如果项目中使用了font资源,需要配置mappingFile = file("./resource_mapping.txt"),同时在app目录的resource_mapping.txt文件中添加

1
2
res path mapping:
res/font -> res/font

0x08 一些需要注意的问题

  • 如果不是对APK size有极致的需求,请不要把resources.arsc添加进compressFilePattern.
  • 对于发布于Google Play的APP,建议不要使用7Zip压缩,因为这个会导致Google Play的优化Patch算法失效.
  • compress参数对混淆效果的影响
    若指定compess 参数.png、.gif以及*.jpg,resources.arsc会大大减少安装包体积。若要支持2.2,resources.arsc需保证压缩前小于1M。
  • 操作系统对7z的影响
    实验证明,linux与mac的7z效果更好
  • keepmapping方式对增量包大小的影响
    影响并不大,但使用keepmapping方式有利于保持所有版本混淆的一致性
  • 渠道包的问题(建议通过修改zip摘要的方式生产渠道包)
    在出渠道包的时候,解压重压缩会破坏7zip的效果,通过repackage命令可用7zip重压缩。
  • 通过getIdentifier方式获得资源,需要放置白名单中。
  • 部分手机桌面快捷图标的实现有问题,务必将程序桌面icon加入白名单
  • 第三方SDK的资源加入白名单。可以在white_list.mdhttps://github.com/shwenzhang/AndResGuard/blob/master/doc/white_list.md查看更多sdk的白名单配置

0x09 相关资源

使用说明:https://github.com/shwenzhang/AndResGuard/blob/master/README.zh-cn.md

原理介绍:https://mp.weixin.qq.com/s?__biz=MzAwNDY1ODY2OQ==&mid=208135658&idx=1&sn=ac9bd6b4927e9e82f9fa14e396183a8f#rd

white_list.mdhttps://github.com/shwenzhang/AndResGuard/blob/master/doc/white_list.md

Android动态权限申请

Android动态权限申请

0x01 介绍

由于 Android 动态权限申请是一个交互比较复杂的模块,整个申请的流程也比较长,所以,写了一个工具来封装了一个,也具体的实现了一个流程。

由于每个App的Ui风格不一致,所以没有把Toast和弹框封装进工具,等后期有好的想法再优化。

0x02 动态权限申请流程

  1. 检查授权状态
  2. 申请权限
  3. 处理权限申请结果
  4. 当用户‘拒绝且不再询问’,引导去手机设置
  5. 检查手机设置后的权限申请结果

0x03 使用说明

主要封装类 PermissionManager

根据业务需要用到安卓定义的高危权限,需要去动态申请权权限。通常是在Activity 和 Fragment 组件中发起。

1 检查权限授权状态

1
2
3
4
5
6
if (PermissionManager.hasPermissions(this, Manifest.permission.READ_PHONE_STATE)) {
// after permission
afterPermission()
} else {
showRequestPermissionDialog("申请手机权限的原因是因为我需要", Manifest.permission.READ_PHONE_STATE)
}

2 申请权限,根据合规化通常需要先弹框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private fun showRequestPermissionDialog(message: String, vararg perm: String) {
AlertDialog.Builder(this)
.setTitle("权限申请")
.setMessage(message)
.setPositiveButton("去授权") { dialog, which ->
dialog.dismiss()
PermissionManager.requestPermissions(this, *perm)
}
.setNegativeButton("取消") { dialog, which ->
dialog.dismiss()
ToastUtil.showToast(this, "已取消授权...")
}
.setCancelable(false)
.create()
.show()
}

3 处理权限申请结果

onRequestPermissionsResult 回调接口中,调用 PermissionManager.onRequestPermissionsResult 方法,并且实现 PermissionManager.OnPermissionResultCallback 这个回调接口。

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
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
PermissionManager.onRequestPermissionsResult(
this,
requestCode,
permissions,
grantResults,
this
)
}

override fun onPermissionsGranted(requestCode: Int, perms: List<String?>) {
ToastUtil.showToast(this, "授权成功")
afterPermission()
}

override fun onPermissionsDenied(requestCode: Int, perms: List<String?>) {
ToastUtil.showToast(this, "授权失败")
}

override fun onPermissionDeniedForever(requestCode: Int, perms: List<String?>) {
showSettingsDialog()
}

4 当用户‘拒绝且不再询问’,引导去手机设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private fun showSettingsDialog() {
AlertDialog.Builder(this)
.setTitle("权限申请")
.setMessage("已永久拒绝,需要去设置->权限设置打开")
.setPositiveButton("前往设置") { dialog, which ->
dialog.dismiss()
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", packageName, null))
startActivityForResult(intent, REQUEST_CODE_FOR_PERMISSION_SETTINGS)
}
.setNegativeButton("取消") { dialog, which ->
dialog.dismiss()
ToastUtil.showToast(this, "已取消授权...")
}
.setCancelable(false)
.create()
.show()
}

5 检查手机设置后的权限申请结果

1
2
3
4
5
6
7
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_FOR_PERMISSION_SETTINGS) {
afterPermission()
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}

0x04 特别推荐 PermissionX

PermissionX

中文文档

PermissionX is an extension Android library that makes Android runtime permission request extremely easy. You can use it for basic permission request occasions or handle more complex conditions, like showing rationale dialog or go to app settings for allowance manually.

Android从现有的项目创建一个皮应用

Android从现有的项目创建一个皮应用

0x01 Copy一个新项目

  1. clean原项目,然后直接Copy原项目所有文件,等待完成
  2. 根据新项目重命名新文件夹名称
  3. 删除原项目的 .idea .git .gitlab 等文件夹,.gradle 可以不删
  4. 用编辑器打开 settings.gradle,修改项目名称 rootProject.name = "xxx"

0x02 重命名项目包名

这一步是要将包名从 com.sample1.android 重命名为 com.sample2.android

  1. 用AndroidStudio打开新项目,去掉 Compat Middle Packages 前面的勾
  2. 选择项目的 sample1 包名,Shift + F6重命名
  3. 一定选择 Rename Package
  4. 等待完成,项目大时间就比较长
  5. 修改 .aidl 文件的包名路径和import的路径
  6. 修改根目录下的 build.gradleapplicationId "com.sample2.android",并 sync

ps: 不清楚为啥Kotlin的扩展函数还需要重新手动导入

0x03 推到代码库

到上面这一步,不出意外的话已经可以编译成功并且跑起来了。编译成功之后可以先推送本地项目到代码库。

远程代码库新建项目,获取新项目git地址,然后执行下面操作。

1
2
3
4
5
6
$ cd existing_folder
$ git init
$ git remote add origin git://github.com/schacon/grit.git
$ git add .
$ git commit
$ git push -u origin master

0x04 后续

下面就是修改名称,URL,替换功能啥的。

  • Logo、名称、资源文件替换
  • H5协议替换
  • 域名修改
  • 第三方账号切换

0x05 审核相关

现在应用市场对新应用的审核非常严格。为了过审,可能需要做很多相关的工作

  • 混淆,或者改文件名
  • 资源混淆,或者资源改名
  • 增加新功能
  • 修改UI界面样式
  • 做审核版功能

0x06 问题

0x0601 如果Shift + F6重命名时没有Rename Package选项

如果Shift + F6重命名时没有Rename Package选项, 并且出现了以下提示,
Package 'example' contains directories in libraries which cannot be renamed. Do you want to rename current directory or all directories in project?
这是因为依赖包中也存在example这个包名,导致无法直接重命名包名。解决的方案分两种:

方案1. 暂时先移除包含example包名的依赖包,等重命名之后重新添加到项目中
方案2. 只能新建package,然后将要rename的包拖动到新的package中,或者F6移动

PS:因为无法重命名,刚刚经历了手动移动十几个module的package包路径的痛苦历程。

RoundImageView圆角控件

RoundImageView圆角控件

示例代码如下:

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
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Path;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.util.Log;

import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageView;

public class RoundImageView extends AppCompatImageView {
private static final String TAG = "RoundImageView";

private int radius = 0;

public RoundImageView(Context context) {
this(context, null);
}

public RoundImageView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public RoundImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setup(context, attrs, defStyleAttr);
}

private void setup(Context context, AttributeSet attrs, int defStyleAttr) {
try {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RoundImageView);
radius = a.getDimensionPixelSize(R.styleable.RoundImageView_riv_radius, 0);
Log.d(TAG, "RoundImageView: radius=" + radius);
a.recycle();
} catch (Exception e) {
e.printStackTrace();
}
}

public void setRadius(int radius) {
this.radius = radius;
}

@Override
protected void onDraw(Canvas canvas) {
if (radius > 0) {
Path path = new Path();
path.addRoundRect(new RectF(0, 0, getWidth(), getHeight()), radius, radius, Path.Direction.CW);
canvas.clipPath(path);//设置可显示的区域,canvas四个角会被剪裁掉
}
super.onDraw(canvas);
}
}

attrs.xml 文件中定义控件的圆角dp值属性:

1
2
3
<declare-styleable name="RoundImageView">
<attr name="riv_radius" format="dimension" />
</declare-styleable>

使用示例

1
2
3
4
5
6
<com.xx.ui.widget.RoundImageView
android:id="@+id/image_view"
android:layout_width="120dp"
android:layout_height="60dp"
android:scaleType="centerCrop"
app:riv_radius="8dp" />

Gradle的环境配置

Gradle的环境配置

原文地址:https://www.cnblogs.com/baiqiantao/p/6890674.html

Installing Gradle: https://docs.gradle.org/current/userguide/installation.html

  • gradlew 和 gradlew.bat:封装 gradle 的脚本,目的是为了更方便的使用 gradle
  • 环境变量 GRADLE_HOME:仅仅是为了可以在任意目录中执行 gradle 命令,没有特殊的意义
  • 环境变量 GRADLE_USER_HOME:控制在命令行中执行 gradlew 命令时,gradle 下载的目录
  • IDEA 的 Gradle user home:控制在 IDEA 点击按钮执行各项 Task 等功能时,gradle 下载的目录
  • IDEA 的 User from gradle:控制在 IDEA 点击按钮执行各项 Task 等功能时,使用的 gradle 的版本

环境变量 GRADLE_HOME

设置环境变量 GRADLE_HOME 的目的,仅仅是为了方便在 Path 中指定 gradle 的位置。
GRADLE_HOME:D:_dev\gradle_GRADLE_HOME\gradle-6.7
Path:%GRADLE_HOME%\bin
将 gradle 添加到 Path 的目的是为了,可以在任意目录中执行 gradle 命令。
实际上,完全没必要设置环境变量 GRADLE_HOME,Do we really need GRADLE_HOME?

1
2
3
4
5
6
gradle -v           # 查看版本
gradle --help # 查看命令使用帮助

λ where gradle # 查看 gradle 命令位置
D:\_dev\gradle\GRADLE_HOME\gradle-6.7\bin\gradle
D:\_dev\gradle\GRADLE_HOME\gradle-6.7\bin\gradle.bat

gradlew 是干嘛的

其实 gradlew 只是一个 gradle 的封装(wrapper),gradlew = gradle wrapper,因为在项目根目录有 gradlewgradlew.bat 这两个可执行文件,所以 能且仅能 在项目根目录中执行 gradlew 命令。

1
2
3
4
5
6
D:\_dev\_code\as\Test> gradlew -v       # 查看版本
D:\_dev\_code\as\Test> gradlew --help # 查看命令使用帮助

D:\_dev\_code\as\Test> where gradlew # 查看 gradlew 命令位置
D:\_dev\_code\as\Test\gradlew
D:\_dev\_code\as\Test\gradlew.bat

这两个文件头部的注释也说明了他们的作用:

  • gradlew:Gradle start up script for UN*X
  • gradlew.bat:Gradle startup script for Windows

之所以添加这个 gradlew 脚本,是为了:

统一项目所使用的 gradle 版本,避免不同开发人员使用不同的 gradle 版本导致的兼容性问题
可以把 gradle-wrapper.properties 里面的下载 gradle 的地址切换到公司的公共空间上,以加快下载速度

1
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip

环境变量 GRADLE_USER_HOME

设置环境变量 GRADLE_USER_HOME 的目的,是为了自定义下载 gradle 时的本地存储路径。
在命令行中执行 gradlew 命令(注意不是 gradle 命令)时,会将对应版本的 gradle 下载到此目录中。
下载 gradle 时,下载地址及版本由项目中的 /gradle/wrapper/gradle-wrapper.properties 决定。

1
2
3
4
5
6
#Mon Nov 16 00:55:48 CST 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip

IDEA 的 Gradle user home

在 IDEA 的 File | Settings | Build, Execution, Deployment | Build Tools | Gradle 中,有一个 Gradle user home 的配置,其作用和环境变量 GRADLE_USER_HOME 类似,只不过该配置只是给 IDEA 使用的。譬如点击 gradle 窗口的各种 Task 按钮执行各项 Task 功能时。

注意:仅 IDEA 中的各种图形化操作会使用此配置,在 IDEA 的 Terminal 中执行 gradlew 命令时,使用的依旧是环境变量 GRADLE_USER_HOME

IDEA 的 User from gradle

在 IDEA 的 File | Settings | Build, Execution, Deployment | Build Tools | Gradle 中,有一个 User from gradle 的配置,它也是仅提供给 IDEA 使用的(对 gradlew 无效)。

其作用是,指定当前工程中 IDEA 所使用的 gradle 版本:

当勾选 gradle-wrapper.properties 时,使用 gradle-wrapper.properties 中指定的 gradle 版本。
为了防止和在 Terminal 中执行 gradlew 命令时使用的 gradle 版本不同,建议勾选此配置(也是默认配置)。
当勾选 Specified location 时,使用指定目录下的 gradle 版本。
如果 gradle 下载很慢,就可以勾选此配置,以便使用指定本地下载好的 gradle 版本。

不管在 IDEA 中怎么配置,在 Terminal 中执行 gradlew 命令时,所使用的 gradle 版本都是由 gradle-wrapper.properties 决定的,并且下载路径也都是由环境变量 GRADLE_USER_HOME 决定的。

org.gradle.java.home 配置

这里的 JDK 指的是执行 Gradle 命令依赖的 JDK,并非 AndroidStudio 工程依赖的 JDK。

通过 File | Settings | Build, Execution, Deployment | Build Tools | Gradle 设置的 JDK,是在运行 IDEA 图形化按钮时使用的。
通过 gradle.properties 设置的 JDK,是在 Terminal 中执行 gradlew 命令时使用的。

1
2
3
4
5
6
# MacOS的路径写法
org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home

# Windows系统的路径写法参考如下
# org.gradle.java.home=C:\\Program Files\\Java\\jdk1.8.0_144
# org.gradle.java.home=C\:/_dev/Android/Android Studio/jre

注意:AGP 从 7.0.0-alpha02 版本起,需要使用 Java 11

用Android自带浏览器打开网页

启动android默认浏览器

1
2
3
4
5
val intent = Intent()
intent.data = Uri.parse(url)
intent.action = Intent.ACTION_VIEW
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)

启动指定浏览器打开(不推荐)

警告:爱加密加固之后的包,会把这个异常给吃掉,导致无法跳转,也无反应。

这种方式需要处理手机中不存在指定浏览器的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {
val intent = Intent()
intent.data = Uri.parse(targetUrl)
intent.action = Intent.ACTION_VIEW
intent.setClassName(
"com.android.browser",
"com.android.browser.BrowserActivity"
)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()

// android.content.ActivityNotFoundException: Unable to find explicit activity class {com.android.browser/com.android.browser.BrowserActivity}; have you declared this activity in your AndroidManifest.xml?
val intent = Intent()
intent.data = Uri.parse(url)
intent.action = Intent.ACTION_VIEW
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}

由于华为鸿蒙系统已经没有Android默认的浏览器,所以此处必须要有异常处理,或者提前处理手机中不存在指定浏览器的情况。

市场上常用浏览器的包名和类名@20220630

1
2
3
4
5
6
华为: "com.huawei.browser/com.huawei.browser.BrowserMainActivity"
Vivo: "com.vivo.browser/com.vivo.browser.MainActivity"
小米: "name=com.android.browser/com.android.browser.BrowserActivity"
uc浏览器: "com.uc.browser", "com.uc.browser.ActivityUpdate"
opera: "com.opera.mini.android", "com.opera.mini.android.Browser"
qq浏览器: "com.tencent.mtt", "com.tencent.mtt.MainActivity"

RecyclerView 根据滑动位置动态改变背景透明度

RecyclerView 根据滑动位置动态改变背景透明度

根据滑动位置动态改变背景透明度,直接上代码:

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
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
onRecyclerScrolled(recyclerView, dx, dy)
}

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
}
})

private val dp180 = dp2px(180)
private var distanceY = 0
private var current = 0
private fun onRecyclerScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
distanceY += dy
when {
distanceY >= dp180 -> {
if (current == 1) return
recyclerView.setBackgroundColor(Color.argb(255, 246, 248, 250))
current = 1
}
distanceY <= 0 -> {
if (current == 0) return
recyclerView.setBackgroundColor(Color.argb(0, 246, 248, 250))
current = 0
}
else -> {
recyclerView.setBackgroundColor(
Color.argb(
distanceY * 255 / dp180,
246, 248, 250
)
)
current = -1
}
}
}

Android 分享功能

Android 分享功能

友盟分享SDK

https://developer.umeng.com/docs

友盟分享,QQ和QQ空间分享成功了,却总是回调分享取消

qqzone_id_value 配置跟当前应用对应不上,PlatformConfig.setQQZone(qqzone_id_value, qqzone_secret_id_value)

JsBridge 开源库

JsBridge 开源库

项目地址:https://github.com/lzyzsd/JsBridge
使用参考:https://www.jianshu.com/p/7aea03838f19

0x00 从H5界面,跳转Native登录,登录之后重新加载H5页面出现JsBridge注入失败[code=-2,message=net::ERR_NAME_NOT_RESOLVED]

解决方案:
App层销毁当前的WebView,重新加载一个新的WebView去loadUrl。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// js-bridge register error
// 回调 onReceivedError(WebView view, WebResourceRequest request, WebResourceError error)
// [code=-2,message=net::ERR_NAME_NOT_RESOLVED]
// so you have to destroy webView and rebuild one.
private void reloadWebView() {
if (mWebView != null) {
mWebView.destroy();
mWebView = null;
}

initView();
initWebView();
webView.loadUrl(url);
}

打开手机自带的应用商店

需求背景:
因为各种原因,需要打开手机自带的应用商店

核心代码

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

import android.content.Context
import android.content.Intent
import android.net.Uri

object MarketUtils {
const val XIAOMI_MARKET = "com.xiaomi.market"
const val HUAWEI_MARKET = "com.huawei.appmarket"
const val OPPO_MARKET = "com.oppo.market"
const val OPPO_MARKET2 = "com.heytap.market"
const val VIVO_MARKET = "com.bbk.appstore"
const val MEIZU_MARKET = "com.meizu.mstore"
const val YYB_MARKET = "com.tencent.android.qqdownloader"

fun startTargetMarket(context: Context, deepLink: String, packageName: String) {
//"deeplink": "market://details?id=com.taobao.taobao&..."
try {
val uri = Uri.parse(deepLink)
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.setPackage(packageName)
context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
val uri = Uri.parse(deepLink)
val intent = Intent(Intent.ACTION_VIEW, uri)
context.startActivity(intent)
}
}

/**
* 跳转到腾讯应用宝
*/
private fun startTencentMarket(context: Context, deepLink: String) {
val uri: Uri = Uri.parse(deepLink) // "market://details?id=com.taobao.taobao&..."
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.setClassName(
"com.tencent.android.qqdownloader",
"com.tencent.pangu.link.LinkProxyActivity"
)
context.startActivity(intent)
}
}

测试代码:

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
private fun initView() {
//
val deepLink =
"market://details?id=com.taobao.taobao&ref=&caller=com.taobao.taobao&token=OWNjY2YyNGNiYzYwYTNkMCMxNjQ5NjQ2NTA0OTA5IzEjY29tLnRhb2Jhby50YW9iYW8jS29iZS8yNEAwZjY1NGI3ZGZlZWJlZWI2NTk3MTkyNWE4ZDFhZTc1Zg==&style=1&m=adapi_2049933&tk_con=eyJ0cmFja0lkIjoiN2VkOGI0ZWM2YjAwN2FmNjkzZDc0ODJkYTBlNzgyMDMiLCJkZXZpY2VJZCI6Ijg2Mzg5NDAzMjE1ODg3NyIsImFwcElkIjoiMjMzNCIsInZlcnNpb25JZCI6IjAiLCJlbnRlcklkIjoiMTQiLCJwYWdlSWQiOiIxMDAwMDEiLCJhYiI6IjFfMF85IiwiYWRJZCI6IjQwNzA2ODY5OCIsInQiOiIxNjQ5NjQ2NTA0OTA3IiwidiI6InYxIn0%3D&tk_ref=%7B%22adId%22%3A%22407068698%22%2C%22trackId%22%3A%227ed8b4ec6b007af693d7482da0e78203%22%7D"
xiaomi_market.setOnClickListener {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.XIAOMI_MARKET)
}
huawei_market.setOnClickListener {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.HUAWEI_MARKET)
}
oppo_market.setOnClickListener {
if (android.os.Build.VERSION.SDK_INT >= 28) {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.OPPO_MARKET2)
} else {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.OPPO_MARKET)
}
}
vivo_market.setOnClickListener {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.VIVO_MARKET)
}
meizu_market.setOnClickListener {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.MEIZU_MARKET)
}
yyb_market.setOnClickListener {
MarketUtils.startTargetMarket(this, deepLink, MarketUtils.YYB_MARKET)
}
}

自定义TextView实现多个文案切换炫酷动画

当显示2个或2个以上文案时,每隔2秒切换气泡文案
4C3FBC04FF667DAAEF17AA6B8F8F7A46

核心实现代码如下:

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115

import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import androidx.appcompat.widget.AppCompatTextView

class TextViewSwitcher @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {

private var strs: List<String>? = null
private var startPos: Int = 0
private val timeStep = 2000L // 2S
private val TAG = "TextViewSwitcher"

private val showNext = Runnable {
showNextStr()
}

override fun onAttachedToWindow() {
Log.d(TAG, "onAttachedToWindow: ")
super.onAttachedToWindow()
val size = strs?.size ?: 0
if (size > 1) {
handler.postDelayed(showNext, timeStep)
}
}

override fun onDetachedFromWindow() {
Log.d(TAG, "onDetachedFromWindow: ")
handler.removeCallbacks(showNext)
super.onDetachedFromWindow()
}

fun setTextList(strs: List<String>?, startPos: Int = 0) {
Log.d(TAG, "setTextList: ")
if (strs.isNullOrEmpty()) return
this.strs = strs
this.startPos = startPos
if (strs.size == 1) {
text = strs[0]
} else {
this.startPos = startPos % strs.size
text = strs[this.startPos]
}
}

private fun showNextStr() {
var startPos = this.startPos + 1
val size = strs?.size ?: 0
if (size <= 1) return
if (startPos >= size) startPos %= size

this.startPos = startPos
changeTextWithAnimator(this, strs?.get(startPos))

handler.postDelayed(showNext, timeStep)
}

private fun changeTextWithAnimator(
textView: AppCompatTextView?,
nextContent: String?
) {
if (textView != null) {
val animator = ValueAnimator.ofFloat(0f, 2f)
animator.duration = 400
var changed = false
textView.pivotX = 0f
val height = textView.measuredHeight
if (height > 0) {
textView.pivotY = height.toFloat()
}

val startWidth = textView.measuredWidth
var endWidth = 0
val params = textView.layoutParams
animator.addUpdateListener { animation ->
val value = animation.animatedValue as Float
when {
value < 1f -> {
textView.rotation = 360 - value * 60
textView.alpha = 1 - value
}
value > 1f -> {
textView.alpha = value - 1
textView.rotation = 360 - (2 - value) * 60
if (!changed) {
changed = true
textView.text = nextContent

val measureSpec = MeasureSpec.makeMeasureSpec(
0,
MeasureSpec.UNSPECIFIED
)
textView.measure(measureSpec, measureSpec)
endWidth = textView.measuredWidth

Log.d(TAG, "changeTextWithAnimator: endWidth=$endWidth")
} else {
if (endWidth > 0) {
params.width =
(startWidth + (endWidth - startWidth) * (value - 1)).toInt()
textView.layoutParams = params
}
}
}
}
}
animator.start()
}
}
}

由于动画要在顶部浮层,这样动画才能不被父类容器的大小所限制和切割,所以,直接在PopWindow中显示。
具体代码如下:

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
56
57
58
59
60
61
62
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow

object HomePromptView {
@JvmStatic
fun showTipPopView(view: View, typedStr: String?): PopupWindow {

val rootView = LayoutInflater.from(view.context).inflate(R.layout.home_prompt_layout, null)
val promptTv = rootView.findViewById<TextViewSwitcher>(R.id.tv_prompt)
promptTv.setTextList(typedStr?.split("|"))
rootView.isClickable = false

// PopWindow
val popTipWid = PopupWindow(
rootView,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
popTipWid.isTouchable = false

// android.view.WindowManager$BadTokenException:
// Unable to add window -- token null is not valid;
// is your activity running?
try {
popTipWid.showAtLocation(view.rootView, 0, 0, 0)
layoutPromptLocation(promptTv, view)
// popTipWid.showAsDropDown(view, UiUtils.dip2px(39), -UiUtils.dip2px(48))
view.addOnLayoutChangeListener { view, i, i2, i3, i4, i5, i6, i7, i8 ->
layoutPromptLocation(promptTv, view)
}
} catch (e: Exception) {
e.printStackTrace()
}
return popTipWid
}


private fun layoutPromptLocation(
promptTv: TextViewSwitcher,
view: View
) {
try {
val params = promptTv.layoutParams as ViewGroup.MarginLayoutParams
val location = IntArray(2)
view.getLocationInWindow(location)
params.topMargin =
location[1] - UiUtils.getStatusBarHeight(view.context) + UiUtils.dip2px(4)
params.leftMargin = location[0] + UiUtils.dip2px(25)
promptTv.layoutParams = params
} catch (e: Exception) {
e.printStackTrace()
}
}

@JvmStatic
fun dismissTipPopView(popTipWid: PopupWindow?) {
popTipWid?.dismiss()
}

}

添加布局代码如下:

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
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/transparent">

<******.TextViewSwitcher
android:id="@+id/tv_prompt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/common_ui_shape_red_heavy_prompt"
android:maxLines="1"
android:paddingStart="5dp"
android:paddingTop="1.5dp"
android:paddingEnd="5dp"
android:paddingBottom="1.5dp"
android:textColor="@color/white"
android:textSize="10sp"
tools:text="硬核安利">

</******.TextViewSwitcher>

</FrameLayout>

drawable:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:bottomRightRadius="9dp"
android:topLeftRadius="9dp"
android:topRightRadius="9dp" />
<solid android:color="#FF5E79" />
<stroke
android:width="0.5dp"
android:color="@color/white" />
</shape>

SpannableString 之显示查看全文

SpannableString 之显示查看全文

显示内容超出规定的行数之后,显示 展开收起

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
import android.content.Context;
import android.graphics.Color;
import android.os.Build;
import android.text.Layout;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.AlignmentSpan;
import android.text.style.ClickableSpan;
import android.text.style.StyleSpan;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.Transformation;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;

import java.lang.reflect.Field;

/**
* Description : 显示展开和收起
* PackageName : com.mrtrying.widget
* Created by mrtrying on 2019/4/17 17:21.
* e_mail : ztanzeyu@gmail.com
*/
public class ExpandableTextView extends AppCompatTextView {
private static final String TAG = ExpandableTextView.class.getSimpleName();

public static final String ELLIPSIS_STRING = new String(new char[]{'\u2026'});
private static final String DEFAULT_OPEN_SUFFIX = " 展开";
private static final String DEFAULT_CLOSE_SUFFIX = " 收起";
volatile boolean animating = false;
boolean isClosed = false;
private int mMaxLines = getMaxLines();
private int initWidth = 0;
private CharSequence originalText;

private SpannableStringBuilder mOpenSpannableStr, mCloseSpannableStr;

private boolean hasAnimation = false;
private Animation mOpenAnim, mCloseAnim;
private int mOpenHeight, mCLoseHeight;
private boolean mExpandable;
private boolean mCloseInNewLine;
@Nullable
private SpannableString mOpenSuffixSpan, mCloseSuffixSpan;
private String mOpenSuffixStr = DEFAULT_OPEN_SUFFIX;
private String mCloseSuffixStr = "";
private int mOpenSuffixColor, mCloseSuffixColor;
private int mNormalColor = Color.parseColor("#FF222222");

private View.OnClickListener mOnClickListener;

private CharSequenceToSpannableHandler mCharSequenceToSpannableHandler;
private IOnOpenSuffixSpanListener onOpenSuffixSpanListener;

public ExpandableTextView(Context context) {
super(context);
initialize();
}

public ExpandableTextView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}

public ExpandableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}

/**
* 初始化
*/
private void initialize() {
mOpenSuffixColor = mCloseSuffixColor = Color.parseColor("#FF0091FF");
setMovementMethod(CustomLinkMovementMethod.getInstance());
// setIncludeFontPadding(false);
updateOpenSuffixSpan();
updateCloseSuffixSpan();
}

@Override
public boolean hasOverlappingRendering() {
return false;
}

public void setOriginalText(CharSequence originalText) {
this.originalText = originalText;
mExpandable = false;
mCloseSpannableStr = new SpannableStringBuilder();
final int maxLines = mMaxLines;
SpannableStringBuilder tempText = charSequenceToSpannable(originalText);
mOpenSpannableStr = charSequenceToSpannable(originalText);

if (maxLines != -1) {
Layout layout = createStaticLayout(tempText);
mExpandable = layout.getLineCount() > maxLines;
if (mExpandable) {
//拼接展开内容
if (mCloseInNewLine) {
mOpenSpannableStr.append("\n");
}
if (mCloseSuffixSpan != null) {
mOpenSpannableStr.append(mCloseSuffixSpan);
}
//计算原文截取位置
int endPos = layout.getLineEnd(maxLines - 1);
if (originalText.length() <= endPos) {
mCloseSpannableStr = charSequenceToSpannable(originalText);
} else {
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, endPos));
}
SpannableStringBuilder tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
tempText2.append(mOpenSuffixSpan);
}
//循环判断,收起内容添加展开后缀后的内容
Layout tempLayout = createStaticLayout(tempText2);
while (tempLayout.getLineCount() > maxLines) {
int lastSpace = mCloseSpannableStr.length() - 1;
if (lastSpace == -1) {
break;
}
if (originalText.length() <= lastSpace) {
mCloseSpannableStr = charSequenceToSpannable(originalText);
} else {
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
}
tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
tempText2.append(mOpenSuffixSpan);
}
tempLayout = createStaticLayout(tempText2);

}
int lastSpace = mCloseSpannableStr.length();
// - mOpenSuffixSpan.length();
// if(lastSpace >= 0 && originalText.length() > lastSpace){
// CharSequence redundantChar = originalText.subSequence(lastSpace, lastSpace + mOpenSuffixSpan.length());
// int offset = hasEnCharCount(redundantChar) - hasEnCharCount(mOpenSuffixSpan) + 1;
// lastSpace = offset <= 0 ? lastSpace : lastSpace - offset;
// mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
// }
//计算收起的文本高度
mCLoseHeight = tempLayout.getHeight() + getPaddingTop() + getPaddingBottom();
// mCloseSpannableStr.setSpan(new ClickableSpan() {
// @Override
// public void onClick(@NonNull View widget) {
// if (mOnClickListener != null) {
// mOnClickListener.onClick(widget);
// }
// }
//
// @Override
// public void updateDrawState(@NonNull TextPaint ds) {
// super.updateDrawState(ds);
// ds.setColor(mNormalColor);
// ds.setUnderlineText(false);
// }
// }, 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
}
}
isClosed = mExpandable;
if (mExpandable) {
// mCloseSpannableStr.setSpan(new ClickableSpan() {
// @Override
// public void onClick(@NonNull View widget) {
// if(listener!=null){
// listener.onClick(widget);
// }
// }
//
// @Override
// public void updateDrawState(@NonNull TextPaint ds) {
// super.updateDrawState(ds);
// ds.setColor(mNormalColor);
// ds.setUnderlineText(false);
// }
// }, 0, mCloseSpannableStr.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
mCloseSpannableStr.append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
mCloseSpannableStr.append(mOpenSuffixSpan);
}
setMovementMethod(CustomLinkMovementMethod.getInstance());
setClickable(false);
setLongClickable(false);
setText(mCloseSpannableStr);

} else {
// mOpenSpannableStr.setSpan(new ClickableSpan() {
// @Override
// public void onClick(@NonNull View widget) {
// if (mOnClickListener != null) {
// mOnClickListener.onClick(widget);
// }
// }
//
// @Override
// public void updateDrawState(@NonNull TextPaint ds) {
// super.updateDrawState(ds);
// ds.setColor(mNormalColor);
// ds.setUnderlineText(false);
// }
// }, 0, mOpenSpannableStr.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
setText(mOpenSpannableStr);
}
}

private int hasEnCharCount(CharSequence str) {
int count = 0;
if (!TextUtils.isEmpty(str)) {
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (c >= ' ' && c <= '~') {
count++;
}
}
}
return count;
}

private void switchOpenClose() {
if (mExpandable) {
isClosed = !isClosed;
if (isClosed) {
close();
} else {
open();
}
}
}

/**
* 设置是否有动画
*
* @param hasAnimation
*/
public void setHasAnimation(boolean hasAnimation) {
this.hasAnimation = hasAnimation;
}

/**
* 展开
*/
private void open() {
if (hasAnimation) {
Layout layout = createStaticLayout(mOpenSpannableStr);
mOpenHeight = layout.getHeight() + getPaddingTop() + getPaddingBottom();
executeOpenAnim();
} else {
ExpandableTextView.super.setMaxLines(Integer.MAX_VALUE);
setText(mOpenSpannableStr);
if (mOpenCloseCallback != null) {
mOpenCloseCallback.onOpen();
}
}
}

/**
* 收起
*/
private void close() {
if (hasAnimation) {
executeCloseAnim();
} else {
ExpandableTextView.super.setMaxLines(mMaxLines);
setText(mCloseSpannableStr);
if (mOpenCloseCallback != null) {
mOpenCloseCallback.onClose();
}
}
}

/**
* 执行展开动画
*/
private void executeOpenAnim() {
//创建展开动画
if (mOpenAnim == null) {
mOpenAnim = new ExpandCollapseAnimation(this, mCLoseHeight, mOpenHeight);
mOpenAnim.setFillAfter(true);
mOpenAnim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
ExpandableTextView.super.setMaxLines(Integer.MAX_VALUE);
setText(mOpenSpannableStr);
}

@Override
public void onAnimationEnd(Animation animation) {
// 动画结束后textview设置展开的状态
getLayoutParams().height = mOpenHeight;
requestLayout();
animating = false;
}

@Override
public void onAnimationRepeat(Animation animation) {

}
});
}

if (animating) {
return;
}
animating = true;
clearAnimation();
// 执行动画
startAnimation(mOpenAnim);
}

/**
* 执行收起动画
*/
private void executeCloseAnim() {
//创建收起动画
if (mCloseAnim == null) {
mCloseAnim = new ExpandCollapseAnimation(this, mOpenHeight, mCLoseHeight);
mCloseAnim.setFillAfter(true);
mCloseAnim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {

}

@Override
public void onAnimationEnd(Animation animation) {
animating = false;
ExpandableTextView.super.setMaxLines(mMaxLines);
setText(mCloseSpannableStr);
getLayoutParams().height = mCLoseHeight;
requestLayout();
}

@Override
public void onAnimationRepeat(Animation animation) {

}
});
}

if (animating) {
return;
}
animating = true;
clearAnimation();
// 执行动画
startAnimation(mCloseAnim);
}

/**
* @param spannable
* @return
*/
private Layout createStaticLayout(SpannableStringBuilder spannable) {
int contentWidth = initWidth - getPaddingLeft() - getPaddingRight();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder builder = StaticLayout.Builder.obtain(spannable, 0, spannable.length(), getPaint(), contentWidth);
builder.setAlignment(Layout.Alignment.ALIGN_NORMAL);
builder.setIncludePad(getIncludeFontPadding());
builder.setLineSpacing(getLineSpacingExtra(), getLineSpacingMultiplier());
return builder.build();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL,
getLineSpacingMultiplier(), getLineSpacingExtra(), getIncludeFontPadding());
} else {
return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL,
getFloatField("mSpacingMult", 1f), getFloatField("mSpacingAdd", 0f), getIncludeFontPadding());
}
}

private float getFloatField(String fieldName, float defaultValue) {
float value = defaultValue;
if (TextUtils.isEmpty(fieldName)) {
return value;
}
try {
// 获取该类的所有属性值域
Field[] fields = this.getClass().getDeclaredFields();
for (Field field : fields) {
if (TextUtils.equals(fieldName, field.getName())) {
value = field.getFloat(this);
break;
}
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return value;
}


/**
* @param charSequence
* @return
*/
private SpannableStringBuilder charSequenceToSpannable(@NonNull CharSequence charSequence) {
SpannableStringBuilder spannableStringBuilder = null;
if (mCharSequenceToSpannableHandler != null) {
spannableStringBuilder = mCharSequenceToSpannableHandler.charSequenceToSpannable(charSequence);
}
if (spannableStringBuilder == null) {
spannableStringBuilder = new SpannableStringBuilder(charSequence);
}
return spannableStringBuilder;
}

/**
* 初始化TextView的可展示宽度
*
* @param width
*/
public void initWidth(int width) {
initWidth = width;
}

@Override
public void setMaxLines(int maxLines) {
this.mMaxLines = maxLines;
super.setMaxLines(maxLines);
}

/**
* 设置展开后缀text
*
* @param openSuffix
*/
public void setOpenSuffix(String openSuffix, IOnOpenSuffixSpanListener onOpenSuffixSpanListener) {
mOpenSuffixStr = openSuffix;
this.onOpenSuffixSpanListener = onOpenSuffixSpanListener;
updateOpenSuffixSpan();
}

public void setNewOpenSuffix(String openSuffix, IOnOpenSuffixSpanListener onOpenSuffixSpanListener) {
mOpenSuffixStr = openSuffix;
this.onOpenSuffixSpanListener = onOpenSuffixSpanListener;
updateNewOpenSuffixSpan();
}


/**
* 设置展开后缀文本颜色
*
* @param openSuffixColor
*/
public void setOpenSuffixColor(@ColorInt int openSuffixColor) {
mOpenSuffixColor = openSuffixColor;
updateOpenSuffixSpan();
}

/**
* 设置收起后缀text
*
* @param closeSuffix
*/
public void setCloseSuffix(String closeSuffix) {
mCloseSuffixStr = closeSuffix;
updateCloseSuffixSpan();
}

/**
* 设置收起后缀文本颜色
*
* @param closeSuffixColor
*/
public void setCloseSuffixColor(@ColorInt int closeSuffixColor) {
mCloseSuffixColor = closeSuffixColor;
updateCloseSuffixSpan();
}

/**
* 收起后缀是否另起一行
*
* @param closeInNewLine
*/
public void setCloseInNewLine(boolean closeInNewLine) {
mCloseInNewLine = closeInNewLine;
updateCloseSuffixSpan();
}

public interface IOnOpenSuffixSpanListener {
void onClick();
}

/**
* 更新展开后缀Spannable
*/
private void updateOpenSuffixSpan() {
if (TextUtils.isEmpty(mOpenSuffixStr)) {
mOpenSuffixSpan = null;
return;
}
mOpenSuffixSpan = new SpannableString(mOpenSuffixStr);

mOpenSuffixSpan.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
if (onOpenSuffixSpanListener != null) {
onOpenSuffixSpanListener.onClick();
} else {
switchOpenClose();
}
}

@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(mOpenSuffixColor);
ds.setUnderlineText(false);
}
}, 0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}

private void updateNewOpenSuffixSpan() {
if (TextUtils.isEmpty(mOpenSuffixStr)) {
mOpenSuffixSpan = null;
return;
}
mOpenSuffixSpan = new SpannableString(mOpenSuffixStr);

mOpenSuffixSpan.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
if (onOpenSuffixSpanListener != null) {
onOpenSuffixSpanListener.onClick();
} else {
switchOpenClose();
}
}

@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(mOpenSuffixColor);
ds.setUnderlineText(false);
}
}, 0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}


/**
* 更新收起后缀Spannable
*/
private void updateCloseSuffixSpan() {
if (TextUtils.isEmpty(mCloseSuffixStr)) {
mCloseSuffixSpan = null;
return;
}
mCloseSuffixSpan = new SpannableString(mCloseSuffixStr);
mCloseSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (mCloseInNewLine) {
AlignmentSpan alignmentSpan = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE);
mCloseSuffixSpan.setSpan(alignmentSpan, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
mCloseSuffixSpan.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
switchOpenClose();
}

@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(mCloseSuffixColor);
ds.setUnderlineText(false);
}
}, 1, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}

public void setContentClickListener(View.OnClickListener onClickListener) {
mOnClickListener = onClickListener;
}

public OpenAndCloseCallback mOpenCloseCallback;

public void setOpenAndCloseCallback(OpenAndCloseCallback callback) {
this.mOpenCloseCallback = callback;
}

public interface OpenAndCloseCallback {
void onOpen();

void onClose();
}

/**
* 设置文本内容处理
*
* @param handler
*/
public void setCharSequenceToSpannableHandler(CharSequenceToSpannableHandler handler) {
mCharSequenceToSpannableHandler = handler;
}

public interface CharSequenceToSpannableHandler {
@NonNull
SpannableStringBuilder charSequenceToSpannable(CharSequence charSequence);
}

class ExpandCollapseAnimation extends Animation {
private final View mTargetView;//动画执行view
private final int mStartHeight;//动画执行的开始高度
private final int mEndHeight;//动画结束后的高度

ExpandCollapseAnimation(View target, int startHeight, int endHeight) {
mTargetView = target;
mStartHeight = startHeight;
mEndHeight = endHeight;
setDuration(400);
}

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
mTargetView.setScrollY(0);
//计算出每次应该显示的高度,改变执行view的高度,实现动画
mTargetView.getLayoutParams().height = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight);
mTargetView.requestLayout();
}
}

}

控件使用方式

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
val tvContent = holder.getView<ExpandableTextView>(R.id.tv_content)
tvContent.movementMethod = LinkMovementMethod.getInstance()
tvContent.isClickable = false
tvContent.isLongClickable = false
val builder = SpannableStringBuilder()
if (item.spoiler && spoilerEnable) builder.append(" ")
if (item.talkList != null && item.talkList.size > 0) {
val talk = item.talkList[0]
val startIndex = builder.length
builder.append("#")
builder.append(talk.name)
builder.append(" ")
val clickableSpan = object : ClickableSpan() {
override fun onClick(view: View) {
talkClickListener?.invoke(item, holder.adapterPosition)
}

override fun updateDrawState(ds: TextPaint) {
ds.color =
if (talk.enable) Color.parseColor("#1890FF")
else Color.parseColor("#85888F")
}
}
builder.setSpan(
clickableSpan,
startIndex,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
builder.append(it)
tvContent.initWidth(UiUtils.getScreenWidth() - UiUtils.dip2px(16 * 2 + 36 + 9))
tvContent.setOpenSuffix("查看全文") {
listener.invoke(item, null)
}
tvContent.setOriginalText(builder)

SettingsHelper

SettingsHelper

SettingsHelper 自启动设置

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log

/**
* 跳转自启动页面
*/
object AutoStartHelper {
private val hashMap = mutableMapOf<String, List<String>>().apply {
put(
"Xiaomi", listOf(
"com.miui.securitycenter/com.miui.permcenter.autostart.AutoStartManagementActivity",
"com.miui.securitycenter"
)
)
put(
"samsung", listOf(
"com.samsung.android.sm_cn/com.samsung.android.sm.ui.ram.AutoRunActivity",
"com.samsung.android.sm_cn/com.samsung.android.sm.ui.appmanagement.AppManagementActivity",
"com.samsung.android.sm_cn/com.samsung.android.sm.ui.cstyleboard.SmartManagerDashBoardActivity",
"com.samsung.android.sm_cn/.ui.ram.RamActivity",
"com.samsung.android.sm_cn/.app.dashboard.SmartManagerDashBoardActivity",
"com.samsung.android.sm/com.samsung.android.sm.ui.ram.AutoRunActivity",
"com.samsung.android.sm/com.samsung.android.sm.ui.appmanagement.AppManagementActivity",
"com.samsung.android.sm/com.samsung.android.sm.ui.cstyleboard.SmartManagerDashBoardActivity",
"com.samsung.android.sm/.ui.ram.RamActivity",
"com.samsung.android.sm/.app.dashboard.SmartManagerDashBoardActivity",
"com.samsung.android.lool/com.samsung.android.sm.ui.battery.BatteryActivity",
"com.samsung.android.sm_cn",
"com.samsung.android.sm"
)
)
put(
"HUAWEI", listOf(
"com.huawei.systemmanager/.startupmgr.ui.StartupNormalAppListActivity",
"com.huawei.systemmanager/.appcontrol.activity.StartupAppControlActivity",
"com.huawei.systemmanager/.optimize.process.ProtectActivity",
"com.huawei.systemmanager/.optimize.bootstart.BootStartActivity",
// "com.huawei.systemmanager/com.huawei.permissionmanager.ui.MainActivity", // 这个是隐私-权限管理,但是没有自启动权限!!!
"com.android.settings/com.android.settings.Settings$" + "AppAndNotificationDashboardActivity", // 鸿蒙系统,应用和服务,列表中有应用启动管理
"com.huawei.systemmanager"
)
)
put(
"vivo", listOf(
"com.iqoo.secure/.ui.phoneoptimize.BgStartUpManager",
"com.vivo.permissionmanager/.activity.BgStartUpManagerActivity",
"com.vivo.permissionmanager/.activity.SoftPermissionDetailActivity",
"com.iqoo.secure/.safeguard.PurviewTabActivity",
"com.iqoo.secure",
"com.vivo.permissionmanager"
)
)
put(
"Meizu", listOf(
"com.meizu.safe/.permission.SmartBGActivity",
"com.meizu.safe/.permission.PermissionMainActivity",
"com.meizu.safe"
)
)
put(
"OPPO", listOf(
"com.coloros.safecenter/.startupapp.StartupAppListActivity",
"com.coloros.safecenter/.permission.startup.StartupAppListActivity",
"com.oppo.safe/.permission.startup.StartupAppListActivity",
"com.coloros.oppoguardelf/com.coloros.powermanager.fuelgaue.PowerUsageModelActivity",
"com.coloros.safecenter/com.coloros.privacypermissionsentry.PermissionTopActivity",
"com.coloros.safecenter",
"com.oppo.safe",
"com.coloros.oppoguardelf"
)
)
put(
"oneplus", listOf(
"com.oneplus.security/.chainlaunch.view.ChainLaunchAppListActivity",
"com.oneplus.security"
)
)
put(
"letv", listOf(
"com.letv.android.letvsafe/.AutobootManageActivity",
"com.letv.android.letvsafe/.BackgroundAppManageActivity",
"com.letv.android.letvsafe"
)
)
put(
"zte", listOf(
"com.zte.heartyservice/.autorun.AppAutoRunManager",
"com.zte.heartyservice"
)
)
//金立
put(
"F", listOf(
"com.gionee.softmanager/.MainActivity",
"com.gionee.softmanager"
)
)
//以下为未确定(厂商名也不确定)
put(
"smartisanos", listOf(
"com.smartisanos.security/.invokeHistory.InvokeHistoryActivity",
"com.smartisanos.security"
)
)
//360
put(
"360", listOf(
"com.yulong.android.coolsafe/.ui.activity.autorun.AutoRunListActivity",
"com.yulong.android.coolsafe"
)
)
//360
put(
"ulong", listOf(
"com.yulong.android.coolsafe/.ui.activity.autorun.AutoRunListActivity",
"com.yulong.android.coolsafe"
)
)
//酷派
put(
"coolpad" /*厂商名称不确定是否正确*/, listOf(
"com.yulong.android.security/com.yulong.android.seccenter.tabbarmain",
"com.yulong.android.security"
)
)
//联想
put(
"lenovo" /*厂商名称不确定是否正确*/, listOf(
"com.lenovo.security/.purebackground.PureBackgroundActivity",
"com.lenovo.security"
)
)
put(
"htc" /*厂商名称不确定是否正确*/, listOf(
"com.htc.pitroad/.landingpage.activity.LandingPageActivity",
"com.htc.pitroad"
)
)
//华硕
put(
"asus" /*厂商名称不确定是否正确*/, listOf(
"com.asus.mobilemanager/.MainActivity",
"com.asus.mobilemanager"
)
)
//酷派
put(
"YuLong", listOf(
"com.yulong.android.softmanager/.SpeedupActivity",
"com.yulong.android.security/com.yulong.android.seccenter.tabbarmain",
"com.yulong.android.security"
)
)
}

fun startAutoBootSetting(context: Context?) {
Log.e("AutoStartHelper", "当前手机型号为:" + Build.MANUFACTURER)
var result = false

run start0@{
for ((manufacturer, componentNameList) in hashMap) {
if (Build.MANUFACTURER.equals(manufacturer, ignoreCase = true)) {
for (actName in componentNameList) {
try {
var intent: Intent? = null
if (actName.contains("/")) {
intent = Intent()
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.component = ComponentName.unflattenFromString(actName)
if (actName.contains("SoftPermissionDetailActivity")) {
intent.putExtra("packagename", context?.packageName)
}
}
// else {
// // 跳转到对应的安全管家/安全中心
// intent = context?.packageManager?.getLaunchIntentForPackage(actName)
// }
intent?.let {
context?.startActivity(intent)
result = true
return@start0
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}

if (!result) {
try {
// 跳转到app详情设置
val intent = Intent()
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.data = Uri.fromParts("package", context?.packageName, null)
context?.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

SpannableString 之居中显示 ImageSpan

自定义布局:SpannableString 之居中显示 ImageSpan

image-20220104185114603
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
class CenteredImageSpan(context: Context, drawableRes: Int) : ImageSpan(context, drawableRes) {
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
// image to draw
val b = drawable
// font metrics of text to be replaced
val fm = paint.fontMetricsInt
var transY = ((y + fm.descent + y + fm.ascent) / 2 - b.bounds.bottom / 2)
// to check the last line.(当 image 在单独一行显示时可能会存在这个问题)
if (transY > bottom - b.bounds.bottom) transY = bottom - b.bounds.bottom
canvas.save()
canvas.translate(x, transY.toFloat())
b.draw(canvas)
canvas.restore()
}
}
1
2
3
4
5
6
7
8
9
10
11
val spanStr = SpannableStringBuilder()
spanStr.append("# ")
spanStr.append(title)
val imageSpan = CenteredImageSpan(this, R.mipmap.ic_topic_detail_jinghao_black)
spanStr.setSpan(imageSpan, 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// corner
spanStr.append(" #")
val len = spanStr.length
val cornerSpan = CenteredImageSpan(this, R.mipmap.ic_topic_detail_remen)
spanStr.setSpan(cornerSpan, len - 1, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.ctTalkDetailInfo.talkNameTv.text = spanStr

FileProvider 的使用

FileProvider 的使用

Dev Doc

https://developer.android.google.cn/reference/androidx/core/content/FileProvider

0x01 定义一个FileProvider

在 androidx 包提供的 FileProvider 提供了 生成文件Uri 的功能。

在 manifest 文件中,声明一个 provider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<manifest>
...
<application>
...
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
tools:replace="android:resource" />
</provider>
...
</application>
</manifest>

0x02 可用文件路径配置

在 res/xml/file_paths.xml 下配置可用的文件路径,FileProvider 只能生成配置了的文件Uri。每个你想要生成Uri的文件路径都需要在 paths 下面定义。

1
2
3
4
5
6
7
8
9
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="my_images" path="images/"/>
<cache-path name="name" path="path" />
<external-path name="name" path="path" />
<external-files-path name="name" path="path" />
<external-cache-path name="name" path="path" />
<external-media-path name="name" path="path" />
...
</paths>

0x03 生成一个Uri

和其他 app 共享一个文件,你需要生成一个Uri。

1
2
3
4
5
6
7
8
9
File imagePath = new File(Context.getFilesDir(), "my_images");
File newFile = new File(imagePath, "default_image.jpg");

Uri uriForFile;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
uriForFile = FileProvider.getUriForFile(mContext, mContext.getPackageName() + ".fileprovider", newFile);
} else {
uriForFile = Uri.parse("file://" + newFile.toString());
}

getUriForFile() 返回一个 content URI content://com.mydomain.fileprovider/my_images/default_image.jpg.

0x04 授予权限

1
2
3
shareContentIntent.setClipData(ClipData.newRawUri("", contentUri));
shareContentIntent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
  1. Put the content URI in an Intent by calling setData().
  2. Call the method Intent.setFlags() with either Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or both.
  3. Send the Intent to another app. Most often, you do this by calling setResult().

0x05 提供Uri给其他app

1
2
3
4
5
// 使用uri
Intent i = new Intent(Intent.ACTION_VIEW);
i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
i.setDataAndType(uriForFile, "application/vnd.android.package-archive");
mContext.startActivity(i);

Problems专题:Provider

Problems专题:Provider

0x01 FileProvider:Failed to find configured root that contains /…/**.jpeg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java.lang.IllegalArgumentException: Failed to find configured root that contains /storage/emulated/0/DCIM/Camera/**.jpeg
at androidx.core.content.FileProvider$SimplePathStrategy.getUriForFile(FileProvider.java:800)
at androidx.core.content.FileProvider.getUriForFile(FileProvider.java:442)
at com.luck.picture.lib.tools.PictureFileUtils.parUri(PictureFileUtils.java:533)
at com.luck.picture.lib.PictureBaseActivity.startOpenCameraImage(PictureBaseActivity.java:705)
at com.luck.picture.lib.PictureSelectorActivity.startCamera(PictureSelectorActivity.java:926)
at com.luck.picture.lib.PictureSelectorActivity.onTakePhoto(PictureSelectorActivity.java:1473)
at com.luck.picture.lib.adapter.PictureImageGridAdapter.lambda$onBindViewHolder$0$PictureImageGridAdapter(PictureImageGridAdapter.java:155)
at com.luck.picture.lib.adapter.-$$Lambda$PictureImageGridAdapter$0EODmJcP4VP0lqmkEhQ1dzLbHi8.onClick(Unknown Source:2)
at android.view.View.performClick(View.java:6608)
at android.view.View.performClickInternal(View.java:6585)
at android.view.View.access$3100(View.java:785)
at android.view.View$PerformClick.run(View.java:25921)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6810)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Back traces end.

问题分析

  1. FileProvider 路径配置文件被互相覆盖
  2. FileProvider 路径配置文件id重复,导致覆盖

解决方案

adb常用指令

adb常用指令指引

官网下载: http://adbshell.com/downloads

命令参考: http://adbshell.com/commands

Android官网adb介绍: https://developer.android.google.cn/studio/command-line/adb?hl=zh_cn

Mumu adb常用指令指引: https://mumu.163.com/help/20210513/35047_947512.html

0x01 查询和连接设备

1
2
3
4
5
6
7
8
9
10
## 连接/断开连接网络电视
adb connect 170.2.10.20
adb disconnect 170.2.10.20

## 连接/断开本地模拟器端口
adb connect 127.0.0.1:7555
adb disconnect 127.0.0.1:7555

## 查询已连接设备列表
adb devices -l

0x02 adb服务器

在某些情况下,您可能需要终止 adb 服务器进程,然后重启才能解决问题。例如,如果 adb 不响应命令,就可能会发生这种情况。

1
2
3
4
5
6
7
8
## 停止adb服务
adb kill-server

## 启动adb服务
adb start-server

## 以root权限重启adb服务(需要可root设备)
adb root

0x03 指定设备操作

命令格式:adb -s <serialNumber> command,如:adb -s 127.0.0.1:7555 shell pm list package
可以通过 adb devices 获取目标设备的serialNumber

1
2
3
4
5
6
$ adb devices
List of devices attached
emulator-5554 device
emulator-5555 device

$ adb -s emulator-5555 install helloWorld.apk

0x04 安装与卸载apk

1
2
3
4
5
## 安装apk
adb install C:\\xx.apk

## 卸载apk
adb uninstall C:\\xx.apk

0x05 已安装应用列表

所有应用包名列表

adb shell pm list packages

第三方应用包名列表

adb shell pm list packages -3

系统应用包名列表

adb shell pm list packages -s

根据某个关键字查找包

adb shell pm list packages |grep tencent

查看包安装位置

adb shell pm list packages -f |grep tencent

0x06 获取设备当前显示应用的包名和Activity

1
2
3
4
5
6
7
8
9
10
11
12
## 正在运行应用包名和Activity
$ adb shell dumpsys window | findstr mCurrentFocus
mCurrentFocus=null
mCurrentFocus=Window{fe571d0 u0 com.zhongduomei.rrmj.society/com.rrtv.rrtvtrunk.main.presentation.MainActivity}

## 设备当前显示应用的包名和Activity名称
$ adb shell dumpsys window w |findstr \/ |findstr name=
mSurface=Surface(name=NavigationBar0)/@0xe825312
mSurface=Surface(name=StatusBar)/@0x2a132d5
mSurface=Surface(name=com.tencent.qqlive/com.tencent.qqlive.ona.activity.SplashHomeActivity)/@0xc6af35b
mSurface=Surface(name=com.android.systemui.ImageWallpaper)/@0x65b6a37

0x07 启动应用

adb shell am start -n 应用包名/应用Activity类名

若想查看启动应用耗时,则可使用adb shell am start -W 应用包名/应用Activity类名

0x08 关闭应用

adb shell am force-stop 应用包名

0x09 查看应用版本号

adb shell dumpsys package 应用包名 | findstr version

0x10 清理应用数据

adb shell pm clear 应用包名

0x11 模拟输入

按键输入

adb shell input keyevent 键值

如:adb shell input keyevent 3表示按下HOME键,其他键值对应键位可网上搜索

字符输入

adb shell input text 字符

如:adb shell input text test则表示输入了test字符串

ps:字符不支持中文

鼠标点击

adb shell input tap X Y

X Y分别为当前屏幕下的x和y轴坐标值

鼠标滑动

adb shell input swipe X1 Y1 X2 Y2

X1 Y1 和X2 Y2分别为滑动起始点的坐标

0x12 从电脑上传文件至模拟器

adb push myfile.txt /sdcard/myfile.txt

0x13 从模拟器复制文件至电脑

adb pull /data/test.apk D:\

0x14 截图

将模拟器当前显示截图

adb shell screencap /data/screen.png

将截图文件下载至电脑

adb pull /data/screen.png C:\

0x15 录制视频

开始录制

adb shell screenrecord /data/test.mp4

结束录制

可按CTRL+C结束录制

导出视频文件

adb pull /data/test.mp4 C:\

0x16 查看设备信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
## 设备型号
$ adb shell getprop ro.product.model

## 设备品牌
$ adb shell getprop ro.product.brand

## 设备处理器型号
$ adb shell getprop ro.product.board

## 设备安卓版本号
$ adb shell getprop ro.build.version.release

## 设备abi
$ adb shell getprop ro.product.cpu.abi

## 设备cpu信息
$ adb shell cat /proc/cupinfo

## 设备引擎渲染模式
$ adb shell dumpsys SurfaceFlinger|findstr "GLES"

## grep product关键字(设备支持的abi列表)
$ adb shell getprop |grep product

0x17 管理设备

命令 功能
adb get-state 判断设备状态
adb devices 显示连接到计算机的设备
adb get-serialno 获取设备的序列号
adb reboot 重启设备
adb reboot bootloader 重启设备进入fastboot模式
adb reboot recovery 重启设备进入recovery模式

APK签名之签名文件的生成和查看

APK签名之签名文件的生成和查看

0x01 keytool生成签名文件

进入 JDK/bin,输入命令:

1
keytool -genkey -alias 密钥别名 -keyalg RSA -keysize 1024 -validity 36500 -keystore D:\test.jks -storetype pkcs12

参数说明:

-genkeypair 生成一条密钥对(由私钥和公钥组成)
-keystore 密钥库名字及存储位置(默认当前目录)
-alias 密钥对的别名(密钥库可以存在多个密钥对,用于区分不同密钥对)
-validity 密钥对的有效期(单位:天)
-keyalg 生成密钥对的算法(常用 RSA/DSA ,DSA 只用于签名,默认采用DSA )

提示:可重复使用此命令,在同一密钥库中创建多条密钥对

0x02 使用AndroidStudio工具生成jks签名文件

0x03 查看签名文件信息

0x0301 keytool工具查看签名信息

进入 JDK/bin,输入命令:

1
keytool -v -list -keystore D:\test.jks

20230907120940

0x0302 signingReport查看签名MD5

部分应用商店需要签名文件的md5,在 AndroidStudio 中执行gradlew命令

1
2
3
gradlew signingReport

gradlew :app:signingReport ## 只打印APP的签名信息

20230907121556

Android专栏-JavaCrash默认处理

Java Crash 默认处理

CrashHandler 处理 Java 异常流程:

  • 区分Debug模式和Release模式、主进程和子进程、主线程和子线程来处理
  • 捕获Activity的生命周期内异常,并主动杀死Activity
  • View绘制流程异常捕获
  • 自定义上报

CrashHandler 源码如下

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.util.Log
import com.alibaba.ha.adapter.AliHaAdapter
import com.rrtv.action.manager.ThreadPoolManager
import com.rrtv.utils.utils.UiUtils
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.lang.reflect.Field
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*


/**
* Java Crash 默认处理
*/
class CrashHandler : Thread.UncaughtExceptionHandler {

private var isDebug = false
private var isMainProcess = false

private var sActivityKiller: IActivityKiller? = null

companion object {
private const val TAG = "CrashHandler"
private var instance = CrashHandler()

@Volatile
@JvmStatic
private var hasInit = false


/**
* 初始化
*
* 在 @link Application#onCreate() 方法中 install()
*
* @param isDebug 是否debug模式 BuildConfig.DEBUG
* @param isMainProcess 是否是主进程 android.os.Process.myPid()
*/
@JvmStatic
fun install(isDebug: Boolean, isMainProcess: Boolean) {
if (!hasInit) {
synchronized(CrashHandler::class.java) {
if (!hasInit) {
Log.d(TAG, "install: isDebug = $isDebug, mainPid = $isMainProcess")
hasInit = true
instance.setup(isDebug, isMainProcess)
Log.d(TAG, "install success.")
}
}
}
}
}


private fun setup(isDebug: Boolean, isMainProcess: Boolean) {
Log.d(TAG, "setup:")
this.isDebug = isDebug
this.isMainProcess = isMainProcess

Thread.setDefaultUncaughtExceptionHandler(this)

if (!isDebug && isMainProcess) {
// release 模式防止主线程奔溃
Handler(Looper.getMainLooper()).post {
while (true) {
try {
Looper.loop()
} catch (e: Exception) {
handleLooperException(e)
}
}
}

try {
// 生命周期的 ActivityKiller
initActivityKiller()
} catch (e: Exception) {
Log.e(TAG, "拦截生命周期失败", e)
}
}
}

/**
* 替换ActivityThread.mH.mCallback,实现拦截Activity生命周期,直接忽略生命周期的异常的话会导致黑屏,目前
* 会调用ActivityManager的finishActivity结束掉生命周期抛出异常的Activity
*/
private fun initActivityKiller() {
Log.d(TAG, "initActivityKiller: Build.VERSION.SDK_INT=${Build.VERSION.SDK_INT}")
//各版本android的ActivityManager获取方式,finishActivity的参数,token(binder对象)的获取不一样
if (Build.VERSION.SDK_INT >= 28) {
sActivityKiller = ActivityKillerV28()
} else if (Build.VERSION.SDK_INT >= 26) {
sActivityKiller = ActivityKillerV26()
} else if (Build.VERSION.SDK_INT == 25 || Build.VERSION.SDK_INT == 24) {
sActivityKiller = ActivityKillerV24_V25()
} else if (Build.VERSION.SDK_INT in 21..23) {
sActivityKiller = ActivityKillerV21_V23()
} else if (Build.VERSION.SDK_INT in 15..20) {
sActivityKiller = ActivityKillerV15_V20()
} else if (Build.VERSION.SDK_INT < 15) {
sActivityKiller = ActivityKillerV15_V20()
}
try {
hookMH()
Log.e(TAG, "hookMH Success.")
} catch (e: Throwable) {
Log.e(TAG, "hookMH 失败: ", e)
}
}

@Throws(Exception::class)
private fun hookMH() {
Log.d(TAG, "hookMH: ")
val LAUNCH_ACTIVITY = 100
val PAUSE_ACTIVITY = 101
val PAUSE_ACTIVITY_FINISHING = 102
val STOP_ACTIVITY_HIDE = 104
val RESUME_ACTIVITY = 107
val DESTROY_ACTIVITY = 109
val NEW_INTENT = 112
val RELAUNCH_ACTIVITY = 126
val activityThreadClass =
Class.forName("android.app.ActivityThread")
val activityThread =
activityThreadClass.getDeclaredMethod("currentActivityThread").invoke(null)
val mhField: Field = activityThreadClass.getDeclaredField("mH")
mhField.setAccessible(true)
val mhHandler = mhField.get(activityThread) as Handler
val callbackField: Field = Handler::class.java.getDeclaredField("mCallback")
callbackField.setAccessible(true)
callbackField.set(mhHandler, Handler.Callback { msg ->
if (Build.VERSION.SDK_INT >= 28) { //android P 生命周期全部走这
val EXECUTE_TRANSACTION = 159
if (msg.what === EXECUTE_TRANSACTION) {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
sActivityKiller?.finishLaunchActivity(msg)
handleLifecycleException(throwable)
}
return@Callback true
}
return@Callback false
}
when (msg.what) {
LAUNCH_ACTIVITY -> {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
sActivityKiller?.finishLaunchActivity(msg)
handleLifecycleException(throwable)
}
return@Callback true
}
RESUME_ACTIVITY -> {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
sActivityKiller?.finishResumeActivity(msg)
handleLifecycleException(throwable)
}
return@Callback true
}
PAUSE_ACTIVITY_FINISHING -> {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
sActivityKiller?.finishPauseActivity(msg)
handleLifecycleException(throwable)
}
return@Callback true
}
PAUSE_ACTIVITY -> {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
sActivityKiller?.finishPauseActivity(msg)
handleLifecycleException(throwable)
}
return@Callback true
}
STOP_ACTIVITY_HIDE -> {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
sActivityKiller?.finishStopActivity(msg)
handleLifecycleException(throwable)
}
return@Callback true
}
DESTROY_ACTIVITY -> {
try {
mhHandler.handleMessage(msg)
} catch (throwable: Throwable) {
handleLifecycleException(throwable)
}
return@Callback true
}
}
false
})
}

/**
* 生命周期异常处理
*/
private fun handleLifecycleException(e: Throwable) {
Log.e(TAG, "lifecycleException: ", e)
reportCustomThrowable(Thread.currentThread(), RuntimeException("Activity生命周期出现异常", e))
// 给个Toast提示
try {
UiUtils.showToastSafe("小猿刚刚捕获了一只BUG")
} catch (e: Exception) {
e.printStackTrace()
}
}

/**
* 主线程Looper异常
*/
private fun handleLooperException(e: Exception) {
// 主线程内发生异常 主动 catch
Log.e(TAG, "handleLooperException: ", e)
reportCustomThrowable(Thread.currentThread(), e)
handleMainThreadException(e)
}


/**
* 本版本不处理
*
* view measure layout draw时抛出异常会导致Choreographer挂掉
* 建议直接杀死app。以后的版本会只关闭黑屏的Activity
*
* @param e
*/
private fun isChoreographerException(e: Throwable?): Boolean {
val elements = e?.stackTrace ?: return false
for (i in elements.size - 1 downTo -1 + 1) {
if (elements.size - i > 20) {
return false
}
val element = elements[i]
if ("android.view.Choreographer" == element.className && "Choreographer.java" == element.fileName && "doFrame" == element.methodName) {
//View 绘制流程出的问题
return true
}
}
return false
}


override fun uncaughtException(t: Thread, e: Throwable) {
Log.e(TAG, "uncaughtException: ")
if (isDebug) {
handleDebugException(t, e)
} else {
handleReleaseException(t, e)
}
}

/**
* 处理 Debug 模式的异常
*/
private fun handleDebugException(t: Thread, e: Throwable) {
Log.e(TAG, "handleDebugException: $t", e)
// 记录本地日志
saveThrowableMessage(Log.getStackTraceString(e))
}

/**
* 处理 !Debug 模式的异常
*/
private fun handleReleaseException(t: Thread, e: Throwable) {
Log.e(TAG, "handleReleaseException: $t", e)
// 自定义 Bug 上报
reportCustomThrowable(t, e)
// 根据情况来处理异常
if (isMainProcess) {
// 为主进程
if (Looper.myLooper() == Looper.getMainLooper()) {
// 主线程异常处理
handleMainThreadException(e)
} else {
// 非主线程
Log.e(TAG, "子线程异常: finish.")
}
} else {
// 如果是子进程发生异常 直接殺掉子進程
Log.e(TAG, "子进程异常: killProcess.")
android.os.Process.killProcess(android.os.Process.myPid())
}

}

/**
* 主线程异常处理
*
* Looper.loop & MainThreadUncaughtException
*/
private fun handleMainThreadException(e: Throwable) {
Log.e(TAG, "handleMainThreadException: ")
try {
// 主线程
when (e) {
is IllegalArgumentException,
is IllegalStateException,
is IndexOutOfBoundsException,
is UnsupportedOperationException,
is ArithmeticException,
is NumberFormatException,
is NullPointerException,
is ClassCastException,
is AssertionError,
is NoSuchElementException -> {
Log.e(TAG, "主线程异常: handle finish.")
if (isChoreographerException(e)) {
UiUtils.showToastSafe("界面刷新出了个小问题")
}
}
else -> {
Log.e(TAG, "主线程未知异常:System exit.")
// 这里也可以只给个提示,反正不会程序不会奔溃
android.os.Process.killProcess(android.os.Process.myPid())
System.exit(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
}

}

private val logFilePath = Environment.getExternalStorageDirectory().toString() +
File.separator + "Example" +
File.separator + "CrashLog"

private fun saveThrowableMessage(errorMessage: String) {
if (TextUtils.isEmpty(errorMessage)) {
return
}
val file = File(logFilePath)
if (file.exists() && file.isDirectory) {
// fall through
} else {
file.mkdirs()
}
writeToFile(errorMessage, file)
}

private val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.CHINA)

private fun writeToFile(errorMessage: String, file: File) {
ThreadPoolManager.getShortPool()?.execute {
var outputStream: FileOutputStream? = null
try {
val timestamp = System.currentTimeMillis()
val time = formatter.format(Date())
val fileName = "crash-$time-$timestamp"
val inputStream = ByteArrayInputStream(errorMessage.toByteArray())
outputStream = FileOutputStream(File(file, "$fileName.txt"))
var len: Int
val bytes = ByteArray(1024)
while (inputStream.read(bytes).also { len = it } != -1) {
outputStream.write(bytes, 0, len)
}
outputStream.flush()
Log.e(TAG, "异常奔溃日志成功写入本地文件:${file.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "异常奔溃日志写入本地文件失败: ", e)
} finally {
if (outputStream != null) {
try {
outputStream.close()
} catch (e: IOException) {
// nothing
}
}
}
}
}

/**
* 自定义 Bug 上报
*/
private fun reportCustomThrowable(t: Thread, e: Throwable) {
Log.e(TAG, "reportCustomThrowable: ")
try {
AliHaAdapter.getInstance().reportCustomError(e) //配置项:自定义错误
} catch (ex: Exception) {
Log.e(TAG, "上报自定义异常Error: ", ex)
}
}
}

使用方式:

ApplicationonCreate() 方法中, 调用 RrCrashHandler.install(BuildConfig.DEBUG, AppUtils.isMainProgress(this))

参考链接:

https://github.com/android-notes/Cockroach

https://github.com/android-notes/Cockroach/blob/master/%E5%8E%9F%E7%90%86%E5%88%86%E6%9E%90.md

Android专栏-SmartRefreshLayout

Android专栏-SmartRefreshLayout

0x01 加载结束之后底部多出一段空白位置

SmartRefreshLayout 嵌套 ViewPager2 上拉加载更多,在 finishLoadMore() 方法之后,底部加载 Loading 位置会多出一段空白不消失。

解决方案:

1
smartRefreshLayout.setEnableScrollContentWhenLoaded(false)

0x02 下拉刷新+PAG动画

自定义下拉刷新头部,使用 PAGView 做动画,可以在 onMoving(boolean b, float v, int i, int i1, int i2) 方法中设置 pagView.setProgress(v); 添加手势动画。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;

import com.scwang.smartrefresh.layout.api.RefreshHeader;
import com.scwang.smartrefresh.layout.api.RefreshKernel;
import com.scwang.smartrefresh.layout.api.RefreshLayout;
import com.scwang.smartrefresh.layout.constant.RefreshState;
import com.scwang.smartrefresh.layout.constant.SpinnerStyle;

import org.libpag.PAGFile;
import org.libpag.PAGView;

public class CommonRefreshHeader extends RelativeLayout implements RefreshHeader {
public static final String DEFAULT_LOADING_FILE = "load_bubble.pag";

protected View mView;
protected ImageView sdv_background;
private int mFinishDuration = 300;
private ViewGroup rootLayout;
private PAGView pagView;
private TextView toastTv;

public CommonRefreshHeader(Context context) {
super(context);
initView(context);
}

public CommonRefreshHeader(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}

public CommonRefreshHeader(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}

public int getLayoutId() {
return R.layout.layout_refresh_head;
}

protected void initView(Context context) {
mView = LayoutInflater.from(context).inflate(getLayoutId(), this);
toastTv = mView.findViewById(R.id.tv_toast);
rootLayout = mView.findViewById(R.id.rootLayout);
sdv_background = mView.findViewById(R.id.sdv_background);
PAGFile file = PAGFile.Load(context.getAssets(), DEFAULT_LOADING_FILE);
pagView = new PAGView(getContext());
LayoutParams params = new LayoutParams(UiUtils.dip2px(39), UiUtils.dip2px(39));
params.addRule(RelativeLayout.CENTER_IN_PARENT);
pagView.setLayoutParams(params);
pagView.setFile(file);
pagView.setRepeatCount(0);
rootLayout.addView(pagView);
}

public void setMarginTop(int marginTop) {
if (mView == null) return;
ViewGroup root = mView.findViewById(R.id.rootLayout);
if (root != null && root.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams params = (MarginLayoutParams) root.getLayoutParams();
params.topMargin = marginTop;
root.setLayoutParams(params);
}
}

@NonNull
@Override
public View getView() {
return mView;
}

@Override
public SpinnerStyle getSpinnerStyle() {
return SpinnerStyle.Translate;
}

@Override
public void setPrimaryColors(@ColorInt int... colors) {

}

@Override
public void onInitialized(@NonNull RefreshKernel kernel, int height, int maxDragHeight) {

}

@Override
public void onMoving(boolean b, float v, int i, int i1, int i2) {
/**
* 【仅限框架内调用】手指拖动下拉(会连续多次调用,添加isDragging并取代之前的onPulling、onReleasing)
* @param isDragging true 手指正在拖动 false 回弹动画
* @param percent 下拉的百分比 值 = offset/footerHeight (0 - percent - (footerHeight+maxDragHeight) / footerHeight )
* @param offset 下拉的像素偏移量 0 - offset - (footerHeight+maxDragHeight)
* @param height 高度 HeaderHeight or FooterHeight (offset 可以超过 height 此时 percent 大于 1)
* @param maxDragHeight 最大拖动高度 offset 可以超过 height 参数 但是不会超过 maxDragHeight
*/
if (pagView != null && !pagView.isPlaying()) {
pagView.setProgress(v);
pagView.flush();
}
}

@Override
public void onReleased(@NonNull RefreshLayout refreshLayout, int i, int i1) {

}

@Override
public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) {

}

@Override
public void onStartAnimator(RefreshLayout layout, int height, int extendHeight) {
LogUtils.e("RefreshHeader", "onStartAnimator");
if (pagView != null) pagView.play();
}

@Override
public int onFinish(RefreshLayout layout, boolean success) {
LogUtils.e("RefreshHeader", "onFinish");
if (pagView != null) pagView.stop();
return mFinishDuration;//延迟500毫秒之后再弹回
}

@Override
public boolean isSupportHorizontalDrag() {
return false;
}

@Override
public void onStateChanged(RefreshLayout refreshLayout, RefreshState oldState, RefreshState newState) {
switch (newState) {
case None:
case PullDownToRefresh:
case Refreshing:
if (pagView != null) pagView.setVisibility(VISIBLE);
if (toastTv != null) toastTv.setVisibility(GONE);
break;
case ReleaseToRefresh:
break;
case RefreshFinish:
if (pagView != null) pagView.setVisibility(GONE);
if (toastTv != null) toastTv.setVisibility(VISIBLE);
break;
}
}

public void setToastText(String str) {
if (StringUtils.isEmpty(str)) return;
if (toastTv != null) {
toastTv.setText(str);
}
}

public void setFinishDuration(int finishDuration) {
this.mFinishDuration = finishDuration;
}
}

Gson 数据解析

Gson 数据解析

0x01 Kotlin Gson 解析 data class 两条黄金法则:

1、 String 必须是可空类型 String?

2、 需要使用默认值,则全部字段都必须给予默认值,以满足kotlin对象有空的构造函数

0x02 手动解析Gson基础字段

1、msg 可空String解析 jsonReader.peek() == JsonToken.NULL

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
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.TypeAdapter
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import okhttp3.ResponseBody
import retrofit2.Converter
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

class ResponseBodyConverter(val gson: Gson, val type: Type) :
Converter<ResponseBody, BaseResponse<Any>> {
override fun convert(value: ResponseBody): BaseResponse<Any> {
val dataType = GenericsUtils.getParameterUpperBound(
0,
type as ParameterizedType
)
val baseResponse = BaseResponse<Any>()
val jsonReader = JsonReader(value.charStream())
try {
jsonReader.beginObject()
while (jsonReader.hasNext()) {
val name: String = jsonReader.nextName()
when (name) {
"code" -> {
baseResponse.code = jsonReader.nextString()
}
"msg" -> {
// this works, but do not do this.
if (jsonReader.peek() == JsonToken.NULL) {
jsonReader.nextNull()
baseResponse.msg = null
} else {
baseResponse.msg = jsonReader.nextString()
}
}
"data" -> {
val mapped: TypeAdapter<*>? = gson.getAdapter(TypeToken.get(dataType))
baseResponse.data = mapped?.read(jsonReader)
}
else -> {
jsonReader.skipValue()
}
}
}
} catch (e: IllegalStateException) {
throw JsonSyntaxException(e)
} catch (e: IllegalAccessException) {
throw AssertionError(e)
}
jsonReader.endObject()
return baseResponse
}
}

大量文本的浏览进度和浏览时长统计

大量文本的浏览进度和浏览时长统计

埋点需求,Android App 需要在onResume 和 onPause 方法中计算浏览的时长,同时上报浏览的进度。

浏览进度Rate具体的计算方式的具体过程:

1、在滚动屏幕过程中,通过 textContent?.viewTreeObserver?.addOnScrollChangedListener 来记录屏幕滚动的位置

2、在滚动监听里通过textContent?.getLocationOnScreen(location)获取在屏幕的具体位置,同时,

计算出visibleHeight = screenHeight - location[1] 当前文本的可见高度

3、当可见高度超过目标的高度,则认为已经全部浏览,rate = 100% ,同时移除滚动监听textContent?.viewTreeObserver?.removeOnScrollChangedListener(mScrollChangeListener)

核心代码如下:

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
private var totalHeight: Int = 0
private var visibleHeight: Int = 0
private var screenHeight = 0

fun getViewRate(): String? =
if (totalHeight <= 0 || visibleHeight < 0) null
else "${(visibleHeight * 100 / totalHeight)}%"

private fun bindViewTreeObserver() {
cleanCache()
contentTv.post {
totalHeight = contentTv.height
screenHeight = ScreenUtils.getScreenHeight(context)
contentVisibleRate()
}
contentTv.viewTreeObserver.addOnScrollChangedListener(mScrollChangeListener)
}

private fun cleanCache() {
totalHeight = 0
screenHeight = 0
visibleHeight = 0
}

private val mScrollChangeListener = ViewTreeObserver.OnScrollChangedListener {
contentVisibleRate()
}

private fun contentVisibleRate() {
val location = IntArray(2)
contentTv.getLocationOnScreen(location)
visibleHeight = (screenHeight - location[1]).coerceAtLeast(visibleHeight)
if (visibleHeight >= totalHeight) {
visibleHeight = totalHeight
removeOnScrollChangedListener()
}
}

private fun removeOnScrollChangedListener() {
mScrollChangeListener?.let {
contentTv.viewTreeObserver.removeOnScrollChangedListener(mScrollChangeListener)
}
}

RecyclerViewHelper

RecyclerViewHelper

提供了注册加载更多,和判断是否不足一屏等工具方法

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager

object RecyclerViewHelper {
/**
* RecyclerView 注册加载更多监听
*/
@JvmStatic
fun addOnScrollListener(recyclerView: RecyclerView, loadMore: () -> Unit) {
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
val layoutManager = recyclerView.layoutManager
if (layoutManager is LinearLayoutManager) {
val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition()
val count = layoutManager.itemCount
if (lastPosition >= count - 2) {
loadMore()
return@onScrollStateChanged
}
} else if (layoutManager is StaggeredGridLayoutManager) {
val spanCount = layoutManager.spanCount
val count = layoutManager.itemCount
val result = IntArray(spanCount)
layoutManager.findLastCompletelyVisibleItemPositions(result)
for (it in result) {
if (it >= count - spanCount - 1) {
loadMore()
return@onScrollStateChanged
}
}
} else if (layoutManager is GridLayoutManager) {
val spanCount = layoutManager.spanCount
val count = layoutManager.itemCount
val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition()
if (lastPosition >= count - spanCount - 1) {
loadMore()
return@onScrollStateChanged
}

}
}
}
})
}


/**
* 判断是否一屏显示
*
* 错误或者空了返回 false
*/
@JvmStatic
fun isOneScreen(recyclerView: RecyclerView?): Boolean {
recyclerView?.let {
val layoutManager = recyclerView.layoutManager
if (layoutManager is LinearLayoutManager) {
// include GridLayoutManager
val count = layoutManager.itemCount
return count > 0 &&
layoutManager.findFirstCompletelyVisibleItemPosition() == 0 &&
layoutManager.findLastCompletelyVisibleItemPosition() == count - 1
} else if (layoutManager is StaggeredGridLayoutManager) {
val spanCount = layoutManager.spanCount
val count = layoutManager.itemCount
val last = IntArray(spanCount)
val first = IntArray(spanCount)
layoutManager.findLastCompletelyVisibleItemPositions(last)
layoutManager.findFirstCompletelyVisibleItemPositions(first)
return count > 0 && first.min() == 0 && last.max() == count - 1
}
}

return false
}

private fun IntArray.min(): Int {
if (this.isNotEmpty()) {
var result = this[0]
this.forEach {
if (result > it) result = it
}
return result
}
return -1
}

private fun IntArray.max(): Int {
if (this.isNotEmpty()) {
var result = this[0]
this.forEach {
if (result < it) result = it
}
return result
}
return -1
}
}

Problems专题:编译环境

Problems专题:编译环境

0x01 java.lang.AssertionError: Could not delete caches dir

CreateProcess error=206, El nombre del archivo o la extensión es demasiado largo

Caused by: java.lang.AssertionError: Could not delete caches dir YourProjectPath\build\kotlin\compileDebugTestingKotlin

临时解决

打开任务管理器,结束 java.exe 或者 OpenJDK Platform Binary

降级 Android Studio

Notice: This happens with the newer AndroidStudio 4.2.x.

Google hasn’t provide us a fix, so you’ll need to downgrade to an older version which works for you. 4.1.3 seems to be working fine.

参考链接:https://stackoverflow.com/questions/65832868/caused-by-java-lang-assertionerror-could-not-delete-caches-dir-yourproject-bui

0x02 Please close other application using ADB:Monitor, DDMS, Eclip

Warning:debug info can be unavailable. Please close other application using ADB:Monitor, DDMS, Eclipse.

方案一:

1
$ adb usb

方案二:

打开任务管理器,结束adb.exe进程。

方案三:

重启 adb 服务

1
2
$ adb kill-server
$ adb start-server

0x03 能安装apk却无法查看log[真机偶现]

能安装apk却无法在Logcat查看log,即使重启Android studio,重启adb服务都无法解决。最后通过重启手机搞定

0x04 platform-tools/api/api-versions.xml java.io.IOException: Stream closed

在 android studio 更新到 v2020.3.1 后遇到

1
cannot load api descriptions from ../Android/android-sdk/platform-tools/api/api-versions.xml java.io.IOException: Stream closed

问题的原因与类SdkUtils (请参阅the source file)相关。SdkUtils类具有对文件platform-tools/api/api-versions.xml的硬引用,但是在最新的平台工具(31.0.3)中,该文件不再存在。

从platforms/android-31/data/api-versions.xml复制文件到platform-tools/api/api-versions.xml。

如果是CI编译,可以尝试以下脚本:

1
2
3
4
5
steps:
- bash: |
echo Android sdk location: $ANDROID_SDK_ROOT
mkdir $ANDROID_SDK_ROOT/platform-tools/api/
cp $ANDROID_SDK_ROOT/platforms/android-30/data/api-versions.xml $ANDROID_SDK_ROOT/platform-tools/api/

0x05 Installed Build Tools revision 31.0.0 is corrupted. Remove and install again using the SDK Manager.

升级android sdk api 版本到31,适配android 12 ,遇到这个问题。当前开发环境:

android studio 版本: 2020.3.1

AGP 版本: 4.1.2 (classpath "com.android.tools.build:gradle:4.1.2"

SDK 版本

1
2
3
4
5
6
7
8
9
android {
compileSdkVersion 31
buildToolsVersion '31.0.0'

defaultConfig {
minSdkVersion 21
targetSdkVersion 31
}
}

SDK Manager更新对应版本都正常下载,编译过程出现异常

1
Installed Build Tools revision 31.0.0 is corrupted. Remove and install again using the SDK Manager.

是 Build Tools 升级之后,DX 变成了 D8。而 AGP 4.x 的版本使用的还是DX。

解决的方案:

1
2
3
4
5
# change below to your Android SDK path
cd ~/Library/Android/sdk/build-tools/31.0.0 \
&& mv d8 dx \
&& cd lib \
&& mv d8.jar dx.jar

C:\Users\user\AppData\Local\Android\Sdk\build-tools\31.0.0\d8.bat 改为 dx.bat
C:\Users\user\AppData\Local\Android\Sdk\build-tools\31.0.0\lib\d8.jar 改为 dx.jar

PS:也可以尝试升级 AGP 到 7.x

Problems专题:ViewPager2

ViewPager2

0x01 FragmentManager is already executing transactions

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
java.lang.IllegalStateException: FragmentManager is already executing transactions
at androidx.fragment.app.FragmentManager.ensureExecReady(FragmentManager.java:1778)
at androidx.fragment.app.FragmentManager.execSingleAction(FragmentManager.java:1814)
at androidx.fragment.app.BackStackRecord.commitNow(BackStackRecord.java:297)

at androidx.viewpager2.adapter.FragmentStateAdapter.removeFragment(FragmentStateAdapter.java:464)
at androidx.viewpager2.adapter.FragmentStateAdapter.gcFragments(FragmentStateAdapter.java:228)
at androidx.viewpager2.adapter.FragmentStateAdapter.restoreState(FragmentStateAdapter.java:569)
at androidx.viewpager2.widget.ViewPager2.restorePendingState(ViewPager2.java:350)
at androidx.viewpager2.widget.ViewPager2.dispatchRestoreInstanceState(ViewPager2.java:375)

at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3829)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3829)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3829)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3829)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3829)

at android.view.View.restoreHierarchyState(View.java:18613)

at androidx.fragment.app.Fragment.restoreViewState(Fragment.java:573)
at androidx.fragment.app.FragmentStateManager.restoreViewState(FragmentStateManager.java:356)
at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1189)
at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1356)
at androidx.fragment.app.FragmentManager.moveFragmentToExpectedState(FragmentManager.java:1434)
at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1497)
at androidx.fragment.app.FragmentManager.dispatchStateChange(FragmentManager.java:2625)
at androidx.fragment.app.FragmentManager.dispatchActivityCreated(FragmentManager.java:2577)

at androidx.fragment.app.FragmentController.dispatchActivityCreated(FragmentController.java:247)

at androidx.fragment.app.FragmentActivity.onStart(FragmentActivity.java:541)
at androidx.appcompat.app.AppCompatActivity.onStart(AppCompatActivity.java:210)
at android.app.Instrumentation.callActivityOnStart(Instrumentation.java:1392)
at android.app.Activity.performStart(Activity.java:7260)
at android.app.ActivityThread.handleStartActivity(ActivityThread.java:3009)

at android.app.servertransaction.TransactionExecutor.performLifecycleSequence(TransactionExecutor.java:180)
at android.app.servertransaction.TransactionExecutor.cycleToPath(TransactionExecutor.java:165)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:142)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1840)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:207)
at android.app.ActivityThread.main(ActivityThread.java:6878)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:876)

解决方案

如果在 Fragment 中使用 ViewPager2,那么 FragmentStateAdapter 应该使用 childFragmentManager。将

1
FragmentStateAdapter viewPagerAdapter = new FragmentStateAdapter(getActivity().getSupportFragmentManager(), titles);

改为

1
FragmentStateAdapter viewPagerAdapter = new FragmentStateAdapter(getChildFragmentManager(), titles);

0x02 ViewPager2+FragmentStateAdapter 的 notifyDataSetChanged 方法失效

原因分析

因为 FragmentStateAdapter 会保存所有 Fragment 实例,当调用 Adapter.notifyDataSetChanged() 方法时,Fragment 并没有走 onCreate 方法。

解决方案:

方案一(这个方法会导致内存泄漏,不推荐)

在调用 notifyDataSetChanged 之前,清空 FragmentStateAdapter 的 Fragment 列表。

方案二

重写 getItemId() containsItem() 这两个方法,并确保 getItemId() 的值是唯一的。

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
override fun createViewPagerAdapter(): RecyclerView.Adapter<*> {
val items = items // avoids resolving the ViewModel multiple times
return object : FragmentStateAdapter(this) {
override fun createFragment(position: Int): PageFragment {
val itemId = items.itemId(position)
val itemText = items.getItemById(itemId)
return PageFragment.create(itemText)
}
override fun getItemCount(): Int = items.size
override fun getItemId(position: Int): Long = items.itemId(position)
override fun containsItem(itemId: Long): Boolean = items.contains(itemId)
}
}

/** A very simple collection of items. Optimized for simplicity (i.e. not performance). */
class ItemsViewModel : ViewModel() {
private var nextValue = 1L

private val items = (1..9).map { longToItem(nextValue++) }.toMutableList()

fun getItemById(id: Long): String = items.first { itemToLong(it) == id }
fun itemId(position: Int): Long = itemToLong(items[position])
fun contains(itemId: Long): Boolean = items.any { itemToLong(it) == itemId }
fun addNewAt(position: Int) = items.add(position, longToItem(nextValue++))
fun removeAt(position: Int) = items.removeAt(position)
fun createIdSnapshot(): List<Long> = (0 until size).map { position -> itemId(position) }
val size: Int get() = items.size

private fun longToItem(value: Long): String = "item#$value"
private fun itemToLong(value: String): Long = value.split("#")[1].toLong()
}

0x03 Design assumption violated

1
2
3
4
5
6
java.lang.IllegalStateException: Design assumption violated.
at androidx.viewpager2.widget.ViewPager2.updateCurrentItem(ViewPager2.java:538)
at androidx.viewpager2.widget.ViewPager2$4.onAnimationsFinished(ViewPager2.java:518)
at androidx.recyclerview.widget.RecyclerView$ItemAnimator.isRunning(RecyclerView.java:13244)
at androidx.viewpager2.widget.ViewPager2.onLayout(ViewPager2.java:515)
at android.view.View.layout(View.java:15596)

解决方案:

如果重写了 getItemId() containsItem() 这两个方法,确保 getItemId() 的值是唯一的。代码同 0x02

0x04 ViewPager2 嵌套 RecyclerView 手势冲突问题

img

原因分析:

同方向滚动事件被 ViewPager2 拦截了。

解决方案:

方案一 自定义 NestedScrollableHost

采用官方提供的自定义 NestedScrollableHost 来包一层 RecyclerView

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.widget.FrameLayout
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
import kotlin.math.absoluteValue
import kotlin.math.sign

/**
* Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem
* where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as
* ViewPager2. The scrollable element needs to be the immediate and only child of this host layout.
*
* This solution has limitations when using multiple levels of nested scrollable elements
* (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2).
*/
class NestedScrollableHost : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f
private val parentViewPager: ViewPager2?
get() {
var v: View? = parent as? View
while (v != null && v !is ViewPager2) {
v = v.parent as? View
}
return v as? ViewPager2
}

private val child: View? get() = if (childCount > 0) getChildAt(0) else null

init {
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}

private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
0 -> child?.canScrollHorizontally(direction) ?: false
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
}
}

override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
handleInterceptTouchEvent(e)
return super.onInterceptTouchEvent(e)
}

private fun handleInterceptTouchEvent(e: MotionEvent) {
val orientation = parentViewPager?.orientation ?: return

// Early return if child can't scroll in same direction as parent
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
return
}

if (e.action == MotionEvent.ACTION_DOWN) {
initialX = e.x
initialY = e.y
parent.requestDisallowInterceptTouchEvent(true)
} else if (e.action == MotionEvent.ACTION_MOVE) {
val dx = e.x - initialX
val dy = e.y - initialY
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

// assuming ViewPager2 touch-slop is 2x touch-slop of child
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

if (scaledDx > touchSlop || scaledDy > touchSlop) {
if (isVpHorizontal == (scaledDy > scaledDx)) {
// Gesture is perpendicular, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
} else {
// Gesture is parallel, query child if movement in that direction is possible
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
// Child can scroll, disallow all parents to intercept
parent.requestDisallowInterceptTouchEvent(true)
} else {
// Child cannot scroll, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
}
}
}

对应的 layout 代码:

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
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
... 水平
<androidx.viewpager2.integration.testapp.NestedScrollableHost
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/first_rv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF" />
</androidx.viewpager2.integration.testapp.NestedScrollableHost>
... 竖直
<androidx.viewpager2.integration.testapp.NestedScrollableHost
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="8dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/second_rv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF" />
</androidx.viewpager2.integration.testapp.NestedScrollableHost>

</LinearLayout>

方案二 自定义 RecyclerView

自定义 NestedRecyclerView 的分发事件通过 requestDisallowInterceptTouchEvent() 方法来限制父布类的拦截事件

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
public class NestedRecyclerView extends RecyclerView {
public NestedRecyclerView(@NonNull Context context) {
super(context);
}
public NestedRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public NestedRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

private int startX, startY;

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
startX = (int) ev.getX();
startY = (int) ev.getY();
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int endX = (int) ev.getX();
int endY = (int) ev.getY();
int disX = Math.abs(endX - startX);
int disY = Math.abs(endY - startY);

if (disX > disY) {
getParent().requestDisallowInterceptTouchEvent(canScrollHorizontally(startX - endX));
} else {
getParent().requestDisallowInterceptTouchEvent(canScrollVertically(startX - endX));
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return super.dispatchTouchEvent(ev);
}
}

方法三 使用 ViewPager

降级,使用 ViewPager 来嵌套 RecyclerView ,可以避免事件冲突,亲测有效。

0x05 ViewPager2 两边保留上一页预览

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
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.FragmentActivity
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2

class PreviewPagesActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_viewpager2)
findViewById<ViewPager2>(R.id.view_pager).apply {
// Set offscreen page limit to at least 1, so adjacent pages are always laid out
offscreenPageLimit = 1
val recyclerView = getChildAt(0) as RecyclerView
recyclerView.apply {
clipToPadding = false
val leftPadding = resources.getDimensionPixelOffset(R.dimen.halfPageMargin) +
resources.getDimensionPixelOffset(R.dimen.peekOffset)
// setting padding on inner RecyclerView puts overscroll effect in the right place
// TODO: expose in later versions not to rely on
// getChildAt(0) which might break
setPadding(leftPadding, 0, leftPadding, 0)
}
adapter = Adapter()
}
}

class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.item_preview_pages, parent, false)
)

class Adapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemCount(): Int {
return 10
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ViewHolder(parent)
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder.itemView.tag = position
holder.itemView.setOnClickListener {
Toast.makeText(it.context, "position=$position", Toast.LENGTH_LONG).show()
}
}
}
}

0x06 Fragment no longer exists for key f1

在 Fragment 中使用 ViewPager 的时候,切换 Fragment 导致 ViewPager 无法正确恢复异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java.lang.IllegalStateException: Fragment no longer exists for key f1: unique id 55efaee5-a65c-4e57-9281-7c8f8f6e4156
at androidx.fragment.app.FragmentManager.getFragment(FragmentManager.java:960)
at androidx.fragment.app.FragmentStatePagerAdapter.restoreState(FragmentStatePagerAdapter.java:328)
at androidx.viewpager.widget.ViewPager.onRestoreInstanceState(ViewPager.java:1461)
at android.view.View.dispatchRestoreInstanceState(View.java:20032)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3922)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:3928)
at android.view.View.restoreHierarchyState(View.java:20010)
at androidx.fragment.app.Fragment.restoreViewState(Fragment.java:639)
at androidx.fragment.app.Fragment.restoreViewState(Fragment.java:3010)
at androidx.fragment.app.Fragment.performActivityCreated(Fragment.java:3001)
at androidx.fragment.app.FragmentStateManager.activityCreated(FragmentStateManager.java:580)
at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:285)
at androidx.fragment.app.FragmentManager.executeOpsTogether(FragmentManager.java:2189)
at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute(FragmentManager.java:2100)
at androidx.fragment.app.FragmentManager.execPendingActions(FragmentManager.java:2002)
at androidx.fragment.app.FragmentManager$5.run(FragmentManager.java:524)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:230)
at android.app.ActivityThread.main(ActivityThread.java:8018)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:526)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1034)

20240301183554

在这个页面中,内容列表使用 ViewPager 嵌套 Fragment 实现,并和时间选择 Tab 绑定。切换【即将上线】和【播出时间表】Tab,实际是使用 FragmentManager 的 replace 方法,动态切换两个 Fragment,然后就报了上面的异常。

网上流行的解决方案是使用 FragmentPagerAdapter 或者添加

1
2
3
4
@Override
public Parcelable saveState() {
return null;
}

但是这样处理会导致 ViewPager 中的 fragments 全部无法恢复,导致 ViewPager 白屏。

本例中的解决方案是:

在切换【即将上线】和【播出时间表】Tab 时,不使用 FragmentManager 的 replace 方法,而采用动态的 Hide 和 show 方法暂时规避 Fragment 被回收的问题。

Problems专题:RecyclerView

Problems专题:RecyclerView

0x01 Called attach on a child which is not detached

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
56
57
58
59
60
61
62
63
64
java.lang.IllegalArgumentException: Called attach on a child which is not detached: BaseViewHolder{2b241e1 position=12 id=-1, oldPos=-1, pLpos:-1} androidx.recyclerview.widget.RecyclerView{afecb06 VFED..... ......ID 0,0-1080,2055 #7f09236e app:id/recycler_view_xxx}, adapter:com.xxxx.adapter.XxxAdapter@cfc75c7, layout:androidx.recyclerview.widget.LinearLayoutManager@24af7f4, context:com.xxxx.XxxActivity@1ed75e2
at androidx.recyclerview.widget.RecyclerView$5.attachViewToParent(RecyclerView.java:917)
at androidx.recyclerview.widget.ChildHelper.attachViewToParent(ChildHelper.java:239)
at androidx.recyclerview.widget.RecyclerView.addAnimatingView(RecyclerView.java:1438)
at androidx.recyclerview.widget.RecyclerView.animateDisappearance(RecyclerView.java:4377)
at androidx.recyclerview.widget.RecyclerView$4.processDisappeared(RecyclerView.java:616)
at androidx.recyclerview.widget.ViewInfoStore.process(ViewInfoStore.java:242)
at androidx.recyclerview.widget.RecyclerView.dispatchLayoutStep3(RecyclerView.java:4210)
at androidx.recyclerview.widget.RecyclerView.dispatchLayout(RecyclerView.java:3864)
at androidx.recyclerview.widget.RecyclerView.onLayout(RecyclerView.java:4410)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.RelativeLayout.onLayout(RelativeLayout.java:1131)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at androidx.viewpager.widget.ViewPager.onLayout(ViewPager.java:1775)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
at android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.RelativeLayout.onLayout(RelativeLayout.java:1131)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332)
at android.widget.FrameLayout.onLayout(FrameLayout.java:270)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
at android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332)
at android.widget.FrameLayout.onLayout(FrameLayout.java:270)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
at android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332)
at android.widget.FrameLayout.onLayout(FrameLayout.java:270)
at com.android.internal.policy.DecorView.onLayout(DecorView.java:905)
at android.view.View.layout(View.java:22213)
at android.view.ViewGroup.layout(ViewGroup.java:6340)
at android.view.ViewRootImpl.performLayout(ViewRootImpl.java:3286)
at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:2757)
at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:1865)
at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:7933)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1018)
at android.view.Choreographer.doCallbacks(Choreographer.java:837)
at android.view.Choreographer.doFrame(Choreographer.java:767)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1003)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:230)
at android.app.ActivityThread.main(ActivityThread.java:7951)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:526)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1034)

问题分析

对同一个 position 位置同时进行notifyItemRemoved(position)notifyItemInserted(position) 操作导致。

解决方案

避免同时对同一个位置先 notifyItemRemoved 再 notifyItemInserted,使用 notifyItemChanged。

1
adapter?.notifyItemChanged(position)

0x02 RecyclerView设置最大高度、宽度

当RecyclerView属性设置为wrap_content+maxHeight时,maxHeight没有效果。

问题分析

当RecyclerView的LayoutManager#isAutoMeasureEnabled()返回true时,RecyclerView高度取决于children view的布局高度,并非取决于RecyclerView自身的测量高度。

解决方案

因此,我们只需要重写LayoutManager的public void setMeasuredDimension(Rect childrenBounds, int wSpec, int hSpec)方法即可为RecyclerView设置最大宽高。

1
2
3
4
5
6
7
8
9
recyclerView.layoutManager = object : LinearLayoutManager(context, RecyclerView.VERTICAL, false) {
override fun setMeasuredDimension(childrenBounds: Rect?, wSpec: Int, hSpec: Int) {
val height = View.MeasureSpec.getSize(hSpec)
val maxHeight = getScreenHeight() * 4 / 5
val realHeight = height.coerceAtMost(maxHeight)
val realHeightSpec = View.MeasureSpec.makeMeasureSpec(realHeight, AT_MOST)
super.setMeasuredDimension(childrenBounds, wSpec, realHeightSpec)
}
}

作者:猫爸iYao
链接:https://www.jianshu.com/p/0dec79ff70df
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Problems专题:Parcel

Problems专题:Parcel

0x01 Unmarshalling unknown type

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
Thread Name: 'main' 
java.lang.RuntimeException: Parcel android.os.Parcel@bbfcc04: Unmarshalling unknown type code 2131296928 at offset 1088
at android.os.Parcel.readValue(Parcel.java:2750)
at android.os.Parcel.readSparseArrayInternal(Parcel.java:3126)
at android.os.Parcel.readSparseArray(Parcel.java:2354)
at android.os.Parcel.readValue(Parcel.java:2728)
at android.os.Parcel.readArrayMapInternal(Parcel.java:3045)
at android.os.BaseBundle.initializeFromParcelLocked(BaseBundle.java:288)
at android.os.BaseBundle.unparcel(BaseBundle.java:232)
at android.os.Bundle.getSparseParcelableArray(Bundle.java:1010)
at com.android.internal.policy.PhoneWindow.restoreHierarchyState(PhoneWindow.java:2133)
at android.app.Activity.onRestoreInstanceState(Activity.java:1173)
at android.app.Activity.performRestoreInstanceState(Activity.java:1128)
at android.app.Instrumentation.callActivityOnRestoreInstanceState(Instrumentation.java:1318)
at android.app.ActivityThread.handleStartActivity(ActivityThread.java:3025)
at android.app.servertransaction.TransactionExecutor.performLifecycleSequence(TransactionExecutor.java:180)
at android.app.servertransaction.TransactionExecutor.cycleToPath(TransactionExecutor.java:165)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:142)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1840)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:207)
at android.app.ActivityThread.main(ActivityThread.java:6878)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:876)

问题分析

情况1 Parcelable 对象为空,反序列化异常

情况2 Parcelable 序列化和反序列化的字段和顺序没有完全对应

情况3 自定义View的数据保存与恢复

解决方案

情况1 Parcelable 对象在序列化和反序列化增加 null 值判断

情况2 Parcelable 对象 read 和 write 字段的类型和顺序保持一直

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Account implements Parcelable {
public Account(Parcel in) {
this.name = in.readString();
this.type = in.readInt();
if (TextUtils.isEmpty(name)) {
throw new android.os.BadParcelableException("the name must not be empty: " + name);
}
// ...
this.accessId = in.readString();
// ...
}

public int describeContents() {
return 0;
}

public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeInt(type);
dest.writeString(accessId);
}
// ...
}

情况3 自定义View的数据保存与恢复

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
override fun onSaveInstanceState(): Parcelable? {
Log.d("NavigationBar", "onSaveInstanceState: selectedId=${mSelectedId}")
return SavedState(super.onSaveInstanceState(), mSelectedId)
}

override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
val id = state.selectedId
Log.d("NavigationBar", "onRestoreInstanceState: selectedId=${state.selectedId}")
super.onRestoreInstanceState(state.superState)
select(id)
return
}
return super.onRestoreInstanceState(state)
}

internal class SavedState : BaseSavedState {
var selectedId: Int = View.NO_ID

constructor(source: Parcel) : super(source) {
selectedId = source.readInt()
Log.d("NavigationBar", "readFromParcel: selectedId=$selectedId")
}

constructor(superState: Parcelable?, selectedId: Int) : super(superState) {
this.selectedId = selectedId
}

override fun writeToParcel(parcel: Parcel, flags: Int) {
super.writeToParcel(parcel, flags)
parcel.writeInt(selectedId)
Log.d("NavigationBar", "writeToParcel: selectedId=$selectedId")
}

override fun describeContents(): Int {
return 0
}

companion object CREATOR : Parcelable.Creator<SavedState> {
override fun createFromParcel(parcel: Parcel): SavedState {
return SavedState(parcel)
}

override fun newArray(size: Int): Array<SavedState?> {
return arrayOfNulls(size)
}
}
}

0x02 android.os.TransactionTooLargeException

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
java.lang.RuntimeException: android.os.TransactionTooLargeException: data parcel size 542688 bytes
at android.app.servertransaction.PendingTransactionActions$StopInfo.run(PendingTransactionActions.java:160)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:207)
at android.app.ActivityThread.main(ActivityThread.java:6878)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:876)
Caused by: android.os.TransactionTooLargeException: data parcel size 542688 bytes
at android.os.BinderProxy.transactNative(Native Method)
at android.os.BinderProxy.transact(BinderProxy.java:479)
at android.app.IActivityManager$Stub$Proxy.activityStopped(IActivityManager.java:3941)
at java.lang.reflect.Method.invoke(Native Method)
at com.taobao.monitor.impl.common.c.invoke(ActivityManagerHook.java:89)
at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
at $Proxy2.activityStopped(Unknown Source)
at android.app.servertransaction.PendingTransactionActions$StopInfo.run(PendingTransactionActions.java:144)
... 7 more
android.os.TransactionTooLargeException: data parcel size 542688 bytes
at android.os.BinderProxy.transactNative(Native Method)
at android.os.BinderProxy.transact(BinderProxy.java:479)
at android.app.IActivityManager$Stub$Proxy.activityStopped(IActivityManager.java:3941)
at java.lang.reflect.Method.invoke(Native Method)
at com.taobao.monitor.impl.common.c.invoke(ActivityManagerHook.java:89)
at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
at $Proxy2.activityStopped(Unknown Source)
at android.app.servertransaction.PendingTransactionActions$StopInfo.run(PendingTransactionActions.java:144)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:207)
at android.app.ActivityThread.main(ActivityThread.java:6878)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:876)

问题分析

1 Intent 传递的数据过大。

2 onSaveInstance 保存的数据过大。

解决方案

尽可能的使用少量的数据。

大数据考虑持久化和其他传递形式。

RecyclerView Item 嵌套 ScrollView

RecyclerView Item 嵌套 ScrollView

RecyclerView Item 嵌套 ScrollView 产生 Touch 事件冲突,通过自定义ScrollView来拦截和处理事件

image-20210908211849925

自定义 ItemScrollView 代码如下

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
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.ScrollView

class ItemScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
parent.requestDisallowInterceptTouchEvent(true)
return super.onInterceptTouchEvent(ev)
}

private var lastY: Float = 0f

override fun onTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_DOWN -> {
lastY = ev.y
}

MotionEvent.ACTION_MOVE -> {
val currentY = ev.y
this.scrollBy(0, (lastY - currentY).toInt())
lastY = currentY
}

MotionEvent.ACTION_UP -> {
lastY = 0f
}
}

return canScroll()
}

private fun canScroll(): Boolean {
val child = getChildAt(0)
child?.let {
return height < child.height
}
return false
}
}

PowerShell最佳实践

PowerShell最佳实践

Windows 10 系统自带 PowerShell,美化教程

image-20210909150114264

0x01 安装Fluent Terminal

在Windows 应用商店安装,或者Github

0x02 安装powershell模块

1、安装posh-git、oh-my-posh和Get-ChildItemColor(美化ls命令):

在powershell管理员模式下:

1
2
3
$ Install-Module posh-git -Scope CurrentUser
$ Install-Module oh-my-posh -Scope CurrentUser
$ Install-Module DirColors

2、设置修改powershell的配置文件:

1
2
$ if (!(Test-Path -Path $PROFILE )) { New-Item -Type File -Path $PROFILE -Force }
$ notepad $PROFILE

输入内容:

1
2
3
4
Import-Module DirColors
Import-Module posh-git
Import-Module oh-my-posh
Set-PoshPrompt -Theme PowerLine

其中主题名可以在下面的路径里找到,可以自行切换主题。

1
C:\Program Files\WindowsPowerShell\Modules\oh-my-posh\3.163.0\themes

0x03 安装Powerline字体

在Fluent Terminal设置 powerline 字体和字体大小。

0x04 文件管理器命令

在文件夹中打开:

1
2
3
4
5
6
7
8
# I 在文件夹中打开
$ explorer (gl)
# II 在文件夹中打开
$ start .
# III 在文件夹中打开
$ ii .
# 打开当前根目录
$ ii /

在当前文件夹打开PowerShell:

空白处 Shift + 鼠标右键,在弹出的菜单中选择PowerShell.

0x05 VS Code 命令

1
$ code .

Problems专题:Dialog

Problems专题:Dialog

0x01 OnKeyDown部分机型无法监听

问题分析

监听返回键和音量键,重载OnKeyDown()方法,部分机型会失效。

解决方案

给相应的Dialog监听setOnKeyListener()。

1
2
3
4
5
6
7
8
9
10
// 解决不同机型版本兼容问题,onKeyDown 可能被拦截
setOnKeyListener { dialog, keyCode, event ->
Log.d(TAG, "setOnKeyListener: keyCode = $keyCode, event = $event")
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
handleKeyEvent(keyCode)
true
} else {
false
}
}

注意区分keycode,防止业务层重复处理

0x02 DialogFragment不能自动弹出软键盘

方案一:延迟弹出软键盘
在dialog显示之后,延迟200ms再显示软键盘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//强制显示或者关闭系统键盘
public static void toggleKeyboard(final EditText editText, final boolean status) {
if (editText == null) return;
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
InputMethodManager m = (InputMethodManager)
ApplicationExtKt.getAppContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (status) {
m.showSoftInput(editText, InputMethodManager.SHOW_FORCED);
} else {
IBinder windowToken = editText.getWindowToken();
if (windowToken != null) {
m.hideSoftInputFromWindow(windowToken, 0);
}
}
}
}, status ? 200 : 100);
}

方案二:设置 SoftInputMode 为 SOFT_INPUT_STATE_ALWAYS_VISIBLE

1
2
getDialog().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
inputEditText.requestFocus();

0x03 关闭DialogFragment无法关闭软键盘

问题分析

一般情况下,在onPause或者dismiss方法直接调用hideKeyboard就可以

1
2
3
4
override fun onPause() {
KeyBoardUtils.hideKeyboard(binding.etSearch)
super.onPause()
}

但是,在某些时候还是会存在关闭不成功的情况。这是由于Dialog下面的Activity或Fragment存在EditText等抢占焦点,导致在DialogFragment在调用dismiss方法时,键盘已经被抢占焦点,所以无法关闭。

解决方案

在DialogFragment的dismiss方法回调

1
2
3
4
override fun onDismiss(dialog: DialogInterface) {
listener?.onDialogDismiss()
super.onDismiss(dialog)
}

在前一个Activity或者Fragment中重新关闭键盘。

1
2
3
4
5
6
7
// 消除弹框遗留下来的keyboard
private fun onDialogDismiss() {
// 消除弹框
Handler().postDelayed({
KeyBoardUtils.hideKeyboard(binding.root)
}, 200)
}

RecyclerView的几种Decoration

RecyclerView的几种Decoration

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
import android.content.res.Resources
import android.graphics.Rect
import android.util.TypedValue
import android.view.View
import androidx.recyclerview.widget.RecyclerView

class SimplePaddingDecoration(
spaceDp: Int,
val orientation: Int = RecyclerView.VERTICAL
) : RecyclerView.ItemDecoration() {
private val dividerHeight: Int = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
spaceDp.toFloat(),
Resources.getSystem().displayMetrics
).toInt()

override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = (view.layoutParams as RecyclerView.LayoutParams).viewLayoutPosition

if (orientation == RecyclerView.VERTICAL) {
// 竖直
if (position + 1 != parent.adapter?.itemCount) {
outRect.set(0, 0, 0, dividerHeight)
} else {
outRect.set(0, 0, 0, 0)
}
} else {
// 水平
if (position + 1 != parent.adapter?.itemCount) {
outRect.set(0, 0, dividerHeight, 0)
} else {
outRect.set(0, 0, 0, 0)
}
}
}
}

自定义布局:西部世界 第一季

自定义布局:西部世界 第一季

这个自定义布局要求显示为 系列名称... + 第一季 ,后面的季内容显示完全,紧贴系列名称显示,系列名称在布局不允许的时候可以部分显示。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
* 系列名称... + 第一季
* 后面的季内容显示完全,紧贴系列名称显示,系列名称在布局不允许的时候可以部分显示
*/
class FixedEndLinearLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
//获取父布局测量size和model
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
if (childCount != 2) throw RuntimeException("FixedEndLinearLayout must have 2 children.")
val wrapChild = getChildAt(0)
val fixedChild = getChildAt(1)

//测量
measureChild(fixedChild, widthMeasureSpec, heightMeasureSpec)
val fixedParams = fixedChild.layoutParams as MarginLayoutParams

val fixedChildWidth =
fixedChild.measuredWidth + fixedParams.leftMargin + fixedParams.rightMargin
val fixedChildHeight =
fixedChild.measuredHeight + fixedParams.topMargin + fixedParams.bottomMargin

val wrapChildWidthSpec = ViewGroup.getChildMeasureSpec(
widthMeasureSpec,
paddingLeft + paddingRight + fixedChildWidth, wrapChild.layoutParams.width
)
val wrapChildHeightSpec = ViewGroup.getChildMeasureSpec(
heightMeasureSpec, paddingTop + paddingBottom, wrapChild.layoutParams.height
)
wrapChild.measure(wrapChildWidthSpec, wrapChildHeightSpec)

val wrapParams = wrapChild.layoutParams as MarginLayoutParams

val wrapChildWidth =
wrapChild.measuredWidth + wrapParams.leftMargin + wrapParams.rightMargin
val wrapChildHeight =
wrapChild.measuredHeight + wrapParams.topMargin + wrapParams.bottomMargin

val width = wrapChildWidth + fixedChildWidth
val height = fixedChildHeight.coerceAtLeast(wrapChildHeight)

start0 = paddingLeft + wrapParams.leftMargin
start1 = paddingLeft + wrapChildWidth + fixedParams.leftMargin

setMeasuredDimension(
if (widthMode == MeasureSpec.EXACTLY) widthSize else width + paddingLeft + paddingRight,
if (heightMode == MeasureSpec.EXACTLY) heightSize else height + paddingTop + paddingBottom
)

}

private var start0 = 0
private var start1 = 0

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val wrapChild = getChildAt(0)
val fixedChild = getChildAt(1)
val y0 = (measuredHeight - wrapChild.measuredHeight) / 2
val y1 = (measuredHeight - fixedChild.measuredHeight) / 2
wrapChild.layout(
start0,
y0,
start0 + wrapChild.measuredWidth,
y0 + wrapChild.measuredHeight
)
fixedChild.layout(
start1,
y1,
start1 + fixedChild.measuredWidth,
y1 + fixedChild.measuredHeight
)
}
}

Android专栏-BaseQuickAdapterHelper

Android专栏-BaseQuickAdapterHelper

0x01 自动加载更多-LoadingFooterView

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
import android.view.View
import android.view.ViewGroup
import com.chad.library.adapter.base.loadmore.BaseLoadMoreView
import com.chad.library.adapter.base.loadmore.LoadMoreStatus
import com.chad.library.adapter.base.util.getItemView
import com.chad.library.adapter.base.viewholder.BaseViewHolder

class LoadingFooterView : BaseLoadMoreView() {
private var loadingView: LoadingPagView? = null
override fun getRootView(parent: ViewGroup): View {
val rootView = parent.getItemView(R.layout.ui_footer_adapter_load_more)
loadingView = rootView.findViewById(R.id.loadingView)
return rootView
}

override fun getLoadingView(holder: BaseViewHolder): View {
return holder.getView(R.id.loadingView)
}

override fun getLoadComplete(holder: BaseViewHolder): View {
return holder.getView(R.id.fakeView)
}

override fun getLoadEndView(holder: BaseViewHolder): View {
return holder.getView(R.id.endView)
}

override fun getLoadFailView(holder: BaseViewHolder): View {
return holder.getView(R.id.fakeView)
}

override fun convert(holder: BaseViewHolder, position: Int, loadMoreStatus: LoadMoreStatus) {
super.convert(holder, position, loadMoreStatus)
when (loadMoreStatus) {
LoadMoreStatus.Complete -> {
loadingView?.stopPlay()
}
LoadMoreStatus.Loading -> {
loadingView?.startPlay()
}
LoadMoreStatus.Fail -> {

}
LoadMoreStatus.End -> {
loadingView?.stopPlay()
}
}
}
}

VolumeDialog音量控制自定义

VolumeDialog音量控制自定义

1 使用自定义 AlertDialog 实现

2 window?.setFlags 设置 dialog 的样式

3 window?.attributes 设置 dialog 的位置

4 返回键监听,兼容机型需要使用 setOnKeyListener

5 按一次音量键回调多次的问题,KeyEvent.action 事件分 KeyEvent.ACTION_UP 和 KeyEvent.ACTION_DOWN

6 音量加减需要获取系统音量 max 值来手动控制,不同手机 max 值域不同

示例代码如下:

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import android.app.Activity
import android.content.Context
import android.content.res.Resources
import android.media.AudioManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.ProgressBar
import androidx.appcompat.app.AlertDialog

/**
* 声音调整控件
*/
class VolumeDialog(activity: Activity) : AlertDialog(activity, R.style.VolumeDialog) {
companion object {
private const val TAG = "VolumeDialog"

@JvmStatic
fun show(activity: Activity) {
activity.let {
if (activity.isFinishing) return
VolumeDialog(activity).show()
}
}
}

private val volumeAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var volume = volumeAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
private val maxVolume = volumeAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
private val step = (maxVolume / 10).coerceAtLeast(1)
private val delayMillis = 1500L

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_volume_progress)

//实现Dialog区域外部事件可以传给Activity
// FLAG_NOT_TOUCH_MODAL作用:即使该window可获得焦点情况下,仍把该window之外的任何event发送到该window之后的其他window
window?.setFlags(
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
)

// FLAG_WATCH_OUTSIDE_TOUCH作用:如果点击事件发生在window之外,就会收到一个特殊的MotionEvent,为ACTION_OUTSIDE
window?.setFlags(
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
)

// 顶部显示
// window?.setGravity(Gravity.TOP)
val attrs = window?.attributes
attrs?.apply {
gravity = Gravity.TOP
height = WindowManager.LayoutParams.WRAP_CONTENT
width = WindowManager.LayoutParams.MATCH_PARENT
y = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
35f,
Resources.getSystem().displayMetrics
).toInt()
}
Log.d(TAG, "onCreate() called with: attrs = $attrs")
window?.attributes = attrs
// 按空白处不能取消
setCanceledOnTouchOutside(false)
// 初始化界面控件
initView()
}

private var volumeProgressView: VolumeProgressView? = null
private var progressBar: ProgressBar? = null

private val _handler = Handler(Looper.getMainLooper())
private val r = Runnable {
try {
dismiss()
} catch (e: Exception) {
// sometimes happens windows token error.
e.printStackTrace()
}
}

override fun dismiss() {
_handler.removeCallbacks(r)
super.dismiss()
}

private fun initView() {
Log.d(TAG, "initView: ")
volumeProgressView = findViewById(R.id.vpv_volume)
progressBar = findViewById(R.id.pb_volume)

refreshProgress(volume * 1f / maxVolume)

// 解决不同机型版本兼容问题,onKeyDown 可能被拦截
setOnKeyListener { _, keyCode, event ->
Log.d(TAG, "setOnKeyListener: keyCode = $keyCode, event = $event")
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
handleKeyEvent(keyCode, event)
true
} else {
false
}
}
}

private fun refreshProgress(volumePercent: Float) {
_handler.removeCallbacks(r)
volumeProgressView?.setProgress(volumePercent)
progressBar?.progress = (volumePercent * 1000).toInt()
_handler.postDelayed(r, delayMillis)
}


private fun handleKeyEvent(keyCode: Int, event: KeyEvent): Boolean {
Log.d(TAG, "handleKeyEvent: volume = $volume, maxVolume = $maxVolume, step = $step")
if ((keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) || event.action != KeyEvent.ACTION_DOWN) return false
volume = if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // up
if (volume == maxVolume) return true
maxVolume.coerceAtMost(volume + step)
} else { // down
if (volume == 0) return true
0.coerceAtLeast(volume - step)
}
val volumePercent = volume * 1f / maxVolume
refreshProgress(volumePercent)

// 变更声音
volumeAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0)
Log.d(TAG, "handleKeyEvent: AudioManager set volume = $volume done.")

return true
}

/**
* 返回事件,仅拦截音量控制事件
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
Log.d(TAG, "onKeyDown() called with: keyCode = $keyCode, event = $event")
return if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
handleKeyEvent(keyCode, event)
return true
} else {
super.onKeyDown(keyCode, event)
}
}
}

自定义Dialog的Style

1
2
3
4
5
6
7
8
9
10
11
12
 <style name="VolumeDialog" parent="android:style/Theme.Dialog">
<!--背景颜色及和透明程度-->
<item name="android:windowBackground">@android:color/transparent</item>
<!--是否去除标题 -->
<item name="android:windowNoTitle">true</item>
<!--是否去除边框-->
<item name="android:windowFrame">@null</item>
<!--是否浮现在activity之上-->
<item name="android:windowIsFloating">true</item>
<!--是否模糊-->
<item name="android:backgroundDimEnabled">false</item>
</style>

观察者模式Kotlin泛型实现消息中心

观察者模式 + Kotlin 泛型实现的简易版消息中心

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
class MessageCenter<T> {
companion object {
private val centers = mutableMapOf<String, Any>()
fun <T> getInstance(clazz: Class<T>): MessageCenter<T> {
return if (centers[clazz.simpleName] != null) {
centers[clazz.simpleName] as MessageCenter<T>
} else {
val messageCenter = MessageCenter<T>()
centers[clazz.simpleName] = messageCenter
messageCenter
}
}
}

fun register(observer: Observer<T>) {
observers.add(observer)
}

fun unregister(observer: Observer<T>) {
if (observers.contains(observer)) {
observers.remove(observer)
}
}

fun post(t: T) {
observers.forEach {
it.receive(t)
}
}

private var observers = mutableListOf<Observer<T>>()
}

interface Observer<T> {
fun receive(t: T)
}

fun main() {
val ob = object : Observer<String> {
override fun receive(t: String) {
println("result is: $t")
}
}
MessageCenter.getInstance(String::class.java).register(ob)
MessageCenter.getInstance(String::class.java).post("txt post.")
MessageCenter.getInstance(String::class.java).unregister(ob)

MessageCenter.getInstance(Int::class.java).post(123)
}

BottomFragment

底部弹出控件 - Fragment 实现

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
56
57
58
59
60
61
62
63
64
65
66
67
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import com.dench.baselib.R
import com.dench.baselib.databinding.FragmentBottomBinding

class BottomFragment : Fragment() {
companion object {
fun start(fm: FragmentManager, fragment: Fragment): BottomFragment {
val bottomFragment = BottomFragment().apply {
setFragment(fragment)
}
fm.beginTransaction()
.setCustomAnimations(
R.anim.fragment_bottom_enter,
0,
0,
R.anim.fragment_bottom_exit
)
.add(android.R.id.content, bottomFragment)
.addToBackStack(null)
.commitAllowingStateLoss()
return bottomFragment
}
}

private lateinit var fragment: Fragment

private fun setFragment(fragment: Fragment) {
this.fragment = fragment
}

private lateinit var binding: FragmentBottomBinding
private val TAG = "BottomFragment"

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentBottomBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.run {
binding.bottomFragmentRl.setOnClickListener(View.OnClickListener {
Log.d(TAG, "bottom root view click.")
dismissSelf()
})

/** add fragment */
childFragmentManager.beginTransaction()
.add(R.id.container, fragment)
.commitAllowingStateLoss()
}
}

private fun dismissSelf() {
parentFragmentManager.popBackStack()
}
}

Android专栏-WebView

Android专栏-WebView

0x00 常规WebViewActivity

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
package com.dench.webviewlib

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Gravity
import android.webkit.*
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.alibaba.android.arouter.facade.annotation.Autowired
import com.alibaba.android.arouter.facade.annotation.Route
import com.alibaba.android.arouter.launcher.ARouter
import com.dench.baselib.provider.WebViewService
import com.dench.baselib.utlis.StatusBarHelper
import com.dench.webviewlib.bridge.JsInterface
import com.dench.webviewlib.databinding.ActivityWebViewBinding
import kotlinx.android.synthetic.main.activity_web_view.*

@Route(path = WebViewService.activityPath)
class WebViewActivity : AppCompatActivity() {
@Autowired
lateinit var title: String

@Autowired
lateinit var url: String

private lateinit var dataViewBinding: ActivityWebViewBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ARouter.getInstance().inject(this)
StatusBarHelper.fitSystemBar(this, false)

dataViewBinding = DataBindingUtil.setContentView(this, R.layout.activity_web_view)

initToolbar()

initWebView()
}

override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
return
}
super.onBackPressed()
}

private fun initToolbar() {
dataViewBinding.toolbar.titleTv.text = title
val backIv = dataViewBinding.toolbar.leftIv
backIv.setImageResource(R.drawable.ic_close)
backIv.setOnClickListener {// 关闭
finish()
}
}

@SuppressLint("JavascriptInterface")
private fun initWebView() {
// webView Settings
initWebViewSetting()

// init Client
initClient()

// add Javascript Interface
webView.addJavascriptInterface(JsInterface(this), JsInterface.NAME)

// register Scroll Listener
registerScrollListener()

loadUrl()
}

// webView Settings
@SuppressLint("SetJavaScriptEnabled")
private fun initWebViewSetting() {
//声明WebSettings子类
val webSettings = webView.settings

//如果访问的页面中要与Javascript交互,则webView必须设置支持Javascript
webSettings.javaScriptEnabled = true
webSettings.javaScriptCanOpenWindowsAutomatically = true //支持通过JS打开新窗口

//设置自适应屏幕,两者合用
webSettings.useWideViewPort = true //将图片调整到适合webView的大小
webSettings.loadWithOverviewMode = true // 缩放至屏幕的大小
//缩放操作
webSettings.setSupportZoom(true)//支持缩放,默认为true。是下面那个的前提。
webSettings.builtInZoomControls = true //设置内置的缩放控件。若为false,则该WebView不可缩放
webSettings.displayZoomControls = false //隐藏原生的缩放控件
// 缓存
webSettings.cacheMode = WebSettings.LOAD_DEFAULT //webView缓存策略
webSettings.domStorageEnabled = true
webSettings.databaseEnabled = true
webSettings.setAppCacheEnabled(true)

//其他
webSettings.allowFileAccess = true //设置可以访问文件
webSettings.loadsImagesAutomatically = true //支持自动加载图片
webSettings.defaultTextEncodingName = "utf-8" //设置编码格式

// 在安卓5.0之后,默认不允许加载http与https混合内容,需要手动设置
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
}

// init Client
private fun initClient() {
webView.webViewClient = object : WebViewClient() {

// 在网页上的所有加载都经过这个方法
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
Log.i(_tag, "shouldOverrideUrlLoading()")
request?.url?.let {
return when (it.scheme) {
"http", "https" -> { // 加载网络html
super.shouldOverrideUrlLoading(view, request)
}
"file", "content" -> { // 加载本地html
super.shouldOverrideUrlLoading(view, request)
}
else -> { // 特殊 scheme 不处理
showToast("$it")
true
}
}
}
return true
}

// 加载页面的服务器出现错误时(如404)调用
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
Log.i(_tag, "onReceivedError()")
super.onReceivedError(view, request, error)
}

// ssl 证书错误
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
Log.i(_tag, "onReceivedSslError()")
handler?.proceed() //表示等待证书响应
// handler?.cancel() //表示挂起连接,为默认方式
// handler?.handleMessage(null) //可做其他处理
}

// 开始载入页面调用的。我们可以设定一个loading的页面,告诉用户程序在等待网络响应
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
Log.i(_tag, "onPageStarted()")
Log.d(
_tag,
"onPageStarted() called with: view = $view, url = $url, favicon = $favicon"
)
super.onPageStarted(view, url, favicon)
webViewProgress.show()
}

// 在页面加载结束时调用。我们可以关闭loading 条,切换程序动作
override fun onPageFinished(view: WebView?, url: String?) {
Log.i(_tag, "onPageFinished()")
super.onPageFinished(view, url)
webViewProgress.hide()
}

// 在加载页面资源时会调用,每一个资源(比如图片)的加载都会调用一次
override fun onLoadResource(view: WebView?, url: String?) {
Log.d(_tag, "onLoadResource()")
Log.d(_tag, "onLoadResource() called with: view = $view, url = $url")
super.onLoadResource(view, url)
}
}

webView.webChromeClient = object : WebChromeClient() {
// 加载进度
override fun onProgressChanged(view: WebView?, newProgress: Int) {
webViewProgress.progress = newProgress
}

// Title
override fun onReceivedTitle(view: WebView?, title: String?) {
if (!TextUtils.isEmpty(title)) {
findViewById<TextView>(R.id.titleTv).text = title
}
}
}
}

// 加载URL
private fun loadUrl() {
//方式1. 加载一个网页
webView.loadUrl(url)
// //方式2:加载apk包中的html页面
// webView.loadUrl("file:///android_asset/test.html")
// //方式3:加载手机本地的html页面
// webView.loadUrl("content://com.android.htmlfileprovider/sdcard/test.html")
}

// 注册监听
private fun registerScrollListener() {
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// webView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
// Log.d(_tag, "scrollY: $scrollY, oldScrollY: $oldScrollY")
// }
// }
}

private val _tag = "WebViewActivity"

private fun showToast(info: String?) {
val toast = Toast.makeText(this, info, Toast.LENGTH_SHORT)
toast.setGravity(Gravity.CENTER_VERTICAL, 0, 0)
toast.show()
}
}

0x01 白屏问题

情景一:如果访问的页面中要与Javascript交互,则webView必须设置支持Javascript

1
2
3
4
val webSettings = webView.settings
//如果访问的页面中要与Javascript交互,则webView必须设置支持Javascript
webSettings.javaScriptEnabled = true
webSettings.javaScriptCanOpenWindowsAutomatically = true //支持通过JS打开新窗口

情景二:在安卓5.0之后,默认不允许加载http与https混合内容,需要手动设置

1
2
3
4
// 在安卓5.0之后,默认不允许加载http与https混合内容,需要手动设置
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}

其他情景: 设置domStorageEnabled 和背景色需要验证,暂时没遇到。

0x02 卡顿问题

由于设置了 android:layerType="software"导致的 webview 卡顿。

关于三个layerType属性介绍:https://blog.csdn.net/a345017062/article/details/7478667

解决:

开启 Activity 硬件加速 ``android:hardwareAccelerated=”true”, 并且设置 webview 的android:layerType=”none”`。

Gradle配置构建多Module项目

Gradle配置构建多Module项目

0x01 配置远程代码库

可以按如下方式声明特定的 Maven 或 Ivy 代码库:

1
2
3
4
5
6
7
8
allprojects {
repositories {
maven { url 'https://maven.aliyun.com/repository/public' } // public仓是包含central仓和jcenter仓的聚合仓
maven { url 'https://maven.aliyun.com/repository/google' } // 阿里镜像库
maven { url "file://local/repo/" } // 本地文件代码库
ivy { url "https://repo.example.com/ivy" } // Ivy代码库
}
}

0x02 统一配置Gradle依赖库版本

随着项目采用模块化,组件化开发,moudle 的个数也会随着增加,统一管理配置gradle就显得比较重要了。

1、在 project 根目录创建一个 config.gradle 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ext {  
// app 相关版本控制
versions = [
compileVersion : 26,
buildVersion : "26.0.2",

sdkMinVersion : 15,
sdkTargetVersion : 26,
appVersionCode : 520,
appVersionName : "1.0.0"
]
// support依赖
support = [
appcompat : "com.android.support:appcompat-v7:26.+",
recyclerview: "com.android.support:recyclerview-v7:26.+"
]
// 依赖
deps = [
glide : "com.github.bumptech.glide:glide:4.11.0"
]
}

2、在 Project 根目录下的 build.gradle 添加apply

1
apply from: 'config.gradle' 

3、在相应Moudle中调用

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
android {  
def versions = rootProject.ext.versions
compileSdkVersion versions.compileVersion
buildToolsVersion versions.buildVersion
defaultConfig {
applicationId "com.dench.wanandroid"
minSdkVersion versions.sdkMinVersion
targetSdkVersion versions.sdkTargetVersion
versionCode versions.appVersionCode
versionName versions.appVersionName
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

dependencies {
def dependencies = rootProject.ext.deps
def support = rootProject.ext.support

implementation support.appcompat
implementation support.recyclerview
implementation dependencies.glide
}

0x03 配置Flavor

创建产品变种与创建构建类型类似:将其添加到构建配置中的 productFlavors 代码块并添加所需的设置。产品变种支持与 defaultConfig 相同的属性,这是因为,defaultConfig 实际上属于 ProductFlavor 类。这意味着,您可以在 defaultConfig 代码块中提供所有变种的基本配置,每个变种均可更改其中任何默认值,如 applicationId

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
android {
defaultConfig {...}
buildTypes {
debug{...}
release{...}
}
// Specifies one flavor dimension.
flavorDimensions "version"
productFlavors {
demo {
dimension "version"
applicationIdSuffix ".demo"
versionNameSuffix "-demo"
versionCode 30000 + android.defaultConfig.versionCode
}
full {
dimension "version"
applicationIdSuffix ".full"
versionNameSuffix "-full"
versionCode 20000 + android.defaultConfig.versionCode
}
}
}

0x04 创建源代码集

1、Gradle 要求:

在所有构建变体之间共享的所有内容创建 main/ 源代码集和目录。

将“debug”构建类型特有的 Java 类文件放在 src/debug/java/ 目录中。

1
2
3
4
5
6
7
8
9
10
11
12
13
debug
----
Compile configuration: compile
build.gradle name: android.sourceSets.debug
Java sources: [app/src/debug/java]
Manifest file: app/src/debug/AndroidManifest.xml
Android resources: [app/src/debug/res]
Assets: [app/src/debug/assets]
AIDL sources: [app/src/debug/aidl]
RenderScript sources: [app/src/debug/rs]
JNI sources: [app/src/debug/jni]
JNI libraries: [app/src/debug/jniLibs]
Java-style resources: [app/src/debug/resources]

依次转到 MyApplication > Tasks > android,然后双击 sourceSets。Gradle 执行该任务后,系统应该会打开 Run 窗口以显示输出。

2、更改默认源代码集配置

1
2
3
4
5
6
7
8
9
android {
sourceSets {
main {
java.srcDirs = ['other/java']
res.srcDirs = ['other/res1', 'other/res2']
manifest.srcFile 'other/AndroidManifest.xml'
}
}
}

0x05 声明依赖项

1
2
3
4
5
6
dependencies {
// Adds the local "mylibrary" module as a dependency to the "free" flavor.
freeImplementation project(":mylibrary")
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

0x06 配置签名

1、在项目的根目录下创建一个名为 keystore.properties 的文件,并使其包含以下信息:

1
2
3
4
storePassword=myStorePassword
keyPassword=myKeyPassword
keyAlias=myKeyAlias
storeFile=myStoreFileLocation

2、在 build.gradle 文件中,按如下方式加载 keystore.properties 文件(必须在 android 代码块前面):

1
2
3
4
5
6
7
8

def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
// before android
android {

}

3、输入存储在 keystoreProperties 对象中的签名信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}

如需从环境变量获取这些密码,请添加以下代码:

1
2
storePassword System.getenv("KSTOREPWD")
keyPassword System.getenv("KEYPWD")

0x07 apk重命名

1
2
3
4
5
6
7
8
9
android {
applicationVariants.all { variant ->
if (variant.buildType.name == 'release') {
variant.outputs.all {
outputFileName = "app_v${variant.versionName}.${buildTime}_${variant.productFlavors[0].name}_${variant.buildType.name}.apk"
}
}
}
}

or

1
2
3
outputFileName = "app_v${versionName}.${buildTime}_${flavorName}_${buildType.name}.apk"
outputFileName = "../../${outputFileName}"
println outputFileName

0x08 将构建变量注入清单

1、如果您需要将变量插入在 build.gradle 文件中定义的 AndroidManifest.xml 文件,可以使用 manifestPlaceholders 属性执行此操作。此属性采用键值对的映射,如下所示:

1
2
3
4
5
6
android {
defaultConfig {
manifestPlaceholders = [hostName:"www.example.com"]
applicationId "com.example.myapp"
}
}

2、您可以将某个占位符作为属性值插入清单文件,如下所示:

1
2
3
4
<intent-filter ... >
<data android:scheme="http" android:host="${hostName}" ... />
<action android:name="${applicationId}.TRANSMOGRIFY" />
</intent-filter>

0x09 gradle自定义Java变量和资源值

在构建时,Gradle 将生成 BuildConfig 类,以便应用代码可以检查与当前构建有关的信息。您也可以从 Gradle 构建配置文件中使用 buildConfigField() 方法将自定义字段添加到 BuildConfig 类中,然后在应用的运行时代码中访问这些值。同样,您也可以使用 resValue() 添加应用资源值。

1
2
3
4
5
6
7
8
9
10
11
12
13
def buildTime = new Data().format("yyyyMMddHHmm", TimeZone.getTimeZone("GTM+08:00"))
android {
buildTypes {
release {
buildConfigField("String", "BUILD_TIME", "\"${buildTime}\"")
resValue("string", "build_time", "${buildTime}")
}
debug {
buildConfigField("String", "BUILD_TIME", "\"0\"")
resValue("string", "build_time", "0")
}
}
}

在应用代码中,您可以按如下方式访问属性:

1
2
Log.i(TAG, BuildConfig.BUILD_TIME);
Log.i(TAG, getString(R.string.build_time));

0x10 设定编码

1
2
3
4
5
allprojects {
tasks.withType(JavaCompile){
options.encoding = "UTF-8"
}
}
Your browser is out-of-date!

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

×