注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

那些大厂架构师是怎样封装网络请求的?

好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便 一、前言 网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会...
继续阅读 »

5235a0e62ecd314a216da5209ff88326.jpeg



好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便



一、前言


网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会到。


网络框架的发展:


1. 从最早的HttpClientHttpURLConnection ,那时候需要自己用线程池封装异步,Handler切换到UI线程,要想从网络层就返回接收实体对象,也需要自己去实现封装


2. 后来,谷歌的 Volley, 三方的 Afinal 再到 XUtils 都是基于上面1中的网络层再次封装实现


3. 再到后来,OkHttp 问世,Retrofit 空降,从那以后基本上网络请求应用层框架就是 OkHttp Retrofit 两套组合拳,基本打遍天下无敌手,最多的变化也就是在这两套组合拳里面秀出各种变化,但是思想实质上还是这两招。


我们试想:从当初的大概2010年,2011年,2012年开始,就启动一个App项目,就网络这一层的封装而言,随着时代的潮流,技术的演进,我们势必会经历上面三个阶段,这一层的封装就得重构三次。


现在是2024年,往后面发展,随着http3.0的逐渐成熟,一定会出现更好的网络请求框架

我们怎么封装一套更容易扩展的框架,而不必每次重构这一层时,改动得那么困难。


本文下面就示例这一思路如何封装,涉及到的知识,jetpack 中的手术刀: Hilt 成员来帮助我们实现。


二 、示例项目


36c2d036-472c-4aa1-acbc-a15bafe2ae6f.jpeg



  1. 上图截图圈出的就是本文重点介绍的内容:怎么快速封装一套可以切换网络框架的项目 及相关 Jetpack中的 Hilt 用法

  2. 其他的1,2,3,4是之前我写的:花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路,大家可以参考,也可以在它的基础上,再结合本文再次封装,可以作为 花式玩法五


三、网络层代码设计


1. 设计请求接口,包含请求地址 Url,请求头,请求参数,返回解析成的对象Class :


interface INetApi {
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
*/


suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, map: MutableMap<String, Any>? = null): R

/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
* @param body:请求body
*/

suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, body: String? = null): R
}

2. 先用早期 HttpURLConnection 对网络请求进行实现:


class HttpUrlConnectionImpl  constructor() : INetApi {
private val gson by lazy { Gson() }

override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
//这里HttpUrlConnectionRequest内部是HttpURLConnection的Get请求真正的实现
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}

override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
////这里HttpUrlConnectionRequest内部是HttpURLConnection的Post请求真正的实现
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}

3. 整个项目 build.gradle 下配置 Hilt插件


buildscript {
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42'
}
}

4. 工程app的 build.gradle 下引入:


先配置:


plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'dagger.hilt.android.plugin'//Hilt使用
id 'kotlin-kapt'//
}

里面的 android 下面添加:


kapt {
generateStubs = true
}

dependencies 里面引入 Hilt 使用


//hilt
implementation "com.google.dagger:hilt-android:2.42"
kapt "com.google.dagger:hilt-android-compiler:2.42"
kapt 'androidx.hilt:hilt-compiler:1.0.0'

5. 使用 Hilt


5.1 在Application上添加注解 @HiltAndroidApp

@HiltAndroidApp
class MyApp : Application() {

}

5.2 在使用的Activity上面添加注解 @AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : BaseViewModelActivity<MainViewModel>(R.layout.activity_main), View.OnClickListener {

override fun onClick(v: View?) {
when (v?.id) {
R.id.btn1 -> {
viewModel.getHomeList()
}
else -> {}
}
}
}

5.3 在使用的ViewModel上面添加注解 @HiltViewModel@Inject

@HiltViewModel
class MainViewModel @Inject constructor(private val repository: NetRepository) : BaseViewModel() {


fun getHomeList() {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList().onEach {
val title = it.datas!![0].title
android.util.Log.e("MainViewModel", "one 111 ${title}")
errorMsgLiveData.postValue(title)
}
}
}
}

5.4 在 HttpUrlConnectionImpl 构造方法上添加注解 @Inject 如下:

class HttpUrlConnectionImpl @Inject constructor() : INetApi {
private val gson by lazy { Gson() }

override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}

override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}

5.5 新建一个 annotationBindHttpUrlConnection 如下:

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindHttpUrlConnection()

5.6 再建一个绑定网络请求的 abstract 修饰的类 AbstractHttp 如下:让 @BindHttpUrlConnectionHttpUrlConnectionImpl 在如下方法中通过注解绑定

@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {


@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}

5.7 在viewModel持有的仓库类 NetRepository 的构造方法中添加 注解 @Inject,并且申明 INetApi,并且绑定注解 @BindHttpUrlConnection 如下: 然后即就可以开始调用 INetApi 的方法

class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {

suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}

到此:Hilt使用就配置完成了,那边调用 网络请求就直接执行到 网络实现 类 HttpUrlConnectionImpl 里面去了。


运行结果看到代码执行打印:


7742b372-a54e-4110-9df5-2e2402c033f1.jpeg


5.8 我们现在切换到 Okhttp 来实现网络请求:

新建 OkhttpImpl 实现 INetApi 并在其构造方法上添加 @Inject 如下:


class OkhttpImpl @Inject constructor() : INetApi {

private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }

override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
try {
val request = Request.Builder().url(buildParamUrl(url, map))
header?.forEach {
request.addHeader(it.key, it.value)
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
val json = response.body?.string()
android.util.Log.e("OkhttpImpl","okhttp 请求:${json}")
return gson.fromJson<R>(json, clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}

override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
try {
val request = Request.Builder().url(url)
header?.forEach {
request.addHeader(it.key, it.value)
}
body?.let {
request.post(RequestBodyCreate.toBody(it))
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
return gson.fromJson<R>(response.body.toString(), clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
}

5.9 再建一个注解 annotation 类型的 BindOkhttp 如下:

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindOkhttp()

5.10 在 AbstractHttp 类中添加 @BindOkhttp 绑定到 OkhttpImpl,如下:

@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {

@BindOkhttp
@Singleton
@Binds
abstract fun bindOkhttp(h: OkhttpImpl): INetApi

@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}

5.11 现在只需要在 NetRepository 中持有的 INetApi 修改其绑定的 注解 @BindHttpUrlConnection 改成 @BindOkhttp 便可以将项目网络请求全部改成由 Okhttp来实现了,如下:

//class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
class NetRepository @Inject constructor(@BindOkhttp val netHttp: INetApi) {

suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}

运行执行结果截图可见:


ff042ce9-2e1b-452a-82a1-ddbebef25779.jpeg


到此:网络框架切换就这样简单的完成了。


四、总结



  1. 本文重点介绍了,怎么对网络框架扩展型封装:即怎么可以封装成快速从一套网络请求框架,切换到另一套网络请求上去

  2. 借助于 Jetpack中成员 Hilt 对其整个持有链路进行切割,简单切换绑定网络实现框架1,框架2,框架xxx等。


项目地址


项目地址:

github地址

gitee地址


感谢阅读:


欢迎 点赞、收藏、关注


这里你会学到不一样的东西


作者:Wgllss
来源:juejin.cn/post/7435904232597372940
收起阅读 »

一个大型 Android 项目的模块划分哲学

最近几天忙着搬家,代码几乎没怎么写,趁着周日晚上有点空来水一篇文章。 大概两年前决定自己做个独立的项目作为未来几年的空余时间消磨利器,并且在其中尝试使用各种最新技术,然后业务也比较复杂(不然也不能做这么久),现在项目迭代了这么久,也上架一段时间了,打算写点文章...
继续阅读 »

最近几天忙着搬家,代码几乎没怎么写,趁着周日晚上有点空来水一篇文章。


大概两年前决定自己做个独立的项目作为未来几年的空余时间消磨利器,并且在其中尝试使用各种最新技术,然后业务也比较复杂(不然也不能做这么久),现在项目迭代了这么久,也上架一段时间了,打算写点文章大概介绍下里面用到的一些技术和思路。


现在项目中大概有十几个模块,拆分模块的主要目的是为了降低未来的修改成本,同时模块的拆分也能反映出技术架构和业务架构



目前项目的模块关系图大概如下图所示。


上图中的所有同层级的模块都是平行模块,这意味着它们不会互相依赖,模块的依赖关系按照图中箭头的方向单向依赖。


理解业务


不同的软件有不同的业务,模块设计应该因地制宜,一个好的设计一定是需要先充分理解业务的。


如果两个模块在业务上就有依赖关系,那么一定要在软件架构上体现出来。 一些原本就有耦合关系的业务但是在软件架构中却彻底分离,这会给未来带来无穷无尽的麻烦。


在理解业务的基础之上可以进行业务形式化建模,在对业务有了足够充分的认知之后再进行软件架构设计,业务架构和软件架构尽可能保持一致。


比如目前国内很多项目中都在使用的路由框架就承担了解除耦合的责任,架构中把一些看起来关系不大的模块做拆分,然后通过路由框架进行通信,实际上造成了业务边界和关系的混乱。因为通过路由跳转就意味着业务有关联,既然业务上有关联那么架构上也应该有所体现,原本可以简单的通过语法来约束和表达的事情最后却只能用 URI 来表达,约束校验只能推迟到运行时再做判断了。


一个解决办法是提供一个上图所示的 Biz Framework 模块和 Common Biz 模块。


Framework


Framework 模块是纯技术的、业务无关的、但根据业务需求编写的通用能力。


它不依赖任何业务模型,只依赖一些 Library,其中包含一些对第三方库的简单化工具,业务无关的基础能力以及各种类型的工具类。


Biz Framework


既然有了技术上的 Framework,那么有一个业务上的 Framework 也不过分吧。


对于一些足够通用,甚至可以作为项目基石的一些业务可以考虑放入这个模块。


由于这个模块是业务的最底层,必须足够抽象和基础,所以这里面大部分会是接口和数据模型。


比如作为一个 Microblogging 客户端,无论是哪个业务模块几乎都会使用到诸如 User、Blog 这样的模型,以及无论哪个模块,都会判断登录状态,发起登陆等,因此可以把它们定义在此处。


Common Biz


通用业务模块,一般来说,大部分的通用业务应该在此处,比如数据分析、通用 UI 组件、通用页面等。该模块负责解决一些通用的能力,可能会被任何一个上层模块依赖,同时也会依赖 Biz Framework 模块获取其中的数据类型等。


对于一些通用的业务工具类也可以放在此处,比如对 Blog 中时间的不同格式化方式、列表内容加载流程范式等。


甚至一些简单的业务也可以放在这里,因为 Features 模块包含的是比较大的业务,对于一些小到不值得划分模块的业务写到这里也可以接受。


Features


这个模块的职责就很清晰了,Features 下面的每个模块都仅包含一个独立的业务。比如上图中的 Feeds 模块就是 Feeds 相关的部分,Account 是账户管理部分等。


对于我的项目来说,我有四个 Features 模块,刚好对应首页底部的四个 TAB。


到了这里会有个问题,不同 Feature 之间几乎肯定是会有互相跳转的需求的,虽然业务比较独立,但这种需求也偶尔会出现,这里可以选择在 common biz 模块提供一个不同模块的 Visitor 接口,每个模块各自实现,然后通过这个 Visitor 来跳转。


如果对于一些更复杂的场景,以及包含了 DeepLink 等需求的场景,可以考虑使用路由,但是使用路由跳转应该谨慎一点,慎重考虑之后再做决定。


Plugins


Plugins 模块一般根据项目的情况决定需不需要,它作为插件化架构的插件层存在,这里的插件是指软件架构中的一种定义。


对于一些可能的动态功能,或者具体实现依赖于运行环境的功能,可以考虑放入此处。


插件层一般不需要被任何模块依赖,它与 Application 处于同一个层级(至少源码级别是这样的),编译时将他打入包内即可,可以通过依赖注入或者一些 SPI 机制获取其实现。


Application


这个模块就更简单了,主要用来组合所有的 Feature 模块,一般不会包含太多代码。


对于跨平台项目来说,可能存在多个 Application 模块,每一个对应一种平台。


上面就是我在项目中使用的模块划分方式,目前使用下来感觉很丝滑,没遇到什么坑,这也是演进了两年的结果,也就是我自己的项目能这么玩了,哪里看着不顺眼就来重构一下,也希望这对大家有所帮助。


作者:张可
来源:juejin.cn/post/7433441848226988032
收起阅读 »

Android - 监听网络状态

前言 早期监听网络状态用的是广播,后面安卓为我们提供了android.net.ConnectivityManager.NetworkCallback,ConnectivityManager有多个方法可以注册NetworkCallback,通过不同方法注册,在回...
继续阅读 »

前言


早期监听网络状态用的是广播,后面安卓为我们提供了android.net.ConnectivityManager.NetworkCallbackConnectivityManager有多个方法可以注册NetworkCallback,通过不同方法注册,在回调时逻辑会有些差异,本文探讨的是以下这个方法:


public void registerNetworkCallback(
@NonNull NetworkRequest request,
@NonNull NetworkCallback networkCallback
)


首先需要创建NetworkRequest


val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

addCapability方法的字面意思是添加能力,可以理解为添加条件,表示回调的网络要满足指定的条件。


这里添加了NetworkCapabilities.NET_CAPABILITY_INTERNET,表示回调的网络应该要满足已连接互联网的条件,即拥有访问互联网的能力。


如果指定多个条件,则回调的网络必须同时满足指定的所有条件。


创建NetworkRequest实例之后就可以调用注册方法了:


val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
manager.registerNetworkCallback(request, _networkCallback)

_networkCallback用来监听网络变化,下文会介绍。


重点来了,每个App只允许最多注册100个回调,如果超过会抛RuntimeException异常,所以在注册时要捕获异常并做降级处理,下文会提到。


NetworkCallback有多个回调方法,重点关注下面2个方法:


public void onCapabilitiesChanged(
@NonNull Network network,
@NonNull NetworkCapabilities networkCapabilities
)
{}

该方法在注册成功以及能力变化时回调,参数是:



  • Network,网络

  • NetworkCapabilities,网络能力


该方法触发的前提是,这个网络要满足addCapability方法传入的条件。具体有哪些网络能力,可以看一下源码,这里就不一一列出来。


public void onLost(@NonNull Network network) {}

onLost比较简单,在网络由满足条件变为不满足条件时回调。


封装


有了前面的基础,就可以开始封装,基本思路如下:



  • 定义一个网络状态类

  • 维护一个满足条件的网络状态流Flow,并在状态变化时,更新Flow

  • 注册NetworkCallback回调,开始监听


网络状态类

interface NetworkState {
/** 网络Id */
val id: String
/** 是否Wifi网络 */
val isWifi: Boolean
/** 是否手机网络 */
val isCellular: Boolean
/** 网络是否已连接,已连接不代表网络一定可用 */
val isConnected: Boolean
/** 网络是否已验证可用 */
val isValidated: Boolean
}

NetworkState是接口,定义了一些常用的属性,就不赘述。


internal data class NetworkStateModel(
/** 网络Id */
val netId: String,
/** [NetworkCapabilities.TRANSPORT_WIFI] */
val transportWifi: Boolean,
/** [NetworkCapabilities.TRANSPORT_CELLULAR] */
val transportCellular: Boolean,
/** [NetworkCapabilities.NET_CAPABILITY_INTERNET] */
val netCapabilityInternet: Boolean,
/** [NetworkCapabilities.NET_CAPABILITY_VALIDATED] */
val netCapabilityValidated: Boolean,
) : NetworkState {
override val id: String get() = netId
override val isWifi: Boolean get() = transportWifi
override val isCellular: Boolean get() = transportCellular
override val isConnected: Boolean get() = netCapabilityInternet
override val isValidated: Boolean get() = netCapabilityValidated
}

NetworkStateModel是实现类,具体的实例在onCapabilitiesChanged方法回调时,根据回调参数创建,创建方法如下:


private fun newNetworkState(
network: Network,
networkCapabilities: NetworkCapabilities,
)
: NetworkState {
return NetworkStateModel(
netId = network.netId(),
transportWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI),
transportCellular = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
netCapabilityInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
netCapabilityValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
}

private fun Network.netId(): String = this.toString()

通过NetworkCapabilities.hasXXX方法,可以知道Network网络的状态或者能力,更多方法可以查看源码。


网络状态流Flow

接下来在回调中,把网络状态更新到Flow


// 满足条件的网络
private val _networks = mutableMapOf<Network, NetworkState>()
// 满足条件的网络Flow
private val _networksFlow = MutableStateFlow<List<NetworkState>?>(null)

private val _networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onLost(network: Network) {
super.onLost(network)
// 移除网络,并更新Flow
_networks.remove(network)
_networksFlow.value = _networks.values.toList()
}

override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
super.onCapabilitiesChanged(network, networkCapabilities)
// 修改网络,并更新Flow
_networks[network] = newNetworkState(network, networkCapabilities)
_networksFlow.value = _networks.values.toList()
}
}

onLostonCapabilitiesChanged中更新_networks_networksFlow


_networksFlow的泛型是一个List<NetworkState>,因为满足条件的网络可能有多个,例如:运营商网络,WIFI网络。


_networks是一个MapKEYNetwork,我们看看Network源码:


public class Network implements Parcelable {
@UnsupportedAppUsage
public final int netId;

@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof Network)) return false;
Network other = (Network)obj;
return this.netId == other.netId;
}

@Override
public int hashCode() {
return netId * 11;
}

@Override
public String toString() {
return Integer.toString(netId);
}
}

把其他非关键代码都移除了,可以看到它重写了equalshashCode方法,所以把它当作HashMap这种算法容器的KEY是安全的。


细心的读者可能会有疑问,NetworkCallback的回调方法是在什么线程执行的,回调中直接操作Map是安全的吗?


默认情况下,回调方法是在子线程按顺序执行的,这里的重点是按顺序,所以在子线程也是安全的,因为没有并发。可以在注册时,调用另一个重载方法传入Handler来修改回调线程,这里就不继续探讨,有兴趣的读者可以看看源码。


开始监听

接下来可以注册回调,开始监听了。上文提到,每个App最多只能注册100个回调,我们的降级策略是:


如果注册失败,直接获取当前网络状态,并更新到Flow,延迟1秒后继续尝试注册,如果注册成功,停止循环,否则一直重复循环。


建议把这个逻辑放在非主线程执行。

如果一直注册失败的话,这种降级策略有如下缺点:



  • 每隔1秒获取一次网络状态,所以有一定的延迟,当然你可以把间隔设置的更小,这个取决于你的业务。

  • 最多只能获取到一个满足条件的网络,因为是通过ConnectivityManager.getActiveNetwork()来获取当前网络状态的。


有的读者可能知道有getAllNetworks()方法获取所有网络,但是该方法已经被废弃了,不建议使用。


了解降级策略后,可以看代码了:


private suspend fun registerNetworkCallback() {
// 1.创建请求对象,指定要满足的条件
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

while (true) {
// 2.注册监听,要捕获RuntimeException异常
val register = try {
manager.registerNetworkCallback(request, _networkCallback)
true
} catch (e: RuntimeException) {
e.printStackTrace()
false
}

// 3.获取当前网络状态
val currentList = manager.currentNetworkState().let { networkState ->
if (networkState == null) {
emptyList()
} else {
listOf(networkState)
}
}

if (register) {
// A: 注册成功,更新Flow,并停止循环
_networksFlow.compareAndSet(null, currentList)
break
} else {
// B: 注册失败,间隔1秒后重新执行上面的循环
_networksFlow.value = currentList
delay(1_000)
continue
}
}
}

代码看起来比较长,实际逻辑比较简单,我们来分析一下。


第1步上文已经解释了,就不赘述了。

后面的逻辑是在while循环中执行的,就是上面提到的降级策略逻辑。


最后根据注册的结果,会走2个分支,B分支是注册失败的降级策略分支。

A分支是注册成功的分支,把当前状态更新到Flow,并停止循环。


注意:这里更新Flow用的是compareAndSet,这是因为注册之后有可能onCapabilitiesChanged已经回调了最新的网络状态,此时不能用currentList直接更新覆盖,而要进行比较,如果是null才更新,因为null是默认值,表示onCapabilitiesChanged还未被回调。


这也解释了上文中定义Flow时,默认值为什么是一个null,而不是一个空列表,因为默认值设置为空列表有歧义,它到底是默认值,还是当前没有满足条件的网络,注册时就没办法compareAndSet


最后我们对外暴露Flow就可以了:


/** 监听所有网络 */
val allNetworksFlow: Flow<List<NetworkState>> = _networksFlow.filterNotNull()

filterNotNull()把默认值null过滤掉。


监听当前网络


实际开发中,大部分时候,仅仅需要知道当前的网络状态,而不是所有的网络状态。有了上面的封装,我们可以很方便的过滤出当前网络状态:


/** 监听当前网络 */
val currentNetworkFlow: Flow<NetworkState> = allNetworksFlow
.mapLatest(::filterCurrentNetwork)
.distinctUntilChanged()
.flowOn(Dispatchers.IO)

过滤的逻辑在filterCurrentNetwork方法中:


private suspend fun filterCurrentNetwork(list: List<NetworkState>): NetworkState {
// 1.列表为空,返回一个代表无网络的状态
if (list.isEmpty()) return NetworkState
while (true) {
// 2.从列表中查找网络Id和当前网络Id一样的状态,即当前网络状态
val target = list.find { it.id == manager.activeNetwork?.netId() }
if (target != null) {
return target
} else {
// 3.如果本次未查询到,延迟后继续查询
delay(1_000)
continue
}
}
}

第2步中有个获取网络Id的扩展函数,上文已经有列出,但未做解释,实际上就是调用Network.toString()


为什么会有第3步呢?因为我们是在回调中直接更新Flow可能导致filterCurrentNetwork立即触发,相当于在回调里面直接查询manager.activeNetwork


NetworkCallback的回调中,同步调用ConnectivityManager的所有方法都可能有先后顺序问题,即本次调用查询到的状态,可能并非最新的状态,这个在源码中有解释,有兴趣的读者可以看看源码。


上面的currentNetworkFlow,我们用了mapLatest,如果在delay时,列表又发生了变化,则会取消本次过滤,重新执行filterCurrentNetwork


当然了distinctUntilChanged也是必须的,假如当前网络activeNetwork是WIFI,另一个满足条件的运营商网络发生变化时也会执行过滤,过滤的结果还是WIFI,就会导致重复回调。


最后建议把这个过滤切换到非主线程执行,可以使用flowOn


实际上,如果你只想监听当前网络,不需要知道所有网络,那么在注册回调的时候可以使用registerDefaultNetworkCallback来监听,此时回调的逻辑和本文介绍的稍有差异,这个方法要求API 24,具体可以看一下源码注释,这里就不展开。


挂起等待网络


有了上面的封装,在协程中,我们可以轻松实现:


在某个操作之前,判断网络已连接才执行,如果未连接则挂起等待。


suspend fun fAwaitNetwork(
condition: (NetworkState) -> Boolean = { it.isConnected },
)
: Boolean {
if (condition(FNetwork.currentNetwork)) return true
FNetwork.currentNetworkFlow.first { condition(it) }
return false
}

FNetwork.currentNetwork是一个获取当前网络状态的属性,最终获取的方法如下:


private fun ConnectivityManager.currentNetworkState(): NetworkState? {
val network = this.activeNetwork ?: return null
val capabilities = this.getNetworkCapabilities(network) ?: return null
return newNetworkState(network, capabilities)
}

fAwaitNetwork调用时,先直接获取一次当前网络状态,如果满足条件,则立即返回,如果不满足条件则开始监听currentNetworkFlow,遇到第一个满足条件的网络时,恢复执行。


上层可以通过返回值true或者false知道本次调用是立即满足的,还是挂起等待之后满足的。


模拟使用代码:


lifecycleScope.launch { 
// 判断网络
fAwaitNetwork()

// 发起请求
requestData()
}

结束


库已经封装好了,在这里:network

该库会在主进程自动初始化,开箱即用,如果你的App需要在其他进程使用,则需要在其他进程手动调用初始化。


感谢你的阅读,如果有问题欢迎一起交流学习,


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

一文搞懂Apk的各种类型

戳蓝字“牛晓伟”关注我哦! 用心坚持输出易读、有趣、有深度、高质量、体系化的技术文章,技术文章也可以有温度。 本文摘要 本文主要介绍Android中Apk的各种类型,通过本文您将了解到Apk分为哪些类型,系统Apk、普通Apk、特权Apk、core Apk、p...
继续阅读 »

戳蓝字“牛晓伟”关注我哦!


用心坚持输出易读、有趣、有深度、高质量、体系化的技术文章,技术文章也可以有温度。


本文摘要


本文主要介绍Android中Apk的各种类型,通过本文您将了解到Apk分为哪些类型系统Apk、普通Apk、特权Apk、core Apk、product Apk等这些Apk之间的区别和作用。 (文中代码基于Android13)


本文采用对话的方式,人物小昱大牛,小昱是Android新人,为了能进入大厂,利用工作之余恶补Android知识。大牛是具有多年开发经验的老手。小昱有问题就会向大牛请教。


本文大纲


image


1. Apk分类


小昱最近正在梳理包管理 (PackageManagerService) 和 权限管理 (PermissionManagerService)的相关内容,但是在梳理关于Apk类型权限类型时,又被搞的头晕脑胀的。于是想起了向大牛请教。


小昱:“大牛,不好意思又来麻烦你了,我看Android中的Apk有普通Apk系统Apkprivileged Apk (特权Apk)、persistent Apkproduct Apk等,就这些不同类别的Apk就把我搞的云里雾里了。而更让人头疼的是Android中的权限还有normal权限dangerous权限privileged权限(特权权限)等这些类别的权限。我在梳理这些类别的时候,真的是越梳理越乱,能帮帮梳理梳理吗?谢谢。”



大牛:“没问题小昱,千万不要慌,一口吃不成一个胖子,那我就先从Apk的类型说起吧,Apk从大类上主要分为系统Apk普通Apk,而系统Apk还可以继续分类,你刚刚提到的privileged Apk就属于系统Apk的一种,那我就先从最复杂的系统Apk说起吧。”


2. 系统Apk


大牛:“Android中像launchersystemuisettingcameragallery等Apk都是系统Apk,能成为系统Apk可是很多普通Apk梦寐以求的事情啊,因为系统Apk相对于普通Apk确实有很多的特权....."


小昱突然礼貌性的打断了大牛的讲话:“大牛,这也是我正想知道的事情,一个Apk需要具备什么样的特性才能成为系统Apk,或者PackageManagerService是根据啥来识别一个Apk是系统Apk的,这个事情一直困扰着我,让我久久不能睡眠。快点告诉我吧,我实在太想知道答案了。”


2.1 如何成为系统Apk


大牛:“这个问题的答案非常的简单,PackageManagerService服务根据Apk所处的目录来判断Apk到底是系统Apk还是普通Apk的,我特意绘制了一幅图,展示了系统Apk所存放的所有目录,凡是Apk存放于以下目录都是系统Apk。”


image


小昱有些不敢相信的说:“啊!难道就这么简单吗?如果是这么简单,那我也可以把一个普通Apk放入这些目录下面,就可以让它变成系统Apk了。”



听了小昱的话,大牛有些好笑又有些气愤,心里默念不知者不为过,说:“把普通Apk放入这些目录,这不是开国际玩笑嘛,要想把普通Apk放入这些目录除非有root权限,否则别白日做梦啊。系统Apk确实就是根据Apk所存放的目录来决定的,那就听我细细道来吧。”


还记得在PackageManagerService服务启动的时候会做一件非常重要的事情扫描所有Apk (不记得可以看这篇文章),扫描所有Apk分为扫描所有系统Apk扫描所有普通Apk,而扫描所有系统Apk需要做如下几个关键事情:



  1. 首先要依次扫描systemodmoemproductsystem_extvendorapex这几个目录 (这几个目录定义在Partition类)

  2. 而在扫描这些目录的时候会增加一些scan flags值,其中对所有目录都要增加的一个值是SCAN_AS_SYSTEM,而不同的目录也会增加自己对应的scan flags值。比如扫描odm目录会增加SCAN_AS_ODMSCAN_AS_SYSTEM 值,扫描product目录会增加SCAN_AS_PRODUCTSCAN_AS_SYSTEM 值 (这些scan值定义在PackageManagerService类)

  3. 扫描Apk的其中一个环节是解析Apk信息,而解析完的Apk信息会存储在ParsedPackage对象中,进而再根据上面的 scan flags 值,对ParsedPackage对象的相应属性进行设置,比如是否是系统Apk,是否是product apk等。如下是相关代码:


//ScanPackageUtils类

//该方法会用scanFlags来设置parsedPackage的相应属性
public static void applyPolicy(ParsedPackage parsedPackage,
final @PackageManagerService.ScanFlags int scanFlags, AndroidPackage platformPkg,
boolean isUpdatedSystemApp) {

//scanFlags有SCAN_AS_SYSTEM,则是系统Apk
if ((scanFlags & SCAN_AS_SYSTEM) != 0) {
//setSystem为true,则认为是系统apk
parsedPackage.setSystem(true);
省略代码······
}

省略代码······

//根据scanFlags值设置是否是Oem、product等等
parsedPackage.setPrivileged((scanFlags & SCAN_AS_PRIVILEGED) != 0)
.setOem((scanFlags & SCAN_AS_OEM) != 0)
.setVendor((scanFlags & SCAN_AS_VENDOR) != 0)
.setProduct((scanFlags & SCAN_AS_PRODUCT) != 0)
.setSystemExt((scanFlags & SCAN_AS_SYSTEM_EXT) != 0)
.setOdm((scanFlags & SCAN_AS_ODM) != 0);

省略代码······
}

小昱:“大牛,我看了你的解释后,终于明白了,也就是说在PackageManagerService执行扫描Apk的过程,不同的目录会携带不同的scan flags值,最终根据该值来判断是不是系统Apk。”


大牛:“是的非常正确,还有一个点要说下系统Apk的安装是在PackageManagerService的扫描阶段完成的,不像普通Apk是有安装界面一说的。那咱们接着介绍下系统Apk的分类吧,系统Apk可以按存放的目录分类,也可以按Apk所具备的能力或特性分类,那就先从前者开始介绍吧。”


2.2 按存放目录分类


下图展示了系统Apk可以存放的目录及其子目录,请看下图:


image


如上图,系统Apk可以存放于/system、/system_ext、/product、/vendor、/odm、/oem、/apex这几个目录下面的子目录中,而系统Apk的分类又可以按根目录分类也可以按按子目录分类


2.2.1 按根目录分类


系统Apk根据存放的根目录可以划分为vendor Apkproduct ApksystemExt Apksystem Apkodm Apkoem Apk (由于存放在apex根目录下的Apk不是咱们的重点因此在这不予介绍)。


2.2.2 按子目录分类


系统Apk一般主要存放于各自根目录下的/app、/priv_app、/overlay这三个子目录中,为啥这里用了一般这个词呢,因为对于system根目录来说,它的framework子目录也是可以存放系统Apk的,比如framework-res.apk就存放于此。


存放于/priv-app子目录的系统Apk又被称为privileged Apk (特权Apk),存放于/overlay子目录的系统Apk又被称为overlay Apk,既不是privileged Apk也不是overlay Apk的系统Apk,是存放于/app子目录的。那就来介绍下privileged Apkoverlay Apk


privileged Apk

privileged Apk翻译为中文是特权Apk,该种类型Apk主要存放于/priv-app目录下,这里的特权是特殊权限 (privileged permission)的简称,Apk使用的权限是有很多种的比如危险权限normal权限等,而特殊权限是其中一种。


privileged Apk也就是该类型的Apk是可以使用特殊权限的,其他类型Apk是不可以使用特殊权限的。也就是特殊权限只归privileged Apk使用,但并不是说privileged Apk只可以使用特殊权限,它还可以使用别的权限。


要变为该类型的Apk,其实特别简单只需要把Apk放入上面提到的几个目录下面的 /priv-app 目录中即可,如/product/priv-app、/system/priv-app等。在扫描所有系统Apk的过程中,针对priv-app目录,会增加SCAN_AS_PRIVILEGEDflag值。如果在privileged ApkAndroidManifest.xml文件中使用了特殊权限,那需要在对应的特权名单内把所有的特殊权限都加入,否则会导致系统启动不了,如下是一个特权名单的例子:


//特权名单的名字是 包名.xml(如android/com.example.myapplication2.xml),并且需要放在对应 xxx/etc/permissions 目录下,xxx代表系统apk存放的根目录,如product、system等
<permissions>
<privapp-permissions package="com.example.myapplication2">
//permission代表授予某个特殊权限
<permission name="android.permission.STATUS_BAR"/>
//deny-permission代表拒绝某个特殊权限
<deny-permission name="android.permission.READ_PRIVILEGED_PHONE_STATE"/>
</privapp-permissions>
</permissions>

overlay Apk

该种类型的Apk主要存放于overlay子目录下,该种类型的Apk不包含任何的代码,只包含资源,该资源是res目录下的资源。该Apk的作用就是起到换肤的作用。当然这种类型的Apk只是对相应系统Apk进行换肤操作,而不会影响普通Apk。


2.2.3 小结


系统Apk可以按根目录分类也可以按子目录分类,比如存放于/product/priv-app/目录下的Apk,该Apk既是product Apk,也是privileged Apk。存放于/system/app/目录下的Apk,就是一个system Apk即系统Apk。


2.3 按Apk所具备的能力或特性分类


系统Apk按Apk所具备的能力或特性可以分为core Apkpersistent Apk,那就来介绍下它们。


core Apk

core Apk翻译为中文是核心Apk,用一句话总结该Apk就是说当Android设备配置特别特别低端的时候,其他的Apk都可以不要,但是core Apk是必须的。该类型的Apk会在PackageManagerService服务启动的时候前置于其他Apk创建data目录。像systemui都属于该类型的Apk。


要变为该类型的Apk,只需要在AndroidManifest.xml文件中,增加 coreApp="true" 即可,如下例子:


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.systemui"
android:sharedUserId="android.uid.systemui"
coreApp="true">


persistent Apk

persistent Apk翻译为中文是持久的Apk,是啥子意思呢?就是说该类别的Apk在App运行过程中,如果意外退出了,系统还会把它给拉起,让它继续保持运行状态。并且在Android设备启动后,是会把所有符合情况的persistent Apk提前启动,如下是相关代码:


//ActivityManagerService类

//该方法会在系统准备好后开始调用
void startPersistentApps(int matchFlags) {
if (mFactoryTest == FactoryTest.FACTORY_TEST_LOW_LEVEL) return;
synchronized (this) {
try {

//从PackageManagerService获取符合条件的persistent App
final List<ApplicationInfo> apps = AppGlobals.getPackageManager()
.getPersistentApplications(STOCK_PM_FLAGS | matchFlags).getList();
for (ApplicationInfo app : apps) {
if (!"android".equals(app.packageName)) {
//启动它们
final ProcessRecord proc = addAppLocked(
app, null, false, null /* ABI override */,
ZYGOTE_POLICY_FLAG_BATCH_LAUNCH);
省略代码······
}
}
} catch (RemoteException ex) {
}
}
}

小昱:“那一个Apk如何变为该类型Apk呢?”


大牛:“答案很简单,只需要在AndroidManifest.xml文件的application tag中加入android:persistent="true"即可,该配置只有对系统Apk才有效。如下例子。”


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:persistent="true">

2.4 小结



  1. 系统Apk按Apk存放的根目录可以分为vendor Apkproduct ApksystemExt Apksystem Apkodm Apkoem Apk

  2. 系统Apk按Apk存放的子目录可以分为privileged Apkoverlay Apk

  3. 系统Apk按Apk所具备的能力或特性可以分为persistent Apkcore Apk


大牛:“我从按目录分类按Apk所具备的能力或特性分类两个方面来介绍系统Apk的分类,而这两个方面分类的系统Apk是可以进行随机组合的。”


小昱:“随机组合?这是啥意思吗?”


大牛:“小昱别急啊,我正要说呢,比如存放于/vendor/priv-app/目录下的Apk是可以配置为persistent Apk或者core Apk甚至这两种类型都可以配置,这样这个Apk可以是vendor privileged persistent类型的Apk或者是vendor privileged core类型的Apk或者是vendor privileged persistent core类型的Apk。这就是它们可以随即组合的意思。好那我来介绍相对简单的普通Apk。”


3. 普通Apk


普通Apk就很简单了,别看微信、抖音是超级Apk,但是它们依然逃脱不了普通Apk的命运,普通Apk被安装后Apk文件是被存放于/data/app目录下的,普通Apk因为它不是系统Apk,因此它也不可能是vendor Apk或者上面提到的其他类型Apk,甚至也不能是persistent Apkcore Apk。在PackageManagerService扫描所有普通Apk时是没有加像扫描系统Apk那些scan flags值的,因此扫描完所有普通Apk后,这些Apk只能被识别为普通Apk。


