注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

掌握协程的边界与环境:CoroutineScope 与 CoroutineContext

CoroutineScope 与 CoroutineContext 的概念 CoroutineContext (协程上下文) CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。 比如: 管理协程流程(生命周期)的 Job。 管理...
继续阅读 »

CoroutineScope 与 CoroutineContext 的概念


CoroutineContext (协程上下文)


CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。


比如:



  • 管理协程流程(生命周期)的 Job

  • 管理线程的 ContinuationInterceptor,它的实现类 CoroutineDispatcher 决定了协程所运行的线程或线程池。


CoroutineScope (协程作用域)


CoroutineScope 是协程作用域,它通过 coroutineContext 属性持有了当前协程代码块的上下文信息。


比如,我们可以获取 JobContinuationInterceptor 对象:


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(EmptyCoroutineContext) // scope 并没有持有已有协程的上下文
val outerJob = scope.launch {
val innerJob = coroutineContext[Job]
val interceptor = coroutineContext[ContinuationInterceptor]
println("job: $innerJob, interceptor: $interceptor")
}

outerJob.join()
}

CoroutineScope 的另一个作用就是提供了 launchasync 协程构建器,我们可以通过它来启动一个协程。


这样,新创建的协程能够自动继承 CoroutineScopecoroutineContext。比如利用 Job,可以建立起父子关系,从而实现结构化并发。


GlobalScope


GlobalScope 是一个单例的 CoroutineScope 对象,所以我们在任何地方通过它来启动协程。


它的第二个特点是,它的 coroutineContext 属性是 EmptyCoroutineContext,也就是说它没有内置的 Job


@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/

override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

即使是我们手动创建的 CoroutineScope,其内部也是有 Job 的。


// 手动创建 CoroutineScope
CoroutineScope(EmptyCoroutineContext)

// CoroutineScope.kt
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job()) // 自动创建Job对象

所以我们在 GlobalScope.coroutineContext 中是获取不到 Job 的:


@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking<Unit> {
val job: Job? = GlobalScope.coroutineContext[Job]
if (job == null) {
println("job is null")
}
try {
val jobNotNull: Job = GlobalScope.coroutineContext.job
} catch (e: IllegalStateException) {
println("job is null, exception is: $e")
}
}

运行结果:


job is null
job is null, exception is: java.lang.IllegalStateException: Current context doesn't contain Job in it: EmptyCoroutineContext

那么,这有什么用吗?


其实,GlobalScope 所启动的协程没有父 Job


这就意味着:



  • 当前协程不和其他 Job 的生命周期绑定,比如不会随着某个界面的关闭而自动取消。

  • 它是顶级协程,生命周期默认为整个应用的生命周期。

  • 它发生异常,并不会影响到其他协程和 GlobalScope。反之,GlobalScope 本身也无法级联取消所有任务,因为它所启动的协程是完全独立的。


总结:GlobalScope 就是用来启动那些不与组件生命周期绑定,而是与整个应用生命周期保持一致的全局任务,比如一个日志上报任务。


关键在使用时,可能会有资源泄露的风险,需要正确管理好协程的生命周期。


Context 的三个实用工具


在挂起函数中获取 CoroutineContext


如果我们要在一个挂起函数中获取 CoroutineContext,我们不得不给将其作为 CoroutineScope 的扩展函数。


suspend fun CoroutineScope.printContinuationInterceptor() {
delay(1000)
val interceptor = coroutineContext[ContinuationInterceptor]
println("interceptor: $interceptor")
}

但我们知道挂起函数的外部一定有协程存在,所以是存在 CoroutineContext 的。为此,Kotlin 协程库提供了一个顶层的 coroutineContext 属性,这个属性的 get() 函数是一个挂起函数,它能在任何挂起函数中访问到当前正在执行的协程的 CoroutineContext


import kotlin.coroutines.coroutineContext

suspend fun printContinuationInterceptor() {
delay(1000)
val interceptor = coroutineContext[ContinuationInterceptor]
println("interceptor: $interceptor")
}

另外,还有一个 currentCoroutineContext() 函数也能获取到 CoroutineContext,它内部实现也是 coroutineContext 属性。


为什么需要这个函数?


为了解决命名冲突,比如下面这段代码。


private fun mySuspendFun() {
flow<String> {
// 顶层属性
coroutineContext
}

GlobalScope.launch {
flow<String> {
// this 的成员属性优先级高于顶层属性
// 所以是外层 launch 的 CoroutineScope 的成员属性 coroutineContext
coroutineContext
}
}
}

在这种情况下,如果需要明确属性的源头,就需要使用 currentCoroutineContext() 函数,它会调用到那个顶层的属性。


CoroutineName 协程命名


CoroutineName 是一个协程上下文信息,我们可以使用它来给协程设置一个名称。


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(EmptyCoroutineContext)
val name = CoroutineName("coroutine-1")
val job = scope.launch(name) {
val coroutineName = coroutineContext[CoroutineName]
println("current coroutine name: $coroutineName")
}

job.join()
}

运行结果:


current coroutine name: CoroutineName(coroutine-1)

它主要用于测试和调试,你可以使用它来区分哪些日志是哪个协程打印的。


自定义 CoroutineContext


如果我们要给协程附加一些功能,我们可以考虑自定义 CoroutineContext



如果是简单的标记,可以优先考虑使用 CoroutineName



自定义 CoroutineContext 需要实现 CoroutineContext.Element,并且提供 Key。为此,Kotlin 协程库提供了 AbstractCoroutineContextElement 来简化这个过程。我们只需这样,即可创建一个用于协程内部记录日志的 Context


// 继承 AbstractCoroutineContextElement,并把 Key 传给父构造函数
class CoroutineLogger(val tag: String) : AbstractCoroutineContextElement(CoroutineLogger) {

// 声明专属的 Key
companion object Key : CoroutineContext.Key<CoroutineLogger>

// 添加专属功能
fun log(message: String) {
println("[$tag] $message")
}
}

使用示例:


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch(CoroutineLogger("Test")) {
val logger = coroutineContext[CoroutineLogger]
logger?.log("Start")
delay(5000)
logger?.log("End")
}
job.join()
}

运行结果:


[Test] Start
[Test] End

coroutineScope() 与 withContext()


coroutineScope 串行的异常封装器


coroutineScope 是一个挂起函数,它会挂起当前协程,直到执行完内部的所有代码(包括会等待内部启动的所有子协程执行完毕),最后一行代码的执行结果会作为函数的返回值。


coroutineScope 会创建一个新的 CoroutineScope,在这个作用域中执行 block 代码块。并且这个作用域严格继承了父上下文(coroutineContext),并会在内部创建一个新的 Job,作为父 Job 的子 Job



coroutineScope 从效果上来看,和 launch().join() 类似。



那么,它的应用场景是什么?


它的应用场景由它的特性决定,有两个核心场景:



  1. 在挂起函数中提供 CoroutineScope。(最常用)


    suspend fun CoroutineScope.mySuspendFunction() {
    delay(1000)
    launch {
    println("launch")
    }
    }

    如果你要在挂起函数中启动一个新的协程,你只好将其定义为 CoroutineScope 的扩展函数。不过,你也可以使用 coroutineScope 来提供作用域。


    它能提供作用域,是因为挂起函数的外部一定存在着协程,所以一定具有 CoroutineScope


    suspend fun doConcurrentWork() {
    val startTime = System.currentTimeMillis()
    coroutineScope {
    val task1 = async { // 任务1
    delay(5000)
    }
    val task2 = async { // 任务2
    delay(3000)
    }
    } // // 挂起,直到上面两个 async 都完成
    val endTime = System.currentTimeMillis()
    println("Total execution time: ${endTime - startTime}") // 5000 左右
    }


  2. 业务逻辑封装并进行异常处理。(最重要)


    我们都知道,我们无法在协程外部使用 try-catch 捕获协程内部的异常。


    但使用 coroutineScope 函数可以,当它内部的任何子协程失败了,它会将这个异常重新抛出来,这时我们可以使用 try-catch 来捕获。


    fun main() = runBlocking<Unit> {
    try {
    coroutineScope {
    val data1 = async {
    "user-1"
    }
    val data2 = async {
    throw IllegalStateException("error")
    }

    awaitAll(data1, data2)
    }
    } catch (e: Exception) {
    println("exception is: $e")
    }
    }

    运行结果:


    exception is: java.lang.IllegalStateException: error

    原因也很简单,因为它是一个串行的挂起函数,外部协程会被挂起,直到它执行完毕。如果它的内部出现了异常,外部协程是能够知晓的。


    coroutineScope 可以将并发的崩溃变为可被捕获、处理的异常,常用于处理并发错误。



withContext 串行的上下文切换器


我们再来看 withContext,其实它和 coroutineScope 几乎一样。



它也是一个串行的挂起函数,也会返回代码块的结果,内部也是启动了一个新的协程。



它和 coroutineScope 的唯一的不同是,withContext 允许我们传递上下文。你也可以这么想,coroutineScope 就是一个不改变任何上下文的 withContext


withContext(EmptyCoroutineContext) { // 沿用旧的 CoroutineContext

}

withContext(coroutineContext) { // 使用旧的 CoroutineContext

}

withContext 的使用场景就很清楚了,我们需要切换上下文的时候会使用它,并且希望代码是串行执行的,之后还能再切回原来的线程继续往下执行。



虽然 withContextcoroutineScope 类似,但 coroutineScope 更多用于封装业务异常。



suspend fun getUserProfile() {
// 当前在 Dispatchers.Main
val profile = withContext(Dispatchers.IO) {
// 自动切换到 IO 线程
Thread.sleep(3000) // 耗时操作
"the user profile"
}

// 自动切回 Dispatchers.Main
println("the user profile is $profile")
}

CoroutineContext 的加、取操作


加法:合并与替换


两个 CoroutineContext 相加调用的是 plus()


public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
context.fold(this) { acc, element ->
val removed = acc.minusKey(element.key)
if (removed === EmptyCoroutineContext) element else {
// make sure interceptor is always last in the context (and thus is fast to get when present)
val interceptor = removed[ContinuationInterceptor]
if (interceptor == null) CombinedContext(removed, element) else {
val left = removed.minusKey(ContinuationInterceptor)
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}

其中关键在于 CombinedContext,它是 CoroutineContext 的实现类:


// CoroutineContextImpl.kt
@SinceKotlin("1.3")
internal class CombinedContext(
private val left: CoroutineContext,
private val element: Element // Element 也是 `CoroutineContext` 的实现类
) : CoroutineContext, Serializable

它会将操作符两边的上下文使用 CombinedContext 对象包裹(合并),如果两个上下文具有相同的 Key,加号右侧的会替换左侧的。


比如 Dispatchers.IO + Job() + CoroutineName("my-name") 一共会进行三次合并,得到三个 CombinedContext 对象,不会进行替换。


fun main() = runBlocking<Unit> {
val handler = CoroutineExceptionHandler { _, throwable ->
println("Caught $throwable")
}
val job =
launch(Dispatchers.IO + Job() + CoroutineName("my-name") + handler) {
println(coroutineContext)
}
job.join()
}

运行结果:


[CoroutineName(my-name), com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]

如果在末尾再加上一个 CoroutineName("your_name"),会进行一次替换,运行结果是:[com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, CoroutineName(your_name), StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]


[] 取值


[] 取值其实调用的是 CoroutineContext.get() 函数,它会从上下文(CombinedContext 树)中找到我们需要的信息。


@SinceKotlin("1.3")
public interface CoroutineContext {
/**
* Returns the element with the given [key] from this context or `null`.
*/

public operator fun <E : Element> get(key: Key<E>): E?

// ...
}

我们填入的参数其实是每一个接口的伴生对象 Key,每个伴生对象都实现了 CoroutineContext.Key<T> 接口,并将泛型指定为了当前接口。


ContinuationInterceptor 为例:


@SinceKotlin("1.3")
public interface ContinuationInterceptor : CoroutineContext.Element {
/**
* The key that defines *the* context interceptor.
*/

public companion object Key : CoroutineContext.Key<ContinuationInterceptor>

// ...
}

比如我们要获取上下文中的 CoroutineDispatcher,我们可以这样做:


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch {
val dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher // 强转
println("CoroutineDispatcher is $dispatcher")
}
job.join()
}

作者:雨白
来源:juejin.cn/post/7564230484126892071
收起阅读 »

Android实战-Native层thread的实现方案

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下: Android的Native层thread的实现方案一般有两种: Linux上的po...
继续阅读 »

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下:


Android的Native层thread的实现方案一般有两种:



  • Linux上的posix线程方案

  • Native层面封装的Thread类(用的最多)


posix线程方案


首先创建空文件夹项目-Linux_Thread


其次在Linux_Thread文件夹下创建一个文件thread_posix.c:主要逻辑是在主线程中创建一个子线程mythread,主线程等待子线程执行完毕后,退出进程:


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <utils/Log.h>

//线程执行逻辑
void *thread_posix_function(void *arg)
{
(void*) arg;
int i;
for(i = 0; i < 40; i++)
{
printf("hello thread i + %d\n", i);
ALOGD("hello thread i + %d\n", i);
sleep(1);
}
return NULL;
}

int main(void)
{

pthread_t mythread;
//创建线程并执行
if(pthread_create(&mythread, NULL, thread_posix_function, NULL))
{
ALOGD("error createing thread");
abort();
}

sleep(1);
//等待mythread线程执行结束
if(pthread_join(mythread, NULL))
{
ALOGD("error joining thread");
abort();
}
ALOGD("hello thread has run end exit\n");
//退出
exit(0);
}

然后在Linux_Thread文件夹下创建项目构建文件Android.mk中将文件编译为可执行文件:


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := linux_thread
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_SRC_FILES := thread_posix.c
LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

最后确认Linux_Thread项目位于aosp源码文件夹内,开始编译Linux_Thread项目:


source build/envsetup.sh
lunch
make linux_thread

执行成功后,找到输出的可执行文件linux_thread,将文件push到Android设备中去:


adb push linux_thread /data/local/tmp/

注意如果出现报错 Permission denied,需要对文件进行权限修改:


chmod -R 777 linux_thread

开始启动linux_thread:


./linux_thread

image.png


同时也可以通过日志打印输出:


 adb shell locat | grep hello

屏幕截图 2025-05-06 173436.png


以上就是posix线程方案的实现。


Native层的Thread类方案


源码分析


Native层即Framework层的C++部分,Thread的相关代码位置



头文件:system/core/libutils/include/utils/Thread.h


源文件:system/core/libutils/Threads.cpp



# system/core/libutils/include/utils/Thread.h
class Thread : virtual public RefBase
{
public:
explicit Thread(bool canCallJava = true);
virtual ~Thread();

virtual status_t run( const char* name,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0)
;

virtual void requestExit();

virtual status_t readyToRun();

status_t requestExitAndWait();

status_t join();

bool isRunning() const;

...
}

Thread继承于RefBase,在构造函数中对canCallJava变量默认赋值为ture,同时声明了一些方法,这些方法都在源文件中实现。


status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
...
if (mRunning) {
// thread already started
return INVALID_OPERATION;
}
...
mRunning = true;

bool res;
if (mCanCallJava) {
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}

if (res == false) {
mStatus = UNKNOWN_ERROR; // something happened!
mRunning = false;
mThread = thread_id_t(-1);
mHoldSelf.clear(); // "this" may have gone away after this.

return UNKNOWN_ERROR;
}
return OK;

// Exiting scope of mLock is a memory barrier and allows new thread to run
}

int androidCreateRawThreadEtc(android_thread_func_t entryFunction,
void *userData,
const char* threadName __android_unused,
int32_t threadPriority,
size_t threadStackSize,
android_thread_id_t *threadId)

{
...
errno = 0;
pthread_t thread;
int result = pthread_create(&thread, &attr,
(android_pthread_entry)entryFunction, userData);
pthread_attr_destroy(&attr);
if (result != 0) {
ALOGE("androidCreateRawThreadEtc failed (entry=%p, res=%d, %s)\n"
"(android threadPriority=%d)",
entryFunction, result, strerror(errno), threadPriority);
return 0;
}
...
return 1;
}

int Thread::_threadLoop(void* user)
{
...
bool first = true;

do {
bool result;
//是否第一次执行
if (first) {
first = false;
self->mStatus = self->readyToRun();
result = (self->mStatus == OK);

if (result && !self->exitPending()) {
result = self->threadLoop();
}
} else {
result = self->threadLoop();
}

// establish a scope for mLock
{
Mutex::Autolock _l(self->mLock);
//根据结果来跳出循环
if (result == false || self->mExitPending) {
self->mExitPending = true;
self->mRunning = false;
self->mThread = thread_id_t(-1);
self->mThreadExitedCondition.broadcast();
break;
}
}
strong.clear();
strong = weak.promote();
} while(strong != nullptr);

return 0;
}

在run方法中,mCanCallJava变量一般为false,是在thread创建的时候赋值的。从而进入androidCreateRawThreadEtc()方法创建线程,在此函数中,可以看见还是通过pthread_create()方法创建Linux的posix线程(可以理解为Native的Thread就是对posiz的一个封装);线程运行时回调的函数参数entryFunction值为_threadLoop函数,因此创建的线程会回调到_threadLoop函数中去;_threadLoop函数里则是一个循环逻辑,线程第一次魂环会调用readyToRun()函数,然后再调用threadLoop函数执行线程逻辑,后面就会根据threadLoop执行的结果来判断是否再继续执行下去。


代码练习


头文件MyThread.h


#ifndef _MYTHREAD_H
#define _MYTHREAD_H

#include <utils/threads.h>

namespace android
{
class MyThread: public Thread
{
public:
MyThread();
//创建线程对象就会被调用
virtual void onFirstRef();
//线程创建第一次运行时会被调用
virtual status_t readyToRun();
//根据返回值是否继续执行
virtual bool threadLoop();
virtual void requestExit();

private:
int hasRunCount = 0;
};
}
#endif

源文件MyThread.cpp


#define LOG_TAG "MyThread"

#include <utils/Log.h>
#include "MyThread.h"

namespace android
{
//通过构造函数对mCanCallJava赋值为false
MyThread::MyThread(): Thread(false)
{
ALOGD("MyThread");
}

bool MyThread::threadLoop()
{
ALOGD("threadLoop hasRunCount = %d", hasRunCount);
hasRunCount++;
//计算10次后返回false,表示逻辑结束,线程不需要再继续执行咯
if(hasRunCount == 10)
{
return false;
}
return true;
}

void MyThread::onFirstRef()
{
ALOGD("onFirstRef");
}

status_t MyThread::readyToRun()
{
ALOGD("readyToRun");
return 0;
}

void MyThread::requestExit()
{
ALOGD("requestExit");
}
}

程序入口Main.cpp


#define LOG_TAG "Main"

#include <utils/Log.h>
#include <utils/threads.h>
#include "MyThread.h"

using namespace android;

int main()
{

sp<MyThread> thread = new MyThread;
thread->run("MyThread", PRIORITY_URGENT_DISPLAY);
while(1)
{
if(!thread->isRunning())
{
ALOGD("main thread -> isRunning == false");
break;
}
}

ALOGD("main end");
return 0;
}

项目构建文件Android.mk


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := android_thread
LOCAL_SHARED_LIBRARIES := libandroid_runtime \
libcutils \
libutils \
liblog
LOCAL_SRC_FILES := MyThread.cpp \
Main.cpp \

LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

项目目录如下:
image.png


通过命令带包得到android_thread可执行文件放入模拟器运行:


屏幕截图 2025-05-08 140036.png


作者:抛空
来源:juejin.cn/post/7501624826286669859
收起阅读 »

Android实战-Native层thread的实现方案

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下: Android的Native层thread的实现方案一般有两种: Linux上的po...
继续阅读 »

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下:


Android的Native层thread的实现方案一般有两种:



  • Linux上的posix线程方案

  • Native层面封装的Thread类(用的最多)


posix线程方案


首先创建空文件夹项目-Linux_Thread


其次在Linux_Thread文件夹下创建一个文件thread_posix.c:主要逻辑是在主线程中创建一个子线程mythread,主线程等待子线程执行完毕后,退出进程:


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <utils/Log.h>

//线程执行逻辑
void *thread_posix_function(void *arg)
{
(void*) arg;
int i;
for(i = 0; i < 40; i++)
{
printf("hello thread i + %d\n", i);
ALOGD("hello thread i + %d\n", i);
sleep(1);
}
return NULL;
}

int main(void)
{

pthread_t mythread;
//创建线程并执行
if(pthread_create(&mythread, NULL, thread_posix_function, NULL))
{
ALOGD("error createing thread");
abort();
}

sleep(1);
//等待mythread线程执行结束
if(pthread_join(mythread, NULL))
{
ALOGD("error joining thread");
abort();
}
ALOGD("hello thread has run end exit\n");
//退出
exit(0);
}

然后在Linux_Thread文件夹下创建项目构建文件Android.mk中将文件编译为可执行文件:


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := linux_thread
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_SRC_FILES := thread_posix.c
LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

最后确认Linux_Thread项目位于aosp源码文件夹内,开始编译Linux_Thread项目:


source build/envsetup.sh
lunch
make linux_thread

执行成功后,找到输出的可执行文件linux_thread,将文件push到Android设备中去:


adb push linux_thread /data/local/tmp/

注意如果出现报错 Permission denied,需要对文件进行权限修改:


chmod -R 777 linux_thread

开始启动linux_thread:


./linux_thread

image.png


同时也可以通过日志打印输出:


 adb shell locat | grep hello

屏幕截图 2025-05-06 173436.png


以上就是posix线程方案的实现。


Native层的Thread类方案


源码分析


Native层即Framework层的C++部分,Thread的相关代码位置



头文件:system/core/libutils/include/utils/Thread.h


源文件:system/core/libutils/Threads.cpp



# system/core/libutils/include/utils/Thread.h
class Thread : virtual public RefBase
{
public:
explicit Thread(bool canCallJava = true);
virtual ~Thread();

virtual status_t run( const char* name,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0)
;

virtual void requestExit();

virtual status_t readyToRun();

status_t requestExitAndWait();

status_t join();

bool isRunning() const;

...
}

Thread继承于RefBase,在构造函数中对canCallJava变量默认赋值为ture,同时声明了一些方法,这些方法都在源文件中实现。


status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
...
if (mRunning) {
// thread already started
return INVALID_OPERATION;
}
...
mRunning = true;

bool res;
if (mCanCallJava) {
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}

if (res == false) {
mStatus = UNKNOWN_ERROR; // something happened!
mRunning = false;
mThread = thread_id_t(-1);
mHoldSelf.clear(); // "this" may have gone away after this.

return UNKNOWN_ERROR;
}
return OK;

// Exiting scope of mLock is a memory barrier and allows new thread to run
}

int androidCreateRawThreadEtc(android_thread_func_t entryFunction,
void *userData,
const char* threadName __android_unused,
int32_t threadPriority,
size_t threadStackSize,
android_thread_id_t *threadId)

{
...
errno = 0;
pthread_t thread;
int result = pthread_create(&thread, &attr,
(android_pthread_entry)entryFunction, userData);
pthread_attr_destroy(&attr);
if (result != 0) {
ALOGE("androidCreateRawThreadEtc failed (entry=%p, res=%d, %s)\n"
"(android threadPriority=%d)",
entryFunction, result, strerror(errno), threadPriority);
return 0;
}
...
return 1;
}

int Thread::_threadLoop(void* user)
{
...
bool first = true;

do {
bool result;
//是否第一次执行
if (first) {
first = false;
self->mStatus = self->readyToRun();
result = (self->mStatus == OK);

if (result && !self->exitPending()) {
result = self->threadLoop();
}
} else {
result = self->threadLoop();
}

// establish a scope for mLock
{
Mutex::Autolock _l(self->mLock);
//根据结果来跳出循环
if (result == false || self->mExitPending) {
self->mExitPending = true;
self->mRunning = false;
self->mThread = thread_id_t(-1);
self->mThreadExitedCondition.broadcast();
break;
}
}
strong.clear();
strong = weak.promote();
} while(strong != nullptr);

return 0;
}

在run方法中,mCanCallJava变量一般为false,是在thread创建的时候赋值的。从而进入androidCreateRawThreadEtc()方法创建线程,在此函数中,可以看见还是通过pthread_create()方法创建Linux的posix线程(可以理解为Native的Thread就是对posiz的一个封装);线程运行时回调的函数参数entryFunction值为_threadLoop函数,因此创建的线程会回调到_threadLoop函数中去;_threadLoop函数里则是一个循环逻辑,线程第一次魂环会调用readyToRun()函数,然后再调用threadLoop函数执行线程逻辑,后面就会根据threadLoop执行的结果来判断是否再继续执行下去。


代码练习


头文件MyThread.h


#ifndef _MYTHREAD_H
#define _MYTHREAD_H

#include <utils/threads.h>

namespace android
{
class MyThread: public Thread
{
public:
MyThread();
//创建线程对象就会被调用
virtual void onFirstRef();
//线程创建第一次运行时会被调用
virtual status_t readyToRun();
//根据返回值是否继续执行
virtual bool threadLoop();
virtual void requestExit();

private:
int hasRunCount = 0;
};
}
#endif

源文件MyThread.cpp


#define LOG_TAG "MyThread"

#include <utils/Log.h>
#include "MyThread.h"

namespace android
{
//通过构造函数对mCanCallJava赋值为false
MyThread::MyThread(): Thread(false)
{
ALOGD("MyThread");
}

bool MyThread::threadLoop()
{
ALOGD("threadLoop hasRunCount = %d", hasRunCount);
hasRunCount++;
//计算10次后返回false,表示逻辑结束,线程不需要再继续执行咯
if(hasRunCount == 10)
{
return false;
}
return true;
}

void MyThread::onFirstRef()
{
ALOGD("onFirstRef");
}

status_t MyThread::readyToRun()
{
ALOGD("readyToRun");
return 0;
}

void MyThread::requestExit()
{
ALOGD("requestExit");
}
}

程序入口Main.cpp


#define LOG_TAG "Main"

#include <utils/Log.h>
#include <utils/threads.h>
#include "MyThread.h"

using namespace android;

int main()
{

sp<MyThread> thread = new MyThread;
thread->run("MyThread", PRIORITY_URGENT_DISPLAY);
while(1)
{
if(!thread->isRunning())
{
ALOGD("main thread -> isRunning == false");
break;
}
}

ALOGD("main end");
return 0;
}

项目构建文件Android.mk


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := android_thread
LOCAL_SHARED_LIBRARIES := libandroid_runtime \
libcutils \
libutils \
liblog
LOCAL_SRC_FILES := MyThread.cpp \
Main.cpp \

LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

项目目录如下:
image.png


通过命令带包得到android_thread可执行文件放入模拟器运行:


屏幕截图 2025-05-08 140036.png


作者:抛空
来源:juejin.cn/post/7501624826286669859
收起阅读 »

聊聊SliverPersistentHeader优先消费滑动的设计

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。 ...
继续阅读 »

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。


带动画的吸顶滑动


那如果需求再复杂一点,吸顶组件滑动的同时还希望增加吸附的动画效果,其实SliverPersistentHeader也可以很轻松的实现。


吸附效果.gif


但是实现这个效果有一个特殊的“Feature”,他会优先消费滑动事件,导致底部滑动没有在我们预期的时机传递给上一级消费。


提前消费.gif


最近项目中处理这个问题时也没搜到相关的文章,所以今天想来聊聊这个组件的优先消费设计,以及很简单的一个定制效果。


在 Flutter 中,SliverPersistentHeader是实现“滚动时动态变化且可持久化”头部的核心组件,其浮动模式(floating: true)的动画交互(如滚动停止自动吸附、反向滚动立即展开)是通过多组件协同实现的。


瞅瞅源码


SliverPersistentHeader


那就从SliverPersistentHeader开始,让我们看看是如何实现动画的吸顶效果


class SliverPersistentHeader extends StatelessWidget {
  const SliverPersistentHeader({
    
super.key,
    required 
this.delegate,
    
this.pinned = false,
    
this.floating = false,
  }
)
;

  final bool floating;

  @override
  Widget build(BuildContext context)
 {
    if (floating && pinned) {
      return _SliverFloatingPinnedPersistentHeader(delegatedelegate);
    }
    if (pinned) {
      return _SliverPinnedPersistentHeader(delegatedelegate);
    }
    if (floating) {
      return _SliverFloatingPersistentHeader(delegatedelegate);
    }
    return _SliverScrollingPersistentHeader(delegatedelegate);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties)
 {
    super.debugFillProperties(properties);
    ....
  }
}

首先这个组件并不直接实现渲染逻辑,而是根据我们传入的flaoting和pinned的配置委派给不同的内部实现类,其中关于floating的有2个内部类分别是_SliverFloatingPinnedPersistentHeader和_SliverFloatingPersistentHeader,两个最后的实现逻辑类似,都会创建同一个Element实现效果。


_SliverFloatingPersistentHeader


以_SliverFloatingPersistentHeader的举例看逻辑


class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjectWidget {
  const _SliverFloatingPersistentHeader({
    required super.
delegate,
  })
 : super(
    floating: 
true,
  )
;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
    return _RenderSliverFloatingPersistentHeaderForWidgets(
      vsync: delegate.vsync,
      snapConfiguration: delegate.snapConfiguration,
      stretchConfiguration: delegate.stretchConfiguration,
      showOnScreenConfiguration: delegate.showOnScreenConfiguration,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderSliverFloatingPersistentHeaderForWidgets renderObject) {
    renderObject.vsync = delegate.vsync;
    renderObject.snapConfiguration = delegate.snapConfiguration;
    renderObject.stretchConfiguration = delegate.stretchConfiguration;
    renderObject.showOnScreenConfiguration = delegate.showOnScreenConfiguration;
  }
}

其中的核心是createRenderObject和updateRenderObject,后者的作用热重载和更新配置信息,前者的作用的是创建一个_RenderSliverFloatingPersistentHeaderForWidgets,在这个RenderObject它内部处理了复杂的逻辑,例如:



  • 响应滚动方向变化;

  • 控制 header 出现/消失动画;

  • 通过 ScrollPosition.hold() 暂停用户滚动;

  • 使用 _FloatingHeaderState 管理动画控制器(AnimationController)。



记住这个RenderObject,后面还会见到它



_SliverPersistentHeaderRenderObjectWidget


可以看到_SliverFloatingPersistentHeader继承于_SliverPersistentHeaderRenderObjectWidget,先看看它的代码


abstract class _SliverPersistentHeaderRenderObjectWidget extends RenderObjectWidget {
  const _SliverPersistentHeaderRenderObjectWidget({
    required 
this.delegate,
    
this.floating = false,
  })
;

  final SliverPersistentHeaderDelegate delegate;
  final bool floating;

  @override
  _SliverPersistentHeaderElement createElement() => _SliverPersistentHeaderElement(this, floating: floating);

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context);

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
    super.debugFillProperties(description);
    description.add(
      DiagnosticsProperty(
        'delegate',
        delegate,
      ),
    );
  }
}

核心逻辑是通过createElement创建了_SliverPersistentHeaderElement,到这里为止刚好对应上flutter渲染树中的三层架构:


层级类名作用
Widget_SliverPersistentHeaderRenderObjectWidget定义静态配置(delegate、floating)
Element_SliverPersistentHeaderElement管理生命周期与子节点(build、mount、update)
RenderObject_RenderSliverPersistentHeaderForWidgetsMixin真正参与布局绘制

简单的说就是:



  • _SliverPersistentHeaderRenderObjectWidget 负责描述,

  • _SliverPersistentHeaderElement 负责执行,

  • _RenderSliverPersistentHeaderForWidgetsMixin 负责绘制。


_SliverPersistentHeaderElement


那这个Element长啥样呢


class _SliverPersistentHeaderElement extends RenderObjectElement {
  _SliverPersistentHeaderElement(
    _SliverPersistentHeaderRenderObjectWidget super.widget, {
    this.floating = false,
  });

  final bool floating;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin get renderObject => super.renderObject as _RenderSliverPersistentHeaderForWidgetsMixin;

  @override
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    renderObject._element = this;
  }

  @override
  void unmount() {
    renderObject._element = null;
    super.unmount();
  }

  @override
  void update(_SliverPersistentHeaderRenderObjectWidget newWidget) {
    ...
  }

  @override
  void performRebuild() {
    super.performRebuild();
    renderObject.triggerRebuild();
  }

  Element? child;

  void _build(double shrinkOffset, bool overlapsContent) {
    owner!.buildScope(this, () {
      final _SliverPersistentHeaderRenderObjectWidget sliverPersistentHeaderRenderObjectWidget = widget as _SliverPersistentHeaderRenderObjectWidget;
      child = updateChild(
        child,
        floating
          ? _FloatingHeader(child: sliverPersistentHeaderRenderObjectWidget.delegate.build(
            this,
            shrinkOffset,
            overlapsContent
          ))
          : sliverPersistentHeaderRenderObjectWidget.delegate.build(this, shrinkOffset, overlapsContent),
        null,
      );
    });
  }

 ...
}

大致的流程是这样的



  • RenderSliverPersistentHeader.performLayout() 在滚动时触发;

  • 它会调用 _element._build(shrinkOffset, overlapsContent);

  • _build() 会重新构建 header 对应的 Widget;

  • 若 floating 模式,则额外包一层 _FloatingHeader;

  • 通过 updateChild() 更新或替换当前子 Element;

  • 生成的 child 会对应到 renderObject.child。


_FloatingHeader


class _FloatingHeaderState extends State<_FloatingHeader{
  ScrollPosition? _position;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    _position = Scrollable.maybeOf(context)?.position;
    if (_position != null) {
      _position!.isScrollingNotifier.addListener(_isScrollingListener);
    }
  }

  @override
  void dispose() {
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    super.dispose();
  }

  RenderSliverFloatingPersistentHeader? _headerRenderer() {
    return context.findAncestorRenderObjectOfType();
  }

  void _isScrollingListener() {
    assert(_position != null);

    // When a scroll stops, then maybe snap the app bar int0 view.
    // Similarly, when a scroll starts, then maybe stop the snap animation.
    // Update the scrolling direction as well for pointer scrolling updates.
    final RenderSliverFloatingPersistentHeader? header = _headerRenderer();
    if (_position!.isScrollingNotifier.value) {
      header?.updateScrollStartDirection(_position!.userScrollDirection);
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStopSnapAnimation(_position!.userScrollDirection);
    } else {
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStartSnapAnimation(_position!.userScrollDirection);
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

这里面监听滚动状态并控制吸附动画的触发/停止,而控制吸附动画的触发和停止的就是RenderSliverFloatingPersistentHeader,也就是前面_RenderSliverFloatingPersistentHeaderForWidgets所继承的类


_RenderSliverFloatingPersistentHeaderForWidgets


void updateScrollStartDirection(ScrollDirection direction) {
  _lastStartedScrollDirection = direction;
}

void maybeStopSnapAnimation(ScrollDirection direction) {
  _controller?.stop();
}

void maybeStartSnapAnimation(ScrollDirection direction) {
  final FloatingHeaderSnapConfiguration? snap = snapConfiguration;
  if (snap == null) {
    return;
  }
  if (direction == ScrollDirection.forward && _effectiveScrollOffset! <= 0.0) {
    return;
  }
  if (direction == ScrollDirection.reverse && _effectiveScrollOffset! >= maxExtent) {
    return;
  }

  _updateAnimation(
    snap.duration,
    direction == ScrollDirection.forward ? 0.0 : maxExtent,
    snap.curve,
  );
  _controller?.forward(from: 0.0);
}

void _updateAnimation(Duration duration, double endValue, Curve curve) {
  assert(
    vsync != null,
    'vsync must not be null if the floating header changes size animatedly.',
  );

  final AnimationController effectiveController =
    _controller ??= AnimationController(vsync: vsync!, duration: duration)
      ..addListener(() {
          if (_effectiveScrollOffset == _animation.value) {
            return;
          }
          _effectiveScrollOffset = _animation.value;
          markNeedsLayout();
        });

  _animation = effectiveController.drive(
    Tween(
      begin: _effectiveScrollOffset,
      end: endValue,
    ).chain(CurveTween(curve: curve)),
  );
}

可以看到核心思路就是: 创建 AnimationController



  • 如果 _controller 为空,则创建一个新的,绑定 vsync(防止动画掉帧);

  • 添加监听器,每帧更新 _effectiveScrollOffset 并调用 markNeedsLayout() 通知 RenderObject 重新布局。 创建 Tween + Curve

  • _animation 表示 header 从当前偏移量 _effectiveScrollOffset 到目标 endValue 的动画;

  • 使用 CurveTween 实现动画曲线(如 easeInOut)。 动画驱动布局

  • 每次动画值变化,RenderObject 会重新计算 header 的位置;

  • _effectiveScrollOffset 在 Render 层直接影响 layout 时 header 的显示/收缩状态。



那为什么SliverPersistentHeader的滑动会被优先消费呢?



@override
void performLayout() {
  final SliverConstraints constraints = this.constraints;
  final double maxExtent = this.maxExtent;
  final bool overlapsContent = constraints.overlap > 0.0;
  layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: overlapsContent);
  final double effectiveRemainingPaintExtent = math.max(0, constraints.remainingPaintExtent - constraints.overlap);
  final double layoutExtent = clampDouble(maxExtent - constraints.scrollOffset, 0.0, effectiveRemainingPaintExtent);
  final double stretchOffset = stretchConfiguration != null ?
    constraints.overlap.abs() :
    0.0;
  geometry = SliverGeometry(
    scrollExtent: maxExtent,
    paintOrigin: constraints.overlap,
    paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
    layoutExtent: layoutExtent,
    maxPaintExtent: maxExtent + stretchOffset,
    maxScrollObstructionExtent: minExtent,
    cacheExtent: layoutExtent > 0.0 ? -constraints.cacheOrigin + layoutExtent : layoutExtent,
    hasVisualOverflowtrue// Conservatively say we do have overflow to avoid complexity.
  );
}

其中layoutExtent的计算就是Header “优先消费滚动”的关键:



  • constraints.scrollOffset:代表当前 sliver 被上层滚动消耗的距离。

  • maxExtent:header 最大高度。

  • 当滚动时,scrollOffset 增大 ⇒ layoutExtent 减小 ⇒ header 收起;

  • 当滚动向下 ⇒ scrollOffset 减小 ⇒ layoutExtent 增大 ⇒ header 露出。


在header尚未完全隐藏(scrollOffset < maxExtent)之前,layoutExtent仍然大于0,意味着这个Header还在继续“吃掉”scrollOffset,下一个Sliver还拿不到这个滚动距离。


换句话说


Header 在Layout阶段主动根据scrollOffset调整可见高度,并在未完全隐藏时持续消耗滚动距离,导致下层列表“迟迟不动”——这就是“优先消费滑动”的根本原因。


利用机制解决问题


那我们又希望有这层动画效果,又不希望滑动被提前消费应该怎么做呢,思路有很多种



  • 重写sliver,去除这层消费

  • 手动接收滑动的offset,模仿实现顶部吸附的动画效果

  • 利用sliver接收的滑动实现我们需要的动画效果


第一种情况下sliver中的很多类是内部类,需要手动复制出来,成本极高


第二种思路需要手动兼容和原本布局的滑动冲突情况


在最快、最简思路下,第三种方案应该是最优解


class CustomSnapHeaderDemo extends StatefulWidget {
  const CustomSnapHeaderDemo({super.key});

  @override
  State createState() => _CustomSnapHeaderDemoState();
}

class _CustomSnapHeaderDemoState extends State<CustomSnapHeaderDemo{
  late final ScrollController _scrollController;
  late ScrollPosition _scrollPosition;

  /// header 的高度
  static const double _headerExtent = 120.0;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();

    /// 等待第一帧绘制后再拿到 ScrollPosition
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollPosition = _scrollController.position;
      // 监听滚动状态变化
      _scrollPosition.isScrollingNotifier.addListener(_onScrollStateChanged);
    });
  }

  @override
  void dispose() {
    _scrollPosition.isScrollingNotifier.removeListener(_onScrollStateChanged);
    _scrollController.dispose();
    super.dispose();
  }

  /// 滚动状态监听器
  void _onScrollStateChanged() {
    final isScrolling = _scrollPosition.isScrollingNotifier.value;
    if (!isScrolling) {
      // 滚动停止时触发吸附逻辑
      _maybeSnapHeader();
    }
  }

  /// 自定义吸附逻辑:
  /// 当 header 显示一半以上时,吸附到完全展开;
  /// 否则隐藏到底部。
  void _maybeSnapHeader() {
    // 当前滚动偏移
    final currentOffset = _scrollPosition.pixels;

    // header 最大可滚动距离
    final maxHeaderOffset = _headerExtent / 2;

    // 如果当前偏移量 < headerExtent,说明 header 仍部分可见
    if (currentOffset >= 0 && currentOffset <= maxHeaderOffset) {
      final visibleRatio = 1.0 - (currentOffset / maxHeaderOffset);
      if (visibleRatio > 0.5) {
        // 吸附展开
        _animateTo(0);
      } else {
        // 吸附隐藏
        _animateTo(maxHeaderOffset);
      }
    }
  }

  /// 平滑滚动到目标位置
  void _animateTo(double targetOffset) {
    _scrollController.animateTo(
      targetOffset,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
    )
;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('SliverPersistentHeader Demo')),
      body: CustomScrollView(
        controller: _scrollController,
        slivers: [
          SliverPersistentHeader(
            pinned: true,
            floating: false,
            delegate: _CustomHeaderDelegate(
              extent: _headerExtent,
            ),
          ),

          // 模拟长列表内容
          SliverList.builder(
            itemCount: 50,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text('Item $index'),
              );
            },
          ),
        ],
      ),
    );
  }
}

