注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

compose 实现时间轴效果

新项目完全用了compose来实现,这两天有个时间轴的需求,搜索了一下完全由compose实现的几个,效果都不算特别好,而且都是用canvas画的,这样的话和原来的view没什么区别,不能发挥compose可定制组合的长处,所以自己实现了一个。由于我自己平时基...
继续阅读 »

新项目完全用了compose来实现,这两天有个时间轴的需求,搜索了一下完全由compose实现的几个,效果都不算特别好,而且都是用canvas画的,这样的话和原来的view没什么区别,不能发挥compose可定制组合的长处,所以自己实现了一个。由于我自己平时基本不写文章,并且内容也是偏向compose新手的,所以可能写的比较啰嗦,大佬们想看的可以直接跳到第三部分。欢迎指导!



在开始之前,先介绍一下这次实现的重点:Layout


Layout用于实现自定义的布局,可用于测量和定位其布局子项。我们可以用这个实现之前自定义view的效果,不过这里画的不是点线之类的东西,而是composable,并且只用计算放的位置就好,基于此我们可以实现有多个插槽的布局。


先来看一下UI效果是什么样的
体检报告详情.png


一、分解UI


通过观察UI,我们可以将每个item分解为以下四个元素:圆点、线、时间、内容。一个合格的组件,要允许使用者随意定义各个元素位置的实现,比如圆点可能变成方的,或者换成图片,线也可能是条实线,并且颜色是渐变的。所以这里这几个元素准确的来说,应该是四个插槽,这几个插槽提供了默认的样式是长这样。


圆点槽和时间槽是垂直居中对齐的,圆点槽和线槽是水平居中对齐的,内容槽和时间槽是左对齐,在圆点槽和时间槽中间有一定间距,我们管他叫内容距左间距。


每个item的最大宽度是圆点槽的宽+内容距左间距+内容的宽。每个item的最大高度是圆点或者时间槽的最大高度+内容的高度,不直接用时间槽的高度是因为圆点槽如果放个图片的话,可能高度比时间槽的高度要高。


由于这个线应该是连接两个圆点槽的,所以它的最大高度和最小高度其实都是一个,取决于两个圆点之间的距离,正好是一个item的高度。


在多个item时,第一个元素的线从点开始往下,而最后一个则没有线(说高度为0也行)


二、实现每个插槽的默认UI



  • 圆点


这个很简单,任意一个空的组件设置下修饰符就可以了。


Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape) // 变圆
.background(MaterialTheme.colorScheme.primary)
)


  • 线


实线很好实现,也通过background就可以


// 实线单色
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary)
)

// 渐变也简单
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primaryContainer
)
)
)
)

虚线稍微麻烦一点,Brush中没有直接实现虚线的方法,所以我用drawBehind来实现了。drawBehind这里的作用和Canvas()是一样的,你可以直接用canvas来实现,重点就是里面的pathEffect。


Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)


  • 时间


简单一个Text就可以。


Text("2023928日")


  • 内容


根据具体的内容来实现。


三、通过自定义的Layout将小UI组装起来


现在我们根据第一步的思路,来定义一个组件。


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit, // 圆点槽
line: @Composable () -> Unit, // 线槽
time: @Composable () -> Unit,// 时间槽
content: @Composable () -> Unit, // 内容槽
contentStartOffset: Dp = 8.dp // 内容距左间距
)

然后我们将第二步中的插槽的默认UI放上去。主要是圆点槽和线槽。


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
},
line: @Composable () -> Unit = {
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
},
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp
)

定义好以后就可以开始做实现了,上面已经说过,我们是通过自定义Layout来实现的,那么先看一下Layout的构成。


@UiComposable
@Composable inline fun Layout(
content: @Composable @UiComposable () -> Unit, // 可组合子项。
modifier: Modifier = Modifier, // 布局的修饰符
measurePolicy: MeasurePolicy //布局的测量和定位的策略
)

这其中的content,就是指我们这四个槽的内容。


Layout(
modifier = modifier,
content = {
dot()
// 通过ProvideTextStyle给时间槽提供了一个默认字体颜色。
ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
time()
}
content()
line()
},
measurePolicy = ...

我们可以看到在content中,我们将四个槽的内容全放进去了,那他们的位置和大小是怎么决定的呢,就是在measurePolicy中定义的。
MeasurePolicy类要求我们必须实现measure方法。


fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
)
: MeasureResult

measurables列表中的每个Measurable都对应于布局的一个布局子级,就是我们刚才在content中传入的内容,将按先后顺序存入这个列表。可以使用Measurable.measure方法来测量子级的大小。该方法需要子级自己所需要的约束Constraints(就是这个子级的最小最大尺寸);不同的子级可以用不同的约束来测量,而不是统一用给出的这个constraints参数。测量子级会返回一个Placeable,它的属性有该子级经过对应约束测量后的大小(一旦经过测量,这个子级的大小就确定了,不能再次测量)。最后在MeasureResult中,设置每个子级的位置就可以。


现在我们的代码变成了这样:


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
},
line: @Composable () -> Unit = {
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,//Color(0xffeeeeee)
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
},
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp,
position: TimelinePosition = TimelinePosition.Center
) {
Layout(
modifier = modifier,
content = {
dot()
ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
time()
}
content()
line()
},
measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
TODO: 具体的四个子级测量大小的位置设置。
}
}
)
}

现在我们来做具体的实现。
我们先来测量一下这里的圆点槽的大小。
val dot = measurables[0].measure(constraints)
因为我们在content中第一个传入的就是dot(),所以这里measurables[0]就是圆点槽组件,这样就得到了其对应的Placeable。
我们先放置下这个圆点槽显示下看看效果。


override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
)
: MeasureResult {
val dot = measurables[0].measure(constraints)

return layout(constraints.maxWidth, constraints.maxHeight) {
dot.place(0, 0, 1f)
}
}

理论上我们应该看到一个大小8dp,主题色的圆点在左上角。大家可以跑一下看看是不是符合预期。


要指出的是,这个方法给出的constraints并不是合适dot的约束,其最小宽度将可能远远大于dot的宽,这将导致测量后dot的宽远超设定的8dp。所以这里我们需要使用dot正确的约束, 而这个圆点槽理论上是不限制大小的,所以其最小宽度应该设置为0。我们依次将圆,时间,和内容的大小也测量出来。


val constraintsFix = constraints.copy(minWidth = 0)
val dot = measurables[0].measure(constraintsFix)
val time = measurables[1].measure(constraintsFix)
val content = measurables[2].measure(constraintsFix)

之所以不一并把线槽的大小也测量了,是因为我们在第一步中说的,线槽的高度,实际上是由圆点或者时间槽的最大高度+内容的高度来决定的。


val topHeight = max(time.height, dot.height) // 取圆点槽和时间槽中最大槽位的高度。
val lineHigh = topHeight + content.height // 整个组件的高度
val line = measurables[3].measure(
constraints.copy(
minWidth = 0,
minHeight = lineHeight,
maxHeight = lineHeight
)
)

至此我们已经将四个槽位的大小全部确定了下来。接下来就该指定每个槽位的位置,在第一步我们已经分析过每个槽位应该所在的位置。


val height = topHeight + content.height // 整个组件的高度
// 时间或内容的最大宽度 + 内容距左间距 + 圆点宽度 = 整个组件的宽度
val width =
max(content.width, time.width) + contentStartOffset.roundToPx() + dot.width

return layout(width, height) { // 设置layout占据的大小
val dotY = (topHeight - dot.height) / 2 // 计算圆点槽y轴位置
dot.place(0, dotY, 1f) // 放圆点槽
val timeY = (topHeight - time.height) / 2 // 计算时间槽y轴位置
time.place(dot.width + contentStartOffset.roundToPx(), timeY) // 放时间槽
content.place(dot.width + contentStartOffset.roundToPx(), topHeight) // 放内容槽,x和时间槽一样,形成左对齐效果。
line.place(
dot.width / 2, // x在圆中间
dotY + dot.height // y从圆的最下面开始
)
}

至此我们就有了一个时间轴节点组件,马上在LazyColumn或者Column中试试效果吧!


四、完善效果


如果你刚才测试了效果,你会发现,在列表中最后一个节点,也有虚线,并且长度超出了列表,而最后一个节点,不应该显示虚线才对。所以我们要来完善一下效果。


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = ...,
line: @Composable () -> Unit = ...,
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp,
isEnd: Boolean = false, // 添加是否为最后一个节点的参数
)
...
//在最后根据是否是最后一个节点来设置是否放置线槽内容。
if (!isEnd){
line.place(
dot.width / 2,
dotY + dot.height
)
}

而在调用时,只要简单的根据是否位于列表最后就可以了,调用示例:


LazyColumn(
Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
itemsIndexed(list.itemSnapshotList) { index, item ->
item?.let {
TimelineItem(
modifier = Modifier.fillMaxWidth(),
time = {
Text(text = it.time)
},
content = {
Column {
// 最好在Column最上面和最下面也添加个spacer来间隔开
}
},
isEnd = index == list.itemCount - 1
)
}
}
}

最后


至此本文就结束啦,由于内容比较简单,且所以的代码均有表现,为了不占篇幅,就不再粘贴完整代码内容了。如果本文有错误之处或者可以改进的地方,请大家一定回复指正;如果文章的内容也对你有帮忙,也请回复鼓励我,谢谢大家!


作者:拎壶冲
来源:juejin.cn/post/7283719464906244151
收起阅读 »

为什么需要弱引用 wp?

Android 中的智能指针是通过引用计数的方式方式来实现内存自动回收的。在大多数情况下我们使用强指针 sp 就好了,那么弱指针 wp 的存在意义有是什么呢? 从使用的角度来说,wp 扮演的是一个指针缓存的角色,想用时候可以用,但不想因此阻止资源被释放。其实,...
继续阅读 »

Android 中的智能指针是通过引用计数的方式方式来实现内存自动回收的。在大多数情况下我们使用强指针 sp 就好了,那么弱指针 wp 的存在意义有是什么呢?


从使用的角度来说,wp 扮演的是一个指针缓存的角色,想用时候可以用,但不想因此阻止资源被释放。其实,简单的裸指针也能很好地完成指针缓存的功能,其功能性并不是 wp 存在的必要条件。


wp 存在的核心原因是:解决循环引用导致的死锁问题


1. 循环引用导致的死锁问题


接下来,我们就通过一个简单的示例程序来演示循环引用导致的死锁问题


首先有两个类,其内部都有一个智能指针指向对方,形成循环引用:


Class A : public RefBase
{
public:
A()
{

}

virtual ~A()
{

}

void setB(sp& b)
{
mB = b;
}

private:
sp mB;
}

Class B : public RefBase
{
public:
B()
{

}

virtual ~B()
{

}

void setA(sp& a)
{
mA = a;
}

private:
sp
mA;
}

整体结构如下图所示:



接下来看 main 函数:


int main(int argc, char** argv)
{
//初始化两个指针
A *a = new A();
B *b = new B();

// 触发构造函数调用 spA 内部强弱计数值 (1,1)
sp
spA = a;
// 触发构造函数调用 spB 内部强弱计数值 (1,1)
sp spB = b;

//setB 内部有赋值操作 mB = b,触发等于操作符函数重载
//spB 内部强弱计数值 (2,2)
spA->setB(spB);

//setA 内部有赋值操作 mA = a,触发等于操作符函数重载
//spA 内部强弱计数值 (2,2)
spB->setA(spA);

return 0;
// spA 析构 内部强弱计数值 (1,1),内存无法回收
// spB 析构 内部强弱计数值 (1,1),内存无法回收
}

//等于操作符函数重载
template<typename T>
sp& sp::operator =(const sp& other) {
// Force m_ptr to be read twice, to heuristically check for data races.
T* oldPtr(*const_castvolatile*>(&m_ptr));
T* otherPtr(other.m_ptr);
// 强弱引用计数分别加 1
if (otherPtr) otherPtr->incStrong(this);
if (oldPtr) oldPtr->decStrong(this);
if (oldPtr != *const_castvolatile*>(&m_ptr)) sp_report_race();
m_ptr = otherPtr;
return *this;
}

从这个示例可以看出,在循环引用的情况下,指针指针在作用域结束后,强弱引用计数值无法变回 (0,0),内存无法回收,导致内存泄漏;


2. 解决方案


只需要把其中一个智能指针改为弱引用即可解决上面的问题:


Class A : public RefBase
{
public:
A()
{

}

virtual ~A()
{

}

void setB(sp& b)
{
mB = b;
}

private:
sp mB;
}

Class B : public RefBase
{
public:
B()
{

}

virtual ~B()
{

}

//函数参数也要变一下
void setA(sp
& a)
{
//触发另外的等于操作符函数重载
mA = a;
}

private:
//这里改成 wp 弱引用
wp
mA;
}

主函数稍作修改:


int main(int argc, char** argv)
{
//初始化两个指针
A *a = new A();
B *b = new B();

// 触发构造函数调用 spA 内部强弱计数值 (1,1)
sp
spA = a;
// 触发构造函数调用 spB 内部强弱计数值 (1,1)
sp spB = b;

//setB 内部有赋值操作 mB = b,触发等于操作符函数重载
//spB 内部强弱计数值 (2,2)
spA->setB(spB);

//setA 内部有赋值操作 mA = a,触发等于操作符函数重载
//spA 内部强弱计数值 (1,2)
spB->setA(spA);

return 0;
// spB 析构 内部强弱计数值 (1,1),内存无法回收
// spA 析构 内部强弱计数值 (0,1),强引用为 0 ,回收 sp
spA 内部的目标对象 A,
// 随着 A 的析构, A 的成员变量 mB 也开始析构, 目标对象 B 强弱引用计数减 1,内部强弱计数值变为 (0,0),回收目标对象 B 以及内部管理对象,B 对象的内存回收工作完成,接着触发 B 对象的成员 mA 的析构函数
// mA 执行析构函数,弱引用计数减 1,内部强弱计数值变为 (0,0),回收 A 对象内部对应的管理对象,A 对象的内存回收工作完成
}

//等于操作符函数重载
template<typename T>
wp& wp::operator = (const sp& other)
{
weakref_type* newRefs =
other != nullptr ? other->createWeak(this) : nullptr; //增加弱引用计数
T* otherPtr(other.m_ptr);
if (m_ptr) m_refs->decWeak(this);
m_ptr = otherPtr;
m_refs = newRefs;
return *this;
}

当程序的一个引用修改为 wp 时,main 函数结束时:



这样就解决了上一节中提出的内存泄漏问题!


3. 总结



  • wp 的基本作用:wp 扮演了指针缓存的角色,想用时候可以用,但不想因此阻止资源被释放

  • wp 存在的根本原因:解决循环引用导致的死锁问题

作者:阿豪讲Framework
来源:juejin.cn/post/7283376651906646035

收起阅读 »

解决Android13上读取本地文件权限错误记录

Android13 WRITE_EXTERNAL_STORAGE 权限失效 1. 需求及问题 需求是读取sdcard上txt文件 Android13(targetSDK = 33)上取消了WRITE_EXTERNAL_STORAGE,READ_EXTERN...
继续阅读 »

Android13 WRITE_EXTERNAL_STORAGE 权限失效


1. 需求及问题



  1. 需求是读取sdcard上txt文件

  2. Android13(targetSDK = 33)上取消了WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE权限。

  3. 取而代之的是READ_MEDIA_VIDEOREAD_MEDIA_AUDIOREAD_MEDIA_IMAGES权限

  4. 测试发现,即便动态申请上面三个权限,仍旧无法读取本地txt文件


image.png


2. 解决方案



  1. AndroidManifest.xml中增加


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

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LocationDemo"
tools:targetApi="31">


<activity
android:name=".MainActivity"
android:exported="true">

<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>


  1. Activity中新增代码


// 方案一:跳转到系统文件访问页面,手动赋予
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.setData(Uri.parse("package:" + this.getPackageName()));
startActivity(intent);

Screenshot_20230927-131444[1].png


// 方案二:跳转到系统所有需要文件访问页面,选择你的APP,手动赋予权限
Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
startActivity(intent);

image.png


作者:OpenGL
来源:juejin.cn/post/7283152332622610492
收起阅读 »

父母在家千万注意别打开“共享屏幕”,银行卡里的钱一秒被转走......

打开屏幕共享,差点直接被转账 今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻: 在辽宁大连务...
继续阅读 »

打开屏幕共享,差点直接被转账


今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻:


在辽宁大连务工的耿女士接到一名自称“大连市公安局民警”的电话,称其涉嫌广州一起诈骗案件,让她跟广州警方对接。耿女士在加上所谓的“广州警官”的微信后,这位“警官”便给耿女士发了“通缉令”,并要求耿女士配合调查,否则将给予“强制措施”。随后,对方与耿女士视频,称因办案需要,要求耿女士提供“保证金”,并将所有存款都集中到一张银行卡上,再把钱转到“安全账户”。


图片


期间,通过 “屏幕共享”,对方掌握了耿女士银行卡的账号和密码。耿女士先后跑到多家银行,取出现金,将钱全部存到了一张银行卡上。正当她打算按照对方指示,进行下一步转账时,被民警及时赶到劝阻。在得知耿女士泄露了银行卡号和密码后,银行工作人员立即帮助耿女士修改了密码,幸运的是,银行卡的近6万元钱没有受到损失。


就这手段,我家里的老人根本无法预防,除非把手机从他们手里拿掉,与世隔绝还差不多,所以还是做APP的各大厂商努力一下吧!


希望各大厂商都能看看下面这个防劫持SDK,让出门在外打工的我们安心一点。


防劫持SDK


一、简介


防劫持SDK是具备防劫持兼防截屏功能的SDK,可有效防范恶意程序对应用进行界面劫持与截屏的恶意行为。


二、iOS版本


2.1 环境要求


条目说明
兼容平台iOS 8.0+
开发环境XCode 4.0 +
CPU架构armv7, arm64, i386, x86_64
SDK依赖libz, libresolv, libc++

2.2 SDK接入


2.2.1 DxAntiHijack获取

官网下载SDK获取,下面是SDK的目录结构


1.png


DXhijack_xxx_xxx_xxx_debug.zip 防劫持debug 授权集成库 DXhijack_xxx_xxx_xxx_release.zip 防劫持release 授权集成库




  • 解压DXhijack_xxx_xxx_xxx_xxx.zip 文件,得到以下文件




    • DXhijack 文件夹



      • DXhijack.a 已授权静态库

      • Header/DXhijack.h 头文件

      • dx_auth_license.description 授权描述文件

      • DXhijackiOS.framework 已授权framework 集成库






2.2.2 将SDK接入XCode

2.2.2.1 导入静态库及头文件

将SDK目录(包含静态库及其头文件)直接拖入工程目录中,或者右击总文件夹添加文件。 或者 将DXhijackiOS.framework 拖进framework存放目录


2.2.2.2 添加其他依赖库

在项目中添加 libc++.tbd 库,选择Target -> Build Phases,在Link Binary With Libraries里点击加号,添加libc++.tbd


2.2.2.3 添加Linking配置

在项目中添加Linking配置,选择Target -> Build Settings,在Other Linker Flags里添加-ObjC配置


2.3 DxAntiHijack使用


2.3.1 方法及参数说明

@interface DXhijack : NSObject

+(void)addFuzzy; //后台模糊效果
+(void)removeFuzzy;//后台移除模糊效果
@end

2.3.2 使用示例

在对应的AppDelegate.m 文件中头部插入


#import "DXhijack.h"

//在AppDelegate.m 文件中applicationWillResignActive 方法调用增加
- (void)applicationWillResignActive:(UIApplication *)application {
[DXhijack addFuzzy];
}

//在AppDelegate.m 文件中applicationDidBecomeActive 方法调用移除
- (void)applicationDidBecomeActive:(UIApplication *)application {
[DXhijack removeFuzzy];
}


三、Android版本


3.1 环境要求


条目说明
开发目标Android 4.0+
开发环境Android Studio 3.0.1 或者 Eclipse + ADT
CPU架构ARM 或者 x86
SDK三方依赖

3.2 SDK接入


3.2.1 SDK获取


  1. 访问官网,注册账号

  2. 登录控制台,访问“全流程端防控->安全键盘SDK”模块

  3. 新增App,填写相关信息

  4. 下载对应平台SDK


3.2.2 SDK文件结构



  • SDK目录结构 android-dx-hijack-sdk.png



    • dx-anti-hijack-${version}.jar Android jar包

    • armeabiarmeabi-v7aarm64-v8ax86 4个abi平台的动态库文件




3.2.3 Android Studio 集成

点击下载Demo


3.2.3.1 Android Studio导入jar, so

把dx-anti-hijack-x.x.x.jar, so文件放到相应模块的libs目录下


android-dx-hijack-as.png



  • 在该Module的build.gradle中如下配置:


 android{
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}

repositories{
flatDir{
dirs 'libs'
}
}
}


dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}



3.2.3.2 权限声明

Android 5.0(不包含5.0)以下需要在项目AndroidManifest.xml文件中添加下列权限配置:


<uses-permission android:name="android.permission.GET_TASKS"/>

3.2.3.3 混淆配置

-dontwarn *.com.dingxiang.mobile.**
-dontwarn *.com.mobile.strenc.**
-keep class com.security.inner.**{*;}
-keep class *.com.dingxiang.mobile.**{*;}
-keep class *.com.mobile.strenc.**{*;}
-keep class com.dingxiang.mobile.antihijack.** {*;}

3.3 DxAntiHijack 类使用


3.3.1 方法及参数说明

3.3.1.1 初始化


建议在Application的onCreate下調用


/**
* 使用API前必須先初始化
* @param context
*/

public static void init(Context context);

3.3.1.2 反截屏功能


/**
* 反截屏功能
* @param activity
*/

public static void DGCAntiHijack.antiScreen(Activity activity);

/**
* 反截屏功能
* @param dialog
*/

public static void DGCAntiHijack.antiScreen(Dialog dialog);

3.3.1.3 反劫持检测


/**
* 调用防劫持检测,通常现在activity的onPause和onStop调用
* @return 是否存在被劫持风险
*/

public static boolean DGCAntiHijack.antiHijacking();

3.3.2 使用示例

//使用反劫持方法
@Override
protected void onPause() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onPause();
}

@Override
protected void onStop() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onStop();
}



//使用反截屏方法
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DXAntiHijack.antiScreen(MainActivity.this);
}

以上。


结语


这种事情层出不穷,真的不是吾等普通民众能解决的,最好有从上至下的政策让相应的厂商(尤其是银行和会议类的APP)统一做处理,这样我们在外打工的人才能安心呀。


作者:昀和
来源:juejin.cn/post/7242145254057312311
收起阅读 »

Kotlin Flow入门

Flow作为Android开发中的重要的作用。尤其在Jetpack Compose里左一个collect,右一个collect。不交接Flow而开发Android是寸步难行。作为一个入门文章,如果你还不是很了解Flow的话,本文可以带你更进一步的了解Flow。...
继续阅读 »

Flow作为Android开发中的重要的作用。尤其在Jetpack Compose里左一个collect,右一个collect。不交接Flow而开发Android是寸步难行。作为一个入门文章,如果你还不是很了解Flow的话,本文可以带你更进一步的了解Flow。


Flow是一个异步数据流,它会发出数据给收集者,最终带或者不带异常的完成任务。下面我们通过例子来学习。


假设我们正在下载一幅图片。在下载的时候,还要把下载的百分比作为值发出来,比如:1%,2%,3%,等。收集者(collector)会接收到这些值并在界面上以合适的方式显示出来。但是如果出现网络问题,任务也会因此终止。


现在我们来看一下Flow里的几个API:



  • 流构建器(Flow builder)

  • 操作符(Operator)

  • 收集器(Collector)


流构建器


简单来说,它会执行一个任务并把值发出来,有时也会只发出值而不会执行什么任务。比如简单的发出一些数字值。你可以把流构建器当做一个发言人。这个发言人会思考(做任务)和说(发出值).


操作符


操作符可以帮助转化数据。


我们可以把操作符当做是一个翻译。一个发言人说了法语,但是听众(收集器)只能听懂英语。这就需要一个翻译来帮忙了。它可以把法语都翻译成英语让听众理解。


当然,操作符可以做的远不止这些。以上的例子只是帮助理解。


收集器


Flow发出的值经过操作符的处理之后会被收集器收集。


收集器可以当做是收听者。实际上收集器也是一种操作符,它有时被称作终端操作符


第一个例子


flow { 
(0..10).forEach {
emit(it)
}
}.map {
it * it
}.collect {
Log.d(TAG, it.toString())
}

flow {}->流构建器
map {}->操作符
collect {}->收集器

我们来过一下上面的代码:



  • 首先,流构建器会发出从0到10的值

  • 之后,一个map操作符会把每个值计算(it * it)

  • 之后,收集器收集这些发出来的值并打印出来:0,1,4,9,16,25,36,49,64,81,100.


注意:collect方法把流构建器和收集器连到了一起,这个方法调用之后流就开始执行了。


流构建器的不同类型


流构建器有四种:



  1. flowOf():从一个给定的数据集合生成流

  2. asFlow(): 一个扩展方法,可以把某个类型转化成流

  3. flow{}: 我们例子中使用的方法

  4. channelFlow{}:使用构造器自带的send方法发送的元素构建流


例如:


flowOf()


flowOf(4, 2, 5, 1, 7) 
.collect {
Log.d(TAG, it.toString())
}

asFlow()


(1..5).asFlow()
.collect {
Log.d(TAG, it.toString())
}

flow{}


flow {
(0..10).forEach {
emit(it)
}
}
.collect {
Log.d(TAG, it.toString())
}

channelFlow{}


channelFlow {
(0..10).forEach {
send(it)
}
}
.collect {
Log.d(TAG, it.toString())
}

flowOn操作符


flowOn这个操作符可以控制flow任务执行的线程的类型。在Android里一般是在一个后台线程执行任务,之后在界面上更新结果。


下面的例子里加了一个500毫秒的延迟来模拟实际任务。


val flow = flow {
// Run on Background Thread (Dispatchers.Default)
(0..10).forEach {
// emit items with 500 milliseconds delay
delay(500)
emit(it)
}
}
.flowOn(Dispatchers.Default)

CoroutineScope(Dispatchers.Main).launch {
flow.collect {
// Run on Main Thread (Dispatchers.Main)
Log.d(TAG, it.toString())
}
}

本例,流的任务就会在Dispatchers.Default这个“线程”里执行。接下来就是要在UI线程里更新UI了。为了做到这一点就需要在UI线程里collect


flowOn操作符就是用来控制任务执行的线程的。它的作用和RxJava的subscribeOn类似。


Dispatchers主要有这些类型:IODefaultMain。flowOn和CoroutineScope都可以使用Dispatchers来执行任务执行的“线程”(暂且这么理解)。


使用流构造器


我们通过几个例子学习。


移动文件


这里我们用流构造器新建一个流,让流任务在后台线程执行。完成后在UI线程显示状态。


val moveFileflow = flow {
// move file on background thread
FileUtils.move(source, destination)
emit("Done")
}
.flowOn(Dispatchers.IO)

CoroutineScope(Dispatchers.Main).launch {
moveFileflow.collect {
// when it is done
}
}

下载图片


这个例子构造一个流在后台线程下载图片,并且不断的在UI线程更新下载的百分比。


val downloadImageflow = flow {
// start downloading
// send progress
emit(10)
// downloading...
// ......
// send progress
emit(75)
// downloading...
// ......
// send progress
emit(100)
}
.flowOn(Dispatchers.IO)

CoroutineScope(Dispatchers.Main).launch {
downloadImageflow.collect {
// we will get the progress here
}
}

现在你对kotlin的流也有初步的了解了,在项目中可以使用简单的流来处理异步任务。


什么是终端操作符


上文已经提到过collect()方法是一个终端操作符。所谓的终端操作符就是让流跑起来的挂起方法(suspend function)。在以上的例子中,流构造器构造出来的流是不动的,让这个流动起来的操作符就是终端操作符。比如collect


还有:



  • 转化为各种集合的,toList, toSet

  • 获取第一个first,与确保流发射单个值的操作符single

  • 使用reduce, fold这类的把流的值规约到单个值的操作符。


比如:


val sum = (1..5).asFlow()
.map { it * it } // 数字 1 至 5 的平方
.reduce { a, b -> a + b } // 求和(末端操作符)
println(sum)

冷热流


前面的例子里的流都是冷流。我们来对比一下流的不同:


冷流热流
收集器调用的时候开始发出值没有收集器也会发出值
不存储数据可以存储数据
不支持多个收集器可以支持多个收集器

冷流,如果带上了多个收集器,流会每次遇到一个收集器就从头把完整的数据发送一次。


热流遇到多个收集器的时候,流会一直发出数据,收集器开始收集数据的时候遇到的是什么数据就收集什么数据。热流的多个收集器共享一份数据。


冷流是推模式,热流是拉模式。


下面看几个例子:


冷流实例


fun getNumbersColdFlow(): ColdFlow<Int> {
return someColdflow {
(1..5).forEach {
delay(1000)
emit(it)
}
}
}

开始收集


val numbersColdFlow = getNumbersColdFlow()

numbersColdFlow
.collect {
println("1st Collector: $it")
}

delay(2500)

numbersColdFlow
.collect {
println("2nd Collector: $it")
}

输出:


1st Collector: 1
1st Collector: 2
1st Collector: 3
1st Collector: 4
1st Collector: 5

2nd Collector: 1
2nd Collector: 2
2nd Collector: 3
2nd Collector: 4
2nd Collector: 5

两个收集器都从头获取到流的数据,在每次收集的时候都相当于遇到了一个全新的流。


热流实例。本例会设置一个热流每隔一秒发出一个1到5的数值。


fun getNumbersHotFlow(): HotFlow<Int> {
return someHotflow {
(1..5).forEach {
delay(1000)
emit(it)
}
}
}

现在开始收集:


val numbersHotFlow = getNumbersHotFlow()

numbersHotFlow
.collect {
println("1st Collector: $it")
}

delay(2500)

numbersHotFlow
.collect {
println("2nd Collector: $it")
}

输出:


1st Collector: 1
1st Collector: 2
1st Collector: 3
1st Collector: 4
1st Collector: 5

2nd Collector: 3
2nd Collector: 4
2nd Collector: 5

StateFlow


在Android开发中,热流的一个很重要的应用就是StateFlow


StateFlow是一种特殊的热流,它可以允许多个订阅者。如果你使用了jetpack compose来开发app的话,StateFlow可以简单而高效的在app的不同地方享状态(state)。因为热流只发送当前的状态(而不像冷流那样从开始发送值)。


要新建一个StateFlow,可以使用MutableStateFlow,然后给它一个初始值:


val count = MutableStateFlow(0)

在这里新建了一个叫做count的StateFlow,初始值为0。要更新它的值可以使用update方法,或者value属性:


this.count.update { v -> v + 1 }
this.count.value = 10

这时,订阅了count状态的订阅者就可以收到更新之后的值了。要订阅可以这样:


count.collect {
//...
}

在冷热流之外还有两种流:回调流和通道流。这个后面会详细讲到。


SharedFlow


SharedFlow也是一种热流,主要用于事件流。它会对所有的活的收集器发送事件。不同的消费者可以在同一时间收到同一个事件。


可以使用MutableSharedFlow()方法来创建一个SharedFlow对象。可以通过replay参数指明多少个已经发送的事件可以再发送给新的收集器,默认的是0。也即是在默认情况下,收集器只会接收到开始收集之后发送过来的事件。


这个时候可以来一个例子了:


class TickHandler(
    private val externalScope: CoroutineScope,
    private val tickIntervalMs: Long = 5000
) {
    // Backing property to avoid flow emissions from other classes
    private val _tickFlow = MutableSharedFlow<Unit>(replay = 0) // 1
    val tickFlow: SharedFlow<Event<String>> = _tickFlow // 2

    init {
        externalScope.launch {
            while(true) {
                _tickFlow.emit(Unit) // 3
                delay(tickIntervalMs)
            }
        }
    }
}

class NewsRepository(
    ...,
    private val tickHandler: TickHandler, // 4
    private val externalScope: CoroutineScope
) {
    init {
        externalScope.launch {
            // Listen for tick updates
            tickHandler.tickFlow.collect { // 5
                refreshLatestNews()
            }
        }
    }

    suspend fun refreshLatestNews() { ... }
    ...
}

示例解析:



  1. MutableSharedFlow声明了一个变量_tickFlow

  2. 定义了属性tickFlow

  3. 在初始化的时候使用SharedFlow成员变量_tickFlow每隔一段时间发送一个空事件

  4. NewsRepository类里声明成员变量tickHandler

  5. NewsRepository初始化之后开始收集事件,并在收集到事件之后调用refreshLatestNews方法来更新新闻。


看完这个例子再结合上面的介绍就会更加深入的了解SharedFlow了。


注意



  • 这SharedFlow是用于事件流处理的,可不是用来维护状态(state)的。

  • SharedFlow的另外一个重要的参数是extraBufferCapacity,它决定了流要在缓存里保留多少个发送过的事件。缓存满了之后会把缓存里面的一个值清理掉,并放入新的值。

  • 要处理缓存溢出的问题可以给onBufferOverflow指定一个方法。比如当缓存满了之后,并遇到新的事件的时候清理掉最旧的值或者暂停发送新事件一直到缓存有空余。

  • 可以使用tryEmit方法来检测是否存在一个活的收集器。这样可以避免无效的事件发送。


热流的坑


如果在同一个协成里订阅了多个热流,只有第一个才会被收集。其他的永远不会得到数据。


所以,要在同一个协成里订阅多个热流可以使用combine或者zip操作符把这些热流都合成到同一个流里。或者分别在每个协程订阅一个热流。


例如:


coroutineScope.launch {
hotFlow1.collect { value ->
// 处理收到的数据
}
hotFlow2.collect { value ->
// 永远不会执行到
}
}

在本例中,第二个collect不会收到数据。因为第一个collect会运行一个无限循环。


背压 (Backpressure)


背压,顾名思义,当消费者消费的速度没有生产者生产的速度快了。在Flow遇到这个情况的时候,生产者就会挂起直到消费者可以消费更多的数据。


runBlocking {
getFastFlow().collect { value ->
delay(1000) // simulate a slow collector
process(value)
}
}

在这个例子中,getFastFlow()会生成数据的速度比process(value)的速度快。因为collect是一个挂起函数,在process(value)数据处理不过来的时候getFastFlow()就会自动挂起。这样就防止了没有处理的数据的堆积。


使用缓存处理背压


有的时候,即使消费者处理速度已经慢于生产者产生数据的速度的时候,你还是想让生产者继续生产数据。这时就可以引入缓存了。Flow可以使用buffer操作符。如:


runBlocking {
getFastFlow().buffer().collect { value -> process(value) }
}

这个例子里使用了buffer操作符,这样在process(value)还在处理旧数据的时候getFastFlow()可以接着生产新的数据。


今日份先更新到这了。to be continued...


作者:小红星闪啊闪
来源:juejin.cn/post/7271153372793946168
收起阅读 »

环信FCM推送详细步骤

集成FCM推推送
准备的地址有 :https://firebase.google.com
1.firebase官网选择我们自己创建的项目

2.点到这个设置按键

3.我们打开到项目设置->常规 拉到最下面有一个“您的应用” 点击下载json文件,json文件的使用是客户端放在安卓项目的app目录下

4.首先环信需要的信息有 项目设置中-> 服务账号 生成新的私钥 生成的文件我们要上传到环信的管理后台证书部分(V1)

5.点击上传证书会选择你下载的文件,注意!! 名称是由你设置的项目名称的json文件 并不是 google-services.json
6.项目名称 是你的发送者ID 这个id 我们在firebase官网中的项目设置-〉常规 -〉您的项目->的项目编号就是您的SenderID 填写到环信官网即可 另外客户端的 google-services.json 这个文件 打开后 project number 也是SenderID

7.将我们下载好的 google-services.json 文件放到app的目录下 (文件获取可以反回步骤3 查看)

8.打开build的根目录添加 :
buildscript {
dependencies {
// classpath 'com.android.tools.build:gradle:7.2.2'
classpath 'com.google.gms:google-services:4.3.8'
}
}

9.build.gradle.app部分添加:
implementation platform('com.google.firebase:firebase-bom:28.4.1')
implementation 'com.google.firebase:firebase-messaging'

10.对应好appkey 以及我们的客户端初始化fcm的senderID

11.在登陆前 初始化以后 添加以下代码:
EMPushHelper.getInstance().setPushListener(new PushListener() {
@Override
public void onError(EMPushType pushType, long errorCode) {
EMLog.e("PushClient", "Push client occur a error: " + pushType + " - " + errorCode);
}

@Override
public boolean isSupportPush(EMPushType pushType, EMPushConfig pushConfig) {
if(pushType==EMPushType.FCM)
{
return GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(MainActivity.this)
== ConnectionResult.SUCCESS;
}
return super.isSupportPush(pushType, pushConfig);
}
});

12.登陆成功后的第一个页面添加 :
if(GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(NewAcitivty.this) != ConnectionResult.SUCCESS) {
return;
}
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(new OnCompleteListener() {
@Override
public void onComplete(@NonNull Task task) {
if (!task.isSuccessful()) {
EMLog.d("PushClient", "Fetching FCM registration token failed:"+task.getException());
return;
}
// 获取新的 FCM 注册 token
String token = task.getResult();
EMClient.getInstance().sendFCMTokenToServer(token);
}
});

13.清单文件注册sevices 主要是为了继承FCM的服务 必要操作!

添加代码: 重写onMessageReceived
收到消息后 就在这个方法中 自己调用 本地通知 因为fCM的推送只有唤醒
public class FireBaseservice extends FirebaseMessagingService {
@Override
public void onMessageReceived(@NonNull RemoteMessage message) {
super.onMessageReceived(message);
if (message.getData().size() > 0) {
String alter = message.getData().get("alter");
Log.d("", "onMessageReceived: " + alter);
}

}
@Override
public void onNewToken(@NonNull String token) {
Log.i("MessagingService", "onNewToken: " + token);
// 若要对该应用实例发送消息或管理服务端的应用订阅,将 FCM 注册 token 发送至你的应用服务器。
if(EMClient.getInstance().isSdkInited()) {
EMClient.getInstance().sendFCMTokenToServer(token);
}
}
}
14.准备测试 这个时候我们就要验证我们的成果了 首先要看自己登录到环信后 是否有绑定证书 借用环信的即时推送功能查看是否有绑定证书
这个时候看到登录了证书还是没有绑定上 那肯定是客户端出现问题了

15.检查错误 看到提示了com.xxxx.play 安装 这个是因为 你的设备没有打开 VPN 或者VPN不稳定,所以你首先要确定VPN打开并且 稳定 然后我们在重新登录测试

16.这个时候我们在借用即时推送查看 看看有没有绑定到环信 看到该字样就证明你的证书已经绑定上了 直接杀掉进程离线 测试离线推送,(一定要在清单文件注册的谷歌服务中 重新的onMessageReceived 中写入本地通知展示 不然fcm的推送只有唤醒)

安卓开发中如何实现一个定时任务

定时任务方式优点缺点使用场景所用的API普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)Timer定时器简单易用,可以设置固定周期或者...
继续阅读 »

定时任务方式优点缺点使用场景所用的API
普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)
Timer定时器简单易用,可以设置固定周期或者延迟执行的任务不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Timer.schedule(TimerTask,long)
ScheduledExecutorService灵活强大,可以设置固定周期或者延迟执行的任务,并支持多线程并发不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要多线程并发的定时任务Executors.newScheduledThreadPool(int).schedule(Runnable,long,TimeUnit)
Handler中的postDelayed方法简单易用,可以设置延迟执行的任务,并与UI线程交互不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要与UI线程交互的定时任务Handler.postDelayed(Runnable,long)
Service + AlarmManger + BroadcastReceiver可靠稳定,可以设置精确或者不精确的闹钟,并在后台长期运行需要声明相关权限,并受系统时间影响需要在App外部执行长期且对时间敏感的定时任务AlarmManager.set(int,PendingIntent), BroadcastReceiver.onReceive(Context,Intent), Service.onStartCommand(Intent,int,int)
WorkManager可靠稳定,不受系统时间影响,并可以设置多种约束条件来执行任务需要添加依赖,并不能保证准时执行需要在App外部执行长期且对时间不敏感且需要满足特定条件才能执行的定时任务WorkManager.enqueue(WorkRequest), Worker.doWork()
RxJava简洁、灵活、支持多线程、支持背压、支持链式操作学习曲线较高、内存占用较大需要处理复杂的异步逻辑或数据流io.reactivex:rxjava:2.2.21
CountDownTimer简单易用、不需要额外的线程或handler不支持取消或重置倒计时、精度受系统时间影响需要实现简单的倒计时功能android.os.CountDownTimer
协程+Flow语法简洁、支持协程作用域管理生命周期、支持流式操作和背压需要引入额外的依赖库、需要熟悉协程和Flow的概念和用法需要处理异步数据流或响应式编程kotlinx-coroutines-core:1.5.0
使用downTo关键字和Flow实现一个定时任务1、可以使用简洁的语法创建一个倒数的范围 2 、可以使用Flow异步地发射和收集倒数的值3、可以使用onEach等操作符对倒数的值进行处理或转换1、需要注意倒数的范围是否包含0,否则可能会出现偏差 2、需要注意倒数的间隔是否与delay函数的参数一致,否则可能会出现不准确 3、需要注意取消或停止Flow的时机,否则可能会出现内存泄漏或资源浪费1、适合于需要实现简单的倒计时功能,例如显示剩余时间或进度 2、适合于需要在倒计时过程中执行一些额外的操作,例如播放声音或更新UI 3、适合于需要在倒计时结束后执行一些额外的操作,例如跳转页面或弹出对话框implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0"
Kotlin 内联函数的协程和 Flow 实现很容易离开主线程,样板代码最少,协程完全活用了 Kotlin 语言的能力,包括 suspend 方法。可以处理大量的异步数据,而不会阻塞主线程。可能会导致内存泄漏和性能问题。处理 I/O 阻塞型操作,而不是计算密集型操作。kotlinx.coroutines 和 kotlinx.coroutines.flow

安卓开发中如何实现一个定时任务


在安卓开发中,我们经常会遇到需要定时执行某些任务的需求,比如轮询服务器数据、更新UI界面、发送通知等等。那么,我们该如何实现一个定时任务呢?本文将介绍安卓开发中实现定时任务的五种方式,并比较它们的优缺点,以及适用场景。


1. 普通线程sleep的方式


这种方式是最简单也最直观的一种实现方法,就是在一个普通线程中使用sleep方法来延迟执行某个任务。例如:


// 创建一个普通线程
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 循环执行
while (true) {
// 执行某个任务
doSomething();
// 延迟10秒
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
// 启动线程
thread.start();


这种方式的优点是简单易懂,不需要借助其他类或组件。但是它也有很多缺点:



  • sleep方法会阻塞当前线程,导致资源浪费和性能下降。

  • sleep方法不准确,它只能保证在指定时间后醒来,但不能保证立即执行。

  • sleep方法受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • sleep方法不可靠,如果线程被异常终止或者进入休眠状态,会导致计时中断。


因此,这种方式只适合一般的轮询Polling场景。


2. Timer定时器


这种方式是使用Java API里提供的Timer类来实现定时任务。Timer类可以创建一个后台线程,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Timer对象
Timer timer = new Timer();
// 创建一个TimerTask对象
TimerTask task = new TimerTask() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
timer.schedule(task, 5000, 10000);


这种方式相比第一种方式有以下优点:



  • Timer类内部使用wait和notify方法来控制线程的执行和休眠,不会浪费资源和性能。

  • Timer类可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • Timer类可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • Timer类只创建了一个后台线程来执行所有的任务,如果其中一个任务耗时过长或者出现异常,则会影响其他任务的执行。

  • Timer类受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • Timer类不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些不太重要的定时任务。


3. ScheduledExecutorService


这种方式是使用Java并发包里提供的ScheduledExecutorService接口来实现定时任务。ScheduledExecutorService接口可以创建一个线程池,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个ScheduledExecutorService对象
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
service.scheduleAtFixedRate(task, 5, 10, TimeUnit.SECONDS);


这种方式相比第二种方式有以下优点:



  • ScheduledExecutorService接口可以创建多个线程来执行多个任务,避免了单线程的弊端。

  • ScheduledExecutorService接口可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • ScheduledExecutorService接口可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • ScheduledExecutorService接口受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • ScheduledExecutorService接口不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些需要多线程并发执行的定时任务。


4. Handler中的postDelayed方法


这种方式是使用Android API里提供的Handler类来实现定时任务。Handler类可以在主线程或者子线程中发送和处理消息,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Handler对象
Handler handler = new Handler();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
// 延迟10秒后再次执行该任务
handler.postDelayed(this, 10000);
}
};
// 延迟5秒后开始执行该任务
handler.postDelayed(task, 5000);


这种方式相比第三种方式有以下优点:



  • Handler类不受系统时间影响,它使用系统启动时间作为参考。

  • Handler类可以在主线程中更新UI界面,避免了线程间通信的问题。


但是这种方式也有以下缺点:



  • Handler类只能在当前进程中使用,如果进程被杀死或者进入休眠状态,会导致计时中断。

  • Handler类需要手动循环调用postDelayed方法来实现周期性地执行任务。


因此,这种方式适合一些需要在主线程中更新UI界面的定时任务.


5. Service + AlarmManager + BroadcastReceiver


这种方式是使用Android API里提供的三个组件来实现定时任务. Service组件可以在后台运行某个长期的服务;AlarmManager组件可以设置一个闹钟,在指定的时间发送一个



  • Intent,用于指定要启动的Service组件和传递一些参数。

  • AlarmManager组件可以设置一个闹钟,在指定的时间发送一个Intent给BroadcastReceiver组件。

  • BroadcastReceiver组件可以接收AlarmManager发送的Intent,并启动Service组件来执行任务。


这种方式相比第四种方式有以下优点:



  • Service组件可以在后台运行,即使进程被杀死或者进入休眠状态,也不会影响计时。

  • AlarmManager组件可以设置精确或者不精确的闹钟,根据不同的需求节省电量。

  • BroadcastReceiver组件可以在系统开机后自动注册,实现开机自启动。


但是这种方式也有以下缺点:



  • Service组件需要在AndroidManifest.xml文件中声明,并申请相关的权限。

  • AlarmManager组件受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • BroadcastReceiver组件需要在代码中动态注册和注销,避免内存泄漏。


因此,这种方式适合一些需要长期在后台执行的定时任务。


6. WorkManager


这种方式是使用Android Jetpack里提供的WorkManager库来实现定时任务. WorkManager库是一个用于管理后台任务的框架,它可以在满足一定条件下执行某个任务,并保证任务一定会被执行。例如:


// 创建一个PeriodicWorkRequest对象
PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(MyWorker.class, 15, TimeUnit.MINUTES)
.setConstraints(new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build();
// 获取一个WorkManager对象
WorkManager workManager = WorkManager.getInstance(this);
// 将PeriodicWorkRequest对象加入到队列中
workManager.enqueue(request);


这种方式相比第五种方式有以下优点:



  • WorkManager库不受系统时间影响,它使用系统启动时间作为参考。

  • WorkManager库可以设置多种约束条件来执行任务,例如网络状态、电量状态、设备空闲状态等。

  • WorkManager库可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • WorkManager库需要添加依赖并配置相关的权限。

  • WorkManager库不能保证任务准时执行,它会根据系统资源和约束条件来调度任务。


因此,这种方式适合一些对时间不敏感且需要满足特定条件才能执行的定时任务


您好,这是必应。我根据您的要求,从网络上搜集了一些关于如何使用RXJAVA、CountDownTimer、协程安卓实现一个定时任务的资料,并整理成了一篇文章。文章的结构如下:


7.如何使用RXJAVA实现定时任务


RXJAVA是一个基于观察者模式和函数式编程的库,它可以用来处理异步事件和数据流。RXJAVA提供了一些操作符,可以用来创建和处理定时任务。其中,interval操作符可以用来创建一个按照固定时间间隔发射整数序列的Observable,可以用来实现周期性的定时任务。例如,以下代码可以实现每隔1秒发射一个整数,从0开始:


//创建一个Observable,每隔1秒发射一个整数,从0开始
Observable timer = Observable.interval(1, TimeUnit.SECONDS);
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


如果要实现倒计时的效果,可以使用map操作符,将发射的整数转换为倒计时的秒数。例如,以下代码可以实现一个10秒的倒计时:


//创建一个Observable,每隔1秒发射一个整数,从0开始,共发射10个
Observable timer = Observable.interval(1, TimeUnit.SECONDS).take(10);
//使用map操作符,将发射的整数转换为倒计时的秒数
timer = timer.map(new Function() {
@Override
public Long apply(Long aLong) throws Exception {
//将发射的整数转换为倒计时的秒数,例如0转换为10,1转换为9,以此类推
return 10 - aLong;
}
});
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


RXJAVA的优点是可以方便地处理异步事件和数据流,可以使用各种操作符来实现复杂的逻辑,可以避免内存泄漏和线程安全


8.如何使用CountDownTimer实现定时任务


CountDownTimer是Android中提供的一个倒计时器类,它可以用来实现一个在一定时间内递减的倒计时。CountDownTimer的构造方法接受两个参数:总时间和间隔时间。例如,以下代码可以创建一个10秒的倒计时,每隔1秒更新一次:


//创建一个10秒的倒计时,每隔1秒更新一次
CountDownTimer timer = new CountDownTimer(10000, 1000) {
@Override
public void onTick(long millisUntilFinished) {
//每隔一秒调用一次,可以用来更新UI或者执行其他逻辑
Log.d("CountDownTimer", "onTick: " + millisUntilFinished / 1000);
}

@Override
public void onFinish() {
//倒计时结束时调用,可以用来释放资源或者执行其他逻辑
Log.d("CountDownTimer", "onFinish");
}
};
//开始倒计时
timer.start();
//取消倒计时
timer.cancel();


CountDownTimer的优点是使用简单,可以直接在UI线程中更新UI,不需要额外的线程或者Handler。CountDownTimer的缺点是只能实现倒计时的效果,不能实现周期性的定时任务,而且精度受系统时间的影响,可能不够准确。


9.如何使用协程实现定时任务


协程可以用来简化异步编程和线程管理。协程是一种轻量级的线程,它可以在不阻塞线程的情况下挂起和恢复执行。协程安卓提供了一些扩展函数,可以用来创建和处理定时任务。其中,delay函数可以用来暂停协程的执行一段时间,可以用来实现倒计时或者周期性的定时任务。例如,以下代码可以实现一个10秒的倒计时,每隔1秒更新一次:


//创建一个协程作用域,可以用来管理协程的生命周期
val scope = CoroutineScope(Dispatchers.Main)
//在协程作用域中启动一个协程,可以用来执行异步任务
scope.launch {
//创建一个变量,表示倒计时的秒数
var seconds = 10
//循环执行,直到秒数为0
while (seconds > 0) {
//打印秒数,可以用来更新UI或者执行其他逻辑
Log.d("Coroutine", "seconds: $seconds")
//暂停协程的执行1秒,不阻塞线程
delay(1000)
//秒数减一
seconds--
}
//倒计时结束,打印日志,可以用来释放资源或者执行其他逻辑
Log.d("Coroutine", "finish")
}
//取消协程作用域,可以用来取消所有的协程
scope.cancel()


协程安卓的优点是可以方便地处理异步任务和线程切换,可以使用简洁的语法来实现复杂的逻辑,可以避免内存泄漏和回调。协程的缺点是需要引入额外的依赖,而且需要一定的学习成本,不太适合初学者。


10.使用kotlin关键字 ‘downTo’ 搭配Flow


// 创建一个倒计时器,从10秒开始,每秒减一
val timer = object: CountDownTimer(10000, 1000) {
override fun onTick(millisUntilFinished: Long) {
// 在每个间隔,发射剩余的秒数
emitSeconds(millisUntilFinished / 1000)
}

override fun onFinish() {
// 在倒计时结束时,发射0
emitSeconds(0)
}
}

// 创建一个Flow,用于发射倒数的秒数
fun emitSeconds(seconds: Long): Flow = flow {
// 使用downTo关键字创建一个倒数的范围
for (i in seconds downTo 0) {
// 发射当前的秒数
emit(i.toInt())
}
}


11.kotlin内联函数的协程和 Flow 实现


fun FragmentActivity.timerFlow(
time: Int = 60,
onStart: (suspend () -> Unit)? = null,
onEach: (suspend (Int) -> Unit)? =
null,
onCompletion: (suspend () -> Unit)? =
null
): Job {
return (time downTo 0)
.asFlow()
.cancellable()
.flowOn(Dispatchers.Default)
.onStart { onStart?.invoke() }
.onEach {
onEach?.invoke(it)
delay(
1000L)
}.onCompletion { onCompletion?.invoke() }
.launchIn(lifecycleScope)
}


//在activity中使用
val job = timerFlow(
time = 60,
onStart = { Log.d("Timer", "Starting timer...") },
onEach = { Log.d("Timer", "Seconds remaining: $it") },
onCompletion = { Log.d("Timer", "Timer completed.") }
)

//取消计时
job.cancel()

作者:淘淘养乐多
来源:juejin.cn/post/7270173192789737487
收起阅读 »

用一个RecyclerView实现抖音二级评论

前一阵,看到一位掘友分享了一篇文章:Android简单的两级评论功能实现,看得出来,是一位Android萌新记录的学习过程。我当时还留了一条建议: 建议用单RecyclerView+多ItemType+ListAdapter实现,保持UI层的清洁,把逻辑处理...
继续阅读 »

前一阵,看到一位掘友分享了一篇文章:Android简单的两级评论功能实现,看得出来,是一位Android萌新记录的学习过程。我当时还留了一条建议:



建议用单RecyclerView+多ItemType+ListAdapter实现,保持UI层的清洁,把逻辑处理集中在数据源的转换上,比如展开/收起二级评论可以利用flatMap和groupBy等操作符转换



其实我之前在工作中,也曾经做过类似抖音的二级评论的需求。但那个时候自己很菜,还没有用过Kotlin,协程更是没有接触过,这个功能和另一位同事一起开发了两周才搞定。


刚好这个周末没啥事,就想着写一个简单实现抖音二级评论基本功能的Demo。一方面,想试试自己现在开发这样一个需求会是什么样的体验;另一方面,也算是给Android掘友,尤其是萌新,分享一点业务开发的心得。


先上个效果图(没有UI,将就看吧),写代码的整个过程花了4个小时左右,相比当初自己开发需求已经快了很多了哈。



给产品估个两天时间,摸一天半的鱼不过分吧(手动斜眼)



评论.gif


需求拆分


这种大家常用的评论功能其实也就没啥好拆分的了,简单列一下:



  • 默认展示一级评论和二级评论中的热评,可以上拉加载更多。

  • 二级评论超过两条时,可以点击展开加载更多二级评论,展开后可以点击收起折叠到初始状态。

  • 回复评论后插入到该评论的下方。


技术选型


前面我在给掘友的评论中,也提到了技术选型的要点:


单RecyclerView + 多ItemType + ListAdapter


这是基本的UI框架。


为啥要只用一个RecyclerView?最重要的原因,就是在RecyclerView中嵌套同方向RecyclerView,会有性能问题和滑动冲突。其次,当下声明式UI正是各方大佬推崇的最佳开发实践之一,虽然我们没有使用声明式UI基础上开发的Compose/Flutter技术,但其构建思想仍然对我们的开发具有一定的指导意义。我猜测,androidx.recyclerview.widget.ListAdapter可能也是响应声明式UI号召的一个针对RecyclerView的解决方案吧。


数据源的转换


数据驱动UI!


既然选用了ListAdapter,那么我们就不应该再手动操作adapter的数据,再用各种notifyXxx方法来更新列表了。更提倡的做法是,基于data class浅拷贝,用Collection操作符对数据源的进行转换,然后将转换后的数据提交到adapter。为了提高数据转换性能,我们可以基于协程进行异步处理。


graph LR
start[原List] --异步数据处理--> 新List --> stop[ListAdapter.submitList]
stop --> start

要点:



  • 浅拷贝


低成本生成一个全新的对象,以保证数据源的安全性。


data class Foo(val id: Int, val content: String)

val foo1 = Foo(0, "content")
val foo2 = foo1.copy(content = "updated content")


  • Collection操作符


Kotlin中提供了大量非常好用的Collection操作符,能灵活使用的话,非常有利于咱们向声明式UI转型。


前面我提到了groupByflatMap这两个操作符。怎么使用呢?


以这个需求为例,我们需要显示一级评论、二级评论和展开更多按钮,想要分别用一个data class来表示,但是后端返回的数据中又没有“展开更多”这样的数据,就可以这样处理:


// 从后端获取的数据List,包括有一级评论和二级评论,二级评论的parentId就等于一级评论的id
val loaded: List<CommentItem> = ...
val grouped = loaded.groupBy {
// (1) 以一级评论的id为key,把源list分组为一个Map<Int, List<CommentItem>>
(it as? CommentItem.Level1)?.id ?: (it as? CommentItem.Level2)?.parentId
?: throw IllegalArgumentException("invalid comment item")
}.flatMap {
// (2) 展开前面的map,展开时就可以在每级一级评论的二级评论后面添加一个控制“展开更多”的Item
it.value + CommentItem.Folding(
parentId = it.key,
)
}


  • 异步处理


前面我们描述的数据源的转换过程,在Kotlin中,可以简单地被抽象为一个操作:


List<CommentItem>.() -> List<CommentItem>

对于这个需求,数据源转换操作就包括了:分页加载,展开二级评论,收起二级评论,回复评论等。按照惯例,抽象一个接口出来。既然我们要在协程框架下进行异步处理,需要给这个操作加一个suspend关键字。


interface Reducer {
val reduce: suspend List<CommentItem>.() -> List<CommentItem>
}

为啥我给这个接口取名Reducer?如果你知道它的意思,说明你可能已经了解过MVI架构了;如果你还不知道它的意思,说明你可以去了解一下MVI了。哈哈!


不过今天不谈MVI,对于这样一个小Demo,完全没必要上架构。但是,优秀架构为我们提供的代码构建思路是有必要的!


这个Reducer,在这里就算是咱们的小小业务架构了。



  • 异步2.0


前面谈到异步,我们印象中可能主要是网络请求、数据库/文件读写等IO操作。


这里我想要延伸一下。


ActivitystartActivityForResult/onActivityResultDialog的拉起/回调,其实也可以看着是异步操作。异步与是否在主线程无关,而在于是否是实时返回结果。毕竟在主线程上跳转到其他页面,获取数据再回调回去使用,也是花了时间的啊。所以在协程的框架下,有一个更适合描述异步的词语:挂起(suspend)


说这有啥用呢?仍以这个需求为例,我们点击“回复”后拉起一个对话框,输入评论确认后回调给Activity,再进行网络请求:


class ReplyDialog(context: Context, private val callback: (String) -> Unit) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_reply)
val editText = findViewById<EditText>(R.id.content)
findViewById<Button>(R.id.submit).setOnClickListener {
if (editText.text.toString().isBlank()) {
Toast.makeText(context, "评论不能为空", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
callback.invoke(editText.text.toString())
dismiss()
}
}
}

suspend List<CommentItem>.() -> List<CommentItem> = {
val content = withContext(Dispatchers.Main) {
// 由于整个转换过程是在IO线程进行,Dialog相关操作需要转换到主线程操作
suspendCoroutine { continuation ->
ReplyDialog(context) {
continuation.resume(it)
}.show()
}
}
...进行其他操作,如网络请求
}

技术选型,或者说技术框架,咱们就实现了,甚至还谈到了部分细节了。接下来进行完整实现细节分享。


实现细节


MainActivity


基于上一章节的技术选型,咱们的MainActivity的完整代码就是这样了。


class MainActivity : AppCompatActivity() {
private lateinit var commentAdapter: CommentAdapter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
commentAdapter = CommentAdapter {
lifecycleScope.launchWhenResumed {
val newList = withContext(Dispatchers.IO) {
reduce.invoke(commentAdapter.currentList)
}
val firstSubmit = commentAdapter.itemCount == 1
commentAdapter.submitList(newList) {
// 这里是为了处理submitList后,列表滑动位置不对的问题
if (firstSubmit) {
recyclerView.scrollToPosition(0)
} else if (this@CommentAdapter is FoldReducer) {
val index = commentAdapter.currentList.indexOf(this@CommentAdapter.folding)
recyclerView.scrollToPosition(index)
}
}
}
}
recyclerView.adapter = commentAdapter
}
}

RecyclerView设置一个CommentAdapter就行了,回调时也只需要把回调过来的Reducer调度到IO线程跑一下,得到新的数据listsubmitList就完事了。如果不是submitList后有列表的定位问题,代码还能更精简。如果有知道更好的解决办法的朋友,麻烦留言分享一下,感谢!


CommentAdapter


别以为我把逻辑处理扔到adapter中了哦!


AdapterViewHolder都是UI组件,我们也需要尽量保持它们的清洁。


贴一下CommentAdapter


class CommentAdapter(private val reduceBlock: Reducer.() -> Unit) :
ListAdapter<CommentItem, VH>(object : DiffUtil.ItemCallback<CommentItem>() {
override fun areItemsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
return oldItem.id == newItem.id
}

override fun areContentsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
if (oldItem::class.java != newItem::class.java) return false
return (oldItem as? CommentItem.Level1) == (newItem as? CommentItem.Level1)
|| (oldItem as? CommentItem.Level2) == (newItem as? CommentItem.Level2)
|| (oldItem as? CommentItem.Folding) == (newItem as? CommentItem.Folding)
|| (oldItem as? CommentItem.Loading) == (newItem as? CommentItem.Loading)
}
}) {

init {
submitList(listOf(CommentItem.Loading(page = 0, CommentItem.Loading.State.IDLE)))
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_LEVEL1 -> Level1VH(
inflater.inflate(R.layout.item_comment_level_1, parent, false),
reduceBlock
)

TYPE_LEVEL2 -> Level2VH(
inflater.inflate(R.layout.item_comment_level_2, parent, false),
reduceBlock
)

TYPE_LOADING -> LoadingVH(
inflater.inflate(
R.layout.item_comment_loading,
parent,
false
), reduceBlock
)

else -> FoldingVH(
inflater.inflate(R.layout.item_comment_folding, parent, false),
reduceBlock
)
}
}

override fun onBindViewHolder(holder: VH, position: Int) {
holder.onBind(getItem(position))
}

override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is CommentItem.Level1 -> TYPE_LEVEL1
is CommentItem.Level2 -> TYPE_LEVEL2
is CommentItem.Loading -> TYPE_LOADING
else -> TYPE_FOLDING
}
}

companion object {
private const val TYPE_LEVEL1 = 0
private const val TYPE_LEVEL2 = 1
private const val TYPE_FOLDING = 2
private const val TYPE_LOADING = 3
}
}

可以看到,就是一个简单的多ItemTypeAdapter,唯一需要注意的就是,在Activity里传入的reduceBlock: Reducer.() -> Unit,也要传给每个ViewHolder


ViewHolder


篇幅原因,就只贴其中一个:


abstract class VH(itemView: View, protected val reduceBlock: Reducer.() -> Unit) :
ViewHolder(itemView) {
abstract fun onBind(item: CommentItem)
}

class Level1VH(itemView: View, reduceBlock: Reducer.() -> Unit) : VH(itemView, reduceBlock) {
private val avatar: TextView = itemView.findViewById(R.id.avatar)
private val username: TextView = itemView.findViewById(R.id.username)
private val content: TextView = itemView.findViewById(R.id.content)
private val reply: TextView = itemView.findViewById(R.id.reply)
override fun onBind(item: CommentItem) {
avatar.text = item.userName.subSequence(0, 1)
username.text = item.userName
content.text = item.content
reply.setOnClickListener {
reduceBlock.invoke(ReplyReducer(item, itemView.context))
}
}
}

也是很简单,唯一特别一点的处理,就是在onClickListener中,让reduceBlockinvoke一个Reducer实现。


Reducer


刚才在技术选型章节,已经提前展示了“回复评论”这一操作的Reducer实现了,其他Reducer也差不多,比如展开评论操作,也封装在一个Reducer实现ExpandReducer中,以下是完整代码:


data class ExpandReducer(
val folding: CommentItem.Folding,
) : Reducer {
private val mapper by lazy { Entity2ItemMapper() }
override val reduce: suspend List<CommentItem>.() -> List<CommentItem> = {
val foldingIndex = indexOf(folding)
val loaded =
FakeApi.getLevel2Comments(folding.parentId, folding.page, folding.pageSize).getOrNull()
?.map(mapper::invoke) ?: emptyList()
toMutableList().apply {
addAll(foldingIndex, loaded)
}.map {
if (it is CommentItem.Folding && it == folding) {
val state =
if (it.page > 5) CommentItem.Folding.State.LOADED_ALL else CommentItem.Folding.State.IDLE
it.copy(page = it.page + 1, state = state)
} else {
it
}
}
}

}

短短一段代码,我们做了这些事:



  • 请求网络数据Entity list(假数据)

  • 通过mapper转换成显示用的Item数据list

  • Item数据插入到“展开更多”按钮前面

  • 最后,根据二级评论加载是否完成,将“展开更多”的状态置为IDLELOADED_ALL


一个字:丝滑!


用于转换EntityItemmapper的代码也贴一下吧:


// 抽象
typealias Mapper<I, O> = (I) -> O
// 实现
class Entity2ItemMapper : Mapper<ICommentEntity, CommentItem> {
override fun invoke(entity: ICommentEntity): CommentItem {
return when (entity) {
is CommentLevel1 -> {
CommentItem.Level1(
id = entity.id,
content = entity.content,
userId = entity.userId,
userName = entity.userName,
level2Count = entity.level2Count,
)
}

is CommentLevel2 -> {
CommentItem.Level2(
id = entity.id,
content = if (entity.hot) entity.content.makeHot() else entity.content,
userId = entity.userId,
userName = entity.userName,
parentId = entity.parentId,
)
}

else -> {
throw IllegalArgumentException("not implemented entity: $entity")
}
}
}
}

细心的朋友可以看到,在这里我顺便也将热评也处理了:


if (entity.hot) entity.content.makeHot() else entity.content

makeHot()就是用buildSpannedString来实现的:


fun CharSequence.makeHot(): CharSequence {
return buildSpannedString {
color(Color.RED) {
append("热评 ")
}
append(this@makeHot)
}
}

这里可以提一句:尽量用CharSequence来抽象表示字符串,可以方便我们灵活地使用Span来减少UI代码。


data class


也贴一下相关的数据实体得了。



  • 网络数据(假数据)


interface ICommentEntity {
val id: Int
val content: CharSequence
val userId: Int
val userName: CharSequence
}

data class CommentLevel1(
override val id: Int,
override val content: CharSequence,
override val userId: Int,
override val userName: CharSequence,
val level2Count: Int,
) : ICommentEntity


  • RecyclerView Item数据


sealed interface CommentItem {
val id: Int
val content: CharSequence
val userId: Int
val userName: CharSequence

data class Loading(
val page: Int = 0,
val state: State = State.LOADING
) : CommentItem {
override val id: Int=0
override val content: CharSequence
get() = when(state) {
State.LOADED_ALL -> "全部加载"
else -> "加载中..."
}
override val userId: Int=0
override val userName: CharSequence=""

enum class State {
IDLE, LOADING, LOADED_ALL
}
}

data class Level1(
override val id: Int,
override val content: CharSequence,
override val userId: Int,
override val userName: CharSequence,
val level2Count: Int,
) : CommentItem

data class Level2(
override val id: Int,
override val content: CharSequence,
override val userId: Int,
override val userName: CharSequence,
val parentId: Int,
) : CommentItem

data class Folding(
val parentId: Int,
val page: Int = 1,
val pageSize: Int = 3,
val state: State = State.IDLE
) : CommentItem {
override val id: Int
get() = hashCode()
override val content: CharSequence
get() = when {
page <= 1 -> "展开20条回复"
page >= 5 -> ""
else -> "展开更多"
}
override val userId: Int = 0
override val userName: CharSequence = ""

enum class State {
IDLE, LOADING, LOADED_ALL
}
}
}

这部分没啥好说的,可以注意两个点:



  • data class也是可以抽象的。但这边我处理不是很严谨,比如CommentItem我把userIduserName也抽象出来了,其实不应该抽象出来。

  • 在基于Reducer的框架下,最好是把data class的属性都定义为val


结语


更多的代码就不贴了,贴太多影响观感。有兴趣的朋友可以移步源码


总结一下实现心得:



  • 数据驱动UI

  • 对业务的精准抽象

  • 对异步的延伸理解

  • 灵活使用Collection操作符

  • 没有UI和PM,写代码真TM爽!


作者:blackfrog
来源:juejin.cn/post/7276808079143190565
收起阅读 »

Android:实现一个简单带动画的展开收起功能

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 今天给大家带来一个展开和收起的简单效果。如果只是代码中简单设置显示或隐藏,熟悉安卓系统的朋友都知道,那一定是闪现。所以笔者结合了动画,使得体验效果瞬间提升一个档次。话不多说,直接上效...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。



今天给大家带来一个展开和收起的简单效果。如果只是代码中简单设置显示或隐藏,熟悉安卓系统的朋友都知道,那一定是闪现。所以笔者结合了动画,使得体验效果瞬间提升一个档次。话不多说,直接上效果:


1.gif


首先观察图中效果,视图有展开和折叠两种状态,右侧图标和文字会跟随这个状态改变。那么其中就有折叠的高度和展开的高度需要我们记录。折叠高度是固定的,展开高度需要动态获取。需要注意的是不能直接通过视图直接获取高度,因为视图的绘制和Activity的生命周期是不同步的,在Activity中直接lin.height获取高度无法保证此时的视图已经完成计算。这里直接用简单的post方式获取到绘制完成的总高度。原理是将这个消息放到队列最后一条,这样就可以保证回调方法中能够获取到真实的高度。


lin?.post {
val h = lin!!.height
hight = if (h > 0) h else baseHight

if (h > 0 && ivTop?.visibility == View.GONE) {
ivTop?.visibility = View.VISIBLE
}
}

接下来就是动画的使用和动态控制视图的高度了。这里需要用到属性动画,我们知道的属性动画有ValueAnimatorObjectAnimatorObjectAnimator是继承于ValueAnimator,说明ValueAnimator能做的事情ObjectAnimator也可以实现。由于我们要控制的视图不止一个,所以还是使用ValueAnimator方便点。通过addUpdateListener添加监听后,animation.animatedValue就是我们需要的当前值。在此处不停将当前高度赋值给视图,并且图标也根据这个值进行等比例的旋转以到达到视图不停更新。


//根据展开、关闭状态传入对应高度
val animator = ValueAnimator.ofInt(
if (isExpand) hight - baseHight else 0,
if (isExpand) 0 else hight - baseHight
)
animator.addUpdateListener { animation ->
val params = lin?.layoutParams
params?.height = if ((animation.animatedValue as Int) < baseHight) baseHight else (animation.animatedValue as Int) //当高度小于基础高度时 给与基础高度
lin?.layoutParams = params//拿到当前高度
//图标旋转
ivTop?.rotation = (animation.animatedValue as Int) * 180f / (hight - baseHight)

}
animator.duration = 500//动画时长
animator.start()

isExpand = !isExpand
tvExpand?.text = if (isExpand) "关闭" else "展开"

编写过程需要注意展开和收起状态下值的正确输入,在回调方法中获取对应的当前值并赋值。


好了,一个简单的展开收起功能就实现了,希望对大家有所帮助。


作者:似曾相识2022
来源:juejin.cn/post/7273079438991376439
收起阅读 »

Android 沉浸式状态栏,透明状态栏 采用系统api,超简单近乎完美的实现

前言 沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接) 有写的不对的地方,欢迎指出 从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11... ... 让...
继续阅读 »

前言


沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接)


有写的不对的地方,欢迎指出


从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11...


... 让我们直接开始吧


导入核心包


老项目非androidx的请自行研究下,这里使用的是androidx,并且用的kotlin语言
本次实现方式跟windowInsets息息相关,这可真是个好东西
首先是需要导入核心包
androidx.core:core

kotlin可选择导入这个:
androidx.core:core-ktx
我用的版本是
androidx.core:core-ktx:1.12.0

开启 “沉浸式” 支持


沉浸式原本的意思似乎是指全屏吧。。。算了,不管那么多,喊习惯了 沉浸式状态栏,就这么称呼吧。

在activity 的oncreate里调用
//将decorView的fitSystemWindows属性设置为false
WindowCompat.setDecorFitsSystemWindows(window, false)
//设置状态栏颜色为透明
window.statusBarColor = Color.TRANSPARENT
//是否需要改变状态栏上的 图标、字体 的颜色
//获取InsetsController
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
//mask:遮罩 默认是false
//mask = true 状态栏字体颜色为黑色,一般在状态栏下面的背景色为浅色时使用
//mask = false 状态栏字体颜色为白色,一般在状态栏下面的背景色为深色时使用
var mask = true
insetsController.isAppearanceLightStatusBars = mask
//底部导航栏是否需要修改
//android Q+ 去掉虚拟导航键 的灰色半透明遮罩
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//设置虚拟导航键的 背景色为透明
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//8.0+ 虚拟导航键图标颜色可以修改,所以背景可以用透明
window.navigationBarColor = Color.TRANSPARENT
} else {
//低版本因为导航键图标颜色无法修改,建议用黑色,不要透明
window.navigationBarColor = Color.BLACK
}
//是否需要修改导航键的颜色,mask 同上面状态栏的一样
insetsController.isAppearanceLightNavigationBars = mask

修改 状态栏、虚拟导航键 的图标颜色,可以在任意需要的时候设置,防止图标和字体颜色和背景色一致导致看不清

补充一下:
状态栏和虚拟导航栏的背景色要注意以下问题:
1.在低于6.0的手机上,状态栏上的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为它们有自己的api能实现修改颜色
2.在低于8.0的手机上,虚拟导航栏的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为他们有自己的api能实现修改颜色
解决方案:
低于指定版本的系统上,对应的颜色就不要用透明,除非你的APP页面是深色背景,否则,建议采用半透明的灰色

在带有刘海或者挖孔屏上,横屏时刘海或者挖孔的那条边会有黑边,解决方法是:
给APP的主题v27加上
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
参考图:

image.png


监听OnApplyWindowInsetsListener


//准备一个boolean变量 作为是否在跑动画的标记
var flagProgress = false

//这里可以使用decorView或者是任意view
val view = window.decorView

//监听windowInsets变化
ViewCompat.setOnApplyWindowInsetsListener(view) { view: View, insetsCompat: WindowInsetsCompat ->
//如果要配合下面的setWindowInsetsAnimationCallback一起用的话,一定要记得,onProgress的时候,这里做个拦截,直接返回 insets
if (flagProgress) return@setOnApplyWindowInsetsListener insetsCompat
//在这里开始给需要的控件分发windowInsets

//最后,选择不消费这个insets,也可以选择消费掉,不在往子控件分发
insetsCompat
}
//带平滑过渡的windowInsets变化,ViewCompat中的这个,官方提供了 api 21-api 29的支持,本来这个只支持 api 30+的,相当不错!
//启用setWindowInsetsAnimationCallback的同时,也必须要启用上面的setOnApplyWindowInsetsListener,否则在某些情况下,windowInsets改变了,但是因为不会触发setWindowInsetsAnimationCallback导致padding没有更新到UI上
//DISPATCH_MODE_CONTINUE_ON_SUBTREE这个代表动画事件继续分发下去给子View
ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(insetsCompat: WindowInsetsCompat, runningAnimations: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
//每一帧的windowInsets
//可以在这里分发给需要的View。例如给一个聊天窗口包含editText的布局设置这个padding,可以实现键盘弹起时,在底部的editText跟着键盘一起滑上去,参考微信聊天界面,这个比微信还丝滑(android 11+最完美)。
//最后,直接原样return,不消费
return insetsCompat
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
//动画结束,将标记置否
flagProgress = false
}

override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
//动画准备开始,在这里可以记录一些UI状态信息,这里将标记设置为true
flagProgress = true
}
})

读取高度值


通过上面的监听,我们能拿到WindowInsetsCompat对象,现在,我们从这里面取到我们需要的高度值


先定义几个变量,我们需要拿的包含:
1. 刘海,挖空区域所占据的宽度或者是高度
2. 被系统栏遮挡的区域
3. 被输入法遮挡的区域

//cutoutPadding 刘海,挖孔区域的padding
var cutoutPaddingLeft = 0
var cutoutPaddingTop = 0
var cutoutPaddingRight = 0
var cutoutPaddingBottom = 0

//获取刘海,挖孔的高度,因为这个不是所有手机都有,所以,需要判空
insetsCompat.displayCutout?.let { displayCutout ->
cutoutPaddingTop = displayCutout.safeInsetTop
cutoutPaddingLeft = displayCutout.safeInsetLeft
cutoutPaddingRight = displayCutout.safeInsetRight
cutoutPaddingBottom = displayCutout.safeInsetBottom
}


//systemBarPadding 系统栏区域的padding
var systemBarPaddingLeft = 0
var systemBarPaddingTop = 0
var systemBarPaddingRight = 0
var systemBarPaddingBottom = 0

//获取系统栏区域的padding
//系统栏 + 输入法
val systemBars = insetsCompat.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
//左右两侧的padding通常直接赋值即可,如果横屏状态下,虚拟导航栏在侧边,那么systemBars.left或者systemBars.right的值就是它的宽度,竖屏情况下,一般都是0
systemWindowInsetLeft = systemBars.left
systemWindowInsetRight = systemBars.right
//这里判断下输入法 和 虚拟导航栏是否存在,如果存在才设置paddingBottom
if (insetsCompat.isVisible(WindowInsetsCompat.Type.ime()) || insetsCompat.isVisible(WindowInsetsCompat.Type.navigationBars())) {
systemWindowInsetBottom = systemBars.bottom
}
//同样判断下状态栏
if (insetsCompat.isVisible(WindowInsetsCompat.Type.statusBars())) {
systemWindowInsetTop = systemBars.top
}

到这里,我们需要的信息已经全部获取到了,接下来就是根据需求,设置padding属性了

补充一下:
我发现在低于android 11的手机上,insets.isVisible(Type)返回始终为true
并且,即使系统栏被隐藏,systemBars.top, systemBars.bottom也始终会有高度
所以这里


保留原本的Padding属性


上述获取的值,直接去设置padding的话,会导致原本的padding属性失效,所以我们需要在首次设置监听,先保存一份原本的padding属性,在最后设置padding的时候,把这份原本的padding值加上即可,就不贴代码了。


第一次写文章,写的粗糙了点

可能我写的不太好,没看懂也没关系,直接去看完整代码吧


我专门写了个小工具,可以去看看:
沉浸式系统栏 小工具


如果有更好的优化方案,欢迎在github上提出,我们一起互相学习!


作者:Matchasxiaobin
来源:juejin.cn/post/7275943802938130472
收起阅读 »

花亿点时间,写个Android抓包库

0x1、引言 上周五版本刚提测,这周边改BUG边摸鱼,百无聊赖,想起前不久没业务需求时,随手写的Android抓包库。 就公司的APP集成了 抓包功能,目的是:方便非Android开发的同事在 接口联调和测试阶段 能够看到APP的请求日志,进行一些简单的问题定...
继续阅读 »

0x1、引言


上周五版本刚提测,这周边改BUG边摸鱼,百无聊赖,想起前不久没业务需求时,随手写的Android抓包库。


就公司的APP集成了 抓包功能,目的是:方便非Android开发的同事在 接口联调和测试阶段 能够看到APP的请求日志,进行一些简单的问题定位(如接口字段错误返回,导致APP UI显示异常),不用动不动就来找Android崽~


手机摇一摇,就能查看 APP发起的请求列表具体的请求信息



能用,但存在一些问题,先是 代码层面



  • 耦合:抓包代码直接硬编码在项目中,线上包不需要抓包功能,也会把这部分代码打包到APK里

  • 复用性差:其它APP想添加抓包功能,需要CV大量代码...

  • 安全性:是否启用抓包功能,通过 BuildConfig.DEBUG 来判断,二次打包修改AndroidManifest.xml文件添加 android:debuggable="true" 或者 root手机后修改ro.debuggable为1 设置手机为可调试模式,生产环境的接口请求一览无余。


当然,上面的安全性有点 夸大 了,编译时,编译器会进一步优化代码,可能会删除未使用的变量或代码块。比如这样的代码:


if (BuildConfig.DEBUG) {
xxx.setBaseUrl(Config.DEBUG_BASE_URL);
} else {
xxx.setBaseUrl(Config.RELEASE_BASE_URL);
}

Release打包,BuildConfig.DEBUG永远为false,编译器会优化下代码,编译后的代码可能就剩这句:


xxx.setBaseUrl(Config.RELEASE_BASE_URL);

不信的读者可以反编译自己的APP试试康~


尽管编译后的Release包不包含 启用抓包的代码,但是把抓包代码打包到APK里,始终是不妥的。


毕竟,反编译apk,smail加个启用抓包的代码,并不是什么难事,最好的处理方式还是不要把抓包代码打包到Release APK中!


接着说说 实用性层面



  • 请求相关信息太少:只有URL、请求参数和响应参数这三个数据,状态吗码都没有,有时需要看下请求头或响应头参数。

  • 只能看不能复制:有时需要把请求参数发给后端。

  • 字段查找全靠肉眼扫:请求/响应Json很长的时候,看到眼花😵‍💫。

  • 不支持URL过滤: 执行一个操作,唰唰唰一堆请求,然后就是滑滑滑,肉👀筛URL。

  • 请求记录不会动态更新,要看新请求得关闭页面再打开。

  • 等等...


综上,还是有必要完善下这个库的,毕竟也是能 提高团队研发效率的一小环~


说得天花龙凤,其实没啥技术难点,库的本质就是:自定义一个okhttp拦截器获取请求相关信息然后进行一系列封装 而已。


库不支持HttpUrlConnection、Flutter、其它协议包的抓取!!!此抓包库的定位是:方便非Android崽,查看公司APP的请求日志


如果是 Android崽或者愿意折腾,想抓手机所有APP包 的朋友,可以参考下面两篇文章:



接着简单记录下库的开发流程~


0x2、库


① 拦截器 和 请求实体类


这一步就是了解API,把能抠的参数都抠出来,请求/响应头,请求体响应体,没啥太的难度,直接参考 lygttpod/AndroidMonitor 拦截器部分的代码:


class CaptureInterceptor : Interceptor {
private var maxContentLength = 5L * 1024 * 1024

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val networkLog = NetworkLog().apply {
method = request.method() // 请求方法
request.url().toString().takeIf(String::isNotEmpty)?.let(URI::create)?.let { uri ->
url = "$uri" // 请求地址
host = uri.host
path = uri.path + if (uri.query != null) "?${uri.query}" else ""
scheme = uri.scheme
requestTime = System.currentTimeMillis()
}
requestHeaders = request.headers().toJsonString() // 请求头
request.body()?.let { body -> body.contentType()?.let { requestContentType = "$it" } }
}
val startTime = System.nanoTime() // 记录请求发起时间(微秒级别)
val requestBody = request.body()
requestBody?.contentType()?.let { networkLog.requestContentType = "$it" }
when {
// 请求头为空、未知编码类、双工(可读可写)、请求体只能用一次
requestBody == null || bodyHasUnknownEncoding(request.headers()) || requestBody.isDuplex || requestBody.isOneShot -> {}
// 上传文件
requestBody is MultipartBody -> {
networkLog.requestBody = StringBuilder().apply {
requestBody.parts().forEach {
val key = it.headers()?.value(0)
append(
if (it.body().contentType()?.toString()?.contains("octet-stream") == true)
"${key}; value=文件流\n" else "${key}; value=${it.body().readString()}\n"
)
}
}.toString()
}
else -> {
val buffer = Buffer()
requestBody.writeTo(buffer)
val charset = requestBody.contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (buffer.isProbablyUtf8()) networkLog.requestBody =
formatBody(buffer.readString(charset), networkLog.requestContentType)
}
}

val response: Response
try {
response = chain.proceed(request)
networkLog.apply {
responseHeaders = response.headers().toJsonString() // 响应头
responseTime = System.currentTimeMillis()
duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) // 当前时间减去请求发起时间得出响应时间
protocol = response.protocol().toString()
responseCode = response.code()
responseMessage = response.message()
}
val responseBody = response.body()
responseBody?.contentType()?.let { networkLog.responseContentType = "$it" }
val bodyHasUnknownEncoding = bodyHasUnknownEncoding(response.headers())
// 响应体不为空、支持获取响应体、知道编码类型
if (responseBody != null && response.promisesBody() && !bodyHasUnknownEncoding) {
val source = responseBody.source()
source.request(Long.MAX_VALUE) // 将响应体的内容都读取到缓冲区中
var buffer = source.buffer // 获取响应体源数据流
// 如果响应体经过Gzip压缩,先解压缩
if (bodyGzipped(response.headers())) {
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
// 获取不到字符集的话默认使用UTF-8 字符集
val charset = responseBody.contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (responseBody.contentLength() != 0L && buffer.isProbablyUtf8()) {
val body = readFromBuffer(buffer.clone(), charset)
networkLog.responseBody = formatBody(body, networkLog.responseContentType)
}
networkLog.responseContentLength = buffer.size()
}
NetworkCapture.insertNetworkLog(networkLog)
Log.d("NetworkInterceptor", networkLog.toString())
return response
} catch (e: Exception) {
networkLog.errorMsg = "$e"
Log.e("NetworkInterceptor", networkLog.toString())
NetworkCapture.insertNetworkLog(networkLog)
throw e
}
}

// 检查头中的内容编码是否为除了 "identity" 和 "gzip" 外的其他未知编码类型
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
!contentEncoding.equals("gzip", ignoreCase = true)
}

// 判断头是否包含Gzip压缩
private fun bodyGzipped(headers: Headers): Boolean {
return "gzip".equals(headers["Content-Encoding"], ignoreCase = true)
}

// 从缓冲区读取字符串数据
private fun readFromBuffer(buffer: Buffer, charset: Charset?): String {
val bufferSize = buffer.size()
val maxBytes = min(bufferSize, maxContentLength)
return StringBuilder().apply {
try {
append(buffer.readString(maxBytes, charset!!))
} catch (e: EOFException) {
append("\n\n--- Unexpected end of content ---")
}
if (bufferSize > maxContentLength) append("\n\n--- Content truncated ---")
}.toString()
}

}

请求实体:


data class NetworkLog(
var id: Long? = null,
var method: String? = null,
var url: String? = null,
var scheme: String? = null,
var protocol: String? = null,
var host: String? = null,
var path: String? = null,
var duration: Long? = null,
var requestTime: Long? = null,
var requestHeaders: String? = null,
var requestBody: String? = null,
var requestContentType: String? = null,
var responseCode: Int? = null,
var responseTime: Long? = null,
var responseHeaders: String? = null,
var responseBody: String? = null,
var responseMessage: String? = null,
var responseContentType: String? = null,
var responseContentLength: Long? = null,
var errorMsg: String? = null,
var source: String? = null
) : Serializable {
fun getRequestTimeStr(): String =
if (requestTime == null) "无" else TIME_LONG.format(Date(requestTime!!))

fun getResponseTimeStr(): String =
if (requestTime == null) "无" else TIME_LONG.format(Date(responseTime!!))
}

② 数据库 和 Dao


直接用原生SQLite实现,就一张表和一些简单操作,就不另外引个第三方库了,自定义SQLiteOpenHelper:


class NetworkLogDB(context: Context) :
SQLiteOpenHelper(context, "cp_network_capture.db", null, DB_VERSION) {
companion object {
private const val DB_VERSION = 1
}

override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(NetworkLogDao.createTableSql())
}

override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {}
}

接着在Dao里编写建表,增删查表的方法:


class NetworkLogDao(private val db: NetworkLogDB) {
companion object {
const val TABLE_NAME = "network_log"

/**
* 建表SQL语句
* */

fun createTableSql() = StringBuilder("CREATE TABLE $TABLE_NAME(").apply {
append("id INTEGER PRIMARY KEY AUTOINCREMENT,")
append("method TEXT,")
append("url TEXT,")
append("scheme TEXT,")
append("protocol TEXT,")
append("host TEXT,")
append("path TEXT,")
append("duration INTEGER,")
append("requestTime INTEGER,")
append("requestHeaders TEXT,")
append("requestBody TEXT,")
append("requestContentType TEXT,")
append("responseCode INTEGER,")
append("responseTime INTEGER,")
append("responseHeaders TEXT,")
append("responseBody TEXT,")
append("responseMessage TEXT,")
append("responseContentType TEXT,")
append("responseContentLength INTEGER,")
append("errorMsg STRING,")
append("source STRING")
append(")")
}.toString()
}


/**
* 插入数据
* */

fun insert(data: NetworkLog) {
db.writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
put("method", data.method)
put("url", data.url)
put("scheme", data.scheme)
put("protocol", data.protocol)
put("host", data.host)
put("path", data.path)
put("duration", data.duration)
put("requestTime", data.requestTime)
put("requestHeaders", data.requestHeaders)
put("requestBody", data.requestBody)
put("requestBody", data.requestBody)
put("requestContentType", data.requestContentType)
put("responseCode", data.responseCode)
put("responseTime", data.responseTime)
put("responseHeaders", data.responseHeaders)
put("responseBody", data.responseBody)
put("responseMessage", data.responseMessage)
put("responseContentType", data.responseContentType)
put("responseContentLength", data.responseContentLength)
put("errorMsg", data.errorMsg)
put("source", data.source)
})
NetworkCapture.context?.contentResolver?.notifyChange(NetworkCapture.networkLogTableUri, null)
}

/**
* 查询数据
* @param offset 第几页,从0开始
* @param limit 分页条数
* */

fun query(
offset: Int = 0,
limit: Int = 20,
selection: String? = null,
selectionArgs: Array<String>? = null
)
: ArrayList<NetworkLog> {
val logList = arrayListOf<NetworkLog>()
val cursor = db.readableDatabase.query(
TABLE_NAME, null, selection, selectionArgs, null, null, "id DESC", "${offset * limit},${limit}"
)
if (cursor.moveToFirst()) {
do {
logList.add(NetworkLog().apply {
id = cursor.getLong(0)
method = cursor.getString(1)
url = cursor.getString(2)
scheme = cursor.getString(3)
protocol = cursor.getString(4)
host = cursor.getString(5)
path = cursor.getString(6)
duration = cursor.getLong(7)
requestTime = cursor.getLong(8)
requestHeaders = cursor.getString(9)
requestBody = cursor.getString(10)
requestContentType = cursor.getString(11)
responseCode = cursor.getInt(12)
responseTime = cursor.getLong(13)
responseHeaders = cursor.getString(14)
responseBody = cursor.getString(15)
responseMessage = cursor.getString(16)
responseContentType = cursor.getString(17)
responseContentLength = cursor.getLong(18)
errorMsg = cursor.getString(19)
source = cursor.getString(20)

})
} while (cursor.moveToNext())
}
cursor.close()
return logList
}

/**
* 根据id删除数据
* @param id 记录id
* */

fun deleteById(id: Long) {
db.writableDatabase.delete(TABLE_NAME, "id = ?", arrayOf("$id"))
}

/**
* 清空数据
* */

fun clear() {
db.writableDatabase.delete(TABLE_NAME, null, null)
}
}

③ UI 与 交互


没带安卓机回家...待补充图片...


④ 集成方式


参考leakcanary的集成方式,利用 activity-alias 标签单独创建一个桌面图标,作为抓包页面入口:


<activity-alias
android:name=".NetworkCaptureActivity"
android:exported="true"
android:icon="@mipmap/cp_network_capture_logo"
android:label="抓包"
android:targetActivity="cn.coderpig.cp_network_capture.ui.activity.NetworkCaptureActivity"
android:taskAffinity="cn.coderpig.cp_dev_helper.${applicationId}">

<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

接着是Context传递的,自定义一个ContentProvider,在onCreate()处获得,顺带加上监听数据库变化:


class CpNetworkCaptureProvider : ContentProvider() {
override fun onCreate(): Boolean {
val context = context
if (context == null) {
Log.e(TAG, "CpNetworkCapture库初始化Context失败")
} else {
Log.e(TAG, context.packageName)
NetworkCapture.init(context)
}
return true
}

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
)
: Cursor? = null

override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
}

接着使用 debugImplementation 方式导入依赖,打debug包才会打包这部分代码,接着使用使用反射的方式添加抓包拦截器即可~


作者:coder_pig
来源:juejin.cn/post/7276750877250699320
收起阅读 »

Android使用无障碍模式跳过应用广告的实现(仿李跳跳功能)

1.前言 当代移动应用广告的过度侵扰问题已经引起了广大用户的关注和不满。而芒果TV平台运营中心的副总经理陈超推出了一项名为"摇一摇开屏广告"的新策略↓ 引发了更多对于用户体验的担忧下↓ 在这种策略下,用户在不经意间被强制打开广告,这对用户来说无疑是一种糟糕...
继续阅读 »

1.前言


当代移动应用广告的过度侵扰问题已经引起了广大用户的关注和不满。而芒果TV平台运营中心的副总经理陈超推出了一项名为"摇一摇开屏广告"的新策略↓


ezgif.com-resize.gif


引发了更多对于用户体验的担忧下↓


image.png


在这种策略下,用户在不经意间被强制打开广告,这对用户来说无疑是一种糟糕的体验。当人处于运动的状态下,打开某些APP。


而“李跳跳”APP通过利用Android的无障碍模式,"李跳跳"成功帮助用户自动跳过这些令人困扰的开屏广告,从而有效地减轻了用户的不便。随之而来的不正当竞争指控引发了对于这类应用的法律和道德讨论。


我决定仿“李跳跳”写一个广告跳过助手,以呼吁对于这种过度侵扰性广告的关注,同时也为广大Android开发者们分享运用的技术原理。


2.效果图


ezgif-2-147d9e39be.gif


3.无障碍模式


当我们深入探讨"李跳跳"及其仿制应用的功能实现时,了解Android的无障碍模式和AccessibilityService以及onAccessibilityEvent函数的详细内容至关重要。这些技术是这些应用背后的核心,让我们更深入地了解它们:


3.1Android的无障碍模式


无障碍模式是Android操作系统的一个功能,旨在提高设备的可用性和可访问性,特别是为了帮助那些有视觉、听觉或运动障碍的用户。通过无障碍模式,应用可以获取有关用户界面和用户操作的信息,以便在需要时提供更好的支持。


3.2 onServiceConnected函数


这是AccessibilityService的回调函数之一,当服务被绑定到系统时会被调用。在这个函数中,可以进行初始化操作,如设置服务的配置、注册事件监听等。


@Override
public void onServiceConnected() {
// 在这里进行服务的初始化操作
// 注册需要监听的事件类型
}

3.3 onAccessibilityEvent函数


这是AccessibilityService的核心函数,用于处理发生的可访问性事件。在这个函数中,可以检查事件类型、获取事件源信息以及采取相应的操作。
本次功能主要用到的就是这个函数


@Override 
public void onAccessibilityEvent(AccessibilityEvent event) {
// 处理可访问性事件
// 获取事件类型、源信息,执行相应操作
}

3.4 onInterrupt函数


这个函数在服务被中断时会被调用,例如,用户关闭了无障碍服务或系统资源不足。可以在这里进行一些清理工作或记录日志以跟踪服务的中断情况。


@Override
public void onInterrupt() {
// 服务中断时执行清理或记录日志操作
}

3.5 onUnbind函数


当服务被解绑时,这个函数会被调用。可以在这里进行资源的释放和清理工作。


@Override
public boolean onUnbind(Intent intent) {
// 解绑时执行资源释放和清理操作
return super.onUnbind(intent);
}

3.6 onKeyEvent函数(未用到)


这个函数用于处理键盘事件。通过监听键盘事件,可以实现自定义的按键处理逻辑。例如,可以捕获特定按键的按下和释放事件,并执行相应操作。


@Override
public boolean onKeyEvent(KeyEvent event) {
// 处理键盘事件,执行自定义逻辑
return super.onKeyEvent(event);
}


3.7 onGesture函数(未用到)


onGesture()函数允许处理手势事件。这些事件可以包括触摸屏幕上的手势,例如滑动、缩放、旋转等。通过监听手势事件,可以实现各种手势相关的应用功能。


@Override
public boolean onGesture(int gestureId) {
// 处理手势事件,执行自定义逻辑
return super.onGesture(gestureId);
}


4.功能实现


4.1无障碍服务的启用和注册



  • 创建AccessibilityService的类。


public class AdSkipService extends AccessibilityService {
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {

}

@Override
public void onInterrupt() {

}

@Override
public boolean onUnbind(Intent intent) {
return super.onUnbind(intent);
}
}


  • 在AndroidManifest.xml文件中声明AccessibilityService。


<service android:name=".service.AdSkipService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:enabled="true"
android:exported="true">

<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />

</service>

4.2 onAccessibilityEvent函数的实现



  • 在onAccessibilityEvent函数中获取当前界面的控件,并在异步遍历所有子控件


@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
// 获取当前界面的控件
AccessibilityNodeInfo nodeInfo = event.getSource();

taskExecutorService.execute(new Runnable() {
@Override
public void run() {
//遍历节点函数,查找所有控件
iterateNodesToSkipAd(nodeInfo);
}
});
}



  • 判断控件的文本是否带有“跳过”二字


/**
* 判断节点内容是否是关键字(默认为”跳过“二字 )
* @param node 节点
* @param keyWords 关键字
* @return 是否包含
* */

public static boolean isKeywords(AccessibilityNodeInfo node, String keyWords){
CharSequence text = node.getText();
if (TextUtils.isEmpty(text)) {
return false;
}
//查询是否包含"跳过"二字
return text.toString().contains(keyWords);
}


  • 触发控件的点击事件


/**
* 点击跳过按钮
* @param node 节点
* @return 是否点击成功
* */

private boolean clickSkipNode(AccessibilityNodeInfo node){
//尝试点击
boolean clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
//打印点击按钮的结果
LogUtil.e("clicked result = " + clicked);
return clicked;
}

注:本篇章为了读者方便理解,对代码进行了简化,删去了繁琐的逻辑判断。具体实现详见源码


5.结语


我们通过AccessibilityService和无障碍模式,提供了一种改善用户体验的方法,帮助用户摆脱令人不快的广告干扰。通过了解如何开发这样的应用,我们可以更好地理解无障碍技术的潜力,并在保护用户权益的前提下改善应用环境。


如果对你有所帮助,请记得帮我点一个赞和star,有什么意见和建议可以在评论区给我留言


源码地址:github.com/Giftedcat/A…


作者:GiftedCat
来源:juejin.cn/post/7275009721760481320
收起阅读 »

Android 多种支付方式的优雅实现!

1.场景 App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。 并且可以添加的支付方式也是不确定的,由后台动态下发。 如下图所示: 根据上图 ui...
继续阅读 »

1.场景


App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。


并且可以添加的支付方式也是不确定的,由后台动态下发。


如下图所示:


image.png


根据上图 ui 理一下执行流程:



  1. 点击不同的添加支付方式 item。

  2. 进入相对应的添加支付方式流程(表单页面、webview、弹框之类的)。

  3. 在第三方回调里面根据不同的支付方式执行不同的操作。

  4. 调用后台接口查询添加是否成功。

  5. 根据接口结果展示不同的成功或者失败的ui.


2.以前的实现方式


用一个 Activity 承载,上述所有的流程都在 Activity 中。Activity 包含了列表展示、多种支付方式的实现和 ui。


伪代码如下:


class AddPaymentListActivity : AppCompatActivity(R.layout.activity_add_card) {

private val addPaymentViewModel : AddPaymentViewModel = ...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPaymentViewModel.checkPaymentStatusLiveData.observer(this) { isSuccess ->
// 从后台结果判断是否添加成功
if (isSuccess) {
addCardSuccess(paymentType)
} else {
addCardFailed(paymentType)
}
}
}

private fun clickItem(paymentType: PaymentType) {
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> //执行添加谷歌支付流程
PaymentType.ADD_PAY_PEL-> //执行添加PayPel支付流程
PaymentType.ADD_ALI_PAY-> //执行添加支付宝支付流程
PaymentType.ADD_STRIPE-> //执行添加Stripe支付流程
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (resultCode) {
PaymentType.ADD_GOOGLE_PAY -> {
// 根据第三方回调的结果,拿到key
// 根据key调用后台的Api接口查询是否添加成功
}
PaymentType.ADD_PAY_PEL -> // 同上
// ...
}
}

private fun addCardSuccess(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式成功,展示成功的ui,然后执行下一步操作
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

private fun addCardFailed(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式失败,展示失败的ui
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

enum class PaymentType {
ADD_GOOGLE_PAY, ADD_PAY_PEL, ADD_ALI_PAY, ADD_STRIPE
}

}

虽然看起来根据 paymentType 来判断,逻辑条理也还过得去,但是实际上复杂度远远不止如此。


• 不同的支付方式跳转的页面相差很大。


• 结果的回调获取也相差很大,并不是所有的都在onActivityResult中。


• 成功和失败实际上也不能统一来处理,里面包含很多的if…else…判断。


• 如果支付方式是后台动态下发的,处理起来判断逻辑就更多了。


此外,最大的问题:扩展性问题。


当新来一种支付方式,例如微信支付之类的,改动代码就很大了,基本就是将整个Activity中的代码都要改动。可以说上面这种方式的可扩展性为零,就是简单的将代码都揉在一起。


3.优化后的代码


要想实现高内聚低耦合,最简单的就是套用常见的设计模式,回想一下,发现策略模式+简单工厂模式非常这种适合这种场景。


先看下优化后的代码:


class AddPlatformActivity : BaseActivity() {

private var addPayPlatform: IAddPayPlatform? = null

private fun addPlatform(payPlatform: String) {
// 将后台返回的支付平台字符串变成枚举类
val platform: PayPlatform = getPayPlatform(payPlatform) ?: return
addPayPlatform = AddPayPlatformFactory.getCurrentPlatform(this, platform)
addPayPlatform?.getLoadingLiveData()?.observe(this@AddPlatformActivity) { showLoading ->
if (showLoading) startLoading() else stopLoading()
}
addPayPlatform?.addPayPlatform(AddCardParameter(platform))
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 将onActivityResult的回调转接到需要监听的策略类里面
addPayPlatform?.thirdAuthenticationCallback(requestCode, resultCode, data)
}
}

4.策略模式


意图: 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。


主要解决: 在有多种算法相似的情况下,使用if…else所带来的复杂和难以维护。


何时使用: 一个系统有许多许多类,而区分它们的只是他们直接的行为。


如何解决: 将这些算法封装成一个一个的类,任意地替换。


关键代码: 实现同一个接口。


**优点: **


1、算法可以自由切换。


2、避免使用多重条件判断。


3、扩展性良好。


缺点


1、策略类会增多。


2、所有策略类都需要对外暴露。


**使用场景: **


1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。


2、一个系统需要动态地在几种算法中选择一种。


3、如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。


5.需要实现的目标


5.1 解耦宿主 Activity


现在宿主Activity中代码太重了,包含多种支付方式实现,还有列表ui的展示,网络请求等。


现在目标是将 Activity 中的代码拆分开来,让宿主 Activity 变得小而轻。


如果产品说新增一种支付方式,只需要改动很少的代码,就可以轻而易举的实现。


5.2 抽取成独立的模块


因为公司中有可能存在多个项目,支付模块的分层应该处于可复用的层级,以后很有可能将其封装成一个独立的 mouble,给不同的项目使用。


现在代码全在 Activity 中,以后若是抽取 mouble 的话,相当于整个需求重做。


5.3 组件黑盒


"组件黑盒"这个名词是我自己的一个定义。大致意思:



将一个 View 或者一个类进行高度封装,尽可能少的暴露public方法给外部使用,自成一体。




业务方在使用时,可以直接黑盒使用某个业务组件,不必关心其中的逻辑。




业务方只需要一个简单的操作,例如点击按钮调用方法,然后逻辑都在组件内部实现,组件内处理外部事件的所有操作,例如:Loading、请求网络、成功或者失败。




业务方都不需要知道组件内部的操作,做到宿主和组件的隔离。




当然这种处理也是要分场景考虑的,其中一个重点就是这个组件是偏业务还是偏功能,也就是是否要将业务逻辑统一包进组件,想清楚这个问题后,才能去开发一个业务组件。 摘自xu’yi’sheng博客。xuyisheng.top/author/xuyi…



因为添加支付方式是一个偏业务的功能,我的设计思路是:


外部 Activity 点击添加对应的支付方式,将支付方式的枚举类型和支付方式有关的参数通过传递,然后不同的策略类组件执行自己的添加逻辑,再通过一层回调将第三方支付的回调从 Activity 中转接过来,每个策略类内部处理自己的回调操作,具体的策略类自己维护成功或者失败的ui。


6.具体实现


6.1 定义顶层策略接口


interface IAddPayPlatform {

fun addPayPlatform(param: AddCardParameter)

fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?)

fun addCardFailed(message: String?)

fun addCardSuccess()
}

6.2 通用支付参数类


open class AddCardParameter(val platform: PayPlatform)

class AddStripeParameter(val card: Card, val setPrimaryCard: Boolean, platform: PayPlatform)
: AddCardParameter(platform = PayPlatform.Stripe)

因为有很多种添加支付方式,不同的支付方式对应的参数都不一样。


所以先创建一个通用的卡片参数基类AddCardParameter, 不同的支付方式去实现不同的具体参数。这样的话策略接口就可以只要写一个添加卡片的方法addPayPlatform(paramAddCardParameter)


6.3 Loading 的处理


因为我想实现的是黑盒组件的效果,所有添加卡片的loading也是封装在每一个策略实现类里面的。


Loading的出现和消失这里有几种常见的实现方式:


• 传递BaseActivity的引用,因为我的loading有关的方法是放在BaseActivity中,这种方式简单但是会耦合BaseActivity。


• 使用消息事件总线,例如EventBus之类的,这种方式解耦强,但是消息事件不好控制,还要添加多余的依赖库。


• 使用LiveData,在策略的通用接口中添加一个方法返回Loading的LiveData, 让宿主Activity自己去实现。


interface IAddPayPlatform {
// ...
fun getLoadingLiveData(): LiveData<Boolean>?
}

6.4 提取BaseAddPayStrategy


因为每一个添加卡的策略会存在很多相同的代码,这里我抽取一个BaseAddPayStrategy来存放模板代码。


需要实现黑盒组件的效果,宿主Activity中都不需要去关注添加支付方式是不是存在网络请求这一个过程,所以网络请求也分装在每一个策略实现类里面。


abstract class BaseAddPayStrategy(val activity: AppCompatActivity, val platform: PayPlatform) : IAddPayPlatform {

private val loadingLiveData = SingleLiveData<Boolean>()

protected val startActivityIntentLiveData = SingleLiveData<Intent>()

override fun getLoadingLiveData(): LiveData<Boolean> = loadingLiveData

protected fun startLoading() = loadingLiveData.setValue(true)

protected fun stopLoading() = loadingLiveData.setValue(false)

private fun reloadWallet() {
startLoading()
// 添加卡片完成后,重新刷新钱包数据
}

override fun addCardSuccess() {
reloadWallet()
}

override fun addCardFailed(message: String?) {
stopLoading()
if (isEWalletPlatform(platform)) showAddEWalletFailedView() else showAddPhysicalCardFailedView(message)
}

/**
* 添加实体卡片失败展示ui
*/

private fun showAddPhysicalCardFailedView(message: String?) {
showSaveErrorDialog(activity, message)
}

/**
* 添加实体卡片成功展示ui
*/

private fun showAddPhysicalCardSuccessView() {
showCreditCardAdded(activity) {
activity.setResult(Activity.RESULT_OK)
activity.finish()
}
}

private fun showAddEWalletSucceedView() {
// 添加电子钱包成功后的执行
activity.setResult(Activity.RESULT_OK)
activity.finish()
}

private fun showAddEWalletFailedView() {
// 添加电子钱包失败后的执行
}

// ---默认空实现,有些支付方式不需要这些方法---
override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?) = Unit

override fun getStartActivityIntent(): LiveData<Intent> = startActivityIntentLiveData
}

6.5 具体的策略类实现


通过传递过来的AppCompatActivity引用获取添加卡片的ViewModel实例AddPaymentViewModel,然后通过AddPaymentViewModel去调用网络请求查询添加卡片是否成功。


class AddXXXPayStrategy(activity: AppCompatActivity) : BaseAddPayStrategy(activity, PayPlatform.XXX) {

protected val addPaymentViewModel: AddPaymentViewModel by lazy {
ViewModelProvider(activity).get(AddPaymentViewModel::class.java)
}

init {
addPaymentViewModel.eWalletAuthorizeLiveData.observeState(activity) {

onSuccess { addCardSuccess()}

onError { addCardFailed(it.detailed) }
}
}

override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, result: Intent?) {
val uri: Uri = result?.data ?: return
if (uri.host == "www.xxx.com") {
uri.getQueryParameter("transactionId")?.let {
addPaymentViewModel.confirmEWalletAuthorize(platform.name, it)
}
}
}

override fun addPayPlatform(param: AddCardParameter) {
startLoading()
addPaymentViewModel.addXXXCard(param)
}
}

7.简单工厂进行优化


因为我不想在Activity中去引用每一个具体的策略类,只想引用抽象接口类IAddPayPlatform, 这里通过一个简单工厂来优化。


object AddPayPlatformFactory {


fun setCurrentPlatform(activity: AppCompatActivity, payPlatform: PayPlatform): IAddPayPlatform? {
return when (payPlatform) {
PayPlatform.STRIPE -> AddStripeStrategy(activity)
PayPlatform.PAYPAL -> AddPayPalStrategy(activity)
PayPlatform.LINEPAY -> AddLinePayStrategy(activity)
PayPlatform.GOOGLEPAY -> AddGooglePayStrategy(activity)
PayPlatform.RAPYD -> AddRapydStrategy(activity)
else -> null
}
}

}


8.再增加一种支付方式


如果再增加一种支付方式,宿主Activity中的代码都可以不要改动,只需要新建一个新的策略类,实现顶层策略接口即可。


这样,不管是删除还是新增一种支付方式,维护起来就很容易了。


策略模式的好处就显而易见了。



今日分享到此结束,对你有帮助的话,点个赞再走呗,每日一个面试小技巧




关注公众号:Android老皮

解锁  《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版



内容如下



1.Android车载应用开发系统学习指南(附项目实战)

2.Android Framework学习指南,助力成为系统级开发高手

3.2023最新Android中高级面试题汇总+解析,告别零offer

4.企业级Android音视频开发学习路线+项目实战(附源码)

5.Android Jetpack从入门到精通,构建高质量UI界面

6.Flutter技术解析与实战,跨平台首要之选

7.Kotlin从入门到实战,全方面提升架构基础

8.高级Android插件化与组件化(含实战教程和源码)

9.Android 性能优化实战+360°全方面性能调优

10.Android零基础入门到精通,高手进阶之路



作者:派大星不吃蟹
来源:juejin.cn/post/7274475842998157353
收起阅读 »

Java切换到Kotlin,Crash率上升了?

前言 最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。 通过本篇文章...
继续阅读 »

前言


最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。

通过本篇文章,你将了解到:

  1. NPE(空指针 NullPointerException)的本质
  2. Java 如何预防NPE?
  3. Kotlin NPE检测
  4. Java/Kotlin 混合调用
  5. 常见的Java/Kotlin互调场景


1. NPE(空指针 NullPointerException)的本质


变量的本质


    val name: String = "fish"

name是什么?

对此问题你可能嗤之以鼻:



不就是变量吗?更进一步说如果是在对象里声明,那就是成员变量(属性),如果在方法(函数)里声明,那就是局部变量,如果是静态声明的,那就是全局变量。



回答没问题很稳当。

那再问为什么通过变量就能找到对应的值呢?



答案:变量就是地址,通过该地址即可寻址到内存里真正的值



无法访问的地址



在这里插入图片描述


如上图,若是name="fish",表示的是name所指向的内存地址里存放着"fish"的字符串。

若是name=null,则说明name没有指向任何地址,当然无法通过它访问任何有用的信息了。


无论C/C++亦或是Java/Kotlin,如果一个引用=null,那么这个引用将毫无意义,无法通过它访问任何内存信息,因此这些语言在设计的过程中会将通过null访问变量/方法的行为都会显式(抛出异常)提醒开发者。


2. Java 如何预防NPE?


运行时规避


先看Demo:


public class TestJava {
public static void main(String args[]) {
(new TestJava()).test();
}

void test() {
String str = getString();
System.out.println(str.length());
}

String getString() {
return null;
}
}

执行上述代码将会抛出异常,导致程序Crash:



在这里插入图片描述


我们有两种解决方式:




  1. try...catch

  2. 对象判空



try...catch 方式


public class TestJava {
public static void main(String args[]) {
(new TestJava()).testTryCatch();
}

void testTryCatch() {
try {
String str = getString();
System.out.println(str.length());
} catch (Exception e) {
}
}

String getString() {
return null;
}
}

NPE被捕获,程序没有Crash。


对象判空


public class TestJava {
public static void main(String args[]) {
(new TestJava()).testJudgeNull();
}

void testJudgeNull() {
String str = getString();
if (str != null) {
System.out.println(str.length());
}
}

String getString() {
return null;
}
}

因为提前判空,所以程序没有Crash。


编译时检测


在运行时再去做判断的缺点:



无法提前发现NPE问题,想要覆盖大部分的场景需要随时try...catch或是判空
总有忘记遗漏的时候,发布到线上就是个生产事故



那能否在编译时进行检测呢?

答案是使用注解。


public class TestJava {
public static void main(String args[]) {
(new TestJava()).test();
}

void test() {
String str = getString();
System.out.println(str.length());
}

@Nullable String getString() {
return null;
}
}

在编写getString()方法时发现其可能为空,于是给方法加上一个"可能为空"的注解:@Nullable


当调用getString()方法时,编译器给出如下提示:



在这里插入图片描述


意思是访问的getString()可能为空,最后访问String.length()时可能会抛出NPE。

看到编译器的提示我们就知道此处有NPE的隐患,因此可以针对性的进行处理(try...catch或是判空)。


当然此处的注解仅仅只是个"弱提示",你即使没有进行针对性的处理也能编译通过,只是问题最后都流转到运行时更难挽回了。


有"可空"的注解,当然也有"非空"的注解:



在这里插入图片描述


@Nonnull 注解修饰了方法后,若是检测到方法返回null,则会提示修改,当然也是"弱提示"。


3. Kotlin NPE检测


编译时检测


Kotlin 核心优势之一:



空安全检测,变量分为可空型/非空型,能够在编译期检测潜在的NPE,并强制开发者确保类型一致,将问题在编译期暴露并解决



先看非空类型的变量声明:


class TestKotlin {

fun test() {
val str = getString()
println("${str.length}")
}

private fun getString():String {
return "fish"
}
}

fun main() {
TestKotlin().test()
}


此种场景下,我们能确保getString()函数的返回一定非空,因此在调用该函数时无需进行判空也无需try...catch。


你可能会说,你这里写死了"fish",那我写成null如何?



在这里插入图片描述


编译期直接提示不能这么写,因为我们声明getString()的返回值为String,是非空的String类型,既然声明了非空,那么就需要言行一致,返回的也是非空的。


有非空场景,那也得有空的场景啊:


class TestKotlin {

fun test() {
val str = getString()
println("${str.length}")
}

private fun getString():String? {
return null
}
}

fun main() {
TestKotlin().test()
}

此时将getString()声明为非空,因此可以在函数里返回null。

然而调用之处就无法编译通过了:



在这里插入图片描述


意思是既然getString()可能返回null,那么就不能直接通过String.length访问,需要改为可空方式的访问:


class TestKotlin {

fun test() {
val str = getString()
println("${str?.length}")
}

private fun getString():String? {
return null
}
}

str?.length 意思是:如果str==null,就不去访问其成员变量/函数,若不为空则可以访问,于是就避免了NPE问题。


由此可以看出:



Kotlin 通过检测声明与实现,确保了函数一定要言行一致(声明与实现),也确保了调用者与被调用者的言行一致



因此,若是用Kotlin编写代码,我们无需花太多时间去预防和排查NPE问题,在编译期都会有强提示。


4. Java/Kotlin 混合调用


回到最初的问题:既然Kotlin都能在编译期避免了NPE,那为啥使用Kotlin重构后的代码反而导致Crash率上升呢?


原因是:项目里同时存在了Java和Kotlin代码,由上可知两者在NPE的检测上有所差异导致了一些兼容问题。


Kotlin 调用 Java


调用无返回值的函数


Kotlin虽然有空安全检测,但是Java并没有,因此对于Java方法来说,不论你传入空还是非空,在编译期我都没法检测出来。


public class TestJava {
void invokeFromKotlin(String str) {
System.out.println(str.length());
}
}

class TestKotlin {

fun test() {
TestJava().invokeFromKotlin(null)
}
}

fun main() {
TestKotlin().test()
}

如上无论是Kotlin调用Java还是Java之间互调,都没法确保空安全,只能由被调用者(Java)自己处理可能的异常情况。


调用有返回值的函数


public class TestJava {
public String getStr() {
return null;
}
}

class TestKotlin {
fun testReturn() {
println(TestJava().str.length)
}
}

fun main() {
TestKotlin().testReturn()
}

如上,Kotlin调用Java的方法获取返回值,由于在编译期Kotlin无法确定返回值,因此默认把它当做非空处理,若是Java返回了null,那么将会Crash。


Java 调用 Kotlin


调用无返回值的函数


先定义Kotlin类:


class TestKotlin {

fun testWithoutNull(str: String) {
println("len:${str.length}")
}

fun testWithNull(str: String?) {
println("len:${str?.length}")
}
}

有两个函数,分别接收可空/非空参数。


在Java里调用,先调用可空函数:


public class TestJava {
public static void main(String args[]) {
(new TestKotlin()).testWithNull(null);
}
}

因为被调用方是Kotlin的可空函数,因此即使Java传入了null,也不会有Crash。


再换个方式,在Java里调用非空函数:


public class TestJava {
public static void main(String args[]) {
(new TestKotlin()).testWithoutNull(null);
}
}

却发现Crash了!



在这里插入图片描述


为什么会Crash呢?反编译查看Kotlin代码:


public final class TestKotlin {
public final void testWithoutNull(@NotNull String str) {
Intrinsics.checkNotNullParameter(str, "str");
String var2 = "len:" + str.length();
System.out.println(var2);
}

public final void testWithNull(@Nullable String str) {
String var2 = "len:" + (str != null ? str.length() : null);
System.out.println(var2);
}
}

对于非空的函数来说,会有检测代码:

Intrinsics.checkNotNullParameter(str, "str"):


    public static void checkNotNullParameter(Object value, String paramName) {
if (value == null) {
throwParameterIsNullNPE(paramName);
}
}
private static void throwParameterIsNullNPE(String paramName) {
throw sanitizeStackTrace(new NullPointerException(createParameterIsNullExceptionMessage(paramName)));
}

可以看出:




  1. Kotlin对于非空的函数参数,先判断其是否为空,若是为空则直接抛出NPE

  2. Kotlin对于可空的函数参数,没有强制检测是否为空



调用有返回值的函数


Java 本身就没有空安全,只能在运行时进行处理。


小结


很容看出来:




  1. Java 调用Kotlin的非空函数有Crash的风险,编译器无法检查到传入的参数是否为空

  2. Java 调用Kotlin的可空函数没有Crash风险,Kotlin编译期检查空安全

  3. Kotlin 调用Java的函数有Crash风险,由Java代码规避风险

  4. Kotlin 调用Java有返回值的函数有Crash风险,编译器无法检查到返回值是否为空



回到文章的标题,我们已经大致知道了Java切换到Kotlin,为啥Crash就升上了的原因了,接下来再详细分析。


5. 常见的Java/Kotlin互调场景


Android里的Java代码分布



在这里插入图片描述


在Kotlin出现之前,Java就是Android开发的唯一语言,Android Framework、Androidx很多是Java代码编写的,因此现在依然有很多API是Java编写的。


而不少的第三方SDK因为稳定性、迁移代价的考虑依然使用的是Java代码。


我们自身项目里也因为一些历史原因存在Java代码。


以下讨论的前提是假设现有Java代码我们都无法更改。


Kotlin 调用Java获取返回值


由于编译期无法判定Java返回的值是空还是非空,因此若是确认Java函数可能返回空,则可以通过在Kotlin里使用可空的变量接收Java的返回值。


class TestKotlin {
fun testReturn() {
val str: String? = TestJava().str
println(str?.length)
}
}

fun main() {
TestKotlin().testReturn()
}

Java 调用Kotlin函数


LiveData Crash的原因与预防


之前已经假设过我们无法改动Java代码,那么Java调用Kotlin函数的场景只有一个了:回调。

上面的有返回值场景还是比较容易防备,回调的场景就比较难发现,尤其是层层封装之后的代码。

这也是特别常见的场景,典型的例子如LiveData。


Crash原因


class TestKotlin(val lifecycleOwner: LifecycleOwner) {
val liveData: MutableLiveData = MutableLiveData()
fun testLiveData() {
liveData.observe(lifecycleOwner) {
println(it.length)
}
}

init {
testLiveData()
}
}

如上,使用Kotlin声明LiveData,其类型是非空的,并监听LiveData的变化。


在另一个地方给LiveData赋值:


TestKotlin(this@MainActivity).liveData.value = null

虽然LiveData的监听和赋值的都是使用Kotlin编写的,但不幸的是还是Crash了。

发送和接收都是用Kotlin编写的,为啥还会Crash呢?

看看打印:



在这里插入图片描述


意思是接收到的字符串是空值(null),看看编译器提示:



在这里插入图片描述


原来此处的回调传入的值被认为是非空的,因此当使用it.length访问的时候编译器不会有空安全提示。


再看看调用的地方:



在这里插入图片描述


可以看出,这回调是Java触发的。


Crash 预防


第一种方式:

我们看到LiveData的数据类型是泛型,因此可以考虑在声明数据的时候定为非空:


class TestKotlin(val lifecycleOwner: LifecycleOwner) {
val liveData = MutableLiveData()
fun testLiveData() {
liveData.observe(lifecycleOwner) {
println(it?.length)
}
}

init {
testLiveData()
}
}

如此一来,当访问it.length时编译器就会提示可空调用。


第二种方式:

不修改数据类型,但在接收的地方使用可空类型接收:


class TestKotlin(val lifecycleOwner: LifecycleOwner) {
val liveData = MutableLiveData()
fun testLiveData() {
liveData.observe(lifecycleOwner) {
val dataStr:String? = it
println(dataStr?.length)
}
}

init {
testLiveData()
}
}

第三种方式:

使用Flow替换LiveData。


LiveData 修改建议:




  1. 若是新写的API,建议使用第三种方式

  2. 若是修改老的代码,建议使用第一种方式,因为可能有多个地方监听LiveData值的变化,如果第一种方式的话需要写好几个地方。



其它场景的Crash预防:


与后端交互的数据结构
比如与后端交互声明的类,后端有可能返回null,此时在客户端接收时若是使用了非空类型的字段去接收,那么会发生Crash。

通常来说,我们会使用网络框架(如retrofit)接收数据,数据的转换并不是由我们控制,因此无法使用针对LivedData的第二种方式。

有两种方式解决:




  1. 与后端约定,不能返回null(等于白说)

  2. 客户端声明的类的字段声明为可空(类似针对LivedData的第一种方式)



Json序列化/反序列化

Json字符串转换为对象时,有些字段可能为空,也需要声明为可空字段。


小结



在这里插入图片描述


您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力


持续更新中,和我一起步步为营系统、深入学习Android/Kotlin


作者:小鱼人爱编程
来源:juejin.cn/post/7274163003158511616
收起阅读 »

拒绝代码PUA,优雅地迭代业务代码

最初的美好 没有历史包袱,就没有压力,就是美好的。 假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。 ...
继续阅读 »

最初的美好


没有历史包袱,就没有压力,就是美好的。


假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。


Ugly1.gif


这样的需求开发起来很简单:



  • 数据实体


data class Car(
var shell: Shell? = null,
var engine: Engine? = null,
var wheel: Wheel? = null,
) : Serializable {
override fun toString(): String {
return "Car: Shell(${shell}), Engine(${engine}), Wheel(${wheel})"
}
}

data class Shell(
...
) : Serializable

data class Engine(
...
) : Serializable

data class Wheel(
...
) : Serializable


  • 零件车间(以车架为例)


class ShellFactoryActivity : AppCompatActivity() {
private lateinit var btn: Button
private lateinit var back: Button
private lateinit var status: TextView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_shell_factory)
val car = intent.getSerializableExtra("car") as Car
status = findViewById(R.id.status)
btn = findViewById(R.id.btn)
btn.setOnClickListener {
car.shell = Shell(
id = 1,
name = "比亚迪车架",
type = 1
)
status.text = car.toString()
}
back = findViewById(R.id.back)
back.setOnClickListener {
setResult(RESULT_OK, intent.apply {
putExtra("car", car)
})
finish()
}
}
}


class EngineFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}

class WheelFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}


  • 提车车间


class MainActivity : AppCompatActivity() {
private var car: Car? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
car = Car()
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
val it = Intent(this, ShellFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_SHELL)
}
findViewById<Button>(R.id.engine).setOnClickListener {
val it = Intent(this, EngineFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_ENGINE)
}
findViewById<Button>(R.id.wheel).setOnClickListener {
val it = Intent(this, WheelFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_WHEEL)
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != RESULT_OK) return
when (requestCode) {
REQUEST_SHELL -> {
Log.i(TAG, "安装车架完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_ENGINE -> {
Log.i(TAG, "安装发动机完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_WHEEL -> {
Log.i(TAG, "安装车轮完成")
car = data?.getSerializableExtra("car") as Car
}
}
refreshStatus()
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car?.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car?.shell != null && car?.engine != null && car?.wheel != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}

companion object {
private const val TAG = "MainActivity"
const val REQUEST_SHELL = 1
const val REQUEST_ENGINE = 2
const val REQUEST_WHEEL = 3
}
}

即使是初学者也能看出来,业务实现起来很简单,通过ActivitystartActivityForResult就能跳转到相应的零件车间,安装好零件回到提车车间就完事了。


开始迭代


往往业务的第一个版本就是这么简单,感觉也没什么好重构的。


但是业务难免会进行迭代。比如业务迭代到1.1版本:客户想要给汽车装上行车电脑,而安装行车电脑不需要跳转到另一个车间,而是在提车车间操作,但是需要很长的时间。


Ugly2.gif


看起来也简单,新增一个Computer实体类和ComputerFactoryHelper


object ComputerFactoryHelper {
fun provideComputer(block: Computer.() -> Unit) {
Thread.sleep(5_000)
block(Computer())
}
}

data class Computer(
val id: Int = 1,
val name: String = "行车电脑",
val cpu: String = "麒麟90000"
) : Serializable {
override fun toString(): String {
return "$name-$cpu"
}
}

再在提车车间新增按钮和逻辑代码:


findViewById<Button>(R.id.computer).setOnClickListener {
object : Thread() {
override fun run() {
ComputerFactoryHelper.provideComputer {
car?.computer = this
runOnUiThread { refreshStatus() }
}
}
}.start()

}

目前看起来也没啥难的,那是因为我们模拟的业务场景足够简单,但是相信很多实际项目的屎山代码,就是通过这样的业务迭代,一点一点地堆积而成的。


从迭代到崩溃


咱们来采访一下最近被一个小小的业务迭代需求搞崩溃的Android开发——小王。



记者:小王你好,听说最近你Emo了,甚至多次萌生了就地辞职的念头?


小王:最近AI不是很火吗,产品给我提了一个需求,在上传音乐时可以选择在后端生成一个AI视频,然后一起上传。


记者:哦?这不是一个小需求吗?


小王:但是我打开目前上传业务的代码就傻了啊!就说Activity吧,有:BasePublishActivity,BasePublishFinallyActivity,SinglePublishMusicActivity,MultiPublishMusicActivity,PublishFinallyActivity,PublishCutMusicFinallyActivity, Publish(好多好多)FinallyActivity... 当然,这只是冰山一角。再说上传流程。如果只上传一首音乐,需要先调一个接口/sts拿到一个Oss Token,再调用第三方的Oss库上传文件,拿到一个url,然后再把这个url和其他的信息(标题、标签等)组成一个HashMap,再调用一个接口/save提交到后端,相当于调3个接口... 如果要批量上传N个音乐,就要调3 * N个接口,如果还要给每个音乐配M个图片,就要调3 * N+3 * N * M个接口... 如果上传一个音乐配一个本地视频,就要调3 * 2 * N个接口,并且,上传视频流程还不一样的是,需要在调用/save接口之后再调用第三方Oss上传视频文件...再说数据类。上面提到上传过程中需要添加图片、视频、活动类型啥的,代码里封装了一个EditInfo类足足有30个属性!,由于是Java代码并且实现了Parcelable接口,光一个Data类就有400多行!你以为这就完了?EditInfo需要在上传时转成PublishInfo类,PublishInfo还可以转成PublishDraft,PublishDraft可以保存到数据库中,从数据库中可以读取PublishDraft然后转成EditInfo再重新编辑...


记者:(感觉小王精神状态有点问题,于是掐掉了直播画面)



相信小王的这种情况,很多业务开发同学都经历过吧。回头再看一下前面的造车业务,其实和小王的上传业务一样,就是一开始很简单,迭代个7、8个版本就开始陷入一种困境:即使迭代需求再小,开发起来都很困难。


优雅地迭代业务代码?


假如咱们想要优雅地迭代业务代码,应该怎么做呢?


小王遇到的这座屎山,咱们现在就不要去碰了,先就从前面提到的造车业务开始吧。


很多同学会想到重构,俺也一样。接下来,我就要讨论一下如何优雅安全地重构既有业务代码。



先抛出一个观点:对于程序员来说,想要保持“优雅”,最重要的品质就是抽象。



❓ 这时可能有同学就要反驳我了:过早的抽象没有必要。


❗ 别急,我现在要说的抽象,并不是代码层面的抽象,而是对业务的抽象,乃至对技术思维的抽象


什么是代码层面的抽象?比如刚刚的Shell/Engine/WheelFactoryActivity,其实是可以抽象为BaseFactoryActivity,然后通过实现其中的零件类型就行了。但我不会建议你这么做,为啥?看看小王刚才的疯言疯语就明白了。各个XxxFactoryActivity看着差不多,但在实际项目中很可能会开枝散叶,各自迭代出不同的业务细节。到那时,项目里就是各种BaseXxxActivityXxxV1ActivityXxxV2Activity...


那什么又是业务的抽象?直接上代码:


interface CarFactory {
val factory: suspend Car.() -> Car
}

造车业务,无论在哪个环节,都是在Car上装配零件(或者任何出其不意的操作),然后产生一个新的Car;另外,这个环节无论是跳转到零件车间安装零件,还是在提车车间里安装行车电脑,都是耗时操作,所以需要加suspend关键词。


❓ 这时可能有同学说:害!你这和BaseFactoryActivity有啥区别,不就是把抽象类换成接口了吗?


❗ 别急,我并没有要让XxxFactoryActivity去继承CarFactory啊,想想小王吧,这个XxxFactoryActivity就相当于他的同事在两年前写的代码,小王肯定打死都不会想去修改这里面的代码的。


Computer是新业务,我们只改它。首先我们根据这个接口把ComputerFactoryHelper改一下:


object ComputerFactoryHelper : CarFactory {
private suspend fun provideComputer(block: Computer.() -> Unit) {
delay(5_000)
block(Computer())
}

override val factory: suspend Car.() -> Car = {
provideComputer {
computer = this
}
this
}
}

那么,在提车车间就可以这样改:


private var computerFactory: CarFactory = ComputerFactoryHelper
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
computerFactory.factory.invoke(car)
refreshStatus()
}
}

❓ 那么XxxFactoryActivity相关的流程又应该怎么重构呢?


Emo时间


我先反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


甚至,很多人即使学了ComposeFlutter,仍然对Activity心心念念。



当你在一千个日日夜夜里,重复地写着XxxActivity,写onCreate/onResume/onDestroy,写startActivityForResult/onActivityResult时,当你每次想要换工作,打开面经背诵Activity的启动模式,生命周期,AMS原理时,可曾对Activity有过厌倦,可曾思考过编程的意义?


你也曾努力查阅Activity的源码,学习MVP/MVVM/MVI架构,试图让你的Activity保持清洁,但无论你怎么努力,却始终活在Activity的阴影之下。


你有没有想过,咱们正在被Activity PUA



说实话,作为一名INFP,本人不是很适合做程序员。相比技术栈的丰富和技术原理的深入,我更看重的是写代码的感受。如果写代码都能被PUA,那我还怎么愉快的写代码?


当我Emo了很久之后,我意识到了,我一直在被代码PUA,不光是同事的代码,也有自己的代码,甚至有Android框架,以及外国大佬不断推出的各种新技术新框架。



对对对!你们都没有问题,是我太菜了555555555



优雅转身


Emo过后,还是得回到残酷的职场啊!但是我们要优雅地转身回来!


❓ 刚才不是说要处理XxxFactoryActivity相关业务吗?


❗ 这时我就要提到另外一种抽象:技术思维的抽象


Activity?F*ck off!


Activity的跳转返回啥的,也无非就是一次耗时操作嘛,咱们也应该将它抽象为CarFactory,就是这个东东:


interface CarFactory {
val factory: suspend Car.() -> Car
}

基于这个信念,我从记忆中想到这么一个东东:ActivityResultLauncher


说实话,我以前都没用过这玩意儿,但是我这时好像抓到了救命稻草。


随便搜了个教程并谢谢他,参考这篇博客,我们可以把startActivityForResultonActivityResult这套流程,封装成一次异步调用。


open class BaseActivity : AppCompatActivity() {
private lateinit var startActivityForResultLauncher: StartActivityForResultLauncher

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivityForResultLauncher = StartActivityForResultLauncher(this)
}

fun startActivityForResult(
intent: Intent,
callback: (resultCode: Int, data: Intent?) -> Unit
)
{
startActivityForResultLauncher.launch(intent) {
callback.invoke(it.resultCode, it.data)
}
}
}

MainActivity继承BaseActivity,就可以绕过Activity了,后面的事情就简单了。只要咱们了解过协程,就能轻易想到异步转同步这一普通操作:suspendCoroutine


于是,我们就可以在不修改XxxFactoryActivity的情况下,写出基于CarFactory的代码了。还是以车架车间为例:


class ShellFactoryHelper(private val activity: BaseActivity) : CarFactory {

override val factory: suspend Car.() -> Car = {
suspendCoroutine { continuation ->
val it = Intent(activity, ShellFactoryActivity::class.java)
it.putExtra("car", this)
activity.startActivityForResult(it) { resultCode, data ->
(data?.getSerializableExtra("car") as? Car)?.let {
Log.i(TAG, "安装车壳完成")
shell = it.shell
continuation.resumeWith(Result.success(this))
}
}
}
}
}

然后在提车车间,和Computer业务同样的使用方式:


private var shellFactory: CarFactory = ShellFactoryHelper(this)
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}

最终,在我们的提车车间,依赖的就是一些CarFactory,所有的业务操作都是抽象的。到达这个阶段,相信大家都有了自己的一些想法了(比如维护一个carFactoryList,用Hilt管理CarFactory依赖,泛型封装等),想要继续怎么重构/维护,就全看自己的实际情况了。


class MainActivity : BaseActivity() {
private var car: Car = Car()
private var computerFactory: CarFactory = ComputerFactoryHelper
private var engineFactory: CarFactory = EngineFactoryHelper(this)
private var shellFactory: CarFactory = ShellFactoryHelper(this)
private var wheelFactory: CarFactory = WheelFactoryHelper(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.engine).setOnClickListener {
lifecycleScope.launchWhenResumed {
engineFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.wheel).setOnClickListener {
lifecycleScope.launchWhenResumed {
wheelFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
Toast.makeText(this@MainActivity, "稍等一会儿", Toast.LENGTH_LONG).show()
computerFactory.factory.invoke(car)
Toast.makeText(this@MainActivity, "装好了!", Toast.LENGTH_LONG).show()
refreshStatus()
}
}
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car.shell != null && car.engine != null && car.wheel != null && car.computer != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}
}

总结



  • 抽象是程序员保持优雅的最重要能力。

  • 抽象不应局限在代码层面,而是要上升到业务,乃至技术思维上。

  • 有意识地对代码PUA说:No!

  • 学习新技术时,不应只学会调用,也不应迷失在技术原理上,更重要的是花哨的技术和朴实的编程思想之间的化学反应。


作者:blackfrog
来源:juejin.cn/post/7274084216286036004
收起阅读 »

仿微信列表左滑删除、置顶。。

仿微信消息列表 前言 最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这...
继续阅读 »

仿微信消息列表


前言


最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这种解决方法的,但由于我对自定义view的了解还是比较少,而且之前也没有做过,所以就作罢。上周看了任玉刚老师的《Android开发艺术探索》中的View事件体系章节,提起了兴趣,就想着试一试吧,反正弄不成功也没关系。最后弄成了,但还是有些小瑕疵(在6、问题中),希望大佬能够指教一二。话不多说,放上一张动图演示下:


messlist.gif


1、典型的事件类型


在附上源码之前,想先向大家介绍下事件类型,在手指接触屏幕后所产生的一系列事件中,典型的事件类型有如下几种:



  • ACTION_DOWN ---- 手指刚接触屏幕

  • ACTION_MOVE ---- 手指在屏幕上移动

  • ACTION_UP ---- 手指刚离开屏幕


正常情况下、一次手指触摸屏幕的行为会触发一系列点击事件:



  • 点击屏幕后松开,事件序列为DOWN -> UP

  • 点击屏幕滑动后松开,事件序列为DOWN -> MOVE -> ... -> MOVE -> UP


2、Scroller


Scroller - 弹性滑动对象,用于实现View的弹性滑动。
当使用View的scrollTo/scrollBy方法来实现滑动时,其过程是在瞬间完成的,这个过程没有过渡效果,用户体验感较差,这个时候就可以使用Scroller来实现有过渡效果的滑动,其过程不是瞬间完成的,而是在一定时间间隔内完成的。


3、View的滑动


Android手机由于屏幕较小,为了给用户呈现更多的内容,就需要使用滑动来显示和隐藏一些内容,不管滑动效果多么绚丽,它们都是由不同的滑动外加特效实现的。View的滑动可以通过三种方式实现:



  • scrollTo/scrollBy:操作简单,适合对View内容的滑动。

  • 修改布局参数:操作稍微复杂,适合有交互的View。

  • 动画:操作简单,适合没有交互的View和实现复杂的动画效果。


3.1、scrollTo/scrollBy


为了实现View的滑动,View提供了专门的方法来实现这一功能,也就是scrollTo/scrollBy。是基于所传参数的绝对滑动。


3.2、修改布局参数


即改变LayoutParams,比如想把一个布局向右平移100px,只需要将该布局LayoutParams中的marginLeft参数值增加100px即可。或者在该布局左边放入一个默认宽度为0px的空View,当需要向右平移时,重新设置空View的宽度就OK了。


3.3、动画


动画和Scroller一样具有过渡效果,View动画是对View的影像做操作,并不能真正改变View的位置,单击新位置无法触发onClick事件,在这篇文章中并没有使用到,所以不再赘叙了。


4、布局文件


<?xml version="1.0" encoding="utf-8"?>
### <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
xmlns:widget="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<com.example.myapplication.view.ScrollerLinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">

<RelativeLayout
android:id="@+id/friend_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">

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

<com.makeramen.roundedimageview.RoundedImageView
android:id="@+id/friend_icon"
android:layout_width="45dp"
android:layout_height="45dp"
android:src="@mipmap/touxiang"
app:riv_corner_radius="5dp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginLeft="12dp"
android:gravity="center_vertical"
android:orientation="vertical">

<TextView
android:id="@+id/friend_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textColor="@color/black"
android:textSize="15dp"
tools:text="好友名" />

<TextView
android:id="@+id/friend_last_mess"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginEnd="18dp"
android:singleLine="true"
android:textColor="@color/color_dbdbdb"
android:textSize="12dp"
tools:text="最后一条信息内容" />
</LinearLayout>

</LinearLayout>

<TextView
android:id="@+id/last_mess_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginTop="5dp"
android:singleLine="true"
android:textColor="@color/color_dbdbdb"
android:textSize="11dp"
tools:text="时间" />
</RelativeLayout>

<LinearLayout
android:layout_width="240dp"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/unread_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:background="@color/color_theme"
android:gravity="center"
android:text="标为未读"
android:textColor="@color/color_FFFFFF" />

<Button
android:id="@+id/top_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:background="@color/color_orange"
android:gravity="center"
android:text="置顶"
android:textColor="@color/color_FFFFFF" />

<Button
android:id="@+id/delete_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:background="@color/color_red"
android:gravity="center"
android:text="删除"
android:textColor="@color/color_FFFFFF" />
</LinearLayout>

</com.example.myapplication.view.ScrollerLinearLayout>

<View
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_alignParentBottom="true"
android:layout_marginLeft="60dp"
android:layout_marginRight="3dp"
android:background="@color/color_e7e7e7" />

</LinearLayout>

ScrollerLinearLayout布局最多包含两个子布局(默认是这样,后面可能还会修改成自定义),一个是展示在用户面前充满屏幕宽度的布局,一个是待展开的布局,在该xml布局中,ScrollerLinearLayout布局包含了一个RelativeLayout和一个LinearLayoutLinearLayout中包含了三个按钮,分别是删除、置顶、标为未读。


5、自定义View-ScrollerLinearLayout


/**
* @Copyright : China Telecom Quantum Technology Co.,Ltd
* @ProjectName : My Application
* @Package : com.example.myapplication.view
* @ClassName : ScrollerLinearLayout
* @Description : 文件描述
* @Author : yulu
* @CreateDate : 2023/8/17 17:05
* @UpdateUser : yulu
* @UpdateDate : 2023/8/17 17:05
* @UpdateRemark : 更新说明
*/

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

private val mScroller = Scroller(context) // 用于实现View的弹性滑动
private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var mVelocityTracker: VelocityTracker? = null // 速度追踪
private var intercept = false // 拦截状态 初始值为不拦截
private var lastX: Float = 0f
private var lastY: Float = 0f // 用来记录手指按下的初始坐标
var expandWidth = 720 // View待展开的布局宽度 需要手动设置 3*dp
private var expandState = false // View的展开状态
private val displayWidth =
context.applicationContext.resources.displayMetrics.widthPixels // 屏幕宽度
private var state = true


override fun onTouchEvent(event: MotionEvent): Boolean {
Log.e(TAG, "onTouchEvent $event")
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (!expandState) {
state = false
}
}
else -> {
state = true
}
}
return state
}


override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.e(TAG, "onInterceptTouchEvent Result : ${onInterceptTouchEvent(ev)}")
Log.e(TAG, "dispatchTouchEvent : $ev")
mVelocityTracker = VelocityTracker.obtain()
mVelocityTracker!!.addMovement(ev)
return super.dispatchTouchEvent(ev)
}

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
Log.e(TAG, "onInterceptTouchEvent $ev")
when (ev?.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.rawX
lastY = ev.rawY
// 处于展开状态且点击的位置不在扩展布局中 拦截点击事件
intercept = expandState && ev.x < (displayWidth - expandWidth)
}
MotionEvent.ACTION_MOVE -> {
// 当滑动的距离超过10 拦截点击事件
intercept = lastX - ev.x > 10
moveWithFinger(ev)
}
MotionEvent.ACTION_UP -> {
// 判断滑动距离是否超过布局的1/2
chargeToRightPlace(ev)
intercept = false
}
MotionEvent.ACTION_CANCEL -> {
chargeToRightPlace(ev)
intercept = false
}
else -> intercept = false
}
return intercept
}

/**
* 将布局修正到正确的位置
*/

private fun chargeToRightPlace(ev: MotionEvent) {
val eventX = ev.x - lastX

Log.e(TAG, "该事件滑动的水平距离 $eventX")
if (eventX < -(expandWidth / 4)) {
smoothScrollTo(expandWidth, 0)
expandState = true
invalidate()
} else {
expandState = false
smoothScrollTo(0, 0)
invalidate()
}

// 回收内存
mVelocityTracker?.apply {
clear()
recycle()
}
//清除状态
lastX = 0f
invalidate()
}

/**
* 跟随手指移动
*/

private fun moveWithFinger(event: MotionEvent) {
//获得手指在水平方向上的坐标变化
// 需要滑动的像素
val mX = lastX - event.x
if (mX > 0 && mX < expandWidth) {
scrollTo(mX.toInt(), 0)
}
// 获取当前水平方向的滑动速度
mVelocityTracker!!.computeCurrentVelocity(500)
val xVelocity = mVelocityTracker!!.xVelocity.toInt()
invalidate()

}

/**
* 缓慢滚动到指定位置
*/

private fun smoothScrollTo(destX: Int, destY: Int) {
val delta = destX - scrollX
// 在多少ms内滑向destX
mScroller.startScroll(scrollX, 0, delta, 0, 600)
invalidate()
translationY = 0f
}

// 流畅地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.currX, mScroller.currY);
postInvalidate()
}
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
expandWidth = childViewWidth()
invalidate()
super.onLayout(changed, l, t, r, b)
}

/**
* 最多只允许有两个子布局
*/

private fun childViewWidth(): Int {
Log.e(TAG, "childCount ${this.childCount}")
return if (this.childCount > 1) {
val expandChild = this.getChildAt(1) as LinearLayout
if (expandChild.measuredWidth != 0){
expandWidth = expandChild.measuredWidth
}
Log.e(TAG, "expandWidth $expandWidth")
expandWidth
} else
0
}

companion object {
const val TAG = "ScrollerLinearLayout_YOLO"
}
}

思路比较简单,就是在ACTION_DOWN时记录初始的横坐标,在ACTION_MOVE中判断是否需要拦截该事件,
当滑动的距离超过10,拦截该点击事件,防止不必要的点击。并且View跟随手指移动。在ACTION_UPACTION_CANCEL中将布局修正到正确的位置,主要是根据滑动的距离来判断是否要展开并记录展开的状态。在ACTION_DOWN中判断是否处于展开状态,如果在展开状态且点击的位置不在扩展布局中,拦截点击事件,防止不必要的点击。


6、问题


自定义布局中的expandWidth参数在childViewWidth()方法和onLayout()方法中都赋值了一次,在onLayout()方法中查看日志expandWidth是有值的,可是在moveWithFinger()方法中打日志查看得到的expandWidth参数值仍然是0,导致无法正常滑动。去到其他的页面再返回到消息界面就可以正常滑动了,再次查看日志参数也有值了,这个问题不知道如何解决,所以需要手动设置expandWidth的值。


7、小结


初步的和自定义View认识了,小试牛刀,自己还是很满意这个学习成果的。希望在接下来的学习中不要因为没有接触过而放弃学习,勇于迈出第一步。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7269590511095054395
收起阅读 »

数据抓取:抓取手机设备各种数据

目录 前言 一、DataCapture 1.通讯录集合数据 2.应用列表集合数据 3.日历事件信息数据 4.电量信息数据 5.sms短信信息数据 6.照片集合信息数据 7.传感器信息数据 8.wifi信息数据...等等数据 二、使用步骤 1.引入库 2....
继续阅读 »

目录


前言


一、DataCapture



  • 1.通讯录集合数据

  • 2.应用列表集合数据

  • 3.日历事件信息数据

  • 4.电量信息数据

  • 5.sms短信信息数据

  • 6.照片集合信息数据

  • 7.传感器信息数据

  • 8.wifi信息数据...等等数据


二、使用步骤



  • 1.引入库

  • 2.获取数据方法,目前因数据量庞大,暂推荐手动在子线程调用

  • 3.关于权限,待更新

  • 总结




前言


基于最近刚完结的外包项目功能——数据抓取,通过调用api和内容提供器来获取手机设备各种数据,诸如SMS短信数据、电量数据、手机应用数据等等,我尝试开发了一个开源库,希望能够帮助到大家来实现这个功能。


习惯性上图展示:


在这里插入图片描述


一、DataCapture


对手机设备的信息数据抓取,目前支持在子线程抓取数据,因为有些数据量过于庞大会阻塞线程,可抓取数据有:


1.通讯录集合数据


字段名详情
contact_display_name联系人名称
last_time_contacted上次通讯时间(毫秒)
number联系人手机号
times_contacted联系次数
up_time编辑时间(毫秒))
type通话类型

2.应用列表集合数据


字段名详情
app_nameAPP名称
app_type是否系统app 0:非系统app 1:系统app
app_versionAPP版本
in_time安装时间(毫秒)
obtain_time数据抓取时间(秒))
package_name包名
up_time更新时间 (毫秒)
version_code版本号

3.日历事件信息数据


字段名详情
description事件描述
end_time事件结束时间(毫秒)
event_id事件ID
event_title事件标题
start_time事件开始时间(毫秒))
reminders提醒列表

4.电量信息数据


字段名详情
battery_level电池电量
battery_max电池容量
battery_pct电池百分比
battery_state电池状态 充电0 不充电1
is_ac_charge是否交流充电(1:yes,0:no)
is_charging是否正在充电
is_usb_charge是否USB充电(1:yes,0:no)

5.sms短信信息数据


字段名详情
content短信消息体
other_phone收件⼈/发件⼈⼿机号
package_name包名
read短信状态 0-未读,1-已读
seen短信是否被用户看到 0-尚未查看,1-已查看
status短信状态:-1表示接收,0-complete,64-pending,128-failed
subject短信主题
time收到短信的时间戳(毫秒),long型
type短信类型:1-接收短信,2-已发出短信

6.照片集合信息数据


字段名详情
addTime添加数据库时间(保存)
author照片作者
createTime照片读取时间(毫秒数时间戳),即当前时间
date拍照时间(毫秒数时间戳)
flash闪光灯
focal_length镜头的实际焦距
gps_altitude海拔高度
gps_processing_method定位的方法名称
height照片高度
latitude照片拍摄时的经度
lens_make镜头制造商
lens_model镜头的序列号
longitude照片拍摄时的纬度
model拍照机型
name照片名称
orientation照片方向
save_time照片修改时间
software生成图像的相机或图像输入设备的软件或固件的名称和版本
take_time创建时间(毫秒数时间戳)
updateTime编辑时间
width照片宽度
x_resolutionX方向上每个分辨率的像素数
y_resolutionY方向上每个分辨率的像素数

7.传感器信息数据


字段名详情
id传感器id,0不支持功能,-1即其类型和名称的组合在系统中唯一标识。-2获取不到
maxRange传感器单元中传感器的最大量程
minDelay两个事件之间允许的最小延迟(以微秒为单位),如果此传感器仅在其测量的数据发生变化时返回值,则为零
name传感器名称
power使用时功率
resolution传感器单元中传感器的分辨率
type该传感器的通用类型
name传感器名称
vendor厂商字符串
version版本

8.wifi信息数据...等等数据


二、使用步骤


1.引入库


在seetings.gradle中引入


repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}

在build.gradle中引入


 implementation 'com.github.Android5730:DataCapture:v0.23'

2.获取数据方法,目前因数据量庞大,暂推荐手动在子线程调用


// 获取通讯录
List<AddressBookBean> addressBookBean = AddressBookUtil.getAddressBookBean(getBaseContext());
// 获取应用列表
List<AppListBean> appListBean = AppListUtil.getAppListBean(this);
// 获取日历事件
List<CalendarListBean> calendarListBean = CalendarListUtil.getCalendarListBean(this);
// 获取电量信息
BatteryStatusBean batteryState = BatteryStatusUtil.getBatteryState(this);
// 获取wifi信息
NetworkBean networkBean = NetworkBeanUtils.getNetworkBean(this);
// 获取sms短信信息
List<SmsBean> smsList = SmsUtil.getSmsList(this);
// 获取照片集合信息
List<PhotoInfosBean> photoInfosBean = PhotoInfosUtil.getPhotoInfosBean(this, LocationUtils.getInstance(this).showLocation());
// 获取传感器集合信息
List<SensorListBean> sensorListBean = SensorListUtil.getSensorListBean(this);


3.关于权限,待更新


注意:因为获取图片时需要外部存储的权限,我这里采取的取消分区存储的做法,所以大家不要忘记在application里添加android:requestLegacyExternalStorage="true"
如果有哪个权限碍眼,或者项目强制不需要,也可以进行删除,如去除读取外部存储的权限:


    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove"/>


    <!-- 定位权限,需动态请求 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 通讯录,需动态请求 -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- 日历信息,需动态请求 -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<!-- wifi信息,不用动态请求 -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- SMS信息,需动态请求 -->
<uses-permission android:name="android.permission.READ_SMS" />
<!-- photo信息,需动态请求-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- 取消分区存储-->
<meta-data
android:name="ScopedStorage"
android:value="true" />


最后附上开源库地址:数据抓取:https://github.com/Android5730/DataCapture
如果有帮助到各位,可以给个star,给我一点信心去完善这个开源库


总结


当然目前该库目前抓取的数据还不到外包项目抓取数据的一半,只是因为最近有点忙,没时间完善所以才匆匆忙忙推出,相信等开学后就有时间完善,现在实习太累了。如果大家有疑问,可以在评论区提出,也可以在issue提出来,如果

作者:人间正四月
来源:juejin.cn/post/7271659608011358264
受到大家欢迎,我会持续完善此库。

收起阅读 »

Android简单的两级评论功能实现

Android简单的两级评论功能实现 前言 在App开发过程中,做了文章页面,那评论的功能自然是必不可少的,怎么做呢,如果只是做一个简单的评论不带回复功能的话,那和之前做毕设时候的我也看不到进步呀,这怎么行呢?于是我打开‘稀土掘金’App,随便看了几个文章,试...
继续阅读 »

Android简单的两级评论功能实现


前言


在App开发过程中,做了文章页面,那评论的功能自然是必不可少的,怎么做呢,如果只是做一个简单的评论不带回复功能的话,那和之前做毕设时候的我也看不到进步呀,这怎么行呢?于是我打开‘稀土掘金’App,随便看了几个文章,试了试评论的功能,于是便开始了我的构思。我想要实现的效果如下图所示,如何实现这样一个页面呢?我使用的方法是RecyclerView中再嵌套一个RecyclerView,一个用来展示一级评论,另一个则用来展示相应的二级评论,思路有了,下面就开始我的实现。


1693188380832.png

一、数据库


1、构建数据库


要想做好一个功能,数据库的构建是重中之重。下图是我构造的评论实体类


image.png

评论表中包含如下字段:



  • id --评论主键(自动生成)

  • newsId -- 主键

  • number -- 评论的用户主键

  • content -- 评论内容

  • time -- 评论时间

  • level -- 评论级别(有两级,当评论的对象是文章作者时,level为1,当评论对象为文章内的评论时,level为2,默认level为1)

  • replyNumber -- 评论回复的用户主键

  • replyId -- 评论回复的评论主键(只有level为2的评论才会用到该字段,所以默认为空)



replyNumber其实这里不应该默认为空的,因为无论是那种类型的回复,都是有对应的用户的,这个疏忽也造成了我在后面构建“我的评论”界面时,无法展示出文章作者的详细信息。



2、封装数据库


数据访问层Dao主要封装了对数据库的访问:


image.png

很平常的SQL语句,只简单说明下:分别是添加评论、根据id删除评论、获取该文章的所有评论、获取该用户的所有评论、通过id获取该评论



(省略了CommentTask接口即实现)


最后仓库层将这些方法都封装起来,方便后续调用,如下图所示:
image.png


二、布局


1、文章详情界面的评论布局


1693191191816.png



就是个RecyclerView哈哈



2、评论的适配器布局


1693191324123.png



可以看到适配器布局中还包含了一个RecyclerView,这里面展示的就是二级评论



3、二级评论的适配器布局


image.png



这个布局很简单,就由几个TextView组件构成



三、代码逻辑


首先,在ViewModel层初始化该文章的所有评论,观察评论数据变化,给评论适配器数据赋值并刷新,在评论适配器中再对level为2的评论数据进行过滤并赋值给回复适配器。


1、获取评论数据


var comments = MutableLiveData<List<CommentInfo>>()
comments.value = commentStoreRepository.getCommentsByNewId(newsId)

通过文章的id获取到评论


2、给评论适配器数据赋值


image.png


3、在评论适配器处理数据



首先,评论适配器中的数据是通过文章的id获取到的所有评论,包含了一级和二级评论,在评论适配器展示的当然不能是所有的评论,而是所有一级的评论,而二级评论的数据需要再进行过滤传递给回复适配器



所以,在绑定ViewHolder以及getItemCount时,需要对传递的数据进行过滤,


image.png
如图所示,allList是通过文章的id获取到的所有评论,list是level为1的所有评论,replyList是level为2的所有评论。getItemCount返回的是一级评论的个数。在绑定ViewHolder时,将一些回调函数和一级评论和二级评论列表传递进去,接着就看ViewHolder中的数据处理逻辑,如下两张图


image.png



这张图只是一些简单的一级数据的赋值和一些回调参数的调用传参



image.png



这里首先对二级评论进行过滤,过滤出与该条一级评论相关联的二级评论,接着对布局进行一些操作,接着是赋值操作和回复适配器中一些函数的实现。



4、在回复适配器处理数据


image.png



在这里就不需要对数据进行处理了,只有简单的赋值和回调了



5、回调函数的实现


image.png


四、实现效果


1、评论功能


7edbc47b85fd05b9c25841161eb4ba8.jpg

2、我的评论展示


dd6d2787fbf6ebf4a397367fc91fd4e.jpg

这里的“@3333333333”就是因为replyNumber为空的导致无法展示出文章作者的详细信息,只有展示用户主键了,后面再进行修改。



五、结语


就这样,一个简单的二级评论功能就完成了。文章若出现错误,欢迎各位批评指正,写文不易,转载<

作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7271991667246694437
/strong>请注明出处谢谢。

收起阅读 »

一文理解贝塞尔曲线

贝塞尔曲线的来源 贝塞尔曲线最早是由贝塞尔在1962年提出来的,目的是获取汽车的外形。贝塞尔曲线看上去非常复杂,其实想法非常简单,(如下图1所示)就是先用折线先绘制出大致的轮廓,然后用曲线来逼近。 图1 这个方式是不是有点熟悉,刚看到的时候,我就想到了计算圆...
继续阅读 »

贝塞尔曲线的来源


贝塞尔曲线最早是由贝塞尔在1962年提出来的,目的是获取汽车的外形。贝塞尔曲线看上去非常复杂,其实想法非常简单,(如下图1所示)就是先用折线先绘制出大致的轮廓,然后用曲线来逼近。




图1


这个方式是不是有点熟悉,刚看到的时候,我就想到了计算圆的面积时,我们会使用多边形来逼近圆的曲线(如图2所示);贝塞尔曲线刚好相反,它是使用曲线来逼近多边形,刚好反着来了😂。




图2


构造贝塞尔曲线


思路虽然简单,但是如何把这个曲线画出来,或者说如何用一个函数来表示这条曲线就很困难了。不过这个不需要我们关心,有大佬已经解决了。我们直接来看看贝塞尔曲线的定义式,如下图3:




图3


先别急着划走,这个公式不用记,因为它太复杂而且计算量大,因此在工程开发中我们不会用它。一般在工程中,我们使用德卡斯特里奥算法(de Casteljau) 来构造贝塞尔曲线。听起来更复杂了,别急让我们举个🌰。下面以2次贝塞尔曲线为例。




图4




图5


看图4,德卡斯特里奥算法(de Casteljau) 的意义就是满足P0Q0P0P1=P1Q1P1P2=Q0BQ0Q1=t  \frac{P_0Q_0}{P_0P_1} = \frac{P_1Q_1}{P_1P_2} = \frac{Q_0B}{Q_0Q_1} = t 的情况下,随着 t 从 0 到 1 逐渐变大,B点经过的点组成的曲线就是我们需要的贝塞尔曲线了。图5是我们常见的动图,之前看的时候一直很懵逼,现在了解了贝塞尔曲线是如何画出来的,是不是清楚多了。


更高阶的贝塞尔曲线绘制方式和上面的一样,只是多了几条边,绘制的动图如下:




3次贝塞尔曲线




4次贝塞尔曲线




5次贝塞尔曲线


贝塞尔曲线的函数表示


看到这里,我们已经对贝塞尔曲线有了一个大概的了解。但是还是一个关键的问题,我们怎么画出贝塞尔曲线呢?或者说有什么函数可以让我们画出这个曲线吗?这个其实更简单,我们高中就学过了。还是以二次贝塞尔曲线为例,它的参数方程如下,其中 P0、P1、P2代表控制点。




我们假设三个控制点的坐标是 P0 (-1, 0)、 P1 (0, 1) 、P2 (1, 0),把值带入上面的参数方程,就可以得到如下结果:


(xy)=(1t)2(10)+2t(1t)(01)+t2(10)\left(\begin{array}{c}x\\ y\end{array}\right) = (1 - t)^{2} \left(\begin{array}{c}-1\\ 0\end{array}\right)
+ 2t(1 - t) \left(\begin{array}{c}0\\ 1\end{array}\right) + t^{2} \left(\begin{array}{c}1\\ 0\end{array}\right)

(xy)=((12t)2+t22t(1t))\left(\begin{array}{c}x\\ y\end{array}\right) = \left(\begin{array}{c}-(1 - 2t)^{2} + t ^ 2\\ 2t(1 - t)\end{array}\right)

{x=2t1y=2t2+2t\begin{cases} x = 2t - 1 \\ y = -2t^2 + 2t\end{cases}

最后化解可得到我们熟悉的 y = f(x) 函数y=12x2+12:y = -\frac{1}{2}x^2 + \frac{1}{2} 效果图如下图。可以看出二次贝塞尔曲线实际上就是我们高中学的抛物线。唯一不同的是,我们高中求的抛物线,会经过 P0、P1、P2三个点,而贝塞尔曲线只会经过 P0、P1两个端点。




类似的:


一次贝塞尔曲线就是一次函数y=a0x+a1:y = a_0x + a_1


三次贝塞尔曲线就是三次函数:y=a0x3+a1x2+a2x+a3y = a_0x^3 + a_1x^2 + a_2x + a_3


四次贝塞尔曲线就是四次函数:y=a0x4+a1x3+a2x2+a3x+a4y = a_0x^4 + a_1x^3 + a_2x^2 + a_3x + a_4


n次贝塞尔曲线就是n次函数:y=a0xn+a1xn1+...+an y = a_0x^n + a_1x^{n-1} + ... + a_{n}


总结


贝塞尔曲线实际上并不复杂,我们可以简单的把n次贝塞尔曲线看成对应的n次函数的曲线。因为贝塞尔曲线的这个特点,也造成了贝塞尔曲线的最大缺陷————不能局部修改,即改变其中一个参数时会改变整条曲线。后面为了解决贝塞尔曲线的这个问题,提出了B样条曲线,下篇文章我们就介绍B样条曲线。


最后这篇文章为了方便读者的理解,省略了很多贝塞尔曲线特性的介绍,如果对贝塞尔曲线感兴趣,可以在B站上看看它的完整课程。


作者:小墙程序员
链接:https://juejin.cn/post/7267417719799857213
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

客户端开发的我,准备认真学前端了

⏰ : 全文字数:2200+ 🥅 : 内容关键字:前端,独立开发者,思考 背景 我呢,一个Android开发工程师,从毕业到现在主要做的是客户端开发,目前在一个手机厂商任职。自己目前知识技能主要在客户端上,其他方面会一点点,会一点点前端知识,会一点点后端知识...
继续阅读 »

⏰ : 全文字数:2200+

🥅 : 内容关键字:前端,独立开发者,思考



背景


我呢,一个Android开发工程师,从毕业到现在主要做的是客户端开发,目前在一个手机厂商任职。自己目前知识技能主要在客户端上,其他方面会一点点,会一点点前端知识,会一点点后端知识,会一点点脚本,用网络的一句话概括起来就是“有点东西,但是不多”😭。


为什么


决定学习前端,并不是心血来潮,一时自嗨,而是经过了比较长时间的思考。对于程序员来说,知识的更新迭代实在是很快,所以保持学习很重要。但是技术防线这么多,到底学什么?我相信这不是一个很容易做出抉择的问题。


对于前端之前有断断续续的学过一些,但是最后没有一直坚持下来。之所以这样,原因很多,比如没有很强的目标、没有足够的时间,前端涉及的知识点太多等。


但是我觉得对自己而言,最重要的一个原因是:**学习完前端,我能用它来干嘛?**如果没有想清楚这个原因,就很难找到目标。做事情没有目标,就无法拆解,也就无法长期坚持下去。直到最近,看了一些文章,碰到了一些事情,才慢慢想清楚这个问题。目前对我而言,开始决定认真学习前端的主要原因有两个:



  • 自己一直想做点什么

  • 工作上有需要


想做点什么


从我接触计算机开始,心底里一直有个梦,就是想利用自己手上技能,做点什么。我也和旁边的朋友同事交流过,大家都有类似的想法,从这看估计很多程序员朋友都会有这样的想法。我从一开始的捣鼓网站,论坛,到后来开发APP等,折腾了好多东西。但是到了最后,都没有折腾出点啥,都无疾而终。


前一段时间,看到一个博主写的一篇文章,文章大概是讲他如何从一个公司的后端开发工程师,走到今天成为一名独立开发者的故事。


其中有一段是说他一直心里念念不忘,想做一款 saas 应用,期间一直在学习和看其他人的产品,学习经验,尝试不同的想法。所谓念念不忘必有回响,终于从别人的产品中产生了一个点子,然后很快写好了后端服务,并自学前端边做边学,完成了这个产品。目前他的这个产品运作的很成功。


这个故事给我很大鼓舞,之前看到过很多这样的故事,有成功的,有失败的。我也去分析看了那些成功的,经过自己的观察,大部分成功的独立开发者,基本上都是多年前成功的那批,那段时间还是处于互联网的红利期,天时地利人和加在一起,造就了他们的成功。


当然这里并不是否认他们能力,觉得是他们运气好。能在当时那么多人中,脱颖而出,依然表明他们是佼佼者。这里只是想表达那个时间段,大环境对开发者来说,是比较友好的,阻力没有那么大。


很少看到最近两年成功的开发者(不排除自己不知道哈),但是从这位博主的经历来看,他确实在成功了,这给了我很大的鼓舞,说明这条路上还是有机会的,只是在现在这种大环境下,成功的难度在增加,阻力变大。如果我们自己始终坚持,寻找机会,不断地尝试,是否有一天可能会成功呢?


那这样的话,我主要关注哪个方向呢?我个人更加偏向于前端全栈方向,包括WebApp,小程序,P C 软件等。


为什么这么认为呢?看下现在的大环境,不提之前上架APP需要各种软件著作权,后来个人无法在各大商店上发布APP,再到现在新出的APP备案制,基本上个人想在Android App上发力,真的很难了。而且,经过自己在ProductHunt上观察,目前大部分独立开发者的作品都是聚焦于WebAppSAAS,或者是PC类软件,剩下就是IOSMAC平台的。


且学习前端技术栈是一个比较好的选择。JavaScript这门语言很强大,整个技术栈不仅可以做前端,也可以做后端开发,还可以做跨平台的 P C 软件开发, 提供的丰富的解决方案,对个人开发者来说极为合适。


当然,我们也可以找合适的人,一起组队合作,不用单打独斗,这样不仅节省期间和精力,也能有好的交流和碰撞。这条路我也经历过,但是说实话执行起来确实有一定的困难。首先就是人难找,要想找到一个三观差不多的伙伴,其实真的挺难的。还有一个就是个人时间和做事方式也很难契合。所以个人认为如果想做点什么,前期一个人自己去实现一个MVP出来,是一个合适的选择。后面如果有必要了,倒是可以考虑慢慢招人。


我们也要认识到技术只是最基础的第一步,要想做成一个产品,还有很多东西要学习。推广、运营,沟通交流无论哪个都是一道坎。但是作为登山者的我们不要关注前面路有多远,而是要确保自己一直在路上。


工作涉及


还有一个原因是,最近工作上和前端打交道有很多。因为项目内部接入了类似 React Native 的框架,有大量的业务场景是基于这个框架开发。这就导致了客户端涉及到大量和前端的交互,流程的优化,工程化等工作。客户端可以不用了解前端和框架的知识,也没什么问题。
但是想着如果后续这一块有什么问题,或者想对这块做一些性能优化、工程提效的事情,如果对前端知识没有一个很好的了解,估计也很难做出彩。


结尾


今天在这里絮絮叨叨这么多,并不是想要告诉大家选择前端技术栈学习就一定咋样,比如第一点说的独立开发者中,有很多的全栈开发者,他们有的已经失败了,有的还在路上,成功的毕竟还是少数。
我想分享的是我个人关于为什么选择前端技术栈作为学习方向,如何做出选择的一些思考。这都是我的一家之言,不一定正确,大家姑且一看。


同时自己心里也还是希望能像文章提到的那位博主一样,在做产品这条路上,也能“念念不忘,必有回响”。正如我一直相信秉持的“日拱一卒,功不唐捐”。

作者:七郎的小院
来源:juejin.cn/post/7271248528999481384

收起阅读 »

论如何在Android中还原设计稿中的阴影

每当设计稿上注明需要添加阴影时,Android上总是显得比较棘手,因为Android的阴影实现方式与Web和iOS有所区别。 一般来说阴影通常格式是有: X: 在X轴的偏移度 Y: 在Y轴偏移度 Blur: 阴影的模糊半径 Color: 阴影的颜色 何为阴影 ...
继续阅读 »

每当设计稿上注明需要添加阴影时,Android上总是显得比较棘手,因为Android的阴影实现方式与Web和iOS有所区别。


一般来说阴影通常格式是有:


X: 在X轴的偏移度


Y: 在Y轴偏移度


Blur: 阴影的模糊半径


Color: 阴影的颜色


何为阴影


但是在Android中却比较单一,只有一个度量单位:Elevation,作为在Android5.0的material2引入的概念,用一个图来形象的描绘一下,其实本质上就是虚拟的Z轴坐标。


image.png


那好,高度差有了,还差个光源,这样才能形成阴影,在material2中,光源不是单一的位于屏幕正上方的,而且有两组光源,分为主光源(Key light)和环境光源(Ambient light)如下图所示:
image.png


最终形成的效果是一种复合光源下更自然的阴影。


image.png


其中环境光源,在屏幕空间中没有实际的位置,但是主光源是有实际的位置的,具体的参数见:


frameworks/base/core/res/res/values/dimens.xml - Android Code Search
image.png


好,既然知道了阴影本身的机制,那下一步现在则是如何自定义控制阴影,这也是本文的目的。


从SDK 21开始,提供了Elevation可以实现类似于阴影的模糊半径的效果,但是毕竟尺度过于单一,往往有时候无法满足所需的效果,所以,还需要控制阴影的颜色。


在SDK 28之后,可以通过outlineSpotShadowColoroutlineAmbientShadowColor来分别设置Key light和Ambient light投射的阴影颜色,但是说实话,这两个属性基本用不到或者说比较鸡肋。


不过这里引入了一个概念:Outline。


四种常见方案


Elevation + Outline


Outline其实是View的边框(轮廓),通过OutlineProvider可以自定义一个View的Outline从而影响View本身在elevation下的投影,比如定义以实现一个圆角ImageView为例:


<ImageView
android:id="@+id/image"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@color/material_dynamic_primary90" />


image.clipToOutline = true
image.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
view ?: return
outline?.setRoundRect(0, 0, view.width, view.height, 32f)
}

}

效果基本没啥问题:
image.png


同样的,既然View的轮廓变化了,阴影自然也会跟着随之变化,所以outline也可以改变阴影:


image.elevation = 32f
image.outlineAmbientShadowColor = Color.RED
image.outlineSpotShadowColor = Color.BLUE
image.clipToOutline = true
image.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
view ?: return
outline?.setRoundRect(0, 0, view.width, view.height, 32f)
}

}

效果如下:(不过outlineAmbientShadowColoroutlineSpotShadowColor仅支持SDK 28及以上)


image.png


通常,到这一步通过调整elevation的数值和outline以及高版本可用的shadowColor大体上可以满足设计师的阴影需求。
而且通常来说shadowColor都是Color.Black以及alpha的区别,所以你也可以这样:


outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
view ?: return
outline?.alpha = 0.5f
outline?.setRoundRect(0, 0, view.width, view.height, 32f)
}
}

但是,还记着前面提到的两个光源吗?其中有一个光源是位于屏幕斜上方的,这就带来了另外一个问题,同一个View设置相同的Elevation在不同的Y轴坐标它的阴影效果是不一样的,如下图所示:



总之,阴影的Blur和Color参数勉强是可以得到满足的。


优点:原生的阴影效果


缺点:设置阴影的颜色需要SDK>=28,需要配合使用outline来实现对阴影的轮廓控制


下面我们先来引申一下Android中了解过的阴影实现方式。


LayerDrawable


我相信大家肯定见过这种实现方式,通过绘制一层层渐变色来模拟阴影,其实官方也有通过该方式实现的阴影:MaterialShapeDrawable,示例如下:


val drawable = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setAllCornerSizes(16.dp)
.build()
)
drawable.fillColor = ColorStateList.valueOf(getColor(com.google.android.material.R.color.material_dynamic_primary90))
drawable.setShadowColor(Color.RED)
drawable.shadowVerticalOffset = 8.dp.toInt()
drawable.elevation = 32f
drawable.shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
image.background = drawable

效果如图:
image.png


只能说很一般,毕竟是模拟的阴影模糊效果,而且目前只支持Y轴的offset。


优点:几乎是开箱即用的Drawable且自带圆角


缺点:模拟的阴影效果,展示效果不够精细且效率不高


NinePatchDrawable


说实话想在Android上实现一个简单的阴影太折腾了,什么奇怪的技巧都来了,比如.9图,至于什么是.9图这里便不再过多介绍。
通过这个网站:Android Shadow Generator (inloop.github.io)


image.png
你可以直接生成一个CSS Style的阴影效果,几乎可以完美还原Figma的阴影效果,效果如下:
image.png


其实还是很还原的,但是它有一个致命的缺点,就是圆角,因为是一张图片,所以圆角的单位本质上是px而非Android上的dp,如果你需要一个带圆角弧度的阴影是达不到预期的。


优点:参数完全可控的阴影,可以做到1:1还原设计稿


缺点:因为是图片,所以阴影的圆角无法跟随像素密度缩放(非常致命的缺点)


Paint.setShadowLayer/BlurMaskFilter


这两个我之所以放在一起本质上是因为实现起来都是类似的,
如:


paint.setShadowLayer(radius, offsetX, offsetY, shadowColor)
// 或者使用maskFilter然后通过paint.color以及绘制的区域进行offset来变相控制阴影的Color及offset
paint.maskFilter = BlurMaskFilter(field, BlurMaskFilter.Blur.NORMAL)

相比之下更推荐使用setShadowLayer,最终效果如下,基本上没啥问题:
image.png


但是值得注意的是,其绘制的阴影本质上等价于BlurMaskFilter,是占位的,而且是需要留出空间来展示的,所以必要时需要对父布局设置android:clipChildren="false"或者预留出足够的空间。


优点:


1. 参数完全可控的阴影,可以做到1:1还原设计稿


2. 参数的自定义程度及可控性强


缺点:


1. 阴影占位,需要通过clipChildren=false来或者预留空间规避


2. 需要自定义View或者Drawable,写起来较为麻烦。


总的来说,上面介绍了4种可能常见的阴影实现方式,其中按我的经验来说,较为推荐采用Outline或者setShadowLayer的方式来实现,如果可以的话原生Elevation配合Outline基本可以满足大部分需求场景。


当然还有部分实现方式比如用RenderScriptBlur等等,我没提是因为是前几种方式较为复杂,性价比不高。


Paint.setShadowLayer 扩展内容


下面则重点讲一下Paint.setShadowLayer/BlurMaskFilter这种方式,为什么说这两种方式实现的阴影都是一致的呢?这个就需要深入到C++层。
首先直接跳到paint.setShadowLayer的native实现类:
frameworks/base/libs/hwui/jni/Paint.cpp


Paint.cpp - Android Code Search


    static void setShadowLayer(CRITICAL_JNI_PARAMS_COMMA jlong paintHandle, jfloat radius,
jfloat dx, jfloat dy, jlong colorSpaceHandle,
jlong colorLong)
{
SkColor4f color = GraphicsJNI::convertColorLong(colorLong);
sk_sp<SkColorSpace> cs = GraphicsJNI::getNativeColorSpace(colorSpaceHandle);

Paint* paint = reinterpret_cast<Paint*>(paintHandle);
if (radius <= 0) {
paint->setLooper(nullptr);
}
else {
SkScalar sigma = android::uirenderer::Blur::convertRadiusToSigma(radius);
paint->setLooper(BlurDrawLooper::Make(color, cs.get(), sigma, {dx, dy}));
}
}


里面将我们传入的阴影radius参数转为Sigma并创建了BlurDrawLooper,我们来看看其实现


#include "BlurDrawLooper.h"
#include <SkMaskFilter.h>

namespace android {

BlurDrawLooper::BlurDrawLooper(SkColor4f color, float blurSigma, SkPoint offset)
: mColor(color), mBlurSigma(blurSigma), mOffset(offset) {}

BlurDrawLooper::~BlurDrawLooper() = default;

SkPoint BlurDrawLooper::apply(Paint* paint) const {
paint->setColor(mColor);
if (mBlurSigma > 0) {
paint->setMaskFilter(SkMaskFilter::MakeBlur(kNormal_SkBlurStyle, mBlurSigma, true));
}
return mOffset;
}

sk_sp<BlurDrawLooper> BlurDrawLooper::Make(SkColor4f color, SkColorSpace* cs, float blurSigma,
SkPoint offset)
{
if (cs) {
SkPaint tmp;
tmp.setColor(color, cs); // converts color to sRGB
color = tmp.getColor4f();
}
return sk_sp<BlurDrawLooper>(new BlurDrawLooper(color, blurSigma, offset));
}

} // namespace android

内容不多,可以看到本质上还是利用了setMaskFilter来实现的。


然后还剩下一个点就是通过SkMaskFilter::MakeBlur生成的模糊是占位的,如果能知道模糊具体需要多大的空间,就可以方便的进行预留以免实际展示时阴影被裁剪。
MakeBlur最终返回的是一个SkBlurMaskFilterImpl对象,我们可以先看一下其父类SkMaskFilterBase的虚函数:重点关注computeFastBounds函数


SkMaskFilterBase.h - Android Code Search


    /**
* The fast bounds function is used to enable the paint to be culled early
* in the drawing pipeline. This function accepts the current bounds of the
* paint as its src param and the filter adjust those bounds using its
* current mask and returns the result using the dest param. Callers are
* allowed to provide the same struct for both src and dest so each
* implementation must accommodate that behavior.
*
* The default impl calls filterMask with the src mask having no image,
* but subclasses may override this if they can compute the rect faster.
*/

virtual void computeFastBounds(const SkRect& src, SkRect* dest) const;

可以看到该函数的作用便是计算MaskFiter的bounds,看一下子类的SkBlurMaskFilterImpl的实现


void SkBlurMaskFilterImpl::computeFastBounds(const SkRect& src,
SkRect* dst)
const
{
// TODO: if we're doing kInner blur, should we return a different outset?
// i.e. pad == 0 ?

SkScalar pad = 3.0f * fSigma;

dst->setLTRB(src.fLeft - pad, src.fTop - pad,
src.fRight + pad, src.fBottom + pad);
}

其中fSigme便是最开始通过convertRadiusToSigma(radius)获取到的返回值,其计算方式如下:
SkBlurMask.cpp - Android Code Search


// This constant approximates the scaling done in the software path's
// "high quality" mode, in SkBlurMask::Blur() (1 / sqrt(3)).
// IMHO, it actually should be 1: we blur "less" than we should do
// according to the CSS and canvas specs, simply because Safari does the same.
// Firefox used to do the same too, until 4.0 where they fixed it. So at some
// point we should probably get rid of these scaling constants and rebaseline
// all the blur tests.
static const SkScalar kBLUR_SIGMA_SCALE = 0.57735f;

SkScalar SkBlurMask::ConvertRadiusToSigma(SkScalar radius) {
return radius > 0 ? kBLUR_SIGMA_SCALE * radius + 0.5f : 0.0f;
}

这样,我们可以得到一个模糊的近似Bound,虽然不是一个准确的值但是至少可以保证绘制的阴影不会被裁剪。
当然,如果无法预留Padding也可以通过clipChildren=false来实现。


总结


最后我也是针对setShadowLayer提供了一个自定义View的实现方式:


Lowae/Shadows: A simple and customizable library on Android to implement CSS style shadows (github.com)


感兴趣的可以尝试使用,有任何兼容性问题欢迎提issue~



(我十分清楚会有很多兼容性问题,没办法,这种Api就是这样,不,准确来说,Android就是这样)



所以,想在Android上1:1还原设计稿上的阴影是比较困难的,但是如果不去追求参数的还原只是寻求视觉的略显一致,那还是可以做到的,简单点的通过第一种方式(Elevation + Outline),如果设置到阴影颜色或者offset这种便可以尝试最后一种方式(

作者:Lowae
来源:juejin.cn/post/7270503053358874664
setShadowLayer)。

收起阅读 »

Volatile 关键字

保证内存可见性 Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读...
继续阅读 »

保证内存可见性


Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都必须经过主内存的传递来完成。 



这样就会存在一个情况,工作内存值改变后到主内存更新一定是需要一定时间的,所以可能会出现多个线程操作同一个变量的时候出现取到的值还是未更新前的值。


这样的情况我们通常称之为「可见性」,而我们加上 volatile 关键字修饰的变量就可以保证对所有线程的可见性。


这里的可见性是什么意思呢?当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。


为什么 volatile 关键字可以有这样的特性?这得益于 Java 语言的先行发生原则(happens-before)。简单地说,就是先执行的事件就应该先得到结果。


但是! volatile 并不能保证并发下的安全。


Java 里面的运算并非原子操作,比如 i++ 这样的代码,实际上,它包含了 3 个独立的操作:读取 i 的值,将值加 1,然后将计算结果返回给 i。这是一个「读取-修改-写入」的操作序列,并且其结果状态依赖于之前的状态,所以在多线程环境下存在问题。



要解决自增操作在多线程下线程不安全的问题,可以选择使用 Java 提供的原子类,如 AtomicInteger 或者使用 synchronized 同步方法。


原子性:在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量)才是原子操作。(变量之间的相互赋值不是原子操作,比如 y = x,实际上是先读取 x 的值,再把读取到的值赋值给 y 写入工作内存)



禁止指令重排


最开始看到「指令重排」这个词语的时候,我也是一脸懵逼。后面看了相关书籍才知道,处理器为了提高程序效率,可能对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。


指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,却会影响到多线程的执行结果。比如下面的代码:


使用场景


从上面的总结来看,我们非常容易得出 volatile 的使用场景:

  1. 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。

比如下面的场景,就很适合使用 volatile 来控制并发,当 shutdown() 方法调用的时候,就能保证所有线程中执行的 work() 立即停下来。

volatile boolean shutdownRequest;
private void shutdown(){
shutdownRequest = true;
}
private void work(){
while (!shutdownRequest){
// do something
}
}

总结


说了这么多,其实对于 volatile 我们只需要知道,它主要特性:保证可见性、禁止指令重排、解决 long 和 double 的 8 字节赋值问题。


还有一个比较重要的是:它并不能保证并发安全,不要和 synchronize 混淆。


作者:PuddingSama
链接:https://juejin.cn/post/7234466978911993916
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

揭秘:Android屏幕中你不知道的刷新机制

前言 之前在整理知识的时候,看到android屏幕刷新机制这一块,以前一直只是知道,Android每16.6ms会去刷新一次屏幕,也就是我们常说的60fpx,那么问题也来了: 16.6ms刷新一次是什么一次,是以这个固定的频率去重新绘制吗?但是请求绘制的代码时...
继续阅读 »

前言


之前在整理知识的时候,看到android屏幕刷新机制这一块,以前一直只是知道,Android每16.6ms会去刷新一次屏幕,也就是我们常说的60fpx,那么问题也来了:


16.6ms刷新一次是什么一次,是以这个固定的频率去重新绘制吗?但是请求绘制的代码时机调用是不同的,如果操作是在16.6ms快结束的时候去绘制的,那么岂不是就是时间少于16.6ms,也会产生丢帧的问题?再者熟悉绘制的朋友都知道请求绘制是一个Message对象,那这个Message是会放进主线程Looper的队列中吗,那怎么能保证在16.6ms之内会执行到这个Message呢?


View ## invalidate()


既然是绘制,那么就从这个方法看起吧


public void invalidate() {
invalidate(true);
}
public void invalidate(boolean invalidateCache) {
invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
}
void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,
boolean fullInvalidate
) {
......
final AttachInfo ai = mAttachInfo;
final ViewParent p = mParent;
if (p != null && ai != null && l < r && t < b) {
final Rect damage = ai.mTmpInvalRect;
damage.set(l, t, r, b);
p.invalidateChild(this, damage);
}
.....
}
}

主要关注这个p,最终调用的是它的invalidateChild()方法,那么这个p到底是个啥,ViewParent是一个接口,那很明显p是一个实现类,答案是ViewRootImpl,我们知道View树的根节点是DecorView,那DecorView的Parent是不是ViewRootImpl呢


熟悉Activity启动流程的朋友都知道,Activity 的启动是在 ActivityThread 里完成的,handleLaunchActivity() 会依次间接的执行到 Activity 的 onCreate(), onStart(), onResume()。在执行完这些后 ActivityThread 会调用 WindowManager#addView(),而这个 addView()最终其实是调用了 WindowManagerGlobal 的 addView() 方法,我们就从这里开始看,因为是隐藏类,所以这里借助Source Insight查看WindowManagerGlobal


public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow
) {
synchronized (mLock) {
.....
root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
}
try {
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
synchronized (mLock) {
final int index = findViewLocked(view, false);
if (index >= 0) {
removeViewLocked(index, true);
}
}
throw e;
}
}
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
synchronized (this) {
....
view.assignParent(this);
...
}
}
void assignParent(ViewParent parent) {
if (mParent == null) {
mParent = parent;
} else if (parent == null) {
mParent = null;
}
}

参数是ViewParent,所以在这里就直接将DecorView和ViewRootImpl给绑定起来了,所以也验证了上述的结论,子 View 里执行 invalidate() 之类的操作,最后都会走到 ViewRootImpl 里来


ViewRootImpl##scheduleTraversals


根据上面的链路最终是会执行到scheduleTraversals方法


void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
复制代码方法不长,首先如果mTraversalScheduled为false,进入判断,同时将此标志位置位true,第二句暂时不管,后续会讲到,主要看postCallback方法,传递进去了一个mTraversalRunnable对象,可以看到这里是一个请求绘制的Runnable对象
final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
performTraversals();
if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}

doTraversal方法里面,又将mTraversalScheduled置位了false,对应上面的scheduleTraversals方法,可以看到一个是postSyncBarrier(),而在这里又是removeSyncBarrier(),这里其实涉及到一个很有意思的东西,叫同步屏障,等会会拉出来单独讲解,然后调用了performTraversals(),这个方法应该都知道了,View 的测量、布局、绘制都是在这个方法里面发起的,代码逻辑太多了,就不贴出来了,暂时只需要知道这个方法是发起测量的开始。


这里我们暂时总结一下,当子View调用invalidate的时候,最终是调用到ViewRootImpl的performTraversals()方法的,performTraversals()方法又是在doTraversal里面调用的,doTraversal又是封装在mTraversalRunnable之中的,那么这个Runnable的执行时机又在哪呢


Choreographer##postCallback


回到上面的scheduleTraversals方法中,mTraversalRunnable是传递进了Choreographer的postCallback方法之中


private void postCallbackDelayedInternal(int callbackType,
Object action, Object token, long delayMillis
) {
if (DEBUG_FRAMES) {
synchronized (mLock) {
final long now = SystemClock.uptimeMillis();
final long dueTime = now + delayMillis;
mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

if (dueTime <= now) {
scheduleFrameLocked(now);
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
msg.arg1 = callbackType;
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, dueTime);
}
}
}

可以看到内部好像有一个类似MessageQueue的东西,将Runnable通过delay时间给存储起来的,因为我们这里传递进来的delay是0,所以执行scheduleFrameLocked(now)方法


private void scheduleFrameLocked(long now) {
if (!mFrameScheduled) {
mFrameScheduled = true;
if (isRunningOnLooperThreadLocked()) {
scheduleVsyncLocked();
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
msg.setAsynchronous(true);
mHandler.sendMessageAtFrontOfQueue(msg);
}
}
}
private boolean isRunningOnLooperThreadLocked() {
return Looper.myLooper() == mLooper;
}

这里有一个判断isRunningOnLooperThreadLocked,看着像是判断当前线程是否是主线程,如果是的话,调用scheduleVsyncLocked()方法,不是的话会发送一个MSG_DO_SCHEDULE_VSYNC消息,但是最终都会调用这个方法


public void scheduleVsync() {
if (mReceiverPtr == 0) {
Log.w(TAG, "Attempted to schedule a vertical sync pulse but the display event "
+ "receiver has already been disposed.");
} else {
nativeScheduleVsync(mReceiverPtr);
}
}

如果mReceiverPtr不等于0的话,会去调用nativeScheduleVsync(mReceiverPtr),这是个native方法,暂不跟踪到C++里面去了,看着英文方法像是一个安排信号的意思 之前是把CallBack存储在一个Queue之中了,那么必然有执行的方法


void doCallbacks(int callbackType, long frameTimeNanos) {
CallbackRecord callbacks;
synchronized (mLock) {
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, CALLBACK_TRACE_TITLES[callbackType]);
for (CallbackRecord c = callbacks; c != null; c = c.next) {
if (DEBUG_FRAMES) {
Log.d(TAG, "RunCallback: type=" + callbackType
+ ", action=" + c.action + ", token=" + c.token
+ ", latencyMillis=" + (SystemClock.uptimeMillis() - c.dueTime));
}
c.run(frameTimeNanos);
}
} finally {
synchronized (mLock) {
mCallbacksRunning = false;
do {
final CallbackRecord next = callbacks.next;
recycleCallbackLocked(callbacks);
callbacks = next;
} while (callbacks != null);
}
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
}

看一下这个方法在哪里调用的,走到了doFrame方法里面


void doFrame(long frameTimeNanos, int frame) {
final long startNanos;
synchronized (mLock) {
try {
.....
mFrameInfo.markInputHandlingStart();
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);

mFrameInfo.markAnimationsStart();
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);

mFrameInfo.markPerformTraversalsStart();
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);

doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
} finally {
AnimationUtils.unlockAnimationClock();
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
.....
}

那么这样一来,就找到关键的方法了:doFrame(),这个方法里会根据一个时间戳去队列里取任务出来执行,而这个任务就是 ViewRootImpl 封装起来的 doTraversal() 操作,而 doTraversal() 会去调用 performTraversals() 开始根据需要测量、布局、绘制整颗 View 树。所以剩下的问题就是 doFrame() 这个方法在哪里被调用了。


private final class FrameDisplayEventReceiver extends DisplayEventReceiver
implements
Runnable {
private boolean mHavePendingVsync;
private long mTimestampNanos;
private int mFrame;

public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
super(looper, vsyncSource);
}

@Override
public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
scheduleVsync();
return;
}
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
@Override
public void run() {
mHavePendingVsync = false;
doFrame(mTimestampNanos, mFrame);
}
}

可以看到,是在onVsync回调中,Message msg = Message.obtain(mHandler, this),传入了this,然后会执行到run方法里面,自然也就执行了doFrame方法,所以最终问题也就来了,这个onVsync()这个是什么时候回调来的,这里查询了网上的一些资料,是这么解释的



FrameDisplayEventReceiver继承自DisplayEventReceiver接收底层的VSync信号开始处理UI过程。VSync信号由SurfaceFlinger实现并定时发送。FrameDisplayEventReceiver收到信号后,调用onVsync方法组织消息发送到主线程处理。这个消息主要内容就是run方法里面的doFrame了,这里mTimestampNanos是信号到来的时间参数。



那也就是说,onVsync是底层回调回来的,那也就是每16.6ms,底层会发出一个屏幕刷新的信号,然后会回调到onVsync方法之中,但是有一点很奇怪,底层怎么知道上层是哪个app需要这个信号来刷新呢,结合日常开发,要实现这种一般使用观察者模式,将app本身注册下去,但是好像也没有看到哪里有往底层注册的方法,对了,再次回到上面的那个native方法,nativeScheduleVsync(mReceiverPtr),那么这个方法大体的作用也就是注册监听了,


同步屏障


总结下上面的知识,我们调用了 invalidate(),requestLayout(),等之类刷新界面的操作时,并不是马上就会执行这些刷新的操作,而是通过 ViewRootImpl 的 scheduleTraversals() 先向底层注册监听下一个屏幕刷新信号事件,然后等下一个屏幕刷新信号来的时候,才会去通过 performTraversals() 遍历绘制 View 树来执行这些刷新操作。


那么这样是不是产生一个问题,因为我们知道,平常Handler发送的消息都是同步消息的,也就是Looper会从MessageQueue中不断去取Message对象,一个Message处理完了之后,再去取下一个Message,那么绘制的这个Message如何尽量保证能够在16.6ms之内执行到呢,


这里就使用到了一个同步屏障的东西,再次回到scheduleTraversals代码


void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

mHandler.getLooper().getQueue().postSyncBarrier(),这一句上面没有分析,进入到方法里


private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
msg.when = when;
msg.arg1 = token;
Message prev = null;
Message p = mMessages;
if (when != 0) {
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
if (prev != null) { // invariant: p == prev.next
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
mMessages = msg;
}
return token;
}
}

可以看到,就是生成Message对象,然后进一些赋值,但是细心的朋友可能会发现,这个msg为什么没有设置target,熟悉Handler流程的朋友应该清楚,最终消息是通过msg.target发送出去的,一般是指Handler对象


那我们再次回到MessageQueue的next方法中看看


Message next() {
for (;;) {
....
synchronized (this) {
...
//对,就是这里了,target==null
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
nextPollTimeoutMillis = -1;
}
}
}

可以看到有一个Message.target==null的判断, do while循环遍历消息链表,跳出循环时,msg指向离表头最近的一个消息,此时返回msg对象


可以看到,当设置了同步屏障之后,next函数将会忽略所有的同步消息,返回异步消息。换句话说就是,设置了同步屏障之后,Handler只会处理异步消息。再换句话说,同步屏障为Handler消息机制增加了一种简单的优先级机制,异步消息的优先级要高于同步消息


这样的话,能够尽快的将绘制的Message给取出来执行,嗯,这里为什么说是尽快呢,因为,同步屏障是在 scheduleTraversals() 被调用时才发送到消息队列里的,也就是说,只有当某个 View 发起了刷新请求时,在这个时刻后面的同步消息才会被拦截掉。如果在 scheduleTraversals() 之前就发送到消息队列里的工作仍然会按顺序依次被取出来执行

作者:花海blog
来源:juejin.cn/post/7267528065809907727

收起阅读 »

高斯模糊

前言 通常,图像处理软件会提供"模糊"(blur)滤镜,使图片产生模糊的效果。 “模糊”的算法不只一种,高斯模糊只是其中一种,甚至它只是其中效率很差的一种。 在Android中使用高斯模糊,需要使用到 JNI 技术,Android Studio开发之 JNI...
继续阅读 »

前言


通常,图像处理软件会提供"模糊"(blur)滤镜,使图片产生模糊的效果。



“模糊”的算法不只一种,高斯模糊只是其中一种,甚至它只是其中效率很差的一种。


在Android中使用高斯模糊,需要使用到 JNI 技术,Android Studio开发之 JNI 篇已具体讨论JNI的用法等。本文主要讲述高斯模糊原理及编码等。


高斯模糊原理


所谓"模糊",可以理解成每一个像素都取周边像素的平均值。



如图所示,2是中间点,周围点都是1。中间点取周围点平均值,就会变成1。在数值上,这是一种"平滑化"。在图形上,就相当于产生"模糊"效果,"中间点"失去细节。


显然,计算平均值时,取值范围越大,"模糊效果"越强烈。


如果使用简单平均,显然不是很合理,因为图像都是连续的,越靠近的点关系越密切,越远离的点关系越疏远。因此,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小。


高斯模糊根据正态分布,决定周围点的权重值。



正态分布是一种钟形曲线,越接近中心,取值越大,越远离中心,取值越小。


计算平均值的时候,我们只需要将"中心点"作为原点,其他点按照其在正态曲线上的位置,分配权重,就可以得到一个加权平均值。


正态分布的一维公式为:



由于每次计算都是以中间点为原点,所以u为标准差,即为0。所以公式进一步进化为:



由于图像是二维的,需要根据二维正态分布函数来计算权重值,它的公式以及曲线如下:



不过为了代码效率问题,不会采用二维正态分布的计算方式,而是分别对 X 轴和 Y 轴进行两次高斯模糊,也能达到效果(即通过一维正态分布计算权重)。


高斯模糊代码


先分别计算正态分布各参数,sigma与高斯模糊半径有关系,2.57既是1除以根号2 PI得来。

float sigma = 1.0 * radius / 2.57;
float deno = 1.0 / sigma * sqrt(2.0 * PI);
float nume = -1.0 / (2.0 * sigma * sigma);

因为对于每一个像素点来说,周围点在正态分布中所占的权重值都是一样的,所以正态分布计算一次即可。

float *gaussMatrix = (float *) malloc(sizeof(float) * (radius + radius + 1));
float gaussSum = 0.0;
for (int i = 0, x = -radius; x <= radius; ++x, ++i) {
float g = deno * exp(1.0 * nume * x * x);
gaussMatrix[i] = g;
gaussSum += g;
}

因为是以中间点自身为原点,所以 x 的取值范围是从 -radius 到 radius,计算结果存储的数组中。请注意周围点权重值与数组的对应关系,x 等于 -radius 时,而 i 等于0,后文会用到。


由于并没有计算所有的周围点,所以权重总合必然不为1,所以需要归一化,设法使权重值为一。

int len = radius + radius + 1;
for (int i = 0; i < len; ++i) {
gaussMatrix[i] /= gaussSum;
}

先进行 x 轴的模糊。

  for (int y = 0; y < h; ++y) {
//取一行像素数据,注意像素总数组的访问方式是 x + y * w
memcpy(rowData, pix + y * w, sizeof(int) * w);
for (int x = 0; x < w; ++x) {
float r = 0, g = 0, b = 0;
gaussSum = 0;
//以当前坐标点 x、y 为中心,查看前后一个模糊半径的周围点,根据正态分布
//重新计算像素点的颜色值
for (int i = -radius; i <= radius; ++i) {
// k 表示周围点的真实坐标
int k = x + i;
// 边界上的像素点,它的周围点只有正常的一半,所以要保证 k 的取值范围
if (k >= 0 && k <= w) {
// 取到周围点的像素,并根据 argb 的排列方式,计算 r、g、b分量
int color = rowData[k];
int cr = (color & 0x00ff0000) >> 16;
int cg = (color & 0x0000ff00) >> 8;
int cb = (color & 0x000000ff);
//真实点坐标为 k,与它对应的权重数组下标是 i + radius
//前文中计算正态分布权重时已经说明相关的对应关系。
//根据正态分布的权重关系,计算中心点的 r g b各分量
int index = i + radius;
r += cr * gaussMatrix[index];
g += cg * gaussMatrix[index];
b += cb * gaussMatrix[index];
gaussSum += gaussMatrix[index];
}
}
//因为边界点的存在,gaussSum值不一定为1,所以需要除以gaussSum,归一化。
int cr = (int) (r / gaussSum);
int cg = (int) (g / gaussSum);
int cb = (int) (b / gaussSum);
//根据权重值与各周围点像素相乘之和,得到新的中间点像素。
pix[y * w + x] = cr << 16 | cg << 8 | cb | 0xff000000;
}
}

y轴的模糊原理和x轴基本一样,这里就不再重复说明了。


JNI图片接口


JNI中处理图片,需要引用 bitmap.h,头文件中主要定义三个方法。

  int AndroidBitmap_getInfo(JNIEnv* env, jobject jbitmap,
AndroidBitmapInfo* info);
int AndroidBitmap_lockPixels(JNIEnv* env, jobject jbitmap, void** addrPtr);
int AndroidBitmap_unlockPixels(JNIEnv* env, jobject jbitmap);

AndroidBitmap_getInfo:获取图片信息,比如宽、高、图片格式等
AndroidBitmap_lockPixels:顾名思义,锁定像素
AndroidBitmap_unlockPixels:解锁。


AndroidBitmap_lockPixels 和 AndroidBitmap_unlockPixels 成对调用,在两个方法之间可对图片像素进行相应处理,解锁像素以后,对图片的调整效果可以立即看到,并不需要再重新生成图片了。


ps:有时并不知道 JNI 有哪些接口可以调用,最好的方式就是看源码,有哪些接口,一目了然。


其它模糊方法


除了高斯模糊之外,还有其它模糊方法,比如说 fastblur,不过这个算法还没看明白,此处不再详述,具体代码本人的github上都有,欢迎访问。


作者:某昆real
链接:https://juejin.cn/post/7225094324527546429
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

当遇到需要在Activity间传递大量的数据怎么办?

在Activity间传递的数据一般比较简单,但是有时候实际开发中也会传一些比较复杂的数据,尤其是面试问道当遇到需要在Activity间传递大量的数据怎么办? Intent 传递数据的大小是有限制的,它大概能传的数据是1M-8K,原因是Binder锁映射的内存大...
继续阅读 »

在Activity间传递的数据一般比较简单,但是有时候实际开发中也会传一些比较复杂的数据,尤其是面试问道当遇到需要在Activity间传递大量的数据怎么办?


Intent 传递数据的大小是有限制的,它大概能传的数据是1M-8K,原因是Binder锁映射的内存大小就是1M-8K.一般activity间传递数据会要使用到binder,因此这个就成为了数据传递的大小的限制。那么当activity间要传递大数据采用什么方式呢?其实方式很多,我们就举几个例子给大家说明一下,但是无非就是使用数据持久化,或者内存共享方案。一般大数据的存储不适宜使用SP, MMKV,DataStore。


Activity之间传递大量数据主要有如下几种方式实现:
  • LruCache
  • 持久化(sqlite、file等)
  • 匿名共享内存

使用LruCache

LruCache是一种缓存策略,可以帮助我们管理缓存,想具体了解的同学可以去Glide章节中具体先了解下。在当前的问题下,我们可以利用LruCache存储我们数据作为一个中转,好比我们需要Activity A向Activity B传递大量数据,我们可以Activity A先向LruCache先写入数据,之后Activity B从LruCache读取。


首先我们定义好写入读出规则:

public interface IOHandler {
   //保存数据
   void put(String key, String value);
   void put(String key, int value);
   void put(String key, double value);
   void put(String key, float value);
   void put(String key, boolean value);
   void put(String key, Object value);

   //读取数据
   String getString(String key);
   double getDouble(String key);
   boolean getBoolean(String key);
   float getFloat(String key);
   int getInt(String key);
   Object getObject(String key);
}

我们可以根据规则也就是接口,写出具体的实现类。实现类中我们保存数据使用到LruCache,这里面我们一定要设置一个大小,因为内存中数据的最大值是确定,我们保存数据的大小最好不要超过最大值的1/8.

LruCache<String, Object> mCache = new LruCache<>( 10 * 1024*1024);

写入数据我们使用比较简单:

@Override
public void put(String key, String value) {
   mCache.put(key, value);
}

好比上面写入String类型的数据,只需要接收到的数据全部put到mCache中去。


读取数据也是比较简单方便:

@Override
public String getString(String key) {
   return String.valueOf(mCache.get(key));
}

持久化数据

那就是sqlite、file等方式。将需要传递的数据写在临时文件或者数据库中,再跳转到另外一个组件的时候再去读取这些数据信息,这种处理方式会由于读写文件较为耗时导致程序运行效率较低。这种方式特点如下:


优势:


(1)应用中全部地方均可以访问


(2)即便应用被强杀也不是问题了


缺点:


(1)操做麻烦


(2)效率低下


匿名共享内存

在跨进程传递大数据的时候,我们一般会采用binder传递数据,但是Binder只能传递1M一下的数据,所以我们需要采用其他方式完成数据的传递,这个方式就是匿名共享内存。


Anonymous Shared Memory 匿名共享内存」是 Android 特有的内存共享机制,它可以将指定的物理内存分别映射到各个进程自己的虚拟地址空间中,从而便捷的实现进程间内存共享。


Android 上层提供了一些内存共享工具类,就是基于 Ashmem 来实现的,比如 MemoryFile、 SharedMemory。



今日分享到此结束,对你有帮助的话,点个赞再走呗,每日一个面试小技巧




关注公众号:Android老皮

解锁  《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版



内容如下



1.Android车载应用开发系统学习指南(附项目实战)

2.Android Framework学习指南,助力成为系统级开发高手

3.2023最新Android中高级面试题汇总+解析,告别零offer

4.企业级Android音视频开发学习路线+项目实战(附源码)

5.Android Jetpack从入门到精通,构建高质量UI界面

6.Flutter技术解析与实战,跨平台首要之选

7.Kotlin从入门到实战,全方面提升架构基础

8.高级Android插件化与组件化(含实战教程和源码)

9.Android 性能优化实战+360°全方面性能调优

10.Android零基础入门到精通,高手进阶之路


作者:派大星不吃蟹
链接:https://juejin.cn/post/7264503091116965940
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

三分钟教会你微信炸一炸,满屏粑粑也太可爱了!

相信这个特效你和你的朋友(或对象)一定玩过 当你发送一个便便的表情,对方如果扔一个炸弹表情,就会立刻将这个便便炸开,实现满屏粑粑的“酷炫”画面。可谓是“臭味十足”,隔着屏幕都能感受到来自微信爸爸的满满恶意。 不清楚大家对这个互动设计怎么看,反正一恩当时是喜欢的...
继续阅读 »

相信这个特效你和你的朋友(或对象)一定玩过


当你发送一个便便的表情,对方如果扔一个炸弹表情,就会立刻将这个便便炸开,实现满屏粑粑的“酷炫”画面。可谓是“臭味十足”,隔着屏幕都能感受到来自微信爸爸的满满恶意。


不清楚大家对这个互动设计怎么看,反正一恩当时是喜欢的不行,拉着朋友们就开始“炸”得不亦乐乎。


同样被虏获芳心的设计小哥哥在玩到尽兴后,突然灵感大发,连夜绘制出了设计稿,第二天就拉上产品和研发开始脑暴。


“微信炸💩打动我的一点是他满屏的设计,能够将用户强烈的情绪抒发出来;同时他能够与上一条表情进行捆绑,加强双方的互动性。”设计小哥哥声情并茂道。


“所以,让我们的表情也‘互动’起来吧!”


这不,需求文档就来了:



改掉常见的emoji表情发送方式,替换为动态表情交互方式。即,

当用户发送或接收互动表情,表情会在屏幕上随机分布,展示一段时间后会消失。

用户可以频繁点击并不停发送表情,因此屏幕上的表情是可以非常多且重叠的,能够将用户感情强烈抒发。


(暂用微信的聊天界面进行解释说明,图1为原样式,图2是需求样式)



这需求一出,动态表情在屏幕上的分布方案便引起了研发内部热烈讨论:当用户点击表情时,到底应该将表情放置在屏幕哪个位置比较好呢?


最直接的做法就是完全随机方式:取0到屏幕宽度和高度中随机值,放置表情贴纸。但这么做的不确定因素太多,比如存在一定几率所有表情都集中在一个区域,布局边缘化以及最差的重叠问题。因此简单的随机算法对于用户的体验是无法接受的。




我们开始探索新的方案:

因为目前点的选择依赖于较多元素,比如与屏幕已有点的间距,与中心点距离以及屏幕已有点的数目。因此最终决定采用点权随机的方案,根据上述元素决策出屏幕上可用点的优度,选取优度最高的插入表情。


基本思路


维护对应屏幕像素的二维数组,数组元素代指新增图形时,图形中心取该点的优度。

采用懒加载的方式,即每次每次新增图形后,仅记录现有方块的位置,当需要一个点的优度时再计算。


遍历所有方块的位置,将图形内部的点优度全部减去 A ,将图形外部的点按到图形的曼哈顿距离从 0 到 max (W,H),映射,减 0 到 A * K2。

每次决策插入位置时,随机取 K + n * K1 个点,取这些点中优度最高的点为插入中心

A, K, K1, K2 四个常数可调整



一次选择的复杂度是 n * randT,n 是场上方块数, randT 是本次决策需要随机取多少个点。 从效率和 badcase来说,这个方案目前最优。





代码展示


```cpp
#include <iostream>
#include <vector>
using namespace std;

const int screenW = 600;
const int screenH = 800;

const int kInnerCost = 1e5;
const double kOuterCof = .1;

const int kOutterCost = kInnerCost * kOuterCof;

class square
int x1;
int x2;
int y1;
int y2;
};

int lineDist(int x, int y, int p){
if (p < x) {
return x - p;
} else if (p > y) {
return p - y;
} else {
return 0;
}
}

int getVal(const square &elm, int px, int py){
int dx = lineDist(elm.x1, elm.x2, px);
int dy = lineDist(elm.y1, elm.y2, py);
int dist = dx + dy;
constexpr int maxDist = screenW + screenH;
return dist ? ( (maxDist - dist) * kOutterCost / maxDist ) : kInnerCost;
}

int getVal(const vector<square> &elmArr, int px, int py){
int rtn = 0;
for (auto elm:elmArr) {
rtn += getVal(elm, px, py);
}
return rtn;
}

int main(void){

int n;
cin >> n;

vector<square> elmArr;
for (int i=0; i<n; i++) {
square cur;
cin >> cur.x1 >> cur.x2 >> cur.y1 >> cur.y2;
elmArr.push_back(cur);
}


for (;;) {
int px,py;
cin >> px >> py;
cout << getVal(elmArr, px, py) << endl;
}

}

优化点

  1. 该算法最优解偏向边缘。因此随着随机值设置越多,得出来的点越偏向边缘,因此随机值不能设置过多。
  2. 为了解决偏向边缘的问题,每一个点在计算优度getVal需要加上与屏幕中心的距离 * n * k3

效果演示


最后就是给大家演示一下最后的效果啦!


圆满完成任务,收工,下班!


作者:李一恩
链接:https://juejin.cn/post/7257410685118677048
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么App独立开发最好别做日记、记账

我在前几天发了条帖子说(新人)独立开发选择笔(日)记、记账、Todo主流三件套之一等于加速死亡。引发了一点点🤏🏻争议。刚好我在选择 app 方向的时候也深思过这个问题,所以我展开讲讲,分享一下我的想法。 认清自己的定位 在独立开发刚开始做,羽翼未丰的时候,你对...
继续阅读 »

我在前几天发了条帖子说(新人)独立开发选择笔(日)记、记账、Todo主流三件套之一等于加速死亡。引发了一点点🤏🏻争议。刚好我在选择 app 方向的时候也深思过这个问题,所以我展开讲讲,分享一下我的想法。


认清自己的定位


在独立开发刚开始做,羽翼未丰的时候,你对自己的定位就应该是一个游击队队员。这也是独立开发的天然优势,因为小,所以灵活机动。游击战的核心奥义是流动性和速决性。因为你没有一个根据地需要防守,你可以找到一个薄弱的地方进攻。因为这个地方薄弱,所以你集中优势兵力可以很快的决胜。因为你小,所以赚到一笔是一笔。因为你是在边缘薄弱的地方建立的优势,这个小细分市场的利润不足以吸引大部队来,你就有了自己的根据地。你想想红军的根据地都是在什么地方,都是在两省交界的山里。可没听说根据地在上海、北京的。


举个例子,一个小业务,几个月工作量,可以有10万利润。对于一个互联网团队是看不上的。一个完整的互联网团队,一个CEO,一个产品,一个设计,两个研发(前端+后端),一个测试,加上行政,加上公司的运营费用。这一套组合拳摊下来,为了组织的长期利益,短期的一个20万利润的项目他们是看不上的。但是如果你是独立开发,两个人搞两个月,赚10万,你做不做?或者说你能做吗?你能做的,只是赚的不多。但是公司这个事情就没法做。这就是大象踩不死蚂蚁的道理。有人觉得就算两个人两个月赚10万,一个月人均25000也不是什么大钱。确实不是很多的钱,但这也是独立开发普遍心态上的一个问题:你想要的太多。平民独立开发早期最关键的是找到一个持续赚钱的方式,你有实力让自己活下来,后期就可以慢慢发展。而不是一上来就觉得一年要赚100万,然后以一个游击队的姿态去攻打大城市。你要赚大钱,就要有对应的能力。但是很多起步的独立开发者并没有这样的能力,那就是一个不匹配的目标了。


局部创新在笔记领域不是决定性的优势


很多人觉得我做笔记,我有一个想法,我有一个痛点,市面上的笔记没有。我进行了一个局部创新,可能是更好看一点点的设计,或者一个特别的笔记格式。我有一个局部优势,我可以做,我入场。


第一个问题:某些品类的局部优势没有决定性。换一句话说,也许你的确解决了一个其他笔记没有的痛点,但是这个不能转化成你的产品整体优势。不足以转化成足够的收入。


如果你解决了一个普遍痛点,一个高商业价值的痛点,有什么理由现有的成熟笔记app不做呢?请问你作为一个独立开发者开发这个功能要做多久?你有没可能一年里没有任何风声开发一个杀手功能,出来的时候瞬间占领用户心智,用户蜂拥而来?在一个成熟品类里,这样很难的。诺曼底登陆不是游击队能做到的。你要是有这个能力和信心,你就不应该做独立开发,你应该成立公司高举高打。真实的现状是如果你的一个功能受欢迎,成熟的笔记团队花一两个月也就做出来了。


而且笔记类还有一个特点:他有数据积累。用户价值=新体验-旧体验-替换成本。假设你的新体验确实有优势,大于现有产品的旧体验,但是笔记类的替换成本是很高的。我已经在这里积累了好几年的数据,一个新的、个人的、上线没多久的数据积累类,替换成本是很高的。我需要同时愿意抛弃我的旧数据,还需要对你建立信任。 所以除非领头的app犯错误,否则后来的小app是没有机会超过他的。因为他有先发优势,他可以在看到你之后,完善自己,提高自己的旧体验。你不能静态的认为头部的成功的app会停在那里。


成熟品类的高入局门槛


笔记类app自 AppStore 开始有以来,就有人做了。这意味着在你的 app 上线之前,高付费意愿的用户已经付费购买过了。这意味着,如果你要获得存量用户,你需要超越现有品类的成功 app,你需要有更好的整体体验,你还需要有足够全面的运营能力。让这些买了其他 app 的用户愿意选择你。你做笔记 app,一上线,真正的笔记用户心里已经有一个对标的门槛了。本来你在农村盖个楼,是个一层土楼也可以,是个木楼也行,大家都认可是个楼。你在市中心盖一个土楼,大家就会觉得你简陋,功能不全了。用户心智里已经把这个品类的成熟应用当做了一个基线。


再说新的用户。新的用户如果要用一个笔记,上 AppStore 搜,上社交平台搜,肯定看到的大量都是成熟 app 的推荐。他们本来就是历经时代活下来的,自然是得到了用户的认可。既然存在了这么久,自然知道他们的人就多。所以你在自然新增流量上也很劣势。


所以你做一个成熟品类,意味着你要成功,你就必须有一个极具说服力的产品优势,加足够好的产品质量,加足够高效的运营。


我有两个理财项目,一个项目利润年化10%,一个项目利润年后1%,你选哪个?你既然都是在做新产品,为什么要选一个更难的赛道?是你觉得自己命中注定要开发一个笔记app,还是你贫瘠的想象力只想到了做笔记?你确定是在100个产品评估出的最好的方向是笔记?


对比一个新市场,比如最近很火的套壳的 gpt 应用。这是一个全新品类,意味着用户心里是没有对标品的。当用户下载你的 app 时,他不会期待你应该有怎样的功能,他没有对比的对象。这个品类里因为没有头部应用,大家搜索的时候也就看到什么下什么。你会有自然新增流量。因为大家都是差不多时间起步的,其他app不会领先很多,意味着你也有可能建立产品优势,至少你没什么太大的劣势,更有希望建立用户口碑。


长线和短线


投资理财主要有两个流派:长线和短线。长线就关注这个企业未来长期的发展,关注企业的价值。如果这个企业是低估的,就可以持有,因为他们评估出未来这个企业会成长。短期的波动对他们并不构成干扰。大概就是大家口中说的价值投资吧。还有一类是短线,他们不关注未来的情况,买入一只股票只关心这只股票下个月会不会涨,明天会不会涨。因此这个股票的已经100倍PE对他们也没影响,后面有人接盘就可以了。量化交易大多是这样的短线逻辑,于是他们有着高换手率。


长线和短线都是合理的策略。最大的问题是,你不能用抱着长线的心态做短线。这大概就是接盘股民的心态吧,他们持有一只股票的时候觉得这个企业未来会成长。但是短期市场遇冷跌了20%,看到很多人抛了,他们就觉得受不了了,于是割肉离场。买的时候听的是做长线人的意见,卖的时候是跟着做短线的人卖的。


把这个逻辑放到 app 上也是这样的。笔记 app 是一个长线价值,越到后面越值钱,做的越久产品优势越大,用户粘性越高。但是你抱着短线的心态进来做,做了4 个月,收入用户都没起色,你怀疑自己,团队开始有意见,于是你就放弃了。这就是问题所在了,大多数独立开发者没有耐心在一个赛道持续亏损做两年,不具备做长线的心态和财力。


所以我建议独立开发起步的时候多关注短线价值。就是你投入三五个月,能有起色的方向。做三五个月,要不就要赚钱,要不就要有用户口碑。一鸟在手胜过二鸟在林。等你解决了起步的时候生存问题,再考虑农村包围城市的问题。


谈谈番茄钟


也有人觉得番茄钟是独立开发的重灾区。虽然番茄钟似乎是一个红海了,但是我却觉得番茄钟反而是一个可以做的赛道。不知道大家有没有留意一下这个有这个功能的app,似乎万物都可以番茄钟。谜底时钟有番茄钟,滴答清单有番茄钟。番茄钟在设计上可以极简,可以是我的番茄,可以是小鸡,可以是面条,可以是像素。


image.png
IMG\_5978.png
IMG\_5979.png
IMG\_5980.png

那么为什么番茄钟可以呢?


番茄钟替换成本低。因为是及时性的工具类,历史积累的番茄时间统计并不太重要。在功能操作上也很简单,核心交互就是点击一下开始计时,25分钟后提醒你要休息5分钟。很容易可以完成一个基础任何。


设计差异化可以成为付费点,市场容量大。 因为交互很简单,所以做出突出的设计成本可以接受。因为这个品类里,设计可以成为卖点,设计又是一个各有所好的事情,因此天然会有很多不同的需求。某个番茄钟可能做的很好用,很好看,但是他不可能吃掉全部用户。一件短袖设计的再好看,也不可能让所有人都买单。


由此我们可以得出一个结论:番茄钟有短线价值。并且番茄钟引入养成和成就体系以后,有可能变成一个长线产品。如果你非要卷,去卷番茄钟吧。


最后


地上有两张钱,一张100,一张10块,你先捡哪张?我想结果是不言而喻的。独立开发者的一个生态位优势就是可以在一个小领域建立极高的人效比。这个小领域有两个可能:一个是这个领域太小太细分,只能容得下小团队做(高人效);这个领域是新的,还没人知道这里行不行,于是小成本的独立开发先做了(高灵活)。建议各位独立开发者如果要做死亡加速三件套的产品的话三思而后行。


作者:没故事的卓同学
来源:juejin.cn/post/7265967971162898487
收起阅读 »

使用 AndroidX 增强 WebView 的能力

在应用开发过程中,为了在多个平台上保持一致的用户体验和提高开发效率,许多应用程序选择使用 H5 技术。在 Android 平台上,通常使用 WebView 组件来承载 H5 内容以供展示。 WebView 存在的问题 自 Android Lollipop 起,...
继续阅读 »

在应用开发过程中,为了在多个平台上保持一致的用户体验和提高开发效率,许多应用程序选择使用 H5 技术。在 Android 平台上,通常使用 WebView 组件来承载 H5 内容以供展示。


WebView 存在的问题


自 Android Lollipop 起,WebView 组件的升级已经独立于 Android 平台。然而,控制 WebView 的 API(android.webkit) 仍然与平台升级相关。这意味着应用开发者只能使用当前平台所定义的接口,而无法充分利用 WebView 的全部能力。例如: WebView.startSafeBrowsing API 在 Android 8.1 上被添加,该 Feature 由 WebView 提供,即使我们在 Android 7.0 更新 WebView 拥有了该 Feature ,由于 Android 7.0 没有 WebView.startSafeBrowsing API ,我们也没办法使用该功能。


WebView 的实现基于 Chromium 开源项目,而 Android 则基于 AOSP 项目,这两个项目有着不同的发布周期,WebView 往往一个月就可以推出下一个版本,而 Android 则需要一年的时间,对于 WebView 新增的 Feature 我们最迟需要一年才能使用。


AndroidX Webkit 的出现


为了解决上面平台能力和 WebView 不匹配的问题,我们可以独立于平台之外定义一套 WebView API ,并让它随着 WebView 的 Feature 更新 API ,这样解决了现有的问题却导入了另一个问题——如何将新定义的 WebView API 和 WebView 进行衔接。


从应用开发的角度,系统 WebView 难以修改,自己编译定制一个 WebView 并随着 apk 提供是一个很好方案。这时候,我们可以轻松的解决衔接问题,并能够按照需求,任意增改 Feature 而不必等官方更新。同时解决了兼容问题和 WebView 内核碎片化的问题。腾讯 X5 ,UC U4 等都是这个方案。维护一份 WebView 并不是一件容易的事,需要投入更多的人力支持,因为将 WebView 打入包中,还伴随着包体积的急剧增加。


从 Android 官方的角度,可以推动 WebView 上游支持该 WebView API , 而这正是 AndroidX Webkit 的解决方案。Android 官方将定义的 WebView API 放置到 AndroidX Webkit 库,以支持频繁的更新,并在 WebView 上游增加“胶水层”与 AndroidX Webkit 进行衔接,这样在旧版的 Android 平台上,只要安装了拥有"胶水"层代码的 WebView ,也就拥有了新版平台的功能。



“胶水层” 是在某个版本之后才后才支持的,旧版本的 WebView 内核并不支持,这也是为什么在调用之前始终应该检查 isFeatureSupported 的原因。



AndroidX Webkit 的功能


初步了解了 AndroidX Webkit 的产生和实现原理,下面带领大家看一下它都提供了哪些新能力能够增强我们的 WebView 。


向下兼容


如上文分析,AndroidX Webkit 提供了向下的兼容,如下面代码所示,由 WebViewCompat 提供兼容的接口调用。



需要注意的是在调用之前对 WebViewFeature 的检查,对于每个 Feature ,AndroidX Webkit 会取平台和 WebView 所提供 Feature 的并集 ,在调用某个 API 之前必须进行检查,如果平台和 WebView 均不支持该API则将抛出 UnsupportedOperationException 异常。

// Old code:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
   WebView.startSafeBrowsing(appContext, callback);
}

// New code:
if (WebViewFeature.isFeatureSupported(WebViewFeature.START_SAFE_BROWSING)) {
   WebViewCompat.startSafeBrowsing(appContext, callback);
}

如果我们扒开 WebViewCompat 的外衣查看他的源码(如下所示),会发现如果在当前版本 Platform API 提供了接口,就会直接调用 Platform API 的接口,而对于低版本,则由 AndroidX Webkit 和 WebView 的"通道"提供服务。

// WebViewCompat#startSafeBrowsing
public static void startSafeBrowsing(@NonNull Context context,
@Nullable ValueCallback<Boolean> callback) {
ApiFeature.O_MR1 feature = WebViewFeatureInternal.START_SAFE_BROWSING;
if (feature.isSupportedByFramework()) {
ApiHelperForOMR1.startSafeBrowsing(context, callback);
} else if (feature.isSupportedByWebView()) {
getFactory().getStatics().initSafeBrowsing(context, callback);
} else {
throw WebViewFeatureInternal.getUnsupportedOperationException();
}
}

对比上面的代码,使用平台 API(old code)时仅可以支持 90% 的用户,而使用 AndroidX Webkit(new code) 则可以覆盖大约 99% 的用户。


代理功能支持


一直以来WebView 的代理设置异常繁琐,当遇到复杂的代理规则就无能为力了。在 AndroidX Webkit 中增加了 ProxyController API 用于为 WebView 设置代理。ProxyConfig.Builder 类提供了设置代理以及配置代理的绕过方式等方法,通过组合可以满足复杂的代理场景。

if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyConfig proxyConfig = new ProxyConfig.Builder()
.addProxyRule("localhost:7890") //添加要用于所有 URL 的代理
.addProxyRule("localhost:1080") //优先级低于第一个代理,仅在上一个失败时应用
.addDirect() //当前面的代理失败时,不使用代理直连
.addBypassRule("www.baidu.com") //该网址不使用代理,直连服务
.addBypassRule("*.cn") //以.cn结尾的网址不使用代理
.build();
Executor executor = ...
Runnable listener = ...
ProxyController.getInstance().setProxyOverride(proxyConfig, executor, listener);

以上代码定义了一个复杂的代理场景,我们为 WebView 设置了两个代理服务器,localhost:1080 仅当 localhost:7890 失败的情况下启用,addDirect 声明了如果两个服务器都失败则直连服务器,addBypassRule 规定了 http://www.baidu.com 和以 .so 结尾的域名始终不应该使用代理。


白名单代理


如果仅有少量的 URL 需要配置代理,我们可以使用 setReverseBypassEnabled(true) 方法将addBypassRule 添加的 URL 转变为使用代理服务器,而其他的 URL 则直连服务。


安全的 WebView 和 Native 通信支持


建立 WebView 和 Native 的双向通信是使用 Hybrid 混合开发模式的基础,在之前 Android 已经提供了一些机制能够让完成基本的通信,但是已有的接口都存在一些安全和性能问题,在 AndroidX 中增加了一个功能强大的接口 addWebMessageListener 兼顾了安全和性能等问题。


代码示例中将 JavaSript 对象 replyObject 注入到匹配 allowedOriginRules的上下文中,这样只有在可信的网站中才能被使用此对象,也就防止了不明来源的网络攻击者对该对象的利用。

// App (in Java)
WebMessageListener myListener = new WebMessageListener() {
  @Override
  public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin,
           boolean isMainFrame, JavaScriptReplyProxy replyProxy) {
    // do something about view, message, sourceOrigin and isMainFrame.
    replyProxy.postMessage("Got it!");
  }
};

HashSet<String> allowedOriginRules = new HashSet<>(Arrays.asList("[https://example.com](https://example.com/)"));
// Add WebMessageListeners.

WebViewCompat.addWebMessageListener(webView, "replyObject", allowedOriginRules,myListener);
调用上述方法之后,在 JavaScript 上下文中我们就可以访问 myObject ,调用 postMessage 就可以回调 Native 端的 onPostMessage 方法并自动切换到主线程执行,当 Native 端需要发送消息给 WebView 时,可以通过 JavaScriptReplyProxy.postMessage 发送到 WebView ,并将消息传递给 onmessage 闭包。
// Web page (in JavaScript)
myObject.onmessage = function(event) {
  // prints "Got it!" when we receive the app's response.
  console.log(event.data);
}
myObject.postMessage("I'm ready!");

文件传递


在以往的通讯机制中,如果我们想传递一个图片只能将其转换为 base64 等进行传输,如果曾经使用过 shouldOverrideUrlLoading 拦截 url 大概率会遇见传输瓶颈,AndroidX Webkit 中很贴心的提供了字节流传递机制。


Native 传递文件给 WebView

// App (in Java)
WebMessageListener myListener = new WebMessageListener() {
  @Override
  public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin,
           boolean isMainFrame, JavaScriptReplyProxy replyProxy) {
    // Communication is setup, send file data to web.
    if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
      // Suppose readFileData method is to read content from file.
      byte[] fileData = readFileData("myFile.dat");
      replyProxy.postMessage(fileData);
    }
  }
}
// Web page (in JavaScript)
myObject.onmessage = function(event) {
  if (event.data instanceof ArrayBuffer) {
    const data = event.data;  // Received file content from app.
    const dataView = new DataView(data);
    // Consume file content by using JavaScript DataView to access ArrayBuffer.
  }
}
myObject.postMessage("Setup!");

WebView 传递文件给 Native

// Web page (in JavaScript)
const response = await fetch('example.jpg');
if (response.ok) {
    const imageData = await response.arrayBuffer();
    myObject.postMessage(imageData);
}
// App (in Java)
WebMessageListener myListener = new WebMessageListener() {
  @Override
  public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin,
           boolean isMainFrame, JavaScriptReplyProxy replyProxy) {
    if (message.getType() == WebMessageCompat.TYPE_ARRAY_BUFFER) {
      byte[] imageData = message.getArrayBuffer();
      // do something like draw image on ImageView.
    }
  }
};

深色主题的支持


Android 10 提供了深色主题的支持,但是在 WebView 中显示的网页却不会自动显示深色主题, 这就表现出严重的割裂感,开发者只能通过修改 css 来达到目的,但这往往费时费力还存在兼容性问题,Android 官方为了改善这一用户体验,为 WebView 提供了深色主题的适配。


一个网页如何表现是和prefers-color-scheme and color-scheme 这两个 Web 标准互操作的。 Android官方提供了一张表阐述了他们之间的关系。


上面这张图比较复杂,简单来说如果你想让 WebView 的内容和应用的主题相匹配,你应该始终定义深色主题并实现 prefers-color-scheme ,而对于未定义 prefers-color-scheme 的页面,系统按照不同的策略选择算法生成或者显示默认页面。



以 Android 12 或更低版本为目标平台的应用 API 设计过于复杂,以 Android 13 或更高版本为目标平台的应用精简了 API ,具体变更请参考官方文档



JavaScript and WebAssembly 执行引擎支持


我们有时候我们会在程序中运行 JavaScript 而不显示任何 Web 内容,比如小程序的逻辑层,使用 WebView 本能够满足我们的要求但是浪费了过多的资源,我们都知道在 WebView 中真正负责执行 JavaScript 的引擎是 V8 ,但是我们又无法直接使用,所以我们的安装包中出现了各种各样的引擎:HermesJSCV8等。


Android 发现了这”群雄割据“的局面,推出了AndroidX JavascriptEngine,JavascriptEngine 直接使用了 WebView 的 V8 实现,由于不用分配其他 WebView 资源所以资源分配更低,并可以开启多个独立运行的环境,还针对传递大量数据做了优化。


代码展示了执行 JavaScript 和 WebAssembly 代码的使用:

if(!JavaScriptSandbox.isSupported()){
return;
}
//连接到引擎
ListenableFuture<JavaScriptSandbox> jsSandboxFuture =
JavaScriptSandbox.createConnectedInstanceAsync(context);
//创建上下文 上下文间有简单的数据隔离
JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();
//执行函数 && 获取结果
final String code = "function sum(a, b) { let r = a + b; return r.toString(); }; sum(3, 4)";
ListenableFuture<String> resultFuture = jsIsolate.evaluateJavaScriptAsync(code);
String result = resultFuture.get(5, TimeUnit.SECONDS);
Futures.addCallback(resultFuture,
new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
text.append(result);
}
@Override
public void onFailure(Throwable t) {
text.append(t.getMessage());
}
},
mainThreadExecutor); //Wasm运行
final byte[] hello_world_wasm = {
0x00 ,0x61 ,0x73 ,0x6d ,0x01 ,0x00 ,0x00 ,0x00 ,0x01 ,0x0a ,0x02 ,0x60 ,0x02 ,0x7f ,0x7f ,0x01,
0x7f ,0x60 ,0x00 ,0x00 ,0x03 ,0x03 ,0x02 ,0x00 ,0x01 ,0x04 ,0x04 ,0x01 ,0x70 ,0x00 ,0x01 ,0x05,
0x03 ,0x01 ,0x00 ,0x00 ,0x06 ,0x06 ,0x01 ,0x7f ,0x00 ,0x41 ,0x08 ,0x0b ,0x07 ,0x18 ,0x03 ,0x06,
0x6d ,0x65 ,0x6d ,0x6f ,0x72 ,0x79 ,0x02 ,0x00 ,0x05 ,0x74 ,0x61 ,0x62 ,0x6c ,0x65 ,0x01 ,0x00,
0x03 ,0x61 ,0x64 ,0x64 ,0x00 ,0x00 ,0x09 ,0x07 ,0x01 ,0x00 ,0x41 ,0x00 ,0x0b ,0x01 ,0x01 ,0x0a,
0x0c ,0x02 ,0x07 ,0x00 ,0x20 ,0x00 ,0x20 ,0x01 ,0x6a ,0x0b ,0x02 ,0x00 ,0x0b,
};
final String jsCode = "android.consumeNamedDataAsArrayBuffer('wasm-1').then(" +
"(value) => { return WebAssembly.compile(value).then(" +
"(module) => { return new WebAssembly.Instance(module).exports.add(20, 22).toString(); }" +
")})";
boolean success = js.provideNamedData("wasm-1", hello_world_wasm);
if (success) {
FluentFuture.from(js.evaluateJavaScriptAsync(jsCode))
.transform(this::println, mainThreadExecutor)
.catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
} else {
// the data chunk name has been used before, use a different name
}

更多支持


AndroidX Webkit 是一个功能强大的库,由于篇幅原因上文将开发者比较常用的功能进行了列举,AndroidX 还提供对 WebView 更精细化的控制,对 Cookie 的便捷访问、对 Web 资源的便捷访问,对 WebView 性能的收集,还有对大屏幕的支持等等强大的 API,大家可以查看发布页面查看最新的功能。


写在最后


本文从实际矛盾出发,带领大家思考 AndroidX Webkit 的产生原因和实现原理,对于AndroidX Webkit 的几个功能分别做了简单的介绍,希望大家能在这篇文章获得一点启发和帮助。


作者:简绘Android
链接:https://juejin.cn/post/7259762775365320741
来源:稀土掘金
收起阅读 »

我的又一个神奇的框架——Skins换肤框架

为什么会有换肤的需求 app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。 换肤是什么 换...
继续阅读 »

为什么会有换肤的需求


app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。


换肤是什么


换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。


Skins怎么使用


Skins就是一个解决这样一种换肤需求的框架。

// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}

我以更换皮肤颜色为例,打开res/colors.xml。

<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>
将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。
然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。
private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}

另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。

val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")
这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。
SkinLoader还提供了更简洁设置View颜色的方法。
override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}

override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}

override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}

框架原理解析

先看BaseSkinActivity的源码。

package dora.skin.base

import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*

abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {

private val constructorArgs = arrayOfNulls<Any>(2)

override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}

private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}

private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}

@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}

override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}

override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}

override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}

companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}
我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。
package dora.lifecycle.application

import android.app.Application
import android.content.Context
import dora.skin.SkinManager

class SkinsAppLifecycle : ApplicationLifecycleCallbacks {

override fun attachBaseContext(base: Context) {
}

override fun onCreate(application: Application) {
SkinManager.init(application)
}

override fun onTerminate(application: Application) {
}
}
所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。
/**
* 从xml的属性集合中获取皮肤相关的属性。
*/
fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}

我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。

package dora.skin.attr

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager

enum class SkinAttrType(var attrType: String) {

/**
* 背景属性。
*/
BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},

/**
* 字体颜色。
*/
TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},

/**
* 图片资源。
*/
SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};

abstract fun apply(view: View, resName: String)

/**
* 获取资源管理器。
*/
val loader: SkinLoader
get() = SkinManager.getLoader()
}

当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。


开源项目传送门


如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dview…] 。

作者:dora
链接:https://juejin.cn/post/7258483700815609916
来源:稀土掘金
收起阅读 »

工信部又出新规!爬坑指南

一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
继续阅读 »

一、背景


工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


二、整改


2.1 个人信息保护


2.1.1 基本模式(无权限、无个人信息获取模式)


这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


这个说法有点抽象,我们来看下友商已经做好的案例。


腾讯视频


从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


网易云音乐


网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


2.1.2 隐私政策内容


如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


2.2 app权限调用


2.2.1 应用内权限调用

  • 获取定位信息和生物特征识别信息

在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。


如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。

其他权限

其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图


2.3 应用软件升级


2.3.1 更新


应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图

2.4 应用签名

需要保证签名的真实有效性。

作者:付十一
链接:https://juejin.cn/post/7253610755126476857
来源:稀土掘金
收起阅读 »

使用RecyclerView实现三种阅读器翻页样式

一、整体逻辑 为何直接对RecyclerView进行扩展而不使用ViewPager/ViewPager2?原因如下: Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附) Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以...
继续阅读 »

一、整体逻辑


image.png


为何直接对RecyclerView进行扩展而不使用ViewPager/ViewPager2?原因如下:



  1. Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附)

  2. Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以实现更好的控制

  3. RecyclerView方便拓展,同时三种模式同时使用RecyclerView实现,便于复用


实现逻辑:三种滑动模式都在RecyclerView地基础上更改其滑动行为,横向滑动需要修改子View层级,仿真翻页需要再覆盖一层仿真动画


二、横向覆盖滑动(Slide Mode)


横向.gif


Slide Mode 最适合直接使用 ViewPager,不过我们还是以 RecyclerView 为基础来实现,让三种模式统一实现方式。实现思路:先实现跨页吸附,再实现覆盖翻页效果


1、跨页吸附


实现跨页吸附,需要在手指离开屏幕时对 RecyclerView 进行复位吸附操作,有两种情况:


(1)Scroll Idle


拖拽发生后,RecyclerView 滑动状态变为 SCROLL_STATE_IDLE 时,需要进行复位吸附操作


// OrientationHelper为系统提供的辅助类,LayoutManager的包装类
// 可以让我们方便的计算出RecyclerView相关的各种宽高,计算结果和LayoutManager方向相关
open fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
val lm = mRecyclerView.layoutManager ?: return null
val childCount = lm.childCount // 可见数量
if (childCount < 1) return null

var closestChild: View? = null
var absClosest = Int.MAX_VALUE
var scrollDistance = 0
// RecyclerView中心点,LayoutManager为竖向则是Y轴坐标,为横向则是X轴坐标
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

// 从可见Item中找到距RecyclerView离中心最近的View
for (i in 0 until childCount) {
val child = lm.getChildAt(i) ?: continue
if (consumeSnap(i, child)) return null // consumeSnap 默认返回false,竖直滑动模式才使用
val childCenter = (helper.getDecoratedStart(child)
+ helper.getDecoratedMeasurement(child) / 2)
val absDistance = abs(childCenter - containerCenter)
if (absDistance < absClosest) {
absClosest = absDistance
closestChild = child
scrollDistance = childCenter - containerCenter
}
}
closestChild ?: return null

// 滑动
when (orientation) {
VERTICAL -> mRecyclerView.smoothScrollBy(0, scrollDistance)
HORIZONTAL -> mRecyclerView.smoothScrollBy(scrollDistance, 0)
}
return Pair(scrollDistance, lm.getPosition(closestChild))
}


(2)Fling


可以通过 RecyclerView 提供的OnFlingListener消费掉Fling,将其转化为 SmoothScroll ,滑动到指定位置


①、找到吸附目标的位置(adapter position)


open fun findTargetSnapPosition(
lm: RecyclerView.LayoutManager,
velocity: Int,
helper: OrientationHelper
)
: Int {
val itemCount: Int = lm.itemCount
if (itemCount == 0) return RecyclerView.NO_POSITION

// 中心点以前距离最近的View
var closestChildBeforeCenter: View? = null
var distanceBefore = Int.MIN_VALUE // 中心点以前,距离为负数
// 中心点以后距离最近的View
var closestChildAfterCenter: View? = null
var distanceAfter = Int.MAX_VALUE // 中心点以后,距离为正数
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

val childCount: Int = lm.childCount
for (i in 0 until childCount) {
val child = lm.getChildAt(i) ?: continue
if (consumeSnap(i, child)) return RecyclerView.NO_POSITION // consumeSnap 默认返回false,竖直滑动模式才使用

val childCenter = (helper.getDecoratedStart(child)
+ helper.getDecoratedMeasurement(child) / 2)
val distance = childCenter - containerCenter

// Fling需要考虑方向,先获取两个方向最近的View
if (distance in (distanceBefore + 1)..0) {
distanceBefore = distance
closestChildBeforeCenter = child
}
if (distance in 0 until distanceAfter) {
distanceAfter = distance
closestChildAfterCenter = child
}
}

// 根据方向选择Fling到哪个View
val forwardDirection = velocity > 0
if (forwardDirection && closestChildAfterCenter != null) {
return lm.getPosition(closestChildAfterCenter)
} else if (!forwardDirection && closestChildBeforeCenter != null) {
return lm.getPosition(closestChildBeforeCenter)
}

// 边界情况处理
val visibleView =
(if (forwardDirection) closestChildBeforeCenter else closestChildAfterCenter)
?: return RecyclerView.NO_POSITION
val visiblePosition: Int = lm.getPosition(visibleView)
val snapToPosition = (visiblePosition - 1)

return if (snapToPosition < 0 || snapToPosition >= itemCount) {
RecyclerView.NO_POSITION
} else snapToPosition
}

②、使用RecyclerView的「LinearSmoothScroller」完成吸附动画


private fun createScroller(
oh: OrientationHelper
)
: LinearSmoothScroller {
return object : LinearSmoothScroller(mRecyclerView.context) {
override fun onTargetFound(
targetView: View,
state: RecyclerView.State,
action: Action
)
{
val d = distanceToCenter(targetView, oh)
val time = calculateTimeForDeceleration(abs(d))
if (time > 0) {
when (orientation) {
VERTICAL -> action.update(0, d, time, mDecelerateInterpolator)
HORIZONTAL -> action.update(d, 0, time, mDecelerateInterpolator)
}
}
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics) =
100f / displayMetrics.densityDpi

override fun calculateTimeForScrolling(dx: Int) =
100.coerceAtMost(super.calculateTimeForScrolling(dx))
}
}

protected fun distanceToCenter(targetView: View, helper: OrientationHelper): Int {
val childCenter = (helper.getDecoratedStart(targetView)
+ helper.getDecoratedMeasurement(targetView) / 2)
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2
return childCenter - containerCenter
}

完整操作:


protected fun snapFromFling(
lm: RecyclerView.LayoutManager,
velocity: Int,
helper: OrientationHelper
)
: Pair<Boolean, Int> {
val targetPosition = findTargetSnapPosition(lm, velocity, helper)
if (targetPosition == RecyclerView.NO_POSITION) return Pair(false, 0)
val smoothScroller = createScroller(helper)
smoothScroller.targetPosition = targetPosition
lm.startSmoothScroll(smoothScroller)
return Pair(true, targetPosition) // 消费fling
}

2、覆盖效果实现


(1)如果使用PageTransform实现


如果使用ViewPagerPageTransform,是可以实现覆盖动画的,实现思路:使可见View的第二个View跟随屏幕滑动


image.png


假设上图蓝色透明矩形为屏幕,其他为ItemView,图片上半部分正常滑动的状态,下半部分为 translate view 之后的状态。可以看到,在横向滑动过程中,最多可见2个View(蓝色透明方框最多覆盖2个View),此时将第二个View跟随屏幕,其他View保持跟随画布滑动,即可达到效果。在OnPageScroll回调中实现这个逻辑:


for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.also { view ->
if (i == 1) {
// view.left是个负数,offsetPx(=-view.left)是个正数
view.translationX = offsetPx.toFloat() - view.width // 需要translate的距离(向前移需要负数)
} else {
// 恢复其余位置的translate
view.translationX = 0f
}
}
}

(2)扩展RecyclerView实现覆盖翻页


知道如何通过 PageTransfrom 实现后,我们来看看直接使用 RecyclerView 如何实现。观看ViewPager2源码可知PageTransfrom的实现方式


image.png


故我们直接copy代码,在OnScrollListener中自行实现onPageScrolled回调即可实现覆盖翻页效果。


但是此时还有一个问题,就是子View的层级问题,你会发现上面的滑动示意图中,绿色View会在黄色View之上,如何解决这个问题呢?我们需要控制View的绘制顺序,前面的View后绘制,保证前面地View在后面的View的绘制层级之上。


观看源码会发现,RecyclerView其实提供了一个回调ChildDrawingOrderCallback,可以很方便地实现这个效果:


override fun attach() {
super.attach()
mRecyclerView.setChildDrawingOrderCallback(this)
}

override fun onGetChildDrawingOrder(childCount: Int, i: Int) = childCount - i - 1 // 反向绘制

三、竖直滑动(Scroll Mode)


垂直.gif


竖直滑动需要滑动到跨章的位置时才吸附(自动回滚到指定位置),需要实现两个效果:跨章吸附、跨章Fling阻断。我们可以在横向覆盖滑动(Slide Mode)的基础上做一个减法,首先将LayoutManager改为竖向的,然后实现上述两个效果。


1、跨章吸附


实现跨章吸附,我们先在 RecyclerViewAdapter 中对每个View进行一个标记:


companion object {
const val TYPE_NONE = 100 // 其他
const val TYPE_FIRST_PAGE = 101 // 首页
const val TYPE_LAST_PAGE = 102 // 末页
}


fun bind() { // onBindViewHolder 时调用
itemView.tag = when {
textPage.isLastPage -> TYPE_LAST_PAGE
textPage.isFirstPage -> TYPE_FIRST_PAGE
else -> TYPE_NONE
}
......
}

其次我们实现横向覆盖滑动(Slide Mode)中的一段代码(做一个减法):


// 如果不是章节的最后一页,则消费Snap(不进行吸附操作)
override fun consumeSnap(index: Int, child: View) =
index == 0 && child.tag != ReadBookAdapter.TYPE_LAST_PAGE

这样就可以实现不是跨越章节的翻页不进行吸附,而跨越章节的滑动会自动吸附。


2、跨章Fling阻断


在滑动过程中,基于可见View只有两个的情况:



  • 如果向上滑动,判断第一个可见View是否「末页」,如果是,smoothScroll到第二个可见View

  • 如果向下滑动,判断第二个可见View是否「首页」,如果是,smoothScroll到第一个可见View


private var inFling = false     // 正在fling,在OnFlingListener中设置为true
private var inBlocking = false // 阻断fling


override val mScrollListener = object : RecyclerView.OnScrollListener() {
var mScrolled = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
inFling = false // 重置inFling
}
RecyclerView.SCROLL_STATE_IDLE -> {
inFling = false // 重置inFling
if (inBlocking) {
inBlocking = false // 忽略阻断造成的IDLE
} else if (mScrolled) {
mScrolled = false
snapToTargetExistingView(orientationHelper.value)
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) {
if (!mScrolled) {
this@VSnapHelper.mCallback.onScrollBegin()
mScrolled = true
}
val lm = mRecyclerView.layoutManager ?: return
// fling阻断
if (inFling && !inBlocking) {
val child: View?
val type: Int
if (dy > 0) { // 向上滑动
child = lm.getChildAt(0)
type = ReadBookAdapter.TYPE_LAST_PAGE
} else {
child = lm.getChildAt(lm.childCount - 1)
type = ReadBookAdapter.TYPE_FIRST_PAGE
}
child?.let {
if (it.tag == type) {
inBlocking = true
val d = distanceToCenter(it, orientationHelper.value)
mRecyclerView.smoothScrollBy(0, d)
}
}
}
}
}
}

四、仿真页(Flip Mode)


仿真.gif


仿真页在横向覆盖滑动(Slide Mode)基础之上实现,我们还需要实现:



  1. 确认手指滑动方向

  2. 所有可见View都跟随屏幕

  3. 绘制次序根据拖拽方向改变,保证目标页在当前页之上

  4. 绘制仿真页

  5. 手指抬起后的翻页动画(确认Fling、Scroll Idle产生的两种Snap的方向,因为手指会来回滑动导致方向判断错误)


1、确认手指滑动方向


滑动方向不能直接在 onTouchdispatchTouchEvent 这些方法中直接判断,
因为极微小的滑动都会决定方向,这样会造成轻微触碰就判定了方向,导致页面内容闪动、抖动等问题。
我们需要在滑动了一定距离后确定方向,最好的选择就是在 onPageScroll 中进行判断,系统为我们保证了ScrollState已变为DRAGGING,此时用户100%已经在滑动。可以看下源码真正触发「onPageScroll」的条件有哪些


image.png


我们实现的判断方向的代码:


// 在onScrolled中调用
// mCurrentItem:onPageSelected中赋值,代表当前Item
// position:第一个可见View的位置
// offsetPx:第一个可见View的left取负
// mForward:方向,true为画布向左滑动(向尾部滑动),false画布向右滑动(向头部滑动)
private fun dispatchScrolled(position: Int, offsetPx: Int) {
if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING) {
mForward = mCurrentItem == position
}
mCallback.onPageScrolled(position, mCurrentItem, offsetPx, mForward)
}

image.png


不过这个规则在超快速滑动时会判断错误,即settling直接变dragging的时候,所以会对滑动做一点限制


override fun dispatchTouchEvent(e: MotionEvent): Boolean {
if (snapHelper.mScrollState == RecyclerView.SCROLL_STATE_SETTLING) {
return true // sellting过程中禁止滑动
}
delegate.onTouch(e)
return super.dispatchTouchEvent(e)
}

2、遮盖效果


所有可见View都跟随屏幕,横向覆盖滑动(Slide Mode)的增强版,因为给 RecyclerView设置了 offScreenLimit=1 的效果,所以 LayoutManagerchild 数量最多会有4个
(参照 ViewPager2 # LinearLayoutManagerImpl 实现,这里设置是为了滑动时可以第一时间生成目标页的截图)


// onPageScrolled中调用
private fun transform(offsetPx: Int, firstVisible: Int) {
val count = layoutManager.childCount
if (count == 2 || (count == 3 && offsetPx == 0)) {
// 可见View只有一个的时候,全部复位
for (i in 0 until count) {
layoutManager.getChildAt(i)?.also { view ->
view.translationX = 0f
}
}
} else {
var target = 1
if (count == 3 && firstVisible == 0) target-- // 首位适配,currentItem=0且存在滑动的时候
for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.also { view ->
when (i) {
target -> view.translationX = offsetPx.toFloat()
target + 1 -> view.translationX = offsetPx.toFloat() - view.width
else -> view.translationX = 0f
}
}
}
}
}

3、绘制次序根据拖拽方向改变


保证目标页在当前页之上,防止绘制的仿真页消失时出现闪屏(瞬间显示了不正确的页)


// 画布左移则反向绘制,右移则正想绘制
override fun getDrawingOrder(childCount: Int, i: Int) =
if (snapHelper.mForward) childCount - i - 1 else i

4、绘制仿真页


我们在 RecyclerView 的父View上直接覆盖绘制一层仿真页Bitmap


(1)生成截图


如上面所说,实现了 offScreenLimit=1 的效果,我们在首次获取到方向时生成截图:


// 生成截图方法
fun View.screenshot(): Bitmap? {
return runCatching {
val screenshot = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
val c = Canvas(screenshot)
c.translate(-scrollX.toFloat(), -scrollY.toFloat())
draw(c)
screenshot
}.getOrNull()
}
private var isBeginDrag = false

override fun onPageStateChange(state: Int) {
when (state) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
isBeginDrag = true
}
}
}

override fun onPageScrolled(firstVisible: Int, current: Int, offsetPx: Int, forward: Boolean) {
if (isBeginDrag) {
isBeginDrag = false
delegate.apply {
if (forward) {
nextBitmap?.recycle()
nextBitmap = layoutManager.findViewByPosition(current + 1)?.screenshot()
curBitmap?.recycle()
curBitmap = layoutManager.findViewByPosition(current)?.screenshot()
} else {
prevBitmap?.recycle()
prevBitmap = layoutManager.findViewByPosition(current - 1)?.screenshot()
curBitmap?.recycle()
curBitmap = layoutManager.findViewByPosition(current)?.screenshot()
}
setDirection(if (forward) AnimDirection.NEXT else AnimDirection.PREV)
}
invalidate()
}
}

(2)绘制仿真页


绘制仿真页参考 gedoor/legadoSimulationPageDelegate



  • 基础知识:三角函数、Android的矩阵、贝塞尔曲线、canvas.clipPath的 XOR & INTERSECT 模式

  • 绘制方法:Android仿真翻页:cnblogs.com

  • 计算方法:使用手指触摸点和触摸点对应的角位置(比如触摸点靠近右下角,角位置就是右下角),这两个点可以算出所有参数


确认方向后,我们只用通过修改手指触碰点的参数即可控制整个动画(根据点击位置实时计算即可)


5、动画控制


手指抬起后的翻页动画通过 Scroller+invalidate实现


override fun computeScroll() {
if (scroller.computeScrollOffset()) {
setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat())
} else if (isStarted) {
stopScroll()
}
}

对于FlingScroll Idle产生的吸附效果,我们需要各自回调方向:


// 选中时开始动画,此时position改变
override fun onPageSelected(position: Int) {
val page = adapter.data[position]
ReadBook.onPageChange(page)
if (canDraw) {
delegate.onAnimStart(300, false)
}
}

// position未改变的情况
override fun onSnap(isFling: Boolean, forward: Boolean, changePosition: Boolean) {
if (!changePosition) {
delegate.onAnimStart(
300,
true,
// 未改变方向,向前则播放向后动画
if (forward) AnimDirection.PREV else AnimDirection.NEXT
)
}
}

Scroll Idle通过 SmoothScroll 所需要滑动的距离正负判断方向:


// Scroll
override fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
mSnapping = true
super.snapToTargetExistingView(helper)?.also {
// first为滑动距离,second为目标Item的position
mCallback.onSnap(false, it.first > 0, mCurrentItem != it.second)
return it
}
return null
}

// Fling
override val mFlingListener = object : RecyclerView.OnFlingListener() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
val lm = mRecyclerView.layoutManager ?: return false
mRecyclerView.adapter ?: return false
val minFlingVelocity = mRecyclerView.minFlingVelocity
val result = snapFromFling(
lm,
velocityX,
orientationHelper.value
)
val consume = abs(velocityX) > minFlingVelocity && result.first
if (consume) {
mSnapping = true
// second为目标Item的position,这里直接通过速度正负来判断方向
mCallback.onSnap(true, velocityX > 0, result.second != mCurrentItem)
}
return consume
}
}

(以上为所有关键点,只截取了部分

作者:三尺丶
来源:juejin.cn/post/7244819106343829564
代码,提供一个思路)

收起阅读 »

Flutter 仿 Hero 的动画

Flutter 模仿 Hero 动画的效果,实现逻辑比较简单,就是用 Stack 结合 AnimatedBuilder 组件实现类似 Hero 的转场的动画效果。 效果 代码 DEMO class TWAnimationHeroApp extends Sta...
继续阅读 »

Flutter 模仿 Hero 动画的效果,实现逻辑比较简单,就是用 Stack 结合 AnimatedBuilder 组件实现类似 Hero 的转场的动画效果。


效果


Simulator Screen Recording - iPhone 14 Pro - 2023-08-08 at 22.32.59.gif


代码


DEMO


class TWAnimationHeroApp extends StatelessWidget {
final controller = TWAnimationHeroController();
TWAnimationHeroApp({super.key});

@override
Widget build(BuildContext context) {
Widget heroChild = GestureDetector(
onTap: () => controller.executeAnimation(),
child: Image.asset(
Assets.beauty.path,
fit: BoxFit.fitHeight,
),
);

return MaterialApp(
theme: ThemeData(primarySwatch: Colors.grey),
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(),
body: TWAnimationHero(
controller: controller,
heroChild: heroChild,
child: Stack(
children: [
ListView(
children: [
Container(
height: 100,
alignment: Alignment.center,
color: Colors.orange,
child: GestureDetector(
onTap: () => controller.reverseAnimation(),
child: SizedBox(
width: 50,
height: 50,
key: controller.targetKey,
child: Image.asset(
Assets.beauty.path,
),
),
),
),
Container(
height: 100,
color: Colors.black,
),
Container(
height: 100,
color: Colors.green,
),
Container(
height: 100,
color: Colors.red,
),
Container(
height: 100,
color: Colors.lime,
),
Container(
height: 100,
color: Colors.green,
),
Container(
height: 100,
color: Colors.yellow,
),
Container(
height: 100,
color: Colors.blueAccent,
),
],
),
],
),
),
),
);
}
}

TWAnimationHeroController


class TWAnimationHeroController extends ChangeNotifier {
GlobalKey targetKey = GlobalKey();
GlobalKey heroKey = GlobalKey();

/// 是否可见
bool get isHeroVisible => _isHeroVisible;

bool _isHeroVisible = true;

set heroVisible(bool value) {
_isHeroVisible = value;
notifyListeners();
}

/// 是否方向状态
bool isReverse = false;
AnimationController? controller;
Animation? animation;

double offTop = 0;
double offBottom = 0;
double offLeft = 0;
double offRight = 0;
TWAnimationHeroController();

/// 执行正向动画
executeAnimation() {
if (isReverse) return;
isReverse = true;
final child1Rect = fetchChildRect(targetKey);
final child2Rect = fetchChildRect(heroKey);
if (child1Rect == null || child2Rect == null) return;
offTop = child1Rect.top - child2Rect.top;
offBottom = child2Rect.bottom - child1Rect.bottom;
offLeft = child1Rect.left - child2Rect.left;
offRight = child2Rect.right - child1Rect.right;
controller?.forward();
}

/// 执行反向动画
reverseAnimation() {
if (!isReverse) return;
heroVisible = true;
isReverse = false;
controller?.reverse();
}

Rect? fetchChildRect(GlobalKey key) {
RenderBox? renderBox = key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return null;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final childRect = offset & size;
return childRect;
}
}

TWAnimationHero 组件


class TWAnimationHero extends StatefulWidget {
final Widget child;
final Widget? heroChild;

final TWAnimationHeroController controller;
const TWAnimationHero({
super.key,
required this.controller,
required this.child,
this.heroChild,
});

@override
State<TWAnimationHero> createState() => _TWAnimationHeroState();
}

class _TWAnimationHeroState extends State<TWAnimationHero>
with TickerProviderStateMixin
{
@override
void initState() {
super.initState();
createController();
}

/// 创建控制器
createController() {
final controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);

//应用curve
widget.controller.animation = CurvedAnimation(
parent: controller,
curve: Curves.easeIn,
);

controller.addListener(() {
// 注意正向动画才会监听到 isCompleted
if (controller.isCompleted) {
widget.controller.heroVisible = false;
}
});

widget.controller.controller = controller;
}

@override
void didUpdateWidget(covariant TWAnimationHero oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller.controller == null) {
widget.controller.controller?.dispose();
createController();
}
}

@override
void dispose() {
widget.controller.controller?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
if (widget.heroChild != null &&
widget.controller.controller != null &&
widget.controller.animation != null)
AnimatedBuilder(
animation: widget.controller.controller!,
builder: (BuildContext context, Widget? child) {
return Positioned(
top: widget.controller.animation!.value *
widget.controller.offTop,
bottom: widget.controller.animation!.value *
widget.controller.offBottom,
left: widget.controller.animation!.value *
widget.controller.offLeft,
right: widget.controller.animation!.value *
widget.controller.offRight,
child: child!,
);
},
child: AnimatedBuilder(
animation: widget.controller,
builder: (BuildContext context, Widget? child) {
return Visibility(
visible: widget.controller.isHeroVisible,
child: Container(
color: Colors.transparent,
key: widget.controller.heroKey,
child: widget.heroChild,
),
);
},
),
),
],
);
}
}


作者:zeqinjie
来源:juejin.cn/post/7264921108398604343
收起阅读 »

Flutter:创建和发布一个 Dart Package

在 Dart 生态系统中使用 packages(包) 实现代码的共享,比如一些 library 和工具。本文旨在介绍如何创建和发布一个 package。 通常来讲,我们所说的 package 一般都是指 library package,即可以被其他的 pac...
继续阅读 »

在 Dart 生态系统中使用 packages(包) 实现代码的共享,比如一些 library 和工具。本文旨在介绍如何创建和发布一个 package。


通常来讲,我们所说的 package 一般都是指 library package,即可以被其他的 package 所依赖,同时它自身也可以依赖其他 package。本文中说的 package 也都默认是指 library package


1.package 的组成


下图展示了最简单的 library package 布局:








  • library package 中需要包括 pubspec.yaml 文件lib 目录





  • library 的 pubspec.yaml 文件和应用程序的 pubspec.yaml 没有本质区别。





  • library 的代码需要位于 lib 目录 下,且对于其他 package 是 公开的。你可以根据需要在 lib 下创建任意目录。但是如果你创建的目录名是 src 的话,会被当做 私有目录,其他 package 不能直接使用。目前一般的做法都是把代码放到 lib/src 目录下,然后将需要公开的 API 通过 export 进行导出。




2.创建一个 package


假设我们要开发一个叫做 yance 的 package。


2.1 通过 IDE 创建一个 package








我们来看看创建好的一个 package 工程的结构:





可以看到 lib 目录和 pubspec.yaml 文件已经默认给我们创建好了。


2.2 认识 main library


我们打开 lib 目录,会发现有一个默认和 package 项目名称同名的 dart 文件,我们把这个文件成为 main library。因为我的 package 名称是 yance,因此,我的 main libraryyance.dart





main library 的作用是用来声明所有需要公开的 API。


我们打开 yance.dart 文件:


library yance;

/// A Calculator.
class Calculator {
  /// Returns [value] plus 1.
  int addOne(int value) => value + 1;
}

第一行使用 library 关键字。这个 library 是用来为当前的 package 声明一个唯一标识。也可以不声明 library,在不声明 library 的情况下,package 会根据当前的路径及文件生成一个唯一标记。


如果你需要为当前的 package 生成 API 文档,那么必须声明 library。


至于 library 下面的 Calculator 代码只是一个例子,可以删除。


前面说了 main library 的作用是用来声明公开的 API,下面我们来演示一下,如何声明。


2.3 在 main library 中公开 API


我们在 lib 目录下新建一个 src 目录,后面所有的 yance package 的实现代码都统一放在 src 目录下,记住,src 下的所有代码都是私有的,其他项目或者 package 不能直接使用。


我们在 src 目录下,创建一个 yance_utils.dart 文件,在里面简单写一点测试代码:


class YanceUtils {
  /// Returns [value] plus 1.
  int addOne(int value) => value + 1;
}

好了,现在需求来了,我要将 YanceUtils 这个工具类声明为一个公开的 API ,好让其他项目或者 package 可以使用。


那么就需要在 yance.dart 这个 main library 中使用 export 关键字进行声明,格式为:


export 'src/xxx.dart';

输入 src 关键字,然后选择 src/ 这个路径:





然后再输入 yance_utils.dart 即可:


library yance;

export 'src/yance_utils.dart';

这样就完成了 API 的公开,yance_utils.dart 里面所有的内容,都可以被其他项目所引用:


import 'package:yance/yance.dart';

class MyDemo{
  void test() {
    var yanceUtils = YanceUtils();
    var addOne = yanceUtils.addOne(1);
    print('结果:$addOne}');
  }
}

此时,可能大家会有个疑问,使用 export 'src/xxx.dart' 的方式,会将该 dart 文件里所有的内容都完全公开,那假如该文件里的内容,我只想公开一部分,该如何操作呢?


需要使用到 show 关键字:


export 'src/xxx.dart' show 需要公开的类名or方法名or变量名

/// 多个公开的 API 用逗号分隔开

还是以 yance_utils.dart 为例子,我们在 yance_utils.dart 再添加一点代码:


String yanceName = "123";

void yanceMain() {
  print('调用了yanceMain方法');
}

class YanceUtils {
  /// Returns [value] plus 1.
  int addOne(int value) => value + 1;
}

class StringUtils {
  String getStr(String value) => value.replaceAll("/""_");
}

此时,我想公开 yanceName 属性yanceMain() 方法YanceUtils 类,可以这样声明:


library yance;

export 'src/yance_utils.dart' show YanceUtils, yanceName, yanceMain;

使用 show 不仅可以避免导出过多的 API,而且可以为开发者提供公开的 API 的概览。


3.发布一个 package


开发完成自己的 package 后,就可以将其发布到 pub.dev 上了。


发布 package 大致需要 5 个步骤:





下面会一一解答每一个步骤。


3.1 关于 pub.dev 的一些政策说明





  • 发布是永久的


只要你在 pub.dev 上发布了你的 package,那么它就是永久存在,不会允许你删除它。这样做的目的是为了保护依赖了你 package 的项目,因为你的删除操作会给他们的项目带来破坏。





  • 可以随时发布 package 的新版本,而旧版本对未升级的用户仍然可用。





  • 对于那些已经发布,但不再维护的 package,你可以把它标记为终止(discontinued)。




进入到 package 页面上的 Admin 标签栏,可以将 package 标记为终止。








标记为终止(discontinued)的 package,以前发布的版本依然留存在 pub.dev 上,并可以被看到,但是它有一个清楚的 终止 徽章,而且不会出现在搜索结果中。


3.2 发布前的准备


3.2.1 首先需要一个 Google 账户


Google 账户申请地址:传送门




如果之前你登录过任何 Google 产品(例如 Gmail、Google 地图或 YouTube),这就意味着你已拥有 Google 帐号。你可以使用自己创建的同一组用户名和密码登录任何其他 Google 产品。



3.2.2 检查 LICENSE 文件


package 必须包含一个 LICENSE 文件。推荐使用 BSD 3-clause 许可证,也就是 Dart 和 Flutter 团队所使用的开源许可证。


参考:


Copyright 2021 com.yance. All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above
      copyright notice, this list of conditions and the following
      disclaimer in the documentation and/or other materials provided
      with the distribution.
    * Neither the name of Google Inc. nor the names of its
      contributors may be used to endorse or promote products derived
      from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

3.2.3 检查包大小


通过 gzip 压缩后,你的 package 必须小于 100 MB


如果它所占空间过大,考虑将它分割为几个小的 package。或者使用 .pubignore 移除不需要的文件,或者减少包含资源或实例的数量。


3.2.4 检查依赖项


package 应该尽量只依赖于托管在 pub.dev 上的库,以避免不必要的风险。


3.3 编写几个重要的文件 🔺


3.3.1 README.md


README.md 的内容在 pub.dev 上会当做一个页面进行展示:





3.3.2 CHANGELOG.md


如果你的 package 中有 CHANGELOG.md 文件,同样会被作为一个页面(Changelog)进行展示:





来看一个例子:


# 1.0.1

Fixed missing exclamation mark in `sayHi()` method.

# 1.0.0

**Breaking change:** Removed deprecated `sayHello()` method.
Initial stable release.

## Upgrading from 0.1.x

Change all calls to `sayHello()` to instead be to `sayHi()`.

# 0.1.1

Deprecated the `sayHello()` method; use `sayHi()` instead.

# 0.1.0

Initial development release.

3.3.3 pubspec.yaml


pubspec.yaml 文件被用于填写关于 package 本身的细节,例如它的描述,主页等等。这些信息将被展现在页面的右侧。





一般来说,需要填写这些信息:





注意:


目前 author 信息已经不需要了,所以大家可以把 author 给删除掉。


3.4 预发布


预发布使用如下命令。执行预发布命令不会真的发布,它可以帮助我们验证填写的发布信息是否符合 pub.dev 的规范,同时展示所有会发布到 pub.dev 的文件。


dart pub publish --dry-run

比如,我运行的结果是:


chenyouyu@chenyouyudeMacBook-Pro-2 yance % dart pub publish --dry-run
Publishing yance 0.0.1 to https://pub.dartlang.org:
|-- CHANGELOG.md
|-- LICENSE
|-- README.md
|-- lib
|   |-- src
|   |   '-- yance_utils.dart
|   '
-- yance.dart
|-- pubspec.yaml
|-- test
|   '-- yance_test.dart
'
-- yance.iml
Package validation found the following potential issue:
* Your pubspec.yaml includes an "author" section which is no longer used and may be removed.

Package has 1 warning.

它提示我们author 信息已经不需要了,可以删除。


删除后,再次运行就没有警告了。





3.5 正式发布


当你已经准备好正式发布你的 package 后,移除 --dry-run 参数:


dart pub publish







点击链接会跳转浏览器验证账户,验证成功后,会有提示:





账户验证通过后,会继续执行上传任务:





此时,去 pub.dev 上就能看到发布成功的 package 了:





pub.dev 会检测 package 支持哪些平台,并呈现到 package 的页面上。


注意:


正式发布可能需要科学上网。


4.参考文章



作者:有余同学
来源:mdnice.com/writing/d5460df39ddd4649be9b102ccb2fb0b2
收起阅读 »

Android协程带你飞越传统异步枷锁

引言 在Android开发中,处理异步任务一直是一项挑战。以往的回调和线程管理方式复杂繁琐,使得代码难以维护和阅读。Jetpack引入的Coroutine(协程)成为了异步编程的新标杆。本文将深入探讨Android Jetpack Coroutine的使用、原...
继续阅读 »

引言


在Android开发中,处理异步任务一直是一项挑战。以往的回调和线程管理方式复杂繁琐,使得代码难以维护和阅读。Jetpack引入的Coroutine(协程)成为了异步编程的新标杆。本文将深入探讨Android Jetpack Coroutine的使用、原理以及高级用法,助您在异步编程的路上游刃有余。


什么是Coroutine?


Coroutine是一种轻量级的并发设计模式,它允许开发者以顺序代码的方式处理异步任务,避免了传统回调和线程管理带来的复杂性。它建立在Kotlin语言的suspend函数上,suspend函数标记的方法能够挂起当前协程的执行,并在异步任务完成后恢复执行。


Coroutine的优势



  • 简洁:通过简洁的代码表达异步逻辑,避免回调地狱。

  • 可读性:顺序的代码结构使得逻辑更加清晰易懂。

  • 卓越的性能:Coroutine能够有效地利用线程,避免过度的线程切换。

  • 取消支持:通过Coroutine的结构,方便地支持任务取消和资源回收。

  • 适用范围广:从简单的后台任务到复杂的并发操作,Coroutine都能应对自如。


Coroutine的原理


挂起与恢复


当遇到挂起函数时,例如delay()或者进行网络请求的suspend函数,协程会将当前状态保存下来,包括局部变量、指令指针等信息,并暂停协程的执行。然后,协程会立即返回给调用者,释放所占用的线程资源。一旦挂起函数的异步操作完成,协程会根据之前保存的状态恢复执行,就好像从挂起的地方继续运行一样,这使得异步编程变得自然、优雅。


线程调度与切换


Coroutine使用调度器(Dispatcher)来管理协程的执行线程。主要的调度器有:



  • Dispatchers.Main:在Android中主线程上执行,用于UI操作。

  • Dispatchers.IO:在IO密集型任务中使用,比如网络请求、文件读写。

  • Dispatchers.Default:在CPU密集型任务中使用,比如复杂的计算。


线程切换通过withContext()函数实现,它智能地在不同的调度器之间切换,避免不必要的线程切换开销,提高性能。


异常处理与取消支持


Coroutine支持异常处理,我们可以在协程内部使用try-catch块来捕获异常,并将异常传播到协程的外部作用域进行处理,这使得我们能够更好地管理和处理异步操作中出现的异常情况。


同时,Coroutine支持任务的取消。当我们不再需要某个协程执行时,可以使用coroutineContext.cancel()或者coroutinecope.cancel()来取消该协程。这样,协程会自动释放资源,避免造成内存泄漏。


基本用法


并发与并行


使用async函数,我们可以实现并发操作,同时执行多个异步任务,并等待它们的结果。而使用launch函数,则可以实现并行操作,多个协程在不同线程上同时执行。


val deferredResult1 = async { performTask1() }
val deferredResult2 = async { performTask2() }

val result1 = deferredResult1.await()
val result2 = deferredResult2.await()

超时与异常处理


通过withTimeout()函数,我们可以设置一个任务的超时时间,当任务执行时间超过指定时间时,会抛出TimeoutCancellationException异常。这使得我们能够灵活地处理超时情况。


try {
withTimeout(5000) {
performLongRunningTask()
}
} catch (e: TimeoutCancellationException) {
// 处理超时情况
}

组合挂起函数


Coroutine提供了一系列的挂起函数,例如delay()withContext()等。我们可以通过asyncawait()函数将这些挂起函数组合在一起,实现复杂的异步操作。


val result1 = async { performTask1() }.await()
val result2 = async { performTask2() }.await()

与jetpack联动


当使用Jetpack组件和Coroutine结合起来时,我们可以在Android应用中更加优雅地处理异步任务。下面通过一个示例演示如何在ViewModel中使用Jetpack组件和Coroutine来处理异步数据加载:


创建一个ViewModel类,例如MyViewModel.kt,并在其中使用Coroutine来加载数据:


import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import kotlinx.coroutine.Dispatchers

class MyViewModel : ViewModel() {

fun loadData() = liveData(Dispatchers.IO) {
emit(Resource.Loading) // 发送加载中状态

try {
// 模拟耗时操作
val data = fetchDataFromRemote()
emit(Resource.Success(data)) // 发送加载成功状态
} catch (e: Exception) {
emit(Resource.Error(e.message)) // 发送加载失败状态
}
}

// 假设这是一个网络请求的方法
private suspend fun fetchDataFromRemote(): String {
// 模拟耗时操作
delay(2000)
return "Data from remote"
}
}

创建一个Resource类用于封装数据状态:


sealed class Resource<out T> {
object Loading : Resource<Nothing>()
data class Success<T>(val data: T) : Resource()
data class Error(val message: String?) : Resource<Nothing>()
}

在Activity或Fragment中使用ViewModel,并观察数据变化:


class MyActivity : AppCompatActivity() {

private val viewModel: MyViewModel by viewModels()

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

viewModel.loadData().observe(this) { resource ->
when (resource) {
is Resource.Loading -> {
// 显示加载中UI
}
is Resource.Success -> {
// 显示加载成功UI,并使用resource.data来更新UI
val data = resource.data
}
is Resource.Error -> {
// 显示加载失败UI,并使用resource.message显示错误信息
val errorMessage = resource.message
}
}
}
}
}

在以上示例中,ViewModel中的loadData()方法使用Coroutine的liveData构建器来执行异步任务。我们通过emit()函数发送不同的数据状态,Activity(或Fragment)通过观察LiveData来处理不同的状态,并相应地更新UI。


结论


Android Jetpack Coroutine是异步编程的高级艺术。通过深入理解Coroutine的原理和高级用法,我们可以写出更加优雅、高效的异步代码。掌握Coroutine的挂起与恢复、线程切换、异常处理和取消支持,使得我们能够更好地处理异步操作,为用户带来更出色的应用体验。

作者:午后一小憩
来源:juejin.cn/post/7264399534474297403

收起阅读 »

如何自动打开你的 App?

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。 那么这种自动打开一个 App 到底是怎么实现的呢? URL Scheme 首先是最原始的方...
继续阅读 »

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。


那么这种自动打开一个 App 到底是怎么实现的呢?


URL Scheme


首先是最原始的方式 URL Scheme。



URL Scheme 是一种特殊的 URL,用于定位到某个应用以及应用的某个功能。



它的格式一般是: [scheme:][//authority][path][?query]


scheme 代表要打开的应用,每个上架应用商店的 App 所注册的 scheme 都是唯一的;后面的参数代表应用下的某个功能及其参数。


在 IOS 上配置 URL Scheme


在 XCode 里可以轻松配置


image.png


在 Android 上配置 URL Scheme


Android 的配置也很简单,在 AndroidManifest.xml 文件下添加以下配置即可


image.png


通过访问链接自动打开 App


配置完成后,只要访问 URL Scheme 链接,系统便会自动打开对应 scheme 的 App。


因此,我们可以实现一个简单的 H5 页面来承载这个跳转逻辑,然后在页面中通过调用 location.href=schemeUrl 或者 <a href='schemeUrl' /> 等方式来触发访问链接,从而自动打开 App


优缺点分析


优点: 这个是最原始的方案,因此最大的优点就是兼容性好


缺点:



  1. 通过 scheme url 这种方式唤起 App,对于 H5 中间页面是无法感知的,并不知道是否已经成功打开 App

  2. 部分浏览器有安全限制,自动跳转会被拦截,必须用户手动触发跳转(即 location.href 行不通,必须 a 标签)

  3. 一些 App 会限制可访问的 scheme,你必须要在白名单内,否则也会被拦截跳转

  4. 通过 scheme url 唤起 App 时,浏览器会提示你是否确定要打开该 App,会影响用户体验


DeepLink


通过上述缺点我们可以看出,传统的 URL Scheme 在用户体验上是存在一定缺陷的。


因此,DeepLink 诞生了。


DeepLink 的宗旨就是通过传统的 HTT P链接就可以唤醒app,而如果用户没有安装APP,则会跳转到该链接对应的页面。


IOS Universal Link


在 IOS 上一般称之为 Universal Link。


【配置你的 Universal Link 域名】


首先要去 Apple 的开发者平台上配置你的 domains,假设是: mysite.com


image.png


【配置 apple-app-site-association 文件】


在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 apple-app-site-association 文件。


文件内容包含 appID 以及 path,path如果配置 /app 则表示访问该域名下的 /app 路径均能唤起App


该文件内容大致如下:


{
"applinks": {
"apps": [],
"details": [
{
"appID": "xxx", // 你的应用的 appID
"paths": [ "/app/*"]
}
]
}
}

【系统获取配置文件】


上面两步配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统都会主动去拉取域名下的配置文件。



即系统会主动去拉取 https://mysite.com/.well-known/apple-app-site-association 这个文件



然后根据返回的 appID 以及 path 判断访问哪些路径是需要唤起哪个App


【自动唤起 App】


当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。


同时,客户端还可以进行一些自定义逻辑处理:


客户端会接收到 NSUserActivity 对象,其 actionType 为 NSUserActivityTypeBrowsingWeb,因此客户端可以在接收到该对象后做一些跳转逻辑处理。


image.png


Android DeepLink


与 IOS Universal Link 原理相似,Android系统也能够直接通过网站地址打开应用程序对应的内容页面,而不需要用户选择使用哪个应用来处理网站地址


【配置 AndroidManifest.xml】
在 AndroidManifest 配置文件中添加对应域名的 intent-filter:


scheme 为 https / http;


host 则是你的域名,假设是: mysite.com


image.png


【生成 assetlinks.json 文件】


首先要去 Google developers.google.com/digital-ass… 生成你的 assetlinks json 文件。


image.png


【配置 assetlinks.json 文件】


生成文件后,同样的需要在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 assetlinks.json 配置文件,文件内容包含应用的package name 和对应签名的sha哈希


【系统获取配置文件】


配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统会进行以下校验:



  1. 如果 intent-filter 的 autoVerify 设置为 true,那么系统会验证其



  • Action 是否为 android.intent.action.VIEW

  • Category 是否为android.intent.category.BROWSABLE 和 android.intent.category.DEFAULT

  • Data scheme 是否为 http 或 https



  1. 如果上述条件都满足,那么系统将会拉取该域名下的 json 配置文件,同时将 App 设置为该域名链接的默认处理App


【自动唤起 App】


当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。


优缺点分析


【优点】



  1. 用户体验好:可以直接打开 App,没有弹窗提示

  2. 唤起App失败则会跳转链接对应的页面


【缺点】



  1. iOS 9 以后才支持 Universal Link,

  2. Android 6.0 以后才支持 DeepLink

  3. DeepLink 需要依赖远程配置文件,无法保证每次都能成功拉取到配置文件


推荐方案: DeepLink + H5 兜底


基于前面两种方案的优缺点,我推荐的解决方案是配置 DeepLink,同时再加上一个 H5 页面作为兜底。


首先按照前面 DeepLink 的教程先配置好 DeepLink,其中访问路径配置为 https://mysite.com/app


接着,我们就可以在 https://mysite.com/app 路径下做文章了。在该路径下放置一个 H5 页面,内容可以是引导用户打开你的 App。


当用户访问 DeepLink 没有自动打开你的 App 时,此时用户会进入浏览器,并访问 https://mysite.com/app 这个 H5 页面。


在 H5 页面中,你可以通过浏览器 ua 获取当前的系统以及版本:



  1. 如果是 Android 6.0 以下,那么可以尝试用 URL Scheme 去唤起 App

  2. 如果是 IOS / Android 6.0 及以上,那么此时可以判断用户未安装 App。这种情况下可以做些额外的逻辑,比如重定向到应用商店引导用户去下载之类的


作者:龙飞_longfe
来源:juejin.cn/post/7201521440612974649
收起阅读 »

Android 记录一次因隐私合规引发的权限hook

背景 一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三...
继续阅读 »

背景


一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三方的技术,询问是否有静默方面的api,结果一番舌战后,对方告诉我他们隐私政策里有添加说明,之后也没有想要改动的打算,但是集团那边说在隐私里说明也不行。


综上,那只能自己动手。


解决的方法:是通过hook系统权限,添加某个业务逻辑点拦截并处理。


涉及到的知识点:java反射、动态代理、一点点耐心。


本文涉及到的敏感权限:


//wifi
android.net.wifi.WifiManager.getScanResults()
android.net.wifi.WifiManager.getConnectionInfo()
//蓝牙
android.bluetooth.le.BluetoothLeScanner.startScan()
//定位
android.location.LocationManager.getLastKnownLocation()

开始


wifi篇


1.首先寻找切入点,以方法WifiManager.getScanResults()为例查看源码


public List<ScanResult> getScanResults() {
  try {
return mService.getScanResults(mContext.getOpPackageName(),
  mContext.getAttributionTag());
  } catch (RemoteException e) {
  throw e.rethrowFromSystemServer();
  }
}

发现目标方法是由mService对象调用,它的定义


@UnsupportedAppUsage
IWifiManager mService;

查看IWifiManager


interface IWifiManager{
...

List<ScanResult> getScanResults(String callingPackage, String callingFeatureId);

WifiInfo getConnectionInfo(String callingPackage, String callingFeatureId);

...
}

可以看到IWifiManager是一个接口类,包含所需方法,可以当成一个切入点。


若以IWifiManager为切入点,进行hook


方法一

private static void hookWifi(Context context) {
try {
//反射获取相关类、字段对象
Class<?> iWifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
Field serviceField = HookUtil.getField("android.net.wifi.WifiManager", "mService");

WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
//获取原始mService对象
Object realIwm = serviceField.get(wifiManager);

//创建IWifiManager代理
Object proxy = Proxy.newProxyInstance(iWifiManagerClass.getClassLoader(),
new Class[]{iWifiManagerClass}, new WifiManagerProxy(realIwm));

//设置新代理
serviceField.set(wifiManager, proxy);
} catch (Exception e) {
e.printStackTrace();
}
}

其中新代理类实现InvocationHandler


public class WifiManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public WifiManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (("getScanResults".equals(methodName) || "getConnectionInfo".equals(methodName))){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}


2.考虑context问题:


获取原始wifiManager需要用到context上下文,不同context获取到的wifiManager不同。若统一使用application上下文可以基本覆盖所需,但是可能会出现遗漏(比如某处使用的是activity#context)。为了保证hook开关唯一,尝试再往上查找新的切入点。


查看获取wifiManager方法,由context调用.getSystemService()


WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);

继续查看context的实现contextImpl


@Override
public Object getSystemService(String name) {
...
return SystemServiceRegistry.getSystemService(this, name);
}

查看SystemServiceRegistry.getSystemService静态方法


public static Object getSystemService(ContextImpl ctx, String name) {
if (name == null) {
return null;
}
final ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
if (fetcher == null) {
...
return null;
}

final Object ret = fetcher.getService(ctx);
if (sEnableServiceNotFoundWtf && ret == null) {
...
return null;
}
return ret;
}

服务由SYSTEM_SERVICE_FETCHERS获取,它是一个静态的HashMap,它的put方法在registerService


private static <T> void registerService(@NonNull String serviceName,
@NonNull Class<T> serviceClass, @NonNull ServiceFetcher<T> serviceFetcher) {
...
SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher);
...
}

static{
...
//Android 11及以上
WifiFrameworkInitializer.registerServiceWrappers()
...
}

...

@SystemApi
public static <TServiceClass> void registerContextAwareService(
@NonNull String serviceName, @NonNull Class<TServiceClass> serviceWrapperClass,
@NonNull ContextAwareServiceProducerWithoutBinder<TServiceClass> serviceProducer) {
...
registerService(serviceName, serviceWrapperClass,
new CachedServiceFetcher<TServiceClass>() {
@Override
public TServiceClass createService(ContextImpl ctx)
throws ServiceNotFoundException {
return serviceProducer.createService(
ctx.getOuterContext(),
ServiceManager.getServiceOrThrow(serviceName));
}});

}


public static void registerServiceWrappers() {
...
SystemServiceRegistry.registerContextAwareService(
  Context.WIFI_SERVICE,
  WifiManager.class,
  (context, serviceBinder) -> {
  IWifiManager service = IWifiManager.Stub.asInterface(serviceBinder);
  return new WifiManager(context, service, getInstanceLooper());
  }
  );
}

SYSTEM_SERVICE_FETCHERS静态代码块中通过.registerServiceWrappers()注册WIFI_SERVICE服务。


registerService中new了一个CachedServiceFetcher,它返回一个serviceProducer.createService(...)


TServiceClass createService(@NonNull Context context, @NonNull IBinder serviceBinder);

其中第二个参数是一个IBinder对象,它的创建


ServiceManager.getServiceOrThrow(serviceName)

继续


public static IBinder getServiceOrThrow(String name) throws ServiceNotFoundException {
  final IBinder binder = getService(name);
  if (binder != null) {
  return binder;
  } else {
  throw new ServiceNotFoundException(name);
  }
  }
...
@UnsupportedAppUsage
public static IBinder getService(String name) {
  try {
  IBinder service = sCache.get(name);
  if (service != null) {
  return service;
  } else {
  return Binder.allowBlocking(rawGetService(name));
  }
  } catch (RemoteException e) {
  Log.e(TAG, "error in getService", e);
  }
  return null;
  }

最终在getServiceIBinder缓存在sCache中,它是一个静态变量


@UnsupportedAppUsage
private static Map<String, IBinder> sCache = new ArrayMap<String, IBinder>();

综上,如果可以创建新的IBinder,再替换掉sCache中的原始值就可以实现所需。


若以sCache为一个切入点


方法二

private static void hookWifi2() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.WIFI_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//生成代理IBinder,并替换原始值
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new WifiBinderProxy(iBinder));
sCacheMap.put(Context.WIFI_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class WifiBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public WifiBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IWifiManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.net.wifi.IWifiManager$Stub", "asInterface", IBinder.class);
Object iwifiManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成新IWifiManager代理
Class<?> iwifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iwifiManagerClass},
new WifiManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

至此完成无需上下文的全局拦截。


蓝牙篇


BluetoothLeScanner.startScan()为例查找切入点,以下省略非必需源码粘贴


private int startScan(List<ScanFilter> filters, ScanSettings settings,
final WorkSource workSource, final ScanCallback callback,
final PendingIntent callbackIntent,
List<List<ResultStorageDescriptor>> resultStorages) {
...
IBluetoothGatt gatt;
try {
gatt = mBluetoothManager.getBluetoothGatt();
} catch (RemoteException e) {
gatt = null;
}
...

private final IBluetoothManager mBluetoothManager;

...
public BluetoothLeScanner(BluetoothAdapter bluetoothAdapter) {
mBluetoothAdapter = Objects.requireNonNull(bluetoothAdapter);
mBluetoothManager = mBluetoothAdapter.getBluetoothManager();
...
}

向上查找IBluetoothManager,它在BluetoothAdapter中;向下代理getBluetoothGatt方法处理IBluetoothGatt


查看BluetoothAdapter的创建


public static BluetoothAdapter createAdapter(AttributionSource attributionSource) {
IBinder binder = ServiceManager.getService(BLUETOOTH_MANAGER_SERVICE);
if (binder != null) {
return new BluetoothAdapter(IBluetoothManager.Stub.asInterface(binder),
attributionSource);
} else {
Log.e(TAG, "Bluetooth binder is null");
return null;
}
}

ok,他也包含由ServiceManager中获取得到IBinder,然后进行后续操作。


若以IBluetoothManager为切入点


private static void hookBluetooth() {
try {
//反射ServiceManager中的getService(BLUETOOTH_MANAGER_SERVICE = 'bluetooth_manager')方法,获取原始IBinder
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, "bluetooth_manager");

//获取ServiceManager对象sCache
Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new BluetoothBinderProxy(iBinder));
sCacheMap.put("bluetooth_manager", (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

代理IBluetoothManager


public class BluetoothBinderProxy implements InvocationHandler {

private final IBinder mOriginalTarget;

public BluetoothBinderProxy(IBinder originalTarget) {
this.mOriginalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
//拦截
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
//不拦截
return method.invoke(mOriginalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IBluetoothManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.bluetooth.IBluetoothManager$Stub", "asInterface", IBinder.class);
Object iBluetoothManagerObject = asInterfaceMethod.invoke(null, mOriginalTarget);

//生成代理IBluetoothManager
Class<?> iBluetoothManagerClass = HookUtil.getClass("android.bluetooth.IBluetoothManager");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothManagerClass},
new BluetoothManagerProxy(iBluetoothManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

代理IBluetoothGatt


public class BluetoothManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("getBluetoothGatt".equals(method.getName())) {
Object object = method.invoke(mOriginalTarget,args);
Object hook = hookGetBluetoothGatt(object);
if (hook != null){
return hook;
}
}
return method.invoke(mOriginalTarget, args);
}

private Object hookGetBluetoothGatt(Object object) {
try {
Class<?> iBluetoothGattClass = HookUtil.getClass("android.bluetooth.IBluetoothGatt");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothGattClass},
new BluetoothGattProxy(object));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

处理业务逻辑


public class BluetoothGattProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothGattProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}


@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startScan".equals(method.getName())){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}

定位篇


LocationManager.getLastKnownLocation()为例查找切入点,此处不粘贴源码,直接展示


private static void hookLocation() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.LOCATION_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new LocationBinderProxy(iBinder));
sCacheMap.put(Context.LOCATION_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class LocationBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public LocationBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始ILocationManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.location.ILocationManager$Stub", "asInterface", IBinder.class);
Object iLocationManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成代理ILocationManager
Class<?> iLocationManagerClass = HookUtil.getClass("android.location.ILocationManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iLocationManagerClass},
new LocationManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

总结


作为Android进程间通信机制Binder的守护进程,本次所hook的权限都可追溯到ServiceManagerServiceManager中的sCache缓存了权限相关的IBinder,以此为切入点可以进行统一处理,不需要引入context。


在此记录一下因隐私合规引发的hook处理流程,同时也想吐槽一下国内应用市场App上架审核是真滴难,每个市场的合规扫描标准都不一样。


附录


源码查看网站 aospxref.com/


路径:/frameworks/base/core/java/android/os/ServiceManager.j

作者:秋至
来源:juejin.cn/post/7262243685898960955
ava

收起阅读 »

如何选择 Android 唯一标识符

前言 大家好,我是未央歌,一个默默无闻的移动开发搬砖者~ 本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。 标识符 IMEI 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10...
继续阅读 »

前言


大家好,我是未央歌,一个默默无闻的移动开发搬砖者~


本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。


标识符


IMEI



  • 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10+ 开始官方取消了获取 IMEI 的 API,无法获取到 IMEI 了


fun getIMEI(context: Context): String {
val telephonyManager = context
.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
return telephonyManager.deviceId
}

Android ID(SSAID)



  • 无需任何权限

  • 卸载安装不会改变,除非刷机或重置系统

  • Android 8.0 之后签名不同的 APP 获取的 Android ID 是不一样的

  • 部分设备由于制造商错误实现,导致多台设备会返回相同的 Android ID

  • 可能为空


fun getAndroidID(context: Context): String {
return Settings.System.getString(context.contentResolver,Settings.Secure.ANDROID_ID)
}

MAC 地址



  • 需要申请权限,Android 12 之后 BluetoothAdapter.getDefaultAdapter().getAddress()需要动态申请 android.permission.BLUETOOTH_CONNECT 权限

  • MAC 地址具有全局唯一性,无法由用户重置,在恢复出厂设置后也不会变化

  • 搭载 Android 10+ 的设备会报告不是设备所有者应用的所有应用的随机化 MAC 地址

  • 在 Android 6.0 到 Android 9 中,本地设备 MAC 地址(如 WLAN 和蓝牙)无法通过第三方 API 使用 会返回 02:00:00:00:00:00,且需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION 权限


Widevine ID



  • DRM 数字版权管理 ID ,访问此 ID 无需任何权限

  • 对于搭载 Android 8.0 的设备,Widevine 客户端 ID 将为每个应用软件包名称和网络源(对于网络浏览器)返回一个不同的值

  • 可能为空


fun getWidevineID(): String {
try {
val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L)
val mediaDrm = MediaDrm(WIDEVINE_UUID)
val widevineId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
val sb = StringBuilder();
for (byte in widevineId) {
sb.append(String.format("x", byte))
}
return sb.toString();
} catch (e: Exception) {
} catch (e: Error) {
}
return ""
}

AAID



  • 无需任何权限

  • Google 推出的广告 ID ,可由用户重置的标识符,适用于广告用例

  • 系统需要自带 Google Play Services 才支持,且用户可以在系统设置中重置



重置后,在未获得用户明确许可的情况下,新的广告标识符不得与先前的广告标识符或由先前的广告标识符所衍生的数据相关联。




还要注意,Google Play 开发者内容政策要求广告 ID“不得与个人身份信息或任何永久性设备标识符(例如:SSAID、MAC 地址、IMEI 等)相关联。”




在支持多个用户(包括访客用户在内)的 Android 设备上,您的应用可能会在同一设备上获得不同的广告 ID。这些不同的 ID 对应于登录该设备的不同用户。



OAID



  • 无需任何权限

  • 国内移动安全联盟出台的“拯救”国内移动广告的广告跟踪标识符

  • 基本上是国内知名厂商 Android 10+ 才支持,且用户可以在系统设置中重置


UUID



  • 生成之后本地持久化保存

  • 卸载后重新安装、清除应用缓存 会改变


如何选择


同个开发商需要追踪对比旗下应用各用户的行为



  • 可以采用 Android ID(SSAID),并且不同应用需使用同一签名

  • 如果获得的 Android ID(SSAID)为空,可以用 UUID 代替【 OAID / AAID 代替也可,但需要引入第三方库】

  • 在 Android 8.0+ 中, Android ID(SSAID)提供了一个在由同一开发者签名密钥签名的应用之间通用的标识符


希望限制应用内的免费内容(如文章)



  • 可以采用 UUID ,作用域是应用范围,用户要想规避内容限制就必须重新安装应用


用户群体主要是大陆



  • 可以采用 OAID ,低版本配合采用 Android ID(SSAID)/ UUID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等


用户群体在海外



  • 可以采用 AAID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等
作者:未央歌
来源:juejin.cn/post/7262558218169008188

收起阅读 »

Flutter路由跳转参数处理小技巧

需求 我们在开发应用中,经常会出现一个界面跳转到另外一个界面并带有参数传递,在Android中大家都知道使用Intent传递参数,在第二个Activity中onCreate中可以获取到这个参数。 实现 那么在Flutter中,我们经常会使用路由跳转到另外...
继续阅读 »

需求


我们在开发应用中,经常会出现一个界面跳转到另外一个界面并带有参数传递,在Android中大家都知道使用Intent传递参数,在第二个Activity中onCreate中可以获取到这个参数。


实现


那么在Flutter中,我们经常会使用路由跳转到另外一个界面,那么如果这个时候需要传参。 代码如下:


/// 路由跳转并带参数
 Navigator.pushNamed(
            context,
            RouteConst.routeNext,
            arguments: (TestArguments("一笑轮回""江苏省徐州市")),
);       
     
     
/// 测试数据模型
class TestArguments {
  String? name;
  String? address;
  TestArguments(this.name, this.address);
}

没错,直接赋值arguments字段就可以了,那么我们如何获取呢?


在第二个页面中


class TwoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 从路由设置中获取传递的参数
    var arguments = ModalRoute.of(context)?.settings.arguments;
    // 其他部分的代码...
  }
}


我们需要通过 ModalRoute.of(context)?.settings.arguments获取数据,那么我们直接在 initState方法中直接通过 ModalRoute.of(context)?.settings.arguments获取,会报错


这里出错原因,可以通过错误并查看源码可知,这里部讲述。


我们有的时候需要在initState方法中获取数据并处理一些事情,我们应该怎么做呢?


下面提供一个小技巧。





  • 路由定义


class RouteConst {
  static const routeNext = "/route_next";
}


class RoutePathConst {
  static var routePaths = <String, Widget Function(BuildContext context)>{
    RouteConst.routeNext: (context) => ArgumentsNextPage(),
  };
}




  • 跳转代码


 Navigator.pushNamed(
            context,
            RouteConst.routeNext,
            arguments: (TestArguments("一笑轮回""江苏省徐州市")),
          );

/// 测试数据模型
class TestArguments {
  String? name;
  String? address;

  TestArguments(this.name, this.address);
}




  • 定义ArgumentsMixin


/// Arguments参数数据
mixin ArgumentsMixin {
  late final Object? arguments;
}

/// 路由拼接的参数数据
mixin RouteQueryMixin {
  final Map<String, String> routeParams = HashMap();
}




  • 重写onGenerateRoute



void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      onGenerateRoute: (settings) {
        var uri = Uri.parse(settings.name ?? "");
        var route = uri.path;
        var params = uri.queryParameters;
        if (!RoutePathConst.routePaths.containsKey(route)) {
          return null;
        }
        return MaterialPageRoute(
          builder: (context) {
            var widgetBuilder = RoutePathConst.routePaths[route];
            var widget = widgetBuilder!(context);
            if (widget is RouteQueryMixin) {
              (widget as RouteQueryMixin).routeParams.addAll(params);
            }
            if (widget is ArgumentsMixin) {
              (widget as ArgumentsMixin).arguments = settings.arguments;
            }
            return widget;
          },
          settings: settings,
        );
      },
    );
  }
}





  • 创建ArgumentsNextPage



///第二页
class ArgumentsNextPage extends StatefulWidget
    with ArgumentsMixin, RouteQueryMixin {
  ArgumentsNextPage({super.key});

  @override
  State<ArgumentsNextPage> createState() => _ArgumentsNextPageState();
}

class _ArgumentsNextPageState extends State<ArgumentsNextPage> {
  /// 传参数据文本
  String get result {
    // Arguments传参数据
    TestArguments? arguments;
    if (widget.arguments != null && widget.arguments is TestArguments) {
      arguments = widget.arguments as TestArguments;
    }

    // 路由拼接的数据
    var params = widget.routeParams;

    // 拼接结果数据
    return "arguments:name=${arguments?.name ?? ""} address=${arguments?.address ?? ""} \nrouteParams=$params";
  }

  @override
  void initState() {
    super.initState();
    print("result=$result}");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: XYAppBar(
        title: "第二页",
        onBack: () {
          Navigator.pop(context);
        },
      ),
      body: Center(
        child: Text(result),
      ),
    );
  }
}


这样就OK了,好像没讲啥,直接看代码吧。


详细代码见:github.com/yixiaolunhui/flutter_xy


作者:移动小样
来源:mdnice.com/writing/3d43c6e3544b45c59773b133a135fb01
收起阅读 »

Flutter-数字切换动画

效果 需求 数字切换时新数字从上往下进入,上个数字从上往下出 新数字进入时下落到位置并带有回弹效果 上个数字及新输入切换时带有透明度和缩放动画 实现 主要采用Animat...
继续阅读 »

效果





需求





  • 数字切换时新数字从上往下进入,上个数字从上往下出



  • 新数字进入时下落到位置并带有回弹效果



  • 上个数字及新输入切换时带有透明度和缩放动画


实现


主要采用AnimatedSwitcher实现需求,代码比较简单,直接撸


import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_xy/widgets/xy_app_bar.dart';

class NumAnimPage extends StatefulWidget {
  const NumAnimPage({super.key});

  @override
  State<NumAnimPage> createState() => _NumAnimPageState();
}

class _NumAnimPageState extends State<NumAnimPage> {
  int _currentNum = 0;

  // 数字文本随机颜色
  Color get _numColor {
    Random random = Random();
    int red = random.nextInt(256);
    int green = random.nextInt(256);
    int blue = random.nextInt(256);
    return Color.fromARGB(255, red, green, blue);
  }

  // 数字累加
  void _addNumber() {
    setState(() {
      _currentNum++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: XYAppBar(
        title: "数字动画",
      ),
      body: Center(
        child: _bodyWidget(),
      ),
    );
  }

  Widget _bodyWidget() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedSwitcher(
          duration: const Duration(milliseconds: 500),
          transitionBuilder: (Widget child, Animation<double> animation) {
            Offset startOffset = animation.status == AnimationStatus.completed
                ? const Offset(0.0, 1.0)
                : const Offset(0.0, -1.0);
            Offset endOffset = const Offset(0.0, 0.0);
            return SlideTransition(
              position: Tween(begin: startOffset, end: endOffset).animate(
                CurvedAnimation(parent: animation, curve: Curves.bounceOut),
              ),
              child: FadeTransition(
                opacity: Tween(begin: 0.0, end: 1.0).animate(
                  CurvedAnimation(parent: animation, curve: Curves.linear),
                ),
                child: ScaleTransition(
                  scale: Tween(begin: 0.5, end: 1.0).animate(
                    CurvedAnimation(parent: animation, curve: Curves.linear),
                  ),
                  child: child,
                ),
              ),
            );
          },
          child: Text(
            '$_currentNum',
            key: ValueKey<int>(_currentNum),
            style: TextStyle(fontSize: 100, color: _numColor),
          ),
        ),
        const SizedBox(height: 80),
        ElevatedButton(
          onPressed: _addNumber,
          child: const Text(
            '数字动画',
            style: TextStyle(fontSize: 25, color: Colors.white),
          ),
        ),
      ],
    );
  }
}


具体见github:https://github.com/yixiaolunhui/flutter_xy


作者:移动小样
来源:mdnice.com/writing/9645b22a9a54493f9f2e3f74e60d17c7
收起阅读 »

我的又一个神奇的框架——Skins换肤框架

为什么会有换肤的需求 app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。 换肤是什么 换...
继续阅读 »

为什么会有换肤的需求


app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。


换肤是什么


换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。


Skins怎么使用


Skins就是一个解决这样一种换肤需求的框架。


// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}

我以更换皮肤颜色为例,打开res/colors.xml。


<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>

将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。
然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。


private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}

另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。


val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")

这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。
SkinLoader还提供了更简洁设置View颜色的方法。


override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}

override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}

override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}

框架原理解析


先看BaseSkinActivity的源码。


package dora.skin.base

import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*

abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {

private val constructorArgs = arrayOfNulls<Any>(2)

override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}

private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}

private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}

@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}

override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}

override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}

override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}

companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}

我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。


package dora.lifecycle.application

import android.app.Application
import android.content.Context
import dora.skin.SkinManager

class SkinsAppLifecycle : ApplicationLifecycleCallbacks {

override fun attachBaseContext(base: Context) {
}

override fun onCreate(application: Application) {
SkinManager.init(application)
}

override fun onTerminate(application: Application) {
}
}

所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。


    /**
* 从xml的属性集合中获取皮肤相关的属性。
*/

fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}

我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。


package dora.skin.attr

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager

enum class SkinAttrType(var attrType: String) {

/**
* 背景属性。
*/

BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},

/**
* 字体颜色。
*/

TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},

/**
* 图片资源。
*/

SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};

abstract fun apply(view: View, resName: String)

/**
* 获取资源管理器。
*/

val loader: SkinLoader
get() = SkinManager.getLoader()
}

当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。


开源项目传送门


如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dvi

ew…] 。

收起阅读 »

一名(陷入Android无法自拔的)大二狗的年中总结

前言 大家好,我是入行没多久,不会写代码的RQ。一名来自双非学校的大二狗。2023时间已经过去了一半,我的大学生活也过去了一半。借着这个2023年中总结的话题我也想给我的前半段大学生活做一个总结和记录(希望大家原谅理工男的表达能力,已经在学着写博客了🙃)。 大...
继续阅读 »

前言


大家好,我是入行没多久,不会写代码的RQ。一名来自双非学校的大二狗。2023时间已经过去了一半,我的大学生活也过去了一半。借着这个2023年中总结的话题我也想给我的前半段大学生活做一个总结和记录(希望大家原谅理工男的表达能力,已经在学着写博客了🙃)。


大学之前


大学之前,我的高中初中都是在一个小乡镇度过,每天都是过着教室、食堂、厕所三点一线的生活。可能偶尔会和几个好兄弟打打球,开开黑。那时候一心只读圣贤书,从未碰过电脑(也只有偶尔去网吧玩玩电脑游戏),也未曾了解过任何跟代码相关的东西。只有在高三快毕业了,学校进行志愿填报培训的时候,我才在想我想干什么。


qq_pic_merged_1689681281489.jpg


我想学编程,我想搞钱,我要成为编程高手!!哈哈哈,当时的确是这么想的,因为我一直都觉得会电脑,会编程的“大黑客”很酷!。然而结果是


qq_pic_merged_1689681309027.jpg


当时我还一时兴起,在京东上买了本0基础学python的书:


IMG_20230718_152056.jpg


奈何高三学业繁忙没时间看,而且也没有电脑实操,看了十几页压根不知道在讲什么,之后这本书也就放着吃灰了。现在看来当时确实挺傻×的。后来上了大学,自学了python,这本书也就送给了室友。


高三的时候大家都想着我要上某某985!我要上某某211!当然我也不例外。然而等到高考出分,才知道现实是多么残酷。我的高考成绩也只够报一个末流的211,报不了什么985。思虑最终我报了一个专业性比较强的双非计算机。因为我觉得一个专业不对口的末流211不如一个专业好的双非。


上大学前我是保持怀疑的,我没有任何相关编程经验,甚至是接触电脑的机会都少。不过幸运的是家里人都支持我,给我买了一台不错的笔记本。那个暑假我加入了我们学校的新生群,我发现原来大家都是卷王。有初中就开始接触编程的,有高中就学完的java的,有暑假已经快把c语言学完了的。为了不落后,高考完的那个后半个暑假我也在偷偷学c语言,能力有限,到开学也才学到指针多一点点(指针这个东西对于当时的我简直就是噩梦)。


大一


大一开学后,我同大多数人一样,满怀期待地踏进了向往的大学生活。在第一次年级集中会上,我收到了一份宣传单。那是一份我们学校的一个互联网组织的宣传单。分有产品、视觉、后端、移动、前端、运维几个部门。听说里面全是编程大牛,学校里顶尖技术人员的集聚地。这不就是我想成为的人吗?于是我下定决心我要加入他们。


大一的时候大部分课余时间都花在了这个叫做红岩网校工作站的课程上面。大一上半个学期学会了javase,下半个学期开始学写APP,会写几个简单的Activity页面,当时我还写了个整蛊APP(只是简单将声音放到最大然后播放整蛊音乐lost-rivers。哈哈哈这个不提倡,小心被打)。当然学校课程我也没有忘记,我记得c语言期末大作业自己写了个贪吃蛇和俄罗斯方块:


image.png


image.png


一行一行敲了八九百行,对于当时还是编程小白的我是个不小的成就了。


后来的一整个寒假都在写我们移动开发部的寒假考核,也是我人生中的第一个项目--彩云天气app(地址就不贴了,现在看来写的代码就是💩)。


大一的下学期,开学自学了Kotlin语言,从此再也不想用java了😭。之后也是按照网校的课程学了jetpack、rxjava、retrofit、MvvM等等。到了五一,写了自己的第二个项目--星球app(时间管理类app),也是网校的期中考核(当然也顺利通过啦~)。后面自学了python,简单写了一个抢课的脚本(以后再也不怕抢不到课了😭)。之后自己租了个服务器用python搭了个QQ机器人,后面搞到网校招新群里去玩了。不得不说Bot社区真的不错,文档什么的都很完善,对QQ机器人感兴趣的可以试试(概览 | Bot (baka.icu))。


大一的暑假,我留在了学校参加了网校的暑期培训。培训期间简单研究了一下Android性能优化跟LeakCanary,然后也是写了自己的第三个项目:开眼APP(RQ527/KaiYan,图片可能寄掉了。)


最终呢也是没有辜负自己的努力通过了最终的考核成为了网校的干事:


mmexport1689672953594.jpg

总的来说,大一学年算是踏入了编程的门吧,没有在荒废中度过。同时也要感谢网校给了我这个机会😁。


大二


大二的课余时间主要都花在了给移动开发部门培养新血液的事情上面。因为我的上一届也就是带我们的学长他们大三了,准备考研的考研,就业的就业,自然教学的任务就落到了我们头上。期间上了三节课,我发现给他们上课的同时也是给我自己上课。学习一个东西最有效的方式就是给别人讲懂。


这是大二刚开学的宣讲会😁:


IMG_20221003_185411.jpg


1664878518099.jpeg


大二期间我还了解了一下ktor和compose,嗯~,不算深入吧,简单写了几个demo。
同时自己也接手了一个多人项目,跟我们部门的另外一个人写一个类似于微博投票表决的项目,不过还没上线。


下半个学期自己用hexo+butterfly搭了个个人博客网站:rq527.github.io (还没钱买域名,暂时先用github吧😭),页面大概长这样:


image-20230719144318074


image-20230719144342289


个人思考


我认识到了什么



  • 接受自己的平庸,接受任何方面的平庸。

  • 永远不要斤斤计较

  • 杜绝一分钟热度,永远保持一颗热忱的心

  • 打铁还需自身硬

    从入行Android 开发以来,网上很多人都说 “Android 开发早就凉了,现在就是死路一条”,“现在学Android就是49年入国军!”等等。但是我身边同行的人还不是能找到实习,找到工作。我的意思是,什么事情都是需要自己有实力。





说实话,上了大学我最痛惜的是那些曾经交好的朋友也逐渐不联系了,一张通知书撕裂了一群人,以后再见也不知道是什么时候了。


未来的事情



  • 管理移动开发部

  • 找实习(目标是进大厂)


盘点一下要做的事情,发现太多了,主要的方向是这两个。人外有人,天外有天,比你牛逼的人还有很多,一直保持学习吧🤕!


最后


最后我想说很感谢家里人的支持,他们没有说反对我,强制要求我当老师,当警察等等,而是支持我所做的一切。同时也很感谢那个她,陪我一起成长,学习,愿意和我分享快乐,听我诉说(世上最幸运的事情莫过于此了吧😁)。也很感谢网校给我这么一个平台,让我认识了很多志

作者:RQ527
来源:juejin.cn/post/7257056512610517048
同道合的兄弟和伙伴。

收起阅读 »

工信部又出新规!爬坑指南

一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
继续阅读 »

一、背景


工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


二、整改


2.1 个人信息保护


2.1.1 基本模式(无权限、无个人信息获取模式)


这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


这个说法有点抽象,我们来看下友商已经做好的案例。


腾讯视频



从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


网易云音乐



网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


2.1.2 隐私政策内容


如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


2.2 app权限调用


2.2.1 应用内权限调用



  1. 获取定位信息和生物特征识别信息


在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。



如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。



  1. 其他权限


其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图



2.3 应用软件升级


2.3.1 更新


应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图



2.4 应用签名


需要保

作者:付十一
来源:juejin.cn/post/7253610755126476857
证签名的真实有效性。

收起阅读 »

Android常见问题

1.1.Demo为啥手机号验证无法登录? 首先我们将demo跑起来是UI是这个样式的点击4.0.3版本号两下,会出现一个提示 我们点击ok2.切换到这个页面我们点击 服务器配置将在管理后台的appkey填写以后 点击下面的保存这样我们在页面正常按照在环信管理后...
继续阅读 »

1.1.Demo为啥手机号验证无法登录?

首先我们将demo跑起来是UI是这个样式的

点击4.0.3版本号两下,会出现一个提示 我们点击ok
2.
切换到这个页面我们点击 服务器配置

将在管理后台的appkey填写以后 点击下面的保存
这样我们在页面正常按照在环信管理后台申请的 环信id 登录就可以了 (登录方式是账号密码登录)
2.修改会话条目的尺寸宽高 他是属于EaseBaseLayout ,相比EaseChatLayout 他是ChatLayout的父类 关于尺寸大小的设计是存在基本都在父类中


3.集成后环信后,App被其他应用平台下架,厂商反馈是自启动的原因

将此服务去除


4.如何将百度地图切换到高德地图


1.因为百度地图将easeimkit中关于百度地图的集成去掉,改成高德地图;2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度。
2..点击位置的点击事件更换 ,demo中的点击事件是在EaseChatFragment下的onExtendMenuItemClick里面官方提供了EaseBaiduMapActivity 这个定位页面。2.修改为高德其实非常简单只需要在ChatFragment操作就可以了2.1修改点击事件在ChatFragment的onExtendMenuItemClick方法中添加2.2 在自己实现高德地图的页面返回定位信息 参数名称不要修改 不然其它地方也要修改2.3接下来在ChatFragment中的onActivityResult中接收定位信息并发送消息走到这里从高德获取的位置消息已经成功发送给好友了 接下来是获取查看好友位置消息2.4 查看位置消息还是在ChatFragment里 通过getCustomChatRow方法LoccationAdapter 继承位置消息展示 重写。
5.播放语音消息语音消息的声音小(不是语音通话)
(1)首先要打开扬声器 如果觉得声音还是比较小

(2)将ui库中调用的原声音量模式修改为媒体音量模式




收起阅读 »

Flutter如何实现IOC与AOP

在Flutter中实现IOC(Inversion of Control,控制反转)与AOP(Aspect-Oriented Programming,面向切面编程)之前,让我们先来了解一下这两个概念。 IOC(控制反转) 是一种设计原则,它将应用程序的控制权从应...
继续阅读 »

在Flutter中实现IOC(Inversion of Control,控制反转)与AOP(Aspect-Oriented Programming,面向切面编程)之前,让我们先来了解一下这两个概念。


IOC(控制反转) 是一种设计原则,它将应用程序的控制权从应用程序本身转移到外部框架或容器。传统上,应用程序会自己创建和管理对象之间的依赖关系。而在IOC中,对象的创建和管理被委托给一个专门的框架或容器。框架负责创建和注入对象,以实现松耦合和可扩展的架构。通过IOC,我们可以将应用程序的控制流程反转,从而实现更灵活、可测试和可维护的代码。


AOP(面向切面编程) 是一种编程范式,用于将横切关注点(如日志记录、事务管理、性能监控等)从应用程序的主要业务逻辑中分离出来。AOP通过在特定的切入点上织入额外的代码(称为切面),从而实现对这些关注点的统一管理。这种分离和集中的方式使得我们可以在不修改核心业务逻辑的情况下添加、移除或修改横切关注点的行为。


对于Java开发者来说,IOC和AOP可能已经很熟悉了,因为在Java开发中有许多成熟的框架,如Spring,提供了强大的IOC和AOP支持。


在Flutter中,尽管没有专门的IOC和AOP框架,但我们可以利用语言本身和一些设计模式来实现类似的功能。


接下来,我们可以探讨在Flutter中如何实现IOC和AOP的一些常见模式和技术。无论是依赖注入还是横切关注点的管理,我们可以使用一些设计模式和第三方库来实现类似的效果,以满足我们的开发需求


1. 控制反转(IOC):


依赖注入(Dependency Injection):依赖注入是一种将依赖关系从组件中解耦的方式,通过将依赖项注入到组件中,实现控制反转的效果。在Flutter中,你可以使用get_it库来实现依赖注入。下面是一个示例:


import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

class UserService {
String getUser() => 'John Doe';
}

class GreetingService {
final UserService userService;

GreetingService(this.userService);

String greet() {
final user = userService.getUser();
return 'Hello, $user!';
}
}

void main() {
// 注册依赖关系
GetIt.instance.registerSingleton<UserService>(UserService());
GetIt.instance.registerSingleton<GreetingService>(
GreetingService(GetIt.instance<UserService>()),
);

runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final greetingService = GetIt.instance<GreetingService>();

return MaterialApp(
title: 'IOC Demo',
home: Scaffold(
appBar: AppBar(title: Text('IOC Demo')),
body: Center(child: Text(greetingService.greet())),
),
);
}
}


在上述示例中,我们定义了UserServiceGreetingService两个类。GreetingService依赖于UserService,我们通过依赖注入的方式将UserService注入到GreetingService中,并通过get_it库进行管理。


2. 面向切面编程(AOP):


在Flutter中,可以使用Dart语言提供的一些特性,如Mixin和装饰器(Decorator)来实现AOP。


Mixin:Mixin是一种通过将一组方法和属性混入到类中来实现代码复用的方式。下面是一个示例:


import 'package:flutter/material.dart';

mixin LogMixin<T extends StatefulWidget> on State<T> {
void log(String message) {
print('[LOG]: $message');
}
}

class LogButton extends StatefulWidget {
final VoidCallback onPressed;

const LogButton({required this.onPressed});

@override
_LogButtonState createState() => _LogButtonState();
}

class _LogButtonState extends State<LogButton> with LogMixin {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
log('Button clicked');
widget.onPressed();
},
child: Text('Click Me'),
);
}
}

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AOP Demo',
home: Scaffold(
appBar: AppBar(title: Text('AOP Demo')),
body: Center(child: LogButton(onPressed: () => print('Button pressed'))),
),
);
}
}



在上面的示例中,我们定义了一个LogMixin,其中包含了一个log方法,用于记录日志。然后我们在_LogButtonState中使用with LogMixin将日志记录功能混入到_LogButtonState中。每次按钮被点击时,会先打印日志,然后调用传入的回调函数。


装饰器:装饰器是一种将额外行为添加到方法或类上的方式。下面是一个示例:


void logDecorator(Function function) {
print('[LOG]: Method called');
function();
}

@logDecorator
void greet() {
print('Hello, world!');
}

void main() {
greet();
}

在Flutter中,虽然没有专门的IOC(控制反转)和AOP(面向切面编程)框架,但我们可以利用一些设计模式和技术来实现类似的效果。


对于IOC,我们可以使用依赖注入(Dependency Injection)的方式实现。依赖注入通过将依赖项注入到组件中,实现了控制反转的效果。在Flutter中,可以借助第三方库如get_itkiwi来管理依赖关系,将对象的创建和管理交由依赖注入框架处理。


在AOP方面,我们可以使用Dart语言提供的Mixin和装饰器(Decorator)来实现类似的功能。Mixin是一种通过将一组方法和属性混入到类中的方式实现代码复用,而装饰器则可以在不修改被装饰对象的情况下,添加额外的行为或改变对象的行为。


通过使用Mixin和装饰器,我们可以在Flutter中实现横切关注点的管理,例如日志记录、性能监测和权限控制等。通过将装饰器应用于关键的方法或类,我们可以在应用程序中注入额外的功能,而无需直接修改原始代码。


需要注意的是,以上仅为一些示例,具体实现方式可能因项目需求和个人偏好而有所不同。在Flutter中,我们可以灵活运用设计模式、第三方库和语言特性,以实现IOC和AOP的效果,从而提升代码的可维护性、可扩展性和重用性。


总结而言,尽管Flutter没有专门的IOC和AOP框架,但我们可以借助依赖注入和装饰器等技术,结合常见的设计模式,构建灵活、可测试和可维护的应用程序。这些技术和模式为开发者提供了良好的开发体验和代码结构。


希望对您有所帮助谢谢!!

作者:北漂十三载
来源:juejin.cn/post/7251032736692600869

收起阅读 »

Android 内存治理之线程

1、 前言   当我们在应用程序中启动一个线程的时候,也是有可能发生OOM错误的。当我们看到以下log的时候,就说明系统分配线程栈失败了。 java.lang.OutOfMemoryError: pthread_create (1040KB stack) fa...
继续阅读 »

1、 前言


  当我们在应用程序中启动一个线程的时候,也是有可能发生OOM错误的。当我们看到以下log的时候,就说明系统分配线程栈失败了。


java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory


这种情况可能是两种原因导致的。



  • 第一个就是系统的内存不足的时候,我们去启动一个线程。

  • 第二种就是进程内运行的线程总数超过了系统的限制。



  如果是内存不足的情况,需按照堆内存治理的方式来进行解决,检查应用内存泄漏问题并优化,此情况不作为本次讨论的重点。

  本次主要讨论进程内运行的线程总数超过了系统的限制所导致的情况。出现此情况时,我们就需要通过控制并发的线程总数来解决这个问题。


  想要控制并发的线程数。最直接的一种方式就是利用回收的思路,也就是让我们的线程通过串行的方式来执行;一个线程执行完毕之后,再启动下一个线程。这样就能够让并发的线程总数达到一个可控的状态。

  另外一种方式就是通过复用来解决,让同一个线程的实例可以被反复的利用,只创建较少的线程实例,就能完成大量的异步操作。


2、异步任务的方式对比


  对比一下,在安卓平台我们比较常用的开启异步任务的方式中,有哪些是更加有利于我们进行线程总数的控制的。


开启异步任务的方式特点
Thread.start()并行,难以管理
HandlerThread带消息循环的线程,线程内部串行任务(线程复用)
AsyncTask轻量级,串行(3.0以上),可以结合线程池使用
线程池可管理并发数,池化复用线程
Kotlin协程简化异步编程代码,复用线程,提高并发效率
##### 2.1 Thread

  从最简单的直接创建Thread的实例的方式来说起。在Java中这种方式虽然是最简单的去开启一个线程的方式,但是在实际开发中,一旦我们通过这种方式去自己创建 Thread 类的实例,并且调用 start 来开启一个线程的话,所开启的线程会非常的难以调度和管理。这种线程也就是我们平时所说的野线程。所以我们最好不要直接的创建thread类的实例。


2.2 HandlerThread

public class HandlerThread extends Thread { }

  HandlerThread是Thread类的子类,对Thread做了很多便利的封装。它有自己的Loop,它能够进行消息循环,所以就能够做到通过Handler执行异步任务,也能够做到在不同的线程之间,通过Handler进行现成的通讯。我们可以利用Handler的post操作,让我们在一个线程内部串行的执行多个异步任务。从内存的角度来说,也就相当于对线程进行了复用。


2.3 AsyncTask

  AsyncTask是一个相对更加轻量级,专门为了完成执行异步任务,然后返回UI线程更新UI的操作而设计的。对于我们来说,AsyncTask更像是一个任务的概念,而不是一个线程的概念。我们不需要把它当做一个线程去理解。 AsyncTask的本质,其实也是对线程和Handler的封装。



  • Android 1.6前,串行执行,原理:一个子线程进行任务的串行执行;

  • Android 1.6到2.3,并行执行,原理:一个线程数为5的线程池并行执行,但如果前五个任务执行时间过长,会堵塞后续任务执行,故不适合大量任务并发执行;

  • Android 3.0后,串行执行,原理:全局线程池进行串行处理任务;


到了Android 3.0以上版本,默认是串行执行的,但是可以结合线程值来实现有限制的并行。也可以达到一个限制线程总数的目的。


2.4 线程池

  Java语言本身也为我们提供了线程池。线程池的作用就是可以管理并发数,并且能够持续的去复用线程。如果在一个应用内部的全部异步操作,全部都采用线程池的方式来开启的话,那么我们就能够管理我们所有的异步任务了。这样一来,能够大大的降低线程治理的成本。


2.5 Kotlin协程

  在Kotlin中还引入了协程的概念。协程给传统的Java的异步编程带来最大的改变,就是能够让我们更加优雅的去实现异步任务。我们前面所说的这几种异步任务的执行方式,都需要我们额外的去写大量的样本代码。而Kotlin协程就能够做到让我们用写同步代码的方式去写异步代码。


  在语法的层面上,协程的另一个优势就是性能方面。协程能够帮助我们用更少的线程去执行更多的并发任务。同样也降低了我们治理内存的成本。从治理内存的角度来说,用线程池接管线程或者采用协程都是很好的方式。

作者:大神仙
来源:juejin.cn/post/7250357906712854589

收起阅读 »

Vue3 如何开发原生(安卓,ios)

Vue3 有没有一款好用的开发原生的工具 1.uniapp 我个人认为uniapp 适合开发小程序之类的,用这个去开发原生应用会存在一些问题 性能限制:由于 Uniapp 是通过中间层实现跨平台,应用在访问底层功能时可能存在性能损失。与原生开发相比,Uni...
继续阅读 »

Vue3 有没有一款好用的开发原生的工具


1.uniapp 我个人认为uniapp 适合开发小程序之类的,用这个去开发原生应用会存在一些问题




  • 性能限制:由于 Uniapp 是通过中间层实现跨平台,应用在访问底层功能时可能存在性能损失。与原生开发相比,Uniapp 在处理大规模数据、复杂动画和高性能要求的应用场景下可能表现较差。




  • 平台限制:不同平台有着各自的设计规范和特性,Uniapp 在跨平台时可能受到一些平台限制。有些平台特有的功能或界面设计可能无法完全实现,需要使用特定平台的原生开发方式来解决。




  • 生态系统成熟度: 相比于原生开发,Uniapp 的生态系统相对较新,支持和资源相对有限。在遇到问题时,可能难以找到完善的解决方案,开发者可能需要花费更多的时间和精力来解决问题。




  • 用户体验差异: 由于不同平台的设计规范和用户习惯不同,使用 Uniapp 开发的应用在不同平台上的用户体验可能存在差异。开发者需要针对每个平台进行特定的适配和调优,以提供更好的用户体验。




  • 功能支持限制: Uniapp 尽可能提供了跨平台的组件和 API,但某些特定平台的功能和接口可能无法完全支持。在需要使用特定平台功能的情况下,可能需要使用原生开发或自定义插件来解决。




  • uni 文档 uniapp.dcloud.net.cn/




2.react 拥有react native 开发原生应用 Vue无法使用 http://www.reactnative.cn/


3.Cordova cordova.apache.org/ 支持原生html js css 打包成 ios android exe dmg


4.ionic 我发现这个框架支持Vue3 angular react ts 构建Android iOS 桌面程序 这不正合我意 ionicframework.com/docs


前置条件


1.安装 java 环境 和 安卓编辑器sdk



安装完成检查环境变量


image.png


image.png


image.png


检查安卓编辑器的sdk 如果没安装就装一下


image.png


image.png


image.png


ionic


npm install -g @ionic/cli

初始化Vue3项目


安装完成后会有ionic 命令


ionic start [name] [template] [options]
# 名称 模板 类型为vue项目
ionic start app tabs --type vue

image.png


npm install #安装依赖

npm run dev 启动测试

image.png


启动完成后自带一个tabs demo


image.png


运行至android 编辑器 调试


npm run build
ionic capacitor copy android

注意检查


image.png


如果没有这个文件 删除android目录 重新执行下面命令


ionic capacitor copy android

预览


ionic capacitor open android

他会帮你打开安卓编辑器


如果报错说丢失sdk 注意检查sdk目录


image.png.


等待编译


image.png


点击上面绿色箭头运行


image.png


热更新


如果要热更新预览App 需要一个安卓设备


一直点击你的版本号就可以开启开发者模式


bd36c9f72990ae5cf2275e7690c7f354.jpg


开启usb调试 连接电脑


8f1085f12207c5107d39dd8d193dadfb.jpg


ionic capacitor run android -l --external

选择刚才的安卓设备


image.png


成功热更新


image.png


20c29c088e7f4f152fe1af0adbc4035f.jpg


作者:小满zs
来源:juejin.cn/post/7251113487317106745
收起阅读 »

gradle 实用技巧

前言 总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。 实现 以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。 输出打包后 apk 文件路径及 ...
继续阅读 »

前言


总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。


实现


以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。


输出打包后 apk 文件路径及 apk 大小。


Android Studio 最新版本 Run 之后,每次输出的 apk 并没有在这 app/build/outputs 文件夹下(不知道 Android 官方是出于什么考虑要更改这个路径),而是移动到了 build\intermediates\apk\{flavor}\debug\ 目录下。为了方便后续快速找到每次运行完成的 apk ,可以在每次打包后输出 apk 文件路径及大小,从而可以关注一下日常开发过程中自己
的 apk 体积大概是一个什么样的范围。


static def getFileHumanSize(length) {
def oneMB = 1024f * 1024f
def size = String.valueOf((length / oneMB))
def value = new BigDecimal(size)
return value.setScale(2, BigDecimal.ROUND_HALF_UP)
}
/**
* 打包完成后输出 apk 大小*/
android {
applicationVariants.all { variant ->
variant.assembleProvider.configure() {
it.doLast {
variant.outputs.forEach {
logger.error("apk fileName ==> ${it.outputFile.name}")
logger.error("apk filePath ==> ${it.outputFile}")
logger.error("apk fileSize ==> ${it.outputFile.length()} , ${getFileHumanSize(it.outputFile.length())} MB")
}
}
}
}
}

apk fileName ==> app-huawei-global-debug.apk
apk filePath ==> D:\workspace\MinApp\app\build\intermediates\apk\huaweiGlobal\debug\app-huawei-global-debug.apk
apk fileSize ==> 11987818 , 11.43 MB

可以看到 apk 的路径在 build/intermediates 目录下。当然,我们可以通过下面的方法修改这个路径,定义成我们习惯的路径。


gradle 自定义功能的模块化


日常开发中,会有很多关于 build.gradle 的修改和更新。日积月累,build.gradle 的内容越来越多,代码几乎要爆炸了。其实,可以用模块化的思路将每一个小功能单独抽取出来,这样不仅可以减少 build.gradle 的规模,同时小功能可以更加容易的复用。


比如上面定义的输出打包后 apk 文件路径及 apk 大小的功能,我们就可以把他定义在 report_apk_size_after_package.gradle 这样一个文件中,然后在要使用的 build.gradle 中导入即可。


比如我们要在 app module 中使用这个功能,那么就可以直接在其 build.gradle 文件中按照相对路径引入即可。


gradle_dep.png


apply from: file("../custom-gradle/report_apk_size_after_package.gradle") // 打包完成后输出 apk 大小


修改 release 包的输出路径及文件名


输出 apk 后改名的需求,应该已经很普遍了。在最终输出的 apk 文件中,我们可以追加一些和代码相关的信息,方便通过 apk 文件名迅速确定一些内容。


def getCommit() {
def stdout = new ByteArrayOutputStream()
exec {
commandLine "git"
args "rev-parse", "--short", "HEAD"
standardOutput = stdout
}
return stdout.toString().trim()
}

def getBranch() {
def stdout = new ByteArrayOutputStream()
exec {
commandLine "git"
args "rev-parse", "--abbrev-ref", "HEAD"
standardOutput = stdout
}
return stdout.toString().trim()
}

def gitLastCommitAuthorName() {
return "git log -1 --pretty=format:'%an'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}

def gitLastCommitAuthorEmail() {
return "git log -1 --pretty=format:'%ae'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}


android {
def i = 0
applicationVariants.all { variant ->
if (variant.assembleProvider.name.contains("Debug")) {
// 只对 release 包生效
return
}

// 打包完成后复制到的目录
def outputFileDir = "${rootDir.absolutePath}/build/${variant.buildType.name}/${variant.versionName}"
//确定输出文件名
def today = new Date()
def path = ((project.name != "app") ? project.name : rootProject.name.replace(" ", "")) + "_" + variant.flavorName + "_" + variant.buildType.name + "_" + variant.versionName + "_" + today.format('yyyy_MM_dd_HH_mm') + "_" + getBranch() + "_" + getCommit() + "_" + gitLastCommitAuthorName() + ".apk"
println("path is $path")
variant.outputs.forEach {
it.outputFileName = path
}
// 打包完成后做的一些事,复制apk到指定文件夹
variant.assembleProvider.configure() {
it.doLast {
File out = new File(outputFileDir)
copy {
variant.outputs.forEach { file ->
copy {
from file.outputFile
into out
}
}
}
}
}
}
}

打 release 包后的日志


let me do something after assembleHuaweiGlobalRelease
apk fileName ==> MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk filePath ==> D:\workspace\MinApp\app\build\outputs\apk\huaweiGlobal\release\MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk fileSize ==> 4959230 , 4.73 MB

通过上面的日志,可以看到 MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk 包含了 ProjectName、flavor、debug/release、打包时间、分支、commitId 即最后一个 commitor 邮箱这些信息。通过这样的信息,可以更加方便快速的定位问题和解决问题。


妙用 flavor 实现不同的功能


使用 flavor 可以定制代码的不同功能及组合。不用把所有内容一锅乱炖似的放在一起搞。比如 MiniApp 随着演示代码的增多,已经逐渐丧失了 Mini 的定位,Apk 大小已经来到了 22 MB之多。究其原因,就是把所有代码验证和功能都放在一起导致的,音视频、compose、C++ 代码全都混在一起。部分功能不常用,但是每次为了验证一部分小功能,却要连带编译这些所有功能,同时打出的 apk 包体积也变大了,从编译到安装,无形中浪费了很多时间。


因此,可以通过 flavor 将一些不常用的功能,定义到不同的 flavor 中,真正需要的时候,编译相应 flavor 的包即可。


首先我们可以从 type 维度定义两个 flavor


    flavorDimensions "channel", "type"
productFlavors {
xiaomi {
dimension "channel"
}
oppo {
dimension "channel"
}
huawei {
dimension "channel"
}

global {
dimension "type"
}
local {
dimension "type"
}
}

在 type 维度,我们可以认为 global 是功能完整的 flavor,而 local 是部分功能缺失的 flavor 。那么具体缺失哪些功能呢?这就要从实际情况出发了,比如产品定义,代码架构及模块组合之类的。回到 Mini App 中,我们使用不同 flavor 的目标就是通过减少非常用功能模块,获得一个体积相对较小的 apk. 因此,可以做如下配置。


    if (source_code.toBoolean()) {
globalImplementation project(path: ':thirdlib')
} else {
globalImplementation 'com.engineer.third:thirdlib:1.0.0'
}
globalImplementation project(path: ':compose')
globalImplementation 'com.github.CarGuo.GSYVideoPlayer:GSYVideoPlayer:v8.1.8-release-jitpack'

如上我们只在 global 这个 flavor 依赖 thirdlib, compose, GSYVideoPlayer 这些组件。这样 local flavor 就不会引入这些组件,那么就会带来一个问题,local flavor 编译的时候没有这些组件的类,会出现找不到类的情况。


class_missing.png


对于这种情况,我们可以在项目 src 和 main 同级的目录下,创建 local 文件夹,然后在其内部按照具体 Class 文件的路径创建相应的类即可。


package com.engineer.compose.ui

import com.engineer.BasePlaceHolderActivity

/**
* Created on 2022/7/31.
* @author rookie
*/

class MainComposeActivity : BasePlaceHolderActivity()



package com.engineer.third

import com.engineer.BasePlaceHolderActivity

/**
* Created on 2022/7/31.
* @author rookie
*/

class CppActivity : BasePlaceHolderActivity()

package com.engineer

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.engineer.android.mini.ext.toast

/**
* Created on 2022/8/1.
* @author rookie
*/

open class BasePlaceHolderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
"please use global flavor ".toast()
finish()
}
}

local_flavor.png


这里的思路其实很简单,类似于 leanCanary ,就是在不需要这个功能的 flavor 提供空实现,保证编译可以正常通过即可。缺什么类,建按照类的完整路径创建相应的 Class 文件,这样既保证了编译可以通过,同时在不需要次功能的 flavor 又减少无冗余的代码。


flavor 扩展


其实顺着上面的思路,基于不同 flavor 我们可以做更多的事情。基于 Java 虚拟机的类加载机制限制,相同的类只能有一个,因此我们无法做的事情是,通过 flavor 创建同名的类,去覆盖或重写其他 flavor 的逻辑,这种在编译阶段(其实是在创建同名类的阶段)就会报错。


但是,有些功能是可以被覆盖和定制的。比如包名、App 的名称、icon 之类。这些配置可以通过 AndroidManifest.xml/gradle 进行配置。可以在 local 目录下创建这个 flavor 特有的一些资源文件,这样就可以实现基于 flavor 的产品功能定制了。


比如最简单的修改 applicationId


        global {
dimension "type"
}
local {
dimension "type"
applicationId "com.engineer.android.mini.x"
}

这样,local 和 global 就有了各自不同的 applicationId, 这两种不同 flavor 的包就可以安装在同一台设备了。当然,现在这两个包的 label 和 icon 都是一样的,完全看不出区别。这里就可以利用 flavor 各自的文件夹,来定制各类资源和命名了。


flavor 过滤


不同维度的 flavor 会导致最终的 variant 变多。比如定义 product、channel、type 这些几个 dimension 的之后,后续新增的 flavor 会以乘积的形式增长,但是有些 flavor 又是我们不需要的,这个时候我们就可以过滤掉某些不需要的 flavor 。


比如以上面定义的 channel,type 这两个维度为例,在这两个维度下分别又扩展了 xiaomi/opop/huawei,global/local 这些 flavor 。按照规则会有 2x3x2=12 种 flavor,但实际情况可能不需要这么多,为了减少编译的压力,提升代码的可维护性,我们可以对 flavor 进行过滤。


    variantFilter { variant ->
println "variant is ${variant.flavors*.name}"
def dimens = variant.flavors*.name
def type = dimens[1]
def channel = dimens[0]
switch (type) {
case "global":
if (channel == "xiaomi") {
setIgnore(true)
}
break
case "local":
if (channel == "oppo") {
setIgnore(true)
}
break
}
}

这样我们就成功的过滤掉了 xiaomiGlobal 和 oppoLocal 的 flavor ,一下子就去掉了 4 个 flavor 。


基于现有 task 定制任务


再回顾一下上面的 修改 release 包的输出路径及文件名 的代码实现,我们是在打包完成之后进行了 apk 文件的重命名。


        // 打包完成后做的一些事,复制apk到指定文件夹
variant.assembleProvider.configure() {
it.doLast {
File out = new File(outputFileDir)
copy {
variant.outputs.forEach { file ->
copy {
from file.outputFile
into out
}
}
}
}
}

这里的 doLast 就是说,无论是否需要,每次都会在 assemble 这个 task 完成之后做一件事。这样在某些情况下显得非常的不灵活,尤其是当 doLast 闭包中要做的事情非常繁重的时候。这里的 copy 操作显然是比较轻量的,但是换做是其他操作,比如 apk 安全加固等操作,并不是每次必然需要的操作。这种情况下,就需要我们换一种方式去实现相应的逻辑了。


我们就以加固为例,一般情况下,我们需要对各个版本的 release 包进行加固。因此,我们可以基于现有的 assembleXXXRelease 这个 task 展开。


android {
applicationVariants.all { variant ->
if (variant.assemble.name.contains("Debug")) {
// 只对 release 包生效
return
}

def taskPrefix = "jiagu"
def groupName = "jiagu"
def assembleTask = variant.assembleProvider.name
def taskName = assembleTask.replace("assemble", taskPrefix)
tasks.create(taskName) {
it.group groupName
it.dependsOn assembleTask
variant.assembleProvider.configure() {
it.doLast {
logger.error("let me do something after $assembleTask")
}
}
}
}
}

添加上面的代码后,再执行一下 gradle sync ,我们就可以看到新添加的 jiagu 这个 group 和其中的 task 了。


jiagu.png


这里使用创建 task 的一种方式,使用 createdependsOn ,动态创建 task,并指定其依赖的 task 。


这样当我们执行 ./gradlew jiaguHuaweiLocalRelease 时就可以看到结果了。


> Task :app:assembleHuaweiLocalRelease
let me do something after assembleHuaweiLocalRelease

>
Task :app:jiaguHuaweiLocalRelease

BUILD SUCCESSFUL in 56s
82 actionable tasks: 12 executed, 70 up-to-date

可以看到我们自定义的 task 已经生效了,会在 assembleXXXRelease 这个 task 完成之后执行。


关于 gradle 的使用,可以说是孰能生巧,只要逐渐熟悉了 groovy 的语法和 Java 语法之间的差异,那么就可以逐渐摸索出更多有意思的用法了。


本文源码可以参考 Github MiniApp


小结


可以看到基于 gradle 构建流程,我们仅仅通过编写一些脚本,可以做的事情还是很多的。但是由于 groovy 语法过于灵活,不像 Java 那样有语法提示,因此尝试一些新的语法时难免不知所措。面对这种情况,去看他的源码就好。通过源码,我们就可以知道某个类有哪

作者:IAM四十二
来源:juejin.cn/post/7250071693543145529
些属性,有哪些方法。

收起阅读 »

Android 冷启动优化的3个小案例

背景 为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机...
继续阅读 »

背景


为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机上加起来也不超过50ms的收益,但为了冷启动场景的极致优化,给用户带来更好的体验,任何有收益的优化手段都是值得尝试的。


类预加载


一个类的完整加载流程至少包括 加载、链接、初始化,而类的加载在一个进程中只会触发一次,因此对于冷启动场景,我们可以异步加载原本在启动阶段会在主线程触发类加载过程的类,这样当原流程在主线程访问到该类时就不会触发类加载流程。


Hook ClassLoader 实现


在Android系统中,类的加载都是通过PathClassLoader 实现的,基于类加载的父类委托机制,我们可以通过Hook PathClassLoader 修改其默认的parent 来实现。


首先我们创建一个MonitorClassLoader 继承自PathClassLoader,并在其内部记录类加载耗时


class MonitorClassLoader(
dexPath: String,
parent: ClassLoader, private val onlyMainThread: Boolean = false,
) : PathClassLoader(dexPath, parent) {

val TAG = "MonitorClassLoader"

override fun loadClass(name: String?, resolve: Boolean): Class<*> {
val begin = SystemClock.elapsedRealtimeNanos()
if (onlyMainThread && Looper.getMainLooper().thread!=Thread.currentThread()){
return super.loadClass(name, resolve)
}
val clazz = super.loadClass(name, resolve)
val end = SystemClock.elapsedRealtimeNanos()
val cost = end - begin
if (cost > 1000_000){
Log.e(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
} else {
Log.d(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
}
return clazz;

}
}

之后,我们可以在Application attach阶段 反射替换 application实例的classLoader 对应的parent指向。


核心代码如下:


    companion object {
@JvmStatic
fun hook(application: Application, onlyMainThread: Boolean = false) {
val pathClassLoader = application.classLoader
try {
val monitorClassLoader = MonitorClassLoader("", pathClassLoader.parent, onlyMainThread)
val pathListField = BaseDexClassLoader::class.java.getDeclaredField("pathList")
pathListField.isAccessible = true
val pathList = pathListField.get(pathClassLoader)
pathListField.set(monitorClassLoader, pathList)

val parentField = ClassLoader::class.java.getDeclaredField("parent")
parentField.isAccessible = true
parentField.set(pathClassLoader, monitorClassLoader)
} catch (throwable: Throwable) {
Log.e("hook", throwable.stackTraceToString())
}
}
}

主要逻辑为



  • 反射获取原始 pathClassLoader 的 pathList

  • 创建MonitorClassLoader,并反射设置 正确的 pathList

  • 反射替换 原始pathClassLoader的 parent指向 MonitorClassLoader实例


这样,我们就获取启动阶段的加载类了



基于JVMTI 实现


除了通过 Hook ClassLoader的方案实现,我们也可以通过JVMTI 来实现类加载监控。关于JVMTI 可参考之前的文章 juejin.cn/post/694278…


通过注册ClassPrepare Callback, 可以在每个类Prepare阶段触发回调。




当然这种方案,相比 Hook ClassLoader 还是要繁琐很多,不过基于JVMTI 还可以做很多其他更强大的事。


类预加载实现


目前应用通常都是多模块的,因此我们可以设计一个抽象接口,不同的业务模块可以继承该抽象接口,定义不同业务模块需要进行预加载的类。


/**
* 资源预加载接口
*/

public interface PreloadDemander {
/**
* 配置所有需要预加载的类
* @return
*/

Class[] getPreloadClasses();
}

之后在启动阶段收集所有的 Demander实例,并触发预加载


/**
* 类预加载执行器
*/

object ClassPreloadExecutor {


private val demanders = mutableListOf<PreloadDemander>()

fun addDemander(classPreloadDemander: PreloadDemander) {
demanders.add(classPreloadDemander)
}

/**
* this method shouldn't run on main thread
*/

@WorkerThread fun doPreload() {
for (demander in localDemanders) {
val classes = demander.preloadClasses
classes.forEach {
val classLoader = ClassPreloadExecutor::class.java.classLoader
Class.forName(it.name, true, classLoader)
}
}
}

}

收益


第一个版本配置了大概90个类,在终端机型测试数据显示 这些类的加载需要消耗30ms左右的cpu时间,不同类加载的消耗时间差异主要来自于类的复杂度 比如继承体系、字段属性数量等, 以及类初始化阶段的耗时,比如静态成员变量的立即初始化、静态代码块的执行等。


方案优化思考


我们目前的方案 配置的具体类列表来源于手动配置,这种方案的弊端在于,类的列表需要开发维护,在版本快速迭代变更的情况下 维护成本较大, 并且对于一些大型App,存在着非常多的AB实验条件,这也可能导致不同的用户在类加载上是会有区别的。


在前面的小节中,我们介绍了使用自定义的 ClassLoader可以手动收集 启动阶段主线程的类列表,那么 我们是否可以在端上 每次启动时 自动收集加载的类,如果发现这个类不在现有 的名单中 则加入到名单,在下次启动时进行预加载。 当然 具体的策略还需要做详细设计,比如 控制预加载名单的列表大小, 被加入预加载名单的类最低耗时阈值, 淘汰策略等等。


Retrofit ServiceMethod 预解析注入


背景


Retrofit 是目前最常用的网络库框架,其基于注解配置的网络请求方式及Adapter的设计模式大大简化了网络请求的调用方式。 不过其并没有采用类似APT的方式在编译时生成请求代码,而是采用运行时解析的方式。


当我们调用Retrofit.create(final Class service) 函数时,会生成一个该抽象接口的动态代理实例。



接口的所有函数调用都会被转发到该动态代理对象的invoke函数,最终调用loadServiceMethod(method).invoke 调用。



在loadServiceMethod函数中,需要解析原函数上的各种元信息,包括函数注解、参数注解、参数类型、返回值类型等信息,并最终生成ServiceMethod 实例,对原接口函数的调用其实最终触发的是 这个生成的ServiceMethod invoke函数的调用。


从源码实现上可以看出,对ServiceMethod的实例做了缓存处理,每个Method 对应一个ServiceMethod。


耗时测试


这里我模拟了一个简单的 Service Method, 并调用archiveStat 观察首次调用及其后续调用的耗时,注意这里的调用还未触发网络请求,其返回的是一个Call对象。




从测试结果上看,首次调用需要触发需要消耗1.7ms,而后续的调用 只需要消耗50微妙左右。



优化方案


由于首次调用接口函数需要触发ServiceMethod实例的生成,这个过程比较耗时,因此优化思路也比较简单,收集启动阶段会调用的 函数,提前生成ServiceMethod实例并写入到缓存中。


serviceMethodCache 的类型本身是ConcurrentHashMap,所以它是并发安全的。



但是源码中 进行ServiceMethod缓存判断的时候 还是以 serviceMethodCache为Lock Object 进行了加锁,这导致 多线程触发同时首次触发不同Method的调用时,存在锁等待问题



这里首先需要理解为什么这里需要加锁,其目的也是因为parseAnnotations 是一个好事操作,这里是为了实现类似 putIfAbsent的完全原子性操作。 但实际上这里加锁可以以 对应的Method类型为锁对象,因为本身不同Method 对应的ServiceMethod实例就是不同的。 我们可以修改其源码的实现来避免这种场景的锁竞争问题。




当然针对我们的优化场景,其实不修改源码也是可以实现的,因为 ServiceMethod.parseAnnotations 是无锁的,毕竟它是一个纯函数。 因此我们可以在异步线程调用parseAnnotations 生成ServiceMethod 实例,之后通过反射 写入 Retrofit实例的 serviceMethodCache 中。这样存在的问题是 不同线程可能同时触发了一个Method的解析注入,但 由于serviceMethodCache 本身就是线程安全的,所以 它只是多做了一次解析,对最终结果并无影响。


ServiceMethod.parseAnnotations是包级私有的,我们可以在当前工程创建一个一样的包,这样就可以直接调用该函数了。 核心实现代码如下


package retrofit2

import android.os.Build
import timber.log.Timber
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier

object RetrofitPreloadUtil {
private var loadServiceMethod: Method? = null
var initSuccess: Boolean = false
// private var serviceMethodCacheField:Map<Method,ServiceMethod<Any>>?=null
private var serviceMethodCacheField: Field? = null

init {
try {
serviceMethodCacheField = Retrofit::class.java.getDeclaredField("serviceMethodCache")
serviceMethodCacheField?.isAccessible = true
if (serviceMethodCacheField == null) {
for (declaredField in Retrofit::class.java.declaredFields) {
if (Map::class.java.isAssignableFrom(declaredField.type)) {
declaredField.isAccessible =true
serviceMethodCacheField = declaredField
break
}
}
}
loadServiceMethod = Retrofit::class.java.getDeclaredMethod("loadServiceMethod", Method::class.java)
loadServiceMethod?.isAccessible = true
} catch (e: Exception) {
initSuccess = false
}
}

/**
* 预加载 目标service 的 相关函数,并注入到对应retrofit实例中
*/

fun preloadClassMethods(retrofit: Retrofit, service: Class<*>, methodNames: Array<String>) {
val field = serviceMethodCacheField ?: return
val map = field.get(retrofit) as MutableMap<Method,ServiceMethod<Any>>

for (declaredMethod in service.declaredMethods) {
if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers)
&& methodNames.contains(declaredMethod.name)) {
try {
val parsedMethod = ServiceMethod.parseAnnotations<Any>(retrofit, declaredMethod) as ServiceMethod<Any>
map[declaredMethod] =parsedMethod
} catch (e: Exception) {
Timber.e(e, "load method $declaredMethod for class $service failed")
}
}
}

}

private fun isDefaultMethod(method: Method): Boolean {
return Build.VERSION.SDK_INT >= 24 && method.isDefault;
}

}

预加载名单收集


有了优化方案后,还需要收集原本在启动阶段会在主线程进行Retrofit ServiceMethod调用的列表, 这里采取的是字节码插桩的方式,使用的LancetX 框架进行修改。



目前名单的配置是预先收集好,在配置中心进行配置,运行时根据配置中写的配置 进行预加载。 这里还可以提供其他的配置方案,比如 提供一个注解用于标注该Retrofit函数需要进行预解析,



之后,在编译期间收集所有需要预加载的Service及函数,生成对应的名单,不过这个方案需要一定开发成本,并且需要去修改业务模块的代码,目前的阶段还处于验证收益阶段,所以暂未实施。


收益


App收集了启动阶段20个左右的Method 进行预加载,预计提升10~20ms。


ARouter


背景


ARouter框架提供了路由注册跳转 及 SPI 能力。为了优化冷启动速度,对于某些服务实例可以在启动阶段进行预加载生成对应的实例对象。


ARouter的注册信息是在预编译阶段(基于APT) 生成的,在编译阶段又通过ASM 生成对应映射关系的注入代码。



而在运行时以获取Service实例为例,当调用navigation函数获取实例最终会调用到 completion函数。



当首次调用时,其对应的RouteMeta 实例尚未生成,会继续调用 addRouteGroupDynamic函数进行注册。



addRouteGroupDynamic 会创建对应预编译阶段生成的服务注册类并调用loadInto函数进行注册。而某些业务模块如何服务注册信息比较多,这里的loadInto就会比较耗时。



整体来看,对于获取Service实例的流程, completion的整个流程 涉及到 loadInto信息注册、Service实例反射生成、及init函数的调用。 而completion函数是synchronized的,因此无法利用多线程进行注册来缩短启动耗时。


优化方案


这里的优化其实和Retroift Service 的注册机制类似,不同的Service注册时,其对应的元信息类(IRouteGroup)其实是不同的,因此只需要对对应的IRouteGroup加锁即可。


在completion的后半部分流程中,针对Provider实例生产的流程也需要进行单独加锁,避免多次调用init函数。



收益


根据线下收集的数据 配置了20+预加载的Service Method, 预期收益 10~20ms (中端机) 。


其他


后续将继续结合自身业务现状以及其他一线大厂分享的样例,在 x2c、class verify、禁用JIT、 disableDex2AOT等方面继续尝试优化。


如果通过本文对你有所收获,可以来个点赞、收藏、关注三连,后续将分享更多性能监控与优化相关的文章。


也可以关注个人公众号:编程物语


image.png


本文相关测试代码已分享至github: github.com/Knight-ZXW/…


APM性能监控与优化专栏


性能优化专栏历史文章:


作者:卓修武K
来源:juejin.cn/post/7249228528573513789
tbody>
文章地址
Android平台下的cpu利用率优化实现juejin.cn/post/724324…
抖音消息调度优化启动速度方案实践juejin.cn/post/721766…
扒一扒抖音是如何做线程优化的juejin.cn/post/721244…
监控Android Looper Message调度的另一种姿势juejin.cn/post/713974…
Android 高版本采集系统CPU使用率的方式juejin.cn/post/713503…
Android 平台下的 Method Trace 实现及应用juejin.cn/post/710713…
Android 如何解决使用SharedPreferences 造成的卡顿、ANR问题juejin.cn/post/705476…
基于JVMTI 实现性能监控juejin.cn/post/694278…
收起阅读 »

Flutter卡片分享功能实现:将你的内容分享给世界

前言 在app中,在实现分享功能的时候,通常会有一种以卡片形式展示和分享内容的分享方式。这种功能可以将信息以整洁、易读的方式呈现给用户,使他们能够快速了解内容的关键信息,并将其分享给其他人。那么在这篇文章中,就一起来探索下,如何使用Flutter来实现这卡片...
继续阅读 »

前言



在app中,在实现分享功能的时候,通常会有一种以卡片形式展示和分享内容的分享方式。这种功能可以将信息以整洁、易读的方式呈现给用户,使他们能够快速了解内容的关键信息,并将其分享给其他人。那么在这篇文章中,就一起来探索下,如何使用Flutter来实现这卡片分享功能吧~


源代码:http://www.aliyundrive.com/s/FH7Xc2vyL…


效果图:



实现方案


为了卡片的样式的灵活性和可定制性,本文采用对组件进行截图的方式来实现卡片保存分享的功能,选择这个方案还有一点好处就是充分利用了flutter跨平台的优势。当然也会有一定的缺点,例如对于性能的考虑,当对复杂的嵌套卡片组件截图时,渲染和图像转换的计算量是需要考虑的,当然也可以选择忽略不计~


创建弹窗&卡片布局


在生成分享卡片的同时还会有其他的操作选项,例如保存图片、复制链接、浏览器打开等等,所以通常分享卡片的形式为弹窗形式,中间为分享卡片主体,剩余空间为操作项。



操作项组件封装:


class ImageDialog extends StatelessWidget {
const ImageDialog({
Key? key,
required this.items,
...
}) : super(key: key);
final List<ItemLittleView> items;
...

@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
...
child: Row(
children: items
.map((e) => itemLittleView(
label: e.label,
icon: e.icon,
onTap: () {
Navigator.pop(context);
e.onTap?.call();
}))
.toList()),
),
],
);
}

Widget itemLittleView({
required String label,
required String icon,
Function()? onTap,
}) =>
InkWell(
onTap: onTap,
child: Container(
margin: EdgeInsets.only(right: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
//图标
),
Container(
//文字
),
],
),
),
);
}
}

class ItemLittleView {
final String label;
final String icon;
final Function()? onTap;

ItemLittleView({required this.label, required this.icon, this.onTap});
}

需要加入新的操作项时,只需要简单的添加一个ItemLittleView即可。


ImageDialog(
items: [
ItemLittleView(
label: "生成图片 ",
icon: "assets/images/icon/ic_down.png",
onTap: () => doSaveImage(),
),
...
],
),

卡片的布局则根据业务的需求自定义即可,本文也只是一个简单的例子。


渲染并截取组件截图


在flutter中可以使用RepaintBoundary将将组件渲染为图像。



  • 第一步:定义全局的GlobalKey,用于获取卡片布局组件的引用


var repaintKey = GlobalKey();

RepaintBoundary(
key: repaintKey,
//分享卡片
child: shareImage(),
),


  • 第二步:使用RenderRepaintBoundary的toImage方法将其转换为图像


Future<Uint8List> getImageData() async {
BuildContext buildContext = repaintKey.currentContext!;
//用于存储截取的图片数据
var imageBytes;
//通过 buildContext 获取到 RenderRepaintBoundary 对象,表示要截取的组件边界
RenderRepaintBoundary boundary =
buildContext.findRenderObject() as RenderRepaintBoundary;

//这行代码获取设备的像素密度,用于设置截取图片的像素密度
double dpr = ui.window.devicePixelRatio;
//将边界对象 boundary 转换为图像,使用指定的像素密度。
ui.Image image = await boundary.toImage(pixelRatio: dpr);
// image.width
//将图像转换为ByteData数据,指定了数据格式为 PNG 格式。
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
//将ByteData数据转换为Uint8List 类型的图片数据。
imageBytes = byteData!.buffer.asUint8List();
return imageBytes;
}


  • 第三步:获取权限&保存截图


//获取权限
_requestPermission() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
].request();

final info = statuses[Permission.storage].toString();
}

Future<String> saveImage(Uint8List imageByte) async {
//将回调拿到的Uint8List格式的图片转换为File格式
//获取临时目录
var tempDir = await getTemporaryDirectory();
//生成file文件格式
var file =
await File('${tempDir.path}/image_${DateTime.now().millisecond}.png')
.create();
//转成file文件
file.writeAsBytesSync(imageByte);
print("${file.path}");
String path = file.path;
return path;
}

//最后通过image_gallery_saver来保存图片
/// 执行存储图片到本地相册
void doSaveImage() async {
await _requestPermission();
Uint8List data = await getImageData();
String path = await saveImage(data);
final result = await ImageGallerySaver.saveFile(path);
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("保存成功!"),
);
});
}

到这里,分享卡片的功能就实现啦~


总结


在本文中,我们探索了使用Flutter实现卡片分享功能的过程。在开发app时,卡片分享功能可以为用户提供更好的交互和共享体验,我猜大家在开发的过程中也会有很大的概率碰上这样的需求。通过设计精美的卡片样式,可以帮助更快速的推广APP。


关于我


Hello,我是Taxze,如果您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需要联系我的话:我在这里 ,也可以通过掘金的新的私信功能联系到我。如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章~万

作者:编程的平行世界
来源:juejin.cn/post/7249347871564300345
一哪天我进步了呢?😝

收起阅读 »

Kotlin1.8新增特性,进来了解一下

大家好,之前我已经写过了分析kotlin1.5、1.6、1.7、1.9插件版本新增的一些特性,唯独kotlin1.8的特性还没好好讲讲,本篇文章就带大家好好分析下kotlin1.8新增了那些特性,能对我们日常开发带来哪些帮助。 其中Kotlin1.8.0提供的...
继续阅读 »

大家好,之前我已经写过了分析kotlin1.5、1.6、1.7、1.9插件版本新增的一些特性,唯独kotlin1.8的特性还没好好讲讲,本篇文章就带大家好好分析下kotlin1.8新增了那些特性,能对我们日常开发带来哪些帮助。


其中Kotlin1.8.0提供的特性有限,本篇文章主要是分析Kotlin1.8.20提供的一些新特性。下面是支持该插件的IDE对应版本:



一. 提供性能更好的Enum.entries替代Enum.values()



在之前,如果我们想遍历枚举内部元素,我们通常会写出以下代码:


enum class Color(val colorName: String, val rgb: String) {
RED("Red", "#FF0000"),
ORANGE("Orange", "#FF7F00"),
YELLOW("Yellow", "#FFFF00")
}

fun main() {
Color.values().forEach {
println("${it.rgb}--${it.colorName}--${it.name}")
}
}

但是不知道大家是否清楚,Color.values() 其实存在性能问题,换句话说,每调用一次该方法,就会触发重新分配一块内存,如果调用的频率过高,就很可能引发内存抖动


我们可以反编译下枚举类简单看下原因:



Color.values()每次都会调用Object.clone()方法重新创建一个新的数组,这就是上面说的潜在的性能问题,github上也有相关的问题链接,感兴趣的可以看下:HttpStatus.resolve allocates HttpStatus.values() once per invocation


同时Color.values()返回的是一个数组,而在我们大多开发场景中,可能集合使用的频率更高,这就可能涉及到一个数组转集合的操作。


基于以上考虑,Kotlin1.8.20官方提供了一个新的属性:Color.entries这个方法会预分配一块内存并返回一个不可变集合,多次调用也不会产生潜在的性能问题


我们简单看下使用:


fun main() {
Color.entries.forEach {
println("${it.rgb}--${it.colorName}--${it.name}")
}
}

输出:



同时我们也可以从反编译的代码中看出区别:



不会每次调用都重新分配一块内存并返回。


如果想要使用这个特性,可以加上下面配置:


compileKotlin.kotlinOptions {
languageVersion = "1.9"
}

另外多说一下,IntelliJ IDEA 2023.1版本也会检测代码中是否存在Enum.values()的使用,存在就提示使用Enum.entries代替。


二. 允许内联类声明次级构造函数



内联类在Kotlin1.8.20之前是不允许带body的次级构造函数存在的,也就是说下面的代码运行会报错:


@JvmInline
value class Person( val fullName: String) {
constructor(name: String, lastName: String) : this("$name $lastName") {
check(lastName.isNotBlank()) {
"Last name shouldn't be empty"
}
}
}

fun main() {
println(Person("a", "b").fullName)
}

运行看下结果:



如果没有次级构造函数body,下面这样写是没问题的:


    constructor(name: String, lastName: String) : this("$name $lastName") 

如果想要支持带body的次级构造函数,只需要在kotlin1.8.20插件版本上和上一个特性一样增加languageVersion = "1.9"配置即可。


然后上面的代码块运行就没问题了,我们看下输出:


fun main() {
println(Person("a", "").fullName)
}


准确的执行了次级构造函数body内的逻辑。


三. 支持java synthethic属性引用



这个特性用文字不好解释,我们直接通过代码去学习下该特性。


当前存在一个类Person1


public class Person1 {
private String name;
private int age;

public Person1(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

在Kotlin1.8.20之前,以下这种写法是会报错的:



而是必须改成sortedBy(Person1::getAge)才能运行通过。


和上面特性一样,如果想要支持Person1::age这种引用方式,只需要在kotlin1.8.20插件版本上和上一个特性一样增加languageVersion = "2.1"配置即可。



PS:请注意,Kotlin官方网站提示配置languageVersion = "1.9" 就能使用上面的实验特性,但是编译器还是提示报错,然后你找报错提示信息改成了languageVersion = "2.1" 就正常了。




四. 新Kotlin K2编译器的更新



就是说目前Kotlin K2编译器还是一个实验阶段,不过Kotlin官方在其stable的路上又增加了一些更新:



  1. 序列化插件的预览版本;

  2. JS IR编译器的alpha支持;

  3. Kotlin2.0版本特性的引入;


如果大家想要体验下最新版的Kotlin K2编译器,增加配置:languageVersion ="2.0"即可。


五. Kotlin标准库支持AutoCloseable



这个AutoCloseable 接口就是用来支持资源关闭的,搭配提供的use扩展函数,就能帮助我们在资源流使用完毕后自动关闭。


Kotlin之所以在标准库中支持,应该是想要支持多平台吧。


六. Kotlin标准库支持Base64编解码


这里不做太多介绍,看下面的使用例子即可:



七. Kotlin标准库@Volatile支持Kotlin/Native


@Volatile注解在Kotlin/JVM就是保证线程之间可见性以及有序性的,kotlin官方在Kotlin/Native中也支持了该注解使用,有兴趣的可以实战试下效果。


总结


本篇文章主要是介绍了Kotlin1.8版本新增的一些特性,主要挑了一些我能理解的、常用的一些特性拉出来介绍,希望能对你有所帮助。


历史文章


两个Kotlin优化小技巧,你绝对用的上


浅析一下:kotlin委托背后的实现机制


Kotlin1.9.0-Beta,它来了!!


聊聊Kotlin1.7.0版本提供的一些特性


聊聊kotlin1.5和1.6版本提供的一些新特性


kotlin密封sealed class/interface的迭代之旅


优化@BuilderInference注解,Kotlin高版本下了这些“毒手”!


@JvmDefaultWithCompatibility优化小技巧

,了解一下~

收起阅读 »