下面是相关代码,请自行取阅:


//InitAppsHelper 类

//扫描所有普通Apk
public void initNonSystemApps(PackageParser2 packageParser, @NonNull int[] userIds,
long startTime) {
if (!mIsOnlyCoreApps) {
省略代码······
//其中 mPm.getAppInstallDir() 获取的值是 data/app,而 mScanFlags值是没有增加扫描系统Apk的那些 scan flag值的
scanDirTracedLI(mPm.getAppInstallDir(), /* frameworkSplits= */ null, 0,
mScanFlags | SCAN_REQUIRE_KNOWN,
packageParser, mExecutorService);
}

省略代码······
}

4. 总结


大牛:“小昱,关于Apk类型的知识就介绍完了,那我来介绍下系统Apk普通Apk的主要区别,以及系统Apk具有哪些特权来作为结尾吧。”


先来说下它们区别:



  1. 系统Apk的安装主要是在PackageManagerService启动时候扫描所有Apk的阶段;而普通Apk的安装是需要通过用户来安装,在安装过程是有安装界面的。

  2. 系统Apk使用Android.bp来配置编译信息;而普通Apk使用gradle进行编译。

  3. 系统Apk是不可以被用户卸载的;而普通Apk是可以被用户卸载的。

  4. 系统Apk的Apk文件是存放在/system、/system_ext、/product、/vendor、/odm、/oem、/apex目录下的子目录中;而普通Apk被安装后Apk文件是存放在/data/app目录下的。

  5. 系统Apk拥有很多的特权;而普通Apk啥也没有。


不想当CTO的程序员不是好程序员,不想成为系统Apk的普通Apk不是好Apk,那就来说说系统Apk到底有多大的魅力,让普通Apk这么着迷吧。



  1. 有些系统Apk希望自己的uid是1000,也就是和systemserver进程一样的uid,那就需要在该Apk的AndroidManifest.xml文件中配置android:sharedUserId="android.uid.system"。该Apk的uid是1000后那做的事情可就多了,比如可以访问systemserver进程的各种文件。

  2. 系统Apk若配置为persistent Apk的话,就可以保持长久运行了。

  3. 若在内存紧张的情况下,普通App被杀掉的概率要远大于系统App。


当然上面只是列出了一些系统Apk相对于普通Apk的优势,其实还有很多没有列出来,关于Apk类型的介绍就到此为止。


欢迎关注我的公众号牛晓伟(搜索或者点击牛晓伟链接)


Android framework和App 进阶是我的知识星球,有兴趣的同学可以加入,跟我一起进阶Android framework和App知识。


作者:牛晓伟已占用
来源:juejin.cn/post/7433074970605551653
收起阅读 »

实战:把一个现有的Compose项目转化为CMP项目

通过前面两篇文章的学习,我们已经对CMP有了一定的了解,接下来要进入实战阶段。在现实的世界中极小数项目会从0开始,今天重点研究一下如何把一个现成的用Jetpack Compose开发的Android项目转成CMP项目。 总体思路 在前面的文章Compose大...
继续阅读 »

通过前面两篇文章的学习,我们已经对CMP有了一定的了解,接下来要进入实战阶段。在现实的世界中极小数项目会从0开始,今天重点研究一下如何把一个现成的用Jetpack Compose开发的Android项目转成CMP项目。


Compose-Multiplatform.png


总体思路


在前面的文章Compose大前端从上车到起飞里面我们学习到了,CMP对Android开发同学是相当友好的,CMP项目与Android项目在项目结构上面是非常相似的。并且因为CMP的开发IDE就是Android Studio,因此,可以直接把一个Android项目改造成为CMP项目,而不是创建一个全新的CMP项目之后把项目代码移动进去。


具体的步骤如下:



  1. 添加CMP的插件,添加源码集合,配置CMP的依赖

  2. 把代码从「androidMain」移动到「commonMain」中去

  3. 把资源转换成为CMP方式

  4. 添加并适配其他平台


小贴士: 针对 不同的类型的任务需要采取 不同的策略,比如开发功能的时候使用「自上而下」的方式要更为好一些,因为先关注大粒度的组件,类与方法,不被细节拖住,更有利于我们看清架构和优先解决掉重点问题;但当做移植任务时,应该采用「自下而上」,因为依赖是一层套一层,先把下面的移好,上面的自然就会更加容易。


这里选用的项目是先前用纯Jetpack Compose开发的一款天气应用,项目比较简单,依赖不多,完全是用Jetpack Compose实现的UI,也符合现代应用开发架构原则,非常适合当作案例。


注意: 其实这里的项目并没有严格要求,只要是一个能运行的Android项目即可,其他的(是不是Jetpack Compose实现的,用的是不是Kotlin)并不是最关键的。因为CMP项目对于每个源码集合本身并没有明确的要求,前面的文章也讲了,每个平台的源码集合,其实就是其平台的完整的项目。移植的目的就是把 可共用共享 的代码从现有项目中抽出来放进「commonMain」中,即可以是原有的业务逻辑,也可以是新开发的代码。采用新技术或者新工具的一个非常重要的原则 就是要循序渐进,不搞一刀切。如果时间不充裕,完全可以新功能和新代码先用CMP方式开发,老代码暂且不动它,待日后慢慢再移植。当然了,纯Jetpack Compose实现的项目移植过程会相对容易一些。


下面我们进行详细的一步一步的实践。


配置CMP的插件,源码集合和依赖


首先要做的是配置Gradle构建插件(这是把Gradle常用的Tasks等打包成为一个构建 插件,是编译过程中使用的):



  • 使用Kotlin Multiplatform(「org.jetbrains.kotlin.multiplatform」)替换Kotlin Android(「org.jetbrains.kotlin.android」),这个主要是Kotlin语言的东西,版本号就是Kotlin的版本号,注意要与其他(如KSP,如Coroutines)版本进行匹配;

  • 添加Compose compiler(「org.jetbrains.kotlin.plugin.compose」)的插件,版本号要与Kotlin版本号保持一致;

  • 以及添加Compose Multiplatform(org.jetbrains.compose」)插件,版本号是CMP的版本号。


注意,构建插件配置是修改项目根目录的那个build.gradle.kts:


// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.1.4" apply false
id("com.android.library") version "8.1.4" apply false
id("org.jetbrains.kotlin.multiplatform") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.compose") version "1.7.0" apply false
}

之后是修改module的build.gradle.kts,先是启用需要的插件,然后是添加kotlin相关的配置(即DSL kotlin {...}),在其中指定需要编译的目标,源码集合以及其依赖,具体的可以仿照着CMP的demo去照抄就好了。对于依赖,可以把其都从顶层DSL dependencies中移动到androidMain.dependencies里面,如果有无法移动的就先放在原来的位置,暂不动它,最终build.gradle.kts会是酱紫:


plugins {
id("com.android.application")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.multiplatform")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
}

kotlin {
androidTarget {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

sourceSets {
androidMain.dependencies {
// Jetpack
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-compose:1.9.3")
val lifecycleVersion = "2.8.7"
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
val navVersion = "2.8.4"
implementation("androidx.navigation:navigation-runtime-ktx:$navVersion")
implementation("androidx.navigation:navigation-compose:$navVersion")
implementation("androidx.datastore:datastore-preferences:1.1.1")

// Google Play Services
implementation("com.google.android.gms:play-services-location:21.3.0")

// Compose
implementation(compose.preview)
implementation(project.dependencies.platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material")

// Network
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

// Accompanist
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
val lifecycleVersion = "2.8.3"
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion")
implementation("org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
}
}
}

android { ... }

dependencies { ... }

最后,把DSL android {...}中不支持的字段删除掉即可,如kotlinOptions,它用来指定Kotlin JVM target的,现改在DSL kotlin中的androidTarget()中指定了,但要注意Kotlin的JVM target要与android中的compileOptions的sourceCompatibility以及targetCompatibility版本保持一致,比如都是17或者都是11,否则会有编译错误。


需要特别注意的是DSL kotlin中的源码集合名字要与真实的目录一致,否则编译会出错。建议的方式就是依照CMP的demo那样在module中去创建androidMain和commonMain即可。另外,可以把module名字从「app」改为「composeApp」,然后把运行配置从「app」改为「androidApp」,这下就齐活儿了:


migrate-struct.png


CMP的插件和依赖配置好了以后,运行「androidApp」应该就可以正常运行。因为仅是配置一些依赖,这仍是一个完整的Android应用,应该能够正常运行。这时第一步就做完了,虽然看起来貌似啥也没干,但这已经是一个CMP项目了,基础打好了,可以大步向前了。


小贴士: 通过配置依赖可以发现,CMP的artifact依赖都是以org.jetbrans.*开头的,哪怕是对于Compose本身,纯Android上面Jetpack Compose的依赖是「"androidx.compose.ui:ui"」,而CMP中的则是「"org.jetbrains.compose.ui:ui"」。虽然都是Jetpack Compose,代码是兼容的,但技术上来讲是两个不同的实现。确切地说JetBrains的Compose是从谷歌的上面fork出来的一个分支,以让其更好的适用于CMP,但完全兼容,标准的Compose代码都是能正常跑的。


把代码从「androidMain」移动到「commonMain」


这是最关键的一步了,也是最难啃的硬骨头,具体的难度取决于项目中使用了多少「不兼容」的库和API。Compose和Jetpack中的绝大多数库都是支持的,可以在CMP中使用,可以无缝切换,这是JetBrains和Google共同努力的结果,谷歌现在对CMP/KMP的态度非常的积极,给与「第一优先支持(First class support)」。所以对于依赖于room,navigation,material和viewmodel的代码都可以直接移到common中。


也就是说对于data部分,model部分以及domain部分(即view models)都可以直接先移到common中,因为这些层,从架构角度来说都属于业务逻辑,都应该是平台独立的,它们的主要依赖应该是Jetpack以及三方的库,这些库大多也都可以直接跨平台。


当然,不可能这么顺利,因为或多或少会用到与平台强相关的API,比如最为常见的就是上下文对象(Context)以及像权限管理和硬件资源(如位置信息),这就需要用到平台定制机制(即expect/actual)来进行定制。


可能有同学会很奇怪,为啥UI层还不移动到common中,UI是用Compose写的啊,而Compose是可以直接在CMP上跑的啊。Compose写的UI确实可以直接跑,但UI必然会用到资源,必须 先把资源从android中移到common中,否则UI是跑不起来的。


把资源转化成为CMP方式


在前一篇文章Compose大前端从上车到起飞有讲过CMP用一个库resources来专门处理资源,规则与Android开发管理资源的方式很像,所以可以把UI用到的资源移动到common中的composeResources里面,就差不多了。


但需要特别注意,不要把全部的资源都从androidMain中移出,只需要把UI层用到的那部分资源移出即可。androidMain中至少要把Android强相关的资源留下,如应用的icon,应用的名字,以及一些关键的需要在manifest中使用的xml等。这是因为这些资源是需要在Android应用的配置文件AndroidManifest中使用的,所以必须还放在android源码集中。


资源文件移动好后,就可以把UI移动到common中了,最后一步就是使用CMP的资源类Res代替Android的资源类R即可。


到此,就完成了从Android项目到CMP项目的转变。


添加并适配其他平台


前面的工作做好后,再适配其他的平台就非常容易了,添加其他平台的target和入口(可以仿照CMP的demo),然后实现相关的expect接口即可。由此,一个大前端 项目就彻底大功告成了。


总结


CMP对项目结构中源码 集合 的限制 并不多,每个平台相关的sourceSet可以保持其原来的样子,这对现有项目是非常友好的,可以让现有的项目轻松的转成为CMP项目,这也是CMP最大的一个优势。


References




欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!


保护原创,请勿转载!



作者:稀有猿诉
来源:juejin.cn/post/7441956051438682138
收起阅读 »

1. OkDownload功能使用与文件下载的大致流程

Author: istyras Date: 2024-10-12 Update: 2024-10-12 0. OkDownload 组件 OkDownload 组件,是由流利说App开发团队开发并开源的一款强大的文件下载功能组件。 完整的使用文档 1. 简单...
继续阅读 »


Author: istyras

Date: 2024-10-12

Update: 2024-10-12




0. OkDownload 组件


OkDownload 组件,是由流利说App开发团队开发并开源的一款强大的文件下载功能组件。


完整的使用文档


1. 简单使用


1.1. 启动一个下载任务与取消任务


DownloadTask task = new DownloadTask.Builder(url, parentFile)
.setFilename(filename)
// the minimal interval millisecond for callback progress
.setMinIntervalMillisCallbackProcess(30)
// do re-download even if the task has already been completed in the past.
.setPassIfAlreadyCompleted(false)
.build();

task.enqueue(listener);

// cancel
task.cancel();

// execute task synchronized
task.execute(listener);

1.2. 启动多个任务和取消


// This method is optimize specially for bunch of tasks
DownloadTask.enqueue(tasks, listener);

// cancel, this method is also optmize specially for bunch of tasks
DownloadTask.cancel(tasks);

1.3. 下载任务队列的启动与取消


DownloadContext.Builder builder = new DownloadContext.QueueSet()
.setParentPathFile(parentFile)
.setMinIntervalMillisCallbackProcess(150)
.commit();
builder.bind(url1);
builder.bind(url2).addTag(key, value);
builder.bind(url3).setTag(tag);
builder.setListener(contextListener);

DownloadTask task = new DownloadTask.Builder(url4, parentFile)
.setPriority(10).build();
builder.bindSetTask(task);

DownloadContext context = builder.build();

context.startOnParallel(listener);

// stop
context.stop();


上述就是简单的使用 OkDownload 进行文件下载的方式。






下面我们开始分析 OkDownload 进行文件下载时的大致流程,在进行分析的时候,如果对于文件下载的流程不熟悉的同学,建议先阅读本系列的开篇文章 《0. 由浅到深地阐述文件下载的原理》,以便能够更好的理解我们接下来对 OkDownload 这款强大的文件下载组件的框架设计。




2. OkDonwload 文件下载的大致流程分析


在阅读和分析源码的情况下,我们可以通过 OkDownload 的下载监听的回调流程来理解其内部的下载流程。


2.1. 简单的下载流程回调


OkDownload.DownloadListener1.png


如图所示,简单的文件下载流程,从任务开始->任务连接->进度回调->任务结束。其中还有一个失败重试的过程。


2.2. 稍复杂的下载流程回调


OkDownload.DownloadListener4.png


如图所示,在这个稍复杂的下载流程回调中,增加了连接相关的流程( connectStart, connectEnd )和 分片下载的相关流程( processBlock, blockEnd )。


2.2.1. 连接相关的流程


连接流程,处理的是真正下载开始之前,预请求资源地址,获得下载的目标资源相关的一些信息(比如:资源大小、是否支持分片下载等等),同时可以判断给定的地址是否需要重定向,判断目标地址是否有效。


当然,由于 OkDownload 支持断点续传、分片下载,所以在连接检查的过程中,同时还会结合本地已经完成的部分记录信息,对已完成部分,以及没有完成部分进行更严格的校验。


所有的校验,都只有一个目的:为了确保下载的资源文件完整与正确。


2.2.2. 进度回调流程


因为支持分片下载,所以下载进度的回调细分的话,还有每个分片部分的流程回调,而整体进度的回调会汇总每个分片的进度总和进行回调出来,这样对于使用方来说就能够得到目标资源的实际的下载进度。


2.3. 完整的下载流程回调


OkDownload.DownloadListener.png
如图所示,完整的下载流程回调中,增加了 断点续传 的状态回调,同时在分片下载的流程中还详细的回调了单块文件下载的全部状态流程。


到此为止,我们对 OkDownload 有粗略地了解,后续我们将开始对其源码进行详细的分析。




作者:磨剑十年
来源:juejin.cn/post/7425932970593779738
收起阅读 »

Android串口,USB,打印机,扫码枪,支付盒子,键盘,鼠标,U盘等开发使用一网打尽

众里寻他千百度,蓦然回首,那人却在灯火阑珊处 一、前言 在Android智能设备开发过程中,难免会遇到串口,USB,扫码枪,支付盒子,打印机,键盘,鼠标等接入场景,其实这些很简单,只是大多数情况下,大家都在做手机端的App开发,接触这方面的很少。本文重点介绍...
继续阅读 »

1111111.jpg



众里寻他千百度,蓦然回首,那人却在灯火阑珊处



一、前言


在Android智能设备开发过程中,难免会遇到串口,USB,扫码枪,支付盒子,打印机,键盘,鼠标等接入场景,其实这些很简单,只是大多数情况下,大家都在做手机端的App开发,接触这方面的很少。本文重点介绍下这些在Android系统下是怎么接入使用的。


二 、串口接入使用


1. 可以到官网下载串口包 里面含有 libprt_serial_port.so 这个库,下载下来按照so使用方式接入就行了,还有 SerialPort 类:如下:

public class SerialPort {

private static final String TAG = "SerialPort";

/*
* Do not remove or rename the field mFd: it is used by native method close();
*/

private FileDescriptor mFd;
private FileInputStream mFileInputStream;
private FileOutputStream mFileOutputStream;

public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException {

mFd = open(device.getAbsolutePath(), baudrate, flags);
if (mFd == null) {
Log.e(TAG, "native open returns null");
throw new IOException();
}
mFileInputStream = new FileInputStream(mFd);
mFileOutputStream = new FileOutputStream(mFd);
}

// Getters and setters
public InputStream getInputStream() {
return mFileInputStream;
}

public OutputStream getOutputStream() {
return mFileOutputStream;
}

// JNI
private native static FileDescriptor open(String path, int baudrate, int flags);
public native void close();
static {
System.loadLibrary("serial_port");
}
}

2. 使用串口读取或者写入数据

需要配置串口路径和波特率,如下:路径为:/dev/ttyS4, 波特率为9600,这2个参数是硬件厂商约定好的。


val serialPort = SerialPort(File("/dev/ttyS4"), 9600, 0);

读写数据需要从串口里面拿到 输入输出流


inputStream = serialPort.inputStream  //
outputStream = serialPort.outputStream

比如读取数据:


val length = inputStream!!.available()
val bytes = new byte[length];
inputStream.read(bytes);

到此,串口的使用基本就完成了。


至于串口读取后的数据怎么解析?

需要看串口数据的文档,不同硬件设备读取的不同内容出来格式不一样,按照厂商给的格式文档解析就完了,比如,串口连接的是秤,秤厂商硬件那边约定好的数据格式是怎样的,数据第1位什么意思,第2到第X位什么意思,xxx位什么意思,这不同的厂商不同的,如果串口连接的不是秤,是其他硬件,约定的格式可能又不一样。


同理:
串口写数据,使用 outputStream流写入就行了, 写的具体内容,具体硬件厂商会有写入的文档,写入哪个数据是干什么用的,都在文档里面有。不同的写入功能,对应不同的写入内容命令。


三 、USB接入使用


1、在AndroidManifest中添加USB使用配置


<uses-permission android:name="android.permission.USB_PERMISSION" />
<uses-feature android:name="android.hardware.usb.host" />

<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_DETACHED" />


2、防止USB插入拔出导致Activity生命周期发生变化需要在Activity 下添加配置

android:configChanges="orientation|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"

3、代码中具体使用:

比如接入USB打印机:


//拿到USB管理器
mUsbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
mPermissionIntent = PendingIntent.getBroadcast(context, 0, Intent(ACTION_USB_PERMISSION), 0)
val filter = IntentFilter(USBPrinter.ACTION_USB_PERMISSION)
filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
//注册监听USB插入拔出监听广播
context.registerReceiver(mUsbDeviceReceiver, filter)
//开始检索出已经连接的USB设备
setUsbDevices()

找到打印机设备,打印机设备的接口类型值固定式为7(usbInterface.interfaceClass)


/**
* 检索usb打印设备
*/

private fun setUsbDevices() {
// 列出所有的USB设备,并且都请求获取USB权限
mUsbManager?.deviceList?.let {
for (device in it.values) {
val usbInterface = device.getInterface(0)
if (usbInterface.interfaceClass == 7) {
//连接了多个USB打印机设备需要 判断vid,pid,(硬件厂商会给这个值的)来确定哪一个打印机
//检查该USB设备是否有权限
if (!mUsbManager!!.hasPermission(device)) {
//申请该打印机USB权限
mUsbManager!!.requestPermission(device, mPermissionIntent)
} else {
connectUsbPrinter(device)
}
break
}
}
}
}

USB权限广播action收到后,就可以连接打印了


private val mUsbDeviceReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (ACTION_USB_PERMISSION == action) {
synchronized(this) {
val usbDevice = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
//UsbDevice:在Android开发中用于表示连接到Android设备的USB设备
mUsbDevice = usbDevice
if (mUsbDevice != null) {
connectUsbPrinter(mUsbDevice)
}
} else {
WLog.e(this, "Permission denied for device $usbDevice")
}
}
} else if (UsbManager.ACTION_USB_DEVICE_ATTACHED == action) {
//USB插入了
} else if (UsbManager.ACTION_USB_DEVICE_DETACHED == action) {
//USB拔出了
if (mUsbDevice != null) {
WLog.e(this, "Device closed")
if (mUsbDeviceConnection != null) {
mUsbDeviceConnection!!.close()
}
}
}
}
}

四、打印机的使用


Android上面的打印机大多数是USB连接的打印机,还有蓝牙打印机。下面重点介绍USB打印机的使用:
在前面代码里找到USB打印设备后,我们需要拿到打印机的 UsbEndpoint,如下:


//UsbEndpoint:表示USB设备的单个端点。USB协议中,端点是用于发送和接收数据的逻辑
private var printerEp: UsbEndpoint? = null
private var usbInterface: UsbInterface? = null

fun connectUsbPrinter(mUsbDevice: UsbDevice?) {
if (mUsbDevice != null) {
usbInterface = mUsbDevice.getInterface(0)
for (i in 0 until usbInterface!!.endpointCount) {
val ep = usbInterface!!.getEndpoint(i)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) {
if (ep.direction == UsbConstants.USB_DIR_OUT) {
mUsbManager?.let {
//与USB设备建立连接
mUsbDeviceConnection = mUsbManager!!.openDevice(mUsbDevice)
//拿到USB设备的端点
printerEp = ep //拿到UsbEndpoint
}
}
}
}
}
}

开始打印:写入打印数据:


/**
* usb写入
*
* @param bytes
*/

fun write(bytes: ByteArray) {
if (mUsbDeviceConnection != null) {
try {
mUsbDeviceConnection!!.claimInterface(usbInterface, true)
//注意设定合理的超时值,以避免长时间阻塞
val b = mUsbDeviceConnection!!.bulkTransfer(printerEp, bytes, bytes.size, USBPrinter.TIME_OUT)
mUsbDeviceConnection!!.releaseInterface(usbInterface)
} catch (e: Exception) {
e.printStackTrace()

}
}
}

一般通用USB打印命令都是ESC打印命令如下:


初始化打印机指令


//初始化打印机
public static byte[] init_printer() {
byte[] result = new byte[2];
result[0] = ESC;
result[1] = 0x40;
return result;
}

打印位置设置为居左对齐指令


 /**
* 居左
*/
public static byte[] alignLeft() {
byte[] result = new byte[3];
result[0] = ESC;
result[1] = 97;
result[2] = 0;
return result;
}

打印位置设置为居中对齐指令


    /**
* 居中对齐
*/
public static byte[] alignCenter() {
byte[] result = new byte[3];
result[0] = ESC;
result[1] = 97;
result[2] = 1;
return result;
}

打印位置设置居右对齐指令


    /**
* 居右
*/
public static byte[] alignRight() {
byte[] result = new byte[3];
result[0] = ESC;
result[1] = 97;
result[2] = 2;
return result;
}

打印结束切刀指令


    //切刀
public static byte[] cutter() {
byte[] box = new byte[6];
box[0] = 0x1B;
box[1] = 0x64;
box[2] = 0x01;
box[3] = 0x1d;
box[4] = 0x56;
box[5] = 0x31;
// byte[] data = new byte[]{0x1d, 0x56, 0x01};
return box;
}

打印文字


/**
* 打印文字
*
* @param msg
*/

///**
// * 安卓9.0之前
// * 只要你传送的数据不大于16384 bytes,传送不会出问题,一旦数据大于16384 bytes,也可以传送,
// * 只是大于16384后面的数据就会丢失,获取到的数据永远都是前面的16384 bytes,
// * 所以,android USB Host 模式与HID使用bulkTransfer(endpoint,buffer,length,timeout)通讯时
// * buffer的长度不能超过16384。
// * &lt;p&gt;
// * controlTransfer( int requestType, int request , int value , int index , byte[] buffer , int length , int timeout)
// * 该方法通过0节点向此设备传输数据,传输的方向取决于请求的类别,如果requestType 为 USB_DIR_OUT 则为写数据 , USB _DIR_IN ,则为读数据
// */
fun printText(msg: String) {
try {
write(msg.toByteArray(charset("gbk")))
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
}
}

打印图片,条码,二维码,可以将图片条码二维码转化为bitmap,然后再打印


//光栅位图打印
public static byte[] printBitmap(Bitmap bitmap) {
byte[] bytes1 = new byte[4];
bytes1[0] = GS;
bytes1[1] = 0x76;
bytes1[2] = 0x30;
bytes1[3] = 0x00;

byte[] bytes2 = getBytesFromBitMap(bitmap);
return byteMerger(bytes1, bytes2);
}

蓝牙打印机,放在下一篇文章介绍吧,一起介绍蓝牙,及蓝牙打印


五、扫码枪、支付盒子、键盘、鼠标使用


扫码枪,支付盒子,键盘,鼠标都是USB连接设备,只需要插入Android 设备即可,前提是Android 设备硬件含有USB 接口,比如智能硬件 收银机,收银秤,车载插入U盘等


收银机 扫码枪、支付盒子 怎么扫码的?

大家知道,我们的支付码,条码,其实是一串数字内容的,扫到后是怎么解析的?
有两种方式的


方式1:广播接收如下:


  1. 先注册扫码广播


<receiver android:name=".ScanGunReceiver">
<intent-filter>
<!-- 这里的 "SCAN_ACTION" 是扫码枪触发的action,需要替换为实际的值 -->
<action android:name="SCAN_ACTION" />
</intent-filter>
</receiver>

2. 在广播接收器里面拿到扫码内容


import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

public class ScanGunReceiver extends BroadcastReceiver {

@Override
public void onReceive(Context context, Intent intent) {
// 获取扫码内容,这里的 "SCAN_RESULT" 是扫码枪提供的action,具体可能不同
String scanContent = intent.getStringExtra("SCAN_RESULT");

// 处理扫码内容
if (scanContent != null) {
// 扫码内容非空,执行相关逻辑
}
}
}

方式2:在Activity的onKeyDown方法中监听,或者在Dialog.setOnKeyListener里面onKey中接收


  1. Activity中onKeyDown:中解析每一个keyCode对应的数字值


override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (KeyUtils.doNotSwitchViewPagerByKey(keyCode)) {
//按了键盘上 左右键 tab 键
return true
}
scanHelpL.get().acceptKey(this, keyCode) {
viewModel.scanByBarcode(it)
}
return super.onKeyDown(keyCode, event)
}

2. keyCode值与具体对照值如下:


object KeyUtils {

//控制按键 左右 tab 键 不切换 viewpage
fun doNotSwitchViewPagerByKey(keyCode: Int) = keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_TAB

/**
* keyCode转换为字符
*/

fun keyCodeToChar(code: Int, isShift: Boolean): String{
return when (code) {
KeyEvent.KEYCODE_SHIFT_LEFT -> ""
KeyEvent.KEYCODE_0 -> if (isShift) ")" else "0"
KeyEvent.KEYCODE_1 -> if (isShift) "!" else "1"
KeyEvent.KEYCODE_2 -> if (isShift) "@" else "2"
KeyEvent.KEYCODE_3 -> if (isShift) "#" else "3"
KeyEvent.KEYCODE_4 -> if (isShift) "$" else "4"
KeyEvent.KEYCODE_5 -> if (isShift) "%" else "5"
KeyEvent.KEYCODE_6 -> if (isShift) "^" else "6"
KeyEvent.KEYCODE_7 -> if (isShift) "&" else "7"
KeyEvent.KEYCODE_8 -> if (isShift) "*" else "8"
KeyEvent.KEYCODE_9 -> if (isShift) "(" else "9"
KeyEvent.KEYCODE_A -> if (isShift) "A" else "a"
KeyEvent.KEYCODE_B -> if (isShift) "B" else "b"
KeyEvent.KEYCODE_C -> if (isShift) "C" else "c"
KeyEvent.KEYCODE_D -> if (isShift) "D" else "d"
KeyEvent.KEYCODE_E -> if (isShift) "E" else "e"
KeyEvent.KEYCODE_F -> if (isShift) "F" else "f"
KeyEvent.KEYCODE_G -> if (isShift) "G" else "g"
KeyEvent.KEYCODE_H -> if (isShift) "H" else "h"
KeyEvent.KEYCODE_I -> if (isShift) "I" else "i"
KeyEvent.KEYCODE_J -> if (isShift) "J" else "j"
KeyEvent.KEYCODE_K -> if (isShift) "K" else "k"
KeyEvent.KEYCODE_L -> if (isShift) "L" else "l"
KeyEvent.KEYCODE_M -> if (isShift) "M" else "m"
KeyEvent.KEYCODE_N -> if (isShift) "N" else "n"
KeyEvent.KEYCODE_O -> if (isShift) "O" else "o"
KeyEvent.KEYCODE_P -> if (isShift) "P" else "p"
KeyEvent.KEYCODE_Q -> if (isShift) "Q" else "q"
KeyEvent.KEYCODE_R -> if (isShift) "R" else "r"
KeyEvent.KEYCODE_S -> if (isShift) "S" else "s"
KeyEvent.KEYCODE_T -> if (isShift) "T" else "t"
KeyEvent.KEYCODE_U -> if (isShift) "U" else "u"
KeyEvent.KEYCODE_V -> if (isShift) "V" else "v"
KeyEvent.KEYCODE_W -> if (isShift) "W" else "w"
KeyEvent.KEYCODE_X -> if (isShift) "X" else "x"
KeyEvent.KEYCODE_Y -> if (isShift) "Y" else "y"
KeyEvent.KEYCODE_Z -> if (isShift) "Z" else "z"
else -> ""
}
}
}

3. 扫码枪和支付盒子扫完,最后一位是回车键:检测到回车键值时候,就可以将扫到的码的内容 提交出去处理支付等操作。如下:


private fun acceptKey(keyCode: Int, block: (result: String) -> Unit) {
//监听扫码广播
if (keyCode != KeyEvent.KEYCODE_ENTER) {
if (isDeleteStringBuilder) {
val tmp: String = KeyUtils.keyCodeToChar(keyCode, hasShift)
stringBuilder.append(tmp)
hasShift = keyCode == KeyEvent.KEYCODE_SHIFT_LEFT
}
} else if (keyCode == KeyEvent.KEYCODE_ENTER) {
if (isDeleteStringBuilder) {
isDeleteStringBuilder = false
if (!TextUtils.isEmpty(stringBuilder.toString())) {
block?.invoke(stringBuilder.toString())
}
stringBuilder.delete(0, stringBuilder.length)
isDeleteStringBuilder = true
}
}
}

需要注意的是,扫码枪,支付盒子,键盘都是输入设备,要避免UI视图上面 控件焦点设置为 false,同时界面不能有 EditText控件,否则会将扫到的内容自动填入EditText控件里面去。

六、总结


本文重点介绍了Android 智能嵌入式设备,接入串口,USB,打印机,扫码枪支付盒子,键盘鼠标等,接入的简单开发。当然涉及到的蓝牙,蓝牙打印机,分屏这些会在后面的文章中进行介绍。


感谢阅读:


欢迎 关注,点赞、收藏


这里你会学到不一样的东西


作者:Wgllss
来源:juejin.cn/post/7439231301869305910
收起阅读 »

Android电视项目焦点跨层级流转

 1. 背景 在智家电视项目中,主要操作方式不是触摸,而是遥控器,通过Focus进行移动,确定点击进行的交互,所以在电视项目中焦点、选中、确定、返回这几个交互比较重要。由于电视屏比较大,在一些复杂页面中会存在一级Tab选择,二级选择,三级选择等,这就涉及到了焦...
继续阅读 »

 1. 背景


在智家电视项目中,主要操作方式不是触摸,而是遥控器,通过Focus进行移动,确定点击进行的交互,所以在电视项目中焦点、选中、确定、返回这几个交互比较重要。由于电视屏比较大,在一些复杂页面中会存在一级Tab选择,二级选择,三级选择等,这就涉及到了焦点与选中的联动实现业务逻辑。这块的逻辑比较复杂,在做好了一个页面后,把这块的内容记录一下,同时提炼出了一个辅助类,MultiLevelFocusHelper,后续可进行复用。


2. 基本使用:遥控器+焦点控制


2.1 使用原则


Android原生就能比较好的支持Focus及切换,使用时只要按照它本身的逻辑使用就好,如果碰到不能很好支撑业务的时候再进行扩展,如下是我们小组实践过后,总结出来的几项原则,实际效果很好:



  • 不进行过度控制,使用默认规则

  • 使用focusable、descendantFocusability把XML中的控件按照父控件统一管控,如必须下放时再进行子控件控制

  • nextFocusUp、nextFocusDown、nextFocusLeft、nextFocusRight、nextFocusForward这几个属性不要轻易使用,只要在需要定制的复杂页面才有可能用到


2.2 View中涉及到焦点的几个属性


属性使用 场景 说明
focusable物理按键时获得焦点的属性 android:focusable="false" android:focusable="true"
descendantFocusability该属性是当一个view获取焦点时,定义viewGr0up和其子控件两者之间的关系,属性的值有三种:- beforeDescendants:viewgroup会优先其子类控件而获取到焦点


  • afterDescendants:viewgroup只有当其子类控件不需要获取焦点时才获取焦点

  • blocksDescendants:viewgroup会覆盖子类控件而直接获得焦点 |
    | nextFocusUpnextFocusDownnextFocusLeftnextFocusRight | android:nextFocusUp-定义当点up键时,哪个控件将获得焦点android:nextFocusDown-定义当点down键时,哪个控件将获得焦点android:nextFocusLeft-定义当点left键时,哪个控件将获得焦点android:nextFocusRight--定义当点right键时,哪个控件将获得焦点 |
    | nextFocusForward | 我是谁,我有什么用??? |


2.3 如何使用



  1. XML中从顶到细,一层一层的看,如果此View及其子View不需要获得焦点,则直接把它的焦点屏蔽掉


android:focusable="false"
android:descendantFocusability="blocksDescendants"

2. 如果只有此ViewGr0up需要获得焦点,它的子View不需要,则设置如下


android:focusable="true"
android:descendantFocusability="blocksDescendants"

3. RecyclerView或ListView,根据需要,如果是简单的能自动处理的则只修改XML即可,否则可以XML+代码进行控制


// 1. 第一种情况:recyclerView的 xml 设置 recyclerView 不获得焦点,子控件获得焦点
android:focusable="false"
android:descendantFocusability="afterDescendants"

// recyclerView的item 布局中添加
android:focusable="true"
android:descendantFocusability="blocksDescendants"


// 2. 第二种情况:代码控制时, recyclerView先获得焦点,然后根据需要,再在它的OnFocusChangeListener中进行焦点转移
android:focusable="true"
android:descendantFocusability="beforeDescendants"

4. 至此,如果没有特殊的需求,只是简单的焦点控制,通过XML配置属性+Android强大的默认功能即可完成


3. 高级用法:增加层级


3.1 层级是什么? 为什么要有三态?


如图,感兴趣的往下看,一切尽在图中,祭镇楼图


image.png



  • 图中的设备列表与全屋节能信息构成了一级焦点,后边的节电数据范围是二级焦点,它俩是一个整体,这里暂且起名叫节能数据查看

  • 其中全屋节能信息是一个ViewGr0up,下边的设备列表是一个RecyclerView

  • 图中的帮助按钮是另一个可欺获得焦点的控件,与上边的节能数据查看是并列关系

  • 根据以上分析,得出:层级就是 完成同一个功能的多级多控件的可分别获得焦点的聚合体,特点如下:



    • 焦点可在多级中的多个控件中自由流转,同时只有一个控件具备焦点

    • 在同一级中,如果没有焦点,则需要有一个控件具备已选中状态,由此引出了三态:有焦点、无焦点选中、无焦点未选中

    • 焦点在多级流转时有一定的规则,大部分情况下是从一级流向另一级时,优先流到已选中的控件上

    • 多级具备方向性,比如1->2->3-4, 或 4->3->2->1, 在这个模型中,不可以跨级流转,如果后续有跨级流转的业务需求,再另说(产品经理不要搞太复杂呀...)