class _CustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double extent;

  _CustomHeaderDelegate({required this.extent});

  @override
  double get minExtent => extent / 2// 最小高度
  @override
  double get maxExtent => extent; // 最大高度

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    final percent = 1.0 - (shrinkOffset / maxExtent);
    return Container(
      color: Colors.blue,
      alignment: Alignment.center,
      child: const Text(
        
'自定义吸附 Header',
        style: TextStyle(
          color: Colors.white,
          fontSize: 
22,
          fontWeight: FontWeight.bold,
        )
,
      ),
    )
;
  }

  @override
  bool shouldRebuild(covariant _CustomHeaderDelegate oldDelegate) {
    return oldDelegate.extent != extent;
  }
}

作者:柿蒂
来源:juejin.cn/post/7564661612319293455
收起阅读 »

Compose 重组优化

1、重组优化的核心思想 定义:状态变化时,让尽可能少的可组合函数以尽可能快的速度执行完成。 关键词:尽可能少、尽可能快 2、常见重组优化   其实在前面介绍Compose的时候,我们也多少提到过一些重组优化,这里主要是将前面提到过的重组优化、实际开发中常见...
继续阅读 »

1、重组优化的核心思想



  • 定义:状态变化时,让尽可能少的可组合函数以尽可能快的速度执行完成。

  • 关键词:尽可能少、尽可能快


2、常见重组优化


  其实在前面介绍Compose的时候,我们也多少提到过一些重组优化,这里主要是将前面提到过的重组优化、实际开发中常见错误怎么重组优化以及Compose中还提供了哪些重组优化的API做一次汇总,帮助我们刚上手时“避坑”。


  2.1 控制重组范围:


让状态变化只影响“必要区域”


 2.1.1 拆分复杂的可组合函数: 避免“牵一发而动全身”



  • 优化前:因name对象未被缓存,每次重组后都会创建新的对象,进而导致名称Text()在每次点击后都会重组。


点击操作 -> age累加 -> Test()重组 -> 重新创建name -> name Text()重组。


@Composable
fun Test(){
val name = "Hello world!!"
//可观察状态
var age by remember { mutableIntStateOf(18) }

Column {
//名称
Text(text = name)
//年龄
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}
}


  • 优化后:点击操作后,与age依赖的函数只有AgeTest(),Test()和NameTest()函数不受其影响。


@Composable
fun Test(){
Column {
//名称
NameTest()
//年龄
AgeTest()
}
}

@Composable
fun NameTest() {
Text(text = "Hello world!!")
}
@Composable
fun AgeTest() {
//可观察状态
var age by remember { mutableIntStateOf(18) }
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}

 2.1.2 列表用key控制重组颗粒度:避免“批量无效重组”


在使用LazyColumn/LazyRow 未指定 key 时,默认用 “列表索引” 作为标识,列表增删 / 排序时会导致大量无关项重组。


如果我们没有指定key,那么默认key就是index,假如我们删除第一项(index =0),会导致后续所有的索性变更(即都会左移:2->1,1->0),从而导致全部重组。--此时后面item无内容变化


指定key后,Compose识别后面item无内容变化,不会重组。--重组数量从“N -> 1”


@Composable
fun ProductList(products: List<Product>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
// 指定 key 为 product.id:唯一标识每个列表项
items(
items = products,
key = { product -> product.id } // 核心优化:用唯一 ID 替代索引
) { product ->
ShopItem(
product = product,
isFavorite = false,
onFavoriteClick = {}
)
Divider()
}
}
}

  2.2 避免无效重组:    让“不变的状态”不发生重组


Compose 简介和基础使用1 简介 1.1 背景 2019 年 5 月(首次亮相)在 Google I/O 大会上, - 掘金
中2.4.5.2 保存界面状态方式章节中提到过


特性rememberrememberSaveable
重组时是否保留是(核心功能)是(继承 remember 的能力)
重建时是否保留否(状态随组件实例销毁)是(通过 Bundle 持久化)
适用数据类型任意类型基本类型、可序列化类型(或需自定义保存逻辑)
性能开销低(内存级保存)略高(涉及 Bundle 读写)
典型使用场景临时状态(如列表展开 / 折叠)需持久化的用户输入(如表单、设置)

  2.2.1 remember


remember 是Compose API提供的缓存接口,避免每次重组时重新创建对象或者重新计算。
如下,“val showName = "Hello world!!--$name"”写法上面分析过,每次点击后name Text()都会发生重组。通过remember 缓存,那么只有name发生变化时name Text()才会重组。


@Composable
fun Test(name: String){
//状态
// val showName = "Hello world!!--$name"
//remember 普通缓存
val showName by remember(name) { mutableStateOf("Hello world!!--$name") }
var age by remember { mutableIntStateOf(18) }

// rememberSaveable 跨配置状态缓存
val showName by rememberSaveable(name) { mutableStateOf("Hello world!!--$name") }
var age by rememberSaveable { mutableIntStateOf(18) }

Column {
//名称
Text(text = showName)

//年龄
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}
}

  2.2.2 rememberSaveable


rememberSaveable 是Compose API提供的缓存接口,当状态需要在配置变更(如屏幕旋转、语言切换)后保留时,使用 rememberSaveable 可以实现跨配置的状态缓存,避免状态丢失和不必要的重新计算。


如上示例假设showName、age需要在屏幕旋转、语言切换后保留之前状态,那么就可以用rememberSaveable 缓存。


  2.2.3 rememberUpdatedState


副作用生命周期大于状态的变化周期(例如副作用中延迟、循环等),且副作用中需要获取最新的状态值。
分析:- LaunchedEffect(Unit)副作用中使用Unit表示没监听任何状态,所以只在首次重组时创建启动协程,后续重组不会再重新创建新的启动协程,并且旧的协程也不会被打断。



  • reportMessage 是可观察状态,内部直接通过副作用使用时,协程捕获到的是这个状态的引用,所以修改后内部延迟也能打印最新的值。而通过参数传递时传递的是具体的值(String),所以不使用rememberUpdatedState只能打印旧值,使用后rememberUpdatedState可以监听值的变化,保证副作用中打印的是最新的值。


@Composable
fun ReportMessageScreen() {
// 父组件管理的消息状态,可动态更新
var reportMessage by remember { mutableStateOf("初始消息") }

// 子组件:负责延迟上报消息
MessageReporter(currentMessage = reportMessage)

LaunchedEffect(Unit) {
delay(5000) // 延迟3秒上报
// 问题:即使currentMessage已更新,仍会上报初始值
Log.d("Report", "内部上报: $reportMessage")
}

// 按钮:更新消息内容
Button(onClick = { reportMessage = "用户修改后的新消息" } ) {
Text(reportMessage)
}

}

@Composable
fun MessageReporter(currentMessage: String) {
Log.d("Report","MessageReporter----start---")
// 错误做法:不使用rememberUpdatedState
LaunchedEffect(Unit) {
delay(5000) // 延迟3秒上报
// 问题:即使currentMessage已更新,仍会上报初始值
Log.d("Report", "错误上报: $currentMessage")
}

// 正确做法:必须使用rememberUpdatedState
val latestMessage by rememberUpdatedState(currentMessage)
LaunchedEffect(Unit) {
delay(10000) // 延迟3秒上报
// 确保上报的是最新值
Log.d("Report", "正确上报: $latestMessage")
}
Log.d("Report","MessageReporter----end---")
}

//日志打印
//初始化
2025-09-11 20:27:26.742 6847-6847 D MessageReporter----start---
2025-09-11 20:27:26.749 6847-6847 D MessageReporter----end---
//点击后
2025-09-11 20:27:29.077 6847-6847 D MessageReporter----start---
2025-09-11 20:27:29.077 6847-6847 D MessageReporter----end---
//延迟消息
2025-09-11 20:27:32.096 6847-6847 D 错误上报: 初始消息
2025-09-11 20:27:37.098 6847-6847 D 正确上报: 用户修改后的新消息

  2.2.4 derivedStateOf


通过派生状态的结果去重,避免因 “依赖状态频繁变化但结果不变” 导致的重组。


示例:只有当userName和password都不为空时才需要重组按钮


@Composable
fun LoginScreen() {
// 状态源1:用户名输入
var username by remember { mutableStateOf("") }
// 状态源2:密码输入
var password by remember { mutableStateOf("") }

//错误写法,每次输入username或者password时,isLoginEnabled都会导致按钮重组
//val isLoginEnabled = username.isNotEmpty() && password.isNotEmpty()

//正确写法 用derivedStateOf组合两个状态,判断按钮是否可点击
val isLoginEnabled by remember {
derivedStateOf {
// 同时依赖username和password两个状态
username.isNotEmpty() && password.isNotEmpty()
}
}

// 依赖isLoginEnabled的按钮
Button(
onClick = { /* 登录逻辑 */ },
enabled = isLoginEnabled
) {Text("登录")}
}

   2.2.5 标记稳定类型 :@Stable/@Immutable


自定义数据类未标记稳定类型,Compose 无法判断其是否变化,可能会 “过度谨慎” 地触发重组。
原因:Compose 默认认为 “未标记的自定义类是不稳定的”,即使所有属性都是val。


        2.2.5.1 @Stable/@Immutable 使用

所以如下未标记时,在Test()重组时,即使person对象本身和name、age没有发生变化,都可能导致name Text()或者age Text()发生重组(过度谨慎重组)


优化方法:添加@Stable/@Immutable标记,防止Compose因过度谨慎带来的不必要的重组。


// 未标记稳定类型的自定义数据类
data class Person(val name: String, val age: Int)

//@Immutable(完全不可变,name和age都是val不可变类型)
//data class Person(val name: String, val age: Int)

//@Stable(稳定类型(不一定完全不可变),name是var可变,age是不可变类型)
//data class Person(var name: String, val age: Int)
@Composable
fun Test(person: Person){
Column {
//名称
Text(text = person.name)

//年龄
Text(text = "${person.age}",)
}
}

        2.2.5.2 @Stable/@Immutable 区别


  • @Immutable 标记的完全不可变的类,只要引用没变Compose就认为内部数据一定没变,不需要重组。特性:


    类的所有属性都是val(不可变)



//正确
@Immutable
data class Book(
val id: Int, // val 不可变
val title: String // val 不可变
)

// 错误:包含 var 属性
@Immutable
data class Book(
val id: Int,
var title: String // var 可变,违反条件
)

// Book 对象做为Composable入参
@Composable
fun Test(book: Book){
Column {
//名称
Text(text = person.title)
}
}

所有属性类型本身也是不可变的(或被@Immutable标记)


// 自定义不可变类(满足 @Immutable 条件)
@Immutable
data class Author(
val name: String, // String 是不可变类型
val age: Int // Int 是不可变类型
)

// 引用 @Immutable 类型的属性
@Immutable
data class Book(
val id: Int,
val title: String,
// Author 被 @Immutable 标记,满足条件
// 如果Author 没有被 @Immutable 标记,则不满足条件
val author: Author
)

// 错误:属性类型是可变的 MutableList
@Immutable
data class Book(
val id: Int,
val tags: MutableList<String> // MutableList 是可变类型,违反条件
)

类本身没有任何可修改状态(包括间接引用对象)


// 最底层:不可变类型
@Immutable
data class Address(
val city: String,
val street: String
)

// 中间层:引用不可变类型
@Immutable
data class User(
val name: String,
val address: Address // Address 是 @Immutable 类型
)

// 顶层:引用不可变类型
@Immutable
data class Order(
val id: Int,
val user: User // User 是 @Immutable 类型
)


  • @Stable 标记稳定的类(可存在可变属性),引用没变且内部状态能被追踪,Compose就能精准判断只有内部状态发生变化时才触发重组,避免无效重组。特性:



    • 类中存在可变属性var

    • 可变属性必须是可被追踪的




@Stable
class User {
// var age = 18 普通变量,不可追踪、被观察,变化后不会触发重组
var age by mutableStateOf(18) // 变化可追踪
}

// 用 User 作为入参的 Composable
@Composable
fun UserInfo(user: User) {
Text("年龄:${user.age}") // 依赖 user.age
}

也就是说要么引用变了(肯定要检查并重组),要么内部状态变了(Compose 能感知到),不会出现引用和内部状态都不变的情况下重组了,也不会出现 “状态变了但 Compose 不知道” 的情况。因此 Compose 可以放心地优化重组逻辑,既不会漏更 UI,也不会做无用功。


       2.2.5.3 总结

使用@Stable/@Immutable标记自定义类目的:因Compose 默认认为 “未标记的自定义类是不稳定的”,可能会发生”过度谨慎“重组。- 添加@Immutable注解完全不可变的类,Compose只有引用对象发生变化时需要重组,自定义类中不可存在可变属性。



  • 添加@Stable注解稳定的类(可存在可变属性),Compose只有引用对象发生变化或内部状态发生变化时需要重组,自定义类中允许存在可变属性。


    2.2.6 snapshotFlow      高频防抖


@Composable
fun SearchInput() {
var searchQuery by remember { mutableStateOf("") }

// 错误:每次输入字符都会触发重组,直接执行搜索,高频调用
//LaunchedEffect(searchQuery) {
// // 模拟搜索网络请求
// Log.d("Search", "搜索:$searchQuery")
// }

// 正确:将状态转为Flow,添加300ms防抖,仅停止输入后执行
LaunchedEffect(Unit) {
snapshotFlow { searchQuery } // 转换Compose状态为Flow
.debounce(300) // 防抖:300ms内无变化才继续
.collect { query ->
if (query.isNotEmpty()) {
Log.d("Search", "搜索:$query") // 仅停止输入后执行
}
}
}


TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("输入搜索内容") }
)
}

  2.3 优化重组效率:


让必须重组的过程 “更快”


    2.3.1 减少可组合函数内的耗时操作


可组合函数应只做 “描述 UI” 的轻量操作,禁止在其中直接执行 IO、网络请求、复杂计算。


@Composable
fun UserProfile(userId: String) {
//错误示例,在组合函数中直接请求网络,每次重组时都会发起网络请求
//fetchDataFromNetwork() // 网络请求(副作用)

var user by remember { mutableStateOf<User?>(null) }

// 副作用:网络请求,依赖 userId(userId 变化时会重新执行)
LaunchedEffect(userId) {
// 耗时操作放在协程中,不阻塞主线程
user = api.fetchUser(userId) // 网络请求(副作用)
}

if (user != null) {
Text("Name: ${user?.name}")
} else {
CircularProgressIndicator()
}
}

    2.3.2 避免在重组中创建新对象


每次重组时创建新对象(如Lambda)会被 Compose 视为 “参数变化”,触发子组件重组。      温故而知新,之前实际开发中也都没注意到这些。- Lambda


//错误示例
@Composable
fun UserProfile(user: User) {
// 每次重组都会创建新的 Lambda 实例
Button(onClick = {
// 处理点击事件
navigateToUserDetail(user.id)
}) {
Text("查看详情")
}
}

//正确示例
@Composable
fun UserProfile(user: User) {
// 无依赖的 remember,仅在首次组合时创建一次 Lambda
val onClick = remember {
{ navigateToUserDetail(user.id) }
}

Button(onClick = onClick) {
Text("查看详情")
}
}
```
```

作者:用户06090525522
来源:juejin.cn/post/7559435122451693622
收起阅读 »

Compose 页面沉浸式体验适配

沉浸式 所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式: 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果; 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。 实现方案 创建一个 And...
继续阅读 »

沉浸式


所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式:



  • 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果;

  • 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。


实现方案


创建一个 Android Compose 项目,会默认生成 MainActivity 的代码:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ImmersiveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

enableEdgeToEdge


在 onCreate 中会默认调用 enableEdgeToEdge(),这个方法是 ComponentActivity 的拓展方法,用来将 Activity 的内容延展到边缘,将状态栏设置为透明,导航栏根据导航模式呈现不同的效果,为这个 Activity 添加一个灰色背景,效果如下:


Screenshot_1729824579.png


Screenshot_1729822985.png


Screenshot_1729824516.png


这是三种导航模式的显示效果,导航模式可以在设置中更改:


Screenshot_1729822926.png


可以看出三种导航模式显示效果略有不同,双按钮导航和三按钮导航模式下,导航栏会有系统配置的蒙层。
而手势导航模式下,Activity 内容的背景是延伸到状态栏和导航栏的。


enableEdgeToEdge() 是 ComponentActivity 的拓展方法:


/**
* 对这个 ComponentActivity 开启边到边的显示
*
* 要使用默认样式进行设置,在你的 Activity's onCreate 方法中调用这个方法:
* ```
* override fun onCreate(savedInstanceState: Bundle?) {
* enableEdgeToEdge()
* super.onCreate(savedInstanceState)
* ...
* }
* ```
*
* 默认样式会在系统能够强制实施对比度的时候(在 API 29 及以上版本),把系统栏设置为透明背景。
* 在旧的平台上(只有 三按钮导航、双按钮导航模式),会应用一个类似的遮光层以确保与系统栏有对比度。
* See [SystemBarStyle] for more customization options.
*
* @param statusBarStyle The [SystemBarStyle] for the status bar.
* @param navigationBarStyle The [SystemBarStyle] for the navigation bar.
*/

@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
,
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
val view = window.decorView
val statusBarIsDark = statusBarStyle.detectDarkMode(view.resources)
val navigationBarIsDark = navigationBarStyle.detectDarkMode(view.resources)
val impl = Impl ?: if (Build.VERSION.SDK_INT >= 29) {
EdgeToEdgeApi29()
} else if (Build.VERSION.SDK_INT >= 26) {
EdgeToEdgeApi26()
} else if (Build.VERSION.SDK_INT >= 23) {
EdgeToEdgeApi23()
} else if (Build.VERSION.SDK_INT >= 21) {
EdgeToEdgeApi21()
} else {
EdgeToEdgeBase()
}.also { Impl = it }
impl.setUp(
statusBarStyle, navigationBarStyle, window, view, statusBarIsDark, navigationBarIsDark
)
}

这个方法的注释中也描述三按钮和双按钮导航模式会有遮光层。


SystemBarStyle


enableEdgeToEdge() 方法中无论是导航栏还是状态栏的 Style 都是 SystemBarStyle 类型,SystemBarStyle 提供默认的系统风格,并且具有自动监测 dark 模式的能力。


SystemBarStyle 源码大致如下:


/**
* [enableEdgeToEdge] 中使用的状态栏或导航栏的样式。
*/

class SystemBarStyle private constructor(
private val lightScrim: Int,
internal val darkScrim: Int,
internal val nightMode: Int,
internal val detectDarkMode: (Resources) -> Boolean
) {

companion object {
@JvmStatic
@JvmOverloads
fun auto(
@ColorInt lightScrim: Int,
@ColorInt darkScrim: Int,
detectDarkMode: (Resources) -> Boolean = { resources ->
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK)
==
Configuration.UI_MODE_NIGHT_YES
}
): SystemBarStyle

@JvmStatic
fun dark(@ColorInt scrim: Int): SystemBarStyle

@JvmStatic
fun light(@ColorInt scrim: Int, @ColorInt darkScrim: Int): SystemBarStyle
}

internal fun getScrim(isDark: Boolean) = if (isDark) darkScrim else lightScrim

internal fun getScrimWithEnforcedContrast(isDark: Boolean): Int {
return when {
nightMode == UiModeManager.MODE_NIGHT_AUTO -> Color.TRANSPARENT
isDark -> darkScrim
else -> lightScrim
}
}
}

SystemBarStyle 提供了三个初始化方法,auto、dark、light,auto,三个方法的行为各不相同。


SystemBarStyle.auto


写个例子:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.dark(Color.Red.toArgb()) // set color for navigationBar
)
setContent {
ImmersiveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Spacer(modifier = Modifier.fillMaxSize().background(Color.Cyan))
Greeting(
name = "Android"
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

效果如下:


Screenshot_1729824579.png


Screenshot_1729824483.png


Screenshot_1729822956.png


在 API 级别 29 及以上,auto 方法在手势导航的情况下是透明的,设置的颜色不会生效。


在三按钮和双按钮导航模式下,系统将自动应用默认的遮光层。请注意,指定的颜色都不会被使用。在 API 级别 28 及以下,导航栏将根据暗黑模式是否开启来展示指定的颜色。



  • lightScrim 当应用处于浅色模式时用于背景的遮光层颜色。

  • darkScrim 当应用处于深色模式时用于背景的遮光层颜色。这也用于系统图标颜色始终为浅色的设备。


SystemBarStyle.dark


创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。参数 scrim 用于背景的遮光层颜色。为了与浅色系统图标形成对比,它应该是深色的。


dark 模式很简单,无论什么导航模式、主题模式,他都显示设置的颜色。


Screenshot_1729828540.png


SystemBarStyle.light


创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。



  • 参数 scrim 用于背景的遮光层颜色。为了与深色系统图标形成对比,它应该是浅色的。

  • 参数 darkScrim 在系统图标颜色始终为浅色的设备上用于背景的遮光层颜色。它应该是深色的。


与 dark 不同,应用可以强制设置为 light 模式,而不用随系统的主题模式变化而变化,此时 darkScrim 生效。其他情况下使用 scrim。


系统栏背景遮光层


在上面的内容中,我们知道系统会给导航栏和状态栏设置一个遮光层,导航栏和状态栏会随着系统的导航模式和主题模式而变化。


但实际上应用希望呈现沉浸式的效果,就需要无论在上面导航模式、主题模式下都呈现与内容相同的颜色效果,所以需要去掉导航栏和状态栏的遮罩。


当我们什么也不设置,只调用 enableEdgeToEdge() 时,是这样的:


Screenshot_1729835423.png


调用去掉导航栏遮罩效果:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 去掉导航栏遮罩
window.isNavigationBarContrastEnforced = false
}

isNavigationBarContrastEnforced 属性可以关闭强制使用导航栏遮罩,源码如下:


    /**
* 当请求完全透明的背景时,设置系统是否应该确保导航栏有足够的对比度
*
* 如果设置为这个值,系统将确定是否需要一个遮光层来确保导航栏与这个应用的内容有足够的对比度,
* 并相应地设置一个适当的有效的导航栏背景颜色。
*
* 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
*
* @see android.R.attr#enforceNavigationBarContrast
* @see #isNavigationBarContrastEnforced
* @see #setNavigationBarColor
*/

public void setNavigationBarContrastEnforced(boolean enforceContrast) {
}

同样地,对于状态栏也有相同的属性:


    /**
* 当请求完全透明的背景时,设置系统是否应该确保栏有足够的对比度
*
* 如果设置为这个值,系统将确定是否需要一个遮光层来确保状态栏与这个应用的内容有足够的对比度,
* 并相应地设置一个适当的有效的导航栏背景颜色。
*
* 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
*
* @see android.R.attr#enforceStatusBarContrast
* @see #isStatusBarContrastEnforced
* @see #setStatusBarColor
*/

public void setStatusBarContrastEnforced(boolean ensureContrast) {
}

所以去掉遮光层效果如下:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 去掉导航栏遮罩
window.isNavigationBarContrastEnforced = false
// 去掉状态栏遮罩
window.isStatusBarContrastEnforced = false
}

系统栏前景色


在状态栏和导航栏中有一些图标,比如状态栏中的电量图标、手势导航模式下的导航条图标,这些图标会随着系统主题(dark or light)变化为深色 icon 或是浅色 icon,



  • 当系统为 dark 主题模式下,icon 是浅色的,以和背景达成一种对比效果;

  • 当系统为 light 主题模式下,icon 是深色的。


		/**
* 如果为 true,则将状态栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
* 如果为 false,则恢复为默认外观。
*
* 此方法在 API 级别小于 23 时没有效果。
*
* 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
*
* @see #isAppearanceLightStatusBars()
*/

public void setAppearanceLightStatusBars(boolean isLight) {
mImpl.setAppearanceLightStatusBars(isLight);
}

同样地,有对导航栏设置的 API:


    /**
* 如果为 true,则将导航栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
* 如果为 false,则恢复为默认外观。
*
* 此方法在 API 级别小于 26 时没有效果。
*
* 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
*
* @see #isAppearanceLightNavigationBars()
*/

public void setAppearanceLightNavigationBars(boolean isLight) {
mImpl.setAppearanceLightNavigationBars(isLight);
}

完整的设置方法:


val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.isAppearanceLightStatusBars = false
windowInsetsController.isAppearanceLightNavigationBars = false

作者:自动化BUG制造器
来源:juejin.cn/post/7429611142706855948
收起阅读 »

Android文件下载完整性保证:快递员小明的故事

有趣的故事:快递员小明的包裹保卫战 想象一下,小明是个快递员,负责从仓库(服务器)运送包裹(文件)到客户(Android设备)。但路上有各种意外: 数据损坏:就像包裹被雨淋湿 网络中断:就像送货路上遇到施工 恶意篡改:就像包裹被坏人调包 小明如何确保客户收...
继续阅读 »

有趣的故事:快递员小明的包裹保卫战


想象一下,小明是个快递员,负责从仓库(服务器)运送包裹(文件)到客户(Android设备)。但路上有各种意外:



  • 数据损坏:就像包裹被雨淋湿

  • 网络中断:就像送货路上遇到施工

  • 恶意篡改:就像包裹被坏人调包


小明如何确保客户收到的包裹完好无损呢?


核心技术原理


1. 校验和验证(Checksum) - "包裹清单核对"


就像快递员对照清单检查物品数量:


// MD5校验 - 快速但安全性较低
public boolean verifyFileMD5(File file, String expectedMD5) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) != -1) {
md.update(buffer, 0, length);
}
byte[] digest = md.digest();

// 转换为十六进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("x", b));
}
String actualMD5 = sb.toString();

fis.close();
return actualMD5.equals(expectedMD5.toLowerCase());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

2. SHA系列校验 - "高级防伪验证"


// SHA-256校验 - 更安全的选择
public boolean verifyFileSHA256(File file, String expectedSHA256) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) != -1) {
digest.update(buffer, 0, length);
}
byte[] hash = digest.digest();

StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}

fis.close();
return hexString.toString().equals(expectedSHA256.toLowerCase());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

3. 完整下载管理器实现


public class SecureDownloadManager {
private Context context;
private DownloadListener listener;

public interface DownloadListener {
void onDownloadProgress(int progress);
void onDownloadSuccess(File file);
void onDownloadFailed(String error);
void onIntegrityCheckFailed();
}

public SecureDownloadManager(Context context, DownloadListener listener) {
this.context = context;
this.listener = listener;
}

public void downloadFileWithVerification(String fileUrl,
String fileName,
String expectedHash,
HashType hashType)
{
new DownloadTask(fileUrl, fileName, expectedHash, hashType).execute();
}

private class DownloadTask extends AsyncTask<Void, Integer, Boolean> {
private String fileUrl;
private String fileName;
private String expectedHash;
private HashType hashType;
private File downloadedFile;

public DownloadTask(String fileUrl, String fileName,
String expectedHash, HashType hashType)
{
this.fileUrl = fileUrl;
this.fileName = fileName;
this.expectedHash = expectedHash;
this.hashType = hashType;
}

@Override
protected Boolean doInBackground(Void... voids) {
try {
// 创建目标文件
File downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
downloadedFile = new File(downloadsDir, fileName);

// 开始下载
URL url = new URL(fileUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.connect();

// 检查响应码
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
return false;
}

// 获取文件大小用于进度计算
int fileLength = connection.getContentLength();

// 下载文件
InputStream input = connection.getInputStream();
FileOutputStream output = new FileOutputStream(downloadedFile);

byte[] buffer = new byte[4096];
long total = 0;
int count;
while ((count = input.read(buffer)) != -1) {
// 如果用户取消了任务
if (isCancelled()) {
input.close();
output.close();
downloadedFile.delete();
return false;
}
total += count;

// 发布进度
if (fileLength > 0) {
publishProgress((int) (total * 100 / fileLength));
}

output.write(buffer, 0, count);
}

output.flush();
output.close();
input.close();

// 验证文件完整性
return verifyFileIntegrity(downloadedFile, expectedHash, hashType);

} catch (Exception e) {
e.printStackTrace();
if (downloadedFile != null && downloadedFile.exists()) {
downloadedFile.delete();
}
return false;
}
}

@Override
protected void onProgressUpdate(Integer... values) {
if (listener != null) {
listener.onDownloadProgress(values[0]);
}
}

@Override
protected void onPostExecute(Boolean success) {
if (success) {
if (listener != null) {
listener.onDownloadSuccess(downloadedFile);
}
} else {
if (downloadedFile != null && downloadedFile.exists()) {
downloadedFile.delete();
}
if (listener != null) {
listener.onIntegrityCheckFailed();
}
}
}
}

private boolean verifyFileIntegrity(File file, String expectedHash, HashType hashType) {
try {
String actualHash;
switch (hashType) {
case MD5:
actualHash = calculateMD5(file);
break;
case SHA256:
actualHash = calculateSHA256(file);
break;
case SHA1:
actualHash = calculateSHA1(file);
break;
default:
actualHash = calculateSHA256(file);
}
return actualHash != null && actualHash.equalsIgnoreCase(expectedHash);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

public enum HashType {
MD5, SHA1, SHA256
}
}

4. 使用示例


public class MainActivity extends AppCompatActivity {
private SecureDownloadManager downloadManager;
private ProgressBar progressBar;
private TextView statusText;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

progressBar = findViewById(R.id.progressBar);
statusText = findViewById(R.id.statusText);

downloadManager = new SecureDownloadManager(this, new SecureDownloadManager.DownloadListener() {
@Override
public void onDownloadProgress(int progress) {
runOnUiThread(() -> {
progressBar.setProgress(progress);
statusText.setText("下载中: " + progress + "%");
});
}

@Override
public void onDownloadSuccess(File file) {
runOnUiThread(() -> {
statusText.setText("下载完成且文件完整!");
Toast.makeText(MainActivity.this, "文件验证成功", Toast.LENGTH_SHORT).show();
});
}

@Override
public void onDownloadFailed(String error) {
runOnUiThread(() -> {
statusText.setText("下载失败: " + error);
});
}

@Override
public void onIntegrityCheckFailed() {
runOnUiThread(() -> {
statusText.setText("文件完整性验证失败!");
Toast.makeText(MainActivity.this, "文件可能已损坏", Toast.LENGTH_LONG).show();
});
}
});

// 开始下载
Button downloadBtn = findViewById(R.id.downloadBtn);
downloadBtn.setOnClickListener(v -> {
String fileUrl = "https://example.com/file.zip";
String expectedSHA256 = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234";

downloadManager.downloadFileWithVerification(
fileUrl,
"myfile.zip",
expectedSHA256,
SecureDownloadManager.HashType.SHA256
);
});
}
}

时序图:完整的下载验证流程


deepseek_mermaid_20251010_1d824c.png


关键要点总结



  1. 双重保障:下载完成 + 完整性验证 = 安全文件

  2. 进度反馈:让用户知道下载状态

  3. 自动清理:验证失败时自动删除损坏文件

  4. 灵活算法:支持多种哈希算法适应不同场景

  5. 异常处理:网络中断、文件损坏等情况的妥善处理


就像快递员小明不仅要把包裹送到,还要确保包裹完好无损一样,我们的下载管理器既要完成下载,又要保证文件的完整性!


作者:Android童话镇
来源:juejin.cn/post/7559190511824519187
收起阅读 »

android ViewBinding

1. 它是什么 & 有啥用 编译期生成与每个布局一一对应的 XXXBinding 类,帮你类型安全地拿到 View 引用;没有反射、没有运行时开销。 仅做“找 View”,不包含表达式/双向绑定/观察者(那是 DataBinding 的职责)。 2...
继续阅读 »

1. 它是什么 & 有啥用



  • 编译期生成与每个布局一一对应的 XXXBinding 类,帮你类型安全地拿到 View 引用;没有反射、没有运行时开销。

  • 仅做“找 View”,不包含表达式/双向绑定/观察者(那是 DataBinding 的职责)。


2. 开启方式(Gradle)


android {
buildFeatures { viewBinding = true }
}


  • 应用/库模块都可开;想排除某些布局,给布局根元素加:


<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true" ... />

3. 生成类与命名规则



  • activity_main.xml → ActivityMainBinding

  • item_user_info.xml → ItemUserInfoBinding

  • 只为有 id 的 View生成字段;布局根通过 binding.root 访问。


4. 三大常用场景


4.1 Activity


class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.title.text = "Hello"
binding.button.setOnClickListener { /* ... */ }
}
}

4.2 Fragment(避免内存泄漏的标准写法)


class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!

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

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.list.adapter = adapter
}

override fun onDestroyView() {
_binding = null // 关键:与 View 的生命周期对齐
}
}


只在 onCreateView ~ onDestroyView 之间使用 binding;不要持有到 Fragment 的字段里跨越 onDestroyView。



可选:更安全的委托


class ViewBindingDelegate<T: ViewBinding>(
val fragment: Fragment,
val binder: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null
override fun getValue(thisRef: Fragment, property: KProperty<*>): T =
binding ?: binder(thisRef.requireView()).also {
binding = it
thisRef.viewLifecycleOwner.lifecycle.addObserver(object: DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) { binding = null }
})
}
}
fun <T: ViewBinding> Fragment.viewBinding(binder: (View)->T) =
ViewBindingDelegate(this, binder)

// 用法:private val binding by viewBinding(FragmentHomeBinding::bind)

4.3 RecyclerView.ViewHolder


