注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Android - 你可能需要这样一个日志库

前言 目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。 这种api风格有什么不好呢? 首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满...
继续阅读 »

前言


目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。


这种api风格有什么不好呢?


首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。


另外,它也可能导致性能陷阱,假设有这么一段代码:


// 打印一个List
Log.d("tag", list.joinToString())

此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。


下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。


作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。


期望


什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD<AppLogger> {
list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


实现分析


日志库的完整实现已经写好了,放在这里xlog



  • 支持限制日志大小,例如限制每天只能写入10MB的日志

  • 支持自定义日志格式

  • 支持自定义日志存储,即如何持久化日志


这一节主要分析一下实现过程中遇到的问题。


问题:如果App运行期间日志文件被意外删除了,怎么处理?


在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?


有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58

可以看到1万次调用的耗时在50毫秒左右。


我们再测试一下对文件写入的耗时:


val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler后,它会在主线程空闲的时候被执行。


我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null

fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false

// 如果已经注册过了,直接返回
_idleHandler?.let { return true }

val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }

// 重置变量,等待下次注册
_idleHandler = null
false
}

// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


问题:如何检测文件大小是否溢出


库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


问题:文件大小溢出后怎么处理?


假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



  1. 写入文件20231128.log

  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


问题:打印日志的性能


性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193

可以看到1万次格式化耗时大概在200毫秒左右。


我们再用java.util.Calendar测试一下:


val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


问题:日志的格式如何显示


手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



  • 优化时间显示


目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



  • 优化日志等级显示


打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}


  • 优化日志标识显示


如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



  • 优化线程ID显示


如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


经过上面的优化之后,日志打印的格式是这样的:


flogI<AppLogger> { "1" }
flogI<AppLogger> { "2" }
flogW<AppLogger> { "3" }
flogI<UserLogger> { "user debug" }
thread {
flogI<UserLogger> { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API


这一节介绍一下库的API,调用FLog.init()方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)关闭日志


常用方法


// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),

//(可选参数)自定义日志格式
formatter = AppLogFormatter(),

//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),

//(可选参数)是否异步发布日志,默认值false
async = false,
)

// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)

// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)

// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)

/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/

FLog.deleteLog(1)

打印日志


interface AppLogger : FLogger

flogV<AppLogger> { "Verbose" }
flogD<AppLogger> { "Debug" }
flogI<AppLogger> { "Info" }
flogW<AppLogger> { "Warning" }
flogE<AppLogger> { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识


可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


FLog.config<AppLogger> {
// 修改日志等级
this.level = FLogLevel.Debug

// 修改tag
this.tag = "AppLoggerAppLogger"
}

自定义日志格式


class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}

interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>

/** 日志tag */
val tag: String

/** 日志内容 */
val msg: String

/** 日志等级 */
val level: FLogLevel

/** 日志生成的时间戳 */
val millis: Long

/** 日志是否在主线程生成 */
val isMainThread: Boolean

/** 日志生成的线程ID */
val threadID: String
}

自定义日志存储


日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}

class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}

// 返回当前日志的大小
override fun size(): Long = 0

// 关闭
override fun close() {}
}

结束


库目前还处于alpha阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。


作者邮箱:565061763@qq.com


作者:Sunday1990
来源:juejin.cn/post/7306423214493270050
收起阅读 »

Flutter大型项目架构:分层设计篇

上篇文章讲的是状态管理(传送门)提到了 Flutter BLoC ,相比与原生的 setState() 及Provider等有哪些优缺点,并结合实际项目写了一个简单的使用,接下来本篇文章来讲 Flutter 大型项目是如何进行分层设计的,费话不多说,直接进入正...
继续阅读 »

上篇文章讲的是状态管理(传送门)提到了 Flutter BLoC ,相比与原生的 setState()Provider等有哪些优缺点,并结合实际项目写了一个简单的使用,接下来本篇文章来讲 Flutter 大型项目是如何进行分层设计的,费话不多说,直接进入正题哈。


为啥需要分层设计


其实这个没有啥固定答案,也许只是因为某一天看到手里的代码如同屎山一样,如下图,而随着业务功能的增加,不停的往这上面堆,这个屎山也会愈发庞大和混乱,如果这样继续下去,直到某一天因为一个小小的Bug,你需要花半天的时间来排查问题出在哪里,最后当你觉得问题终于改好了的时候,却不料碰了不该碰的地方,结果就是 fixing 1 bug will create 10 new bugs,甚至程序的崩溃。



随着这种问题的凸显,于是团队里的显眼包A提出了要求团队里的每个人都必须负责完成给自己写的代码添加注释和文档,规范命名等措施,一段时间后,发现代码是规范了,但问题依然存在,这时候才发现如果工程的架构分层没有做好,再规范的代码和注释也只是在屎山上雕花,治标不治本而已。



请原谅我打了一个这么俗的比方,但话糙理不糙,那么啥是应用的分层设计呢?


简单的来说,应用的分层设计是一种将应用程序划分为不同层级的方法,每个层级负责特定的功能或责任。其中表示层(Presentation Layer)负责用户界面和用户交互,将数据呈现给用户并接收用户输入;业务逻辑层(Business Logic Layer)处理应用程序的业务逻辑,包括数据验证、处理和转换;数据访问层(Data Access Layer)负责与数据存储交互,包括数据库或文件系统的读取和写入操作。



这样做有什么好处呢?一句话总结就是为了让代码层级责任清晰,维护、扩展和重用方便,每个模块能独立开发、测试和修改。


App 原生开发的分层设计


说到 iOSAndroid 的分层设计,就会想到如 MVCMVVM 等,它们主要是围绕着控制器层(Controller)、视图层(View)、和数据层(Model),还有连接 ViewModel 之间的模型视图层(ViewModel)这些来讲的。


MVVM


然而,MVCMVVM 概念还不算完整的分层架构,它们只是关注的 App 分层设计当中的应用层(Applicaiton Layer)组织方式,对于一个简单规模较小的App来说,可能单单一个应用层就能搞定,不用担心业务增量和复杂度上升对后期开发的压力,而一旦 App 上了规模之后就有点应付不过来了。


App 有了一定规模之后,必然会涉及到分层的设计,还有模块化、Hybrid 机制、数据库、跨项目开发等等,拿 iOS 的原生分层设计落地实践来说,通常会将工程拆分成多个Pod私有库组件,拆分的标准视情况而定,每一个分层组件是独立的开发和测试,再在主工程添加 Pod 私有库依赖来做分层设计开发。


此处应该有 Pod 分层组件化设计的配图,但是太懒了,就没有一个个的去搭建新项目和 Pod 私有库,不过 iOS 原生分层设计不是本篇文章的重点,本篇主要谈论的是 Flutter App 的分层设计。


Flutter 的分层设计


分层架构设计的理念其实是相通的,差别在于语言的特性和具体项目实施上,Flutter 项目也是如此。试想一下,当各种逻辑混合在一次的时候,即便是选择了像 Bloc 这样的状态管理框架来隔离视图层和逻辑实现层,也很难轻松的增强代码的拓展性,这时候选择采用一个干净的分层架构就显得尤为重要,怎样做到这一点呢,就需要将代码分成独立的层,并依赖于抽象而不是具体的实现。


分层架构设计


Flutter App 想要实现分层设计,就不得不提到包管理工具,如果在将所有分层组件代码放在主工程里面,那样并不能达到每个组件单独开发、维护和测试的目的,而如果放在新建的 Dart Package 中,没发跨多个组件改代码和测试,无法实现本地包链接和安装。使用 melos 就能解决这个问题,类似于 iOS 包管理工具 Pod, 而 melosFlutter 项目的包管理工具。


组件包管理工具



  1. 安装 Melos,将 Melos 安装为全局包,这样整个系统环境都可以使用:


    dart pub global activate melos


  2. 创建 workspace 文件夹,我这里命名为 flutter_architecture_design,添加 melos 的配置文件melos.yamlpubspec.yaml,其目录结构大概是这样的:


     flutter_architecture_design
    ├── melos.yaml
    ├── pubspec.yaml
    └── README.md


  3. 新建组件,以开发工具 Android Studio 为例,选择 File -> New -> New Flutter Project,根据需要创建组件包,需要注意的是组件包存放的位置要放在 workspace 目录中。


    新建组件


  4. 编辑 melos.yaml 配置文件,将上一步新建的组件包名放在 packages 之下,添加 scripts 相关命令,其目的请看下一步:


    name: flutter_architecture_design

    packages:
      - widgets/**
      - shared/**
      - data/**
      - initializer/**
      - domain/**
      - resources/**
      - app/**

    command:
      bootstrap:
        usePubspecOverrides: true

    scripts:
      analyze:
        run: dart pub global run melos exec --flutter "flutter analyze --no-pub --suppress-analytics"
        description: Run analyze.

      pub_get:
        run: dart pub global run melos exec --flutter "flutter pub get"
        description: pub get

      build_all:
        run: dart pub global run melos exec --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
        description: build_runner build all modules.

      build_data:
        run: dart pub global run melos exec --fail-fast --scope="*data*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
        description: build_runner build data module.

      build_domain:
        run: dart pub global run melos exec --fail-fast --scope="*domain*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
        description: build_runner build domain module.

      build_app:
        run: dart pub global run melos exec --fail-fast --scope="*app*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
        description: build_runner build app module.

      build_shared:
        run: dart pub global run melos exec --fail-fast --scope="*shared*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
        description: build_runner build shared module.

      build_widgets:
        run: dart pub global run melos exec --fail-fast --scope="*widgets*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
        description: build_runner build shared module.


  5. 打开命令行,切换到 workspace 目录,也就是 flutter_architecture_design 目录,执行命令。


     melos bootstrap

    出现 SUCCESS 之后,现在的目录结构是这样的:


    目录结构


  6. 点击 Android Studioadd configuration,将下图中的 Shell Scripts 选中后点击 OK


    Add Shell Scripts


    以上的 Scripts 添加完后就可以在这里看到了,操作起来也很方便,不需要去命令行那里执行命令。


    Shell Scripts



Flutter 分层设计实践


接下来介绍一下上面创建的几个组件库。



  • app:项目的主工程,存放业务逻辑代码、 UI 页面和 Bloc,还有 stylescolors 等等。

  • domain:实体类(entity)组件包,还有一些接口类,如 repositoryusercase等。

  • data:数据提供组件包,主要有:api_requestdatabaseshared_preference等,该组件包所有的调用实现都在 domain 中接口 repository 的实现类 repository_impl 中。

  • shared:工具类组件包,包括:utilhelperenumconstantsexceptionmixins等等。

  • resources:资源类组件包,有intl、公共的images

  • initializer:模块初始化组件包。

  • widgets:公共的 UI 组件包,如常用的:alertbuttontoastslider 等等。


它们之间的调用关系如下图:


Flutter App Architecture Design


其中 sharedresources 作为基础组件包,本身不依赖任何组件,而是给其它组件包提供支持。


作为主工程 App 也不会直接依赖 data 组件包,其调用是通过 domain 组件包中 UseCase 来实现,在 UseCase 会获取数据、处理列表数据的分页、参数校验、异常处理等等,获取数据是通过调用抽象类 repository 中相关函数,而不是直接调用具体实现类,此时Apppubspec.yaml 中配置是这样的:


name: app
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1

environment:
  sdk: ">=2.17.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  widgets:
    path: ../widgets
  shared:
    path: ../shared
  domain:
    path: ../domain
  resources:
    path: ../resources
  initializer:
    path: ../initializer

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
  generate: false
  assets:
    - assets/images/

提供的数据组件包 data 实现了抽象类 repository 中相关函数,只负责调用 Api 接口获取数据,或者从数据库获取数据。当上层调用的时候不需要关心数据是从哪里来的,全部交给 data 组件包负责。


initializer 作为模块初始化组件包,仅有一个 AppInitializer 类,其主要目的是将其它的模块的初始化收集起来放在 AppInitializer 类中 init() 函数中,然后在主工程入口函数:main() 调用这个 init() 函数,常见的初始化如:GetIt 初始化、数据库 objectbox 初始化、SharedPreferences初始化,这些相关的初始会分布在各自的组件包中。


class AppInitializer {
  AppInitializer();

  Future<void> init() async {
    await SharedConfig.getInstance().init();
    await DataConfig.getInstance().init();
    await DomainConfig.getInstance().init();
  }
}

widgets 作为公共的 UI 组件库,不处理业务逻辑,在多项目开发时经常会使用到。上图中的 Other Plugin Module 指的的是其它组件包,特别是需要单独开发与原生交互的插件时会用到,


这种分层设计出来的架构或许在开发过程中带来一下不便,如调用一个接口,第一步:需要先在抽象类 repository 写好函数声明;第二步:然后再去Api Service 写具体请求代码,并在repository_impl 实现类中调用;第三步:还需要在 UserCase 去做业务调用,错误处理等;最后一步:在blocevent中调用。这么一趟下来,确实有些繁琐或者说是过度设计。但是如果维度设定在大的项目中多人合作开发的时候,却能规避很多问题,每个分层组件都有自己的职责互不干扰,都支持单独的开发测试,尽可能的做到依赖于抽象而不是具体的实现。


本篇文章就到这里,源码后面这个系列的文章里放出来,感谢您的阅读,也希望您能关注我的公众号 Flutter技术实践,原创不易,您的关注是我更新下去最大的动力。


作者:那年星空
来源:juejin.cn/post/7350876924393422886
收起阅读 »

当 App 有了系统权限,真的可以为所欲为?

前一段时间有个 App 很火,是 Android App 利用了 Android 系统漏洞,获得了系统权限,做了很多事情。想看看这些个 App 在利用系统漏洞获取系统权限之后,都干了什么事,于是就有了这篇文章。由于准备仓促,有些 Code 没有仔细看,感兴趣的...
继续阅读 »

前一段时间有个 App 很火,是 Android App 利用了 Android 系统漏洞,获得了系统权限,做了很多事情。想看看这些个 App 在利用系统漏洞获取系统权限之后,都干了什么事,于是就有了这篇文章。由于准备仓促,有些 Code 没有仔细看,感兴趣的同学可以自己去研究研究,多多讨论,对应的文章和 Code 链接都在下面:



关于这个 App 是如何获取这个系统权限的,Android 反序列化漏洞攻防史话,这篇文章讲的很清楚,就不再赘述了,我也不是安全方面的专家,但是建议大家多读几遍这篇文章



序列化和反序列化是指将内存数据结构转换为字节流,通过网络传输或者保存到磁盘,然后再将字节流恢复为内存对象的过程。在 Web 安全领域,出现过很多反序列化漏洞,比如 PHP 反序列化、Java 反序列化等。由于在反序列化的过程中触发了非预期的程序逻辑,从而被攻击者用精心构造的字节流触发并利用漏洞从而最终实现任意代码执行等目的。



这篇文章主要来看看 XXX apk 内嵌提权代码,及动态下发 dex 分析 这个库里面提供的 Dex ,看看 App 到底想知道用户的什么信息?总的来说,App 获取系统权限之后,主要做了下面几件事(正常 App 无法或者很难做到的事情),各种不把用户当人了。



  1. 自启动、关联启动相关的修改,偷偷打开或者默认打开:与手机厂商斗智斗勇。

  2. 开启通知权限。

  3. 监听通知内容。

  4. 获取用户的使用手机的信息,包括安装的 App、使用时长、用户 ID、用户名等。

  5. 修改系统设置。

  6. 整一些系统权限的工具方便自己使用。


另外也可以看到,这个 App 对于各个手机厂商的研究还是比较深入的,针对华为、Oppo、Vivo、Xiaomi 等终端厂商都有专门的处理,这个也是值得手机厂商去反向研究和防御的。


最好我还加上了这篇文章在微信公众号发出去之后的用户评论,以及知乎回答的评论区(问题已经被删了,但是我可以看到:如何评价拼多多疑似利用漏洞攻击用户手机,窃取竞争对手软件数据,防止自己被卸载? - Gracker 的回答 - 知乎 http://www.zhihu.com/question/58… 2471 个赞)可以说是脑洞大开(关于 App 如何作恶)。


0. Dex 文件信息


本文所研究的 dex 文件是从 XXX apk 内嵌提权代码,及动态下发 dex 分析 这个仓库获取的,Dex 文件总共有 37 个,不多,也不大,慢慢看。这些文件会通过后台服务器动态下发,然后在 App 启动的时候进行动态加载,可以说是隐蔽的很,然而 Android 毕竟是开源软件,要抓你个 App 的行为还是很简单的,这些 Dex 就是被抓包抓出来的,可以说是人脏货俱全了。



由于是 dex 文件,所以直接使用 github.com/tp7309/TTDe… 这个库的反编译工具打开看即可,比如我配置好之后,直接使用 showjar 这个命令就可以



showjar 95cd95ab4d694ad8bdf49f07e3599fb3.dex



默认是用 jadx 打开,就可以看到反编译之后的内容,我们重点看 Executor 里面的代码逻辑即可



打开后可以看到具体的功能逻辑,可以看到一个 dex 一般只干一件事,那我们重点看这件事的核心实现部分即可


1. 通知监听和通知权限相关


1.1 获取 Xiaomi 手机通知内容



  1. 文件 : 95cd95ab4d694ad8bdf49f07e3599fb3.dex

  2. 功能 :获取用户的 Active 通知

  3. 类名 :com.google.android.sd.biz_dynamic_dex.xm_ntf_info.XMGetNtfInfoExecutor


1. 反射拿到 ServiceManager


一般我们会通过 ServiceManager 的 getService 方法获取系统的 Service,然后进行远程调用


2. 通过 NotificationManagerService 获取通知的详细内容


通过 getService 传入 NotificationManagerService 获取 NotificationManager 之后,就可以调用 getActiveNotifications 这个方法了,然后具体拿到 Notification 的下面几个字段



  1. 通知的 Title

  2. 发生通知的 App 的包名

  3. 通知发送时间

  4. key

  5. channelID :the id of the channel this notification posts to.


可能有人不知道这玩意是啥,下面这个图里面就是一个典型的通知



其代码如下


可以看到 getActiveNotifications 这个方法,是 System-only 的,普通的 App 是不能随便读取 Notification 的,但是这个 App 由于有权限,就可以获取



当然微信的防撤回插件使用的一般是另外一种方法,比如辅助服务,这玩意是合规的,但是还是推荐大家能不用就不用,它能帮你防撤回,他就能获取通知的内容,包括你知道的和不知道的


1.2. 打开 Xiaomi 手机上的通知权限(Push)



  1. 文件 :0fc0e98ac2e54bc29401efaddfc8ad7f.dex

  2. 功能 :可能有的时候小米用户会把 App 的通知给关掉,App 想知道这个用户是不是把通知关了,如果关了就偷偷打开

  3. 类名 :com.google.android.sd.biz_dynamic_dex.xm_permission.XMPermissionExecutor


这么看来这个应该还是蛮实用的,你个调皮的用户,我发通知都是为了你好,你怎么忍心把我关掉呢?让我帮你偷偷打开吧


App 调用 NotificationManagerService 的 setNotificationsEnabledForPackage 来设置通知,可以强制打开通知

frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java


然后查看 NotificationManagerService 的 setNotificationsEnabledForPackage 这个方法,就是查看用户是不是打开成功了

frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java


还有针对 leb 的单独处理~细 !


1.3. 打开 Vivo 机器上的通知权限(Push)



  1. 文件 :2eb20dc580aaa5186ee4a4ceb2374669.dex

  2. 功能 :Vivo 用户会把 App 的通知给关掉,这样在 Vivo 手机上 App 就收不到通知了,那不行,得偷偷打开

  3. 类名 :com.google.android.sd.biz_dynamic_dex.vivo_open_push.VivoOpenPushExecutor


核心和上面那个是一样的,只不过这个是专门针对 vivo 手机的



1.4 打开 Oppo 手机的通知权限



  1. 文件 :67c9e686004f45158e94002e8e781192.dex

  2. 类名 :com.google.android.sd.biz_dynamic_dex.oppo_notification_ut.OppoNotificationUTExecutor


没有反编译出来,看大概的逻辑应该是打开 App 在 oppo 手机上的通知权限



1.5 Notification 监听



  1. 文件 :ab8ed4c3482c42a1b8baef558ee79deb.dex

  2. 类名 :com.google.android.sd.biz_dynamic_dex.ud_notification_listener.UdNotificationListenerExecutor


这个就有点厉害了,在监听 App 的 Notification 的发送,然后进行统计



监听的核心代码


这个咱也不是很懂,是时候跟做了多年 SystemUI 和 Launcher 的老婆求助了....@史工


1.6 App Notification 监听



  1. 文件 :4f260398-e9d1-4390-bbb9-eeb49c07bf3c.dex

  2. 类名 :com.google.android.sd.biz_dynamic_dex.notification_listener.NotificationListenerExecutor


上面那个是 UdNotificationListenerExecutor , 这个是 NotificationListenerExecutor,UD 是啥?



这个反射调用的 setNotificationListenerAccessGranted 是个 SystemAPI,获得通知的使用权,果然有权限就可以为所欲为




1.7 打开华为手机的通知监听权限



  1. 文件 :a3937709-b9cc-48fd-8918-163c9cb7c2df.dex

  2. 类名 :com.google.android.sd.biz_dynamic_dex.hw_notification_listener.HWNotificationListenerExecutor


华为也无法幸免,哈哈哈


1.8 打开华为手机通知权限



  1. 文件 :257682c986ab449ab9e7c8ae7682fa61.dex

  2. 类名 :com.google.android.sd.biz_dynamic_dex.hw_permission.HwPermissionExecutor



2. Backup 状态


2.1. 鸿蒙 OS 上 App Backup 状态相关,保活用?



  1. 文件 :6932a923-9f13-4624-bfea-1249ddfd5505.dex

  2. 功能 :Backup 相关


这个看了半天,应该是专门针对华为手机的,收到 IBackupSessionCallback 回调后,执行 PackageManagerEx.startBackupSession 方法




查了下这个方法的作用,启动备份或恢复会话



2.2. Vivo 手机 Backup 状态相关



  1. 文件 :8c34f5dc-f04c-40ba-98d4-7aa7c364b65c.dex

  2. 功能 :Backup 相关



3. 文件相关


3.1 获取华为手机 SLog 和 SharedPreferences 内容



  1. 文件 : da03be2689cc463f901806b5b417c9f5.dex

  2. 类名 :com.google.android.sd.biz_dynamic_dex.hw_get_input.HwGetInputExecutor


拿这个干嘛呢?拿去做数据分析?



获取 SharedPreferences



获取 slog



4. 用户数据


4.1 获取用户使用手机的数据



  1. 文件 : 35604479f8854b5d90bc800e912034fc.dex

  2. 功能 :看名字就知道是获取用户的使用手机的数据

  3. 类名 :com.google.android.sd.biz_dynamic_dex.usage_event_all.UsageEventAllExecutor


看核心逻辑是同 usagestates 服务,来获取用户使用手机的数据,难怪我手机安装了什么 App、用了多久这些,其他 App 了如指掌


那么他可以拿到哪些数据呢?应有尽有~,包括但不限于 App 启动、退出、挂起、Service 变化、Configuration 变化、亮灭屏、开关机等,感兴趣的可以看一下:


frameworks/base/core/java/android/app/usage/UsageEvents.java
private static String eventToString(int eventType) {
switch (eventType) {
case Event.NONE:
return "NONE";
case Event.ACTIVITY_PAUSED:
return "ACTIVITY_PAUSED";
case Event.ACTIVITY_RESUMED:
return "ACTIVITY_RESUMED";
case Event.FOREGROUND_SERVICE_START:
return "FOREGROUND_SERVICE_START";
case Event.FOREGROUND_SERVICE_STOP:
return "FOREGROUND_SERVICE_STOP";
case Event.ACTIVITY_STOPPED:
return "ACTIVITY_STOPPED";
case Event.END_OF_DAY:
return "END_OF_DAY";
case Event.ROLLOVER_FOREGROUND_SERVICE:
return "ROLLOVER_FOREGROUND_SERVICE";
case Event.CONTINUE_PREVIOUS_DAY:
return "CONTINUE_PREVIOUS_DAY";
case Event.CONTINUING_FOREGROUND_SERVICE:
return "CONTINUING_FOREGROUND_SERVICE";
case Event.CONFIGURATION_CHANGE:
return "CONFIGURATION_CHANGE";
case Event.SYSTEM_INTERACTION:
return "SYSTEM_INTERACTION";
case Event.USER_INTERACTION:
return "USER_INTERACTION";
case Event.SHORTCUT_INVOCATION:
return "SHORTCUT_INVOCATION";
case Event.CHOOSER_ACTION:
return "CHOOSER_ACTION";
case Event.NOTIFICATION_SEEN:
return "NOTIFICATION_SEEN";
case Event.STANDBY_BUCKET_CHANGED:
return "STANDBY_BUCKET_CHANGED";
case Event.NOTIFICATION_INTERRUPTION:
return "NOTIFICATION_INTERRUPTION";
case Event.SLICE_PINNED:
return "SLICE_PINNED";
case Event.SLICE_PINNED_PRIV:
return "SLICE_PINNED_PRIV";
case Event.SCREEN_INTERACTIVE:
return "SCREEN_INTERACTIVE";
case Event.SCREEN_NON_INTERACTIVE:
return "SCREEN_NON_INTERACTIVE";
case Event.KEYGUARD_SHOWN:
return "KEYGUARD_SHOWN";
case Event.KEYGUARD_HIDDEN:
return "KEYGUARD_HIDDEN";
case Event.DEVICE_SHUTDOWN:
return "DEVICE_SHUTDOWN";
case Event.DEVICE_STARTUP:
return "DEVICE_STARTUP";
case Event.USER_UNLOCKED:
return "USER_UNLOCKED";
case Event.USER_STOPPED:
return "USER_STOPPED";
case Event.LOCUS_ID_SET:
return "LOCUS_ID_SET";
case Event.APP_COMPONENT_USED:
return "APP_COMPONENT_USED";
default:
return "UNKNOWN_TYPE_" + eventType;
}
}

4.2 获取用户使用数据



  1. 文件:b50477f70bd14479a50e6fa34e18b2a0.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.usage_event.UsageEventExecutor


上面那个是 UsageEventAllExecutor,这个是 UsageEventExecutor,主要拿用户使用 App 相关的数据,比如什么时候打开某个 App、什么时候关闭某个 App,6 得很,真毒瘤



4.3 获取用户使用数据



  1. 文件:1a68d982e02fc22b464693a06f528fac.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.app_usage_observer.AppUsageObserver


看样子是注册了 App Usage 的权限,具体 Code 没有出来,不好分析



5. Widget 和 icon 相关


经吃瓜群众提醒,App 可以通过 Widget 伪造一个 icon,用户在长按图标卸载这个 App 的时候,你以为卸载了,其实是把他伪造的这个 Widget 给删除了,真正的 App 还在 (不过我没有遇到过,这么搞真的是脑洞大开,且不把 Android 用户当人)


5.1. Vivo 手机添加 Widget



  1. 文件:f9b6b139-4516-4ac2-896d-8bc3eb1f2d03.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_widget.VivoAddWidgetExecutor


这个比较好理解,在 Vivo 手机上加个 Widget



5.2 获取 icon 相关的信息



  1. 文件:da60112a4b2848adba2ac11f412cccc7.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.get_icon_info.GetIconInfoExecutor


这个好理解,获取 icon 相关的信息,比如在 Launcher 的哪一行,哪一列,是否在文件夹里面。问题是获取这玩意干嘛???迷


image.png


5.3 Oppo 手机添加 Widget



  1. 文件:75dcc8ea-d0f9-4222-b8dd-2a83444f9cd6.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.oppoaddwidget.OppoAddWidgetExecutor


image.png


5.4 Xiaomi 手机更新图标?



  1. 文件:5d372522-b6a4-4c1b-a0b4-8114d342e6c0.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.xm_akasha.XmAkashaExecutor


小米手机上的桌面 icon 、shorcut 相关的操作,小米的同学来认领



6. 自启动、关联启动、保活相关


6.1 打开 Oppo 手机自启动



  1. 文件:e723d560-c2ee-461e-b2a1-96f85b614f2b.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.oppo_boot_perm.OppoBootPermExecutor


看下面这一堆就知道是和自启动相关的,看来自启动权限是每个 App 都蛋疼的东西啊



image.png


6.2 打开 Vivo 关联启动权限



  1. 文件:8b56d820-cac2-4ca0-8a3a-1083c5cca7ae.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_association_start.VivoAssociationStartExecutor


看名字就是和关联启动相关的权限,vivo 的同学来领了


直接写了个节点进去



6.3 关闭华为耗电精灵



  1. 文件:7c6e6702-e461-4315-8631-eee246aeba95.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.hw_hide_power_window.HidePowerWindowExecutor


看名字和实现,应该是和华为的耗电精灵有关系,华为的同学可以来看看




6.4 Vivo 机型保活相关



  1. 文件:7877ec6850344e7aad5fdd57f6abf238.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_get_loc.VivoGetLocExecutor


猜测和保活相关,Vivo 的同学可以来认领一下




7. 安装卸载相关


7.1 Vivo 手机回滚卸载



  1. 文件:d643e0f9a68342bc8403a69e7ee877a7.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_rollback_uninstall.VivoRollbackUninstallExecutor


这个看上去像是用户卸载 App 之后,回滚到预置的版本,好吧,这个是常规操作


7.2 Vivo 手机 App 卸载



  1. 文件:be7a2b643d7e8543f49994ffeb0ee0b6.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_official_uninstall.OfficialUntiUninstallV3


看名字和实现,也是和卸载回滚相关的


7.3 Vivo 手机 App 卸载相关



  1. 文件:183bb87aa7d744a195741ce524577dd0.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_official_uninstall.VivoOfficialUninstallExecutor


同上


其他


SyncExecutor



  1. 文件:f4247da0-6274-44eb-859a-b4c35ec0dd71.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.sync.SyncExecutor


没看懂是干嘛的,核心应该是 Utils.updateSid ,但是没看到实现的地方



UdParseNotifyMessageExecutor



  1. 文件:f35735a5cbf445c785237797138d246a.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.ud_parse_nmessage.UdParseNotifyMessageExecutor


看名字应该是解析从远端传来的 Notify Message,具体功能未知


TDLogcatExecutor



  1. 文件



    1. 8aeb045fad9343acbbd1a26998b6485a.dex

    2. 2aa151e2cfa04acb8fb96e523807ca6b.dex



  2. 类名



    1. com.google.android.sd.biz_dynamic_dex.td.logcat.TDLogcatExecutor

    2. com.google.android.sd.biz_dynamic_dex.td.logcat.TDLogcatExecutor




没太看懂这个是干嘛的,像是保活又不像,后面有时间了再慢慢分析


QueryLBSInfoExecutor



  1. 文件:74168acd-14b4-4ff8-842e-f92b794d7abf.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.query_lbs_info.QueryLBSInfoExecutor


获取 LBS Info



WriteSettingsExecutor



  1. 文件:6afc90e406bf46e4a29956aabcdfe004.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.write_settings.WriteSettingsExecutor


看名字应该是个工具类,写 Settings 字段的,至于些什么应该是动态下发的



OppoSettingExecutor



  1. 文件:61517b68-7c09-4021-9aaa-cdebeb9549f2.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.opposettingproxy.OppoSettingExecutor


Setting 代理??没看懂干嘛的,Oppo 的同学来认领,难道是另外一种形式的保活?



CheckAsterExecutor



  1. 文件:561341f5f7976e13efce7491887f1306.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.check_aster.CheckAsterExecutor


Check aster ?不是很懂



OppoCommunityIdExecutor



  1. 文件:538278f3-9f68-4fce-be10-12635b9640b2.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.oppo_community_id.OppoCommunityIdExecutor


获取 Oppo 用户的 ID?要这玩意干么?



GetSettingsUsernameExecutor



  1. 文件:4569a29c-b5a8-4dcf-a3a6-0a2f0bfdd493.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.oppo_get_settings_username.GetSettingsUsernameExecutor


获取 Oppo 手机用户的 username,话说你要这个啥用咧?



LogcatExecutor



  1. 文件:218a37ea-710d-49cb-b872-2a47a1115c69.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.logcat.LogcatExecutor


配置 Log 的参数


VivoBrowserSettingsExecutor



  1. 文件:136d4651-df47-41b4-bb80-2ec0ab1bc775.dex

  2. 类名:com.google.android.sd.biz_dynamic_dex.vivo_browser_settings.VivoBrowserSettingsExecutor


Vivo 浏览器相关的设置,不太懂要干嘛



评论区比文章更精彩


微信公众号评论区


image.png


image-20230514203940833


image-20230514203951666


image-20230514204055973


image-20230514204002395


image-20230514204022808


image-20230514204042836


image-20230514204123412


image-20230514204200492


知乎评论区


知乎回答已经被删了,我通过主页可以看到,但是点进去是已经被删了:如何评价拼多多疑似利用漏洞攻击用户手机,窃取竞争对手软件数据,防止自己被卸载? - Gracker 的回答 - 知乎 http://www.zhihu.com/question/58…


image-20230514205638861


image-20230514205909534


image-20230514205857945


image-20230514205937705


image-20230514205947268


image-20230514210010062


image-20230514210020926


image-20230514210040479


image-20230514210107839


image-20230514210122906


image-20230514210141653


image-20230514210152755


image-20230514210226176


image-20230514210235233


image-20230514210255912


image-20230514210344475


iOS 和 Android 哪个更安全?


这里就贴一下安全大佬 sunwear 的评论


img


关于我 && 博客



  1. 关于我 , 非常希望和大家一起交流 , 共同进步 .

  2. 博客内容导航

  3. 优秀博客文章记录 - Android 性能优化必知必会



一个人可以走的更快 , 一群人可以走的更远



作者:Gracker
来源:juejin.cn/post/7310474225809784884
收起阅读 »

为什么同事的前端代码我改不动了?

《如何写出高质量的前端代码》学习笔记 在日常开发中,我们经常会遇到需要修改同事代码的情况。有时可能会花费很长时间却只改动了几行代码,而且改完后还可能引发新的bug。我们聊聊导致代码难以维护的常见原因,以及相应的解决方案。 常见问题及解决方案 1. 单文件代码过...
继续阅读 »

如何写出高质量的前端代码》学习笔记


在日常开发中,我们经常会遇到需要修改同事代码的情况。有时可能会花费很长时间却只改动了几行代码,而且改完后还可能引发新的bug。我们聊聊导致代码难以维护的常见原因,以及相应的解决方案。


常见问题及解决方案


1. 单文件代码过长


问题描述:



  • 单个文件动辄几千行代码

  • 包含大量DOM结构、JS逻辑和样式

  • 需要花费大量时间才能理解代码结构


解决方案: 将大文件拆分成多个小模块,每个模块负责独立的功能。


以一个品牌官网为例,可以这样拆分:


<template>
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
</main>
<Footer/>
</div>

</template>

2. 模块耦合严重


问题描述:



  • 模块之间相互依赖

  • 修改一处可能影响多处

  • 难以进行单元测试


错误示例:


<script>
export default {
methods: {
getUserDetail() {
// 错误示范:多处耦合
let userId = this.$store.state.userInfo.id
|| window.currentUserId
|| this.$route.params.userId;

getUser(userId).then(res => {
// 直接操作子组件内部数据
this.$refs.userBaseInfo.data = res.baseInfo;
this.$refs.userArticles.data = res.articles;
})
}
}
}
</script>

正确示例:


<template>
<div>
<userBaseInfo :base-info="baseInfo"/>
<userArticles :articles="articles"/>
</div>

</template>

<script>
export default {
props: ['userId'],
data() {
return {
baseInfo: {},
articles: []
}
},
methods: {
getUserDetail() {
getUser(this.userId).then(res => {
this.baseInfo = res.baseInfo
this.articles = res.articles
})
}
}
}
</script>


3. 职责不单一


问题描述:



  • 一个方法承担了多个功能

  • 代码逻辑混杂在一起

  • 难以复用和维护


错误示例:


<script>
export default {
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data
// 一个方法中做了太多事情
let vipCount = 0
let activeVipsCount = 0
let activeUsersCount = 0

this.userData.forEach(user => {
if(user.type === 'vip') {
vipCount++
}
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
if(user.type === 'vip') {
activeVipsCount++
}
activeUsersCount++
}
})

this.vipCount = vipCount;
this.activeVipsCount = activeVipsCount;
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>

正确示例:


<script>
export default {
computed: {
// 将不同统计逻辑拆分为独立的计算属性
activeUsers() {
return this.userData.filter(user =>
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
)
},
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.activeUsers.filter(user => user.type === 'vip').length
},
activeUsersCount() {
return this.activeUsers.length
}
},
methods: {
getUserData() {
// 方法只负责获取数据
userService.getUserList().then(res => {
this.userData = res.data
})
}
}
}
</script>

4. 代码复制代替复用


问题描述:



  • 发现相似功能就直接复制代码

  • 维护时需要修改多处相同的代码

  • 容易遗漏修改点,造成bug


解决方案:



  • 提前抽取公共代码

  • 将重复逻辑封装成独立函数或组件

  • 通过参数来处理细微差异


5. 强行复用/假装复用


问题描述:


将不该复用的代码强行糅合在一起,比如:



  • 将登录弹窗和修改密码弹窗合并成一个组件

  • 把一个实体的所有操作(增删改查)都塞进一个方法


错误示例:


<template>
<div>
<UserManagerDialog ref="UserManagerDialog"/>
</div>

</template>

<script>
export default {
methods: {
addUser() {
this.$refs.UserManagerDialog.showDialog({
type: 'add'
})
},
editName() {
this.$refs.UserManagerDialog.showDialog({
type: 'editName'
})
},
deleteUser() {
this.$refs.UserManagerDialog.showDialog({
type: 'delete'
})
}
}
}
</script>


正确做法:



  • 不同业务逻辑使用独立组件

  • 只抽取真正可复用的部分(如表单验证规则、公共UI组件等)

  • 保持每个组件职责单一


6. 破坏数据一致性


问题描述: 使用多个关联状态来维护同一份数据,容易造成数据不一致。


错误示例:


<script>
export default {
data() {
return {
sourceData: [], // 原始数据
tableData: [], // 过滤后的数据
name: '', // 查询条件
type: ''
}
},
methods: {
nameChange(name) {
this.name = name;
// 手动维护 tableData,容易遗漏
this.tableData = this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
);
},
typeChange(type) {
this.type = type;
// 重复的过滤逻辑
this.tableData = this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
);
}
}
}
</script>

正确示例:


<script>
export default {
data() {
return {
sourceData: [],
name: '',
type: ''
}
},
computed: {
// 使用计算属性自动维护派生数据
tableData() {
return this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
)
}
}
}
</script>

7. 解决方案不“正统”


问题描述:


使用不常见或不合理的方案解决问题,如:



  • 直接修改 node_modules 中的代码,更好的实践:



    • 优先使用框架/语言原生解决方案

    • 遵循最佳实践和设计模式

    • 进行方案评审和代码审查

    • 对于第三方库的 bug:



      • 向作者提交 issue 或 PR

      • 将修改后的包发布到企业内部仓库

      • 寻找替代方案





  • 使用 JS 实现纯 CSS 可实现的效果


错误示例:


// 不恰当的鼠标悬停效果实现
element.onmouseover = function() {
this.style.color = 'red';
}
element.onmouseout = function() {
this.style.color = 'black';
}

正确示例:


/* 使用 CSS hover 伪类 */
.element:hover {
color: red;
}


  • 过度使用全局变量


如何进行代码重构


重构的原则



  1. 不改变软件功能

  2. 小步快跑,逐步改进

  3. 边改边测试

  4. 随时可以暂停


重构示例


以下展示如何一步步重构上面的统计代码:


第一步:抽取 vipCount



  • 删除data中的vipCount

  • 增加计算属性vipCount,将getUserData中关于vipCount的逻辑挪到这里

  • 删除getUserData中vipCount的计算逻辑


<script>
export default {
computed: {
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data
let activeVipsCount = 0
let activeUsersCount = 0

this.userData.forEach(user => {
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
if(user.type === 'vip') {
activeVipsCount++
}
activeUsersCount++
}
})

this.activeVipsCount = activeVipsCount;
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>

完成本次更改后,测试下各项数据是否正常,不正常查找原因,正常我们继续。


第二步:抽取 activeVipsCount



  • 删除data中的activeVipsCount

  • 增加计算属性activeVipsCount,将getUserData中activeVipsCount的计算逻辑迁移过来

  • 删除getUserData中关于activeVipsCount计算的代码


<script>
export default {
computed: {
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.userData.filter(user =>
user.type === 'vip' &&
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
).length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data
let activeUsersCount = 0

this.userData.forEach(user => {
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
activeUsersCount++
}
})

this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>

...


最终版本:


<script>
export default {
computed: {
activeUsers() {
return this.userData.filter(user =>
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
)
},
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.activeUsers.filter(user => user.type === 'vip').length
},
activeUsersCount() {
return this.activeUsers.length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data
})
}
}
}
</script>

总结


要写出易维护的代码,需要注意:



  1. 合理拆分模块,避免单文件过大

  2. 降低模块间耦合

  3. 保持职责单一

  4. 使用计算属性处理派生数据

  5. 定期进行代码重构


记住:重构是一个渐进的过程,不要试图一次性完成所有改进。在保证功能正常的前提下,通过小步快跑的方式逐步优化代码质量。


作者:Cyrus丶
来源:juejin.cn/post/7438647460219961395
收起阅读 »

Android逆向之某影音app去广告

前言 本文介绍通过抓包的方式,分析出某影音app的去广告逆向点,难度极低,适合新手上路。 所谓逆向,三分逆,七分猜。 分析过程 首先打开app,可以看到不时有广告弹出。我们的目标就是去除这些广告。 首先想到的思路是定位到加载广告的代码删掉即可,使用 MT 管...
继续阅读 »

前言


本文介绍通过抓包的方式,分析出某影音app的去广告逆向点,难度极低,适合新手上路。


所谓逆向,三分逆,七分猜。


分析过程


首先打开app,可以看到不时有广告弹出。我们的目标就是去除这些广告。


image.png

首先想到的思路是定位到加载广告的代码删掉即可,使用 MT 管理器查看安装包的 dex 文件,可以看到大量 a、b、c 的目录,可见代码被混淆过的,直接上手分析太费劲了。


image.png

接着猜测,既然 app 能动态加载各种广告,必然会发起 http 网络请求,只需要分析出哪些请求是和广告相关的,将其拦截,即可实现去广告的目的。


所以接下来尝试抓包分析一下。


抓 http 请求推荐使用 Burp Suite,使用社区版即可。


打开 Burp Suite,切换到 Proxy 页。Proxy 即创建一个代理服务器,配置所有的网络请求连接到这个代理服务器,就可以看到所有经过代理服务器的 http 请求,并且能拦截修改请求、丢弃请求。


打开 Proxy settings,编辑默认的代理服务器地址配置。


image.png

端口号我这里填写 8888,地址选择当前机器的 ip 地址,与 ipconfig 命令显示的 ip 保持一致。


image.png


确定后选择导出 DER 格式的证书。


image.png


任意取名,文件扩展名为 .cer。


image.png


由于抓包需要电脑与手机在同一网络环境下, 因此建议使用安卓模拟器。


将 cer 文件导入到安卓模拟器中,之后打开设置 - 安全 - 加密与凭据 - 从SD卡安装(不同安卓会有所不同)。


image.png


选择 cer 文件后,随意命名,凭据用途选择 WLAN。确定后安装成功。
image.png


编辑当前连接的 wifi,设置代理为手动,主机名和端口填我们在 Burp Suite 中填写的内容。


image.png


打开 Burp Suite 打开代理拦截。


image.png


此时重新打开app,可以看到 Burp Suite 成功拦截了一条网络请求,并且app卡在启动页上。


image.png


此时点击 Drop (丢弃该请求),该请求会重发又被拦截,全部 Drop 掉。


image.png


此时惊喜的发现,进入了app首页,并且没有任何广告弹窗了。


由此可见,启动app时首先会加载 json 配置,根据配置去加载广告,只要将这条请求去掉就可以达到去广告的目的。只需要到app中反编译搜索拦截到的请求 url ,即可定位到拉取广告的代码。


搜索 sjmconfig,即可定位到目标代码。


image.png


将域名修改为 localhost,那么这条请求将永远不会成功。


image.png


之后保存修改、签名,重新安装,完事收工。


作者:seasonhai
来源:juejin.cn/post/7343139490901737482
收起阅读 »

车机系统与Android的关系

前言:搞懂 Android 系统和汽车到底有什么关系。 一、基本概念 1、Android Auto 1)是什么 Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的; 运行环境:需要在 Android 5.0 或者更高版本的...
继续阅读 »

前言:搞懂 Android 系统和汽车到底有什么关系。



一、基本概念


1、Android Auto


1)是什么



  • Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的;

  • 运行环境:需要在 Android 5.0 或者更高版本的系统,并且需要 Google 地图和 Google Play 音乐应用;


2)功能



  • Android Atuo 可以用来将 Android 设备上的部分功能映射到汽车屏幕上;

  • 满足了很多人在开车时会使用手机的需求;


2、Google Assistant



  • Google 将 GoofleAssistant 集成到 AndroidAuto 中;

  • 交互方式有键盘、触摸、语音等;

  • 对于汽车来说,语音无疑是比触摸更好的交互方式;

  • 在驾驶环境中,语音交换存在的优势

    • 用户不改变自身的物理姿势,这种交互方式不影响驾驶员对驾驶的操作;

    • 有需要多次触摸的交互时,可能只需要一条语音就可以完成;

    • 语音交互不存在入口的层次嵌套,数据更加扁平;

    • 优秀的语音系统可以利用对话的上下文完成任务,避免用户重复输入;




3、Android Automotive


1、Android Auto 和 Android Automotive 的区别



  • Android Auto 是以手机为中心的

    • 好处:数据和应用始终是一致的,不存在需要数据同步的问题,手机上装的软件和已有数据,接到汽车上就直接有了;

    • 坏处:每次都需要拿出手机,汽车只是作为手机的一个外设;这种模式不便于对于汽车本身的控制和相关数据的获取;



  • Android Automotive

    • 如果将系统直接内置于汽车中,会大大提升用户体验;

    • Android Automotive 就是面向这个方向进行设计的;

    • 一旦将系统内置于汽车,可以完成的功能就会大大增加;例如,直接在中控触摸屏上调整座椅和空调;同时,系统也能获取更多关于汽车的信息,例如:油耗水平、刹车使用等;




加两张中控和仪表的图片


4、App


1)App 的开发



  • Android Auto 目前仅支持两类第三方应用

    • 音频应用:允许用户浏览和播放汽车中的音乐和语音内容;

    • 消息应用:通过 text-to-speech 朗读消息并通过语音输入回复消息;




2)App 的设计



  • Google 专门为 Android Auto 上的 UI 设计做了一个指导网站:Auto UI guidelines;

  • 基本指导原则(车机交互系统的借鉴)

    • Android Auto 上的互动步调必须由驾驶员控制;

    • 汽车界面上的触摸目标必须足够大,以便可以轻松地浏览和点击;

    • 适当的私彩对比可以帮助驾驶员快速解读信息并做出决定;

    • 应用必须支持夜间模式,因为过高的强度可能会干扰注意力;

    • Roboto 字体在整个系统中用于保持一致性并帮助提高可读性;

    • 通过触摸来进行分页应用用来作为滑动翻页的补充;

    • 有节制地使用动画来描述两个状态间的变化;






二、源码和架构


1、Android Automative的整体架构




  • Android Automative 的源码包含在 AOSP 中;

  • Android Automative 是在原先 Android的 系统架构上增加了一些与车相关的(图中虚线框中绿色背景的)模块;

    • Car App:包括 OEM 和第三方开发的 App;

      • OEM:就是汽车厂商利用自身掌握的核心技术负责设计和开发新产品,而具体的生产制造任务则通过合同订购的方式委托给同类产品的其他厂家进行,最终产品会贴上汽车厂商自己的品牌商标。这种生产方式被称为定牌生产合作,俗称“贴牌”。承接这种加工任务的制造商就被称为OEM厂商,其生产的产品就是OEM产品;



    • Car API:提供给汽车 App 特有的接口;

    • Car Service:系统中与车相关的服务;

    • Vehicle Network Service:汽车的网络服务;

    • Vehicle HAL:汽车的硬件抽象层描述;




1)Car App



  • /car_product/build/car.mk 这个文件中列出了汽车系统中专有的模块;

  • 列表中,首字母大写的模块基本上都是汽车系统中专有的 App;

  • App的源码都位于 /platform/packages/services/Car/ 目录下


    # Automotive specific packages
    PRODUCT_PACKAGES += \
    vehicle_monitor_service \
    CarService \
    CarTrustAgentService \
    CarDialerApp \
    CarRadioApp \
    OverviewApp \
    CarLensPickerApp \
    LocalMediaPlayer \
    CarMediaApp \
    CarMessengerApp \
    CarHvacApp \
    CarMapsPlaceholder \
    CarLatinIME \
    CarUsbHandler \
    android.car \
    libvehiclemonitor-native \



2)Car API



  • 开发汽车专有的App自然需要专有的API;

  • 这些API对于其他平台(例如手机和平板)通常是没有意义的;

  • 所以这些API没有包含在Android Framework SDK中;

  • 下图列出了所有的 Car API;




  • android.car:包含了与车相关的基本API。例如:车辆后视镜,门,座位,窗口等。

    • cabin:座舱相关API。

    • hvac:通风空调相关API。(hvac是Heating, ventilation and air conditioning的缩写)

    • property:属性相关API。

    • radio:收音机相关API。

    • pm:应用包相关API。

    • render:渲染相关API。

    • menu:车辆应用菜单相关API。

    • annotation:包含了两个注解。

    • app

    • cluster:仪表盘相关API。

    • content

    • diagnostic:包含与汽车诊断相关的API。

    • hardware:车辆硬件相关API。

    • input:输入相关API。

    • media:多媒体相关API。

    • navigation:导航相关API。

    • settings:设置相关API。

    • vms:汽车监测相关API。




3)Car Service



  • Car Service并非一个服务,而是一系列的服务。这些服务都在ICarImpl.java构造函数中列了出来;


public ICarImpl(Context serviceContext, IVehicle vehicle, SystemInterface systemInterface,
CanBusErrorNotifier errorNotifier)
{
mContext = serviceContext;
mHal = new VehicleHal(vehicle);
mSystemActivityMonitoringService = new SystemActivityMonitoringService(serviceContext);
mCarPowerManagementService = new CarPowerManagementService(
mHal.getPowerHal(), systemInterface);
mCarSensorService = new CarSensorService(serviceContext, mHal.getSensorHal());
mCarPackageManagerService = new CarPackageManagerService(serviceContext, mCarSensorService,
mSystemActivityMonitoringService);
mCarInputService = new CarInputService(serviceContext, mHal.getInputHal());
mCarProjectionService = new CarProjectionService(serviceContext, mCarInputService);
mGarageModeService = new GarageModeService(mContext, mCarPowerManagementService);
mCarInfoService = new CarInfoService(serviceContext, mHal.getInfoHal());
mAppFocusService = new AppFocusService(serviceContext, mSystemActivityMonitoringService);
mCarAudioService = new CarAudioService(serviceContext, mHal.getAudioHal(),
mCarInputService, errorNotifier);
mCarCabinService = new CarCabinService(serviceContext, mHal.getCabinHal());
mCarHvacService = new CarHvacService(serviceContext, mHal.getHvacHal());
mCarRadioService = new CarRadioService(serviceContext, mHal.getRadioHal());
mCarNightService = new CarNightService(serviceContext, mCarSensorService);
mInstrumentClusterService = new InstrumentClusterService(serviceContext,
mAppFocusService, mCarInputService);
mSystemStateControllerService = new SystemStateControllerService(serviceContext,
mCarPowerManagementService, mCarAudioService, this);
mCarVendorExtensionService = new CarVendorExtensionService(serviceContext,
mHal.getVendorExtensionHal());
mPerUserCarServiceHelper = new PerUserCarServiceHelper(serviceContext);
mCarBluetoothService = new CarBluetoothService(serviceContext, mCarCabinService,
mCarSensorService, mPerUserCarServiceHelper);
if (FeatureConfiguration.ENABLE_VEHICLE_MAP_SERVICE) {
mVmsSubscriberService = new VmsSubscriberService(serviceContext, mHal.getVmsHal());
mVmsPublisherService = new VmsPublisherService(serviceContext, mHal.getVmsHal());
}
mCarDiagnosticService = new CarDiagnosticService(serviceContext, mHal.getDiagnosticHal());

4)Car Tool


a、VMS



  • VMS全称是Vehicle Monitor Service。正如其名称所示,这个服务用来监测其他进程;

  • 在运行时,这个服务是一个独立的进程,在init.car.rc中有关于它的配置


service vms /system/bin/vehicle_monitor_service
class core
user root
group root
critical

on boot
start vms


  • 这是一个Binder服务,并提供了C++和Java的Binder接口用来供其他模块使用;


作者:一个写代码的修车工
来源:juejin.cn/post/7356981730765291558
收起阅读 »

写给开发者的Material Design3

M3是Google开源的一套设计规范,主要是UI/UX设计人员设计APP的重要参考依据,与Material相比设计人员可能更偏爱苹果的设计规范,至少我认识的朋友是这样。 关于Material You的疑惑 M3还有一个名字叫Material You,至于区别C...
继续阅读 »

M3是Google开源的一套设计规范,主要是UI/UX设计人员设计APP的重要参考依据,与Material相比设计人员可能更偏爱苹果的设计规范,至少我认识的朋友是这样。


关于Material You的疑惑


M3还有一个名字叫Material You,至于区别ChatGPT是这样说的



Material You 是 Material Design 3 (M3) 的市场化和用户友好的名称,而 M3 则是更技术和官方的术语,两者指的是同一个设计系统,只是表达方式不同。



我推测Google为了彰显新版本的高大上,一开始起了高大上的名字Material You,后来发现还有可能发新版本,叫卖油就不妥了,又改回一贯的风格就又叫M3了。所以,现在M3官网上名字都是Material 3,偶尔在看Android文档是会有Material You,以后再看到这两个名字请不要疑惑。


一些其它的设计规范


有很多组件库遵顼Material的设计规范,像Android UI库的风格默认就是Material、还有Flutter、Web都有遵循这套设计规范的UI组件库。国内的Ant Design应该也是参考M3搞得,一开始只有Web端的React组件可以用,后来社区支持了Vue组件。最近看到字节也出了一套设计规范叫Arco Design,大家要知道这是一类xx。


Material 3


M3主要由三大部分组成:



  • Foundations:规范的基础部分,包含一些核心原则。Foundations部分上来就强调了Accessibility,也就是从设计角度要尽量要满足每个人,包含正常人和残疾人(失明、听力障碍、认知障碍、情景障碍如手臂骨折)。这应是设计师的底限,却是很多APP的上限。尤其是一些单位外包APP,如某电网、某银行和一些基层单位的信息化工具,钱没少挣,设计么呵呵。以对比度为例,对于不同的字号以及图片都有不同的规范。此外还有布局规范、交互规范、Token、自定义等。

  • Styles:Styles部分包含颜色、层、图标、动画、图形、排版,在 Foundations 之上应用的视觉风格,定义如何将基础元素组合和调整,以实现特定的品牌和界面风格。

  • Components:使用 Foundations 和 Styles 构建的可复用 UI 元素,它们是设计和开发中实际使用的界面模块。例如:按钮、输入框、导航栏等。


关于Android UI组件库


Android的两套UI组件库,Compose和基于XML写法的组件库都实现了M3规范,需要区分一下M3中的Components和Compose中的Component。我感觉后者是前者的超集,因为Compose的代码中包含了很多在M3中没有的组件,例如Scaffold。细细想来应该是Android开发团队为了方便应用开发者快速开发,依照M3规范扩充了UI组件库中的组件。


基于XML写法的组件库是旧版本的,但是目前使用人数还是最多的。估计是切换的到新版Compose写法收益不大,另外也可能是Android开发者圈子相对前端活跃度低。换做前端圈子,早就卷起来了。我没经历过大厂,记忆中16年我就听说过React,到19年React就已经非常流行了。而Jetpack Compose都多少年了,还是半死不过。我在14年还在上大专时,接触过Android开发,那时还是使用XML定义UI,感觉极其难用,后来就没有接触了,时隔10年,Android使用Compose的开发体验已经相当开门了,然鹅这玩意为啥不火呢?而且JB让这个技术扩展到了PC端,甚至Web端,应该是值得学习的吧,难道JB还不够硬。。还是gg不够硬啊


作者:程序饲养员
来源:juejin.cn/post/7432866688365740041
收起阅读 »

Flutter 新一代混合栈管理框架(已适配HarmonyOS Next)

简介 Fusion 是新一代的混合栈管理框架,用于 Flutter 与 Native 页面统一管理,并支持页面通信、页面生命周期监听等功能。Fusion 即 融合,我们的设计初衷就是帮助开发者在使用 Flutter 与 Native 进行混合开发时尽量感受不到...
继续阅读 »

简介


Fusion 是新一代的混合栈管理框架,用于 Flutter 与 Native 页面统一管理,并支持页面通信、页面生命周期监听等功能。Fusion 即 融合,我们的设计初衷就是帮助开发者在使用 Flutter 与 Native 进行混合开发时尽量感受不到两者的隔阂,提升开发体验。此外,Fusion 彻底解决了混合开发过程中普遍存在的黑屏、白屏、闪屏等问题,更加适合重视用户体验的App使用。


从 4.0 开始,Fusion 已完成纯鸿蒙平台(HarmonyOS Next/OpenHarmony,以下简称 HarmonyOS)的适配,开发者可以在Android、iOS、HarmonyOS上得到完全一致的体验。(HarmonyOS 的 Flutter SDK 可以在这里获取)


OSAndroidiOSHarmonyOS
SDK5.0(21)+11.0+4.1(11)+

Fusion 采用引擎复用方案,在 Flutter 与 Native 页面多次跳转情况下,APP 始终仅有一份 FlutterEngine 实例,因此拥有更好的性能和更低的内存占用。


Fusion 也是目前仅有的支持混合开发时应用在后台被系统回收后,所有Flutter页面均可正常恢复的混合栈框架。


开始使用


0、准备


在开始前需要按照 Flutter 官方文档,将 Flutter Module 项目接入到 Android、iOS、HarmonyOS 工程中。


1、初始化


Flutter 侧


使用 FusionApp 替换之前使用的 App Widget,并传入所需路由表,默认路由表和自定义路由表可单独设置也可同时设置。


void main() {
runApp(FusionApp(
// 默认路由表
routeMap: routeMap,
// 自定义路由表
customRouteMap: customRouteMap,
));
}

// 默认路由表,使用默认的 PageRoute
// 使用统一的路由动画
final Map<String, FusionPageFactory> routeMap = {
'/test': (arguments) => TestPage(arguments: arguments),
kUnknownRoute: (arguments) => UnknownPage(arguments: arguments),
};

// 自定义路由表,可自定义 PageRoute
// 比如:某些页面需要特定的路由动画则可使用该路由表
final Map<String, FusionPageCustomFactory> customRouteMap = {
'/mine': (settings) => PageRouteBuilder(
opaque: false,
settings: settings,
pageBuilder: (_, __, ___) => MinePage(
arguments: settings.arguments as Map<String, dynamic>?)),
};

P.S: kUnknownRoute表示未定义路由


注意:如果项目使用了 flutter_screenutil,需要在 runApp 前调用 Fusion.instance.install(),没有使用 flutter_screenutil则无须该步骤。


void main() {
Fusion.instance.install();
runApp(FusionApp(
// 默认路由表
routeMap: routeMap,
// 自定义路由表
customRouteMap: customRouteMap,
));
}

Android 侧


在 Application 中进行初始化,并实现 FusionRouteDelegate 接口


class MyApplication : Application(), FusionRouteDelegate {

override fun onCreate() {
super.onCreate()
Fusion.install(this, this)
}

override fun pushNativeRoute(name: String?, arguments: Map<String, Any>?) {
// 根据路由 name 跳转对应 Native 页面
}

override fun pushFlutterRoute(name: String?, arguments: Map<String, Any>?) {
// 根据路由 name 跳转对应 Flutter 页面
// 可在 arguments 中存放参数判断是否需要打开透明页面
}
}

iOS 侧


在 AppDelegate 中进行初始化,并实现 FusionRouteDelegate 代理


@UIApplicationMain
@objc class AppDelegate: UIResponder, UIApplicationDelegate, FusionRouteDelegate {

func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
...
Fusion.instance.install(self)
...
return true
}

func pushNativeRoute(name: String?, arguments: Dictionary<String, Any>?) {
// 根据路由 name 跳转对应 Native 页面
}

func pushFlutterRoute(name: String?, arguments: Dictionary<String, Any>?) {
// 根据路由 name 跳转对应 Flutter 页面
// 可在 arguments 中存放参数判断是否需要打开透明页面
// 可在 arguments 中存放参数判断是 push 还是 present
}
}

HarmonyOS 侧


在 UIAbility 中进行初始化,并实现 FusionRouteDelegate 代理


export default class EntryAbility extends UIAbility implements FusionRouteDelegate {
private static TAG = 'EntryAbility'
private mainWindow: window.Window | null = null
private windowStage: window.WindowStage | null = null

override async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
await Fusion.instance.install(this.context, this)
GeneratedPluginRegistrant.registerWith(Fusion.instance.defaultEngine!)
}

pushNativeRoute(name: string, args: Map<string, Object> | null): void {
// 根据路由 name 跳转对应 Native 页面
}

pushFlutterRoute(name: string, args: Map<string, Object> | null): void {
// 根据路由 name 跳转对应 Flutter 页面
// 可在 arguments 中存放参数判断是否需要打开透明页面
}
}

2、Flutter 容器


普通页面模式


Android 侧


通过 FusionActivity(或其子类) 创建 Flutter 容器,启动容器时需要使用 Fusion 提供的 buildFusionIntent 方法,其中参数 transparent 需设为 false。其 xml 配置参考如下(如果使用 FusionActivity 则不用配置):


        <activity
android:name=".CustomFusionActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="standard"
android:theme="@style/FusionNormalTheme"
android:windowSoftInputMode="adjustResize" />


iOS 侧


通过 FusionViewController (或其子类)创建 Flutter 容器,pushpresent 均支持。FusionViewController 默认隐藏了 UINavigationController。


在 iOS 中需要处理原生右滑退出手势和 Flutter 手势冲突的问题,解决方法也很简单:只需在自定义的 Flutter 容器中实现 FusionPopGestureHandler 并在对应方法中启用或者关闭原生手势即可,这样可以实现如果当前 Flutter 容器存在多个 Flutter 页面时,右滑手势是退出 Flutter 页面,而当 Flutter 页面只有一个时则右滑退出 Flutter 容器。


    // 启用原生手势
func enablePopGesture() {
// 以下代码仅做演示,不可直接照搬,需根据APP实际情况自行实现
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}

// 关闭原生手势
func disablePopGesture() {
// 以下代码仅做演示,不可直接照搬,需根据APP实际情况自行实现
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}

HarmonyOS 侧


通过 FusionEntry(或其子类) 创建 Flutter 容器,启动容器时需要使用 Fusion 提供的 buildFusionParams 方法,也可直接使用 FusionPage。默认全屏模式。


    const params = buildFusionParams(name, args, false, backgroundColor)
this.mainLocalStorage?.setOrCreate('params', params)
router.pushNamedRoute({name: FusionConstant.FUSION_ROUTE_NAME})

透明页面模式


Android 侧


使用方式与普通页面模式相似,只是buildFusionIntent 方法的参数 transparent 需设为 true,其 xml 配置参考如下:


        <activity
android:name=".TransparentFusionActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="standard"
android:theme="@style/FusionTransparentTheme"
android:windowSoftInputMode="adjustResize" />


iOS 侧


使用方式与普通页面模式相似:


let fusionVc = CustomViewController(routeName: name, routeArguments: arguments, transparent: true)
navController?.present(fusionVc, animated: false)

HarmonyOS 侧


使用方式与普通页面模式相似:


    const params = buildFusionParams(name, args, true, backgroundColor)
this.windowStage?.createSubWindow(FusionConstant.TRANSPARENT_WINDOW, (_, win) => {
const record: Record<string, Object> = {
'params': params
}
win.loadContentByName(FusionConstant.FUSION_ROUTE_NAME, new LocalStorage(record))
win.showWindow()
})

Flutter 侧


同时Flutter页面背景也需要设置为透明


子页面模式


子页面模式是指一个或多个 Flutter 页面同时嵌入到 Native 容器中的场景,如:使用Tab切换Flutter和原生页面,Fusion 支持多个 Flutter 页面嵌入同一个 Native 容器中


Android 侧


使用 FusionFragment 以支持子页面模式,创建 FusionFragment 对象需要使用 buildFusionFragment 方法


iOS 侧


与页面模式一样使用 FusionViewController


HarmonyOS 侧


与页面模式一样使用 FusionEntry,配合 buildFusionParams方法配置参数


自定义容器背景色


默认情况下容器的背景为白色,这是因为考虑到绝大多数的页面都是使用白色背景,但如果打开的首个Flutter页面的背景是其他颜色,比如夜间模式下页面为深灰色,此时是为了更好的视觉效果,可以自定义容器的背景色与首个Flutter页面的背景色一致。


Android 侧


buildFusionIntentbuildFusionFragment方法中参数 backgroundColor 设为所需背景色


iOS 侧


在创建 FusionViewController (或其子类)对象时,参数 backgroundColor 设为所需背景色


HarmonyOS 侧


buildFusionParams方法中参数 backgroundColor 设为所需背景色


3、路由API(FusionNavigator)



  • push:将对应路由入栈,Navigator.pushNamed 与之等同,根据FusionRouteType分为以下几种方式:

    • flutter模式: 在当前Flutter容器中将指定路由对应的Flutter页面入栈,如果没有则跳转kUnknownRoute对应Flutter页面

    • flutterWithContainer模式: 创建一个新的Flutter容器,并将指定路由对应的Flutter页面入栈,如果没有则跳转kUnknownRoute对应Flutter页面。即执行FusionRouteDelegate的pushFlutterRoute

    • native模式: 将指定路由对应的Native页面入栈,即执行FusionRouteDelegate的pushNativeRoute

    • adaption模式: 自适应模式,默认类型。首先判断该路由是否是Flutter路由,如果不是则进入native模式,如果是再判断当前是否是页面是否是Flutter容器,如果是则进入flutter模式,如果不是则进入flutterWithContainer模式



  • pop:在当前Flutter容器中将栈顶路由出栈,Navigator.pop 与之等同

  • maybePop:在当前Flutter容器中将栈顶路由出栈,可被WillPopScope拦截

  • replace:在当前Flutter容器中将栈顶路由替换为对应路由,Navigator.pushReplacementNamed 与之等同

  • remove:在当前Flutter容器中移除对应路由


路由跳转与关闭等操作既可使用FusionNavigator的 API,也可使用Navigator中与之对应的API(仅上述提到的部分)


4、Flutter Plugin 注册


在 Android 和 iOS 平台上框架内部会自动注册插件,无须手动调用 GeneratedPluginRegistrant.registerWith 进行注册,但 HarmonyOS 必须手动调用该方法。


5、自定义 Channel


如果需要 Native 与 Flutter 进行通信,则需要自行创建 Channel,创建 Channel 方式如下(以 MethodChannel 为例):


Android 侧


①、与容器无关的方法


在 Application 中进行注册


val channel = Fusion.defaultEngine?.dartExecutor?.binaryMessenger?.let {
MethodChannel(
it,
"custom_channel"
)
}
channel?.setMethodCallHandler { call, result ->
}

②、与容器相关的方法


在自实现的 FusionActivity、FusionFragmentActivity、FusionFragment 上实现 FusionMessengerHandler 接口,在 configureFlutterChannel 中创建 Channel,在 releaseFlutterChannel 释放 Channel


class CustomActivity : FusionActivity(), FusionMessengerHandler {

override fun configureFlutterChannel(binaryMessenger: BinaryMessenger) {
val channel = MethodChannel(binaryMessenger, "custom_channel")
channel.setMethodCallHandler { call, result ->

}
}

override fun releaseFlutterChannel() {
channel?.setMethodCallHandler(null)
channel = null
}
}

iOS 侧


①、与容器无关的方法


在 AppDelegate 中进行注册


var channel: FlutterMethodChannel? = nil
if let binaryMessenger = Fusion.instance.defaultEngine?.binaryMessenger {
channel = FlutterMethodChannel(name: "custom_channel", binaryMessenger: binaryMessenger)
}
channel?.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
}

②、与容器相关的方法


在自实现的 FusionViewController 上实现 FusionMessengerHandler 协议,在协议方法中创建 Channel


class CustomViewController : FusionViewController, FusionMessengerHandler {
func configureFlutterChannel(binaryMessenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "custom_channel", binaryMessenger: binaryMessenger)
channel?.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in

}
}

func releaseFlutterChannel() {
channel?.setMethodCallHandler(nil)
channel = nil
}
}

HarmonyOS 侧


①、与容器无关的方法


在 UIAbility 中进行注册


const binaryMessenger = Fusion.instance.defaultEngine?.dartExecutor.getBinaryMessenger()
const channel = new MethodChannel(binaryMessenger!, 'custom_channel')
channel.setMethodCallHandler({
onMethodCall(call: MethodCall, result: MethodResult): void {

}
})

②、与容器相关的方法


在自实现的 FusionEntry 上实现 FusionMessengerHandler 接口,在 configureFlutterChannel 中创建 Channel,在 releaseFlutterChannel 释放 Channel


export default class CustomFusionEntry extends FusionEntry implements FusionMessengerHandler, MethodCallHandler {
private channel: MethodChannel | null = null

configureFlutterChannel(binaryMessenger: BinaryMessenger): void {
this.channel = new MethodChannel(binaryMessenger, 'custom_channel')
this.channel.setMethodCallHandler(this)
}

onMethodCall(call: MethodCall, result: MethodResult): void {
result.success(`Custom Channel:${this}_${call.method}`)
}

releaseFlutterChannel(): void {
this.channel?.setMethodCallHandler(null)
this.channel = null
}
}


BasicMessageChannel 和 EventChannel 使用也是类似



P.S.: 与容器相关的方法是与容器生命周期绑定的,如果容器不可见或者销毁了则无法收到Channel消息。


6、生命周期


应用生命周期监听:



  • ①、在 Flutter 侧任意处注册监听皆可,并implements FusionAppLifecycleListener

  • ②、根据实际情况决定是否需要注销监听


void main() {
...
FusionAppLifecycleBinding.instance.register(MyAppLifecycleListener());
runApp(const MyApp());
}

class MyAppLifecycleListener implements FusionAppLifecycleListener {
@override
void onBackground() {
print('onBackground');
}

@override
void onForeground() {
print('onForeground');
}
}

FusionAppLifecycleListener 生命周期回调函数:



  • onForeground: 应用进入前台会被调用(首次启动不会被调用,Android 与 iOS 保持一致)

  • onBackground: 应用退到后台会被调用


页面生命周期监听:



  • ①、在需要监听生命周期页面的 State 中 implements FusionPageLifecycleListener

  • ②、在 didChangeDependencies 中注册监听

  • ③、在 dispose 中注销监听


class LifecyclePage extends StatefulWidget {
const LifecyclePage({Key? key}) : super(key: key);

@override
State<LifecyclePage> createState() => _LifecyclePageState();
}

class _LifecyclePageState extends State<LifecyclePage>
implements FusionPageLifecycleListener
{
@override
Widget build(BuildContext context) {
return Container();
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
FusionPageLifecycleBinding.instance.register(this);
}

@override
void onPageVisible() {}

@override
void onPageInvisible() {}

@override
void onForeground() {}

@override
void onBackground() {}

@override
void dispose() {
super.dispose();
FusionPageLifecycleBinding.instance.unregister(this);
}
}

PageLifecycleListener 生命周期回调函数:



  • onForeground: 应用进入前台会被调用,所有注册了生命周期监听的页面都会收到

  • onBackground: 应用退到后台会被调用,所有注册了生命周期监听的页面都会收到

  • onPageVisible: 该 Flutter 页面可见时被调用,如:从 Native 页面或其他 Flutter 页面 push 到该 Flutter 页面时;从 Native 页面或其他 Flutter 页面 pop 到该 Flutter 页面时;应用进入前台时也会被调用。

  • onPageInvisible: 该 Flutter 页面不可见时被调用,如:从该 Flutter 页面 push 到 Native 页面或其他 Flutter 页面时;如从该 Flutter 页面 pop 到 Native 页面或其他 Flutter 页面时;应用退到后台时也会被调用。


7、全局通信


支持消息在应用中的传递,可以指定 Native 还是 Flutter 或者全局接收和发送。


注册消息监听


Flutter侧



  • ①、在需要监听消息的类中 implements FusionNotificationListener,并复写 onReceive 方法,该方法可收到发送过来的消息

  • ②、在合适时机注册监听

  • ③、在合适时机注销监听


class TestPage extends StatefulWidget {

@override
State<TestPage> createState() => _TestPageState();
}

class _TestPageState extends State<TestPage> implements FusionNotificationListener {

@override
void onReceive(String name, Map<String, dynamic>? body) {

}

@override
void didChangeDependencies() {
super.didChangeDependencies();
FusionNotificationBinding.instance.register(this);
}

@override
void dispose() {
super.dispose();
FusionNotificationBinding.instance.unregister(this);
}
}

Native侧



  • ①、在需要监听消息的类中实现 FusionNotificationListener 接口,并复写 onReceive 方法,该方法可收到发送过来的消息

  • ②、在适当时机使用 FusionNotificationBindingregister 方法注册监听

  • ③、在适当时机使用 FusionNotificationBindingunregister 方法注销监听


发送消息


三端均可使用FusionNavigatorsendMessage 方法来发送消息,根据使用FusionNotificationType 不同类型有不同效果:



  • flutter: 仅 Flutter 可以收到

  • native: 仅 Native 可以收到

  • global(默认): Flutter 和 Native 都可以收到


8、返回拦截


在纯 Flutter 开发中可以使用WillPopScope组件拦截返回操作,Fusion 也完整支持该功能,使用方式与在纯 Flutter 开发完全一致,此外使用FusionNavigator.maybePop的操作也可被WillPopScope组件拦截。


9、状态恢复


Fusion 支持 Android 和 iOS 平台 APP 被回收后 Flutter 路由的恢复。


作者:gtbluesky
来源:juejin.cn/post/7329573765087019034
收起阅读 »

Android Activity 之间共享的 ViewModel

Android Activity 之间共享的 ViewModel 提供一个 Application 作用域的 ViewModel 去实现 要尽量避免被滥用 按需考虑加数据销毁、资源释放的逻辑 AppSharedViewModel class AppShar...
继续阅读 »

Android Activity 之间共享的 ViewModel



  • 提供一个 Application 作用域的 ViewModel 去实现

  • 要尽量避免被滥用

  • 按需考虑加数据销毁、资源释放的逻辑


AppSharedViewModel


class AppSharedViewModel: ViewModel() {
var testLiveData = MutableLiveData(0)
}

class AppApplication : Application(), ViewModelStoreOwner {
companion object {
private lateinit var sInstance: AppApplication
fun getInstance() = sInstance
}

override fun onCreate() {
super.onCreate()
sInstance = this
}

private val appSharedViewModelStore by lazy {
ViewModelStore()
}

override fun getViewModelStore(): ViewModelStore {
return appSharedViewModelStore
}
}

    override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//使用
val appSharedViewModel = ViewModelProvider(AppApplication.getInstance())[AppSharedViewModel::class.java]
}

让 AppSharedViewModel 继承自 AndroidViewModel


class AppSharedViewModel(application: Application) : AndroidViewModel(application) {
var testLiveData = MutableLiveData(0)
}


  • 方案1


改写 ViewModel 创建获取的地方传入 AndroidViewModelFactory 实例


val appSharedViewModel = ViewModelProvider(
AppApplication.getInstance(),
ViewModelProvider.AndroidViewModelFactory.getInstance(AppApplication.getInstance())
)[AppSharedViewModel::class.java]


  • 方案2


改写 Application 实现 HasDefaultViewModelProviderFactory 接口,因为 ViewModelProvider 构造方法里有调用 ViewModelProvider.AndroidViewModelFactory.defaultFactory 方法传入 ViewModelStoreOwner 去判断处理 HasDefaultViewModelProviderFactory 接口的逻辑


class AppApplication : Application(), ViewModelStoreOwner, HasDefaultViewModelProviderFactory {
companion object {
private lateinit var sInstance: AppApplication
fun getInstance() = sInstance
}

override fun onCreate() {
super.onCreate()
sInstance = this
}

private val appSharedViewModelStore by lazy {
ViewModelStore()
}

override fun getViewModelStore(): ViewModelStore {
return appSharedViewModelStore
}

override fun getDefaultViewModelProviderFactory(): ViewModelProvider.Factory {
return ViewModelProvider.AndroidViewModelFactory.getInstance(this)
}
}

作者:louisgeek
来源:juejin.cn/post/7380579037113237554
收起阅读 »

我穿越回2013年,拿到一台旧电脑,只为给Android2.3设备写一个时钟程序

昨天收拾屋子,翻出一台 lenovo A360e ,其搭载联了发科单核芯片(MT6567)的3G智能(Android 2.3.6)手机,上市于2012年,于2017年停产。其屏幕尺寸为3.5英寸,分辨率是480x320像素。具备重力感应、光线感应和距离传感器。...
继续阅读 »

昨天收拾屋子,翻出一台 lenovo A360e ,其搭载联了发科单核芯片(MT6567)的3G智能(Android 2.3.6)手机,上市于2012年,于2017年停产。其屏幕尺寸为3.5英寸,分辨率是480x320像素。具备重力感应、光线感应和距离传感器。


然而,现在是2024年。几乎没有什么应用可以在Android2.3上面跑了。


所以,打开 AndroidStudio,新建一个项目。
2024-10-30 17 48 51.png


完犊子了,最低只支持Android5.0!


好吧,我立刻坐进时光机,穿越到2013年,拿到当年我的一台旧电脑。上面有Android2.2的开发环境。


新建一个 Android 2.2 的项目。


image.png


接下来就是 xml 布局。对于习惯 jetpack components的人来讲,xml布局简直就是一坨屎。但是没办法,为了能在 Android 2.3 上面跑,只好硬着头搞了。


首先画一个简单的布局图:


image.png


看起来有点复杂,其实一点也不简单。


但是,可以先做上下结构:


image.png


<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="#000"
tools:context="org.deviceartist.clock.FullscreenActivity" >

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >

</LinearLayout>
</FrameLayout>

然后在下面的结构中,再分出一个左右结构:


image.png


<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="#000"
tools:context="org.deviceartist.clock.FullscreenActivity" >

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal" >

</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="250sp"
android:gravity="center_vertical" >

</LinearLayout>
</LinearLayout>
</FrameLayout>

然后按照布局图写 xml 的 layout 文件:


<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="#000"
tools:context="org.deviceartist.clock.FullscreenActivity" >

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >


<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal" >


<TextView
android:id="@+id/tab1"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="left"
android:padding="0dp"
android:text="STAT"
android:textColor="@color/green"
android:textSize="22sp" />


<TextView
android:id="@+id/tab2"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="center"
android:padding="0dp"
android:text="INV"
android:textColor="@color/green"
android:textSize="22sp" />


<TextView
android:id="@+id/tab3"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="2"
android:gravity="center"
android:padding="0dp"
android:text="DATA"
android:textColor="@color/green"
android:textSize="22sp" />


<TextView
android:id="@+id/tab4"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="center"
android:padding="0dp"
android:text="MAP"
android:textColor="@color/green"
android:textSize="22sp" />


<TextView
android:id="@+id/tab5"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="right"
android:padding="0dp"
android:text="TERMUX"
android:textColor="@color/green"
android:textSize="22sp" />


</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="250sp"
android:gravity="center_vertical" >


<TextView
android:id="@+id/textViewTime"
android:layout_width="210sp"
android:layout_height="200sp"
android:textSize="100sp" />

<TextView
android:id="@+id/textViewTimeS"
android:gravity="center"
android:layout_width="50sp"
android:layout_height="150sp"
android:textSize="20sp" />


<org.deviceartist.clock.MyCanvas
android:id="@+id/myCanvas"
android:layout_width="200sp"
android:layout_height="200sp" />

</LinearLayout>
</LinearLayout>
</FrameLayout>

相应的 java 代码


package org.deviceartist.clock;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Timer;
import org.deviceartist.clock.util.SystemUiHider;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;

public class FullscreenActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
if (Build.VERSION.SDK_INT < 16) {
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
setContentView(R.layout.activity_fullscreen);
Typeface typeface = Typeface.createFromAsset(this.getAssets(), "fonts/font.ttf");

final TextView textViewTime = (TextView) findViewById(R.id.textViewTime);
final TextView textViewTimeS = (TextView) findViewById(R.id.textViewTimeS);
final MyCanvas c = (MyCanvas) findViewById(R.id.myCanvas);
textViewTime.setTextColor(0xff5CB31D);
textViewTime.setTypeface(typeface);
textViewTimeS.setTextColor(0xff5CB31D);
textViewTimeS.setTypeface(typeface);
final Handler handler = new Handler();
Runnable runnable = new Runnable(){
@Override
public void run() {
String currentTime = new SimpleDateFormat("HH\nmm",Locale.getDefault()).format(new Date());
textViewTime.setText(currentTime);
String currentTimeS = new SimpleDateFormat("ss",Locale.getDefault()).format(new Date());
textViewTimeS.setText(currentTimeS);
handler.postDelayed(this, 1000);
}
};
handler.postDelayed(runnable, 0);
final Handler handler2 = new Handler();
Runnable runnable2 = new Runnable(){
@Override
public void run() {
c.next();
handler.postDelayed(this, 100);
}
};
handler2.postDelayed(runnable2, 100);
}
}

知识点:


1、定时器


final Handler handler = new Handler();
Runnable runnable = new Runnable(){
@Override
public void run() {
//todo
}
};
handler.postDelayed(runnable, 0);

2、Canvas画布就是自定义的View类


关键代码:


package org.deviceartist.clock;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.View;

public class MyCanvas extends View {

private Paint paint;
Canvas canvas;

public MyCanvas(Context context) {
super(context);
init();
}

public MyCanvas(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public MyCanvas(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
paint = new Paint();
paint.setColor(0xff5CB31D);
paint.setStyle(Paint.Style.FILL);
}

protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
}

全部代码:


package org.deviceartist.clock;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.View;

public class MyCanvas extends View {

private int index = 0;
ArrayList<Bitmap> bitmaps = new ArrayList<>();

Bitmap voltage;
Bitmap nuclear;
Bitmap shield;
Bitmap aim;
Bitmap gun;
Bitmap helmet;

private Paint paint;
Canvas canvas;

public MyCanvas(Context context) {
super(context);
init();
// TODO Auto-generated constructor stub
}

public MyCanvas(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public MyCanvas(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {


voltage = BitmapFactory.decodeResource(getResources(),
R.drawable.voltage);
nuclear = BitmapFactory.decodeResource(getResources(),
R.drawable.nuclear);
shield = BitmapFactory.decodeResource(getResources(),
R.drawable.shield);
aim = BitmapFactory.decodeResource(getResources(),
R.drawable.aim);
gun = BitmapFactory.decodeResource(getResources(),
R.drawable.gun);
helmet = BitmapFactory.decodeResource(getResources(),
R.drawable.helmet);
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy1));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy2));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy3));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy4));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy5));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy6));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy7));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy8));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy9));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy10));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy11));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy12));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy13));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy14));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy15));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy16));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy17));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy18));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy19));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy10));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy11));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy12));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy13));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy14));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy15));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy16));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy17));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy18));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy19));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy20));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy21));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy22));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy23));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy24));

paint = new Paint();
paint.setColor(0xff5CB31D); // 设置圆形的颜色
paint.setStyle(Paint.Style.FILL); // 设置填充样式
}

void next() {
index += 1;
index += 1;
if (index == 24) {
index = 0;
}
invalidate();
}

protected void onDraw(Canvas canvas) {
this.canvas = canvas;

super.onDraw(canvas);

Bitmap bitmap = bitmaps.get(index);
int w = bitmap.getWidth();
int h = bitmap.getHeight();

// 获取View的中心点坐标
int x = getWidth() / 2 - w/2;
int y = getHeight() / 2 - h/2;

canvas.drawBitmap(bitmap, x, y, paint);


canvas.drawLine(10, 20, 55, 20, paint);
canvas.drawLine(55, 20, 90, 70, paint);
canvas.drawBitmap(shield, 10, 30, paint);


canvas.drawLine(50, getHeight()/2, 100, getHeight()/2, paint);
canvas.drawText("98%", 50, getHeight()/2-10, paint);
canvas.drawBitmap(voltage, 10, getHeight()/2-30, paint);


canvas.drawLine(10, getHeight()-30, 90, getHeight()-30, paint);
canvas.drawLine(90, getHeight()-30, 100, getHeight()-80, paint);
canvas.drawBitmap(gun, 10, getHeight()-90, paint);

canvas.drawLine(getWidth()-30, 20, getWidth(), 20, paint);
canvas.drawLine(getWidth()-30, 20, getWidth()-90, 70, paint);
canvas.drawBitmap(aim, getWidth()-40, 30, paint);


canvas.drawLine(getWidth()-110, getHeight()/2, getWidth()-50, getHeight()/2, paint);
canvas.drawText("9.9", getWidth()-80, getHeight()/2-10, paint);
canvas.drawBitmap(nuclear, getWidth()-50, getHeight()/2-30, paint);

canvas.drawLine(getWidth()-100, getHeight()-80, getWidth()-70, getHeight()-30, paint);
canvas.drawLine(getWidth()-70, getHeight()-30, getWidth(), getHeight()-30, paint);
canvas.drawBitmap(helmet, getWidth()-70, getHeight()-90, paint);

}
}

最终效果


2024-10-30 17 44 48.gif


源码地址:
git.sr.ht/~devicearti…


作者:DeviceArtist
来源:juejin.cn/post/7431455141084528650
收起阅读 »

什么?Flutter 又要凉了? Flock 是什么东西?

今天突然看到这个消息,突然又有一种熟悉的味道,看来这个月 Flutter “又要凉一次了”: 起因 flutter foundation 决定 fork Flutter 并推出 Flock 分支用于自建维护,理由是: foundation 推测 Flutt...
继续阅读 »

今天突然看到这个消息,突然又有一种熟悉的味道,看来这个月 Flutter “又要凉一次了”



起因 flutter foundation 决定 fork Flutter 并推出 Flock 分支用于自建维护,理由是:



foundation 推测 Flutter 团队的劳动力短缺,因为 Flutter 需要维护 Android、iOS、Mac、Window、Linux、Web 等平台,但是 Flutter团队的规模仅略有增加。



在 foundation 看来,保守估计全球至少有 100 万 Flutter 相关开发者,而 Flutter 团队的规模大概就只有 50+ 人,这个比例并不健康。



问题在于这个数据推测就很迷,没有数据来源的推测貌似全靠“我认为”。。。。



另外 foundation 做这个决定,还因为 Flutter 官方团队对其 6 个支持的平台中,有 3 个处于维护模式(Window、Mac、Linux),所以他们无法接受桌面端的现场,因为他们认为桌面端很可能是 Flutter 最大的未开发价值。



关于这点目前 PC 端支持确实缓慢,但也并没有完全停止,如果关注 PC issue 的应该看到, Mac 的 PlatformView 和 WebView 支持近期才初步落地。



而让 foundation 最无法忍受的是,issue 的处理还有 pr 的 merge 有时候甚至可能会积累数年之久。



事实上这点确实成立,因为 Flutter 在很多功能上都十分保守,同时 issue 量大且各平台需求多等原因,很多能力的支持时间跨度多比较长,例如 「Row/Column 即将支持 Flex.spacing」「宏编程支持」「支持 P3 色域」 等这些都是持续了很久才被 merge 的 feature 。



所以 Flutter 的另外一个支持途径是来自社区 PR,但是 foundation 表示 Flutter 的代码 Review 和审计工作缓慢,并且沟通困难,想法很难被认可等,让 foundation 无法和 Flutter 官方有效沟通。


总结起来,在 foundation 的角度是,Flutter 官方团队维护 Flutter 不够尽心尽力



所以他们决定,创建 Flutter 分支,并称为 Flock:意寓位 “Flutter+”。



不过 foundation 表示,他们其实并不想也不打算分叉 Flutter 社区,Flock 将始终与 Flutter 保持同步


Flock 的重点是添加重要的错误修复和全新的社区功能支持,例如 Flutter 团队不做的,或者短期不会实现:



并且 Flock 的目的是招募一个比 Flutter 团队大得多的 PR 审查团队,从而加快 PR 的审计和推进。


所以看起来貌似这是一件好事,那么为什么解读会是“崩盘”和“内斗”?大概还是 Flutter 到时间凉了,毕竟刚刚过完 Flutter 是十周年生日 ,凉一凉也挺好的。



更多可见:flutterfoundation.dev/blog/posts/…


作者:恋猫de小郭
来源:juejin.cn/post/7431032490284236839
收起阅读 »

自研一套带双向认证的Android通用网络库

当前,许多网络库基于Retrofit或OkHttp开发,但实际项目中常需要定制化,并且需要添加类似双向认证等安全功能。这意味着每个项目都可能需要二次开发。那么,有没有一种通用的封装方式,可以满足大多数项目需求?本文将介绍一种通用化的封装方法,帮助你用最少的代码...
继续阅读 »

当前,许多网络库基于Retrofit或OkHttp开发,但实际项目中常需要定制化,并且需要添加类似双向认证等安全功能。这意味着每个项目都可能需要二次开发。那么,有没有一种通用的封装方式,可以满足大多数项目需求?本文将介绍一种通用化的封装方法,帮助你用最少的代码量开发出自己的网络库

源码及涉及思路参考:如何开发一款安全高效的Android网络库(详细教程)

框架简介

FlexNet 网络库是基于 Square 公司开源的 Retrofit 网络框架进行封装的。Retrofit 底层采用 OkHttp 实现,但相比于 OkHttp,Retrofit 更加便捷易用,尤其适合用于 RESTful API 格式的请求。

在网络库内部,我们实现了双向认证功能。在初始化时,您可以选择是否开启双向认证,框架会自动切换相应的 URL,而业务方无需关注与服务端认证的具体细节。

接入方式

1. 本地aar依赖

下载aar到本地(下载地址见文末),copy到app的libs目录下,如图:

image.png

implementation(files("libs/flex-net.aar"))

然后sync只会即可

2. 通过Maven远程依赖

FlexNet目前已上传Maven,可通过Maven的方式引入,在app的build.gradle中加入以下依赖:

implementation("com.max.android:flex-net:3.0.0")

sync之后即可拉到Flex-Net

快速上手

网络库中默认打开了双向认证,并根据双向认证开关配置了相应的 baseUrl,大多数场景下只需要控制双向认证开关,其余配置走默认即可。

  1. 初始化

在发起网络请求之前(建议在ApplicationonCreate()中),调用:

fun initialize(
app: Application,
logEnable: Boolean = BuildConfig.LOG_DEBUG,
sslParams: SSLParams? = null,
)

  • application: Application类型,传入当前App的Application实例;
  • logEnable: Boolean类型,网络日志开关,会发打印Http的Request和Resonpse信息,可能涉及敏感数据,release包慎用;(仅限网络请求日志,和双向认证的日志不同)
  • sslParams: 双向认证相关参数,可选,为空则关闭双向认证。具体描述见下文。

当App需要双向认证功能时,需要在initialize()方法中传递sslParams参数,所有双向认证相关的参数都放在sslParams当中,传此参数默认打开双向认证。

SSLParams的定义如下:

data class SSLParams(
/** App 是否在白名单之中。默认不在 */
val inWhiteList: Boolean = false,
/** 双向认证日志开关,可能涉及隐私,release版本慎开。默认关 */
val logSwitch: Boolean = true,
/** 是否开启双向认证。默认开 */
val enable: Boolean = true,
/** 双向认证回调。默认null */
val callback: MutualAuthCallback = null,
)
  • inWhiteList: App是否在白名单中,默认不在
  • logSwitch: 双向认证日志开关,可能涉及隐私,release版本慎开。默认关,注意这里仅针对双向认证日志,与initialize()方法中的logEnable不同
  • callback  监听初始化结果回调,true表示成功,反之失败。可选参数,默认为null,仅enableMutualAuth为true时有效

在调用了initialize之后就完成了初始化工作,内部包含了双向认证、网络状态、本地网络缓存等等功能,所有的网络请求都需要在初始化之后发起。

初始化示例代码:

FlexNetManger.initialize(this,
logEnable = true,
SSLParams {
Timber.i("Mutual auth result : $it")
})

PS * *部分App在启动的时候获取不到证书,所以这里会失败。如果失败了后续可以在合适的时机通过MutualAuthenticate.isSSLReady()来检查是否认证成功,然后通过MutualAuthenticate.suspendBuildSSL()来主动触发双向认证,成功之后方可开始网络请求。具体可参见文档“配置项”的内容。

双向认证失败及其相关问题,可参考双向认证文档  [双向认证])

  1. 定义数据 Model

在请求之前需要根据接口协议的字段定义对应的数据Model,用来做Request或者Response的body。

比如我们需要通过UserId获取对应用户的UserName

  1. 定义 Request 数据 Model

后端请求接口参数如下:

{
"userId" : "123456"
}

那么根据参数定义一个UserNameReq类:

data class UserNameReq(
/** 用户id */
var userId: String
)

  1. 定义 Response 数据 Model

后端返回数据如下:

{
"userName" : "MC"
}

对应定义一个UserNameRsp:

data class UserNameRsp(
/** 用户id */
var userId: String
)
  1. 编写 Http 接口

接口类必须继承自IServerAPI:

interface UserApi: IServerApi

然后在IServerApi的实现类中,每个接口需要用注解的形式标注 Http 方法,通过参数传入 http 请求的 url:

interface UserApi: IServerApi {

/** 获取用户ID */
@POST("api/cloudxcar/atmos/v1/getName")
suspend fun getUserName(@Body request: UserNameReq): ResponseEntity
}

这里需要注意的是,我们的UserNameRsp需要用ResponseEntity封装一层,看一下ResponseEntity的内容:

sealed class ResponseEntity<T>(val body: T?, val code: Int, val msg: String)

有3个参数:

  • body: 消息体,即UserNameReq。仅成功时有效
  • code  返回码,这里要分多种情况描述。

    • Http错误:此时code为Http错误码
    • 其他异常:code对应错误原因,后面会附上映射表
    • 请求成功:区分网络数据和缓存数据
  • msg  错误信息

可调用ResponseEntity.isSuccessful()来判断是否请求成功,然后通过ResponseEntity.body获取数据,返回的是一个根据服务端返回的 Json 解析而来的UserNameRsp实体类。

如果请求失败,则从ResponseEntity.msgResponseEntity.code中获取失败ma失败码和失败提示

  1. 创建网络请求Repo

继承自BaseRepo,泛型参数为步骤3中创建的IserverApi实现类:

class VersionRepo : BaseRepo<VersionAPI>
  1. 其中需要有1个必覆写的变量:

    1. baseUrl: 网络接口的baseUrl
  2. 两个可选项:

    1. mutualAuthSwitch: 双向认证开关,此开关仅针对当前 baseUrl 生效。默认开
    2. interceptorList: 需要设置的拦截器列表
  3. 一个必覆写的方法:

    1. createRepository(): 创建当前网络仓库

完整的Repo类内容如下:

class UserRepo: BaseRepo<UserApi>() {
// 必填
override val baseUrl = "https://juejin.cn/editor/drafts/7379502040140218422"
// 必填
override fun createRepository(): VersionAPI =
MutualAuthenticate.getServerApi(baseUrl, mutualAuthSwitch, interceptorList)
// 可选:双向认证开关,仅针对当前repo生效
override val mutualAuthSwitch = true
// 可选:Http拦截器
override val interceptorList: List? = listOf(HeaderInterceptor())

// 请求接口
suspend fun getUserName(): ResponseEntity{
return mRepo.upgradeVersion(UserNameReq("123456"))
}
}

注: 其中拦截器的设置interceptorList,如果声明的时候提示错误,可以尝试加上完整的类型声明:

interceptorList: List?

5 发起网络请求

最后就可以在业务代码中通过Repo类完成网络请求的调用了:

lifecycleScope.launch {
val entity= UserRepo().getUserName()
Timber.i("Get responseEntity: $entity")

if (entity.isSuccessful()) {
val result = entity.body
Timber.i("Get user name result: $result")
} else {
val code = entity.code
val msg = entity.msg
Timber.i("Get user name failed: code->$code; msg->$msg")
}
}

到这里,就可以发起一次基础的网络请求接口了。

依赖项

  1. 双向认证

目前引入的双向认证版本为1.6.0,如果需要切换版本,或者编译出现依赖冲突,可以尝试使用exclude的方式自行依赖。当然也请自行确保功能正常。

  1. 日志库

implementation("com.jakewharton.timber:timber:4.7.0")

组件库中的日志库。FlexNet推荐宿主使用Timber进行日志输出,但是需要宿主App在初始化FlexNet之前对Timber做plant操作。

  1. 网络请求内核

// Net
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")

底层网络请求目前依赖OkHttp完成。

  1. 本地持久化

implementation("com.tencent:mmkv:1.2.14")

网络库中的本地存储,主要用于保存网络缓存,目前采用MMKV-1.2.14版本,同样如果有冲突,或者需要另换版本,可通过exclude实现。

  1. Gson

api(core.network.retrofit.gson) {
exclude(module = "okio")
exclude(module = "okhttp")
}

依赖Gson,用于做数据结构和Json的相互转化

错误码对照表

CODE_SUCCESS10000请求成功,数据来源网络
CODE_SUCCESS_CACHE10001返回成功,数据来源于本地缓存
CODE_SUCCESS_BODY_NULL10002请求成功,但消息体为空
CODE_ERROR_UNKNOWN-200未知错误
CODE_ERROR_UNKNOWN_HOST-201host解析失败,无网络也属于其中
CODE_ERROR_NO_NETWORK-202无网络

日志管理

从FlexNet 2.0.5开始,对接入方使用的日志库不再限制(2.0.5以下必须用Timber,否则无日志输出)。可以通过以下接口来设置日志监视器:

setLogMonitor(log: ILog)

设置之后所有的网络日志都会回调给ILog,即可由接入方自行决定如何处理日志数据。

如果没有设置LogMonitor,则会使用Timber或者Android原生Log来进行日志输出。当宿主App的Timber挂载优先于FlexNet的初始化,则会采用Timber做日志输出,反之使用Android Log。

文件下载

网络库内置了下载功能,可配置下载链接和下载目录。注意外部存储地址需要自行申请系统权限。

1 构建下载器

使用Downloader.builder()来构建你的下载器,Builder需要传入以下参数:

  • url:待下载文件的url
  • filePath:下载文件路径
  • listener:下载状态回调。可选参数,空则无回调

示例代码如下:

Downloader.Builder("https://juejin.cn/editor/drafts/7379502040140218422.zip",
File(requireContext().filesDir, "MC").absolutePath)

2 回调监听

builder()最后一个参数,可传入下载监听器接口DownloadListener,内部有3个方法需要实现:

  • onFinish(file: File): 下载完成,返回下载完成的文件对象
  • onProgress( progress : Int, downloadedLengthKb: Long, totalLengthKb: Long): 下载进度回调,回传进度百分比、已下载的大小、总大小
  • onFailed(errMsg: String?): 下载失败,回调失败信息

示例代码如下:

val downloader = Downloader.Builder("https://juejin.cn/editor/drafts/7379502040140218422.zip",
File(Environment.getExternalStorageDirectory(), "MC").absolutePath,
object : DownloadListener {
override fun onFinish(file: File) {
Timber.e("下载的文件地址为:${file.absolutePath}".trimIndent())
}

override fun onProgress(
progress: Int,
downloadedLengthKb: Long,
totalLengthKb: Long,
)
{
runOnUiThread {
textView.text =
"文件文件下载进度:${progress}% \n\n已下载:%${downloadedLengthKb}KB | 总长:${totalLengthKb}KB"
}
}

override fun onFailed(errMsg: String?) {
Timber.e("Download Failed: $errMsg")
}
}).build()

PS  这里要注意,FlexNet会在业务方调用下载的线程返回下载回调,所以绝大部分时候回调是发生在子线程,此时如果有线程敏感的功能(比如刷新UI),需要自行处理线程切换。

3 触发下载

通过Builder.build()创建 Downloader 下载器,最后调用Downloader.download()方法即可开始下载。

和Http Request一样,download()是一个suspend方法,需要在协程中使用:

lifecycleScope.launch(Dispatchers.IO) {
downloader.download()
}

整体架构

设置配置项

1. 设置双向认证开关

在初始化的时候控制双向认证开关:

fun init(context: Application, needMutualAuth: Boolean = true)

方法内部会根据开关值来切换不同的后端服务器,但是有些App不能过早的获取证书,这样会有双向认证失败的风险,FlexNet同时支持懒汉式的主动双向认证

2. 主动双向认证接口

在确定拿到证书,或者确定可以双向认证的时机,可随时发起双向认证请求:

MutualAuthenticate.suspendBuildSSL()

可通过

MutualAuthenticate.isSSLReady()

接口来检查当前双向认证是否成功。

主动触发示例代码如下:

MutualAuthenticate.suspendBuildSSL {
if (it) {
Toast.makeText(context, "双向认证成功,可以开始访问加密资源", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "双向认证失败", Toast.LENGTH_SHORT).show()
}
}

3. 数据缓存

在前面发起请求调用httpRequest顶层函数的时候,可以传入一个可选参数cacheKey,这个key不为空则网络库会在本地保存当前请求的返回数据。Key作为缓存的唯一标识,在无网络或请求失败的时候,会通知调用方错误,并返回缓存的数据。

缓存部分流程如下:

4. 错误及异常处理

在发起请求的顶层函数 httpRequest 中,有两个参数用来提供给调用方处理错误和异常。

首先区分一下错误和异常:

错误通常是发起了网络请求,且网络请求有响应,只是由于接口地址或者参数等等原因导致服务端解析失败,最终返回错误码及错误信息。

而异常是指在发起网络请求的过程中出现了 Exception,导致整个网络请求流程被中断,所以当异常发生的时候,网络库是不会返回错误码和错误信息的,只能返回异常信息供调用方定位问题。

回调的使用方式很简单,只需要在httpRequest中传入两个回调:failerror,下面分别看看二者的处理方式:

1. 错误处理

fai的定义如下:

fail: (response: ResponseEntity) -> Unit = {
onFail(it)
}

传入的回调有一个 ResponseEntity 参数,这是网络请求返回的响应实体,内部包含errorCodeerrorMessage,不传则默认打印这两个字段,可以在 Logcat 中通过Tag:Http Request **过滤出来。

2. 异常处理

error的定义如下:

error: (e: Exception) -> Unit = {
onError(it)
} ,

回调函数只有一个 Exeption 对象,和前面的定义相符,在异常的时候将异常返回供调用方定位问题。不传网络库默认打印异常,可以在 Logcat 中通过Tag:Http Request **过滤出来。

扩展接口:发起请求并处理返回结果

网络库定义了一个顶层函数用来发起请求并接收返回结果或处理异常:

fun httpRequest(block, fail, error, cacheKey): T?

  • block: 实际请求体,必填。可以传入步骤 4 中实现的接口
  • fail: 请求错误回调,非必填。用来处理服务端返回的请求错误,会携带错误码及错误信息
  • error: 请求异常回调,非必填。用来处理请求中发生的异常,此时没有response返回
  • cacheKey: 数据缓存唯一标识,非必填

httpRequest 中的泛型 T 就是接入步骤2定义的 Response 实体,正常返回会在方法内部自动解析出 UserNameRsp,到此就完成了一次网络请求。

以上是基本的使用方式,涵盖了安全、数据请求、缓存、异常处理等功能,可以适应于多种项目场景。应大家的建议,后续会完善几篇文章拆解具体的原理及开发思路,从源码的角度教你如何从0开发一套完善的网络库

大家如果想了解设计思路及框架原理,可以参考:源码及涉及思路参考:如何开发一款安全高效的Android网络库(详细教程)

需要体验的同学可以在评论区留下联系方式,我给你发送aar以及源码。有问题欢迎随时探讨


作者:超低空
来源:juejin.cn/post/7379521155286941708
收起阅读 »

Android串口开发入门

最近的开发项目有涉及到Android串口开发,所以比较好奇安卓项目中是如何读取串口数据的,官方给了一个代码示例,但是发现官方的示例比较久了,没有使用CMake,下载后运行老是报错,踩坑,最后使用CMake的方式终于运行成功,在此记录一下,源码:gitee.co...
继续阅读 »

最近的开发项目有涉及到Android串口开发,所以比较好奇安卓项目中是如何读取串口数据的,官方给了一个代码示例,但是发现官方的示例比较久了,没有使用CMake,下载后运行老是报错,踩坑,最后使用CMake的方式终于运行成功,在此记录一下,源码:gitee.com/hluck/hello…


目录结构


QQ截图20240617143025.png


1.创建一个HelloWord项目


QQ截图20240617143924.png


2.引入jni和so库


将jni文件夹和jniLibs文件夹复制到main目录下:


QQ截图20240617144227.png


3.修改gradle


由于此时Android studio编译时,不会去编译加载CMakeLists.txt,所以要告诉他在哪加载:


android {
...
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt" // 指定 CMakeLists.txt 文件路径
// 其他 CMake 选项
}
}
}

4.加载动态库,编译native方法


官方示例中有两个类是关于打开和关闭串口api的:


QQ截图20240617145352.png


1.SeriaPort

其中加载动态库,打开和关闭串口的native方法在SerialPort类中:


QQ截图20240617145618.png


这两个native方法对应的是jni文件下的SerialPort.c文件中,如果你的SerialPort类所在包名和我的不一样,记得修改一下这个文件,值得一提的是,open方法中的第一个参数是串口地址,第二个参数是波特率,第三个参数是打开串口时的操作模式,0表示默认,当调用读写操作时,如果串口没有准备好数据,程序会阻塞等待,直到有数据可以读取或写入。


QQ截图20240617145945.png


2.FileDescriptor

上面的open方法会返回一个FileDescriptor实例,通过这个实例获取写入和读取串口数据的流。


QQ截图20240617152233.png


5.读取或写入串口数据


在Application类中保存一个SerialPort实例,这样就能通过获取SerialPort实例来读写串口数据了。


QQ截图20240617154236.png


QQ截图20240617154322.png


参考文章


安卓与串口通信-基础篇


安卓与串口通信-实践篇


Android移植谷歌官方串口库


作者:等你等了那么久
来源:juejin.cn/post/7381347654743326746
收起阅读 »

Android ConstraintLayout使用进阶

前言 曾经Android有五大布局,LinearLayout、FrameLayout、TableLayout、RelativeLayout和AbsoluteLayout,那会我们比较常用的布局就两三个,写xml的时候根据界面灵活选择布局,但是往往会面临布局嵌套...
继续阅读 »

前言


曾经Android有五大布局,LinearLayout、FrameLayout、TableLayout、RelativeLayout和AbsoluteLayout,那会我们比较常用的布局就两三个,写xml的时候根据界面灵活选择布局,但是往往会面临布局嵌套过深的问题,阅读也不方便。随着Android生态的发展,Google后来推出了新的布局——ConstraintLayout(约束布局)。


我很快去学习并将其用在项目中,刚开始的时候觉得比较抽象难懂,各种不适应;一段时间过后,这玩意儿真香!


本文不讲ConstraintLayout基本使用(网上资料很多),而是关于使用ConstraintLayout的进阶。


导入依赖:(2.x版本)


implementation 'androidx.constraintlayout:constraintlayout:2.0.2'

进阶1


在开发中可能需要实现如下效果:
在这里插入图片描述
长文本
文本外层有背景,短文本的时候宽度自适应,长文本超过屏幕的时候,背景贴右边,文字显示...,这样的UI需求很常见,我们来一步步拆解。


1、文本背景需要占满屏幕,并且文本显示...


<TextView
android:layout_width="0dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:background="@drawable/xxx"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>


2、这时候TextView会水平居中,我们需要添加


app:layout_constraintHorizontal_bias="0"

layout_constraintHorizontal_bias表示水平偏移,即“当组件左侧和右侧 ( 或者 开始 和 结束 ) 两边被约束后, 两个联系之间的比例”,取值为0-1,具体看ConstraintLayout 偏移 ( Bias ) 计算方式详解,我们只需要将水平偏移量设置为0,控件就会被约束在左侧了。


3、最后一步,短文本的时候宽度自适应,长文本的时候占满屏幕,需要添加


app:layout_constraintWidth_max="wrap"

layout_constraintWidth_max表示指定视图的最大宽度,取值为“wrap”,它和“wrap_content”不同,虽然都是适应内容,但仍然允许视图比约束要求的视图更小。
最终代码:


<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/xxx"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintWidth_max="wrap"
tools:text="这是一个测试文案"
/>


进阶2


再来看个效果图:
在这里插入图片描述
在这里插入图片描述
还是文本适配的问题,短昵称的时候自适应,长昵称的时候,性别图标跟随文本长度移动,但是图标必须在“聊天”按钮左侧,文本显示...


我们再来一步步拆解(仅针对昵称Textview):


一、重复上面的步骤1和步骤2,代码如下(注意layout_width="wrap_content",上面的是0dp)


<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我是昵称"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/iv_head"
app:layout_constraintEnd_toStartOf="@id/iv_gender"
/>


二、这时候我们会发现布局是居中的,而且昵称TextView都需要收尾元素相连,我们可以使用layout_constraintHorizontal_chainStyle改变整条链的约束状态,它有三个值,分别是spread、spread_inside和packed,其中packed表示将所有 Views 打包到一起不分配多余的间隙(当然不包括通过 margin 设置多个 Views 之间的间隙),然后将整个组件组在可用的剩余位置居中(可以查看Chains链布局),同时由于layout_constraintHorizontal_bias="0"的作用,布局将会向左侧偏移。


app:layout_constraintHorizontal_chainStyle="packed"

三、最后,当我们输入文本时,发现文本并没有约束到“聊天”按钮左侧,因为layout_width="wrap_content",添加的约束是不起作用的,所以需要强制约束


 app:layout_constrainedWidth="true"

代码动态改变约束


初始约束:
在这里插入图片描述
修改后的约束:
在这里插入图片描述
如上图,初始状态,中间按钮约束在按钮1右侧,某个条件下需要将中间按钮约束在按钮2左侧,这种时候,我们就需要在代码动态设置约束了。
具体代码:


constraintLayout?.let {
//初始化一个ConstraintSet
val set = ConstraintSet()
//将原布局复制一份
set.clone(it)
//分别将“中间按钮”START方向和BOTTOM方向的约束清除
set.clear(“中间按钮”, ConstraintSet.START)
set.clear(“中间按钮”, ConstraintSet.BOTTOM)
//重新建立新的约束
//“中间按钮”的END约束“按钮2”控件的START
//相当于 app:layout_constraintEnd_toStartOf="@id/按钮2"
set.connect(
“中间按钮”,
ConstraintSet.END,
“按钮2”,
ConstraintSet.START,
resources.getDimensionPixelSize(R.dimen.dp_9)
)
//以及底部方向的约束
...
//最后将更新的约束应用到布局
set.applyTo(it)
}

MotionLayout


接下来是今天重头戏——MotionLayout。


MotionLayout继承自ConstraintLayout,能够通过约束关系构建丰富的view动画,动画状态分为start与end两个状态,它还能作为支持库,兼容到api 14。


来看下效果图,这是我司App某个页面的动画效果,就是用MotionLayout实现。


在这里插入图片描述


我们可以写个简单的demo实现上面一部分动画效果,如下图


在这里插入图片描述


首先我们需要在资源文件夹 res 下新建一个名为 xml 的资源文件夹,然后在 文件夹内新建一个根节点是 MotionScene 的 xml 文件,文件名为 test_motion_scene.xml,如下:


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">


</MotionScene>

activity的xml根布局改为MotionLayout,使用app:layoutDescription与之关联


在这里插入图片描述
再编写视图,定义视图具体的view和对应id


<androidx.constraintlayout.motion.widget.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:layoutDescription="@xml/test_motion_scene"
...
>


<ImageView
android:id="@+id/iv_head"
...
/>

<TextView
android:id="@+id/tv1"
...
/>


然后切换到test_motion_scene.xml,我们需要明确动画布局的两个状态,start和end。
在MotionScene标签下定义Transition标签,指定动画的start和end状态


<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="500">

</Transition>

之后,在Transition同级下再定义ConstrainSet标签,它表示用于指定所有视图在动画序列中某一点上的位置和属性,你可以把它理解成一个集合,集合了所有参与动画的view相关位置和属性,如下:


<?xml version="1.0" encoding="utf-8"?>
<MotionScene
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition>
...
</Transition>

<ConstraintSet android:id="@+id/start">
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
</ConstraintSet>
</MotionScene>

大体的框架搭建好了,最后就是填充约束view状态的代码了。这时候我们需要明确动画的start状态和end状态,即


(start状态)↓
在这里插入图片描述


(end状态)↓
在这里插入图片描述


前面提到,ConstraintSet是存放一些view 约束和属性的的集合,而具体描述View约束和属性是通过Constraint 标签。我们声明Constraint标签,它支持一组标准 ConstraintLayout 属性,用于添加每个view start状态的约束。


<ConstraintSet android:id="@+id/start">
<Constraint
<!-- "android:id"表示activity的xml对应的view id
android:id="@id/iv_head"
android:layout_width="90dp"
android:layout_height="90dp"
motion:layout_constraintTop_toTopOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintEnd_toEndOf="parent"/>

<Constraint
android:id="@id/iv1"
.../>

<Constraint
android:id="@id/iv2"
.../>

...
</ConstraintSet>

接下来以同样的方式添加end状态的view约束


<ConstraintSet android:id="@+id/end">
...
</ConstraintSet>

最后,我们需要让它动起来,在Transition标签写添加一个OnClick标签,run,就能让动画动起来


<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="1000">

<!-- 点击-->
<OnClick
motion:clickAction="toggle"
motion:targetId="@id/search_go_btn"/>

</Transition>

OnClick:表示由用户点击触发


属性:


motion:targetId="@id/target_view" (目标View的id)
如果不指定次属性,就是点击整个屏幕触发如果写了这个属性,就是点击对应id的View 触发转场动画


motion:clickAction=“action” 点击后要进行的行为 ,此属性可以设置以下几个值:


transitionToStart
过渡到 元素 motion::constraintSetStart 属性指定的状态,有过度动画效果。


transitionToEnd
过渡到 元素motion:constraintSetEnd 属性指定的状态,有过度动画效果。


jumpToStart
直接跳转到 元素 motion::constraintSetStart 属性指定的状态,没有动画效果。


jumpToEnd
直接跳转到 元素 motion:constraintSetEnd 属性指定的状态。


toggle
默认值就是这个,在 元素motion:constraintSetStart和 motion:constraintSetEnd 指定的布局之间切换,如果处于start状态就过度到end状态,如果处于end状态就过度到start状态,有过度动画。


除了OnClick之外,还有OnSwipe,它是根据用户滑动行为调整动画的进度,具体可查看文末资料。


改变动画运动过程(关键帧KeyFrameSet)


上面讲解了动画的start与end状态,但是如果我们想在动画运动过程去改变一些属性,比如设置view的透明度、旋转,又或者是改变动画运动过程的轨迹等,这时候可以用到关键帧。


KeyFrameSet是Transition的子元素,与OnClick、OnSwipe同级。KeyFrameSet中可以包含KeyPositionKeyAttributeKeyCycleKeyTimeCycleKeyTrigger,它们都可以用来改变动画过程。


此外还有与KeyFrameSet同级的KeyPositionKeyAttribute,具体大家根据需要自行了解即可。


最后再提一下MotionLayout一些常用的java api:


loadLayoutDescription() ——对应xml"app:layoutDescription",通过代码加载MotionScene;


transitionToStart() ——表示切换到动画start状态;


transitionToEnd() ——表示切换到动画end状态;


它们都默认有过渡效果,如果不需要过渡效果,可以通过**setProgress(float pos)**处理过渡进度,取值0-1;


transitionToState(int id) ——表示切换到动画某个状态,可以是start也可以是end,参数id指的是ConstraintSet标签定义的id;


setTransitionListener(MotionLayout.TransitionListener listener) ——监听MotionLayout动画执行过程,接口有四个方法,onTransitionStartedonTransitionChangeonTransitionCompletedonTransitionTrigger


OK,最最后,ConstraintLayout能有效提升日常的开发效率,通过这篇文章的介绍,此刻你学废了嘛~


参考


MotionLayout官网文档


ConstraintLayout / MotionLayout GitHub示例


MotionLayout 使用说明书(入门级详解)


ConstraintLayout使用小技巧


作者:哆Laker梦
来源:juejin.cn/post/6886337167279259661
收起阅读 »

大公司如何做 APP:背后的开发流程和技术

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用...
继续阅读 »

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用户量级够大,公司才愿意在技术上投入更多的人力资源。因此,在大公司里做技术,对个人的眼界、技术细节和深度的提升都有帮助。


我记得之前我曾跟同事调侃说,有一天我离职了,我可以说我毕业了,因为我这几年学到了很多。现在我想借这个机会总结下这些年在公司里经历的让我印象深刻的技术。


1、研发流程


首先在产品的研发流程上,我把过去公司的研发模式分成两种。


第一种是按需求排期的。在评审阶段一次性评审很多需求,和开发沟通后可能删掉优先级较低的需求,剩下的需求先开发,再测试,最后上线。上线的时间根据开发和测试最终完成的时间确定。


第二种是双周迭代模式,属于敏捷开发的一种。这种开发机制里,两周一个版本,时间是固定的。开发、测试和产品不断往时间周期里插入需求。如下图,第一周和第三周的时间是存在重叠的。具体每个阶段留多少时间,可以根据自身的情况决定。如果需求比较大,则可以跨迭代,但发布的时间窗口基本是固定的。


截屏2023-12-30 13.00.33.png


有意思的是,第二种开发机制一直是我之前的一家公司里负责人羡慕的“跑火车”模式。深度参与过两种开发模式之后,我说下我的看法。


首先,第一种开发模式适合排期时间比较长的需求。但是这种方式时间利用率相对较低。比如,在测试阶段,开发一般是没什么事情做的(有的会在这个时间阶段布置支线需求)。这种开发流程也有其好处,即沟通和协调成本相对较低。


注意!在这里,我们比较时间利用率的时候是默认两种模式的每日工作时间是相等的且在法律允许范围内。毕竟,不论哪一种研发流程,强制加班之后,时间利用率都“高”(至少老板这么觉得)。


第二种开发方式的好处:



  1. 响应速度快。可以快速发现问题并修复,适合快速试错。

  2. 时间利用率高。相比于按需求排期的方式,不存在开发和测试的间隙期。


但这种开发方式也有缺点:



  1. 员工压力大,容易造成人员流失。开发和测试时间穿插,开发需要保证开发的质量,否则容易影响整个迭代内开发的进度。

  2. 沟通成本高。排期阶段出现人力冲突需要协调。开发过程中出现问题也需要及时、有效的沟通。因此,在这种开发模式里还有一个角色叫项目经理,负责在中间协调,而第一种开发模式里项目经理的存在感很低。

  3. 这种开发模式中,产品要不断想需求,很容易导致开发的需求本身价值并不大。


做了这么多年开发,让人很难拒绝一个事实是,绝大多数互联网公司的壁垒既不是技术,也不是产品,而是“快速迭代,快速试错”。从这个角度讲,双周迭代开发机制更适应互联网公司的要求。就像我们调侃公司是给电脑配个人,这种开发模式里就是给“研发流水线”配个人,从产品、到开发、到测试,所有人都像是流水线上的一员。


2、一个需求的闭环


以上是需求的研发流程。如果把一个需求从产品提出、到上线、到线上数据回收……整个生命周期列出来,将如下图所示,


需求闭环.drawio.png


这里我整合了几个公司的研发过程。我用颜色分成了几个大的流程。相信每个公司的研发流程里或多或少都会包含其中的几个。在这个闭环里,我说一下我印象比较深刻的几个。


2.1 产品流程


大公司做产品一个显著的特点是数据驱动,一切都拿数据说话。一个需求的提出只是一个假设,开发上线之后效果评估依赖于数据。数据来源主要有埋点上报和舆情监控。


1. 数据埋点


埋点数据不仅用于产品需求的验证,也用于推荐算法的训练。因此,大公司对数据埋点的重视可以说是深入骨髓的。埋点数据也经常被纳入到绩效考核里。


开发埋点大致要经过如下流程,



  • 1). 产品提出需要埋的点。埋点的类型主要包括曝光和点击等,此外还附带一些上报的参数,统计的维度包括用户 uv 和次数 pv.

  • 2). 数据设计埋点。数据拿到产品要埋的点之后,设计埋点,并在埋点平台录入。

  • 3). 端上开发埋点。端上包括移动客户端和 Web,当然埋点框架也要支持 RN 和 H5.

  • 4). 端上验证埋点。端上埋点完成之后需要测试,上报埋点,然后再在平台做埋点校验。

  • 5). 产品提取埋点数据。

  • 6). 异常埋点数据修复。


由此可见,埋点及其校验对开发来说也是需要花费精力的一环。它不仅需要多个角色参与,还需要一个大数据平台,一个录入、校验和数据提取平台,以及端上的上报框架,可以说成本并不低。


2. 舆情监控


老实说,初次接触舆情监控的时候,它还是给了我一点小震撼的。没想到大公司已经把舆情监控做到了软件身上。


舆情监控就是对网络上关于该 APP 的舆情的监控,数据来源不仅包括应用内、外用户提交的反馈,还包括主流社交平台上关于该软件的消息。所有数据在整合到舆情平台之后会经过大数据分析和分类,然后进行监控。舆情监控工具可以做到对产品的负面信息预警,帮助产品经理优化产品,是产品研发流程中重要的一环。


3. AB 实验


很多同学可能对 AB 实验都不陌生。AB 实验就相当于同时提出多套方案,然后左右手博弈,从中择优录用。AB 实验的一个槽点是,它使得你代码中同时存在多份作用相同的代码,像狗皮膏药一样,也不能删除,非常别扭,最后导致的结果是代码堆积如山。


4. 路由体系建设


路由即组件化开发中的页面路由。但是在有些应用里,会通过动态下发路由协议支持运营场景。这在偏运营的应用里比较常见,比如页面的推荐流。一个推荐流里下发的模块可能打开不同的页面,此时,只需要为每个页面配置一个路由路径,然后推荐流里根据需要下发即可。所以,路由体系也需要 Android 和 iOS 双端统一,同时还要兼容 H5 和 RN.


mdn-url-all.png


在路由协议的定义上,我们可以参考 URL 的格式,定义自己的协议、域名、路径以及参数。以 Android 端为例,可以在一个方法里根据路由的协议、域名对原生、RN 和 H5 等进行统一分发。


2.2 开发流程


在开发侧的流程里,我印象深的有以下几个。


1. 重视技术方案和文档


我记得之前在一家公司里只文档平台就换了几个,足见对文档的重视。产品侧当然更重文档,而对研发侧,文档主要有如下几类:1). 周会文档;2).流程和规范;3).技术方案;4).复盘资料等。


对技术方案,现在即便我自己做技术也保留了写大需求技术方案先行的习惯。提前写技术方案有几个好处:



  • 1). 便于事后回忆:当我们对代码模糊的时候,可以通过技术方案快速回忆。

  • 2). 便于风险预知:技术方案也有助于提前预知开发过程中的风险点。前面我们说敏捷开发提前发现风险很重要,而做技术方案就可以做到这点。

  • 3). 便于全面思考:技术方案能帮助我们更全面地思考技术问题。一上来就写代码很容易陷入“只见树木,不见森林”的困境。


2. Mock 开发


Mock 开发也就是基于 Mock 的数据进行开发和测试。在这里它不局限于个人层面(很多人可能有自己 Mock 数据开发的习惯),而是在公司层面将其作为一种开发模式,以实现前后端分离。典型的场景是客户端先上线预埋,而后端开发可能滞后一段时间。为了支持 Mock 开发模式,公司需要专门的平台,提供以接口为维度的 Mock 工具。当客户端切换到 Mock 模式之后,上传到网络请求在后端的网关直接走 Mock 服务器,拉取 Mock 数据而不是真实数据。


这种开发模式显然也是为了适应敏捷开发模式而提出的。它可以避免前后端依赖,减轻人力资源协调的压力。这种开发方式也有其缺点:



  • 1). 数据结构定义之后无法修改。客户端上线之后后端就无法再修改数据结构。因此,即便后端不开发,也需要先投入人力进行方案设计,定义数据结构,并拉客户端进行评审。

  • 2). 缺少真实数据的验证。在传统的开发模式中,测试要经过测试和 UAT 两个环境,而 UAT 本身已经比较接近线上环境,而使用 Mock 开发就完全做不到这么严谨。当我们使用 Mock 数据测试时,如果我们自己的 Mock 的数据本身失真比较严重,那么在意识上你也不会在意数据的合理性,因此容易忽视一些潜在的问题。


3. 灰度和热修复


灰度的机制是,在用户群体中选择部分用户进行应用更新提示的推送。这要求应用本身支持自动更新,同时需要对推送的达到率、用户的更新率进行统计。需要前后端一套机制配合。灰度有助于提前发现应用中存在的问题,这对超大型应用非常有帮助,毕竟,现在上架之后发现问题再修复的成本非常高。


但如果上架之后确实出现了问题就需要走热修复流程。热修复的难点在于热修复包的下发,同时还需要审核流程,因此需要搭建一个平台。这里涉及的细节比较多,后面有时间再梳理吧。


4. 配置下发


配置下发就是通过平台录入配置,推送,然后在客户端读取配置信息。这也是应用非常灵活的一个功能,可以用来下发比如固定的图片、文案等。我之前做个人开发的时候也在服务器上做了配置下发的功能,主要用来绕过某些应用商店的审核,但是在数据结构的抽象上做得比较随意。这里梳理下配置下发的细节。



  • 首先,下发的配置是区分平台特征的。这包括,应用的目标版本(一个范围)、目标平台(Android、iOS、Web、H5 或者 RN)。

  • 其次,为了适应组件化开发,也为了更好地分组管理,下发的配置命名时采用 模块#配置名称 的形式。

  • 最后,下发的数据结构支持,整型、布尔类型、浮点数、字符串和 Json.


我自己在做配置下发的时候还遇到一个比较棘手的问题——多语言适配。国内公司的产品一般只支持中文,这方面就省事得多。


5. 复盘文化


对于敏捷开发,复盘是不可或缺的一环。有助于及时发现问题,纠正和解决问题。复盘的时间可以是定期的,在一个大需求上线之后,或者出现线上问题之后。


3、技术特点


3.1 组件化开发的痛点


在大型应用开发过程中,组件化开发的意义不仅局限于代码结构层面。组件化的作用体现在以下几个层面:



  • 1). 团队配合的利器。想想几十个人往同一份代码仓库里提交代码的场景。组件化可以避免无意义的代码冲突。

  • 2). 提高编译效率。对于大型应用,全源码编译一次的时间可能要几十分钟。将组件打包成 aar 之后可以减少需要编译的代码的数量,提升编译效率。

  • 3). 适应组织架构。将代码细分为各个组件,每个小团队只维护自己的组件,更方便代码权限划分。


那么,在实际开发过程中组件化开发会存在哪些问题呢?


1. 组件拆分不合理


这在从单体开发过渡到组件化开发的应用比较常见,即组件化拆分之后仍然存在某些模块彼此共用,导致提交代码的时候仍然会出现冲突问题。冲突包含两个层面的含义,一是代码文件的 Git 冲突,二是在打包合入过程中发布的 aar 版本冲突。比较常见的是,a 同学合入了代码到主干之后,b 同学没有合并主干到自己的分支就打包,导致发布的 aar 没有包含最新的代码。这涉及打包的问题,是另一个痛点问题,后面再总结。


单就拆分问题来看,避免上述冲突的一个解决办法是在拆分组件过程中尽可能解耦。根据我之前的观察,存在冲突的组件主要是数据结构和 SPI 接口。这是我之前公司没做好的地方——数据结构仓库和 SPI 接口是共用的。对于它们的组件化拆分,我待过的另一家公司做得更好。他们是如下拆分的,这里以 A 和 B 来命名两个业务模块。那么,在拆分的时候做如下处理,


模块:A-api
模块:A
模块:B-api
模块:B

即每个业务模块拆分成 api 和实现两部分。api 模块里包含需要共享的数据结构和 SPI 接口,实现模块里是接口的具体实现。当模块 A 需要和模块 B 进行交互的时候,只需要依赖 B 的 api 模块。可以参考开源项目:arch-android.


2. 打包合入的痛点


上面我们提到了一种冲突的情况。在我之前的公司里,每个组件有明确的负责人,在每个迭代开发的时候,组件负责人负责拉最新 release 分支。其他同学在该分支的开发需要经过负责人同意再合入到该分支。那么在最终打包的过程中,只需要保证这个分支的 aar 包含了全部最新的代码即可。也就是说,这种打包方式只关心每个 aar 的版本,而不关心实际的代码。因为它最终打包是基于 aar 而不是全源码编译。


这种打包方式存在最新的分支代码没有被打包的风险。一种可行的规避方法是,在平台通过 Git tag 和 commit 判断该分支是否已经包含最新代码。此外,还可能存在某个模块修改了 SPI 接口,而另一个模块没有更新,导致运行时异常的风险。


另一个公司是基于全源码编译的。不过,全源码编译只在最终打包阶段或者某个固定的时间点进行,而不是每次合入都全源码编译(一次耗时太久)。同时,虽然每个模块有明确的负责人,但是打包的 aar 不是基于当前 release 分支,而是自己的开发分支。这是为了保障当前 release 分支始终是可用的。合并代码到 release 分支的同时需要更新 aar 的版本。但它也存在问题,如果合并到 release 而没有打包 aar,那么可能导致 release 分支无法使用。如果打包了 aar 但是此时其他同学也打包了 aar,则可能导致本次打包的 aar 落后,需要重新打包。因此,这种合入方式也是苦不堪言。


有一种方法可以避免上述问题,即将打包和合入事件设计成一个消息队列。每次合入之前自动化执行上述操作,那么自然就可以保证每次操作的原子性(因为本身就是单线程的)。


对比两种打包和合入流程,显然第二种方式更靠谱。不过,它需要设计一个流程。这需要花费一点功夫。


3. 自动化切源码


我在之前的一家公司开发时,在开发过程中需要引用另一个模块的修改时,需要对另一个模块打 SNAPSHOT 包。这可行,但有些麻烦。之前我也尝试过手动修改 settings.gradle 文件进行源码依赖开发。不过,太麻烦了。


后来在另一个公司里看到一个方案,即动态切换到源码开发。可以将某个依赖替换为源码而只需要修改脚本即可。这个实践很棒,我已经把它应用到独立开发中。之前已经梳理过《组件化开发必备:Gradle 依赖切换源码的实践》.


3.2 大前端化开发


1. React Native


如今的就业环境,哪个 Android 开发不是同时会五六门手艺。跨平台开发几乎是不可避免的。


之前的公司为什么选择 React Native 而不是 Flutter 等新锐跨平台技术呢?我当时还刻意问了这个问题。主要原因:



  • 1). 首先是 React Native 相对更加成熟,毕竟我看了下 Github 第一个版本发布已经是 9 年前的事情了,并且至今依旧非常活跃。

  • 2). React Native 最近更新了 JavaScript 引擎,页面启动时间、包大小和内存占用性能都有显著提升。参考这篇文章《干货 | 加载速度提升15%,携程对RN新一代JS引擎Hermes的调研》.

  • 3). 从团队人才配置上,对 React Native 熟悉的更多。


React Native 开发是另一个领域的东西,不在本文讨论范围内。每个公司选择 React Native 可能有它的目的。比如,我之前的一家公司存粹是为了提效,即一次开发双端运行。而另一家公司,则是为了兼顾提效和动态化。如果只为提效,那么本地编译和打包 js bundle 就可以满足需求。若要追求动态化,就需要搭建一个 RN 包下发平台。实际上,在这个公司开发 RN 的整个流程,除了编码环节,从代码 clone 到最终发布都是在平台上执行的。平台搭建涉及的细节比较多,以后用到再总结。对于端侧,RN 的动态化依赖本地路由以及 RN 容器。


2. BFF + DSL


DSL 是一种 UI 动态下发的方案。相比于 React Native,DSL 下发的维度更细,是控件级别的(而 RN 是页面级别的)。简单的理解是,客户端和后端约定 UI 格式,然后按照预定的格式下发的数据。客户端获取到数据之后渲染。DSL 不适合需要复杂动画的场景。若确实要复杂动画,则需要自定义控件。


工作流程如下图中左侧部分所示,右侧部分是每个角色的责任。


DSL workflow.drawio.png


客户端将当前页面和位置信息传给 DSL 服务器。服务器根据上传的信息和位置信息找到业务接口,调用业务接口拉取数据。获取到数据后根据开发过程中配置的脚本对数据进行处理。数据处理完成之后再交给 DSL 服务器渲染。渲染完成之后将数据下发给客户端。客户端再根据下发的 UI 信息进行渲染。其中接口数据的处理是通过 BFF 实现的,由客户端通过编写 Groovy 脚本实现数据结构的转换。


这种工作流程中,大部分逻辑在客户端这边,需要预埋点位信息。预埋之后可以根据需求进行下发。这种开发的一个痛点在于调试成本高。因为 DSL 服务器是一个黑盒调用。中间需要配置的信息过多,搭建 UI 和编写脚本的平台分散,出现问题不易排查。


总结


所谓他山之石,可以攻玉。在这篇文章中,我只是选取了几个自己印象深刻的技术点,零零碎碎地写了很多,比较散。对于有这方面需求的人,会有借鉴意义。


作者:开发者如是说
来源:juejin.cn/post/7326268908984434697
收起阅读 »

车机系统与Android的关系

前言:搞懂 Android 系统和汽车到底有什么关系。 一、基本概念 1、Android Auto 1)是什么 Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的; 运行环境:需要在 Android 5.0 或者更高版本的...
继续阅读 »

前言:搞懂 Android 系统和汽车到底有什么关系。



一、基本概念


1、Android Auto


1)是什么



  • Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的;

  • 运行环境:需要在 Android 5.0 或者更高版本的系统,并且需要 Google 地图和 Google Play 音乐应用;


2)功能



  • Android Atuo 可以用来将 Android 设备上的部分功能映射到汽车屏幕上;

  • 满足了很多人在开车时会使用手机的需求;


2、Google Assistant



  • Google 将 GoofleAssistant 集成到 AndroidAuto 中;

  • 交互方式有键盘、触摸、语音等;

  • 对于汽车来说,语音无疑是比触摸更好的交互方式;

  • 在驾驶环境中,语音交换存在的优势

    • 用户不改变自身的物理姿势,这种交互方式不影响驾驶员对驾驶的操作;

    • 有需要多次触摸的交互时,可能只需要一条语音就可以完成;

    • 语音交互不存在入口的层次嵌套,数据更加扁平;

    • 优秀的语音系统可以利用对话的上下文完成任务,避免用户重复输入;




3、Android Automotive


1、Android Auto 和 Android Automotive 的区别



  • Android Auto 是以手机为中心的

    • 好处:数据和应用始终是一致的,不存在需要数据同步的问题,手机上装的软件和已有数据,接到汽车上就直接有了;

    • 坏处:每次都需要拿出手机,汽车只是作为手机的一个外设;这种模式不便于对于汽车本身的控制和相关数据的获取;



  • Android Automotive

    • 如果将系统直接内置于汽车中,会大大提升用户体验;

    • Android Automotive 就是面向这个方向进行设计的;

    • 一旦将系统内置于汽车,可以完成的功能就会大大增加;例如,直接在中控触摸屏上调整座椅和空调;同时,系统也能获取更多关于汽车的信息,例如:油耗水平、刹车使用等;




加两张中控和仪表的图片


4、App


1)App 的开发



  • Android Auto 目前仅支持两类第三方应用

    • 音频应用:允许用户浏览和播放汽车中的音乐和语音内容;

    • 消息应用:通过 text-to-speech 朗读消息并通过语音输入回复消息;




2)App 的设计



  • Google 专门为 Android Auto 上的 UI 设计做了一个指导网站:Auto UI guidelines;

  • 基本指导原则(车机交互系统的借鉴)

    • Android Auto 上的互动步调必须由驾驶员控制;

    • 汽车界面上的触摸目标必须足够大,以便可以轻松地浏览和点击;

    • 适当的私彩对比可以帮助驾驶员快速解读信息并做出决定;

    • 应用必须支持夜间模式,因为过高的强度可能会干扰注意力;

    • Roboto 字体在整个系统中用于保持一致性并帮助提高可读性;

    • 通过触摸来进行分页应用用来作为滑动翻页的补充;

    • 有节制地使用动画来描述两个状态间的变化;






二、源码和架构


1、Android Automative的整体架构




  • Android Automative 的源码包含在 AOSP 中;

  • Android Automative 是在原先 Android的 系统架构上增加了一些与车相关的(图中虚线框中绿色背景的)模块;

    • Car App:包括 OEM 和第三方开发的 App;

      • OEM:就是汽车厂商利用自身掌握的核心技术负责设计和开发新产品,而具体的生产制造任务则通过合同订购的方式委托给同类产品的其他厂家进行,最终产品会贴上汽车厂商自己的品牌商标。这种生产方式被称为定牌生产合作,俗称“贴牌”。承接这种加工任务的制造商就被称为OEM厂商,其生产的产品就是OEM产品;



    • Car API:提供给汽车 App 特有的接口;

    • Car Service:系统中与车相关的服务;

    • Vehicle Network Service:汽车的网络服务;

    • Vehicle HAL:汽车的硬件抽象层描述;




1)Car App



  • /car_product/build/car.mk 这个文件中列出了汽车系统中专有的模块;

  • 列表中,首字母大写的模块基本上都是汽车系统中专有的 App;

  • App的源码都位于 /platform/packages/services/Car/ 目录下


    # Automotive specific packages
    PRODUCT_PACKAGES += \
    vehicle_monitor_service \
    CarService \
    CarTrustAgentService \
    CarDialerApp \
    CarRadioApp \
    OverviewApp \
    CarLensPickerApp \
    LocalMediaPlayer \
    CarMediaApp \
    CarMessengerApp \
    CarHvacApp \
    CarMapsPlaceholder \
    CarLatinIME \
    CarUsbHandler \
    android.car \
    libvehiclemonitor-native \



2)Car API



  • 开发汽车专有的App自然需要专有的API;

  • 这些API对于其他平台(例如手机和平板)通常是没有意义的;

  • 所以这些API没有包含在Android Framework SDK中;

  • 下图列出了所有的 Car API;




  • android.car:包含了与车相关的基本API。例如:车辆后视镜,门,座位,窗口等。

    • cabin:座舱相关API。

    • hvac:通风空调相关API。(hvac是Heating, ventilation and air conditioning的缩写)

    • property:属性相关API。

    • radio:收音机相关API。

    • pm:应用包相关API。

    • render:渲染相关API。

    • menu:车辆应用菜单相关API。

    • annotation:包含了两个注解。

    • app

    • cluster:仪表盘相关API。

    • content

    • diagnostic:包含与汽车诊断相关的API。

    • hardware:车辆硬件相关API。

    • input:输入相关API。

    • media:多媒体相关API。

    • navigation:导航相关API。

    • settings:设置相关API。

    • vms:汽车监测相关API。




3)Car Service



  • Car Service并非一个服务,而是一系列的服务。这些服务都在ICarImpl.java构造函数中列了出来;


public ICarImpl(Context serviceContext, IVehicle vehicle, SystemInterface systemInterface,
CanBusErrorNotifier errorNotifier)
{
mContext = serviceContext;
mHal = new VehicleHal(vehicle);
mSystemActivityMonitoringService = new SystemActivityMonitoringService(serviceContext);
mCarPowerManagementService = new CarPowerManagementService(
mHal.getPowerHal(), systemInterface);
mCarSensorService = new CarSensorService(serviceContext, mHal.getSensorHal());
mCarPackageManagerService = new CarPackageManagerService(serviceContext, mCarSensorService,
mSystemActivityMonitoringService);
mCarInputService = new CarInputService(serviceContext, mHal.getInputHal());
mCarProjectionService = new CarProjectionService(serviceContext, mCarInputService);
mGarageModeService = new GarageModeService(mContext, mCarPowerManagementService);
mCarInfoService = new CarInfoService(serviceContext, mHal.getInfoHal());
mAppFocusService = new AppFocusService(serviceContext, mSystemActivityMonitoringService);
mCarAudioService = new CarAudioService(serviceContext, mHal.getAudioHal(),
mCarInputService, errorNotifier);
mCarCabinService = new CarCabinService(serviceContext, mHal.getCabinHal());
mCarHvacService = new CarHvacService(serviceContext, mHal.getHvacHal());
mCarRadioService = new CarRadioService(serviceContext, mHal.getRadioHal());
mCarNightService = new CarNightService(serviceContext, mCarSensorService);
mInstrumentClusterService = new InstrumentClusterService(serviceContext,
mAppFocusService, mCarInputService);
mSystemStateControllerService = new SystemStateControllerService(serviceContext,
mCarPowerManagementService, mCarAudioService, this);
mCarVendorExtensionService = new CarVendorExtensionService(serviceContext,
mHal.getVendorExtensionHal());
mPerUserCarServiceHelper = new PerUserCarServiceHelper(serviceContext);
mCarBluetoothService = new CarBluetoothService(serviceContext, mCarCabinService,
mCarSensorService, mPerUserCarServiceHelper);
if (FeatureConfiguration.ENABLE_VEHICLE_MAP_SERVICE) {
mVmsSubscriberService = new VmsSubscriberService(serviceContext, mHal.getVmsHal());
mVmsPublisherService = new VmsPublisherService(serviceContext, mHal.getVmsHal());
}
mCarDiagnosticService = new CarDiagnosticService(serviceContext, mHal.getDiagnosticHal());

4)Car Tool


a、VMS



  • VMS全称是Vehicle Monitor Service。正如其名称所示,这个服务用来监测其他进程;

  • 在运行时,这个服务是一个独立的进程,在init.car.rc中有关于它的配置


service vms /system/bin/vehicle_monitor_service
class core
user root
group root
critical

on boot
start vms


  • 这是一个Binder服务,并提供了C++和Java的Binder接口用来供其他模块使用;


作者:一个写代码的修车工
来源:juejin.cn/post/7356981730765291558
收起阅读 »

安卓开发转做鸿蒙后-开篇

一、为什么转做鸿蒙 本人从事安卓开发已近十年,大部分时间还是在不停的需求迭代,或者一遍遍优化各种轮子,自己的职业生涯已经进入了瓶颈期,同时现有工作也很难让自己产生成就感。正好年初有机会转入鸿蒙开发团队,虽然清楚肯定少不了加班,最终也不一定会有预期中的产出,还是...
继续阅读 »

一、为什么转做鸿蒙


本人从事安卓开发已近十年,大部分时间还是在不停的需求迭代,或者一遍遍优化各种轮子,自己的职业生涯已经进入了瓶颈期,同时现有工作也很难让自己产生成就感。正好年初有机会转入鸿蒙开发团队,虽然清楚肯定少不了加班,最终也不一定会有预期中的产出,还是希望自己能有一些新东西的刺激和积累。


二、App鸿蒙化的回顾


本人所在公司差不多算是中厂,C端App日活大概有个几百万,各部门团队大概有30人+,历时半年多的时间,差不多完成了全部功能70%左右。前期主要是个人自学及各种培训、前期调研、App基础库的排期、业务排期、开发上架等几个环节。


1、基础库



  • 网络库

  • 图片库

  • 埋点库

  • 路由库

  • 公共组件

  • 崩溃监控

  • 打包构建


2、业务排期



  • 业务拆分优先级

  • 分期迭代开发测试


三、跟安卓相比的差异性


1、ArkUI和Android布局



  • Android控件习惯于宽高自适应,ArkUI中部分子组件会超过容器组件区域,所以部分组件需要控制宽度

  • Android是命令式UI比较简单直接,ArkUI是声明式,需要重点关注状态管理的合理使用

  • Android列表重复相对简单,ArkUI中List懒加载和组件复用使用比较繁琐

  • Android基于Java可以通过继承抽取一些公共能力,ArkUI组件无法进行继承


2、鸿蒙开发便捷的一面


1、问题的反馈和响应比较及时,华为技术支持比较到位。


2、应用市场对性能要求和各类适配要求比较高,倒逼开发提高自己的开发能力。


3、跟安卓比提供了各种相对完善的组件,避免了开发者需要进行各种封装



  • 路由库

  • 网络库

  • 图片库

  • 扫码

  • 人脸识别

  • picker

  • 统一拖拽

  • 预加载服务

  • 应用接续

  • 智能填充

  • 意图框架

  • AI语音识别


3、鸿蒙开发不便的一面



  • ArkTS文档不够完善,没有从0到1的完整学习流程

  • ArkUI部分组件使用繁琐

  • DevEco-Studio的稳定性需要提升

  • 组件渲染性能需要提升,


四、跨平台方案



  • RN

  • Flutter

  • ArkUI-X


ArkUI-X作为鸿蒙主推的跨平台框架,主要问题是生态的建立和稳定性。所以还是要基于公司基建的完善程度和技术生态进行选择。同时由于鸿蒙的加入,适配3个OS系统的成本提高,公司为降本提效会加快跨平台技术的接入和推进。后续还是需要熟悉跨平台开发的技术。


五、知识体系(待完善)


1、ArkTS应用


1、应用程序包结构(hap、har、hsp)


2、整体架构


3、开发模型


2、ArkTs


1、基本语法


2、方舟字节码


3、容器类库


4、并发


3、ArkUI


1、基本语法


2、声明式UI描述


3、自定义组件


4、装饰器


5、状态管理


6、渲染控制


4、Stage模型


1、应用配置文件


2、应用组件


3、后台任务


4、进程模块


5、线程模型


5、性能优化


1、冷启动


2、响应时延


3、完成时延


4、滑动帧率


5、包大小


作者:村口老王
来源:juejin.cn/post/7409877909999026217
收起阅读 »

拼多多冷启真的秒开

背景 最近在使用拼多多购物,除了价格比较香之外,每次冷启打开的体验非常好,作为一个Android开发不免好奇 简单分析记录一下 冷启数据 体验好,让我想到了郭德纲的那句话"全靠同行的衬托",那找几个同行过来对比下,这里使用淘宝、京东、闲鱼,从点击开始图标开始录...
继续阅读 »



背景


最近在使用拼多多购物,除了价格比较香之外,每次冷启打开的体验非常好,作为一个Android开发不免好奇 简单分析记录一下


冷启数据


体验好,让我想到了郭德纲的那句话"全靠同行的衬托",那找几个同行过来对比下,这里使用淘宝、京东、闲鱼,从点击开始图标开始录个屏直接数秒,



测试手机是 华为 Mate 60



我这个样本比较少,机器性能也比较好,仅仅是个人对比,不代表大众使用的真实情况


可以粗略的看几个常见app的冷启对比下,这里录屏使用的剪映来分析
帧率为 每秒30帧,后面会涉及一些时间换算
image.png


拼多多


无广告冷启动 从点击图标到到首页完整展示 大概花了 29帧,
1000ms * 29/30 约为 0.96s
,太惊人了,基本冷启秒开



拼多多可能真的没有开屏广告,我印象中没有见过拼多多的开屏广告



image.pngimage.png


淘宝


无广告冷启东 从点击图标到到首页完整展示
image.png
image.png
大概花了 1s+21帧,
1000ms+ 21/30*1000ms = 1.7s
还可以



淘宝可能没有开屏广告,或者非常克制,我刷了十几次都没有见到开屏广告



京东


无广告冷启京东
从点击图标到到首页完整展示
image.png
image.png


大概花了 1s+28帧,
1000ms + 28/30*1000ms 约为 1.93s
也是不错的



不过京东的开屏有开屏广告,但是做了用户频控,刷了几次就没了,这里仅对比无广告冷启开屏



闲鱼


毕竟是国内最大的二手平台(虽然现在小商家也特别多),而且是flutter深度使用者,看看它的表现
image.png
image.png
大概花了** 2s+10帧**
2000ms+ 10/30*1000ms 约为 2.3s


image.png
从上面数据来看,怪不得 我使用拼多多之后,打开app 确实比较舒服,因为我就是奔着买东西去的,越快到购物页面越舒服的。或许这就是极致的用户体验吧


首屏细节


拼多多的首页数据咋这么快就准备好了,网络耗时应该也有呢,应该是它提前准备好了数据
image.png
我们来实操验证下



  • 切后台的截图


我们记住 手枪、去虾线、行李箱、停电 这几个卡片
image.png


冷启打开之后首先展示的是 还是切后台之前的数据
image.png
紧接着网络数据到了做了一次屏幕刷新
image.png


到这里大概就明白了,冷启使用上次feeds流的数据,先让用户看到数据,然后等新数据请求到之后再刷新页面就好


为了严谨点,把缓存数据清除的话,那么肯定首次冷启白屏,ok最后再验证一下
image.png


此时冷启白茫茫的一片,看来拼多多的策略还是让用户尽快进应用优先,或者这里并没有刻意设计🤔,都是先进首页有缓存就使用 没有的话就等网络数据,毕竟这种情况也只是新用户或者缓存数据过期才会这样
image.png


因此这里我可以得出把这种缓存优先的技术方案也可以学习学习,看看我们自己的app是不是可以复用一下,绩效这不就来了吗🤔
首页 = 数据 + UI
数据是使用缓存,UI也能吧一些UI组件提前预加载,不过这里也无法判断 是否预加载了首页UI🤔


开屏无广告


我目前在字节就是搞广告的,所以对广告稍微敏感些,开屏广告是一个很棒收入来源,特别是合约广告这种,之前应用冷启时间长,有时候其实是故意抽出一些时间来等待冷启的开屏广告,
但是我试了很多次,确实没看过拼多多的开屏广告,不过从这个结果来看 肯定是 经过严密的ab实验,不过拼多多在开屏广告上确实比较克制,



关于现在互联网的计算广告业务还是蛮有意思的比如 广告类型有 开屏、原生、激励、插屏、横幅,sdk类型有单个adn或者聚合广告sdk,有时间再单独分享几篇。



image.png


冷启优化一些常见手段


冷启动往往是大型应用的必争之地



  1. 实打实的提升用户体验

  2. 可能会带来一些GMV的转化


拼多多技术是应该是有些东西的,但是非常低调,属于人狠话不多那种,也没找到他们的方案。这里结合自身经验聊聊这块,主要是以下4个阶段结合技术手段做优化
image.png


Application attachBaseContext


这个阶段由于 Applicaiton Context 赋值等问题,一般不会有太多的业务代码,可能的耗时会在低版本机器4.x机器比较多,首次由于MultiDex.install耗时



dex 的指令格式设计并不完善,单个 dex 文件中引用的 Java 方法总数不能超过 65536 个,在方法数超过 65536 的情况下,将拆分成多个 dex。一般情况下 Dalvik 虚拟机只能执行经过优化后的 odex 文件,在 4.x 设备上为了提升应用安装速度,其在安装阶段仅会对应用的首个 dex 进行优化。对于非首个 dex 其会在首次运行调用 MultiDex.install 时进行优化,而这个优化是非常耗时的,这就造成了 4.x 设备上首次启动慢的问题。



可以使用一些开源方案,比如 github.com/bytedance/B…
不过 这里优化难度比较大,roi的话 看看app低版本的机型占比再做决定


ContentProvider


这里要注意检查 ContentProvider,特别是一些sdk在 AndroidManifest 里面注册了自己的 xxSDkProvider,然后在 xxSDkProvider 的 onCreate 方面里面进行初始化,确实调用者不需要自己初始化了,可却增加了启动耗时,
我们可以打开 Apk,看一下最终merge的 AndroidManiest 里面有多少 provider,看一下是否有这样的骚操作,往往这里容易忽视,这种情况可以使用谷歌App Startup来收敛ContentProvider


Application 优化



  1. 精简Application 中的启动任务

  2. 基于进程进行任务排布,比如常见的push进程、webview进程


西瓜视频 在冷启优化就将 push、小程序、sandboxed这几个进程做了优化拿到一些不错的收益mp.weixin.qq.com/s/v23jEhF9k…



搞进程难度大风险高




  1. 启动链路任务编排


这里需要先梳理启动链路,做成1任务编排,



  1. 比如之前串2.2行的,搞成并行初始化

  2. 核心任务做有向无环图(DGA)编排,非核心的延迟初始化


idlehandler是个好东西。
image.png
image.png



关于初始化DGA框架有不少框架,谷歌官方也有个 App Startup,感兴趣可以研究下



首页优化


首页是用户感知到的第一个页面,也是冷启优化的关键,前面也提过 首页 = 数据 + UI



  1. 数据 可以使用缓存

  2. UI的话 通常是xml解析优化,或者预加载


在性能较差的手机上,xml inflate 的时间可能在 200 到 500 毫秒之间。自定义控件和较深的 UI 层级会加重这个解析耗时。
一些框架比如x2c,或者AsyncLayoutInflater 可以帮助我们在UI这里做做文章



  1. 插件化


把非核心模块做成插件,使用时候下载使用,一劳永逸,不过插件化也有各种弊端


后台任务优化


主线程相关耗时的优化,事实上除了主线程直接的耗时,后台任务的耗时也是会影响到我们的启动速度的,因为它们会抢占我们前台任务的 cpu、io 等资源,导致前台任务的执行时间变长,因此我们在优化前台耗时的同时也需要优化我们的后台任务



  1. 减少后台线程不必要的任务的执行,特别是一些重 CPU、IO 的任务;

  2. 对启动阶段线程数进行收敛,防止过多的并发任务抢占主线程资源,同时也可以避免频繁的线程间调度降低并发效率

  3. GC 抑制


触发 GC 后可能会抢占我们的 cpu 资源甚至导致我们的线程被挂起,如果启动过程中存在大量的 GC,那么我们的启动速度将会受到比较大的影响。通过hook手段在启动阶段去抑制部分类型的 GC,以达到减少 GC 的目的。这个就比较高端了,也是只在一些大厂文章里面见过。


OK 本期就到这里了


作者:程序员龙湫
来源:juejin.cn/post/7331607384932876326
收起阅读 »

【实现环信 SDK登陆跳转至Harmony 底部导航栏】

1.在 Index.ets 的 aboutToApper 方法中 实现环信SDK 初始化代码块(可直接 copy):let optionss = new ChatOptions("输入管理后台注册的环信 APPkey");//管理后台网址:https://co...
继续阅读 »

1.在 Index.ets 的 aboutToApper 方法中 实现环信SDK 初始化


代码块(可直接 copy):

let optionss = new ChatOptions("输入管理后台注册的环信 APPkey");

//管理后台网址:https://console.easemob.com/user/login
//环信初始化
ChatClient.getInstance().init(getContext(),optionss)

2.登录环信SDK并跳转至少导航栏页面


代码块://userID 自定义 String类型参数 =环信id

  //userPasswrod 自定义 String类型参数  登录的密码
ChatClient.getInstance().login(this.userID,this.userPassword).then(()=>{
//登录成功后跳转到导航栏的指定类中
router.replaceUrl({url:'pages/Pages'})


}).catch((e:ChatError)=>{
//登录失败则提示错误信息
console.log("ccc== "+e.errorCode,"")

})

3.在 Harmony 平台下自定义容器


代码块:

@State currentIndex: number = 0
//定义TabsController控件
private Controller: TabsController = new TabsController()
//自定义布局 该布局定义时 可以卸载 页面的 build 方法外面
@Builder
TabBuilder(title: string, index: number, selectedImage: Resource, normalImage: Resource) {
//定义一个容器
Column() {
//容器的图片
Image(this.currentIndex === index ? selectedImage : normalImage)
.height(30)
.width(30)
//容器的文本
Text(title)
.margin({ top: 5 })
.fontSize(10)
.fontColor(this.currentIndex === index ? $r('app.color.start_window_background') :
$r('app.color.start_window_background'))
}
//居中
.justifyContent(FlexAlign.Center)
//容器布局的宽占满
.width('100%')
//容器布局的高尺寸 25
.height(25)
//点击事件改变数字
.onClick(() => {
this.currentIndex = index
this.Controller.changeIndex(this.currentIndex)
})
}


4.build 中通过 Tabs 组件实现底部导航栏


代码块:

build() {
//必写
Tabs({
barPosition: BarPosition.End,
controller: this.Controller
}) {
TabContent() {
//首页
}
.padding({ left: 12, right: 12 })
//背景颜色
.backgroundColor($r('app.color.start_window_background'))
//自定义布局的 名字 index位置,点击后和点击其他需要展示的图片
.tabBar(this.TabBuilder('首页', 0, $r('首次展示的图片'), $r('切到其他页面后展示的图片')))

TabContent() {
//会话列表页
}
//内边距
.padding({ left: 12, right: 12 })
//背景颜色
.backgroundColor($r('app.color.mainPage_backgroundColor'))
//自定义布局的 名字 index位置,点击后和点击其他需要展示的图片
.tabBar(this.TabBuilder('会话', 1, $r('被点击后展示的图片'), $r('被动展示图片')))
TabContent() {
//我的详情页
}.padding({ left: 12, right: 12 })
//背景颜色
.backgroundColor($r('定义背景颜色'))
// //自定义布局的 名字 index位置,点击后和点击其他需要展示的图片
.tabBar(this.TabBuilder('我的详情', 1, $r('被点击后展示的图片'), $r('被动展示图片')))
}
.width('100%')
.backgroundColor(Color.White)
.barHeight(56)
.barMode(BarMode.Fixed)
.onChange((index: number) => {
this.currentIndex = index
})

}


收起阅读 »

拿去吧你!Flutter 仿抖音个人主页下拉拖拽效果

引言 最近产品经理看到抖音的个人主页下拉效果很不错,让我也实现一个,如果是native还好办,开源成熟的库一大堆,可我是Flutter呐🤣,业内成熟可用的库非常有限,最终跟产品经理batte失败后,没办法只能参考native代码硬肝出来。 效果图 整体构思 ...
继续阅读 »

引言


最近产品经理看到抖音的个人主页下拉效果很不错,让我也实现一个,如果是native还好办,开源成熟的库一大堆,可我是Flutter呐🤣,业内成熟可用的库非常有限,最终跟产品经理batte失败后,没办法只能参考native代码硬肝出来。


效果图


掘金素材.gif


整体构思


实现拖拽滑动功能,关键在于对手势事件的识别。在 Flutter 中,可使用Listener来监听触摸事件,如下所示:


Listener(
onPointerDown: (result) {

},
onPointerMove: (result) {

},
onPointerUp: (_) {

}

在手指滑动的过程中不断的刷新背景图高度是不是就可以实现图片的拉伸效果呢?我们这里图片加载库使用CachedNetworkImage,高度在156的基础上动态识别手指的滑动距离extraPicHeight


CachedNetworkImage(
width: double.infinity,
height: 156 + extraPicHeight,
imageUrl: backgroundUrl,
fit: fitType,
)

识别到手指滑动就不断的刷新拉伸高度extraPicHeight,flutter setState 内部已经做了优化,不用担心性能问题,实际效果体验很不错。


setState(() {
extraPicHeight;
});

经过实验思路是没有问题,那么监听哪些事件,extraPicHeight到底怎么计算,有什么边界值还考虑到呢?我们从手势的顺序开始梳理一下。


首先按压屏幕会识别到触碰屏幕起点,也就是initialDx initialDy,对于下拉拖拽我们关心更多的是纵向坐标result.position.dy


onPointerDown: (result) {
initialDy = result.position.dy;
initialDx = result.position.dx;
},

当手指在屏幕滑动会触发onPointerMovew,result.position.dy代表的就是手势滑动的位置


onPointerMove: (result) {
//手指的移动时
// updatePicHeight(result.position.dy); //自定义方法,图片的放大由它完成。
},

这边处理逻辑比较复杂,我们先抽成函数updatePicHeight


updatePicHeight(changed) {
//。。。已省略不重要细节代码
extraPicHeight += changed - prev_dy; //新的一个y值减去前一次的y值然后累加,作为加载到图片上的高度。
debugPrint('extraPicHeight updatePicHeight : $extraPicHeight');
//这里是为了限制我们的最大拉伸效果
if (extraPicHeight > 300) {
extraPicHeight = 300;
}
if (extraPicHeight > 0) {
setState(() {
prev_dy = changed;
});
}
}

这里简化了很多细节逻辑,核心目的就是要不断的累加我们的拖动距离来计算extraPicHeight高度,这里的changed是我们手指的y坐标,滑动的距离需要减去上次滑动的回调y,所以我们必须声明一个过去y坐标的变量也就是prev_dy,通过通过 changed - prev_dy就可以得出真正滑动的距离,然后我们不断累加 extraPicHeight += changed - prev_dy就是图片的拉伸距离。


手指下拉以后图片确实拉伸了,但是松开手后发现回不去了🤣因为我们还需要处理图回去的问题,既然可以通过setState把图片高度拉高,我们也可以通过setState把图片高度刷回去,核心要思考的是如何平滑的让图片自己缩回去呢?有经验的你一定想到动画了。


flutter这里的动画库是TweenTween可以通过addListener监听距离的回调,当距离变化不断刷新图片高度


anim = Tween(begin: extraPicHeight, end: 0.0).animate(animationController)
..addListener(() {
setState(() {
extraPicHeight = anim.value;
fitType = BoxFit.cover;
});
});
prev_dy = 0; //同样归零

动画的效果最终由控制器animationController来决定,这里给了一个300ms的时间还不错,可以根据自己业务扩展


animationController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));

所有在手抬起的时候执行我们的动画runAnimate函数即可


onPointerUp: (_) {
//当手指抬起离开屏幕时
if (isVerticalMove) {
if (extraPicHeight < 0) {
extraPicHeight = 0;
prev_dy = 0;
return;
}
debugPrint('extraPicHeight onPointerUp : $extraPicHeight');
runAnimate(); //动画执行
animationController.forward(from: 0); //重置动画
}
},

整体的技术方案履完了,之后就是细节问题了


问题1:横行稍微有倾角的滑动也会导致页面拖拽,比如侧滑返回上一页面


这是由于手指滑动的角度没有限制, 这里我们计算一下滑动倾角,超过45度无效,角度计算通过x,y坐标计算tan函数即可


onPointerMove: (result) {
double deltaY = result.position.dy - initialDy;
double deltaX = result.position.dx - initialDx;
double angle =
(deltaY == 0) ? 90 : atan(deltaX.abs() / deltaY.abs()) * 180 / pi;
debugPrint('onPointerMove angle : $angle');
if (angle < 45) {
isVerticalMove = true; // It's a valid vertical movement
updatePicHeight(result
.position.dy); // Custom method to handle vertical movement
} else {
isVerticalMove =
false; // It's not a valid vertical movement, ignore it
}
}

问题2:图片高度变了,为啥没有拉伸啊!


图片拉伸取决于你图片库的加载配置,以flutter举例,我们的图片库是CachedNetworkImage


 CachedNetworkImage(
width: double.infinity,
height: 156 + extraPicHeight,
imageUrl: backgroundUrl,
fit: fitType,
)

加载效果取决于fit,默认不变形我们使用cover,拉伸时使用fitHeight或者fill


updatePicHeight(changed) {
if (prev_dy == 0) {
//如果是手指第一次点下时,我们不希望图片大小就直接发生变化,所以进行一个判定。
prev_dy = changed;
}
if (extraPicHeight > 0) {
//当我们加载到图片上的高度大于某个值的时候,改变图片的填充方式,让它由以宽度填充变为以高度填充,从而实现了图片视角上的放大。
fitType = BoxFit.fitHeight;
} else {
fitType = BoxFit.cover;
}
extraPicHeight += changed - prev_dy; //新的一个y值减去前一次的y值然后累加,作为加载到图片上的高度。
debugPrint('extraPicHeight updatePicHeight : $extraPicHeight');
if (extraPicHeight > 300) {
extraPicHeight = 300;
}
if (extraPicHeight > 0) {
setState(() {
prev_dy = changed;
fitType = fitType;
});
}
}

最后看下组件如何布局


 CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: buildTopWidget(),
),
SliverToBoxAdapter(
child: Column(
children: contents,
),
)
]
),
)

整个列表使用CustomScrollView,因为在flutter上用他才能实现这种变化效果,未来还可以扩展顶部导航栏的变化需求。buildTopWidget就是我们头部组件,包括内部的背景图,但是整个组件和背景图的高度都是依赖extraPicHeight变化的,contents是我们的内容,当头部组件挤压,会正常跟随滑动到底部。


全局变量依赖以下参数就够了,核心要注意的就是边界值问题,什么时候把状态值重置问题。


//初始坐标
double initialDy = 0;
double initialDx = 0;
double extraPicHeight = 0; //初始化要加载到图片上的高度
late double prev_dy; //前一次滑动y
//是否是垂直滑动
bool isVerticalMove = false;
//动画器
late AnimationController animationController;
late Animation<double> anim;


技术语言不是我分享的核心,解决这个需求的技术思维路线是我们大家可以借鉴学习的。



如果你有任何疑问可以通过掘金联系我,如果文章对你有所启发,希望能得到你的点赞、关注和收藏,这是我持续写作的最大动力。Thanks~


作者:小虎牙007
来源:juejin.cn/post/7419248277382021135
收起阅读 »

一次接手远古Android项目终于运行起来了

我也没做过安卓开发,2020年外包开发的app在客户新手机上安装不上,搞呗。apk安装报错此应用与最新版Android不兼容,试了同事的Android 14 确实同样报错 网上查到解决方案。 http://www.duidaima.com/Gr0up/Top...
继续阅读 »

我也没做过安卓开发,2020年外包开发的app在客户新手机上安装不上,搞呗。apk安装报错此应用与最新版Android不兼容,试了同事的Android 14 确实同样报错


image.png


网上查到解决方案。


http://www.duidaima.com/Gr0up/Topic…


按照第一点增加64位指令集后,重新打包apk解决问题了


1、【成功并上线】在build.gradel文件的ndk部分添加arm64-v8a的指令集


2、【未实验】targetSdkVersion最少为29就能在安卓14上避免异常弹框


安装Android开发环境过程很曲折,重点是要安装项目需要的开发环境版本,不然各种错误失败


第一步确认项目开发环境版本


最开始下载Android Studio 2024最新版,2021版等等,JDK21最新版,JDK17都失败。


得出结论:



  • 确认Android Studio 版本要看根目录build.gradle中gradle版本,再去官网下载对应版本号

  • 确认JDK版本要看另一个build.gradle中targetCompatibility的版本号


image.png


JDK 版本


http://www.oracle.com/java/techno…


根据build.gradle中看出要JDK8,而且jdk8安装后默认有jre目录,不像jdk21要手动生成jre目录


注意上面网站用Chrome打开登录Oracle后报错Cookie太长,改为360极速版正常下载


image.png


登录或注册oracle账号才能下载


image.png


配置环境变量


新建JAVA_HOME    C:\Program Files\Java\jdk-1.8


修改PATH    %JAVA_HOME%\bin       ;%JAVA_HOME%\jre\bin


网上说前面第二个前面一定要带分号


image.png


image.png


测试正常


image.png


额外补充 JDK17 和 JDK21 生成 jre目录


上面用的JDK8在安装好后默认是生成jre目录的,但是如果JDK17和JDK21没有默认生成jre目录,需要手动生成


必须管理员权限打开CMD


image.png


进入到jdk-21目录执行命令就可以生成jre文件夹了


bin\jlink.exe --module-path jmods --add-modules java.desktop --output jre

image.png


image.png


Android Studio 版本


developer.android.google.cn/studio/arch…


根据build.gradle中gradle:4.1.1看出要下载Android Studio 4.1.1 , 其他新版本项目有各种报错


image.png


再去官网下载对应版本


image.png


第二步 Android Studio 安装过程中问题解决


正常安装Android Studio


image.png


初始化设置sdk代理


启动后报错 Unable to access Android SDK add-on list,点 Setup Proxy


修改Automatic proxy configuration URL设置为:mirrors.neusoft.edu.cn

因为后面都是google的域名,不设置sdk代理多半是下载不了的


image.png


image.png


可能设置Proxy再报错同样Unable这个错,就点 Cancel 跳过,后面都点 Next 直达 Finiash


image.png


安装sdk版本


第一次进入启动页面,在Configure选择SDK Manager,我把API Level的28,29,30都勾选上,因为我看老项目代码里面targetSdkVersion 28,而我找到的解决方法说最少29,干脆我就勾上这三个


image.png


image.png


后面点Accept,就直接下载到Finish呗


image.png


后面遇到报错 Installed Build Tools revision 35.0.0 is corrupted. Remove and install again using the SDK Manager.


那打开工具条 File -> Settings 找到 Android SDK 项,在 Android SDK Location 点 Edit 重新点Next安装后报错消失


再把 build.gradle 中35都改成28


image.png


具体看Build Tools有哪些版本,可以查看SDK安装目录build-tools有哪些,改成有的版本即可


image.png


安装 avd 模拟器


Android项目要运行是要模拟器的,avd就是官方调试模拟器,也可以用第三方的逍遥模拟器,夜游模拟器等


image.png


进去后随便选个 Pixel 4 XL,再进去我老项目是API Level 28的,就需要点 Download 下载


image.png


image.png


安装 HAXM


运行项目要求安装 HAXM,默认安装即可


image.png


image.png


第三步运行老项目解决问题


打开项目


SDK目录与原项目不匹配,点OK自动更新,估计原项目是苹果电脑开发,我这是windows环境


image.png


设置Gradle阿里云代理


找到 gradle-wrapper.properties 文件修改 distributionUrl 为国内代理,国外域名下载gradle超时失败了


替换域名后点 Sync Now


distributionUrl=https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-6.5-all.zip

image.png


image.png


image.png


image.png


如果 sync now点了出现proxy settings弹窗,那在第一个HOST name填写 mirrors.neusoft.edu.cn


image.png


设置 maven 阿里云代理


在根目录build.gradle的allproject下面增加


maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/public' }

image.png


设置 64位指令集


在 app/build.gradle的ndk下增加 arm64-v8a


image.png


运行项目,如果遇到问题可以点工具栏 build -> clean project 再 rebuild project


image.png


第四步签名打包apk


工具栏 build -> Generate Signed Bundle / Apk ...


image.png


选 APK


image.png


选择签名文件输入 password这三个输入框,如果没有就create new新建


image.png


选择打包apk存放目录,Finish就完成了


image.png


右下角显示成功


image.png


作者:一个不会重复的id
来源:juejin.cn/post/7410711559964229682
收起阅读 »

Flutter GPU 是什么?为什么它对 Flutter 有跨时代的意义?

Flutter 3.24 版本引入了 Flutter GPU 概念的新底层图形 API flutter_gpu ,还有 flutter_scene 的 3D 渲染支持库,它们目前都是预览阶段,只能在 main channel 上体验,并且依赖 Impel...
继续阅读 »

Flutter 3.24 版本引入了 Flutter GPU 概念的新底层图形 API flutter_gpu ,还有 flutter_scene 的 3D 渲染支持库,它们目前都是预览阶段,只能在 main channel 上体验,并且依赖 Impeller 的实现。



Flutter GPU 是 Flutter 内置的底层图形 API,它可以通过编写 Dart 代码和 GLSL 着色器在 Flutter 中构建和集成自定义渲染器,而无需 Native 平台代码。


目前 Flutter GPU 处于早期预览阶段并只提供基本的光栅化 API,但随着 API 接近稳定,会继续添加和完善更多功能。



详细说,Flutter GPU 是 Impeller 对于 HAL 的一层很轻的包装,并搭配了关于着色器和管道编排的自动化能力,也通过 Flutter GPU 就可以使用 Dart 直接构建自定义渲染器。


Flutter GPU 和 Impeller 一样,它的着色器也是使用 impellerc 提前编译,所以 Flutter GPU 也只支持 Impeller 的平台上可用。



Impeller 的 HAL 和 Flutter GPU 都没打算成为类似 WebGPU 这样的正式标准,相反,Flutter GPU 主要是由 Flutter 社区开发和发展,专职为了 Flutter 服务,所以不需要考虑「公有化」的兼容问题。



在 Flutter GPU 上,可直接从 Dart 与 Impeller 的 HAL 对话,甚至 Impeller Scene API(3D)也将作为重写的一部分出现。



说人话就是,可以用 Dart 通过 Flutter GPU 直接构建自定义渲染效果,未来直接支持 3D



可能有的人对于 Impeller 的整体结构和 HAL 还很模式无法理解,那么这里我们简单过一下:



  • 在 Framework 上层,我们知道 Widget -> Element -> RenderObject -> Layer 这样的过程,而后其实流程就来到了 Flutter 自定义抽象的 DisplayList

  • DisplayList 帮助 Flutter 在 Engine 做了接耦,从而让 Flutter 可以在 skia 和 Impeller 之间进行的切换

  • 之后 Impeller 架构的顶层是 Aiks,这一层主要作为绘图操作的高级接口,它接受来自 Flutter 框架的命令,例如绘制路径或图像,并将这些命令转换为一组更精细的 “Entities”,然后转给下一层。

  • Entities Framework,它是 Impeller 架构的核心组件,当 Aiks 处理完命令时生成 Entities 后,每一个 Entity 其实就是渲染指令的独立单元,其中包含绘制特定元素的所有必要信息(编码位置、旋转、缩放、content object),此时还不能直接作用于 GPU

  • HAL(Hardware Abstraction Layer) 则为底层图形硬件提供了统一的接口,抽象了不同图形 API 的细节,该层确保了 Impeller 的跨平台能力,它将高级渲染命令转换为低级 GPU 指令,充当 Impeller 渲染逻辑和设备图形硬件之间的桥梁。


所以 HAL 它包装了各种图形 API,以提供通用的设备作业调度接口、一致的资源管理语义和统一的着色器创作体验,而对于 Impeller , Entities (2D renderer) 和 Scene (3D renderer) 都是直接通过 HAL 对接,甚至可以认为,Impeller 的 HAL 抽象并统一了 Metal 和 Vulkan 的常见用法和相似结构。



Unity 现在也有在 C# 直接向用户公开其 HAL 版本,称为 "Scriptable Render Pipeline" ,并提供了两个基于该 API 构建的默认渲染器 "Universal RP" / "High Definition RP" 用于服务不同的场景,所以 Unity 开发可以从使用这些渲染器去进行修改或扩展一些特定渲染需求。





而在 Flutter 的设计上,Flutter GPU 会作为 Flutter SDK 的一部分,并以 flutter_gpu 的 Dart 包的形式提供使用。


当然,Flutter GPU 由 Impeller 支持,但重要的是要记住它不是 Impeller ,Impeller 的 HAL 是私有内部代码与 Flutter GPU 的要求非常不同, Impeller 的私有 HAL 和 Flutter GPU 的公共 API 设计之间是存在一定差异化实现,而前面的流程,如 Scene (3D renderer) ,也可以被调整为基于 Flutter GPU 的全新模式实现。


而通过 Flutter GPU,如曾经的 Scene (3D renderer) 支持,也可以被调整为基于 Flutter GPU 的全新模式实现,因为 Flutter GPU 的 API 允许完全控制渲染通道附件、顶点阶段和数据上传到 GPU。这种灵活性对于创建复杂的渲染解决方案(从 2D 角色动画到复杂的 3D 场景)至关重要。



Flutter GPU 支持的自定义 2D 渲染器的一个很好的用例:依赖于骨骼网格变形的 2D 角色动画格式。


Spine 2D 就是一个很好的例子,骨骼网格解决方案通常具有动画剪辑,可以按层次结构操纵骨骼的平移、旋转和缩放属性,并且每个顶点都有几个相关的“bone weights”,这些权重决定了哪些骨骼应该影响顶点以及影响程度如何。



使用像 drawVertices 这样的 Canvas 解决方案,需要在 CPU 上对每个顶点应用骨骼权重变换,而 使用 Flutter GPU,骨骼变换可以用统一数组或纹理采样器的形式发送到顶点着色器,从而允许根据骨架状态和每个顶点的 “bone weights” 在 GPU 上并行计算每个顶点的最终位置。


使用 Flutter GPU


首先你需要在最新的 main channel 分支,然后通过 flutter pub add flutter_gpu --sdk=flutter 将 flutter_gpu SDK 包添加到你的 pubspec。


为了使用 Flutter GPU 渲染内容,你会需要编写一些 GLSL 着色器,Flutter GPU 的着色器与 Flutter 的 fragment shader 功能所使用的着色器具有不同的语义,特别是在统一绑定方面,还需要定义一个顶点(vertex)着色器来与 fragment shader 一起使用,然后配合 gpu.ShaderLibrary 等 API 就可以直接实现 Flutter GPU 渲染。


当然,本篇不会介绍详细的 API 使用 ,这里只是单纯做一个简单的介绍,目前 Flutter GPU 进行光栅化的简单流程如下:



  • 获取 GPUContext。

  • GpuContext.createCommandBuffer 创建一个 CommandBuffer

  • CommandBuffer.createRenderPass 创建一个 RenderPass

  • 使用各种方法设置状态/管道并绑定资源 RenderPass

  • 附加绘图命令 RenderPass.draw

  • CommandBuffer 使用 CommandBuffer.submit (异步)提交绘制,所有 RenderPass 会按照其创建顺序进行编码


·····
///导入 flutter_gpu
import 'package:flutter_gpu/gpu.dart' as gpu;

ByteData float32(List<double> values) {
return Float32List.fromList(values).buffer.asByteData();
}

ByteData float32Mat(Matrix4 matrix) {
return Float32List.fromList(matrix.storage).buffer.asByteData();
}

class TrianglePainter extends CustomPainter {
TrianglePainter(this.time, this.seedX, this.seedY);

double time;
double seedX;
double seedY;

@override
void paint(Canvas canvas, Size size) {
/// Allocate a new renderable texture.
final gpu.Texture? renderTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.devicePrivate, 300, 300,
enableRenderTargetUsage: true,
enableShaderReadUsage: true,
coordinateSystem: gpu.TextureCoordinateSystem.renderToTexture);
if (renderTexture == null) {
return;
}

final gpu.Texture? depthTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.deviceTransient, 300, 300,
format: gpu.gpuContext.defaultDepthStencilFormat,
enableRenderTargetUsage: true,
coordinateSystem: gpu.TextureCoordinateSystem.renderToTexture);
if (depthTexture == null) {
return;
}

/// Create the command buffer. This will be used to submit all encoded
/// commands at the end.
final commandBuffer = gpu.gpuContext.createCommandBuffer();

/// Define a render target. This is just a collection of attachments that a
/// RenderPass will write to.
final renderTarget = gpu.RenderTarget.singleColor(
gpu.ColorAttachment(texture: renderTexture),
depthStencilAttachment: gpu.DepthStencilAttachment(texture: depthTexture),
);

/// Add a render pass encoder to the command buffer so that we can start
/// encoding commands.
final encoder = commandBuffer.createRenderPass(renderTarget);

/// Load a shader bundle asset.
final library = gpu.ShaderLibrary.fromAsset('assets/TestLibrary.shaderbundle')!;

/// Create a RenderPipeline using shaders from the asset.
final vertex = library['UnlitVertex']!;
final fragment = library['UnlitFragment']!;
final pipeline = gpu.gpuContext.createRenderPipeline(vertex, fragment);

encoder.bindPipeline(pipeline);

/// (Optional) Configure blending for the first color attachment.
encoder.setColorBlendEnable(true);
encoder.setColorBlendEquation(gpu.ColorBlendEquation(
colorBlendOperation: gpu.BlendOperation.add,
sourceColorBlendFactor: gpu.BlendFactor.one,
destinationColorBlendFactor: gpu.BlendFactor.oneMinusSourceAlpha,
alphaBlendOperation: gpu.BlendOperation.add,
sourceAlphaBlendFactor: gpu.BlendFactor.one,
destinationAlphaBlendFactor: gpu.BlendFactor.oneMinusSourceAlpha));

/// Append quick geometry and uniforms to a host buffer that will be
/// automatically uploaded to the GPU later on.
final transients = gpu.HostBuffer();
final vertices = transients.emplace(float32(<double>[
-0.5, -0.5, //
0, 0.5, //
0.5, -0.5, //
]));
final color = transients.emplace(float32(<double>[0, 1, 0, 1])); // rgba
final mvp = transients.emplace(float32Mat(Matrix4(
1, 0, 0, 0, //
0, 1, 0, 0, //
0, 0, 1, 0, //
0, 0, 0.5, 1, //
) *
Matrix4.rotationX(time) *
Matrix4.rotationY(time * seedX) *
Matrix4.rotationZ(time * seedY)));

/// Bind the vertex data. In this case, we won't bother binding an index
/// buffer.
encoder.bindVertexBuffer(vertices, 3);

/// Bind the host buffer data we just created to the vertex shader's uniform
/// slots. Although the locations are specified in the shader and are
/// predictable, we can optionally fetch the uniform slots by name for
/// convenience.
final mvpSlot = pipeline.vertexShader.getUniformSlot('mvp')!;
final colorSlot = pipeline.vertexShader.getUniformSlot('color')!;
encoder.bindUniform(mvpSlot, mvp);
encoder.bindUniform(colorSlot, color);

/// And finally, we append a draw call.
encoder.draw();

/// Submit all of the previously encoded passes. Passes are encoded in the
/// same order they were created in.
commandBuffer.submit();

/// Wrap the Flutter GPU texture as a ui.Image and draw it like normal!
final image = renderTexture.asImage();

canvas.drawImage(image, Offset(-renderTexture.width / 2, 0), Paint());
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}

class TrianglePage extends StatefulWidget {
const TrianglePage({super.key});

@override
State<TrianglePage> createState() => _TrianglePageState();
}

class _TrianglePageState extends State<TrianglePage> {
Ticker? tick;
double time = 0;
double deltaSeconds = 0;
double seedX = -0.512511498387847167;
double seedY = 0.521295573094847167;

@override
void initState() {
tick = Ticker(
(elapsed) {
setState(() {
double previousTime = time;
time = elapsed.inMilliseconds / 1000.0;
deltaSeconds = previousTime > 0 ? time - previousTime : 0;
});
},
);
tick!.start();
super.initState();
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Slider(
value: seedX,
max: 1,
min: -1,
onChanged: (value) => {setState(() => seedX = value)}),
Slider(
value: seedY,
max: 1,
min: -1,
onChanged: (value) => {setState(() => seedY = value)}),
CustomPaint(
painter: TrianglePainter(time, seedX, seedY),
),
],
);
}
}

GpuContext 是分配所有 GPU 资源并调度 GPU 的存在,而 GpuContext 仅有启用 Impeller 时才能访问。


DeviceBuffer 和 Texture 就是 GPU 拥有的资源,可以通过 GPUContext 创建获取,如 createDeviceBuffercreateTexture



  • DeviceBuffer 简单理解就是在 GPU 上分配的简单字节串,主要用于存储几何数据(索引和顶点属性)以及统一数据

  • Texture 是一个特殊的设备缓冲区


CommandBuffer 用于对 GPU 上的异步执行进行排队和调度工作。


RenderPass 是 GPU 上渲染工作的顶层单元。


RenderPipeline 提供增量更改绘制所有状态以及附加绘制调用的方法如 RenderPass.draw()


可以想象,通过 Flutter GPU,Flutter 开发者可以更简单地对 GPU 进行更精细的控制,通过与 HAL 直接通信,创建 GPU 资源并记录 GPU 命令,从而最大限度的发挥 Flutter 的渲染能力。


另外,对于 3D 支持的 Flutter Scene , 可以通过使用 native-assets 来设置 Flutter Scene 的 3D 模型自动导入,通过导入编译模型 .model 之后,就可以通过 Dart 实现一些 3D 的渲染。


import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_scene/camera.dart';
import 'package:flutter_scene/node.dart';
import 'package:flutter_scene/scene.dart';
import 'package:vector_math/vector_math.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});

@override
MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
double elapsedSeconds = 0;
Scene scene = Scene();

@override
void initState() {
createTicker((elapsed) {
setState(() {
elapsedSeconds = elapsed.inMilliseconds.toDouble() / 1000;
});
}).start();

Node.fromAsset('build/models/DamagedHelmet.model').then((model) {
model.name = 'Helmet';
scene.add(model);
});

super.initState();
}

@override
Widget build(BuildContext context) {
final painter = ScenePainter(
scene: scene,
camera: PerspectiveCamera(
position: Vector3(sin(elapsedSeconds) * 3, 2, cos(elapsedSeconds) * 3),
target: Vector3(0, 0, 0),
),
);

return MaterialApp(
title: 'My 3D app',
home: CustomPaint(painter: painter),
);
}
}

class ScenePainter extends CustomPainter {
ScenePainter({required this.scene, required this.camera});
Scene scene;
Camera camera;

@override
void paint(Canvas canvas, Size size) {
scene.render(camera, canvas, viewport: Offset.zero & size);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}



目前 Flutter GPU 和 Flutter Scene 的支持还十分有限,但是借助 Impeller ,Flutter 开启了新的可能,可以说是,Flutter 团队完全掌控了渲染堆栈,在除了自定义更丰富的 2D 场景之外,也为 Flutter 开启了 3D 游戏的可能,2023 年 Flutter Forward 大会的承诺,目前正在被落地实现




详细 API 使用例子可以参看 :medium.com/flutter/get…



如果你对 Flutter Impeller 和其着色器感兴趣,也可以看:



作者:恋猫de小郭
来源:juejin.cn/post/7399985723673821193
收起阅读 »

uni-app初体验,如何实现一个外呼APP

起因 2024年3月31日,我被公司裁员了。 2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。 2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回...
继续阅读 »

起因


2024年3月31日,我被公司裁员了。


2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。


2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。


2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。


可行性分析


涉及到的修改:



  • 系统前后端

  • 拨号功能的APP


拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。


我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!


因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。


第一版


需求分析


虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。


但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。



  • 拨号APP

    • 权限校验

      • 实现部分(拨号、录音、文件读写)



    • ❌权限引导

    • 查询当前手机号

      • 直接使用input表单,由用户输入



    • 查询当前手机号的拨号任务

      • 因为后端没有socket,使用setTimeout模拟轮询实现。



    • 拨号、录音、监测拨号状态

      • 根据官网API和一些安卓原生实现



    • 更新任务状态

      • 告诉后端拨号完成



    • ❌通话录音上传

    • ❌通话日志上传

    • ❌本地通时通次统计

    • 程序运行日志

    • 其他

      • 增加开始工作、开启录音的状态切换

      • 兼容性,只兼容安卓手机即可






基础设计


一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。


开干


虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。


1、下载 HbuilderX。


2、新建项目,直接选择了默认模板。


3、清空 Hello页面,修改文件名,配置路由。


4、在vue文件里写主要的功能实现,并增加 Http.jsRecord.jsPhoneCall.jsPower.js来实现对应的模块功能。


⚠️关于测试和打包


运行测试


在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:



  • 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。

    • 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。



  • 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。

    • 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。




关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。


但是不知道为什么,我这里一直显示安装自定义基座失败。。。


打包测试


除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。


点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。


我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。


另外,在打包之前我们首先要配置manifest.json,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置Android官方权限常量文档。以下是拨号所需的一些权限:



// 录制音频
<uses-permission android:name="android.permission.RECORD_AUDIO" />
// 修改音频设置
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

// 照相机
<uses-permission android:name="android.permission.CAMERA" />
// 写入外部存储
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// 读取外部存储
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

// 读取电话号码
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
// 拨打电话
<uses-permission android:name="android.permission.CALL_PHONE" />
// 呼叫特权
<uses-permission android:name="android.permission.CALL_PRIVILEGED" />
// 通话状态
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 读取拨号日志
<uses-permission android:name="android.permission.READ_CALL_LOG" />
// 写入拨号日志
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
// 读取联系人
<uses-permission android:name="android.permission.READ_CONTACTS" />
// 写入联系人
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
// 读取SMS?
<uses-permission android:name="android.permission.READ_SMS" />

// 写入设置
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
// 唤醒锁定?
<uses-permission android:name="android.permission.WAKE_LOCK" />
// 系统告警窗口?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 接受完整的引导?
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。


⚠️权限校验


1、安卓 1


好像除了这样的写法还可以写"scope.record"或者permission.CALL_PHONE


permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
// 1 获得权限 2 本次拒绝 -1 永久拒绝
});

2、安卓 2


plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
// e.granted 获得权限
// e.deniedPresent 本次拒绝
// e.deniedAlways 永久拒绝
});

3、uni-app


这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。


// 检查权限
uni.hasPermission({
permission: 'makePhoneCall',
success() {
console.log('已经获得拨号权限');
},
fail() {
// 示例:请求权限
uni.authorize({
scope: 'scope.makePhoneCall',
success() {
console.log('已经获得授权');
},
fail() {
console.log('用户拒绝授权');
// 引导用户到设置中开启权限
uni.showModal({
title: '提示',
content: '请在系统设置中打开拨号权限',
success: function(res) {
if (res.confirm) {
// 引导用户到设置页
uni.openSetting();
}
}
});
}
});
}
});

✅拨号


三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。


另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;,我这里只需要兼容固定机型。


1、uni-app API


uni.makePhoneCall({
phoneNumber: phone,
success: () => {
log(`成功拨打电话${phone}`);
},
fail: (err) => {
log(`拨打电话失败! ${err}`);
}
});

2、Android


plus.device.dial(phone, false);

3、Android 原生


写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。


// Android
function PhoneCallAndroid(phone) {
if (!plus || !plus.android) return;
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
}

✅拨号状态查询


第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。


export function getCallStatus(callback) {
if (!plus || !plus.android) return;
let maintest = plus.android.runtimeMainActivity();
let Contexttest = plus.android.importClass("android.content.Context");
let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: (Contexttest, intent) => {
plus.android.importClass(intent);
let phoneStatus = telManager.getCallState();
callback && callback(phoneStatus);
//电话状态 0->空闲状态 1->振铃状态 2->通话存在
}
});
let IntentFilter = plus.android.importClass("android.content.IntentFilter");
let filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
maintest.registerReceiver(receiver, filter);
}

⚠️录音


录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!


一坑


就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。


二坑


后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。


但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。


三坑


虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。


另辟蹊径


其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。


// 录音

var log = console.log,
recorder = null,
// innerAudioContext = null,
isRecording = false;

export function startRecording(logFun = console.log) {
if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
log = logFun;
recorder = uni.getRecorderManager();
// innerAudioContext = uni.createInnerAudioContext();
// innerAudioContext.autoplay = true;
recorder.onStart(() => {
isRecording = true;
log(`录音已开始 ${new Date()}`);
});
recorder.onError((err) => {
log(`录音出错:${err}`);
console.log("录音出错:", err);
});
recorder.onInterruptionBegin(() => {
log(`检测到录音被来电中断...`);
});
recorder.onPause(() => {
log(`检测到录音被来电中断后尝试启动录音..`);
recorder.start({
duration: 10 * 60 * 1000,
});
});
recorder.start({
duration: 10 * 60 * 1000,
});
}

export function stopRecording() {
if (!recorder) return
recorder.onStop((res) => {
isRecording = false;
log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
// 处理录制的音频文件(例如,保存或上传)
// powerCheckSaveRecord(res.tempFilePath);
saveRecording(res.tempFilePath);
});
recorder.stop();
}

export function saveRecording(filePath) {
// 使用uni.saveFile API保存录音文件
log('开始保存录音文件');
uni.saveFile({
tempFilePath: filePath,
success(res) {
// 保存成功后,res.savedFilePath 为保存后的文件路径
log(`录音保存成功:${res.savedFilePath}`);
// 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
},
fail(err) {
log(`录音保存失败! ${err}`);
console.error("录音保存失败:", err);
},
});
}

运行日志


为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。


联调、测试、交工


搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。


image.png


第二版


2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。


我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。


需求分析



  • ✅拨号APP

    • 登录

      • uni-id实现



    • 权限校验

      • 拨号权限、文件权限、自带通话录音配置



    • 权限引导

      • 文件权限引导

      • 通话录音配置引导

      • 获取手机号权限配置引导

      • 后台运行权限配置引导

      • 当前兼容机型说明



    • 拨号

      • 获取手机号

        • 是否双卡校验

        • 直接读取手机卡槽中的手机号码

        • 如果用户不会设置权限兼容直接input框输入



      • 拨号

      • 全局拨号状态监控注册、取消

        • 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断





    • 录音

      • 读取录音文件列表

        • 支持全部或按时间查询



      • 播放录音

      • ❌上传录音文件到云端



    • 通时通次统计

      • 云端数据根据上面状态监控获取并上传

        • 云端另写一套页面



      • 本地数据读取本机的通话日志并整理统计

        • 支持按时间查询

        • 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等





    • 其他

      • 优化日志显示形式

        • 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式

        • 在上个组件的基础上实现权限校验和权限引导

        • 在上两个组件的基础上实现主页面逻辑功能



      • 增加了拨号测试、远端连接测试

      • 修改了APP名称和图标

      • 打包时增加了自有证书






中间遇到并解决的一些问题


关于框架模板


这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。


建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id配置一个JSON文件来约定用户系统的一些配置。


打包的时候也要在manifest.json将部分APP模块配置进去。


还搞了挺久的,半天才查出来。。


类聊天组件实现



  • 设计

    • 每个对话为一个无状态组件

    • 一个图标、一个名称、一个白底的展示区域、一个白色三角

    • 内容区域通过类型判断如何渲染

    • 根据前后两条数据时间差判断是否显示灰色时间



  • 参数

    • ID、名称、图标、时间、内容、内容类型等



  • 样式

    • 根据左边右边区分发送接收方,给与不同的类名

    • flex布局实现




样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。


关于后台运行


这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。



  • 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)

  • 通过不停的访问位置信息

  • 通过查找相应的插件、询问GPT、百度查询

  • 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)

  • 通过切入后台后,发送消息实现(没测试)


测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。


关于通话状态、通话记录中的类型


这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。


通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。


通话日志:呼入、呼出、未接、语音邮件、拒接


交付


总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。


image.png


后面的计划



  • 把图标改好

  • 把录音文件是否已上传、录音上传功能做好

  • 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等

  • 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限

  • 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去

  • 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西

  • 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的

  • 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤


大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。


最后


现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!



😂被举报标题党了,换个名字。


作者:前端湫
来源:juejin.cn/post/7368421971384860684
收起阅读 »

uniapp-实现安卓app水印相机

写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。 1.相机实现 水印相机实现有两种方式,在小程序端可以用cam...
继续阅读 »

写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。


1.相机实现


水印相机实现有两种方式,在小程序端可以用camera来实现,但在安卓端不支持camera,使用uniapp的live-pusher来实现相机。


而live-pusher推荐使用nvue来做,好处是



  1. nvue也可一套代码编译多端。

  2. nvue的cover-view比vue的cover-view更强大,在视频上绘制元素更容易。如果只考虑App端的话,不用cover-view,任意组件都可以覆盖组件,因为nvue没有层级问题

  3. 若需要视频内嵌在swiper里上下滑动(类抖音、映客首页模式),App端只有nvue才能实现 当然nvue相比vue的坏处是css写法受限,如果只开发微信小程序,不考虑App,那么使用vue页面也是一样的。



  • App平台:使用 <live-pusher/> 组件,打包 App 时必须勾选 manifest.json->App 模块权限配置->LivePusher(直播推流) 模块。




上代码!


<template>
<view class="live-camera" :style="{ width: windowWidth, height: windowHeight }">
<view class="preview" :style="{ width: windowWidth, height: windowHeight }">
<live-pusher
id="livePusher"
ref="livePusher"
class="livePusher"
mode="FHD"
beauty="0"
whiteness="0"
:aspect="aspect"
min-bitrate="1000"
audio-quality="16KHz"
device-position="back"
:auto-focus="true"
:muted="true"
:enable-camera="true"
:enable-mic="false"
:zoom="false"
@statechange="statechange"
:style="{ width: windowWidth, height: windowHeight }"
>
</live-pusher>
<!--这里修改水印的样式-->
<cover-view class="remind">
<text class="remind-text" style="">{{ message }}</text>
<text class="remind-text" style="">经度:1002.32</text>
<text class="remind-text" style="">纬度:1002.32</text>
</cover-view>
</view>
<view class="menu">
<!--底部菜单区域背景-->
<cover-image class="menu-mask" src="/static/live-camera/bar.png"></cover-image>

<!--返回键-->
<cover-image class="menu-back" @tap="back" src="/static/live-camera/back.png"></cover-image>

<!--快门键-->
<cover-image class="menu-snapshot" @tap="snapshot" src="/static/live-camera/shutter.png"></cover-image>

<!--反转键-->
<cover-image class="menu-flip" @tap="flip" src="/static/live-camera/flip.png"></cover-image>
</view>
</view>
</template>

<script>
let _this = null;
export default {
data() {
return {
dotype: 'watermark',
message: '水印相机', //水印内容
poenCarmeInterval: null, //打开相机的轮询
aspect: '2:3', //比例
windowWidth: '', //屏幕可用宽度
windowHeight: '', //屏幕可用高度
camerastate: false, //相机准备好了
livePusher: null, //流视频对象
snapshotsrc: null //快照
};
},
onLoad(e) {
_this = this;
if (e.dotype != undefined) this.dotype = e.dotype;
this.initCamera();
},
onReady() {
this.livePusher = uni.createLivePusherContext('livePusher', this);
this.startPreview(); //开启预览并设置摄像头
this.poenCarme();
},
methods: {
//轮询打开
poenCarme() {
//#ifdef APP-PLUS
if (plus.os.name == 'Android') {
this.poenCarmeInterval = setInterval(function () {
console.log(_this.camerastate);
if (!_this.camerastate) _this.startPreview();
}, 2500);
}
//#endif
},
//初始化相机
initCamera() {
uni.getSystemInfo({
success: function (res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
let zcs = _this.aliquot(_this.windowWidth, _this.windowHeight);
_this.aspect = _this.windowWidth / zcs + ':' + _this.windowHeight / zcs;
console.log('画面比例:' + _this.aspect);
}
});
},

//整除数计算
aliquot(x, y) {
if (x % y == 0) return y;
return this.aliquot(y, x % y);
},

//开始预览
startPreview() {
this.livePusher.startPreview({
success: (a) => {
console.log(a);
}
});
},

//停止预览
stopPreview() {
this.livePusher.stopPreview({
success: (a) => {
_this.camerastate = false; //标记相机未启动
}
});
},

//状态
statechange(e) {
//状态改变
console.log(e);
if (e.detail.code == 1007) {
_this.camerastate = true;
} else if (e.detail.code == -1301) {
_this.camerastate = false;
}
},

//返回
back() {
uni.navigateBack();
},

//抓拍
snapshot() {
this.livePusher.snapshot({
success: (e) => {
_this.snapshotsrc = e.message.tempImagePath;
_this.stopPreview();
_this.setImage();
uni.navigateBack();
}
});
},

//反转
flip() {
this.livePusher.switchCamera();
},

//设置
setImage() {
let pages = getCurrentPages();
let prevPage = pages[pages.length - 2]; //上一个页面

//直接调用上一个页面的setImage()方法,把数据存到上一个页面中去
prevPage.$vm.setImage({ path: _this.snapshotsrc, dotype: this.dotype });
}
}
};
</script>

<style lang="scss">
.live-camera {
justify-content: center;
align-items: center;
.preview {
justify-content: center;
align-items: center;
.remind {
position: absolute;
bottom: 180rpx;
left: 20rpx;
width: 130px;
z-index: 100;
.remind-text {
color: #dddddd;
font-size: 40rpx;
text-shadow: #fff 1px 0 0, #fff 0 1px 0, #fff -1px 0 0, #fff 0 -1px 0;
}
}
}
.menu {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
align-items: center;
justify-content: center;
.menu-mask {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
}
.menu-back {
position: absolute;
left: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
.menu-snapshot {
width: 130rpx;
height: 130rpx;
z-index: 99;
}
.menu-flip {
position: absolute;
right: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
}
}
</style>

2.水印图片绘制


图片水印返回上一页用<canvas>添加水印

<template>
<view class="page">
<view style="height: 80rpx;"></view>
<navigator class="buttons" url="../camera/watermark/watermark"><button type="primary">打开定制水印相机</button></navigator>
<view style="height: 80rpx;"></view>

<view>拍摄结果预览图,见下方</view>
<image class="preview" :src="imagesrc" mode="aspectFit" style="width:710rpx:height:710rpx;margin: 20rpx;"></image>

<canvas id="canvas-clipper" canvas-id="canvas-clipper" type="2d" :style="{width: canvasSiz.width+'px',height: canvasSiz.height+'px',position: 'absolute',left:'-500000px',top: '-500000px'}" />
</view>
</template>

<script>
var _this;
export default {
data() {
return {
windowWidth:'',
windowHeight:'',
imagesrc: null,
canvasSiz:{
width:188,
height:273
}
};
},
onLoad() {
_this= this;
this.init();
},
methods: {

//设置图片
setImage(e) {
console.log(e);
//显示在页面
//this.imagesrc = e.path;
if(e.dotype =='idphoto'){
_this.zjzClipper(e.path);
}else if(e.dotype =='watermark'){
_this.watermark(e.path);
}else{
_this.savePhoto(e.path);
}
},


//添加照片水印
watermark(path){
uni.getImageInfo({
src: path,
success: function(image) {
console.log(image);

_this.canvasSiz.width =image.width;
_this.canvasSiz.height =image.height;

//担心尺寸重置后还没生效,故做延迟
setTimeout(()=>{
let ctx = uni.createCanvasContext('canvas-clipper', _this);

ctx.drawImage(
path,
0,
0,
image.width,
image.height
);

//具体位置如需和相机页面上一致还需另外做计算,此处仅做大致演示
ctx.setFillStyle('white');
ctx.setFontSize(40);
ctx.fillText('live-camera', 20, 100);


//再来加个时间水印
var now = new Date();
var time= now.getFullYear()+'-'+now.getMonth()+'-'+now.getDate()+' '+now.getHours()+':'+now.getMinutes()+':'+now.getMinutes();
ctx.setFontSize(30);
ctx.fillText(time, 20, 140);

ctx.draw(false, () => {
uni.canvasToTempFilePath(
{
destWidth: image.width,
destHeight: image.height,
canvasId: 'canvas-clipper',
fileType: 'jpg',
success: function(res) {
_this.savePhoto(res.tempFilePath);
}
},
_this
);
});
},500)


}
});
},

//保存图片到相册,方便核查
savePhoto(path){
this.imagesrc = path;
//保存到相册
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
uni.showToast({
title: '已保存至相册',
duration: 2000
});
}
});
},

//初始化
init(){
let _this = this;
uni.getSystemInfo({
success: function(res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
}
});
}

}
};
</script>

<style lang="scss">
.page {
width: 750rpx;
justify-content: center;
align-items: center;
flex-direction:column;
display: flex;
.buttons {
width: 600rpx;
}
}


</style>

8dbee86b262efefc549933df666fbc7.jpg


作者:山沫微云
来源:juejin.cn/post/7399983106750447627
收起阅读 »

鸿蒙next高仿微信来了 我不允许你不会

前言导读 各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习 效果图 特点 高仿程度80 目前不支持即时通...
继续阅读 »

前言导读


各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习


效果图


image-20240809140521780


image-20240809140834969


image-20240809140849586


image-20240809140529987


image-20240809140538888


image-20240809140719388


特点



  1. 高仿程度80

  2. 目前不支持即时通讯功能

  3. 支持最新的api 12

  4. 目前做了账号注册和登录自动登录功能入口


具体实现




  • 启动页面




/**
* 创建人:xuqing
* 创建时间:2024年7月14日22:56:15
* 类说明:欢迎页面
*
*/


import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
import CommonConstant from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginModel } from '../bean/LoginModel';

let dataPreferences: preferences.Preferences | null = null;


@Entry
@Component
struct Welcome {

  async aboutToAppear(){
    let options: preferences.Options = { name: 'myStore' };
    dataPreferences = preferences.getPreferencesSync(getContext(), options);
    let getusername=dataPreferences.getSync('username','');
    let getpassword=dataPreferences.getSync('password','');
    if(getusername===''||getpassword===''){
      router.pushUrl({
        url:'pages/LoginPage'
      })
    }else {
      let username:string='username=';
      let password:string='&password=';
      let netloginurl=CommonConstant.LOGIN+username+getusername+password+getpassword;
      httpRequestGet(netloginurl).then((data)=>{
        Logger.error("请求数据--->"+ data.toString());
        let loginmodel:LoginModel=JSON.parse(data.toString());
        if(loginmodel.code===200){
          router.pushUrl({
            url:'pages/Index'
          })
        }else{
          router.pushUrl({
            url:'pages/LoginPage'
          })
        }
      })
    }

}

build() {
  RelativeContainer(){
    Image($r('app.media.weixinbg'))
      .width('100%')
      .height('100%')

  }.height('100%')
  .width('100%')
  .backgroundColor(Color.Green)

}
}

登录页面


import CommonConstant, * as commonConst from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginData, LoginModel} from '../bean/LoginModel';
import prompt from '@ohos.promptAction';
import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
let dataPreferences: preferences.Preferences | null = null;



/**
* 创建人:xuqing
* 创建时间:2024年7月14日17:00:03
* 类说明:登录页面
*
*/

//输入框样式
@Extend(TextInput) function inputStyle(){
.placeholderColor($r('app.color.placeholder_color'))
.height(45)
.fontSize(18)
.backgroundColor($r('app.color.background'))
.width('100%')
.padding({left:0})
.margin({top:12})
}
//线条样式
@Extend(Line) function lineStyle(){
.width('100%')
.height(1)
.backgroundColor($r('app.color.line_color'))
}
//黑色字体样式
@Extend(Text) function blackTextStyle(size?:number ,height?:number){
.fontColor($r('app.color.black_text_color'))
.fontSize(18)
.fontWeight(FontWeight.Medium)
}

@Entry
@Component
struct LoginPage {

@State accout:string='';
@State password:string='';
async login(){
  let username:string='username=';
  let password:string='&password=';
  let netloginurl=CommonConstant.LOGIN+username+this.accout+password+this.password;
    Logger.error("请求url"+netloginurl);
    await httpRequestGet(netloginurl).then((data)=>{
      Logger.error("请求结果"+data.toString());
      let loginModel:LoginModel=JSON.parse(data.toString());
      let msg=loginModel.msg;
      let logindata:LoginData=loginModel.user;
      let token=loginModel.token;
      let userid=logindata.id;
      let options: preferences.Options = { name: 'myStore' };
      dataPreferences = preferences.getPreferencesSync(getContext(), options);

      if(loginModel.code===200){
        Logger.error("登录成功");
        dataPreferences.putSync('token',token);
        dataPreferences.putSync('id',userid);
        dataPreferences.putSync('username',this.accout);
        dataPreferences.putSync('password',this.password);
        dataPreferences!!.flush()
        router.pushUrl({
          url:'pages/Index'
        })
      }else {
        prompt.showToast({
          message:msg
        })
      }
    })
}


build() {
    Column(){
      Image($r('app.media.weixinicon'))
        .width(48)
        .height(48)
        .margin({top:100,bottom:8})
        .borderRadius(8)
        Text('登录界面')
          .fontSize(24)
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.title_text_color'))
      Text('登录账号以使用更多服务')
        .fontSize(16)
        .fontColor($r('app.color.login_more_text_color'))
        .margin({bottom:30,top:8})

      Row(){
        Text('账号').blackTextStyle()
        TextInput({placeholder:'请输入账号'})
          .maxLength(12)
          .type(InputType.Number)
          .inputStyle()
          .onChange((value:string)=>{
            this.accout=value;
          }).margin({left:20})

      }.justifyContent(FlexAlign.SpaceBetween)
      .width('100%')
      .margin({top:8})
      Line().lineStyle().margin({left:80})



      Row(){
        Text('密码').blackTextStyle()
        TextInput({placeholder:'请输入密码'})
          .maxLength(12)
          .type(InputType.Password)
          .inputStyle()
          .onChange((value:string)=>{
            this.password=value;
          }).margin({left:20})
      }.justifyContent(FlexAlign.SpaceBetween)
      .width('100%')
      .margin({top:8})
      Line().lineStyle().margin({left:80})

      Button('登录',{type:ButtonType.Capsule})
        .width('90%')
        .height(40)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .backgroundColor($r('app.color.login_button_color'))
        .margin({top:47,bottom:12})
        .onClick(()=>{
          this.login()
        })
      Text('注册账号').onClick(()=>{
        router.pushUrl({
          url:'pages/RegisterPage'
        })
      }).fontColor($r('app.color.login_blue_text_color'))
        .fontSize(12)
        .fontWeight(FontWeight.Medium)
    }.backgroundColor($r('app.color.background'))
  .height('100%')
  .width('100%')
  .padding({
    left:12,
    right:12,
    bottom:24
  })

}
}



  • 主页index


    import home from './Home/Home';
    import contacts from './Contact/Contacts';
    import Discover from './Discover/Discover';
    import My from './My/My';
    import common from '@ohos.app.ability.common';
    import prompt from '@ohos.promptAction';


    @Entry
    @Component
    struct Index {
    @State message: string = 'Hello World';

    private backTime :number=0;
    @State fontColor: string = '#182451'
    @State selectedFontColor: string = 'rgb(0,196,104)'
    private controller:TabsController=new TabsController();
    showtoast(msg:string){
      prompt.showToast({
        message:msg
      })
    }



    @State SelectPos:number=0;
    private positionClick(){
      this.SelectPos=0;
      this.controller.changeIndex(0);

    }

    private companyClick(){
      this.SelectPos=1;
      this.controller.changeIndex(1);
    }
    private messageClick(){
      this.SelectPos=2;
      this.controller.changeIndex(2);

    }

    private myClick(){
      this.SelectPos=3;
      this.controller.changeIndex(3);

    }

    onBackPress(): boolean | void {
      let nowtime=Date.now();
      if(nowtime-this.backTime<1000){
        const mContext=getContext(this) as common.UIAbilityContext;
        mContext.terminateSelf()
      }else{
        this.backTime=nowtime;
        this.showtoast("再按一次将退出当前应用")
      }
      return true;
    }


    // ['微信','通讯录','发现','我']
    build() {
      Flex({direction:FlexDirection.Column,alignItems:ItemAlign.Center,justifyContent:FlexAlign.Center}){
        Tabs({controller:this.controller}){
          TabContent(){
            home();
          }
          TabContent(){
            contacts();
          }
          TabContent(){
            Discover();
          }
          TabContent(){
            My()
          }
        }.scrollable(false)
        .barHeight(0)
        .animationDuration(0)

        Row(){
          Column(){
            Image((this.SelectPos==0?$r('app.media.wetab01'):$r('app.media.wetab00')))
              .width(20).height(20)
              .margin({top:5})
            Text('微信')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==0?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.positionClick.bind(this))



          Column(){
            Image((this.SelectPos==1?$r('app.media.wetab11'):$r('app.media.wetab10')))
              .width(20).height(20)
              .margin({top:5})
            Text('通讯录')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==1?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.companyClick.bind(this))


          Column(){
            Image((this.SelectPos==2?$r('app.media.wetab21'):$r('app.media.wetab20')))
              .width(20).height(20)
              .margin({top:5})
            Text('发现')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==2?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.messageClick.bind(this))


          Column(){
            Image((this.SelectPos==5?$r('app.media.wetab31'):$r('app.media.wetab30')))
              .width(20).height(20)
              .margin({top:5})
            Text('我')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==5?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.myClick.bind(this))
        }.alignItems(VerticalAlign.Bottom).width('100%').height(60).margin({top:0,right:0,bottom:0,left:0})

      }.width('100%')
      .height('100%')


    }
    }

    后续目标



    1. 微信朋友圈

    2. 聊天菜单(相册,拍摄...)组件栏

    3. 语音|视频页面

    4. 支持群聊头像

    5. 支持图片,红包等聊天内容类型(现已支持图片类型)

    6. 二维码扫描




最后总结:


因为篇幅有限我也不能整个项目都展开讲,有兴趣的同学能可以关注我B站课程。 后续能我会把这个项目更新到项目里面 供大家学习


B站课程地址:http://www.bilibili.com/cheese/play…


团队介绍


团队介绍:坚果派由坚果等人创建,团队由12位华为HDE以及若干热爱鸿蒙的开发者和其他领域的三十余位万粉博主运营。专注于分享 HarmonyOS/OpenHarmony,ArkUI-X,元服务,仓颉,团队成员聚集在北京,上海,南京,深圳,广州,宁夏等地,目前已开发鸿蒙 原生应用,三方库60+,欢迎进行课程,项目等合作。


作者:坚果派_xq9527
来源:juejin.cn/post/7400741845508522019
收起阅读 »

UNIAPP开发电视app教程

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。 开发难点 如何方便的开发调试 如何使需要被聚焦的元素获取聚焦状态 如何使被聚焦的元素滚动到视图中心位置 如何在切换路由时,缓存聚焦的状态 如...
继续阅读 »

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。


开发难点



  1. 如何方便的开发调试

  2. 如何使需要被聚焦的元素获取聚焦状态

  3. 如何使被聚焦的元素滚动到视图中心位置

  4. 如何在切换路由时,缓存聚焦的状态

  5. 如何启用wgt和apk两种方式的升级


一、如何方便的开发调试


之前我在论坛看到人家说,没办法呀,电脑搬到电视,然后调试。


其实大可不必,安装android studio里边创建一个模拟器就可以了。


注意:最好安装和电视系统相同的版本号,我这里是长虹电视,安卓9所以使用安卓9的sdk


二、如何使需要被聚焦的元素获取聚焦状态


uniapp的本质上是webview, 因此我们可以在它的元素上添加tabIndex, 就可以获取焦点了。


  <view class="card" tabindex="0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>
</view>


.card {
border-radius: 1.25vw;
overflow: hidden;
}
.card:focus {
box-shadow: 0 0 0 0.3vw #fff, 0 0 1vw 0.3vw #333;
outline: none;
transform: scale(1.03);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}


三、如何使被聚焦的元素滚动到视图中心位置


使用renderjs进行实现如下


<script  module="homePage" lang="renderjs">
export default {
mounted() {
let isScrolling = false; // 添加一个标志位,表示是否正在滚动
document.body.addEventListener('focusin', e => {
if (!isScrolling) {
// 检查是否正在滚动
isScrolling = true; // 设置滚动标志为true
requestAnimationFrame(() => {
// @ts-ignore
e.target.scrollIntoView({
behavior: 'smooth', // @ts-ignore
block: e.target.dataset.index ? 'end' : 'center'
});
isScrolling = false; // 在滚动完成后设置滚动标志为false
});
}
});
}
};
</script>

就可以使被聚焦元素滚动到视图中心,requestAnimationFrame的作用是缓存


四、如何在切换路由时,缓存聚焦的状态


通过设置tabindex属性为0和1,会有不同的效果:



  1. tabindex="0":将元素设为可聚焦,并按照其在文档中的位置来确定焦点顺序。当使用Tab键进行键盘导航时,tabindex="0"的元素会按照它们在源代码中的顺序获取焦点。这可以用于将某些非交互性元素(如
    等)设为可聚焦元素,使其能够被键盘导航。

  2. tabindex="1":将元素设为可聚焦,并将其置于默认的焦点顺序之前。当使用Tab键进行键盘导航时,tabindex="1"的元素会在默认的焦点顺序之前获取焦点。这通常用于重置焦点顺序,或者将某些特定的元素(如重要的输入字段或操作按钮)置于首位。


需要注意的是,如果给多个元素都设置了tabindex属性,那么它们的焦点顺序将取决于它们的tabindex值,数值越小的元素将优先获取焦点。如果多个元素具有相同的tabindex值,则它们将按照它们在文档中的位置来确定焦点顺序。同时,负数的tabindex值也是有效的,它们将优先于零和正数值获取焦点。


我们要安装缓存插件,如pinia或vuex,需要缓存的页面单独配置


import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({ home_active_tag: 'active0', hot_active_tag: 'hot0', dish_active_tag: 'dish0' })
});


更新一下业务代码


组件区域
<view class="card" :tabindex="home_active_tag === 'packagecard' + props.id ? 1 : 0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>

</view>

const { home_active_tag } = storeToRefs(useGlobalStore());

页面区域

<view class="content">
<FoodCard
v-for="_package in list.dishes"
@click="goShopByFood(_package)"
:id="_package.id"
:name="_package.name"
:image="_package.image"
:tags="_package.tags"
:price="_package.price"
:shop_name="_package.shop_name"
:shop_id="_package.shop_id"
:key="_package.id"
></FoodCard>
<image
class="card"
@click="goMore"
:tabindex="home_active_tag === 'more' ? 1 : 0"
style="width: 29.375vw; height: 25.9375vw"
src="/static/home/more.png"
mode="aspectFill"
/>
</view>

const goShopByFood = async (row: Record<string, any>) => {
useGlobalStore().home_active_tag = 'foodcard' + row.id;
uni.navigateTo({
url: `/pages/shop/index?shop_id=${row.shop_id}`,
animationDuration: 500,
animationType: 'zoom-fade-out'
});
};


如果,要设置启动默认焦点 id和index可默认设置,推荐启动第一个焦点组用index,它可以确定


  <view class="active">
<image
v-for="(active, i) in list.active"
:key="active.id"
@click="goActive(active, i)"
:tabindex="home_active_tag === 'active' + i ? 1 : 0"
:src="`${VITE_URL}${active.image}`"
data-index="0"
fade-show
lazy-load
mode="aspectFill"
class="card"
></image>
</view>

import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
home_active_tag: 'active0', //默认选择
hot_active_tag: 'hot0',
dish_active_tag: 'dish0'
})
});


对于多层级的,要注意销毁,在前往之前设置默认焦点


const goHot = (index: number) => {
useGlobalStore().home_active_tag = 'hotcard' + index;
useGlobalStore().hot_active_tag = 'hot0';
uni.navigateTo({ url: `/pages/hot/index?index=${index}`, animationDuration: 500, animationType: 'zoom-fade-out' });
};


五、如何启用wgt和apk两种方式的升级


pages.json


{
"path": "components/update/index",
"style": {
"disableScroll": true,
"backgroundColor": "#0068d0",
"app-plus": {
"backgroundColorTop": "transparent",
"background": "transparent",
"titleNView": false,
"scrollIndicator": false,
"popGesture": "none",
"animationType": "fade-in",
"animationDuration": 200
}
}
}


组件


<template>
<view class="update">
<view class="content">
<view class="content-top">
<text class="content-top-text">发现版本</text>
<image class="content-top" style="top: 0" width="100%" height="100%" src="@/static/bg_top.png"> </image>
</view>
<text class="message"> {{ message }} </text>
<view class="progress-box">
<progress
class="progress"
border-radius="35"
:percent="progress.progress"
activeColor="#3DA7FF"
show-info
stroke-width="10"
/>

<view class="progress-text">
<text>安装包正在下载,请稍后,系统会自动重启</text>
<text>{{ progress.totalBytesWritten }}MB/{{ progress.totalBytesExpectedToWrite }}MB</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app';
import { reactive, ref } from 'vue';
const message = ref('');
const progress = reactive({ progress: 0, totalBytesExpectedToWrite: '0', totalBytesWritten: '0' });
onLoad((query: any) => {
message.value = query.content;
const downloadTask = uni.downloadFile({
url: `${import.meta.env.VITE_URL}/${query.url}`,
success(downloadResult) {
plus.runtime.install(
downloadResult.tempFilePath,
{ force: false },
() => {
plus.runtime.restart();
},
e => {}
);
}
});
downloadTask.onProgressUpdate(res => {
progress.progress = res.progress;
progress.totalBytesExpectedToWrite = (res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2);
progress.totalBytesWritten = (res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2);
});
});
</script>
<style lang="less">
page {
background: transparent;
.update {
/* #ifndef APP-NVUE */
display: flex; /* #endif */
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.65);
.content {
position: relative;
top: 0;
width: 50vw;
height: 50vh;
background-color: #fff;
box-sizing: border-box;
padding: 0 50rpx;
font-family: Source Han Sans CN;
border-radius: 2vw;
.content-top {
position: absolute;
top: -5vw;
left: 0;
image {
width: 50vw;
height: 30vh;
}
.content-top-text {
width: 50vw;
top: 6.6vw;
left: 3vw;
font-size: 3.8vw;
font-weight: bold;
color: #f8f8fa;
position: absolute;
z-index: 1;
}
}
}
.message {
position: absolute;
top: 15vw;
font-size: 2.5vw;
}
.progress-box {
position: absolute;
width: 45vw;
top: 20vw;
.progress {
width: 90%;
border-radius: 35px;
}
.progress-text {
margin-top: 1vw;
font-size: 1.5vw;
}
}
}
}
</style>


App.vue


import { onLaunch } from '@dcloudio/uni-app';
import { useRequest } from './hooks/useRequest';
import dayjs from 'dayjs'; onLaunch(() => {
// #ifdef APP-PLUS
plus.runtime.getProperty('', async app => { const res: any = await useRequest('GET', '/api/tv/app'); if (res.code === 2000 && res.row.version > (app.version as string)) { uni.navigateTo({ url: `/components/update/index?url=${res.row.url}&type=${res.row.type}&content=${res.row.content}`, fail: err => { console.error('更新弹框跳转失败', err); } }); } });
// #endif
});

如果要获取启动参数


plus.android.importClass('android.content.Intent');
const MainActivity = plus.android.runtimeMainActivity();
const Intent = MainActivity.getIntent();
const roomCode = Intent.getStringExtra('roomCode');
if (roomCode) {
uni.setStorageSync('roomCode', roomCode);
} else if (!uni.getStorageSync('roomCode') && !roomCode) {
uni.setStorageSync('roomCode', '8888');
}

作者:前端纸飞机官方
来源:juejin.cn/post/7272348543625445437
收起阅读 »

小小扫码枪bug引发的思考

最近新公司发生了一件bug引发思考的事 产品需求 大致如上图,一个输入框,我们制作了自定义数字键盘,input框可以回显键盘的输入,并且,可以支持扫码枪输入回显 bug描述 在win 系统没有问题,但在安卓系统: 每次自定义键盘输入时,还会吊起系统软键盘,...
继续阅读 »

最近新公司发生了一件bug引发思考的事


产品需求


image.png


大致如上图,一个输入框,我们制作了自定义数字键盘,input框可以回显键盘的输入,并且,可以支持扫码枪输入回显


bug描述


在win 系统没有问题,但在安卓系统



  1. 每次自定义键盘输入时,还会吊起系统软键盘,且通过系统软键盘输入,input是无法回显的!键盘.jpg

  2. 不支持 扫码枪输入了


最讨厌研究 系统兼容性问题了,但问题出了,就得研究


我们先看一下,自定义数字键盘是怎么实现的?


在了解自定义键盘之前,我先问问大家,键盘输入会触发哪些事件?


对,就是这三个 keydown,keypress, keyup


如何控制Input框只回显数字呢?答案就是在keyDown事件里,通过捕获 event.key来获取用户按下的物理按键的值,非数字的值直接return就能做到了


那么言归正传,自定义键盘怎么实现呢?


其实到这边我们不难想到一个解决方案的思路就是,当按下自定义键盘时,我们模拟一个 keydown事件,并向获得焦点的input 派发这个keydown事件,那么就能模拟键盘输入了


上代码:


      const input = document.activeElement

      const event = document.createEvent('UIEvents')

      event.initUIEvent('keydown', true, true, window, 0)

      event.key = key

      event.keyCode = -1

      input.dispatchEvent(event)

扫码枪又是个啥?


就是这个东东:


image.png


去过超市的都看过吧


用扫码枪或者其他设备扫描图形码(条形码或其他码)后将其为文本输入,
input需要识别到扫码枪输入结束,并回显input区,


其实扫码枪输入和用户键盘输入一样都可以触发keydown事件,派发给聚焦的input


那么问题来了?


怎样识别 扫码枪输入结束呢?


答案是onEnter事件


我们再来看看 安卓端出现的bug


1,为啥每次我们在自定义键盘上输入,会同时弹出系统软键盘呢??


问了下安卓侧RD,原来只要input获得焦点,系统键盘就会弹出


但是不聚焦,自定义键盘/扫码枪也没办法回显了呀?


难道真的无解了吗?这时候第n个知识点来了!用readOnly!
readonly,对,就是它,


什么?readonly不是只读吗?有了它,相当于 用户无法输入,因此无法触发系统键盘,这个可以理解,但是,加上它之后,还有焦点吗?


这里有个问题要问大家,你知道readonly和disabled的区别吗?


答案就是在交互上,readonly 仍是可以聚焦的!disabled 就不能了


并且readOnly 是禁止用户输入,所以在允许聚焦的同时,又阻止了软键盘的弹出,这时我不禁感叹: 完美!


2,安卓为啥不支持扫码枪扫码了?


我们通过调试发现,在安卓上,keyDown事件 捕获到的event.key 是 Unidentified, 被我们判定为非数字,直接return了


那解法呢?我们神奇的发现,当我们解了bug1,加上readonly后,bug2也好了!


至于为啥它也好了,具体原因我还不清楚,以下是我的猜测:


前文我们提到,只要input聚焦,软键盘就会弹出,而扫码枪其实也可以看成一个特殊的键盘,可能两个键盘冲突导致 event.key 无法识别,加上readonly禁掉 软键盘后,冲突解除,自然event.key 也可以正常识别了


清楚原因的同学可以留言给我哈!我好想知道!!


反思来了


这件问题的最终解决方案只有一行代码,一个单词: readOnly


简单到令人发指,而且这个问题是一个刚来两天的新同学搞定的


我在想这一连串的故事,太神奇了


为啥这个困扰前辈同学包括我很久的问题,一个萌新一下子就解决了呢?虽然我也是萌新
image.png


readOnly可以 解决禁止软键盘弹出,网上的答案是有的,但是我pass了这��方案,


为什么呢?



  1. input相关基础差,我错误的认为readOnly是只读嘛,肯定会不带焦点啊,虽然禁用了软键盘,但是 扫码枪输入也不能回显了啊

  2. 当我看到 event.key 是 Unidentified 时,研究重点跑偏了

  3. 我觉得这可能某种程度上是一种 beginer’s luck, 因为当时新同学的任务是研究如何禁用软键盘,并没有提到其他扫码枪问题,可能这种心无旁骛反而成了事

  4. 工作中,尤其遇到一些诡异的兼容性问题,真的需要多尝试,不要被自己的想当然绑手绑脚

  5. 对于兼容性问题,因为要不断尝试,最好找到一种简单方便的调试方法,会大大加快调研进度


最后还是感谢一切的发生,收获了知识,也让我有冲动分享给大家我的一点小思考,感恩感恩!


作者:sophie旭
来源:juejin.cn/post/7388459061758017571
收起阅读 »

安卓开发转鸿蒙开发到底有多简单?

前言 相信各位搞安卓的同学多多少少都了解过鸿蒙了,有些一知半解而有些已经开始学习起来。那这个鸿蒙到底好不好搞?要不要搞? 安卓反正目前工作感觉不好找,即便是上海这样的大城市也难搞,人员挺饱和的。最近临近年关裁员的也很多。想想还是搞鸿蒙吧现在刚刚要起步说不定有机...
继续阅读 »

前言


相信各位搞安卓的同学多多少少都了解过鸿蒙了,有些一知半解而有些已经开始学习起来。那这个鸿蒙到底好不好搞?要不要搞?


安卓反正目前工作感觉不好找,即便是上海这样的大城市也难搞,人员挺饱和的。最近临近年关裁员的也很多。想想还是搞鸿蒙吧现在刚刚要起步说不定有机会!


首先可以肯定的一点,对于做安卓的来说鸿蒙很好搞,究竟有多好搞我来给大家说说。最近开始学鸿蒙,对其开发过程有了一定了解。刚好可以进行一些对比。


好不好搞?


开发环境


要我说,好搞的很。首先开发环境一样,不是说长得像,而是就一模一样。


截屏2023-12-04 09.25.27 (2).png


你看这个DevEco-Studio和Android Studio什么关系,就是双胞胎。同样基于Intellj IDEA开发, 刚装上的时候我都惊呆了,熟悉的感觉油然而生。


再来仔细看看:



  • 项目文件管理栏,同样可以切换Project和Packages视图


截屏2023-12-04 09.29.40.png



  • 底部工具栏,文件管理,日志输出,终端,Profiler等


截屏2023-12-04 09.31.05.png



  • SDK Manager, 和安卓一样也内建了SDK管理器,可以下载管理不同版本的SDK


截屏2023-12-04 09.32.55.png



  • 模拟器管理器


截屏2023-12-04 09.35.07.png


可以看出鸿蒙开发的IDE是功能完备并且安卓开发人员可以无学习成本进行转换。


开发工具


安卓开发中需要安装Java语言支持,由于开发过程需要进行调试,adb也是必不可少的。
在鸿蒙中,安装EcoDev-Studio后,可以在IDE中选择安装Node.js即可。由于鸿蒙开发使用的语言是基于TS改进增强而来,也就是熟悉JS语言就可以上手。而会JAVA的话很容易可以上手JS



  • 语言支持


截屏2023-12-04 09.44.25.png



  • 鸿蒙上的类似adb的工具名叫hdc



hdc(HarmonyOS Device Connector)是HarmonyOS为开发人员提供的用于调试的命令行工具,通过该工具可以在windows/linux/mac系统上与真实设备或者模拟器进行交互。




  1. hdc list targets

  2. hdc file send local remote

  3. hdc install package File


这里列举的几个命令是不是很熟悉?一看名字就知道和安卓中的adb是对应关系。不需要去记忆,在需要使用到的时候去官网查一下就行: hdc使用指导


配置文件


安卓中最主要的配置文件是AndroidManifest.xml。 其中定义了版本号,申明了页面路径,注册了广播和服务。并且申明了App使用的权限。


而鸿蒙中也对应有配置文件,但与安卓稍有不同的是鸿蒙分为多个文件。



  • build-profile.json5


Sdk Version配置在这里, 代码的模块区分也在这里


{
"app": {
"signingConfigs": [],
"compileSdkVersion": 9,
"compatibleSdkVersion": 9,
"products": [
{
"name": "default",
"signingConfig": "default",
}
],
"buildModeSet": [
{
"name": "debug",
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}


  • app.json5


包名,VersionCode,VersionName等信息


{
"app": {
"bundleName": "com.example.firstDemo",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}


  • module.json5


模块的详细配置,页面名和模块使用到的权限在这里申明


{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ts",
"description": "$string:EntryAbility_desc",
"icon": "$media:icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"action.system.home"
]
}
]
}
],
"requestPermissions":[
{
"name" : "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:reason",
"usedScene": {
"abilities": [
"FormAbility"
],
"when":"inuse"
}
}
]
}
}

官方指导


安卓开发的各种技术文档在网上可以很方便的搜索到,各种demo也有基数庞大的安卓开发者在技术网站上分享。虽然鸿蒙目前处于刚起步的阶段,但是官方的技术文档目前也已经非常完善,并且可以感受到鸿蒙的官方维护团队肯定在高强度加班中,他们的文档更新的太快了。经常能看到文档的编辑日期在迅速迭代。


截屏2023-12-04 10.18.47.png


截屏2023-12-04 10.19.16.png


从日期可以看到非常新。而且文档都是中文的,学习和查找起来都特别方便。


并且不仅仅是api文档,鸿蒙官方还提供了各种用以学习的demo, 甚至还有官方的视频教程和开发论坛。


截屏2023-12-04 10.21.55.png


截屏2023-12-04 10.22.46.png


截屏2023-12-04 10.23.09.png


截屏2023-12-04 10.23.36.png


遇到问题有各种方法可以解决,查文档,看视频课程,抄官方demo, 论坛发帖提问,简直是保姆级的官方支持!


其他



  • 鸿蒙的UI开发模式是一种响应式开发,与安卓的compose UI很像。组件的名字可能不同,但是概念上是一致的,并且鸿蒙的原生组件种类丰富也比较全。熟悉以后使用起来很方便。


build() {
Column() {
Text(this.accessText)
.fontSize(20)
.fontWeight(FontWeight.Bold)

if (!this.hasAccess) {
Button('点击申请').margin({top: 12})
.onClick(() => {
this.reqPermissionsFromUser(this.permissions);
})
} else {
Text('设备模糊位置信息:' + '\n' + this.locationText)
.fontSize(20)
.margin({top: 12})
.width('100%')
}
}
.height('100%')
.width('100%')
.padding(12)
}


  • 对应安卓的权限管理


鸿蒙有ATM,ATM (AccessTokenManager) 是HarmonyOS上基于AccessToken构建的统一的应用权限管理能力。



  • 对应安卓的SharedPreferences能力,鸿蒙有首选项能力。


截屏2023-12-04 10.27.27.png


这里就不一一列举了


我们只需要知道在安卓上有的概念,就可以在鸿蒙官方文档中去找一下对应的文档。


原理都是相通的。所以有过安卓开发经验的同学相对于前端FE来说有对客户端开发理解的优势。


要不要搞?


先看看目前的情况, 各家大厂正在积极布局鸿蒙客户端开发。


截屏2023-12-04 10.35.36.png


截屏2023-12-04 10.36.15.png


截屏2023-12-04 10.37.25.png


虽说移动端操作系统领域对安卓和iOS进行挑战的先例也有且还没有成功的先例。但是当前从国内互联网厂商的支持态度,从国际形势的情况,从华为对鸿蒙生态的投入来看。 我觉得很有搞头!
明年鸿蒙即将剔除对安卓的支持,届时头部互联网公司的大流量App也将完成鸿蒙原生纯血版的开发。


更有消息称鸿蒙PC版本也在路上了,了解信创的朋友应该能感受到这将意味着国产移动端和PC端操作系统会占有更大比例的市场。不仅仅是企业的市场行为,也是国产操作系统快速提升市占率的大好时机。


话说回来,作为安卓开发者,学习鸿蒙的成本并不高!


而对我们来说这是个机遇,毕竟技多不压身,企业在选取人才的时候往往也会偏好掌握更多技术的候选人。


如果鸿蒙起飞,你要不要考虑乘上这股东风呢?


我是张保罗,一个老安卓。最近在学鸿蒙




作者:张保罗
来源:juejin.cn/post/7308001278420320275
收起阅读 »

关于我在HarmonyOS中越陷越深这件事...

前言 上次发文已是2023年,在上一篇 前端的春天!拥抱HarmonyOS4.0🤗 - 掘金 (juejin.cn)一文中我介绍了一些鸿蒙OS知识,此文一出大家的看法也层出不穷,笔者持开放的态度对待大家对于新生态的看法。在2024年的今天,我想来说说这几个月我...
继续阅读 »

前言


上次发文已是2023年,在上一篇 前端的春天!拥抱HarmonyOS4.0🤗 - 掘金 (juejin.cn)一文中我介绍了一些鸿蒙OS知识,此文一出大家的看法也层出不穷,笔者持开放的态度对待大家对于新生态的看法。在2024年的今天,我想来说说这几个月我有哪些思考和行动。


在短短几个月的时间里,HarmonyOS已经来到了Next版本,迎来属于鸿蒙的春天。俗话说光说不练假把式,实践是试金石,我深知在做开发的这一行只有不断试错,反复的验证,才能创造新的轮子,创造力一个人无法被替代的根本。


我写这篇文章的目的不在于极力推荐大家去学习这项技术,更多的是以一个求学者的角度去阐述自己对新技术学习的心路历程。


为什么学习鸿蒙?


迷茫


笔者是25届的学生,对于学生来说最多的是时间和学习热情,自己也曾经经历过一段时间的专业方向选择困惑期,或许当人越迷茫的时候越容易听信别人的话吧,好与坏是相对的,分人也分时间,在合适的时间选择做了合适的事情这就没有什么问题了,至少学习鸿蒙这件事情对我来说,无论将来何时都会让我记忆犹新。


渴望


在学校里老师会告诉你成熟的解决方案,会告诉你应该这样做,不应该那样做,你仿佛一个机器人,进行一些机械系的学习,时间太急,急到我们只能应付相对的课程考试与学习,内容太多,多到我们最后仅靠老师给出的精简知识点去实际开发项目。这显然不是我想要的学习方式和结果...


动力源泉



有人说:这不就是Vue、React、Flutter、就是个缝合怪....



面对互联网高速发展的今天,各家博采众长,相互吸收优秀的开发思想已不是一件新鲜的事情了。


我自己学习的方向是大前端,加上之前开发的项目都是web的与小程序相关的,自己一直想尝试结合之前开发的项目开发一个基于HarmonyOS的App,听到“一次开发多端部署”这句话让我眼前一亮(很可惜这里的多端部署在4.0的开放版本是不支持的)。在接触鸿蒙的第一天,犹如我第一次接触前端开发,那种所见即所得的开发体验让我从内心里竟有了一丝“自信”,但也恰恰是这种“自信”也逐渐将我推入了深渊


坎坷与前行


在我真正尝试开发一个鸿蒙App的时候是在2023年底,我希望通过我所学的东西去做一个完整的东西并参加 2024年的计算机设计大赛


在十二月份的那几周,我不断的使用Figma进行原型的绘制,与指导老师探讨功能、确定交互逻辑,期间我也参考了大量的App类设计准则,最后发现鸿蒙的ArkUI是具有工业审美的(至少是符合我的想法),这使得我不必耗费太多的精力在从0到1的去做一些组件,仅需适配设计规范上所涉及到的即可,将更多的精力放在逻辑的完整性。


在开发过程中遇到了各种形形色色的问题,例如:http请求封装upload组件无法拿到回调、地图功能无法使用的解决方案、websocket连接不上、创建时间、地理位置编码......


所幸所有问题都有解决办法,只是过程真的很痛苦,反复尝试、不断验证,我很喜欢在夜晚写代码,天空越黑星星越亮,当空气都变得安静时,我的内心反而会激发一种向上的力量来支撑我,可能是因为自己太想进步了吧(hhhh),在开发的App的日子里,每天都很崩溃,但是我的老师、朋友也都在鼓励我,我又不太想都付出这么多了又轻易放弃......


在2024年的4月,我去看了武汉的樱花,距离比截止还有七天不到,因为我实在撑不住了,在这个时间点,与其逼自己一把,不如放自己一马,于是和朋友相约武汉一起赏樱......


69166f616e7b27afdad013bd61aabe2.jpg


在五月,我得知自己的鸿蒙原生应用拿到了省一等奖,内心是非常激动的,但同时有一些失落的是,我无缘继续参与今年七月的国赛,因为赛制名额原因,我无法被上推。


比赛结束后,我开始准备投递简历实习,但最终都石沉大海,行业现状让我十分焦虑,我时常觉得自己能力不足......


星河璀璨,紧接着HarmonyOS Next 正式面世,我内心不断在问自己,难道我就所有的努力都要止步于此了吗?


....


学习现状


六月我的一位学习伙伴邀请我和他们一起开发研究院的一款基于ArkUI-X的软件,这将对我来说是一个非常宝贵的机会,几个月的时间,兜兜转转回到了我梦开始的地方......


240624-3.jpg


Harmony Next 正式beta发布已过去半个多月了,这期间我了解到了很多之前没有学习到的新东西,鸿蒙提供了3w+的api,这些api有什么用?我打个比方,你要做满汉全席首先得要食材,其次需要烹饪技巧。而鸿蒙他会为你提供所需的所有食材,但是,你要做松鼠桂鱼还是佛跳墙,完全取决于你自己!至于烹饪技巧,鸿蒙开设了相关的做菜视频,你可以从中学习。


笔者也看到了许多鸿蒙原生开发者,一起交流关于鸿蒙的技术问题,在这里我附上一个宝藏鸿蒙优秀案例仓库
HarmonyOS NEXT应用开发案例集


感悟



真正的强大不是对抗,而是允许和接受,接纳挫折,接纳无常,接纳情绪,接纳不同,当你允许一切发生之后,就会不再那么尖锐,会渐渐变得柔和。



Per aspera ad astra. 没有人能熄灭满天星光,鸿蒙让我见证了从星光微微到星河璀璨,它教会我的不是一项技术,更多的是教会我如何去解决问题,去思考问题,当问题没有解决方案的时候,是否自己能够结合现有资源去提出自己的想法,并不断进行验证与总结。


路上会有风


会有浪漫


会有悲伤


会有孤独


也会有无尽的星辰与希望


作者:彼日花
来源:juejin.cn/post/7390956576180109312
收起阅读 »

关于鸿蒙开发,我暂时放弃了

起因 在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。 # 鸿蒙HarmonyOS从零实现类微信app效果第一篇,基础界面搭建 # 鸿蒙HarmonyOS从零实现类微信app效果第二篇,我的+发现页面实...
继续阅读 »

image.png


image.png


起因


在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。


企业微信截图_6f8acb94-bd68-4f56-9460-4a59d2370a4a.png



鸿蒙的arkui,使用typescript作为基调,然后响应式开发,对于我这个old android来说,确实挺惊艳的。而且在模拟器中运行起来也很快,写demo的过程鸡血满满,着实很愉快。


后面自己写的文章,也在掘金站点上获得了不错的评价。


企业微信截图_fa34f233-af43-4567-8dac-57ef5666f1bd.png


image.png


打击


今天下午,刚好同事有一个遥遥领先(meta 40 pro),鸿蒙4.0版本


怀着秀操作的想法,在同事手机上运行了起来。very nice。 一切出奇的顺利。


but ...


尼玛,点击的时候,直接卡住不对,黑屏。让人瞬间崩溃。


本着优先怀疑自己的原则,我找了一个官方的demo。 运行起来。


额...


尼玛。还是点击之后卡住了,大概30s之后,才跳转到新的页面。


image.png


这一切,让我熬夜掉的头发瞬间崩溃。


放弃了...


放弃了...


后续


和其他学习鸿蒙的伙伴沟通,也遇到了同样的问题,真机不能运行,会卡线程。但是按下home键,再次回到界面,页面会刷新过来


我个人暂时决定搁置对于鸿蒙开发的学习了,后续如果慢慢变得比较成熟之后,再次接触学习吧。



后续个人计划:




  • 1、还是会持续关注后续版本是否真机能运行,传言api 10对黑屏和真机无法运行的修复了。奈何官方所有渠道的编译器都没有api 10 的模拟器,真机4.0按道理是支持api10,但是还是黑屏,再持续观察吧。插个眼

  • 2、为了贯彻执行持续学习。后续可能会持续更新jetpack compose相关内容,包含且不局限于 compose desktop以及multi platform


最新情报:有网友告知我,在meta60上是运行没问题的,可能是最新版4.0是ok的,那么结论就是目前真机适配不完善


作者:王先生技术栈
来源:juejin.cn/post/7304538094736343052
收起阅读 »

使用uniapp制作安卓app容器

1. 背景项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。用webview也方便快速修复页面问题...
继续阅读 »

1. 背景

项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。

webview也方便快速修复页面问题。

所以最后选择了uniapp,但是uniapp本身就是套在一个大的webview下的, 所以再套一个webview难免会有一些意想不到的问题,下面就是一些踩过的坑记录。

2. 项目初始化

新建项目就默认模板就行,我只需要壳子。

image.png 启动了之后可以看到有两个调试工具

image.png

第一个就是网页上常用的vue调试工具,可以看到vue组件属性啥的,第二个就是类似chrome的控制台,但是无法查看元素,还有就是必须让设备和电脑在同一个网段下才行,不然连接不上。

hbuilder的控制台本身也有一些输出,比如页面的console

image.png

但是这里输出对象的时候不是很方便查看,如果你需要的话就打开上面说的第二个调试工具。

3. webview使用

整个项目很简单,大概就这样一个页面

<template>
<web-view :src='PROJECT_PATH' @message="onMessage">web-view>
template>
<script>
// ...
script>

3.1 网页与app通信

这是最重要的一个功能,可以参考官方文档

网页和app交互总结起来就是这两点:

  • 网页 -> APPwindow.uni.postMessage();
  • APP -> 网页webview.evalJS()

3.1.1. 网页 -> APP

首先要在项目中引入uni.webview.js,这个就相当于jsbridge,可以让网页操作uniapp

初始化完成后会在window上挂载一个uni对象,通过uni.postMessage就能往app发送消息,app中监听onMessage就行。

这里有几个小坑:

  1. 发送的格式window.uni.postMessage({ data: 数据 }),必须要有个字段data,这样app才能收到数据。源码

image.png 2. 发送的数据不需要序列化成字符串,uniapp会转换json。 3. appmessage事件中接收到事件参数应该这样解构

function onMessage(e) {
const {
type,
data
} = e.detail.data[0]
}

3.1.2. APP -> 网页

app向网页传输消息就直接调用网页的js就行了。这里我统一封装了一个函数:

// app向网页发送消息
const deliverMessage = (msg) => {
// 调用webview中的deliverMessage函数
// 这个函数是我在网页挂载的一个全局函数,调用deliverMessage后会触发页面中的一些事件
currentWebview.evalJS(`deliverMessage(${JSON.stringify(msg)})`)
}

上面的代码例子中出现的currentWebview需要我们自己去获取。

// vue2中
const rootWebview = this.$scope.$getAppWebview()
this.currentWebview = rootWebview.children()[0]

// vue3中
import {
getCurrentInstance,
ref,
} from "vue";
const currentWebview = ref(null)
const vueInstance = getCurrentInstance()
const rootWebview = vueInstance.proxy.$scope.$getAppWebview()
currentWebview.value = rootWebview.children()[0]

这里也有一个坑,rootWebview.children()如果你一渲染就获取是无法获取到webview实例的,具体原因没有深入研究,估计是异步的原因

这里提供两个思路:

  1. 加一个定时器,延迟获取webview,这个方法虽然听起来不保险,但是实际测试还是挺稳当的。关键是简单
setTimeout(() => {
currentWebview.value = rootWebview.children()[0]
}, 1000)
  1. 你要是觉得定时器不保险,那就使用plusapi手动创建webview。但是消息处理这块比较麻烦。官网参考
<template>

template>
// 我这里vue3为例
onMounted(() => {
plus.globalEvent.addEventListener('plusMessage', ({data: {type, args}}) => {
// 是网页调用uni的api
if(type === 'WEB_INVOKE_APPSERVICE') {
const {data: {name, arg}} = args
// 是发送消息事件
if(name === 'postMessage') {
// arg就是传过来的数据
}
}
})
const wv = plus.webview.create("", "webview", {
'uni-app': 'none',
})
wv.loadURL(网页地址)
rootWebview.append(wv);
})

plus.globalEvent.addEventListener这个是翻源码找到的,主要是我不想改uni.webview.js的源码,所以只有找到正确的监听事件。

WEB_INVOKE_APPSERVICEuniapp内部定义的一个名字,反正就是用来交互操作的命名空间。

这样基础的互操作就有了。

3.1.3. 整个流程

  1. 网页调用window.uni.postMessage({ data }) => app监听(用组件的onMessage或者自定义的globalEvent
  2. app调用网页定义的函数deliverMessage并传递参数,网页中的deliverMessage内部处理监听
// 网页中的deliverMessage
window.deliverMessage = (msg) => {
// 触发网页注册的监听器
eventListeners.forEach((listener) => {

});
};

3.2. 返回拦截

默认情况下,手机按下返回键,app会响应提示是否退出,但是实际我需要网页进入二级路由的时候,按下手机返回键是返回上一级路由而不是退出。当路由是一级路由时才提示是否退出app

import {
onBackPress,
onShow,
} from '@dcloudio/uni-app'
// 页面当前的路由信息
const pageRoute = shallowRef()
onBackPress(() => {
// tab页正常app返回逻辑
if (pageRoute.value?.isTab) {
return false
} else {
// 二级路由拦截app返回
return true
}
})

pageRoute是页面当前路由信息,页面通过监听路由变化触发routeChange事件,将路由信息传给app。当按下返回键的时候,判断当前路由配置是不是tab页,如果是就正常退出,不是就拦截返回。

4. 总结

有了通信功能,很多操作就可以实现了,比如获取设备safeArea,获取设备联网状态等等。


作者:头上有煎饺
来源:juejin.cn/post/7313740940773097482

收起阅读 »

35岁,是终点?还是拐点?

35岁,是终点还是拐点,取决于我们对生活和事业的态度、目标以及行动。这个年龄可以看作是一个重要的转折点,具有多重意义和可能性。 很多人在35岁时,已经在自己的职业生涯中建立了一定的基础,可能达到了管理层或专家级别。如果你还是一个基层员工,那你要反思一下,你的...
继续阅读 »

35岁,是终点还是拐点,取决于我们对生活和事业的态度、目标以及行动。这个年龄可以看作是一个重要的转折点,具有多重意义和可能性。



很多人在35岁时,已经在自己的职业生涯中建立了一定的基础,可能达到了管理层或专家级别。如果你还是一个基层员工,那你要反思一下,你的职业生涯规划可能出了问题,工作能力与人情世故为什么都没有突破?是否在某个领域深耕多年?



有些人可能会选择在这个年龄段重新评估自己的职业,考虑转型或创业,寻找新的挑战和机遇。这是个不错的想法,基于过往积累的经验和能力,现在自媒体发达,个人的创业成本低到0也可以创业,就是你能坚持多久,给自己多长时间的规划,又想达成怎样的目标。



35岁通常是家庭责任较重的时期,可能要照顾孩子和父母,家庭生活会影响个人的时间和精力分配。这是35岁中年油腻大叔最难的地方,上有老,下有小,自己还是很渺小。这是最痛苦的,家里顶梁柱,连倒下的资格都没有。



在自我认知层面,35岁的人通常对自己的优缺点、兴趣爱好有更清晰的认识,知道自己想要什么,不想要什么。所以这个年龄段的人可能会更加关注健康和自我提升,进行一些以前没有时间或精力去做的事情,如学习新技能、锻炼身体等。


举个例子,现在的 AI 和鸿蒙,热得烫手,是程序员学习的新方向,不同于区块链、AR/VR这些事件,AI 落地各行各业产品,未来是 XXX+AI 的时代,不管你正在做 JAVA 后端开发,还是即将学习 JAVA 开发,你都逃脱不了 AI。



鸿蒙就不用说了,国产操作系统,多少年了,国人终于可以用上自己的操作系统,这不是事件,不是概念,是必然趋势,一个新的技术领域将要开启,我是很坚信的,拒绝反驳。



经过多年的生活和工作经历,相信大多数人已经积累了较为丰富的经验和智慧,对待问题更加冷静和理性。


35岁既不是终点,也不是绝对的拐点,而是人生旅途中一个重要的里程碑。它为未来的发展提供了丰富的经验和更明确的方向。关键在于如何利用过去的积累,进行自我调整和规划,为未来的生活和事业开辟新的道路。以上是 V哥的浅见,突出想到这些问题,就记录了下来,你有什么见解,咱们评论区讨论,拍砖请下手轻点,欢迎关注威哥爱编程,程序员路上愿与你成为一起前行的基友。


作者:威哥爱编程
来源:juejin.cn/post/7383342927509471283
收起阅读 »

Android 复杂项目崩溃率收敛至0.01%实践

一、崩溃收敛机制 1、创建修BUG分支 在我们的项目中,每个版本发布之后,我们会创建一个opt分支,用于修复线上崩溃以及业务逻辑BUG。 开发过程中,一个APP可能同时并行开发多个需求,每个需求上线的预期时间可能会有不同。但是这个opt分支我们会保证在下个版本...
继续阅读 »

一、崩溃收敛机制


1、创建修BUG分支


在我们的项目中,每个版本发布之后,我们会创建一个opt分支,用于修复线上崩溃以及业务逻辑BUG。


开发过程中,一个APP可能同时并行开发多个需求,每个需求上线的预期时间可能会有不同。但是这个opt分支我们会保证在下个版本一定上线,QA同学也会在每个版本发布前预留测试opt分支的时间。


2、每天早晨查看Dump后台


每天上班第一件事就是查看DUMP后台,收集昨天线上发生的DUMP崩溃,具体的堆栈分配给对应的业务负责人。


业务负责人收到崩溃之后,会优先跟进排查。排查下来如果相对好修复,会第一时间直接修复掉,并提交到opt分支。如果排查下来发现,较难定位或者耗时较久,则需要给出修复预期。也可以将Bug转为技术优化,作为专项推进。因为确实有一些Bug需要通盘考虑,所有业务配合。


二、崩溃容灾机制


1、背景


我们为什么要开发一套崩溃容灾逻辑?


在对线上崩溃进行收敛时,我们发现线上有几类崩溃是我们在应用无法修复的。


例如:案例一


java.lang.NullPointerException: Attempt to invoke virtual method 'boolean android.content.ClipDescription.hasMimeType(java.lang.String)' on a null object reference
at android.widget.TextView.canPasteAsPlainText(TextView.java:15065)
at android.widget.Editor$TextActionModeCallback.populateMenuWithItems(Editor.java:4692)
at android.widget.Editor$TextActionModeCallback.onCreateActionMode(Editor.java:4627)

案例二:小米手机上出现


java.lang.NullPointerException:Attempt to invoke virtual method 'int android.text.Layout.getLineForOffset(int)' on a null object reference

案例三:集成华为推送SDK后,偶现


ava.lang.RuntimeException:Unable to start activity ComponentInfo{com.netease.popo/com.huawei.hms.activity.BridgeActivity}:
android.util.AndroidRuntimeException: requestFeature() must be called before adding content"

案例四:BadTokenException


android.view.WindowManager$BadTokenException:Unable to add window -- token android.os.BinderProxy

我们大致将以上问题划分为四类:



  • 我们认为是系统异常,应用层仅能在使用的位置try cache,有些崩溃甚至无处try cache;

  • 排查下来发现仅在某个厂商的手机上出现;

  • 集成的一些第三方SDK所引入,依赖对方修复,时间上不好掌控;

  • 由于Android系统的一些机制引发的崩溃,如弹出弹框时,恰好依赖的Actity正在销毁。业务层希望弹框可以不弹出但不要崩溃,可是系统最终是抛出来一个BadTokenException。我们可以在使用DiaLog时做判断,但是总会用有同学忘记。


基于以上我们思考是否可以开发一个框架,将这些崩溃统计进行拦截,使其不影响用户的使用。


2、技术方案


作为Android开发应该都比较清楚Handler机制。我们的崩溃容灾主要是利用了Handler机制。
具体的逻辑图如下:


popo_2022-05-15  16-16-01.jpg



  • 应用启动后,初始化崩溃白名单(应用内置,也支持服务端动态下发)

  • 通过Handler#post()方法,向主线程中发送一条消息。

  • 在Runnable#run()方法中,执行一个死循环逻辑

  • 死循环中逻辑中使用try cache将Looper.loop()防护

  • 这样只要应用的进程不结束,相当于任务一直执行在我们前面post的消息中

  • 只是我们在这个消息中,再次执行了Looper.loop()方法,执行后续消息队列中所有的消息

  • 一旦后续所有消息遇到崩溃,会先被try cache捕获。

  • 然后判断崩溃信息是否在我们的白名单中,一旦在白名单中直接捕获掉,不向外抛异常,逻辑会回到外部的死循环中,继续执行Looper.loop()方法获取后续的消息。这样就保证了逻辑的连贯,后续的事件可以继续处理。

  • 不再我们的白名单中则继续将这个异常throw出去。


3、现状


崩溃拦截框架上线至今几年的时间,积累的崩溃种类目前已经达到81种。


比较典型的除了上面介绍的几类崩溃以外,还有如我们在适配Android 12的SplasScree时,遇到的TransferSplashScreenViewStateItem 相关的错误


java.lang.IllegalArgumentException: Activity client record must not be null to execute transaction item: android.app.servertransaction.TransferSplashScreenViewStateItem@de845fa
at android.app.servertransaction.ActivityTransactionItem.getActivityClientRecord(ActivityTransactionItem.java:85)
at android.app.servertransaction.ActivityTransactionItem.getActivityClientRecord(ActivityTransactionItem.java:58)
at android.app.servertransaction.ActivityTransactionItem.execute(ActivityTransactionItem.java:43)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:149)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:103)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2708)
at android.os.Handler.dispatchMessage(Handler.java:114)
at android.os.Looper.loopOnce(Looper.java:206)
at android.os.Looper.loop(Looper.java:296)

按照过往的手段,我们仅能等待Google官方来修复这个错误,或者先下线掉这个错误。本框架可以直接将其进行拦截,同时用户无感知。


三、其他崩溃收敛


我们针对自身业务特点,大致梳理以下几种业务中非常常见的崩溃场景,提醒每一位同学注意。同时内部维护了一个研发质量表,每个需求提测时我们会过一遍研发质量表格,提醒同学注意相关代码质量与性能。


1、空指针问题


NPE应该是最常见的问题了。针对NPE的问题,我们的解决方式有:



  • 推荐组内所有同学习惯使用注解@NonNull和@Nullable

  • 推荐大家使用Kotlin;并且在Java调用kotlin方法时,一定要注意Kotlin方法是否要求入参不为空

  • 从List、Map中取到的对象,使用前必须判空

  • 业务代码需要将Context传给第三方工具类,传入之前必须判空

  • 对象建议声明为final或者val,防止后续其他位置置空引起NPE

  • 外接传入的对象使用前必须判空

  • 基于AS插件进行检测


2、IndexOutBoundsException


角标越界异常在平时开发中也特别常见,在我们的业务中常见于集合以及Span操作



  • 集合传入index时需要判断是否在[0, size]内

  • 操作Spannable接口setSpan方法时,需要start与end的数值不会超过长度,同时不能够为负数


3、ConcurrentModificationException 并发修改异常


并发修改异常在复杂的业务中,是非常容易遇到的。通常有两个场景容易触发,分别是foreach循环中直接调用remove方法移除元素,以及线程不安全环境下使用线程不安全集合。


针对并发修改异常:



  • 我们推荐在遍历集合时,可以new一个新的List集合,将要遍历的List集合作为参数传入,然后遍历新的集合。这样原集合在遍历时改变也不会抛异常

  • 使用线程安全的集合,如CopyOnWriteArraylist、ConcurrentHashMap等


4、系统服务(FrameWork API)



  • 调用系统服务通常需要跨进程通信,其内部很可能会抛异常,所有调用系统服务的地方都必须使用try cache。cache异常必须写入日志文件,根据业务重要性判断是否需要上报埋点数据;

  • 系统服务频繁调用时可能会引发ANR,这点也需要特别注意;


5、数据库类问题


由于我们的业务重度依赖数据库,所以数据库相关的问题占比也比较高。
主要有以下几类问题:


CursorWindowAllocationException 2048问题:


com.tencent.wcdb.CursorWindowAllocationException: 
Cursor window allocation of 2048 kb failed. total:8159,active:49
at com.tencent.wcdb.CursorWindow.<init>(SourceFile:127)

针对CursorWindowAllocationException,在我们的工程中主要是短时间内大量的内存申请。 解决方案是基于SQL监控,统计工程中SQL执行的数量,基于SQL语句针对性的优化相关逻辑,将SQL语句执行数量降低了90%以上,这个问题线上不再复现。


存储空间不足


Caused by: 
com.tencent.wcdb.database.SQLiteFullException:database or disk is full (code 13,errno 0):
at com.tencent.wcdb.database.SQLiteConnection.nativeExecute(Native Method)
at com.tencent.wcdb.database.SQLiteConnection.execute(SourceFile:728)
at com.tencent.wcdb.database.SQLiteSession.endTransactionUnchecked(SourceFile:436)
at com.tencent.wcdb.database.SQLiteSession.endTransaction(SourceFile:400)
at com.tencent.wcdb.database.SQLiteDatabase.endTransaction(SourceFile:533)
at com.tencent.wcdb.room.db.WCDBDatabase.endTransaction(SourceFile:100)

针对存储空间不足,在我们的APP中主要是添加手机存储空间检测,当空间不足时引导用户清理。


数据库损坏


com tencent wcdb database.SQLiteDatabaseCorruptException: database disk image is malformed (code 11, errno 0): 
at com.tencent.wcdb.database.SQLiteConnection.nativePrepareStatement(Native Method)
at com.tencent.wcdb.database.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:1004)
at com,tencent.wcdb.database.SQLiteConnection.executeForString(SQLiteConnection.java:807)
at com.tencent.wcdb.database.SQLiteConnection.setJournalMode(SQLiteConnection.java:424)
at com.tencent.wcdb.database.SQLiteConnection.setWalModeFromConfiguration(SQLiteConnection.java:414)
at com.tencent.wcdb.database.SQLiteConnection.open(SQLiteConnection.java:289)
at com.tencent.wcdb.database.SQLiteConnection.open(SQLiteConnection.java:254)
at com,tencent.wcdb.database.SQLiteConnectionPool.openConnectionLocked(SQLiteConnectionPool.java:603)
at com.tencent.wcdb.database.SQLiteConnectionPool.open(SQLiteConnectionPool.java:225)
at com.tencent.wcdb.database.SQLiteConnectionPool.open(SQLiteConnectionPool.java:217)
at com.tencent.wcdb.database.SQLiteDatabase.openInner(SQLiteDatabase.java:1002)

数据库损坏我们是引入了修复工具进行修复,同时对数据库损坏崩溃进行拦截。修复完成后退出到登录页面引导用户重新登录。


数据库的崩溃问题一度在我们的工程中占比超过50%,所以我们有启动数据库优化专项投入大量时间。针对数据库优化的具体介绍可以查看:Android 数据库系列三:复杂项目SQL治理与数据库的优化总结
,内部有更加详细的介绍。


四、OOM问题收敛


1、OOM介绍


OOM的问题在Android中也是非常常见了,所以这里单独拎出来说说。
OOM产生的条件:待申请的内存大于系统分配给应用的剩余内存。
OOM原因大致可以归为以下几类



  • 堆内存分配失败

    • 堆内存溢出

    • 没有足够的连续内存空间



  • 创建线程失败(pthread_create (1040KB stack) failed: Try again)

  • FD数量超出限制

  • Native虚拟内存OOM


2、内存泄露监控


线上内存泄露的监控我们是使用的快手的KOOM。
KOOM原理这里笔者就不详解了,社区内也有专门分析的文章,大家可以找找看,不过还是建议去读读源码,写的挺不错的。



指路地址:github.com/KwaiAppTeam…



将KOOM分析的报告上报到我们的后台中,有专门的同学每周会排时间跟进。


3、全局浮窗实时显示APP当前总体内存


除了线上的监控,我们也有一个自研的开发者工具。工具有一个浮窗功能,我们会在浮窗上实时显示当前应用的内存信息(每秒采集一次)。数据主要是通过获取Debug.MemoryInfo#getTotalPss()。与Android Studio Profile中Memory数据基本一致。同时在UI层面我们还会设置一个阈值,超过阈值就会将浮窗中内存的数值颜色改为红色,旨在提醒开发同学关注内存变化。


通过实时显示内存我们在开发过程中,就可以发现一些问题。如我们再进入某一个业务时,发现内存会固定涨50M+,基于此开发同学去排查,发现了很多优化点。线下发现这个问题的意义就是可以质量左移,避免到线上影响到用户。


4、线程数量监控与收敛


获取线程数量,我们可以读取文件/proc/[pid]/status 中的线程数量,代码大致如下:


public static String readThreadStatus(String pid){
RandomAccessFile reader2= null;
try {
reader2 = new RandomAccessFile("/proc/" + pid + "/status", "r");
String str;
while ((str = reader2.readLine()) != null) {
if (str.contains("Threads")) {
return str;
}
}
reader2.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (reader2 != null) {
reader2.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
return "";
}


在前面提到的开发者工具的浮窗中,我们也有一行用来实时显示线程的数量。不过在我们的工具中,我们没有使用上面的方法,而是使用:Thread.getAllStackTraces();


使用Thread.getAllStackTraces();获取的数量比 /proc/[pid]/status 获取的少,但是在我们的工程中,我们主要关注Java线程而且通过Java线程的数量的波动也能观察到App当中线程的变化。而且Thread.getAllStackTraces()会返回Thread对象,以及堆栈数据这个对我们更加有用。


在开发者工具我们有一个单独的页面可以实时查看线程的ID、名称以及对应堆栈。在线上我们会间隔一段收集一次线程数据上报到我们的后台中。


在笔者过往的开发经历中,遇到过一次由于线程数量较多直接导致应用崩溃的情况,即某个独立业务使用OkHttp没有创建OkHttpClient单例对象,而是每次接口回调都创建一个新的client...


定位过程比较简单,在特定的场景下,可以看到浮窗中的线程数量基本处于线性增长,通过开发者工具查看线程列表可以直接看到非常多的OkHttp相关的线程。


5、FD 数量监控


获取FD数量大致可以通过以下代码


public static int getCurrentFdSize() {
int size = 0;
File dir = new File("/proc/self/fd");
try {
File[] fds = dir.listFiles();
if (fds != null) {
size = fds.length;
for (File fd : fds) {
if (Build.VERSION.SDK_INT >= 21) {
MLog.d("message", Os.readlink(fd.getAbsolutePath()));
}
}
}

} catch (Exception e) {
e.printStackTrace();
}
return size;
}


同时在KOOM中每次分析的结果中会携带所有FD句柄信息,所以我们没有单独做额外的监控了,直接查看KOOM的解析数据。


笔者仅遇到过一次由于FD句柄超限导致的异常。异常信息如下


java.lang.RuntimeException: Could not read input channel file descriptors from parcel.
at android.view.InputChannel.nativeReadFromParcel(Native Method)
at android.view.InputChannel.readFromParcel(InputChannel.java:148)
at android.view.InputChannel$1.createFromParcel(InputChannel.java:39)at android.view.InputChannel$1.createFromParcel(InputChannel.java:37)
at com.android.internal.view.InputBindResult.<init>(InputBindResult.java:68)
at com.android.internal.view.InputBindResult$1.createFromParcel(InputBindResult.java:112)at com.android.internal.view.InputBindResult$1.createFromParcel(InputBindResult.java:110)at com.android.internal.view.IInputMethodManager$Stub$Proxy.startInputOrWindowGainedFocus(IInp
at android.view.inputmethod.InputMethodManager.startInputInner(InputMethodManager.java:1361)
at android.view.inputmethod.InputMethodManager.onPostWindowFocus (InputMethodManager.java:1631)
at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:4259)
at android.os.Handler.dispatchMessage(Handler.java:109)
at android.os.Looper.loop(Looper.java:166)

后面经过排查进入内置浏览器查看某网页,FD句柄的数量会瞬间飙升。通过遍历 /proc/pid/fd 文件发现大多是都是Socket。后面排查下来是应用内某个前端页面存在Bug,疯狂new Socket...


五、总结


以上简单介绍了一下我们在工程中如何针对各类崩溃信息进行收敛,值得欣喜的是经过几年的努力基本可以将崩溃控制在万一。回过头来看很多问题还是遇到问题-解决问题的思路,这就依赖我们的开发同学本身所写的代码质量要高,否则就会陷入到写Bug-改Bug这样的循环中。


所以我们也在积极探索如何通过前期的review,工具扫描的方式尽量降低线上问题的发生概率。尽可能将问题提前暴露。不过这方面目前还没有建设的特别好,人工review实验了一段时间发现在一个业务较多的团队的实施起来很难,没有时间不说盯着代码review也不见得就能发现一些逻辑上的异常。好在公司内其他团队再研究基于AI的代码扫描,后续计划接入到当项目中看看。


作者:半山居士
来源:juejin.cn/post/7377200392059617295
收起阅读 »

【干货分享】uniapp做的安卓App如何加固

2023年了,uniapp还有人用吗? 对于这个问题,只能说,2023年了,使用uniapp去开发APP的人越来越多了。 一方面在于全平台兼容确实很香,对于一些小项目或者时间要求比较高的项目来说,可以节省大量的时间与精力,也为公司节约了成本;另一方面,开发速度...
继续阅读 »

2023年了,uniapp还有人用吗?


对于这个问题,只能说,2023年了,使用uniapp去开发APP的人越来越多了。


一方面在于全平台兼容确实很香,对于一些小项目或者时间要求比较高的项目来说,可以节省大量的时间与精力,也为公司节约了成本;另一方面,开发速度非常快。就像前面说的,对于一些小项目来说,几天就可以搞定,而对于一些大项目来说,性能和原生大差不差,而且全平台兼容的特性也可以弥补这点;最后就是社区,里面有很多优质的框架和插件,节约了大量的时间(时间就是发量!!!),更重要的是,社区出人才,总能找到人和你一起吐槽(bushi)睿智的官方......


总而言之,虽然uniapp文档一般好,bug一般多,更新像拆炸弹,但是,对于很多人来说,还是很有意义的。所以用的人还是很多。


但是目前随着各种商城上架政策的严格审查,对于加固等需求也慢慢起来了,所以今天我们来讲讲uniapp开发的安卓APP要如何加固。


加固原理


先来看看一般加固会从哪几个方向进行加固


image.png


而我们如果把uniapp制作的安卓APP在加固上其实大同小异--只要是apk或者aab格式都可以,所以我们就基于这个原理来进行加固。


加固流程


01 代码混淆


按照一般的思路,先给他混淆一下子。使用代码混淆工具来混淆 JavaScript 代码,以使其难以被逆向工程和破解。常用的混淆工具包括 ProGuard 和 DexGuard。在 UniApp 中,你可以在打包安卓应用时配置 ProGuard 来进行代码混淆。示例代码如下所示,在项目根目录下的 uniapp.pro 文件中添加以下配置:


-keep class com.dcloud.** { *; }
-keep public class * extends io.dcloud.* {
*;
}

02 加固资源文件 & 防止调试和反调试


加固资源文件: 将敏感资源文件(如证书、配置文件等)进行加密或混淆,以防止被攻击者获取。可以使用第三方工具对资源文件进行加密,或者自定义加密算法来保护资源文件的安全


防止调试和反调试: 这一步可以使用第三方库或自定义代码来实现这些保护措施。比如说,可以检测应用程序是否在调试模式下运行,并在调试模式下采取相应的措施,例如关闭应用程序或隐藏敏感信息。


import android.os.Debug;

public class DebugUtils {
public static boolean isDebugMode() {
return Debug.isDebuggerConnected();
}
}

就是说,在应用程序中调用 DebugUtils.isDebugMode() 方法,可以根据返回值来判断应用程序是否在调试模式下运行,并采取相应的措施。


03 加密敏感数据


我们直接使用PBEWithMD5AndDES 算法对数据进行加密和解密。使用的时候,你可以调用 EncryptionUtils.encrypt(data) 方法来加密敏感数据,并调用 EncryptionUtils.decrypt(encryptedData) 方法来解密数据。记得将 PASSWORDSALT 替换为你自己的密码和盐值(重要!!!)。


import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.security.spec.KeySpec;
import java.util.Base64;

public class EncryptionUtils {
private static final String ALGORITHM = "PBEWithMD5AndDES";
private static final String PASSWORD = "your_secret_password"; // 自定义密码,请更换为自己的密码
private static final byte[] SALT = {
(byte) 0x4b, (byte) 0x6d, (byte) 0x7d, (byte) 0x15,
(byte) 0x78, (byte) 0x56, (byte) 0x34, (byte) 0x22
}; // 自定义盐值,请更换为自己的盐值

public static String encrypt(String data) {
try {
KeySpec keySpec = new PBEKeySpec(PASSWORD.toCharArray(), SALT, 65536);
SecretKey secretKey = SecretKeyFactory.getInstance(ALGORITHM).generateSecret(keySpec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
PBEParameterSpec parameterSpec = new PBEParameterSpec(SALT, 100);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
byte[] encryptedBytes = cipher.doFinal(data.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

public static String decrypt(String encryptedData) {
try {
KeySpec keySpec = new PBEKeySpec(PASSWORD.toCharArray(), SALT, 65536);
SecretKey secretKey = SecretKeyFactory.getInstance(ALGORITHM).generateSecret(keySpec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
PBEParameterSpec parameterSpec = new PBEParameterSpec(SALT, 100);
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
byte[] decodedBytes = Base64.getDecoder().decode(encryptedData);
byte[] decryptedBytes = cipher.doFinal(decodedBytes);
return new String(decryptedBytes, "UTF-8");
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

04 防止篡改


我们使用 SHA-256 哈希算法计算数据的哈希值。使用的时候,可以调用 IntegrityUtils.calculateHash(data) 方法来计算数据的哈希值,并将其与原始的哈希值进行比较,以验证数据的完整性。例如:


String data = "Hello, world!";
String originalHash = "2ef7bde608ce5404e97d5f042f95f89f1c232871";
String calculatedHash = IntegrityUtils.calculateHash(data);

boolean isIntegrityVerified = IntegrityUtils.verifyIntegrity(data, originalHash);
if (isIntegrityVerified) {
System.out.println("Data integrity verified.");
} else {
System.out.println("Data has been tampered with!");
}

05 签名功能


补充一个Android签名。


1)简介


本工具用于对android加固后的apk进行重新签名。


版本文件备注
Windows版apk签名工具压缩包.exe该版本包含Java运行环境,不需要额外安装。
通用版dx-signer-v1.9r.jar该版本需要Java 8+的运行环境,请依照操作系统进行安装:Adoptium

本工具依照Apache 2.0 协议开源,可以在这里查看源码github.com/dingxiangte…



使用说明



  1. 下载签名工具dx-signer.jar,双击运行。

  2. 选择输入apk、aab文件。

  3. 选择签名的key文件,并输入key密码。

  4. 选择重签后apk、aab的路径,以apk结束。如:D:\sign.apk

  5. 点击“签名”按钮,等待即可签名完成。


ps:如果有alias(证书别名)密钥的或者有多个证书的,请在高级tab中选择alias并输入alias密码


2)多渠道功能简介


多渠道工具兼容友盟和美团walle风格的多渠道包,方便客户把APP发布到不同的应用平台,进行渠道统计。



使用说明



  1. 在app中预留读取渠道信息的入口,具体见5.2.2读取渠道信息

  2. 在5.1.1的签名使用基础上,点击选择渠道清单

  3. 选择清单文件channel.txt。具体文件格式见5.2.3

  4. 点击签名,等待生成多个带签名的渠道app


读取渠道信息


顶象多渠道工具兼容友盟和美团walle风格的多渠道包,下面是两种不同风格的渠道信息读取方法。选其中之一即可


读取渠道信息:UMENG_CHANNEL

输出的Apk中将会包含UMENG_CHANNELmata-data



name="UMENG_CHANNEL"
android:value="XXX" />


可以读取这个字段。


public static String getChannel(Context ctx) {
String channel = "";
try {
ApplicationInfo appInfo = ctx.getPackageManager().getApplicationInfo(ctx.getPackageName(),
PackageManager.GET_META_DATA);
channel = appInfo.metaData.getString("UMENG_CHANNEL");
} catch (PackageManager.NameNotFoundException ignore) {
}
return channel;
}

读取渠道信息:Walle

输出的Apk也包含Walle风格的渠道信息


可以在使用Walle的方式进行读取。


implementation 'com.meituan.android.walle:library:1.1.7'


String channel = WalleChannelReader.getChannel(this.getApplicationContext());

渠道文件格式说明


请准备渠道清单文件channel.txt, 格式为每一行一个渠道, 例如:


0001_my
0003_baidu
0004_huawei
0005_oppo
0006_vivo

结语


以上就是基于uniapp制作的Android APP的加固方式,仅供参考~ 欢迎一起交流学习~




作者:昀和
来源:juejin.cn/post/7256615625882615866
收起阅读 »

安卓高版本HTTPS抓包:终极解决方案

虽然市面上有好多抓包工具,但是 Android 高版本都需要安装抓包工具的证书到系统目录,才能抓 https 协议的包。本文就以 Charles这个抓包工具来介绍,如何安装证书到 Android 的系统目录,实现 https 抓包。 修改证书名称 启动 Cha...
继续阅读 »

虽然市面上有好多抓包工具,但是 Android 高版本都需要安装抓包工具的证书到系统目录,才能抓 https 协议的包。本文就以 Charles这个抓包工具来介绍,如何安装证书到 Android 的系统目录,实现 https 抓包。


修改证书名称


启动 Charles,通过菜单栏中的 Help → SSL Proxying → Save Charles Root Certificate… 将 Charles 的证书导出。
使用 OpenSSL 查看证书在 Android 系统中对应的文件名,并重命名证书文件


openssl x509 -subject_hash_old -in charles-ssl-proxying-certificate.pem | head -n 1  #cdfb61bc
mv charles-ssl-proxying-certificate.pem cdfb61bc.0

将证书安装到系统证书目录下


使用 adb push 命令将我们的证书文件放到 SD 卡中


adb push cdfb61bc.0 /sdcard/Download

使用 adb 连接手机并切换到 root 用户


adb shell
su

将证书文件移动到 /system/etc/security/cacerts 目录下,由于 /system 默认是只读的,所以要先重新挂载为其添加写入权限


cat /proc/mounts  #查看挂载信息,这里我的 /system 是直接挂载到 / 的

mount -o rw,remount /
mv /sdcard/Download/cdfb61bc.0 /system/etc/security/cacerts
chmod 644 /system/etc/security/cacerts/cdfb61bc.0 #设置文件权限

如果👆的步骤你都能成功,就不用继续往下看了。


终极解决方案


我用我手上的手机都试了一下,用上面的方式安装正式,发现不能成功,一直提示 Read-only file system,但是HttpToolkit这个软件确可以通过 Android Device Via ADB来抓 https 的包。
它是怎么实现的呢?
这下又开始了漫长的谷歌之旅,最后在他们官网找到一篇文章,详细讲述了 通过有root权限的adb 来写入系统证书的神奇方案。



  1. 通过 ADB 将 HTTP Toolkit CA 证书推送到设备上。

  2. 从 /system/etc/security/cacerts/ 中复制所有系统证书到临时目录。

  3. 在 /system/etc/security/cacerts/ 上面挂载一个 tmpfs 内存文件系统。这实际上将一个可写的全新空文件系统放在了 /system 的一小部分上面。 将复制的系统证书移回到该挂载点。

  4. 将 HTTP Toolkit CA 证书也移动到该挂载点。

  5. 更新临时挂载点中所有文件的权限为 644,并将系统文件的 SELinux 标签设置为 system_file,以使其看起来像是合法的 Android 系统文件。


关键点就是挂载一个 内存文件系统, 太有才了。
具体命令如下


# 创建一个独立的临时目录,用于存储当前的证书
# 如果不这样做,在我们添加挂载后将无法再读取到当前的证书。
mkdir -m 700 /data/local/tmp/htk-ca-copy
# 复制现有的证书到临时目录
cp /system/etc/security/cacerts/* /data/local/tmp/htk-ca-copy/
# 在系统证书文件夹之上创建内存挂载点
mount -t tmpfs tmpfs /system/etc/security/cacerts
# 将之前复制的证书移回内存挂载点中,确保继续信任这些证书
mv /data/local/tmp/htk-ca-copy/* /system/etc/security/cacerts/
# 将新的证书复制进去,以便我们也信任该证书
cp /data
/local/tmp/c88f7ed0.0 /system/etc/security/cacerts/
# 更新权限和SELinux上下文标签,确保一切都和之前一样可读
chown root:root /system/etc/security/cacerts/*
chmod 644 /system/etc/security/cacerts/*
chcon u:object_r:system_file:s0 /system/etc/security/cacerts/*
# 删除临时证书目录
rm -r /data/local/tmp/htk-ca-copy

注意:由于是内存文件系统,所以重启手机后就失效了。可以将以上命令写成 shell 脚本,需要抓包的时候执行下就可以了


作者:平行绳
来源:juejin.cn/post/7360242772303577125
收起阅读 »

为什么 Android 要采用 Binder 作为 IPC 机制?

Hi 大家好,我是 DHL,大厂程序员,公众号:ByteCode ,在美团、快手、小米工作过。搞过逆向,做过性能优化,研究过系统,擅长鸿蒙、Android、Kotlin、性能优化、职场分享。 微信小程序「猿面试」每日分享一道大厂面试题,涉及 Java、And...
继续阅读 »

网站.jpg



Hi 大家好,我是 DHL,大厂程序员,公众号:ByteCode ,在美团、快手、小米工作过。搞过逆向,做过性能优化,研究过系统,擅长鸿蒙、Android、Kotlin、性能优化、职场分享。



微信小程序「猿面试」每日分享一道大厂面试题,涉及 JavaAndroid鸿蒙和ArkTS设计模式算法和数据结构 等内容。




本篇文章主要以面试为主,因此只要记住这些即可。Android 采用 Binder 作为 IPC (进程间通信) 机制的原因主要包括以下几点,


高效性


Binder 机制通过减少数据拷贝次数来提高 IPC 的效率。在 Binder 机制中,发送方只需要将数据从用户空间拷贝到内核空间一次,接收方可以直接访问内核空间中的数据,避免了额外的数据拷贝。


与其他 IPC 机制相比,Binder 更高效。Binder 数据拷贝只需要一次,而管道、消息队列、Socket 都需要 2 次,但共享内存方式一次内存拷贝都不需要;从性能角度看,Binder 性能仅次于共享内存。


对象级别的通信


与基于消息的通信方式不同,Binder 机制提供了一种面向对象的 IPC 方法。Binder 允许在进程间传递对象引用,开发者可以像调用本地对象一样调用远程对象的方法,而无需关心对象实际所在的进程。这种面向对象的 IPC 方式让编程模型更自然,易于理解和使用。


支持异步通信


除了同步调用外,Binder 还支持异步通信,这对于构建响应式应用尤其重要。通过异步通信,应用可以在等待 Binder 事务完成时继续执行其他任务,提高了应用的响应性和性能。


安全性


Binder 通过使用 UID(用户 ID)和 PID(进程 ID)来验证请求的来源,提供了进程间通信的安全性保障。这意味着每个 Binder 事务都可以精确到发起者,系统可以据此实施安全策略,例如权限检查,从而防止未授权的数据访问或通信。


每个 Binder 通信都有明确的权限控制,可以限制哪些进程可以访问 Binder 服务,从而增强了系统的安全性。


稳定性


与其他 IPC 机制相比,Binder 是基于 C/S 架构的,是指客户端 (Client) 和服务端 (Server) 组成的架构,Client 端有什么需求,直接发送给 Server 端去完成,架构清晰明朗,Server 端与 Client 端相对独立。


而共享内存实现方式复杂,没有客户与服务端之别,需要充分考虑到访问临界资源的并发同步问题,否则可能会出现死锁等问题;


从这稳定性角度看,Binder 架构优越于共享内存


简便性


Binder 为开发者提供了一套易于使用的 API 来进行进程间通信,隐藏了复杂的内部实现。它使得不同应用之间或应用与系统服务之间的数据传递和方法调用变得简单直观。


总之,由于其高效、安全、简便、面向对象等特性,Binder 成为了 Android 系统中进行 IPC 通信的首选机制。这些特性使得 Binder 非常适合移动设备这种资源受限的环境。



作者:程序员DHL
来源:juejin.cn/post/7378321582399602707
收起阅读 »

一个Android App最少有多少个线程?

守护线程 Signal Catcher线程 Signal Catcher是一个守护线程,用于捕获 SIGQUIT, SIGUSR1 信号,并采取相应的行为。 Android系统中,由Zygote孵化而来的子进程,包含system_server进程和各种APP进...
继续阅读 »

守护线程


Signal Catcher线程


Signal Catcher是一个守护线程,用于捕获 SIGQUIT, SIGUSR1 信号,并采取相应的行为。


Android系统中,由Zygote孵化而来的子进程,包含system_server进程和各种APP进程都存在一个Signal Catcher线程,但是Zygote进程本身没有这个线程。


在Process类中有SIGQUIT,SIGUSR1 的定义:


    public static final int SIGNAL_QUIT = 3;
public static final int SIGNAL_KILL = 9;
public static final int SIGNAL_USR1 = 10;

当前进程的Signal Catcher线程接收到信号SIGNAL_QUIT,则挂起进程中的所有线程并DUMP所有线程的状态。


当前进程的Signal Catcher线程接收到信号SIGNAL_USR1,则触发进程强制执行GC操作。


发送信号的方式:


Process.sendSignal(android.os.Process.myPid(), Process.SIGNAL_QUIT);
Process.sendSignal(android.os.Process.myPid(), Process.SIGNAL_USR1);

当我们直接在代码中调用上诉代码会发生什么?


Process.SIGNAL_USR1:


I/Process: Sending signal. PID: 12363 SIG: 10
I/etease.popo.ap: Thread[3,tid=12373,WaitingInMainSignalCatcherLoop,Thread*=0x793d816400,peer=0x13700020,"Signal Catcher"]: reacting to signal 10
I/etease.popo.ap: SIGUSR1 forcing GC (no HPROF) and profile save
I/etease.popo.ap: Explicit concurrent copying GC freed 18773(4MB) AllocSpace objects, 7(140KB) LOS objects, 68% free, 2MB/8MB, paused 130us total 13.633ms

Process.SIGNAL_QUIT


I/Process: Sending signal. PID: 12812 SIG: 3
I/etease.popo.ap: Thread[3,tid=12823,WaitingInMainSignalCatcherLoop,Thread*=0x793d816400,peer=0x148051b0,"Signal Catcher"]: reacting to signal 3
I/etease.popo.ap: Wrote stack traces to '[tombstoned]'

RenderThread 渲染线程


Android 5.0之后新增的一个线程,用来协助UI线程进行图形绘制。所有的GL命令执行都放在这个线程上。渲染线程在RenderNode中存有渲染帧的所有信息,并监听VSync信号,因此可以独立做一些属性动画。


在清单文件APP中添加:android:hardwareAccelerated="false"
启动APP将会不存在渲染线程;硬件加速在Android中试默认开启的。


Render Thread在运行时主要是做以下两件事情:



  • Task Queue的任务,这些Task一般就是由MainThread发送过来的,例如,MainThread通过发送一个DrawFrameTask给RenderThread的TaskQueue中,请求RenderThread渲染窗口的下一帧。

  • PendingRegistrationFrameCallbacks列表的IFrameCallback回调接口。每一个IFrameCallback回调接口代表的是一个动画帧,这些动画帧被同步到Vsync信号到来由RenderThread自动执行。具体来说,就是每当Vsync信号到来时,就将一个类型为DispatchFrameCallbacks的Task添加到RenderThread的TaskQueue去等待调度。一旦该Task被调度,就可以在RenderThread中执行注册在PendingRegistrationFrameCallbacks列表中的IFrameCallback回调接口了。


FinalizerDaemon 析构守护线程


对于重写成员函数finailze的对象,他们被GC决定回收时,并没有马上被回收,而是被放入到一个队列中,等待FinalizerDaemon守护线程去调用他们的成员函数finalize,然后再被回收。


FinalizerWatchdogDaemon 析构监护守护线程。


用来监控FinalizerDaemon线程的执行。一旦检测哪些重写了成员函数finalize的对象在执行成员函数finalize时超出一定的时候,那么就会退出VM。


ReferenceQueueDaemon 引用队列守护线程


我们知道在创建引用对象的时候,可以关联一个队列。当被引用对象引用的对象被GC回收的时候呀,被引用对象就会呗加入到其创建时关联的队列中去,这个加入队列的操作就是由ReferenceQueueDaemon守护线程来完成的。这样应用程序就可以知道哪些被引用对象引用的对象已经被回收了。


HeapTrimmerDaemon 堆裁剪守护线程


用于执行裁剪堆的操作,也就是用来将哪些空闲的堆内存归还给系统。


HeapTaskDaemon 线程


Android每个进程都有一个HeapTaskDaemon线程,在该线程内进行GC操作。
HeapTaskDaemon 继承自 Daemon 对象。Daemon 对象实际是一个Runnale,并且内部会创建一个线程,用于执行当前这个 Daemon unnable,这个内部线程的线程名就叫 HeapTaskDaemon。


private static class HeapTaskDaemon extends Daemon {
private static final HeapTaskDaemon INSTANCE = new HeapTaskDaemon();

HeapTaskDaemon() {
super("HeapTaskDaemon");
}

// Overrides the Daemon.interupt method which is called from Daemons.stop.
public synchronized void interrupt(Thread thread) {
VMRuntime.getRuntime().stopHeapTaskProcessor();
}

@Override public void runInternal() {
synchronized (this) {
if (isRunning()) {
// Needs to be synchronized or else we there is a race condition where we start
// the thread, call stopHeapTaskProcessor before we start the heap task
// processor, resulting in a deadlock since startHeapTaskProcessor restarts it
// while the other thread is waiting in Daemons.stop().
VMRuntime.getRuntime().startHeapTaskProcessor();
}
}
// This runs tasks until we are stopped and there is no more pending task.
VMRuntime.getRuntime().runHeapTasks();
}
}

Binder 线程


每个APP进程在启动之后会创建一个binder线程池,用于相应IPC客户端的请求。


例如APP与AMS等服务之间可以通过IPC双向通信,当APP作为服务端的时候,就需要通过Binder线程相应来自AMS的请求。一个Server进程中有一个最大的Binder线程数限制,默认为16个binder线程。


与系统服务通信或者自行实现多进程Binder通信大致需要注意一下几点:



  • 与系统通信的方法,建议使用try cache进行防护,防止App出现崩溃。

  • 需要注意调用频率,部分API调用频率过快响应会比较慢,从而导致主线程卡顿甚至ANR。


主线程


主线程也较UI线程。


这个线程作为Android 开发都比较熟悉。


三方线程


OkHttp相关


如果你使用OkHttp作为网络请求库,那么工程中会有以下几类线程


OkHttp Dispatcher


用于实际执行HTTP请求


  @get:Synchronized
@get:JvmName("executorService") val executorService: ExecutorService
get() {
if (executorServiceOrNull == null) {
executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
SynchronousQueue(), threadFactory("$okHttpName Dispatcher", false))
}
return executorServiceOrNull!!
}

OkHttp TaskRunner


4.x版本引入,用于管理和调度内部任务


  companion object {
@JvmField
val INSTANCE = TaskRunner(RealBackend(threadFactory("$okHttpName TaskRunner", daemon = true)))

val logger: Logger = Logger.getLogger(TaskRunner::class.java.name)
}

OkHttp ConnectionPool


负责清理和回收无用的连接池


  private val cleanupTask = object : Task("$okHttpName ConnectionPool") {
override fun runOnce() = cleanup(System.nanoTime())
}

Okio Watchdog


OkHttp中使用Watchdog来处理超时逻辑


  private class Watchdog internal constructor() : Thread("Okio Watchdog") {
init {
isDaemon = true
}

override fun run() {
while (true) {
try {
var timedOut: AsyncTimeout? = null
synchronized(AsyncTimeout::class.java) {
timedOut = awaitTimeout()

// The queue is completely empty. Let this thread exit and let another watchdog thread
// get created on the next call to scheduleTimeout().
if (timedOut === head) {
head = null
return
}
}

// Close the timed out node, if one was found.
timedOut?.timedOut()
} catch (ignored: InterruptedException) {
}
}
}
}

Glide相关


如果你使用Glide加载图片,那么工程中会有以下几类线程


"glide-source-thread-x"


用于从网络、文件系统或其他数据源中加载原始图像数据


  public static GlideExecutor.Builder newSourceBuilder() {
return new GlideExecutor.Builder(/* preventNetworkOperations= */ false)
.setThreadCount(calculateBestThreadCount())
.setName(DEFAULT_SOURCE_EXECUTOR_NAME);
}

"glide-disk-cache-thread-x"


用于从缓存中读取数据


  public static GlideExecutor.Builder newDiskCacheBuilder() {
return new GlideExecutor.Builder(/* preventNetworkOperations= */ true)
.setThreadCount(DEFAULT_DISK_CACHE_EXECUTOR_THREADS)
.setName(DEFAULT_DISK_CACHE_EXECUTOR_NAME);
}

source-unlimited


  public static GlideExecutor newUnlimitedSourceExecutor() {
return new GlideExecutor(
new ThreadPoolExecutor(
0,
Integer.MAX_VALUE,
KEEP_ALIVE_TIME_MS,
TimeUnit.MILLISECONDS,
new SynchronousQueue<Runnable>(),
new DefaultThreadFactory(
new DefaultPriorityThreadFactory(),
DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME,
UncaughtThrowableStrategy.DEFAULT,
false)));
}

animation


用于执行动画


  public static GlideExecutor.Builder newAnimationBuilder() {
int maximumPoolSize = calculateAnimationExecutorThreadCount();
return new GlideExecutor.Builder(/* preventNetworkOperations= */ true)
.setThreadCount(maximumPoolSize)
.setName(DEFAULT_ANIMATION_EXECUTOR_NAME);
}

ARouter相关


如果你使用ARouter作为路由


ARouter task pool No.x , thread No.X


创建位置


    public static DefaultPoolExecutor getInstance() {
if (null == instance) {
synchronized (DefaultPoolExecutor.class) {
if (null == instance) {
instance = new DefaultPoolExecutor(
INIT_THREAD_COUNT,
MAX_THREAD_COUNT,
SURPLUS_THREAD_LIFE,
TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(64),
new DefaultThreadFactory());
}
}
}
return instance;
}

另外ARouter也为开发者提供了使用自己线程池的接口:


    static synchronized void setExecutor(ThreadPoolExecutor tpe) {
executor = tpe;
}

三方库总结


关于三方库中的线程池这里仅列举了几个库。如果你的工程中含有大量的三方库,我详细也会存在大量的工作线程。


一些三方库会提供接口允许开发者将其替换成工程内部统一维护的线程池,这样可以做到工程中线程的收敛。笔者遇到最离谱的应该是腾讯X5浏览器,由另外一个小组的同事引入到项目中,集成后发现其引入了大量的线程(大概几十个)。


其他


我个人不太喜欢发这些纯概念的东西,更喜欢总结一些在工程中遇到的问题、对应的解决方案以及思考。后面考虑尽量少发这类笔记,多输出一些思考。


作者:半山居士
来源:juejin.cn/post/7372445124753883155
收起阅读 »

两台Android 设备同一个局域网下如何自由通信?

一、背景 笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。 基于此我们需...
继续阅读 »

一、背景


笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。


基于此我们需要考虑以下几点



  • 如何发现对方

  • 两款App如何通信

  • 通信协议如何选择


二、发现对方


在局域网中查找识别我们可以基于DNS-SD协议。


1、DNS-SD协议介绍


DNS-SD(Domain Name System - Service Discovery)是一种用于在局域网(Local Area Network,LAN)中发现服务的协议。它使用DNS协议扩展了域名系统(Domain Name System,DNS)的功能,使得客户端能够在局域网中查找特定类型的服务,并获取有关该服务的信息。


在Android 中也有对应的Api可以使用:NsdManager。
NsdManager主要实现三个功能:



  • 注册

  • 发现

  • 解析


其中一台设备作为服务端(即上面的Server App)通过NsdManager注册服务,提供自己的IP地址与端口号,同时可以提供用于标识自己的信息,例如设备ID。当一个局域网中有多台服务时,客户端在发现服务后,可以基于标识信息确认是否为自己需要找到的服务端。


另一台设备作为客户端(即上面的Client App),通过NsdManager的发现接口去发现服务。发现服务之后,再调用解析接口,获取到对应的信息,如服务端的IP地址、端口号、设备标识信息等。



详细的官方介绍地址:developer.android.com/reference/a…



2、注册


作为服务端的Server App,通过NsdManager注册。


NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName(ServiceName);
serviceInfo.setServiceType(ServiceType);
int localPort = getLocalPort();
serviceInfo.setPort(localPort);
serviceInfo.setAttribute(Attribute_UUID, UuidManager.INSTANCE.getUUID());
String ipAddress = LocalIpUtils.getIPAddress();
serviceInfo.setAttribute(Attribute_IP, ipAddress);
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, new NsdManager.RegistrationListener() {
@Override
public void onRegistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {
//异常上报
}

@Override
public void onUnregistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {}

@Override
public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) {
//注册成功
}

@Override
public void onServiceUnregistered(NsdServiceInfo nsdServiceInfo) {}
});


3、发现


Client App,调用NsdManager#discoverServices()接口去发现服务。


NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
discoveryListener = new NsdManager.DiscoveryListener() {
@Override
public void onStartDiscoveryFailed(String s, int i) {}

@Override
public void onStopDiscoveryFailed(String s, int i) {}

@Override
public void onDiscoveryStarted(String s) {}

@Override
public void onDiscoveryStopped(String s) {}

@Override
public void onServiceFound(NsdServiceInfo nsdServiceInfo) {}

@Override
public void onServiceLost(NsdServiceInfo nsdServiceInfo) {}
};
nsdManager.discoverServices(NsdServer.ServiceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener);


4、解析


Client App 收到onServcieFound回调之后,就可以调用解析接口解析数据了


nsdManager.resolveService(nsdServiceInfo, new NsdManager.ResolveListener() {
@Override
public void onServiceResolved(NsdServiceInfo nsdServiceInfo) {
Map<String, byte[]> attributes = nsdServiceInfo.getAttributes();
}

@Override
public void onResolveFailed(NsdServiceInfo nsdServiceInfo, int i) {}
});


二、TCP通信


发现设备之后,我们要考虑两台设备要如何通信。此时我们可以有两套方案,分别是HTTP以及TCP。


HTTP


使用HTTP的话,就是在Server App上启动一个HTTP服务,我们可以预定义一些接口用于和Client来通信。
优点是比较简单,缺点是Server App没有办法主动通知Client App。


TCP


直接使用TCP协议的话,我们可以考虑选择一个支持TCP通信的框架,自定义一个简易的通信协议。优点是客户端与服务端可以互相通信,满足我们的需求。缺点是相对复杂一些。


在我们的项目中,我们有两台设备交互通信的需求,所以选择了TCP协议。


1、TCP通信库的选择


在我们的项目中使用了Netty作为TCP通信框架。关于Netty其大名鼎鼎,网络上的分析文章一大把,这里就不详细介绍了。Netty在国内有一位步道的大神名为李林峰(在华为工作),有兴趣的同学可以去看看大神的书。


2、简易的TCP协议设计


由于业务比较简单,所以这里我们将TCP通信协议进行简化。整体协议大致如下:[Int][String]。


其中Int用于指定后面String字符串长度,我们可以基于这个Int来处理拆包、粘包的问题,这个逻辑后面详细介绍。String可以是JSON结构,可以依据业务来定义JSON中字段。


3、TCP服务端


使用Netty实现TCP服务端大致代码


bossGr0up = new NioEventLoopGr0up();
workerGr0up = new NioEventLoopGr0up();
//构建引导程序
mBootstrap = new ServerBootstrap();
//设置EventGr0up
mBootstrap.group(bossGr0up, workerGr0up);
//设置Channel
mBootstrap.channel(NioServerSocketChannel.class);
mBootstrap.option(ChannelOption.SO_BACKLOG, 128);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.childHandler(new CustomChannelInitializer());
channelFuture = mBootstrap.bind(inetPort).sync();

以上TCP服务就启动了。


4、TCP客户端


使用Netty实现TCP客户端大致代码:


初始化


//构建线程池
mGr0up = new NioEventLoopGr0up();
//构建引导程序
mBootstrap = new Bootstrap();
//设置EventGr0up
mBootstrap.group(mGr0up);
//设置Channel
mBootstrap.channel(NioSocketChannel.class);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.remoteAddress(ip.getIp(), ip.getPort());
mBootstrap.handler(new CustomChannelInitializer());

建立连接


ChannelFuture channelFuture = mBootstrap.connect();
channelFuture.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) {
final boolean isSuccess = future.isSuccess();
writeLog("operation complete future.isSuccess: " + isSuccess);
}
});


断开连接


public void disConnect(boolean onPurpose) {
try {
if (mGr0up != null) {
mGr0up.shutdownGracefully();
}
} catch (Exception e) {
e.printStackTrace();
}
try {
if (mChannel != null) {
mChannel.close();
}
} catch (Exception e) {
e.printStackTrace();
}
try {
if (mChannelHandlerContext != null) {
mChannelHandlerContext.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}

我们可以在operationComplete()方法中,确认建立连接成功。


ChannelInitializer


为了让更多协议和其他各种方式处理数据,Netty有了Handler组件。Handler就是为了处理Netty里面的置顶事件或一组事件。 ChannelInitializer 的作用就是将Handler 添加到ChannelPipeline中。当你发送或收到消息的时候,这些Handler就决定怎么处理你的数据。


public class CustomChannelInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel socketChannel) {
ChannelPipeline pipeline = socketChannel.pipeline();
mCustomDecoder = new CustomDecoder();
mCustomEncoder = new CustomEncoder();
mCustomHandler = new CustomHandler();

pipeline.addLast(mCustomDecoder);
pipeline.addLast(mCustomEncoder);
pipeline.addLast(mCustomHandler);
}

@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
super.handlerAdded(ctx);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
}
}


ChannelPipeline 是一个管道,Handler就是里面一层一层要对数据进行处理的事件。所有的Handler都一个顶层接口ChannelHandler。ChannelHandler有两个子接口:



  • ChannelInboundHandler

  • ChannelOutboundHandler


Netty中数据流有2个方向:



  • 数据进(应用收到消息)的时候与ChannelInboundHandler有关。

  • 数据出(应用发出数据)的时候与ChannelOutboundHandler有关。


为了将数据从一端发送到另一端,一般都会有一个或多个ChannelHandler用各种方式对数据进行操作。 决定这些Handler以一种特定的顺序处理数据的是ChannelPipeline。


ChannelInboundHandler与ChannelOutboundHandler可以混在同一个ChannelPipeline里面。当应用收到数据时,首先从ChannelPipeline的头部进入到一个ChannelInboundHandler 。第一个ChannelInboundHandler处理后传给下一个ChannelInboundHandler。 然后ChannelPipeline中没有其他的ChannelInboundHandler 了数据就会到达ChannelPipeline的尾部,也就是应用对数据的处理已经完成了。


数据流出的过程是返过来的,首先从ChannelPipeline的尾部开始进入到最后一个ChannelOutboundHandler,最后一个ChannelOutboundHandler处理后,传给前面一个ChannelOutboundHandler。 和进不同的,进是从前到后,而出是从后到前。没有多余的ChannelOutboundHandler的时候,数据进入实际网络中传输,触发一些IO操作。


一旦一个ChannelHandler被添加到ChannelPipeline中,它就会获得一个ChannelHandlerContext。一般情况下,获得这个对象并持有它是安全的, 不过在数据包协议的时候不一样安全,例如UDP协议。 在Netty中有两种发送数据的方式。你可以写到Channel中或者使用ChannelhandlerContext对象。他们的主要区别是,直接写到Channel,则数据会从ChannelPipeline头部传到尾部。 每一个ChannelHandler都会处理数据,而使用ChannelHandlerContext则是将数据传送到下一个ChannelHandler。


一个 Channel 包含了一个 ChannelPipeline, 而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表, 并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。 入站事件和出站事件在一个双向链表中,入站事件会从链表head往后传递到最后一个入站的handler,出站事件会从链表tail往前传递到最前一个出站的handler,两种类型的handler互不干扰。


我们在 CustomChannelInitializer#initChannel()方法中添加编码、解码器。其中 CustomHandler 继承自SimpleChannelInboundHandler用来接收消息。


三、编解码


当你用Netty接收或发送消息,必须将其从一种格式转成另一种格式。比如收消息,你需要从字节转为Java对象。发消息就是将Java对象转成字节发出去。


Netty中有各种各样的编码器和解码器基类。



  • ByteToMessageDecoder

  • MessageToByteEncoder

  • ProtobufEncoder

  • ProtobufDecoder

  • StringDecoder

  • StringEncode


这里,编码器都是继承自ChannelInboundHandlerAdapter或者实现了ChannelInboundHandler。当读到数据时,会调用ChannelRead方法。重写此方法,然后就会调用decode 方法进行解码。并且会执行ChannelHandlerContext,fireChannelRead方法,将解码后的消息传给下一个Channelhandler。 当发送消息的时候,也执行类似的过程,编码器将消息转为字节,然后传给下一个ChannelOutboundHandler。


在我们的项目中,由于自定义了协议所以使用了ByteToMessageDecoder、MessageToByteEncoder。


1、编码


编码器比较简单,我们可以自定义类继承 MessageToByteEncoder 来实现解码。
需要注意的是,按照我们定义的协议顺序向 ByteBuf 中写入数据就好了。示例代码:


public class CustomEncoder extends MessageToByteEncoder<CustomMessage> {

@Override
protected void encode(ChannelHandlerContext channelHandlerContext, CustomMessage message, ByteBuf byteBuf) {
//byteBuf.writeInt();
//byteBuf.writeByte();
}

}


2、解码


解码我们要解决TCP拆包、粘包的问题。


一般TCP中应对拆包、粘包基本有以下几种方案:



  • 消息定长,这但比较好理解,由于定长了,我们可以直接判断ByteBuffer中数组长度。不过在实践过程中,一般我们不会使用这个方案,因为扩展性太差了。

  • 使用特殊字符作为结尾。

  • 自定义协议。


前面我们有提到我们的通信协议为[Int][String]实际上就是一个非常简易的自定义协议(由于业务简单,所以这里没有设计的非常复杂)。我们使用Int来标记后面的String长度,这样两端收到消息后,按照这个格式解析实际上就知道消息的长度了。


在Netty中,我们可以自定义类继承自 ByteToMessageDecoder,来处理解码。Netty中 ByteBuf 来处理数据。具体的步骤是:



  • 先标记已读位置:byteBuf.markReaderIndex();

  • 判断 ByteBuf 可读是否达到4个字节长度,如果不足直接返回,重置已读位置。

  • 读取前4个字节,转为Int。

  • 如果字符长度 > 0,那么接下来继续读 length 长度的字符,转为String。

  • 这样整个协议就解码完成了。


这里需要注意的是:客户端要与服务端约定好是大端字节序还是小端字节序。


以上在局域网中,两款App进行TCP通信就已经搭建好了,我们有了协议上层业务就可以基于此来封装业务需要的逻辑了。实际上很多IM 通信SDK,基本上都是自定义通信协议,只是协议会比本篇中举例的协议要复杂的多。


四、优化点


项目上线一段时间后,用户反馈偶现存在两台设备无法通信的问题。后面经过调研发现是服务端进程一直存在(即Server App 并没有挂),只是TCP服务挂了。


我们在客户端有处理断线重连逻辑,但是在服务端没有做任何监控重启逻辑。


如何优化


经过调研,我们在服务端Server App中,另外启动一个客户端来与TCP服务端建立连接(相当于是我监控我自己了),如果建立失败,就重启TCP服务。



  • 建立一个TCP客户端,启动轮询逻辑

  • 如果该客户端没有与服务端建立连接,那么尝试建立连接。

  • 如果连续三次都无法建立成功连接,那么认为此时TCP服务存在异常,重启TCP服务。

  • 如果可以正常与TCP服务建立连接,那么开始向TCP服务发送心跳包

  • 如果连续三次TCP服务没有回复心跳回包,那么也认为TCP服务存在异常,重启TCP服务。
    以上,就是我们针对TCP服务端的优化,上面逻辑上线后,无法建立连接的反馈就没有了。


五、总结


本篇主要是介绍如何在局域网中,两个APP使用Dns-SD协议来发现对方。自定义协议进行TCP通信,使用Netty作为TCP通信框架。


作者:半山居士
来源:juejin.cn/post/7375275474006802443
收起阅读 »

Dialog 可不可以传Application

自定义Dialog继承Dialogclass SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) { constructor(contex...
继续阅读 »

自定义Dialog

  1. 继承Dialog
class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

constructor(context: Context) : this(context, R.style.CustomDialogTheme)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
}
}
  1. 创建自己的主题样式
<style name="CustomDialogTheme" parent="@android:style/Theme.Dialog">  
<item name="android:windowBackground">@android:color/transparentitem> //透明背景
<item name="android:windowNoTitle">trueitem> //没有标题
<item name="android:windowFullscreen">trueitem> //是否全屏
<item name="android:backgroundDimEnabled">trueitem> //背景黑暗
<item name="android:backgroundDimAmount">0.5item> //背景黑暗透明度
style>

可以传入自己创建的主题,也可以不传,Android 会有默认的主题

Dialog(@UiContext @NonNull Context context, @StyleRes int themeResId,  
boolean createContextThemeWrapper) {
if (createContextThemeWrapper) {
if (themeResId == Resources.ID_NULL) {
final TypedValue outValue = new TypedValue();
//这里会指定默认的主题,如果不传主题
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
}
mContext = new ContextThemeWrapper(context, themeResId);
} else {
mContext = context;
}

mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

final Window w = new PhoneWindow(mContext);
mWindow = w;
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
w.setOnWindowSwipeDismissedCallback(() -> {
if (mCancelable) {
cancel();
}
});
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
mListenersHandler = new ListenersHandler(this);
}
  1. 使用
val dialog=SourceDialog(context) 
dialog.show()

遇到的问题

  1. Dialog 可不可以传Application ?

背景:这几天接到一个需求,收到动作需要在任何界面上弹出信号源选择器页面(铺满整个屏幕),我一开始是选择了Service+WindowManager 添加View显示的。之前也看了一下公司的CommonUI (展示一下亮度条,音量条之类的全局UI) 用到的是Dialog 弹出界面的。我也跟着写一个,才发现一个一个坑接着来。

答案是可以的,是要window 传一个 type

这是我的Dialog

class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

constructor(context: Context) : this(context, R.style.CustomDialogTheme)

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

//传入Application
val dialog=SourceDialog(MyApplication.CONTEXT)
dialog.show()

我就很疑惑,提示Activity需要运行

屏幕截图 2024-05-24 151913.png

我后来换成了,正常运行

//传入activity
val dialog=SourceDialog(this@MainActivity)
dialog.show()

很疑惑,对比同事负责的项目发现我需要给window设置了一些东西

//Dialog 

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
window?.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT)
}

//这样就可以正常展示
val dialog=SourceDialog(MyApplication.CONTEXT)
dialog.show()

  1. 设置Dialog 全屏宽高不成功

我的布局文件 最外层是线性布局

<LinearLayout android:id="@+id/group_source"  
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:gravity="center"
android:orientation="horizontal"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">


LinearLayout>

但是我发现 设置主题样式true 不起作用。 我在这里参考了 三句代码创建全屏Dialog或者DialogFragment

  • 粗暴一点直接设置window 大小 ==需要在setContentView 之后设置window的大小才会生效,如果在setContentView 之前设置,此时window的dectorView为空不会更新布局
class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

constructor(context: Context) : this(context, R.style.CustomDialogTheme)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
window?.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT)
window?.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT) //也可以换成具体数值宽高
}
}
  • 主题样式增加一样 false 因为很多默认的Dialog 主题这个属性一般为true。
<style name="CustomDialogTheme" parent="@android:style/Theme.Dialog">  
<item name="android:windowBackground">@android:color/transparentitem>
<item name="android:windowNoTitle">trueitem>
<item name="android:windowFullscreen">trueitem>
<item name="android:windowIsFloating">falseitem>
<item name="android:backgroundDimEnabled">trueitem>
<item name="android:backgroundDimAmount">0.5item>
style>

Dialog setContentView 会走到PhoneWindow 的这个方法 走到installDecor -> generateLayout

屏幕截图 2024-05-24 160441.png

屏幕截图 2024-05-24 160620.png

屏幕截图 2024-05-24 160736.png 会发现这个属性为true的话,会根据内容展示。我们给这个属性设置为false这样就不用给Window设置大小了。


作者:很好881
来源:juejin.cn/post/7372396174249738278
收起阅读 »

我们如何让Android客户端暴瘦了100M

一、 引言随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的...
继续阅读 »

一、 引言

随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的时候,效果往往很有限,本文将详细介绍我们在包大小优化方面的实践经验,并通过一系列技术手段实现了显著的包体积缩减。

二、  安装包大小分析

Android的apk通常有以下几部分组成:

  1. 代码:包含应用中Java/Kotlin代码,在包中以dex的形式存在
  2. 资源:包含图片、布局文件等
  3. lib库: 包含了应用的Native代码库,以.so文件的形式存在
  4. assets:包含了应用运行时所需的非代码资源,如音频、视频、字体、配置文件等
  5. 其他:签名文件、资源索引文件等

通过分析安装包大小的组成,我们发现项目中lib库和assets占比达到70%,代码占比20%,资源和其他占比10%。

三、基础优化方案

  1. 代码优化:开启代码混淆,混淆可以帮助缩减代码尺寸、移除无用代码,通过分析反编译后的代码,我们发现很多本该混淆的类没有混淆,最终定位到工程中引入的一些三方库的混淆规则keep范围过大,导致混淆效果不理想;通过混淆规则的优化,包大小缩减了6M左右;
  2. 资源优化:解压apk文件,把res目录下的图片按照大小进行排序,我们发现项目中有一些尺寸较大的图片,把图片格式转为webp格式后,尺寸大大降低;同时通过对比资源的md5值,发现一些资源名字虽然不一样,但是内容是一样的,这些重复资源可以移除;通过资源的优化,包大小缩减了15M左右;
  3. assets资源优化:通过分析apk assets目录下的文件,我们发现里面有很多不用的文件,比如arm64位包中存在x86、armeabi-v7a等其他架构的so库,这些assets目录下的so库是三方库引入的,运行时动态加载,由于设置abiFilters无法过滤掉这些so库,导致打包进apk中;我们通过自定义构建流程,在mergeAssetsTask执行结束后移除assets中不用的so库;通过assets资源的优化,包大小缩减了4M左右.
project.afterEvaluate {
android.applicationVariants.all { variant ->
def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
mergeAssetsTask.doLast {
def assetsDir = mergeAssetsTask.outputDir.get().toString()
// 移除无用的assets资源
removeUnusedAssets(assetsDir)
}
}
}

通过基础的代码和资源等的优化,包大小缩减了25M左右,但是对于一个180M的apk来说,效果非常有限,需要探索其他方案进一步降低包大小。

三、  进阶优化方案

上面我们分析过apk中的lib库和assets文件占比达到70%,因此我们重点针对lib库和assets文件尺寸大的问题进行优化,我们可以把这些文件放到云端,在应用启动的时候下载到本地,但是这样做有以下问题:

  1. 一些lib库和assets文件在应用启动的时候就会用到,如果放到云端,会导致应用启动时间变长甚至崩溃;
  2. 应用中加载assets资源是通过系统API AssetManager.open来加载的,但是把assets文件从apk中移除后,需要修改使用AssetManager.open的地方,改为从本地私有目录加载,这样会导致改动的地方很多,而且容易漏改和错改;一些三方库由于没有开源,修改起来会更加困难;
  3. 应用中加载lib库是通过系统API System.loadLibrary来加载的,如果把so库从apk中移除后,需要修改为使用System.load加载私有目录下的so库,同样存在改动地方多,不开源的三方库修改困难的问题;
  4. 把移除的so库和assets文件打包成一个文件下发会存在由于文件尺寸大导致下载时间长,容易下载失败问题,同时会导致当用户使用到相关功能的时候需要长时间的等待,体验差。

针对以上问题,我们采用了以下优化策略:

  1. 选择性移除:只把一些尺寸大,用户使用频次较低的功能中使用的assets和so库从apk包中移除,在不影响用户体验的同时,降低安装包大小。
  2. 分包下载:需要移除的so库和assets文件按功能模块进行分包,首次使用时再去下载对应的资源包,这样能确保功能模块依赖的云端资源尽可能的小,大幅降低下载时间,提升下载成功率,减少用户等待时间。
  3. 自动化构建:通过编写gradle脚本,自定义构建过程,在构建阶段自动把assets和so库从apk包中移除并打包。
project.afterEvaluate {
android.applicationVariants.all { variant ->
def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
mergeAssetsTask.doLast {
def assetsDir = mergeAssetsTask.outputDir.get().toString()
// 把assetsDir中需要移除的assets文件移除,放到模块指定的目录下
}

def mergeNativeLibsTask = project.getTasksByName("merge${variant.name.capitalize()}NativeLibs", false)
mergeNativeLibsTask.doLast {
def libDir = mergeNativeLibsTask.outputDir.get().toString()
// 把 libDir中需要移除的so库移除,放到模块指定的目录下
// 打包压缩模块目录
}
}
}
  1. 字节码插桩:开发gradle插件,使用字节码插桩技术,在编译阶段自动把调用AssetManager.openSystem.loadLibrary的地方替换为我们的自定义加载器,工程中的代码和三方闭源库无需做任何改动。
public class MyMethodVisitor extends MethodVisitor {
@Override
public void visitMethodInsn(int opcode, String owner, String name, string desc, boolean isInterface) {
// 替换System.loadLibrary为DynamicLoader.loadLibrary
if ("java/lang/System".equals(owner) && "loadLibrary".equals(name)) {
return super.visitMethodInsn(opcode, "com/xxx/loader/DynamicLoader", name, desc, isInterface);
}

// 替换AssetManager.open为DynamicLoader.openAsset
if ("android/content/res/AssetManager".equals(owner) && "open".equals(name) && "(Ljava/lang/String;)Ljava/io/InputStream".equals(desc)) {
return super.visitMethodInsn(Opcodes.INVOKESTATIC, "com/xxx/loader/DynamicLoader", "openAsset", "(Landroid/content/res/AssetManager;Ljava/lang/String;)Ljava/io/InputStream");
}
return super.visitMethodInsn(opcode, owner, name, desc, isInterface);
}
}
  1. 双重加载机制:在自定义加载器中先尝试加载apk内置的so库和assets文件,如果出现异常,则从动态下发的文件中查找并加载,这样可以保证无论so库是否移除都可以正常加载。
// 自定义加载器

public class DynamicLoader {
public static void loadLibrary(string libname) throw Throwable {
try {
// 先加载apk包中的so库
System.loadLibrary(libname);
return
} catch(Throwable e) {
}

String soPath = findLibrary(libName);
// apk包中的so库加载失败时加载动态下发的so库
return System.load(soPath);
}

public static InputStream openAsset(AssetManager am, String fileName) throw IOException {
try {
// 先加载apk包中的asset文件
return am.open(fileName);
} catch(IOException e) {
}

// apk包中的asset文件加载失败时加载动态下发的asset文件
String assetPath = findAsset(fileName);
return new FileInputStream(assetPath);
}
}

四、  实施效果

采用上述包优化方案后,我们的Android客户端安装包大小从180M缩减到78M,实现了显著的包体积缩减。同时,通过监控优化后版本的崩溃率和用户反馈,未出现明显的崩溃率升高和用户体验下降的情况。

五、  未来展望

应用的安装包大小优化是一个长期的过程,需要建立一套包大小的监控、预警、原因分析、自动优化等机制,确保安装包大小在合理范围,我们将从以下几个方面进行探索:

  1. 设定安装包大小基准,持续监控安装包大小的变化,当安装包大小偏移基准值过大的时候,触发预警,并自动分析包大小增加原因,找出导致包大小增大的文件;
  2. 优化构建流程,构建阶段自动压缩大图片为webp格式,自动合并重复资源;
  3. 持续优化应用的性能表现和用户体验,并根据实际情况进行进一步的优化调整。


作者:jack5288
来源:juejin.cn/post/7379168502455222311
收起阅读 »

鸿蒙实现动态增删 View,看这一篇就够了!!

在 Android 开发的过程中,我们经常使用 addView ,removeView等实现在 java 代码中动态添加和删除 View 的能力 但是在鸿蒙中,组件是相对于静态的结构,而且鸿蒙也没有提供类似于 addView ,removeView的方法,那我...
继续阅读 »

在 Android 开发的过程中,我们经常使用 addViewremoveView等实现在 java 代码中动态添加和删除 View 的能力


但是在鸿蒙中,组件是相对于静态的结构,而且鸿蒙也没有提供类似于 addViewremoveView的方法,那我们怎么来实现动态化增删组件的能力呢


1. 使用 ForEach 实现动态化增删组件


1.1. ForEach 的入参


翻阅了鸿蒙的官方文档,终于看到了一种方法来解决这个问题,那就是使用 ForEach这个组件


这个组件的官方定义如下


ForEach(
arr: Array,
itemGenerator: (item: any, index: number) => void,
keyGenerator?: (item: any, index: number) => string
)


  • arr


    arr 有多少个元素,ForEach 就会渲染多少个组件


  • itemGenerator


    将 arr 中的元素转换为对应的组件样式


    决定了组件长什么样子


  • keyGenerator


    生成唯一的 key



1.2. ForEach 的渲染


在ForEach循环渲染过程中,系统会为每个数组元素生成一个唯一且持久的键值,用于标识对应的组件。当这个键值变化时,ArkUI框架将视为该数组元素已被替换或修改,并会基于新的键值创建一个新的组件。


ForEach 的渲染分为两种:



  • 首次渲染


在ForEach首次渲染时,会根据前述键值生成规则为数据源的每个数组项生成唯一键值,并创建相应的组件。



  • 非首次渲染:


在ForEach组件进行非首次渲染时,它会检查新生成的键值是否在上次渲染中已经存在。如果键值不存在,则会创建一个新的组件;如果键值存在,则不会创建新的组件,而是直接渲染该键值所对应的组件。


2. ForEach 的简单 demo


@Entry
@Component
export struct MyPage {
@State items: Item[] = [
{ text: "1" },
{ text: "2" },
{ text: "3" },
]

build() {
Column() {
ForEach(
this.items,
(item: Item, index: number) => {
Text(item.text)
.width(40)
.height(40)
}
)

Button("add item ")
.onClick(
()=>{
this.items.push(
{ text: "11" },
)
}
)
}
.height('100%')
.width('100%')
}
}


interface Item {
text: string,
}

运行后的状态为这样:每次点击按钮,都会新增一个



3. ForEach 的注意事项


3.1. 数组中元素子属性发生变化时的处理


数组中元素子属性发生变化时,鸿蒙默认是不会触发渲染的


解决办法是:使用@Observed@ObjectLink


3.2. 最好自定义 key,key 中不含index


鸿蒙的默认 key


鸿蒙 Foreach 的默认key 为


(item: T, index: number) => {
//👇 这里带着 index
return index + '__' + JSON.stringify(item);
}

默认的 key 里面带着 index,如果我们在对数组进行操作的过程中,将元素的 index改变了,就会导致 index 改变的元素对应的组件被重绘


鸿蒙 ForEach 的渲染


鸿蒙通过 key 来判断组件是新组件还是现有组件


index 改变,key 就改变了,鸿蒙就会执行两个操作



  1. 删除原来的 key 对应的组件

  2. 重新创建组件


就会导致原来的组件中的所有状态全部没有了比如说我们正在执行动画,但是 key 被改变了,动画就会中断,重新创建的组件没有动画执行的状态,如果我们想继续执行动画,那么必须保存动画的状态,这样成本太大了,


而我们只需要自定义 key,让 index 不在 key 里面,就解决这个问题了


为什么 index 会改变?


比如说中间的元素被删除了,后面的元素 index 就会改变


而且我们不可能在执行时,一定保证移除的是最后一个元素,添加的一定在最后面


而如果我们自定义key 里面没有 index,那么我们可以随意的增删数组中的元素


自定义 key 的优点



  • 可以让我们任意对数组进行操作,

  • 优化性能


比如说我们只是删除了数组中间的一个元素,后面的元素没有任何的改变


如果我们使用鸿蒙默认的 key,后面的元素 index 改变,key 改变,全部会重绘


如果我们自定义 key,后面的元素就不会重绘了,节省性能


自定义 key 的注意事项



  1. 一定不要包含 index

  2. key 的组成部分尽量全部为常量,不要变量


如果是变量,保证组件生命周期内不会变化


或者如果生命周期发生变化,是自己预期内的



作者:wddin
来源:juejin.cn/post/7374984900372709412
收起阅读 »

协程Job的取消,你真的用对了吗?

前言我们知道,调用协程的lifecycleScope的launch方法后会生成一个Job对象,Job可以调用cancel()方法来取消,也可以由lifecycle宿主在生命周期结束时自行取消。但job取消后,并不代表其后面的代码都不执行了,在老油条同事的代码里...
继续阅读 »

前言

我们知道,调用协程的lifecycleScope的launch方法后会生成一个Job对象,Job可以调用cancel()方法来取消,也可以由lifecycle宿主在生命周期结束时自行取消。但job取消后,并不代表其后面的代码都不执行了,在老油条同事的代码里也发现了同样的问题,cancel后并没有真正停掉后台的任务

结论

先说结论,协程Job的cancel()方法并不会立即中断后续代码的执行,只是将任务状态isActive改为false。只有当执行下一个可取消的suspend方法时,才会抛出一个CancellationException,停掉后面的代码。 这意味着,如果一个Job在任务过程中不存在一个可取消suspend方法的调用,那么直到任务结束都不会停止,即使是调用了cancel()方法。

fun jobTest() {
runBlocking {
val job1 = launch(Dispatchers.IO) {
Log.d(TAG, "job1 start")
Thread.sleep(2_000)
Log.d(TAG, "job1 finish")
}
val job2 = launch {
Log.d(TAG, "job2 start")
delay(2_000)
Log.d(TAG, "job2 finish")
}
delay(1000)
job1.cancel()
job2.cancel()
}
}
2024-06-10 23:05:37.407 21238-21272 JobTest    D  job1 start
2024-06-10 23:05:37.407 21238-21327 JobTest D job2 start
2024-06-10 23:05:39.407 21238-21272 JobTest D job1 finish

如上述示例中,job1跟job2都调用了cancel()方法取消,但由于job1任务内没有suspend方法,job1在cancel后依然执行完了代码;而job2在第二个delay方法前取消了,后面的代码也不再执行。

虽然说协程任务的错误取消,通常情况下也不会导致逻辑出错或者业务异常,但还是会造成一些后台资源的浪费或者内存泄漏问题。而且也由于没有太大影响,很多时候也难以被发现,像是代码刺客一样的东西在危害着项目。

如何取消协程

  1. 既然job取消后会改变任务状态,可以在代码语句中根据isActive状态决定是否继续执行
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (isActive) {
//..
}
Log.d(TAG, "job finish")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-10 23:54:46.430  4094-4353  JobTest        D  job start
2024-06-10 23:54:47.434 4094-4330 JobTest D job cancel
2024-06-10 23:54:47.434 4094-4353 JobTest D job finish

  1. 在代码执行语句中有suspend修饰的挂起方法,在协程取消后执行到suspend方法会抛出异常,从而停止协程job
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (true) {
delay(1)
}
Log.d(TAG, "job finish")
}
job.invokeOnCompletion {
Log.d(TAG, "invokeOnCompletion:$it")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-10 23:59:22.531 10172-10371 JobTest        D  job start
2024-06-10 23:59:23.536 10172-10270 JobTest D job cancel
2024-06-10 23:59:23.539 10172-10380 JobTest D invokeOnCompletion:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@3df8870

可以看到任务抛出了JobCancellationException,并且不会执行到job finish语句。

两种任务停止方式的区别在于,第二种方式因为delay()这个suspend方法抛出了异常而终止执行,第一种由于没有遇到suspend方法并不会抛出异常,可以执行到结束。

那么只要是suspend方法就一定能停止协程吗?

lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (true) {
emptySuspend()
}
Log.d(TAG, "job finish")
}
job.invokeOnCompletion {
Log.d(TAG, "invokeOnCompletion:$it")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}

private suspend fun emptySuspend() {
return suspendCoroutine {
it.resume(Unit)
}
}

2024-06-11 00:04:45.144 14010-14234 JobTest        D  job start
2024-06-11 00:04:46.151 14010-14241 JobTest D job cancel

运行后等待数秒,发现并不会抛出异常。明明一直在调用suspend方法,任务取消后却不会响应。

事实上,普通suspend方法并不会处理cancel标志,只有suspendCancelable类型方法会在执行前判断cancel状态并抛出异常。而常见的delay、emit方法都是suspendCancelable类型。

将emptySuspend()方法做一个修改如下

private suspend fun emptySuspend() {
return suspendCancellableCoroutine {
it.resume(Unit)
}
}

运行后发现任务可以被cancel()掉而停止

2024-06-11 00:09:11.169 17728-17872 JobTest        D  job start
2024-06-11 00:09:12.174 17728-17865 JobTest D job cancel
2024-06-11 00:09:12.177 17728-17872 JobTest D invokeOnCompletion:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@7cc1e91

协程取消原理

再简单从协程的实现原理解释一下为什么协程Job要在执行suspend方法时才能中断。

挂起方法

用suspend修饰的方法称为挂起方法,需要在协程作用域才能调用。

suspend fun delaySuspend() {
Log.d(TAG, "start delay: ")
delay(100)
Log.d(TAG, "delay end")
}

挂起方法会编译成Switch状态机模式,每个挂起方法都是其中一个case,每个case执行都依赖前面的case,这就是协程切换与挂起停止的原理。协程本质上是产生了一个 switch 语句,每个挂起点之间的逻辑都是一个 case 分支的逻辑。 参考 协程是如何实现的 中的例子:

Function1 lambda = (Function1)(new Function1((Continuation)null) {
    int label;

    @Nullable
    public final Object invokeSuspend(@NotNull Object $result) {
        byte text;
        @BlockTag1: {
            Object result;
            @BlockTag2: {
                result = IntrinsicsKt.getCOROUTINE_SUSPENDED();
                switch(this.label) {
                    case 0:
                        ResultKt.throwOnFailure($result);
                        this.label = 1;
                        if (SuspendTestKt.dummy(this) == result) {
                            return result;
                        }
                        break;
                    case 1:
                        ResultKt.throwOnFailure($result);
                        break;
                    case 2:
                        ResultKt.throwOnFailure($result);
                        break @BlockTag2;
                    case 3:
                        ResultKt.throwOnFailure($result);
                        break @BlockTag1;
                    default:
                        throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
            }

            text = 1;
            System.out.println(text);
            this.label = 2;
            if (SuspendTestKt.dummy(this) == result) {
                return result;
            }
        }

        text = 2;
        System.out.println(text);
        this.label = 3;
        if (SuspendTestKt.dummy(this) == result) {
            return result;
        }
    }
    text = 3;
    System.out.println(text);
    return Unit.INSTANCE;
}

@NotNull
public final Continuation create(@NotNull Continuation completion) {
    Intrinsics.checkNotNullParameter(completion, "completion");
    Function1 funcation = new constructor>(completion);
    return funcation;
}

public final Object invoke(Object object) {
    return (()this.create((Continuation)object)).invokeSuspend(Unit.INSTANCE);
        }
});

任务取消

任务取消后,对于suspendCancelable方法的分支,会因为取消的状态而抛出JobCancellationException,停止后续代码的执行。如果在job中对于异常进行捕获,将可能导致任务取消失败。

lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
kotlin.runCatching {
while (true) {
emptySuspend()
}
}.onFailure {
Log.e(TAG, "catch: $it")
}
Log.d(TAG, "job finish")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-11 00:22:22.686 25890-26199 JobTest        D  job start
2024-06-11 00:22:23.690 25890-26217 JobTest D job cancel
2024-06-11 00:22:23.696 25890-26199 JobTest E catch: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@e557022
2024-06-11 00:22:23.696 25890-26199 JobTest D job finish

由于捕获了JobCancellationException,导致job finish语句正常执行了。在一些情况下,可能会由于JobCancellationException被捕获导致任务没有及时取消。因此在job内捕获异常时,选择性的过滤掉JobCancellationException,将异常再度抛出

kotlin.runCatching {
// ...
}.onFailure {
if (it is CancellationException) {
throw it
}
}

协程异常处理

协程遇到无法处理的异常后,会按照停止自身子任务-停止自身任务-停止父任务的顺序依次停掉任务,并将异常抛给父作用域。当所有作用域都无法处理异常,会抛给unCautchExceptionHandler。如果异常一直没被处理,则可能引起崩溃。

值得一提的是,由Job.cancel()方法引起的CancellationException并不会传给父Job,在cancelParent之前会被过滤掉,也就是cancel()方法只能取消自身和子协程,不会影响父协程,也不会引起程序崩溃。


作者:护城河编程大师
来源:juejin.cn/post/7378363694939635722
收起阅读 »

鸿蒙,ArkTs 一段诡异的代码

分享一段我之前在学习 ArkTs 的时候,看到的一段诡异的代码。我们来看看下面的代码,按照你多年的经验,分析一下下面的代码是否可以正常执行,如果可以执行的话,能说出运行结果吗。for (let i = 0; i < 3; i++) { let i =...
继续阅读 »

分享一段我之前在学习 ArkTs 的时候,看到的一段诡异的代码。我们来看看下面的代码,按照你多年的经验,分析一下下面的代码是否可以正常执行,如果可以执行的话,能说出运行结果吗。

for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}

以上代码是可以正常运行的,这段代码的执行结果,将会输出了 3 个 'abc' :

abc
abc
abc

这段代码中的 for 循环尝试执行三次循环,每次循环中都声明了一个新的局部变量 i,并将其赋值为字符串 'abc'。然后,它打印出这个新的局部变量 i

在每次迭代中,尽管外部循环的控制变量也叫 i,但内部的 let i = 'abc'; 实际上创建了一个新的、同名的局部变量 i,这个变量的作用域仅限于 for 循环的块内部。因此,每次迭代打印的都是这个块作用域内的字符串 'abc',而不是外部循环控制变量的数值。

从执行结果也能间接说明,for 循环内部声明的变量 i 和循环变量 i 不在同一个作用域,它们有各自单独的作用域,设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域,这是 for 循环的特别之处。

如果在同一个作用域,是不可使用 let 或者 const 重复声明相同名字的变量。比如下面的代码会报错。

if(true){
let a = 1;
let a = 2; // 报错

const b = 3;
const b = 4; // 报错
}

这就引发出了另外一问题 块作用域

块作用域是指变量在定义它的代码块或者说是大括号 {} 内有效的作用域。使用 let 或者 const 关键字声明的变量具有块级作用域(block scope),这意味着变量在包含它的块(在这个例子中是 for 循环的大括号内)以及任何嵌套的子块中都是可见的。

块作用域示例:

let blockScopedVariable = 'I am dhl';
if (true) {
let blockScopedVariable = 'I am block scoped';
console.log(blockScopedVariable); // 输出: I am block scoped
}
console.log(blockScopedVariable); // 输出: I am dhl

从执行结果可以看出,在 if 语句中定义的变量 blockScopedVariable,仅在代码块内有效,外层变量不会被内层同名变量的声明和赋值影响。

但是需要注意,在 ArkTS 中不能使用 for .. in,否则会有一个编译警告。

之所以不能使用 for .. in 是因为在 ArkTS 中,对象的布局在编译时是确定的,且不能在程序执行期间更改对象的布局,换句话说,ArkTS 禁止以下行为:

  • 向对象中添加新的属性或方法
  • 从对象中删除已有的属性或方法
  • 将任意类型的值赋值给对象属性

如果修改对象布局会影响代码的可读性以及运行时性能。

从开发的角度来说,在某处定义类,然后又在其它地方修改了实际对象布局,这很容易引入错误,另外如果修改对象布局,需要在运行时支持,这样会增加执行开销降低性能。


作者:程序员DHL
来源:juejin.cn/post/7376158566598705167
收起阅读 »

什么?这个 App 抓不到包

有个朋友说,某个车联网的 app 竟然抓不到包,让我帮忙看下,行吧,那就看下。 样本:byd海洋(v1.4.0) 小试牛刀 先用 Charles软件试试,使用这个软件抓 App 的包,有几个前提: 手机端要配置好Charles的证书 电脑端安装好 Cha...
继续阅读 »

有个朋友说,某个车联网的 app 竟然抓不到包,让我帮忙看下,行吧,那就看下。



  • 样本:byd海洋(v1.4.0)


小试牛刀


先用 Charles软件试试,使用这个软件抓 App 的包,有几个前提:



  • 手机端要配置好Charles的证书

  • 电脑端安装好 Charles 的根证书,并信任

  • 手机端证书要放到系统证书目录 (Android 系统 7.0 一下,这一步可以省略)


没配置好的话是抓不到 https 请求的,环境配置好后先抓个 https 的包测试一下
image.png
可以正常获取到数据,说明抓包环境配置成功。
现在对目标 app 抓包,看下是否成功,结果如下图
image.png
可以发现,并没有成功,为什么会这样呢🤔


为什么抓不到App包


目前App用到的网络请求库都是 OKHttp3,这个库有一个 api:CertificatePinner这个 API 的作用是



用于自定义证书验证策略,以增强网络安全性。在进行TLS/SSL连接时,服务器会提供一个SSL证书,客户端需要验证该证书的有效性以确保连接的安全性。CertificatePinner允许你指定哪些SSL证书是可接受的,从而有效地限制了哪些服务器可以与你的应用程序通信。


具体来说,CertificatePinner允许你指定一组预期的证书公钥或证书固定哈希值(SHA-256),以便与服务器提供的证书进行比较。如果服务器提供的证书与你指定的公钥或哈希值不匹配,则连接会被拒绝,从而防止中间人攻击和其他安全风险。



我们大胆的猜测一下,目标 App 就是用到了这个 API,添加了自定义的证书验证策略,我们既要大胆猜测,又要小心验证,怎么验证呢?这就要用到 Frida 了,用Frida Hook 这个 API,使其失效。Frida 代码如下


try {

var CertificatePinner = Java.use('okhttp3.CertificatePinner');

quiet_send('OkHTTP 3.x Found');

CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function () {

quiet_send('OkHTTP 3.x check() called. Not throwing an exception.');
}

} catch (err) {

// If we dont have a ClassNotFoundException exception, raise the
// problem encountered.
if (err.message.indexOf('ClassNotFoundException') === 0) {

throw new Error(err);
}
}

验证了一下,发现还是抓不到包。
除了这个 API 可以用来防止中间人攻击,OKHttp3还有其他防止中间人攻击的方法,如X509TrustManager



X509TrustManager 是Java安全架构中的一个接口,位于 javax.net.ssl 包中,主要用于处理 SSL(Secure Sockets Layer)和后续的 TLS(Transport Layer Security)协议中的证书信任管理功能。在基于SSL/TLS的网络通信中,特别是HTTPS连接,X509TrustManager扮演着至关重要的角色,它的主要职责是验证远程主机提供的X.509证书链的有效性。



同样,再用 Frida 验证一下,是不是X509TrustManager导致的抓不到包,代码如下


  var TrustManager;
try {
TrustManager = Java.registerClass({
name: 'org.wooyun.TrustManager',
implements: [X509TrustManager],
methods: {
checkClientTrusted: function (chain, authType) {
},
checkServerTrusted: function (chain, authType) {
},
getAcceptedIssuers: function () {
return [];
}
}
});
} catch (e) {
quiet_send("registerClass from X509TrustManager >>>>>>>> " + e.message);
}


// Prepare the TrustManagers array to pass to SSLContext.init()
var TrustManagers = [TrustManager.$new()];

try {
// Prepare a Empty SSLFactory
var TLS_SSLContext = SSLContext.getInstance("TLS");
TLS_SSLContext.init(null, TrustManagers, null);
} catch (e) {
quiet_send(e.message);
}

send('Custom, Empty TrustManager ready');

// Get a handle on the init() on the SSLContext class
var SSLContext_init = SSLContext.init.overload(
'[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom');

// Override the init method, specifying our new TrustManager
SSLContext_init.implementation = function (keyManager, trustManager, secureRandom) {

quiet_send('Overriding SSLContext.init() with the custom TrustManager');

SSLContext_init.call(this, null, TrustManagers, null);
};

Frida 执行上面的代码,再看下是否抓到包
image.png
这次就可以顺利的抓到数据了。


抓包工具推荐


虽然利用上面 Frida 的脚本可以成功抓包,但是上面的操作还是略显复杂,况且有的 App 还会有 Frida 检测,操作起来就难度骤升。
这里推荐一个抓包工具 httptoolkit,官网界面如下
image.png
使用起来也很简单



  • 下载安装这个软件

  • 连接手机

  • 选择 Android Device Via ADB选项卡

  • 打开目标应用,开始抓包


看下这个软件的抓包效果
image.png
可以看到,数据都出来了并且不用额外的设置。


Xposed 方案


如果你手机刷了 Xposed,那就很简单了,只需要安装 JustTrustMe++模块就可以了。安装之后,也可以通过 Charles软件直接抓包了。



本文的目的只有一个就是学习更多的逆向技巧和思路,如果有人利用本文技术去进行非法商业获取利益带来的法律责任都是操作者自己承担,和本文以及作者没关系.


本文涉及到的代码项目可以去 爱码者说 知识星球自取,欢迎加入知识星球一起学习探讨技术。
关注公众号 爱码者说 及时获取最新推送文章。



作者:平行绳
来源:juejin.cn/post/7374665776537567286
收起阅读 »

如何让不同Activity之间共享同一个ViewModel

问题背景 存在一个场景,在Acitivity1可以跳转到Activity2,但是两个Activty之间希望能共享数据 提出假设的手段 可以定义一个ViewModel,让这两个Activity去共享这个ViewModel 存在的问题 根据不同的Lifecycle...
继续阅读 »

问题背景


存在一个场景,在Acitivity1可以跳转到Activity2,但是两个Activty之间希望能共享数据


提出假设的手段


可以定义一个ViewModel,让这两个Activity去共享这个ViewModel


存在的问题


根据不同的LifecycleOwner创建出来的ViewModel是不同的实例,所以在两个不同的Activity之间无法创建同一个ViewModel对象


问题分析


先来梳理一下一个正常的ViewModel是怎么被构造出来的:



  1. ViewModel是由ViewModelFactoty负责构造出来

  2. 构造出来之后,存储在ViewModelStore里面
    但是问题是ViewModelStore是 和 (宿主Activity或者Fragment)是一一对应的关系
    具体代码如下


@MainThread  
public inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val owner by lazy(LazyThreadSafetyMode.NONE) { ownerProducer() }
return createViewModelLazy(
VM::class,
{ owner.viewModelStore },
{
(owner as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras
?: CreationExtras.Empty
},
factoryProducer ?: {
(owner as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory
?: defaultViewModelProviderFactory
})
}

看到上面的代码第9行,viewModelStore和owner是对应关系,所以原则上根据不同LifecycleOwner无法构造出同一个ViewModel对象


解决思路



  1. 无法在不同的LifecycleOwner之间共享ViewMode对象的原因是:ViewModel的存储方ViewModelStore是和LifecycleOwner绑定,那如果可以解开这一层绑定关系,理论上就可以实现共享;

  2. 另外我们需要定义ViewModel的销毁时机:


    我们来模拟一个场景:由Activty1跳转到Activity2,然后两个Activity共享同一个ViewModel,两个activity都要拿到同一个ViewModel的实例,那这个时候ViewModel的销毁时机应该是和Acitivity1的生命周期走,也就是退出Activity1(等同于Activity1走onDestroy)的时候,去销毁这个ViewModel。



所以按照这个思路走,ViewModel需要在activity1中被创建出来,并且保存在一个特定的ViewModelStore里面,要保证这个ViewModelStore可以被这两个Activity共享;


然后等到Activity2取的时候,就直接可以从这个ViewModelStore把这个ViewModel取出来;


最后在Activity1进到destroy的时候,销毁这个ViewModel


具体实现


重写一个ViewModelProvider实现如下功能点:



  1. 把里面的ViewModelStore定义成一个单例供所有的LifecycleOwner共享

  2. 定义ViewModel的销毁时机: LifecycleOwner走到onDestroy的时机


// 需要放到lifecycle这个包,否则访问不到ViewModelStore
package androidx.lifecycle

class GlobalViewModelProvider(factory: Factory = NewInstanceFactory()) :
ViewModelProvider(globalStore, factory) {
companion object {
private val globalStore = ViewModelStore()
private val globalLifecycleMap = HashMap<String, MutableSet<Lifecycle>>()
private const val DEFAULT_KEY = "androidx.lifecycle.ViewModelProvider.DefaultKey"
}

@MainThread
fun <T: ViewModel> get(lifecycle: Lifecycle, modelClass: Class<T>): T {
val canonicalName = modelClass.canonicalName ?: throw IllegalArgumentException("Local and anonymous classes can not be ViewModels")
return get(lifecycle, "$DEFAULT_KEY:$canonicalName", modelClass)
}

@MainThread
fun <T: ViewModel> get(lifecycle: Lifecycle, key: String, modelClass: Class<T>): T {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Could not get viewmodel when lifecycle was destroyed")
}
val viewModel = super.get(key, modelClass)
val lifecycleList = globalLifecycleMap.getOrElse(key) { mutableSetOf() }
globalLifecycleMap[key] = lifecycleList
if (!lifecycleList.contains(lifecycle)) {
lifecycleList.add(lifecycle)
lifecycle.addObserver(ClearNegativeVMObserver(lifecycle, key, globalStore, globalLifecycleMap))
}
return viewModel
}

private class ClearNegativeVMObserver(
private val lifecycle: Lifecycle,
private val key: String,
private val store: ViewModelStore,
private val map: HashMap<String, MutableSet<Lifecycle>>,
): LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
val lifecycleList = map.getOrElse(key) { mutableSetOf() }
lifecycleList.remove(lifecycle)
if (lifecycleList.isEmpty()) {
store.put(key, null)
map.remove(key)
}
}
}
}
}

具体使用


@MainThread  
inline fun <reified VM: ViewModel> LifecycleOwner.sharedViewModel(
viewModelClass: Class<VM> = VM::class.java,
noinline keyFactory: (() -> String)? = null,
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null,
)
: Lazy<VM> {
return SharedViewModelLazy(
viewModelClass,
keyFactory,
{ this },
factoryProducer ?: { ViewModelProvider.NewInstanceFactory() }
)
}

@PublishedApi
internal class SharedViewModelLazy<VM: ViewModel>(
private val viewModelClass: Class<VM>,
private val keyFactory: (() -> String)?,
private val lifecycleProducer: () -> LifecycleOwner,
private val factoryProducer: () -> ViewModelProvider.Factory,
): Lazy<VM> {
private var cached: VM? = null
override val value: VM
get() {
return cached ?: kotlin.run {
val factory = factoryProducer()
if (keyFactory != null) {
GlobalViewModelProvider(factory).get(
lifecycleProducer().lifecycle,
keyFactory.invoke(),
viewModelClass
)
} else {
GlobalViewModelProvider(factory).get(
lifecycleProducer().lifecycle,
viewModelClass
)
}.also {
cached = it
}
}
}

override fun isInitialized() = cached != null
}

场景使用


val vm : MainViewModel by sharedViewModel()

作者:红鲤驴
来源:juejin.cn/post/7366913974624059427
收起阅读 »

Android:解放自己的双手,无需手动创建shape文件

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 现在的移动应用中为了美化界面,会给各类视图增加一些圆角、描边、渐变等等效果。当然系统也提供了对应的功能,那就是创建shape标签的XML文件,例如下图就是创建一个圆角为10dp,填充...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。



现在的移动应用中为了美化界面,会给各类视图增加一些圆角、描边、渐变等等效果。当然系统也提供了对应的功能,那就是创建shape标签的XML文件,例如下图就是创建一个圆角为10dp,填充是白色的shape文件。再把这个文件设置给目标视图作为背景,就达到了我们想要的圆角效果。


<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="#FFFFFF" />
</shape>

//圆角效果
android:background="@drawable/shape_white_r10"

但不是所有的圆角和颜色都一样,甚至还有四个角单独一个有圆角的情况,当然还有描边、虚线描边、渐变填充色等等各类情况。随着页面效果的多样和复杂性,我们添加的shape文件也是成倍增加。


这时候不少的技术大佬出现了,大佬们各显神通打造了许多自定义View。这样我们就可以使用三方库通过在目标视图外嵌套一层视图来达到原本的圆角等效果。不得不说,这确实能够大大减少我们手动创建各类shape的情况,使用起来也是得心应手,方便了不少。


问题:


简单的布局,嵌套层级较少的页面使用起来还好。但往往随着页面的复杂程度越高,嵌套层级也越来多,这个时候再使用三方库外层嵌套视图会越来越臃肿和复杂。那么有没有一种方式可以直接在XML中当前视图中增减圆角等效果呢?


还真有,使用DataBinding可以办到!


这里就不单独介绍DataBinding的基础配置,网上一搜到处都是。咱们直接进入正题,使用**@BindingAdapter** 注解,这是用来扩展布局XML属性行为的注解。


使用DataBinding实现圆角


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = ["shape_radius""shape_solidColor"])
fun View.setViewBackground(radius: Int = 0,solidColor: Int = Color.TRANSPARENT){
val drawable = GradientDrawable()
drawable.cornerRadius = context.dp2px(radius.toFloat()).toFloat()
drawable.setColor(solidColor)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_solidColor="@{@color/white}"

其实就是对当前视图的一个扩展,有点和kotlin的扩展函数类似。既然这样我们可以通过代码配置更多自定义的属性:


各方向圆角的实现:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = ["
"shape_solidColor",//填充颜色
"shape_tl_radius",//上左圆角
"shape_tr_radius",//上右圆角
"shape_bl_radius",//下左圆角
"shape_br_radius"//下右圆角
])
fun View.setViewBackground(radius: Int = 0,solidColor: Int = Color.TRANSPARENT){
val drawable = GradientDrawable()
drawable.setColor(solidColor)
drawable.cornerRadii = floatArrayOf(
context.dp2px(shape_tl_radius.toFloat()).toFloat(),
context.dp2px(shape_tl_radius.toFloat()).toFloat(),
context.dp2px(shape_tr_radius.toFloat()).toFloat(),
context.dp2px(shape_tr_radius.toFloat()).toFloat(),
context.dp2px(shape_br_radius.toFloat()).toFloat(),
context.dp2px(shape_br_radius.toFloat()).toFloat(),
context.dp2px(shape_bl_radius.toFloat()).toFloat(),
context.dp2px(shape_bl_radius.toFloat()).toFloat(),
)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_tl_radius="@{@color/white}"//左上角
shape_tr_radius="@{@color/white}"//右上角
shape_bl_radius="@{@color/white}"//左下角
shape_br_radius="@{@color/white}"//右下角

虚线描边:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = [
"shape_radius"
"shape_solidColor"
"shape_strokeWitdh",//描边宽度
"shape_dashWith",//描边虚线单个宽度
"shape_dashGap",//描边间隔宽度
])
fun View.setViewBackground(
radius: Int = 0,
solidColor: Int = Color.TRANSPARENT,
strokeWidth: Int = 0,
shape_dashWith: Int = 0,
shape_dashGap: Int = 0
){
val drawable = GradientDrawable()
drawable.setStroke(
context.dp2px(strokeWidth.toFloat()),
strokeColor,
shape_dashWith.toFloat(),
shape_dashGap.toFloat()
)
drawable.setColor(solidColor)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_solidColor="@{@color/white}"
strokeWidth="@{1}"
shape_dashWith="@{2}"
shape_dashGap="@{3}"

渐变色的使用:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = [
"shape_startColor",//渐变开始颜色
"shape_centerColor",//渐变中间颜色
"shape_endColor",//渐变结束颜色
"shape_gradualOrientation",//渐变角度
])
fun View.setViewBackground(
shape_startColor: Int = Color.TRANSPARENT,
shape_centerColor: Int = Color.TRANSPARENT,
shape_endColor: Int = Color.TRANSPARENT,
shape_gradualOrientation: Int = 1,//TOP_BOTTOM = 1 ,TR_BL = 2,RIGHT_LEFT = 3,BR_TL = 4,BOTTOM_TOP = 5,BL_TR = 6,LEFT_RIGHT = 7,TL_BR = 8
){
val drawable = GradientDrawable()
when (shape_gradualOrientation) {
1 -> drawable.orientation = GradientDrawable.Orientation.TOP_BOTTOM
2 -> drawable.orientation = GradientDrawable.Orientation.TR_BL
3 -> drawable.orientation = GradientDrawable.Orientation.RIGHT_LEFT
4 -> drawable.orientation = GradientDrawable.Orientation.BR_TL
5 -> drawable.orientation = GradientDrawable.Orientation.BOTTOM_TOP
6 -> drawable.orientation = GradientDrawable.Orientation.BL_TR
7 -> drawable.orientation = GradientDrawable.Orientation.LEFT_RIGHT
8 -> drawable.orientation = GradientDrawable.Orientation.TL_BR
}
drawable.gradientType = GradientDrawable.LINEAR_GRADIENT//线性
drawable.shape = GradientDrawable.RECTANGLE//矩形方正
drawable.colors = if (shape_centerColor != Color.TRANSPARENT) {//有中间色
intArrayOf(
shape_startColor,
shape_centerColor,
shape_endColor
)
} else {
intArrayOf(shape_startColor, shape_endColor)
}//渐变色
background = drawable
}

//xml文件中
shape_startColor="@{@color/cl_F1E6A0}"
shape_centerColor="@{@color/cl_F8F8F8}"
shape_endColor=@{@color/cl_3CB9FF}

不止设置shape功能,只要可以通过代码设置的功能一样可以在BindingAdapter注解中自定义,使用起来是不是更加方便了。


总结:



  • 注解BindingAdapter中value数组的自定义属性一样要和方法内的参数一一对应,否则会报错。

  • 布局中使用该自定义属性时需要将布局文件最外层修改为layout标签

  • XML中使用自定义属性时一定要添加@{}


好了,以上便是解放自己的双手,无需手动创建shape文件的全部内容,希望能给大家带来帮助!


作者:似曾相识2022
来源:juejin.cn/post/7278858311596359739
收起阅读 »

Android — 实现同意条款功能

在开发App时,让用户知晓并同意隐私政策、服务协议等条款是必不可少的功能,恰逢Facebook最近对隐私政策的审核愈发严格,本文介绍如何通过TextView和ClickableSpan简单快速的实现同意条款功能。 下面是掘金(小米应用商店下载)和Github(...
继续阅读 »

在开发App时,让用户知晓并同意隐私政策、服务协议等条款是必不可少的功能,恰逢Facebook最近对隐私政策的审核愈发严格,本文介绍如何通过TextViewClickableSpan简单快速的实现同意条款功能。


下面是掘金(小米应用商店下载)和Github(Google Play下载)两个App的同意条款功能示例图:


掘金Github
image.pngimage.png

可以看见二者有所区别,这是由于国内政策不允许App自动勾选同意,必须要用户手动勾选,如果不按照要求处理甚至无法上架。


实现同意条款功能


先梳理一下实现同意条款功能的核心需求:



  1. 可以根据上架的区域不同(是否海外)决定是否显示勾选框,显示勾选框时需要提供可以获取选中状态的方法。

  2. 同意条款的提示中可能仅包含单个条款或同时包含多个条款。

  3. 条款名的颜色需要与其他文案不同,可以根据需求决定是否显示下划线,点击条款名时可以查看条款的具体内容。


上述三项需求最关键,但可以调整的细节有很多,本文仅通过较为简单的方式来实现(不使用自定义View),各位读者可以根据实际项目需求进行调整。


自定义配置类


上面的三点需求中都包含了一些配置项,可以通过配置类来管理这些参数,根据外部设定的配置进行相应处理。示例代码如下:


class ConfirmTermsConfiguration private constructor() {

// 同意提示文案
var confirmTipsContent: String = ""
private set

// 可点击的条款文案,键为条款文案,值为条款内容(链接)
var clickableTerms = ArrayMap<String, String>()
private set

// 同意条款控件距离底部的距离,默认为32dp
// 左右两侧的边距可以根据实际需求决定是否需要提供配置方法
var viewBottomMargin = DensityUtil.dp2Px(36)
private set

// 文字大小,默认14sp
var textSize = 14f
private set

// 文字颜色,默认黑色
var textColor = android.R.color.black
private set

// 可点击文字的颜色,默认为蓝色
var clickableTextColor = R.color.color_blue_229CE9
private set

// 是否显示下滑线,默认不显示
var showUnderline = false
private set

// 是否显示勾选框,默认为false
// 示例中勾选框直接使用可点击文案的颜色
// 可以根据实际需求决定是否提供相应的配置方法
var showCheckbox = false
private set

class Builder() {
private var confirmTipsContent: String = ""
private val clickableTerms = ArrayMap<String, String>()
private var viewBottomMargin = DensityUtil.dp2Px(36)
private var textSize = 14f
private var textColor = android.R.color.black
private var clickableTextColor = R.color.color_blue_229CE9
private var showUnderline = false
private var showCheckbox = false

fun setConfirmTipContent(confirmTipsContent: String): Builder {
this.confirmTipsContent = confirmTipsContent
return this
}

fun setClickableTerm(clickableTerm: String, termsLink: String): Builder {
clickableTerms.clear()
clickableTerms[clickableTerm] = termsLink
return this
}

fun addClickableTerms(clickableTerms: Map<String, String>): Builder {
this.clickableTerms.clear()
this.clickableTerms.putAll(clickableTerms)
return this
}

fun setViewBottomMargin(viewBottomMargin: Int): Builder {
this.viewBottomMargin = viewBottomMargin
return this
}

fun setTextSize(textSize: Float): Builder {
this.textSize = textSize
return this
}

fun setTextColor(textColor: Int): Builder {
this.textColor = textColor
return this
}

fun setClickableTextColor(clickableTextColor: Int): Builder {
this.clickableTextColor = clickableTextColor
return this
}

fun setShowUnderline(showUnderline: Boolean): Builder {
this.showUnderline = showUnderline
return this
}

fun setShowCheckbox(showCheckbox: Boolean): Builder {
this.showCheckbox = showCheckbox
return this
}

fun build(): ConfirmTermsConfiguration {
return ConfirmTermsConfiguration().also {
it.confirmTipsContent = confirmTipsContent
it.clickableTerms = clickableTerms
it.viewBottomMargin = viewBottomMargin
it.textSize = textSize
it.textColor = textColor
it.clickableTextColor = clickableTextColor
it.showUnderline = showUnderline
it.showCheckbox = showCheckbox
}
}
}
}

自定义ClickSpan


ClickSpan是Android中专门处理可点击文本的类,继承ClickSpan类可以实现定制可点击文本的样式以及响应事件。可以使用自定义ClickSpan来实现第三点需求,示例代码如下:


class ClickSpan(
// 默认颜色为白色
private var colorRes: Int = -1,
// 默认不显示下划线
private var isShoeUnderLine: Boolean = false,
// 点击事件监听,必须传入
private var clickListener: () -> Unit
) : ClickableSpan() {

override fun onClick(widget: View) {
// 回调点击事件监听
clickListener.invoke()
}

override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
//设置文本颜色
ds.color = colorRes
//设置是否显示下划线
ds.isUnderlineText = isShoeUnderLine
}
}


显示、隐藏同意条款控件


有了配置类和自定义ClickSpan类之后,就可以实现显示、隐藏同意条款控件了,示例代码如下:



  • 辅助类


class ConfirmTermsHelper {

private var confirmTermsView: View? = null

var confirmStatus = false
private set

fun showConfirmTermsView(activity: Activity, confirmTermsConfiguration: ConfirmTermsConfiguration) {
val confirmTipsContent = confirmTermsConfiguration.confirmTipsContent
val clickableTerms = confirmTermsConfiguration.clickableTerms
val showCheckBox = confirmTermsConfiguration.showCheckbox
// 同意条款的提示文案为空直接结束方法执行
if (confirmTipsContent.isEmpty()) {
return
}
// 先把当前的控件移除
hideConfirmTermsView()
activity.runOnUiThread {
if (showCheckBox) {
ConstraintLayout(activity).apply {
// 代码中创建CheckBox存在Padding,暂时未解决
addView(AppCompatCheckBox(activity).apply {
id = R.id.cb_confirm_terms
val checkboxSize = DensityUtil.dp2Px(30)
layoutParams = ConstraintLayout.LayoutParams(checkboxSize, checkboxSize).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
startToStart = ConstraintLayout.LayoutParams.PARENT_ID
}
setButtonDrawable(R.drawable.selector_confirm_terms_chekcbox)
buttonTintList = ColorStateList.valueOf(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor))
setOnCheckedChangeListener { _, isChecked ->
confirmStatus = isChecked
}
})
addView(AppCompatTextView(activity).apply {
id = R.id.tv_confirm_terms
layoutParams = ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
startToEnd = R.id.cb_confirm_terms
endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
marginStart = DensityUtil.dp2Px(10)
}
textSize = confirmTermsConfiguration.textSize
setTextColor(ContextCompat.getColor(activity, confirmTermsConfiguration.textColor))
movementMethod = LinkMovementMethodCompat.getInstance()
text = SpannableStringBuilder(confirmTipsContent).apply {
clickableTerms.entries.forEach { clickableTermEntry ->
val startHighlightIndex = confirmTipsContent.indexOf(clickableTermEntry.key)
if (startHighlightIndex > 0) {
setSpan(
ClickSpan(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor), confirmTermsConfiguration.showUnderline) {
// 通过CustomTab打开链接
CustomTabHelper.openSimpleCustomTab(activity, clickableTermEntry.value)
},
startHighlightIndex, startHighlightIndex + clickableTermEntry.key.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
})
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM).apply {
val defaultLeftRightSpace = DensityUtil.dp2Px(20)
marginStart = defaultLeftRightSpace
marginEnd = defaultLeftRightSpace
bottomMargin = confirmTermsConfiguration.viewBottomMargin
}
}
} else {
AppCompatTextView(activity).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM).apply {
val defaultLeftRightSpace = DensityUtil.dp2Px(20)
marginStart = defaultLeftRightSpace
marginEnd = defaultLeftRightSpace
bottomMargin = confirmTermsConfiguration.viewBottomMargin
}
textSize = confirmTermsConfiguration.textSize
setTextColor(ContextCompat.getColor(activity, confirmTermsConfiguration.textColor))
movementMethod = LinkMovementMethodCompat.getInstance()
text = SpannableStringBuilder(confirmTipsContent).apply {
clickableTerms.entries.forEach { clickableTermEntry ->
val startHighlightIndex = confirmTipsContent.indexOf(clickableTermEntry.key)
if (startHighlightIndex > 0) {
setSpan(
ClickSpan(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor), confirmTermsConfiguration.showUnderline) {
// 通过CustomTab打开链接
CustomTabHelper.openSimpleCustomTab(activity, clickableTermEntry.value)
},
startHighlightIndex, startHighlightIndex + clickableTermEntry.key.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
}
}.run {
confirmTermsView = this
removeViewInParent(this)
getRootView(activity).addView(this)
}
}
}

fun hideConfirmTermsView() {
confirmStatus = false
confirmTermsView?.run { post { removeViewInParent(this) } }
confirmTermsView = null
}

private fun getRootView(activity: Activity): FrameLayout {
return activity.findViewById(android.R.id.content)
}

private fun removeViewInParent(targetView: View) {
try {
(targetView.parent as? ViewGr0up)?.removeView(targetView)
} catch (e: Exception) {
e.printStackTrace()
}
}
}


  • 示例页面


class ConfirmTermsExampleActivity : AppCompatActivity() {

private lateinit var binding: LayoutConfirmTermsExampleActivityBinding

private val confirmTermsHelper = ConfirmTermsHelper()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutConfirmTermsExampleActivityBinding.inflate(layoutInflater).apply {
setContentView(root)
}

binding.btnWithCheckBox.setOnClickListener {
confirmTermsHelper.showConfirmTermsView(this, ConfirmTermsConfiguration.Builder()
.setConfirmTipContent("已阅读并同意\"隐私政策\"")
.setClickableTerm("隐私政策", "https://lf3-cdn-tos.draftstatic.com/obj/ies-hotsoon-draft/juejin/7b28b328-1ae4-4781-8d46-430fef1b872e.html")
.setShowCheckbox(true)
.setTextColor(R.color.color_gray_999)
.setClickableTextColor(R.color.color_black_3B3946)
.build())
binding.btnGetConfirmStatus.visibility = View.VISIBLE
}
binding.btnWithoutCheckBox.setOnClickListener {
confirmTermsHelper.showConfirmTermsView(this, ConfirmTermsConfiguration.Builder()
.setConfirmTipContent("By signing in you accept out Terms of use and Privacy policy")
.addClickableTerms(
mapOf(
Pair("Terms of use", "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service"),
Pair("Privacy policy", "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement")
)
)
.setShowUnderline(true)
.setTextColor(R.color.color_gray_999)
.build())
binding.btnGetConfirmStatus.visibility = View.GONE
}
binding.btnGetConfirmStatus.setOnClickListener {
showSnackbar("Current confirm status:${confirmTermsHelper.confirmStatus}")
}
}

private fun showSnackbar(message: String) {
runOnUiThread {
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
}
}

override fun onDestroy() {
super.onDestroy()
confirmTermsHelper.hideConfirmTermsView()
}
}

效果演示与完整示例代码


最终演示效果如下:


Screen_recording_202 -original-original.gif

所有演示代码已在示例Demo中添加。


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7372577541112872972
收起阅读 »

我的又一个神奇的框架——Skins换肤框架

为什么会有换肤的需求 app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。 换肤是什么 换...
继续阅读 »

为什么会有换肤的需求


app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。


换肤是什么


换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。


Skins怎么使用


Skins就是一个解决这样一种换肤需求的框架。


// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}

我以更换皮肤颜色为例,打开res/colors.xml。


<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>

将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。
然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。


private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}

另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。


val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")

这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。
SkinLoader还提供了更简洁设置View颜色的方法。


override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}

override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}

override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}

框架原理解析


先看BaseSkinActivity的源码。


package dora.skin.base

import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*

abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {

private val constructorArgs = arrayOfNulls<Any>(2)

override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}

private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}

private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}

@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}

override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}

override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}

override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}

companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}

我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。


package dora.lifecycle.application

import android.app.Application
import android.content.Context
import dora.skin.SkinManager

class SkinsAppLifecycle : ApplicationLifecycleCallbacks {

override fun attachBaseContext(base: Context) {
}

override fun onCreate(application: Application) {
SkinManager.init(application)
}

override fun onTerminate(application: Application) {
}
}

所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。


    /**
* 从xml的属性集合中获取皮肤相关的属性。
*/

fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}

我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。


package dora.skin.attr

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager

enum class SkinAttrType(var attrType: String) {

/**
* 背景属性。
*/

BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},

/**
* 字体颜色。
*/

TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},

/**
* 图片资源。
*/

SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};

abstract fun apply(view: View, resName: String)

/**
* 获取资源管理器。
*/

val loader: SkinLoader
get() = SkinManager.getLoader()
}

当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。


开源项目传送门


如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dview…] 。


作者:dora
来源:juejin.cn/post/7258483700815609916
收起阅读 »

如何选择 Android 唯一标识符

前言 大家好,我是未央歌,一个默默无闻的移动开发搬砖者~ 本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。 标识符 IMEI 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10...
继续阅读 »

前言


大家好,我是未央歌,一个默默无闻的移动开发搬砖者~


本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。


标识符


IMEI



  • 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10+ 开始官方取消了获取 IMEI 的 API,无法获取到 IMEI 了


fun getIMEI(context: Context): String {
val telephonyManager = context
.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
return telephonyManager.deviceId
}

Android ID(SSAID)



  • 无需任何权限

  • 卸载安装不会改变,除非刷机或重置系统

  • Android 8.0 之后签名不同的 APP 获取的 Android ID 是不一样的

  • 部分设备由于制造商错误实现,导致多台设备会返回相同的 Android ID

  • 可能为空


fun getAndroidID(context: Context): String {
return Settings.System.getString(context.contentResolver,Settings.Secure.ANDROID_ID)
}

MAC 地址



  • 需要申请权限,Android 12 之后 BluetoothAdapter.getDefaultAdapter().getAddress()需要动态申请 android.permission.BLUETOOTH_CONNECT 权限

  • MAC 地址具有全局唯一性,无法由用户重置,在恢复出厂设置后也不会变化

  • 搭载 Android 10+ 的设备会报告不是设备所有者应用的所有应用的随机化 MAC 地址

  • 在 Android 6.0 到 Android 9 中,本地设备 MAC 地址(如 WLAN 和蓝牙)无法通过第三方 API 使用 会返回 02:00:00:00:00:00,且需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION 权限


Widevine ID



  • DRM 数字版权管理 ID ,访问此 ID 无需任何权限

  • 对于搭载 Android 8.0 的设备,Widevine 客户端 ID 将为每个应用软件包名称和网络源(对于网络浏览器)返回一个不同的值

  • 可能为空


fun getWidevineID(): String {
try {
val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L)
val mediaDrm = MediaDrm(WIDEVINE_UUID)
val widevineId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
val sb = StringBuilder();
for (byte in widevineId) {
sb.append(String.format("x", byte))
}
return sb.toString();
} catch (e: Exception) {
} catch (e: Error) {
}
return ""
}

AAID



  • 无需任何权限

  • Google 推出的广告 ID ,可由用户重置的标识符,适用于广告用例

  • 系统需要自带 Google Play Services 才支持,且用户可以在系统设置中重置



重置后,在未获得用户明确许可的情况下,新的广告标识符不得与先前的广告标识符或由先前的广告标识符所衍生的数据相关联。




还要注意,Google Play 开发者内容政策要求广告 ID“不得与个人身份信息或任何永久性设备标识符(例如:SSAID、MAC 地址、IMEI 等)相关联。”




在支持多个用户(包括访客用户在内)的 Android 设备上,您的应用可能会在同一设备上获得不同的广告 ID。这些不同的 ID 对应于登录该设备的不同用户。



OAID



  • 无需任何权限

  • 国内移动安全联盟出台的“拯救”国内移动广告的广告跟踪标识符

  • 基本上是国内知名厂商 Android 10+ 才支持,且用户可以在系统设置中重置


UUID



  • 生成之后本地持久化保存

  • 卸载后重新安装、清除应用缓存 会改变


如何选择


同个开发商需要追踪对比旗下应用各用户的行为



  • 可以采用 Android ID(SSAID),并且不同应用需使用同一签名

  • 如果获得的 Android ID(SSAID)为空,可以用 UUID 代替【 OAID / AAID 代替也可,但需要引入第三方库】

  • 在 Android 8.0+ 中, Android ID(SSAID)提供了一个在由同一开发者签名密钥签名的应用之间通用的标识符


希望限制应用内的免费内容(如文章)



  • 可以采用 UUID ,作用域是应用范围,用户要想规避内容限制就必须重新安装应用


用户群体主要是大陆



  • 可以采用 OAID ,低版本配合采用 Android ID(SSAID)/ UUID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等


用户群体在海外



  • 可以采用 AAID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等




作者:未央歌
来源:juejin.cn/post/7262558218169008188
收起阅读 »

Android 沉浸式状态栏,透明状态栏 采用系统api,超简单近乎完美的实现

前言 沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接) 有写的不对的地方,欢迎指出 从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11... ... 让...
继续阅读 »

前言


沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接)


有写的不对的地方,欢迎指出


从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11...


... 让我们直接开始吧


导入核心包


老项目非androidx的请自行研究下,这里使用的是androidx,并且用的kotlin语言
本次实现方式跟windowInsets息息相关,这可真是个好东西
首先是需要导入核心包
androidx.core:core

kotlin可选择导入这个:
androidx.core:core-ktx
我用的版本是
androidx.core:core-ktx:1.12.0

开启 “沉浸式” 支持


沉浸式原本的意思似乎是指全屏吧。。。算了,不管那么多,喊习惯了 沉浸式状态栏,就这么称呼吧。

在activity 的oncreate里调用
//将decorView的fitSystemWindows属性设置为false
WindowCompat.setDecorFitsSystemWindows(window, false)
//设置状态栏颜色为透明
window.statusBarColor = Color.TRANSPARENT
//是否需要改变状态栏上的 图标、字体 的颜色
//获取InsetsController
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
//mask:遮罩 默认是false
//mask = true 状态栏字体颜色为黑色,一般在状态栏下面的背景色为浅色时使用
//mask = false 状态栏字体颜色为白色,一般在状态栏下面的背景色为深色时使用
var mask = true
insetsController.isAppearanceLightStatusBars = mask
//底部导航栏是否需要修改
//android Q+ 去掉虚拟导航键 的灰色半透明遮罩
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//设置虚拟导航键的 背景色为透明
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//8.0+ 虚拟导航键图标颜色可以修改,所以背景可以用透明
window.navigationBarColor = Color.TRANSPARENT
} else {
//低版本因为导航键图标颜色无法修改,建议用黑色,不要透明
window.navigationBarColor = Color.BLACK
}
//是否需要修改导航键的颜色,mask 同上面状态栏的一样
insetsController.isAppearanceLightNavigationBars = mask

修改 状态栏、虚拟导航键 的图标颜色,可以在任意需要的时候设置,防止图标和字体颜色和背景色一致导致看不清

补充一下:
状态栏和虚拟导航栏的背景色要注意以下问题:
1.在低于6.0的手机上,状态栏上的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为它们有自己的api能实现修改颜色
2.在低于8.0的手机上,虚拟导航栏的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为他们有自己的api能实现修改颜色
解决方案:
低于指定版本的系统上,对应的颜色就不要用透明,除非你的APP页面是深色背景,否则,建议采用半透明的灰色

在带有刘海或者挖孔屏上,横屏时刘海或者挖孔的那条边会有黑边,解决方法是:
给APP的主题v27加上
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
参考图:

image.png


监听OnApplyWindowInsetsListener


//准备一个boolean变量 作为是否在跑动画的标记
var flagProgress = false

//这里可以使用decorView或者是任意view
val view = window.decorView

//监听windowInsets变化
ViewCompat.setOnApplyWindowInsetsListener(view) { view: View, insetsCompat: WindowInsetsCompat ->
//如果要配合下面的setWindowInsetsAnimationCallback一起用的话,一定要记得,onProgress的时候,这里做个拦截,直接返回 insets
if (flagProgress) return@setOnApplyWindowInsetsListener insetsCompat
//在这里开始给需要的控件分发windowInsets

//最后,选择不消费这个insets,也可以选择消费掉,不在往子控件分发
insetsCompat
}
//带平滑过渡的windowInsets变化,ViewCompat中的这个,官方提供了 api 21-api 29的支持,本来这个只支持 api 30+的,相当不错!
//启用setWindowInsetsAnimationCallback的同时,也必须要启用上面的setOnApplyWindowInsetsListener,否则在某些情况下,windowInsets改变了,但是因为不会触发setWindowInsetsAnimationCallback导致padding没有更新到UI上
//DISPATCH_MODE_CONTINUE_ON_SUBTREE这个代表动画事件继续分发下去给子View
ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(insetsCompat: WindowInsetsCompat, runningAnimations: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
//每一帧的windowInsets
//可以在这里分发给需要的View。例如给一个聊天窗口包含editText的布局设置这个padding,可以实现键盘弹起时,在底部的editText跟着键盘一起滑上去,参考微信聊天界面,这个比微信还丝滑(android 11+最完美)。
//最后,直接原样return,不消费
return insetsCompat
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
//动画结束,将标记置否
flagProgress = false
}

override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
//动画准备开始,在这里可以记录一些UI状态信息,这里将标记设置为true
flagProgress = true
}
})

读取高度值


通过上面的监听,我们能拿到WindowInsetsCompat对象,现在,我们从这里面取到我们需要的高度值


先定义几个变量,我们需要拿的包含:
1. 刘海,挖空区域所占据的宽度或者是高度
2. 被系统栏遮挡的区域
3. 被输入法遮挡的区域

//cutoutPadding 刘海,挖孔区域的padding
var cutoutPaddingLeft = 0
var cutoutPaddingTop = 0
var cutoutPaddingRight = 0
var cutoutPaddingBottom = 0

//获取刘海,挖孔的高度,因为这个不是所有手机都有,所以,需要判空
insetsCompat.displayCutout?.let { displayCutout ->
cutoutPaddingTop = displayCutout.safeInsetTop
cutoutPaddingLeft = displayCutout.safeInsetLeft
cutoutPaddingRight = displayCutout.safeInsetRight
cutoutPaddingBottom = displayCutout.safeInsetBottom
}


//systemBarPadding 系统栏区域的padding
var systemBarPaddingLeft = 0
var systemBarPaddingTop = 0
var systemBarPaddingRight = 0
var systemBarPaddingBottom = 0

//获取系统栏区域的padding
//系统栏 + 输入法
val systemBars = insetsCompat.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
//左右两侧的padding通常直接赋值即可,如果横屏状态下,虚拟导航栏在侧边,那么systemBars.left或者systemBars.right的值就是它的宽度,竖屏情况下,一般都是0
systemWindowInsetLeft = systemBars.left
systemWindowInsetRight = systemBars.right
//这里判断下输入法 和 虚拟导航栏是否存在,如果存在才设置paddingBottom
if (insetsCompat.isVisible(WindowInsetsCompat.Type.ime()) || insetsCompat.isVisible(WindowInsetsCompat.Type.navigationBars())) {
systemWindowInsetBottom = systemBars.bottom
}
//同样判断下状态栏
if (insetsCompat.isVisible(WindowInsetsCompat.Type.statusBars())) {
systemWindowInsetTop = systemBars.top
}

到这里,我们需要的信息已经全部获取到了,接下来就是根据需求,设置padding属性了

补充一下:
我发现在低于android 11的手机上,insets.isVisible(Type)返回始终为true
并且,即使系统栏被隐藏,systemBars.top, systemBars.bottom也始终会有高度
所以这里


保留原本的Padding属性


上述获取的值,直接去设置padding的话,会导致原本的padding属性失效,所以我们需要在首次设置监听,先保存一份原本的padding属性,在最后设置padding的时候,把这份原本的padding值加上即可,就不贴代码了。


第一次写文章,写的粗糙了点

可能我写的不太好,没看懂也没关系,直接去看完整代码吧


我专门写了个小工具,可以去看看:
沉浸式系统栏 小工具


如果有更好的优化方案,欢迎在github上提出,我们一起互相学习!


作者:Matchasxiaobin
来源:juejin.cn/post/7275943802938130472
收起阅读 »