3.2 自定义的层级管理辅助类:MultiLevelFocusHelper


基于以上的层级焦点定义,我封装了一个辅助类,MultiLevelFocusHelper,可用于简化层级焦点的操作实现,它主要实现的功能有:



  • 当某一层级的控件获得焦点时,通过它可记录最新的有焦点控件,并同时设置其中选中状态

  • 设置当前层级有焦点的控件往下一级流转时的按键,并精准定位到下一级的选中控件上

  • 获得所有层级的当前控件对应的附加数据

  • 遵循了最小实现、不过渡设计的原则,当前只实现了两级,如果将来需要支持更多的级数,可扩展此类


代码如下:



class MultiLevelFocusHelper(private val totalLevel: Int) {
private var mCurLevel1View: View? = null
private var mCurLevel1ViewId: Int? = null
private var mCurLevel1Data: Any? = null

private var mCurLevel2View: View? = null
private var mCurLevel2ViewId: Int? = null
private var mCurLevel2Data: Any? = null

/**
* 某一个控件得到了焦点
* @param level: 得到焦点的控件的层级
* @param view: 得到焦点的控件
* @param viewId: 得到焦点的控件的Id,如果是recycleView,则设置它的父控件的Id,主要是为的是上下级Level切换
* @param extraData: 得到焦点的控件对应的附属数据,暂存一下,后续业务需要时可直接get
* @param nextLevelMoveDirect:
*/

fun receiveFocus(level: Int, view: View, viewId: Int, extraData: Any) {
if (level > totalLevel) return

when(level) {
1 -> {
if (mCurLevel1View != null) {
mCurLevel1View!!.isSelected = false
}
mCurLevel1View = view
mCurLevel1View!!.isSelected = true
mCurLevel1ViewId = viewId

mCurLevel1Data = extraData
}
2 -> {
if (mCurLevel2View != null) {
mCurLevel2View!!.isSelected = false
}
mCurLevel2View = view
mCurLevel2View!!.isSelected = true
mCurLevel2ViewId = viewId

mCurLevel2Data = extraData
}
else -> {
// nothing
}
}
}

/**
* 设置某一层级当前选中View的 nextFocusLeftId, nextFocusDownId 等
* @param moveDirect, 移动方向,用于决定设置当前级Level的哪个属性 nextFocusLeftId 等。 传入值:使用本类中定义的四个常量值,多个方向时可进行&运算再传进来
* @param moveCommander, 移动命令,是前进还是后退,用于确定要设置的Value是上一级还是下一步当前选中的View
* 为 null,忽略,如果是头尾的,只有一个方向,直接设就行。 如果是中间的则忽略不进行设置了。
*/

fun setDirectToCurrentView(level: Int, moveDirect: Int, moveCommander: MoveCommander? = null) {
if (level > totalLevel) return

when(level) {
1 -> {
// 第一层,只能往下移,不能回移
setNextMoveTarget(mCurLevel1View, moveDirect, mCurLevel2ViewId)
}
2 -> {
if (level < totalLevel) {
if (moveCommander != null) {
if (moveCommander == MoveCommander.forward) {
// TODO, 当 totalLevel 大于等于 3 的时候,加上这一个分支, 它应该往 3 去移动了
// setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel3ViewId)
} else {
setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel1ViewId)
}
}
} else {
// 这是最后一层, 只有一个方向
setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel1ViewId)
}
}
else -> {
// nothing
}
}
}

/**
* 所有控件失去焦点, 暂时应该没有场景调到它,如果有的话,需要考虑一下行为是否正确
*/

fun clearAllFocus() {
if (mCurLevel1View != null) {
mCurLevel1View!!.isSelected = false
}
mCurLevel1Data = null

if (mCurLevel2View != null) {
mCurLevel2View!!.isSelected = false
}
mCurLevel2Data = null
}

/**
* 获得某一层当前选中控件对应的 View
*/

fun getView(level: Int): View? {
if (level > totalLevel) return null

return when(level) {
1 -> {
mCurLevel1View
}

2 -> {
mCurLevel2View
}

else -> {
null
}
}
}

/**
* 获得某一层当前选中控件对应的数据
*/

fun getData(level: Int): Any? {
if (level > totalLevel) return null

return when(level) {
1 -> {
mCurLevel1Data
}

2 -> {
mCurLevel2Data
}

else -> {
null
}
}
}

private fun setNextMoveTarget(view: View?, direct: Int?, nextViewId: Int?) {
if (view == null || direct == null || nextViewId == null) {
return
}

if (direct and Direct_Up > 0) {
view.nextFocusUpId = nextViewId
}
if (direct and Direct_Right > 0) {
view.nextFocusRightId = nextViewId
}
if (direct and Direct_Down > 0) {
view.nextFocusDownId = nextViewId
view.nextFocusDownId
}
if (direct and Direct_Left > 0) {
view.nextFocusLeftId = nextViewId
}
}
}

3.3 MultiLevelFocusHelper要点说明



  1. 构造函数中的参数 totalLevel



    1. 总级数,从1开始的, 比如totalLevel为3, 则所有级别即为1,2,3

    2. 目前 totalLevel 最大为 2,超过2 按 2 计算



  2. 对外函数receiveFocus(level: Int, view: View, viewId: Int, extraData: Any)



    1. 当层级中的某一个控件获得焦点时调用此函数

    2. 参数说明



      1. *@ *param level: 得到焦点的控件的层级

      2. @param view: 得到焦点的控件

      3. @param viewId: 得到焦点的控件的Id,如果是recycleView,则设置它的父控件的Id,主要是为的是上下级Level切换

      4. @param extraData: 得到焦点的控件对应的附属数据,暂存一下,后续业务需要时可直接get



    3. 这里的 viewId 可以是 view 的Id,也可以不是, 基本用法是,如果是ListView或RecyclerView,则可以把viewId设置为 recyclerView 的Id,这样再在业务代码的 recyclerView 获得焦点事件中转一下即可



  3. 层级流转



    1. level 移动顺序: 目前是一个约定,不能自定义。 1->2->3->4, 或 4->3->2->1。 如果后续有不同需求,可以再进行扩充

    2. 两个概念:MoveCommander, MoveDirect:


      // 层级移动命令,向前进,还是后退,参考按照类说明了中的移动顺序
      enum class MoveCommander {
      forward,
      back
      }

      // 焦点移动方向,比如按了遥控器上的上下左右, 使用Int值表示, 多个方向时可以进行&运算
      val Direct_Up = 0x01
      val Direct_Right = 0x02
      val Direct_Down = 0x04
      val Direct_Left = 0x08


    3. 对外函数:setDirectToCurrentView(level: Int, moveDirect: Int, moveCommander: MoveCommander? = null)



      1. 设置某一层级当前选中View的 nextFocusLeftId, nextFocusDownId 等,当某一个控件获得焦点后,再马上调用此函数设置一下

      2. 参数说明



        1. moveDirect, 移动方向,用于决定设置当前级Level的哪个属性 nextFocusLeftId 等。 传入值:使用本类中定义的四个常量值,多个方向时可进行&运算再传进来

        2. moveCommander, 移动命令,是前进还是后退,用于确定要设置的Value是上一级还是下一步当前选中的View为 null,忽略,如果是头尾的,只有一个方向,直接设就行。 如果是中间的则忽略不进行设置了








4. 使用实例


这里附上全屋节能的使用示例,它结合了 MultiLevelFocusHelper,并在Activity中实现了业务关联的一部分代码


4.1 相关控件的XML设置



  1. 设置所有没有焦点的控件中的属性, focusable 和 descendantFocusability

  2. 有焦点的控件属性设置上, focusable 和 descendantFocusability

  3. recyclerView 设置为: android:focusable="true" android:descendantFocusability="beforeDescendants"


4.2 帮助按钮的Focus监听不必设置,使用系统默认的即可


4.3 初始化时,把默认的Focus给到 一级中的全屋信息


mMultiLevelFocusHelper.receiveFocus(1, mFullHouseSaveInfo, mFullHouseSaveInfo.id, "all") // 初始一化一下 mMultiLevelFocusChangeManager 中的状态
mMultiLevelFocusHelper.setDirectToCurrentView(1, MultiLevelFocusHelper.Direct_Right)
mMultiLevelFocusHelper.receiveFocus(2, mTextViewSaveElectricDurationLastMonth, mTextViewSaveElectricDurationLastMonth.id, ElectricIndexDateRange.LAST_MONTH)
mMultiLevelFocusHelper.setDirectToCurrentView(2, MultiLevelFocusHelper.Direct_Down)
mFullHouseSaveInfo.requestFocus()

4.4 RecyclerView 和 它的 item 设置 OnFocusChangeListener


mRecyclerViewDeviceDetailInfo.setOnFocusChangeListener(object : OnFocusChangeListener {
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v == null) return
if (!hasFocus) return
val view = mMultiLevelFocusHelper.getView(1)
val tag = view?.getTag() // 看它有没有存 tag 来判断它是不是 recyclerView 的 item
if (view == null || tag == null) {
// 没有上一次的View 或 上一次的第一层View 不是 recyclerView的 item 时
if (mRecyclerViewDeviceDetailInfo.getChildAt(0) != null) {
mRecyclerViewDeviceDetailInfo.getChildAt(0).requestFocus()
}
} else {
view.requestFocus()
}
}
})


// 这里的最后一个参数 OnFocusChangeListener, 内部又传给了 item, 当它有 FocusChange事件时,再转调用此参数实例
mAdapterDeviceDetailInfo = SaveEnergyAdapterDeviceDetailInfo(
mViewModal.getAllSavingDevice(),
mViewModal.getAllSavingDeviceRank(),
mViewModal.getAllSavingSwitchStatus(),
object: OnFocusChangeListener {
// 给 设备列表的 recycleview item 设置焦点移动回调
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v == null) {
return
}
if (!hasFocus) {
return
}
val deviceId = v.getTag()
mMultiLevelFocusHelper.receiveFocus(1, v, mRecyclerViewDeviceDetailInfo.id, deviceId)
mMultiLevelFocusHelper.setDirectToCurrentView(1, MultiLevelFocusHelper.Direct_Right)
initSavingElectricData()
}
})

这里啰嗦一下,RecyclerView拿到焦点时,把焦点转给它下边的之前具有焦点的控件;item中的view有一个tag,存的是业务数据(deviceId),当它拿到焦点时,取到此业务数据,传入到了 mMultiLevelFocusHelper 中


4.5 设置全屋信息 和 所有二级控件的 setOnFocusChangeListener,代码略


5. 总结



  1. 如果没有特殊的需求,只是简单的焦点控制,通过XML配置属性+Android强大的默认功能即可完成。

  2. 如果具有多个层级,焦点需要在多层级间进行流转并需要记忆功能,则可使用MultiLevelFocusHelper类,经过实践检验,可完美应用于此场景。


6. 团队介绍


三翼鸟数字化技术平台-场景设计交互平台」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。


作者:三翼鸟数字化技术团队
来源:juejin.cn/post/7442541343685148681
收起阅读 »

如何避免别人的SDK悄悄破坏你App的混淆规则,记一次APK体积优化

所谓的包体积优化,其实根本取决于,更早接触项目的前辈留下了多少机会。最近在重新配置项目的R8规则,有将近10%的优化空间。这里分享一下。 很多关于配置混淆规则的博客,教人随意添加-keep class * entends androidx.**或者-donto...
继续阅读 »

所谓的包体积优化,其实根本取决于,更早接触项目的前辈留下了多少机会。最近在重新配置项目的R8规则,有将近10%的优化空间。这里分享一下。


很多关于配置混淆规则的博客,教人随意添加-keep class * entends androidx.**或者-dontoptimize-dontshrink,甚至给了所谓的“常用万能混淆规则”,估计一些SDK开发者也干脆复制了他们的代码,然后影响到了依赖这些SDK的项目。


好在很容易编写gradle任务改掉SDK向APK贡献的混淆规则。本文AGP版本7.3.1




降低包体积 · 先优化我方代码


删掉dontoptimize


先改自己模块的缺点。


主要是自己模块的-dontoptimize直接删掉。包括proguard-android.txt改为proguard-android-optimize.txt,这两个文件的区别之一就是是否包含了-dontoptimize


buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}

通常去掉-dontoptimize之后,包体积就有明显降低了,如果你删了之后,包体积无任何变化,就说明没删干净,或者是第三方SDK依然在用它,下文继续处理。


多观察printconfiguration


可以在混淆规则里添加一个-printconfiguration 'configuration.txt'


然后打个minifyEnabled true的包,再用AS直接找到configuration.txt文件,这里就是项目和第三方SDK配置的所有混淆规则。


我们项目到这里就开始崩溃了,主要是GSON相关问题,查前辈的博客要警惕,因为有两种方案:


-dontoptimize
-dontshrink
# 然后就是各种keep...

以及:


-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken

前者更容易被搜到,但这种做法等于第一步白做了。


如果项目用的Gson库比较旧,按照后者去配。


如果用的是2.11及更高版本,其实也不会遇到这个崩溃问题了,因为它开始内置自己需要的混淆规则,无需我们配置。从configuration.txt就能看到:


# The proguard configuration file for the following section is /home/这里无所谓忽略/transformed/rules/lib/META-INF/proguard/gson.pro

# 太长了,这里忽略

# Keep class TypeToken (respectively its generic signature) if present
-if class com.google.gson.reflect.TypeToken
-keep,allowobfuscation class com.google.gson.reflect.TypeToken

# Keep any (anonymous) classes extending TypeToken
-keep,allowobfuscation class * extends com.google.gson.reflect.TypeToken

# Keep classes with @JsonAdapter annotation
-keep,allowobfuscation,allowoptimization @com.google.gson.annotations.JsonAdapter class *

# 太长了,这里忽略

# End of content from /home/这里无所谓忽略/transformed/rules/lib/META-INF/proguard/gson.pro

改掉不良习惯


比如我们这里竟然有-keep class * implements java.io.Serializable-keep class * extends java.io.Serializable,然后Serializable接口一直起到的就是@Keep的作用。后来又当需要往intent传入数据时,刚好发现某个类,正好啊,实现了Serializable接口,直接putSerializable···这也算是代码越来越劣化的因素之一了。


这个规则对包体积影响还是比较大的,因为Kotlin里面的一些lambda(比如by lazy { xxx })编译后生成的类也会间接实现Serializable。


还有就是毫无意义的封装该删掉了,比如如今时代竟然还有关于ActivityViewModelBaseXXX。原本现在的androidX各种库,只需要一行代码就可创建viewBinding、ViewModel实例,就别再冒着ViewBinding、ViewModel不能被混淆的缺陷,用反射泛型等“高级”技巧“封装”个几十行的BaseXXX...


剩下的一些优化就是根据业务逻辑去降低keep的范围就好了,主要是小心反射(包括JNI、属性动画)、序列化等少数特殊场景。


降低包体积 · 改变别人


上文提到,如果做了一些优化之后打包效果毫无变化,那就是第三方SDK有问题了。


可以添加这样的混淆规则:-whyareyoukeeping class 这里就写你发现没被混淆的类名,然后打包时就会输出哪个文件的哪一行规则keep了这个类。


某广告SDK配置了-keep class * entends androidx.**-keep class * implements androidx.**,我不敢推测它们到底在反射调用androidX的哪一部分,反正造成了我们各种ViewModel,ViewBinding等androidX子类没有被混淆、大约3~5%包体积的无用代码没有被R8移除。


SDK本来也不需要反射调用我们自己的业务代码。我需要把它改为-keep class !我们app的包名.**, * entends androidx.**


不要想着通过/home/这里无所谓忽略/transformed/...这个文件去修改第三方库的混淆配置,因为每次打包时,这个目录内容会重新生成。(已踩坑)


方法一:直接解压替换文件


找到不优雅的混淆规则后,如何修改?


如果是AAR文件,可以直接解压软件打开这个AAR,找到proguard.txt文件,替换进压缩包。


image.png


如果是JAR,这样:


image.png


找不到AAR文件,比如是用implementation依赖的库?随便进一个类,这样找:


image.png


image.png


这样就能找到implementation背后的jar或aar文件了,然后改为用文件依赖的方式。


如果有多个SDK配置了不优雅的规则怎么办?一个个找、一个个改显然比较麻烦,未来更新这些SDK的版本时还要再次修改,所以要探索一下能否通过gradle任务完成这件事。


方法二:编写gradle任务


通过这次,这是我第一次尝试给gradle插件下断点,真是降低了太多观察源码的成本,特此记录...


首先要能方便的在AndroidStudio中查看AGP源码,技巧:直接在app模块build.gradle依赖AGP。为了不影响编译,这里用compileOnly而不是implementation。


compileOnly 'com.android.tools.build:gradle:7.3.1'

然后就可以轻松找到R8相关任务类:


image.png


然后配置一个"Configuration"


image.png


image.png


image.png


端口号改一下,避免冲突就行,建议弄大一些,避免电脑对这方面有权限之类的限制。直接点OK就好了。


R8Task类只有几百行,很容易看到混淆相关的入口方法runR8,打上断点,然后这样让R8运行起来:./gradlew assembleRelease "-Dorg.gradle.debug=true" "-Dorg.gradle.debug.port=15000" --no-daemon。然后gradle就会等待我们附加上去才会继续运行,这时候就可以点Debug按钮了。


image.png


这样,我们需要用gradle任务控制哪个参数,一目了然,自己的各个模块、第三方SDK文件的混淆配置都在这了:


image.png


接下来寻找proguardConfigurationFiles的来源,这里分析过程略过,最终可以确定它来自于ProguardConfigurableTask这个task的成员configurationFiles。于是可以编写如下任务:


import com.android.build.gradle.internal.tasks.ProguardConfigurableTask

ConfigurableFileCollection cf;

def fixFoolishRules = tasks.register('fixFoolishRules') {
var iterator = cf.iterator()
while (iterator.hasNext()) {
var item = iterator.next()
if(item.absolutePath.contains("这里过滤一下需要修改的sdk文件名")){
var content = "# file: ${item.absolutePath}\n"
var foolish = "-keep public class * extends androidx.**\n"
var fixed = "-keep public class !自己业务逻辑包名.**, * extends androidx.**\n"
var newContent = item.getText().replace(foolish, fixed)
item.write(content.concat(newContent))
}
}
}

tasks.withType(ProguardConfigurableTask).configureEach { task ->
cf = ((ProguardConfigurableTask)task).configurationFiles
task.finalizedBy(fixFoolishRules)
}

这里有个无所谓的小问题:为什么不能在tasks.register('fixFoolishRules') {里面直接ProguardConfigurableTask.configurationFiles,而是要在tasks.withType(ProguardConfigurableTask)...{ 里面用这个额外的cf变量获取,否则会有如下报错,暂时没研究了。


Could not determine the dependencies of task ':app:minifyReleaseWithR8'.
> Could not create task ':app:fixFoolishRules'.
> No such property: configurationFiles for class: com.android.build.gradle.internal.tasks.ProguardConfigurableTask
Possible solutions: configurationFiles

再记录一个踩过的坑:ConfigurableFileCollection这个类本身继承了FileCollection接口,而这个接口继承了Iterable<File>。所以直接用它去遍历就好了。如果尝试去找它的files成员,进行删除和增加,反而没什么意义,因为每次调用它getFiles都是在生成一个新的Set对象。


不用担心直接修改这些文件,而不是替换configurationFiles集合。因为我上文也提到了,/home/这里无所谓忽略/transformed/rules/lib/META-INF/proguard/gson.pro这种文件每次编译时都会重新生成。


既要激进,又要保守


有一家SDK比较坑,它们的文档,以及AAR内置的混淆规则漏掉了某个包里面的类,但是,我估计他们开发环境一直配着-dontoptimize,导致他们不会触发这个问题。


还好,一初始化他们的SDK就崩溃了,很容易发现,也就没有带到线上。


但如果有什么SDK犯了类似错误,而且是那种开发阶段不会触发,后续通过热更新或者在线配置之类的触发,那就完蛋了。所以为了避免他们犯错,我主动解包在我们App启动期间就会初始化的SDK,把他们SDK内部代码特有的包名或者类统统全部添加-keep


另外就是准备做一个类似于微信频繁崩溃时会触发的“安全模式”(也好像是“修复模式”?忘了名字,以后有时间研究一下他们)。


如果App启动后,发现上次启动成功到进程结束未超过5秒,则先等待版本更新接口返回数据,再决定:是初始化第三方SDK并正常启动,还是弹出强制更新窗口。


(艺高人胆大,不要学...)




如果上文有错误或建议,请指出。


作者:k3x1n
来源:juejin.cn/post/7453809061906645011
收起阅读 »

Android 工位运动小助手

背景 在社会日益发展的今天,我们大部分人在工位上长时间的办公,从而忽略了站起来活动一下的必要性。时长活动一下有益于身心健康,同时也可以缓解工作的疲劳。基于以上情况,我开发了一个定时提醒用户运动的工具类app. 功能介绍 下面我们来具体看看,这个工具具体的功能吧...
继续阅读 »

背景


在社会日益发展的今天,我们大部分人在工位上长时间的办公,从而忽略了站起来活动一下的必要性。时长活动一下有益于身心健康,同时也可以缓解工作的疲劳。基于以上情况,我开发了一个定时提醒用户运动的工具类app.


功能介绍


下面我们来具体看看,这个工具具体的功能吧


Screenshot_20241025_144509.png
Screenshot_20241025_144529.png
Screenshot_20241025_153710.png
Screenshot_20241025_144544.png
Screenshot_20241023_124424.png
Screenshot_20241025_144447.png

第一张图开始设置任务的间隔时间,第二张图是任务准备执行,第三张图是任务已经在执行,第四张图是任务完成了第一次进入到下一次的周期任务。第五,第六张图显示的是通知提醒用户起来活动一下。这个工具可以让你 设置任意时间的周期,然后每n min 后就会提醒你该起来活动一下了。那么具体是怎么实现这个功能的呢?


实现方法


我们使用workManager构建一个周期性的任务,设置一个具体的时间间隔,通过service在需要的时候启动这个任务,就可以让这个任务运行,通过notification,从而提醒用户起来活动一下。


具体实现代码


package com.fly.heat.service

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.fly.heat.R
import com.fly.heat.constant.Config.STOP_SERVICE
import com.fly.heat.constant.Config.TASK_DEFAULT_TIME
import com.fly.heat.constant.Config.TASK_INTERVAL
import com.fly.heat.task.ActivityReminderWorker
import com.fly.heat.ui.RemindAc
import com.fly.heat.util.MMKVHelper
import java.util.concurrent.TimeUnit

class ForegroundService : Service() {

private lateinit var workManager: WorkManager


companion object {
const val NOTIFICATION_ID = 2
const val CHANNEL_ID = "ForegroundServiceChannel"
const val CHANNEL_NAME = "Foreground Service Channel"
const val DESCRIPTION = "Channel for Foreground Service"
}

override fun onCreate() {
super.onCreate()
createNotificationChannel()
workManager = WorkManager.getInstance(this)
}

@RequiresApi(Build.VERSION_CODES.Q)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == STOP_SERVICE) {
stopServiceAndCancelTasks()
return START_NOT_STICKY
}
startForegroundService()
scheduleReminder()
return START_STICKY
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = DESCRIPTION
}

val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}

@RequiresApi(Build.VERSION_CODES.Q)
private fun startForegroundService() {
val notificationIntent = Intent(this, RemindAc::class.java)
var pendingIntent: PendingIntent? = null
pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(this.getString(R.string.app_name))
.setContentText(
getString(R.string.remind_content,MMKVHelper.getInstance().getLong(TASK_INTERVAL,TASK_DEFAULT_TIME))
)
.setSmallIcon(R.mipmap.logo)
.setContentIntent(pendingIntent)
.build()

startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
}


//停止这个服务并取消所有工作请求
private fun stopServiceAndCancelTasks() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
removeNotification()
}


private fun removeNotification() {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll()
}

override fun onDestroy() {
super.onDestroy()
// 取消所有工作请求
workManager.cancelAllWork()
}

private fun scheduleReminder() {
val taskInterval = MMKVHelper.getInstance().getLong(TASK_INTERVAL, TASK_DEFAULT_TIME)

val inputData = Data.Builder()
.putString("message", getString(R.string.please_stand_up))
.build()

val periodicWorkRequest = PeriodicWorkRequestBuilder<ActivityReminderWorker>(
taskInterval, TimeUnit.MINUTES
).setInputData(inputData)
.build()

workManager.enqueue(periodicWorkRequest)
}

override fun onBind(intent: Intent?): IBinder? {
return null
}
}

这个ForegroundService是用来启动前台通知的,同时让服务运行,便于任务在后台运行时间变长

package com.fly.heat.task

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.text.format.DateFormat
import android.util.Log
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.fly.heat.R
import com.fly.heat.ui.RemindAc
import com.fly.heat.mi_step.StepUtil
import java.util.Calendar

class ActivityReminderWorker(context: Context, private var workerParams: WorkerParameters) : Worker(context, workerParams) {


companion object {
var notificationId = 1
var channelId = "activity_reminder_channel"
var chanelName = "Activity Reminder Channel"
}

override fun doWork(): Result {
val message = workerParams.inputData.getString("message") ?: "默认消息"
Log.d("ActivityReminderWorker", "doWork: $message")
// 执行提醒逻辑
showCustomNotification()
return Result.success()
}

private fun showStep(remoteViews: RemoteViews){
remoteViews.setTextViewText(R.id.step, "步数:${StepUtil.getTodayStepsCount(applicationContext)}")
}

private fun showTime(remoteViews: RemoteViews) {
val currentTime = Calendar.getInstance().time
val formattedTime = DateFormat.format("HH:mm:ss", currentTime).toString()
remoteViews.setTextViewText(R.id.time, formattedTime)
}
private fun showCustomNotification() {
val notificationLayout = RemoteViews(applicationContext.packageName, R.layout.notification_small)
val notificationLayoutExpanded = RemoteViews(applicationContext.packageName, R.layout.notification_large)
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
showTime(notificationLayout)
showStep(notificationLayout)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
chanelName,
NotificationManager.IMPORTANCE_HIGH
).apply {
// 设置通道的默认声音
val soundUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
setSound(soundUri, null)
// 设置震动模式
enableVibration(false)
vibrationPattern = longArrayOf(0, 1000, 500, 1000) // 震动模式:0ms延迟,1000ms震动,5
}
notificationManager.createNotificationChannel(channel)
}

val intent = Intent(applicationContext, RemindAc::class.java)
val pendingIntent = PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

val notification = NotificationCompat.Builder(applicationContext, channelId)
// .setContentTitle("活动提醒")
// .setContentText("起来活动一下吧!")
.setSmallIcon(R.mipmap.sport)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
// .setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(notificationLayout)
// .setCustomBigContentView(notificationLayoutExpanded)
.build()

notificationManager.notify(notificationId, notification)
}
}

这个ActivityReminderWorker是wokeManager具体的任务操作,这里显示一个notification来提醒用户。

package com.fly.heat.ui

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.Chronometer
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.example.customprogressview.ProgressCircleView
import com.fly.heat.service.ForegroundService
import com.fly.heat.R
import com.fly.heat.constant.Config
import com.fly.heat.constant.Config.STOP_SERVICE
import com.fly.heat.constant.TaskStatus
import com.fly.heat.util.MMKVHelper


class RemindAc : AppCompatActivity() {
private lateinit var chronometer: Chronometer //任务倒计时
private var countDownTimer: CountDownTimer? = null
private var btnStart: Button? = null //执行按钮
private var taskStatus = TaskStatus.READY//记录服务是否正在运行
private var tvStatus: TextView? = null //显示任务状态
private lateinit var waterView: ProgressCircleView //动画的进度显示条
private val handler = Handler(Looper.getMainLooper())
private var currentProgress = 0 //动画的当前进度

private var taskInterval = Config.TASK_DEFAULT_TIME //任务时间间隔


init {
taskInterval = MMKVHelper.getInstance().getLong(
Config.TASK_INTERVAL,
Config.TASK_DEFAULT_TIME
)
}
companion object {
fun start(context: Context) {
val intent = Intent(context, RemindAc::class.java)
context.startActivity(intent)
}
}

private lateinit var animalRunnable:Runnable;
//开始动画
private fun startProgressAnimation(duration:Long) {
val delayTime = 600*duration//延时时间 ms
animalRunnable = object : Runnable {
override fun run() {
if (currentProgress < 100) {
currentProgress += 1
waterView.setProgress(currentProgress)
handler.postDelayed(this, delayTime)
}else {
resetProgressAnimation()//重置动画
}
}
}
handler.postDelayed(animalRunnable,delayTime)
}


//停止进度动画
private fun stopProgressAnimation(){
handler.removeCallbacks(animalRunnable)
currentProgress = 0
waterView.setProgress(currentProgress)
}

//重置进度动画
private fun resetProgressAnimation() {
stopProgressAnimation()
startProgressAnimation(taskInterval)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.ac_remind)
chronometer = findViewById(R.id.chronometer)
btnStart = findViewById(R.id.btn_remind)
tvStatus = findViewById(R.id.tv_task_status)
btnStart?.setOnClickListener {
if(taskStatus == TaskStatus.READY){
startTask()
}else if(taskStatus == TaskStatus.RUNNING){
finishTask()
}

}
waterView = findViewById(R.id.waterView)
}


//开始任务
private fun startTask() {
if (taskStatus == TaskStatus.RUNNING) {
Toast.makeText(this, getString(R.string.task_running), Toast.LENGTH_SHORT).show()
return
}else if(taskStatus == TaskStatus.READY){
taskStatus = TaskStatus.RUNNING
tvStatus?.text = getString(R.string.task_running)
startForegroundService();//开启前台任务
startCountdown()//开始倒计时
runningButton()//运行按钮可用
startProgressAnimation(taskInterval)//开始动画
}

}

//启动前台服务
private fun startForegroundService() {
val intent = Intent(this, ForegroundService::class.java)
startService(intent)
}


//结束服务并且运行任务
private fun stopForegroundService() {
val intent = Intent(this, ForegroundService::class.java).apply {
action = STOP_SERVICE
}
startService(intent)
}






private fun finishTask() {
if (taskStatus == TaskStatus.READY) {
Toast.makeText(this, getString(R.string.task_not_running), Toast.LENGTH_SHORT).show()
return
}else if(taskStatus == TaskStatus.RUNNING){
taskStatus = TaskStatus.READY
Toast.makeText(this, getString(R.string.task_finish), Toast.LENGTH_SHORT).show()
stopCountDown();//结束定时器
stopForegroundService()//结束服务
tvStatus?.text = getString(R.string.task_finish)
readyButton()//重置按钮
stopProgressAnimation()//停止进度动画
}

}

//开始任务不可用
private fun readyButton() {
btnStart?.apply {
isEnabled = true
isClickable = true
background = ContextCompat.getDrawable(context, R.drawable.round_red)
text = getString(R.string.start_task)
}
}

//开始任务可用
private fun runningButton() {
btnStart?.apply {
isEnabled = true
isClickable = true
background = ContextCompat.getDrawable(context, R.drawable.round_green)
text = getString(R.string.finish_task)
}
}


//停止计时器
private fun stopCountDown(){
chronometer.stop()
countDownTimer?.cancel()
chronometer.text = getString(R.string.start_time)
}


//开始倒计时
private fun startCountdown() {
// 15分钟倒计时,单位为毫秒
val duration = taskInterval * 60 * 1000L

countDownTimer = object : CountDownTimer(duration, 1000) {
override fun onTick(millisUntilFinished: Long) {
val minutes = millisUntilFinished / 1000 / 60
val seconds = (millisUntilFinished / 1000) % 60
chronometer.text = String.format("d:d", minutes, seconds)
}

override fun onFinish() {
chronometer.text = getString(R.string.start_time)
startCountdown()//结束后重新开始倒计时
resetProgressAnimation()//重置动画
}
}.start()
}

override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
}



}

这个RemindAc对应的是任务运行的app界面,这里会绘制ui,执行按钮的响应事件,开启任务执行的进度的动画,让用户清晰的看到自己任务的执行情况。比如我的时间周期是30min,那么用户从用户开始任务后,每隔30min就可以收到提醒,这就可以让我们知道需要起来活动一下了。假如你想终止任务,那么只需要结束任务哭就可以终止任务了。

package com.fly.heat.ui

import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.NumberPicker
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.customprogressview.ProgressCircleView
import com.fly.heat.R
import com.fly.heat.constant.Config.TASK_INTERVAL
import com.fly.heat.util.MMKVHelper

class TaskSettingAc : AppCompatActivity() {
private var btn_sumbit: Button? = null
private var numberPick: NumberPicker? = null
private var tv_time: TextView? = null
private var time: Long = 0


companion object {
const val MIN = 15
const val MAX = 120
}

private fun unableButton() {
btn_sumbit?.isEnabled = false
}

private fun enableButton() {
btn_sumbit?.isEnabled = true
}


private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
"android.permission.POST_NOTIFICATIONS"
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf("android.permission.POST_NOTIFICATIONS"),
1
)
unableButton()
} else {
enableButton()
}
}
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
)
{
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
runOnUiThread {
// 用户授予了通知权限
Toast.makeText(this, "已获得通知权限", Toast.LENGTH_SHORT).show()
enableButton()
}

} else {
runOnUiThread {
// 用户拒绝了通知权限
Toast.makeText(this, "用户拒绝了通知权限", Toast.LENGTH_SHORT).show()
unableButton()
}
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.ac_task_setting)
initView()
requestNotificationPermission()
}



private fun initView() {
tv_time = findViewById(R.id.tv_time)
numberPick = findViewById(R.id.picker)
btn_sumbit = findViewById(R.id.submit)
initNumberPicker()
}

private fun initNumberPicker() {
numberPick?.apply {
minValue = MIN
maxValue = MAX
wrapSelectorWheel = false
setOnValueChangedListener { _, _, newVal ->
time = newVal.toLong()
tv_time?.text = String.format(getString(R.string.your_choose), time)
}
}
}


fun submit(view: View) {
if (time < 15) {
Toast.makeText(this, getString(R.string.choose_time), Toast.LENGTH_SHORT).show()
return
} else {
MMKVHelper.getInstance().putLong(TASK_INTERVAL, time)
RemindAc.start(this)
}
}


}

这个TaskSettingAc是用来设置任务的执行时间的,这就可以很灵活的控制自己需要执行任务的时间。

最后总结

技术层面采用kotlin+notification+service+workManager的方式


生活层面提醒在办公室坐着的我们,每隔一段时间需要起来活动一下,有益于我们的身体健康


作者:生如夏花爱学习22966
来源:juejin.cn/post/7429606384704995382
收起阅读 »

Android 动效方案探索

前言我们知道在 Android 中实现动画效果,可以通过补间动画、帧动画和属性动画。对于一些简单的动画效果,用上述方式实现没啥问题。但是对于复杂的动画,无论从动态效果展示和动画资源大小,还是支持动态更新,上述三种方式都无法完全满足这种需求。这时候就需要重新考虑...
继续阅读 »

前言

我们知道在 Android 中实现动画效果,可以通过补间动画、帧动画和属性动画。对于一些简单的动画效果,用上述方式实现没啥问题。但是对于复杂的动画,无论从动态效果展示和动画资源大小,还是支持动态更新,上述三种方式都无法完全满足这种需求。这时候就需要重新考虑实现方式了,下面介绍两种市面上比较常见的动效播放 SDK,主要从如何接入和 UI 动效两方面进行介绍。

开始

PAG

PAG(Portable Animated Graphics)是腾讯出品的一套完整动效解决方案,目标是降低或消除动效相关的研发成本,能够一键将设计师在 AE(Adobe After Effects)中制作的动效内容导出成素材文件,并快速上线应用于几乎所有的主流平台。

其中提供社区版和企业版版本供大家选择,其中企业版又提供大杯、中杯、小杯三种选择。社区版只提供基础能力,支持 2D 效果的动效展示。社区版同时支持视频和音频播放、3D 动效的展示,并且支持在线动效资源动态替换。

PAG 优势

高效的动效文件

  • PAG 动效文件采用了二进制的数据结构来存储AE动效信息,这使得它能够非常方便地单文件集成任何资源,如位图、音频、视频资源等,实现单文件交付。
  • 二进制数据结构不需要像 JSON 一样处理字符串匹配问题,解码速度可以快 90% 以上。
  • 在压缩率方面,相比 JSON,二进制数据结构可以跳过 Key 的内容,只存储 Value,这样能节省大量空间。
  • 经过一系列的压缩策略,导出相同的AE动效内容,PAG 在文件解码速度和压缩率上均大幅领先于同类型方案。