class UserVH(val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root)

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): UserVH {
val inflater = LayoutInflater.from(parent.context)
return UserVH(ItemUserBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: UserVH, position: Int) {
val item = getItem(position)
holder.binding.name.text = item.name
}

5. inflate / bind 的三种入口



  • XXXBinding.inflate(layoutInflater):常用于 Activity。

  • XXXBinding.inflate(inflater, parent, attachToParent):用于列表/Fragment。



    • 根为 的布局:必须提供非空 parent,且 attachToParent=true。



  • XXXBinding.bind(view):当你已有一个 View(比如 Dialog#setContentView(view) 后)再创建 binding。


6. include / merge 的细节



  • include:给 一个 android:id,生成的字段类型直接是被包含布局的 Binding


<include
android:id="@+id/header"
layout="@layout/include_header"/>


  • 使用:


binding.header.title.text = "Title"


  • merge 根布局:不产生多余容器,使用:


val b = IncludeToolbarBinding.inflate(inflater, parent, /*attachToParent=*/true)
// 注意:merge 必须 attachToParent = true

7. Dialog / BottomSheet / AlertDialog


class EditDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val b = DialogEditBinding.inflate(layoutInflater)
return AlertDialog.Builder(requireContext())
.setView(b.root)
.setPositiveButton("OK") { _, _ -> /* read b.editText */ }
.create()
}
}

8. 与 DataBinding / Compose 的区别与选型



  • ViewBinding:只做找 View,最快、最轻,无 包裹、无表达式。推荐大多数传统 View 项目使用。

  • DataBinding:支持 @{} 表达式、@BindingAdapter、双向绑定 @={};复杂但强大,编译慢、心智成本高。

  • Compose:声明式 UI。新项目优先;老项目可“渐进式”在局部用 ComposeView。

  • Compose × ViewBinding 互操作:在 Compose 内直接用 AndroidViewBinding(依赖 ui-viewbinding):


@Composable
fun LegacyCard() {
AndroidViewBinding(factory = LegacyCardBinding::inflate) {
title.text = "Hello from ViewBinding"
}
}

9. 常见坑 & 排查



  1. Fragment 泄漏:忘记在 onDestroyView() 置空 _binding。—— 现象:导航返回/旋转后崩溃或持有旧 View。

  2. merge 布局用了 attachToParent=false:导致 IllegalStateException 或看不见 UI。

  3. 在 onCreate() 就用 Fragment 的 binding:此时 View 还没创建,应在 onViewCreated() 之后使用。

  4. 重复 inflate:同一布局多次 inflate 却多次 setContentView/addView,导致层级重复/点击穿透。

  5. 多模块命名冲突:不同模块同名布局会各自产生 Binding,不会冲突;若共享资源注意命名前缀。

  6. 列表里频繁创建 binding:放在 onCreateViewHolder,不要在 onBindViewHolder 重复 inflate。


10. 实战小抄(可直接套用)


(1)列表条目 ViewHolder 模板)


class MsgVH(val b: ItemMsgBinding) : RecyclerView.ViewHolder(b.root)
override fun onCreateViewHolder(p: ViewGr0up, vt: Int) =
MsgVH(ItemMsgBinding.inflate(LayoutInflater.from(p.context), p, false))
override fun onBindViewHolder(h: MsgVH, pos: Int) = with(h.b) {
title.text = getItem(pos).title
time.text = getItem(pos).time
}

(2)Fragment × ViewBinding × Lifecycle


override fun onViewCreated(v: View, s: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.ui.collect { ui -> binding.progress.isVisible = ui.loading }
}
}
}

(3)include 组合标题栏


<!-- layout: activity_main.xml -->
<LinearLayout ...>
<include
android:id="@+id/toolbar"
layout="@layout/include_toolbar"/>

<!-- page content -->
</LinearLayout>

binding.toolbar.title.text = "主页"
binding.toolbar.back.setOnClickListener { onBackPressedDispatcher.onBackPressed() }

作者:南北是北北
来源:juejin.cn/post/7561077821995630644
收起阅读 »

自定义 View 的 “快递失踪案”:为啥 invalidate () 喊不动 onDraw ()?

讲了个 “快递站送货” 的故事 —— 毕竟 View 的绘制流程,本质就是一场 “指令上报→调度→执行” 的快递游戏。 一、先搞懂:正常情况下,“快递” 是怎么送到的? 我们先把 View 体系比作一个城市快递网络: 你写的自定义View = 小区里的 “快...
继续阅读 »

讲了个 “快递站送货” 的故事 —— 毕竟 View 的绘制流程,本质就是一场 “指令上报→调度→执行” 的快递游戏。


一、先搞懂:正常情况下,“快递” 是怎么送到的?


我们先把 View 体系比作一个城市快递网络



  • 你写的自定义View = 小区里的 “快递站”(负责接收指令、安排送货);

  • invalidate() = 你给快递站打 “要送货” 的电话(请求重绘);

  • onDraw() = 快递站的 “送货员”(实际执行绘制逻辑);

  • ViewGr0up(父容器)= “区域调度中心”(转发快递站的请求);

  • ViewRootImpl = 快递总公司(连接快递站和 “城市交通系统”——Android 的 UI 线程);

  • Choreographer = 总公司的 “帧调度室”(负责安排每帧的工作,避免堵车)。


正常送货的时序图(代码 + 流程)


先看一段 “正常能收到货” 的自定义 View 代码:


// 小区快递站(自定义View)
public class NormalCustomView extends View {
private Paint mPaint;

public NormalCustomView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setTextSize(50);
}

// 送货员(执行绘制)
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员onDraw出发!画个文字");
canvas.drawText("快递送到啦~", 100, 100, mPaint);
}
}

// 你(开发者)打电话下单
NormalCustomView view = new NormalCustomView(this);
view.invalidate(); // 打“要送货”的电话

这通电话后,“快递” 会按以下流程送到(时序图用文字拆解):


exported_image.png


你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGr0up)快递站快递站(CustomView)你(开发者)你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGr0up)快递站快递站(CustomView)你(开发者)打call:invalidate()1. 检查自身状态(门开了吗?有货要送吗?)2. 上报:“我要送货,帮我转总公司!”3. 层层转发:“总公司,有个快递站要送货!”4. 申请排期:“下一帧给这个快递站留个位置!”5. 下一帧到了:“可以开始送货流程了!”6. 下达指令:“执行draw(),让送货员出发!”7. 派单:“onDraw,去把货(绘制)送了!”8. 完成:log打印“送货员onDraw出发!”


二、“快递失踪” 的 6 种常见原因(故事 + 代码 + 解决方案)


小明的问题,本质是 “快递在某个环节卡住了”。我们一个个拆穿这些 “卡壳点”—— 每个原因都对应故事里的场景,再给代码验证。


原因 1:快递站 “没开门”(View 不可见)


故事场景:小明早上给快递站打电话,站长接了说:“兄弟,我们还没开门(visibility=GONE),货送不了,挂了啊!”


原理:View 在收到invalidate()后,会先检查visibility属性:



  • 只有visibility == View.VISIBLE时,才会继续上报请求;

  • 如果是GONE(完全隐藏,不占空间)或INVISIBLE(隐藏但占空间),直接 “挂电话”,不触发后续流程。


代码验证(坑)


public class ClosedStationView extends View {
public ClosedStationView(Context context) {
super(context);
// 坑:设置为GONE,快递站没开门
setVisibility(View.GONE);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 永远不会打印
}
}

// 你打电话,但快递站没开门
ClosedStationView view = new ClosedStationView(this);
view.invalidate(); // 白打!

解决方案:确保visibilityView.VISIBLE(代码里setVisibility(View.VISIBLE),或 XML 里android:visibility="visible")。


原因 2:快递站 “没地方放货”(宽高为 0)


故事场景:小明这次确认快递站开了门,但站长说:“我们仓库是 0 平米(宽高 = 0),货没地方放,送不了!”


原理:View 绘制需要 “有空间”——getMeasuredWidth()getMeasuredHeight()必须都大于 0。如果宽高为 0,即使invalidate(),也会跳过后续流程(总不能在 “空气” 里画画吧)。


代码验证(坑)


<!-- XML里坑:宽高设为0 -->
<com.example.MyView
android:layout_width="0dp"
android:layout_height="0dp" />


public class ZeroSizeView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印,因为宽高0
}
}

解决方案



  • 检查 XML 的layout_width/layout_height(别设 0dp);

  • 代码里避免setLayoutParams(new LayoutParams(0, 0))

  • 重写onMeasure()时,确保setMeasuredDimension(width, height)的宽高大于 0。


原因 3:快递站 “只中转不送货”(ViewGr0up 默认不绘制)


故事场景:小明找的是 “区域调度中心”(ViewGr0up)当快递站,结果调度中心说:“我们只负责转发子快递站的货,自己不送货(willNotDraw=true)!”


原理ViewGr0up的默认值willNotDraw = true,意思是 “我是容器,只管子 View 的布局,自己不用绘制”。所以即使你给ViewGr0up调用invalidate(),它也会跳过onDraw()


代码验证(坑)


// 区域调度中心(ViewGr0up),默认不送货
public class NoDrawViewGr0up extends ViewGr0up {
public NoDrawViewGr0up(Context context) {
super(context);
// 坑:没改willNotDraw,默认true
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("调度中心", "自己送货!"); // 不打印
}
}

// 你给调度中心打电话
NoDrawViewGr0up group = new NoDrawViewGr0up(this);
group.invalidate(); // 白打!

解决方案:在ViewGr0up的构造里加一句setWillNotDraw(false),告诉它 “我也要自己送货(绘制)”:


public NoDrawViewGr0up(Context context) {
super(context);
setWillNotDraw(false); // 打开“自己绘制”开关
}

原因 4:你 “打错电话”(非 UI 线程调用 invalidate ())


故事场景:小明在外地出差,用 “公用电话”(非 UI 线程)给快递站打电话,结果电话直接被总公司拦截:“非本人手机(UI 线程),不接!”


原理:Android 的 View 体系是线程不安全的,只有创建 View 的 “UI 线程(主线程)” 才能调用invalidate()。非 UI 线程调用会:



  • 要么直接抛异常(Only the original thread that created a view hierarchy can touch its views);

  • 要么 “悄悄失败”(没抛异常但不触发onDraw())。


代码验证(坑)


public class WrongThreadView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印
}
}

// 你用“公用电话”(非UI线程)打电话
WrongThreadView view = new WrongThreadView(this);
new Thread(() -> {
view.invalidate(); // 非UI线程!要么抛异常,要么白打
}).start();

解决方案:确保在 UI 线程调用invalidate(),常用方式:



  • view.post(Runnable)view.post(() -> view.invalidate())

  • Handler发消息到主线程;

  • ActivityrunOnUiThread(Runnable)里调用。


原因 5:区域调度中心 “拦截了请求”(父 View 阻断上报)


故事场景:小明的快递站属于 “郊区调度中心”,调度中心跟总公司关系不好,收到快递站的请求后,直接扔了:“不给你转总公司,爱咋咋地!”


原理:View 的invalidate()需要通过ViewParent(父 View)层层上报到ViewRootImpl。如果父 View 重写了invalidateChildInParent()(上报方法)并返回null,就会 “拦截” 请求,导致后续流程中断。


代码验证(坑)


// 坑爹的区域调度中心(父View),拦截请求
public class BlockParentViewGr0up extends ViewGr0up {
public BlockParentViewGr0up(Context context) {
super(context);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}

// 重写上报方法,返回null=拦截请求
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
Log.d("坑爹调度中心", "拦截请求,不转总公司!");
return null; // 关键:返回null阻断上报
}
}

// 子快递站(被拦截)
public class ChildView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("子快递站", "送货员出发!"); // 不打印
}
}

// 布局关系:BlockParentViewGr0up包含ChildView
BlockParentViewGr0up parent = new BlockParentViewGr0up(this);
ChildView child = new ChildView(this);
parent.addView(child);
child.invalidate(); // 子View的请求被父View拦截

解决方案



  • 检查父 View 是否重写了invalidateChildInParent(),避免返回null

  • 若父 View 有clipChildren="true"(XML 属性),且子 View 超出父 View 范围,超出部分的invalidate()也会被拦截,可设clipChildren="false"


原因 6:快递站 “用了缓存,不用重送”(硬件加速 Layer 缓存)


故事场景:快递站之前送过一次货,把货存在了 “临时仓库”(硬件加速 Layer)里。这次小明再打电话,站长说:“仓库里有现成的,直接拿,不用再让送货员跑一趟!”


原理:当 View 设置了硬件加速 LayersetLayerType(LAYER_TYPE_HARDWARE, null)),系统会把 View 的绘制结果缓存成一个 “图片(Layer)”。后续调用invalidate()时:



  • 如果只是轻微修改(比如文字颜色不变,只改内容),系统直接复用 Layer,不调用onDraw()

  • 只有 Layer 失效(比如 View 大小改变、Layer 类型切换),才会重新调用onDraw()生成新 Layer。


代码验证(坑)


public class LayerCacheView extends View {
private Paint mPaint;
private String mText = "第一次送货";

public LayerCacheView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.BLUE);
mPaint.setTextSize(50);
// 坑:设置硬件加速Layer,开启缓存
setLayerType(LAYER_TYPE_HARDWARE, null);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!当前文字:" + mText); // 只打印一次
canvas.drawText(mText, 100, 100, mPaint);
}

// 你修改文字后打电话
public void updateText() {
mText = "第二次送货";
invalidate(); // 调用后,onDraw不回调(复用Layer缓存)
}
}

// 调用流程
LayerCacheView view = new LayerCacheView(this);
view.invalidate(); // 第一次:onDraw回调(生成Layer)
view.updateText(); // 第二次:invalidate()但onDraw不回调(复用Layer)

解决方案



  • 若需要每次invalidate()都回调onDraw(),可关闭 Layer:setLayerType(LAYER_TYPE_NONE, null)

  • 若必须用 Layer,可手动让 Layer 失效:invalidate()后加setLayerType(LAYER_TYPE_HARDWARE, null)(强制重建 Layer)。


三、总结:“快递失踪” 排查四步法


小明听完故事,半小时就解决了他的问题(原来是忘了给 ViewGr0up 加setWillNotDraw(false))。最后我给他总结了一套 “排查口诀”,小白也能套用:



  1. 查基础状态:View 是不是VISIBLE?宽高是不是大于 0?(对应原因 1、2)

  2. 查绘制开关:如果是 ViewGr0up,有没有开setWillNotDraw(false)?(对应原因 3)

  3. 查线程归属invalidate()是不是在 UI 线程调用的?(对应原因 4)

  4. 查拦截和缓存:父 View 有没有拦截请求?View 是不是开了硬件加速 Layer?(对应原因 5、6)


按这四步走,90% 的 “invalidate () 不回调 onDraw ()” 问题都能解决。记住:View 的绘制流程就像快递,每个环节都不能少,卡住一个就 “送货失败”~


作者:Android童话镇
来源:juejin.cn/post/7559399860119224383
收起阅读 »

Android 性能调优与故障排查:ADB 诊断命令终极指南

在 Android 开发与测试的日常工作中,快速诊断和解决应用崩溃 (Crash)、无响应 (ANR) 和性能卡顿 (Jank) 是保障应用质量的关键。Android Debug Bridge (ADB) 提供了强大的命令行工具集,能够帮助我们深入系统底层,获...
继续阅读 »

在 Android 开发与测试的日常工作中,快速诊断和解决应用崩溃 (Crash)、无响应 (ANR) 和性能卡顿 (Jank) 是保障应用质量的关键。Android Debug Bridge (ADB) 提供了强大的命令行工具集,能够帮助我们深入系统底层,获取所需的所有诊断数据。


本文将为您全面梳理最常用、最核心的 ADB 诊断命令行工具,助您成为一名高效的故障排查专家。




一、 核心诊断命令:系统快照与错误记录


这些命令用于获取设备某一时刻的全局状态或关键错误记录。


1. 抓取全面的系统诊断报告:adb bugreport


adb bugreport 是最强大的诊断工具,它生成一个关于设备当前状态的全面的、打包的(.zip 格式)系统快照


命令作用备注
adb bugreport生成包含所有诊断信息的 .zip 文件。适用于分析复杂问题、系统级错误,或需提交给平台开发者时。
adb bugreport <文件名>.zip指定导出的文件名。报告内容包括:完整的 Logcat 历史、ANR/Crash 堆栈、所有 dumpsys 信息等。

2. 提取崩溃和 ANR 记录:adb shell dumpsys dropbox


dropbox 服务相当于系统的“黑匣子”,专门收集系统运行过程中的关键错误摘要。


命令作用备注
adb shell dumpsys dropbox --print打印所有 dropbox 记录的详细内容。快速检查是否有最近的系统级或应用级崩溃/ANR 记录。
adb shell dumpsys dropbox --print > crash.txt将所有记录重定向输出到本地 crash.txt 文件。方便离线分析。

3. 提取 ANR 堆栈文件


ANR 发生后,系统将所有线程堆栈记录在 traces.txt 中,这是分析 ANR 的核心。


命令作用备注
cat /data/anr/traces.txt读取 ANR 发生时的详细堆栈信息。⚠️ 通常需要 Root 权限 (adb rootsu) 才能访问 /data/anr/ 目录。
cat /data/anr/traces.txt > /mnt/sdcard/tt.txt在设备内部将受保护的 traces.txt 复制到用户存储区。复制后,可使用 adb pull 导出到电脑。



二、 实时日志与基础性能分析


这些是日常调试和性能监控最频繁使用的命令。


1. Logcat 日志操作


命令作用备注
adb logcat实时打印设备所有日志。可通过 taglevel(如 *:E 只看错误)进行过滤。
adb logcat -c清除设备上当前的日志缓冲区。建议在测试前执行,以确保日志干净、聚焦。
adb logcat -d > log.txt将设备上当前缓存的所有日志导出到本地文件。-d (dump) 参数用于导出当前缓存,而非实时监听。

2. CPU 与内存监控


命令作用关注问题
adb shell ps -t / adb shell top -m 10实时查看进程的 CPU、内存占用情况。性能监控,快速定位资源消耗高的进程。
adb shell dumpsys meminfo [package_name]获取特定应用的详细内存使用情况(Java Heap, Native Heap 等)。内存泄漏、OOM(Out of Memory)分析的核心工具。
adb shell dumpsys cpuinfo获取设备整体和各个进程的 CPU 使用率。诊断后台过度使用 CPU 导致的耗电或发热问题。



三、 性能与卡顿(Jank)分析


专门用于分析应用启动速度和 UI 流畅度的命令。


命令作用备注
adb shell dumpsys gfxinfo [package_name]抓取应用的图形渲染性能数据包含丢帧 (Jank) 统计和渲染时间线,用于分析 UI 卡顿问题。
adb shell am crash [package_name]强制让目标应用崩溃。用于测试崩溃报告系统的稳定性和流程。
adb shell am start -W [package_name]/[activity_name]启动指定的 Activity 并等待初始化完成,同时打印启动耗时用于量化分析应用启动速度(Total Time, Wait Time)。



四、 深入系统诊断(dumpsys 子集)


dumpsys 可以针对不同的系统服务进行深入诊断。


命令关注领域作用
adb shell dumpsys activityActivity Manager (AMS)获取当前运行的 Activity 栈、后台进程列表等,用于分析应用生命周期和任务管理问题。
adb shell dumpsys battery电池状态获取设备的电池和充电状态。
adb shell dumpsys power电源管理 (PMS)获取唤醒锁 (Wake Locks) 的持有情况,用于分析设备无法休眠导致的持续耗电问题。
adb shell dumpsys window windows窗口管理 (WMS)获取当前屏幕上可见的窗口列表、层级和焦点情况,用于分析屏幕显示或输入事件问题。



五、 文件系统操作(导出/导入文件)


这些命令是确保诊断文件能够顺利在设备和电脑间传输的基础。


命令作用示例
adb pull [remote_path] [local_path]从设备拉取文件到电脑。adb pull /sdcard/tt.txt . (将文件拉取到电脑当前目录)。
adb push [local_path] [remote_path]从电脑推送文件到设备。通常用于推送测试用例或工具。

实际分析流程


在实际的故障排查中,开发者通常遵循以下高效流程:



  1. 准备阶段: 执行 adb logcat -c 清除旧日志,确保日志的清洁度。

  2. 复现问题: 在设备上准确重现崩溃、ANR 或卡顿的现象。

  3. 抓取证据:



    • 一般问题: 立即执行 adb logcat -d > log.txt 抓取当前的日志缓冲区。

    • 严重复杂问题: 立即执行 adb bugreport 抓取最全面的系统报告。

    • ANR 问题: 如果有权限,则导出 /data/anr/traces.txt 文件进行线程分析。




掌握并熟练运用这些 ADB 诊断命令,将极大地提升您在 Android 故障排查和性能优化的效率。


作者:用户4165967369355
来源:juejin.cn/post/7564540677470126121
收起阅读 »

画三角形报错bad_Alloc 原因,回调用错

surfaceCreated(SurfaceHolder holder)和:onSurfaceCreated(GL10 gl, EGLConfig c)是Android OpenGL ES开发中涉及Surface管理的两个关键方法,但它们属于不同类别的回调函数...
继续阅读 »

surfaceCreated(SurfaceHolder holder):onSurfaceCreated(GL10 gl, EGLConfig c)是Android OpenGL ES开发中涉及Surface管理的两个关键方法,但它们属于不同类别的回调函数:


surfaceCreated(SurfaceHolder holder)



  • 所属类‌:SurfaceHolder的回调方法,用于监听Surface创建事件。当SurfaceView的Surface被创建时触发,通常用于初始化渲染线程或资源。 ‌

  • 典型用法‌:在SurfaceHolder.addCallback(this)中注册回调,确保在Surface可用后进行绘制操作。


onSurfaceCreated(GL10 gl, EGLConfig c)



  • 所属类‌:EGL的初始化回调,用于EGL配置完成后的初始化操作。通常在EGL初始化流程中调用,与OpenGL ES渲染线程相关。 ‌


关键区别



  1. 触发时机‌:surfaceCreated在Surface生命周期开始时触发;onSurfaceCreated在EGL配置完成后调用。

  2. 应用场景‌:前者用于自定义视图渲染或相机预览;后者涉及OpenGL ES的底层配置和渲染线程初始化。 ‌

  3. 线程安全‌:surfaceCreated需在非UI线程操作;onSurfaceCreated需在EGL初始化线程中调用。 ‌


作者:小王lj
来源:juejin.cn/post/7559588025615564842
收起阅读 »

Android唤醒锁优化指南

原文:xuanhu.info/projects/it… Android唤醒锁优化指南 唤醒锁机制深度剖析 底层工作原理 当Android应用需要保持CPU运行时,会通过PowerManager.PartialWakeLock向系统发起请求。该机制直接与Lin...
继续阅读 »

原文:xuanhu.info/projects/it…



Android唤醒锁优化指南


唤醒锁机制深度剖析


底层工作原理


当Android应用需要保持CPU运行时,会通过PowerManager.PartialWakeLock向系统发起请求。该机制直接与Linux内核的wakelock子系统交互:


// 获取PowerManager实例
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);

// 创建PARTIAL_WAKE_LOCK标记的唤醒锁
PowerManager.WakeLock wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"MyApp::LocationUpdateWakeLock" // 推荐命名规范:应用名::功能模块
);

// 获取唤醒锁(必须在后台线程操作)
wakeLock.acquire();

try {
// 执行需要保持CPU唤醒的任务
processLocationUpdates();
} finally {
// 确保在任何情况下都释放锁
if (wakeLock.isHeld()) {
wakeLock.release();
}
}

关键点解析:



  • PARTIAL_WAKE_LOCK允许CPU运行但屏幕保持关闭

  • 命名规范需明确标识功能模块,便于问题追踪

  • try-finally块是防止锁泄漏的核心防御机制


Android Vitals监控标准


Google Play Console定义过度使用阈值:


image.png


高级优化策略实战


场景化最佳实践


定位服务优化方案

// 使用带超时的唤醒锁
wakeLock.acquire(10 * 60 * 1000); // 10分钟超时

// 结合JobScheduler实现智能唤醒
JobInfo.Builder builder = new JobInfo.Builder(jobId, serviceComponent);
builder.setMinimumLatency(intervalMillis);
builder.setRequiresDeviceIdle(true); // 仅在设备空闲时执行

网络请求优化技巧

// 使用WorkManager的灵活约束
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()

val uploadWork = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.build()

调试工具链深度应用


Perfetto系统追踪


// 捕获唤醒锁事件
trace_config {
buffers {
size_kb: 10240
}
data_sources {
config {
name: "android.power"
android_power_config {
battery_poll_ms: 1000
collect_power_rails: true
}
}
}
}

分析路径: PowerManagerService > wake_lock_acquire/release事件


WorkManager调试


// 获取任务停止原因
WorkManager.getInstance(context).getWorkInfoById(workRequest.id)
.addListener({ workInfo ->
if (workInfo.state == WorkInfo.State.FAILED) {
val stopReason = workInfo.getStopReason()
when (stopReason) {
WorkInfo.STOP_REASON_CONSTRAINT_NOT_MET -> // 约束未满足
WorkInfo.STOP_REASON_DEVICE_STATE -> // 设备状态限制
}
}
}, executor)

生产环境监控


# 通过ProfilingManager收集现场数据
profilingManager = context.getSystemService(Context.PROFILING_SERVICE)
if (profilingManager != null) {
profilingManager.startProfiling(
"wake_lock_debug",
Duration.ofMinutes(5),
Executors.newSingleThreadExecutor()
)
}

架构级优化方案


现代后台任务架构


graph LR
A[前台服务] --> B(唤醒锁)
C[WorkManager] --> D[系统级节流]
E[AlarmManager] --> F[精确时间任务]
G[JobScheduler] --> H[批处理任务]

classDef optimal fill:#9f9,stroke:#333;
class C,G optimal;

电池优化白名单策略


<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>

// 检查当前状态
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
// 引导用户手动添加
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + packageName));
startActivity(intent);
}

性能监控体系构建


自定义监控指标


class WakeLockMonitor {
private val lockHoldTimes = ConcurrentHashMap<String, Long>()

fun trackAcquisition(tag: String) {
lockHoldTimes[tag] = SystemClock.elapsedRealtime()
}

fun trackRelease(tag: String) {
val start = lockHoldTimes[tag] ?: return
val duration = SystemClock.elapsedRealtime() - start

FirebaseAnalytics.getInstance(context).logEvent("wake_lock_duration", bundleOf(
"tag" to tag,
"duration_min" to TimeUnit.MILLISECONDS.toMinutes(duration)
))
}
}

总结


核心优化原则总结



  1. 必要性原则

    只在必须保持CPU运行的场景使用唤醒锁,如:



    • 实时位置追踪

    • 关键数据同步

    • 媒体播放场景



  2. 最小化原则


    // 错误示例:整个下载过程持有锁
    wakeLock.acquire();
    downloadFile();
    processData(); // 非必要CPU操作
    wakeLock.release();

    // 优化后:仅网络IO期间持有
    downloadFile {
    wakeLock.acquire(30_000); // 30秒超时
    networkRequest();
    wakeLock.release();
    }
    processData(); // 在无锁状态下执行


  3. 防御性编程


    CoroutineScope(Dispatchers.IO).launch {
    val wakeLock = powerManager.newWakeLock(...).apply {
    acquire(10_000)
    }

    try {
    withTimeout(9_000) { // 设置小于超时时间
    performCriticalTask()
    }
    } catch (e: TimeoutCancellationException) {
    Log.w(TAG, "任务超时中断")
    } finally {
    if (wakeLock.isHeld()) wakeLock.release()
    }
    }


    原文:xuanhu.info/projects/it…





作者:CodingFisher
来源:juejin.cn/post/7562064417258422310
收起阅读 »

鸿蒙应用开发从入门到实战(十一):ArkUI组件Text&TextInput

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注! ArkUI提供了丰富的系统组件,用于制作鸿蒙原生应用APP的UI,本文主要讲解文本组件Text和...
继续阅读 »

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!


ArkUI提供了丰富的系统组件,用于制作鸿蒙原生应用APP的UI,本文主要讲解文本组件Text和TextInput的使用。


一、文本Text


1.1 概述


Text为文本组件,用于显示文字内容。


1.2 参数


Text组件的参数类型为string | Resource,下面分别对两个参数类型进行介绍:



  • string类型


Text('我是一段文本')


  • Resource 类型


Resource类型的参数用于引用 resources/*/element目录中定义的字符串,同样需要使用$r()引用。


例如resources/base/element目录中有一个string.json文件,内容如下


{
"string": [
  {
    "name": "greeting",
    "value": "你好"
  }
]
}

此时我们便可通过如下方式引用并显示greeting的内容。


Text($r('app.string.greeting'))

示例代码:


1、分别在resources下的base、en_US、zh_CN目录下的element下的string.json中添加对应的配置


在base和zh_CN下的element下的string.json中添加


 {
    "name": "greeting",
    "value": "你好,鸿蒙"
  }

在en_US目录下的element下的string.json中添加


{
    "name": "greeting",
    "value": "hello,harmony"
  }

2、component目录下新建text目录,新建TextParameterPage.ets文件


@Entry
@Component
// text组件
struct TextParameterPage {
build() {
  Column({ space: 50 }) {
    // text组件参数
    //1、字符串类型
    Text('你好,鸿蒙')
      .fontSize(50)

    //2、Resource类型
    Text($r('app.string.greeting'))
      .fontSize(50)
  }.width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}
}

1.3 常用属性


1.3.1 字体大小


字体大小可通过fontSize()方法进行设置,该方法的参数类型为string | number| Resource,下面逐一介绍



  • string类型


string类型的参数可用于指定字体大小的具体单位,例如fontSize('100px'),字体大小的单位支持pxfp。其中fp(font pixel)vp类似,具体大小也会随屏幕的像素密度变化而变化。



  • number类型


number类型的参数,默认以fp作为单位。



  • Resource类型


Resource类型参数用于引用resources下的element目录中定义的数值。


示例代码:


在component/text目录下新建FontSizePage.ets文件


@Entry
@Component
// text属性:字体大小
struct FontSizePage {
 build() {
     Column({ space: 50 }) {
       // 1、参数为string类型
       Text('你好,鸿蒙')
        .fontSize('150px')

       Text('你好,鸿蒙')
        .fontSize('50fp')

       // 2、参数为number类型
       Text('你好,鸿蒙')
        .fontSize(50)
    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
}
}

1.3.2 字体粗细


字体粗细可通过fontWeight()方法进行设置,该方法参数类型为number | FontWeight | string,下面逐一介绍



  • number类型


number类型的取值范围是[100,900],取值间隔为100,默认为400,取值越大,字体越粗。



  • FontWeight类型


FontWeight为枚举类型,可选枚举值如下


名称描述
FontWeight.Lighter字体较细。
FontWeight.Normal字体粗细正常。
FontWeight.Regular字体粗细正常。
FontWeight.Medium字体粗细适中。
FontWeight.Bold字体较粗。
FontWeight.Bolder字体非常粗。


  • string类型


string类型的参数仅支持number类型和FontWeight类型参数的字符串形式,例如例如'100'bold


示例代码:


在component/text下新建FontWeightPage.ets文件


@Entry
@Component
// 字体粗细
struct FontWeightPage {

 build() {
   Column({ space: 50 }) {

     //默认效果
     Text('你好,鸿蒙')
      .fontSize(50)

     // 1、number类型
     Text('你好,鸿蒙')
      .fontSize(50)
      .fontWeight(666)

     // 2、FontWeight类型
     Text('你好,鸿蒙')
      .fontSize(50)
      .fontWeight(FontWeight.Lighter)

     // 3、string类型
     Text('你好,鸿蒙')
      .fontSize(50)
      .fontWeight('800')

  }.width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}
}

1.3.3 字体颜色


字体颜色可通过fontColor()方法进行设置,该方法参数类型为Color | string | number | Resource,下面逐一介绍



  • Color类型


    Color为枚举类型,其中包含了多种常用颜色,例如Color.Green


  • string类型


    string类型的参数可用于设置 rgb 格式的颜色,具体写法可以为'rgb(0, 128, 0)'或者'#008000'


  • number类型


    number类型的参数用于使用16进制的数字设置 rgb 格式的颜色,具体写法为0x008000


  • Resource类型


    Resource类型的参数用于应用resources下的element目录中定义的值。



示例代码:


在component/text目录下新建FontColorPage.ets文件


@Entry
@Component
// 字体颜色
struct FontColorPage {

 build() {
   Column({ space: 50 }) {
     // 1、Color类型
     Text('Color.Green')
      .fontSize(40)
      .fontWeight(FontWeight.Bold)
      .fontColor(Color.Green)

     // 2、string类型
     Text('rgb(0, 128, 0)')
      .fontSize(40)
      .fontWeight(FontWeight.Bold)
      .fontColor('rgba(59, 171, 59, 0.33)')

     Text('#008000')
      .fontSize(40)
      .fontWeight(FontWeight.Bold)
      .fontColor('#a4008000')

     // 3、number类型
     Text('0x008000')
      .fontSize(40)
      .fontWeight(FontWeight.Bold)
      .fontColor(0xa4008000)

  }.width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}
}

1.3.4 文本对齐


文本对齐方向可通过textAlign()方法进行设置,该方法的参数为枚举类型TextAlign,可选的枚举值如下


名称描述
TextAlign.Start首部对齐
TextAlign.Center居中对齐
TextAlign.End尾部对齐

各选项效果如下


1文本对齐效果.png
示例代码:


text目录下新建TextAlignPage.ets文件


@Entry
@Component
// 文本对齐
struct TextAlignPage {

 build() {
   Row() {
     Column({ space: 50 }) {
       Column({ space: 10 }) {
         // 1、TextAlign.Start
         Text('鸿蒙操作系统是由华为公司开发的全场景、分布式的新一代操作系统,旨在实现各类智能设备的高效协同工作和统一体验')
          .fontSize(20)
          .width(300)
          .borderWidth(1)
          .textAlign(TextAlign.Start)
         Text('Start')
      }

       Column({ space: 10 }) {
         // 2、TextAlign.Center
         Text('鸿蒙操作系统是由华为公司开发的全场景、分布式的新一代操作系统,旨在实现各类智能设备的高效协同工作和统一体验')
          .fontSize(20)
          .width(300)
          .borderWidth(1)
          .textAlign(TextAlign.Center)
         Text('Center')
      }

       Column({ space: 10 }) {
         // 3、TextAlign.End
         Text('鸿蒙操作系统是由华为公司开发的全场景、分布式的新一代操作系统,旨在实现各类智能设备的高效协同工作和统一体验')
          .fontSize(20)
          .width(300)
          .borderWidth(1)
          .textAlign(TextAlign.End)
         Text('End')
      }

    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
}

1.3.5 最大行数和超长处理


可使用maxLines()方法控制文本的最大行数,当内容超出最大行数时,可使用textOverflow()方法处理超出部分,该方法的参数类型为{ overflow: TextOverflow },其中TextOverflow为枚举类型,可用枚举值有


名称描述
TextOverflow.Clip文本超长时,进行裁剪显示。
TextOverflow.Ellipsis文本超长时,显示不下的文本用省略号代替。

各选项效果如下


2最大行数处理.png


示例代码:


在component/text目录下新建TextOverFlowPage.ets文件


@Entry
@Component
// 最大行数和超长处理
struct TextOverFlowPage {

 build() {
   Column({ space: 50 }) {
     Column({ space: 10 }) {
       Text('鸿蒙操作系统是由华为公司开发的全场景、分布式的新一代操作系统,旨在实现各类智能设备的高效协同工作和统一体验')
        .fontSize(20)
        .width(300)
        .borderWidth(1)
       Text('原始内容')
    }

     // 1、TextOverflow.Clip 文本超长时,进行裁剪显示
     Column({ space: 10 }) {
       Text('鸿蒙操作系统是由华为公司开发的全场景、分布式的新一代操作系统,旨在实现各类智能设备的高效协同工作和统一体验')
        .fontSize(20)
        .width(300)
        .borderWidth(1)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Clip })
       Text('Clip')
    }

     // 2、TextOverflow.Ellipsis 文本超长时,显示不下的文本用省略号代替
     Column({space:10}) {
       Text('鸿蒙操作系统是由华为公司开发的全场景、分布式的新一代操作系统,旨在实现各类智能设备的高效协同工作和统一体验')
        .fontSize(20)
        .width(300)
        .borderWidth(1)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
       Text('Ellipsis')
    }

  }.width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}
}

二、文本输入TextInput


2.1 概述


TextInput为文本输入组件,用于接收用户输入的文本内容。


2.2 参数


TextInput组件的参数定义如下


TextInput(value?:{placeholder?: string|Resource , text?: string|Resource})


  • placeholder


placeholder属性用于设置无输入时的提示文本,效果如下


3placeholder.png



  • text


text用于设置输入框当前的文本内容,效果如下


4text.png


示例代码:


component目录下新建input目录,新建TextInputParameter.ets文件


@Entry
@Component
// 文本输入参数
struct TextInputParameter {

 build() {
   Column({ space: 50 }) {
     TextInput()
      .width('70%')

     // 1、placeholder参数
     TextInput({ placeholder: '请输入用户名' })
      .width('70%')

     // 2、text参数
     TextInput({ text: '当前内容' })
      .width('70%')

  }.width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}
}

2.3 常用属性


2.3.1 输入框类型


可通过type()方法设置输入框的类型,该方法的参数为InputType枚举类型,可选的枚举值有


名称描述
InputType.Normal基本输入模式
InputType.Password密码输入模式
InputType.Number纯数字输入模式

2.3.2 光标样式


可通过caretColor()方法设置光标的颜色,效果如下


5光标样式.png


2.3.3 placeholder样式


可通过placeholderFont()placeholderColor()方法设置 placeholder 的样式,其中placeholderFont()用于设置字体,包括字体大小、字体粗细等,placeholderColor()用于设置字体颜色,效果如下


6placeholcer样式.png


2.3.4 文本样式


输入文本的样式可通过fontSize()fontWeight()fontColor()等通用属性方法进行设置。


示例代码:


在input目录下新建TextInputAttributePage.ets文件


@Entry
@Component
// TextInput属性
struct TextInputAttributePage {

build() {
Column({ space: 50 }) {

// 1、输入框类型 type()设置类型, InputType
Column({ space: 10 }) {
Text('输入框类型')
TextInput({ placeholder: '请输入任意内容' })
.width('70%')
.type(InputType.Normal)
TextInput({ placeholder: '请输入数字' })
.width('70%')
.type(InputType.Number)
TextInput({ placeholder: '请输入密码' })
.width('70%')
.type(InputType.Password)
}

// 2、光标样式 caretColor()设置光标的颜色
Column({ space: 10 }) {
Text('光标样式')
TextInput()
.width('70%')
.caretColor(Color.Red)
}

// 3、placeholder样式 placeholderFont、placeholderColor
Column({ space: 10 }) {
Text('placeholder样式')
TextInput({ placeholder: '请输入用户名' })
.width('70%')
.placeholderFont({ weight: 800 ,style:FontStyle.Italic})
.placeholderColor('#66008000')
}

}.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}

2.4 常用事件


2.4.1 change事件


每当输入的内容发生变化,就会触发 change 事件,开发者可使用onChange()方法为TextInput组件绑定 change 事件,该方法的参数定义如下


onChange(callback: (value: string) => void)

其中value为最新内容。


2.4.2 焦点事件


焦点事件包括获得焦点失去焦点两个事件,当输入框获得焦点时,会触发 focus 事件,失去焦点时,会触发 blur 事件,开发者可使用onFocus()onBlur()方法为 TextInput 组件绑定相关事件,两个方法的参数定义如下


onFocus(event: () => void)	

onBlur(event: () => void)

示例代码:


在input目录下新建TextInputEvent.ets文件


@Entry
@Component
// TextInput事件
struct TextInputEvent {

build() {
Column({ space: 50 }) {
TextInput({ placeholder: '请输入用户名' })
.width('70%')
.type(InputType.Normal)
// 1、change事件
.onChange((value) => {
console.log(`用户名:${value}`)
})
// 2、获得焦点
.onFocus(() => {
console.log('用户名输入框获得焦点')
})
// 3、失去焦点
.onBlur(() => {
console.log('用户名输入框失去焦点')
})

TextInput({ placeholder: '请输入密码' })
.width('70%')
.type(InputType.Password)
.onChange((value) => {
console.log(`密码:${value}`)
})
.onFocus(() => {
console.log('密码输入框获得焦点')
})
.onBlur(() => {
console.log('密码输入框失去焦点')
})
}.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}

《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!


作者:程序员潘Sir
来源:juejin.cn/post/7552700954286653483
收起阅读 »

鸿蒙Flex与Row/Column对比

在鸿蒙(HarmonyOS)应用开发中,Flex布局与Row/Column布局是两种核心的容器组件,它们在功能、性能及适用场景上存在显著差异。以下从五个维度进行详细对比: 📊 ​1. 核心差异对比​ ​特性​​Flex布局​​Row/Column布局​​布局...
继续阅读 »

在鸿蒙(HarmonyOS)应用开发中,Flex布局与Row/Column布局是两种核心的容器组件,它们在功能、性能及适用场景上存在显著差异。以下从五个维度进行详细对比:




📊 ​1. 核心差异对比


特性Flex布局Row/Column布局
布局机制动态弹性计算,支持二次布局(重新分配空间)单次线性排列,无二次布局
方向控制支持水平(Row)、垂直(Column)及反向排列Row仅水平,Column仅垂直
换行能力支持自动换行(FlexWrap.Wrap不支持换行,子组件溢出时被截断或压缩
子组件控制支持flexGrowflexShrinkflexBasis动态分配空间仅支持layoutWeight按比例分配空间
性能表现较低(二次布局增加计算开销)较高(单次布局完成)


⚠️ ​二次布局问题​:当子组件总尺寸与容器不匹配时,Flex需通过拉伸/压缩重新计算布局,导致性能损耗。





🔧 ​2. Flex布局的核心特点与场景



  • 核心优势



    • 多方向布局​:通过direction自由切换主轴方向(水平/垂直)。

    • 复杂对齐​:组合justifyContent(主轴)和alignItems(交叉轴)实现精准对齐。

    • 动态空间分配​:



      • flexGrow:按比例分配剩余空间(如搜索框占满剩余宽度)。

      • flexShrink:空间不足时按比例压缩子组件(需配合minWidth避免过度压缩)。





  • 必用场景



    • 多行排列​:标签组、商品网格布局(需设置wrap: FlexWrap.Wrap)。

    • 响应式适配​:跨设备屏幕(如手机/车机动态调整列数)。






📐 ​3. Row/Column布局的核心特点与场景



  • 核心优势



    • 轻量高效​:线性排列无弹性计算,渲染性能更高。

    • 简洁属性​:



      • space:控制子组件间距(如导航栏按钮间隔)。

      • layoutWeight:一次遍历完成空间分配(性能优于flexGrow)。





  • 推荐场景



    • 单向排列​:



      • Row:水平导航栏、头像+文字组合。

      • Column:垂直表单、卡片内容堆叠。



    • 固定尺寸布局​:子组件尺寸明确时(如按钮宽度固定)。






⚡ ​4. 性能差异与优化建议



  • Flex性能瓶颈



    • 二次布局触发条件​:子组件总尺寸 ≠ 容器尺寸、优先级冲突(如displayPriority分组计算)。

    • 后果​:嵌套过深或动态数据下易引发界面卡顿。



  • 优化策略



    • 替代方案​:简单布局优先用Row/Column,避免Flex嵌套超过3层。

    • 属性优化​:



      • 固定尺寸组件设置flexShrink(0)禁止压缩。

      • 等分布局用layoutWeight替代flexGrow(如Row中占比1:2)。



    • 预设尺寸​:尽量让子组件总尺寸接近容器尺寸,减少拉伸需求。






🛠️ ​5. 选择策略与工程实践



  • 何时选择Flex?​


    ✅ 需换行(如标签云)、复杂弹性对齐(如交叉轴居中)、动态网格布局。


    ❌ 避免在简单列表、表单等场景使用,优先Row/Column。


  • 何时选择Row/Column?​


    ✅ 单向排列(水平/垂直)、子组件尺寸固定或比例明确(如30%+70%)。


    ✅ 高频场景:导航栏(Row)、表单(Column)、图文混排(Row+垂直居中)。


  • 工程最佳实践



    • 多端适配​:通过DeviceType动态调整参数(如车机增大点击区域)。

    • 调试工具​:用DevEco Studio布局分析器监测二次布局次数。

    • 混合布局​:Flex内嵌套Row/Column(如Flex容器中的商品项用Column)。






💎 ​总结



  • Flex​:强大但“重”,适合复杂弹性多行响应式布局,需警惕二次布局问题。

  • Row/Column​:轻量高效,是单向排列场景的首选,性能优势明显。

  • 决策关键​:



    简单布局看方向(水平用Row,垂直用Column),


    复杂需求看弹性(换行/动态分配用Flex)。





通过合理选择组件并优化属性配置,可显著提升鸿蒙应用的渲染效率与用户体验。


作者:风冷
来源:juejin.cn/post/7541339617489600555
收起阅读 »

鸿蒙next 游戏授权登录教程王者归来

前沿导读 各位同学很久没有分享技术文章给大家了,因为最近需要兼职讲课,所以我比较忙。都也没有多少个人时间,所以也是趁着现在有空我们就分享下 效果图 调用效果 日志打印 需求背景 工作中最近接到需求,需要接入鸿蒙的游戏授权登录和内购支...
继续阅读 »

前沿导读


各位同学很久没有分享技术文章给大家了,因为最近需要兼职讲课,所以我比较忙。都也没有多少个人时间,所以也是趁着现在有空我们就分享下


效果图




  • 调用效果




image.png




  • 日志打印




image.png




  • 需求背景




工作中最近接到需求,需要接入鸿蒙的游戏授权登录和内购支付,今天把流程走完整,所以现在就做一个分享




  • 开发步骤




image.png




  • 初始化




这里如果不是在 EntryAbility 接入初始化代码需要做调整


// 在EntryAbility 里面初始化

try {
gamePlayer.init(this.context,()=>{
hilog.info(0x0000, 'testTag', `Succeeded in initing.`);
});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'testTag', `Failed to init. Code: ${err.code}, message: ${err.message}`);
}

// 不在EntryAbility 里面执行初始化

try {
gamePlayer.init(context as common.UIAbilityContext ,()=>{
hilog.info(0x0000, 'testTag', `Succeeded in initing.`);

});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'testTag', `Failed to init. Code: ${err.code}, message: ${err.message}`);
}

获取 gamePlayerId


let context = getContext(this) as common.UIAbilityContext;

let request: gamePlayer.UnionLoginParam = {
showLoginDialog: false, // 是否弹出联合登录面板。true表示强制弹出面板,false表示优先使用玩家上一次的登录选择,不弹出联合登录面板,若玩家首次登录或卸载重装,则正常弹出。
thirdAccountInfos: [] // 若游戏无官包或无官方账号体系,请传空数组。
};
try {
gamePlayer.unionLogin(context, request).then((result: gamePlayer.UnionLoginResult) => {
hilog.info(0x0000, 'testTag', `Succeeded in logining: ${result?.accountName}`);
console.log("gamePlayerId accountName --- >" +result.accountName)
console.log("gamePlayerId thirdOpenId --- >" +result.boundPlayerInfo.thirdOpenId)
console.log("gamePlayerId bindType --- >" +result.boundPlayerInfo.bindType)

let localPlayer=result.localPlayer;
if(localPlayer.gamePlayerId){
console.log("index gamePlayerId localPlayer gamePlayerId --- >" +localPlayer.gamePlayerId)

}

if(localPlayer.teamPlayerId){
console.log("index gamePlayerId localPlayer teamPlayerId --- >" +localPlayer.teamPlayerId)

}

}).catch((error: BusinessError) => {
hilog.error(0x0000, 'testTag', `Failed to login. Code: ${error.code}, message: ${error.message}`);
});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'testTag', `Failed to login. Code: ${err.code}, message: ${err.message}`);
}



  • 获取authorizationCode




let loginRequest = new authentication.HuaweiIDProvider().createLoginWithHuaweiIDRequest();
loginRequest.state = util.generateRandomUUID();
// 执行认证请求
try {
let controller = new authentication.AuthenticationController(getContext(this));
controller.executeRequest(loginRequest, (err, data) => {
if (err) {
hilog.error(0x0000, 'testTag', `Failed to login. Code: ${err.code}, message: ${err.message}`);
return;
}
let loginWithHuaweiIDResponse = data as authentication.LoginWithHuaweiIDResponse;
let state = loginWithHuaweiIDResponse.state;
console.log("index authorizationCode state ---> "+state)
if (state != undefined && loginRequest.state != state) {
hilog.error(0x0000, 'testTag', `Failed to login. State is different.`);
return;
}
hilog.info(0x0000, 'testTag', `Succeeded in logining.`);

let loginWithHuaweiIDCredential = loginWithHuaweiIDResponse.data!;
let authorizationCode = loginWithHuaweiIDCredential.authorizationCode;
console.log("index authorizationCode ---> "+authorizationCode)
// 开发者处理authorizationCode
});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'testTag', `Failed to login. Code: ${err.code}, message: ${err.message}`);
}

我们拿到了 gamePlayerId 和 authorizationCode 去请求服务端去获取




  • 服务端流程




获取 Access Token地址


image.png




  • 调用on接口注册playerChanged事件监听





aboutToAppear(): void {
// 调用on接口注册playerChanged事件监听
try {
gamePlayer.on('playerChanged', this.onPlayerChangedEventCallback);
hilog.info(0x0000, 'testTag', `Succeeded in registering.`);
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'testTag', `Failed to register. Code: ${err.code}, message: ${err.message}`);
}

}

监听事件回调


private onPlayerChangedEventCallback(result: gamePlayer.PlayerChangedResult) {
if (result.event === gamePlayer.PlayerChangedEvent.SWITCH_GAME_ACCOUNT) {
// ...
// 游戏号已切换,完成本地缓存清理工作后,再次调用unionLogin接口等

}
}

提交玩家角色信息


let context = getContext(this) as common.UIAbilityContext;
let request: gamePlayer.GSKPlayerRole = {
roleId: '123', // 玩家角色ID,如游戏没有角色系统,请传入“0”,务必不要传""和null。
roleName: 'Jason', // 玩家角色名,如游戏没有角色系统,请传入“default”,务必不要传""和null。
serverId: '456',
serverName: 'Zhangshan',
gamePlayerId: '789', // 请根据实际获取到的gamePlayerId传值。
thirdOpenId: '123' // 接入华为账号登录时不传该字段。接入游戏官方账号登录时,请根据实际获取到的thirdOpenId传值。
};
try {
gamePlayer.savePlayerRole(context, request).then(() => {
hilog.info(0x0000, 'testTag', `Succeeded in saving.`);
});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'testTag', `Failed to save. Code: ${err.code}, message: ${err.message}`);
}

参数配置


我们需要在 添加如此下配置


image.png


"metadata": [
// 配置如下信息
{
"name": "client_id",
"value": "xxxxxxxxx"
// 华为Client ID 请替换成你自己的正式参数
},
{
"name": "app_id",
"value": "6917581951060909508"
// 华为APP ID 请替换成你自己的正式参数
}
],

"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
],



  • 配置手动签名测试




image.png


image.png


查看日志

image.png




  • 写在最后




整个鸿蒙游戏授权登录相对比较简单,但是有一个槽点,就是获取 gamePlayerId 和 authorizationCode需要分开两个方法回调 。其实可以做成一个回调更简单,这个希望后期能完善, 服务端逻辑对比客户端来说,还是相对复杂一点点,不过对着文档也是很快能解决这次接入 华为技术支持也帮了不少忙,这个点赞, 对于鸿蒙生态的推广这块,华为确实下了决心,也非常积极的回应。希望鸿蒙越来越好,国产系统早日完善。我依然是你们最爱的徐老师。我们下一期再见。


作者:坚果派_xq9527
来源:juejin.cn/post/7543421087759433738
收起阅读 »

尝试解决 Android 适配最后一公里

框架介绍 Android 碎片化至始至终是一个令人非常头疼的问题,特别为 XXPermissions 上面为不同的厂商做适配的时候就非常头疼,因为市面上能找到的开源库只能判断机型的品牌,而不能判断 Android 厂商定制的系统类型,用机型的品牌去做适配会导...
继续阅读 »

框架介绍



  • Android 碎片化至始至终是一个令人非常头疼的问题,特别为 XXPermissions 上面为不同的厂商做适配的时候就非常头疼,因为市面上能找到的开源库只能判断机型的品牌,而不能判断 Android 厂商定制的系统类型,用机型的品牌去做适配会导致出现误判的情况,例如在小米手机上面运行的厂商系统不一定是 MIUI 或者 HyperOS,也有可能是被人刷成了 Android 原生的系统或者其他,反过来也一样,我时常在想,要是有这么一个工具就好了,可以判断 Android 厂商系统的类型及获取厂商系统的版本号,这样就很方便我们做 Android 适配,于是 DeviceCompat 就诞生了,可以轻松识别各种国内外手机厂商和系统版本,帮助大家解决 Android 适配最后一公里的问题

  • 截至到目前,我是行业内第一个也是唯一一个开源这套方案的人,在这里先感谢网上的同行分享的各种方法和思路,让我在开发的过程中少走了很多弯路,另外我也很能理解为什么行业内一直没有人愿意站出来开源这种框架,因为过程非常麻烦,这不仅仅是一个技术问题,还是一个苦活,因为要针对成千上万的机型进行适配。



框架亮点



  • 支持识别各种定制 Android 系统(HarmonyOS、MagicOS、MIUI、HyperOS、ColorOS、OriginOS 等)

  • 支持判断多种手机厂商品牌(华为、小米、OPPO、vivo、三星等)

  • 使用简单,一行代码即可判断设备品牌、厂商系统类型、厂商系统版本

  • 兼容性好,支持 Android 4.0 及以上系统

  • 体积小巧,仅 12 KB,不会增加应用体积负担


集成步骤



  • 如果你的项目 Gradle 配置是在 7.0 以下,需要在 build.gradle 文件中加入


allprojects {
repositories {
// JitPack 远程仓库:https://jitpack.io
maven { url 'https://jitpack.io' }
}
}


  • 如果你的 Gradle 配置是 7.0 及以上,则需要在 settings.gradle 文件中加入


dependencyResolutionManagement {
repositories {
// JitPack 远程仓库:https://jitpack.io
maven { url 'https://jitpack.io' }
}
}


  • 配置完远程仓库后,在项目 app 模块下的 build.gradle 文件中加入远程依赖


dependencies {
// 设备兼容框架:https://github.com/getActivity/DeviceCompat
implementation 'com.github.getActivity:DeviceCompat:1.0'
}

框架 API 介绍



  • 判断系统类型


// 判断当前设备的厂商系统是否为 HyperOS(小米新系统)
DeviceOs.isHyperOs();
// 判断当前设备的厂商系统是否为国内版本的 HyperOS
DeviceOs.isHyperOsByChina();
// 判断当前设备的厂商系统是否为国际版本的 HyperOS
DeviceOs.isHyperOsByGlobal();
// 判断当前设备的厂商系统开启了 HyperOS 的系统优化选项
DeviceOs.isHyperOsOptimization();

// 判断当前设备的厂商系统是否为 MIUI(小米老系统)
DeviceOs.isMiui();
// 判断当前设备的厂商系统是否为国内版本的 MIUI
DeviceOs.isMiuiByChina();
// 判断当前设备的厂商系统是否为国际版本的 MIUI
DeviceOs.isMiuiByGlobal();
// 判断当前设备的厂商系统是否开启了 MIUI 优化选项
DeviceOs.isMiuiOptimization();

// 判断当前设备的厂商系统是否为 RealmeUI(真我系统)
DeviceOs.isRealmeUi();

// 判断当前设备的厂商系统是否为 ColorOS(OPPO 系统)
DeviceOs.isColorOs();

// 判断当前设备的厂商系统是否为 OriginOS(VIVO 系统)
DeviceOs.isOriginOs();

// 判断当前设备的厂商系统是否为 FuntouchOS(VIVO 的老系统)
DeviceOs.isFuntouchOs();

// 判断当前设备的厂商系统是否为 MagicOS(荣耀系统)
DeviceOs.isMagicOs();

// 判断当前设备的厂商系统是否为 HarmonyOS(华为鸿蒙的系统)
DeviceOs.isHarmonyOs();

// 判断当前设备的厂商系统是否为 EMUI(华为和荣耀的老系统)
DeviceOs.isEmui();

// 判断当前设备的厂商系统是否为 OneUI(三星系统)
DeviceOs.isOneUi();

// 判断当前设备的厂商系统是否为 OxygenOS(一加的老系统)
DeviceOs.isOxygenOs();

// 判断当前设备的厂商系统是否为 H2OS(一加的老系统)
DeviceOs.isH2Os();

// 判断当前设备的厂商系统是否为 Flyme(魅族系统)
DeviceOs.isFlyme();

// 判断当前设备的厂商系统是否为 MyOS(中兴或者努比亚的系统)
DeviceOs.isMyOs();

// 判断当前设备的厂商系统是否为 MifavorUI(中兴老系统)
DeviceOs.isMifavorUi();

// 判断当前设备的厂商系统是否为 SmartisanOS(锤子系统)
DeviceOs.isSmartisanOs();

// 判断当前设备的厂商系统是否为 EUI(乐视的系统)
DeviceOs.isEui();

// 判断当前设备的厂商系统是否为 ZUI(摩托罗拉的系统)
DeviceOs.isZui();

// 判断当前设备的厂商系统是否为 360UI(360 系统)
DeviceOs.is360Ui();

// 获取当前设备的厂商系统名称
DeviceOs.getOsName();

// 获取当前设备的厂商系统名称
DeviceOs.getOsName();
// 获取经过美化的厂商系统版本号
DeviceOs.getOsVersionName();
// 获取厂商系统版本的大版本号
DeviceOs.getOsBigVersionCode();
// 获取原始的厂商系统版本号
DeviceOs.getOriginalOsVersionName();


  • 判断设备品牌


// 判断当前设备的品牌是否为华为
DeviceBrand.isHuaWei();

// 判断当前设备的品牌是否为荣耀
DeviceBrand.isHonor();

// 判断当前设备的品牌是否为 vivo
DeviceBrand.isVivo();

// 判断当前设备的品牌是否为小米
DeviceBrand.isXiaoMi();

// 判断当前设备的品牌是否为 OPPO
DeviceBrand.isOppo();

// 判断当前设备的品牌是否为真我
DeviceBrand.isRealMe();

// 判断当前设备的品牌是否为乐视
DeviceBrand.isLeEco();

// 判断当前设备的品牌是否为 360
DeviceBrand.is360();

// 判断当前设备的品牌是否为中兴
DeviceBrand.isZte();

// 判断当前设备的品牌是否为一加
DeviceBrand.isOnePlus();

// 判断当前设备的品牌是否为努比亚
DeviceBrand.isNubia();

// 判断当前设备的品牌是否为酷派
DeviceBrand.isCoolPad();

// 判断当前设备的品牌是否为 LG
DeviceBrand.isLg();

// 判断当前设备的品牌是否为 Google
DeviceBrand.isGoogle();

// 判断当前设备的品牌是否为三星
DeviceBrand.isSamsung();

// 判断当前设备的品牌是否为魅族
DeviceBrand.isMeiZu();

// 判断当前设备的品牌是否为联想
DeviceBrand.isLenovo();

// 判断当前设备的品牌是否为锤子
DeviceBrand.isSmartisan();

// 判断当前设备的品牌是否为 HTC
DeviceBrand.isHtc();

// 判断当前设备的品牌是否为索尼
DeviceBrand.isSony();

// 判断当前设备的品牌是否为金立
DeviceBrand.isGionee();

// 判断当前设备的品牌是否为摩托罗拉
DeviceBrand.isMotorola();

// 判断当前设备的品牌是否为传音
DeviceBrand.isTranssion();

// 获取当前设备的品牌名称
DeviceBrand.getBrandName();


  • 系统属性相关的方法


// 获取单个系统属性值
SystemPropertyCompat.getSystemPropertyValue((@Nullable String key);

// 获取多个系统属性值
SystemPropertyCompat.getSystemPropertyValues(@Nullable String[] keys);

// 获取多个系统属性中的任一一个值
SystemPropertyCompat.getSystemPropertyAnyOneValue(@Nullable String[] keys);

// 判断某个系统属性是否存在
SystemPropertyCompat.isSystemPropertyExist(@Nullable String key);

// 判断多个系统属性是否有任一一个存在
SystemPropertyCompat.isSystemPropertyAnyOneExist(@Nullable String[] keys);

附上项目开源地址:DeviceCompat


作者:Android轮子哥
来源:juejin.cn/post/7540524749425180735
收起阅读 »

鸿蒙模块间资源引用

CrossModuleResourceAccess项目 跨模块资源访问-程序包结构-应用框架 - 华为HarmonyOS开发者 根据官方文档和项目实践,以下是关于跨模块资源访问的总结: 1. 跨模块资源访问的核心目标 资源共享:通过 HAR(Harmony ...
继续阅读 »

CrossModuleResourceAccess项目


跨模块资源访问-程序包结构-应用框架 - 华为HarmonyOS开发者


根据官方文档和项目实践,以下是关于跨模块资源访问的总结:


1. 跨模块资源访问的核心目标



  • 资源共享:通过 HAR(Harmony Archive)和 HSP(Harmony Shared Package)模块,实现资源(如文本、图片、样式等)的复用,减少冗余定义。

  • 模块化开发:支持功能模块的独立开发和维护,提升开发效率和代码可维护性。


2. 资源访问方式



  • 直接引用

    • 使用 $r('app.type.name')$rawfile('name') 访问当前模块资源。

    • 使用 $r('[hsp].type.name')$rawfile('[hsp].name') 访问 HSP 模块资源。



  • 动态 API 访问

    • 通过 resourceManager 接口(如 getStringSyncgetMediaContentSync)动态获取资源。

    • 使用 createModuleContext 创建其他模块的上下文,获取其 resourceManager 对象。




3. 资源优先级规则



  • 优先级从高到低

    1. 当前模块(HAP/HSP):自身模块的资源优先级最高。

    2. 依赖的 HAR/HSP 模块

      • 如果多个依赖模块中存在同名资源,按照依赖顺序覆盖(依赖顺序靠前的优先级更高)。






4. 官方文档补充



  • 资源隔离与访问控制

    • 类似腾讯云 CAM(访问管理)的权限设计,HarmonyOS 通过模块化设计实现资源的逻辑隔离。

    • 开发者可以通过显式依赖和资源命名规范避免冲突。



  • 跨模块通信

    • 除了资源访问,还可以通过模块间接口调用实现功能共享。




5. 最佳实践



  • 命名规范:为资源文件添加模块前缀(如 hsp1_icon.png),避免命名冲突。

  • 依赖管理:在 oh-package.json5 中明确模块依赖顺序,确保资源优先级符合预期。

  • 动态加载:对于插件化场景,优先使用 resourceManager 动态加载资源。


6. 适用场景



  • 多模块共享通用资源(如主题、图标、多语言文本)。

  • 动态加载不同模块的资源(如插件化设计)。


如果需要进一步分析具体实现或优化建议,请告诉我!


作者:风冷
来源:juejin.cn/post/7541339617489616939
收起阅读 »

老板:咱们公司的设备开机,怎么显示Android。这怎么行,把Android替换掉,显示公司的logo,最好加点牛逼的动画.。

老板: 咱们公司的设备开机,怎么显示Android。这怎么行,把Android替换掉,显示公司的logo,最好加点牛逼的动画. 小卡拉米: 好的,老板 小卡拉米 to UI: 老板说要一个牛逼的动画。 UI: 我*&……%%¥……&&...
继续阅读 »

老板: 咱们公司的设备开机,怎么显示Android。这怎么行,把Android替换掉,显示公司的logo,最好加点牛逼的动画.


小卡拉米: 好的,老板


image.png


小卡拉米 to UI: 老板说要一个牛逼的动画。

UI: 我*&……%%¥……&&*………………%



1、UI设计帧动画


跟UI沟通,最好说明,老板的意思,怎么牛逼。最重要的是:



  • 把动画输出成序列帧。

  • 分辨率和板子的分辨率一致。

  • 命名规则:00.jpg 01.jpg ...99.jpg


2、制作动画包


新建一个文件夹,名字为:bootanimation
新建子文件夹第一部分动画:命名为part1,将UI设计好的非常牛逼的动画,放进去
新建子文件夹第二部分定格动画:命名为part2 ,将帧动画的最后一帧放到这里边
新建一个desc.txt
desc.txt的内容:


1024 768 60
p 1 0 part1
p 0 10 part2

全局参数(第一行)



  1. 1024

    屏幕的 宽度(Width),单位为像素(px)。表示动画的分辨率宽度为 1024px。

  2. 768

    屏幕的 高度(Height),单位为像素(px)。表示动画的分辨率高度为 768px。

  3. 60

    动画的 帧率(FPS, Frames Per Second)。表示每秒播放 60 帧,但实际帧率受硬件和系统限制(可能无法真正达到 60 FPS)。




分段参数(后续行)


每行定义一个动画片段,格式为:

[类型] [循环次数] [间隔时间] [目录名]


第一片段:p 1 0 generic1



  1. p

    播放类型(Play Mode):



    • p 表示正常播放(逐帧播放后停止)。

    • 另一种类型是 c(hold last frame),表示播放完成后停留在最后一帧。



  2. 1

    循环次数(Loop Count):



    • 1 表示该片段播放 1 次

    • 0 表示无限循环(直到开机完成或被中断)。



  3. 0

    间隔时间(Delay):



    • 单位为帧数(基于全局帧率)。0 表示片段播放完成后 无额外延迟,直接进入下一片段。



  4. generic1

    动画资源目录名:



    • 对应 part0part1 等目录,存放该片段的 PNG 图片(按序号命名,如 img000.png)。






第二片段:p 0 10 generic2



  1. p

    正常播放模式。

  2. 0

    无限循环,直到开机流程结束或被系统终止。

  3. 10

    间隔时间为 10 帧(若全局帧率为 60 FPS,则实际延迟为 10/60 ≈ 0.167 秒)。

  4. generic2

    第二个动画资源目录名。



  • 系统会先播放 generic1 目录中的动画,播放 1 次,无延迟。

  • 接着播放 generic2 目录中的动画,无限循环,每次循环结束后等待 10 帧的时间。

  • 开机动画会持续播放,直到系统启动完成(或强制终止)。


压缩动画包(很重要的一步)



  • 压缩的时候,不要在外边一层去压缩,要点进去bootanimation文件夹,全选压缩。

  • 压缩的时候格式要选择成存储。360压缩,自定义里边有。


3、替换动画包


使用adb命令进行替换,替换之前设备要进行root,一般开发板都是 root过的,然后使用命令进行替换

默认情况下,Android 设备的 /system 分区是只读的(出于系统安全性考虑)。adb remount 会临时将其重新挂载为可读写(rw),允许你修改系统文件(如删除预装应用、替换系统文件等)。


adb root // 获取root 权限

adb remount //重新挂载为可读写

adb push 你的动画包路径 system/media


动画包的名称一般是bootanimation.zip,有些厂商的可能不一样,如果不生效可以咨询板子厂家进行修改


替换成功之后,直接运行 adb reboot 进行重启,见证奇迹。


4、Android动画前的小企鹅(Linux开机动画)


我真服了,板子出厂前,竟然还有Linux开机动画,这个关掉,需要更改内核启动参数。下边是修改方法,但是建议联系厂家,建议联系厂家,建议联系厂家




4.1. 确认 Logo 类型



  • Linux 内核 Logo:企鹅 Logo 通常是内核编译时内置的,文件格式可能是 .ppm.rle.png

  • Bootloader Logo:某些设备的厂商会在 Bootloader 阶段显示自定义 Logo(如高通设备的 splash.img)。


需要先确定企鹅 Logo 的来源:



  • 如果设备启动时先显示企鹅,再显示 Android 开机动画(bootanimation.zip),则属于 内核 Logo


4.2. 替换 Linux 内核 Logo


步骤 1:获取内核源码



  • 需要设备的 内核源代码(从厂商或开源社区获取,如 LineageOS、XDA 论坛等)。

  • 如果厂商未公开源码,此方法不可行。


步骤 2:准备自定义 Logo



  • 内核支持的格式通常为 PPM(Portable PixMap),尺寸需与屏幕分辨率匹配(如 1024x768)。

  • 使用工具(如 GIMP、ffmpeg)将图片转换为 .ppm 格式,并保存为 logo_linux_clut224.ppm(文件名可能因内核版本而异)。


步骤 3:替换内核 Logo 文件



  • 将自定义的 .ppm 文件替换内核源码目录中的对应文件:
    # 示例路径(不同内核可能不同)
    cp custom_logo.ppm drivers/video/logo/logo_linux_clut224.ppm



步骤 4:编译内核



  • 配置内核编译选项,确保启用 Logo 显示:
    make menuconfig
    # 进入 Device Drivers -> Graphics support -> Bootup logo
    # 启用 "Standard 224-color Linux logo"


  • 编译内核并生成 boot.img
    make -j$(nproc)



步骤 5:刷入新内核



  • 使用 fastboot 刷入编译后的 boot.img
    fastboot flash boot boot.img





4.3. 替换 Bootloader Logo(高通设备示例)


如果企鹅 Logo 是 Bootloader 阶段的 Splash Screen(如高通设备):


步骤 1:提取当前 Splash Image



  • 从设备中提取 splash.img
    adb pull /dev/block/bootdevice/by-name/splash splash.img



步骤 2:修改 Splash Image



  • 使用工具(如 splash_screen_tool)解包 splash.img,替换其中的图片,再重新打包。


步骤 3:刷入新 Splash Image



  • 通过 fastboot 刷入:
    fastboot flash splash splash.img





4. 隐藏企鹅 Logo(无需替换)


如果无法修改内核或 Bootloader,可以尝试以下方法:



  • 修改内核启动参数:在内核命令行中添加 logo.nologo 参数(需解锁 Bootloader 并修改 boot.imgcmdline)。

  • 禁用 Framebuffer:在内核配置中关闭 CONFIG_LOGO 选项(需重新编译内核)。


注意事项



  1. 风险提示:建议联系厂家进行修改

    • 修改内核或 Bootloader 可能导致设备无法启动(变砖)。

    • 需要解锁 Bootloader(会清除设备数据并失去保修)。



  2. 兼容性

    • 不同设备的 Logo 实现方式差异较大,需查阅设备的具体文档。



  3. 备份

    • 操作前备份重要数据,并保留原版 boot.imgsplash.img




作者:一杯凉白开
来源:juejin.cn/post/7508646757884690468
收起阅读 »

安卓突然终止「开源」,开发者遭背叛?社区炸锅了

【新智元导读】谷歌将改变一直以来对 Android 开源项目(AOSP)的公开开发模式,转而在私有环境中进行。但这并非意味着 Android 彻底闭源。对于普通用户而言不会有什么影响,但却让科技爱好者失去了一扇「窥视」安卓内部的窗口。 据 Android Au...
继续阅读 »


【新智元导读】谷歌将改变一直以来对 Android 开源项目(AOSP)的公开开发模式,转而在私有环境中进行。但这并非意味着 Android 彻底闭源。对于普通用户而言不会有什么影响,但却让科技爱好者失去了一扇「窥视」安卓内部的窗口。

据 Android Authority 报道,谷歌已经向其确认,谷歌将很快在私有环境中开发 Android 开源项目(AOSP,Android Open Source Project),但依然会开源代码。



网站地址:http://www.android.com/


很多小伙伴可能会慌了,我的安卓手机不能用了?


目前来看,谷歌私下开发 AOSP 项目还不至于到「天塌下来」的地步,普通手机用户更是几乎感觉不到什么变化。


大部分主流手机厂商(比如小米、vivo、三星等)早就跟谷歌签好了各种合作伙伴协议。


只要这些协议还在,厂商们就还能照常拿到最新的 Android 源代码,通过 Google 自家的认证,正常预装 Google Play、Gmail 这些服务和应用。


谷歌对安卓系统的支持也不会断。


一句话,还是老样子。


那么问题来了,谷歌到底做了什么?


这就要从谷歌的安卓开源项目(AOSP)说起了。


什么是 Android Open Source Project(AOSP)?


AOSP 简单来说,就是谷歌给所有 Android 设备提供了一个「毛坯房」——操作系统的基本框架和核心部件。


任何开发者都可以免费下载它的代码,随意改动、分发,然后打造自己的定制系统。


比如小米 HyperOS、vivo OriginOS 都是在 AOSP 基础上搭建起来的。



网站地址:source.android.com/?hl=zh-cn


而 Android 系统本身是跑在 Linux 内核上,这个内核用的是 GPL 许可证,规则挺严格。


简单说就是,只要使用采用了 GPL 许可证的代码,你就得开源,体现「要玩就一起玩」的精神。


但 Google 为了让 Android 既开源又能赚钱,玩了个聪明设计:底层 Linux 内核老实按 GPL 开源,但中间 AOSP 大部分代码却用宽松的 Apache 2.0 许可证。


这样厂商既能自由改动 Android,不用全盘公开,还能加自己专有的东西,既开放又灵活。


具体来说,Linux 内核和模块还得开源,但到了用户空间的应用就不受 GPL 限制,想闭源就闭源。


结果就是,AOSP 底层 GPL 开源,中层 Apache 宽松开源,上层应用随开发者意愿,想怎么玩就怎么玩。


谷歌的这点小聪明那是相当的成功。


回想将近二十年前,智能手机刚起步那会儿,苹果发布了 iPhone。


谷歌也想在移动市场分一杯羹,于是决定推出 Android。


这不光帮助谷歌赚了个技术开放的好名声,还把一大堆厂商和用户从塞班、诺基亚、Windows Mobile、黑莓手里抢了过来。


真是神来之笔。


Android 开源这步棋,绝对是谷歌今天能占据移动操作系统市场七成以上份额的最大功臣。


市场是拿到了,代价是 AOSP 软件的维护是要做的。


问题是,随着手机的功能越来越多,这种维护工作的代价也越来越大。


终于,谷歌忍不了了。


代码同步难,谷歌决定「关起门」来开发,但依然开源代码


写过代码的都知道相比写代码,「合并代码」反而是最令人头疼的问题。


2007 年,谷歌开放了安卓的核心代码,这步棋让谷歌摘取了移动互联网时代最大的果实。


但是也导致安卓这个项目有了两个「主分支」。


一个分支就是公共的 AOSP 分支,这个分支对任何人都开放,大家所说的「安卓是开源」就是指这个分支。



一些附属功能,比如蓝牙功能,仍然在 AOSP 分支中公开开发,你可以在开源的 Android Code Search 中搜索到相关源代码。



然而,AOSP 公共分支并不包含谷歌专有的应用和服务,比如 Google Play 商店、Gmail、Google Maps 等。


AOSP 虽然没有谷歌自己的服务,但是仍然可以编译为一个完整的可用操作系统。


许多设备制造商基于 AOSP 开发自己的操作系统,包括:



  • 三星:开发了 One UI。

  • 小米:开发了 MIUI。

  • OPPO:开发了 ColorOS。

  • 华为:开发了早期的 EMUI。

  • 一加:开发了 OxygenOS。


另一个分支则是完全的闭源开发,可以看做谷歌自己的安卓「亲儿子」。


这个分支仅限于拥有谷歌移动服务(GMS)许可协议的公司使用,以上类似三星 One UI 这种 Android 系统也可以使用,只要谷歌给予授权。


目前来看,大多数组件,包括核心 Android 操作系统框架,都是在 Google 的内部分支中私下开发的。


两个分支导致一个很大问题,就是内部分支的开发进度领先于公开的 AOSP,导致两个分支差异很大。


这种差异逼得谷歌必须花费时间和精力在公共 AOSP 分支与其内部分支之间合并补丁上。


这就到了程序员「喜闻乐见」的环节,由于分支差异很大,合并冲突经常出现。


以这个启用导航栏和键盘屏幕放大功能的补丁为例,该补丁引入了新的辅助功能设置,该设置被放置在辅助功能设置列表的末尾。


这会导致合并冲突,因为 AOSP 与谷歌内部分支之间的列表长度不同(图中变量 accessibility_magnify_nav_and_ime 设置为 58 和 59 冲突)。


虽然针对此特定问题的修复很简单,但当其他许多 AOSP 补丁集成到谷歌的内部分支时,都会触发类似的合并冲突。



另一个例子是,开发 Android 的新仅解锁存储区域 API 需要一位 Google 工程师从内部分支中挑选一个补丁到 AOSP 以解决合并冲突。


这是因为虽然 API 是在 AOSP 中开发的,但包含新 Android 构建标志的文件是在内部开发的。


因此,必须在内部提交一个更新构建标志文件的补丁,然后应用到 AOSP。



也许这些冲突单独看都不难处理,但是架不住可能会有无数这样「合并冲突」的例子。


「累觉不爱」,也许这就是谷歌放弃当前双管齐下的 Android 开发策略,转而将所有开发工作内部化的原因。


这对我们意味着什么?


这一决策整体来说,并不意味着 Android 正在变成「闭源」。


谷歌只是想把「开发过程」藏起来,依然会继续发布源代码。


最大的区别在于,AOSP 公共分支存在时,对于 Android 爱好者和科技行业记者来说,这是一个能够「窥探」Android 最新动向的窗口。


现在这个「窗口」要被谷歌关上了,这可能会让这些科技极客们感到沮丧,因为这减少了他们对 Google 开发工作的洞察力。


对于开发者,这会让他们更难跟上新的 Android 平台变化,因为他们将无法再跟踪 AOSP 中的变化。


比如外国的一个记者在 AOSP 中发现了某些代码变更,然后提前数月就预测了 Pixel 的网络摄像头功能,他还利用 AOSP 中的线索推断出 Android 16 的提前发布日期。


而对于大多数的我们,甚至包括安卓应用开发者,可以说毫无影响。


事实上,从逻辑的角度上,谷歌大概率就是觉得维护代码的成本过高,不论是从 AOSP 合并到内部版本,还是将内部版本的更新带给 AOSP 公共分支,这些工作都需要工程师完成。


可以说这些处理冲突的工作过于「低端」,对于谷歌的工程师来说,耗时耗力而且毫无意义。


但是 AOSP 某种意义上已经可以看做是谷歌在开源生态和程序员心目中的「投名状」。


作为以「不作恶」为公司理念的谷歌,安卓开源这步棋被认为是谷歌最成功的一次战略决策之一。


在极客们看来,这次决策类似于谷歌自己推倒了过去十几年树立起来的「精神丰碑」。


当然,从谷歌自己的角度看来,选择将工作整合在一个内部分支下,同时简化操作系统开发和源代码发布,是可以理解的。


毕竟 AOSP 对 Google 的商业价值,跟当年比起来,已经完全不是一个量级了。


从最近谷歌对 Gemini 以及 Gemma 的疯狂更新来看,AI 才是其工作的重点。


其实所有人都知道,相比于 Gemini,安卓对于谷歌已不再那么重要。


参考资料:


arstechnica.com/gadgets/202…


http://www.androidauthority.com/google-andr…


作者:新智元
来源:juejin.cn/post/7486315070362075173
收起阅读 »

我写了个App,上架 Google Play 一年,下载不到 10 次,于是决定把它开源了

缘起 起初接触某某标签笔记,我被其以标签为核心的卡片笔记模式深深吸引。然而,99 元一年的会员费感觉有点贵了,再加上数据存储在它的服务器,总感觉缺乏一份安全感。 身为 Android 开发者,一个念头在我脑海中闪现:何不亲手写一款属于自己的类似应用? 开发历程...
继续阅读 »

pEK8AXT.jpg


缘起


起初接触某某标签笔记,我被其以标签为核心的卡片笔记模式深深吸引。然而,99 元一年的会员费感觉有点贵了,再加上数据存储在它的服务器,总感觉缺乏一份安全感。


身为 Android 开发者,一个念头在我脑海中闪现:何不亲手写一款属于自己的类似应用?


开发历程


说干就干,最初采用 xml 方式进行开发,慢悠悠的写了几个月写完后。但随着谷歌大力推广 Compose,我决定试试新的技术,发现真香,于是对项目进行 Compose 重构。在 UI 风格上,也经历多次迭代,从最初的随意设计,到遵循谷歌 MD 风格,再到引入 Slat UI,改了无数次,程序员设计 ui 是真的难🥲。


写完后,原本计划在酷安或国内应用市场上架,于是觉得申请软著,反反复复打回,半年后终于申请下来了。结果新政出来了,要公安局备案,还要买服务器。


无奈之下,我选择放弃国内上架计划,转而开通谷歌开发者账号,当时谷歌开发者账号容易弄,交钱绑卡,很快就将应用上架至 Google Play。


现在一年左右了,没做宣传, 10+左右的下载量😓。


pEKJCQ0.webp


现在换了一个朝九晚九的的工作,再加上要带娃,决定将这个项目开源,有兴趣的朋友可以一起维护与开发。


理想与现实的碰撞


从事 Android 开发多年,一直在写公司业务代码,初入行时便怀揣着打造一款个人 App,上架应用市场,甚至有可能获得一点睡后收入的梦想。


开发完成后,我才发现,开发只是整个过程中最简单的环节。


在国内上架应用,软著开公司备案买服务器各种卡流程,很佩服那些上班又自己能这么折腾的开发者。感觉现在国内个人 Android 开发者这条路基本是断了。


Google Play 以前很简单,交钱就行了,现在好像也需要拉人头内测等流程。


App如果不氪金去推广,现在移动互联网已经卷成一片死海,应用市场 App 一大堆,特别是程序员三件套,再去做的话基本凉凉。


如果软件收费,售后问题同样棘手。我在酷安付费购买过多款优秀 App,并加入开发者群,潜水好几年。一旦软件收费,便意味着要对用户负责,面对用户五花八门的需求、各机型适配问题、还有跨平台需求,还有的人无脑喷,管理和维护成本其实非常高。


折腾了这么久,对于大多数人而言,个人开发者之路并不好走,尤其是身处 996 工作环境时,更是难上加难。过去工作稍有空闲,还能抽时间写代码,现在到家都快 10 点了,看到电脑就想吐(真实的心里反应)。


浅谈一下 35 岁程序员焦虑和副业问题,程序员软件三件套是行不通的,因为这些产品非常多,并且大多都是没什么亮点的,大部分估计赚的钱还不够服务器和各种流程的成本。我觉得如果是客户端,2025 年 个人开发者这条路都不建议去走了,前段时间看到很多套壳的 AI 客户端,套完壳容易,推广如果不砸钱,基本没人问津。好不容易推广,你干的过免费的 deepseek 和 kimi 之类的吗🤔


对于 35 岁焦虑, 个人觉得最好的路是找个制造业或者二三线城市的国企银行这种不怎么裁人的企业,躺平式继续 coding。
尾声


开源后,会不定时更新,后续打算试试 KMP 跨平台技术,平常时间大部分带娃,代码写的比较乱,大佬们将就的看。
欢迎提 PR,一起维护。


Github


github.com/ldlywt/Idea…


如果能给一个 star,不胜感激🙏


2025.2.26更新


对于有些人说是AI做的,我贴几张图。最开始在Github,然后迁到Gitee,后面又迁到Github,开源后为了防止敏感信息,把以前的提交记录给清了。


play store发版记录


image.png


Gitee 记录


image.png


作者:方之长
来源:juejin.cn/post/7471630643534512164
收起阅读 »

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

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

5235a0e62ecd314a216da5209ff88326.jpeg



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



一、前言


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


网络框架的发展:


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


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


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


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


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

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


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


二 、示例项目


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



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

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


三、网络层代码设计


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


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


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

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

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

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


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

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

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

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


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

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


先配置:


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

里面的 android 下面添加:


kapt {
generateStubs = true
}

dependencies 里面引入 Hilt 使用


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

5. 使用 Hilt


5.1 在Application上添加注解 @HiltAndroidApp

@HiltAndroidApp
class MyApp : Application() {

}

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

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

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

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

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


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

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

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

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

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

5.5 新建一个 annotationBindHttpUrlConnection 如下:

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

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

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


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

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

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

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

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


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


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


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

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


class OkhttpImpl @Inject constructor() : INetApi {

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

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

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

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

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

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

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

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

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

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

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

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

运行执行结果截图可见:


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


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


四、总结



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

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


项目地址


项目地址:

github地址

gitee地址


感谢阅读:


欢迎 点赞、收藏、关注


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


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

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

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

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


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


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



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


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


理解业务


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


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


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


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


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


Framework


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


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


Biz Framework


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


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


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


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


Common Biz


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


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


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


Features


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


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


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


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


Plugins


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


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


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


Application


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


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


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


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

Android - 监听网络状态

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

前言


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


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


首先需要创建NetworkRequest


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

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


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


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


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


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

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


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


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


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

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



  • Network,网络

  • NetworkCapabilities,网络能力


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


public void onLost(@NonNull Network network) {}

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


封装


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



  • 定义一个网络状态类

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

  • 注册NetworkCallback回调,开始监听


网络状态类

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

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


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

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


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

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

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


网络状态流Flow

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


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

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

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

onLostonCapabilitiesChanged中更新_networks_networksFlow


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


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


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

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

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

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

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


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


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


开始监听

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


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


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

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



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

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


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


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


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

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

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

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

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


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

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


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

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


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


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


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


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

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


监听当前网络


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


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

过滤的逻辑在filterCurrentNetwork方法中:


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

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


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


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


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


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


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


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


挂起等待网络


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


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


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

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


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

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


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


模拟使用代码:


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

// 发起请求
requestData()
}

结束


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

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


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


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

一文搞懂Apk的各种类型

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

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


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


本文摘要


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


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


本文大纲


image


1. Apk分类


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


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



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


2. 系统Apk


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


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


2.1 如何成为系统Apk


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


image


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



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


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



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

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

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


//ScanPackageUtils类

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

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

省略代码······

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

省略代码······
}

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


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


2.2 按存放目录分类


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


image


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


2.2.1 按根目录分类


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


2.2.2 按子目录分类


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


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


privileged Apk

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


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


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


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

overlay Apk

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


2.2.3 小结


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


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


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


core Apk

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


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


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


persistent Apk

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


//ActivityManagerService类

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

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

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


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


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

2.4 小结



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

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

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


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


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


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


3. 普通Apk


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


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


//InitAppsHelper 类

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

省略代码······
}

4. 总结


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


先来说下它们区别:



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

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

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

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

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


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



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

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

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


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


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


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


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

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

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

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


Compose-Multiplatform.png


总体思路


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


具体的步骤如下:



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

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

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

  4. 添加并适配其他平台


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


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


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


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


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


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



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

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

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


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


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

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


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

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

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

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

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

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

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

android { ... }

dependencies { ... }

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


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


migrate-struct.png


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


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


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


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


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


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


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


把资源转化成为CMP方式


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


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


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


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


添加并适配其他平台


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


总结


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


References




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


保护原创,请勿转载!



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

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

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


Author: istyras

Date: 2024-10-12

Update: 2024-10-12




0. OkDownload 组件


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


完整的使用文档


1. 简单使用


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


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

task.enqueue(listener);

// cancel
task.cancel();

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

1.2. 启动多个任务和取消


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

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

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


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

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

DownloadContext context = builder.build();

context.startOnParallel(listener);

// stop
context.stop();


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






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




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


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


2.1. 简单的下载流程回调


OkDownload.DownloadListener1.png


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


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


OkDownload.DownloadListener4.png


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


2.2.1. 连接相关的流程


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


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


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


2.2.2. 进度回调流程


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


2.3. 完整的下载流程回调


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


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




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

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

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

1111111.jpg



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



一、前言


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


二 、串口接入使用


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

public class SerialPort {

private static final String TAG = "SerialPort";

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

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

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

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

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

public OutputStream getOutputStream() {
return mFileOutputStream;
}

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

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

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


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

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


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

比如读取数据:


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

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


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

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


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


三 、USB接入使用


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


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

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


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

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

3、代码中具体使用:

比如接入USB打印机:


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

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


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

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

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


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

四、打印机的使用


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


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

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

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


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

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

}
}
}

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


初始化打印机指令


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

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


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

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


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

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


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

打印结束切刀指令


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

打印文字


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

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

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


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

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

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


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


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


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

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


方式1:广播接收如下:


  1. 先注册扫码广播


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

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


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

public class ScanGunReceiver extends BroadcastReceiver {

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

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

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


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


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

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


object KeyUtils {

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

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

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

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


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

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

六、总结


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


感谢阅读:


欢迎 关注,点赞、收藏


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


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

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

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

 1. 背景


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


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


2.1 使用原则


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



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

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

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


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


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


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

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


2.3 如何使用



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


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

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


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

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


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

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


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

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


3. 高级用法:增加层级


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


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


image.png



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

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

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

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



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

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

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

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




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


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



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

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

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

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


代码如下:



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2 -> {
mCurLevel2View
}

else -> {
null
}
}
}

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

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

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

2 -> {
mCurLevel2Data
}

else -> {
null
}
}
}

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

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

3.3 MultiLevelFocusHelper要点说明



  1. 构造函数中的参数 totalLevel



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

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



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



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

    2. 参数说明



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

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

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

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



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



  3. 层级流转



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

    2. 两个概念:MoveCommander, MoveDirect:


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

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


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



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

      2. 参数说明



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

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








4. 使用实例


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


4.1 相关控件的XML设置



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

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

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


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


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


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

4.4 RecyclerView 和 它的 item 设置 OnFocusChangeListener


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


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

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


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


5. 总结



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

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


6. 团队介绍


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


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

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

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

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


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


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




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


删掉dontoptimize


先改自己模块的缺点。


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


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

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


多观察printconfiguration


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


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


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


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

以及:


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

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


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


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


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

# 太长了,这里忽略

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

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

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

# 太长了,这里忽略

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

改掉不良习惯


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


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


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


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


降低包体积 · 改变别人


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


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


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


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


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


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


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


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


image.png


如果是JAR,这样:


image.png


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


image.png


image.png


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


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


方法二:编写gradle任务


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


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


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

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


image.png


然后配置一个"Configuration"


image.png


image.png


image.png


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


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


image.png


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


image.png


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


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

ConfigurableFileCollection cf;

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

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

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


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

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


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


既要激进,又要保守


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


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


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


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


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


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




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


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

Android 工位运动小助手

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

背景


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


功能介绍


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


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

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


实现方法


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


具体实现代码


package com.fly.heat.service

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

class ForegroundService : Service() {

private lateinit var workManager: WorkManager


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

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

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

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

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

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

startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
}


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


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

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

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

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

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

workManager.enqueue(periodicWorkRequest)
}

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

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

package com.fly.heat.task

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

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


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

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

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

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

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

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

notificationManager.notify(notificationId, notification)
}
}

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

package com.fly.heat.ui

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


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

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


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

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


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

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

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

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


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

}

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


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






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

}

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

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


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


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

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

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

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



}

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

package com.fly.heat.ui

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

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


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

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

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


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

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

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

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



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

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


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


}

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

最后总结

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


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


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

Android 动效方案探索

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

前言

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

开始

PAG

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

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

PAG 优势

高效的动效文件

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

广泛的平台支持

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

高性能的渲染

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

丰富的应用场景

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

PAG 集成

aar 集成

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

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

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

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

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

Maven 集成

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

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

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

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

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

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

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

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

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

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

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

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

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

示例

代码实现

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

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

UI 动效

output3.gif

Lottie

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

Lottie 优势

文件小

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

无限可扩展

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

多平台支持和库

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

交互性

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

Lottie 集成

配置 Gradle

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

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

示例

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

Kotlin 实现

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

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

}

XML 实现

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

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

UI 动效

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

output.gif

Lottie 详细介绍

动画资源

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

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

动画缓存

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

全局配置

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

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

动画监听器

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

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

自定义动画效果

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

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

Lottie 对 APK 大小有什么影响

非常小:

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

Lottie 的优点

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

Lottie 的缺点

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

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

DotLottie

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

DotLottie 优势

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

DotLottie 集成

配置 Gradle

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

示例

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

Kotlin 实现

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

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

UI 动效

output1.gif

Compose 实现

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

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

UI 动效

output2.gif

总结

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

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

收起阅读 »

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

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

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


a0802da38d503daac59b98999452dcd.jpg


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


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

效果如下图所示:


image.png


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


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

效果如下图所示:


image.png


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


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

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

}

效果如下所示:


image.png


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


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

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

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

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

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

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

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

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

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

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

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

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

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


代码示例如下:


class WaveLineBackgroundSpan(val waveColor: Int) : LineBackgroundSpan {

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

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

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

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

效果如下图所示:


image.png


参考



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

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

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

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



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



1、CAN简介


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


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



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

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

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

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

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

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

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

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

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

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


image.png


废话说完,进入正题

2、CAN通信原理


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


2.1、CAN物理接线


image.png


image.png



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

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

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

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


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


image.png


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


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


2.2、 CAN电平标准


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


高速CAN规定



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

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


image.png


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

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


2.3、收发器原理


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


image.png


2.4、CAN物理层特性


image.png


2.5、CAN通讯思路总结


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


3、CAN总线帧格式


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


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

3.1 数据帧


image.png


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


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


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


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


3.1.1 SOF(帧起始)


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


3.1.2 Identifier(ID)报文ID


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


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


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


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


3.1.3 RTR (远程请求标志位)


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


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


3.1.4 IDE (ID扩展标志位)


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


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


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


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


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


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


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


3.1.10 ACK槽


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


3.1.11 ACK界定符


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


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


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


3.1.13 扩展帧


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


3.2 遥控帧


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


3.3 错误帧


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


image.png


3.4 过载帧


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


image.png


3.5 帧间隔


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


image.png


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


4、位填充


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


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


位填充作用:



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

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

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


5、接收方数据采样



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

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

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

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


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


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


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


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


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


image.png


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


6、 仲裁规则


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


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


规则一 先占先得


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



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

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

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

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


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


规则二 非破坏性仲裁



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

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

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

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




仲裁过程


image.png


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


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


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


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


从仲裁的过程,我们可以看出,仲裁的最后的胜利者,它所有的回读,和自己发送的数据是一样的。原有的数据都没发生改变所以它叫非破坏性仲裁。


数据帧和遥控帧的优先级,先按id号仲裁,如果id号一致,再走RTR位仲裁。


image.png
标准帧的id号,不允许出现一样的,遥控帧的id号也不能出现一样的。如果一样的话,他们的仲裁段完全相同。到后边数据会被破坏的。


扩展帧和数据帧的优先级
标准格式11位ID号和扩展格式的29位ID号的高11位一样时,标准格式的优先级,高于扩展帧(SRR必须始终为1,以保证此要求)


image.png


还有一种极端情况,就是标准遥控帧的id号和扩展帧的高11位相同时,怎么仲裁的呢。
到这里标准遥控帧的仲裁端已经结束了,扩展帧的SRR 是0,标准遥控帧的RTR 也是0,但是,扩展帧的仲裁段还没有结束,SRR 后边是IDE 因为是扩展帧所有它的idE 是1,标准帧的ide是0,扩展帧就会出现发1读0的情况,仲裁失利,退出竞争。


7、错误处理


1730447477206.png



  • 主动错误状态的设备正常参与通信并在检测到错误是发出主动错误帧

  • 被动错误状态的设备正常参与通信,但检测的错误时,只能发出被动错误帧,不会破坏别人发送的数据。

  • 总线关闭状态的设备不能参与通信

  • 每个设备内部管理一个TEC和REC,更具TEC和REC的值确定自己的状态
    TEC和REC是计数器,TEC发送错误计数一次,正确发送减少一次,REC接收错误计数一次,正确接收减少一次。
    image.png


image.png


8、总结


我们从CAN的物理接线,开始介绍,介绍了协议的主要内容,也介绍了协议对特殊情况的处理。消息仲裁,和错误处理。相信大家可以正常的跟硬件工程师交流了。至于Android代码实现,这篇有点太长了,会再写个文章发出。多多见谅。


作者:一杯凉白开
来源:juejin.cn/post/7433076509574905908
收起阅读 »

UNIAPP实现APP自动更新

整体思路和API使用 工作流程 App 启动时检查更新 发现新版本时显示更新提示 如果是强制更新,用户必须更新 下载完成后自动安装 API getVersion:自己服务器API,返回版本号、下载地址等信息 plus.runtime.getPropert...
继续阅读 »

整体思路和API使用


工作流程



  • App 启动时检查更新

  • 发现新版本时显示更新提示

  • 如果是强制更新,用户必须更新

  • 下载完成后自动安装


API



  • getVersion:自己服务器API,返回版本号、下载地址等信息

  • plus.runtime.getProperty:获取APP当前版本号

  • uni.downloadFile:下载文件

  • plus.runtime.install:安装软件

  • downloadTask.onProgressUpdate:监听下载进度


具体实现


后端getVersionAPI代码


// Version.java
@Data
public class Version {
private String version; // 版本号
private String downloadUrl; // 下载地址
private String description; // 更新说明
private boolean forceUpdate; // 是否强制更新
}

// VersionController.java
@RestController
@RequestMapping("/api/version")
public class VersionController {

@GetMapping("/check")
public Result checkVersion(@RequestParam String currentVersion) {
Version version = new Version();
version.setVersion("1.1.7"); // 最新版本号
version.setDownloadUrl("软件下载地址"); // 下载地址
version.setDescription("1. 修复已知问题\n2. 新增功能");
version.setForceUpdate(true); // 是否强制更新

// 比较版本号
if (compareVersion(currentVersion, version.getVersion()) < 0) {
return Result.success(version);
}

return Result.success(null);
}

// 版本号比较方法
private int compareVersion(String v1, String v2) {
String[] version1 = v1.split("\\.");
String[] version2 = v2.split("\\.");

int i = 0;
while (i < version1.length && i < version2.length) {
int num1 = Integer.parseInt(version1[i]);
int num2 = Integer.parseInt(version2[i]);

if (num1 < num2) return -1;
else if (num1 > num2) return 1;
i++;
}

if (version1.length < version2.length) return -1;
if (version1.length > version2.length) return 1;
return 0;
}
}

其中Version类可以写到数据库中获取


前端update.js封装


// 版本更新工具类 - 使用单例模式确保全局只有一个更新实例
import {
check
} from "../api/util/util";

class AppUpdate {
constructor() {
// 当前应用版本号
this.currentVersion = '';
// 服务器返回的更新信息
this.updateInfo = null;
}

// 检查更新方法
checkUpdate() {
//仅在app环境下运行
// #ifdef APP-PLUS
plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
this.currentVersion = widgetInfo.version;
console.log('当前版本:' + this.currentVersion);
check(this.currentVersion).then(res => {
if (res.data.data) {
this.updateInfo = res.data.data;
this.showUpdateDialog();
}
})
.catch(err => {
console.log(err);
});
});

// #endif
}
showUpdateDialog() {
uni.showModal({
title: '发现新版本',
content: this.updateInfo.description,
confirmText: '立即更新',
cancelText: '稍后再说',
showCancel: !this.updateInfo.forceUpdate, // 强制更新时禁止取消
success: (res) => {
if (res.confirm) {
this.downloadApp();
} else if (this.updateInfo.forceUpdate) {
plus.runtime.quit();
}
}
});
}

downloadApp() {
/* uni.showLoading({
title: '下载中...',
mask: true // 添加遮罩防止重复点击
}); */