广泛的平台支持

  • PAG 支持 Android、iOS、Web、macOS、Windows、Linux 和微信小程序等平台,为开发者提供了跨平台的一致性体验。

高性能的渲染

  • PAG 的渲染主体通过跨平台的 C++ 来实现,所有平台均一致开启 GPU 硬件加速,确保各平台测的渲染一致性。
  • 高效的动效文件和优化的渲染引擎使得 PAG 在性能上表现出色,能够轻松应对复杂场景下的动效渲染需求。

丰富的应用场景

  • PAG 可以应用于照片模板、视频模板、智能剪辑等多种场景,满足设计师和开发者在不同业务场景下的需求。

PAG 集成

aar 集成

  1. 将 libpag 的 aar 文件放置在 android 工程项目的 libs 目录下。
  2. 添加 aar 库依赖,在 app 的 gradle 文件 app/build.gradle,添加 libpag 的库依赖。
    android {
repositories {
flatDir {
dirs 'libs'
}
}

dependencies {
//libpag 的核心库
//将 libpag_enterprise_4.2.41_android_armeabi_armv7a_arm64v8a.aar 换成你下载的 aar 文件名
implementation(name: 'libpag_enterprise_4.2.41_android_armeabi_armv7a_arm64v8a.aar', ext: 'aar')
implementation("androidx.exifinterface:exifinterface:1.3.3")
}

注意:  需要在混淆列表里面,添加 libpag 的 keep 规则:

    -keep class org.libpag.** {*;}
-keep class androidx.exifinterface.** {*;}

配置完以后,sync 一下,再编译。

Maven 集成

这里介绍一下,PAG 一共提供六个版本(以4.2.41版本为例):

企业基础版本:com.tencent.tav:libpag-enterprise:4.2.41,不包含 Movie 模块,不支持多字节 emoji,包含素材加密和 3D 图层能力。

企业 movie 版本:com.tencent.tav:libpag-enterprise:4.2.41-movie,包含音频播放、素材加密、占位图一键替换视频、导出视频文件和 3D 图层以及多字节 emoji 的能力。

企业 noffavc 版本:com.tencent.tav:libpag-enterprise:4.2.41-noffavc,不包含 Movie 模块和多字节 emoji 能力、内部不包含软件解码器,支持解码器外部注入。

社区基础版本 com.tencent.tav:libpag:4.2.41 不支持多字节 emoji,包含 PAG 的基础能力。

社区 harfbuzz 版本 com.tencent.tav:libpag:4.2.41-harfbuzz 支持多字节 emoji 的能力。

社区 noffavc 版本 com.tencent.tav:libpag:4.2.41-noffavc 不支持多字节 emoji,内部不包含软件解码器,支持解码器外部注入。

  1. 在 root 工程目录下面修改 build.gradle 文件,增加mavenCentral()
buildscript {

repositories {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
}
}

  1. 在 app 的 gradle 文件 app/build.gradle,添加 libpag 的库依赖
    dependencies {
//基础版本,如需保持最新版本,可以使用 latest.release 指代
implementation 'com.tencent.tav:libpag:latest.release'
}

注意:  需要在混淆列表里面,添加 libpag 的 keep 规则:

    -keep class org.libpag.** {*;}
-keep class androidx.exifinterface.** {*;}

配置完以后,sync 一下,再编译。

示例

代码实现

在 XML 中引入 PAGImageView,然后在代码中设置动画资源并开启播放。

libpag.PAGImageView
android:id="@+id/pagImageView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
class PAGAnimActivity:AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_pag_anim)
val pagIv = findViewById<PAGImageView>(R.id.pagImageView)
//设置资源路径
pagIv.setPath("assets://data_video.pag")
//设置重复播放次数
pagIv.setRepeatCount(Int.MAX_VALUE)
//开启播放
pagIv.play()
}
}

UI 动效

output3.gif

Lottie

Lottie 是 Airbnb 开源的一套跨平台的完整动画效果解决方案,是一种基于 JSON 的动画文件格式,可以在任意平台进行动画播放。在不同的设备上,可以放大或缩小而不会出现像素化。在多个平台上无缝运行,大大节省了开发资源。

Lottie 优势

文件小

与 GIF 或 MP4 等其他格式相比,Lottie 动画更小,但质量保持不变。

无限可扩展

Lottie 动画基于矢量,这意味着您可以放大或缩小它们而不必担心分辨率。

多平台支持和库

对于所有开发人员来说,Lottie 的交付非常简单。您可以在 iOS、Android、Web 和 React Native 上使用 Lottie 动画,无需修改。

交互性

在 Lottie 动画中,动画元素是公开的,因此您可以操纵它们进行交互并响应滚动、点击和悬停等交互。在交互指南中了解更多信息。

Lottie 集成

配置 Gradle

dependencies {
implementation "com.airbnb.android:lottie:$lottieVersion"
}

目前最新的版本是 6.6.2,如需获取最新版本请戳这里

示例

下面用两种实现方式演示 Lottie 播放动画的效果。

Kotlin 实现

首先用代码的方式实现,主要方法也进行了注释。

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val lottieView = findViewById<LottieAnimationView>(R.id.lottieView)
//设置动画资源,资源放在assets目录下,注意这里只设置资源名称即可
lottieView.setAnimation("anim2.json")
//设置动画重复播放次数
lottieView.repeatCount = Int.MAX_VALUE
//播放动画
lottieView.playAnimation()
}

}

XML 实现

首先我们在 XML 布局中引入 LottieAnimationView,通过 lottie_fileName 设置资源文件,并设置无限轮询播放和自动开启播放。

airbnb.lottie.LottieAnimationView
android:id="@+id/lottieView"
android:layout_width="0dp"
android:layout_height="0dp"
app:lottie_fileName="anim2.json"
app:lottie_loop="true"
app:lottie_autoPlay="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

UI 动效

播放的效果如下图所示,动画播放流畅度还是比较丝滑的。

output.gif

Lottie 详细介绍

动画资源

Lottie 支持以下来源的动画,能满足产品需求。

  • src/main/res/raw目录下,json 格式的动画资源文件。
  • src/main/assets目录中,json/zip/[dotLottie] 格式的动画资源文件。
  • 来源于 url/InputStream 的 json 或 zip 动画资源文件 。
  • JSON 字符串,来源方式不限。

动画缓存

Lottie 同样也支持动画缓存,通过 LruCache 来实现,支持最大缓存数是20。可以通过 setCacheComposition(boolean cacheComposition) 方法来决定是否开启预缓存。

全局配置

Lottie 支持全局配置,如有以下需求可以进行单独配置,放在Application进行初始化:

  • 从网络加载动画时,使用自定义网络请求框架。
  • 从网络获取的动画使用自定义缓存目录,摒弃原有的 Lottie 的默认目录 ( cacheDir/lottie_network_cache)。
  • 启用 Systrace 标记以进行调试。
  • 自定义网络框架缓存策略,需要关闭 Lottie 的网络缓存。
Lottie.initialize(
LottieConfig.Builder()
.setNetworkFetcher(...)
.setEnableSystraceMarkers(true)
.setNetworkCacheDir(...)
.setEnableNetworkCache(false)
)

动画监听器

Lottie 支持多种动画播放状态的监听,记得注册和解注册成对出现。

lottieView.addAnimatorListener()
lottieView.addAnimatorPauseListener()
lottieView.addAnimatorUpdateListener()

自定义动画效果

通过 Lottie 实现动画基本上满足我们大部分场景需求,当然要是有特殊要求,Lottie 也支持自定义动画效果,下面示例是对动画透明度进行单独设置。

val animator = ValueAnimator.ofFloat(0f, 1f)
animator.addUpdateListener {
lottieView.alpha = animator.animatedValue as Float
}
animator.duration = 3000
animator.start()

Lottie 对 APK 大小有什么影响

非常小:

  • 约 1600 种方法。
  • 未压缩时为 287kb。

Lottie 的优点

  • 支持更多 After Effects 功能。请参阅支持的功能以获取完整列表。
  • 手动设置进度以将动画连接到手势、事件等。
  • 支持网络下载动画资源。
  • 可以动态改变播放速度。
  • 图像支持抗锯齿。
  • 动态改变动画特定部分的颜色

Lottie 的缺点

Lottie 是为矢量形状而设计的,虽然 Lottie 支持渲染图像,但使用它们也有一些缺点:

  • 相同的动画效果,Lottie 使用的文件大小要比等效的矢量动画要大一个数量级。
  • 当 Lottie 缩放时,动画会变得像素化。
  • 用 Lottie 增加了动画的复杂性,动画资源不仅仅是一个文件,而是 json 文件加上所有图像。

DotLottie

DotLottie是一个新的 Lottie 播放器,依靠 ThorVG 进行渲染,其通过新的 dotLottie Runtimes 实现跨平台支持,拥有更快的加载速度,同时还能保证不同平台的动画一致性和高性能的表现。

DotLottie 优势

  • 动画文件小:高达 80% 动画压缩,且在放大或缩小而不会出现像素化。
  • 自适应主题:支持昼夜主题模式,或者自定义模式
  • 支持动画资源包:资源包中的 dotLottie 文件中包含多个动画,简化动画的管理和部署。
  • 高性能:dotLottie 图形处理由高性能图形引擎 ThorVG 提供支持,支持比普通 JSON 小 80% 的 dotLottie 格式。

DotLottie 集成

配置 Gradle

repositories {
maven(url = "https://jitpack.io")
}
dependencies {
implementation("com.github.LottieFiles:dotlottie-android:0.5.0")
}

示例

下面用两种实现方式演示 DotLottie 播放动画的效果。

Kotlin 实现

首先在 XML 布局中引入 DotLottieAnimation,然后在代码里面配置相应的 Config,这里 Config 是必须要配置的,否则无法正常播放动画。

lottiefiles.dotlottie.core.widget.DotLottieAnimation
android:id="@+id/dotLottieView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
class DotLottieAnimActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_dot_lottie_anim)
val dotLottieView = findViewById<DotLottieAnimation>(R.id.dotLottieView)
val dotConfig = Config.Builder()
.autoplay(true)
.speed(1f)
.loop(true)
// 本地资源,支持.json或.lottie两种格式
// .source(DotLottieSource.Asset("anim.lottie"))
//在线资源
.source(DotLottieSource.Url("https://lottie.host/5525262b-4e57-4f0a-8103-cfdaa7c8969e/VCYIkooYX8.json"))
.playMode(Mode.FORWARD)
.useFrameInterpolation(true)
.build()
dotLottieView.load(dotConfig)
dotLottieView.play()
}
}

UI 动效

output1.gif

Compose 实现

用 Compose 实现相对来说简单许多,只需设置对应的资源文件和播放参数。

@Composable
fun AnimDotLottieView() {
DotLottieAnimation(
source = DotLottieSource.Asset("bicycle.lottie"),
autoplay = true,
loop = true,
speed = 1f,
useFrameInterpolation = true,
playMode = com.dotlottie.dlplayer.Mode.FORWARD
)
}

UI 动效

output2.gif

总结

  • Lottie/DotLottie:适用于需要在多种平台上实现一致动画效果的应用场景,采用 JSON 或 Lottie 文件格式。在Android上通过Canvas绘制,并且支持动态更新动画内容。由于其轻量级和高效渲染的特点,即使在低端设备上也能保持流畅的动画效果。
  • PAG:广泛应用于腾讯等公司的产品中,涵盖 UI 动画、贴纸动画、照片/视频模板等场景。采用 PAG 二进制文件格式,采用动态比特位压缩技术,所以文件体积小。渲染方式各端共享一套 C++ 实现,平台端只做接口封装,并且支持动态更新动画内容

作者:码上搬砖
来源:juejin.cn/post/7452547398670319653

收起阅读 »

Android 实现微信读书划线的效果

最近遇到过一个实现类似微信读书的划线效果的需求。如下图所示,可以看到,微信读书划线支持涂抹、直线以及波浪线三种效果。 对于涂抹效果可以使用 BackgroundColorSpan实现,代码示例如下: val content = SpannableString...
继续阅读 »

最近遇到过一个实现类似微信读书的划线效果的需求。如下图所示,可以看到,微信读书划线支持涂抹、直线以及波浪线三种效果。


a0802da38d503daac59b98999452dcd.jpg


对于涂抹效果可以使用 BackgroundColorSpan实现,代码示例如下:


val content = SpannableStringBuilder(textView.text)  
content.setSpan(BackgroundColorSpan(Color.RED), 0, content.length / 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
textView.text = content

效果如下图所示:


image.png


对于直线划线的效果则可以通过 UnderlineSpan 来实现,代码如下所示:


val content = SpannableStringBuilder(textView.text)  
content.setSpan(UnderlineSpan(), 0, content.length / 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
textView.text = content

效果如下图所示:


image.png


如果你需要设置下划线的颜色和粗细,则需要自定义 UnderlineSpan,代码示例如下:


class CustomUnderLine(val color: Int, val underlineThickness: Float): UnderlineSpan() {

@RequiresApi(Build.VERSION_CODES.Q)
override fun updateDrawState(ds: TextPaint) {
ds.underlineColor = color // 下划线的颜色
ds.underlineThickness = underlineThickness // 下划线的粗细
super.updateDrawState(ds)
}

}

效果如下所示:


image.png


但是对于绘制波浪线,Android 没有没有提供直接的接口来实现。这时我们可以通过 LineBackgroundSpan 来间接实现波浪线的效果。


class Standard implements LineBackgroundSpan, ParcelableSpan {
// 存储背景颜色的变量
private final int mColor;

// 构造方法,接受一个颜色整数值作为参数,用于定义背景颜色
public Standard(@ColorInt int color) {
mColor = color;
}

// 从包裹中创建 LineBackgroundSpan.Standard 对象的构造方法
public Standard(@NonNull Parcel src) {
mColor = src.readInt();
}

@Override
public int getSpanTypeId() {
return getSpanTypeIdInternal();
}

/** @hide */
@Override
public int getSpanTypeIdInternal() {
return TextUtils.LINE_BACKGROUND_SPAN;
}

@Override
public int describeContents() {
return 0;
}

@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
writeToParcelInternal(dest, flags);
}

/** @hide */
@Override
public void writeToParcelInternal(@NonNull Parcel dest, int flags) {
dest.writeInt(mColor);
}

/**
* 获取该 span 的颜色
* @return 颜色整数值
*/

@ColorInt
public final int getColor() {
return mColor;
}

// 绘制背景的方法,在画布上绘制指定颜色的矩形作为行背景
// left:该行相对于输入画布的左边界位置,以像素为单位。
// right:该行相对于输入画布的右边界位置,以像素为单位。
// top:该行相对于输入画布的上边界位置,以像素为单位。
// baseline:该行文本的基线相对于输入画布的位置,以像素为单位。
// bottom:该行相对于输入画布的下边界位置,以像素为单位。
// text:当前的文本内容。
// start:该行文本在整个文本中的起始字符索引。
// end:该行文本在整个文本中的结束字符索引。
// lineNumber:在当前文本布局中的行号。
@Override
public void drawBackground(@NonNull Canvas canvas, @NonNull Paint paint,
@Px int left, @Px int right,
@Px int top, @Px int baseline, @Px int bottom,
@NonNull CharSequence text,
int start,
int end,
int lineNumber) {

final int originColor = paint.getColor();
paint.setColor(mColor);
canvas.drawRect(left, top, right, bottom, paint);
paint.setColor(originColor);
}
}

如上的源码所示,LineBackgroundSpan 主要用于改变文本中的行的背景。LineBackgroundSpan 有一个实现LineBackgroundSpan.Standard,作用和 BackgroundColorSpan 都是改变文本的背景颜色,区别是LineBackgroundSpan 主要是用于改变文本中某一行或者某几行的背景。它在绘制背景时,考虑的是行的位置信息,如行的左右边界(leftright)、顶部和底部位置(topbottom)。简单说就是 LineBackgroundSpan 提供了更多行的信息,方便我们做更细致的处理。


代码示例如下:


class WaveLineBackgroundSpan(val waveColor: Int) : LineBackgroundSpan {

// 创建画笔用于绘制波浪线,初始化时设置颜色、样式和线宽
val wavePaint = Paint().apply {
color = waveColor
style = Paint.Style.STROKE
strokeWidth = 6f
}

override fun drawBackground(
canvas: Canvas, paint: Paint,
@Px left: Int, @Px right: Int,
@Px top: Int, @Px baseline: Int, @Px bottom: Int,
text: CharSequence, start: Int, end: Int,
lineNumber: Int
)
{
// 定义波浪线的振幅和波长,振幅决定波浪的高度,波长决定波浪的周期
val amplitude = 5
val wavelength = 15

// 获取要绘制波浪线的文本宽度
val width = paint.measureText(text.subSequence(start, end).toString()).toInt()

// 遍历文本宽度范围内的每个点,计算并绘制波浪线上的点
for (x in left until (left + width)) {
// 根据正弦函数计算每个点的 y 坐标,实现波浪效果
val y = (amplitude * Math.sin((x.toFloat() / wavelength).toDouble())).toInt()
// 在画布上绘制波浪线上的点,确保 x 坐标不超过右边界
canvas.drawPoint(x.toFloat().coerceAtMost(right.toFloat()), (bottom + y).toFloat(), wavePaint)
}
}
}

效果如下图所示:


image.png


参考



作者:小墙程序员
来源:juejin.cn/post/7429738006230630434
收起阅读 »

车载Android开发的秘密--搞懂CAN通信

全文五千字,码字不易求个赞赞 我以前写了一篇搞懂串口通信,一经发出,就获得好多人观看收藏和点赞。最近工作用到了CAN通信,我就把CAN通信总结一下。 学习CAN通信之前,我在搜索学习资料的时候,大部分都介绍CAN的历史,等等,什么车载应用估计是培训机构的文章...
继续阅读 »

全文五千字,码字不易求个赞赞



我以前写了一篇搞懂串口通信,一经发出,就获得好多人观看收藏和点赞。最近工作用到了CAN通信,我就把CAN通信总结一下。
学习CAN通信之前,我在搜索学习资料的时候,大部分都介绍CAN的历史,等等,什么车载应用估计是培训机构的文章,读完感觉没啥用。写代码和硬件沟通还是无从下手。我先讲通信原理,再讲协议。



1、CAN简介


CAN总线(Controller Area Network Bus)控制器局域网总线
CAN总线是构建的一种局域网网络。每个挂载在CAN总线的设备,都可以利用这个局域网去发送自己的消息,也可以接收局域网的各种消息。每个设备都是平等的,都在共享这个局域网的通信资源。这个就是CAN总线的设计理念。


CAN总线是由BOSCH公司开发的一种简介易用,传输速度快,易扩展,可靠性高的串行通信总线,广泛应用于汽车,嵌入式,工业控制等领域。CAN开始之初是为了汽车领域而研究的,对其可靠性和稳定性要求都是非常高的。
CAN总线特征



  • 两根通信线(CAN_Hight,CAN_Low)线路少无需共地只需两根线

  • 差分信号通信,差分信号的特点。抗干扰能力强。线路如果产生干扰,一般两根线都会受到干扰。但是两根线的电压差值是不变的。所以差分信号会极大的避免干扰

  • 高速CAN(ISO11898):125K-1Mbps <40m

  • 低速CAN(ISO11519):10k-125kbps <1km

  • 异步,无需时钟线,通信速率由设备各自约定

  • 半双工,可挂载多设备,多设备同时发送数据时,通过仲裁决定发送顺序

  • 11位(标准格式)/29位报文ID(扩展格式),用于区分消息功能,同时决定优先级

  • 可配置1-8字节的有效载荷

  • 可实现广播式和请求式两种传输方式

  • 应答、CRC校验、位填充,位同步,错误处理等特性。体现了严谨和安全


image.png


废话说完,进入正题

2、CAN通信原理


在计算机领域中,我们任何数据通信其实传输的是0和1的信号,无论是串口,还是网线TCP,其底层都是传输的0和1的信号。
那在CAN中是怎么传递这些信号的呢。


2.1、CAN物理接线


image.png


image.png



  • 每个设备通过CAN收发器挂载在CAN总线网络上

  • CAN控制器引出TX和RX与CAN收发器相连,CAN收发器引出CAN_H和CANL分别与总线CAN_H和CAN_LOW相连

  • 高速CAN使用闭环网络,CAN_H和CAN_L两端添加120Ω的终端电阻

  • 低速CAN使用开环网络,CAN_H和CAN_L其中一端添加2.2kΩ的终端电阻


CAN收发器:他是个什么东西呢,是个芯片,主要实现电平转换,输出驱动和输入采样几个功能。也就是用来采集和输出电平信号的。
CAN控制器:这个就是我们Android程序员需要操作的东西了,因为信号要转成可视化的数据才行,转成byte数组,数组转成字符串啊,数字我们来显示。然后我们要发送的消息,也可能是数字,字符串之类的。我们需要把我们的指令传给收发器,收发器再转成电平信号,传给其他设备。


image.png


高速CAN总线,没有设备进行通讯的时候,终端电阻会收紧,终端电阻就像一根弹簧一样,收紧状态会使,CAN_H和CAN_L的电压相同,其差值是0,代表1信号,如果CAN设备想发送信号1,终端电阻就会张开,使其两边的电压差增大,表示其0状态。如果CAN设备想发送1时,无需对总线进行任何操作,CAN总线默认就是收紧状态就是1。


低速CAN的原理,有兴趣的同学可以去自行搜索资料学习一下。我们这里就不做介绍了。只要知道有这玩意就可以了。


2.2、 CAN电平标准


CAN总线采用差分信号,即两线电压差(Vcan_h -Vcan_l)传输数据位


高速CAN规定



  • 电压差为0V时,表示逻辑1(隐形电平)

  • 电压差为2V时,表示逻辑0(显性电平)


image.png


显示电平,隐形电平表示的线路实际状态,因为总线默认状态时收紧状态,不需要设备干预,所以收紧状态为隐形电平,而张开状态需要设备干预,所以定义为显性电平。
在与逻辑电路的对应上,电路约定俗成的习惯,默认状态为高电平1 ,所以默认的隐形电平就和逻辑1绑定了,显性电平就和0绑定了,显性电平和隐形电平同时出现时,总线会表现出显性电平状态,这样能对应电路中 0 强于1 的规定。

我们分析帧时序时,用逻辑电平。


2.3、收发器原理


收发器的工作原理,有点复杂,而且还需要对电路有一定了解,感觉有点复杂。我们就不介绍了,有兴趣的同学可以自行学习。
CAN收发器 TJA1050(高速CAN)


image.png


2.4、CAN物理层特性


image.png


2.5、CAN通讯思路总结


其通讯思路,CAN总线好比一个大灯, CAN设备分别是小明,小红,和小华, 这三个人时刻关注灯的状态。小明想发送1101,他就会在四个时序分别 灭灯,灭灯,量灯,灭灯。小红和小华会根据灯的状态解析出来1101。我觉得这样比较容易理解。至于他们都想发消息怎么办,谁先发谁后发,这就到了我们通讯协议环节


3、CAN总线帧格式


帧格式规定了通讯协议,就是规定传输的0和1代表什么意思。


帧类型用途
数据帧发送设备主动发送数据 (广播式)
遥控帧接收设备主动请求数据 (请求式)
错误帧某个设备检测出错误时向其他设备通知错误
过载帧接收设备通知其尚未做好接收准备
帧间隔用于将数据帧及遥控帧与前面的帧分离开

3.1 数据帧


image.png


我们先看一下图例 D Dominat 显性电平, R Recessive 隐性电平
灰色部分D只能发送显性电平0,紫色部分D/R 可以发送显示电平或者隐性电平,白色部分代表R只能发送隐性电平。


ACK位槽 这个时应答位特有的,发送方必须发隐形电平,接收方发显示电平


图里边的数字,代表此段时序所占的位数,比如1位,11位,18位。


然后我们分析一下标准数据帧。


3.1.1 SOF(帧起始)


我们发送数据帧之前,总线必须处在空闲状态,空闲状态总线时隐性电平1,随后数据帧开始,SOF(帧起始)灰色部分,显示电平0,帧起始的作用是打破宁静。因为空闲时隐性1,所有设备都不去碰总线,你想要发送数据,第一位必须张开总线,发送显性0,如果你发送隐性1,那就会与前边的隐性状态融为一体。没人知道你开始发数据了。还有一个作用是告诉接收方,如果后边我再释放总线,总线不是空闲状态,而是我发送的就是1


3.1.2 Identifier(ID)报文ID


帧起始后边就是报文ID,标准格式是11位,


报文ID的功能,可以表示后边数据段的功能,因为总线上各种报文信息都有,如果不以ID加以区分,消息就会混乱,不知道哪个是那个了。


报文ID的第二个功能,就是用来区分优先级,当多个设备同时发送时,根据仲裁规则,ID小的报文优先发送。ID大的报文等待下一次总线空闲再重试发送。


不同功能的数据帧,其ID都不同,否则两个设备同时发相同ID的数据帧,仲裁规则就无法谁先谁后发送了。


3.1.3 RTR (远程请求标志位)


用来区分遥控帧和数据帧的标志位,数据帧必须为显性0,遥控帧必须为隐性1,我们分析的数据帧,所以这一位必须是0


Identifier 和 RTR,这两段加起来叫做仲裁段,我们主要是靠ID仲裁,为啥把RTR加进来呢?是因为遥控帧和数据帧的ID是可以相同的,然后相同ID的数据帧和遥控帧,数据帧优先发送。


3.1.4 IDE (ID扩展标志位)


这一位是ID扩展标志位,作用用来区分这个数据帧是标准帧,还是扩展帧。标准格式,位固定显性电平0,扩展格式为隐性电平1,


3.1.5 r0(保留位)目前没有用到


3.1.6 DLC 数据段的长度,数据段的字节数


3.1.7 Data 数据段,数据段长度占的位数,要是8的倍数,也可以是0


3.1.8 CRC Sequence CRC校验校验符 占15位


它会对前边所有的数据位进行CRC算法计算,从SOF到Data 这些所有数据位计算得到一个校验码,放到里面,接收方接收到校验码之后,也会调用CRC算法计算,看校验码是否一致。以判断传输是否有误


3.1.9 CRC界定符 必须为隐性电平1


3.1.10 ACK槽


发送方可以根据ACK槽,知道数据是否被接收,可以用来做重发机制。
发送方会在这一位释放总线,然后会读这一位,如果这一位被拉高,置为显性0,说明数据被接收了,发送方就可以安心了。
如果发送方回读还是隐性1,那么就可以安排重发,或者不用管。


3.1.11 ACK界定符


他的作用是接收方接到消息后ACK拉高之后,要交出控制权。所以要用一个界定符,让接收方发送隐性1.


3.1.12EOF 帧结束,七个隐性1 代表帧结束


这个数据段波形,是接收方和发送方一起完成的,就是帧起始开始,接收方已经开始接收了,并不是,发送方发完这一帧,接收方才开始接收的。 理解这一句话,上边的才好理解。


3.1.13 扩展帧


扩展帧出现的原因,就是标准格式的ID不够用了,需要加一些,而且扩展格式,也要考虑必须对标准格式的兼容。
我们分析完标准帧,扩展帧就相对于来说,更容易了。
扩展帧的RTR挪到了扩展ID后边,原来的RTR 变为了SRR,现在也没有作用, 必须搞成隐性1,然后后边就是IDE,扩展帧标志位,如果是显性0,则后续按照标准帧格式进行解析,如果是隐性1,按扩展帧解析,再往后就是18位扩展id。扩展格式rtr 后边的 r1,和r0 是保留位必须显性0,后面的格式就是和标准数据帧一样了。


3.2 遥控帧


image.png
遥控帧无数据段,RTR位隐性电平1,其他部分与数据帧相同。
用于数据不是频繁更新的场景,和数据帧搭配使用。


3.3 错误帧


总线上所有设备多会监督总线的数据,一旦发现位错误或者填充错误或CRC错误或格式错误,或者应答错误,这些设备便会发出错误帧来破坏数据,同时终止当前的发送设备


image.png


3.4 过载帧


当接收方收到大量数据无法处理时,其可以发出过载帧,延缓发送方的数据发送,以平衡总线负载,避免数据丢失


image.png


3.5 帧间隔


将数据帧与远程帧与前面的帧分离开


image.png


错误帧,过载帧 和帧间隔。在设计的时候是非常复杂的,建议初学者了解就可以。我们学会收发数据即可。


4、位填充


位填充规则:发送方每发送五个相同电平后,自动追加一个相反电平的填充位,
接收方检测到填充位时,会自动移除填充位,恢复原始数据


image.png
如果位填充之后,和后边的四位相同,则会再填充一位。填充位与后边的数据位合并,之后再用填充规则进行位填充。


位填充作用:



  • 增加波形的定时信息,利于接收方执行再同步,防止波形长时间无变化,导致接收方不能精确掌握数据采样时机。如果长时间相同的电平,时钟稍有偏差,就会接收出错。

  • 将正常数据流与错误帧和过载帧区分开,标志错误帧和过载帧的特异性。(都有连续六位相同的电平)

  • 保持CAN总线在发送正常数据流时的活跃状态,防止被误认为总线空闲(如果你要发送的数据是一大串1)


5、接收方数据采样



  • CAN总线没有时钟线,总线上的所有设备通过约定波特率的方式确定每一个数据位的时长

  • 发送方以约定的位时长,每 隔固定时间输出一个数据位

  • 接收方以约定的位时长每隔固定时间采样总线的电平,输入一个数据位

  • 理想状态下,接收方能依次采样到发送方发出的每个数据位,且采样点位于数据位中心附近


image.png
上面是理想状态啊,实际操作肯定会遇到问题的。


接收方数据采样遇到的问题


接收方以约定的位时长进行采样,但是采样点没有对齐数据位中心附近


image.png
这里就涉及到数据同步问题,如何让采样点对齐数据位中心呢? 如果在跳变沿采样,这个数据是1还是0,有点说不清了,所以上图采的数据是有问题的,如果没对齐,我们参考第一个跳变沿,采样时间往后延半个数据位的时间,然后后边的再用数据位时间间隔进行采样,这样就对齐了。这就涉及到硬同步了。


接收方刚开始采样正确,但是时钟有误差,随着误差累积,采样点逐渐偏离。


image.png


这个问题,如果采样时间过慢,我们可以在偏差不是很大的时候,减少一次采样间隔时间,这样对于后边所有的采样时间,就会往前提一点。如果过快,相反,我们增加一次采样间隔时间,后边所有采样的时间都会往后移一点。这就是用到了再同步的概念
通过这两个问题,我们也知道了位填充的重要性,如果波形长时间,不变,我们就无法进行同步,采集的数据就会有问题。
我们了解个大概就可以了,如果你想做硬件,可以继续再研究一下,硬同步和再同步。


6、 仲裁规则


CAN总线只有一对差分信号线,同一时间只能有一个设备操作总线发送数据,若多个设备有同时发送数据的需求,该如何分配总线资源?


思路: 指定资源分配规则,一次满足多个设备的发送需求,确保同一时间只有一个设备操作总线。


规则一 先占先得


先占先得,如果设备一已经开始发送了,发送的途中,第二个设备想发送数据,禁止发送。



  • 若当前已经有设备正在操作总线发送数据帧/遥控帧,则其他任何设备不能再同时发送数据帧/遥控帧(可以发送错误帧/过载帧,破坏当前数据)

  • 任何设备检测到连续11个隐性电平,即认为总线空闲,只有在总线空闲时,设备才能发送数据帧

  • 一旦有设备正在发送数据帧/遥控帧,进行了位填充总线就会变为活跃状态,必然不会出现连续11个隐性电平,其他设备自然也不会破坏当前发送。

  • 若总线活跃状态其他设备有发送需求,则需要等待总线变为空闲,才能执行发送需求。


但是如果,在开始的时候,两个设备都想发送数据呢。都没开始呢?


规则二 非破坏性仲裁



  • 若多个设备的发送需求同时到来或因等待而同时到来,则CAN总线协议会根据ID号(仲裁段 )进行非破坏性仲裁,ID号小的(优先级高)取到总线控制权,ID号大的(优先级低)仲裁失利后将转入接收状态,等待下一次总线空闲时再尝试发送。

  • 实现非破坏性仲裁需要两个要求

    1. 线与特性,总线上任何设备发送显性电平0时,总线就会呈现显性电平0状态,只有当前所有设备都发送隐性电平1时,总线才呈现隐性电平1状态。即: 0&X&X =0,1&1&1=1(X代表可以是0可以是1)

    2. 回读机制:每个设备发出一个数据位后,都会读回总线当前的电平状态,已确认自己发出的电平是否被真实的发送出去了,根据线与特性,发出0读回必然是0,发出1,读回不一定是1(ACK槽)




仲裁过程


image.png


数据位从前到后依次比较,出现差异且数据位为1的设备仲裁失利。


单元1和单元2是两个设备,他们都可以回读总线电平。前边的数据位是想通的,所以回读的数据也是相同的,所以会继续发送,当走到红色部分时,单元一发送隐性1,但是单元二发送的事显性0,总线电平这时时显性0,单元二回读和自己发送一样,单元一回读和自己发送有差别,感知到总线有其他设备抢占资源,仲裁失利,下一位起变为接收状态。


id号越小,其二进制,出现1就会越晚,也就越晚退出仲裁。完美解释了id号小优先级高的问题。


位填充不会影响仲裁优先级。你找不到两个ID A和B,没有填充A比B优先级高,填充了B比A高,找不到,根本找不到。


从仲裁的过程,我们可以看出,仲裁的最后的胜利者,它所有的回读,和自己发送的数据是一样的。原有的数据都没发生改变所以它叫非破坏性仲裁。


数据帧和遥控帧的优先级,先按id号仲裁,如果id号一致,再走RTR位仲裁。


image.png
标准帧的id号,不允许出现一样的,遥控帧的id号也不能出现一样的。如果一样的话,他们的仲裁段完全相同。到后边数据会被破坏的。


扩展帧和数据帧的优先级
标准格式11位ID号和扩展格式的29位ID号的高11位一样时,标准格式的优先级,高于扩展帧(SRR必须始终为1,以保证此要求)


image.png


还有一种极端情况,就是标准遥控帧的id号和扩展帧的高11位相同时,怎么仲裁的呢。
到这里标准遥控帧的仲裁端已经结束了,扩展帧的SRR 是0,标准遥控帧的RTR 也是0,但是,扩展帧的仲裁段还没有结束,SRR 后边是IDE 因为是扩展帧所有它的idE 是1,标准帧的ide是0,扩展帧就会出现发1读0的情况,仲裁失利,退出竞争。


7、错误处理


1730447477206.png



  • 主动错误状态的设备正常参与通信并在检测到错误是发出主动错误帧

  • 被动错误状态的设备正常参与通信,但检测的错误时,只能发出被动错误帧,不会破坏别人发送的数据。

  • 总线关闭状态的设备不能参与通信

  • 每个设备内部管理一个TEC和REC,更具TEC和REC的值确定自己的状态
    TEC和REC是计数器,TEC发送错误计数一次,正确发送减少一次,REC接收错误计数一次,正确接收减少一次。
    image.png


image.png


8、总结


我们从CAN的物理接线,开始介绍,介绍了协议的主要内容,也介绍了协议对特殊情况的处理。消息仲裁,和错误处理。相信大家可以正常的跟硬件工程师交流了。至于Android代码实现,这篇有点太长了,会再写个文章发出。多多见谅。


作者:一杯凉白开
来源:juejin.cn/post/7433076509574905908
收起阅读 »

UNIAPP实现APP自动更新

整体思路和API使用 工作流程 App 启动时检查更新 发现新版本时显示更新提示 如果是强制更新,用户必须更新 下载完成后自动安装 API getVersion:自己服务器API,返回版本号、下载地址等信息 plus.runtime.getPropert...
继续阅读 »

整体思路和API使用


工作流程



  • App 启动时检查更新

  • 发现新版本时显示更新提示

  • 如果是强制更新,用户必须更新

  • 下载完成后自动安装


API



  • getVersion:自己服务器API,返回版本号、下载地址等信息

  • plus.runtime.getProperty:获取APP当前版本号

  • uni.downloadFile:下载文件

  • plus.runtime.install:安装软件

  • downloadTask.onProgressUpdate:监听下载进度


具体实现


后端getVersionAPI代码


// Version.java
@Data
public class Version {
private String version; // 版本号
private String downloadUrl; // 下载地址
private String description; // 更新说明
private boolean forceUpdate; // 是否强制更新
}

// VersionController.java
@RestController
@RequestMapping("/api/version")
public class VersionController {

@GetMapping("/check")
public Result checkVersion(@RequestParam String currentVersion) {
Version version = new Version();
version.setVersion("1.1.7"); // 最新版本号
version.setDownloadUrl("软件下载地址"); // 下载地址
version.setDescription("1. 修复已知问题\n2. 新增功能");
version.setForceUpdate(true); // 是否强制更新

// 比较版本号
if (compareVersion(currentVersion, version.getVersion()) < 0) {
return Result.success(version);
}

return Result.success(null);
}

// 版本号比较方法
private int compareVersion(String v1, String v2) {
String[] version1 = v1.split("\\.");
String[] version2 = v2.split("\\.");

int i = 0;
while (i < version1.length && i < version2.length) {
int num1 = Integer.parseInt(version1[i]);
int num2 = Integer.parseInt(version2[i]);

if (num1 < num2) return -1;
else if (num1 > num2) return 1;
i++;
}

if (version1.length < version2.length) return -1;
if (version1.length > version2.length) return 1;
return 0;
}
}

其中Version类可以写到数据库中获取


前端update.js封装


// 版本更新工具类 - 使用单例模式确保全局只有一个更新实例
import {
check
} from "../api/util/util";

class AppUpdate {
constructor() {
// 当前应用版本号
this.currentVersion = '';
// 服务器返回的更新信息
this.updateInfo = null;
}

// 检查更新方法
checkUpdate() {
//仅在app环境下运行
// #ifdef APP-PLUS
plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
this.currentVersion = widgetInfo.version;
console.log('当前版本:' + this.currentVersion);
check(this.currentVersion).then(res => {
if (res.data.data) {
this.updateInfo = res.data.data;
this.showUpdateDialog();
}
})
.catch(err => {
console.log(err);
});
});

// #endif
}
showUpdateDialog() {
uni.showModal({
title: '发现新版本',
content: this.updateInfo.description,
confirmText: '立即更新',
cancelText: '稍后再说',
showCancel: !this.updateInfo.forceUpdate, // 强制更新时禁止取消
success: (res) => {
if (res.confirm) {
this.downloadApp();
} else if (this.updateInfo.forceUpdate) {
plus.runtime.quit();
}
}
});
}