// 先打印下载地址,检查 URL 是否正确
console.log('下载地址:', this.updateInfo.downloadUrl);
let showLoading=plus.nativeUI.showWaiting('正在下载');
const downloadTask = uni.downloadFile({
url: this.updateInfo.downloadUrl,
success: (res) => {
console.log('下载结果:', res); // 添加日志
if (res.statusCode === 200) {
console.log('开始安装:', res.tempFilePath); // 添加日志
plus.runtime.install(
res.tempFilePath, {
force: false
},
() => {
console.log('安装成功'); // 添加日志
plus.nativeUI.closeWaiting();
plus.runtime.restart();
},
(error) => {
console.error('安装失败:', error); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '安装失败: ' + error.message,
icon: 'none',
duration: 2000
});
}
);
} else {
console.error('下载状态码异常:', res.statusCode); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '下载失败: ' + res.statusCode,
icon: 'none',
duration: 2000
});
}
},
fail: (err) => {
console.error('下载失败:', err); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '下载失败: ' + err.errMsg,
icon: 'none',
duration: 2000
});
}
});

//监听下载进度
downloadTask.onProgressUpdate((res) => {
console.log('下载进度:', res.progress); // 添加进度日志
if (res.progress > 0) { // 只在有实际进度时更新提示
showLoading.setTitle('正在下载'+res.progress+'%');
}
});
}
}

//单例模式实现
let instance = null;

export default {
getInstance() {
if (!instance) {
instance = new AppUpdate();
}
return instance;
}
}

注意:如果直接使用uni.showLoading来显示下载进度,会造成闪烁效果,所以这里用let showLoading=plus.nativeUI.showWaiting('正在下载');


引用js


以app.vue为例,在启动时触发检查更新


import AppUpdate from '@/utils/update.js';

export default {
onLaunch: function() {
// #ifdef APP-PLUS
AppUpdate.getInstance().checkUpdate();
// #endif
}
}

在 manifest.json 中配置权限


{
"app-plus": {
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INSTALL_PACKAGES\"/>",
"<uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>"
]
}
}
}
}

这样封装的优点



  • 代码更加模块化

  • 可以在任何地方调用

  • 使用单例模式避免重复创建

  • 更容易维护和扩展


作者:HuoWang
来源:juejin.cn/post/7457206505021341730
收起阅读 »

Android技巧:学习使用GridLayout

GridLayout是一个非常强大的网格类布局,它不但能像TableLayout那样,实现网格类布局,但它更为强大的地方在于每个Cell的大小可以横向或者纵向拉伸,每个Cell的对齐方式也有很多种,而且不像TableLayout,需要一个TableRow,Gr...
继续阅读 »

GridLayout是一个非常强大的网格类布局,它不但能像TableLayout那样,实现网格类布局,但它更为强大的地方在于每个Cell的大小可以横向或者纵向拉伸,每个Cell的对齐方式也有很多种,而且不像TableLayout,需要一个TableRow,GridLayout可以通过指定Cell的坐标位置就能实现Cell的拉伸,从而实现,大小不一致的风格卡片式布局。


header


基本概念


GridLayout把页面分成m行和n列,使用m+1条线和n+1条线,把页面共分成n*m个Cell。指定位置时行坐标是从0到m,列坐标是从0到n。每一个子View占一个或多个Cell。比如(0, 0)到(0, 1)就是占第一个Cell的区域。(0, 0), (0, 2)就是占第一行的2个Cell的区域(横向拉伸).


使用方法


主要介绍一下如何添加Cell,以及设置Cell的位置和拉伸。其他的跟普通的ViewGr0up没什么区别的,也没啥好说的。


GridLayout的基本设置


首先需要给GridLayout设置行数和列数:



  • android:columnCount 整数,最多的列数

  • android:rowCount 整数,最多的行数


在添加Cell就需要注意,不能超过设置的最大行数和列数,否则在添加Cell时会有异常。


元素Cell的位置控制


添加Cell时需要指定其位置



  • android:layout_column 整数n,在哪一列开始显示n=[0, 最大列-1]

  • android:layout_columnSpan 整数k,指定元素横跨几列,需要注意保证n+k <= 最大列数

  • android:layout_row 指定从哪一行开始显示,规则同列数

  • android:layout_rowSpan 纵向跨几行,规则同列


行高和列宽的确定


每一行的高度是由这一行中Cell的最大高度决定的,以及每一列的宽度是由每一列中最大的宽度决定的,小于行高和列宽的元素可以设置其对齐方式和填充方式。


填充方式


通过Cell的android:layout_gravity参数来指定,Cell的填充方式,注意仅当Cell元素本身的尺寸小于它所占格子的大小时才有效,比如元素本身尺寸小于行高和列宽,或者当它占多行,或者占多列时:



  • center -- 不改变元素的大小,仅居中

  • center_horizontal -- 不改变大小,水平居中

  • center_vertical -- 不改变大小,垂直居中

  • top -- 不改变大小,置于顶部

  • left -- 不改变大小,置于左边

  • bottom -- 不改变大小,置于底部

  • right -- 不改变大小,置于右边

  • start -- 不改变大小,置于开头(这个是与RTL从右向左读的文字有关的,如果使用start/end,那么当LTR文字时start=left,end=right,当RTL时start=right,end=left,也就是说系统会自动处理了)

  • end -- 不改变大小,置于结尾

  • fill -- 拉伸元素控件,填满其应该所占的格子

  • fill_vertical -- 仅垂直方向上拉伸填充

  • fill_horizontal -- 仅水平方向上拉伸填充

  • clip_vertical -- 垂直方向上裁剪元素,仅当元素大小超过格子的空间时

  • clip_horizontal -- 水平方向上裁剪元素,仅当元素大小超过格子的空间时


需要注意的是这些值是可以组合的,比如:


android:layout_gravity="center_vertical|clip_horizontal"

Cell之间的间距如何控制


默认间距


可以使用默认的间距android:useDefaultMargins="true"或者GridLayout#setUseDefaultMargins()。这个属性默认值是"false"。


另外一种方式就是跟普通布局管理器一样,给每个Cell设置其margins


通常如果不满意系统的默认间距,就可以设置useDefaultMargins="false",然后通过给Cell设置margin来控制间距。