downloadApp() {
/* uni.showLoading({
title: '下载中...',
mask: true // 添加遮罩防止重复点击
}); */


// 先打印下载地址,检查 URL 是否正确
console.log('下载地址:', this.updateInfo.downloadUrl);
let showLoading=plus.nativeUI.showWaiting('正在下载');
const downloadTask = uni.downloadFile({
url: this.updateInfo.downloadUrl,
success: (res) => {
console.log('下载结果:', res); // 添加日志
if (res.statusCode === 200) {
console.log('开始安装:', res.tempFilePath); // 添加日志
plus.runtime.install(
res.tempFilePath, {
force: false
},
() => {
console.log('安装成功'); // 添加日志
plus.nativeUI.closeWaiting();
plus.runtime.restart();
},
(error) => {
console.error('安装失败:', error); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '安装失败: ' + error.message,
icon: 'none',
duration: 2000
});
}
);
} else {
console.error('下载状态码异常:', res.statusCode); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '下载失败: ' + res.statusCode,
icon: 'none',
duration: 2000
});
}
},
fail: (err) => {
console.error('下载失败:', err); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '下载失败: ' + err.errMsg,
icon: 'none',
duration: 2000
});
}
});

//监听下载进度
downloadTask.onProgressUpdate((res) => {
console.log('下载进度:', res.progress); // 添加进度日志
if (res.progress > 0) { // 只在有实际进度时更新提示
showLoading.setTitle('正在下载'+res.progress+'%');
}
});
}
}

//单例模式实现
let instance = null;

export default {
getInstance() {
if (!instance) {
instance = new AppUpdate();
}
return instance;
}
}

注意:如果直接使用uni.showLoading来显示下载进度,会造成闪烁效果,所以这里用let showLoading=plus.nativeUI.showWaiting('正在下载');


引用js


以app.vue为例,在启动时触发检查更新


import AppUpdate from '@/utils/update.js';

export default {
onLaunch: function() {
// #ifdef APP-PLUS
AppUpdate.getInstance().checkUpdate();
// #endif
}
}

在 manifest.json 中配置权限


{
"app-plus": {
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INSTALL_PACKAGES\"/>",
"<uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>"
]
}
}
}
}

这样封装的优点



  • 代码更加模块化

  • 可以在任何地方调用

  • 使用单例模式避免重复创建

  • 更容易维护和扩展


作者:HuoWang
来源:juejin.cn/post/7457206505021341730
收起阅读 »

Android技巧:学习使用GridLayout

GridLayout是一个非常强大的网格类布局,它不但能像TableLayout那样,实现网格类布局,但它更为强大的地方在于每个Cell的大小可以横向或者纵向拉伸,每个Cell的对齐方式也有很多种,而且不像TableLayout,需要一个TableRow,Gr...
继续阅读 »

GridLayout是一个非常强大的网格类布局,它不但能像TableLayout那样,实现网格类布局,但它更为强大的地方在于每个Cell的大小可以横向或者纵向拉伸,每个Cell的对齐方式也有很多种,而且不像TableLayout,需要一个TableRow,GridLayout可以通过指定Cell的坐标位置就能实现Cell的拉伸,从而实现,大小不一致的风格卡片式布局。


header


基本概念


GridLayout把页面分成m行和n列,使用m+1条线和n+1条线,把页面共分成n*m个Cell。指定位置时行坐标是从0到m,列坐标是从0到n。每一个子View占一个或多个Cell。比如(0, 0)到(0, 1)就是占第一个Cell的区域。(0, 0), (0, 2)就是占第一行的2个Cell的区域(横向拉伸).


使用方法


主要介绍一下如何添加Cell,以及设置Cell的位置和拉伸。其他的跟普通的ViewGr0up没什么区别的,也没啥好说的。


GridLayout的基本设置


首先需要给GridLayout设置行数和列数:



  • android:columnCount 整数,最多的列数

  • android:rowCount 整数,最多的行数


在添加Cell就需要注意,不能超过设置的最大行数和列数,否则在添加Cell时会有异常。


元素Cell的位置控制


添加Cell时需要指定其位置



  • android:layout_column 整数n,在哪一列开始显示n=[0, 最大列-1]

  • android:layout_columnSpan 整数k,指定元素横跨几列,需要注意保证n+k <= 最大列数

  • android:layout_row 指定从哪一行开始显示,规则同列数

  • android:layout_rowSpan 纵向跨几行,规则同列


行高和列宽的确定


每一行的高度是由这一行中Cell的最大高度决定的,以及每一列的宽度是由每一列中最大的宽度决定的,小于行高和列宽的元素可以设置其对齐方式和填充方式。


填充方式


通过Cell的android:layout_gravity参数来指定,Cell的填充方式,注意仅当Cell元素本身的尺寸小于它所占格子的大小时才有效,比如元素本身尺寸小于行高和列宽,或者当它占多行,或者占多列时:



  • center -- 不改变元素的大小,仅居中

  • center_horizontal -- 不改变大小,水平居中

  • center_vertical -- 不改变大小,垂直居中

  • top -- 不改变大小,置于顶部

  • left -- 不改变大小,置于左边

  • bottom -- 不改变大小,置于底部

  • right -- 不改变大小,置于右边

  • start -- 不改变大小,置于开头(这个是与RTL从右向左读的文字有关的,如果使用start/end,那么当LTR文字时start=left,end=right,当RTL时start=right,end=left,也就是说系统会自动处理了)

  • end -- 不改变大小,置于结尾

  • fill -- 拉伸元素控件,填满其应该所占的格子

  • fill_vertical -- 仅垂直方向上拉伸填充

  • fill_horizontal -- 仅水平方向上拉伸填充

  • clip_vertical -- 垂直方向上裁剪元素,仅当元素大小超过格子的空间时

  • clip_horizontal -- 水平方向上裁剪元素,仅当元素大小超过格子的空间时


需要注意的是这些值是可以组合的,比如:


android:layout_gravity="center_vertical|clip_horizontal"

Cell之间的间距如何控制


默认间距


可以使用默认的间距android:useDefaultMargins="true"或者GridLayout#setUseDefaultMargins()。这个属性默认值是"false"。


另外一种方式就是跟普通布局管理器一样,给每个Cell设置其margins


通常如果不满意系统的默认间距,就可以设置useDefaultMargins="false",然后通过给Cell设置margin来控制间距。