居中方法



  • 仅有一个Cell或者仅有一行,或者仅有一列时


    当仅有一个子View时或者仅有一行或者一列的时候,可以把每个Cell设置其android:layout_gravitiy="center"(相应代码为LayoutParams#GravityCENTER),就可以让其在GridLayout中居中。



让一行居中:


header


    <GridLayout
android:layout_width="wrap_content"
android:layout_height="200dip"
android:useDefaultMargins="true"
android:background="@android:color/white"
android:rowCount="1"
android:columnCount="2">

<Button android:layout_column="0"
android:layout_row="0"
android:text="Left Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

<Button android:layout_column="1"
android:layout_row="0"
android:text="Right Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

</GridLayout>

让一个元素居中:

header


    <GridLayout
android:layout_width="200dip"
android:layout_height="200dip"
android:useDefaultMargins="true"
android:background="@android:color/white"
android:rowCount="1"
android:columnCount="1">

<Button android:layout_column="0"
android:layout_row="0"
android:text="Left Button"
android:layout_gravity="center"/>

</GridLayout>


  • 其他情况


    其他情况,设置子View的Gravity就不再起作用了,这时最好的办法就是让GridLayout的高度是WRAP_CONTENT,然后让GridLayout在其父布局中居中。



header


     <LinearLayout
android:layout_width="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="@android:color/darker_gray"
android:layout_height="200dip">

<GridLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:useDefaultMargins="true"
android:background="@android:color/white"
android:rowCount="2"
android:columnCount="2">

<Button android:layout_column="0"
android:layout_row="0"
android:text="Left Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

<Button android:layout_column="1"
android:layout_row="0"
android:text="Right Button"
android:layout_gravity="fill_horizontal|center_vertical"/>

<Button android:layout_column="1"
android:layout_row="1"
android:text="Right Button 2"
android:layout_gravity="fill_horizontal|center_vertical"/>

</GridLayout>
</LinearLayout>

适用场景


GridLayout虽然强大,可以当作LinearLayout使用,也可以当作RelativeLayout使用,甚至也能当FrameLayout使用。但是,我们不可以滥用,对于任意布局都一样,不能是它能实现需求就使用它,而是要根据实际的需求,选择最简单,最方便的,同时也要考虑性能。


通常对于类似于网格的布局就可以考虑用GridLayout来实现,或者用LinearLayout横七竖八的套了好几层时也要考虑使用GridLayout。


GridLayout vs GridView or RecyclerView


当要实现网格布局,或者非均匀风格布局时,可能首先想到的就是GridView,但是这也要看实际的情况而定。GridView,ListView以及RecyclerView是用于无限长度列表或者网格的场景,它们最大的特点是无限长度,因此这几个组件的重点在于如何复用Cell以提升性能,以及处理手势事件(Fling)等。所以,每当遇到列表或者网格的时候,先想一下这个长度大概会是多少,如果是在百个以内,且不会随时增长,这时就可以考虑使用静态(非动态复用)的组件比如LinearLayout或者GridLayout来实现。


实例


说的太多都是废话,来一个实例感觉一下子是最直接的:


header


<GridLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:background="@android:color/white"
android:alignmentMode="alignMargins"
android:useDefaultMargins="true"
android:columnCount="4"
android:rowCount="5"
android:visibility="visible">

<Button android:layout_column="0"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="1"/>

<Button android:layout_column="1"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="2"/>

<Button android:layout_column="2"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="3"/>

<Button android:layout_column="0"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="4"/>

<Button android:layout_column="1"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="5"/>

<Button android:layout_column="2"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="6"/>

<Button android:layout_column="0"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="7"/>

<Button android:layout_column="1"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="8"/>

<Button android:layout_column="2"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="9"/>

<Button android:layout_column="0"
android:layout_row="3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="0"/>

<Button android:layout_column="1"
android:layout_row="3"
android:layout_gravity="fill_horizontal"
android:layout_columnSpan="2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="Delete"/>

<Button android:layout_column="0"
android:layout_row="4"
android:layout_columnSpan="2"
android:layout_gravity="fill_horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="Clear"/>

<Button android:layout_column="2"
android:layout_row="4"
android:layout_columnSpan="2"
android:layout_gravity="fill_horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="="/>

<Button android:layout_column="3"
android:layout_row="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill"
android:padding="10dip"
android:text="+"/>

<Button android:layout_column="3"
android:layout_row="1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill"
android:padding="10dip"
android:text="-"/>

<Button android:layout_column="3"
android:layout_row="2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill"
android:padding="10dip"
android:text="*"/>

<Button android:layout_column="3"
android:layout_row="3"
android:layout_columnSpan="1"
android:layout_gravity="fill"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dip"
android:text="/"/>

</GridLayout>

参考资料




欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!


保护原创,请勿转载!



作者:稀有猿诉
来源:juejin.cn/post/7449673188131717174
收起阅读 »

Android树形结构,项目通用

效果图 思路 树形展开时,将该节点的childList添加到该节点下,并更新树结构 mDataList.addAll(position, childList) notifyItemRangeInserted(position, childList.size...
继续阅读 »

效果图


通用多选树.gif


思路



  • 树形展开时,将该节点的childList添加到该节点下,并更新树结构


    mDataList.addAll(position, childList)
    notifyItemRangeInserted(position, childList.size)


  • 树形关闭时,树的数据结构移除该节点的childList的数量,并更新树结构


    for (i in 0 until childList.size) {
       mDataList[position].isExpand=false
       mDataList.removeAt(position)
    }
    notifyItemRangeRemoved(position, childList.size)


  • 对于含CheckBox的树形结构,每一个节点都需要监听他的状态,当状态和上一次的状态不一样时,则进行更新。更新不仅更新本节点,还需要递归的方式更新他的子节点;因为当前的选中状态还会牵连到他的父节点,他的父节点变更的话还会牵扯到再上一层,所以也需要递归的方式来更新。


    private fun updateNodeState(bean: T) {
       //更新子节点
       updateChildState(bean)

       //更新父节点状态
       updateParentState(bean)

       notifyDataSetChanged()
    }

    更新子节点


    private fun updateChildState(bean: T) {
       for (child in bean.getChildList()) {
           //更新子节点状态
           child.checkState = bean.checkState
           //递归更新子节点
           updateChildState(child)
      }
    }

    更新父节点


    private fun updateParentState(bean: T) {
       //找到父节点并更新
       mDataList.forEach { parent ->
           if (bean.getMyId() in parent.getChildList().map { it.getMyId() }) {
               //全部选中
               val allChecked =
                   parent.getChildList().all { it.checkState == TriStateCheckBox.State.CHECKED }
               val allUnChecked =
                   parent.getChildList().all { it.checkState == TriStateCheckBox.State.UNCHECKED }

               if (allChecked) {
                   parent.checkState = TriStateCheckBox.State.CHECKED
              } else if (allUnChecked) {
                   parent.checkState = TriStateCheckBox.State.UNCHECKED
              } else {
                   parent.checkState = TriStateCheckBox.State.PARTIALLY_CHECKED
              }
               //递归更新父节点
               updateParentState(parent)
          }
      }
    }


  • 设置选中项时,可以先获取到selectList中的所有叶子节点,然后再更新整个树形结构的mDataList选项


    //获取所有的叶子节点
    private fun getLeafNodeList(selectedList: List<T>):List<T>{
       val result = mutableListOf<T>()
       for (bean in selectedList){
           if (bean.hasChild()){
               result.addAll(getLeafNodeList(bean.getChildList()))
          }else{
               result.add(bean)
          }
      }
       return result
    }

    fun setSelectedList(selectedList:List<T>){
       //选中的叶子节点列表
       val selectedChildNodeList = getLeafNodeList(selectedList).toMutableList()
    //通过递归的方式检查子列表
       updateSelectedTree(mDataList, selectedChildNodeList)
       notifyDataSetChanged()
    }


  • 因为想通过泛型的方式,适用于任何项目,所以我们搞一个抽象类,包含该节点的层级、是否展开、选中状态等属性


    abstract class TreeBaseBean<T>{
       
       //层级
       var level:Int=0
       //是否展开
       var isExpand = false
       //当前节点状态
       var checkState: TriStateCheckBox.State = TriStateCheckBox.State.UNCHECKED

       //判断是否有子节点
       fun hasChild():Boolean = !getChildList().isNullOrEmpty()
       
       //获取子节点列表
       abstract fun getChildList():List<T>
       //获取当前节点id
       abstract fun getMyId():Any
       //获取父节点id
       abstract fun getMyParentId():Any?

    }



步骤


处理数据


将项目中的树形数据结构继承自TreeBaseBean,重写该抽象类中的方法


data class MenuBean(
   var id: String = "",
   var parentId: Any? = null,
   var menuName: String = "",
   var menuType: String = "",
   var router: String = "",
   var sort: Int = 0,
   var icon: String = "",
   var sonList: List<MenuBean> = listOf(),
   var status: String = "",
   var userid: String=""
): TreeBaseBean<MenuBean>() {

   override fun getChildList(): List<MenuBean> {
       return sonList
  }

   override fun getMyId(): Any {
       return id
  }

   override fun getMyParentId(): Any? {
       return parentId
  }
}

2、建立自己的ItemView,确保里面包含有一个命名为ivArrow的箭头图片,一个命名为mCheckBox的CheckBox或自定义三种状态的TriStateCheckBox


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:id="@+id/root">

   <ImageView
       android:id="@+id/ivArrow"
       android:layout_width="18dp"
       android:layout_height="18dp"
       android:src="@drawable/ic_keyboard_arrow_right_black_18dp"
       android:visibility="invisible"
       app:layout_constraintBottom_toBottomOf="@id/mCheckBox"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintTop_toTopOf="@id/mCheckBox" />

   <com.sxygsj.treefinalcase.TriStateCheckBox
       android:id="@+id/mCheckBox"
       android:layout_width="20dp"
       android:layout_height="20dp"
       app:layout_constraintLeft_toRightOf="@id/ivArrow"
       app:layout_constraintTop_toTopOf="@id/tvCheckName"
       app:layout_constraintBottom_toBottomOf="@id/tvCheckName"/>

   <TextView
       android:id="@+id/tvCheckName"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:text="内容"
       app:layout_constraintLeft_toRightOf="@id/mCheckBox"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       android:layout_marginLeft="5dp"
       android:paddingVertical="5dp"/>
   
</androidx.constraintlayout.widget.ConstraintLayout>

使用适配器


//数据项
private val dataList= mutableListOf<MenuBean>()
//设置的选中项
private val selectedList = mutableListOf<MenuBean>()

private lateinit var adapter: ChainCommonTreeCheckboxAdapter<MenuBean>

private fun initRcy() {
   adapter = ChainCommonTreeCheckboxAdapter.Builder<MenuBean>()
      .setData(dataList)
      .setLayoutId(R.layout.item_checkbox_tree)
      .addBindView { itemView, bean, level ->
           itemView.findViewById<TextView>(R.id.tvCheckName).setText("层级${level}:"+bean.menuName)
      }
      .addItemClickListener {
           Toast.makeText(this,"点击了:${it.menuName}", Toast.LENGTH_SHORT).show()
      }
      .setPadding(16)
      .create()


   binding.apply {
       mRcy.layoutManager = LinearLayoutManager(this@ChainMultiActivity)
       mRcy.adapter=adapter
  }

}

//设置选中项
adapter.setSelectedList(selectedList)

//默认获取选中和部分选中节点
val list = adapter.getSelectedList()

//获取所有的叶子节点(具体想要获取怎么的选中项,可以通过lambda方式来自己设定规则)
val list = adapter.getSelectedList { bean ->
       bean.checkState == TriStateCheckBox.State.CHECKED&&!bean.hasChild()
}

总结


其实整体的关键操作是数据的处理,怎么通过递归的方式,联动更新各个节点的状态是最重要的,关键代码在思路里已整理,剩余的三种状态的CheckBox、链式调用的通用适配器有时间再更新。


作者:重拾丢却的梦
来源:juejin.cn/post/7434799466974134284
收起阅读 »

Android手机投屏方案实现方式对比

1.概述 手机投屏是目前市场上常见的一个功能,在车机娱乐场景,辅助驾驶场景比如苹果的carplay,VR 场景都很常见,目前市场上的投屏分为三类: 第一类: 镜像模式,直接把手机上整个界面原封不动进行投射。这类投屏通常是对手机进行录屏,然后编码成视频流数据的方...
继续阅读 »

1.概述


手机投屏是目前市场上常见的一个功能,在车机娱乐场景,辅助驾驶场景比如苹果的carplay,VR 场景都很常见,目前市场上的投屏分为三类:
第一类: 镜像模式,直接把手机上整个界面原封不动进行投射。这类投屏通常是对手机进行录屏,然后编码成视频流数据的方式给到接受端,接收端再解码播放,以此完成投屏功能。比如AirPlay的镜像模式、MiraCast、乐播投屏等;
第二类: 推送模式,播视频的场景比较常见。即A把一个视频链接传给B,B自己进行播放,后学A可以传输一些简单控制指令。比如DLNA协议等;
第三类: 基于特殊协议投射部分应用或部分功能,车载领域居多。比如苹果的CarPlay、华为HiCar、百度CarLife等。



这里还有一种投屏方式比较新颖,将手机上的画面投到车机上,然后手机上可以操作自己的功能,车机上也可以操作手机的功能,而且两者互不干涉,具体可以参考蔚来手机和车机的投屏:蔚来手机的投屏视频 今天的主要内容是介绍实现投屏的各种技术方式,主要介绍Miracast、scrcpy、以及Google cast的实现方式以及优缺点局限性。



2.术语解释


2.1 miracast


Miracast是一种以WiFi直连为基础的无线显示标准,它允许用户通过无线方式分享视频画面。这种技术支持用户将智能手机、平板电脑、笔记本电脑等设备上的内容投射到大屏幕电视或其他显示设备上,而无需使用线缆连接。


2.2 scrcpy


Scrcpy是一种开源的命令行工具,允许用户通过USB数据线或Android ADB(Android调试桥)来控制他们的Android设备,包括手机和平板电脑。使用Scrcpy,用户可以在电脑上实时查看和控制他们的Android设备,就像使用一个远程屏幕一样。
2.3 DLNA投屏
DLNA投屏是一种通过网络将多媒体内容从一台设备传输到另一台设备的技术。它允许用户将智能手机、平板电脑或电脑上的视频、音频和图片等内容投射到支持DLNA的电视、音响系统或其他显示设备上。DLNA投屏基于设备之间的WiFi连接,无需额外的物理连接或设置,使用户能够轻松地将手机上的媒体内容投屏到大屏幕上并实现双向控制。


2.4 Wifi Direct


WiFi Direct是一种允许设备通过WiFi直接相互连接的技术,无需通过路由器或中继点。这种技术使得设备之间的连接更加直接和便捷,常用于文件共享、打印服务和Miracast投屏等场景。


2.5 app_process


是Android原生的一个可执行程序,位于/system/bin目录下,zygote进程便是由这个执行文件启动的。


3.技术实现对比


3.1 Miracast


3.1.1 Miracast介绍


Miracast是一种无线技术,用于将屏幕无线连接到我们的计算机。它是由WiFi联盟制定,以WiFi-Direct、IEEE802.11为无线传输标准,允许手机向电视或其他接收设备进行无线投送视频、图片。和Miracast类似的投屏协议,还有Airplay、DLNA、chromecast等,Miracast是点对点网络,用于类似蓝牙的方式(比蓝牙更高效)无线发送由Wi-Fi Direct连接组成的截屏视频。大多数最新一代的设备(例如笔记本电脑、智能电视和智能手机)都可以支持该技术,Miracast还支持高达1080p(全高清)的分辨率和5.1环绕声。它还支持4k分辨率。通过无线连接,视频数据以H.264格式发送,这是当今最常见的高清视频编码标准。Miracast在诞生之初就以跨平台标准而设计,这意味着它能在多种平台间使用。


3.1.2 Miracast原理


Miracast基于WiFi P2P,或TDLS,或Infrastructure进行设备发现,位于OSI模型的数据链路层。而媒体传输控制使用RTSP协议,还有远程I2C数据读写、UIBC用户输入反向信道、HDCP高带宽内容保护等,位于OSI模型的TCP/IP传输控制层与网络层。其中,由音视频数据封装成PES包,经过HDCP内容保护,再封装成TS包,接着封装成RTP包,使用RTSP协议发送。如下图所示
在这里插入图片描述


3.1.3 Miracast优缺点分析


优点:投屏画质清晰,兼容性好。Android手机集成了Mircast投屏,如果想要二次开发可以从AOSP源码中找到对应的实现,网上的开发文档多
缺点: Miracast正常工作时,Wi-Fi工作在P2P模式,源端与接收端建立一对一的联接。也即当一个设备与一个接收端建立连接后,其它设备不可见该接收端,也就不能投屏。只有当该设备退出连接后,其它设备才能投屏。所以无法实现抢占功能。Miracast底层封装了UDP传输协议,没有严谨的问答机制。所以在实际使用过程中,当遇到干扰时,容易造成丢帧花屏现象。而传输过程中,一旦出现花屏,给客人的感觉就非常糟糕,现在市面上,哪些无线投屏设备之所以经常出现花屏、马赛克就是这个原因。另外,Miracast是操作系统供应商提供,一般都是在安卓系统上使用,但是安卓协议导致手机投屏没有声音,所以大多数用户在安卓手机无线投屏的时候,需要开启蓝牙,以便于把声音投屏过去。如果我们需要使用Mircast,需要对ROM进行二次开发。下面是一个投屏技术公司的关于Miracast的技术文档,描述了目前Mircast存在的问题。Mircast目前存在的问题 若要实现双向控制,需要加一个控制的通道和事件转换和注入


3.2 Scrcpy


3.2.1 scrcpy 介绍


scrcpy通过adb调试的方式来将手机屏幕投到电脑上,并可以通过电脑控制Android设备。它可以通过USB连接,也可以通过Wifi连接(类似于隔空投屏),使用adb的无线连接后投屏,而且不需要任何root权限,不需要在手机里安装任何程序。scrcpy同时适用于GNU / Linux,Windows和macOS。Scrcpy 显示的每帧画面的大小达到1920x1080或者更高,帧率在30~60fps,延迟很低(大约35~70ms),启动快,第一帧画面显示出来的时间大约为1秒,并且不需要安装任何apk。并且代码完全开源,源码地址:github.com/Genymobile/…


3.2.2 scrcpy的实现原理


Scrcpy的基本原理是通过ADB(Android Debug Bridge)将电脑和手机连接到一起后,推送一个jar文件到手机/data/local/tmp的目录下,然后通过adb shell 执行app_process 程序将jar文件运行起来,这个jar文件相当于是手机上运行的一个服务器,它的作用是处理来自电脑端的的数据请求。它的免root原理主要基于两个关键点:



  1. 利用AIDL (Android Interface Definition Language):Scrcpy通过ADB(Android Debug Bridge)连接手机,AIDL允许非系统应用(如scrcpy)与系统服务交互。尽管root可以访问更多的底层功能,但是像显示屏幕这样的操作通常是安全的,并且无需获得root权限。

  2. 屏幕录制协议:Scrcpy设计了一个简单的UDP(User Datagram Protocol)服务器,在手机上运行,这个服务器只处理来自客户端(如电脑上的scrcpy软件)的数据请求,而不是系统级别的控制命令。这种方式避免了直接修改系统的文件系统或设置。
    简单总结scrcpy的原理就是电脑端和手机端建立连接后通过3个socke通道分别传输音频,录频,控制信号去实现手机和电脑的数据共享,录屏和音频都可以通过aidl和系统的服务交互拿到对应的显示屏ID然后创建虚拟屏录制,然后再编码给到客户端(电脑端)解码显示。控制指令通过socket传输到手机端后,通过手机端的服务(shell 通过app_process启动的那个程序) 反射调用Android的事件注入接口实现的。下面是scrcpy的源码中关于事件注入的部分。
    在这里插入图片描述


3.2.3 scrcpy的优缺点分析


优点:Scrcpy的优点是显示的画质好,延迟低(大约3570ms),帧率3060fps,非常流畅,而且代码完全开源并有很详细的文档,并且不需要安装任何apk和root权限。能自定义控制的行为,比如显示音频和视频,只播放音频,只显示视频,只投屏(不接受电脑端的控制,类似于投屏中的镜像)
缺点:需要用户打开开发者模式中的USB调试模式,否则很多的操作都无法进行了。这点会导致产品无法用于正式的生产环境中,因为用户一般都不会打开开发者选项中的USB调试模式。如果通过修改源码的方式,则无法实现事件注入的功能,因为事件注入需要依赖adb shell。


3.3 Google cast


3.3.1 Google cast 介绍


Google Cast类似于DLNA,AirPlayer,Miracast,就是一种投屏技术。Google Cast的作用在于把小屏幕(诸如手机、平板、笔记本)的内容通过无线(WIFI)方式发送到大屏设备(google TV、chromeCast)进行播放。Google Cast所做的便在于基于不同的平台提供提供为应用开支这种功能的SDK,这些平台即有发送端的也有接收端的,发送端的有IOS、android、chrome浏览器,接收端的有google TV, chromeCast等,可以说这一套解决方案是比较大而全的(就其涵盖的平台)。


3.3.2 Google cast 的实现原理


发送端 app(sender app)使用 SDK,将需要播放的媒体的信息发送到 Google 的服务器,服务器再通知接收端播放(所以发送端和接收端必须都可以访问 Google 的服务器才行)。接收端运行的是一个浏览器,它会根据发送端的app ID和媒体信息,去载入对应的一个网页,这个网页(receiver app)也是由发送端 app 的开发者提供的,的将会负责播放相应的媒体内容。即使接收端是 Chromecast Audio 之类只能播放音频的硬件,这个网页也是会载入并渲染的。Google Cast 和 DLNA 或者苹果的 AirPlay 不同之处,一是依赖 Google 的服务器,也就是说必须连接到 Internet 才可以用,如果只有一个局域网是不行的。二是前两个的接收端播放器接收端本身提供的,开发者只需要提供要播放的内容就可以,但是 Google Cast 则是需要提供自己的receiver app,这样的好处是开发者可以高度定制(比如可以定制UI,或者加入弹幕、歌词滚动、音乐可视化之类复杂功能),虽然接收端往往运行的并不是Android这样的开放操作系统,但是因为receiver app的本质是网页,所以开发难度并不高。


3.3.3 优缺点分析


优点:就是高度可定制,有官方成熟的SDK可接入,从宣传视频中看到手机可以投屏到大屏后,然后就可以随意操作其他应用而不会影响到大屏的显示内容了。
缺点:平台依赖性强,必须可以访问Google服务器,而由于国情的原因,必须可访问Google服务器这个缺点就可以宣告这个方案不合适了


总结


本文主要介绍了各种Android手机投屏的实现方式以及优缺点,手机投屏经常会涉及到投屏端和接收端端相互操作以及音频的播放。所以在建立了投屏需要建立好几个连接通道,分别传输音频、控制指令和录屏的视频流。scrcpy就是这样实现的,如果我们能获取到权限,目前决定scrcpy是最好的投屏实现方式。由于没有权限,现在的大多数控制都是通过Android手机的无障碍模式实现的。这就是我对手机投屏的一些调研总结,希望能帮到有需要的读者


作者:职场007
来源:juejin.cn/post/7419297143787716618
收起阅读 »

Android热修

大家好,我是瑞英。 本篇会描述一个完整的安卓热修复工具的设计与实现。该热修工具基于instantRun原理,并且参照美团开源的robust框架,能够有效进行代码热修、so热修以及资源热修 热修复:无需通过发版,修复线上客户端问题的一种技术方案,特点是快速止...
继续阅读 »

大家好,我是瑞英。



本篇会描述一个完整的安卓热修复工具的设计与实现。该热修工具基于instantRun原理,并且参照美团开源的robust框架,能够有效进行代码热修、so热修以及资源热修



热修复:无需通过发版,修复线上客户端问题的一种技术方案,特点是快速止损。



本文描述中:base包就是宿主包【需要被修复的包】,patch包是下发到base包的修复包


为何要热修?


客户端线上出现问题,传统的解决方案就是发一个新的客户端版本,让用户主动触发升级应用,覆盖速度十分有限。问题修复时间越长,损失就会越大。需要一种可以快速修复线上客户端问题的技术-称之为热修复。
热修复能够做到用户无感知,快速修复线上问题


image.png


热修方案概述


原理上看,目前安卓热修主要分三种:基于类加载、基于底层替换、侵入方法插桩的。


主流热修产品


厂商产品修复范围修复时机稳定性接入成本技术方案
腾讯tinker类、资源、so冷启一般合成差量热修dex并冷启加载
阿里sophix类、资源、so冷启动、即时修复都支持(可选)高(商用)综合方案(底层替换方案&类加载方案)
美团robust方法修复及时修复下文详细介绍

代码修复方案


底层替换方案


直接在native层,将被修复类对应的artMethod进行替换,即可完成方法修复。


每一个java方法在art中都对应着一个ArtMethod,记录了这个java方法的所有信息:所属类、访问权限、代码执行地址等


特性:



  1. 无法实现对原有类方法和字段的增减(只支持方法替换)

  2. 修复了的非静态方法,无法被正常发射调用(因为反射调用的时候会verifyObjectIsClass)

  3. 实效性好,可立即加载生效无需重启应用

  4. 需要针对dalvik虚拟机和art虚拟机做适配,需要考虑指令集的兼容问题

  5. 无法解决匿名内部类增减的情况

  6. 不支持 <clinit>方法热修


类加载方案


合成修复后全量dex,冷启重新加载类,完成修复


特性:



  1. 需要冷启生效

  2. 高兼容性,几乎可以修复任何代码修复的场景


so修复方案


通过反射将指定热修so路径插入到nativeLibraryDirectories


base构建时保留所有so的md5值,patch包构建时,会进行校验,识别出发生变动的热修so,并将其打入patch中


资源修复方案


资源热修包的构建:


base构建时会保留base包的资源id,以及所有资源md5值,patch构建时,利用base id实现资源id固定,同时将新增资源打入patch中,使用新增资源的方法被自动标注为修复方法


资源热修包的加载:
通过反射调用AssetManager.addAssetPath,添加热修资源路径,在activity loadResources时,触发load热修资源


代码修复方案详解


在base包构建时,对需要被热修的方法进行插桩,保留相关base包构建信息【方法、类、属性以及其混淆信息】,在热修包构建时,依赖注解识别出被热修的方法,并结合base包相关信息,最终构建出热修包。


image.png


实现修复的原理


在base包构建时,对于方法都插入一个条件分支,执行热修代理调用。如果热修代理方法返回结果为true,则当前方法直接返回热修result,即该方法被成功热修【如下图所示】。当然这种侵入base包构建的热修方案,会导致包体积有所增加。


image.png


详解base包插桩指令


根据方法的参数和返回值特性,进行不同proxy方法的插入



  • 根据返回值分类:


    无返回值,则proxy方法直接返回boolean即可,如此被插桩方法中不需要出现proxyResult.isSupport的判断


    有返回值:需要返回ProxyResult


  • 根据参数个数进行分类,使得在插桩时,插桩方法的参数尽可能的少且简单,即插入指令尽可能的少。(目前对5个及以下的参数个数进行分类)


    只有5个以上的参数方法被插桩时,需要采用Object[]数组传递所有的参数。因为构建数组并且初始化数组元素,所需要的指令较多。


    例如:若方法只有一个参数,那么直接传递object对象只需要1条指令,如果通过Object[]传递该对象需要6条指令


    //有一个参数str:String,存放与局部变量表中 index = 1
    //直接传递该object对象
    mv.visitMethodInsn(ALOAD, 1)

    //利用object数组进行传递
    mv.visitInsn(1)//数组大小
    mv.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/Object")
    mv.visitInsn(Opcodes.DUP)// 创建数组object[]
    mv.visitInsn(Opcodes.ICONST_0)// 下标索引
    mv.visitVarInsn(Opcodes.ALOAD, 1) //获取局部变量表中该object对象
    mv.visitInsn(Opcodes.AASTORE) //存入数组中


  • 插入的热修代理方法示例



@JvmStatic
fun proxyVoid4Para(
param1: Any?,
param2: Any?,
param3: Any?,
param4: Any?,
obj: Any?,
cls: Class<*>,
methodNumber: Int
)
: Boolean {
return proxy(arrayOf(param1, param2, param3, param4), obj, cls, methodNumber).isSupported
}

@JvmStatic
fun proxy4Para(param1: Any?, param2: Any?, param3: Any?, param4: Any?, obj: Any?, cls: Class<*>, methodNumber: Int): PatchProxyResult {
return proxy(arrayOf(param1, param2, param3, param4), obj, cls, methodNumber)
}


  • proxy方法传递的参数详解



    • 当前方法的参数

    • 当前类(用于查找当前类是否有热修对象)

    • 当前类对象(如果是静态方法则传null,用于对当前类非静态属性的访问)

    • 方法编号(用于匹配热修方法)




详解patch包插桩


每一个被修复的类(PatchTestAct)必然会插桩生成两个类:



  • Patch类(PatchTestActPatch),这个类中有修复方法

  • 一个控制类(实现ChangeQuickRedirect接口,PatchTestActPatchControl),分发执行Patch类中的修复方法


从上述PatchProxy.proxy方法中可以看出。所有被热修的类,会被存在一个重定向map中。执行proxy方法时,若表中有该被插桩类,则对应执行该插桩类的热修对象(ChangeQUickRedirect实现类对象),执行该对象的


accessDispatch方法。每个方法在base构建时都会有一个编号。热修对象通过传入的方法编号,确定最终执行的热修方法。


public interface ChangeQuickRedirect {

/**
* 将方法的执行分发到对应的修复方法
* @param methodName 被插桩的方法编号
* @param paramArrayOfObject 参数值列表
* @param obj 被插桩类对象
* @return
*/

Object accessDispatch(String methodNumber, Object[] paramArrayOfObject, Object obj);

/**
* 判断方法是否能被分发到对应的修复方法
*/

boolean isSupport(String methodNumber);

/** * 判断方法是否能被分发到对应的修复方法 */ boolean isSupport(String methodNumber);
}

如上述例子中,要热修该PatchTestAct2.test方法,对该方法加上@Modify注解后,进行热修patch构建后生成的PatchControl类和Patch类分别是:


public class PatchTestActPatchControl implements ChangeQuickRedirect {
public static final String MATCH_ALL_PARAMETER = "(\\w*\\.)*\\w*";
private static final Map<Object, Object> keyToValueRelation = new WeakHashMap();

public PatchTestActPatchControl() {
}

public Object accessDispatch(String methodNumber, Object[] paramArrayOfObject, Object var3) {
try {
PatchTestActPatch var4 = null;
if (var3 != null) {
if (keyToValueRelation.get(var3) == null) {
var4 = new PatchTestActPatch(var3);
keyToValueRelation.put(var3, (Object)null);
} else {
var4 = (PatchTestActPatch)keyToValueRelation.get(var3);
}
} else {
var4 = new PatchTestActPatch((Object)null);
}
if ("119".equals(methodNumber)){var4.invokeAddMethod((Context)paramArrayOfObject[0]);
}

if ("120".equals(methodNumber)) {
var4.test((String)paramArrayOfObject[0], (Function1)paramArrayOfObject[1]);
}

} catch (Throwable var7) {
var7.printStackTrace();
}

return null;
}

public boolean isSupport(String methodName) {
return ":119::120:".contains(":" + methodName + ":");
}

private static Object fixObj(Object booleanObj) {
if (booleanObj instanceof Byte) {
byte byteValue = (Byte)booleanObj;
boolean booleanValue = byteValue != 0;
return new Boolean(booleanValue);
} else {
return booleanObj;
}
}
// 看起来好像没有用到这个方法
public Object getRealParameter(Object var1) {
return var1 instanceof PatchTestAct ? new PatchTestActPatch(var1) : var1;
}
}

public class PatchTestActPatch {
PatchTestAct originClass;

/**
* 传入原始对象
*/

public PatchTestActPatch(Object var1) {
this.originClass = (PatchTestAct)var1;
}
/**
* 将所访问的变量做一个转换,如果访问的是当前类this,则需要转换为this.originClass对象
*/

public Object[] getRealParameter(Object[] var1) {
if (var1 != null && var1.length >= 1) {
Object[] var2 = (Object[])Array.newInstance(var1.getClass().getComponentType(), var1.length);

for(int var3 = 0; var3 < var1.length; ++var3) {
if (var1[var3] instanceof Object[]) {
var2[var3] = this.getRealParameter((Object[])var1[var3]);
} else if (var1[var3] == this) {
var2[var3] = this.originClass;
} else {
var2[var3] = var1[var3];
}
}
return var2;
} else {
return var1;
}
}

/**
* 被修复的方法
*/

public final void test(String str, Function1<? super String, Unit> a) {
String var3 = "str";
Object[] var5 = this.getRealParameter(new Object[]{str, var3});
Class[] var6 = new Class[]{Object.class, String.class};
EnhancedRobustUtils.invokeReflectStaticMethod("checkNotNullParameter", Intrinsics.class, var5, var6);
String var7 = "a";
Object[] var9 = this.getRealParameter(new Object[]{a, var7});
Class[] var10 = new Class[]{Object.class, String.class};
EnhancedRobustUtils.invokeReflectStaticMethod("checkNotNullParameter", Intrinsics.class, var9, var10);
Object[] var12 = this.getRealParameter(new Object[]{str});
Class[] var13 = new Class[]{Object.class};
Object var14;
if (a == this && 0 == 0) {
var14 = ((PatchTestActPatch)a).originClass;
} else {
var14 = a;
}

Object var10000 = (Object)EnhancedRobustUtils.invokeReflectMethod("invoke", var14, var12, var13, Function1.class);
}
}

每一个新增方法(在base包中不存在的方法):


对这个新增方法所在类打一个InlinePatch.class类,该类中定义这个新增方法


热修代码的处理过程


从字节码到patch.dex中


image.png


代码修复中解决的关键问题


本方案支持,方法修复、新增方法、新增类、新增属性、新增override方法。主要解决了以下问题:



  • 修复方法中对其他类属性、方法的调用

  • 修复代码中,存在调用base包中被删除的方法的指令

  • 修复代码中存在匿名内部类的生成和使用、when表达式与enum联用

  • 修复方法中存在调用父类方法的指令

  • 修复代码中存在invokeDynamic指令(单接口lambda表达式/函数式接口、高阶函数等)

  • 新增方法是override方法,并且使用其多态属性

  • 修复构造方法、新增构造方法

  • 修复方法有@JvmStatic注解,@JvmOverloads注解,这些注解方法被java 和kotlin调用不同而编译出不同的字节码

  • r8内联、外联、类合并等系列优化操作,使得编译结果与原始字节码有很大的差异


总结


本文所描述的代码修复方案,相对于美团原始方案做了较大优化,base插桩对插入指令做了精简,且不再对每个类插入属性用于判断当前类是否被热修,而是将被修复类的信息存在一个静态map中。patch插桩完全重新处理,大大拓展了可修复的范围,提高了热修工具可用性。后续也扩展支持了,通过字节码对比自动识别需要修复的代码,无需开发者手动标注。


除上文所述之外,热修也有一些其他方面值得讨论,热修sop、热修包的构建速度提升,以及热修包的下发和加载等。


参考:
github.com/Meituan-Dia…


作者:瑞英
来源:juejin.cn/post/7426988056635015206
收起阅读 »

协程:解锁 Android 开发的超级英雄技能!

开发 Android 应用时,是否有过这样的时刻? "我只是想请求个网络数据,为什么我的主线程就卡住了!" "多线程真香,但这锁和回调让我头都大了!" 别担心! 今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优...
继续阅读 »

开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!


🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。


🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"


🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。


🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!


以下是一篇轻松有趣、但同时技术性强的文章,主题是 "协程:解锁 Android 开发的超级英雄技能!"




协程:解锁 Android 开发的超级英雄技能!


开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!




🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。




🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"




🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。




🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!




🎮 协程的高阶玩法


1️⃣ 并发:一心多用


协程中的并发很简单,像玩双开游戏一样:


suspend fun loadData() = coroutineScope {
val data1 = async { fetchData() }
val data2 = async { fetchData() }
println("数据1: ${data1.await()}, 数据2: ${data2.await()}")
}

只要用了 async,就能并发运行多个任务,效率提升 N 倍!


2️⃣ 结构化并发:协程的守护者


协程不像传统线程那么“放飞自我”。当它的宿主(比如 CoroutineScope)取消了,所有子协程也会跟着取消。这种“组队行动”叫做结构化并发


coroutineScope {
launch { delay(1000); println("任务1完成") }
launch { delay(2000); println("任务2完成") }
println("等待任务完成")
}

coroutineScope 结束时,所有子任务都会自动完成或取消。


💡 协程的隐藏技能:Flow


如果协程是单任务英雄,那 Flow 就是它的“数据流忍术”。用 Flow,你可以优雅地处理连续的数据流,比如加载分页数据、实时更新状态。


fun fetchDataFlow(): Flow<String> = flow {
for (i in 1..5) {
delay(500)
emit("第 $i 条数据")
}
}

CoroutineScope(Dispatchers.Main).launch {
fetchDataFlow().collect { data ->
println(data) // 每次收到数据时打印
}
}

🧩 总结


协程就像一位超级英雄,它能:



  • 解决主线程阻塞的问题。

  • 简化复杂的异步操作。

  • 提供更高效、更安全的并发管理。


而它的乐趣在于:



  • 让开发者从回调地狱中解脱出来。

  • 代码更简洁、更易读,就像写同步代码一样。


如果你还没使用协程,试试吧!它会让你感受到开发 Android 应用的魔法力量!




“用协程开发,就像给代码装上了飞行装置,既轻松又高效!”

愿你的 Android 开发之路充满乐趣与协程的超能力! 😊


作者:DawnG
来源:juejin.cn/post/7444518315559714866
收起阅读 »

协程:解锁 Android 开发的超级英雄技能!

开发 Android 应用时,是否有过这样的时刻? "我只是想请求个网络数据,为什么我的主线程就卡住了!" "多线程真香,但这锁和回调让我头都大了!" 别担心! 今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优...
继续阅读 »

开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!


🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。


🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"


🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。


🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!


以下是一篇轻松有趣、但同时技术性强的文章,主题是 "协程:解锁 Android 开发的超级英雄技能!"




协程:解锁 Android 开发的超级英雄技能!


开发 Android 应用时,是否有过这样的时刻?

"我只是想请求个网络数据,为什么我的主线程就卡住了!"

"多线程真香,但这锁和回调让我头都大了!"


别担心!

今天我们来认识 Android 开发中的超级英雄——协程(Coroutines) ,它能让你以最优雅的方式,化解多线程的痛点,轻松应对复杂的异步操作!




🦸‍♂️ 协程是个啥?


想象一下,你正在玩一款冒险游戏。游戏里的主角不仅可以“战斗”,还能“暂停”战斗,去完成一些重要任务(比如喝口水、回血),然后再无缝回归到战斗中继续打怪。


协程就像这个灵活的冒险主角:



  • 轻量级线程:协程不是普通线程,但它可以暂停和恢复。

  • 灵活暂停和恢复:随时挂起(suspend),随时回来。

  • 并发管理高手:让你在多任务之间游刃有余,再也不用担心线程锁或回调地狱。




🎯 协程的核心武器


协程的核心技能就三个字:

挂起(Suspend)!



挂起函数是协程的魔法技能,虽然它暂停了自己,但不会阻塞线程。



比如这个最简单的例子:


suspend fun fetchData(): String {
delay(1000) // 模拟网络请求,暂停1秒
return "服务器返回的数据"
}

看到没有?协程就像在说:

"我歇一会儿,待会儿继续,不耽误别人干活!"




🤹‍♀️ 协程的奇幻队伍


协程离不开一支强大的“队友团队”,它们是:


1️⃣ GlobalScope


协程的孤狼模式,一旦启动就运行到世界尽头,但稍有不慎可能造成内存泄漏,慎用!


GlobalScope.launch {
println("这是一个孤单的协程")
}

2️⃣ CoroutineScope


协程的队伍领袖,能保证所有子协程的生命周期跟它挂钩,避免资源泄漏。


class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)

override fun onDestroy() {
super.onDestroy()
scope.cancel() // 活动销毁时取消协程
}
}

3️⃣ Dispatchers


调度器,决定协程的运行地点:



  • Main:UI线程,适合更新界面。

  • IO:专注网络请求、文件读写。

  • Default:CPU密集型任务。

  • Unconfined:自由漂流,不常用。




🛠️ 协程实战:网络请求案例


假设你是一位披风加身的 Android 开发英雄,任务是从服务器获取一段数据并显示在界面上:


fun fetchDataAndShow() {
CoroutineScope(Dispatchers.Main).launch {
try {
val data = withContext(Dispatchers.IO) {
fetchData() // 网络请求在 IO 线程中执行
}
textView.text = data // 回到主线程更新 UI
} catch (e: Exception) {
textView.text = "出错啦!${e.message}"
}
}
}

suspend fun fetchData(): String {
delay(2000) // 模拟网络请求
return "这是来自服务器的数据"
}

你看,协程让异步操作简洁优雅,完全不需要复杂的回调!




🎮 协程的高阶玩法


1️⃣ 并发:一心多用


协程中的并发很简单,像玩双开游戏一样:


suspend fun loadData() = coroutineScope {
val data1 = async { fetchData() }
val data2 = async { fetchData() }
println("数据1: ${data1.await()}, 数据2: ${data2.await()}")
}

只要用了 async,就能并发运行多个任务,效率提升 N 倍!


2️⃣ 结构化并发:协程的守护者


协程不像传统线程那么“放飞自我”。当它的宿主(比如 CoroutineScope)取消了,所有子协程也会跟着取消。这种“组队行动”叫做结构化并发


coroutineScope {
launch { delay(1000); println("任务1完成") }
launch { delay(2000); println("任务2完成") }
println("等待任务完成")
}

coroutineScope 结束时,所有子任务都会自动完成或取消。


💡 协程的隐藏技能:Flow


如果协程是单任务英雄,那 Flow 就是它的“数据流忍术”。用 Flow,你可以优雅地处理连续的数据流,比如加载分页数据、实时更新状态。


fun fetchDataFlow(): Flow<String> = flow {
for (i in 1..5) {
delay(500)
emit("第 $i 条数据")
}
}

CoroutineScope(Dispatchers.Main).launch {
fetchDataFlow().collect { data ->
println(data) // 每次收到数据时打印
}
}

🧩 总结


协程就像一位超级英雄,它能:



  • 解决主线程阻塞的问题。

  • 简化复杂的异步操作。

  • 提供更高效、更安全的并发管理。


而它的乐趣在于:



  • 让开发者从回调地狱中解脱出来。

  • 代码更简洁、更易读,就像写同步代码一样。


如果你还没使用协程,试试吧!它会让你感受到开发 Android 应用的魔法力量!




“用协程开发,就像给代码装上了飞行装置,既轻松又高效!”

愿你的 Android 开发之路充满乐趣与协程的超能力! 😊


作者:DawnG
来源:juejin.cn/post/7444518315559714866
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »

耗时三个月,我高仿了一个起点小说阅读器

前言 起因是最近看小说的APP广告越来越多,但不少书源内容也时常出现问题。正好摆烂太久让我很有负罪感,就想着趁着这个契机学点新的东西。公司里用的都是vue技术栈,所以我想着用vue3做个小项目,顺便熟悉一下vue3的语法。从八月开始,断断续续搞了点Demo,直...
继续阅读 »

前言


起因是最近看小说的APP广告越来越多,但不少书源内容也时常出现问题。正好摆烂太久让我很有负罪感,就想着趁着这个契机学点新的东西。公司里用的都是vue技术栈,所以我想着用vue3做个小项目,顺便熟悉一下vue3的语法。从八月开始,断断续续搞了点Demo,直到年底稍微有点空闲,才开始着手把整个项目完善起来。


项目地址


github



gitee



支持平台


平台是否支持
H5
Android
IOS
小程序需要修改renderjs

项目介绍


eReader 是一款基于 uni-app 开发的小说阅读器,功能完善,使用便捷,支持跨平台部署。移动端完全由前端实现,无需后端支持,打包后即为一个独立的APP,极大降低了部署和维护成本。H5端由于跨域问题需要启用一个简单的后端服务器,但移动端打包后完全开箱即用。


技术架构与部署



  • uni-app 的跨平台特性使得该项目在移动端和H5端之间无缝切换,移动端是纯前端实现,不依赖额外的服务器。

  • H5端需要启用后端服务器来解决跨域问题,但移动端完全是前端应用,避免了额外的服务器负担,极大简化了部署和维护流程。

  • 使用技术栈如下



    1. Vue3 + TypeScript:项目基于Vue3和TypeScript实现。

    2. Node + Express:H5端使用Node + Express搭建了一个简单的后台,负责爬取数据。

    3. uni.request:在APP端通过uni.request获取数据,不用启用后端应用。

    4. Cheerio:用Cheerio来解析HTML,提取书籍信息。

    5. uni.setStorage:数据缓存使用了uni.setStorage存储。

    6. 阅读引擎:主要是用 canvas.measureText 来计算文本宽度,通过JS计算宽高分页,支持两端对齐、标点避头等排版优化。

    7. 分页:分页计算用了uni-app的 renderjs 操作Canvas, uni.createCanvasContext 在APP端性能表现不佳,应尽量避免使用。

    8. 海报分享:海报分享功能使用了 limeui-painter




平台功能



  • 丰富的书源:内置多个书源,满足大多数阅读需求,并支持灵活切换。

  • 全面的功能:包括书架管理、小说搜索、阅读器设置(夜间模式、字体、背景主题、翻页方式)、章节缓存等,功能齐全。

  • 个性化体验:支持书签、目录跳转、缓存、夜间模式等用户自定义设置。

  • 逻辑闭环:书源管理、阅读设置、书签等功能平滑切换,确保使用流畅、体验一致。

  • 详细功能列表



    1. 书架:可以加入/移除书架、置顶小说、分享(APP端)、查看详情、搜索、小说排序和浏览历史等功能。

    2. 分组:可以管理小说分组,支持新增、删除、修改、置顶等操作。

    3. 精选推荐:集成了 夸克热搜 的书单推荐,帮助大家发现热门书籍。

    4. 我的:包括书源管理、浏览历史、夜间模式、关于、意见反馈、缓存清除和分享等设置。

    5. 小说搜索:内置了 12 个书源,基本能满足大部分人的阅读需求。

    6. 书籍详情:展示书籍信息、简介、目录等,支持分享功能。

    7. 阅读器:支持添加/移除书架、添加/删除书签、查看目录、白天/夜间模式切换、翻页方式、字号和背景主题切换等多项个性化设置。此外,还支持其余书源切换章节缓存(包括缓存全部、缓存后20章和缓存当前章节后的所有章节)。

    8. 目录:支持目录查看、缓存状态、书签、章节跳转、快速跳转(比如去当前章节、去底部)等功能。




项目结构


|-- undefined
|-- .prettierignore
|-- .prettierrc.js
|-- index.html
|-- package.json
|-- tsconfig.json
|-- vite.config.ts
|-- src
|-- App.vue
|-- env.d.ts
|-- main.ts
|-- manifest.json
|-- pages.json
|-- type.d.ts
|-- uni.scss
|-- api #请求接口
| |-- common.ts
|-- components
| |-- BookTip.vue #阅读页第一次打开提示
| |-- Expand.vue #书籍详情简介收起与展开
| |-- share.vue #分享组件
| |-- TabBar.vue #重写tabbar,没使用uni自带tabbar
| |-- global #全局组件
| | |-- g-confirm.vue #确认和输入弹窗
| | |-- g-icon-fonts.vue #图标
| | |-- g-page.vue #每个页面根元素,主要是做主题切换,设置全局css样式(uniapp的APP.vue没有根元素)
| | |-- g-popup.vue #底部和中间弹窗封装
| | |-- g-statusbar.vue #顶部statusbar占位组件,h5端高度为0,app端有默认高度
| |-- painter #海报绘制组件
| |-- popover #书架排序气泡窗
|-- directives #vLongPress指令封装
| |-- index.ts
|-- pages
| |-- blank #我的-跳转页面
| | |-- about.vue #关于我们
| | |-- agreement.vue #用户协议
| | |-- feedback.vue #意见反馈
| | |-- history.vue #浏览历史
| | |-- origin.vue #书源管理
| | |-- policy.vue #隐私政策
| |-- bookDetail #书籍详情页
| |-- catalogs #目录页
| |-- groupDetail #分组详情页
| |-- reader #阅读器
| | |-- index.vue
| | |-- index_v1.vue #第一版,使用columns布局分页
| | |-- index_v2.vue #第二版,使用canvas.measureText计算宽度,js计算宽高进行分页(算法不完善,可以看看思路)
| | |-- readerLayout.ts #第三版,感谢 [@前端一锅煮] 大佬的分享
| | |-- components
| | |-- Origin.vue #换源组件
| | |-- Renderjs.vue #使用uniapp的rendejs获取 document 文档对象
| | |-- Renderjs_v2.vue #第二版renderjs
| |-- search #搜索页
| |-- tabBar #自定义tabbar
| |-- book.vue #精选
| |-- home.vue #书架
| |-- personal.vue #我的
| |-- components
| |-- addGr0up.vue #书架、分组详情里[移至分组]功能
| |-- bookDetail.vue #书架、分组详情里长按展示详情功能
| |-- groupItem.vue #分组项
|-- parser #app端数据解析
| |-- catalog.ts #目录解析
| |-- content.ts #章节内容解析
| |-- index.ts
| |-- search.ts #搜索内容解析
| |-- source.ts #内置书源
| |-- top.ts #精选内容解析
|-- static
|-- store #store
| |-- AppOption.ts #app的系统信息
| |-- index.ts #一些缓存相关数据处理:书架、历史、缓存章节、搜索历史等
|-- styles
|-- types
|-- utils
|-- Config.ts
|-- Control.ts
|-- index.ts
|-- request.ts #请求处理和响应拦截
|-- RequestHeader.ts #最初是想伪造请求头的,但是uni的app端ua固定了

后续功能优化



  • 错误处理:当前未处理极端情况下的错误请求,导致产品在特定条件下可能不够健壮,后续会加强异常处理。

  • 网络字体支持:项目打包后APK约15MB,内置字体包增大了文件体积,后续会考虑支持网络字体加载以实现更丰富的阅读体验。

  • 书源导入与更新:第三方书源存在不稳定性,网站变动可能导致解析错误。后续会考虑支持书源离线导入和在线更新,有助于解决此问题。

  • 听书功能:作为干眼症患者,听书功能对我来说还是非常重要的,未来计划加入该功能。

  • 去除广告:第三方书源可能包含广告和无关链接,影响阅读体验。后续考虑支持长按选择内容去除,并应用到所有章节,将极大提升阅读质量。


项目展示


h5表现



  • 书架
    PixPin_2025-01-15_11-11-06.gif

  • 精选


PixPin_2025-01-15_11-33-27.gif



  • 我的
    PixPin_2025-01-15_11-23-31.gif

  • 搜索


PixPin_2025-01-15_11-35-22.gif



  • 详情


PixPin_2025-01-15_13-47-58.gif



  • 阅读器


PixPin_2025-01-15_13-51-20.gif


app端表现(IOS)



Android端未完整测试,可能存在部分兼容问题




  • 书架(亮)
    书架.jpg

  • 搜索(亮)
    081215e1ebc858e4a3e2bb6b25e7591.jpg

  • 书源管理(亮)
    97a215463434014bfca7a9e306758bb.jpg

  • 我的(亮)
    c24b6f22642b73a89fb29c61c486eda.jpg

  • 浏览历史(亮)


5378049e7f666f069a3c7893964859f.jpg



  • 分组(暗)


647cdc2d9ef238c763c0481ac3f4dd6.jpg



  • 分组详情(暗)
    605e704b77169fcfd8f6d7c75b06974.jpg

  • 我的(暗)
    0d43113e996a69f52d58813504784b5.jpg

  • 意见反馈(暗)
    ea5984628d3013dab64992e49b72e0c.jpg

  • 详情(暗)
    8c2feac4fb0a99b3be6ed359c91cdad.jpg

  • 分享


325a55ea810dde156bf03c8e9acfd1f.jpg



  • 阅读器


ios.gif


总结



  • 最初只是为了学习新技术栈,项目框架、组件设计没考虑太多。但随着功能的增加,组件复用和方法抽象的需求变得明显,过程中也渐渐感觉到有些力不从心。

  • 尽管仍有一些缺漏,但是整体来看来这个项目已经勉强算得上是一个完整的、功能闭环的产品。作为一个人独立完成,自己也算是比较满意了。

  • 开发过程中遇到了不少挑战,比如阅读器排版引擎就经历了三次重构,才最终达到了理想效果。那段时间搞得头都要秃了(本来所剩无几的发量越加稀少)。 后续会写写教程,记录下开发过程中遇到的坑。


相关


水了几篇文章,回家过年咯(逃~)



参考


感谢下面两位大佬的文章



作者:何日
来源:juejin.cn/post/7460023342592901183
收起阅读 »

一个Kotlin版Demo带你入门JNI,NDK编程

Android 越往深处研究,必然离不开NDK,和JNI相关知识 一、前言 Android开发中,最重要的一项技能便是NDK开发,它涉及到JNI,C,C++等相关知识 我们常见的MMKV,音视频库FFmpeg等库的应用,都有相关这方面的知识,它是Androi...
继续阅读 »

u=2801888995,840623646&fm=253&fmt=auto&app=138&f=PNG.webp



Android 越往深处研究,必然离不开NDK,和JNI相关知识



一、前言


Android开发中,最重要的一项技能便是NDK开发,它涉及到JNI,C,C++等相关知识

我们常见的MMKV,音视频库FFmpeg等库的应用,都有相关这方面的知识,它是Android开发人员通往深水区的一张门票。


本文我们就简单介绍JNI,NDK的相关入门知识:

1. JNI方法注册(静态注册,动态注册)

2. JNI的基础数据类型

3. JNI引用数据类型

4. JNI函数签名信息

5. JNIEnv的介绍

6. JNI编译之Cmake




7. 示例:获取JNI返回字符串(静态注册)

8. 示例:调用JNI,JNI调用Java层无返回值方法(静态注册)

9. 示例:调用JNI,JNI调用Java层无返回值方法(带参数)(静态注册)

10. 示例:调用JNI去调用java方法(静态注册)

11. 示例:调用JNI去调用java 变量值(静态注册)

12. 示例:去调用JNI去调用Java带callback方法,带参数(静态注册)

13. 示例:动态注册


二、基础介绍


1. JNI是什么? JNI(Java Native Interface),它是提供一种Java字节码调用C/C++的解决方案,JNI描述的是一种技术。


a63332d245e450bd38b7b571c4988397_webp.webp

2. NDK是什么? NDK(Native Development Kit)

Android NDK 是一组允许您将 C 或 C++(“原生代码”)嵌入到 Android 应用中的工具,NDK描述的是工具集。 能够在 Android 应用中使用原生代码对于想执行以下一项或多项操作的开发者特别有用:



  • 在平台之间移植其应用。

  • 重复使用现有库,或者提供其自己的库供重复使用。

  • 在某些情况下提高性能,特别是像游戏这种计算密集型应用。


3. JNI方法静态注册:

JNI函数名格式(需将”.”改为”—”):

Java_ + 包名(com.example.auto.jnitest)+ 类名(MainActivity) + 函数名(stringFromJNI)


静态方法的缺点:



  • 要求JNI函数的名字必须遵循JNI规范的命名格式;

  • 名字冗长,容易出错;

  • 初次调用会根据函数名去搜索JNI中对应的函数,会影响执行效率;

  • 需要编译所有声明了native函数的Java类,每个所生成的class文件都要用javah工具生成一个头文件;


4. JNI方法动态注册:

Java与JNI通过JNINativeMethod的结构来建立函数映射表,它在jni.h头文件中定义,其结构内容如下:


typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;

创建映射表后,调用RegisterNatives函数将映射表注册给JVM;

当Java层通过System.loadLibrary加载JNI库时,会在库中查JNI_OnLoad函数。可将JNI_OnLoad视为JNI库的入口函数,需要在这里完成所有函数映射和动态注册工作,及其他一些初始化工作。


5. JNI的基础数据类型对照表:


Java类型JNI类型描述
boolean(布尔型)jboolean无符号8位
byte(字节型)jbyte有符号8位
char(字符型)jchar无符号16位
short(短整型)jshort有符号16位
int(整型)jint有符号32位
long(长整型)jlong有符号64位
foat(浮点型)jfloat32位
double(双精度浮点型)jdouble64位

6. JNI引用数据类型对照表:


Java引用类型JNI类型Java引用类型JNI类型
All objectsjobjectchar[ ]jcharArray
java.lang.Classjclassshort[ ]jshortArray
java.lang.Stringjstringint[]jintArray
java.lang.Throwablejthrowablelong[ ]jlongArray
Object[ ]jobjectArrayfloat[]jfloatArray
boolean[ ]jbooleanArraydouble[ ]jdoubleArray
byte[ ]jbyteArray

7. JNI函数签名信息

由于Java支持函数重载,因此仅仅根据函数名是没法找到对应的JNI函数。为了解决这个问题,JNI将参数类型和返回值类型作为函数的签名信息。


JNI规范定义的函数签名信息格式:  (参数1类型字符…)返回值类型字符


函数签名例子:


308c38d80edf5aac76fbfdd0f8d07548_webp.webp


JNI常用的数据类型及对应字符对照表:


Java类型字符
voidV
booleanZ (容易误写成B)
intI
longJ (容易误写成L)
doubleD
floatF
byteB
charC
shortS
int[ ][I (数组以"["开始)
StringLjava/lang/String; (引用类型格式为”L包名类名;”,要记得加";")
Object[][Ljava/lang/object;

8. JNIEnv的介绍



  1. JNIEnv概念 : JNIEnv是一个线程相关的结构体, 该结构体代表了 Java 在本线程的运行环境。通过JNIEnv可以调用到一系列JNI系统函数。

  2. JNIEnv线程相关性: 每个线程中都有一个 JNIEnv 指针。JNIEnv只在其所在线程有效, 它不能在线程之间进行传递。



注意:在C++创建的子线程中获取JNIEnv,要通过调用JavaVM的AttachCurrentThread函数获得。在子线程退出时,要调用JavaVM的DetachCurrentThread函数来释放对应的资源,否则会出错。



9. JNI编译之Cmake



CMake 则是一个跨平台的编译工具,它并不会直接编译出对象,而是根据自定义的语言规则(CMakeLists.txt)生成 对应 makefile 或 project 文件,然后再调用底层的编译, 在Android Studio 2.2 之后支持Cmake编译。




  • add_library 指令

    语法:add_library(libname [SHARED | STATIC | MODULE] [EXCLUDE_FROM_ALL] [source])

    将一组源文件 source 编译出一个库文件,并保存为 libname.so (lib 前缀是生成文件时 CMake自动添加上去的)。其中有三种库文件类型,不写的话,默认为 STATIC;



    • SHARED: 表示动态库,可以在(Java)代码中使用 System.loadLibrary(name) 动态调用;

    • STATIC: 表示静态库,集成到代码中会在编译时调用;

    • MODULE: 只有在使用 dyId 的系统有效,如果不支持 dyId,则被当作 SHARED 对待;

    • EXCLUDE_FROM_ALL: 表示这个库不被默认构建,除非其他组件依赖或手工构建;




#将compress.c 编译成 libcompress.so 的共享库
add_library(compress SHARED compress.c)


  • target_link_libraries 指令 语法:target_link_libraries(target library <debug | optimized> library2…)  这个指令可以用来为 target 添加需要的链接的共享库,同样也可以用于为自己编写的共享库添加共享库链接。如:


#指定 compress 工程需要用到 libjpeg 库和 log 库
target_link_libraries(compress libjpeg ${log-lib})


  • find_library 指令 语法:find_library( name1 path1 path2 ...)  VAR 变量表示找到的库全路径,包含库文件名 。例如:


find_library(libX  X11 /usr/lib)
find_library(log-lib log) #路径为空,应该是查找系统环境变量路径

示例工程Cmake截图如下:


ca8c9dfcd9a9697885245bb1e54410a.png


三、示例工程代码


示例工程截图:


fc733d67be28090d46db73b44785f42.png


示例MainActivity内需要加载SO:


companion object {
// Used to load the 'native_kt_demo' library on application startup.
init {
System.loadLibrary("native_kt_demo")
}
}

1. 示例:获取JNI返回字符串(静态注册)


Kotlin 代码


external fun stringFromJNI(): String

JNI层下代码


//extern "C" 避免编绎器按照C++的方式去编绎C函数
extern "C"
//JNIEXPORT :用来表示该函数是否可导出(即:方法的可见性
//1、宏 JNIEXPORT 代表的就是右侧的表达式: __attribute__ ((visibility ("default")))
//2、或者也可以说: JNIEXPORT 是右侧表达式的别名;
//3、宏可表达的内容很多,如:一个具体的数值、一个规则、一段逻辑代码等
JNIEXPORT
//jstring 代表方法返回类型为Java中的 String
jstring
//用来表示函数的调用规范(如:__stdcall)
JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}

2. 示例:调用JNI,JNI调用Java层无返回值方法(静态注册)

Kotlin代码:


external fun callJNI()

JNI层代码:


extern "C" JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callJNI(JNIEnv *env, jobject thiz) {
LOGE("-----静态注册 , 无返回值方法 调用成功-----");
jclass js = env->GetObjectClass(thiz);
jmethodID jmethodId = env->GetMethodID(js, "toast", "(Ljava/lang/String;)V");
env->CallVoidMethod(thiz, jmethodId, env->NewStringUTF("静态注册 无返回值方法 调用成功"));
}

3. 示例:调用JNI,JNI调用Java层无返回值方法(带参数)(静态注册)

Kotlin代码:


external fun stringFromJNIwithParameter(str: String): String

JNI层代码:


extern "C" JNIEXPORT jstring JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_stringFromJNIwithParameter(JNIEnv *env, jobject thiz, jstring str)
{
const char *data = env->GetStringUTFChars(str, NULL);
LOGE("-----获取到Java 传来的数据:data %s-----", data);
env->ReleaseStringChars(str, reinterpret_cast<const jchar *>(data));
const char *src = "111---";
const int size = sizeof(data) + sizeof(src);
char datares[size] = "111---";
return env->NewStringUTF(strcat(datares, data));
}

4. 示例:调用JNI去调用java方法(静态注册)

Kotlin代码:


external fun callNativeCallJavaMethod()

JNI层代码:


extern "C"
JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callNativeCallJavaMethod(JNIEnv *env, jobject thiz) {
jclass js = env->GetObjectClass(thiz);
jmethodID jmethodId = env->GetMethodID(js, "toast", "(Ljava/lang/String;)V");
env->CallVoidMethod(thiz, jmethodId, env->NewStringUTF("jni 通过反射调用 java toast方法"));
}

5. 示例:调用JNI去调用java 变量值(静态注册)


Kotlin代码:


external fun callNativeCallJavaField()

JNI层代码:


extern "C"
JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callNativeCallJavaField(JNIEnv *env, jobject thiz) {
jclass js = env->GetObjectClass(thiz);
jfieldID jfieldId = env->GetFieldID(js, "androidData", "Ljava/lang/String;");
jstring newDataValue = env->NewStringUTF("四海一家");
// jclass js = env->GetObjectClass(thiz);
jmethodID jmethodId = env->GetMethodID(js, "toast", "(Ljava/lang/String;)V");
env->CallVoidMethod(thiz, jmethodId, newDataValue);
// env->SetObjectField(thiz, jfieldId, newDataValue);
}

6. 示例:去调用JNI去调用Java带callback方法,带参数(静态注册)

Kotlin代码:


external fun callNativeWithCallBack(callBack: NativeCallBack)

JNI层代码:


extern "C"
JNIEXPORT void JNICALL
Java_com_wx_nativex_kt_demo_MainActivity_callNativeWithCallBack(JNIEnv *env, jobject thiz, jobject call_back) {
LOGE("-----静态注册 , callback 调用成功-----");
jclass js = env->GetObjectClass(call_back);
jmethodID jmethodId = env->GetMethodID(js, "nmd", "(Ljava/lang/String;)V");
env->CallVoidMethod(call_back, jmethodId, env->NewStringUTF("我是Jni Native层callBack回调回来的数据值"));
}

7. 示例:动态注册
Kotlin代码:


external fun dynamicRegisterCallBack(callBack: NativeCallBack)

JNI层代码:



void regist(JNIEnv *env, jobject thiz, jobject call_back) {
LOGD("--动态注册调用成功-->");
jclass js = env->GetObjectClass(call_back);
jmethodID jmethodId = env->GetMethodID(js, "nmd", "(Ljava/lang/String;)V");
env->CallVoidMethod(call_back, jmethodId, env->NewStringUTF("我是Jni Native层动态注册callBack回调回来的数据值"));
}

jint RegisterNatives(JNIEnv *env) {
jclass activityClass = env->FindClass("com/wx/nativex/kt/demo/MainActivity");
if (activityClass == NULL) {
return JNI_ERR;
}
JNINativeMethod methods_MainActivity[] = {
{
"dynamicRegisterCallBack",
"(Lcom/wx/nativex/kt/demo/NativeCallBack;)V",
(void *) regist
}
};

return env->
RegisterNatives(activityClass, methods_MainActivity,
sizeof(methods_MainActivity) / sizeof(methods_MainActivity[0]));
}


//JNI_OnLoad java
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
LOGE("-----JNI_OnLoad 方法调用了-----");
JNIEnv *env = NULL;
if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}

jint result = RegisterNatives(env);
// 函数注册
return JNI_VERSION_1_6;
}

总结


本文简单介绍了NDK编程中JNI的基础:并写了相关示例Demo代码



  1. JNI方法注册(静态注册,动态注册)

  2. JNI的基础数据类型

  3. JNI引用数据类型

  4. JNI函数签名信息

  5. JNIEnv的介绍

  6. JNI编译之Cmake

  7. 示例:获取JNI返回字符串(静态注册)

  8. 示例:调用JNI,JNI调用Java层无返回值方法(静态注册)

  9. 示例:调用JNI,JNI调用Java层无返回值方法(带参数)(静态注册)

  10. 示例:调用JNI去调用java方法(静态注册)

  11. 示例:调用JNI去调用java 变量值(静态注册)

  12. 示例:去调用JNI去调用Java带callback方法,带参数(静态注册)

  13. 示例:动态注册


感谢阅读:


欢迎用你发财的小手: 点点赞、收藏收藏,或者 关注关注


这里你会学到不一样的东西


项目地址


Gitee地址

Github地址


作者:Wgllss
来源:juejin.cn/post/7452181029996380171
收起阅读 »

Android 车载应用开发——「RecyclerView」

前言 实践是最好的学习方式,技术也如此。 一、简介 RecyclerView 是列表; 好处:更高效率的列表控件; 用法:重点 RecycleerView.Adapter 的写法;可以通过 LayoutManager(布局管理器)来决定布局的样式,是线性...
继续阅读 »

前言



实践是最好的学习方式,技术也如此。



一、简介



  • RecyclerView 是列表;

  • 好处:更高效率的列表控件;

  • 用法:重点 RecycleerView.Adapter 的写法;可以通过 LayoutManager(布局管理器)来决定布局的样式,是线性的、网格列表还是瀑布流列表;

  • RecyclerView 列表是如何实现显示的 ?

    • 是将数据放到对应的位置上,根据数据内容的数量来显示(即告诉列表有多少个条目) ;




二、Adapter



  • 是什么



    • 适配器、连接器;



  • 为什么要有 Adapter



    • 列表中不只有一条数据,不像 TextViewImageView 一样,一个控件对应一条数据;

    • 列表形式的数据,如何将多个布局与多个数据连接起来?中间就通过 adapter,将数据放到对应的控件的位置;



  • Adapter 的分类



    • ArrayAdapter:简单列表;

    • SimpleAdapter:图文列表;

    • BaseAdapter:图文复杂列表 ;





三、示例


1、背景



用 RecyclerView 列表显示各个城市天气数据