居中方法



  • 仅有一个Cell或者仅有一行,或者仅有一列时


    当仅有一个子View时或者仅有一行或者一列的时候,可以把每个Cell设置其android:layout_gravitiy="center"(相应代码为LayoutParams#GravityCENTER),就可以让其在GridLayout中居中。



让一行居中:


header


    <GridLayout
android:layout_width="wrap_content"
android:layout_height="200dip"
android:useDefaultMargins="true"
android:background="@android:color/white"
android:rowCount="1"
android:columnCount="2">

<Button android:layout_column="0"
android:layout_row="0"
android:text="Left Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

<Button android:layout_column="1"
android:layout_row="0"
android:text="Right Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

</GridLayout>

让一个元素居中:

header


    <GridLayout
android:layout_width="200dip"
android:layout_height="200dip"
android:useDefaultMargins="true"
android:background="@android:color/white"
android:rowCount="1"
android:columnCount="1">

<Button android:layout_column="0"
android:layout_row="0"
android:text="Left Button"
android:layout_gravity="center"/>

</GridLayout>


  • 其他情况


    其他情况,设置子View的Gravity就不再起作用了,这时最好的办法就是让GridLayout的高度是WRAP_CONTENT,然后让GridLayout在其父布局中居中。



header


     <LinearLayout
android:layout_width="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="@android:color/darker_gray"
android:layout_height="200dip">

<GridLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:useDefaultMargins="true"
android:background="@android:color/white"
android:rowCount="2"
android:columnCount="2">

<Button android:layout_column="0"
android:layout_row="0"
android:text="Left Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

<Button android:layout_column="1"
android:layout_row="0"
android:text="Right Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

<Button android:layout_column="1"
android:layout_row="1"
android:text="Right Button 2"
android:layout_gravity="fill_horizontal|center_vertical"/>

</GridLayout>
</LinearLayout>

适用场景


GridLayout虽然强大,可以当作LinearLayout使用,也可以当作RelativeLayout使用,甚至也能当FrameLayout使用。但是,我们不可以滥用,对于任意布局都一样,不能是它能实现需求就使用它,而是要根据实际的需求,选择最简单,最方便的,同时也要考虑性能。


通常对于类似于网格的布局就可以考虑用GridLayout来实现,或者用LinearLayout横七竖八的套了好几层时也要考虑使用GridLayout。


GridLayout vs GridView or RecyclerView


当要实现网格布局,或者非均匀风格布局时,可能首先想到的就是GridView,但是这也要看实际的情况而定。GridView,ListView以及RecyclerView是用于无限长度列表或者网格的场景,它们最大的特点是无限长度,因此这几个组件的重点在于如何复用Cell以提升性能,以及处理手势事件(Fling)等。所以,每当遇到列表或者网格的时候,先想一下这个长度大概会是多少,如果是在百个以内,且不会随时增长,这时就可以考虑使用静态(非动态复用)的组件比如LinearLayout或者GridLayout来实现。


实例


说的太多都是废话,来一个实例感觉一下子是最直接的:


header


<GridLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:background="@android:color/white"
android:alignmentMode="alignMargins"
android:useDefaultMargins="true"
android:columnCount="4"
android:rowCount="5"
android:visibility="visible">

<Button android:layout_column="0"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="1"/>

<Button android:layout_column="1"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="2"/>

<Button android:layout_column="2"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="3"/>

<Button android:layout_column="0"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="4"/>

<Button android:layout_column="1"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="5"/>

<Button android:layout_column="2"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="6"/>

<Button android:layout_column="0"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="7"/>

<Button android:layout_column="1"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="8"/>

<Button android:layout_column="2"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="9"/>

<Button android:layout_column="0"
android:layout_row="3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="0"/>

<Button android:layout_column="1"
android:layout_row="3"
android:layout_gravity="fill_horizontal"
android:layout_columnSpan="2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="Delete"/>

<Button android:layout_column="0"
android:layout_row="4"
android:layout_columnSpan="2"
android:layout_gravity="fill_horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="Clear"/>

<Button android:layout_column="2"
android:layout_row="4"
android:layout_columnSpan="2"
android:layout_gravity="fill_horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="="/>

<Button android:layout_column="3"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill"
android:padding="10dip"
android:text="+"/>

<Button android:layout_column="3"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill"
android:padding="10dip"
android:text="-"/>

<Button android:layout_column="3"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill"
android:padding="10dip"
android:text="*"/>

<Button android:layout_column="3"
android:layout_row="3"
android:layout_columnSpan="1"
android:layout_gravity="fill"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="/"/>

</GridLayout>

参考资料




欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!


保护原创,请勿转载!



作者:稀有猿诉
来源:juejin.cn/post/7449673188131717174
收起阅读 »

Android树形结构,项目通用

效果图 思路 树形展开时,将该节点的childList添加到该节点下,并更新树结构 mDataList.addAll(position, childList) notifyItemRangeInserted(position, childList.size...
继续阅读 »

效果图


通用多选树.gif


思路



  • 树形展开时,将该节点的childList添加到该节点下,并更新树结构


    mDataList.addAll(position, childList)
    notifyItemRangeInserted(position, childList.size)


  • 树形关闭时,树的数据结构移除该节点的childList的数量,并更新树结构


    for (i in 0 until childList.size) {
       mDataList[position].isExpand=false
       mDataList.removeAt(position)
    }
    notifyItemRangeRemoved(position, childList.size)


  • 对于含CheckBox的树形结构,每一个节点都需要监听他的状态,当状态和上一次的状态不一样时,则进行更新。更新不仅更新本节点,还需要递归的方式更新他的子节点;因为当前的选中状态还会牵连到他的父节点,他的父节点变更的话还会牵扯到再上一层,所以也需要递归的方式来更新。


    private fun updateNodeState(bean: T) {
       //更新子节点
       updateChildState(bean)

       //更新父节点状态
       updateParentState(bean)

       notifyDataSetChanged()
    }

    更新子节点


    private fun updateChildState(bean: T) {
       for (child in bean.getChildList()) {
           //更新子节点状态
           child.checkState = bean.checkState
           //递归更新子节点
           updateChildState(child)
      }
    }

    更新父节点


    private fun updateParentState(bean: T) {
       //找到父节点并更新
       mDataList.forEach { parent ->
           if (bean.getMyId() in parent.getChildList().map { it.getMyId() }) {
               //全部选中
               val allChecked =
                   parent.getChildList().all { it.checkState == TriStateCheckBox.State.CHECKED }
               val allUnChecked =
                   parent.getChildList().all { it.checkState == TriStateCheckBox.State.UNCHECKED }

               if (allChecked) {
                   parent.checkState = TriStateCheckBox.State.CHECKED
              } else if (allUnChecked) {
                   parent.checkState = TriStateCheckBox.State.UNCHECKED
              } else {
                   parent.checkState = TriStateCheckBox.State.PARTIALLY_CHECKED
              }
               //递归更新父节点
               updateParentState(parent)
          }
      }
    }


  • 设置选中项时,可以先获取到selectList中的所有叶子节点,然后再更新整个树形结构的mDataList选项


    //获取所有的叶子节点
    private fun getLeafNodeList(selectedList: List<T>):List<T>{
       val result = mutableListOf<T>()
       for (bean in selectedList){
           if (bean.hasChild()){
               result.addAll(getLeafNodeList(bean.getChildList()))
          }else{
               result.add(bean)
          }
      }
       return result
    }

    fun setSelectedList(selectedList:List<T>){
       //选中的叶子节点列表
       val selectedChildNodeList = getLeafNodeList(selectedList).toMutableList()
    //通过递归的方式检查子列表
       updateSelectedTree(mDataList, selectedChildNodeList)
       notifyDataSetChanged()
    }


  • 因为想通过泛型的方式,适用于任何项目,所以我们搞一个抽象类,包含该节点的层级、是否展开、选中状态等属性


    abstract class TreeBaseBean<T>{
       
       //层级
       var level:Int=0
       //是否展开
       var isExpand = false
       //当前节点状态
       var checkState: TriStateCheckBox.State = TriStateCheckBox.State.UNCHECKED

       //判断是否有子节点
       fun hasChild():Boolean = !getChildList().isNullOrEmpty()
       
       //获取子节点列表
       abstract fun getChildList():List<T>
       //获取当前节点id
       abstract fun getMyId():Any
       //获取父节点id
       abstract fun getMyParentId():Any?

    }



步骤


处理数据


将项目中的树形数据结构继承自TreeBaseBean,重写该抽象类中的方法


data class MenuBean(
   var id: String = "",
   var parentId: Any? = null,
   var menuName: String = "",
   var menuType: String = "",
   var router: String = "",
   var sort: Int = 0,
   var icon: String = "",
   var sonList: List<MenuBean> = listOf(),
   var status: String = "",
   var userid: String=""
): TreeBaseBean<MenuBean>() {

   override fun getChildList(): List<MenuBean> {
       return sonList
  }

   override fun getMyId(): Any {
       return id
  }

   override fun getMyParentId(): Any? {
       return parentId
  }
}

2、建立自己的ItemView,确保里面包含有一个命名为ivArrow的箭头图片,一个命名为mCheckBox的CheckBox或自定义三种状态的TriStateCheckBox


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:id="@+id/root">

   <ImageView
       android:id="@+id/ivArrow"
       android:layout_width="18dp"
       android:layout_height="18dp"
       android:src="@drawable/ic_keyboard_arrow_right_black_18dp"
       android:visibility="invisible"
       app:layout_constraintBottom_toBottomOf="@id/mCheckBox"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintTop_toTopOf="@id/mCheckBox" />

   <com.sxygsj.treefinalcase.TriStateCheckBox
       android:id="@+id/mCheckBox"
       android:layout_width="20dp"
       android:layout_height="20dp"
       app:layout_constraintLeft_toRightOf="@id/ivArrow"
       app:layout_constraintTop_toTopOf="@id/tvCheckName"
       app:layout_constraintBottom_toBottomOf="@id/tvCheckName"/>

   <TextView
       android:id="@+id/tvCheckName"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:text="内容"
       app:layout_constraintLeft_toRightOf="@id/mCheckBox"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       android:layout_marginLeft="5dp"
       android:paddingVertical="5dp"/>
   
</androidx.constraintlayout.widget.ConstraintLayout>

使用适配器


//数据项
private val dataList= mutableListOf<MenuBean>()
//设置的选中项
private val selectedList = mutableListOf<MenuBean>()

private lateinit var adapter: ChainCommonTreeCheckboxAdapter<MenuBean>

private fun initRcy() {
   adapter = ChainCommonTreeCheckboxAdapter.Builder<MenuBean>()
      .setData(dataList)
      .setLayoutId(R.layout.item_checkbox_tree)
      .addBindView { itemView, bean, level ->
           itemView.findViewById<TextView>(R.id.tvCheckName).setText("层级${level}:"+bean.menuName)
      }
      .addItemClickListener {
           Toast.makeText(this,"点击了:${it.menuName}", Toast.LENGTH_SHORT).show()
      }
      .setPadding(16)
      .create()


   binding.apply {
       mRcy.layoutManager = LinearLayoutManager(this@ChainMultiActivity)
       mRcy.adapter=adapter
  }

}

//设置选中项
adapter.setSelectedList(selectedList)

//默认获取选中和部分选中节点
val list = adapter.getSelectedList()

//获取所有的叶子节点(具体想要获取怎么的选中项,可以通过lambda方式来自己设定规则)
val list = adapter.getSelectedList { bean ->
       bean.checkState == TriStateCheckBox.State.CHECKED&&!bean.hasChild()
}

总结


其实整体的关键操作是数据的处理,怎么通过递归的方式,联动更新各个节点的状态是最重要的,关键代码在思路里已整理,剩余的三种状态的CheckBox、链式调用的通用适配器有时间再更新。


作者:重拾丢却的梦
来源:juejin.cn/post/7434799466974134284
收起阅读 »

Android手机投屏方案实现方式对比

1.概述 手机投屏是目前市场上常见的一个功能,在车机娱乐场景,辅助驾驶场景比如苹果的carplay,VR 场景都很常见,目前市场上的投屏分为三类: 第一类: 镜像模式,直接把手机上整个界面原封不动进行投射。这类投屏通常是对手机进行录屏,然后编码成视频流数据的方...
继续阅读 »

1.概述


手机投屏是目前市场上常见的一个功能,在车机娱乐场景,辅助驾驶场景比如苹果的carplay,VR 场景都很常见,目前市场上的投屏分为三类:
第一类: 镜像模式,直接把手机上整个界面原封不动进行投射。这类投屏通常是对手机进行录屏,然后编码成视频流数据的方式给到接受端,接收端再解码播放,以此完成投屏功能。比如AirPlay的镜像模式、MiraCast、乐播投屏等;
第二类: 推送模式,播视频的场景比较常见。即A把一个视频链接传给B,B自己进行播放,后学A可以传输一些简单控制指令。比如DLNA协议等;
第三类: 基于特殊协议投射部分应用或部分功能,车载领域居多。比如苹果的CarPlay、华为HiCar、百度CarLife等。



这里还有一种投屏方式比较新颖,将手机上的画面投到车机上,然后手机上可以操作自己的功能,车机上也可以操作手机的功能,而且两者互不干涉,具体可以参考蔚来手机和车机的投屏:蔚来手机的投屏视频 今天的主要内容是介绍实现投屏的各种技术方式,主要介绍Miracast、scrcpy、以及Google cast的实现方式以及优缺点局限性。



2.术语解释


2.1 miracast


Miracast是一种以WiFi直连为基础的无线显示标准,它允许用户通过无线方式分享视频画面。这种技术支持用户将智能手机、平板电脑、笔记本电脑等设备上的内容投射到大屏幕电视或其他显示设备上,而无需使用线缆连接。


2.2 scrcpy


Scrcpy是一种开源的命令行工具,允许用户通过USB数据线或Android ADB(Android调试桥)来控制他们的Android设备,包括手机和平板电脑。使用Scrcpy,用户可以在电脑上实时查看和控制他们的Android设备,就像使用一个远程屏幕一样。
2.3 DLNA投屏
DLNA投屏是一种通过网络将多媒体内容从一台设备传输到另一台设备的技术。它允许用户将智能手机、平板电脑或电脑上的视频、音频和图片等内容投射到支持DLNA的电视、音响系统或其他显示设备上。DLNA投屏基于设备之间的WiFi连接,无需额外的物理连接或设置,使用户能够轻松地将手机上的媒体内容投屏到大屏幕上并实现双向控制。


2.4 Wifi Direct


WiFi Direct是一种允许设备通过WiFi直接相互连接的技术,无需通过路由器或中继点。这种技术使得设备之间的连接更加直接和便捷,常用于文件共享、打印服务和Miracast投屏等场景。


2.5 app_process


是Android原生的一个可执行程序,位于/system/bin目录下,zygote进程便是由这个执行文件启动的。


3.技术实现对比


3.1 Miracast


3.1.1 Miracast介绍


Miracast是一种无线技术,用于将屏幕无线连接到我们的计算机。它是由WiFi联盟制定,以WiFi-Direct、IEEE802.11为无线传输标准,允许手机向电视或其他接收设备进行无线投送视频、图片。和Miracast类似的投屏协议,还有Airplay、DLNA、chromecast等,Miracast是点对点网络,用于类似蓝牙的方式(比蓝牙更高效)无线发送由Wi-Fi Direct连接组成的截屏视频。大多数最新一代的设备(例如笔记本电脑、智能电视和智能手机)都可以支持该技术,Miracast还支持高达1080p(全高清)的分辨率和5.1环绕声。它还支持4k分辨率。通过无线连接,视频数据以H.264格式发送,这是当今最常见的高清视频编码标准。Miracast在诞生之初就以跨平台标准而设计,这意味着它能在多种平台间使用。


3.1.2 Miracast原理


Miracast基于WiFi P2P,或TDLS,或Infrastructure进行设备发现,位于OSI模型的数据链路层。而媒体传输控制使用RTSP协议,还有远程I2C数据读写、UIBC用户输入反向信道、HDCP高带宽内容保护等,位于OSI模型的TCP/IP传输控制层与网络层。其中,由音视频数据封装成PES包,经过HDCP内容保护,再封装成TS包,接着封装成RTP包,使用RTSP协议发送。如下图所示
在这里插入图片描述


3.1.3 Miracast优缺点分析


优点:投屏画质清晰,兼容性好。Android手机集成了Mircast投屏,如果想要二次开发可以从AOSP源码中找到对应的实现,网上的开发文档多
缺点: Miracast正常工作时,Wi-Fi工作在P2P模式,源端与接收端建立一对一的联接。也即当一个设备与一个接收端建立连接后,其它设备不可见该接收端,也就不能投屏。只有当该设备退出连接后,其它设备才能投屏。所以无法实现抢占功能。Miracast底层封装了UDP传输协议,没有严谨的问答机制。所以在实际使用过程中,当遇到干扰时,容易造成丢帧花屏现象。而传输过程中,一旦出现花屏,给客人的感觉就非常糟糕,现在市面上,哪些无线投屏设备之所以经常出现花屏、马赛克就是这个原因。另外,Miracast是操作系统供应商提供,一般都是在安卓系统上使用,但是安卓协议导致手机投屏没有声音,所以大多数用户在安卓手机无线投屏的时候,需要开启蓝牙,以便于把声音投屏过去。如果我们需要使用Mircast,需要对ROM进行二次开发。下面是一个投屏技术公司的关于Miracast的技术文档,描述了目前Mircast存在的问题。Mircast目前存在的问题 若要实现双向控制,需要加一个控制的通道和事件转换和注入


3.2 Scrcpy


3.2.1 scrcpy 介绍


scrcpy通过adb调试的方式来将手机屏幕投到电脑上,并可以通过电脑控制Android设备。它可以通过USB连接,也可以通过Wifi连接(类似于隔空投屏),使用adb的无线连接后投屏,而且不需要任何root权限,不需要在手机里安装任何程序。scrcpy同时适用于GNU / Linux,Windows和macOS。Scrcpy 显示的每帧画面的大小达到1920x1080或者更高,帧率在30~60fps,延迟很低(大约35~70ms),启动快,第一帧画面显示出来的时间大约为1秒,并且不需要安装任何apk。并且代码完全开源,源码地址:github.com/Genymobile/…


3.2.2 scrcpy的实现原理


Scrcpy的基本原理是通过ADB(Android Debug Bridge)将电脑和手机连接到一起后,推送一个jar文件到手机/data/local/tmp的目录下,然后通过adb shell 执行app_process 程序将jar文件运行起来,这个jar文件相当于是手机上运行的一个服务器,它的作用是处理来自电脑端的的数据请求。它的免root原理主要基于两个关键点:



  1. 利用AIDL (Android Interface Definition Language):Scrcpy通过ADB(Android Debug Bridge)连接手机,AIDL允许非系统应用(如scrcpy)与系统服务交互。尽管root可以访问更多的底层功能,但是像显示屏幕这样的操作通常是安全的,并且无需获得root权限。

  2. 屏幕录制协议:Scrcpy设计了一个简单的UDP(User Datagram Protocol)服务器,在手机上运行,这个服务器只处理来自客户端(如电脑上的scrcpy软件)的数据请求,而不是系统级别的控制命令。这种方式避免了直接修改系统的文件系统或设置。
    简单总结scrcpy的原理就是电脑端和手机端建立连接后通过3个socke通道分别传输音频,录频,控制信号去实现手机和电脑的数据共享,录屏和音频都可以通过aidl和系统的服务交互拿到对应的显示屏ID然后创建虚拟屏录制,然后再编码给到客户端(电脑端)解码显示。控制指令通过socket传输到手机端后,通过手机端的服务(shell 通过app_process启动的那个程序) 反射调用Android的事件注入接口实现的。下面是scrcpy的源码中关于事件注入的部分。
    在这里插入图片描述


3.2.3 scrcpy的优缺点分析


优点:Scrcpy的优点是显示的画质好,延迟低(大约3570ms),帧率3060fps,非常流畅,而且代码完全开源并有很详细的文档,并且不需要安装任何apk和root权限。能自定义控制的行为,比如显示音频和视频,只播放音频,只显示视频,只投屏(不接受电脑端的控制,类似于投屏中的镜像)
缺点:需要用户打开开发者模式中的USB调试模式,否则很多的操作都无法进行了。这点会导致产品无法用于正式的生产环境中,因为用户一般都不会打开开发者选项中的USB调试模式。如果通过修改源码的方式,则无法实现事件注入的功能,因为事件注入需要依赖adb shell。


3.3 Google cast


3.3.1 Google cast 介绍


Google Cast类似于DLNA,AirPlayer,Miracast,就是一种投屏技术。Google Cast的作用在于把小屏幕(诸如手机、平板、笔记本)的内容通过无线(WIFI)方式发送到大屏设备(google TV、chromeCast)进行播放。Google Cast所做的便在于基于不同的平台提供提供为应用开支这种功能的SDK,这些平台即有发送端的也有接收端的,发送端的有IOS、android、chrome浏览器,接收端的有google TV, chromeCast等,可以说这一套解决方案是比较大而全的(就其涵盖的平台)。


3.3.2 Google cast 的实现原理


发送端 app(sender app)使用 SDK,将需要播放的媒体的信息发送到 Google 的服务器,服务器再通知接收端播放(所以发送端和接收端必须都可以访问 Google 的服务器才行)。接收端运行的是一个浏览器,它会根据发送端的app ID和媒体信息,去载入对应的一个网页,这个网页(receiver app)也是由发送端 app 的开发者提供的,的将会负责播放相应的媒体内容。即使接收端是 Chromecast Audio 之类只能播放音频的硬件,这个网页也是会载入并渲染的。Google Cast 和 DLNA 或者苹果的 AirPlay 不同之处,一是依赖 Google 的服务器,也就是说必须连接到 Internet 才可以用,如果只有一个局域网是不行的。二是前两个的接收端播放器接收端本身提供的,开发者只需要提供要播放的内容就可以,但是 Google Cast 则是需要提供自己的receiver app,这样的好处是开发者可以高度定制(比如可以定制UI,或者加入弹幕、歌词滚动、音乐可视化之类复杂功能),虽然接收端往往运行的并不是Android这样的开放操作系统,但是因为receiver app的本质是网页,所以开发难度并不高。


3.3.3 优缺点分析


优点:就是高度可定制,有官方成熟的SDK可接入,从宣传视频中看到手机可以投屏到大屏后,然后就可以随意操作其他应用而不会影响到大屏的显示内容了。
缺点:平台依赖性强,必须可以访问Google服务器,而由于国情的原因,必须可访问Google服务器这个缺点就可以宣告这个方案不合适了


总结


本文主要介绍了各种Android手机投屏的实现方式以及优缺点,手机投屏经常会涉及到投屏端和接收端端相互操作以及音频的播放。所以在建立了投屏需要建立好几个连接通道,分别传输音频、控制指令和录屏的视频流。scrcpy就是这样实现的,如果我们能获取到权限,目前决定scrcpy是最好的投屏实现方式。由于没有权限,现在的大多数控制都是通过Android手机的无障碍模式实现的。这就是我对手机投屏的一些调研总结,希望能帮到有需要的读者


作者:职场007
来源:juejin.cn/post/7419297143787716618
收起阅读 »

Android热修

大家好,我是瑞英。 本篇会描述一个完整的安卓热修复工具的设计与实现。该热修工具基于instantRun原理,并且参照美团开源的robust框架,能够有效进行代码热修、so热修以及资源热修 热修复:无需通过发版,修复线上客户端问题的一种技术方案,特点是快速止...
继续阅读 »

大家好,我是瑞英。



本篇会描述一个完整的安卓热修复工具的设计与实现。该热修工具基于instantRun原理,并且参照美团开源的robust框架,能够有效进行代码热修、so热修以及资源热修



热修复:无需通过发版,修复线上客户端问题的一种技术方案,特点是快速止损。



本文描述中:base包就是宿主包【需要被修复的包】,patch包是下发到base包的修复包


为何要热修?


客户端线上出现问题,传统的解决方案就是发一个新的客户端版本,让用户主动触发升级应用,覆盖速度十分有限。问题修复时间越长,损失就会越大。需要一种可以快速修复线上客户端问题的技术-称之为热修复。
热修复能够做到用户无感知,快速修复线上问题


image.png


热修方案概述


原理上看,目前安卓热修主要分三种:基于类加载、基于底层替换、侵入方法插桩的。


主流热修产品


厂商产品修复范围修复时机稳定性接入成本技术方案
腾讯tinker类、资源、so冷启一般合成差量热修dex并冷启加载
阿里sophix类、资源、so冷启动、即时修复都支持(可选)高(商用)综合方案(底层替换方案&类加载方案)
美团robust方法修复及时修复下文详细介绍

代码修复方案


底层替换方案


直接在native层,将被修复类对应的artMethod进行替换,即可完成方法修复。


每一个java方法在art中都对应着一个ArtMethod,记录了这个java方法的所有信息:所属类、访问权限、代码执行地址等


特性:



  1. 无法实现对原有类方法和字段的增减(只支持方法替换)

  2. 修复了的非静态方法,无法被正常发射调用(因为反射调用的时候会verifyObjectIsClass)

  3. 实效性好,可立即加载生效无需重启应用

  4. 需要针对dalvik虚拟机和art虚拟机做适配,需要考虑指令集的兼容问题

  5. 无法解决匿名内部类增减的情况

  6. 不支持 <clinit>方法热修


类加载方案


合成修复后全量dex,冷启重新加载类,完成修复


特性:



  1. 需要冷启生效

  2. 高兼容性,几乎可以修复任何代码修复的场景


so修复方案


通过反射将指定热修so路径插入到nativeLibraryDirectories


base构建时保留所有so的md5值,patch包构建时,会进行校验,识别出发生变动的热修so,并将其打入patch中


资源修复方案


资源热修包的构建:


base构建时会保留base包的资源id,以及所有资源md5值,patch构建时,利用base id实现资源id固定,同时将新增资源打入patch中,使用新增资源的方法被自动标注为修复方法


资源热修包的加载:
通过反射调用AssetManager.addAssetPath,添加热修资源路径,在activity loadResources时,触发load热修资源


代码修复方案详解


在base包构建时,对需要被热修的方法进行插桩,保留相关base包构建信息【方法、类、属性以及其混淆信息】,在热修包构建时,依赖注解识别出被热修的方法,并结合base包相关信息,最终构建出热修包。


image.png


实现修复的原理


在base包构建时,对于方法都插入一个条件分支,执行热修代理调用。如果热修代理方法返回结果为true,则当前方法直接返回热修result,即该方法被成功热修【如下图所示】。当然这种侵入base包构建的热修方案,会导致包体积有所增加。


image.png


详解base包插桩指令


根据方法的参数和返回值特性,进行不同proxy方法的插入



  • 根据返回值分类:


    无返回值,则proxy方法直接返回boolean即可,如此被插桩方法中不需要出现proxyResult.isSupport的判断


    有返回值:需要返回ProxyResult


  • 根据参数个数进行分类,使得在插桩时,插桩方法的参数尽可能的少且简单,即插入指令尽可能的少。(目前对5个及以下的参数个数进行分类)


    只有5个以上的参数方法被插桩时,需要采用Object[]数组传递所有的参数。因为构建数组并且初始化数组元素,所需要的指令较多。


    例如:若方法只有一个参数,那么直接传递object对象只需要1条指令,如果通过Object[]传递该对象需要6条指令


    //有一个参数str:String,存放与局部变量表中 index = 1
    //直接传递该object对象
    mv.visitMethodInsn(ALOAD, 1)

    //利用object数组进行传递
    mv.visitInsn(1)//数组大小
    mv.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/Object")
    mv.visitInsn(Opcodes.DUP)// 创建数组object[]
    mv.visitInsn(Opcodes.ICONST_0)// 下标索引
    mv.visitVarInsn(Opcodes.ALOAD, 1) //获取局部变量表中该object对象
    mv.visitInsn(Opcodes.AASTORE) //存入数组中


  • 插入的热修代理方法示例



@JvmStatic
fun proxyVoid4Para(
param1: Any?,
param2: Any?,
param3: Any?,
param4: Any?,
obj: Any?,
cls: Class<*>,
methodNumber: Int
)
: Boolean {
return proxy(arrayOf(param1, param2, param3, param4), obj, cls, methodNumber).isSupported
}

@JvmStatic
fun proxy4Para(param1: Any?, param2: Any?, param3: Any?, param4: Any?, obj: Any?, cls: Class<*>, methodNumber: Int): PatchProxyResult {
return proxy(arrayOf(param1, param2, param3, param4), obj, cls, methodNumber)
}


  • proxy方法传递的参数详解



    • 当前方法的参数

    • 当前类(用于查找当前类是否有热修对象)

    • 当前类对象(如果是静态方法则传null,用于对当前类非静态属性的访问)

    • 方法编号(用于匹配热修方法)




详解patch包插桩


每一个被修复的类(PatchTestAct)必然会插桩生成两个类:



  • Patch类(PatchTestActPatch),这个类中有修复方法

  • 一个控制类(实现ChangeQuickRedirect接口,PatchTestActPatchControl),分发执行Patch类中的修复方法


从上述PatchProxy.proxy方法中可以看出。所有被热修的类,会被存在一个重定向map中。执行proxy方法时,若表中有该被插桩类,则对应执行该插桩类的热修对象(ChangeQUickRedirect实现类对象),执行该对象的


accessDispatch方法。每个方法在base构建时都会有一个编号。热修对象通过传入的方法编号,确定最终执行的热修方法。


public interface ChangeQuickRedirect {

/**
* 将方法的执行分发到对应的修复方法
* @param methodName 被插桩的方法编号
* @param paramArrayOfObject 参数值列表
* @param obj 被插桩类对象
* @return
*/

Object accessDispatch(String methodNumber, Object[] paramArrayOfObject, Object obj);

/**
* 判断方法是否能被分发到对应的修复方法
*/

boolean isSupport(String methodNumber);

/** * 判断方法是否能被分发到对应的修复方法 */ boolean isSupport(String methodNumber);
}

如上述例子中,要热修该PatchTestAct2.test方法,对该方法加上@Modify注解后,进行热修patch构建后生成的PatchControl类和Patch类分别是:


public class PatchTestActPatchControl implements ChangeQuickRedirect {
public static final String MATCH_ALL_PARAMETER = "(\\w*\\.)*\\w*";
private static final Map<Object, Object> keyToValueRelation = new WeakHashMap();

public PatchTestActPatchControl() {
}

public Object accessDispatch(String methodNumber, Object[] paramArrayOfObject, Object var3) {
try {
PatchTestActPatch var4 = null;
if (var3 != null) {
if (keyToValueRelation.get(var3) == null) {
var4 = new PatchTestActPatch(var3);
keyToValueRelation.put(var3, (Object)null);
} else {
var4 = (PatchTestActPatch)keyToValueRelation.get(var3);
}
} else {
var4 = new PatchTestActPatch((Object)null);
}
if ("119".equals(methodNumber)){var4.invokeAddMethod((Context)paramArrayOfObject[0]);
}

if ("120".equals(methodNumber)) {
var4.test((String)paramArrayOfObject[0], (Function1)paramArrayOfObject[1]);
}

} catch (Throwable var7) {
var7.printStackTrace();
}

return null;
}

public boolean isSupport(String methodName) {
return ":119::120:".contains(":" + methodName + ":");
}

private static Object fixObj(Object booleanObj) {
if (booleanObj instanceof Byte) {
byte byteValue = (Byte)booleanObj;
boolean booleanValue = byteValue != 0;
return new Boolean(booleanValue);
} else {
return booleanObj;
}
}
// 看起来好像没有用到这个方法
public Object getRealParameter(Object var1) {
return var1 instanceof PatchTestAct ? new PatchTestActPatch(var1) : var1;
}
}

public class PatchTestActPatch {
PatchTestAct originClass;

/**
* 传入原始对象
*/

public PatchTestActPatch(Object var1) {
this.originClass = (PatchTestAct)var1;
}
/**
* 将所访问的变量做一个转换,如果访问的是当前类this,则需要转换为this.originClass对象
*/

public Object[] getRealParameter(Object[] var1) {
if (var1 != null && var1.length >= 1) {
Object[] var2 = (Object[])Array.newInstance(var1.getClass().getComponentType(), var1.length);

for(int var3 = 0; var3 < var1.length; ++var3) {
if (var1[var3] instanceof Object[]) {
var2[var3] = this.getRealParameter((Object[])var1[var3]);
} else if (var1[var3] == this) {
var2[var3] = this.originClass;
} else {
var2[var3] = var1[var3];
}
}
return var2;
} else {
return var1;
}
}

/**
* 被修复的方法
*/

public final void test(String str, Function1<? super String, Unit> a) {
String var3 = "str";
Object[] var5 = this.getRealParameter(new Object[]{str, var3});
Class[] var6 = new Class[]{Object.class, String.class};
EnhancedRobustUtils.invokeReflectStaticMethod("checkNotNullParameter", Intrinsics.class, var5, var6);
String var7 = "a";
Object[] var9 = this.getRealParameter(new Object[]{a, var7});
Class[] var10 = new Class[]{Object.class, String.class};
EnhancedRobustUtils.invokeReflectStaticMethod("checkNotNullParameter", Intrinsics.class, var9, var10);
Object[] var12 = this.getRealParameter(new Object[]{str});
Class[] var13 = new Class[]{Object.class};
Object var14;
if (a == this && 0 == 0) {
var14 = ((PatchTestActPatch)a).originClass;
} else {
var14 = a;
}

Object var10000 = (Object)EnhancedRobustUtils.invokeReflectMethod("invoke", var14, var12, var13, Function1.class);
}
}

每一个新增方法(在base包中不存在的方法):


对这个新增方法所在类打一个InlinePatch.class类,该类中定义这个新增方法


热修代码的处理过程


从字节码到patch.dex中


image.png


代码修复中解决的关键问题


本方案支持,方法修复、新增方法、新增类、新增属性、新增override方法。主要解决了以下问题:



  • 修复方法中对其他类属性、方法的调用

  • 修复代码中,存在调用base包中被删除的方法的指令

  • 修复代码中存在匿名内部类的生成和使用、when表达式与enum联用

  • 修复方法中存在调用父类方法的指令

  • 修复代码中存在invokeDynamic指令(单接口lambda表达式/函数式接口、高阶函数等)

  • 新增方法是override方法,并且使用其多态属性

  • 修复构造方法、新增构造方法

  • 修复方法有@JvmStatic注解,@JvmOverloads注解,这些注解方法被java 和kotlin调用不同而编译出不同的字节码

  • r8内联、外联、类合并等系列优化操作,使得编译结果与原始字节码有很大的差异


总结


本文所描述的代码修复方案,相对于美团原始方案做了较大优化,base插桩对插入指令做了精简,且不再对每个类插入属性用于判断当前类是否被热修,而是将被修复类的信息存在一个静态map中。patch插桩完全重新处理,大大拓展了可修复的范围,提高了热修工具可用性。后续也扩展支持了,通过字节码对比自动识别需要修复的代码,无需开发者手动标注。


除上文所述之外,热修也有一些其他方面值得讨论,热修sop、热修包的构建速度提升,以及热修包的下发和加载等。


参考:
github.com/Meituan-Dia…


作者:瑞英
来源:juejin.cn/post/7426988056635015206
收起阅读 »

协程:解锁 Android 开发的超级英雄技能!

开发 Android 应用时,是否有过这样的时刻? "我只是想请求个网络数据,为什么我的主线程就卡住了!" "多线程真香,但这锁和回调让我头都大了!" 别担心! 今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优...
继续阅读 »

开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!


🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。


🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"


🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。


🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!


以下是一篇轻松有趣、但同时技术性强的文章,主题是 "协程:解锁 Android 开发的超级英雄技能!"




协程:解锁 Android 开发的超级英雄技能!


开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!




🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。




🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"




🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。




🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!




🎮 协程的高阶玩法


1️⃣ 并发:一心多用


协程中的并发很简单,像玩双开游戏一样:


suspend fun loadData() = coroutineScope {
val data1 = async { fetchData() }
val data2 = async { fetchData() }
println("数据1: ${data1.await()}, 数据2: ${data2.await()}")
}

只要用了 async,就能并发运行多个任务,效率提升 N 倍!


2️⃣ 结构化并发:协程的守护者


协程不像传统线程那么“放飞自我”。当它的宿主(比如 CoroutineScope)取消了,所有子协程也会跟着取消。这种“组队行动”叫做结构化并发


coroutineScope {
launch { delay(1000); println("任务1完成") }
launch { delay(2000); println("任务2完成") }
println("等待任务完成")
}

coroutineScope 结束时,所有子任务都会自动完成或取消。


💡 协程的隐藏技能:Flow


如果协程是单任务英雄,那 Flow 就是它的“数据流忍术”。用 Flow,你可以优雅地处理连续的数据流,比如加载分页数据、实时更新状态。


fun fetchDataFlow(): Flow<String> = flow {
for (i in 1..5) {
delay(500)
emit("第 $i 条数据")
}
}

CoroutineScope(Dispatchers.Main).launch {
fetchDataFlow().collect { data ->
println(data) // 每次收到数据时打印
}
}

🧩 总结


协程就像一位超级英雄,它能:



  • 解决主线程阻塞的问题。

  • 简化复杂的异步操作。

  • 提供更高效、更安全的并发管理。


而它的乐趣在于:



  • 让开发者从回调地狱中解脱出来。

  • 代码更简洁、更易读,就像写同步代码一样。


如果你还没使用协程,试试吧!它会让你感受到开发 Android 应用的魔法力量!




“用协程开发,就像给代码装上了飞行装置,既轻松又高效!”

愿你的 Android 开发之路充满乐趣与协程的超能力! 😊


作者:DawnG
来源:juejin.cn/post/7444518315559714866
收起阅读 »

协程:解锁 Android 开发的超级英雄技能!

开发 Android 应用时,是否有过这样的时刻? "我只是想请求个网络数据,为什么我的主线程就卡住了!" "多线程真香,但这锁和回调让我头都大了!" 别担心! 今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优...
继续阅读 »

开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!


🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。


🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"


🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。


🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!


以下是一篇轻松有趣、但同时技术性强的文章,主题是 "协程:解锁 Android 开发的超级英雄技能!"




协程:解锁 Android 开发的超级英雄技能!


开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!




🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。




🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"




🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。




🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!




🎮 协程的高阶玩法


1️⃣ 并发:一心多用


协程中的并发很简单,像玩双开游戏一样:


suspend fun loadData() = coroutineScope {
val data1 = async { fetchData() }
val data2 = async { fetchData() }
println("数据1: ${data1.await()}, 数据2: ${data2.await()}")
}

只要用了 async,就能并发运行多个任务,效率提升 N 倍!


2️⃣ 结构化并发:协程的守护者


协程不像传统线程那么“放飞自我”。当它的宿主(比如 CoroutineScope)取消了,所有子协程也会跟着取消。这种“组队行动”叫做结构化并发


coroutineScope {
launch { delay(1000); println("任务1完成") }
launch { delay(2000); println("任务2完成") }
println("等待任务完成")
}

coroutineScope 结束时,所有子任务都会自动完成或取消。


💡 协程的隐藏技能:Flow


如果协程是单任务英雄,那 Flow 就是它的“数据流忍术”。用 Flow,你可以优雅地处理连续的数据流,比如加载分页数据、实时更新状态。


fun fetchDataFlow(): Flow<String> = flow {
for (i in 1..5) {
delay(500)
emit("第 $i 条数据")
}
}

CoroutineScope(Dispatchers.Main).launch {
fetchDataFlow().collect { data ->
println(data) // 每次收到数据时打印
}
}

🧩 总结


协程就像一位超级英雄,它能:



  • 解决主线程阻塞的问题。

  • 简化复杂的异步操作。

  • 提供更高效、更安全的并发管理。


而它的乐趣在于:



  • 让开发者从回调地狱中解脱出来。

  • 代码更简洁、更易读,就像写同步代码一样。


如果你还没使用协程,试试吧!它会让你感受到开发 Android 应用的魔法力量!




“用协程开发,就像给代码装上了飞行装置,既轻松又高效!”

愿你的 Android 开发之路充满乐趣与协程的超能力! 😊


作者:DawnG
来源:juejin.cn/post/7444518315559714866
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »

耗时三个月,我高仿了一个起点小说阅读器

前言 起因是最近看小说的APP广告越来越多,但不少书源内容也时常出现问题。正好摆烂太久让我很有负罪感,就想着趁着这个契机学点新的东西。公司里用的都是vue技术栈,所以我想着用vue3做个小项目,顺便熟悉一下vue3的语法。从八月开始,断断续续搞了点Demo,直...
继续阅读 »

前言


起因是最近看小说的APP广告越来越多,但不少书源内容也时常出现问题。正好摆烂太久让我很有负罪感,就想着趁着这个契机学点新的东西。公司里用的都是vue技术栈,所以我想着用vue3做个小项目,顺便熟悉一下vue3的语法。从八月开始,断断续续搞了点Demo,直到年底稍微有点空闲,才开始着手把整个项目完善起来。


项目地址


github



gitee



支持平台


平台是否支持
H5
Android
IOS
小程序需要修改renderjs

项目介绍


eReader 是一款基于 uni-app 开发的小说阅读器,功能完善,使用便捷,支持跨平台部署。移动端完全由前端实现,无需后端支持,打包后即为一个独立的APP,极大降低了部署和维护成本。H5端由于跨域问题需要启用一个简单的后端服务器,但移动端打包后完全开箱即用。


技术架构与部署



  • uni-app 的跨平台特性使得该项目在移动端和H5端之间无缝切换,移动端是纯前端实现,不依赖额外的服务器。

  • H5端需要启用后端服务器来解决跨域问题,但移动端完全是前端应用,避免了额外的服务器负担,极大简化了部署和维护流程。

  • 使用技术栈如下



    1. Vue3 + TypeScript:项目基于Vue3和TypeScript实现。

    2. Node + Express:H5端使用Node + Express搭建了一个简单的后台,负责爬取数据。

    3. uni.request:在APP端通过uni.request获取数据,不用启用后端应用。

    4. Cheerio:用Cheerio来解析HTML,提取书籍信息。

    5. uni.setStorage:数据缓存使用了uni.setStorage存储。

    6. 阅读引擎:主要是用 canvas.measureText 来计算文本宽度,通过JS计算宽高分页,支持两端对齐、标点避头等排版优化。

    7. 分页:分页计算用了uni-app的 renderjs 操作Canvas, uni.createCanvasContext 在APP端性能表现不佳,应尽量避免使用。

    8. 海报分享:海报分享功能使用了 limeui-painter




平台功能



  • 丰富的书源:内置多个书源,满足大多数阅读需求,并支持灵活切换。

  • 全面的功能:包括书架管理、小说搜索、阅读器设置(夜间模式、字体、背景主题、翻页方式)、章节缓存等,功能齐全。

  • 个性化体验:支持书签、目录跳转、缓存、夜间模式等用户自定义设置。

  • 逻辑闭环:书源管理、阅读设置、书签等功能平滑切换,确保使用流畅、体验一致。

  • 详细功能列表



    1. 书架:可以加入/移除书架、置顶小说、分享(APP端)、查看详情、搜索、小说排序和浏览历史等功能。

    2. 分组:可以管理小说分组,支持新增、删除、修改、置顶等操作。

    3. 精选推荐:集成了 夸克热搜 的书单推荐,帮助大家发现热门书籍。

    4. 我的:包括书源管理、浏览历史、夜间模式、关于、意见反馈、缓存清除和分享等设置。

    5. 小说搜索:内置了 12 个书源,基本能满足大部分人的阅读需求。

    6. 书籍详情:展示书籍信息、简介、目录等,支持分享功能。

    7. 阅读器:支持添加/移除书架、添加/删除书签、查看目录、白天/夜间模式切换、翻页方式、字号和背景主题切换等多项个性化设置。此外,还支持其余书源切换章节缓存(包括缓存全部、缓存后20章和缓存当前章节后的所有章节)。

    8. 目录:支持目录查看、缓存状态、书签、章节跳转、快速跳转(比如去当前章节、去底部)等功能。




项目结构


|-- undefined
|-- .prettierignore
|-- .prettierrc.js
|-- index.html
|-- package.json
|-- tsconfig.json
|-- vite.config.ts
|-- src
|-- App.vue
|-- env.d.ts
|-- main.ts
|-- manifest.json
|-- pages.json
|-- type.d.ts
|-- uni.scss
|-- api #请求接口
| |-- common.ts
|-- components
| |-- BookTip.vue #阅读页第一次打开提示
| |-- Expand.vue #书籍详情简介收起与展开
| |-- share.vue #分享组件
| |-- TabBar.vue #重写tabbar,没使用uni自带tabbar
| |-- global #全局组件
| | |-- g-confirm.vue #确认和输入弹窗
| | |-- g-icon-fonts.vue #图标
| | |-- g-page.vue #每个页面根元素,主要是做主题切换,设置全局css样式(uniapp的APP.vue没有根元素)
| | |-- g-popup.vue #底部和中间弹窗封装
| | |-- g-statusbar.vue #顶部statusbar占位组件,h5端高度为0,app端有默认高度
| |-- painter #海报绘制组件
| |-- popover #书架排序气泡窗
|-- directives #vLongPress指令封装
| |-- index.ts
|-- pages
| |-- blank #我的-跳转页面
| | |-- about.vue #关于我们
| | |-- agreement.vue #用户协议
| | |-- feedback.vue #意见反馈
| | |-- history.vue #浏览历史
| | |-- origin.vue #书源管理
| | |-- policy.vue #隐私政策
| |-- bookDetail #书籍详情页
| |-- catalogs #目录页
| |-- groupDetail #分组详情页
| |-- reader #阅读器
| | |-- index.vue
| | |-- index_v1.vue #第一版,使用columns布局分页
| | |-- index_v2.vue #第二版,使用canvas.measureText计算宽度,js计算宽高进行分页(算法不完善,可以看看思路)
| | |-- readerLayout.ts #第三版,感谢 [@前端一锅煮] 大佬的分享
| | |-- components
| | |-- Origin.vue #换源组件
| | |-- Renderjs.vue #使用uniapp的rendejs获取 document 文档对象
| | |-- Renderjs_v2.vue #第二版renderjs
| |-- search #搜索页
| |-- tabBar #自定义tabbar
| |-- book.vue #精选
| |-- home.vue #书架
| |-- personal.vue #我的
| |-- components
| |-- addGr0up.vue #书架、分组详情里[移至分组]功能
| |-- bookDetail.vue #书架、分组详情里长按展示详情功能
| |-- groupItem.vue #分组项
|-- parser #app端数据解析
| |-- catalog.ts #目录解析
| |-- content.ts #章节内容解析
| |-- index.ts
| |-- search.ts #搜索内容解析
| |-- source.ts #内置书源
| |-- top.ts #精选内容解析
|-- static
|-- store #store
| |-- AppOption.ts #app的系统信息
| |-- index.ts #一些缓存相关数据处理:书架、历史、缓存章节、搜索历史等
|-- styles
|-- types
|-- utils
|-- Config.ts
|-- Control.ts
|-- index.ts
|-- request.ts #请求处理和响应拦截
|-- RequestHeader.ts #最初是想伪造请求头的,但是uni的app端ua固定了

后续功能优化



  • 错误处理:当前未处理极端情况下的错误请求,导致产品在特定条件下可能不够健壮,后续会加强异常处理。

  • 网络字体支持:项目打包后APK约15MB,内置字体包增大了文件体积,后续会考虑支持网络字体加载以实现更丰富的阅读体验。

  • 书源导入与更新:第三方书源存在不稳定性,网站变动可能导致解析错误。后续会考虑支持书源离线导入和在线更新,有助于解决此问题。

  • 听书功能:作为干眼症患者,听书功能对我来说还是非常重要的,未来计划加入该功能。

  • 去除广告:第三方书源可能包含广告和无关链接,影响阅读体验。后续考虑支持长按选择内容去除,并应用到所有章节,将极大提升阅读质量。


项目展示


h5表现



  • 书架
    PixPin_2025-01-15_11-11-06.gif

  • 精选


PixPin_2025-01-15_11-33-27.gif



  • 我的
    PixPin_2025-01-15_11-23-31.gif

  • 搜索


PixPin_2025-01-15_11-35-22.gif



  • 详情


PixPin_2025-01-15_13-47-58.gif



  • 阅读器


PixPin_2025-01-15_13-51-20.gif


app端表现(IOS)



Android端未完整测试,可能存在部分兼容问题




  • 书架(亮)
    书架.jpg

  • 搜索(亮)
    081215e1ebc858e4a3e2bb6b25e7591.jpg

  • 书源管理(亮)
    97a215463434014bfca7a9e306758bb.jpg

  • 我的(亮)
    c24b6f22642b73a89fb29c61c486eda.jpg

  • 浏览历史(亮)


5378049e7f666f069a3c7893964859f.jpg



  • 分组(暗)


647cdc2d9ef238c763c0481ac3f4dd6.jpg



  • 分组详情(暗)
    605e704b77169fcfd8f6d7c75b06974.jpg

  • 我的(暗)
    0d43113e996a69f52d58813504784b5.jpg

  • 意见反馈(暗)
    ea5984628d3013dab64992e49b72e0c.jpg

  • 详情(暗)
    8c2feac4fb0a99b3be6ed359c91cdad.jpg

  • 分享


325a55ea810dde156bf03c8e9acfd1f.jpg



  • 阅读器


ios.gif


总结



  • 最初只是为了学习新技术栈,项目框架、组件设计没考虑太多。但随着功能的增加,组件复用和方法抽象的需求变得明显,过程中也渐渐感觉到有些力不从心。

  • 尽管仍有一些缺漏,但是整体来看来这个项目已经勉强算得上是一个完整的、功能闭环的产品。作为一个人独立完成,自己也算是比较满意了。

  • 开发过程中遇到了不少挑战,比如阅读器排版引擎就经历了三次重构,才最终达到了理想效果。那段时间搞得头都要秃了(本来所剩无几的发量越加稀少)。 后续会写写教程,记录下开发过程中遇到的坑。


相关


水了几篇文章,回家过年咯(逃~)



参考


感谢下面两位大佬的文章



作者:何日
来源:juejin.cn/post/7460023342592901183
收起阅读 »

一个Kotlin版Demo带你入门JNI,NDK编程

Android 越往深处研究,必然离不开NDK,和JNI相关知识 一、前言 Android开发中,最重要的一项技能便是NDK开发,它涉及到JNI,C,C++等相关知识 我们常见的MMKV,音视频库FFmpeg等库的应用,都有相关这方面的知识,它是Androi...
继续阅读 »

u=2801888995,840623646&fm=253&fmt=auto&app=138&f=PNG.webp



Android 越往深处研究,必然离不开NDK,和JNI相关知识



一、前言


Android开发中,最重要的一项技能便是NDK开发,它涉及到JNI,C,C++等相关知识

我们常见的MMKV,音视频库FFmpeg等库的应用,都有相关这方面的知识,它是Android开发人员通往深水区的一张门票。


本文我们就简单介绍JNI,NDK的相关入门知识:

1. JNI方法注册(静态注册,动态注册)

2. JNI的基础数据类型

3. JNI引用数据类型

4. JNI函数签名信息

5. JNIEnv的介绍

6. JNI编译之Cmake




7. 示例:获取JNI返回字符串(静态注册)

8. 示例:调用JNI,JNI调用Java层无返回值方法(静态注册)

9. 示例:调用JNI,JNI调用Java层无返回值方法(带参数)(静态注册)

10. 示例:调用JNI去调用java方法(静态注册)

11. 示例:调用JNI去调用java 变量值(静态注册)

12. 示例:去调用JNI去调用Java带callback方法,带参数(静态注册)

13. 示例:动态注册


二、基础介绍


1. JNI是什么? JNI(Java Native Interface),它是提供一种Java字节码调用C/C++的解决方案,JNI描述的是一种技术。


a63332d245e450bd38b7b571c4988397_webp.webp

2. NDK是什么? NDK(Native Development Kit)

Android NDK 是一组允许您将 C 或 C++(“原生代码”)嵌入到 Android 应用中的工具,NDK描述的是工具集。 能够在 Android 应用中使用原生代码对于想执行以下一项或多项操作的开发者特别有用:



  • 在平台之间移植其应用。

  • 重复使用现有库,或者提供其自己的库供重复使用。

  • 在某些情况下提高性能,特别是像游戏这种计算密集型应用。


3. JNI方法静态注册:

JNI函数名格式(需将”.”改为”—”):

Java_ + 包名(com.example.auto.jnitest)+ 类名(MainActivity) + 函数名(stringFromJNI)


静态方法的缺点:



  • 要求JNI函数的名字必须遵循JNI规范的命名格式;

  • 名字冗长,容易出错;

  • 初次调用会根据函数名去搜索JNI中对应的函数,会影响执行效率;

  • 需要编译所有声明了native函数的Java类,每个所生成的class文件都要用javah工具生成一个头文件;


4. JNI方法动态注册:

Java与JNI通过JNINativeMethod的结构来建立函数映射表,它在jni.h头文件中定义,其结构内容如下:


typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;

创建映射表后,调用RegisterNatives函数将映射表注册给JVM;

当Java层通过System.loadLibrary加载JNI库时,会在库中查JNI_OnLoad函数。可将JNI_OnLoad视为JNI库的入口函数,需要在这里完成所有函数映射和动态注册工作,及其他一些初始化工作。


5. JNI的基础数据类型对照表:


Java类型JNI类型描述
boolean(布尔型)jboolean无符号8位
byte(字节型)jbyte有符号8位
char(字符型)jchar无符号16位
short(短整型)jshort有符号16位
int(整型)jint有符号32位
long(长整型)jlong有符号64位
foat(浮点型)jfloat32位
double(双精度浮点型)jdouble64位

6. JNI引用数据类型对照表:


Java引用类型JNI类型Java引用类型JNI类型
All objectsjobjectchar[ ]jcharArray
java.lang.Classjclassshort[ ]jshortArray
java.lang.Stringjstringint[]jintArray
java.lang.Throwablejthrowablelong[ ]jlongArray
Object[ ]jobjectArrayfloat[]jfloatArray
boolean[ ]jbooleanArraydouble[ ]jdoubleArray
byte[ ]jbyteArray

7. JNI函数签名信息

由于Java支持函数重载,因此仅仅根据函数名是没法找到对应的JNI函数。为了解决这个问题,JNI将参数类型和返回值类型作为函数的签名信息。


JNI规范定义的函数签名信息格式:  (参数1类型字符…)返回值类型字符


函数签名例子:


308c38d80edf5aac76fbfdd0f8d07548_webp.webp


JNI常用的数据类型及对应字符对照表:


Java类型字符
voidV
booleanZ (容易误写成B)
intI
longJ (容易误写成L)
doubleD
floatF
byteB
charC
shortS
int[ ][I (数组以"["开始)
StringLjava/lang/String; (引用类型格式为”L包名类名;”,要记得加";")
Object[][Ljava/lang/object;

8. JNIEnv的介绍



  1. JNIEnv概念 : JNIEnv是一个线程相关的结构体, 该结构体代表了 Java 在本线程的运行环境。通过JNIEnv可以调用到一系列JNI系统函数。

  2. JNIEnv线程相关性: 每个线程中都有一个 JNIEnv 指针。JNIEnv只在其所在线程有效, 它不能在线程之间进行传递。



注意:在C++创建的子线程中获取JNIEnv,要通过调用JavaVM的AttachCurrentThread函数获得。在子线程退出时,要调用JavaVM的DetachCurrentThread函数来释放对应的资源,否则会出错。



9. JNI编译之Cmake



CMake 则是一个跨平台的编译工具,它并不会直接编译出对象,而是根据自定义的语言规则(CMakeLists.txt)生成 对应 makefile 或 project 文件,然后再调用底层的编译, 在Android Studio 2.2 之后支持Cmake编译。




  • add_library 指令

    语法:add_library(libname [SHARED | STATIC | MODULE] [EXCLUDE_FROM_ALL] [source])

    将一组源文件 source 编译出一个库文件,并保存为 libname.so (lib 前缀是生成文件时 CMake自动添加上去的)。其中有三种库文件类型,不写的话,默认为 STATIC;



    • SHARED: 表示动态库,可以在(Java)代码中使用 System.loadLibrary(name) 动态调用;

    • STATIC: 表示静态库,集成到代码中会在编译时调用;

    • MODULE: 只有在使用 dyId 的系统有效,如果不支持 dyId,则被当作 SHARED 对待;

    • EXCLUDE_FROM_ALL: 表示这个库不被默认构建,除非其他组件依赖或手工构建;




#将compress.c 编译成 libcompress.so 的共享库
add_library(compress SHARED compress.c)


  • target_link_libraries 指令 语法:target_link_libraries(target library <debug | optimized> library2…)  这个指令可以用来为 target 添加需要的链接的共享库,同样也可以用于为自己编写的共享库添加共享库链接。如:


#指定 compress 工程需要用到 libjpeg 库和 log 库
target_link_libraries(compress libjpeg ${log-lib})


  • find_library 指令 语法:find_library( name1 path1 path2 ...)  VAR 变量表示找到的库全路径,包含库文件名 。例如:


find_library(libX  X11 /usr/lib)
find_library(log-lib log) #路径为空,应该是查找系统环境变量路径

示例工程Cmake截图如下:


ca8c9dfcd9a9697885245bb1e54410a.png


三、示例工程代码


示例工程截图:


fc733d67be28090d46db73b44785f42.png


示例MainActivity内需要加载SO:


companion object {
// Used to load the 'native_kt_demo' library on application startup.
init {
System.loadLibrary("native_kt_demo")
}
}

1. 示例:获取JNI返回字符串(静态注册)


Kotlin 代码


external fun stringFromJNI(): String

JNI层下代码


//extern "C" 避免编绎器按照C++的方式去编绎C函数
extern "C"
//JNIEXPORT :用来表示该函数是否可导出(即:方法的可见性
//1、宏 JNIEXPORT 代表的就是右侧的表达式: __attribute__ ((visibility ("default")))
//2、或者也可以说: JNIEXPORT 是右侧表达式的别名;
//3、宏可表达的内容很多,如:一个具体的数值、一个规则、一段逻辑代码等
JNIEXPORT
//jstring 代表方法返回类型为Java中的 String
jstring
//用来表示函数的调用规范(如:__stdcall)
JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}

2. 示例:调用JNI,JNI调用Java层无返回值方法(静态注册)

Kotlin代码:


external fun callJNI()

JNI层代码:


extern "C" JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callJNI(JNIEnv *env, jobject thiz) {
LOGE("-----静态注册 , 无返回值方法 调用成功-----");
jclass js = env->GetObjectClass(thiz);
jmethodID jmethodId = env->GetMethodID(js, "toast", "(Ljava/lang/String;)V");
env->CallVoidMethod(thiz, jmethodId, env->NewStringUTF("静态注册 无返回值方法 调用成功"));
}

3. 示例:调用JNI,JNI调用Java层无返回值方法(带参数)(静态注册)

Kotlin代码:


external fun stringFromJNIwithParameter(str: String): String

JNI层代码:


extern "C" JNIEXPORT jstring JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_stringFromJNIwithParameter(JNIEnv *env, jobject thiz, jstring str)
{
const char *data = env->GetStringUTFChars(str, NULL);
LOGE("-----获取到Java 传来的数据:data %s-----", data);
env->ReleaseStringChars(str, reinterpret_cast<const jchar *>(data));
const char *src = "111---";
const int size = sizeof(data) + sizeof(src);
char datares[size] = "111---";
return env->NewStringUTF(strcat(datares, data));
}

4. 示例:调用JNI去调用java方法(静态注册)

Kotlin代码:


external fun callNativeCallJavaMethod()

JNI层代码:


extern "C"
JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callNativeCallJavaMethod(JNIEnv *env, jobject thiz) {
jclass js = env->GetObjectClass(thiz);
jmethodID jmethodId = env->GetMethodID(js, "toast", "(Ljava/lang/String;)V");
env->CallVoidMethod(thiz, jmethodId, env->NewStringUTF("jni 通过反射调用 java toast方法"));
}

5. 示例:调用JNI去调用java 变量值(静态注册)


Kotlin代码:


external fun callNativeCallJavaField()

JNI层代码:


extern "C"
JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callNativeCallJavaField(JNIEnv *env, jobject thiz) {
jclass js = env->GetObjectClass(thiz);
jfieldID jfieldId = env->GetFieldID(js, "androidData", "Ljava/lang/String;");
jstring newDataValue = env->NewStringUTF("四海一家");
// jclass js = env->GetObjectClass(thiz);
jmethodID jmethodId = env->GetMethodID(js, "toast", "(Ljava/lang/String;)V");
env->CallVoidMethod(thiz, jmethodId, newDataValue);
// env->SetObjectField(thiz, jfieldId, newDataValue);
}

6. 示例:去调用JNI去调用Java带callback方法,带参数(静态注册)

Kotlin代码:


external fun callNativeWithCallBack(callBack: NativeCallBack)

JNI层代码:


extern "C"
JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callNativeWithCallBack(JNIEnv *env, jobject thiz, jobject call_back) {
LOGE("-----静态注册 , callback 调用成功-----");
jclass js = env->GetObjectClass(call_back);
jmethodID jmethodId = env->GetMethodID(js, "nmd", "(Ljava/lang/String;)V");
env->CallVoidMethod(call_back, jmethodId, env->NewStringUTF("我是Jni Native层callBack回调回来的数据值"));
}

7. 示例:动态注册
Kotlin代码:


external fun dynamicRegisterCallBack(callBack: NativeCallBack)

JNI层代码:



void regist(JNIEnv *env, jobject thiz, jobject call_back) {
LOGD("--动态注册调用成功-->");
jclass js = env->GetObjectClass(call_back);
jmethodID jmethodId = env->GetMethodID(js, "nmd", "(Ljava/lang/String;)V");
env->CallVoidMethod(call_back, jmethodId, env->NewStringUTF("我是Jni Native层动态注册callBack回调回来的数据值"));
}

jint RegisterNatives(JNIEnv *env) {
jclass activityClass = env->FindClass("com/wx/nativex/kt/demo/MainActivity");
if (activityClass == NULL) {
return JNI_ERR;
}
JNINativeMethod methods_MainActivity[] = {
{
"dynamicRegisterCallBack",
"(Lcom/wx/nativex/kt/demo/NativeCallBack;)V",
(void *) regist
}
};

return env->
RegisterNatives(activityClass, methods_MainActivity,
sizeof(methods_MainActivity) / sizeof(methods_MainActivity[0]));
}


//JNI_OnLoad java
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
LOGE("-----JNI_OnLoad 方法调用了-----");
JNIEnv *env = NULL;
if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}

jint result = RegisterNatives(env);
// 函数注册
return JNI_VERSION_1_6;
}

总结


本文简单介绍了NDK编程中JNI的基础:并写了相关示例Demo代码



  1. JNI方法注册(静态注册,动态注册)

  2. JNI的基础数据类型

  3. JNI引用数据类型

  4. JNI函数签名信息

  5. JNIEnv的介绍

  6. JNI编译之Cmake

  7. 示例:获取JNI返回字符串(静态注册)

  8. 示例:调用JNI,JNI调用Java层无返回值方法(静态注册)

  9. 示例:调用JNI,JNI调用Java层无返回值方法(带参数)(静态注册)

  10. 示例:调用JNI去调用java方法(静态注册)

  11. 示例:调用JNI去调用java 变量值(静态注册)

  12. 示例:去调用JNI去调用Java带callback方法,带参数(静态注册)

  13. 示例:动态注册


感谢阅读:


欢迎用你发财的小手: 点点赞、收藏收藏,或者 关注关注


这里你会学到不一样的东西


项目地址


Gitee地址

Github地址


作者:Wgllss
来源:juejin.cn/post/7452181029996380171
收起阅读 »

Android 车载应用开发——「RecyclerView」

前言 实践是最好的学习方式,技术也如此。 一、简介 RecyclerView 是列表; 好处:更高效率的列表控件; 用法:重点 RecycleerView.Adapter 的写法;可以通过 LayoutManager(布局管理器)来决定布局的样式,是线性...
继续阅读 »

前言



实践是最好的学习方式,技术也如此。



一、简介



  • RecyclerView 是列表;

  • 好处:更高效率的列表控件;

  • 用法:重点 RecycleerView.Adapter 的写法;可以通过 LayoutManager(布局管理器)来决定布局的样式,是线性的、网格列表还是瀑布流列表;

  • RecyclerView 列表是如何实现显示的 ?

    • 是将数据放到对应的位置上,根据数据内容的数量来显示(即告诉列表有多少个条目) ;




二、Adapter



  • 是什么



    • 适配器、连接器;



  • 为什么要有 Adapter



    • 列表中不只有一条数据,不像 TextViewImageView 一样,一个控件对应一条数据;

    • 列表形式的数据,如何将多个布局与多个数据连接起来?中间就通过 adapter,将数据放到对应的控件的位置;



  • Adapter 的分类



    • ArrayAdapter:简单列表;

    • SimpleAdapter:图文列表;

    • BaseAdapter:图文复杂列表 ;





三、示例


1、背景



用 RecyclerView 列表显示各个城市天气数据



2、代码



  • FutureWeatherAdapter 是一个自定义的适配器类,它继承自 RecyclerView.Adapter 类;在泛型参数中,指定了一个内部类 WeatherViewHolder 作为适配器的视图持有者



    • WeatherViewHolder 是用于在 RecyclerView 中显示每个天气数据的视图持有者类;

    • 通常情况下,你会在适配器内部定义一个继承自 RecyclerView.ViewHolder 的内部类来表示列表项的视图结构和布局



  • onCreateViewHolder() 方法用于创建 ViewHolder,即创建用于显示单个天气条目的视图,并返回 ViewHolder 对象;使用布局填充器从 XML 布局文件中实例化视图,并将其传递给自定义的 ViewHolder 对象。



    • 在创建新的 ViewHolder 实例时调用。当 RecyclerView 需要显示新的列表项时,会调用该方法来创建一个 ViewHolder 对象 ;

    • onCreateViewHolder() 返回的 ViewHolder 对象会被 RecyclerView 用于显示列表项。当 RecyclerView 需要显示新的列表项时,它会调用 onCreateViewHolder() 方法来创建一个新的 ViewHolder 对象,并将其返回



  • onBindViewHolder() 方法用于将数据绑定到 ViewHolder 上,即将具体的天气数据填充到对应的视图控件中。在这个方法中,获取当前位置的天气数据对象,然后将其属性分别设置到 ViewHolder 中的各个 TextView 和 ImageView 中;



    • 方法在 RecyclerView 需要将数据绑定到 ViewHolder 以显示新的列表项时被调用。当 RecyclerView 中的列表项需要更新或者需要显示新的列表项时,会调用该方法;



  • getItemCount() 方法用于获取数据集中的条目数,即天气数据列表的大小;



    • getItemCount() 方法返回的数据会告诉 RecyclerView 有多少个列表项需要在屏幕上显示。当 RecyclerView 需要确定列表的大小时,它会调用 getItemCount() 方法



  • 内部类 WeatherViewHolder 继承自 RecyclerView.ViewHolder,用于持有每个天气条目的视图控件的引用;在构造方法中,通过传入的视图参数找到并引用了各个视图控件;



    public class FutureWeatherAdapter extends RecyclerView.Adapter<com.example.weatherapp.adapter.FutureWeatherAdapter.WeatherViewHolder> {
    private Context mContext; // 上下文
    private List<DayWeatherBean> mWeatherBeans; // 数据

    public FutureWeatherAdapter(Context mContext, List<DayWeatherBean> mWeatherBeans) {
    this.mContext = mContext;
    this.mWeatherBeans = mWeatherBeans;
    }

    // 先创建ViewHolder再将数据绑定
    @NonNull
    @Override
    public WeatherViewHolder onCreateViewHolder(@NonNull ViewGr0up parent, int viewType) {
    // onCreateViewHolder()方法负责创建ViewHolder并将其返回给RecyclerView
    View view = LayoutInflater.from(mContext).inflate(R.layout.weather_item_layout, parent, false); // 布局
    WeatherViewHolder weatherViewHolder = new WeatherViewHolder(view);
    return weatherViewHolder;
    }


    @Override
    public void onBindViewHolder(@NonNull WeatherViewHolder holder, int position) {
    // onBindViewHolder()方法负责将数据绑定到ViewHolder
    // holder: 表示要绑定的ViewHolder对象,position: 表示ViewHolder在RecyclerView中的位置
    // onBindViewHolder()方法负责将数据填充到ViewHolder的视图中
    // 它会被调用多次,每次RecyclerView需要显示一个新的ViewHolder时都会调用
    DayWeatherBean weatherBean = mWeatherBeans.get(position); // 拿到当前位置的JavaBean对象
    holder.tvWeather.setText(weatherBean.getWea());
    holder.tvTem.setText(weatherBean.getTeamDay());
    holder.tvAir.setText(weatherBean.getWin_speed());
    holder.tvWin.setText(weatherBean.getWin());
    holder.tvTemLowHigh.setText(weatherBean.getTeamNight());
    holder.ivWeather.setImageResource(getImgResOfWeather(weatherBean.getWeaImg()));
    }

    // 总共有多少个条目
    @Override
    public int getItemCount() {
    return (mWeatherBeans == null) ? 0 : mWeatherBeans.size();
    }

    class WeatherViewHolder extends RecyclerView.ViewHolder {
    TextView tvWeather, tvTem, tvTemLowHigh, tvWin, tvAir;
    ImageView ivWeather;

    public WeatherViewHolder(@NonNull View itemView) {
    super(itemView);

    tvWeather = itemView.findViewById(R.id.tv_weather);
    tvAir = itemView.findViewById(R.id.air);
    tvTem = itemView.findViewById(R.id.tv_tem);
    tvTemLowHigh = itemView.findViewById(R.id.tv_tem_low_high);
    tvWin = itemView.findViewById(R.id.tv_win);
    ivWeather = itemView.findViewById(R.id.iv_weather);
    }
    }



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

花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路

前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成一、前言本文介绍思路:本文重点介绍思路:四种方式花式解决Repository中模版式的代码,逐级递增1.1 :涉及...
继续阅读 »

e1ff3706ea196f758818da129df6de53.png

前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。

注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成

一、前言

  1. 本文介绍思路:
    本文重点介绍思路:四种方式花式解决Repository中模版式的代码,逐级递增
    1.1 :涉及到Kotlin协程Flow、viewModel、Retrofit、Okhttp相关用法
    1.2 :涉及到注解反射泛型注解处理器相关用法
    1.3 :涉及到动态代理kotlinsuspend方法反射调用及反射中异常处理
    1.4 :本示例4个项目如图:

    380Xt8NSYZ.jpg

  2. 网络框架搭建的封装,到目前为止最为流行又很优雅的的是 Kotlin+协程+Flow+Retrofit+OkHttp+Repository
  3. 先来看看中间各个类的职责: whiteboard_exported_image.png
  4. 从上图可以看出单一职责:

    NetApi: 负责网络接口配置,包括 请求地址,请求头,请求方式,参数等等所有配置

    Flow+Retrofit+Okhttp: 联合起来负责把 NetApi 中的各种配置组装成网络请求行为,并且通过Flow 组装成流,通过它可以控制该行为的异步方式,异步开始结束等等一系列的流行为。

    Repository: 负责 Flow+Retrofit+Okhttp 请求结果的数据流,进行加工处理成我们想要的数据,大多数不需要处理的,可以直接给到 ViewModel

    ViewModel: 负责调用 Repository,拿到想要的数据然后提供给UI方展示使用或者相关使用

    也可以看到 它的 持有链 从右向左 一条线性持有:ViewModel 持有 RepositoryRepository持有 Flow+Retrofit+Okhttp ,Flow+Retrofit+Okhttp 持有 NetApi

  5. 最终我们可以得到:
    5.1. 网络请求行为 会根据 NetApi 写出模板式的代码,这块解决模版式的代码在 Retrofit 中它通过动态代理,把所有模版式的代码统一成了一个
    5.2. 同理:Repository 也是根据 NetApi 配置的接口,写成模版式的代码转换成流

二、花式封装(一)

  1. NetApi 的配置:
interface NetApi {

// 示例get 请求
@GET("https://www.wanandroid.com/article/list/0/json")
suspend fun getHomeList(): CommonResult

// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") a: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") f: Float): CommonResult

// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList2222(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList3333(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList5555(@Path("path") page: Int, @Query("d") ss: String, @HeaderMap map: Map): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList6666(
@Path("path") page: Int,
@Query("d") float: Float,
@Query("d") long: Long,
@Query("d") double: Double,
@Query("d") byte: Byte,
@Query("d") short: Short,
@Query("d") char: Char,
@Query("d") boolean: Boolean,
@Query("d") string: String,
@Body body: RequestBodyWrapper
): CommonResult

//示例post 请求
@FormUrlEncoded
@POST("https://www.wanandroid.com/user/register")
suspend fun register(
@Field("username") username: String,
@Field("password") password: String,
@Field("repassword") repassword: String
): String
/************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/


// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded") //todo 固定 header
@POST("https://xxxxxxx")
suspend fun post1(@Body body: RequestBody): String

// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("https://xxxxxxx22222")
suspend fun post12(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写

suspend fun post1222(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
}

2. NetRepository 中是 根据 NetApi 写出下面类似的全模版式的代码:都是返回 Flow 流

class NetRepository private constructor() {
val service by lazy { RetrofitUtils.instance.create(NetApi::class.java) }

companion object {
val instance by lazy { NetRepository() }
}

// 示例get 请求
fun getHomeList() = flow { emit(service.getHomeList()) }

// 示例get 请求2
fun getHomeList(page: Int) = flow { emit(service.getHomeList(page)) }

fun getHomeList(page: Int, a: Int) = flow { emit(service.getHomeList(page, a)) }

fun getHomeList(page: Int, f: Float) = flow { emit(service.getHomeList(page, f)) }

// 示例get 请求2
fun getHomeList2222(page: Int) = flow { emit(service.getHomeList2222(page)) }

fun getHomeList3333(page: Int) = flow { emit(service.getHomeList3333(page)) }

fun getHomeList5555(page: Int, ss: String, map: Map<String, String>) = flow { emit(service.getHomeList5555(page, ss, map)) }

fun getHomeList6666(
page: Int, float: Float, long: Long, double: Double, byte: Byte,
short: Short, char: Char, boolean: Boolean, string: String, body: RequestBodyWrapper
)
= flow {
emit(service.getHomeList6666(page, float, long, double, byte, short, char, boolean, string, body))
}

fun register(username: String, password: String, repassword: String) = flow { emit(service.register(username, password, repassword)) }

//
// /************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
//
//
fun post1(body: RequestBody) = flow { emit(service.post1(body)) }

fun post12(body: RequestBody, map: Map<String, String>) = flow { emit(service.post12(body, map)) }

fun post1222(id: Long, asr: String) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
emit(service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader))
}
}

3. viewModel 调用端:

class MainViewModel : BaseViewModel() {

private val repository by lazy { NetRepository.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "one 111 ${it.data?.datas!![0].title}")
}
}
}
}

—————————————————我是分割线君—————————————————

  1. 上面花式玩法(一): 此种写法被广泛称作 最优雅的一套网络封装 框架,

    绝大多数中、大厂 基本也就封装到此为止了

    可能还有些人想着:你的 repository 中就返回了 Flow , 里面就全是简单的 emit(xxx) ,我项目里面不是这样的,我的还封装了成功,失败,或者其他的,但总体还是全是模版式的,除了特殊的一些方法,需要在请求前 ,请求后做些处理,有规律有模版的还是占大多数吧,只要大多数都一样的规律模版,都是可以处理的,里面稍微修改下细节,思路都是一样的。

    哪还能有什么玩法?

    可能会有人想到 借助 Hilt ,Dagger2 ,Koin 来创建 Retrofit,和创建 repository,创建 ViewModel 这里不是讨论依赖注入创建对象的事情

    哪还有什么玩法?

    有,必须有的。

三、花式封装(二)

  1. 既然上面是 Repository 类中,所有写法都是固定模版式的代码,那么让其根据 NetApi: 自动生成 Repository 类,我们这里借用注解处理器。
  2. 具体怎么使用介绍,请参考:
    注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成
  3. 本项目中只需要编译 app_wx2 工程
  4. 在下图中找到

img_v3_02f0_d5bd4278-53ac-4008-aac2-abcfdf81668g.jpg 5. viewModel调用端

class MainViewModel : BaseViewModel() {

private val repository by lazy { RNetApiRepository() }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "two 222 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

6. 如果 Repository 中某个接口方法需要特殊处理怎么办?比如下图,请求前处理一下,从 拿到数据后我需要再次转化处理之后再给到 viewModel 怎么办?

//我这个接口 ,请求前需要 判断处理一下,拿到数据后也需要再处理一下
fun post333(id: Long, asr: String, m: String, n: String, list: List<String>) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"

//接口调用前 根据 需要处理操作
list.forEach {
if (map.containsKey(id.toString())) {
///
}
}

val result = service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader)
// 拿到数据后需要处理操作
val result1 = result
emit(result1)
}.map {
//需要再转化一下
it
}.filter {
//过滤一下
it.length == 3
}

7. 可以在 接口 NetApi 中该方法上配置 @Filter 注解过滤 ,该方法需要自己特殊处理,不自动生成,如下


@Filter
@POST("https://xxxxxxx22222")
suspend fun post333(@Body body: RequestBody, @HeaderMap map: Map): String
  1. 如果想 post请求的 RequestBody 内部参数单独出来进入方法传参,可以加上 在 NetApi 中方法加上 @PostBody:如下:
@PostBody("{"ID":"Long","name":"String"}")
@POST("https://www.wanandroid.com/user/register")
suspend fun testPostBody222(@Body body: RequestBody): String

这样 该方法生成出来的对应方法就是:

public suspend fun testPostBody222(ID: Long, name: java.lang.String): Flow =
kotlinx.coroutines.flow.flow {
val map = mutableMapOf()
map["ID"] = ID
map["name"] = name
val result = service.testPostBody222(com.wx.test.api.retrofit.RequestBodyCreate.toBody(com.google.gson.Gson().toJson(map)))
emit(result)
}

怎么特殊处理,单独手动建一个Repository,针对该方法,单独写,特殊就要特殊手动处理,但是大多数模版式的代码,都可以让其自动生成。

—————————————————我是分割线君—————————————————

到了这里,我们再想, NetApi 是一个接口类,
但是实际上没有写接口实现类啊, 它怎么实现的呢?
我们上面 花式玩法(二) 中虽然是自动生成的,但是还是有方法体,

可不可以再省略点?

可以,必须有!

四、花式玩法(三)

  1. 我们可以根据 NetApi 里面的配置,自动生成 INetApiRepository 接口类, 接口名和参数 都和 NetApi 保持一致,唯一区别就是返回的对象变成了 Flow 了,
    这样在 Repository 中就把数据转变为 flow 流了
  2. 配置让代码自动生成的类:
@AutoCreateRepositoryInterface(interfaceApi = "com.wx.test.api.net.NetApi")
class KaptInterface {
}

生成的接口类 INetApiRepository 代码如下:


public interface INetApiRepository {
public fun getHomeList(): Flow>

public fun getHomeList(page: Int): Flow>

public fun getHomeList(page: Int, f: Float): Flow>

public fun getHomeList(page: Int, a: Int): Flow>

public fun getHomeList2222(page: Int): Flow>

public fun getHomeList3333(page: Int): Flow>

public fun getHomeList5555(
page: Int,
ss: String,
map: Map<String, String>
)
: Flow>

public fun getHomeList6666(
page: Int,
float: Float,
long: Long,
double: Double,
byte: Byte,
short: Short,
char: Char,
boolean: Boolean,
string: String,
body: RequestBodyWrapper
)
: Flow>

public fun getHomeListA(page: Int): Flow>

public fun getHomeListB(page: Int): Flow

public fun post1(body: RequestBody): Flow

public fun post12(body: RequestBody, map: Map<String, String>): Flow

public fun post1222(body: RequestBody, map: Map<String, Any>): Flow

public fun register(
username: String,
password: String,
repassword: String
)
: Flow

public fun testPostBody222(ID: Long, name: java.lang.String): Flow
}
  1. Repository 职责承担的调用端:用动态代理:

class RepositoryPoxy private constructor() : BaseRepositoryProxy() {

val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }


companion object {
val instance by lazy { RepositoryPoxy() }
}

fun callApiMethod(serviceR: Class<R>): R {
return Proxy.newProxyInstance(serviceR.classLoader, arrayOf(serviceR)) { proxy, method, args ->
flow {
val funcds = findSuspendMethod(service, method.name, args)
if (args == null) {
emit(funcds?.callSuspend(api))
} else {
emit(funcds?.callSuspend(api, *args))
}
// emit((service.getMethod(method.name, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call).execute().body())
}.catch {
if (it is InvocationTargetException) {
throw Throwable(it.targetException)
} else {
it.printStackTrace()
throw it
}
}
} as R
}
}
  1. BaseRepositoryProxy 中内容:

open class BaseRepositoryProxy {

private val map by lazy { mutableMapOf?>() }
private val sb by lazy { StringBuffer() }

@OptIn(ExperimentalStdlibApi::class)
fun findSuspendMethod(service: Class<T>, methodName: String, args: Array<out Any>): KFunction<*>? {
sb.delete(0, sb.length)
sb.append(service.name)
.append(methodName)
args.forEach {
sb.append(it.javaClass.typeName)
}
val key = sb.toString()
if (!map.containsKey(key)) {
val function = service.kotlin.memberFunctions.find { f ->
var isRight = 0
if (f.name == methodName && f.isSuspend) {
if (args.size == 0 && f.parameters.size == 1) {
isRight = 2
} else {
f.parameters.forEachIndexed { index, it ->
if (index > 0 && args.size > 0) {
if (args.size == 0) {
isRight = 2
return@forEachIndexed
}
if (it.type.javaType.typeName == javaClassTransform(args[index - 1].javaClass).typeName) {
isRight = 2
} else {
isRight = 1
return@forEachIndexed
}
}
}
}
}
//方法名一直 是挂起函数 方法参数个数一致, 参数类型一致
f.name == methodName && f.isSuspend && f.parameters.size - 1 == args.size && isRight == 2
}
map[key] = function
}
return map[key]
}

private fun javaClassTransform(clazz: Class<Any>) = when (clazz.typeName) {
"java.lang.Integer" -> Int::class.java
"java.lang.String" -> String::class.java
"java.lang.Float" -> Float::class.java
"java.lang.Long" -> Long::class.java
"java.lang.Boolean" -> Boolean::class.java
"java.lang.Double" -> Double::class.java
"java.lang.Byte" -> Byte::class.java
"java.lang.Short" -> Short::class.java
"java.lang.Character" -> Char::class.java
"SingletonMap" -> Map::class.java
"LinkedHashMap" -> MutableMap::class.java
"HashMap" -> HashMap::class.java
"Part" -> MultipartBody.Part::class.java
"RequestBody" -> RequestBody::class.java
else -> {
if ("RequestBody" == clazz.superclass.simpleName) {
RequestBody::class.java
} else {
Any::class.java
}
}
}
}
  1. ViewModel中调用端:
class MainViewModel : BaseViewModel() {

private val repository by lazy { RepositoryPoxy.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiMethod(INetApiRepository::class.java).getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "three 333 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

—————————————————我是分割线君—————————————————

  1. 上面生成的接口类 INetApiRepository 其实方法和 NetApi 拥有相似的模版,唯一区别就是返回类型,一个是对象,一个是Flow 流的对象

    还能省略吗?

    有,必须有

五、花式玩法(四)

  1. 直接修改 RepositoryPoxy ,作为Reposttory的职责 ,连上面的 INetApiRepository 的接口类全部省略了, 如下:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {

val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }


companion object {
val instance by lazy { RepositoryPoxy() }
}

fun callApiMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val clssss = mutableListOfout Any>>()
args?.forEach {
clssss.add(javaClassTransform(it.javaClass))
}
val parameterTypes = clssss.toTypedArray()
val call = (service.getMethod(methodName, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call)
call?.execute()?.body()?.let {
emit(it as R)
}
}
}

@OptIn(ExperimentalStdlibApi::class)
fun callApiSuspendMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val funcds = findSuspendMethod(service, methodName, args)
if (args == null) {
emit(funcds?.callSuspend(api) as R)
} else {
emit(funcds?.callSuspend(api, *args) as R)
}
}
}
}

2. ViewModel中调用入下:

class MainViewModel : BaseViewModel() {

private val repository by lazy { RepositoryPoxy.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiSuspendMethod(HomeData::class.java, "getHomeListB", page).onEach {
android.util.Log.e("MainViewModel", "four 444 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

六、总结

通过上面4中花式玩法:

  1. 花式玩法1: 我们知道了最常见最优雅的写法,但是模版式 repository 代码太多,而且需要手动写
  2. 花式玩法2: 把花式玩法1中的模版式 repository ,让其自动生成,对于特殊的方法,单独手动再写个 repository ,这样让大多数模版式代码全自动生成
  3. 花式玩法3: NetApi,可以根据配置,动态代理生成网络请求行为,该行为统一为动态代理实现,无需对接口类 NetApi 单独实现,那么我们的 repository 也可以 生成一个接口类 INetApiRepository ,然后动态代理实现其内部 方法体逻辑
  4. 花式玩法4:我连花式玩法3中的接口类 INetApiRepository 都不需要了,直接反射搞定所有。
  5. 同时可以学习到,注解、反射、泛型、注解处理器、动态代理

项目地址

项目地址:
github地址
gitee地址

感谢阅读:

欢迎 点赞、收藏、关注


作者:Wgllss
来源:juejin.cn/post/7417847546323042345
收起阅读 »

什么黑科技?纯血鸿蒙又可以运行Android应用了!

背景 纯血鸿蒙OS Next系统最近出现了两款热门应用:出境易、卓易通,其功能是:让你在出境后可以方便安装到各种Android应用。 发生了什么? 「出境易」这款应用可以在纯血鸿蒙OS NEXT里直接安装&运行Android应用! 纯血鸿蒙官方声...
继续阅读 »


背景


纯血鸿蒙OS Next系统最近出现了两款热门应用:出境易、卓易通,其功能是:让你在出境后可以方便安装到各种Android应用




发生了什么?


「出境易」这款应用可以在纯血鸿蒙OS NEXT里直接安装&运行Android应用!



纯血鸿蒙官方声称:「不支持运行Android应用」!



于是:



  • 很多正在努力开发、兼容纯血鸿蒙应用的开发者都在议论:是不是可以停鸿蒙开发、继续写回Android了?

  • 也有很多粉丝后台私信Carson,问:目前继续学鸿蒙开发到底还有没意义






本文意图


今天Carson来带大家扒扒「出境易」、「卓易通」到底是怎么能在纯血鸿蒙OS NEXT里运行Android应用的。具体包括:「出境易」、「卓易通」这两款Android应用



  1. 在纯血鸿蒙系统里运行的底层支持是什么?

  2. 在纯血鸿蒙系统里的运行环境是什么?

  3. 「出境易」、「卓易通」本质是什么?

  4. 在「出境易」、「卓易通」上的Android应用性能、体验如何?




问题1:在纯血鸿蒙系统里运行的底层支持是什么?



  • 无论黑科技有多 “黑”,总需要底层给与相关支持才有运行的可能。

  • 在初次安装「出境易」、「卓易通」时需下载一个环境,抓包&解包可得到:



其中最为关键的文件:anco_hmos.img,从字面解释来看:



  • anco:AndroidCompatible = 安卓兼容

  • hmos:HarmonyOS = 鸿蒙OS系统

  • 整体看,即**「鸿蒙OS系统里的安卓兼容」**


实际上,这其实是一个安卓镜像文件,是一个嵌入到鸿蒙OS系统层面的安卓运行环境(类似虚拟机的作用,但实际不是虚拟机)。



其实是类似wsl技术,即Windows Subsystem for Linux = Windows的Linux子系统,能让开发者在Windows操作系统中直接运行Linux环境,而无需任何虚拟机。



所以,要在纯血鸿蒙OS 上安装「出境易」、「卓易通」不仅需要下载很大的安卓镜像资源包,还需要重启系统,因为「出境易」和 「卓易通」是单独的“运行环境”。


值得一提的是:



  • 因为本身鸿蒙OS内核就兼容了Linux ABI(应用程序二进制接口),即鸿蒙OS内核本身就可以运行为Linux设计的应用。

  • 所以,虽然这是一个安卓镜像,但这个属于鸿蒙的安卓镜像并没有包含Linux 内核,只是包含运行时(Runtime)部分。 以下是鸿蒙内核架构图:
    鸿蒙内核示意图




问题2:在纯血鸿蒙系统里的运行环境是什么?


那么,这类Android应用到底是运行在什么环境上的呢?打开「出境易」内的app后,通过执行shell ps -ef会出现以下进程:



  • 即其运行环境是:通过lxc-start命令启动了一个基于iSulad的容器的进程。

  • iSulad 是华为自研的容器引擎,是一个非常通用的容器引擎,具有轻、快、 易、灵的特点。以下是其架构图:



iSulad官网介绍:http://www.openeuler.org/zh/other/pr…






问题3:「出境易」、「卓易通」本质是什么?



二者的功能都是:让你在出境后可以方便安装到各种app。听起来是不是有点类似国内的应用商店



  • 实际上,二者在鸿蒙next商店下载的是一层壳,负责与纯血鸿蒙OS进行权限交互(图片、文件IO等)

  • 本体也是Android应用的apk,即出境易.apk、卓易通.apk。拿出来也是可以在Android手机上安装的。(如下图)


除此以外,「出境易」还含有一个「文件共享.apk」、「卓易通」还有一个「搜应用.apk」、「文件共享.apk」。


这里值得一提的是,两个“应用商店”可搜到的应用原理不同:



  • 出境易:白名单方式,即只有与其合作的Android应用可以安装;

  • 卓易通:黑名单方式,即只有纯血鸿蒙OS上架的应用不可以安装;


下面附上视频:纯血鸿蒙OS 「出境易」、「卓易通」安装Android应用实机演示



http://www.bilibili.com/video/BV1Q9…





性能如何?


既然能跑了,那么用户体验如何呢?网友们已经开始跑分了:



  • 测试环境:麒麟9000s;

  • 结论:单核心正常跑分1000,目前「出境易」是930分左右,效率是93%;




  • 分析:上面提到其底层支持是类似wsl的技术,同时运行环境是采用华为自研的iSulad 容器引擎的方式,并非所谓的虚拟机环境。这种嵌入方式可以使得安卓应用能够在鸿蒙系统上运行,但又不会占用过多的资源或影响系统的稳定性

  • 结合业界常见容器水平93%左右,华为的iSulad容器达到了业界水平,可理解为:GPU性能几乎无损。


但是对于内存使用就不太友好了,容器本身内存占用极大,基本一个容器进程就是8GB,随便开两个应用12GB就没了。

同时结合网上使用的评价:手机容易发烫(功耗高)、应用Bug较多等等,可以总结为:以这种方式在纯血鸿蒙OS上运行的Android应用 「能用」,但是「不好用」,与原生体验还是存在很大差距




结论



  • 技术角度 分析:基于anco_hmos,采用类似wsl的方式同时结合iSulad容器引擎,使得在纯血鸿蒙OS上运行Android应用成为了板上钉钉的现实

  • 性能角度 实践:在CPU性能可认为几乎无损的情况下,内存跟功耗问题短时间内还是无法解决;

  • 用户体验 观察:应用Bug较多,结合性能内存问题,目前暂时仅处于一个**「能用」**的状态。


基于上述分析 & 问题表现,在纯血鸿蒙OS上运行Android应用在国内大范围使用短时间内几乎不可能,更多的是在一些小众、边缘、尝试探索的场景,比如一些使用频率较低的小众app、尝试出海境外的场景(如本文提到的「出境易」等)


最后


如何看待这次在纯血鸿蒙OS上运行Android应用的事件呢?评论区留言你的看法!


参考文章:



作者:Carson带你学Android
来源:juejin.cn/post/7448576110823047202
收起阅读 »

BOE(京东方)“向新2025”年终媒体智享会落地深圳 “屏”实力赋能产业创新发展

12月27日,BOE(京东方)“向新 2025”年终媒体智享会的收官之站在创新之都深圳圆满举行,为这场为期两周、横跨三地的年度科技盛会画上了完美句号。活动期间,全面回顾了 BOE(京东方)2024年在多个关键领域取得的卓越成绩,深入剖析其在六大维度构建的“向新...
继续阅读 »

12月27日,BOE(京东方)“向新 2025”年终媒体智享会的收官之站在创新之都深圳圆满举行,为这场为期两周、横跨三地的年度科技盛会画上了完美句号。活动期间,全面回顾了 BOE(京东方)2024年在多个关键领域取得的卓越成绩,深入剖析其在六大维度构建的“向新”发展格局,精彩呈现了以“屏”为核心搭建起的技术引领、伙伴赋能以及绿色发展等平台,全方位赋能全球生态合作伙伴,充分彰显BOE(京东方)作为全球领先的物联网创新企业的引领地位与责任担当。深圳活动现场,BOE(京东方)执行委员会委员、副总裁刘竞以及 BOE(京东方)副总裁、首席品牌官司达亲临现场,发表了主旨演讲。此次系列智享会的成功举办,进一步加深了与会嘉宾对 BOE(京东方)发展理念、技术实力与创新成果的认知和理解,也为BOE(京东方)新一年的发展拉开了充满希望和活力的序幕。

经过三十余年创新发展,秉持着对技术的尊重和对创新的坚持,在“屏之物联”战略指导下,BOE(京东方)从半导体显示领域当之无愧的领军巨擘迅速蝶变,成功转型为全球瞩目的物联网创新企业,并不断引领行业发展风潮。面对下一发展周期,BOE(京东方)将从战略、技术、应用、生态、模式、ESG六大方面全方位“向新”突破,以实现全面跃迁,并为产业高质发展注入强劲动力。

战略向新:自2021年“屏之物联”战略重磅发布以来,BOE(京东方)又于2024年京东方全球创新伙伴大会(BOE IPC·2024)上发布了基于“屏之物联”战略升维的“第N曲线”理论,以半导体显示技术、玻璃基加工、大规模集成智能制造三大核心优势为基础,精准布局玻璃基封装、钙钛矿光伏器件等前沿新兴领域,全力塑造业务增长新赛道。目前,玻璃基封装领域,BOE(京东方)已布局试验线,成立了玻璃基先进封装项目组,实现样机产出;钙钛矿领域,仅用38天就已成功产出行业首片2.4×1.2m中试线样品,标志着钙钛矿产业化迈出了重要一步。

技术向新:2021年,BOE(京东方)发布了中国半导体显示领域首个技术品牌,开创了产业“技术+品牌”双价值驱动的新纪元。以技术品牌为着力点,BOE(京东方)深入赋能超5000家全球顶尖品牌厂商和生态合作伙伴,包括AOC、ROG、创维、华硕、机械师、雷神、联想等,助力行业向高价值增长的路径迈进,也为用户提供了众多行业领先、全球首发的更优选择。BOE(京东方)还将全力深化人工智能与半导体显示技术以及产业发展的深度融合,并在AI+产品、AI+制造、AI+运营三大关键领域持续深耕,并依托半导体显示、物联网创新、传感器件三大技术策源地建设,与产业伙伴和产学研合作伙伴共同创新,为产业高质量可持续发展保驾护航。

应用向新:BOE(京东方)不仅是半导体显示领域的领军企业,也是应用场景创新领域的领跑者,BOE(京东方)秉持“屏之物联”战略,以全面领先的显示技术为基础,通过极致惊艳的显示效果、颠覆性的形态创新,为智慧座舱、电竞、视觉艺术、户外地标等场景注入了新鲜血液,带给用户更加美好智慧的使用体验。以智慧座舱为例,根据市场调研机构Omdia最新数据显示,2024年前三季度BOE(京东方)车载显示出货量及出货面积持续保持全球第一,在此基础上BOE(京东方)还推出“HERO”车载场景创新计划,进一步描绘智能化时代汽车座舱蓝图。

生态向新:BOE(京东方)持续深化与电视、手机、显示器、汽车等众多品牌伙伴的合作,共同打造“Powered by BOE”产业生态集群,赢得众多客户的认可与赞誉。与此同时,BOE(京东方)还持续拓展跨产业生态,通过与上海电影集团、故宫博物院、微博等文化产业领先机构展开跨界合作,以创新技术赋能传统文化艺术与影像艺术。此外,通过战略直投、产业链基金等股权投资方式协同众多生态合作伙伴,通过协同合作、资源聚合共同构筑产业生态发展圈层。

模式向新:为适配公司国际化、市场化、专业化的长远发展,BOE(京东方)持续深化“1+4+N+生态链”的业务发展架构,以及“三横三纵”组织架构和运营机制。在充分市场化和充分授权的机制保障下,形成了以半导体显示核心业务为牵引,传感、物联网创新、MLED业务、智慧医工四大高潜航道全面开花,聚焦包括智慧车联、工业互联、数字艺术、3D光场等规模化应用场景,生态链确保产业上下游合作伙伴协同跃迁的“万马奔腾”的发展图景。此外,BOE(京东方)还鼓励员工创新创业,通过激发人才创新热情,共同为集团发展注入强劲内生动力。

ESG向新:2024年,BOE(京东方)承诺将在2050年实现自身运营碳中和,并通过坚持“Green+”、“Innovation+”、“Community+”可持续发展理念,推动全球显示产业高质永续发展。“Green+”方面,BOE(京东方)依托超过16 家国家级绿色工厂、显示领域唯一1家国家级无废工厂、1 座灯塔工厂及2座零碳工厂,以绿色产品、制造与运营践行低碳路径;“Innovation+”方面,BOE(京东方)凭借全部为自主创新的9万件专利的行业佳绩,以及技术策源地、技术公益池等举措,携手产业上下游伙伴协同创新;“Community+”方面,BOE(京东方)在教育、医疗、环境等公益领域持续投入,积极履行社会责任,例如在“照亮成长路”公益项目中,BOE(京东方)十年间在偏远地区建设的智慧教室已经突破120所。

BOE(京东方):屏即平台赋能创新

在新一轮数智化浪潮中,全球显示行业的龙头企业 BOE(京东方)以屏为核心,充分发挥技术引领作用,积极赋能合作伙伴,并秉持绿色发展理念,全力构建产业高质量、可持续发展的创新生态平台,引领行业在高速发展的科技浪潮中稳步前行,为全球用户缔造更加智能美好的生活体验。作为 BOE(京东方)全球创新生态布局的关键一环,珠三角区域不仅是其创新要素汇聚的高地,更是其全球化发展的重要窗口与强大驱动力,为“屏之物联”战略落地提供了有效支撑。

技术引领方面,BOE(京东方)多年来始终秉持对技术的尊重和对创新的坚持,致力于推动显示技术全面向新发展,以完美画质、AI+显示、无界形态、氧化物(Oxide)关键技术等关键领域,持续挖掘“屏”在物联网领域的无限潜力。

完美画质,BOE(京东方)深入洞察用户真实需求,基于ADS Pro技术优化升级的高端LCD解决方案UB Cell,所呈现的完美画质可以媲美OLED,堪称LCD显示技术发展的重要里程碑。目前,BOE(京东方)已携手合作伙伴推出了一系列搭载UB Cell高端液晶电视旗舰产品,引领液晶显示技术升级风向标;

AI+显示,BOE(京东方)在软硬件层面均已为AI的深度应用构筑完美平台,不仅实现了光感、温感、NFC等传感器件的屏内集成,还开发了屏幕局部刷新、远端功能监测等软件技术,显著增强了用户感知,提升了交互体验;

无界形态,BOE(京东方)作为国内在柔性OLED领域布局早、技术优、市场应用广的领军企业,在材料、工艺等领域具备全面优势,不仅在屏幕轻薄化、超清化方面性能卓越,更能够实现折叠、卷曲等形态变化,同时不断探索屏下摄像、屏下指纹、3D touch等多功能的智慧集成,更是打造出行业首款三折屏等具有行业里程碑意义的产品,带领用户迈入更多变、更智能的未来生活;

氧化物技术,BOE(京东方)在产能、技术以及产品性能上均位居行业领先地位,凭借高刷新率、高分辨率、低功耗等优势,在未来高端IT产品领域展现出广阔的应用前景。

伙伴赋能方面,BOE(京东方)始终以合作共赢为宗旨,高效整合资源,与生态伙伴携手向新发展,共筑高价值发展空间。BOE(京东方)坚持第一时间捕捉行业及市场需求动向,通过内部研发及运营保障机制,完成技术开发应用,组织建设和人才培养,完善流程、数据、组织以及IT能力建设,输出市场化、专业化、国际化的服务能力,并联动上下游及科研机构等生态伙伴,共同探讨“以人为本”的最优解决方案,深度拓展更多高端应用场景;同时,持续进行智能制造实践探索,确保稳定交付,赋能终端伙伴,使其能更好融入更真实、更丰富的消费者使用场景,实现产业高价值增长。

绿色发展方面,BOE(京东方)早已将可持续发展刻入企业基因,融入企业日常经营与管理的全链路,从绿色规划、低碳设计到碳足迹量化认证等各个环节,全力实现极致降碳目标。原材料环节,BOE(京东方)通过打造绿色供应链,积极使用可回收、可降解以及清洁材料,为产品低碳化发展奠定坚实基础;生产制造阶段实现全面绿色低碳;产品流通及回收阶段,BOE(京东方)已完成49个产品的碳足迹认证,凭借可回收、可降解的绿色材料,在产品的全生命周期中均实现了最大化降碳,让“科技创新+绿色发展”成为产业升级的主旋律。

“向新2025”年终媒体智享会,是BOE(京东方)2024创新营销的收官之作和全新实践,系统深化了大众对BOE(京东方)品牌和技术创新实力的认知与理解。近年来,BOE(京东方)通过多种创意独具的品牌破圈推广,包括“你好BOE”系列品牌线下活动、技术科普综艺《BOE解忧实验室》等生动鲜活地传递出BOE(京东方)以创新科技赋能美好生活的理念,为企业业务增长提供了强大动力,也为科技企业品牌推广打造了全新范式。BOE(京东方)“向新2025”主题系列活动已先后于上海、成都、深圳成功举办,为BOE(京东方)2024创新传播划上圆满句号。

面向未来,BOE(京东方)将胸怀“Best on Earth”宏伟愿景,坚持“屏之物联”战略引领,持续推动显示技术和物联网、AI等前沿技术的深度融合。从提升产品视觉体验到优化产业生态协同,从升级智能制造体系到践行社会责任担当,BOE(京东方)将砥砺奋进、创新不辍,为全球用户呈献超凡科技体验,领航全球产业创新发展的新篇章。

收起阅读 »

一些之前遇到过但没答上来的Android面试题

这段时间面了几家公司,也跟不同的面试官切磋了一些面试题,有的没啥难度,有的则是问到了我的知识盲区,没办法,Android能问的东西太多了,要全覆盖到太难了,既然没法全覆盖,那么只好亡羊补牢,将这些没答上来的题目做下记录,让自己如果下次遇到了可以答上来 TCP与...
继续阅读 »

这段时间面了几家公司,也跟不同的面试官切磋了一些面试题,有的没啥难度,有的则是问到了我的知识盲区,没办法,Android能问的东西太多了,要全覆盖到太难了,既然没法全覆盖,那么只好亡羊补牢,将这些没答上来的题目做下记录,让自己如果下次遇到了可以答上来


TCP与UDP有哪些差异


这道题回答的不全,仅仅只是将两个协议的概念说了一下,但是真正的差异却没有真正答上来,后来查询了一下资料,两者的差异如下



  • TCP是传输控制协议,是面向连接的协议,发送数据前需要建立连接,TCP传输的数据不会丢失,不会重复,会按照顺序到达

  • 与TCP相对的,UDP是无连接的协议,发送数据前不需要建立连接,数据没有可靠性

  • TCP的通信类似于打电话,需要确认身份后才可以通话,而UDP更像是广播,不关心对方是不是接收,只需要播报出去即可

  • TCP支持点对点通信,而UDP支持一对一,一对多,多对一,多对多

  • TCP传输的是字节流,而UDP传输的是报文

  • TCP首部开销为20个字节,而UDP首部开销是8个字节

  • UDP主机不需要维持复杂的连接状态表


TCP的三次握手


这道题以及下面那道虽然说上来了,但是也没有说的很对,仅仅只是说了下每次握手或者挥手的目的,中间的过程没有说出来,以下是三次握手以及四次挥手的详细过程



  • 第一次握手:客户端将SYN置为1,随机生成一个初始序列号seq发送给服务端,客户端进入SYN_SENT状态

  • 第二次握手:服务端收到客户端的SYN=1之后,知道客户端请求建立连接,将自己的SYN置1,ACK置1,产生一个ack=seq+1,并随机产生一个自己的初始序列号,发送给客户端,服务端进入SYN_RCVD状态

  • 第三次握手:客户端检查ack是否为序列号+1,ACK是否为1,检查正确之后将自己的ACK置为1,产生一个ack=服务器的seq+1,发送给服务器;进入ESTABLISHED状态;服务器检查ACK为1和ack为序列号+1之后,也进入ESTABLISHED状态;完成三次握手,连接建立


TCP的四次挥手



  • 第一次挥手:客户端将FIN设置为1,发送一个序列号seq给服务端,客户端进入FIN_WAIT_1状态

  • 第二次挥手:服务端收到FIN之后,发送一个ACK为1,ack为收到的序列号加一,服务端进入CLOSE_WAIT状态,这个时候客户端已经不会再向服务端发送数据了

  • 第三次挥手:服务端将FIN置1,发送一个序列号给客户端,服务端进入LAST_ACK状态

  • 第四次挥手:客户端收到服务器的FIN后,进入TIME_WAIT状态,接着将ACK置1,发送一个ack=序列号+1给服务器,服务器收到后,确认ack后,变为CLOSED状态,不再向客户端发送数据。客户端等待2* MSL(报文段最长寿命)时间后,也进入CLOSED状态。完成四次挥手


从浏览器输入地址到最终显示页面的整个过程


这个真的知识盲区了,谁会平时没事在用浏览器的时候去思考这个问题呢,结果一查居然还是某大厂的面试题,算了也了解下吧



  1. 第一步,浏览器查询DNS,获取域名对应的ip地址

  2. 第二步,获取ip地址后,浏览器向服务器建立连接请求,发起三次握手请求

  3. 第三步,连接建立好之后,浏览器向服务器发起http请求

  4. 第四步,服务器收到请求之后,根据路径的参数映射到特定的请求处理器进行处理,并将处理结果以及相应的视图返回给浏览器

  5. 第五步,浏览器解析并渲染视图,若遇到js,css以及图片等静态资源,则重复向服务器请求相应资源

  6. 第六步,浏览器根据请求到的数据,资源渲染页面,最终将完整的页面呈现在浏览器上


为什么Zygote进程使用socket通信而不是binder


应用层选手遇到偏底层问题就头疼了,但是这个问题还是要知道的,毕竟跟我们app的启动流程相关



  1. 原因一:从初始化时机上,Binder通信需要在Android运行时以及Binder驱动已经初始化之后才能使用,而在这之前,Zygote已经启动了,所以只能使用socket通信

  2. 原因二:从出现的先后顺序上,Zygote相比于Binder机制,更早的被设计以及投入使用,所以在Android的早期版本中,Android就已经使用socket来监听其他进程的请求

  3. 原因三:从使用上,socket通信不依赖于Binder机制,它是一种简单通用的IPC机制,也不需要复杂的接口定义

  4. 原因四:从兼容性上来讲,socket是一种跨平台的IPC机制,可以在不同的操作系统和环境中使用。

  5. 原因五:从性能上来讲,由于使用Zygote通信并不是频繁的操作,所以使用socket通信不会对系统性能造成显著影响

  6. 原因六:从安全性上来讲,使用socket可以确保只有系统中特定的服务如system_server才能与Zygote通信,从而提升一定的安全性


使用Binder的好处有哪些


上面那个问题问好了紧接着就是这道题,我嗯嗯啊啊的零碎说了几个,肯定也是不过关的,回头查了下资料,使用Binder的优势如下



  • 从效率上来讲,Binder比较高效,相比较于其他几种进程的通信方式(管道,消息队列,Socket,共享内存),Binder只需要拷贝一次内存就好了,而除了共享内存,其余都都要拷贝两次内存,共享内存虽然不需要拷贝,但是实现方式复杂,所以综合考虑Binder占优势

  • 使用的是更加便于理解,更简单的面向对象的IPC通信方式

  • Binder既支持同步调用,也支持异步调用

  • Binder使用UID和PID来验证请求的来源,这样可以确保每个Binder事务可以精确到发起者,为进程间的通信提供了保障

  • Binder是基于c/s架构,架构清晰明确,Server端与Client端相对独立

  • Binder有一套易于使用的API供进程间通信,将复杂的内部实现隐藏起来


如果一个线程连续调用两次start,会怎样?


会怎样?谁知道呀,正常人谁会没事去调用两次start呢?但是这个还真有人问了,我只能说没遇到过,后来回去自己试了下才知道


image.png

如上述代码所示,有一个线程,然后连续调用了两次start方法,当我们运行一下这段代码后,得到的结果如下


image.png

可以发现线程有正常运行,但同时也因为多调了一次start而抛出了异常,这个异常在start方法里面就能看到


image.png

有一个状态为started,正常第一次启动线程时候,started为false,所以是不会抛出异常的,started为true的地方是在下面这个位置


image.png

调用了native方法nativeCreated后,started状态位才变成true,这个时候如果再去调用start方法,那么必然会抛出异常


如何处理协程并发的数据安全


之前遇到过这么个问题,并发处理的协程之间是否可以保证数据安全,这个由于之前有实验过,所以想都没想就说可以保证数据安全,但面试官只是呵呵了一下,我捉摸着难道不对吗,后来回去试了一下才发现,不一定就能保证数据安全,看下面这段代码


image.png

这段代码里面在runBlocking中创建了1000个协程,每一个协程都对变量count做自增操作,最后把结果打印出来,我们预期的是打印出的结果就是1000,实际结果如下


image.png

看到的确就是1000,没啥毛病,多试几次也是一样的,但是如果换一种写法试试看呢


image.png

原本都是runBlocking里面的子协程,现在将这些协程变成非runBlocking的子协程,结果是不是还是1000呢,看下结果


image.png

明显不是了,所以并发处理的协程,并不能保证数据安全,那么如何可以让数据安全呢,有以下几个办法


原子类


image.png

这个好理解,同处理线程安全差不多


channel


image.png

receive函数只有等到阻塞队列里面有数据的时候才会执行,没有数据的时候会一直等待,所以这就能保证这些协程可以并发执行,不过要注意的是这里的Channel一定要设置队列大小,不然程序会一直阻塞,receive一直在等待队列里面有数据


mutex


image.png

使用互斥锁的方式,withLock函数内部执行了获取锁跟释放锁逻辑,将变量count保护起来,实现数据安全,除此之外,还可以使用lockunLock函数来实现,代码如下


image.png

总结


总的来讲自己在系统层面,偏底层的那些问题上,还是掌握的不多,这个也跟自己多年徘徊在应用层开发有关,底层知识用到的不多,自然也就忽略了,但是如果面试的话,就算是面的应用层,也是需要知道一些底层方面的知识,不然面试官随便问几个,你不会,别人会,岗位不就被别人拿走了吗


作者:Coffeeee
来源:juejin.cn/post/7402204610978545673
收起阅读 »

Android 新一代图片加载库 - Coil

Coil 是 Android 的新一代图片加载库,它的全名叫做 Coroutine Image Loader,即协程图片加载器,用于显示网络或本地图像资源。 特点 快速:执行了多项优化,包括内存和磁盘缓存,图像降采样,自动暂停/取消请求等。 轻量级:依赖于 ...
继续阅读 »

Coil 是 Android 的新一代图片加载库,它的全名叫做 Coroutine Image Loader,即协程图片加载器,用于显示网络或本地图像资源。


特点



  • 快速:执行了多项优化,包括内存和磁盘缓存,图像降采样,自动暂停/取消请求等。

  • 轻量级:依赖于 Kotlin,协程和 Okio,并与谷歌的 R8 等代码缩减器无缝协作。

  • 易于使用:API 利用 Kotlin 的语言特性来实现简洁性和最小化的样板代码。

  • 现代化:以 Kotlin 为首要语言,并与协程,Okio,Ktor 和 OkHttp 等现代库实现互操作。


加载图片


先引入依赖


implementation(libs.coil)

最简单的加载方法就是使用这个扩展函数了


inline fun ImageView.load(
data: Any?,
imageLoader: ImageLoader = context.imageLoader,
builder: ImageRequest.Builder.() -> Unit = {}
)
: Disposable {
val request = ImageRequest.Builder(context)
.data(data)
.target(this)
.apply(builder)
.build()
return imageLoader.enqueue(request)
}

使用扩展函数来加载本地或网络中的图片


// 加载网络图片
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
// 加载资源图片
binding.imageView.load(R.drawable.girl)
// 加载文件中的图片
val file = File(requireContext().getExternalFilesDir(null), "saved_image.jpg")
binding.imageView.load(file.absolutePath)

支持设置占位图,裁剪变换,生命周期关联等


binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg") {
crossfade(true) //渐进渐出
crossfade(1000) //渐进渐出时间
placeholder(R.mipmap.sym_def_app_icon) //加载占位图
error(R.mipmap.sym_def_app_icon) //加载失败占位图
allowHardware(true) //硬件加速
allowRgb565(true) //支持565格式
lifecycle(lifecycle) //生命周期关联
transformations(CircleCropTransformation()) //圆形裁剪变换
}

变为圆角矩形


binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg") {
lifecycle(lifecycle)
transformations(RoundedCornersTransformation(20f))
}

可以创建自定义的图片加载器,为其添加一些日志拦截器等。


class LoggingInterceptor : Interceptor {

companion object {
private const val TAG = "LoggingInterceptor"
}

override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val url = chain.request.data.toString()
val width = chain.size.width.toString()
val height = chain.size.height.toString()
Log.i(TAG, "url: $url, width: $width, height: $height")
return chain.proceed(chain.request)
}
}

class MyApplication : Application(), ImageLoaderFactory {

override fun newImageLoader() =
ImageLoader.Builder(this.applicationContext).components { add(LoggingInterceptor()) }
.build()
}

替换 Okhttp 实例


val okHttpClient = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
val imageLoader = ImageLoader.Builder(requireContext()).okHttpClient {
okHttpClient
}.build()
Coil.setImageLoader(imageLoader)
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")

加载 gif


添加依赖


implementation(libs.coil.gif)

按照官方的做法,设置 ImageLoader。


val imageLoader = ImageLoader.Builder(requireContext())
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}.build()
Coil.setImageLoader(imageLoader)
binding.imageView.load(GIF_URL)

下载监听


可以监听下载过程


binding.imageView.load(IMAGE_URL) {
listener(
onStart = {
Log.i(TAG, "onStart")
},
onError = { request, throwable ->
Log.i(TAG, "onError")
},
onSuccess = { request, result ->
Log.i(TAG, "onSuccess")
},
onCancel = { request ->
Log.i(TAG, "onCancel")
}
)
}

取消下载


val disposable = binding.imageView.load(IMAGE_URL)
disposable.dispose()

对 Jetpack Compose 的支持


引入依赖:


implementation(libs.coil.compose)

使用 AsyncImage


@Composable
@NonRestartableComposable
fun AsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
transform: (State) -> State = DefaultTransform,
onState: ((State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DefaultFilterQuality,
clipToBounds: Boolean = true,
modelEqualityDelegate: EqualityDelegate = DefaultModelEqualityDelegate,
)


比如显示一张网络图片,就可以这样干。


@Composable
fun DisplayPicture() {
AsyncImage(
model = "https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg",
contentDescription = null
)
}

支持设置占位图,过程监听,裁剪等


@Composable
fun DisplayPicture() {
AsyncImage(
modifier = Modifier
.clip(CircleShape)
.size(200.dp),
onSuccess = {
Log.i(TAG, "onSuccess")
},
onError = {
Log.i(TAG, "onError")
},
onLoading = {
Log.i(TAG, "onLoading")
},
model = ImageRequest.Builder(LocalContext.current)
.data("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
.crossfade(true)
.placeholder(R.drawable.default_image)
.error(R.drawable.default_image)
.build(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

这里介绍一下这个 ContentScale,它是用来指定图片如何适应其容器大小的,有以下几个值:



  • ContentScale.FillBounds:图片会被拉伸或压缩以完全填充其容器的宽度和高度,这可能会导致图片的宽高比失真。

  • ContentScale.Fit:图片会保持其原始宽高比,并尽可能大地缩放以适应容器,同时确保图片的任一边都不会超出容器的边界,这可能会导致容器的某些部分未被图片覆盖。

  • ContentScale.Crop:图片会被裁剪以完全覆盖其容器的宽度和高度,同时保持图片的宽高比,这通常用于需要确保整个容器都被图片覆盖的场景,但可能会丢失图片的一部分内容。

  • ContentScale.FillWidth:图片会保持其原始宽高比,并调整其高度以完全填充容器的宽度,这可能会导致图片的高度超出容器的高度,从而被裁剪或需要额外的布局处理。

  • ContentScale.FillHeight:图片会保持其原始宽高比,并调整其宽度以完全填充容器的高度,这可能会导致图片的宽度超出容器的宽度,从而需要相应的处理。

  • ContentScale.Inside:图片会保持其原始宽高比,并缩放以确保完全位于容器内部,同时其任一边都不会超出容器的边界。

  • ContentScale.:图片将以其原始尺寸显示,不会进行任何缩放或裁剪。


作者:阿健君
来源:juejin.cn/post/7403546034763235378
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »

使用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

收起阅读 »

如何在鸿蒙ArkTs中进行全局弹框

背景 刚接触鸿蒙开发不久,从iOS转过来的,经常会遇到在一个公共的类里,会想要给当前window上添加一个全屏的自定义视图,那在鸿蒙中应该如何实现这一个效果呢? 这里介绍一下我自己想到的实现方式,不一定是最优解,大家有其他更好的方式或者问题,欢迎指正。 代码是...
继续阅读 »

背景


刚接触鸿蒙开发不久,从iOS转过来的,经常会遇到在一个公共的类里,会想要给当前window上添加一个全屏的自定义视图,那在鸿蒙中应该如何实现这一个效果呢?


这里介绍一下我自己想到的实现方式,不一定是最优解,大家有其他更好的方式或者问题,欢迎指正。


代码是基于鸿蒙next和模拟器


思路


在鸿蒙中,虽然可以通过下面的系统方法获取到window,但是目前我不知道如何像iOS一样,在其上添加自定义的组件。所以,在研究了系统的window之后,想到是否可以直接弹出一个全屏的window,然后在这个自定义的window上,添加我们的自定义组件。类似于iOS的三方库SwiftEntryKit


import { window } from '@kit.ArkUI'
function findWindow(name: string): Window;

实现步骤



  1. 通过调用createWindow函数,创建一个自定义的window,并设置windowType枚举为
    TYPE_DIALOG,这个是一个API10之后有的类型。

  2. 通过调用loadContent(path: string, storage: LocalStorage, callback: AsyncCallback<void>): void创建一个指定的页面作为这个window的根视图,我们后面自己的自定义弹框组件,都是加载到这个页面中。第二个参数storage也很重要,因为通过该方法指定了页面,但是无法将自定义的参数直接传入到页面中,所以通过LocalStorage进行中转传值。

  3. 在需要进行传值的属性中,非常重要的是一个entry?: CustomBuilder自定义组件的属性,因为我们毕竟是要封装一个通用的类,去支持你传入任意的自定义视图。这里有个非常重要的点:在class中传入的这个属性,是一个代码块,里面是我们自定义的组件代码,但是我们无法在page中,直接去执行这个代码块,来获取到相应的布局。这里其实还需要在page的代码中新增一个属性@BuilderParam entryView: CustomBuilder,这个应该很熟悉,就是如果我们是直接初始化一个包含这个属性的组件时,就可以直接传入一个@Builder function()自定义组件,并且内部可以直接使用。那我们这里需要做的就是,在page的aboutToAppear中,将我们传入的参数,赋值给这个页面声明的属性,这样就可以在布局中去加载这个布局了。

  4. 传入到页面中的参数,还可以包含布局/动画等参数,这里只实现了布局,后续可以继续完善动画相关方法

  5. 最后在传入这个布局代码的时候,如果有自定义的点击事件,需要注意this的绑定当前调用方。


代码


公共模块:


import { window } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'

export class HDEntryKit {
static display(use: EKAttributes) {
HDWindowProvider.instance().display(use)
}

static dismiss(complete?: (() => void)) {
HDWindowProvider.instance().dismiss(complete)
}
}

class HDWindowProvider {
private static windowProvider: HDWindowProvider
context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
windowName: string = "HDEntryWindow"

static instance() {
if (!HDWindowProvider.windowProvider) {
HDWindowProvider.windowProvider = new HDWindowProvider();
}
return HDWindowProvider.windowProvider;
}

display(use: EKAttributes) {
let windowClass: window.Window
window.createWindow({
name: this.windowName,
windowType: window.WindowType.TYPE_DIALOG,
ctx: this.context
}, (err, data) => {
if (err.code == 0) {
windowClass = data
windowClass.setWindowLayoutFullScreen(true)
let bundleName = this.context.applicationInfo.name
let page = `@bundle:${bundleName}/uicomponents/ets/HDEntryKit/HDEntryPage`
let storage: LocalStorage = new LocalStorage()
storage.setOrCreate('use', use)
windowClass.loadContent(page, storage, err => {
if (err.code == 0) {
windowClass.setWindowBackgroundColor(use.backgroundColor?.toString())
}
})
windowClass.showWindow(() => {
})
}
})
}

dismiss(complete?: (() => void)) {
window.findWindow(this.windowName).destroyWindow((err, e) => {
if (err.code == 0 && complete) {
complete()
}
})
}
}

export class Size {
width: Length | null = null
height: Length | null = null
margin: Length | Padding = 0
}

export class EKAttributes {
name?: string
entry?: CustomBuilder
position: FlexAlign = FlexAlign.Center
backgroundColor: ResourceColor = "#99000000"
displayDuration: number = 1000
size: Size = new Size()
}

import { EKAttributes, HDEntryKit } from './HDEntryKit'

let storage = LocalStorage.getShared()
@Entry(storage)
@Component
struct HDEntryPage {
@BuilderParam entryView: CustomBuilder
@LocalStorageProp('use') use: EKAttributes = new EKAttributes()

build() {
Column() {
Row() {
Column() {
if (this.entryView) {
this.entryView()
}
}
.width('100%')
.onClick(e => {})
}
.width(this.use.size.width)
.height(this.use.size.height)
.margin(this.use.size.margin)
.backgroundColor(Color.Blue)
}
.width('100%')
.height('100%')
.justifyContent(this.use.position)
.onClick(event => {
HDEntryKit.dismiss()
})
}

aboutToAppear(): void {
this.entryView = this.use.entry
}


调用方:


/// 弹框的配置
let use = new EKAttributes()
use.size.height = 100
use.size.margin = 20
use.position = FlexAlign.End
use.entry = this.text.bind(this)
HDEntryKit.display(use)

/// 自定义的弹框组件
@Builder text() {
Row() {
Text("123")
.backgroundColor('#ff0000')
.onClick(() => {
this.test()
})
}
.width('100%')
.justifyContent(FlexAlign.Start)
}

/// 弹框组件中的页面跳转事件
test() {
HDEntryKit.dismiss(() => {
let bundleName = this.context.applicationInfo.name
let loginPage = `@bundle:${bundleName}/login/ets/pages/LoginRegisterPage`
router.pushUrl({url: loginPage})
})
}

注意


通过自定义window方法弹出页面后,如果在调用router.push,则是默认在这个自定义的window进行页面跳转,当你销毁这个window的时候,打开的页面都会被关闭。所以,在demo里是在window销毁后,再进行页面跳转


作者:Taeyss
来源:juejin.cn/post/7342038143162466340
收起阅读 »

Compose Desktop 写一个 Android 提效工具

前言 在日常的工作中,很多工作和操作其实都是重复的,这个时候,就会想,能不能通过工具进行一键操作。 由于本人是Android开发,寻找解决方案时发现了compose-multiplatform,于是就写个工具玩一玩。 软件介绍 AdbDevTools 是支持w...
继续阅读 »

前言


在日常的工作中,很多工作和操作其实都是重复的,这个时候,就会想,能不能通过工具进行一键操作。


由于本人是Android开发,寻找解决方案时发现了compose-multiplatform,于是就写个工具玩一玩。


软件介绍


AdbDevTools 是支持windows和mac的,并且支持浅色模式和暗黑模式,下面的截图都是在暗黑模式下。



  • 目的:都是为了减少重复性工作,节省开发者时间。

  • 简化Hprof文件管理:轻松一键导出、管理和分析Hprof文件,全面支持LeakCanary数据处理。

  • 内存泄漏分析:对 Hprof 文件进行内存泄漏分析,快速定位问题根源。

  • 位图资源管理:提供位图预览、分析和导出功能。

  • Deep Link快速调用:管理和测试Deep Link,提高开发和调试速度。

  • 开发者选项快捷操作:包含多项开发者选项的快捷操作。


功能介绍


内存快照文件管理和分析


常规操作:



  • 打开AS Memory Profiler,dump 出内存快照文件,等待内存快照文件生成,查看泄露的 Activity 或者 Fragment。

  • Android 8以下还可以有个 BitmapPreview 预览 Bitmap,但是每次只能预览一个 Bitmap。

  • 如果重新打开 AS,刚刚生成的 hprof 文件在哪里??

  • 所以如果想保存刚刚生成的 hprof 文件,就得在生成文件后,手动点击把文件保存一下到电脑上。

  • 如果想找到 LeakCanary 生成的文件,得找到对应的文件目录,然后再用 adb pull 一下到电脑上。。


懒人操作:



  • 一键 dump 出内存快照,自动化分析,生成一份报告。

  • Android 8以下的快照文件,可以一键导出所有 Bitmap 实例,方便预览。

  • 通过工具,管理最近打开的 hprof 文件

  • 一键导出 LeakCanary 生成的文件,无需手动操作。


image.png


开发者选项快捷操作


在日常的开发工作中,可能要经常打开开发者选项页面,打开某一个开关。


常规操作:打开设置页面,找到开发者选项,点击进入开发者页面,上下滑动,找到某一个开关,进行调整。这一系列的操作,有点繁琐。


懒人操作:在PC软件内,一键操作,直接打开开关。一步到位,不需要在手机里找来找去和点点点。


image.png


开发


代码架构设计


github.com/theapache64…,基于这个库,可以使用 Android 的开发方式,去开发一个桌面软件。


简单的这样理解。


对于单个桌面应用,其实就是类似 Android 的 Application。


对于应用内的窗口,其实就是类似 Android 的 Activity。


对于窗口内的各种子页面,其实就是类似 Android 的 Fragment,这边当成一个个的 Component 实现。


Application



  • 基类 Application。提供一个 startActivity 方法,用于打开某个页面。

  • 自定义 MyApplication,继承 Application,在 onCreate 方法里面,执行一些应用初始化操作。

  • 比如 onCreate 的时候,启动 MainActivity。

  • main() 方法,调用 MyApplication 的 onCreate 方法即可。


open class Application  {

protected fun startActivity(intent: Intent) {
val activity = intent.to.java.newInstance()
activity.intent = intent
activity.onCreate()
}

open fun onCreate() {

}
}

class MyApplication(args: AppArgs) : Application() {

override fun onCreate() {
super.onCreate()

Arbor.d("onCreate")

val splashIntent = MainActivity.getStartIntent()
startActivity(splashIntent)
}
}

fun main() {
MyApplication(appArgs).onCreate()
}

Activity



  • 自定义 MainActivity,在 onCreate 方法里面,创建和展示 Window 。


class MainActivity : Activity() {

companion object {
fun getStartIntent(): Intent {
return Intent(MainActivity::class).apply {
// putExtra
}
}
}

@OptIn(ExperimentalComposeUiApi::class)
override fun onCreate() {
super.onCreate()

val lifecycle = LifecycleRegistry()
val root = NavHostComponent(DefaultComponentContext(lifecycle))

application {

val intUiThemes by mainActivityViewModel.intUiThemes.collectAsState()
val themeDefinition = if (intUiThemes.isDark()) {
JewelTheme.darkThemeDefinition()
} else {
JewelTheme.lightThemeDefinition()
}

IntUiTheme(
themeDefinition,
styling = ComponentStyling.decoratedWindow(
titleBarStyle = when (intUiThemes) {
IntUiThemes.Light -> TitleBarStyle.light()
IntUiThemes.LightWithLightHeader -> TitleBarStyle.lightWithLightHeader()
IntUiThemes.Dark -> TitleBarStyle.dark()
IntUiThemes.System -> if (intUiThemes.isDark()) {
TitleBarStyle.dark()
} else {
TitleBarStyle.light()
}
}
)
) {
DecoratedWindow(visible = mainWindowVisible,
onCloseRequest = {
::exitApplication
mainActivityViewModel.exitMainWindow()
}, state = rememberWindowState(),
title = "${MyApplication.appArgs.appName} (${MyApplication.appArgs.version})",
onPreviewKeyEvent = {
if (
it.key == Key.Escape &&
it.type == KeyEventType.KeyDown
) {
root.onBackClicked()
true
} else {
false
}
}
) {
TitleBarView(intUiThemes)
root.render()
}

}

}
}
}

Component


Component:组件,可以是一个窗口,也是可以是窗口中的某一个页面,都可以当成组件处理。


对应单个组件,每个组件封装对应的业务逻辑处理,驱动相应的UI进行显示。


对于业务逻辑的处理,可以采用 Store+Reducer 这种偏前端思想的方式,也可以采用 Android 现在比较流行的 MVI 进行处理。


状态管理容器,只需要提供一些可观察对象就行了,驱动View层进行重组,刷新UI。



组件树:应用中的多个窗口,窗口中的多个页面,可以分别拆分成多个组件,每个组件封装处理各自的逻辑,最后构成一棵组件树的结构。




比如这个应用,被我拆成若干个Componet,分别处理相应的业务逻辑。


@Singleton
@Component(
modules = [
PreferenceModule::class
]
)

interface AppComponent {

fun inject(splashScreenComponent: SplashScreenComponent)

fun inject(mainScreenComponent: MainScreenComponent)

fun inject(adbScreenComponent: AdbScreenComponent)

fun inject(analyzeScreenCompoment: AnalyzeScreenCompoment)

fun inject(updateScreenComponent: UpdateScreenComponent)

fun inject(importLeakCanaryComponent: ImportLeakCanaryComponent)
}

ViewModel



  • ViewModel 这个比较简单,只是一个普通的类,用于处理业务逻辑,并维护UI层所需的状态数据。

  • ViewModel 的创建和销毁,这个会利用到 DisposableEffect 这个东西。DisposableEffect 的主要作用是在组合函数的启动和销毁时执行一些清理工作,以确保资源正确释放。

  • 在组合函数启动的时候,创建 ViewModel,并进行初始化。

  • 在组合函数销毁的时候,销毁 ViewModel,释放 ViewModel 的资源,类似 Android 中 ViewModel 的 clear 方法。


class AnalyzeViewModel @Inject constructor(
val hprofRepo: HprofRepo
) {

private lateinit var viewModelScope: CoroutineScope

fun init(scope: CoroutineScope) {
this.viewModelScope = scope
}

fun analyze(
heapDumpFile: File, proguardMappingFile: File?
)
{
viewModelScope.launch(Dispatchers.IO) {
//耗时方法,分析文件
}
}


fun dispose() {
viewModelScope.cancel()
}
}

/**
* 分析内存数据
*/

class AnalyzeScreenCompoment(
appComponent: AppComponent,
private val componentContext: ComponentContext,
private val hprofFile: String,
private val onBackClicked: () -> Unit,
) : Component, ComponentContext by componentContext {


init {
appComponent.inject(this)
}

@Inject
lateinit var analyzeViewModel: AnalyzeViewModel

@Composable
override fun render() {
val scope = rememberCoroutineScope()

DisposableEffect(analyzeViewModel) {
//初始化ViewModel
analyzeViewModel.init(scope)
//调用ViewModel里面的方法
analyzeViewModel.analyze(heapDumpFile = File(hprofFile), proguardMappingFile = null)

onDispose {
//销毁ViewModel
analyzeViewModel.dispose()
}
}

//观察ViewModel,实现UI逻辑
analazeScreen(analyzeViewModel)

}
}

adb 功能开发


比如 dump 内存快照,安装adb,一部分开发者选项控制,本质上都是可以通过 adb 命令进行设置的。



  • Adb第三方库:malinskiy.github.io/adam/,这个库是 Kotlin 编写的。

  • 库代码主要是协程、Flow、Channel,使用起来挺方便的。

  • 一条 adb 命令就是一个 Request,内置了挺多现成的 Request 可以使用,也可以自定义 Request 编写一些复杂的命令。

  • 比如使用adb devices,列出当前的设备列表,只需要一行代码即可。


val devices: List<Device> = adb.execute(request = ListDevicesRequest())


  • 如果需要监听设备的连接状态变化,可以通过执行 AsyncDeviceMonitorRequest 即可,返回值是一个 Channel 。


val deviceEventsChannel: ReceiveChannel<List<Device>> = adb.execute(
request = AsyncDeviceMonitorRequest(),
scope = GlobalScope
)

for (currentDeviceList in deviceEventsChannel) {
//...
}


  • 安装 apk,执行 StreamingPackageInstallRequest,传入相应的参数即可。


    suspend fun installApk(file: String, serial: String): Boolean {
Arbor.d("installApk file:$file,serial:$serial")
try {
val result = adb.execute(
request = StreamingPackageInstallRequest(
pkg = File(file),
supportedFeatures = listOf(Feature.CMD),
reinstall = true,
extraArgs = emptyList()
),
serial = serial
)
Arbor.d("installApk:$result")
return result
} catch (e: Exception) {
e.printStackTrace()
return false
}
}

开发者选项控制


打开过度绘制、布局边界



  • 开发者选项里面的很多配置,都是系统属性。关于系统属性的部分原理,可以在这里了解一下。


Android 系统属性学习与使用 - 掘金



  • 一部分系统属性,是可以支持 adb 修改,并且可以立马生效的。

  • 比如布局边界的属性是 debug.layout,设置为 true 即可打开开关。

  • 比如过度绘制对应的属性是 debug.hwui.overdraw,设置为 show 即可打开开关。

  • 通过下面几个 adb 命令,转化成相应的代码实现即可。


//读取所有的prop,会输出所有系统属性的key和value
adb shell getprop
//读取key为propName的系统属性
adb shell getprop ${propName}
//修改key为propName的系统属性,新值为propValue
adb shell setprop ${propName} ${propValue}


  • adb shell service call activity 1599295570,这个命令,主要是为了修改 prop 之后能够立马生效。


    /**
* 修改 prop 手机配置
*/

suspend fun changeProp(propName: String, propValue: String, serial: String) {
adb.execute(request = ShellCommandRequest("setprop $propName $propValue"), serial = serial)
adb.execute(request = ShellCommandRequest("service call activity 1599295570"), serial = serial)
}

跳转到开发者选项页面


有些开关还是得手动去设置的,所以提供了这样的一个按钮,点击直接跳转到开发者选项页面。


如果使用命令是这样的。


adb shell am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS

转化成对应的代码实现。


    suspend fun startDevelopActivity(serial: String){
adb.execute(
request = ShellCommandRequest("am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS"),
serial = serial
)
}

内存分析



  • 这里就不细讲了,主要是使用 shark 库进行解析 Hprof 文件,然后分析内存泄露问题。

  • 使用shark库解析Hprof文件:juejin.cn/post/704375…

  • 过程挺简单的,就是通过 adb dump 出内存快照文件,然后 pull 到电脑上,并删掉原文件。


1、识别本地所有应用的 packageName
2、adb shell ps | grep packageName 查看应用 pid
3、adb shell am dumpheap <PID> <HEAP-DUMP-FILE-PATH> 开始 dump pid 进程的 hprof 文件到 path
4、adb pull 命令


  • 另一种情况,如果你有使用 LeakCanary,但是 LeakCanary App是运行在手机上的,在手机上查看泄露引用链,其实不是那么方便。

  • 后面分析了一下,LeakCanary 生成的文件,都放在了 /storage/emulated/0/Download 的目录下,所以搞个命令一键拉取到电脑上,在软件里面进行分析即可。



Html 文件生成


根据内存分析结果,生成一份 html 格式的文件报告,方便在浏览器中进行预览。



  • 尴尬的是,自己不太会写 html,另一个是,这个软件是纯 Kotlin 开发,要引入 js 貌似也不太方便。

  • github.com/Kotlin/kotl…

  • 刚好官方有个 kotlinx-html 库,可以使用 Kotlin 来开发 HTML 页面。

  • 引入相关依赖


    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1")
implementation("org.jetbrains.kotlinx:kotlinx-html:0.9.1")


  • 按照官方文档进行使用,还是挺简单的。


        val html = createHTML().html {
head {
title { +"My HTML File" }
}
body {
h1 { +"Memory Analysis Report" }
h2 { +"Basic Info" }
p { +"Heap dump file path: ${hprofFile}" }

p { +"Build.VERSION.SDK_INT: ${androidMetadataMap?.get("Build.VERSION.SDK_INT")}" }
p { +"Build.MANUFACTURER: ${androidMetadataMap?.get("Build.MANUFACTURER")}" }
p { +"App process name: ${androidMetadataMap?.get("App process name")}" }

h2 { +"Memory leaks" }
}
}

下载地址


现在只有 mac 版本,没有 windows 版本。


http://www.github.com/LXD31256949…


填写License key可以激活:9916E3FF-2189-4A8E-B721-94442CDAA215


总结



  • 这篇文章,算是对这个软件的一个阶段性总结吧。

  • 一个是学习 Compose 相关的知识,以及了解 compose-desktop 相关的桌面组件,并进行开发桌面应用。

  • 另一个方面是 Android 这方面的知识学习。


作者:入魔的冬瓜
来源:juejin.cn/post/7369838480983490610
收起阅读 »

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
收起阅读 »