2、代码



  • FutureWeatherAdapter 是一个自定义的适配器类,它继承自 RecyclerView.Adapter 类;在泛型参数中,指定了一个内部类 WeatherViewHolder 作为适配器的视图持有者



    • WeatherViewHolder 是用于在 RecyclerView 中显示每个天气数据的视图持有者类;

    • 通常情况下,你会在适配器内部定义一个继承自 RecyclerView.ViewHolder 的内部类来表示列表项的视图结构和布局



  • onCreateViewHolder() 方法用于创建 ViewHolder,即创建用于显示单个天气条目的视图,并返回 ViewHolder 对象;使用布局填充器从 XML 布局文件中实例化视图,并将其传递给自定义的 ViewHolder 对象。



    • 在创建新的 ViewHolder 实例时调用。当 RecyclerView 需要显示新的列表项时,会调用该方法来创建一个 ViewHolder 对象 ;

    • onCreateViewHolder() 返回的 ViewHolder 对象会被 RecyclerView 用于显示列表项。当 RecyclerView 需要显示新的列表项时,它会调用 onCreateViewHolder() 方法来创建一个新的 ViewHolder 对象,并将其返回



  • onBindViewHolder() 方法用于将数据绑定到 ViewHolder 上,即将具体的天气数据填充到对应的视图控件中。在这个方法中,获取当前位置的天气数据对象,然后将其属性分别设置到 ViewHolder 中的各个 TextView 和 ImageView 中;



    • 方法在 RecyclerView 需要将数据绑定到 ViewHolder 以显示新的列表项时被调用。当 RecyclerView 中的列表项需要更新或者需要显示新的列表项时,会调用该方法;



  • getItemCount() 方法用于获取数据集中的条目数,即天气数据列表的大小;



    • getItemCount() 方法返回的数据会告诉 RecyclerView 有多少个列表项需要在屏幕上显示。当 RecyclerView 需要确定列表的大小时,它会调用 getItemCount() 方法



  • 内部类 WeatherViewHolder 继承自 RecyclerView.ViewHolder,用于持有每个天气条目的视图控件的引用;在构造方法中,通过传入的视图参数找到并引用了各个视图控件;



    public class FutureWeatherAdapter extends RecyclerView.Adapter<com.example.weatherapp.adapter.FutureWeatherAdapter.WeatherViewHolder> {
    private Context mContext; // 上下文
    private List<DayWeatherBean> mWeatherBeans; // 数据

    public FutureWeatherAdapter(Context mContext, List<DayWeatherBean> mWeatherBeans) {
    this.mContext = mContext;
    this.mWeatherBeans = mWeatherBeans;
    }

    // 先创建ViewHolder再将数据绑定
    @NonNull
    @Override
    public WeatherViewHolder onCreateViewHolder(@NonNull ViewGr0up parent, int viewType) {
    // onCreateViewHolder()方法负责创建ViewHolder并将其返回给RecyclerView
    View view = LayoutInflater.from(mContext).inflate(R.layout.weather_item_layout, parent, false); // 布局
    WeatherViewHolder weatherViewHolder = new WeatherViewHolder(view);
    return weatherViewHolder;
    }


    @Override
    public void onBindViewHolder(@NonNull WeatherViewHolder holder, int position) {
    // onBindViewHolder()方法负责将数据绑定到ViewHolder
    // holder: 表示要绑定的ViewHolder对象,position: 表示ViewHolder在RecyclerView中的位置
    // onBindViewHolder()方法负责将数据填充到ViewHolder的视图中
    // 它会被调用多次,每次RecyclerView需要显示一个新的ViewHolder时都会调用
    DayWeatherBean weatherBean = mWeatherBeans.get(position); // 拿到当前位置的JavaBean对象
    holder.tvWeather.setText(weatherBean.getWea());
    holder.tvTem.setText(weatherBean.getTeamDay());
    holder.tvAir.setText(weatherBean.getWin_speed());
    holder.tvWin.setText(weatherBean.getWin());
    holder.tvTemLowHigh.setText(weatherBean.getTeamNight());
    holder.ivWeather.setImageResource(getImgResOfWeather(weatherBean.getWeaImg()));
    }

    // 总共有多少个条目
    @Override
    public int getItemCount() {
    return (mWeatherBeans == null) ? 0 : mWeatherBeans.size();
    }

    class WeatherViewHolder extends RecyclerView.ViewHolder {
    TextView tvWeather, tvTem, tvTemLowHigh, tvWin, tvAir;
    ImageView ivWeather;

    public WeatherViewHolder(@NonNull View itemView) {
    super(itemView);

    tvWeather = itemView.findViewById(R.id.tv_weather);
    tvAir = itemView.findViewById(R.id.air);
    tvTem = itemView.findViewById(R.id.tv_tem);
    tvTemLowHigh = itemView.findViewById(R.id.tv_tem_low_high);
    tvWin = itemView.findViewById(R.id.tv_win);
    ivWeather = itemView.findViewById(R.id.iv_weather);
    }
    }



作者:一个写代码的修车工
来源:juejin.cn/post/7345379878240501771
收起阅读 »

花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路

前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成一、前言本文介绍思路:本文重点介绍思路:四种方式花式解决Repository中模版式的代码,逐级递增1.1 :涉及...
继续阅读 »

e1ff3706ea196f758818da129df6de53.png

前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。

注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成

一、前言

  1. 本文介绍思路:
    本文重点介绍思路:四种方式花式解决Repository中模版式的代码,逐级递增
    1.1 :涉及到Kotlin协程Flow、viewModel、Retrofit、Okhttp相关用法
    1.2 :涉及到注解反射泛型注解处理器相关用法
    1.3 :涉及到动态代理kotlinsuspend方法反射调用及反射中异常处理
    1.4 :本示例4个项目如图:

    380Xt8NSYZ.jpg

  2. 网络框架搭建的封装,到目前为止最为流行又很优雅的的是 Kotlin+协程+Flow+Retrofit+OkHttp+Repository
  3. 先来看看中间各个类的职责: whiteboard_exported_image.png
  4. 从上图可以看出单一职责:

    NetApi: 负责网络接口配置,包括 请求地址,请求头,请求方式,参数等等所有配置

    Flow+Retrofit+Okhttp: 联合起来负责把 NetApi 中的各种配置组装成网络请求行为,并且通过Flow 组装成流,通过它可以控制该行为的异步方式,异步开始结束等等一系列的流行为。

    Repository: 负责 Flow+Retrofit+Okhttp 请求结果的数据流,进行加工处理成我们想要的数据,大多数不需要处理的,可以直接给到 ViewModel

    ViewModel: 负责调用 Repository,拿到想要的数据然后提供给UI方展示使用或者相关使用

    也可以看到 它的 持有链 从右向左 一条线性持有:ViewModel 持有 RepositoryRepository持有 Flow+Retrofit+Okhttp ,Flow+Retrofit+Okhttp 持有 NetApi

  5. 最终我们可以得到:
    5.1. 网络请求行为 会根据 NetApi 写出模板式的代码,这块解决模版式的代码在 Retrofit 中它通过动态代理,把所有模版式的代码统一成了一个
    5.2. 同理:Repository 也是根据 NetApi 配置的接口,写成模版式的代码转换成流

二、花式封装(一)

  1. NetApi 的配置:
interface NetApi {

// 示例get 请求
@GET("https://www.wanandroid.com/article/list/0/json")
suspend fun getHomeList(): CommonResult

// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") a: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") f: Float): CommonResult

// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList2222(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList3333(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList5555(@Path("path") page: Int, @Query("d") ss: String, @HeaderMap map: Map): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList6666(
@Path("path") page: Int,
@Query("d") float: Float,
@Query("d") long: Long,
@Query("d") double: Double,
@Query("d") byte: Byte,
@Query("d") short: Short,
@Query("d") char: Char,
@Query("d") boolean: Boolean,
@Query("d") string: String,
@Body body: RequestBodyWrapper
): CommonResult

//示例post 请求
@FormUrlEncoded
@POST("https://www.wanandroid.com/user/register")
suspend fun register(
@Field("username") username: String,
@Field("password") password: String,
@Field("repassword") repassword: String
): String
/************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/


// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded") //todo 固定 header
@POST("https://xxxxxxx")
suspend fun post1(@Body body: RequestBody): String

// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("https://xxxxxxx22222")
suspend fun post12(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写

suspend fun post1222(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
}

2. NetRepository 中是 根据 NetApi 写出下面类似的全模版式的代码:都是返回 Flow 流

class NetRepository private constructor() {
val service by lazy { RetrofitUtils.instance.create(NetApi::class.java) }

companion object {
val instance by lazy { NetRepository() }
}

// 示例get 请求
fun getHomeList() = flow { emit(service.getHomeList()) }

// 示例get 请求2
fun getHomeList(page: Int) = flow { emit(service.getHomeList(page)) }

fun getHomeList(page: Int, a: Int) = flow { emit(service.getHomeList(page, a)) }

fun getHomeList(page: Int, f: Float) = flow { emit(service.getHomeList(page, f)) }

// 示例get 请求2
fun getHomeList2222(page: Int) = flow { emit(service.getHomeList2222(page)) }

fun getHomeList3333(page: Int) = flow { emit(service.getHomeList3333(page)) }

fun getHomeList5555(page: Int, ss: String, map: Map<String, String>) = flow { emit(service.getHomeList5555(page, ss, map)) }

fun getHomeList6666(
page: Int, float: Float, long: Long, double: Double, byte: Byte,
short: Short, char: Char, boolean: Boolean, string: String, body: RequestBodyWrapper
)
= flow {
emit(service.getHomeList6666(page, float, long, double, byte, short, char, boolean, string, body))
}

fun register(username: String, password: String, repassword: String) = flow { emit(service.register(username, password, repassword)) }

//
// /************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
//
//
fun post1(body: RequestBody) = flow { emit(service.post1(body)) }

fun post12(body: RequestBody, map: Map<String, String>) = flow { emit(service.post12(body, map)) }

fun post1222(id: Long, asr: String) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
emit(service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader))
}
}

3. viewModel 调用端:

class MainViewModel : BaseViewModel() {

private val repository by lazy { NetRepository.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "one 111 ${it.data?.datas!![0].title}")
}
}
}
}

—————————————————我是分割线君—————————————————

  1. 上面花式玩法(一): 此种写法被广泛称作 最优雅的一套网络封装 框架,

    绝大多数中、大厂 基本也就封装到此为止了

    可能还有些人想着:你的 repository 中就返回了 Flow , 里面就全是简单的 emit(xxx) ,我项目里面不是这样的,我的还封装了成功,失败,或者其他的,但总体还是全是模版式的,除了特殊的一些方法,需要在请求前 ,请求后做些处理,有规律有模版的还是占大多数吧,只要大多数都一样的规律模版,都是可以处理的,里面稍微修改下细节,思路都是一样的。

    哪还能有什么玩法?

    可能会有人想到 借助 Hilt ,Dagger2 ,Koin 来创建 Retrofit,和创建 repository,创建 ViewModel 这里不是讨论依赖注入创建对象的事情

    哪还有什么玩法?

    有,必须有的。

三、花式封装(二)

  1. 既然上面是 Repository 类中,所有写法都是固定模版式的代码,那么让其根据 NetApi: 自动生成 Repository 类,我们这里借用注解处理器。
  2. 具体怎么使用介绍,请参考:
    注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成
  3. 本项目中只需要编译 app_wx2 工程
  4. 在下图中找到

img_v3_02f0_d5bd4278-53ac-4008-aac2-abcfdf81668g.jpg 5. viewModel调用端

class MainViewModel : BaseViewModel() {

private val repository by lazy { RNetApiRepository() }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "two 222 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

6. 如果 Repository 中某个接口方法需要特殊处理怎么办?比如下图,请求前处理一下,从 拿到数据后我需要再次转化处理之后再给到 viewModel 怎么办?

//我这个接口 ,请求前需要 判断处理一下,拿到数据后也需要再处理一下
fun post333(id: Long, asr: String, m: String, n: String, list: List<String>) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"

//接口调用前 根据 需要处理操作
list.forEach {
if (map.containsKey(id.toString())) {
///
}
}

val result = service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader)
// 拿到数据后需要处理操作
val result1 = result
emit(result1)
}.map {
//需要再转化一下
it
}.filter {
//过滤一下
it.length == 3
}

7. 可以在 接口 NetApi 中该方法上配置 @Filter 注解过滤 ,该方法需要自己特殊处理,不自动生成,如下


@Filter
@POST("https://xxxxxxx22222")
suspend fun post333(@Body body: RequestBody, @HeaderMap map: Map): String
  1. 如果想 post请求的 RequestBody 内部参数单独出来进入方法传参,可以加上 在 NetApi 中方法加上 @PostBody:如下:
@PostBody("{"ID":"Long","name":"String"}")
@POST("https://www.wanandroid.com/user/register")
suspend fun testPostBody222(@Body body: RequestBody): String

这样 该方法生成出来的对应方法就是:

public suspend fun testPostBody222(ID: Long, name: java.lang.String): Flow =
kotlinx.coroutines.flow.flow {
val map = mutableMapOf()
map["ID"] = ID
map["name"] = name
val result = service.testPostBody222(com.wx.test.api.retrofit.RequestBodyCreate.toBody(com.google.gson.Gson().toJson(map)))
emit(result)
}

怎么特殊处理,单独手动建一个Repository,针对该方法,单独写,特殊就要特殊手动处理,但是大多数模版式的代码,都可以让其自动生成。

—————————————————我是分割线君—————————————————

到了这里,我们再想, NetApi 是一个接口类,
但是实际上没有写接口实现类啊, 它怎么实现的呢?
我们上面 花式玩法(二) 中虽然是自动生成的,但是还是有方法体,

可不可以再省略点?

可以,必须有!

四、花式玩法(三)

  1. 我们可以根据 NetApi 里面的配置,自动生成 INetApiRepository 接口类, 接口名和参数 都和 NetApi 保持一致,唯一区别就是返回的对象变成了 Flow 了,
    这样在 Repository 中就把数据转变为 flow 流了
  2. 配置让代码自动生成的类:
@AutoCreateRepositoryInterface(interfaceApi = "com.wx.test.api.net.NetApi")
class KaptInterface {
}

生成的接口类 INetApiRepository 代码如下:


public interface INetApiRepository {
public fun getHomeList(): Flow>

public fun getHomeList(page: Int): Flow>

public fun getHomeList(page: Int, f: Float): Flow>

public fun getHomeList(page: Int, a: Int): Flow>

public fun getHomeList2222(page: Int): Flow>

public fun getHomeList3333(page: Int): Flow>

public fun getHomeList5555(
page: Int,
ss: String,
map: Map<String, String>
)
: Flow>

public fun getHomeList6666(
page: Int,
float: Float,
long: Long,
double: Double,
byte: Byte,
short: Short,
char: Char,
boolean: Boolean,
string: String,
body: RequestBodyWrapper
)
: Flow>

public fun getHomeListA(page: Int): Flow>

public fun getHomeListB(page: Int): Flow

public fun post1(body: RequestBody): Flow

public fun post12(body: RequestBody, map: Map<String, String>): Flow

public fun post1222(body: RequestBody, map: Map<String, Any>): Flow

public fun register(
username: String,
password: String,
repassword: String
)
: Flow

public fun testPostBody222(ID: Long, name: java.lang.String): Flow
}
  1. Repository 职责承担的调用端:用动态代理:

class RepositoryPoxy private constructor() : BaseRepositoryProxy() {

val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }


companion object {
val instance by lazy { RepositoryPoxy() }
}

fun callApiMethod(serviceR: Class<R>): R {
return Proxy.newProxyInstance(serviceR.classLoader, arrayOf(serviceR)) { proxy, method, args ->
flow {
val funcds = findSuspendMethod(service, method.name, args)
if (args == null) {
emit(funcds?.callSuspend(api))
} else {
emit(funcds?.callSuspend(api, *args))
}
// emit((service.getMethod(method.name, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call).execute().body())
}.catch {
if (it is InvocationTargetException) {
throw Throwable(it.targetException)
} else {
it.printStackTrace()
throw it
}
}
} as R
}
}
  1. BaseRepositoryProxy 中内容:

open class BaseRepositoryProxy {

private val map by lazy { mutableMapOf?>() }
private val sb by lazy { StringBuffer() }

@OptIn(ExperimentalStdlibApi::class)
fun findSuspendMethod(service: Class<T>, methodName: String, args: Array<out Any>): KFunction<*>? {
sb.delete(0, sb.length)
sb.append(service.name)
.append(methodName)
args.forEach {
sb.append(it.javaClass.typeName)
}
val key = sb.toString()
if (!map.containsKey(key)) {
val function = service.kotlin.memberFunctions.find { f ->
var isRight = 0
if (f.name == methodName && f.isSuspend) {
if (args.size == 0 && f.parameters.size == 1) {
isRight = 2
} else {
f.parameters.forEachIndexed { index, it ->
if (index > 0 && args.size > 0) {
if (args.size == 0) {
isRight = 2
return@forEachIndexed
}
if (it.type.javaType.typeName == javaClassTransform(args[index - 1].javaClass).typeName) {
isRight = 2
} else {
isRight = 1
return@forEachIndexed
}
}
}
}
}
//方法名一直 是挂起函数 方法参数个数一致, 参数类型一致
f.name == methodName && f.isSuspend && f.parameters.size - 1 == args.size && isRight == 2
}
map[key] = function
}
return map[key]
}

private fun javaClassTransform(clazz: Class<Any>) = when (clazz.typeName) {
"java.lang.Integer" -> Int::class.java
"java.lang.String" -> String::class.java
"java.lang.Float" -> Float::class.java
"java.lang.Long" -> Long::class.java
"java.lang.Boolean" -> Boolean::class.java
"java.lang.Double" -> Double::class.java
"java.lang.Byte" -> Byte::class.java
"java.lang.Short" -> Short::class.java
"java.lang.Character" -> Char::class.java
"SingletonMap" -> Map::class.java
"LinkedHashMap" -> MutableMap::class.java
"HashMap" -> HashMap::class.java
"Part" -> MultipartBody.Part::class.java
"RequestBody" -> RequestBody::class.java
else -> {
if ("RequestBody" == clazz.superclass.simpleName) {
RequestBody::class.java
} else {
Any::class.java
}
}
}
}
  1. ViewModel中调用端:
class MainViewModel : BaseViewModel() {

private val repository by lazy { RepositoryPoxy.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiMethod(INetApiRepository::class.java).getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "three 333 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

—————————————————我是分割线君—————————————————

  1. 上面生成的接口类 INetApiRepository 其实方法和 NetApi 拥有相似的模版,唯一区别就是返回类型,一个是对象,一个是Flow 流的对象

    还能省略吗?

    有,必须有

五、花式玩法(四)

  1. 直接修改 RepositoryPoxy ,作为Reposttory的职责 ,连上面的 INetApiRepository 的接口类全部省略了, 如下:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {

val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }


companion object {
val instance by lazy { RepositoryPoxy() }
}

fun callApiMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val clssss = mutableListOfout Any>>()
args?.forEach {
clssss.add(javaClassTransform(it.javaClass))
}
val parameterTypes = clssss.toTypedArray()
val call = (service.getMethod(methodName, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call)
call?.execute()?.body()?.let {
emit(it as R)
}
}
}

@OptIn(ExperimentalStdlibApi::class)
fun callApiSuspendMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val funcds = findSuspendMethod(service, methodName, args)
if (args == null) {
emit(funcds?.callSuspend(api) as R)
} else {
emit(funcds?.callSuspend(api, *args) as R)
}
}
}
}

2. ViewModel中调用入下:

class MainViewModel : BaseViewModel() {

private val repository by lazy { RepositoryPoxy.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiSuspendMethod(HomeData::class.java, "getHomeListB", page).onEach {
android.util.Log.e("MainViewModel", "four 444 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

六、总结

通过上面4中花式玩法:

  1. 花式玩法1: 我们知道了最常见最优雅的写法,但是模版式 repository 代码太多,而且需要手动写
  2. 花式玩法2: 把花式玩法1中的模版式 repository ,让其自动生成,对于特殊的方法,单独手动再写个 repository ,这样让大多数模版式代码全自动生成
  3. 花式玩法3: NetApi,可以根据配置,动态代理生成网络请求行为,该行为统一为动态代理实现,无需对接口类 NetApi 单独实现,那么我们的 repository 也可以 生成一个接口类 INetApiRepository ,然后动态代理实现其内部 方法体逻辑
  4. 花式玩法4:我连花式玩法3中的接口类 INetApiRepository 都不需要了,直接反射搞定所有。
  5. 同时可以学习到,注解、反射、泛型、注解处理器、动态代理

项目地址

项目地址:
github地址
gitee地址

感谢阅读:

欢迎 点赞、收藏、关注


作者:Wgllss
来源:juejin.cn/post/7417847546323042345
收起阅读 »

什么黑科技?纯血鸿蒙又可以运行Android应用了!

背景 纯血鸿蒙OS Next系统最近出现了两款热门应用:出境易、卓易通,其功能是:让你在出境后可以方便安装到各种Android应用。 发生了什么? 「出境易」这款应用可以在纯血鸿蒙OS NEXT里直接安装&运行Android应用! 纯血鸿蒙官方声...
继续阅读 »


背景


纯血鸿蒙OS Next系统最近出现了两款热门应用:出境易、卓易通,其功能是:让你在出境后可以方便安装到各种Android应用




发生了什么?


「出境易」这款应用可以在纯血鸿蒙OS NEXT里直接安装&运行Android应用!



纯血鸿蒙官方声称:「不支持运行Android应用」!



于是:



  • 很多正在努力开发、兼容纯血鸿蒙应用的开发者都在议论:是不是可以停鸿蒙开发、继续写回Android了?

  • 也有很多粉丝后台私信Carson,问:目前继续学鸿蒙开发到底还有没意义






本文意图


今天Carson来带大家扒扒「出境易」、「卓易通」到底是怎么能在纯血鸿蒙OS NEXT里运行Android应用的。具体包括:「出境易」、「卓易通」这两款Android应用



  1. 在纯血鸿蒙系统里运行的底层支持是什么?

  2. 在纯血鸿蒙系统里的运行环境是什么?

  3. 「出境易」、「卓易通」本质是什么?

  4. 在「出境易」、「卓易通」上的Android应用性能、体验如何?




问题1:在纯血鸿蒙系统里运行的底层支持是什么?



  • 无论黑科技有多 “黑”,总需要底层给与相关支持才有运行的可能。

  • 在初次安装「出境易」、「卓易通」时需下载一个环境,抓包&解包可得到:



其中最为关键的文件:anco_hmos.img,从字面解释来看:



  • anco:AndroidCompatible = 安卓兼容

  • hmos:HarmonyOS = 鸿蒙OS系统

  • 整体看,即**「鸿蒙OS系统里的安卓兼容」**


实际上,这其实是一个安卓镜像文件,是一个嵌入到鸿蒙OS系统层面的安卓运行环境(类似虚拟机的作用,但实际不是虚拟机)。



其实是类似wsl技术,即Windows Subsystem for Linux = Windows的Linux子系统,能让开发者在Windows操作系统中直接运行Linux环境,而无需任何虚拟机。



所以,要在纯血鸿蒙OS 上安装「出境易」、「卓易通」不仅需要下载很大的安卓镜像资源包,还需要重启系统,因为「出境易」和 「卓易通」是单独的“运行环境”。


值得一提的是:



  • 因为本身鸿蒙OS内核就兼容了Linux ABI(应用程序二进制接口),即鸿蒙OS内核本身就可以运行为Linux设计的应用。

  • 所以,虽然这是一个安卓镜像,但这个属于鸿蒙的安卓镜像并没有包含Linux 内核,只是包含运行时(Runtime)部分。 以下是鸿蒙内核架构图:
    鸿蒙内核示意图




问题2:在纯血鸿蒙系统里的运行环境是什么?


那么,这类Android应用到底是运行在什么环境上的呢?打开「出境易」内的app后,通过执行shell ps -ef会出现以下进程:



  • 即其运行环境是:通过lxc-start命令启动了一个基于iSulad的容器的进程。

  • iSulad 是华为自研的容器引擎,是一个非常通用的容器引擎,具有轻、快、 易、灵的特点。以下是其架构图:



iSulad官网介绍:http://www.openeuler.org/zh/other/pr…






问题3:「出境易」、「卓易通」本质是什么?



二者的功能都是:让你在出境后可以方便安装到各种app。听起来是不是有点类似国内的应用商店



  • 实际上,二者在鸿蒙next商店下载的是一层壳,负责与纯血鸿蒙OS进行权限交互(图片、文件IO等)

  • 本体也是Android应用的apk,即出境易.apk、卓易通.apk。拿出来也是可以在Android手机上安装的。(如下图)


除此以外,「出境易」还含有一个「文件共享.apk」、「卓易通」还有一个「搜应用.apk」、「文件共享.apk」。


这里值得一提的是,两个“应用商店”可搜到的应用原理不同:



  • 出境易:白名单方式,即只有与其合作的Android应用可以安装;

  • 卓易通:黑名单方式,即只有纯血鸿蒙OS上架的应用不可以安装;


下面附上视频:纯血鸿蒙OS 「出境易」、「卓易通」安装Android应用实机演示



http://www.bilibili.com/video/BV1Q9…





性能如何?


既然能跑了,那么用户体验如何呢?网友们已经开始跑分了:



  • 测试环境:麒麟9000s;

  • 结论:单核心正常跑分1000,目前「出境易」是930分左右,效率是93%;




  • 分析:上面提到其底层支持是类似wsl的技术,同时运行环境是采用华为自研的iSulad 容器引擎的方式,并非所谓的虚拟机环境。这种嵌入方式可以使得安卓应用能够在鸿蒙系统上运行,但又不会占用过多的资源或影响系统的稳定性

  • 结合业界常见容器水平93%左右,华为的iSulad容器达到了业界水平,可理解为:GPU性能几乎无损。


但是对于内存使用就不太友好了,容器本身内存占用极大,基本一个容器进程就是8GB,随便开两个应用12GB就没了。

同时结合网上使用的评价:手机容易发烫(功耗高)、应用Bug较多等等,可以总结为:以这种方式在纯血鸿蒙OS上运行的Android应用 「能用」,但是「不好用」,与原生体验还是存在很大差距




结论



  • 技术角度 分析:基于anco_hmos,采用类似wsl的方式同时结合iSulad容器引擎,使得在纯血鸿蒙OS上运行Android应用成为了板上钉钉的现实

  • 性能角度 实践:在CPU性能可认为几乎无损的情况下,内存跟功耗问题短时间内还是无法解决;

  • 用户体验 观察:应用Bug较多,结合性能内存问题,目前暂时仅处于一个**「能用」**的状态。


基于上述分析 & 问题表现,在纯血鸿蒙OS上运行Android应用在国内大范围使用短时间内几乎不可能,更多的是在一些小众、边缘、尝试探索的场景,比如一些使用频率较低的小众app、尝试出海境外的场景(如本文提到的「出境易」等)


最后


如何看待这次在纯血鸿蒙OS上运行Android应用的事件呢?评论区留言你的看法!


参考文章:



作者:Carson带你学Android
来源:juejin.cn/post/7448576110823047202
收起阅读 »

BOE(京东方)“向新2025”年终媒体智享会落地深圳 “屏”实力赋能产业创新发展

12月27日,BOE(京东方)“向新 2025”年终媒体智享会的收官之站在创新之都深圳圆满举行,为这场为期两周、横跨三地的年度科技盛会画上了完美句号。活动期间,全面回顾了 BOE(京东方)2024年在多个关键领域取得的卓越成绩,深入剖析其在六大维度构建的“向新...
继续阅读 »

12月27日,BOE(京东方)“向新 2025”年终媒体智享会的收官之站在创新之都深圳圆满举行,为这场为期两周、横跨三地的年度科技盛会画上了完美句号。活动期间,全面回顾了 BOE(京东方)2024年在多个关键领域取得的卓越成绩,深入剖析其在六大维度构建的“向新”发展格局,精彩呈现了以“屏”为核心搭建起的技术引领、伙伴赋能以及绿色发展等平台,全方位赋能全球生态合作伙伴,充分彰显BOE(京东方)作为全球领先的物联网创新企业的引领地位与责任担当。深圳活动现场,BOE(京东方)执行委员会委员、副总裁刘竞以及 BOE(京东方)副总裁、首席品牌官司达亲临现场,发表了主旨演讲。此次系列智享会的成功举办,进一步加深了与会嘉宾对 BOE(京东方)发展理念、技术实力与创新成果的认知和理解,也为BOE(京东方)新一年的发展拉开了充满希望和活力的序幕。

经过三十余年创新发展,秉持着对技术的尊重和对创新的坚持,在“屏之物联”战略指导下,BOE(京东方)从半导体显示领域当之无愧的领军巨擘迅速蝶变,成功转型为全球瞩目的物联网创新企业,并不断引领行业发展风潮。面对下一发展周期,BOE(京东方)将从战略、技术、应用、生态、模式、ESG六大方面全方位“向新”突破,以实现全面跃迁,并为产业高质发展注入强劲动力。

战略向新:自2021年“屏之物联”战略重磅发布以来,BOE(京东方)又于2024年京东方全球创新伙伴大会(BOE IPC·2024)上发布了基于“屏之物联”战略升维的“第N曲线”理论,以半导体显示技术、玻璃基加工、大规模集成智能制造三大核心优势为基础,精准布局玻璃基封装、钙钛矿光伏器件等前沿新兴领域,全力塑造业务增长新赛道。目前,玻璃基封装领域,BOE(京东方)已布局试验线,成立了玻璃基先进封装项目组,实现样机产出;钙钛矿领域,仅用38天就已成功产出行业首片2.4×1.2m中试线样品,标志着钙钛矿产业化迈出了重要一步。

技术向新:2021年,BOE(京东方)发布了中国半导体显示领域首个技术品牌,开创了产业“技术+品牌”双价值驱动的新纪元。以技术品牌为着力点,BOE(京东方)深入赋能超5000家全球顶尖品牌厂商和生态合作伙伴,包括AOC、ROG、创维、华硕、机械师、雷神、联想等,助力行业向高价值增长的路径迈进,也为用户提供了众多行业领先、全球首发的更优选择。BOE(京东方)还将全力深化人工智能与半导体显示技术以及产业发展的深度融合,并在AI+产品、AI+制造、AI+运营三大关键领域持续深耕,并依托半导体显示、物联网创新、传感器件三大技术策源地建设,与产业伙伴和产学研合作伙伴共同创新,为产业高质量可持续发展保驾护航。

应用向新:BOE(京东方)不仅是半导体显示领域的领军企业,也是应用场景创新领域的领跑者,BOE(京东方)秉持“屏之物联”战略,以全面领先的显示技术为基础,通过极致惊艳的显示效果、颠覆性的形态创新,为智慧座舱、电竞、视觉艺术、户外地标等场景注入了新鲜血液,带给用户更加美好智慧的使用体验。以智慧座舱为例,根据市场调研机构Omdia最新数据显示,2024年前三季度BOE(京东方)车载显示出货量及出货面积持续保持全球第一,在此基础上BOE(京东方)还推出“HERO”车载场景创新计划,进一步描绘智能化时代汽车座舱蓝图。

生态向新:BOE(京东方)持续深化与电视、手机、显示器、汽车等众多品牌伙伴的合作,共同打造“Powered by BOE”产业生态集群,赢得众多客户的认可与赞誉。与此同时,BOE(京东方)还持续拓展跨产业生态,通过与上海电影集团、故宫博物院、微博等文化产业领先机构展开跨界合作,以创新技术赋能传统文化艺术与影像艺术。此外,通过战略直投、产业链基金等股权投资方式协同众多生态合作伙伴,通过协同合作、资源聚合共同构筑产业生态发展圈层。

模式向新:为适配公司国际化、市场化、专业化的长远发展,BOE(京东方)持续深化“1+4+N+生态链”的业务发展架构,以及“三横三纵”组织架构和运营机制。在充分市场化和充分授权的机制保障下,形成了以半导体显示核心业务为牵引,传感、物联网创新、MLED业务、智慧医工四大高潜航道全面开花,聚焦包括智慧车联、工业互联、数字艺术、3D光场等规模化应用场景,生态链确保产业上下游合作伙伴协同跃迁的“万马奔腾”的发展图景。此外,BOE(京东方)还鼓励员工创新创业,通过激发人才创新热情,共同为集团发展注入强劲内生动力。

ESG向新:2024年,BOE(京东方)承诺将在2050年实现自身运营碳中和,并通过坚持“Green+”、“Innovation+”、“Community+”可持续发展理念,推动全球显示产业高质永续发展。“Green+”方面,BOE(京东方)依托超过16 家国家级绿色工厂、显示领域唯一1家国家级无废工厂、1 座灯塔工厂及2座零碳工厂,以绿色产品、制造与运营践行低碳路径;“Innovation+”方面,BOE(京东方)凭借全部为自主创新的9万件专利的行业佳绩,以及技术策源地、技术公益池等举措,携手产业上下游伙伴协同创新;“Community+”方面,BOE(京东方)在教育、医疗、环境等公益领域持续投入,积极履行社会责任,例如在“照亮成长路”公益项目中,BOE(京东方)十年间在偏远地区建设的智慧教室已经突破120所。

BOE(京东方):屏即平台赋能创新

在新一轮数智化浪潮中,全球显示行业的龙头企业 BOE(京东方)以屏为核心,充分发挥技术引领作用,积极赋能合作伙伴,并秉持绿色发展理念,全力构建产业高质量、可持续发展的创新生态平台,引领行业在高速发展的科技浪潮中稳步前行,为全球用户缔造更加智能美好的生活体验。作为 BOE(京东方)全球创新生态布局的关键一环,珠三角区域不仅是其创新要素汇聚的高地,更是其全球化发展的重要窗口与强大驱动力,为“屏之物联”战略落地提供了有效支撑。

技术引领方面,BOE(京东方)多年来始终秉持对技术的尊重和对创新的坚持,致力于推动显示技术全面向新发展,以完美画质、AI+显示、无界形态、氧化物(Oxide)关键技术等关键领域,持续挖掘“屏”在物联网领域的无限潜力。

完美画质,BOE(京东方)深入洞察用户真实需求,基于ADS Pro技术优化升级的高端LCD解决方案UB Cell,所呈现的完美画质可以媲美OLED,堪称LCD显示技术发展的重要里程碑。目前,BOE(京东方)已携手合作伙伴推出了一系列搭载UB Cell高端液晶电视旗舰产品,引领液晶显示技术升级风向标;

AI+显示,BOE(京东方)在软硬件层面均已为AI的深度应用构筑完美平台,不仅实现了光感、温感、NFC等传感器件的屏内集成,还开发了屏幕局部刷新、远端功能监测等软件技术,显著增强了用户感知,提升了交互体验;

无界形态,BOE(京东方)作为国内在柔性OLED领域布局早、技术优、市场应用广的领军企业,在材料、工艺等领域具备全面优势,不仅在屏幕轻薄化、超清化方面性能卓越,更能够实现折叠、卷曲等形态变化,同时不断探索屏下摄像、屏下指纹、3D touch等多功能的智慧集成,更是打造出行业首款三折屏等具有行业里程碑意义的产品,带领用户迈入更多变、更智能的未来生活;

氧化物技术,BOE(京东方)在产能、技术以及产品性能上均位居行业领先地位,凭借高刷新率、高分辨率、低功耗等优势,在未来高端IT产品领域展现出广阔的应用前景。

伙伴赋能方面,BOE(京东方)始终以合作共赢为宗旨,高效整合资源,与生态伙伴携手向新发展,共筑高价值发展空间。BOE(京东方)坚持第一时间捕捉行业及市场需求动向,通过内部研发及运营保障机制,完成技术开发应用,组织建设和人才培养,完善流程、数据、组织以及IT能力建设,输出市场化、专业化、国际化的服务能力,并联动上下游及科研机构等生态伙伴,共同探讨“以人为本”的最优解决方案,深度拓展更多高端应用场景;同时,持续进行智能制造实践探索,确保稳定交付,赋能终端伙伴,使其能更好融入更真实、更丰富的消费者使用场景,实现产业高价值增长。

绿色发展方面,BOE(京东方)早已将可持续发展刻入企业基因,融入企业日常经营与管理的全链路,从绿色规划、低碳设计到碳足迹量化认证等各个环节,全力实现极致降碳目标。原材料环节,BOE(京东方)通过打造绿色供应链,积极使用可回收、可降解以及清洁材料,为产品低碳化发展奠定坚实基础;生产制造阶段实现全面绿色低碳;产品流通及回收阶段,BOE(京东方)已完成49个产品的碳足迹认证,凭借可回收、可降解的绿色材料,在产品的全生命周期中均实现了最大化降碳,让“科技创新+绿色发展”成为产业升级的主旋律。

“向新2025”年终媒体智享会,是BOE(京东方)2024创新营销的收官之作和全新实践,系统深化了大众对BOE(京东方)品牌和技术创新实力的认知与理解。近年来,BOE(京东方)通过多种创意独具的品牌破圈推广,包括“你好BOE”系列品牌线下活动、技术科普综艺《BOE解忧实验室》等生动鲜活地传递出BOE(京东方)以创新科技赋能美好生活的理念,为企业业务增长提供了强大动力,也为科技企业品牌推广打造了全新范式。BOE(京东方)“向新2025”主题系列活动已先后于上海、成都、深圳成功举办,为BOE(京东方)2024创新传播划上圆满句号。

面向未来,BOE(京东方)将胸怀“Best on Earth”宏伟愿景,坚持“屏之物联”战略引领,持续推动显示技术和物联网、AI等前沿技术的深度融合。从提升产品视觉体验到优化产业生态协同,从升级智能制造体系到践行社会责任担当,BOE(京东方)将砥砺奋进、创新不辍,为全球用户呈献超凡科技体验,领航全球产业创新发展的新篇章。

收起阅读 »

一些之前遇到过但没答上来的Android面试题

这段时间面了几家公司,也跟不同的面试官切磋了一些面试题,有的没啥难度,有的则是问到了我的知识盲区,没办法,Android能问的东西太多了,要全覆盖到太难了,既然没法全覆盖,那么只好亡羊补牢,将这些没答上来的题目做下记录,让自己如果下次遇到了可以答上来 TCP与...
继续阅读 »

这段时间面了几家公司,也跟不同的面试官切磋了一些面试题,有的没啥难度,有的则是问到了我的知识盲区,没办法,Android能问的东西太多了,要全覆盖到太难了,既然没法全覆盖,那么只好亡羊补牢,将这些没答上来的题目做下记录,让自己如果下次遇到了可以答上来


TCP与UDP有哪些差异


这道题回答的不全,仅仅只是将两个协议的概念说了一下,但是真正的差异却没有真正答上来,后来查询了一下资料,两者的差异如下



  • TCP是传输控制协议,是面向连接的协议,发送数据前需要建立连接,TCP传输的数据不会丢失,不会重复,会按照顺序到达

  • 与TCP相对的,UDP是无连接的协议,发送数据前不需要建立连接,数据没有可靠性

  • TCP的通信类似于打电话,需要确认身份后才可以通话,而UDP更像是广播,不关心对方是不是接收,只需要播报出去即可

  • TCP支持点对点通信,而UDP支持一对一,一对多,多对一,多对多

  • TCP传输的是字节流,而UDP传输的是报文

  • TCP首部开销为20个字节,而UDP首部开销是8个字节

  • UDP主机不需要维持复杂的连接状态表


TCP的三次握手


这道题以及下面那道虽然说上来了,但是也没有说的很对,仅仅只是说了下每次握手或者挥手的目的,中间的过程没有说出来,以下是三次握手以及四次挥手的详细过程



  • 第一次握手:客户端将SYN置为1,随机生成一个初始序列号seq发送给服务端,客户端进入SYN_SENT状态

  • 第二次握手:服务端收到客户端的SYN=1之后,知道客户端请求建立连接,将自己的SYN置1,ACK置1,产生一个ack=seq+1,并随机产生一个自己的初始序列号,发送给客户端,服务端进入SYN_RCVD状态

  • 第三次握手:客户端检查ack是否为序列号+1,ACK是否为1,检查正确之后将自己的ACK置为1,产生一个ack=服务器的seq+1,发送给服务器;进入ESTABLISHED状态;服务器检查ACK为1和ack为序列号+1之后,也进入ESTABLISHED状态;完成三次握手,连接建立


TCP的四次挥手



  • 第一次挥手:客户端将FIN设置为1,发送一个序列号seq给服务端,客户端进入FIN_WAIT_1状态

  • 第二次挥手:服务端收到FIN之后,发送一个ACK为1,ack为收到的序列号加一,服务端进入CLOSE_WAIT状态,这个时候客户端已经不会再向服务端发送数据了

  • 第三次挥手:服务端将FIN置1,发送一个序列号给客户端,服务端进入LAST_ACK状态

  • 第四次挥手:客户端收到服务器的FIN后,进入TIME_WAIT状态,接着将ACK置1,发送一个ack=序列号+1给服务器,服务器收到后,确认ack后,变为CLOSED状态,不再向客户端发送数据。客户端等待2* MSL(报文段最长寿命)时间后,也进入CLOSED状态。完成四次挥手


从浏览器输入地址到最终显示页面的整个过程


这个真的知识盲区了,谁会平时没事在用浏览器的时候去思考这个问题呢,结果一查居然还是某大厂的面试题,算了也了解下吧



  1. 第一步,浏览器查询DNS,获取域名对应的ip地址

  2. 第二步,获取ip地址后,浏览器向服务器建立连接请求,发起三次握手请求

  3. 第三步,连接建立好之后,浏览器向服务器发起http请求

  4. 第四步,服务器收到请求之后,根据路径的参数映射到特定的请求处理器进行处理,并将处理结果以及相应的视图返回给浏览器

  5. 第五步,浏览器解析并渲染视图,若遇到js,css以及图片等静态资源,则重复向服务器请求相应资源

  6. 第六步,浏览器根据请求到的数据,资源渲染页面,最终将完整的页面呈现在浏览器上


为什么Zygote进程使用socket通信而不是binder


应用层选手遇到偏底层问题就头疼了,但是这个问题还是要知道的,毕竟跟我们app的启动流程相关



  1. 原因一:从初始化时机上,Binder通信需要在Android运行时以及Binder驱动已经初始化之后才能使用,而在这之前,Zygote已经启动了,所以只能使用socket通信

  2. 原因二:从出现的先后顺序上,Zygote相比于Binder机制,更早的被设计以及投入使用,所以在Android的早期版本中,Android就已经使用socket来监听其他进程的请求

  3. 原因三:从使用上,socket通信不依赖于Binder机制,它是一种简单通用的IPC机制,也不需要复杂的接口定义

  4. 原因四:从兼容性上来讲,socket是一种跨平台的IPC机制,可以在不同的操作系统和环境中使用。

  5. 原因五:从性能上来讲,由于使用Zygote通信并不是频繁的操作,所以使用socket通信不会对系统性能造成显著影响

  6. 原因六:从安全性上来讲,使用socket可以确保只有系统中特定的服务如system_server才能与Zygote通信,从而提升一定的安全性


使用Binder的好处有哪些


上面那个问题问好了紧接着就是这道题,我嗯嗯啊啊的零碎说了几个,肯定也是不过关的,回头查了下资料,使用Binder的优势如下



  • 从效率上来讲,Binder比较高效,相比较于其他几种进程的通信方式(管道,消息队列,Socket,共享内存),Binder只需要拷贝一次内存就好了,而除了共享内存,其余都都要拷贝两次内存,共享内存虽然不需要拷贝,但是实现方式复杂,所以综合考虑Binder占优势

  • 使用的是更加便于理解,更简单的面向对象的IPC通信方式

  • Binder既支持同步调用,也支持异步调用

  • Binder使用UID和PID来验证请求的来源,这样可以确保每个Binder事务可以精确到发起者,为进程间的通信提供了保障

  • Binder是基于c/s架构,架构清晰明确,Server端与Client端相对独立

  • Binder有一套易于使用的API供进程间通信,将复杂的内部实现隐藏起来


如果一个线程连续调用两次start,会怎样?


会怎样?谁知道呀,正常人谁会没事去调用两次start呢?但是这个还真有人问了,我只能说没遇到过,后来回去自己试了下才知道


image.png

如上述代码所示,有一个线程,然后连续调用了两次start方法,当我们运行一下这段代码后,得到的结果如下


image.png

可以发现线程有正常运行,但同时也因为多调了一次start而抛出了异常,这个异常在start方法里面就能看到


image.png

有一个状态为started,正常第一次启动线程时候,started为false,所以是不会抛出异常的,started为true的地方是在下面这个位置


image.png

调用了native方法nativeCreated后,started状态位才变成true,这个时候如果再去调用start方法,那么必然会抛出异常


如何处理协程并发的数据安全


之前遇到过这么个问题,并发处理的协程之间是否可以保证数据安全,这个由于之前有实验过,所以想都没想就说可以保证数据安全,但面试官只是呵呵了一下,我捉摸着难道不对吗,后来回去试了一下才发现,不一定就能保证数据安全,看下面这段代码


image.png

这段代码里面在runBlocking中创建了1000个协程,每一个协程都对变量count做自增操作,最后把结果打印出来,我们预期的是打印出的结果就是1000,实际结果如下


image.png

看到的确就是1000,没啥毛病,多试几次也是一样的,但是如果换一种写法试试看呢


image.png

原本都是runBlocking里面的子协程,现在将这些协程变成非runBlocking的子协程,结果是不是还是1000呢,看下结果


image.png

明显不是了,所以并发处理的协程,并不能保证数据安全,那么如何可以让数据安全呢,有以下几个办法


原子类


image.png

这个好理解,同处理线程安全差不多


channel


image.png

receive函数只有等到阻塞队列里面有数据的时候才会执行,没有数据的时候会一直等待,所以这就能保证这些协程可以并发执行,不过要注意的是这里的Channel一定要设置队列大小,不然程序会一直阻塞,receive一直在等待队列里面有数据


mutex


image.png

使用互斥锁的方式,withLock函数内部执行了获取锁跟释放锁逻辑,将变量count保护起来,实现数据安全,除此之外,还可以使用lockunLock函数来实现,代码如下


image.png

总结


总的来讲自己在系统层面,偏底层的那些问题上,还是掌握的不多,这个也跟自己多年徘徊在应用层开发有关,底层知识用到的不多,自然也就忽略了,但是如果面试的话,就算是面的应用层,也是需要知道一些底层方面的知识,不然面试官随便问几个,你不会,别人会,岗位不就被别人拿走了吗


作者:Coffeeee
来源:juejin.cn/post/7402204610978545673
收起阅读 »

Android 新一代图片加载库 - Coil

Coil 是 Android 的新一代图片加载库,它的全名叫做 Coroutine Image Loader,即协程图片加载器,用于显示网络或本地图像资源。 特点 快速:执行了多项优化,包括内存和磁盘缓存,图像降采样,自动暂停/取消请求等。 轻量级:依赖于 ...
继续阅读 »

Coil 是 Android 的新一代图片加载库,它的全名叫做 Coroutine Image Loader,即协程图片加载器,用于显示网络或本地图像资源。


特点



  • 快速:执行了多项优化,包括内存和磁盘缓存,图像降采样,自动暂停/取消请求等。

  • 轻量级:依赖于 Kotlin,协程和 Okio,并与谷歌的 R8 等代码缩减器无缝协作。

  • 易于使用:API 利用 Kotlin 的语言特性来实现简洁性和最小化的样板代码。

  • 现代化:以 Kotlin 为首要语言,并与协程,Okio,Ktor 和 OkHttp 等现代库实现互操作。


加载图片


先引入依赖


implementation(libs.coil)

最简单的加载方法就是使用这个扩展函数了


inline fun ImageView.load(
data: Any?,
imageLoader: ImageLoader = context.imageLoader,
builder: ImageRequest.Builder.() -> Unit = {}
)
: Disposable {
val request = ImageRequest.Builder(context)
.data(data)
.target(this)
.apply(builder)
.build()
return imageLoader.enqueue(request)
}

使用扩展函数来加载本地或网络中的图片


// 加载网络图片
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
// 加载资源图片
binding.imageView.load(R.drawable.girl)
// 加载文件中的图片
val file = File(requireContext().getExternalFilesDir(null), "saved_image.jpg")
binding.imageView.load(file.absolutePath)

支持设置占位图,裁剪变换,生命周期关联等


binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg") {
crossfade(true) //渐进渐出
crossfade(1000) //渐进渐出时间
placeholder(R.mipmap.sym_def_app_icon) //加载占位图
error(R.mipmap.sym_def_app_icon) //加载失败占位图
allowHardware(true) //硬件加速
allowRgb565(true) //支持565格式
lifecycle(lifecycle) //生命周期关联
transformations(CircleCropTransformation()) //圆形裁剪变换
}

变为圆角矩形


binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg") {
lifecycle(lifecycle)
transformations(RoundedCornersTransformation(20f))
}

可以创建自定义的图片加载器,为其添加一些日志拦截器等。


class LoggingInterceptor : Interceptor {

companion object {
private const val TAG = "LoggingInterceptor"
}

override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val url = chain.request.data.toString()
val width = chain.size.width.toString()
val height = chain.size.height.toString()
Log.i(TAG, "url: $url, width: $width, height: $height")
return chain.proceed(chain.request)
}
}

class MyApplication : Application(), ImageLoaderFactory {

override fun newImageLoader() =
ImageLoader.Builder(this.applicationContext).components { add(LoggingInterceptor()) }
.build()
}

替换 Okhttp 实例


val okHttpClient = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
val imageLoader = ImageLoader.Builder(requireContext()).okHttpClient {
okHttpClient
}.build()
Coil.setImageLoader(imageLoader)
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")

加载 gif


添加依赖


implementation(libs.coil.gif)

按照官方的做法,设置 ImageLoader。


val imageLoader = ImageLoader.Builder(requireContext())
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}.build()
Coil.setImageLoader(imageLoader)
binding.imageView.load(GIF_URL)

下载监听


可以监听下载过程


binding.imageView.load(IMAGE_URL) {
listener(
onStart = {
Log.i(TAG, "onStart")
},
onError = { request, throwable ->
Log.i(TAG, "onError")
},
onSuccess = { request, result ->
Log.i(TAG, "onSuccess")
},
onCancel = { request ->
Log.i(TAG, "onCancel")
}
)
}

取消下载


val disposable = binding.imageView.load(IMAGE_URL)
disposable.dispose()

对 Jetpack Compose 的支持


引入依赖:


implementation(libs.coil.compose)

使用 AsyncImage


@Composable
@NonRestartableComposable
fun AsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
transform: (State) -> State = DefaultTransform,
onState: ((State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DefaultFilterQuality,
clipToBounds: Boolean = true,
modelEqualityDelegate: EqualityDelegate = DefaultModelEqualityDelegate,
)


比如显示一张网络图片,就可以这样干。


@Composable
fun DisplayPicture() {
AsyncImage(
model = "https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg",
contentDescription = null
)
}

支持设置占位图,过程监听,裁剪等


@Composable
fun DisplayPicture() {
AsyncImage(
modifier = Modifier
.clip(CircleShape)
.size(200.dp),
onSuccess = {
Log.i(TAG, "onSuccess")
},
onError = {
Log.i(TAG, "onError")
},
onLoading = {
Log.i(TAG, "onLoading")
},
model = ImageRequest.Builder(LocalContext.current)
.data("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
.crossfade(true)
.placeholder(R.drawable.default_image)
.error(R.drawable.default_image)
.build(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

这里介绍一下这个 ContentScale,它是用来指定图片如何适应其容器大小的,有以下几个值:



  • ContentScale.FillBounds:图片会被拉伸或压缩以完全填充其容器的宽度和高度,这可能会导致图片的宽高比失真。

  • ContentScale.Fit:图片会保持其原始宽高比,并尽可能大地缩放以适应容器,同时确保图片的任一边都不会超出容器的边界,这可能会导致容器的某些部分未被图片覆盖。

  • ContentScale.Crop:图片会被裁剪以完全覆盖其容器的宽度和高度,同时保持图片的宽高比,这通常用于需要确保整个容器都被图片覆盖的场景,但可能会丢失图片的一部分内容。

  • ContentScale.FillWidth:图片会保持其原始宽高比,并调整其高度以完全填充容器的宽度,这可能会导致图片的高度超出容器的高度,从而被裁剪或需要额外的布局处理。

  • ContentScale.FillHeight:图片会保持其原始宽高比,并调整其宽度以完全填充容器的高度,这可能会导致图片的宽度超出容器的宽度,从而需要相应的处理。

  • ContentScale.Inside:图片会保持其原始宽高比,并缩放以确保完全位于容器内部,同时其任一边都不会超出容器的边界。

  • ContentScale.:图片将以其原始尺寸显示,不会进行任何缩放或裁剪。


作者:阿健君
来源:juejin.cn/post/7403546034763235378
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »