注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Rust在Android端的入门开发

前言 IOS上应用还在半路上,遇到了一些集成问题。在了解、学习过程中发现,IOS的Swifit UI动画真的是比Flutter做的好几倍,后面有时间可以记录记录。本次先记录Android集成吧,对比性能的话,可以在rust中for循环个10万次,对比C的时间消...
继续阅读 »

前言


IOS上应用还在半路上,遇到了一些集成问题。在了解、学习过程中发现,IOSSwifit UI动画真的是比Flutter做的好几倍,后面有时间可以记录记录。本次先记录Android集成吧,对比性能的话,可以在rustfor循环个10万次,对比C的时间消耗。

参考资料

Building and Deploying a Rust library on Android

JNI Create

Create JNI


目录


Rust在Android端的入门开发.png


一、环境准备


rustup配置


这个配置,在装rust的时候就配置了,可以忽略。如果没有配置,想了解的可以看二、Rust入门之Hello World


配置NDK


第一步

先确定自己的NDK目录

默认目录一般都在 /Users/你的用户名/Library/Android/sdk/ndk-bundle 这个位置,用户目录可以用 ${HOME} 代替。


第二步

创建库crate


cargo new android_demo --lib

第三步

切换到 android_demo 项目下,创建 NDK 文件

找到 make_standalone_toolchain.py 文件,执行以下语句


python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch arm64 --install-dir NDK/arm64
python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch arm --install-dir NDK/arm
python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch x86 --install-dir NDK/x86

对应的NDK目录如下


rust_ndk_1.PNG


第四步

找到 cargo的配置文件,~/.cargo/config


[target.aarch64-linux-android]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm64/bin/aarch64-linux-android-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm64/bin/aarch64-linux-android-clang"

[target.armv7-linux-androideabi]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm/bin/arm-linux-androideabi-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm/bin/arm-linux-androideabi-clang"

[target.i686-linux-android]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/x86/bin/i686-linux-android-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/x86/bin/i686-linux-android-clang"

其中 E:/VSCodeWorkspace/rust/android_demo 是本次项目目录。


第五步

添加工具链


rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android

第六步

在当前 android_demo 目录下,执行以下语句

编译Rust项目,按需要的架构编译即可。


cargo build --target aarch64-linux-android --release
cargo build --target armv7-linux-androideabi --release
cargo build --target i686-linux-android --release


  • 出现问题

    • note: %1 不是有效的 Win32 应用程序。 (os error 193) ,第三步和第六步编译不一致。解决方法:将第四步,换成Android SDK 目录下的ndk,看下面代码示例。

    • error: linker cc not found,解决方案也是按照下面,一定要使用 .cmd




解决方案


[target.aarch64-linux-android]
ar = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android-ar"
linker = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android26-clang.cmd"

[target.armv7-linux-androideabi]
ar = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\arm-linux-androideabi-ar"
linker = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\armv7a-linux-androideabi26-clang++.cmd"
xxx

产物


rust_target_2.PNG


二、Rust实现


Cargo.toml


[package]
name = "android_demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
jni-sys = "0.3.0"

[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.5", default-features = false }

[lib]
crate-type = ["dylib"]

lib.rs


/*
* @Author: axiong
*/

use std::os::raw::{c_char};
use std::ffi::{CString, CStr};

#[no_mangle]
pub extern fn rust_greeting(to: *const c_char) -> *mut c_char {
let c_str = unsafe { CStr::from_ptr(to) };
let recipient = match c_str.to_str() {
Err(_) => "there",
Ok(string) => string,
};

CString::new("Hello ".to_owned() + recipient).unwrap().into_raw()
}

/// Expose the JNI interface for android below
/// 只有在目标平台是Android的时候才开启 [cfg(target_os="android")
/// 由于JNI要求驼峰命名,所以要开启 allow(non_snake_case)
#[cfg(target_os="android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;

use super::*;
use self::jni::JNIEnv;
use self::jni::objects::{JClass, JString};
use self::jni::sys::{jstring};

#[no_mangle]
pub unsafe extern fn Java_com_rjx_rustdemo_RustGreeting_greeting(env: JNIEnv, _: JClass, java_pattern: JString) -> jstring {
// Our Java companion code might pass-in "world" as a string, hence the name.
let world = rust_greeting(env.get_string(java_pattern).expect("invalid pattern string").as_ptr());
// Retake pointer so that we can use it below and allow memory to be freed when it goes out of scope.
let world_ptr = CString::from_raw(world);
let output = env.new_string(world_ptr.to_str().unwrap()).expect("Couldn't create java string!");

output.into_inner()
}
}

三、Android集成


SO集成


rust_android_001.PNG


RustGreeting.java


public class RustGreeting {
static {
System.loadLibrary("android_demo");
}

private static native String greeting(final String pattern);

public static String sayHello(String to) {
return greeting(to);
}

}

MainActivity.java


public class MainActivity extends AppCompatActivity {

// Used to load the 'native-lib' library on application startup.
static {
//System.loadLibrary("native-lib");
}

private ActivityMainBinding binding;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

// Example of a call to a native method
TextView tv = binding.sampleText;
tv.setText(RustGreeting.sayHello("Rust!!"));
}

}

效果


Rust_Hello.PNG


作者:CodeOver
来源:juejin.cn/post/7170696817682694152
收起阅读 »

在 Flutter 多人视频中实现虚拟背景、美颜与空间音效

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。 本篇主要带你了解 SDK 里几个实用的 API ...
继续阅读 »

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。



本篇主要带你了解 SDK 里几个实用的 API 实现,相对简单



虚拟背景


虚拟背景是视频会议里最常见的特效之一,在声网 SDK 里可以通过 enableVirtualBackground 方法启动虚拟背景支持。


首先,因为我们是在 Flutter 里使用,所以我们可以在 Flutter 里放一张 assets/bg.jpg 图片作为背景,这里有两个需要注意的点:




  • assets/bg.jpg 图片需要在 pubspec.yaml 文件下的 assets 添加引用


      assets:
      - assets/bg.jpg



  • 需要在 pubspec.yaml 文件下添加 path_provider: ^2.0.8path: ^1.8.2 依赖,因为我们需要把图片保存在 App 本地路径下




如下代码所示,首先我们通过 Flutter 内的 rootBundle 读取到 bg.jpg ,然后将其转化为 bytes, 之后调用 getApplicationDocumentsDirectory 获取路径,保存在的应用的 /data" 目录下,然后就可以把图片路径配置给 enableVirtualBackground 方法的 source ,从而加载虚拟背景。


Future<void> _enableVirtualBackground() async {
 ByteData data = await rootBundle.load("assets/bg.jpg");
 List<int> bytes =
     data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
 Directory appDocDir = await getApplicationDocumentsDirectory();
 String p = path.join(appDocDir.path, 'bg.jpg');
 final file = File(p);
 if (!(await file.exists())) {
   await file.create();
   await file.writeAsBytes(bytes);
}

 await _engine.enableVirtualBackground(
     enabled: true,
     backgroundSource: VirtualBackgroundSource(
         backgroundSourceType: BackgroundSourceType.backgroundImg,
         source: p),
     segproperty:
         const SegmentationProperty(modelType: SegModelType.segModelAi));
 setState(() {});
}

如下图所示是都开启虚拟背景图片之后的运行效果,当然,这里还有两个需要注意的参数:



  • BackgroundSourceType :可以配置 backgroundColor(虚拟背景颜色)、backgroundImg(虚拟背景图片)、backgroundBlur (虚拟背景模糊) 这三种情况,基本可以覆盖视频会议里的所有场景

  • SegModelType :可以配置为 segModelAi (智能算法)或 segModelGreen(绿幕算法)两种不同场景下的抠图算法。




这里需要注意的是,在官方的提示里,建议只在搭载如下芯片的设备上使用该功能(应该是对于 GPU 有要求):



  • 骁龙 700 系列 750G 及以上

  • 骁龙 800 系列 835 及以上

  • 天玑 700 系列 720 及以上

  • 麒麟 800 系列 810 及以上

  • 麒麟 900 系列 980 及以上



另外需要注意的是,为了将自定义背景图的分辨率与 SDK 的视频采集分辨率适配,声网 SDK 会在保证自定义背景图不变形的前提下,对自定义背景图进行缩放和裁剪


美颜


美颜作为视频会议里另外一个最常用的功能,声网也提供了 setBeautyEffectOptions 方法支持一些基础美颜效果调整。


如下代码所示, setBeautyEffectOptions 方法里主要是通过 BeautyOptions 来调整画面的美颜风格,参数的具体作用如下表格所示。



这里的 .5 只是做了一个 Demo 效果,具体可以根据你的产品需求,配置出几种固定模版让用户选择。



_engine.setBeautyEffectOptions(
 enabled: true,
 options: const BeautyOptions(
   lighteningContrastLevel:
       LighteningContrastLevel.lighteningContrastHigh,
   lighteningLevel: .5,
   smoothnessLevel: .5,
   rednessLevel: .5,
   sharpnessLevel: .5,
),
);

属性作用
lighteningContrastLevel对比度,常与 lighteningLevel 搭配使用。取值越大,明暗对比程度越大
lighteningLevel美白程度,取值范围为 [0.0,1.0],其中 0.0 表示原始亮度,默认值为 0.0。取值越大,美白程度越大
smoothnessLevel磨皮程度,取值范围为 [0.0,1.0],其中 0.0 表示原始磨皮程度,默认值为 0.0。取值越大,磨皮程度越大
rednessLevel红润度,取值范围为 [0.0,1.0],其中 0.0 表示原始红润度,默认值为 0.0。取值越大,红润程度越大
sharpnessLevel锐化程度,取值范围为 [0.0,1.0],其中 0.0 表示原始锐度,默认值为 0.0。取值越大,锐化程度越大

运行后效果如下图所示,开了 0.5 参数后的美颜整体画面更加白皙,同时唇色也更加明显。


没开美颜开了美颜

色彩增强


接下来要介绍的一个 API 是色彩增强: setColorEnhanceOptions ,如果是美颜还无法满足你的需求,那么色彩增强 API 可以提供更多参数来调整你的需要的画面风格。


如下代码所示,色彩增强 API 很简单,主要是调整 ColorEnhanceOptionsstrengthLevelskinProtectLevel 参数,也就是调整色彩强度和肤色保护的效果


  _engine.setColorEnhanceOptions(
     enabled: true,
     options: const ColorEnhanceOptions(
         strengthLevel: 6.0, skinProtectLevel: 0.7));

如下图所示,因为摄像头采集到的视频画面可能存在色彩失真的情况,而色彩增强功能可以通过智能调节饱和度和对比度等视频特性,提升视频色彩丰富度和色彩还原度,最终使视频画面更生动。



开启增强之后画面更抢眼了。



没开增加开了美颜+增强

属性参数
strengthLevel色彩增强程度。取值范围为 [0.0,1.0]。0.0 表示不对视频进行色彩增强。取值越大,色彩增强的程度越大。默认值为 0.5。
skinProtectLevel肤色保护程度。取值范围为 [0.0,1.0]。0.0 表示不对肤色进行保护。取值越大,肤色保护的程度越大。默认值为 1.0。 当色彩增强程度较大时,人像肤色会明显失真,你需要设置肤色保护程度; 肤色保护程度较大时,色彩增强效果会略微降低。 因此,为获取最佳的色彩增强效果,建议动态调节 strengthLevel 和 skinProtectLevel 以实现最合适的效果。

空间音效


其实声音调教才是重头戏,声网既然叫声网,在音频处理上肯定不能落后,在声网 SDK 里就可以通过 enableSpatialAudio 打开空间音效的效果。


_engine.enableSpatialAudio(true);

什么是空间音效?简单说就是特殊的 3D 音效,它可以将音源虚拟成从三维空间特定位置发出,包括听者水平面的前后左右,以及垂直方向的上方或下方。



本质上空间音效就是通过一些声学相关算法计算,模拟实现类似空间 3D 效果的音效实现



同时你还可以通过 setRemoteUserSpatialAudioParams 来配置空间音效的相关参数,如下表格所示,可以看到声网提供了非常丰富的参数来让我们可以自主调整空间音效,例如这里面的 enable_blurenable_air_absorb 效果就很有意思,十分推荐大家去试试。


属性作用
speaker_azimuth远端用户或媒体播放器相对于本地用户的水平角。 取值范围为 [0,360],单位为度,例如 (默认)0 度,表示水平面的正前方;90 度,表示水平面的正左方;180 度,表示水平面的正后方;270 度,表示水平面的正右方;360 度,表示水平面的正前方;
speaker_elevation远端用户或媒体播放器相对于本地用户的俯仰角。 取值范围为 [-90,90],单位为度。(默认)0 度,表示水平面无旋转;-90 度,表示水平面向下旋转 90 度;90 度,表示水平面向上旋转 90 度
speaker_distance远端用户或媒体播放器相对于本地用户的距离,取值范围为 [1,50],单位为米,默认值为 1 米。
speaker_orientation远端用户或媒体播放器相对于本地用户的朝向。 取值范围为 [0,180],单位为度。默认)0 度,表示声源和听者朝向同一方向;180: 180 度,表示声源和听者面对面
enable_blur是否开启声音模糊处理
enable_air_absorb是否开启空气衰减,即模拟声音在空气中传播的音色衰减效果:在一定的传输距离下,高频声音衰减速度快、低频声音衰减速度慢。
speaker_attenuation远端用户或媒体播放器的声音衰减系数,取值范围为[0,1]。 0:广播模式,即音量和音色均不随距离衰减;(0,0.5):弱衰减模式,即音量和音色在传播过程中仅发生微弱衰减;0.5:(默认)模拟音量在真实环境下的衰减,效果等同于不设置 speaker_attenuation 参数;(0.5,1]:强衰减模式,即音量和音色在传播过程中发生迅速衰减
enable_doppler是否开启多普勒音效:当声源与接收声源者之间产生相对位移时,接收方听到的音调会发生变化


音频类的效果这里就无法展示了,强烈推荐大家自己动手去试试。



人声音效


另外一个推荐的 API 就是人声音效:setAudioEffectPreset, 调用该方法可以通过 SDK 预设的人声音效,在不会改变原声的性别特征的前提下,修改用户的人声效果,例如:


_engine.setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);

声网 SDK 里预设了非常丰富的 AudioEffectPreset ,如下表格所示,从场景效果如 KTV、录音棚,到男女变声,再到恶搞的音效猪八戒等,可以说是相当惊艳。


参数作用
audioEffectOff原声
roomAcousticsKtvKTV
roomAcousticsVocalConcert演唱会
roomAcousticsStudio录音棚
roomAcousticsPhonograph留声机
roomAcousticsVirtualStereo虚拟立体声
roomAcousticsSpacial空旷
roomAcousticsEthereal空灵
roomAcousticsVirtualSurroundSound虚拟环绕声
roomAcoustics3dVoice3D 人声
voiceChangerEffectUncle大叔
voiceChangerEffectOldman老年男性
voiceChangerEffectBoy男孩
voiceChangerEffectSister少女
voiceChangerEffectGirl女孩
voiceChangerEffectPigking猪八戒
voiceChangerEffectHulk绿巨人
styleTransformationRnbR&B
styleTransformationPopular流行
pitchCorrection电音


PS:为获取更好的人声效果,需要在调用该方法前将 setAudioProfile 的 scenario 设为 audioScenarioGameStreaming(3):


_engine.setAudioProfile(
 profile: AudioProfileType.audioProfileDefault,
 scenario: AudioScenarioType.audioScenarioGameStreaming);


当然,这里需要注意的是,这个方法只推荐用在对人声的处理上,不建议用于处理含音乐的音频数据


最后,完整代码如下所示:


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

 @override
 State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
 late final RtcEngine _engine;

 ///初始化状态
 late final Future<bool?> initStatus;

 ///当前 controller
 late VideoViewController currentController;

 ///是否加入聊天
 bool isJoined = false;

 /// 记录加入的用户id
 Map<int, VideoViewController> remoteControllers = {};

 @override
 void initState() {
   super.initState();
   initStatus = _requestPermissionIfNeed().then((value) async {
     await _initEngine();

     ///构建当前用户 currentController
     currentController = VideoViewController(
       rtcEngine: _engine,
       canvas: const VideoCanvas(uid: 0),
    );
     return true;
  }).whenComplete(() => setState(() {}));
}

 Future<void> _requestPermissionIfNeed() async {
   if (Platform.isMacOS) {
     return;
  }
   await [Permission.microphone, Permission.camera].request();
}

 Future<void> _initEngine() async {
   //创建 RtcEngine
   _engine = createAgoraRtcEngine();
   // 初始化 RtcEngine
   await _engine.initialize(const RtcEngineContext(
     appId: appId,
  ));

   _engine.registerEventHandler(RtcEngineEventHandler(
     // 遇到错误
     onError: (ErrorCodeType err, String msg) {
       if (kDebugMode) {
         print('[onError] err: $err, msg: $msg');
      }
    },
     onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
       // 加入频道成功
       setState(() {
         isJoined = true;
      });
    },
     onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
       // 有用户加入
       setState(() {
         remoteControllers[rUid] = VideoViewController.remote(
           rtcEngine: _engine,
           canvas: VideoCanvas(uid: rUid),
           connection: const RtcConnection(channelId: cid),
        );
      });
    },
     onUserOffline:
        (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
       // 有用户离线
       setState(() {
         remoteControllers.remove(rUid);
      });
    },
     onLeaveChannel: (RtcConnection connection, RtcStats stats) {
       // 离开频道
       setState(() {
         isJoined = false;
         remoteControllers.clear();
      });
    },
  ));

   // 打开视频模块支持
   await _engine.enableVideo();
   // 配置视频编码器,编码视频的尺寸(像素),帧率
   await _engine.setVideoEncoderConfiguration(
     const VideoEncoderConfiguration(
       dimensions: VideoDimensions(width: 640, height: 360),
       frameRate: 15,
    ),
  );

   await _engine.startPreview();
}

 @override
 void dispose() {
   _engine.leaveChannel();
   super.dispose();
}

 @override
 Widget build(BuildContext context) {
   return Scaffold(
       appBar: AppBar(),
       body: Stack(
         children: [
           FutureBuilder<bool?>(
               future: initStatus,
               builder: (context, snap) {
                 if (snap.data != true) {
                   return const Center(
                     child: Text(
                       "初始化ing",
                       style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                 return AgoraVideoView(
                   controller: currentController,
                );
              }),
           Align(
             alignment: Alignment.topLeft,
             child: SingleChildScrollView(
               scrollDirection: Axis.horizontal,
               child: Row(
                 ///增加点击切换
                 children: List.of(remoteControllers.entries.map(
                  (e) => InkWell(
                     onTap: () {
                       setState(() {
                         remoteControllers[e.key] = currentController;
                         currentController = e.value;
                      });
                    },
                     child: SizedBox(
                       width: 120,
                       height: 120,
                       child: AgoraVideoView(
                         controller: e.value,
                      ),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
       floatingActionButton: FloatingActionButton(
         onPressed: () async {
           // 加入频道
           _engine.joinChannel(
             token: token,
             channelId: cid,
             uid: 0,
             options: const ChannelMediaOptions(
               channelProfile:
                   ChannelProfileType.channelProfileLiveBroadcasting,
               clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
       persistentFooterButtons: [
         ElevatedButton.icon(
             onPressed: () {
               _enableVirtualBackground();
            },
             icon: const Icon(Icons.accessibility_rounded),
             label: const Text("虚拟背景")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.setBeautyEffectOptions(
                 enabled: true,
                 options: const BeautyOptions(
                   lighteningContrastLevel:
                       LighteningContrastLevel.lighteningContrastHigh,
                   lighteningLevel: .5,
                   smoothnessLevel: .5,
                   rednessLevel: .5,
                   sharpnessLevel: .5,
                ),
              );
               //_engine.setRemoteUserSpatialAudioParams();
            },
             icon: const Icon(Icons.face),
             label: const Text("美颜")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.setColorEnhanceOptions(
                   enabled: true,
                   options: const ColorEnhanceOptions(
                       strengthLevel: 6.0, skinProtectLevel: 0.7));
            },
             icon: const Icon(Icons.color_lens),
             label: const Text("增强色彩")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.enableSpatialAudio(true);
            },
             icon: const Icon(Icons.surround_sound),
             label: const Text("空间音效")),
         ElevatedButton.icon(
             onPressed: () {                
               _engine.setAudioProfile(
                   profile: AudioProfileType.audioProfileDefault,
                   scenario: AudioScenarioType.audioScenarioGameStreaming);
               _engine
                  .setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);
            },
             icon: const Icon(Icons.surround_sound),
             label: const Text("人声音效")),
      ]);
}

 Future<void> _enableVirtualBackground() async {
   ByteData data = await rootBundle.load("assets/bg.jpg");
   List<int> bytes =
       data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
   Directory appDocDir = await getApplicationDocumentsDirectory();
   String p = path.join(appDocDir.path, 'bg.jpg');
   final file = File(p);
   if (!(await file.exists())) {
     await file.create();
     await file.writeAsBytes(bytes);
  }

   await _engine.enableVirtualBackground(
       enabled: true,
       backgroundSource: VirtualBackgroundSource(
           backgroundSourceType: BackgroundSourceType.backgroundImg,
           source: p),
       segproperty:
           const SegmentationProperty(modelType: SegModelType.segModelAi));
   setState(() {});
}
}

最后


本篇的内容作为上一篇的补充,相对来说内容还是比较简单,不过可以看到不管是在画面处理还是在声音处理上,声网 SDK 都提供了非常便捷的 API 实现,特别在声音处理上,因为文章限制这里只展示了简单的 API 介绍,所以强烈建议大家自己尝试下这些音频 API ,真的非常有趣。


作者:无知小猿
来源:juejin.cn/post/7211388928242352184
收起阅读 »

Android悬浮窗自己踩的2个小坑

最近在做一个全局悬浮窗基于ChatGPT应用快Ai,需要悬浮于在其他应用上面,方便从悬浮窗中,和ChatGPT对话后,对ChatGPT返回的内容拖拽到其他应用内部。快Ai应用本身透明,通过WindowManger添加悬浮窗。类似现在很多应用跳转到其他应用,会悬...
继续阅读 »

最近在做一个全局悬浮窗基于ChatGPT应用快Ai,需要悬浮于在其他应用上面,方便从悬浮窗中,和ChatGPT对话后,对ChatGPT返回的内容拖拽到其他应用内部。快Ai应用本身透明,通过WindowManger添加悬浮窗。类似现在很多应用跳转到其他应用,会悬浮一个小按钮,方便用户点击调回自身一样。只不过快Ai窗口比较大,但不全屏。


碰到以下几个问题:


1、悬浮窗中EditText无法获得弹出键盘


主要是没有明白下面两个属性的作用,在网上搜索之后直接设置了。



  • WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE


设置FLAG_NOT_FOCUSABLE,悬浮窗外的点击才有效,会把事件分发给悬浮窗底层的其他应用Activity。但设置了FLAG_NOT_FOCUSABLE,屏幕上除悬浮窗之外的地方也可以点击、但是悬浮窗上的EditText会掉不起键盘。


此时悬浮窗外的事件是不会触发悬浮窗内ViewonToucheEvent函数,可以通过添加WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH标志位,但无法拦截事件。




  • WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL


    屏幕上除了悬浮窗外能够点击、弹窗上的EditText也可以输入、键盘能够弹出来。




所以根据业务需要,我只需要添加WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL标志位即可。


2、悬浮窗无法录音


通过Activity调起Service,然后在Service通过WindowManager添加悬浮窗。在没有进行任何操作,正常情况下,可以调起科大讯飞进行录音转成文字发给ChatGPT。


问题点一:同事为了解决我还没来得及修复的windowManger.removeView改成exitProcess问题,强行进行各种修改,最终还调用了activityfinish函数,把activity干掉。最终导致无法调起科大讯飞的语音识别。总是报录音权限问题,找不到任何的问题点,网上资料都说没有给录音权限,其实是有的。最后通过代码回退,定位到是Activity被干掉了,同事也承认他的愚蠢行为。


问题点二:在进行一些操作,例如授权跳转到设置之后,退出设置回到原先界面,科大讯飞调不起录音,还是报权限问题。在有了问题点一的经验后,在Activity的各个生命周期打印日志,发现但onResume函数没有被回调到,也就是应用在后台运行时,该问题必现。


所以就一顿顿顿搜索后,找到官方文档:
Android 9 对后台运行的应用增加了权限限制。


image.png


解决方法:



  1. 声明为系统应用,没问题。但我们想做通用软件。

  2. 增加前台服务。实测没效果。

  3. 在2的基础上,再添加一个属性:android:foregroundServiceType="microphone"。完美。


<service android:name=".ui.service.AiService"
android:foregroundServiceType="microphone"
/>

image.png


希望本文对君有用!


作者:新小梦
来源:juejin.cn/post/7211116982513811516
收起阅读 »

Android应用被抓包?防护手段需知道

为了提高网络数据传输的安全性,业内采用HTTPS的方式取代原来的HTTP,Android的应用开发也不例外,我们似乎只需要修改一下域名就能完成http到https的切换,无需做其他额外的操作,那么这个HTTPS是如何实现的?是否真的就安全了?在不同的Andro...
继续阅读 »

为了提高网络数据传输的安全性,业内采用HTTPS的方式取代原来的HTTP,Android的应用开发也不例外,我们似乎只需要修改一下域名就能完成http到https的切换,无需做其他额外的操作,那么这个HTTPS是如何实现的?是否真的就安全了?在不同的Android版本上是否有差异?今天我们就来详细研究一下以上的问题。


Tips:本篇旨在讨论HTTPS传输的安全性,应用本地安全不在讨论范畴。


HTTPS原理



诞生背景



首先就是老生常谈的问题,什么是HTTPS,相信大家有有所了解,这里简单提一下:


由于HTTP协议(HyperText Transfer Protocol,超文本传输协议)中,传输的内容是明文的,请求一旦被劫持,内容就会完全暴露,劫持者可以对其进行窃取或篡改,因此这种数据的传输方式存在极大的安全隐患。


因此,在基于HTTP协议的基础上对传输内容进行加密的HTTPS协议(HyperText Transfer Protocol over Secure Socket Layer)便诞生了,这样即使传输的内容被劫持,由于数据是加密的,劫持者没有对应的密钥也很难对内容进行破解,从而提高的传输的安全性。



密钥协商



既然要对传输的内容进行加密,那就要约定好加密的方式与密钥管理。首先在加密方式的选择上,有对称加密非对称加密两种,这两种方式各有有缺。


对称加密:


加密和解密使用相同的密钥,这种效率比较高,但是存在密钥维护的问题。如果密钥通过请求动态下发,会有泄漏的风险。如果密钥存放到Client端,那么密钥变更时就要重新发版更新密钥,而且如果要请求多个服务器就要维护多个密钥,对于服务器端也是同理,这种密钥的维护成本极高。


非对称加密:


加密和解密使用不同的密钥,即公钥与私钥,私钥存放在Server端,不对外公开,公钥是对外公开的,这样无论是公钥打包进Client端还是由Server端动态下发,也无需担心泄漏的问题。但是这种加密方式效率较低。


HTTPS协议中,结合了对称加密和非对称加密两种方式,取其精华,弃其糟粕,发挥了两者各自的优势。


假设目前Server端有一对密钥,公钥A和私钥A,在Client端发起请求时,Server端下发公钥A给Client端,Client端生成一个会话密钥B,并使用公钥A对会话密钥B进行加密传到Server端,Server端使用私钥A进行解密得到会话密钥B,这时Client端和Server端完成了密钥协商工作,之后Client和和Server端交互的数据都使用会话密钥B进行对称加解密。在密钥协商过程中,就算被劫持,由于劫持者没有私钥A,也无法获取协商的会话密钥B,因此保证了数据传输的安全性。


密钥协商过程简图如下:


密钥协商简图.png



CA证书



上面的过程貌似解决了数据传输的安全问题,但依然有一个漏洞,就是如果劫持者篡改了Server端下发给Client端的公钥的情况。


中间人攻击(MITM攻击)简图如下:


中间人攻击简图.png


为了解决Client端对Server端下发公钥的信任问题,出现了一个被称作CA(Certificate Authority)的机构。


CA机构同样拥有采用非对称加密的公钥和私钥,公钥加上一些其他的信息(组织单位、颁发时间、过期时间等)信息被制作成一个cer/pem/crt等格式的文件,被称作证书,这些CA机构用来给其他组织单位签发证书的证书叫做根证书,根证书一般都会被预装在我们的设备中,被无条件信任


以Android设备为例,我们可以在设置 -> 安全 -> 更多安全设置 -> 加密与凭据 -> 信任的凭据中查看当前设备所有的预装的证书。


设备预装的证书.jpeg


如果Server端部署的证书是正规CA机构签发的证书(CA机构一般不会直接用根证书为企业签发域名证书,而是使用根证书生成的中间证书,一般情况下证书链是三级,根证书-中间证书-企业证书),那么我们在进行HTTPS请求的时候,不需要做其他额外操作,Client端获取到Server端下发的证书会自动与系统预装的证书进行校验,以确认证书是否被篡改。


如果Server端的证书是自签的,则需要在Client端自行处理证书校验规则,否则无法正常完成HTTPS请求。


这也是为什么,我们在Android开发网络请求时,无需做额外操作便能丝滑切换到HTTPS,但是这样真的就能保证网络请求的安全性了吗?


真的安全了吗?


经过上面的介绍我们可以了解到,如果Client端(手机、浏览器)中预装了大量正规CA机构的根证书,Server端如果是正规CA签发的证书,理论上是解决了HTTPS通信中双端的信任问题,但是还存在一个问题,就是这些Client端一般都会支持用户自行安装证书,这将会给Android端的网络安全带来哪些风险?接下来我们就继续来聊聊。


由于Android版本更新迭代较快,且不同版本之前差异较大,因此分析这个问题的时候一定要基于一个特定的系统版本,区别分析。Android 5.0(21)之前的版本太过古老,这里就不再进行分析,直接分析5.0之后的版本。


在一个只采用默认配置的的测试项目中进行HTTPS请求的抓包测试,发现在5.0(包括)到7.0(不包括)之间的版本,可以通过中间人或VPN的方式进行抓包,而7.0及以上版本则无法正常抓包,抓包情况如下



7.0以下手机代理抓包情况:



Android7.0以下.jpeg



7.0及以上手机代理抓包情况:



之所以7.0是个分水岭,是因为在Android7.0之前,系统除了对系统内置CA证书无条件信任外,对用户手动安装的CA证书也无条件信任了。


虽然说7.0及以上的设备不再信用用户自行添加的CA证书,安全性比之前的高很多,但是无门却无法阻止那些抓包的人使用7.0之下的手机,除非提高应用的最小支持版本,但这样就意味着要放弃一些用户,显然也不适用于所有情况。


那么如何在保证低版本兼容性的同时兼顾安全性呢,我们接下来继续探讨。


如何更安全


除了系统默认的安全校验之外,我们也可以通过如下手段来提高请求的安全性,让抓包变得更加困难。



禁用代理



该方式适用于所有Android版本。


在网络请求时,通过调用系统API获取当前网络是否设置了代理,如果设置了就终止请求,达到保护数据安全的目的。因为通过中间人的方式进行抓包,需要把网络请求转发到中间人的代理服务器,如果禁止了代理相当于从源头解决了问题。


优势:设置简单,系统API简单调用即可获取代理状态。


劣势:




  1. 会错杀一些因为其他场景而使用代理的用户,导致这样的用户无法正常使用




  2. 通过开启VPN在VPN上设置代理转发到中间人服务器的方式绕过




由于设置禁用代理的方式很容易被绕过且有可能影响正常开启VPN用户的使用,因此不推荐使用该方式。



数据加密



该方式适用于所有Android版本。


对请求传输的数据进行加密,然后再通过HTTPS协议传输。HTTPS本身在传输过程中会生成一个会话密钥,但是这个密钥可以被抓包获取,如果对传输的数据进行一次加密后再传输,即使被抓包也没法解析出真实的数据。


优势:安全性较高,只要密钥没有泄漏,数据被破获的风险较低


劣势:




  1. 修改同时修改Client端和Server端代码,增加加解密逻辑




  2. 加解密操作影响效率且有密钥维护的成本




在对数据安全性要求比较高的接口上,可以采用这种方式对传输内容进行增强保护。



证书单向认证



该方式适用于所有Android版本。


在默认情况下,HTTPS在握手时,Server端下证书等信息到Client端,Client端校验该证书是否为正规CA机构签发,如果是则通过校验。这里我们可以自定义校验规则,可以下载Server端的证书到打包到APK中,在请求时进行证书校验。


https单向认证.png


优势:安全性高。


劣势:证书容易过期,当前企业证书有效期只有1年,需要每年进行续签,Client需要维护证书更新的问题。



证书双向认证



该方式适用于所有Android版本。


在单向认证中,Client端会验证Server端是否安全,但是Server端并没有对Client进行校验,这里可以让Server端对Client也进行一次认证。这种认证需要在单向认证的基础上再额外创建一套证书B,存放在Client端,并在Client端完成对Server端的校验后,把Client端的公钥证书发送到Server端,由Server端进行校验,校验通过后开始密钥协商等后续步骤。


https双向认证.png


优势:安全性非常高!


劣势:




  1. Server端要存放Client端公钥证书,如果一个Server对应多个Client则需要维护多套




  2. 增加了校验成本,会降低相应速度





网络安全配置文件



该方案为google官方推荐的方案,也是一种证书的单向校验,不过在Android7.0及更高版本上,配置简单,只需要再清单文件的application节点下增加一个networkSecurityConfig项,并指向一个按固定的格式创建一个xml文件,即可完成网络安全的校验,体验相当丝滑,唯一美中不足的是该配置不支持7.0以下的版本。


在7.0及以上版本中,在xml文件夹下创建名为network_security_config_wanandroid的网络安全配置文件:


网络安全文件配置.jpeg


该文件只需要在清单文件application节点的networkSecurityConfig中引用该文件即可,如此就完成了对wanandroid.com域名及其所有次级域名的证书单向认证。


在7.0以下版本中:


由于networkSecurityConfig是7.0版本新增的,因此在所有7.0以下的设备上无法生效,所以针对7.0以下的设备只能通过代码进行认证。推荐使用OkHttp:


okHttp进行证书校验.png


需要注意的是,在通过代码配置指定域名的证书校验时,根域名和次级域名需要分别进行配置。


优势:安全性较高,代码改动少。


劣势:本质还是证书的单向认证。



选择要校验的证书



如果说采取了google推荐的方式进行安全校验,那校验证书链中的哪个证书比较合适呢?


理论上来说,当然是校验企业自己的证书最好,即证书链的第三层企业证书


image.png


但是该层证书的有效期比较短,一般每年都要进行重签,重签之后证书的Sha256就会发生变化,这时候就要及时更新Client端中信息,否则就无法正常完成校验。


为了规避证书频繁过期的问题,我们可以直接对根证书进行校验,一般来说,根证书的有效期是比较长的:


image.png


这样就不用担心证书频繁过期的问题了,但是如果再企业证书续签的时候更换了CA机构,那就必须要更新Client端中的根证书信息了,不过这就是另外的一个问题了。



只校验根证书会不会存在风险?



几乎不会,因为正规的CA机构在给一个企业颁发证书的时候,会有审核机制的,一般不会出现错误办法的状况,但在历史上确实出现过CA机构被骗,将证书颁发给了相应域名之外的人。下面截图来自Google官网:


列入黑名单.png


不过这是非常小概率的事件了,因此校验域名+根证书摘要算是即安全又避免了证书频繁过期的问题,再加上google官方的推荐,算的上是最佳解决方案了。


这篇文章就介绍到这里,感谢观看~~


上号.jpg


作者:王远道
来源:juejin.cn/post/7210688688921821221
收起阅读 »

扒一扒抖音是如何做线程优化的

背景 最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。 问题 创建线程卡顿 在...
继续阅读 »

背景


最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。


问题


创建线程卡顿


在Java中,真正的内核线程被创建是在执行 start函数的时候, nativeCreate的具体流程可以参考我之前的一篇分析文章 Android虚拟机线程启动过程解析 。这里假设你已经了解了,我们可以可以知道 start()函数底层涉及到一系列的操作,包括 栈内存空间分配、内核线程创建 等操作,这些操作在某些情况下可能出现长耗时现象,比如由于linux系统中,所有系统线程的创建在内核层是由一个专门的线程排队实现,那么是否可能由于队列较长同时内核调度出现问题而出现长耗时问题? 具体的原因因为没有在线下复现过此类问题,因此只能大胆猜测,不过在线上确实收集到一些case, 以下是线上收集到一个阻塞现场样本:



那么是不是不要直接在主线程创建其他线程,而是直接使用线程池调度任务就没有问题? 让我们看下 ThreadPoolExecutor.execute(Runnable command)的源码实现



从文档中可以知道,execute函数的执行在很多情况下会创建(JavaThread)线程,并且跟踪其内部实现后可以发现创建Java线程对象后,也会立即在当前线程执行start函数。



来看一下线上收集到的一个在主线程使用线程池调度任务依旧发生卡顿的现场。



线程数过多的问题


在ART虚拟机中,每创建一个线程都需要为其分配独立的Java栈空间,当Java层未显示设置栈空间大小时,native层会在FixStackSize函数会分配默认的栈空间大小.



从这个实现中,可以看出每个线程至少会占用1M的虚拟内存大小,而在32位系统上,由于每个进程可分配的用户用户空间虚拟内存大小只有3G,如果一个应用的线程数过多,而当进程虚拟内存空间不足时,创建线程的动作就可能导致OOM问题.



另一个问题是某些厂商的应用所能创建的线程数相比原生Android系统有更严格的限制,比如某些华为的机型限制了每个进程所能创建的线程数为500, 因此即使是64位机型,线程数不做控制也可能出现因为线程数过多导致的OOM问题。


优化思路


线程收敛


首先在一个Android App中存在以下几种情况会使用到线程



  • 通过 Thread类 直接创建使用线程

  • 通过 ThreadPoolExecutor 使用线程

  • 通过 ThreadTimer 使用线程

  • 通过 AsyncTask 使用线程

  • 通过 HandlerThread 使用线程


线程收敛的大致思路是, 我们会预先创建上述几个类的实现类,并在自己的实现类中做修改, 之后通过编译期的字节码修改,将App中上述使用线程的地方都替换为我们的实现类。


使用以上线程相关类一般有几种方式:



  1. 直接通过 new 原生类 创建相关实例

  2. 继承原生类,之后在代码中 使用 new 指令创建自己的继承类实例


因此这里的替换包括:



  • 修改类的继承关系,比如 将所有 继承 Thread类的地方,替换为 我们实现 的 PThread

  • 修改上述几种类直接创建实例的地方,比如将代码中存在 new ThreadPoolExecutor(..) 调用的地方替换为 我们实现的 PThreadPoolExecutor


通过字码码修改,将代码中所有使用线程的地方替换为我们的实现类后,就可以在我们的实现类做一些线程收敛的操作。


Thread类 线程收敛


在Java虚拟机中,每个Java Thread 都对应一个内核线程,并且线程的创建实际上是在调用 start()函数才开始创建的,那么我们其实可以修改start()函数的实现,将其任务调度到指定的一个线程池做执行, 示例代码如下


class ThreadProxy : Thread() {
override fun start() {
SuperThreadPoolExecutor.execute({
this@ThreadProxy.run()
}, priority = priority)
}
}

线程池 线程收敛


由于每个ThreadPoolExecutor实例内部都有独立的线程缓存池,不同ThreadPoolExecutor实例之间的缓存互不干扰,在一个大型App中可能存在非常多的线程池,所有的线程池加起来导致应用的最低线程数不容小视。


另外也因为线程池是独立的,线程的创建和回收也都是独立的,不能从整个App的任务角度来调度。举个例子: 比如A线程池因为空闲正在释放某个线程,同时B线程池确可能正因为可工作线程数不足正在创建线程,如果可以把所有的线程池合并成 一个统一的大线程池,就可以避免类似的场景。


核心的实现思路为:



  1. 首先将所有直接继承 ThreadPoolExecutor的类替换为 继承 ThreadPoolExecutorProxy,以及代码中所有new ThreadPoolExecutor(..)类 替换为 new ThreadPoolExecutorProxy(...)

  2. ThreadPoolExecutorProxy 持有一个 大线程池实例 BigThreadPool ,该线程池实例为应用中所有线程池共用,因此其核心线程数可以根据应用当前实际情况做调整,比如如果你的应用当前线程数平均是200,你可以将BigThreadPool 核心线程设置为150后,再观察其调度情况。

  3. 在 ThreadPoolExecutorProxy 的 addWorker 函数中,将任务调度到 BigThreadPool中执行



AsyncTask 线程收敛


对于AsyncTask也可以用同样的方式实现,在execute1函数中调度到一个统一的线程池执行



public abstract class AsyncTaskProxy<Params,Progress,Result> extends AsyncTask<Params,Progress,Result>{

private static final Executor THREAD_POOL_EXECUTOR = new PThreadPoolExecutor(0,20,
3, TimeUnit.MILLISECONDS,
new SynchronousQueue<>(),new DefaultThreadFactory("PThreadAsyncTask"));


public static void execute(Runnable runnable){
THREAD_POOL_EXECUTOR.execute(runnable);
}

/**
* TODO 使用插桩 将所有 execute 函数调用替换为 execute1
* @param params The parameters of the task.
* @return This instance of AsyncTask.
*/

public AsyncTask<Params, Progress, Result> execute1(Params... params) {
return executeOnExecutor(THREAD_POOL_EXECUTOR,params);
}


}

Timer类


Timer类一般项目中使用的地方并不多,并且由于Timer一般对任务间隔准确性有比较高的要求,如果收敛到线程池执行,如果某些Timer类执行的task比较耗时,可能会影响原业务,因此暂不做收敛。


卡顿优化


针对在主线程执行线程创建可能会出现的阻塞问题,可以判断下当前线程,如果是主线程则调度到一个专门负责创建线程的线程进行工作。


    private val asyncExecuteHandler  by lazy {
val worker = HandlerThread("asyncExecuteWorker")
worker.start()
return@lazy Handler(worker.looper)
}


fun execute(runnable: Runnable, priority: Int) {
if (Looper.getMainLooper().thread == Thread.currentThread() && asyncExecute
){
//异步执行
asyncExecuteHandler.post {
mExecutor.execute(runnable,priority)
}
}else{
mExecutor.execute(runnable, priority)
}

}

32位系统线程栈空间优化


在问题分析中的环节中,我们已经知道 每个线程至少需要占用 1M的虚拟内存,而32位应用的虚拟内存空间又有限,如果希望在线程这里挤出一点虚拟内存空间来,可以参考微信的一个方案, 其利用PLT hook需改了创建线程时的栈空间大小。


而在另一篇 juejin.cn/post/720930… 技术文章中,也介绍了另一个取巧的方案 :在Java层直接配置一个 负值,从而起到一样的效果



OOM了? 我还能再抢救下!


针对在创建线程时由于内存空间不足或线程数限制抛出的OOM问题,可以做一些兜底处理, 比如将任务调度到一个预先创建的线程池进行排队处理, 而这个线程池核心线程和最大线程是一致的 因此不会出现创建线程的动作,也就不会出现OOM异常了。



另外由于一个应用可能会存在非常多的线程池,每个线程池都会设置一些核心线程数,要知道默认情况下核心线程是不会被回收的,即使一直处于空闲状态,该特性是由线程池的 allowCoreThreadTimeOut控制。



该参数值可通过 allowCoreThreadTimeOut(value) 函数修改



从具体实现中可以看出,当value值和当前值不同 且 value 为true时 会触发 interruptIdleWorkers()函数, 在该函数中,会对空闲Worker 调用 interrupt来中断对应线程



因此当创建线程出现OOM时,可以尝试通过调用线程池的 allowCoreThreadTimeOut 来触发 interruptIdleWorkers 实现空闲线程的回收。 具体实现代码如下:



因此我们可以在每个线程池创建后,将这些线程池用弱引用队列保存起来,当线程start 或者某个线程池execute 出现OOM异常时,通过这种方式来实现线程回收。


线程定位


线程定位 主要是指在进行问题分析时,希望直接从线程名中定位到创建该线程的业务,关于此类优化的文章网上已经介绍的比较多了,基本实现是通过ASM 修改调用函数,将当前类的类名或类名+函数名作为兜底线程名设置。这里就不详细介绍了,感兴趣的可以看 booster 中的实现



字节码修改工具


前文讲了一些优化方式,其中涉及到一个必要的操作是进行字节码修改,这些需求可以概括为如下



  • 替换类的继承关系,比如将 所有继承于 java.lang.Thread的类,替换为我们自己实现的 ProxyThread

  • 替换 new 指令的实例类型,比如将代码中 所有 new Thread(..) 的调用替换为 new ProxyThread(...)


针对这些通用的修改,没必要每次遇到类似需求时都 进行插件的单独开发,因此我将这种修改能力集成到 LanceX插件中,我们可以通过以下 注解方便实现上述功能。


替换 new 指令


@Weaver
@Group("threadOptimize")
public class ThreadOptimize {

@ReplaceNewInvoke(beforeType = "java.lang.Thread",
afterType = "com.knightboost.lancetx.ProxyThread")
public static void replaceNewThread(){
}

}

这里的 beforeType表示原类型,afterType 表示替换后的类型,使用该插件在项目编译后,项目中的如下源码



会被自动替换为



替换类的继承关系


@Weaver
@Group("threadOptimize")
public class ThreadOptimize {

@ChangeClassExtends(
beforeExtends = "java.lang.Thread",
afterExtends = "com.knightboost.lancetx.ProxyThread"
)
public void changeExtendThread(){};



}

这里的beforeExtends表示 原继承父类,afterExtends表示修改后的继承父类,在项目编译后,如下源码



会被自动替换为



总结


本文主要介绍了有关线程的几个方面的优化



  • 主线程创建线程耗时优化

  • 线程数收敛优化

  • 线程默认虚拟空间优化

  • OOM优化


这些不同的优化手段需要根据项目的实际情况进行选择,比如主线程创建线程优化的实现方面比较简单、影响面也比较低,可以优先实施。 而线程数收敛需要涉及到字节码插桩、各种对象代理 复杂度会高一些,可以根据当前项目的实际线程数情况再考虑是否需要优化。


线程OOM问题主要出现在低端设备 或一些特定厂商的机型上,可能对于某些大厂的用户基数来说有一定的收益,如果你的App日活并没有那么大,这个优化的优先级也是较低的。


性能优化专栏历史文章:


文章地址
监控Android Looper Message调度的另一种姿势juejin.cn/post/713974…
Android 高版本采集系统CPU使用率的方式juejin.cn/post/713503…
Android 平台下的 Method Trace 实现及应用juejin.cn/post/710713…
Android 如何解决使用SharedPreferences 造成的卡顿、ANR问题juejin.cn/post/705476…
基于JVMTI 实现性能监控juejin.cn/post/694278…

参考资料


1.某音App


2.内核线程创建流程


3.juejin.cn/post/720930… 虚拟内存优化: 线程 + 多进程优化


4.github.com/didi/booste…


作者:卓修武K
来源:juejin.cn/post/7212446354920407096
收起阅读 »

Android无需权限调起系统相机拍照

在进行一些小型APP的开发,或者是对拍照界面没有自定义要求时,我们可以用调起系统相机的方式快速完成拍照需求 和不需读写权限进行读写操作的方案一样,都是通过Intent启动系统的activity让用户进行操作,系统再将用户操作的结果告诉我们,因为过程对APP是完...
继续阅读 »

在进行一些小型APP的开发,或者是对拍照界面没有自定义要求时,我们可以用调起系统相机的方式快速完成拍照需求


和不需读写权限进行读写操作的方案一样,都是通过Intent启动系统的activity让用户进行操作,系统再将用户操作的结果告诉我们,因为过程对APP是完全透明的,所以不会侵犯用户隐私。


有两种方法可以调起系统相机拍照获取图片,我们先讲比较简单的一种


1、直接获取用户拍照结果

val launcher = registerForActivityResult(ActivityResultContracts.TakePicturePreview()) {bitmap->
bitmap ?: return@registerForActivityResult
vm.process(bitmap)
}

launcher.launch("image/*")

这个在旧版本的API中就等于


startActivityForResult(Intent(MediaStore.ACTION_IMAGE_CAPTURE),CODE)

等到用户完成拍照,返回我们的activity时,我们就可以得到一张经过压缩的bitmap。这个方法很简单,它的缺点就是获得的bitmap像素太低了,如果对图片像素有要求的话需要使用第二种方法


2、用户拍照之后指定相机将未压缩的图片存放到我们指定的目录

var uri: Uri? = null

val launcher =
registerForActivityResult(ActivityResultContracts.TakePicture()) {
if(it){
uri?.let { it1 -> vm.process(it1) }
}
}

val picture = File(externalCacheDir?.path, "picture")
picture.mkdirs()
uri = FileProvider.getUriForFile(
this,
"${BuildConfig.APPLICATION_ID}.fileprovider",
File(picture, "cache")
)
launcher.launch(uri)

这里我逐行进行解释:



  1. 首先,我们需要指定拍摄的照片要存到哪,所以我们先指定图片的存放路径为externalCacheDir.path/picture/cache 注意这张图片在文件系统中的名字就叫做cache了(没有文件后缀)。

  2. 然后我们通过FileProvider构建一个有授权的Uri给系统相机,相机程序拿到我们的临时授权,才有权限将文件存放到APP的私有目录。

  3. 系统相机拍照完成之后就会走到回调,如果resultCode为RESULT_OK才说明用户成功拍照并保存图片了。这样我们就能得到一张系统相机拍出来的原图的Uri,这样我们就可以用这张图片去处理业务了。


注意:使用方法二需要用到FileProvider,所以我们还要在AndroidManifest里声明


<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>

@xml/provider_paths是我们授权访问的文件路径,这里我写的是


<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="."/>
</paths>

关于这个"path.xml",其实还有一些可以补充说明的,后面有空会补上,这里我简单说明一下:


因为我们创建临时文件的时候,文件指定的目录是externalCacheDir?.path,对应的path就是external-cache-path,表示我们要临时授权的目录是externalCacheDir,如果文件目录指定的是其他路径,那path节点也需要改成代表对应文件夹的节点,这样其他应用才能访问到

作者:用户5944254635000
来源:juejin.cn/post/7211400484104388663
我们APP的私有目录

收起阅读 »

从framework角度看app保活问题

问题背景 最近在群里看到群友在讨论app保活的问题,回想之前做应用(运动类)开发时也遇到过类似的需求,于是便又来了兴趣,果断加入其中,和群友展开了激烈的讨论 不少群友的想法和我当初的想法一样,这特么保活不是看系统的心情么,系统想让谁活谁才能活,作为app开发...
继续阅读 »

问题背景


最近在群里看到群友在讨论app保活的问题,回想之前做应用(运动类)开发时也遇到过类似的需求,于是便又来了兴趣,果断加入其中,和群友展开了激烈的讨论


保活


不少群友的想法和我当初的想法一样,这特么保活不是看系统的心情么,系统想让谁活谁才能活,作为app开发者,根本无能为力,可真的是这样的吗?


保活方案


首先,我整理了从古到今,app开发者所使用过的以及当前还在使用的保活方式,主要思路有两个:保活和复活


保活的方案有:



  • 1像素惨案




  • 后台无声音乐




  • 前台service




  • 心跳机制




  • socket长连接




  • 无障碍服务




  • ......




复活的方案有:


  • 双进程守护(java层和native层)

  • JobScheduler定时任务

  • 推送/相互唤醒

  • ......


不难看出,app开发者为了能让自己的应用多存活一会儿,可谓是绞尽脑汁,但即使这样,随着Android系统升级,尤其是进入8.0之后,系统对应用的限制越来越高,传统的保活方式已经不生效,这让Android开发者手足无措,于是乎,出现了一种比较和谐的保活方式:



  • 引导用户开启手机白名单


这也是目前绝大多数应用所采用的的方式,相对于传统黑科技而言,此方式显得不那么流氓,比较容易被用户所接受。


但跟微信这样的国民级应用比起来,保活效果还是差了一大截,那么微信是怎么实现保活的呢?或者回到我们开头的问题,应用的生死真的只能靠系统调度吗?开发者能否干预控制呢?


进程调度原则


解开这个疑问之前,我们需要了解一下Android系统进程调度原则,主要介绍framework中承载四大组件的进程是如何根据组件状态而动态调节自身状态的。进程有两个比较重要的状态值:




  • oom_adj,定义在frameworks/base/services/core/java/com/android/server/am/ProcessList.java当中




  • procState,定义在frameworks/base/core/java/android/app/ActivityManager.java当中




OOM_ADJ

以Android10的源码为例,oom_adj划分为20级,取值范围[-10000,1001],Android6.0以前的取值范围是[-17,16]




  • oom_adj值越大,优先级越低




  • oom_adj<0的进程都是系统进程。




public final class ProcessList {
static final String TAG = TAG_WITH_CLASS_NAME ? "ProcessList" : TAG_AM;

// The minimum time we allow between crashes, for us to consider this
// application to be bad and stop and its services and reject broadcasts.
static final int MIN_CRASH_INTERVAL = 60 * 1000;

// OOM adjustments for processes in various states:

// Uninitialized value for any major or minor adj fields
static final int INVALID_ADJ = -10000;

// Adjustment used in certain places where we don't know it yet.
// (Generally this is something that is going to be cached, but we
// don't know the exact value in the cached range to assign yet.)
static final int UNKNOWN_ADJ = 1001;

// This is a process only hosting activities that are not visible,
// so it can be killed without any disruption.
static final int CACHED_APP_MAX_ADJ = 999;
static final int CACHED_APP_MIN_ADJ = 900;

// This is the oom_adj level that we allow to die first. This cannot be equal to
// CACHED_APP_MAX_ADJ unless processes are actively being assigned an oom_score_adj of
// CACHED_APP_MAX_ADJ.
static final int CACHED_APP_LMK_FIRST_ADJ = 950;

// Number of levels we have available for different service connection group importance
// levels.
static final int CACHED_APP_IMPORTANCE_LEVELS = 5;

// The B list of SERVICE_ADJ -- these are the old and decrepit
// services that aren't as shiny and interesting as the ones in the A list.
static final int SERVICE_B_ADJ = 800;

// This is the process of the previous application that the user was in.
// This process is kept above other things, because it is very common to
// switch back to the previous app. This is important both for recent
// task switch (toggling between the two top recent apps) as well as normal
// UI flow such as clicking on a URI in the e-mail app to view in the browser,
// and then pressing back to return to e-mail.
static final int PREVIOUS_APP_ADJ = 700;

// This is a process holding the home application -- we want to try
// avoiding killing it, even if it would normally be in the background,
// because the user interacts with it so much.
static final int HOME_APP_ADJ = 600;

// This is a process holding an application service -- killing it will not
// have much of an impact as far as the user is concerned.
static final int SERVICE_ADJ = 500;

// This is a process with a heavy-weight application. It is in the
// background, but we want to try to avoid killing it. Value set in
// system/rootdir/init.rc on startup.
static final int HEAVY_WEIGHT_APP_ADJ = 400;

// This is a process currently hosting a backup operation. Killing it
// is not entirely fatal but is generally a bad idea.
static final int BACKUP_APP_ADJ = 300;

// This is a process bound by the system (or other app) that's more important than services but
// not so perceptible that it affects the user immediately if killed.
static final int PERCEPTIBLE_LOW_APP_ADJ = 250;

// This is a process only hosting components that are perceptible to the
// user, and we really want to avoid killing them, but they are not
// immediately visible. An example is background music playback.
static final int PERCEPTIBLE_APP_ADJ = 200;

// This is a process only hosting activities that are visible to the
// user, so we'd prefer they don't disappear.
static final int VISIBLE_APP_ADJ = 100;
static final int VISIBLE_APP_LAYER_MAX = PERCEPTIBLE_APP_ADJ - VISIBLE_APP_ADJ - 1;

// This is a process that was recently TOP and moved to FGS. Continue to treat it almost
// like a foreground app for a while.
// @see TOP_TO_FGS_GRACE_PERIOD
static final int PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ = 50;

// This is the process running the current foreground app. We'd really
// rather not kill it!
static final int FOREGROUND_APP_ADJ = 0;

// This is a process that the system or a persistent process has bound to,
// and indicated it is important.
static final int PERSISTENT_SERVICE_ADJ = -700;

// This is a system persistent process, such as telephony. Definitely
// don't want to kill it, but doing so is not completely fatal.
static final int PERSISTENT_PROC_ADJ = -800;

// The system process runs at the default adjustment.
static final int SYSTEM_ADJ = -900;

// Special code for native processes that are not being managed by the system (so
// don't have an oom adj assigned by the system).
static final int NATIVE_ADJ = -1000;

// Memory pages are 4K.
static final int PAGE_SIZE = 4 * 1024;

//省略部分代码
}

ADJ级别取值说明(可参考源码注释)
INVALID_ADJ-10000未初始化adj字段时的默认值
UNKNOWN_ADJ1001缓存进程,无法获取具体值
CACHED_APP_MAX_ADJ999不可见activity进程的最大值
CACHED_APP_MIN_ADJ900不可见activity进程的最小值
CACHED_APP_LMK_FIRST_ADJ950lowmemorykiller优先杀死的级别值
SERVICE_B_ADJ800旧的service的
PREVIOUS_APP_ADJ700上一个应用,常见于应用切换场景
HOME_APP_ADJ600home进程
SERVICE_ADJ500创建了service的进程
HEAVY_WEIGHT_APP_ADJ400后台的重量级进程,system/rootdir/init.rc文件中设置
BACKUP_APP_ADJ300备份进程
PERCEPTIBLE_LOW_APP_ADJ250受其他进程约束的进程
PERCEPTIBLE_APP_ADJ200可感知组件的进程,比如背景音乐播放
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ50最近运行的后台进程
FOREGROUND_APP_ADJ0前台进程,正在与用户交互
PERSISTENT_SERVICE_ADJ-700系统持久化进程已绑定的进程
PERSISTENT_PROC_ADJ-800系统持久化进程,比如telephony
SYSTEM_ADJ-900系统进程
NATIVE_ADJ-1000native进程,不受系统管理

可以通过cat /proc/进程id/oom_score_adj查看目标进程的oom_adj值,例如我们查看电话的adj


dialer_oom_adj


值为935,处于不可见进程的范围内,当我启动电话app,再次查看


dialer_oom_adj_open


此时adj值为0,也就是正在与用户交互的进程


ProcessState

process_state划分为23类,取值范围为[-1,21]


@SystemService(Context.ACTIVITY_SERVICE)
public class ActivityManager {
//省略部分代码
/** @hide Not a real process state. */
public static final int PROCESS_STATE_UNKNOWN = -1;

/** @hide Process is a persistent system process. */
public static final int PROCESS_STATE_PERSISTENT = 0;

/** @hide Process is a persistent system process and is doing UI. */
public static final int PROCESS_STATE_PERSISTENT_UI = 1;

/** @hide Process is hosting the current top activities. Note that this covers
* all activities that are visible to the user. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_TOP = 2;

/** @hide Process is hosting a foreground service with location type. */
public static final int PROCESS_STATE_FOREGROUND_SERVICE_LOCATION = 3;

/** @hide Process is bound to a TOP app. This is ranked below SERVICE_LOCATION so that
* it doesn't get the capability of location access while-in-use. */

public static final int PROCESS_STATE_BOUND_TOP = 4;

/** @hide Process is hosting a foreground service. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_FOREGROUND_SERVICE = 5;

/** @hide Process is hosting a foreground service due to a system binding. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_BOUND_FOREGROUND_SERVICE = 6;

/** @hide Process is important to the user, and something they are aware of. */
public static final int PROCESS_STATE_IMPORTANT_FOREGROUND = 7;

/** @hide Process is important to the user, but not something they are aware of. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_IMPORTANT_BACKGROUND = 8;

/** @hide Process is in the background transient so we will try to keep running. */
public static final int PROCESS_STATE_TRANSIENT_BACKGROUND = 9;

/** @hide Process is in the background running a backup/restore operation. */
public static final int PROCESS_STATE_BACKUP = 10;

/** @hide Process is in the background running a service. Unlike oom_adj, this level
* is used for both the normal running in background state and the executing
* operations state. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_SERVICE = 11;

/** @hide Process is in the background running a receiver. Note that from the
* perspective of oom_adj, receivers run at a higher foreground level, but for our
* prioritization here that is not necessary and putting them below services means
* many fewer changes in some process states as they receive broadcasts. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_RECEIVER = 12;

/** @hide Same as {@link #PROCESS_STATE_TOP} but while device is sleeping. */
public static final int PROCESS_STATE_TOP_SLEEPING = 13;

/** @hide Process is in the background, but it can't restore its state so we want
* to try to avoid killing it. */

public static final int PROCESS_STATE_HEAVY_WEIGHT = 14;

/** @hide Process is in the background but hosts the home activity. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_HOME = 15;

/** @hide Process is in the background but hosts the last shown activity. */
public static final int PROCESS_STATE_LAST_ACTIVITY = 16;

/** @hide Process is being cached for later use and contains activities. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_CACHED_ACTIVITY = 17;

/** @hide Process is being cached for later use and is a client of another cached
* process that contains activities. */

public static final int PROCESS_STATE_CACHED_ACTIVITY_CLIENT = 18;

/** @hide Process is being cached for later use and has an activity that corresponds
* to an existing recent task. */

public static final int PROCESS_STATE_CACHED_RECENT = 19;

/** @hide Process is being cached for later use and is empty. */
public static final int PROCESS_STATE_CACHED_EMPTY = 20;

/** @hide Process does not exist. */
public static final int PROCESS_STATE_NONEXISTENT = 21;
//省略部分代码
}

state级别取值说明(可参考源码注释)
PROCESS_STATE_UNKNOWN-1不是真正的进程状态
PROCESS_STATE_PERSISTENT0持久化的系统进程
PROCESS_STATE_PERSISTENT_UI1持久化的系统进程,并且正在操作UI
PROCESS_STATE_TOP2处于栈顶Activity的进程
PROCESS_STATE_FOREGROUND_SERVICE_LOCATION3运行前台位置服务的进程
PROCESS_STATE_BOUND_TOP4绑定到top应用的进程
PROCESS_STATE_FOREGROUND_SERVICE5运行前台服务的进程
PROCESS_STATE_BOUND_FOREGROUND_SERVICE6绑定前台服务的进程
PROCESS_STATE_IMPORTANT_FOREGROUND7对用户很重要的前台进程
PROCESS_STATE_IMPORTANT_BACKGROUND8对用户很重要的后台进程
PROCESS_STATE_TRANSIENT_BACKGROUND9临时处于后台运行的进程
PROCESS_STATE_BACKUP10备份进程
PROCESS_STATE_SERVICE11运行后台服务的进程
PROCESS_STATE_RECEIVER12运动广播的后台进程
PROCESS_STATE_TOP_SLEEPING13处于休眠状态的进程
PROCESS_STATE_HEAVY_WEIGHT14后台进程,但不能恢复自身状态
PROCESS_STATE_HOME15后台进程,在运行home activity
PROCESS_STATE_LAST_ACTIVITY16后台进程,在运行最后一次显示的activity
PROCESS_STATE_CACHED_ACTIVITY17缓存进程,包含activity
PROCESS_STATE_CACHED_ACTIVITY_CLIENT18缓存进程,且该进程是另一个包含activity进程的客户端
PROCESS_STATE_CACHED_RECENT19缓存进程,且有一个activity是最近任务里的activity
PROCESS_STATE_CACHED_EMPTY20空的缓存进程,备用
PROCESS_STATE_NONEXISTENT21不存在的进程

进程调度算法

frameworks/base/services/core/java/com/android/server/am/OomAdjuster.java中,有三个核心方法用于计算和更新进程的oom_adj值



  • updateOomAdjLocked():更新adj,当目标进程为空,或者被杀则返回false,否则返回true。

  • computeOomAdjLocked():计算adj,计算成功返回true,否则返回false。

  • applyOomAdjLocked():应用adj,当需要杀掉目标进程则返回false,否则返回true。


adj更新时机

也就是updateOomAdjLocked()被调用的时机。通俗的说,只要四大组件被创建或者状态发生变化,或者当前进程绑定了其他进程,都会触发adj更新,具体可在源码中查看此方法被调用的地方,比较多,这里就不列举了


adj的计算过程

computeOomAdjLocked()计算过程相当复杂,将近1000行代码,这里就不贴了,有兴趣可自行查看,总体思路就是根据当前进程的状态,设置对应的adj值,因为状态值很多,所以会有很多个if来判断每个状态是否符合,最终计算出当前进程属于哪种状态。


adj的应用

计算得出的adj值将发送给lowmemorykiller(简称lmk),由lmk来决定进程的生死,不同的厂商,lmk的算法略有不同,下面是源码中对lmk的介绍


/* drivers/misc/lowmemorykiller.c
*
* The lowmemorykiller driver lets user-space specify a set of memory thresholds
* where processes with a range of oom_score_adj values will get killed. Specify
* the minimum oom_score_adj values in
* /sys/module/lowmemorykiller/parameters/adj and the number of free pages in
* /sys/module/lowmemorykiller/parameters/minfree. Both files take a comma
* separated list of numbers in ascending order.
*
* For example, write "0,8" to /sys/module/lowmemorykiller/parameters/adj and
* "1024,4096" to /sys/module/lowmemorykiller/parameters/minfree to kill
* processes with a oom_score_adj value of 8 or higher when the free memory
* drops below 4096 pages and kill processes with a oom_score_adj value of 0 or
* higher when the free memory drops below 1024 pages.
*
* The driver considers memory used for caches to be free, but if a large
* percentage of the cached memory is locked this can be very inaccurate
* and processes may not get killed until the normal oom killer is triggered.
*
* Copyright (C) 2007-2008 Google, Inc.
*
* This software is licensed under the terms of the GNU General Public
* License version 2, as published by the Free Software Foundation, and
* may be copied, distributed, and modified under those terms.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
*/


保活核心思路


根据上面的Android进程调度原则得知,我们需要尽可能降低app进程的adj值,从而减少被lmk杀掉的可能性,而我们传统的保活方式最终目的也是降低adj值。而根据adj等级分类可以看出,通过应用层的方式最多能将adj降到100~200之间,我分别测试了微信、支付宝、酷狗音乐,启动后返回桌面并息屏,测试结果如下


微信测试结果:


weixin_oom_adj


微信创建了两个进程,查看这两个进程的adj值均为100,对应为adj等级表中的VISIBLE_APP_ADJ,此结果为测试机上微信未登录状态测试结果,当换成我的小米8测试后发现,登录状态下的微信有三个进程在运行


weixin_login_oom_adj


后查阅资料得知,进程名为com.tencent.soter.soterserver的进程是微信指纹支付,此进程的adj值居然为-800,上面我们说过,adj小于0的进程为系统进程,那么微信是如何做到创建一个系统进程的,我和我的小伙伴都惊呆了o.o,为此,我对比了一下支付宝的测试结果


支付宝测试结果:


alipay_oom_adj


支付宝创建了六个进程,查看这六个进程的adj值,除了一个为915,其余均为0,怎么肥事,0就意味着正在与用户交互的前台进程啊,我的世界要崩塌了,只有一种可能,支付宝通过未知的黑科技降低了adj值。


酷狗测试结果:


kugou_oom_adj.png


酷狗创建了两个进程,查看这两个进程的adj值分别为700、200,对应为adj等级表中的PREVIOUS_APP_ADJPERCEPTIBLE_APP_ADJ,还好,这个在意料之中。


测试思考


通过上面三个app的测试结果可以看出,微信和支付宝一定是使用了某种保活手段,让自身的adj降到最低,尤其是微信,居然可以创建系统进程,简直太逆天了,这是应用层绝对做不到的,一定是在native层完成的,但具体什么黑科技就不得而知了,毕竟反编译技术不是我的强项。


正当我郁郁寡欢之时,我想起了前两天看过的一篇文章《当 App 有了系统权限,真的可以为所欲为?》,文章讲述了第三方App如何利用CVE漏洞获取到系统权限,然后神不知鬼不觉的干一些匪夷所思的事儿,这让我茅塞顿开,或许这些大厂的app就是利用了系统漏洞来保活的,不然真的就说不通了,既然都能获取到系统权限了,那创建个系统进程不是分分钟的事儿吗,还需要啥厂商白名单。


总结


进程保活是一把双刃剑,增加app存活时间的同时牺牲的是用户手机的电量,内存,cpu等资源,甚至还有用户的忍耐度,作为开发者一定要合理取舍,不要为了保活而保活,即使需要保活,也尽量采用白色保活手段,别让用户手机变板砖,然后再来哭爹骂娘。


参考资料:


探讨Android6.0及以上系统APP常驻内存(保活)实现-争宠篇


探讨Android6.0及以上系统APP常驻内存(保活)实现-复活篇


探讨一种新型的双进程守护应用保活


史上最强Android保活思路:深入剖析腾讯TIM的进程永生技术


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


「 深蓝洞察 」2022 年度最“不可赦”漏洞


作者:小迪vs同学
来源:juejin.cn/post/7210375037114138680
收起阅读 »

Android记一次JNI内存泄漏

记一次JNI内存泄漏 前景 在视频项目播放界面来回退出时,会触发内存LeakCanary内存泄漏警告。 分析 查看leakCanary的日志没有看到明确的泄漏点,所以直接取出leakCanary保存的hprof文件,保存目录在日志中有提醒,需要注意的是如果是a...
继续阅读 »

记一次JNI内存泄漏


前景


在视频项目播放界面来回退出时,会触发内存LeakCanary内存泄漏警告。


分析


查看leakCanary的日志没有看到明确的泄漏点,所以直接取出leakCanary保存的hprof文件,保存目录在日志中有提醒,需要注意的是如果是android11系统及以上的保存目录和android11以下不同,android11保存的目录在:


   /data/media/10/Download/leakcanary-包名/2023-03-14_17-19-45_115.hprof 

使用Memory Analyzer Tool(简称MAT) 工具进行分析,需要讲上面的hrof文件转换成mat需要的格式:


   hprof-conv -z 转换的文件 转换后的文件

hprof-conv -z 2023-03-14_17-19-45_115.hprof mat115.hprof

打开MAT,导入mat115文件,等待一段时间。


在预览界面打开Histogram,搜索需要检测的类,如:VideoActivity


screenshot-20230314-204413.png


搜索结果查看默认第一栏,如果没有泄漏,关闭VideoActivity之后,Objects数量一般是零,如果不为零,则可能存在泄漏。


右键Merge Shortest Paths to GC Roots/exclude all phantom/weak/soft etc,references/ 筛选出强引用的对象。


image.png


筛选出结果后,出现com.voyah.cockpit.video.ui.VideoActivity$1 @0x3232332 JIN Global 信息,且无法继续跟踪下去。


screenshot-20230314-205257.png


筛选出结果之后显示有六个VideoActivity对象没有释放,点击该对象也无法看到GC对象路径。(正常的java层内存泄漏能够看到泄漏的对象具体是哪一个)


正常的内存泄漏能够看到具体对象,如图:


image.png
这个MegaDataStorageConfig就是存在内存泄漏。


而我们现在的泄漏确实只知道VideoActivity$1 对象泄漏了,没有具体的对象,这样就没有办法跟踪下去了。


解决办法:


虽然无法继续跟踪,但泄漏的位置说明就是这个VideoActivity1,我们可以解压apk,在包内的class.dex中找到VideoActivity1 ,我们可以解压apk,在包内的class.dex中找到VideoActivity1这个Class类(class.dex可能有很多,一个个找),打开这个class,查看字节码(可以android studio中快捷打开build中的apk),根据【 .line 406 】等信息定位代码的位置,找到泄漏点。


screenshot-20230314-205442.png


screenshot-20230314-205600.png
screenshot-20230314-205523.png


根据方法名、代码行数、类名,直接定位到了存在泄漏的代码:


screenshot-20230314-205730.png


红框区内就是内存泄漏的代码,这个回调是一个三方sdk工具,我使用时进行了注册,在onDestory中反注册,但还是存在内存泄漏。(该对象未使用是我代码修改之后的)


修改方法


将这个回调移动到Application中去,然后进行事件或者回调的方式通知VideoActivity,在VideoActivity的onDestory中进行销毁回调。


修改完之后,多次进入VideoAcitivity然后在退出,导出hprof文件到mat中筛选查看,如图:


image.png


VideoActiviyty的对象已经变成了零,说明开始存在的内存泄漏已经修改好了,使用android proflier工具也能看到在退出videoactivity界面之后主动进行几次gc回收,内存使用量会回归到进入该界面之前。


总结:



  1. LeakCanary工具为辅助,MAT工具进行具体分析。因为LeakCanary工具的监听并不准确,如触发leakcanary泄漏警告时代码已经泄漏了很多次。

  2. 如果能够直接查看泄漏的对象,那是最好修改的,如果不能直接定位泄漏的对象,可以通过泄漏的Class对象在apk解压中找到改class,查看字节码定位具体的代码泄漏位置。

  3. 使用第三方的sdk时,最好使用Application Context,统一分发统一管理,减少内存泄漏。


作者:懵逼树上懵逼果
来源:juejin.cn/post/7210574525665771557
收起阅读 »

Android 指纹识别(给应用添加指纹解锁)

使用指纹 说明 : 指纹解锁在23 的时候,官方就已经给出了api ,但是由于Android市场复杂,无法形成统一,硬件由不同的厂商开发,导致相同版本的软件系统,搭载的硬件千变万化,导致由的机型不支持指纹识别,但是,这也挡不住指纹识别在接下来的时间中进入An...
继续阅读 »

使用指纹



说明 : 指纹解锁在23 的时候,官方就已经给出了api ,但是由于Android市场复杂,无法形成统一,硬件由不同的厂商开发,导致相同版本的软件系统,搭载的硬件千变万化,导致由的机型不支持指纹识别,但是,这也挡不住指纹识别在接下来的时间中进入Android市场的趋势,因为它相比较输入密码或图案,它更加简单,相比较密码或者图案,它更炫酷 ,本文Demo 使用最新的28 支持的androidx 库中的API及最近火热的kotlin语言完成的



需要知道的



  • FingerprintManager : 指纹管理工具类

  • FingerprintManager.AuthenticationCallback :使用验证的时候传入该接口,通过该接口进行验证结果回调

  • FingerprintManager.CryptoObject: FingerprintManager 支持的分装加密对象的类



以上是28以下API 中使用的类 在Android 28版本中google 宣布使用Androidx 库代替Android库,所以在28版本中Android 推荐使用androidx库中的类 所以在本文中我 使用的是推荐是用的FingerprintManagerCompat 二者的使用的方式基本相似



如何使用指纹



  • 开始验证 ,系统默认的每段时间验证指纹次数为5次 次数用完之后自动关闭验证,并且30秒之内不允行在使用验证


验证的方法是authenticate()


/**
*
*@param crypto object associated with the call or null if none required.
* @param flags optional flags; should be 0
* @param cancel an object that can be used to cancel authentication
* @param callback an object to receive authentication events
* @param handler an optional handler for events
**/

@RequiresPermission(android.Manifest.permission.USE_FINGERPRINT)
public void authenticate(@Nullable CryptoObject crypto, int flags,
@Nullable CancellationSignal cancel, @NonNull AuthenticationCallback callback,
@Nullable Handler handler)
{
if (Build.VERSION.SDK_INT >= 23) {
final FingerprintManager fp = getFingerprintManagerOrNull(mContext);
if (fp != null) {
android.os.CancellationSignal cancellationSignal = cancel != null
? (android.os.CancellationSignal) cancel.getCancellationSignalObject()
: null;
fp.authenticate(
wrapCryptoObject(crypto),
cancellationSignal,
flags,
wrapCallback(callback),
handler);
}
}
}



arg1: 用于通过指纹验证取出AndroidKeyStore中key的值
arg2: 系统建议为0




arg3: 取消指纹验证 手动关闭验证 可以调用该参数的cancel方法




arg4:返回验证结果




arg5: Handler fingerprint 中的
消息都是通过handler来传递的 如果不需要则传null 会自动默认创建一个主线程的handler来传递消息



使用指纹识别的条件



  • 添加权限(这个权限不需要在6.0中做处理)

  • 判断硬件是否支持

  • 是否已经设置了锁屏 并且已经有一个被录入的指纹

  • 判断是否至少存在一条指纹信息




通过零碎的知识完成一个Demo


这里写图片描述


指纹识别通过之后跳转到 指定页面


进入之后首先弹出对话框,进行指纹验证


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">


<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/fingerprint" />


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="20dp"
android:text="验证指纹" />


<TextView
android:id="@+id/fingerprint_error_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="10dp"
android:maxLines="1" />


<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:layout_marginLeft="5dp"
android:layout_marginTop="10dp"
android:layout_marginRight="5dp"
android:background="#696969" />


<TextView
android:id="@+id/fingerprint_cancel_tv"
android:layout_width="wrap_content"
android:layout_height="50dp"
android:layout_gravity="center"
android:gravity="center"
android:text="取消"
android:textSize="16sp" />


</LinearLayout>


使用DialogFragment 完成对话框 新建一个DialogFragment 并且初始化相关的api


 override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//获取fingerprintManagerCompat对象
fingerprintManagerCompat = FingerprintManagerCompat.from(context!!)
setStyle(DialogFragment.STYLE_NORMAL, android.R.style.Theme_Material_Light_Dialog)
}


在界面显示在前台的时候开始扫描


override fun onResume() {
super.onResume()
startListening()
}
@SuppressLint("MissingPermission")
private fun startListening() {
isSelfCancelled = false
mCancellationSignal = CancellationSignal()
fingerprintManagerCompat.authenticate(FingerprintManagerCompat.CryptoObject(mCipher), 0, mCancellationSignal, object : FingerprintManagerCompat.AuthenticationCallback() {
//验证错误
override fun onAuthenticationError(errMsgId: Int, errString: CharSequence?) {
if (!isSelfCancelled) {
errorMsg.text = errString
if (errMsgId == FingerprintManager.FINGERPRINT_ERROR_LOCKOUT) {
Toast.makeText(mActivity, errString, Toast.LENGTH_SHORT).show()
dismiss()
mActivity.finish()
}
}
}
//成功
override fun onAuthenticationSucceeded(result: FingerprintManagerCompat.AuthenticationResult?) {
MainActivity.startActivity(mActivity, true)
}
//错误时提示帮助,比如说指纹错误,我们将显示在界面上 让用户知道情况
override fun onAuthenticationHelp(helpMsgId: Int, helpString: CharSequence?) {
errorMsg.text = helpString
}
//验证失败
override fun onAuthenticationFailed() {
errorMsg.text = "指纹验证失败,请重试"
}
}, null)
}

在不可见的时候停止验证


if (null != mCancellationSignal) {
mCancellationSignal.cancel()
isSelfCancelled = true
}

在MainActivity 中首先判断是否验证成功 是 跳转到目标页 否则的话需要进行验证
在这个过程中我们需要做的就是判断是否支持,判断是否满足指纹验证的条件(条件在上面)


if (intent.getBooleanExtra("isSuccess", false)) {
WelcomeActivity.startActivity(this)
finish()
} else {
//判断是否支持该功能
if (supportFingerprint()) {
initKey() //生成一个对称加密的key
initCipher() //生成一个Cipher对象
}
}


验证条件


 if (Build.VERSION.SDK_INT < 23) {
Toast.makeText(this, "系统不支持指纹功能", Toast.LENGTH_SHORT).show()
return false
} else {
val keyguardManager = getSystemService(KeyguardManager::class.java)
val managerCompat = FingerprintManagerCompat.from(this)
if (!managerCompat.isHardwareDetected) {
Toast.makeText(this, "系统不支持指纹功能", Toast.LENGTH_SHORT).show()
return false
} else if (!keyguardManager.isKeyguardSecure) {
Toast.makeText(this, "屏幕未设置锁屏 请先设置锁屏并添加一个指纹", Toast.LENGTH_SHORT).show()
return false
} else if (!managerCompat.hasEnrolledFingerprints()) {
Toast.makeText(this, "至少在系统中添加一个指纹", Toast.LENGTH_SHORT).show()
return false
}
}

必须生成一个加密的key 和一个Cipher对象


//生成Cipher
private fun initCipher() {
val key = keyStore.getKey(DEFAULT_KEY_NAME, null) as SecretKey
val cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
+ KeyProperties.BLOCK_MODE_CBC + "/"
+ KeyProperties.ENCRYPTION_PADDING_PKCS7)
cipher.init(Cipher.ENCRYPT_MODE, key)
showFingerPrintDialog(cipher)
}
//生成一个key
private fun initKey() {
keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val builder = KeyGenParameterSpec.Builder(DEFAULT_KEY_NAME,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setUserAuthenticationRequired(true)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
keyGenerator.init(builder.build())
keyGenerator.generateKey()
}

Demo 是kotlin 写的
Demo地址


作者:狼窝山下的青年
来源:juejin.cn/post/7210220134601572410
收起阅读 »

Android 可视化预览及编辑Json

Android 可视化编辑json JsonPreviewer 项目中涉及到广告开发, 广告的配置是从API动态下发, 广告配置中,有很多业务相关参数,例如关闭或开启、展示间隔、展示时间、重试次数、每日最大显示次数等。 开发时单个广告可能需要多次修改配置来测试...
继续阅读 »


Android 可视化编辑json JsonPreviewer


项目中涉及到广告开发, 广告的配置是从API动态下发, 广告配置中,有很多业务相关参数,例如关闭或开启、展示间隔、展示时间、重试次数、每日最大显示次数等。


开发时单个广告可能需要多次修改配置来测试,为了方便测试,广告配置的json文件,有两种途径修改并生效





    1. 每次抓包修改配置





    1. 本地导入配置,从磁盘读取




但两种方式都有一定弊端



  • 首先测试时依赖电脑修改配置

  • 无法直观预览广告配置


考虑到开发时经常使用的Json格式化工具,既可以直观的预览Json, 还可以在线编辑


那么就考虑将Json格式化工具移植到项目测试模块中


web网页可以处理Json格式化,同理在Android webView 中同样可行, 只需要引入处理格式化的JS代码即可。


查找资料,发现一个很实用的文章可视化编辑json数据——json editor


开始处理


首先准备好WebView的壳子


    //初始化
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView() {
binding.webView.settings.apply {
javaScriptEnabled = true
javaScriptCanOpenWindowsAutomatically = true
setSupportZoom(true)
useWideViewPort = true
builtInZoomControls = true
}
binding.webView.addJavascriptInterface(JsInterface(this@MainActivity), "json_parse")
}

//webView 与 Android 交互
inner class JsInterface(context: Context) {
private val mContext: Context

init {
mContext = context
}

@JavascriptInterface
fun configContentChanged() {
runOnUiThread {
contentChanged = true
}
}

@JavascriptInterface
fun toastJson(msg: String?) {
runOnUiThread { Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show() }
}

@JavascriptInterface
fun saveConfig(jsonString: String?) {
runOnUiThread {
contentChanged = false
Toast.makeText(mContext, "verification succeed", Toast.LENGTH_SHORT).show()
}
}

@JavascriptInterface
fun parseJsonException(e: String?) {
runOnUiThread {
e?.takeIf { it.isNotBlank() }?.let { alert(it) }
}
}
}


加载json并在WebView中展示



viewModel.jsonData.observe(this) { str ->
if (str?.isNotBlank() == true) {
binding.webView.loadUrl("javascript:showJson($str)")
}
}


WebView 加载预览页面


        binding.webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
viewModel.loadAdConfig(this@MainActivity)
}
}

binding.webView.loadUrl("file:///android_asset/preview_json.html")



Json 预览页, preview_json.html实现



<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="jquery.json-viewer.css"
rel="stylesheet" type="text/css">

</head>
<style type="text/css">
#json-display {
margin: 2em 0;
padding: 8px 15px;
min-height: 300px;
background: #ffffff;
color: #ff0000;
font-size: 16px;
width: 100%;
border-color: #00000000;
border:none;
line-height: 1.8;
}
#json-btn {
display: flex;
align-items: center;
font-size: 18px;
width:100%;
padding: 10;

}
#format_btn {
width: 50%;
height: 36px;
}
#save_btn {
width: 50%;
height: 36px;
margin-left: 4em;
}

</style>
<body>
<div style="padding: 2px 2px 2px 2px;">
<div id="json-btn" class="json-btn">
<button type="button" id="format_btn" onclick="format_btn();">Format</button>
<button type="button" id="save_btn" onclick="save_btn();">Verification</button>

</div>
<div>
<pre id="json-display" contenteditable="true"></pre>
</div>
<br>
</div>

<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="jquery.json-viewer.js"></script>
<script>

document.getElementById("json-display").addEventListener("input", function(){
console.log("json-display input");
json_parse.configContentChanged();
}, false);
function showJson(jsonObj){
$("#json-display").jsonViewer(jsonObj,{withQuotes: true});//format json and display
}
function format_btn() {
var my_json_val = $("#json-display").clone(false);
my_json_val.find("a.json-placeholder").remove();
var jsonval = my_json_val.text();
var jsonObj = JSON.parse(jsonval); //parse string to json
$("#json-display").jsonViewer(jsonObj,{withQuotes: true});//format json and display
}


function save_btn() {
var my_json_val = $("#json-display").clone(false);
my_json_val.find("a.json-placeholder").remove();
var jsonval = my_json_val.text();
var saveFailed = false;
try {
var jsonObj = JSON.parse(jsonval); //parse
} catch (e) {
console.error(e.message);
saveFailed = true;
json_parse.parseJsonException(e.message); // throw exception
}
if(!saveFailed) {
json_parse.saveConfig(jsonval);
}
}

</script>
</body>
</html>


这其中有两个问题需注意





    1. 如果value的值是url, 格式化后缺少引号
      从json-viewer.js源码可以发现,源码中会判断value是否是url,如果是则直接输出




处理方式:在json 左右添加上双引号


    if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + '"' +json + '"' + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '
\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}




    1. 如果折叠后json-viewer会增加<a>标签,即使使用text()方法获取到纯文本数据,这里面也包含了“n items”的字符串,那么该如何去除掉这些字符串呢?




 var my_json_val = $("#json-display").clone(false);
my_json_val.find("a.json-placeholder").remove();

总结


使用时只需将json文件读取,传入preview_json.html的showJson方法


编辑结束后, 点击Save 即可保存


示例代码 Android 可视化编辑json JsonPreviewer




(可视化编辑json数据——json editor)[blog.51cto.com/u_56500

11/5…]

收起阅读 »

Android 插件化:插件内部跳转

在Android 插件化(加载插件)中,简单的用一个demo 讲了如何加载一个插件,并使用插件里的资源。 那如果我们的插件中有多个页面呢,要怎么办? 其实,也是很简单,还是通过外部 PluginActivity 的 startActivity来实现 一、Lif...
继续阅读 »

在Android 插件化(加载插件)中,简单的用一个demo 讲了如何加载一个插件,并使用插件里的资源。


那如果我们的插件中有多个页面呢,要怎么办?

其实,也是很简单,还是通过外部 PluginActivitystartActivity来实现


一、LifeActivitystartActivity


LifeActivity 这个插件类中定义一个 startActivity 方法,用宿主的 context 调用 startActivity 方法


public void startActivity(Intent intent) {
if (context != null) {
if (intent==null||intent.getComponent()==null)return;
Intent newIntent=new Intent();
String className=intent.getComponent().getClassName();
if (TextUtils.isEmpty(className))return;
Log.e("startActivity","className="+className);
newIntent.putExtra("className",intent.getComponent().getClassName());
context.startActivity(newIntent);
}
}

而对于第一个插件中的页面 TestActivity


image.png


可以看到,插件中的第一个页面 TestActivity 点击打开 插件页面 Test2Activity 时。写法跟我们在Android中的风格是一模一样的。其中的 findViewById 等,只要是用到上下文的,全部替换成宿主的,这里不多赘述了。


image.png


二、重写 PluginActivitystartActivity


注意:由于 Test2Activity 不是一个真正 Activity ,PluginActivitystartActivity 中,就不能打开这个页面,只能再重新打开一个PluginActivity,并将Test2Activity 类的信息再重新加载实例化一次,跟我们第一个加载TestActivity 是一样的。

override fun startActivity(intent: Intent?) {
val className = intent?.getStringExtra("className")
if (className.isNullOrBlank()) return
val newIntent = Intent(this, PluginActivity::class.java)
newIntent.putExtra("className", className)
super.startActivity(newIntent)
}

传入进去的 className 就是 Test2Activity ,在PluginActivity 走生命周期onCreate 时,loadClassActivity ,至此就完成了插件内部的跳转,是不是非常简单。


9b2ee0aed19ba5e77698cb1f9582b93d.gif


三、同理,有 Activity 就会有其他组件


我们可以在插件中自己实现 serviceContentProviderBroadcastReceiver
等等组件,并重写生命周期等方法。原理都非常简单,难的是思想,这些都是插件化中的冰山一脚,我自己的项目中的更加复杂。


由于这样的方式,需要手动创建生命周期管理,和后续Activity启动模式,入栈出栈的管理等等。其实可以使用ASM等字节码来转换,将四大组件转成普通类,这样,开发过程中容易调试,插件生成也相对简单。


四、动态加载


由于插件apk 是可以从外部sdk 等地方加载的,给我们带来很多便利。而且插件部分的资源都是动态的,可以做到热更新的效果,只要我修改了再重新打包下发就行了。后续可以自己实现一套插件管理,用于加载外部apk ,做到热插拔的作用。


作者:大强Dev
来源:juejin.cn/post/7209971268483825722
收起阅读 »

[崩溃] Android应用自动重启

背景 在App开发过程中,我们经常需要自动重启的功能。比如: 登录或登出的时候,为了清除缓存的一些变量,比较简单的方法就是重新启动app。 crash的时候,可以捕获到异常,直接自动重启应用。 在一些debug的场景中,比如设置了一些测试的标记位,需要重启才...
继续阅读 »

背景


在App开发过程中,我们经常需要自动重启的功能。比如:



  • 登录或登出的时候,为了清除缓存的一些变量,比较简单的方法就是重新启动app。

  • crash的时候,可以捕获到异常,直接自动重启应用。

  • 在一些debug的场景中,比如设置了一些测试的标记位,需要重启才能生效,此时可以用自动重启,方便测试。


那我们如何实现自动重启的功能呢?我们都知道如何杀掉进程,但是当我们的进程被杀掉之后,如何唤醒呢?


这篇文章就来和大家介绍一下,实现应用自动重启的几种方法。


方法1 AlarmManager


    private void setAlarmManager(){
Intent intent = new Intent();
intent.setClass(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT);
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis()+100, pendingIntent);
Process.killProcess(Process.myPid());
System.exit(0);
}

使用AlarmManager实现自动重启的核心思想:创建一个100ms之后的Alarm任务,等Alarm任务到执行时间了,会自动唤醒App。


缺点:



  • 在App被杀和拉起之间,会显示系统Launcher桌面,体验不好。

  • 在高版本不适用


方法2 直接启动Activity


private void restartApp(){
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
Process.killProcess(Process.myPid());
System.exit(0);
}

缺点:



  • MainActivity必须是Standard模式


方法3 ProcessPhoenix


JakeWharton大神开源了一个叫ProcessPhoenix的库,这个库可以实现无缝重启app。


实现原理其实很简单,我们先讲怎么使用,然后再来分析源码。


使用方法


首先引入ProcessPhoenix库,这个库不需要初始化,可以直接使用。


implementation 'com.jakewharton:process-phoenix:2.1.2'

使用1:如果想重启app后进入首页:


ProcessPhoenix.triggerRebirth(context);

使用2:如果想重启app后进入特定的页面,则需要构造具体页面的intent,当做参数传入:


Intent nextIntent = //...
ProcessPhoenix.triggerRebirth(context, nextIntent);

有一点需要特别注意。



  • 我们通常会在ApplicationonCreate方法中做一系列初始化的操作。

  • 如果使用Phoenix库,需要在onCreate方法中判断,如果当前进程是Phoenix进程,则直接return,跳过初始化的操作。


if (ProcessPhoenix.isPhoenixProcess(this)) {
return;
}

源码


ProcessPhoenix的原理:



  • 当调用triggerRebirth方法的时候,会启动一个透明的Activity,这个Activity运行在:phoenix进程

  • Activity启动后,杀掉主进程,然后用:phoenix进程拉起主进程的Activity

  • 关闭当前Activity,杀掉:phoenix进程


先来看看ManifestActivity的注册代码:


 <activity
android:name=".ProcessPhoenix"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:process=":phoenix"
android:exported="false"
/>


可以看到这个Activity确实是在:phoenix进程启动的,且是Translucent透明的。


整个ProcessPhoenix的代码只有不到120行,非常简单。我们来看下triggerRebirth做了什么。


  public static void triggerRebirth(Context context) {
triggerRebirth(context, getRestartIntent(context));
}

不带intenttriggerRebirth,最后也会调用到带intenttriggerRebirth方法。


getRestartIntent会获取主进程的Launch Activity


  private static Intent getRestartIntent(Context context) {
String packageName = context.getPackageName();
Intent defaultIntent = context.getPackageManager().getLaunchIntentForPackage(packageName);
if (defaultIntent != null) {
return defaultIntent;
}
}

所以要调用不带intenttriggerRebirth,必须在当前Appmanifest里,指定Launch Activity,否则会抛出异常。


接着来看看真正的triggerRebirth方法:


  public static void triggerRebirth(Context context, Intent... nextIntents) {
if (nextIntents.length < 1) {
throw new IllegalArgumentException("intents cannot be empty");
}
// 第一个activity添加new_task标记,重新开启一个新的stack
nextIntents[0].addFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK);

Intent intent = new Intent(context, ProcessPhoenix.class);
// 这里是为了防止传入的context非Activity
intent.addFlags(FLAG_ACTIVITY_NEW_TASK); // In case we are called with non-Activity context.
// 将待启动的intent作为参数,intent是parcelable的
intent.putParcelableArrayListExtra(KEY_RESTART_INTENTS, new ArrayList<>(Arrays.asList(nextIntents)));
// 将主进程的pid作为参数
intent.putExtra(KEY_MAIN_PROCESS_PID, Process.myPid());
// 启动ProcessPhoenix Activity
context.startActivity(intent);
}

triggerRebirth方法,主要的功能是启动ProcessPhoenix Activity,相当于启动了:phoenix进程。同时,会将nextIntents和主进程的pid作为参数,传给新启动的ProcessPhoenix Activity


下面我们再来看看,ProcessPhoenix ActivityonCreate方法,看看新进程启动后做了什么。


  @Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 首先杀死主进程
Process.killProcess(getIntent().getIntExtra(KEY_MAIN_PROCESS_PID, -1)); // Kill original main process

ArrayList<Intent> intents = getIntent().getParcelableArrayListExtra(KEY_RESTART_INTENTS);
// 再启动主进程的intents
startActivities(intents.toArray(new Intent[intents.size()]));
// 关闭当前Activity,杀掉当前进程
finish();
Runtime.getRuntime().exit(0); // Kill kill kill!
}

:phoenix进程主要做了以下事情:



  • 杀死主进程

  • 用传入的Intent启动主进程的Activity(也可以是Service)

  • 关闭phoenix Activity,杀掉phoenix进程


总结


如果App有自动重启的需求,比较推荐使用ProcessPhoenix的方法。


原理其实非常简单:



  • 启动一个新的进程

  • 杀掉主进程

  • 用新的进程,重新拉起主进程

  • 杀掉新的进程


我们可以直接在工程里引入ProcessPhoenix开源库,也可以自己用代码实现这样的机

作者:尹学姐
来源:juejin.cn/post/7207743145999024165
制,总之都比较简单。

收起阅读 »

一个app到底会创建多少个Application对象

问题背景 最近跟群友讨论一个技术问题: 一个应用开启了多进程,最终到底会创建几个application对象,执行几次onCreate()方法? 有的群友根据自己的想法给出了猜想 甚至有的群友直接咨询起了ChatGPT 但至始至终都没有一个最终的结论。于是...
继续阅读 »

问题背景


最近跟群友讨论一个技术问题:


交流1


一个应用开启了多进程,最终到底会创建几个application对象,执行几次onCreate()方法?


有的群友根据自己的想法给出了猜想


交流2


甚至有的群友直接咨询起了ChatGPT


chatgpt1.jpg


但至始至终都没有一个最终的结论。于是乎,为了弄清这个问题,我决定先写个demo测试得出结论,然后从源码着手分析原因


Demo验证


首先创建了一个app项目,开启多进程


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


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

<!--android:process 开启多进程并设置进程名-->
<activity
android:name=".MainActivity"
android:exported="true"
android:process=":remote">

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

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

</activity>
</application>

</manifest>

然后在DemoApplication的onCreate()方法打印application对象的地址,当前进程名称


public class DemoApplication extends Application {
private static final String TAG = "jasonwan";

@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "Demo application onCreate: " + this + ", processName=" + getProcessName(this));
}

private String getProcessName(Application app) {
int myPid = Process.myPid();
ActivityManager am = (ActivityManager) app.getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningAppProcessInfo> runningAppProcesses = am.getRunningAppProcesses();
for (ActivityManager.RunningAppProcessInfo runningAppProcess : runningAppProcesses) {
if (runningAppProcess.pid == myPid) {
return runningAppProcess.processName;
}
}
return "null";
}
}

运行,得到的日志如下


2023-03-07 11:15:27.785 19563-19563/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote

查看当前应用所有进程


查看进程1


说明此时app只有一个进程,且只有一个application对象,对象地址为@fb06c2d


现在我们将进程增加到多个,看看情况如何


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


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

<!--android:process 开启多进程并设置进程名-->
<activity
android:name=".MainActivity"
android:exported="true"
android:process=":remote">

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

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

</activity>
<activity
android:name=".TwoActivity"
android:process=":remote2" />

<activity
android:name=".ThreeActivity"
android:process=":remote3" />

<activity
android:name=".FourActivity"
android:process=":remote4" />

<activity
android:name=".FiveActivity"
android:process=":remote5" />

</application>

</manifest>

逻辑是点击MainActivity启动TwoActivity,点击TwoActivity启动ThreeActivity,以此类推。最后我们运行,启动所有Activity得到的日志如下


2023-03-07 11:25:35.433 19955-19955/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote
2023-03-07 11:25:43.795 20001-20001/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote2
2023-03-07 11:25:45.136 20046-20046/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote3
2023-03-07 11:25:45.993 20107-20107/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote4
2023-03-07 11:25:46.541 20148-20148/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote5

查看当前应用所有进程


查看进程2


此时app有5个进程,但application对象地址均为@fb06c2d,地址相同意味着它们是同一个对象。


那是不是就可以得出结论,无论启动多少个进程都只会创建一个application对象呢?并不能妄下此定论,我们将MainActivityprocess属性去掉再运行,得到的日志如下


2023-03-07 11:32:10.156 20318-20318/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@5d49e29, processName=com.jason.demo0307
2023-03-07 11:32:15.143 20375-20375/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote2
2023-03-07 11:32:16.477 20417-20417/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote3
2023-03-07 11:32:17.582 20463-20463/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote4
2023-03-07 11:32:18.882 20506-20506/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote5

查看当前应用所有进程


查看进程3


此时app有5个进程,但有2个application对象,对象地址为@5d49e29和@fb06c2d,且子进程的application对象都相同。


上述所有进程的父进程ID为678,而此进程正是zygote进程


zygote进程


根据上面的测试结果我们目前能得出的结论:



  • 结论1:单进程只创建一个Application对象,执行一次onCreate()方法;

  • 结论2:多进程至少创建2个Application对象,执行多次onCreate()方法,几个进程就执行几次;


结论2为什么说至少创建2个,因为我在集成了JPush的商业项目中测试发现,JPush创建的进程跟我自己创建的进程,Application地址是不同的。


jpush进程


这里三个进程,分别创建了三个Application对象,对象地址分别是@f31ba9d,@2c586f3,@fb06c2d


源码分析


这里需要先了解App的启动流程,具体可以参考《App启动流程》


Application的创建位于frameworks/base/core/java/android/app/ActivityThread.javahandleBindApplication()方法中


	@UnsupportedAppUsage
private void handleBindApplication(AppBindData data) {
long st_bindApp = SystemClock.uptimeMillis();
//省略部分代码

// Note when this process has started.
//设置进程启动时间
Process.setStartTimes(SystemClock.elapsedRealtime(), SystemClock.uptimeMillis());

//省略部分代码

// send up app name; do this *before* waiting for debugger
//设置进程名称
Process.setArgV0(data.processName);
//省略部分代码

// Allow disk access during application and provider setup. This could
// block processing ordered broadcasts, but later processing would
// probably end up doing the same disk access.
Application app;
final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskWrites();
final StrictMode.ThreadPolicy writesAllowedPolicy = StrictMode.getThreadPolicy();
try {
// If the app is being launched for full backup or restore, bring it up in
// a restricted environment with the base application class.
//此处开始创建application对象,注意参数2为null
app = data.info.makeApplication(data.restrictedBackupMode, null);

//省略部分代码
try {
if ("com.jason.demo0307".equals(app.getPackageName())){
Log.d("jasonwan", "execute app onCreate(), app=:"+app+", processName="+getProcessName(app)+", pid="+Process.myPid());
}
//执行application的onCreate方法()
mInstrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
if (!mInstrumentation.onException(app, e)) {
throw new RuntimeException(
"Unable to create application " + app.getClass().getName()
+ ": " + e.toString(), e);
}
}
} finally {
// If the app targets < O-MR1, or doesn't change the thread policy
// during startup, clobber the policy to maintain behavior of b/36951662
if (data.appInfo.targetSdkVersion < Build.VERSION_CODES.O_MR1
|| StrictMode.getThreadPolicy().equals(writesAllowedPolicy)) {
StrictMode.setThreadPolicy(savedPolicy);
}
}
//省略部分代码
}

实际创建过程在frameworks/base/core/java/android/app/LoadedApk.java中的makeApplication()方法中,LoadedApk顾名思义就是加载好的Apk文件,里面包含Apk所有信息,像包名、Application对象,app所在的目录等,这里直接看application的创建过程


	@UnsupportedAppUsage
public Application makeApplication(boolean forceDefaultAppClass,
Instrumentation instrumentation)
{
if ("com.jason.demo0307".equals(mApplicationInfo.packageName)) {
Log.d("jasonwan", "makeApplication: mApplication="+mApplication+", pid="+Process.myPid());
}
//如果已经创建过了就不再创建
if (mApplication != null) {
return mApplication;
}

Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "makeApplication");

Application app = null;

String appClass = mApplicationInfo.className;
if (forceDefaultAppClass || (appClass == null)) {
appClass = "android.app.Application";
}

try {
java.lang.ClassLoader cl = getClassLoader();
if (!mPackageName.equals("android")) {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER,
"initializeJavaContextClassLoader");
initializeJavaContextClassLoader();
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
}
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
//反射创建application对象
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
if ("com.jason.demo0307.DemoApplication".equals(appClass)){
Log.d("jasonwan", "create application, app="+app+", processName="+mActivityThread.getProcessName()+", pid="+Process.myPid());
}
appContext.setOuterContext(app);
} catch (Exception e) {
Log.d("jasonwan", "fail to create application, "+e.getMessage());
if (!mActivityThread.mInstrumentation.onException(app, e)) {
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
throw new RuntimeException(
"Unable to instantiate application " + appClass
+ ": " + e.toString(), e);
}
}
mActivityThread.mAllApplications.add(app);
mApplication = app;

if (instrumentation != null) {
try {
//第一次启动创建时,instrumentation为null,不会执行onCreate()方法
instrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
if (!instrumentation.onException(app, e)) {
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
throw new RuntimeException(
"Unable to create application " + app.getClass().getName()
+ ": " + e.toString(), e);
}
}
}

// 省略部分代码
return app;
}

为了看清application到底被创建了几次,我在关键地方埋下了log,TAG为jasonwan的log是我自己加的,编译验证,得到如下log


启动app,进入MainActivity
03-08 17:20:29.965 4069 4069 D jasonwan: makeApplication: mApplication=null, pid=4069
//创建application对象,地址为@c2f8311,当前进程id为4069
03-08 17:20:29.967 4069 4069 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307, pid=4069
03-08 17:20:29.988 4069 4069 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307, pid=4069
03-08 17:20:29.989 4069 4069 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307, pid=4069
03-08 17:20:36.614 4069 4069 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4069

点击MainActivity,跳转到TwoActivity
03-08 17:20:39.686 4116 4116 D jasonwan: makeApplication: mApplication=null, pid=4116
//创建application对象,地址为@c2f8311,当前进程id为4116
03-08 17:20:39.687 4116 4116 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote2, pid=4116
03-08 17:20:39.688 4116 4116 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote2, pid=4116
03-08 17:20:39.688 4116 4116 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote2, pid=4116
03-08 17:20:39.733 4116 4116 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4116

点击TwoActivity,跳转到ThreeActivity
03-08 17:20:41.473 4147 4147 D jasonwan: makeApplication: mApplication=null, pid=4147
//创建application对象,地址为@c2f8311,当前进程id为4147
03-08 17:20:41.475 4147 4147 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote3, pid=4147
03-08 17:20:41.475 4147 4147 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote3, pid=4147
03-08 17:20:41.476 4147 4147 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote3, pid=4147
03-08 17:20:41.519 4147 4147 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4147

点击ThreeActivity,跳转到FourActivity
03-08 17:20:42.966 4174 4174 D jasonwan: makeApplication: mApplication=null, pid=4174
//创建application对象,地址为@c2f8311,当前进程id为4174
03-08 17:20:42.968 4174 4174 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote4, pid=4174
03-08 17:20:42.969 4174 4174 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote4, pid=4174
03-08 17:20:42.969 4174 4174 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote4, pid=4174
03-08 17:20:43.015 4174 4174 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4174

点击FourActivity,跳转到FiveActivity
03-08 17:20:44.426 4202 4202 D jasonwan: makeApplication: mApplication=null, pid=4202
//创建application对象,地址为@c2f8311,当前进程id为4202
03-08 17:20:44.428 4202 4202 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote5, pid=4202
03-08 17:20:44.429 4202 4202 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote5, pid=4202
03-08 17:20:44.430 4202 4202 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote5, pid=4202
03-08 17:20:44.473 4202 4202 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4202

结果很震惊,我们在5个进程中创建的application对象,地址均为@c2f8311,也就是至始至终创建的都是同一个Application对象,那么上面的结论2显然并不成立,只是测试的偶然性导致的。


可真的是这样子的吗,这也太颠覆我的三观了,为此我跟群友讨论了这个问题:


不同进程中的多个对象,内存地址相同,是否代表这些对象都是同一个对象?


群友的想法是,java中获取的都是虚拟内存地址,虚拟内存地址相同,不代表是同一个对象,必须物理内存地址相同,才表示是同一块内存空间,也就意味着是同一个对象,物理内存地址和虚拟内存地址存在一个映射关系,同时给出了java中获取物理内存地址的方法Android获取对象地址,主要是利用Unsafe这个类来操作,这个类有一个作用就是直接访问系统内存资源,具体描述见Java中的魔法类-Unsafe,因为这种操作是不安全的,所以被标为了私有,但我们可以通过反射去调用此API, 然后我又去请教了部门搞寄存器的大佬,大佬肯定了群友的想法,于是我添加代码,尝试获取对象的物理内存地址,看看是否相同


public class DemoApplication extends Application {
public static final String TAG = "jasonwan";

@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "DemoApplication=" + this + ", address=" + addressOf(this) + ", pid=" + Process.myPid());
}

//获取对象的真实物理地址
public static long addressOf(Object o) {
Object[] array = new Object[]{o};
long objectAddress = -1;
try {
Class cls = Class.forName("sun.misc.Unsafe");
Field field = cls.getDeclaredField("theUnsafe");
field.setAccessible(true);
Object unsafe = field.get(null);
Class unsafeCls = unsafe.getClass();
Method arrayBaseOffset = unsafeCls.getMethod("arrayBaseOffset", Object.class.getClass());
int baseOffset = (int) arrayBaseOffset.invoke(unsafe, Object[].class);
Method size = unsafeCls.getMethod("addressSize");
int addressSize = (int) size.invoke(unsafe);
switch (addressSize) {
case 4:
Method getInt = unsafeCls.getMethod("getInt", Object.class, long.class);
objectAddress = (int) getInt.invoke(unsafe, array, baseOffset);
break;
case 8:
Method getLong = unsafeCls.getMethod("getLong", Object.class, long.class);
objectAddress = (long) getLong.invoke(unsafe, array, baseOffset);
break;
default:
throw new Error("unsupported address size: " + addressSize);
}
} catch (Exception e) {
e.printStackTrace();
}
return objectAddress;
}
}

运行后得到如下日志


2023-03-10 11:01:54.043 6535-6535/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@930d275, address=8050489105119022792, pid=6535
2023-03-10 11:02:22.610 6579-6579/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119027136, pid=6579
2023-03-10 11:02:36.369 6617-6617/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119029912, pid=6617
2023-03-10 11:02:39.244 6654-6654/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119032760, pid=6654
2023-03-10 11:02:40.841 6692-6692/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119036016, pid=6692
2023-03-10 11:02:52.429 6729-6729/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119038720, pid=6729

可以看到,虽然Application的虚拟内存地址相同,都是331b3b9,但它们的真实物理地址却不同,至此,我们可以得出最终结论



  • 单进程,创建1个application对象,执行一次onCreate()方法

  • 多进程(N),创建N个application对象,执行N次onCreate()方法


作者:小迪vs同学
来源:juejin.cn/post/7208345469658415159
收起阅读 »

Android 获取IP和UA

最近接入了一个新的SDK,初始化接口需要传入当前设备的IP和UA作为参数。本文介绍如何获取设备的IP和UA。 获取IP 使用WIFI联网与不使用WIFI,获取到的IP地址不同。因此,需要先判断当前设备通过哪种方式联网,然后再获取对应的IP地址。 判断网络连接...
继续阅读 »

最近接入了一个新的SDK,初始化接口需要传入当前设备的IP和UA作为参数。本文介绍如何获取设备的IP和UA。


获取IP


使用WIFI联网与不使用WIFI,获取到的IP地址不同。因此,需要先判断当前设备通过哪种方式联网,然后再获取对应的IP地址。



  • 判断网络连接类型


通过ConnectivityManager判断网络连接类型,代码如下:


private fun checkCurrentNetworkType() {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.run {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
when (activeNetworkInfo?.type) {
ConnectivityManager.TYPE_MOBILE -> {
// 通过手机流量
}
ConnectivityManager.TYPE_WIFI -> {
// 通过WIFI
}
else -> {}
}
} else {
// Android M 以上建议使用getNetworkCapabilities API
activeNetwork?.let { network ->
getNetworkCapabilities(network)?.let { networkCapabilities ->
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
when {
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
// 通过手机流量
}
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
// 通过WIFI
}
}
}
}
}
}
}
}


  • 获取手机卡联网 IP


通过NetworkInterface获取IPV4地址,代码如下:


NetworkInterface.getNetworkInterfaces().let {
loo@ for (networkInterface in Collections.list(it)) {
for (inetAddresses in Collections.list(networkInterface.inetAddresses)) {
if (!inetAddresses.isLoopbackAddress && !inetAddresses.isLinkLocalAddress) {
// IP地址
val mobileIp = inetAddresses.hostAddress
break@loo
}
}
}
}


  • 获取WIFI联网 IP


通过ConnectivityManagerWifiManager来获取IP地址,代码如下:


private fun getWIFIIp() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
// IP 地址
val wifiIp = Formatter.formatIpAddress(wifiManager.connectionInfo.ipAddress)
} else {
// Android Q 以上建议使用getNetworkCapabilities API
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.run {
activeNetwork?.let { network ->
(getNetworkCapabilities(network)?.transportInfo as? WifiInfo)?.let { wifiInfo ->
// IP 地址
val wifiIp = Formatter.formatIpAddress(wifiInfo.ipAddress)
}
}
}
}
}

获取UA


获取设备的UserAgent比较简单,代码如下:


// 系统 UA
System.getProperty("http.agent")

// WebView UA
WebSettings.getDefaultUserAgent(context)

示例


在示例Demo中添加了相关的演示代码。


ExampleDemo github


ExampleDemo gitee


效果如图:


device-2023-03-12-09 -original-original.gif
作者:ChenYhong
来源:juejin.cn/post/7209272192852148282
收起阅读 »

虚拟内存优化:线程+多进程优化

在介绍内存的基础知识的时候,我们讲过在 32 位系统上虚拟内存只有 4G,因为有 1G 是给内核使用的,所以留给应用的只有 3G 了。3G 虽然看起来挺多,但依然会因为不够用而导致应用崩溃。为什么会这样呢? 我们在学习 Java 堆的组成时就知道 MainSp...
继续阅读 »

在介绍内存的基础知识的时候,我们讲过在 32 位系统上虚拟内存只有 4G,因为有 1G 是给内核使用的,所以留给应用的只有 3G 了。3G 虽然看起来挺多,但依然会因为不够用而导致应用崩溃。为什么会这样呢?


我们在学习 Java 堆的组成时就知道 MainSpace 会申请 512M 的虚拟内存,LargeObjectSpace 也会申请 512M 的虚拟内存,这就用掉了 1G 的虚拟内存,再加上其他 Space 和段映射申请的虚拟内存,如 bss 段、text 段以及各种 so 库文件的映射等,这样算下来,3G 的虚拟内存就没剩下多少了。


所以,虚拟内存的优化,在提升程序的稳定性上,是一种很重要的方案。虚拟内存的优化手段也有很多,这一章我们主要介绍 3 种优化方案:




  1. 通过线程治理来优化虚拟内存;




  2. 通过多进程架构来优化虚拟内存;




  3. 通过一些“黑科技”手段来优化虚内存。




方案 1 和 2 相对简单但效果更佳,投入产出比最高,也是我们最常用的。而方案 3 是通过多个“黑科技”的手段来完成虚拟内存的优化,这些手段虽然属于“黑科技”,但还是会用到我们学过的 Native Hook 等技术,所以你理解、吸收起来并不会很难。


那今天我们先介绍 方案 1 和 方案 2 ,方案 3 会在下一章节单独介绍,下面就开始这一章的学习吧。


线程治理


首先,为什么治理线程能优化虚拟内存呢?实际上,即使是一个空线程也会申请 1M 的虚拟空间来作为栈空间大小,我们可以分析 Thread 创建的源码来验证这一点。同时,对线程创建的分析,也能让你能更好的理解后面的优化方案。


线程创建流程


当我们使用线程执行任务时,通常会先调用 new Thread(Runnable runnable) 来创建一个 Thread.java 对象的实例,Thread 的构造函数中会将 stackSize 这个变量设置为 0,这个 stackSize 变量决定了线程栈大小,接着我们便会执行 Thread 实例提供的 start 方法运行这个线程,start 方法中会调用 nativeCreate 这个 Native 函数在系统层创建一个线程并运行。


Thread(ThreadGroup group, String name, int priority, boolean daemon) {
……
this.stackSize = 0;
}

public synchronized void start() {
if (started)
throw new IllegalThreadStateException();
group.add(this);
started = false;
try {
nativeCreate(this, stackSize, daemon);
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {

}
}
}

通过上面 Start 函数的源码可以看到,nativeCreate 会传入 stackSize。你可能想问,这个 stackSize 不是决定了线程栈空间的大小吗?但是它现在的值为 0,那前面为什么说线程有 1M 大小的栈空间呢?我们接着往下看就能知道答案了。


我们接着看 nativeCreate 的源码实现(),它的实现类是 java_lang_Thread.cc


static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size, jboolean daemon) {
Runtime* runtime = Runtime::Current();
if (runtime->IsZygote() && runtime->IsZygoteNoThreadSection()) {
jclass internal_error = env->FindClass("java/lang/InternalError");
CHECK(internal_error != nullptr);
env->ThrowNew(internal_error, "Cannot create threads in zygote");
return;
}

Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

nativeCreate 会执行 Thread::CreateNativeThread 函数,这个函数才是最终创建线程的地方,它的实现在 Thread.cc 这个对象中,并且在这个函数中会调用 FixStackSize 方法将 stack_size 调整为 1M,所以前面那个疑问在这里就解决了,即使我们将 stack_size 设置为 0,这里依然会被调整。我们继续往下分析,看看一个线程究竟是怎样被创建出来的?


void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
……
// 调整 stack_size,默认值为 1 M
stack_size = FixStackSize(stack_size);
……

if (child_jni_env_ext.get() != nullptr) {
pthread_t new_pthread;
pthread_attr_t attr;
child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
CHECK_PTHREAD_CALL(pthread_attr_init, (&attr), "new thread");
CHECK_PTHREAD_CALL(pthread_attr_setdetachstate, (&attr, PTHREAD_CREATE_DETACHED),
"PTHREAD_CREATE_DETACHED");
CHECK_PTHREAD_CALL(pthread_attr_setstacksize, (&attr, stack_size), stack_size);
// 创建线程
pthread_create_result = pthread_create(&new_pthread,
&attr,
Thread::CreateCallback,
child_thread);
CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

if (pthread_create_result == 0) {
child_jni_env_ext.release(); // NOLINT pthreads API.
return;
}
}

……
}

在上面简化后的代码中我们可以看到,CreateNativeThread 的源码实现最终调用的是 pthread_create 函数,它是一个 Linux 函数,而 pthread_create 函数最终会调用 clone 这个内核函数。clone 函数会根据传入的 stack 大小,通过 mmap 函数申请一块对应大小的虚拟内存,并且创建一个进程。


int clone(int (*fn)(void * arg), void *stack, int flags, void *arg);

所以,对于 Linux 系统来说,一个线程实际是一个精简的进程。我们创建线程时,最终会执行 clone 这个内核函数去创建一个进程,通过查看官方文档也能看到,Clone 函数实际上会创建一个新的进程(These system calls create a new ("child") process, in a manner similar to fork)。


image.png


这里我就不继续深入介绍 Linux 中线程的原理了,如果你有兴趣可以参考这篇文章 《掌握 Android 和 Java 线程原理》。


除了通过线程的创建流程可以证明一个线程需要占用 1M 大小的虚拟内存,我们还能在 maps 文件中证明这一点,还是拿前面篇章提到的“设置”这个系统应用的 maps 文件为例,也能发现 anno:stack_and_tls 也就是线程的虚拟内存,大小为 1M 左右。


image.png


理解了一个线程会占用 1M 大小的虚拟内存,我们自然而然也能想到通过减少线程的数量和减少每个线程所占用的虚拟内存大小来进行优化。接下来,我们就详细了解一下如何实现这两种方案。


减少线程数量


首先是减少线程的数量,我们主要有 2 种手段:




  1. 在应用中使用统一的线程池;




  2. 将应用中的野线程及野线程池进行收敛。




Java 开发者应该都知道线程池,但有的人认知可能不深。实际上,线程池是非常重要的知识点,需要我们熟悉并能熟练使用的。线程池对应用的性能提升有很大的帮助,它可以帮助我们更高效和更合理地使用线程,提升应用的性能。但这里就不详细介绍线程池的使用了,在后面的章节中我们会深入来讲线程池的使用。如果你不熟悉线程池,那我建议你尽快熟悉起来,这里主要针对如何减少线程数这个方向,介绍一下线程池中线程数量的最优设置。


对于线程池,我们需要手动设置核心线程数和最大线程数。核心线程是不会退出的线程,被线程池创建之后会一直存在。最大线程数是该线程池最大能达到的线程数量,当达到最大线程数后,线程池处理新的任务便当做异常,放在兜底逻辑中处理。那么,这两个线程数设置成多少比较合适呢?这个问题也经常作为面试题,需要引起注意。


线程池可以分为 CPU 线程池和 IO 线程池,CPU 线程池用来处理 CPU 类型的任务,如计算,逻辑等操作,需要能够迅速响应,但任务耗时又不能太久。那些耗时较久的任务,如读写文件、网络请求等 IO 操作便用 IO 线程池来处理,IO 线程池专门处理耗时久,响应又不需要很迅速的任务。因此,对于 CPU 的线程池,我们会将核心线程数设置为该手机的 CPU 核数,理想状态下每一个核可以运行一个线程,这样能减少 CPU 线程池的调度损耗又能充分发挥 CPU 性能。


至于 CPU 线程池的最大线程数,和核心线程数保持一致即可。 因为当最大线程数超过了核心线程数时,反倒会降低 CPU 的利用率,因为此时会把更多的 CPU 资源用于线程调度上,如果 CPU 核数的线程数量无法满足我们的业务使用,很大可能就是我们对 CPU 线程池的使用上出了问题,比如在 CPU 线程中执行了 IO 阻塞的任务。


对于 IO 线程池,我们通常会将核心线程数设置为 0 个,而且 IO 线程池并不需要响应的及时性,所以将常驻线程设置为 0 可以减少该应用的线程数量。但并不是说这里一定要设置为 0 个,如果我们的业务 IO 任务比较多,这里也可以设置为不大于 3 个数量。对于 IO 线程池的最大线程数,则可以根据应用的复杂度来设置,如果是中小型应用且业务较简单设置 64 个即可,如果是大型应用,业务多且复杂,可以设置成 128 个


可以看到,如果业务中所有的线程都使用公共线程池,那即使我们将线程的数量设置得非常宽裕,所有线程加起来所占用的虚拟内存也不会超过 200 M。但现实情况下是,应用中总会有大量地方不遵守规范,独自创建线程或者线程池,我们称之为野线程或者野线程池。那如何才能收敛野线程和野线程池呢?


对于简单的应用,我们一个个排查即可,通过全局搜索 new Thread() 线程创建代码,以及全局搜索 newFixedThreadPool 线程池创建代码,然后将不合规范的代码,进行修改收敛进公共线程池即可。


但如果是一个中大型应用,还大量使用了二方库、三方库和 aar 包等,那全局搜索也不管用了,这个时候就需要我们使用字节码操作的方式了,技术方案还是前面文章介绍过的 Lancet,通过 hook 住 newFixedThreadPool 创建线程池的函数,并在函数中将线程池的创建替换成我们公共的线程池,就能完成对线程池的收敛。


public class ThreadPoolLancet {

@TargetClass("java.util.concurrent.Executors")
@Proxy(value = "newFixedThreadPool")
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
// 替换并返回我们的公共线程池
……
}

@TargetClass("java.util.concurrent.Executors")
@Proxy(value = "newFixedThreadPool")
public static ExecutorService newFixedThreadPool(int nThreads) {
// 替换并返回我们的公共线程池
……
}
}

收敛完了野线程池,那直接使用 new Thread() 创建的野线程又该怎么收敛呢? 对于三方库中的野线程,我们没有太好的收敛手段,因为即使 Thread 的构造函数被 hook 住了,也不能将其收敛到公共线程池中。好在我们使用的三方库大都已经很成熟并经过大量用户验证过,直接使用野线程的地方会很少。我们可以采用 hook 住 Thread 的构造函数并打印堆栈的方式,来确定这个线程是不是通过线程池创建出来的,如果三方库中确实有大量的野线程,那么我们只能将源码下载下来之后手动修改了。


减少线程占用的虚拟内存


在刚才讲解 CreateNativeThread 源码的时候我们讲过,该函数会执行 FixStackSize 方法将 stack_size 调整为 1M。那结合前面各种 hook 的案例,我们很容易就能想到,通过 hook FixStackSize 这个函数,是不是可以将 stack_size 的从 1M 减少到 512 KB 了呢? 当时是可以的,但是这个时候我们没法通过 PLT Hook 的方案来实现了,而是要通过 Inline Hook 方案实现,因为 FixStackSize 是 so 库内部函数的调用,所以只有 FixStackSize 才能实现。


那如果我们想用 PLT Hook 方案来实现可以做到么?其实也可以。CreateNativeThread 是位于 libart.so 中的函数,但是 CreateNativeThread 实际是调用 pthread_create 来创建线程的,而 pthread_create 是位于 libc.so 库中的函数,如果在 CreateNativeThread 中调用 pthread_create ,同样需要通过走 plt 表和 got 表查询地址的方式,所以我们通过 bhook 工具 hook 住 libc.so 库中的 pthread_create 函数,将入参 &attr 中的 stack_size 直接设置成 512KB 即可,实现起来也非常简单,一行代码即可。


static int AdjustStackSize(pthread_attr_t const* attr) {
pthread_attr_setstacksize(attr, 512 * 1024);
}

至于如何 hook 住 pthread_create 这个函数的方法也非常简单,通过 bhook 也是一行代码就能实现,前面的篇章已经讲过怎么使用了,所以这个方案剩下的部分就留给你自己去实践啦。


除了 Native Hook 方案,我们还能在 Java 层通过字节码操作的方式来实现该方案。stack_size 不就是通过 Java 层传递到 Native 层嘛,那我们直接在 Java 层调整 stack_size 的大小就可以了,但在这之前之前,要先看看在 FixStackSize 函数中是如何调整 stack_size 大小的。


static size_t FixStackSize(size_t stack_size) {

if (stack_size == 0) {
stack_size = Runtime::Current()->GetDefaultStackSize();
}

stack_size += 1 * MB;

……

return stack_size;
}

FixStackSize 函数的源码实现很简单,就是通过 stack_size += 1 * MB 来设置 stack_size 的:如果我们传入的 stack_size 为 0 时,默认大小就是 1 M ;如果我们传入的 stack_size 为 -512KB 时,stack_size 就会变成 512KB(1M - 512KB)。那我们是不是只用带有 stackSize 入参的构造函数去创建线程,并且设置 stackSize 为 -512KB 就行了呢?


public Thread(ThreadGroup group, Runnable target, String name,
long stackSize)
{
this(group, target, name, stackSize, null, true);
}

是的,但是因为应用中创建线程的地方太多很难一一修改,而且我们实际不需要这样去修改。前面我们已经将应用中的线程全部收敛到公共线程池中去创建了,所以只需要修改公共线程池中创建的线程方式就可以了,并且线程池刚好也可以让我们自己创建线程,那只需要传入自定义的 ThreadFactory 就能实现需求。


image.pngimage.png

在我们自定义的 ThreadFactory 中,创建 stack_size 为 - 512 kb 的线程,这么一个简单的操作就能减少线程所占用的虚拟内存。


image.png


当我们将应用中线程栈的大小全改成 512 kb 后,可能会导致一些任务比较重的线程出现栈溢出,此时我们可以通过埋点收集会栈溢出的线程,不修改这部分线程的大小即可。总的来说,这是一个容易落地且投入产出比高的方案。


通过上面的方案介绍,我们也可以看到,减少一个线程所占用的虚拟内存的方案很多,可以通过 Native Hook,也可以通过 Java 代码直接修改。我们在做业务或者性能相关的工作时,往往都有多个实现方案,但是我们在敲定最终方案时,始终要选择最简单、最稳定且投入产出比最高的方案。


多进程架构优化


在 Java 堆内存优化中,我们已经讲到了可以通过多进程优化,那对于虚拟内存,我们依然可以通过多进程的架构来优化。比如说,下面这些业务我都建议你放在独立的进程中:




  1. WebView 相关的业务




  2. 小程序相关的业务




  3. Flutter 相关的业务




  4. RN 相关的业务




这些业务都是虚拟内存占用的大户,用独立的进程来承载,会减少很多虚拟内存的占用,也会减少相应的异常情况。并且,将这些业务放在子进程中也很简单,只需要在承载这些业务的 activity 的 mainfest 配置文件中添加 android:process = "子进程名" 即可。需要注意的是,如果我们把业务放在子进程,就没法直接和主进程通信了,需要借助 Binder 跨进程通信的方式来完成。


当然,你还可能会担心把这些业务放在独立进程后,会影响这些业务的启动速度,其实这都可以通过各种优化方案来解决,比如预启动子进程等。在后面速度提升优化的章节中,我们会进行详细讲解。


小结


这一节课我们介绍了两种虚拟内存优化方案,如下图:


image.png


这两种优化方案相对简单,容易落地,投入产出比高。对于一个中小型应用来说,这两个方案几乎能保证 32 位手机上有足够可用的虚拟内存了。如果这两个方案落地后,还是会有因虚拟内存不足导致的应用崩溃问题,我们就需要接着用“黑科技”手段来进行优化了,所以在下一篇文章中,会接着带大家看看有哪些“黑科技”可以用在虚拟内存优化上,它们又能带来什么样的效果!


作者:helson赵子健
来源:juejin.cn/post/7209306358582853688
收起阅读 »

ChatGPT3微调-评论文本情感分析

前言 如果阅读过openai的文档,便能看到对于模型提供了fine-turning功能,即微调。GPT-3已经在互联网中进行了大量文本的预训练,当我们给出少量示例的提示时,它通常可以直观地了解正在尝试执行的任务并生成一个合理的完成。这通常被称为“小样本学习”。...
继续阅读 »

前言


如果阅读过openai的文档,便能看到对于模型提供了fine-turning功能,即微调。GPT-3已经在互联网中进行了大量文本的预训练,当我们给出少量示例的提示时,它通常可以直观地了解正在尝试执行的任务并生成一个合理的完成。这通常被称为“小样本学习”。但我们需要的是一些特定的需求,比如GPT之前未预训练过的数据或是一些私有数据,便可以用微调通过训练来改进小样本学习。


那么微调都可以解决什么问题呢?结合官网的指南,常见的场景有:



  • 文本生成:可以通过提供相关数据集和指导性的文本,生成更加准确和有针对性的文本

  • 文本分类:将一段文本分成多个类别,例如电子邮件分类

  • 情感分析:分析一段文本的情感倾向,是否积极或消极


本文将对情感分析进行一次尝试。


准备数据


先从网上获取了一份关于酒店评论的数据,总共就两列。


第一列是评论内容,第二列1代表积极, 0 代表消极。


image.png


有需要的可以从这里下载,总共是1w条
评论文本情感分析


不过目前的数据是不能直接使用的,我们需要转换成GPT能接受的格式


{"prompt": "", "completion": ""}
{"prompt": "", "completion": ""}
...

数据预处理


openai很贴心的准备一个工具来验证、格式化数据。


安装CLI


pip install --upgrade openai


验证、格式化


openai tools fine_tunes.prepare_data -f


image.png


执行命令后我们看到他返回的提示中告诉了数据一共有300条,并猜测我们是要进行分类模型,同时建议我们用ada模型,拆分出训练集和测试集,加入分隔符(加入分隔符可以帮助模型更好地理解输入的数据),分别会在接下来让我们选择


为所有提示添加后缀分隔符 `->`
- [Recommended] Add a suffix separator ` ->` to all prompts [Y/n]: Y
在完成的开头添加空格字符
- [Recommended] Add a whitespace character to the beginning of the completion [Y/n]: Y
是否要将其拆分为训练和验证集
- [Recommended] Would you like to split into training and validation set? [Y/n]: Y

无特殊情况全部选Y即可。


image.png


之后会生成两个jsonl文件,同时返回一段参考命令、训练预计的时间。


训练模型


选择模型


首先,我们需要对模型进行一个选择,目前只支持davincicuriebabbageada


模型名称描述训练/1K tokens使用/1K tokens
Davinci最强大的GPT-3模型,可以完成其他模型可以完成的任何任务,通常具有更高的质量$0.0300 $0.1200
Curie非常有能力,但速度更快,成本更低,比Davinci更适合$0.0030$0.0120
Babbage适用于简单任务,非常快速,成本更低$0.0006$0.0024
Ada适用于非常简单的任务,通常是GPT-3系列中速度最快,成本最低的模型$0.0004$0.0016

模型的训练和使用都是需要费用的。出于各种原因我们直接选择Ada。


开始训练


在此之前,我们先将key添加到环境变量中


export OPENAI_API_KEY=""


然后再来看一下之前openai给我们参考的代码


openai api fine_tunes.create 
-t ".\train_data_prepared_train.jsonl"
-v ".\train_data_prepared_valid.jsonl"
--compute_classification_metrics
--classification_positive_class " 1"

-t、-v分别是训练集和测试集


--compute_classification_metrics可以计算模型在分类任务中的性能指标,在验证集上计算模型的准确率(accuracy)、精确率(precision)、召回率(recall)和F1分数。这有助于评估模型的性能,并调整微调过程中的超参数和训练策略。


--classification_positive_class是指分类的积极类别或正例


这里还需要一个 -m,来设置选择的模型。我也是手快直接回车了,本来以为会报错,可它正常运行了,但是默认的模型可能不是我们期望的ada,所以我们需要取消这次训练。


3VGP%(3UDXQ@4`7`}0`IG%V.gif


openai api fine_tunes.cancel -i


不过我也是用list查了一下,发现默认的模型是curie


openai api fine_tunes.list


image.png


接下来我们加上模型等待训练完成即可。
如果过程中不小心关掉窗口或者中断了可以用以下命令恢复。


openai api fine_tunes.follow -i


结束训练


耗时25分钟,花费了0.06刀(比预计的少很多)。


image.png


最后我们看一下分析结果

openai api fine_tunes.results -i


image.png


详细的解析大家可以阅读官方文档,这里我们主要看一下准确度

image.png


使用模型


模型的性能指标给出了0.85的准确率,这里用Playground调用测试一下。


除此之外还可以使用CLI命令


openai api completions.create -m -p


或者使用API


const openai = new OpenAIApi(configuration);
const response = await openai.createCompletion({
model: "训练完后模型的id",
prompt: "Say this is a test",
});

输入的prompt末尾需要加上之前CLI自动给我们补齐的分隔符。


从大数据集中随机拿了几个例子,结果是对的,但是输出有问题
image.png


image.png


image.png


D9}6@O_VYQ@W5R)BI)J%Q_W.gif


应该是completion结尾没有分隔符的原因,明天再试试,顺便扩大一下样本。

梅开二度


第二次训练在completion的末尾全都加上了" ###"作为分隔符。


在playgroud、API、CLI中记得设置Stop


image.png


image.png


image.png


R`F1(}96)`OO(YWJD9`{U]D.jpg


作者:Defineee
来源:juejin.cn/post/7208108117837217848
收起阅读 »

Android将so库封装到jar包中并加载其中的so库

说明 因为一些原因,我们提供给客户的sdk,只能是jar包形式的,一些情况下,sdk里面有native库的时候,就不太方便操作了,此篇文章主要解决如何把so库放入jar包里面,如何打包成jar,以及如何加载。 1.如何把so库放入jar包 so库放入jar参考...
继续阅读 »

说明


因为一些原因,我们提供给客户的sdk,只能是jar包形式的,一些情况下,sdk里面有native库的时候,就不太方便操作了,此篇文章主要解决如何把so库放入jar包里面,如何打包成jar,以及如何加载。


1.如何把so库放入jar包


so库放入jar参考此文章ANDROID将SO库封装到JAR包中并加载其中的SO库
放置路径
将so库改成.jet后缀,放置和加载so库的SoLoader类同一个目录下面。


2.如何使用groovy打包jar


打包jar
先把需要打包的class放置到同一个文件夹下面,然后打包即可,利用groovy的copy task完成这项工作非常简单。


3.如何加载jar包里面的so


3.1.首先判断当前jar里面是否存在so

InputStream inputStream = SoLoader.class.getResourceAsStream("/com/dianping/logan/arm64-v8a/liblogan.jet");

如果inputStream不为空就表示存在。


3.2.拷贝

判断是否已经把so库拷贝到手机里面了,如果没有拷贝过就进行拷贝,这个代码逻辑很简单。


public class SoLoader {
private static final String TAG = "SoLoader";

/**
* so库释放位置
*/

public static String getPath() {
String path = GlobalCtx.getApp().getFilesDir().getAbsolutePath();
//String path = GlobalCtx.getApp().getExternalFilesDir(null).getAbsolutePath();
return path;
}

public static String get64SoFilePath() {
String path = SoLoader.getPath();
String v8a = path + File.separator + "jniLibs" + File.separator +
"arm64-v8a" + File.separator + "liblogan.so";
return v8a;
}

public static String get32SoFilePath() {
String path = SoLoader.getPath();
String v7a = path + File.separator + "jniLibs" + File.separator +
"armeabi-v7a" + File.separator + "liblogan.so";
return v7a;
}

/**
* 支持两种模式,如果InputStream inputStream = SoLoader.class.getResourceAsStream("/com/dianping/logan/arm64-v8a/liblogan.jet");
* 返回了空,表示可能此库是aar接入的,普通加载so库就行,不为空,需要拷贝so库,动态加载
*/

public static boolean jarMode() {
boolean jarMode = false;
InputStream inputStream = SoLoader.class.getResourceAsStream("/com/dianping/logan/arm64-v8a/liblogan.jet");
if (inputStream != null) {
jarMode = true;
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return jarMode;
}

/**
* 是否已经拷贝过so了
*/

public static boolean alreadyCopySo() {
String v8a = SoLoader.get64SoFilePath();
File file = new File(v8a);
if (file.exists()) {
String v7a = SoLoader.get32SoFilePath();
file = new File(v7a);
return file.exists();
}
return false;
}

/**
* 拷贝logan的so库
*/

public static boolean copyLoganJni() {
boolean load;
File dir = new File(getPath(), "jniLibs");
if (!dir.exists()) {
load = dir.mkdirs();
if (!load) {
return false;
}
}
File subdir = new File(dir, "arm64-v8a");
if (!subdir.exists()) {
load = subdir.mkdirs();
if (!load) {
return false;
}
}
File dest = new File(subdir, "liblogan.so");
//load = copySo("/lib/arm64-v8a/liblogan.so", dest);
load = copySo("/com/dianping/logan/arm64-v8a/liblogan.jet", dest);
if (load) {
subdir = new File(dir, "armeabi-v7a");
if (!subdir.exists()) {
load = subdir.mkdirs();
if (!load) {
return false;
}
}
dest = new File(subdir, "liblogan.so");
//load = copySo("/lib/armeabi-v7a/liblogan.so", dest);
load = copySo("/com/dianping/logan/armeabi-v7a/liblogan.jet", dest);
}
return load;
}

public static boolean copySo(String name, File dest) {
InputStream inputStream = SoLoader.class.getResourceAsStream(name);
if (inputStream == null) {
Log.e(TAG, "inputStream == null");
return false;
}
boolean result = false;
FileOutputStream outputStream = null;
try {
outputStream = new FileOutputStream(dest);
int i;
byte[] buf = new byte[1024 * 4];
while ((i = inputStream.read(buf)) != -1) {
outputStream.write(buf, 0, i);
}
result = true;
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return result;
}

}

3.3.加载

首先判断当前应用是32位还是64位Process.is64Bit();。然后加载对应的32或者64位的so。


static {
try {
if (SoLoader.jarMode()) {
if (SoLoader.alreadyCopySo()) {
sIsCloganOk = loadLocalSo();
} else {
boolean copyLoganJni = SoLoader.copyLoganJni();
if (copyLoganJni) {
sIsCloganOk = loadLocalSo();
}
}
} else {
System.loadLibrary(LIBRARY_NAME);
sIsCloganOk = true;
}
} catch (Throwable e) {
e.printStackTrace();
sIsCloganOk = false;
}
}

static boolean loadLocalSo() {
boolean bit = Process.is64Bit();
if (bit) {
String v8a = SoLoader.get64SoFilePath();
try {
System.load(v8a);
return true;
} catch (Throwable e) {
e.printStackTrace();
return false;
}
} else {
String v7a = SoLoader.get32SoFilePath();
try {
System.load(v7a);
return true;
} catch (Throwable e) {
e.printStackTrace();
return false;
}
}
}

作者:CCtomorrow
来源:juejin.cn/post/7206627150621851707
收起阅读 »

Android 完整的apk打包流程

在Android Studio中,我们需要打一个apk包,可以在Gradle task 任务中选一个 assembleDebug/assembleRelease 任务, 控制台上就可以看到所有的构建相关task: 可以看到,这么多个task任务,执行是有...
继续阅读 »

在Android Studio中,我们需要打一个apk包,可以在Gradle task 任务中选一个
assembleDebug/assembleRelease 任务,


企业微信截图_fa2194a8-735e-4720-91be-81fd2524d20f.png


控制台上就可以看到所有的构建相关task:


截屏2023-03-05 20.48.57.png
截屏2023-03-05 20.49.28.png
可以看到,这么多个task任务,执行是有先后顺序的,其实主要就是以下步骤:


//aidl 转换aidl文件为java文件
> Task :app:compileDebugAidl

//生成BuildConfig文件
> Task :app:generateDebugBuildConfig

//获取gradle中配置的资源文件
> Task :app:generateDebugResValues

// merge资源文件
> Task :app:mergeDebugResources

// merge assets文件
> Task :app:mergeDebugAssets
> Task :app:compressDebugAssets

// merge所有的manifest文件
> Task :app:processDebugManifest

//AAPT 生成R文件
> Task :app:processDebugResources

//编译kotlin文件
> Task :app:compileDebugKotlin

//javac 编译java文件
> Task :app:compileDebugJavaWithJavac

//转换class文件为dex文件
> Task :app:dexBuilderDebug

//打包成apk并签名
> Task :app:packageDebug

依靠这些关键步骤最后就能打包出一个apk。


首先看


第一步:aidl(编译aidl文件)


将项目中的aidl文件编译为java文件,AIDL用于进程间通信


第二步:生成BuildConfig文件


在项目中配置了
buildConfigField等信息,会在BuildConfig class类里以静态属性的方式展示:


截屏2023-03-05 21.09.18.png


第三步:合并Resources、assets、manifest、so等资源文件


在我们的项目中会依赖不同的库、组件,也会有多渠道的需求,所以merge这一步操作就是将不同地方的资源文件进行整合。
多个manifest文件也需要整理成一个完整的文件,所以如果有属性冲突这一步就会报错。资源文件也会整理分类到不同的分辨率目录中。


资源处理用的工具是aapt/aapt2


注意AGP3.0.0之后默认通过AAPT2来编译资源,AAPT2支持了增量更新,大大提升了效率。


AAPT 工具负责编译项目中的这些资源文件,所有资源文件会被编译处理,XML 文件(drawable 图片除外)会被编译成二进制文件,所以解压 apk 之后无法直接打开 XML 文件。但是 assets 和 raw 目录下的资源并不会被编译,会被原封不动的打包到 apk 压缩包中。
资源文件编译之后的产物包括两部分:resources.arsc 文件和一个 R.java。前者保存的是一个资源索引表,后者定义了各个资源 ID 常量。这两者结合就可以在代码中找到对应的资源引用。比如如下的 R.java 文件:


截屏2023-03-05 21.19.59.png
实际上被打包到 apk 中的还有一些其他资源,比如 AndroidManifest.xml 清单文件和三方库中使用的动态库 .so 文件。


第四步:编译java文件(用到的工具 javac )


1、java文件包含之前提到的AIDL 生成的java文件


2、java代码部份:通过Java Compiler 编译项目中所有的Java代码,包括R.java.aidl文件生成的.java文件、Java源文件,生成.class文件。在对应的build目录下可以找到相关的代码


3、kotlin代码部份:通过Kotlin Compiler编译项目中的所有Kotlin代码,生成.class文件


注:注解处理器(APT,KAPT)生成代码也是在这个阶段生成的。当注解的生命周期被设置为CLASS的时候,就代表该注解会在编译class文件的时候生效,并且生成java源文件和Class字节码文件。

第五步: Class文件打包成DEX(dx/r8/d8等工具编译class文件)


image.png



  • 在原来 dx是最早的转换工具,用于转换class文件为dex文件。

  • Android Studio 3.1之后,引入了D8编译器和 R8 工具。

  • Android Studio 3.4之后,默认开启 R8
    具体的区别可以点击看看


注意:JVM 和 Dalvik(ART) 的区别:JVM执行的是.class文件、Dalvik和ART执行的.dex文件。具体的区别可以点击看看


而在编译class文件过程也常用于编译插桩,比如ASM,通过直接操作字节码文件完成代码修改或生成。


第六步:apkbuilder/zipflinger(生成APK包)


这一步就是生成APK文件,将manifest文件、resources文件、dex文件、assets文件等等打包成一个压缩包,也就是apk文件。
在老版本使用的工具是apkbuilder,新版本用的是 zipflinger
而在AGP3.6.0之后,使用zipflinger作为默认打包工具来构建APK,以提高构建速度。


第七步: zipalign(对齐处理)


对齐是Android apk 很重要的优化,它会使 APK 中的所有未压缩数据(例如图片或原始文件)在 4 字节边界上对齐。这使得CPU读写就会更高效。


也就是使用工具 zipalign 对 apk 中的未压缩资源(图片、视频等)进行对齐操作,让资源按照 4 字节的边界进行对齐。这种思想同 Java 对象内存布局中的对齐空间非常类似,主要是为了加快资源的访问速度。如果每个资源的开始位置都是上一个资源之后的 4n 字节,那么访问下一个资源就不用遍历,直接跳到 4n 字节处判断是不是一个新的资源即可。


第八步: apk 签名


没有签名的apk 无法安装,也无法发布到应用市场。


大家比较熟知的签名工具是JDK提供的jarsigner,而apksignerGoogle专门为Android提供的签名和签证工具。


其区别就在于jarsigner只能进行v1签名,而apksigner可以进行v2v3v4签名。



  • v1签名


v1签名方式主要是利用META-INFO文件夹中的三个文件。


首先,将apk中除了META-INFO文件夹中的所有文件进行进行摘要写到 META-INFO/MANIFEST.MF;然后计算MANIFEST.MF文件的摘要写到CERT.SF;最后计算CERT.SF的摘要,使用私钥计算签名,将签名和开发者证书写到CERT.RSA。


所以META-INFO文件夹中这三个文件就能保证apk不会被修改。



  • v2签名


Android7.0之后,推出了v2签名,为了解决v1签名速度慢以及签名不完整的问题。


apk本质上是一个压缩包,而压缩包文件格式一般分为三块:


文件数据区,中央目录结果,中央目录结束节。


而v2要做的就是,在文件中插入一个APK签名分块,位于中央目录部分之前,如下图:


图片


这样处理之后,文件就完成无法修改了,这也是为什么 zipalign(对齐处理) 要在签名之前完成。



  • v3签名


Android 9 推出了v3签名方案,和v2签名方式基本相同,不同的是在v3签名分块中添加了有关受支持的sdk版本和新旧签名信息,可以用作签名替换升级。



  • v4签名


Android 11 推出了v4签名方案。


最后,apk得以完成打包


PMS 在安装过程中会检查 apk 中的签名证书的合法性,具体安装apk内容稍后介绍。


apk内容包含如下:


截屏2023-03-05 22.01.14.png


总体的打包流程图如下:


截屏2023-03-05 22.02.33.png


,,


作者:大强Dev
来源:juejin.cn/post/7206998548343668796
收起阅读 »

字节跳动音视频面试一面挂,转拿腾讯音视频 offer

一、面试官: 视频为什么需要压缩 心理分析:视频压缩在音视频领域是一个恒久不变的话题,有压缩也就意味有解压操作,我们把压 缩称为编码 解压成为解码。它们是成对出现的,做音视频最难的就在音视频编解码。如何提高音 视频播放效率,在不牺牲视频质量下 做高度压缩就显...
继续阅读 »

一、面试官: 视频为什么需要压缩



心理分析:视频压缩在音视频领域是一个恒久不变的话题,有压缩也就意味有解压操作,我们把压
缩称为编码 解压成为解码。它们是成对出现的,做音视频最难的就在音视频编解码。如何提高音
视频播放效率,在不牺牲视频质量下 做高度压缩就显得格外重要了。面试官想问的问题并不是压
缩了什么,而是编码中对视频帧做了什么



求职者:需要求职者对视频编码有所了解,接下来我们从帧内压缩,与帧间压缩讲起



  • 未经压缩的数字视频的数据量巨大 下图一分钟的视频量 差不多需要68G

  • 存储困难:一张32G的U盘只能存储几秒钟的未压缩数字视频。

  • 传输困难 : 1兆的带宽传输一秒的视频需要大约10分钟。



二、面试官: 封装格式是什么



心理分析:很多人对音视频的概念停留在 苍老师的小电影上,只能理解他是一个视频文件。面试官考
的对视频文件下的封装格式,封装格式里面的内容有没有了解



求职者:首先需要从封装格式概念讲起,慢慢深入到封装格式基础下,然后散发解封装与封装过程


(1)封装格式(也叫容器)就是将已经编码压缩好的视频轨和音频轨按照一定的格式放到一个文件中,也就
是说仅仅是一个外壳,可以把它当成一个放视频轨和音频轨的文件夹也可以。
(2)通俗点说视频轨相当于饭,而音频轨相当于菜,封装格式就是一个碗,或者一个锅,用来盛放饭菜的容
器。
(3)封装格式和专利是有关系的,关系到推出封装格式的公司的盈利。
(4)有了封装格式,才能把字幕,配音,音频和视频组合起来。
(5)常见的AVI、RMVB、MKV、ASF、WMV、MP4、3GP、FLV等文件都指的是一种封装格式。


举例MKV格式的封装



三、面试官: 一个视频文件是否可以完成倒放(或者你们的倒放如何实现)



心理分析:面试官考的是 是否有经历过音视频剪辑相关的经验,需要从求职者中得到的答案,不是
“能”或者“不能” 而是分析为什么不能倒放,



不能倒放的本质原因,对I B P有有没有了解



求职者:倒放在视频剪辑中 是必备功能,按常理来看,倒放肯定是能够实现的,但是问题就出现在
这里,求职者如果对视频编码原理不理解的话,对视频倒放肯定打不上来的,求职者需要首先答对
“一个视频不能实现倒放,两个文件可以" 这个入手, 再从编解码入手 讲解为什么不能实现倒放



答案


第一种方式:



  1. 从第一个gop然后顺序解码

  2. 将一个解码的gop的yuv写入文件中

  3. 将第二个gop顺序解码yuv写入文件

  4. 第三个gop序列,以此类推....


然后倒序读入内存中,进行编码即可.



缺点:如果文件过大,不能使用此方法,因为yuv文件较大,一分钟yuv就有1-2G左右,有可能撑爆sdcard.



第二种方式



  1. 全部遍历视频一遍,获取一共有多少gop序列

  2. 跳到(seek)到最后一个gop的I帧,然后把这个gop解码的yuv存放在sdcard

  3. 再逆序读出这个解码的gop的yuv,进行编码,这样最后一个gop就变成了第一帧的gop;

  4. 接下来seek到倒数第一个gop的I帧,依次类推,把每个gop解码、然后编码


其实在音视频岗位面试中,问到得远远不止上面的相关问题,上述知识举例,还有更多内容可以面试题可以参考:



1.什么是I帧 P帧 B帧?
2.简述H264视频编码流程?
3.视频能倒放吗,倒放如何实现?
4.硬编码与软编码有什么区别?
5.你对sps 和pps的理解?
6.如何从一段残缺H264数据 解析出画面?
7.讲讲MediaCodec硬编码底层解码机制?
8.音频播放过快,视频慢,如何选择丢帧
9.码率和分辨率都会影响视频的清晰度
10.生产者和消费者的关系
11.sps和pps的区别
12.……



对一些没有学习过、了解过音视频这块知识点的朋友,仿佛是在看天书一般,在这里请大家不要着急,在这为大家准备了《Android 音视频开发入门到精通》的学习笔记:https://qr18.cn/Ei3VPD,帮助大家快速提升。


作者:冬日毛毛雨
来源:juejin.cn/post/7208092574162157626
收起阅读 »

简单教你Intent如何传大数据

前言 最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,...
继续阅读 »

前言


最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,并且简单聊聊这背后所涉及到的东西。


Intent传大数据


平时可能不会发生这种问题,但比如我之前是做终端设备的,我的设备每秒都会生成一些数据,而长时间的话数据量自然大,这时当我跳到另外一个页面使用intent把数据传过去的时候,就会报错


我们调用


intent.putExtra("key", value) // value超过1M

会报错


android.os.TransactionTooLargeException: data parcel size xxx bytes

这里的xxx就是1M左右,告诉你传输的数据大小不能超过1M,有些话咱也不敢乱说,有点怕误人子弟。我这里是凭印象说的,如果有大佬看到我说错,请狠狠的纠正我。


这个错误描述是这么描述,但真的是限死1M吗,说到这个,就不得不提一样东西,Binder机制,先不要跑,这里不会详细讲Binder,只是提一嘴。


说到Binder那就会联系到mmap内存映射,你可以先简单理解成内存映射是分配一块空间给内核空间和用户空间共用,如果还是不好理解,就简单想成分配一块空间通信用,那在android中mmap分配的空间是多少呢?1M-4K。


那是不是说Intent传输的数据超过1M-4K就会报错,理论上是这样,但实际没到这个值,比如0.8M也可能会报错。所以你不能去走极限操作,比如你的数据到了1M,你觉得只要减少点数据,减到8K,应该就能过了,也许你自己测试是正常的,但是这很危险。


所以能不传大数据就不要传大数据,它的设计初衷也不是为了传大数据用的。如果真要传大数据,也不要走极限操作。


那怎么办,切莫着急,请听我慢慢讲。就这个Binder它是什么玩意,它是Android中独特的进程通信的方式,而Linux中进程通信的方式,在Android中同样也适用。进程间通信有很多方式,Binder、管道、共享内存等。为什么会有这么多种通信方式,因为每种通信方式都有自己的特点,要在不同的场合使用不同的通信方式。


为什么要提这个?因为要看懂这个问题,你需要知道Binder这种通信方式它有什么特点,它适合大量的数据传输吗?那你Binder又与我Intent何干,你抓周树人找我鲁迅干嘛~~所以这时候你就要知道Android四大组件之间是用什么方式通信的。


有点扯远了,现在可以来说说结论了,Binder没办法传大数据,我就1M不到你想怎样?当然它不止1M,只是Android在使用时限制了它只能最多用1M,内核的最大限制是4M。又有点扯远了,你不要想着怎么把限制扩大到4M,不要往这方面想。前面说了,不同的进程通信方式,有自己的特点,适用于某些特定的场景。那Binder不适用于传输大数据,我共享内存行不行?


所以就有了解决办法


bundle.putBinder()

有人可能一看觉得,这有什么不同,这在表面上看差别不大,实则内部大大的不同,bundle.putBinder()用了共享内存,所以能传大数据,那为什么这里会用共享内存,而putExtra不是呢?想搞清楚这个问题,就要看源码了。 这里就不深入去分析了,我怕劝退,不是劝退你们,是劝退我自己。有些东西是这样的,你要自己去看懂,看个大概就差不多,但是你要讲出来,那就要看得细致,而有些细节确实会劝退人。所以想了解为什么的,可以自己去看源码,不想看的,就知道这是怎么一回事就行。


那还有没有其它方式呢?当然有,你不懂共享内存,你写到本地缓存中,再从本地缓存中读取行不行?


办法有很多,如果你不知道这个问题怎么解决,你找不到你觉得可行的解决方案,甚至可以通过逻辑通过流程的方式去绕开这个问题。但是你要知道为什么会出现这样的问题,如果你没接触过进程通信,没接触过Binder,让你看一篇文章就能看懂我觉得不切实际,但是至少得知道是怎么一回事。


比如我只说bundle.putBinder()能解决这个问题,你一试,确实能解决,但是不知道为什么,你又怕会不会有其它问题。虽然这篇文章我一直在打擦边球,没有提任何的原理,但我觉得还是能大概让人知道为什么bundle.putBinder()能解决Intent传大数据,

作者:流浪汉kylin
来源:juejin.cn/post/7205138514870829116
你也就能放心去用了。

收起阅读 »

Android必知必会-Stetho调试工具

一、背景 Stetho是 Facebook 出品的一个强大的 Android 调试工具,使用该工具你可以在 Chrome Developer Tools查看APP的布局, 网络请求(仅限使用Volle, okhttp的网络请求库), Sqlite, Pref...
继续阅读 »

一、背景



Stetho是 Facebook 出品的一个强大的 Android 调试工具,使用该工具你可以在 Chrome Developer Tools查看APP的布局, 网络请求(仅限使用Volle, okhttp的网络请求库), Sqlite, Preference, 一切都是可视化的操作,无须自己在去使用adb, 也不需要root你的设备



本人使用自己的Nubia Z9 Mini作为调试机,由于牵涉到Sqlite数据库,所以尝试了很多办法把它Root了,然而Root之后就无法正常升级系统。
今天得知一调试神器Stetho,无需Root就能查看数据库以及APP的布局(这一点没有Android Device Monitor使用方便,但是Android Device Monitor在Mac上总是莫名其妙出问题),使用起来很方便,大家可以尝试一下。


二、配置流程


1.引入主库


使用Gradle方式:


// Gradle dependency on Stetho 
dependencies {
compile 'com.facebook.stetho:stetho:1.3.1'
}

此外还支持Maven方式,这里不做介绍。


2.引入网络请求库


如果需要调试网络且你使用的网络请求库是Volle或者Okhttp,那么你才需要配置,否则跳过此步。
以下根据自己使用的网络请求库情况来导入相应的库:
1.使用okhttp 2.X


 dependencies { 
compile 'com.facebook.stetho:stetho-okhttp:1.3.1'
}

2.使用okhttp 3.X


dependencies { 
compile 'com.facebook.stetho:stetho-okhttp3:1.3.1'
}

3.使用HttpURLConnection


dependencies { 
compile 'com.facebook.stetho:stetho-urlconnection:1.3.1'
}

3.配置代码


配置Application


public class XXX extends Application {
public void onCreate() {
super.onCreate();
Stetho.initializeWithDefaults(this);
}
}

配置网络请求库:
OkHttp 2.2.x+ 或 3.x


//方案一
OkHttpClient client = new OkHttpClient();
client.networkInterceptors().add(new StethoInterceptor());

//方案二
new OkHttpClient.Builder()
.addNetworkInterceptor(new StethoInterceptor())
.build();

如果使用的是HttpURLConnection,请查阅相关文档。


4.使用


运行重新编译后的APP程序,保持手机与电脑的连接,然后打开Chrome浏览器,在地址栏里输入:chrome://inspect然后选择自己的设备下运行的APP进程名下的Inspect链接 即可进行调试。


三、遇到的问题


1.okhttp版本问题:


可能你还在使用okhttp 2.x的版本,在引入网络库的时候,你需要去查看一下Stetho当前版本使用的okhttp版本,避免在项目中使用多个不同版本的okhttp


PSokhttp2.x和3.x的引入方式略有不同,不可以直接修改版本号来导入:


//2.x
compile 'com.squareup.okhttp:okhttp:2.x.x'
//3.x
compile 'com.squareup.okhttp3:okhttp:3.x.x'

2.配置okhttp代码方案一报错:


//方案一
OkHttpClient client = new OkHttpClient();
client.networkInterceptors().add(new StethoInterceptor());

//方案二
OkHttpClient client = new OkHttpClient.Builder()
.addNetworkInterceptor(new StethoInterceptor())
.build();

我在使用方案一进行配置okhttp的时候,会报错:


 Caused by: java.lang.UnsupportedOperationException

不知道是不是兼容的问题,大家在使用的时候请注意。



Stetho官网






转载请注明出处,如果有什么建议或者问题可以随时联系我,共同探讨学习:



作者:cafeting
来源:juejin.cn/post/7202164243612860472

收起阅读 »

Android:我是如何优化APP体积的

前言 在日常开发中,随着APP功能迭代发现打出的安装包体积越来越大,这里说的大是猛增的那种大,而并非一点一点增大。从最开始的几兆到后面的几十兆,虽然市面上的很多APP甚至达到上百兆,但毕竟别人功能强大,用到的一些底层库就特别占面积,流量也多所以也可理解。但自...
继续阅读 »

前言



在日常开发中,随着APP功能迭代发现打出的安装包体积越来越大,这里说的大是猛增的那种大,而并非一点一点增大。从最开始的几兆到后面的几十兆,虽然市面上的很多APP甚至达到上百兆,但毕竟别人功能强大,用到的一些底层库就特别占面积,流量也多所以也可理解。但自研的一些APP可经不住这些考验,所以能压缩就压缩,能优化就尽量优化,以得到用户最好的体验,下面就来说说我在项目中是如何优化APP体积的。



1. 本地资源优化


这里主要是压缩一些图片和视频。项目中本地资源用到最多的应该就是图片,几乎每个页面都离不开图标,甚至一些页面采用大图片的形式。你可知道,正常不经压缩的图片大的可以上大几十兆,小则也是一兆起步。这里做了个实验,同一个文件分别采用svg、png、使用tiny压缩后的png、webp四种类型图片进行展示(顺序是从左到右,从上到下):


image.png


可以看到,加载出来的效果几乎没有什么区别,但体积却有很大的差别(其中webp是采取的默认75%转换):


image.png


所以,别再使用png格式图片,太浪费资源了,就算经过压缩还是不及svg和webp,这里的webp其实还可以加大转换力度,但个人还是比较喜欢svg。


至于音视频文件也是可以通过其他工具进行压缩再放入本地,如非必要,尽量还是使用网络资源。


2. lib优化


一些三方库会使用底层so文件,一般在配置的时候我们尽量选择一种cpu类型,这里选择armeabi-v7a,其实几乎都兼容


ndk {
//设置支持的SO库架构 armeabi、armeabi-v7a、arm64-v8a、x86、x86_64、mips、mips64
abiFilters 'armeabi-v7a'
}

可以看看APK体积分析,每种cpu占用体积都比较大,少配置一种就能省下不少空间。
image.png


3. 代码混淆、无用资源的删除


在bulid.gradle中配置minifyEnabled true开启代码混淆,还需要配置混淆规则,否则无法找到目标类。shrinkResources true则是打包时不会将无用资源打入包内,这里有个小坑。之前使用腾讯地图时,某些第三方的静态资源会因为这个操作不被打入包内,导致无法找到资源,所以根据具体情况使用。


 release {
buildConfigField "boolean", "LOG_DEBUG", "false"
minifyEnabled true
// shrinkResources true 慎用,可能会导致第三方资源文件找不到
zipAlignEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}

4. 代码复用,剔除无用代码


项目中由于多人协同开发会出现各写各的情况,需要抽出一些公共库之类的工具,方便代码复用。一些注释掉的代码该删除就删除。其实这一部分优化的体积相当少,但也得做,也是对代码质量的一种提升。


总结


其实只要做到了以上四步,APP体积优化已经得到了很大程度的提升了,其他再怎么优化效果也不是很明显了,最主要的就是本地资源和第三方so包体积占用较多。图片的使用我们尽量做到:小图标用svg,全屏类的大图可以考虑webp,最好不要使用png。ndk配置最好只配置一款cpu,几乎都可兼容,万不得已再加一个。


以上便是全部内容,希望对大家有所帮助。



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

面试题:Android 中 Intent 采用了什么设计模式?

答案是采用了原型模式。 原型模式的好处在于方便地拷贝某个实例的属性进行使用、又不会对原实例造成影响,其逻辑在于对 Cloneable 接口的实现。 话不多说看下 Intent 的关键源码:  // frameworks/base/core/java/andro...
继续阅读 »

答案是采用了原型模式


原型模式的好处在于方便地拷贝某个实例的属性进行使用、又不会对原实例造成影响,其逻辑在于对 Cloneable 接口的实现。


话不多说看下 Intent 的关键源码:


 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     private static final int COPY_MODE_ALL = 0;
     private static final int COPY_MODE_FILTER = 1;
     private static final int COPY_MODE_HISTORY = 2;
 ​
     @Override
     public Object clone() {
         return new Intent(this);
    }
 ​
     public Intent(Intent o) {
         this(o, COPY_MODE_ALL);
    }
 ​
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
         this.mData = o.mData;
         this.mType = o.mType;
         this.mIdentifier = o.mIdentifier;
         this.mPackage = o.mPackage;
         this.mComponent = o.mComponent;
         this.mOriginalIntent = o.mOriginalIntent;
        ...
 ​
         if (copyMode != COPY_MODE_FILTER) {
            ...
             if (copyMode != COPY_MODE_HISTORY) {
                ...
            }
        }
    }
    ...
 }

可以看到 Intent 实现的 clone() 逻辑是直接调用了 new 并传入了自身实例,而非调用 super.clone() 进行拷贝。


默认的拷贝策略是 COPY_MODE_ALL,顾名思义,将完整拷贝源实例的所有属性进行构造。其他的拷贝策略是 COPY_MODE_FILTER 指的是只拷贝跟 Intent-filter 相关的属性,即用来判断启动目标组件的 actiondatatypecomponentcategory 等必备信息。无视启动 flagbundle 等数据。


 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     public @NonNull Intent cloneFilter() {
         return new Intent(this, COPY_MODE_FILTER);
    }
 ​
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
        ...
 ​
         if (copyMode != COPY_MODE_FILTER) {
             this.mFlags = o.mFlags;
             this.mContentUserHint = o.mContentUserHint;
             this.mLaunchToken = o.mLaunchToken;
            ...
        }
    }
 }

还有中拷贝策略是 COPY_MODE_HISTORY,不需要 bundle 等历史数据,保留 action 等基本信息和启动 flag 等数据。


 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     public Intent maybeStripForHistory() {
         if (!canStripForHistory()) {
             return this;
        }
         return new Intent(this, COPY_MODE_HISTORY);
    }
 ​
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
        ...
 ​
         if (copyMode != COPY_MODE_FILTER) {
            ...
             if (copyMode != COPY_MODE_HISTORY) {
                 if (o.mExtras != null) {
                     this.mExtras = new Bundle(o.mExtras);
                }
                 if (o.mClipData != null) {
                     this.mClipData = new ClipData(o.mClipData);
                }
            } else {
                 if (o.mExtras != null && !o.mExtras.isDefinitelyEmpty()) {
                     this.mExtras = Bundle.STRIPPED;
                }
            }
        }
    }
 }

总结起来:


Copy Modeaction 等数据flags 等数据bundle 等历史
COPY_MODE_ALLYESYESYES
COPY_MODE_FILTERYESNONO
COPY_MODE_HISTORYYESYESNO

除了 Intent,Android 源码中还有很多地方采用了原型模式。




  • Bundle 也实现了 clone(),提供了 new Bundle(this) 的处理:


     public final class Bundle extends BaseBundle implements Cloneable, Parcelable {
        ...
         @Override
         public Object clone() {
             return new Bundle(this);
        }
     }



  • 组件信息类 ComponentName 也在 clone() 中提供了类似的实现:


     public final class ComponentName implements Parcelable, Cloneable, Comparable<ComponentName> {
        ...
         public ComponentName clone() {
             return new ComponentName(mPackage, mClass);
        }
     }



  • 工具类 IntArray 亦是如此:


     public class IntArray implements Cloneable {
        ...
         @Override
         public IntArray clone() {
             return new IntArray(mValues.clone(), mSize);
        }
     }



原型模式也不一定非得实现 Cloneable,提供了类似的实现即可。比如:




  • Bitmap 没有实现该接口但提供了 copy(),内部将传递原始 Bitmap 在 native 中的对象指针并伴随目标配置进行新实例的创建:


     public final class ComponentName implements Parcelable, Cloneable, Comparable<ComponentName> {
        ...
         public Bitmap copy(Config config, boolean isMutable) {
            ...
             noteHardwareBitmapSlowCall();
             Bitmap b = nativeCopy(mNativePtr, config.nativeInt, isMutable);
             if (b != null) {
                 b.setPremultiplied(mRequestPremultiplied);
                 b.mDensity = mDensity;
            }
             return b;
        }
     }


  • <
    作者:TechMerger
    来源:juejin.cn/post/7204013918958649405
    li>
收起阅读 »

Android斩首行动——接口预请求

前言 开发同学应该都很熟悉我们页面的渲染过程一般是从Activity#onCreate开始,再发起网络请求,等请求回调回来后,再基于网络数据渲染页面。可以用下面这幅图来粗略描述这个过程: 可以看到,目标页面渲染完成前必须得等待网络请求,导致渲染速度并没有那么...
继续阅读 »

前言


开发同学应该都很熟悉我们页面的渲染过程一般是从Activity#onCreate开始,再发起网络请求,等请求回调回来后,再基于网络数据渲染页面。可以用下面这幅图来粗略描述这个过程:


image.png


可以看到,目标页面渲染完成前必须得等待网络请求,导致渲染速度并没有那么快。尤其是当网络并不好的时候感受会更加明显。并且,当目标页面是H5页面或者是Flutter页面的时候,因为涉及到H5容器与Flutter容器的创建,白屏时间会更长。


那么有没有可能提前发起请求,来缩短网络请求这一部分的等待时间呢?这就是我们今天要讲的部分,接口预请求。


目标


我们要达到的目标很简单,就是提前异步发起目标页面的网络请求,从而加快目标页面的渲染速度。改善后的过程可以用下图表示:


image.png


并且,我们的预请求能力需要尽量少地侵入业务,与业务解耦,并保证能力的通用性,适用于工程内的任意页面(Android页面、H5页面、Flutter页面)。


方案


整体链路


首先给大家看一下整体链路,具体的细节可以先不用去抠,下面会一一讲到。


image.png


预请求时机


预请求时机一般有三种选择:



  1. 由业务层自行选择时机进行异步预请求

  2. 点击控件时进行异步预请求

  3. 路由最终跳转前进行异步预请求


第1种选择,由业务层自行选择时机进行预请求,需要涉及到业务层的改造,以及对时机合理性的把握。一方面是存在改造成本,另一方面是无法保证业务侧调用时机的合理性。


第2种选择,点击控件时进行预请求。若点击时进行预请求,点击事件监听并不是业务域统一的,无法形成有效封装。并且,若后续路由拦截器修改了参数,或是终止了跳转,这次预请求就失去了意义。


因此这里我们选择第3种,基于统一路由框架,在路由最终跳转前进行预请求。既保证了良好的封装性,也实现了对业务的零侵入,同时也做到了懒请求,即用户必然要发起该请求时才会去预请求。这里需要注意的是必须是在最终跳转前进行预请求,可以理解为是路由的最后一个前置异步拦截器。


预请求规则配置


我们通过本地的json文件(当然,有需要也可以上云通过配置后台下发),对预请求的规则进行配置,并将这份配置在App启动阶段异步读入到内存。后续在路由过程中,只有命中了预请求规则,才能发起预请求。配置demo如下:


{
"routeConfig":{
"scheme://domain/path?param1=true&itemId=123":["prefetchKey"],
"
route2":["prefetchKey2"],
"
route3":["prefetchKey3","prefetchKey4"]
},
"
prefetcher":{
"
prefetchKey":{
"
prefetchType":"network",
"
prefetchInfo":{
"
api":"network.api.name",
"
apiVersion":"1.0",
"
method":"post",
"
needLogin":"false",
"
showLoginUI":"false",
"
params": {
"
itemId":"$route.itemId",
"
firstTime":"true"
},
"
headers": {

},
"
prefetchImgInResponse": [
{
"
imgUrl":"$data.imgData.img",
"
imgWidth":"$data.imgData.imgWidth",
"
imgHeight":150
}
]
}
},
"
prefetchKey2":{
"
prefetchType":"network",
"
prefetchInfo":{
"
api":"network.api.name2",
"
apiVersion":"1.0",
"
method":"post",
"
needLogin":"false",
"
showLoginUI":"false",
"
params": {
"
itemId":"$route.productId",
"
firstTime":"false"
},
"
headers": {

}
},
"
prefetchKey3":{
"
prefetchType":"image",
"
prefetchInfo":{
"
imgUrl":"$route.imgUrl",
"
imgWidth":"$route.imgWidth",
"
imgHeight": 150
}
},
"
prefetchKey4":{
"
prefetchInfo":{}
}
}
}

规则解读


参数名描述备注
routeConfig路由配置配置路由到预请求的映射
prefetcher预请求配置记录所有的预请求
prefetchKey预请求的key
prefetchType预请求类型分为network类型与image类型,两种类型所需要的参数不同
prefetchInfo预请求所需要的信息其中value若为route.param格式,那么该值从路由中获取;若为route.param格式,那么该值从路由中获取;若为data.param格式,则从响应数据中获取。
paramsnetwork请求所需要的请求params
headersnetwork请求所需要的请求headers
prefetchImgFromResponse预请求的响应返回后,需要预加载的图片用于需要预加载图片时,无法确定图片url,图片url只能从预请求响应中获取的场景。

举例说明


网络预请求


例如跳转目标页面,它的路由是scheme://domain/path?param1=true&itemId=123


首先我们在跳转路由时,若跳转的路由是这个目标页面,我们就会尝试去发起预请求。根据上面的demo配置文件,它将匹配到prefetchKey这个预请求。


那么我们详细看prefetchKey这个预请求,预请求类型prefetchTypenetwork,是一个网络预请求,prefetchInfo中具备了请求的基本参数(如apiName、apiVersion、method、请求params与请求headers,不同工程不一样,大家可以根据自己的工程项目进行修改)。具体看params中,有一个参数为itemId:$route.itemId。以$route.开头的意思,就是这个value值要从路由中获取,即itemId=123,那么这个值就是123。


图片预请求


在做网络预请求的过程中,我忽然想到图片做预请求也是可以大大提升用户体验的,尤其是当大图片首次下载到内存中渲染需要的时间会比较长。图片预请求分为url已知url未知两种场景,下面各举两个例子。


图片url已知

什么是图片url已知呢?比如我们在首页跳转首页的二级页面时,如果二级页面需要预加载的图片跟首页的某张图是一样的(尺寸可能不同),那么首页跳转路由时我们是能够提前知道这个图片的url的,所以我们看到prefetchKey3中配置了prefetchTypeimage的预请求。image的信息来自于路由参数,需要在跳转时将图片url和宽高作为路由参数之一。


比如scheme://domain/path?imgUrl=${encodeUrl}&imgWidth=200,那么根据配置项,我们将提前将encodeUrl这个图片以宽200,高150的尺寸,加载到内存中去。当目标页面用到这个图片时,将能很快渲染出来。


图片url未知

相反,当跳转目标页面时,目标页面所要加载的图片url没法取到,就对应了图片url未知的场景。


例如闪屏页跳转首页时,如果需要预加载首页顶部的图片,此时闪屏页是无法获取到图片的url的,因为这个图片url是首页接口返回的。这种情况下,我们只能依赖首页的预请求进行。


在demo配置文件中,我们可以看到prefetchImgFromResponse字段。这个字段代表着,当这个预请求响应回来之后,我需要去预请求某张图片。其中,imgUrl$data.param格式,以$data.开头,代表着这份数据是来自于响应数据的。响应数据就是一串json串,可以凭此,索引到预请求响应中图片url的位置,就能实现图片的提前加载了。


至于图片怎么提前加载到内存中,以及真实图片的加载怎么匹配到内存中的图片,这一部分是通过glide已有的preload机制实现的,感兴趣的同学可以去看一下源码了解一下,这里就不展开了。后面讲的预请求的方案细节,都只限于网络请求。


预请求匹配


预请求匹配指的是实际的业务请求怎样与已经执行的预请求匹配上,从而节省请求的空中时间,直接返回预请求的结果。


首先网络预请求执行前先在内存中生成一份PrefetchRecord,代表着已经执行的预请求,其中的字段跟配置文件中差不多,主要就是记录预请求相关的信息:


class PrefetchRecord {
// 请求信息
String api;
String apiVersion;
String method;
String needLogin;
String showLoginUI;
JSONObject params;
JSONObject headers;

// 预请求状态
int status;
// 预请求结果
ResponseModel response;
// 生成的请求id
String requestId;

boolean isMatch(RealRequest realRequest) {
requestId.equals(realRequest.requestId)
}
}

每一个PrefetchRecord生成时,都会生成一个requestId,用于跟实际业务请求进行匹配。requestId的生成规则可以自行制定,比如将所有请求信息包一起做一下md5处理之类。


在实际业务请求发起之前,也会根据同样的规则生成requestId。若内存中存在相同requestId对应的PrefetchRecord,那么就相当于匹配成功了。匹配成功后,再根据预请求的状态进行进一步的处理。


预请求状态


预请求状态分为START、FINISH、ABORT,对应“正在发起预请求”、“已经获得预请求结果”、“预请求被抛弃”。ABORT状态下一节再讲。


为什么要记录这个状态呢?因为我们无法保证,预请求的响应一定在实际请求之前。用图来表示:


image.png


因为预请求是一个并发行为。当预请求的空中时间特别长,长到目标页面已经发出实际请求了,预请求的响应还没回来,即预请求状态为START,而非FINISH。那么此时该怎么办?我们就需要让实际请求在一旁等着(记录到内存中,RealRequestRecord),等预请求接收到响应了,再根据requestId去进行匹配,匹配到RealRequestRecord了,就触发RealRequestRecord中的回调,返回数据。


另外,在匹配过程中需要注意一点,因为每次路由跳转,如果发起预请求了,总会生成一个Record在内存中等待匹配。因此在匹配结束后,不管是匹配成功还是匹配失败,都要及时释放将Record从内存中释放掉。


超时重试机制


基于实际请求等待预请求响应的场景,我们再延伸一下。若预请求请求超时,迟迟拿不到响应,该怎么办?用图表示:


image.png


假设目前的网络请求,端上默认的超时时间是30s。那么在超时场景下,实际的业务请求在30s内若拿不到预请求的结果,就需要重新发起业务请求,抛弃预请求,并将预请求的状态置为ABORT,这样即使后面预请求响应回来了也不做任何处理。


image.png


忽然想到一个很贴切的场景来比喻这个预请求方案。


我们把跳转页面理解为去柜台取餐。


预请求代表着我们人还没到柜台,就先远程下单让柜员去准备食物。


如果柜员准备得比较快,那么我们到柜台后就能直接把食物拿走了,就能快点吃上了(代表着页面渲染速度变快)。


如果柜员准备得比较慢,那么我们到柜台后还是得等一会儿才能取餐,但总体上吃上食物的速度还是要比到柜台后再点餐来得快。


但如果这个柜员消极怠工准备得太慢了,我们到柜台等了很久都没拿到食物,那么我们就只能换个柜员重新点了(超时后发起实际的业务请求),同时还不忘投诉一把(预请求空中时间太慢了)。


总结


通过这篇文章,我们知道了什么是接口预请求,怎么实现接口预请求。我们通过配置文件+统一路由处理+预请求发起、匹配、回调,实现了与业务解耦的,可适用于任意页面的轻量级预请求方案,从而提升页面的渲染速度。


作者:孝之请回答
来源:juejin.cn/post/7203615594390732855
收起阅读 »

我发现了 Android 指纹认证 Api 内存泄漏

我发现了 Android 指纹认证 Api 内存泄漏 目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt 先说问题,使用Biome...
继续阅读 »

我发现了 Android 指纹认证 Api 内存泄漏


目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt


先说问题,使用BiometricPrompt 会造成内存泄漏,目前该问题试了 Android 11 到 13 都发生,而且没有什么好的办法。目前想到的最好的方法是漏的少一点。当然谁有好的办法欢迎留言。


问题再现


先看动画


在这里插入图片描述


动画中操作如下



  1. MainAcitivity 跳转到 SecondActivity

  2. SecondActivity 调用 BiometricPrompt 三次

  3. 从SecondActivity 返回到 MainAcitivity


以下是使用 BiometricPrompt 的代码


public fun showBiometricPromptDialog() {
val keyguardManager = getSystemService(
Context.KEYGUARD_SERVICE
) as KeyguardManager;

if (keyguardManager.isKeyguardSecure) {
var biometricPromptBuild = BiometricPrompt.Builder(this).apply {// this is SecondActivity
setTitle("verify")
setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK)
}
val biometricPromp = biometricPromptBuild.build()
biometricPromp.authenticate(CancellationSignal(), mExecutor, object :
BiometricPrompt.AuthenticationCallback() {

})
}
else {
Log.d("TAG", "showLockScreen: isKeyguardSecure is false");
}
}

以上逻辑 biometricPromp 是局部变量,应该没有问题才对。


内存泄漏如下


在这里插入图片描述
可以看到每启动一次生物认证,创建的 BiometricPrompt 都不会被回收。


规避方案:


修改方案也简单


方案一:



  1. biometricPromp 改为全局变量。

  2. this 改为 applicationContext


方案一存在的问题,SecondActivity 可能频繁创建,所以 biometricPromp 还会存在多个实例。


方案二(目前想到的最优方案):



  1. biometricPromp 改为单例

  2. this 改为 applicationContext


修改后,App memory 中只存在一个 biometricPromp ,且没有 Activity 被泄漏。


想到这里,应该会觉得奇怪,biometricPromp 为什么不会被回收?提供的 API 都看过了,没有发现什么方法可以解决这个问题。直觉告诉我这个可能是系统问题,下来分析下BiometricPrompt 吧。


BiometricPrompt 源码分析


在这里插入图片描述


App 相关信息通过 BiometricPrompt 传递到 System 进程,System 进程再通知 SystemUI 显示认证界面。


App 信息传递到 System 进程,应该会使用 Binder。这个查找 BiometricPrompt 使用哪些 Binder。


private final IBiometricServiceReceiver mBiometricServiceReceiver =
new IBiometricServiceReceiver.Stub() {

......
}

源码中发现 IBiometricServiceReceiver 比较可疑,IBiometricServiceReceiver 是匿名内部类,内部是持有 BiometricPrompt 对象的引用。


接下来看下 System Server 进程信息(注:系统是 UserDebug 的手机,才可以查看,买的手机版本是不支持的)


在这里插入图片描述



😂 App 使用优化后(方案二)App 只存在一个 IBiometricServiceReceiver ,而 system 进程中存在三个 IBiometricServiceReceiver 的 binder proxy。 每次启动 BiometricPrompt 都会创建一个。这个就不解释为什么会出现三个binder proxy,感兴趣可以看下面推荐的文章。GC root 是 AuthSession。

再看下 AuthSession 的实例数


在这里插入图片描述


果然 AuthSession 也存在三个。


在这里插入图片描述


这里有个知识点,binder 也是有生命周期的,三个 Proxy 这篇文章也是解释了的。有兴趣的可以了看下。


Binder | 对象的生命周期


一开始,我以为 AuthSession 没有被置空,看下代码,发现 AOSP 的代码,还是比较严谨的,有置空的操作。


细心的同学发现,上图中 AuthSession 没有被任何对象引用,AuthSession 就是 GC Root,哈哈哈。


问题解密


一个实例什么情况可以作为GC Root,有兴趣的同学,可以自行百度,这里就不卖关子了,直接说问题吧。


Binder.linkToDeath()


public void linkToDeath(@NonNull DeathRecipient recipient, int flags) {
}

需要传递 IBinder.DeathRecipient ,这个 DeathRecipient 会被作为 GC root。当调用 unlinkToDeath(@NonNull DeathRecipient recipient, int flags),GC root 才被收回。


AuthSession 初始化的时候,会调用 IBiometricServiceReceiver .linkToDeath。


public final class AuthSession implements IBinder.DeathRecipient {
AuthSession(@NonNull Context context,
......
@NonNull IBiometricServiceReceiver clientReceiver,
......
) {
Slog.d(TAG, "Creating AuthSession with: " + preAuthInfo);
......
try {
mClientReceiver.asBinder().linkToDeath(this, 0 /* flags */);//this 变成 GC root
} catch (RemoteException e) {
Slog.w(TAG, "Unable to link to death");
}

setSensorsToStateUnknown();
}
}

Jni 中 通过 env->NewGlobalRef(object),告诉虚拟机 AuthSession 是 GC Root。


core/jni/android_util_Binder.cpp

static void android_os_BinderProxy_linkToDeath(JNIEnv* env, jobject obj,
jobject recipient, jint flags)
// throws RemoteException
{
if (recipient == NULL) {
jniThrowNullPointerException(env, NULL);
return;
}

BinderProxyNativeData *nd = getBPNativeData(env, obj);
IBinder* target = nd->mObject.get();

LOGDEATH("linkToDeath: binder=%p recipient=%p\n", target, recipient);

if (!target->localBinder()) {
DeathRecipientList* list = nd->mOrgue.get();
sp<JavaDeathRecipient> jdr = new JavaDeathRecipient(env, recipient, list);//java 中 DeathRecipient 会被封装为 JavaDeathRecipient
status_t err = target->linkToDeath(jdr, NULL, flags);
if (err != NO_ERROR) {
// Failure adding the death recipient, so clear its reference
// now.
jdr->clearReference();
signalExceptionForError(env, obj, err, true /*canThrowRemoteException*/);
}
}
}

JavaDeathRecipient(JNIEnv* env, jobject object, const sp<DeathRecipientList>& list)
: mVM(jnienv_to_javavm(env)), mObject(env->NewGlobalRef(object)),// object -> DeathRecipient 变为 GC root
mObjectWeak(NULL), mList(list)
{
// These objects manage their own lifetimes so are responsible for final bookkeeping.
// The list holds a strong reference to this object.
LOGDEATH("Adding JDR %p to DRL %p", this, list.get());
list->add(this);

gNumDeathRefsCreated.fetch_add(1, std::memory_order_relaxed);
gcIfManyNewRefs(env);
}

unlinkToDeath 最终会在 Jni 中 通过 env->DeleteGlobalRef(mObject),告诉虚拟机 AuthSession 不是GC root。


virtual ~JavaDeathRecipient()
{
//ALOGI("Removing death ref: recipient=%p\n", mObject);
gNumDeathRefsDeleted.fetch_add(1, std::memory_order_relaxed);
JNIEnv* env = javavm_to_jnienv(mVM);
if (mObject != NULL) {
env->DeleteGlobalRef(mObject);// object -> DeathRecipient GC root 被撤销
} else {
env->DeleteWeakGlobalRef(mObjectWeak);
}
}

解决方式


AuthSession 置空的时候调用 IBiometricServiceReceiver 的 unlinkToDeath 方法。


总结


以上梳理的其实就是 Binder 的造成的内存泄漏。


问题严重性来看,也不算什么大问题,因为调用 BiometricPrompt 的进程被杀,system 进程相关实例也就回收释放了。一般 app 也不太可能出现,常驻进程,而且还频繁调用手机认证的。


这里主要介绍了一种容易被忽略的内存泄漏,Binder.linktoDeath()。
Google issuetracker


参考资料


Binder | 对象的生命周期


作者:Jingle_zhang
来源:juejin.cn/post/7202066794299129914
收起阅读 »

Android 手写热修复dex

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

现有的热修复框架很多,尤以AndFix 和Tinker比较多



具体的实现方式和项目引用可以参考网络上的文章,今天就不谈,也不是主要目的



今天就来探讨,如何手写一个热修复的功能



对于简单的项目,不想集成其他修复框架的SDK,也不想用第三方平台,只是紧急修复一些bug
还是挺方便的



言归正传,如果一个或多个类出现bug,导致了崩溃或者数据显示异常,如果修复呢,如果熟悉jvm dalvik 类的加载机制,就会清楚的了解 ClassLoader的 双亲委托机制 就可以通过这个


什么是双亲委托机制



  1. 当前ClassLoader首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。
    每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。

  2.  当前classLoader的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到bootstrp ClassLoader.

  3. 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。



突破口来了,看1(如果已经加载则直接返回原来已经加载的类)
对于同一个类,如果先加载修复的类,当后续在加载未修复的类的时候,直接返回修复的类,这样bug不就解决了吗?



Nice ,多看源码和jvm 许多问题可以从framework和底层去解决


话不多说,提出了解决方法,下面着手去实现


public class InitActivity extends FragmentActivity {

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//这里默认在SD卡根目录,实际开发过程中可以把dex文件放在服务器,在启动页下载后加载进来
//第二次进入的时候可以根据目录下是否已经下载过,处理,避免重新下载
//最后根据当前app版本下载不同的修复dex包 等等一系列处理
String dexFilePath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/fix.dex";
DexFile dexFile = null;
try {
dexFile = DexFile.loadDex(dexFilePath, null, Context.MODE_PRIVATE);
} catch (IOException e) {
e.printStackTrace();
}

patchDex(dexFile);

startActivity(new Intent(this, MainActivity.class));
}

/**
* 修复过程,可以放在启动页,这样在等待的过程中,网络下载修复dex文件
*
* @param dexFile
*/

public void patchDex(DexFile dexFile) {
if (dexFile == null) return;
Enumeration<String> enumeration = dexFile.entries();
String className;
//遍历dexFile中的类
while (enumeration.hasMoreElements()) {
className = enumeration.nextElement();
//加载修复后的类,只能修复当前Activity后加载类(可以放入Application中执行)
dexFile.loadClass(className, getClassLoader());
}
}
}
复制代码

方法很简单在启动页,或者Application中提前加载有bug的类



这里写的很简单,只是展示核心代码,实际开发过程中,dex包下载的网络请求,据当前app版本下载不同的修复dex,文件存在的时候可以在Application中先加载一次,启动页就不用加载,等等,一系列优化和判断处理,这里就不过多说明,具体一些处理看github上的代码



###ok 代码都了解了,这个 fix.dex 文件哪里来的呢
熟悉Android apk生成的小伙伴都知道了,跳过这个步骤,不懂的小伙伴继续往下看


上面的InitActivitystartActivity(new Intent(this, MainActivity.class)); 启动了一个MainActivity
看看我的MainActivity


public class MainActivity extends FragmentActivity {

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//0不能做被除数,这里会报ArithmeticException异常
Toast.makeText(this, "结果" + 10 / 0, Toast.LENGTH_LONG).show();
}
}
复制代码

哎呀不小心,写了一个bug 0 咋能做除数呢,app已经上线了,这里必崩啊,咋办
不要急,按照以下步骤:



  1. 我们要修复这个类MainActivity,先把bug解决


 Toast.makeText(this, "结果" + 10 / 2, Toast.LENGTH_LONG).show();
复制代码


  1. 把修复类生成.class文件(可以先run一次,之后在 build/intermediates/javac/debug/classes/com开的的文件夹,找到生成的class文件,也可以通过javac 命令行生成,也可以通过右边的gradle Task生成)
    class 路径图

  2. 把修复类.class文件 打包成dex (其他.class删除,只保留修复类) 打开cmd命令行,输入下面命令


D:\Android\sdk\build-tools\28.0.3\dx.bat --dex --output C:\Users\pei\Desktop\dx\fix.dex C:\Users\pei\Desktop\dx\
复制代码

D:\Android\sdk 为自己sdk目录 28.0.3build-tools版本,可以根据自己已经下载的版本更换
后面两个目录分别是生成.dex文件目录,和.class文件目录



切记 .class文件的目录必须是包名一样的,我的目录是 C:\Users\pei\Desktop\dx\com\pei\test\MainActivity.class,不然会报 class name does not match path




  1. 这样dx文件夹下就会生成fix.dex文件了,把fix.dex放进手机根目录试试吧


再次打开App,完美Toast 结果5,完美解决


总结



  1. 修复方法要在bug类之前执行

  2. 适合少量bug,太多bug影响性能

  3. 目前只能修复类,不能修复资源文件

  4. 目前只能适配单dex的项目,多dex的项目由于当前类和所有的引用类在同一个dex会 当前类被打上CLASS_ISPREVERIFIED标记,被打上这个标记的类不能引用其他dex中的类,否则就会报错
    解决办法是在构造方法里引用一个单独的dex中的类,这样不符合规则就不会被标记了
作者:one裴s
来源:https://juejin.cn/post/7203989318271483960
收起阅读 »

介绍一个令强迫症讨厌的小红点组件

@charset "UTF-8";.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:15px;overflow-x:hidden;color:#333...
继续阅读 »

前言


在 App 的运营中,活跃度是一个重要的指标,日活/月活……为了提高活跃度,就发明了小红点,然后让强迫症用户“没法活”。


image.png


小红点虽然很讨厌,但是为了 KPI,程序员也不得不屈从运营同学的逼迫(讨好),得想办法实现。这一篇,来介绍一个徽标(Badge)组件,能够快速搞定应用内的小红点。


Badge 组件


Badge 组件被 Flutter 官方推荐,利用它让小红点的实现非常轻松,只需要2个步骤就能搞定。



  1. 引入依赖


pubspec.yaml文件种引入相应版本的依赖,如下所示。


badges: ^2.0.3
复制代码


  1. 将需要使用小红点的组件使用 Badge 作为上级组件,设置小红点的位置、显示内容、颜色(没错,也可以改成小蓝点)等参数,示例代码如下所示。


Badge(
badgeContent: Text('3'),
position: BadgePosition.topEnd(top: -10, end: -10),
badgeColor: Colors.blue,
child: Icon(Icons.settings),
)
复制代码

position可以设置徽标在组件的相对位置,包括右上角(topEnd)、右下角(bottomEnd)、左上角(topStart)、左下角(bottomStart)和居中(center)等位置。并可以通过调整垂直方向和水平方向的相对位置来进行位置的细微调整。当然,Badge 组件考虑了很多应用场景,因此还有其他的一些参数:



  • elevation:阴影偏移量,默认为2,可以设置为0消除阴影;

  • gradient:渐变色填充背景;

  • toAnimate:徽标内容改变后是否启用动效哦,默认有动效。

  • shape:徽标的形状,默认是原型,也可以设置为方形,设置为方形的时候可以使用 borderRadius 属性设置圆角弧度。

  • borderRadius:圆角的半径。

  • animationType:内容改变后的动画类型,有渐现(fade)、滑动(slide)和缩放(scale)三种效果。

  • showBadge:是否显示徽标,我们可以利用这个控制小红点的显示与否,比如没有提醒的时候该值设置为 false 即可隐藏掉小红点。


总的来说,这些参数能够满足所有需要使用徽标的场景了。


实例


我们来看一个实例,我们分别在导航栏右上角、内容区和底部导航栏使用了三种类型的徽标,实现效果如下。


badge.gif


其中导航栏的代码如下,这是 Badge 最简单的实现方式了。


AppBar(
title: const Text('Badge Demo'),
actions: [
Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(4.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: 4, end: 4),
child: IconButton(
onPressed: () {},
icon: const Icon(
Icons.message_outlined,
color: Colors.white,
),
),
),
],
),
复制代码

内容区的徽标代码如下,这里使用了渐变色填充,动画形式为缩放,并且将徽标放到了左上角,注意如果使用了渐变色那么会覆盖 badgeColor 指定的背景色。


Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(6.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 10.0,
),
),
position: BadgePosition.topStart(top: -10, start: -10),
badgeColor: Colors.blue,
animationType: BadgeAnimationType.scale,
elevation: 0.0,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.red,
Colors.orange,
Colors.green,
],
),
child: Image.asset(
'images/girl.jpeg',
width: 200,
height: 200,
),
),
复制代码

底部导航栏的代码如下所示,这里需要注意,Badge 组件会根据内容区的尺寸自动调节大小,底部导航栏的显示控件有限,推荐使用小红点(不用数字标识)即可。


BottomNavigationBar(items: [
BottomNavigationBarItem(
icon: Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(2.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: -4, end: -6),
animationType: BadgeAnimationType.fade,
child: const Icon(Icons.home_outlined)),
label: '首页',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.star_border,
),
label: '推荐',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.account_circle_outlined,
),
label: '我的',
),
]),
复制代码

总结


本篇介绍了使用 Badge 组件实现小红点徽标组件。可以看到,Badge 组件的使用非常简单,相比我们自己从零写一个 Badge 组件来说,使用它可以让我们省时省力、快速地完成运营同学要的小红点。本篇源码已上传至:实用组件相关代码

作者:岛上码农
来源:https://juejin.cn/post/7188124857958137911

收起阅读 »

运动APP视频轨迹回放分享实现

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

喜欢户外运动的朋友一般都应该使用过运动APP(keep, 咕咚,悦跑圈,国外的Strava等)的一项功能,就是运动轨迹视频分享,分享到朋友圈或是运动群的圈子里。笔者本身平常也是喜欢户外跑、骑行、爬山等户外运动,也跑过半马、全马,疫情原因之前报的杭州的全马也延期了好几次了。回归正题,本文笔者基于自己的思想实现运动轨迹回放的一套算法策略,实现本身是基于Mapbox地图的,但是其实可以套用在任何地图都可以实现,基本可以脱离地图SDK的API。Mapbox 10 版本之后的官方给出的Demo里已经有类似轨迹回放的Case了,但是深度地依赖地图SDK本身的API,倘若在高德上实现很难可以迁移的。


这里先看下gif动图的效果,这是我在奥森跑的10KM的一个轨迹:


轨迹视频回放_AdobeExpress .gif


整个的实现包含了轨迹的回放,视频的录制,然后视频的录制这块不再笔者这篇文章的介绍的范畴内。所以这里主要介绍轨迹的回放,这个回放过程其实也是包含了大概10多种动画在里面的,辅助信息距离的文字跳转动画;距离下面配速、运动时间等的flap in 及 out的动画;播放button,底部button的渐变Visibility; 地图的缩放以及视觉角度的变化等;以上的这些也不做讨论。主要介绍轨迹回放、整公里点的显示(起始、结束), 回放过程中窗口控制等,作为主要的讲解范畴。


首先介绍笔者最开始的一种实现,假如以上轨迹List 有一百个点,每相邻的两个点做Animation之后,在AnimationEnd的Listener里开起距离下一个点的Animation,直到所有点结束,这里有个问题每次的运动轨迹的点的数量不一样,所以开起Animation的次数也不一样,整个轨迹回放的时间等于所有的Animation执行的时间和,每次动画启动需要损耗20~30ms。倘若要分享到微信朋友圈,视频的时间是限制的,但之前的那种方式时间上显然不可控,每次动画启动的损耗累加导致视频播放不完。


紧接着换成AnimationSet, 将各个线段Animation的动画放入Set里,然后playSequentially执行,同样存在上面的问题。假如只执行一次动画,那么这次动画start的损耗在整个视频播放上时长上的占比就可以忽略不计了,那如何才能将整个List的回放在一个Animation下执行完呢?假如轨迹只是一个普通的 Path,那么我们就可以基于Path的 length一个属性动画了,当转化到地图运动轨迹上去时,又如何去实现呢?


基于Path Length的属性动画



  1. 计算List对应的Path

  2. 通过PathMeasure获取 Path 的 Length

  3. 对Path做 Length的属性动画


这里有两套Point体系,一个是View的Path对应的Points, 然后就是Map上的List对应的Points,运动轨迹原始数据是Map上的List 点,上面的第一步就是将Map上的Points 转成屏幕Pixel对应的点并生成Path; 第二部通过PathMeasure 计算Path的Length; 最后在Path Length上做属性动画,然而这里并非将属性动画中每次渐变的值(这里对应的是View的Point点)绘制成View对应的Path,而是将渐变中的点又通过Map的SDK转成地图Location点,绘制地图轨迹。这里一共做了两道转换,中间只是借助View的Path做了一个依仗Length属性做的一个动画。因为基本上每种地图SDK都有Pixel 跟Location Point点互相transform的API,所以这个可以直接迁移到其它地图上,例如高德地图等。


下面具体看下代码,先将Location 转成View的Point体系,这里保存了总的一个Path,以及List 中两两相邻点对应的分段Path的一个list.



  • 生成Path:


1.1 生成Path2.png


其中用到 Mapbox地图API Location 点转View的PointF 接口API toScreenLocation(LatLng latlng), 这里生成List, 然后计算得到Path.




  • 基于Length做属性动画:


1.3 Path length 属性动画.png


首先创建属性动画的 Instance:


ValueAnimator.ofObject(new DstPathEvaluator(), 0, mPathMeasure.getLength());
复制代码

将每次渐变的值经过 calculateAnimPathData(value) 计算后存入到 以下的四个变量中,这里除了Length的渐变值,还附带有角度的一个二元组值。


dstPathEndPoint[0] = 0;//x坐标
dstPathEndPoint[1] = 0;//y坐标
dstPathTan[0] = 0;//角度值
dstPathTan[1] = 0;//角度值
复制代码

然后将dstPathEndPoint 的值转成Mapbox的 Location的 Latlng 经纬度点,


PointF lastPoint = new PointF(dstPathEndPoint[0], dstPathEndPoint[1]);
LatLng lastLatLng = mapboxMap.getProjection().fromScreenLocation(lastPoint);
Point point = Point.fromLngLat(lastLatLng.getLongitude(), lastLatLng.getLatitude());
复制代码

过滤掉一些动画过程中可能产生的异常点,最后加入到Mapbox的轨迹绘制的Layer中形成轨迹的一个渐变:


Location curLocation = mLocationList.get(animIndex);
float degrees = MapBoxPathUtil.getRotate(curLocation, point);
if (animIndex < 5 || Math.abs(degrees - curRotate) < 5) {//排除异常点
setMarkerRecord(point);
}
复制代码

setMarkerRecord(point) 方法调用加入到 Map 轨迹的绘制Layer中


1.4 加入到Map轨迹绘制.png


动画过程中,当加入到Path中的点超过一定占比时,做了一个窗口显示的动画,窗口List跟整个List的一个计算:


//这里可以取后半段的数据,滑动窗口,保持 moveCamera 的窗口值不变。
int moveSize = passedPointList.size();
List windowPassList = passedPointList.subList(moveSize - windowLength, moveSize);
复制代码

接下来看整公里点的绘制,看之前先看下上面的calculateAnimPathData()方法的逻辑


1.5 Path渐变的计算.png


如上,length为当前Path走过的距离,假设轨迹一共100点,当前走到 49 ~ 50 点之间,那么calculateLength就是0到50这个点的Path的长度,它是大于length的,offsetLength = calculateLength - length; 记录的是 当前点到50号点的一个长度offsetLength,animIndex值当前值对应50,recordPathList为一开始提到的跟计算总Path时一个分段Path的List, 获取到49 ~ 50 这个Path对应的一个model.


RecordPathBean recordPathBean = recordPathList.get(animIndex);
复制代码

获得Path(49 ~ 50) 的长度减去 当前点到 50的Path(cur ~ 50)的到 Path(49 ~ cur) 的长度


float stopD = (float) (pathMeasure.getLength() - offsetLengthCur);
复制代码

然后最终通过PathMeasure的 getPosTan 获得dstPathEndPoint以及dstPathTan数据。


pathMeasure.getSegment(0, stopD, dstPath, false);
mDstPathMeasure = new PathMeasure(dstPath, false);
//这里有个参数 tan
mDstPathMeasure.getPosTan(mDstPathMeasure.getLength(), dstPathEndPoint, dstPathTan);
复制代码


  • 整公里点的绘制


原始数据中的List的Location中存储了一个字段kilometer, 当某个Location是整公里点时该字段就有对应的值,每次Path属性渐变时,上面的逻辑里记录了lastAnimIndex, animIndex。当 animIndex > lastAnimIndex时, 上面的calculateAnimPathData() 方法里分析animIndex有可能还没走到,所以在animIndex > lastAnimIndex时lastAnimIndex肯定走到了。


1.6 整公里点动画.png


当lastAnimIndex对应的点是 整公里时,做一个响应的属性动画。


至此,运动轨迹回放的一个动画执行逻辑分析完了,如文章开始所说,整个过程中其实还包含了好多种其它的动画,处理它们播放的一个时序问题,如何编排实现等等也是一个难点。另外还就是轨迹播放时的一个Camera的一个视觉跟踪的效果没有实现,这个用地图本身的Camera 的API是一种实现,但是如何跟上面的这些结合到一块;然后就是自行通过计算角度偏移,累计到一定的旋转角度时,转移地图的指南针;以上是笔者想到的方案,以上有计算角度的,但需要找准那个累计的角度值,然后大量实际数据适配。


最后,有需要了解轨迹回放功能其它实现的,可留言或私信笔者进行一起探讨。

作者:cxy107750
来源:https://juejin.cn/post/7183602475591548986

收起阅读 »

Android再探全面屏适配

.markdown-body{color:#383838;font-size:15px;line-height:30px;letter-spacing:2px;word-break:break-word;font-family:-apple-system,Bl...
继续阅读 »

前言


简单来说,以前是做app的,然后转去做了终端几年,现在又做回了app,然后就涉及到了全面屏的适配,但是很多年前做的适配也不记得了,所以来重新再探究一遍。


以前做终端的时候,适配?我不知道什么叫适配,就一个机型,想怎么玩就怎么玩,自己就是爹。现在做应用,不好意思,手机厂商才是大爹,我们都是孙子。


我简单的回顾了一下,其实全面屏的适配一开始是因为刘海屏才开始这条路线,然后就出现一大堆奇奇怪怪的东西。幸好谷歌也是做人,在28之后就提出一套规范。


Android P前后


对于Android P,其实也就android 8.0和android 9.0两个版本,因为是从android 8.0开始流行的,各做各的,然后在9.0的时候google给出了一套规范。


对于Android 9.0也就是28,google推出了DisplayCutout,它统一了android凹凸屏的处理,使用起来也很方便。


WindowManager.LayoutParams wlp = getWindow().getAttributes();
wlp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(wlp);
复制代码

给WindowManager.LayoutParams设置layoutInDisplayCutoutMode就行,是不是很简单。

它有几个参数可供选择


(1)LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT:默认值,一般效果和LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER相同。

(2)LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:内容显示到凹凸屏区域。

(3)LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容不会显示到凹凸屏区域。


对于Android 28以下的适配


这个比较麻烦,因为在28以下是没有layoutInDisplayCutoutMode的,所以要单独去调,网上也有很多说如何去对不同的厂商去做适配,但其实这东西还是要调的。哪怕你是相同的机型,不同的系统版本都可能会产生不同的效果,没错,就是这么恐怖。基本都是只能做if-else单独对不同的机型做适配。要么就是让28以下的统一不做全面屏的效果,比如说把内容显示到凹凸屏区域,你就判断在28的时候不做这种操作,但一般不是你说的算,多多少少还是需要做适配,只能具体情况具体调试。


对不同的场景做适配


你觉得你说你就对28做适配,28以下就不管了,我就设置layoutInDisplayCutoutMode一行代码就行。可事情哪有这么简单。


系统的Bar主要分为3种,一种是在屏幕上方的状态栏,一种是在屏幕底端的导航栏,还是一直是仿IOS的底部横条代替导航栏,这在和导航栏一起分析但会有些许不同。


而这个过程中又会区分为横屏和竖屏的情况,多少也会又些许差异,当然我也没办法把全部特殊的常见列举出来。不同的手机厂商之间也会存在有不同的情况,还有上面说的android28前后,这里主要是对android28之后进行分析。


状态栏


假如要实现全屏显示的效果,我们要如何去对状态栏做适配。


为了方便调试,我把window的颜色设置为橙色,把布局的颜色设置成绿色

作者:流浪汉kylin

来源:juejin.cn/post/7201332537338806328

收起阅读 »

Android自定义View绘制进阶-水波浪温度刻度表

.markdown-body{color:#595959;font-size:15px;font-family:-apple-system,system-ui,BlinkMacSystemFont,Helvetica Neue,PingFang SC,Hira...
继续阅读 »

波浪形温度刻度表实现


前言


之前的绘制圆环,我们了解了如何绘制想要的形状和进度的一些特点,那么此篇文章我们更近一步,绘制一个稍微复杂一点的刻度与波浪。来一起复习一下Android的绘制。


相对应的这种类型的自定义View网上并不少见,但是如果我们要做一些个性化的效果,最好还是自己绘制一份,也相对的比较容易控制效果,如果想实现上面的效果,我们一般来说分为以下几个步骤:



  1. 重写测量方法,确保它是一个正方形

  2. 绘制刻度

  3. 绘制中心的圆与文字

  4. 水波纹的动画

  5. 设置进度与动画,一起动起来


思路我们已经有了,下面一步一步的来实现吧。


话不多说,Let's go


300.png


1、onMeasure重新测量


之前的圆环进度,我们并没有重写 onMeasure 方法,而是在布局中指定为固定的宽高,其实兼容性和健壮性并不好,万一写错了就会变形导致显示异常。


最好的办法是不管xml中设置为什么值,这里都能保证为一个正方形,要么是取宽度为准,让高度和宽度一致,要么就是宽度高度取最大值,让他们保持一致。由于我们是竖屏的应用,所以我就取宽度为准,让高度和宽度一致。


前面我们只是讲了 onDraw 并没有讲到 onMeasure , 这里简单的说一下。


我们为什么要重写 onMeasure ?



  1. 为了自定义View尺寸的规则,如果你的自定义View的尺寸是根据父控件行为一致,就不需要重写onMeasure()方法。

  2. 如果不重写onMeasure方法,那么自定义view的尺寸默认就和父控件一样大小,当然也可以在布局文件里面写死宽高,而重写该方法可以根据自己的需求设置自定义view大小。


一般来说我们重写的 onMeasure 长这样:


 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec,heightMeasureSpec)
}
复制代码

widthMeasureSpec ,heightMeasureSpec 并不是真正的宽高,看名字就知道,它只是宽高测量的规格,我们通过 MeasureSpec 的一些静态方法,通过它们拿到一些信息。


static int getMode(int measureSpec):根据提供的测量值(规格)提取模式(上述三个模式之一)


测量的 Model 一共有三种



  1. UNSPECIFIED(未指定),父元素部队自元素施加任何束缚,子元素可以得到任意想要的大小;

  2. EXACTLY(完全),父元素决定自元素的确切大小,子元素将被限定在给定的边界里而忽略它本身大小;

  3. AT_MOST(至多),子元素至多达到指定大小的值。


我们常用的就是 EXACTLY 和 AT_MOST ,EXACTLY 对应的就是我们设置的match_parent或者300这样的精确值,而 AT_MOST 对应的就是wrap_content。


static int getSize(int measureSpec):根据提供的测量值(规格)提取大小值(这个大小也就是我们通常所说的大小)


通过此方法就能获取控件的宽度和高度值。


static int makeMeasureSpec(int size,int mode):根据提供的大小值和模式创建一个测量值(规格)


通过具体的宽高和model,创建对应的宽高测量规格,用于确定View的测量


onMeasure 的最终设置确定宽度的测量有两种方式,



  1. setMeasuredDimension(width, height)

  2. super.onMeasure(widthMeasureSpec,heightMeasureSpec)


实战:


比如我们的自定义温度刻度View,我们整个View要确保一个正方形,那么就拿到宽度,设置同样的高度,然后确定测量,流程如下:


    //重新测量
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

//获取控件的宽度,高度
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int newWidthMeasureSpec = widthMeasureSpec;

//如果没有指定宽度,默认给200宽度
if (widthMode != MeasureSpec.EXACTLY) {
newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY);
}

//获取到最新的宽度
int width = MeasureSpec.getSize(newWidthMeasureSpec) - getPaddingLeft() - getPaddingRight();

//我们要的是矩形,不管高度是多高,让它总是和宽度一致
int height = width;

centerPosition.x = width / 2;
centerPosition.y = height / 2;
radius = width / 2f;
mRectF.set(0f, 0f, width, height);


//最后设置生效-下面两种方式都可以
// setMeasuredDimension(width, height);

super.onMeasure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
);

}
复制代码

这里有详细的注释,大致实现的效果如下:


image.png


2、绘制刻度


由于原本的 Canvas 内部没有绘制刻度这么一说,所以我们只能用绘制线条的方式,就是 drawLine 方法。


为了了解到坐标系和方便实现,我们可以先绘制一个圆环,定位我们刻度需要绘制的位置。


    @Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

//画圆环
canvas.drawArc(
mRectF.left + 2f, mRectF.top + 2f, mRectF.right - 2f, mRectF.bottom - 2f,
mStartAngle, mSweepAngle, false, mDegreeCirPaint
);
}
复制代码

这个圆环是之前讲到过了,就不过多赘述了,实现效果如下:


image.png


由于开始绘制的地方在左上角位置,我们要移动到圆的中心点开始绘制,也就是红色点移动到蓝色点。


我们就需要x轴和y轴做一下偏移 canvas.translate(radius, radius);


默认的 drawLine 都是横向绘制,我们想要实现效果图的效果,就需要旋转一下画笔,也就是用到 canvas.rotate(rotateAngle);


那么旋转多少了,如果说最底部是90度,我们的起始角度是120度开始的,我们就起始旋转30度。后面每一次旋转就按照百分比来,比如我们100度的温度,那么就相当于要画100个刻度,我们就用需要绘制的角度除以100,就是每一个刻度的角度。


具体的刻度实现代码:



private float mStartAngle = 120f; // 圆弧的起始角度
private float mSweepAngle = 300f; //绘制的起始角度和滑过角度(绘制300度)
private float mTargetAngle = 300f; //刻度的角度(根据此计算需要绘制有色的进度)

private void drawDegreeLine(Canvas canvas) {
//先保存
canvas.save();

// 移动画布
canvas.translate(radius, radius);
// 旋转坐标系,需要确定旋转角度
canvas.rotate(30);

// 每次旋转的角度
float rotateAngle = mSweepAngle / 100;
// 累计叠加的角度
float currentAngle = 0;
for (int i = 0; i <= 100; i++) {

if (currentAngle <= mTargetAngle && mTargetAngle != 0) {
// 计算累计划过的刻度百分比
float percent = currentAngle / mSweepAngle;

//动态的设置颜色
mDegreelinePaint.setColor(evaluateColor(percent, Color.GREEN, Color.RED));

canvas.drawLine(0, radius, 0, radius - 20, mDegreelinePaint);

// 画过的角度进行叠加
currentAngle += rotateAngle;

} else {
mDegreelinePaint.setColor(Color.WHITE);
canvas.drawLine(0, radius, 0, radius - 20, mDegreelinePaint);
}

//画完一个刻度就要旋转移动位置
canvas.rotate(rotateAngle);
}

//再恢复
canvas.restore();

}
复制代码

加上圆环与刻度的效果图:
image.png


3. 设置刻度动画


前面的一篇我们使用的是属性动画不停的绘制从而实现进度的效果,那么这一次我们使用定时任务的方式也是可以实现动画的效果。


由于我们之前的 drawDegreeLine 方法内部控制绘制进度的变量就是 targetAngle 来控制的,所以我们通过入口方法设置温度的时候通过定时任务的方式来控制。


代码如下:



//动画状态
private boolean isAnimRunning;
// 手动实现越来越慢的效果
private int[] slow = {10, 10, 10, 8, 8, 8, 6, 6, 6, 6, 4, 4, 4, 4, 2};
// 动画的下标
private int goIndex = 0;

//设置温度,入口的开始
public void setupTemperature(float temperature) {
mCurPercent = 0f;
totalAngle = (temperature / 100) * mSweepAngle;
targetAngle = 0f;
mCurPercent = 0f;
mCurTemperature = "0.0";
mWaveUpValue = 0;

startTimerAnim();
}

//使用定时任务做动画
private void startTimerAnim() {

if (isAnimRunning) {
return;
}

mAnimTimer = new Timer();
mAnimTimer.schedule(new TimerTask() {

@Override
public void run() {

isAnimRunning = true;
targetAngle += slow[goIndex];
goIndex++;
if (goIndex == slow.length) {
goIndex--;
}
if (targetAngle >= totalAngle) {
targetAngle = totalAngle;
isAnimRunning = false;
mAnimTimer.cancel();
}

// 计算的温度
mCurPercent = targetAngle / mSweepAngle;
mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

// 水波纹的高度
mWaveUpValue = (int) (mCurPercent * (mSmallRadius * 2));

postInvalidate();
}
}, 250, 30);

}
复制代码

那么刻度动画的效果如下:


rote-02.gif


4. 绘制中心的圆与文字


我们再动画中记录动画的百分比进度,和动画当前的温度。


    ...    
// 计算的温度
mCurPercent = targetAngle / mSweepAngle;
mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

postInvalidate();

...
复制代码

我们记录一下小圆的半径和文本的画笔资源


   private float mSmallRadius = 0f;
private Paint mTextPaint;
private Paint mSmallCirclePaint;
private float mCurPercent = 0f; //进度
private String mCurTemperature = "0.0";
private DecimalFormat mDecimalFormat;

private void init() {
...

mTextPaint = new Paint();
mTextPaint.setAntiAlias(true);
mTextPaint.setTextAlign(Paint.Align.CENTER);
mTextPaint.setColor(Color.WHITE);

mSmallCirclePaint = new Paint();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

...

//画小圆
drawSmallCircle(canvas, evaluateColor(mCurPercent, Color.GREEN, Color.RED));

//画中心的圆与文本
drawTemperatureText(canvas);

}
复制代码

具体的文本与小圆的绘制


    private void drawSmallCircle(Canvas canvas, int evaluateColor) {
mSmallCirclePaint.setColor(evaluateColor);
mSmallCirclePaint.setAlpha(65);
canvas.drawCircle(centerPosition.x, centerPosition.y, mSmallRadius, mSmallCirclePaint);
}

private void drawTemperatureText(Canvas canvas) {

//提示文字
mTextPaint.setTextSize(mSmallRadius / 6f);
canvas.drawText("当前温度", centerPosition.x, centerPosition.y - mSmallRadius / 2f, mTextPaint);

//温度文字
mTextPaint.setTextSize(mSmallRadius / 2f);
canvas.drawText(mCurTemperature, centerPosition.x, centerPosition.y + mSmallRadius / 4f, mTextPaint);

//绘制单位
mTextPaint.setTextSize(mSmallRadius / 6f);
canvas.drawText("°C", centerPosition.x + (mSmallRadius / 1.5f), centerPosition.y, mTextPaint);

}
复制代码

由于进度和温度都是动画在 invalidate 之前赋值的,所以我们的文本和小圆天然就支持动画的效果了。


效果如下:


rote-03.gif


5. 水波纹动画


水波纹的效果,我们不能直接用 Canvas 来绘制,我们可以用刻度的方法用 drawLine的方式来绘制,如何绘制呢?相信大家也有了解,就是正弦函数了。


由于我们的效果是两个水波纹相互叠加起起伏伏的效果,所以我们定义两个函数。


总体的思路是:我们定义两个数组来管理我们的Y轴的值,通过正弦函数给Y轴赋值,然后在drawLine的时候取出对应的x轴的y值就可以绘制出来。


x轴其实就是我们的控件宽度,我们先用一个数组保存起来


    private float[] mFirstWaterLine;
private float[] mSecondWaterLine;

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

//获取控件的宽度,高度
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int newWidthMeasureSpec = widthMeasureSpec;

//如果没有指定宽度,默认给200宽度
if (widthMode != MeasureSpec.EXACTLY) {
newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY);
}

//获取到最新的宽度
int width = MeasureSpec.getSize(newWidthMeasureSpec) - getPaddingLeft() - getPaddingRight();

//我们要的是矩形,不管高度是多高,让它总是和宽度一致
int height = width;


mFirstWaterLine = new float[width];
mSecondWaterLine = new float[width];


super.onMeasure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
);

}

复制代码

然后我们再绘制之前就先对x轴对应的y值赋值,然后绘制的时候就取出对应的y值来 drawLine,具体的代码如下:


动画的时候先对横向运动和垂直运动的变量做一个赋值:


    private int mWaveUpValue = 0;
private float mWaveMoveValue = 0f;


//使用定时任务做动画
private void startTimerAnim() {

if (isAnimRunning) {
return;
}
mAnimTimer = new Timer();
mAnimTimer.schedule(new TimerTask() {

@Override
public void run() {

...

// 计算的温度
mCurPercent = targetAngle / mSweepAngle;
mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

// 水波纹的高度
mWaveUpValue = (int) (mCurPercent * (mSmallRadius * 2));

postInvalidate();
}
}, 250, 30);

}

public void moveWaterLine() {
mWaveTimer = new Timer();
mWaveTimer.schedule(new TimerTask() {

@Override
public void run() {
mWaveMoveValue += 1;
if (mWaveMoveValue == 100) {
mWaveMoveValue = 1;
}
postInvalidate();
}
}, 500, 200);
}
复制代码

拿到了对应的变量值之后,然后开始绘制:


 /**
* 绘制水波
*/

private void drawWaterWave(Canvas canvas, int color) {

int len = (int) mRectF.right;

// 将周期定为view总宽度
float mCycleFactorW = (float) (2 * Math.PI / len);

// 得到第一条波的峰值
for (int i = 0; i < len; i++) {
mFirstWaterLine[i] = (float) (10 * Math.sin(mCycleFactorW * i + mWaveMoveValue) - mWaveUpValue);
}
// 得到第一条波的峰值
for (int i = 0; i < len; i++) {
mSecondWaterLine[i] = (float) (15 * Math.sin(mCycleFactorW * i + mWaveMoveValue + 10) - mWaveUpValue);
}

canvas.save();

// 裁剪成圆形区域
Path path = new Path();
path.addCircle(len / 2f, len / 2f, mSmallRadius, Path.Direction.CCW);
canvas.clipPath(path);
path.reset();

// 将坐标系移到底部
canvas.translate(0, centerPosition.y + mSmallRadius);

mSmallCirclePaint.setColor(color);

for (int i = 0; i < len; i++) {
canvas.drawLine(i, mFirstWaterLine[i], i, len, mSmallCirclePaint);
}
for (int i = 0; i < len; i++) {
canvas.drawLine(i, mSecondWaterLine[i], i, len, mSmallCirclePaint);
}

canvas.restore();

}
复制代码

一个是对Y轴赋值,一个是取出x轴对应的y轴进行绘制,这里需要注意的是我们裁剪出了一个小圆的图形,并且覆盖在小圆上面实现出效果图的样子。


运行的效果如下:


rote-04.gif


要记得对定时器进行资源你的关闭哦。


    @Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mWaveTimer != null) {
mWaveTimer.cancel();
}
if (mAnimTimer != null && isAnimRunning) {
mAnimTimer.cancel();
}
}
复制代码

使用的时候我们只需要设置温度即可开始动画。


       findViewById(R.id.set_progress).click {

val temperatureView = findViewById(R.id.temperature_view)
temperatureView .setupTemperature(70f)
}
复制代码

后记


由于是自用定制的,本人也比较懒,所以并没有对一些配置的属性做自定义属性的抽取,比如圆环的间距,大小,颜色,波纹的间距,动画的快慢等等。


内部加了一点点测量的用法,但是主要还是绘制的流程,基本上把常用的几种绘制方式都用到了。以后有类似的效果大家也可以按需修改即可。


由于是自用的一个View,相对圆环进度没有那么多场景使用,就没有抽取出来上传到Maven,如果大家有兴趣可以查看源码点击【传送门】


同时,你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。


作者:newki
来源:https://juejin.cn/post/7166151382154608670

收起阅读 »

Android:面向单Activity开发

记得前一两年很多人都跟风面向单Activity开发,顾名思义,就是整个项目只有一个Activity。一个Activity里面装着N多个Fragment,再给Fragment加上转场动画,效果和多Activity跳转无异。其实想想还比较酷,以前还需要关注多个Ac...
继续阅读 »

记得前一两年很多人都跟风面向单Activity开发,顾名思义,就是整个项目只有一个Activity。一个Activity里面装着N多个Fragment,再给Fragment加上转场动画,效果和多Activity跳转无异。其实想想还比较酷,以前还需要关注多个Acitivity之间的生命周期,现在只需关注一个,但还是需要对Fragment的生命周期进行关注。



其实早在六七年前GitHub上就有单Activity的开源库Fragmentation,后来谷歌也出了一个库Navigation。本来以为官方出品必为经典,当时跟着官方文档一步一步踩坑,最后还是放弃了该方案。理由大概如下:



  1. 需要创建XML文件,配置导航关系和跳转参数等

  2. 页面回退是重新创建,需要配合livedata使用

  3. 貌似还会存在卡顿,一些栈内跳转处理等问题


而Github上Fragmentation库已经停止维护,所幸的是再lssuse中发现了一个基于它继续维护的SFragmentation,于是正是开启了面向单Activity的开发。


提供了可滑动返回的版本


dependencies {
//请使用最新版本
implementation 'com.github.weikaiyun.SFragmentation:fragmentation:latest'
//滑动返回,可选
implementation 'com.github.weikaiyun.SFragmentation:fragmentation_swipeback:latest'
}
复制代码

由于是Fragment之间的跳转,我们需要将原有的Activity跳转动画在框架初始化时设置到该框架中


Fragmentation.builder() 
//设置 栈视图 模式为 (默认)悬浮球模式 SHAKE: 摇一摇唤出 NONE:隐藏, 仅在Debug环境生效
.stackViewMode(Fragmentation.BUBBLE)
.debug(BuildConfig.DEBUG)
.animation(
R.anim.public_translate_right_to_center, //进入动画
R.anim.public_translate_center_to_left, //隐藏动画
R.anim.public_translate_left_to_center, //重新出现时的动画
R.anim.public_translate_center_to_right //退出动画
)
.install()
复制代码

因为只有一个Activity,所以需要在这个Activity中装载根Fragment


loadRootFragment(int containerId, SupportFragment toFragment)
复制代码

但现在的APP几乎都是一个页面多个Tab组成的怎么办呢?


loadMultipleRootFragment(int containerId, int showPosition, SupportFragment... toFragments);
复制代码

有了多个Fragment的显示,我们需要切换Tab实际也很简单


showHideFragment(ISupportFragment showFragment);
复制代码

是不是使用起来很简单,首页我们解决了,关于跳转和返回、参数的接受和传递呢?


//启动目标fragment
start(SupportFragment fragment)
//带返回的启动方式
startForResult(SupportFragment fragment,int requestCode)
//接收返回参数
override fun onFragmentResult(requestCode: Int, resultCode: Int, data: Bundle?) {
super.onFragmentResult(requestCode, resultCode, data)
}
//返回到上个页面,和activity的back()类似
pop()
复制代码

对于单Activity而言,我们其实也可以注册一个全局的Fragment监听,这样就能掌控当前的Fragmnet


supportFragmentManager.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
}
override fun onFragmentCreated(
fm: FragmentManager,
f: Fragment,
savedInstanceState: Bundle?
) {
super.onFragmentCreated(fm, f, savedInstanceState)
}
override fun onFragmentStarted(fm: FragmentManager, f: Fragment) {
super.onFragmentStarted(fm, f)
}
override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
super.onFragmentResumed(fm, f)
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
super.onFragmentDestroyed(fm, f)
}
},
true
)
复制代码

接下来我们看看Pad应用。对于手机应用来说,一般不会存在局部页面跳转的情况,但是Pad上是常规操作。


image.png


如图,点击左边列表的单个item,右边需要显示详情,这时候再点左边的其他item,此时的左边页面是保持不动的,但右边的详情页需要跳转对应的页面。使用过Pad的应该经常见到这种页面,比如Pad的系统设置等页面。这时只使用Activty应该是不能实现的,必须配合Fragment,左右分为两个Fragment。


但问题又出现了,这时候点击back怎么区分局部返回和整个页面返回呢?


//整个页面回退,主要是用于当前装载了Fragment的页面回退
_mActivity.pop()
//局部回退,被装载的Fragment之间回退
pop()
复制代码

如下图,这样的页面我们又应该怎么装载呢?
image.png


可以分析,页面最外面是一个Activty,要实现单Activity其内部必装载了一个根Fragment。接着这个根Fragment中使用ViewPage和tablayout完成主页框架。当前tab页要满足右边详情页的单独跳转,还得将右边页面作为主页面,以此装载子Fragment才能实现。


image.png


总结


单Activity开发在手机和平板上使用都一样,但在平板上注意的地方更多,尤其是平板一个页面可能是多个页面组成,其局部还能单独跳转的功能,其中涉及到参数回传和栈的回退问题。使用下来,我还是觉得某些页面对硬件要求很高的使用单Activity会出现体验不好的情况,有可能是优化不到位。手机应用我还是使用多Activity方式,平板应用则使用该框架实现单Activity方式。


作者:似曾相识2022
链接:https://juejin.cn/post/7204100079430123557
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

教你解决字符对齐问题

前言其实很多人都会碰到文本不对齐,文字不对齐的情况,但是只要不明显被提出,一般都会置之不理。我关注这个问题是因为有个老哥问我倒计时的时候,10以上和10以下会出现宽度变化,因为2位数变1位数确实会变化很大,有的人会说1位数的时候前面补零,这也是一个方法,还有人...
继续阅读 »

前言

其实很多人都会碰到文本不对齐,文字不对齐的情况,但是只要不明显被提出,一般都会置之不理。我关注这个问题是因为有个老哥问我倒计时的时候,10以上和10以下会出现宽度变化,因为2位数变1位数确实会变化很大,有的人会说1位数的时候前面补零,这也是一个方法,还有人说,你设置控件的宽度固定不就行了吗?其实还真不好,即便你宽度固定,你的文字内容也是会变的。

所以我就去想这个问题,虽然不是一个什么大问题,但当你去探究,确实能收获一些不一样的东西。

基础概念

首先回顾一些基础的东西。

1字节是8位,所以1字节能有256种组合,说到这个,就能联系出ASCII码,ASCII码都熟吧,就是数字和字母啊这些。然后ASCII码的定义的符号,是没有到256的,这个也很容易理解,去看看ASCII码的表就知道了。所以,ASCII码中的符号,都能用1个字节表示。

但是你的汉字是没办法用256表示的,我们中华文化博大精深,不是区区256能容纳得下的。所以汉字得用2个字节表示,甚至3个字节表示。然后emoji好像是要占3个字节还是4个字节得,这个我记得不太清了。而且不同的编码占的也不同。

回顾一下这些内容主要是为了找找感觉。

半角和全角

这个相信大家也有点了解,我们平时用输入法的时候就能进行半角全角的切换。

简单来说,全角em是指一个字符占用两个标准字符位置,半角en是指一个字符占用一个标准字符的位置。注意这里说的是占多少的位置,和上面提的字节没关系,不是说你2个字节就占2个位置,1个字节只占一个位置。

但是一般半角和圆角都是针对ASCII码里面的符号的(这个我没找到相应的概念,我是根据现象推导的)

所以先来看看直接设置半角和全角的效果


上面是半角,下面是全角,能明显看出来,中文的半角和全角都是占了两个标准字符的位置,而ASCII码中的符号,在半角的情况下是占一个,在全角的情况下是占两个。

汉字是这样,但是我在找资料的时候看到一个挺有意思的场景。就是日文,因为编码方式,会出现部分日文的半角效果和全角效果是不同的。可以参考这个老哥写的juejin.cn/post/716953… ,用的是JIS C 6220这种编码方式。

那说到这里,其实你就已经有一个概念了,数字中,每个数字在半角情况下都是占一个字符(我这里说占一个坑位可能会更好理解),默认变量输出都是半角,那两位数,就占两个坑位。所以要让1位数的显示和两位数的相同,让1位数占两个坑位不就行了吗,把1位数转成全角就行了。


看我这的效果,蓝色的区域就是全角的效果,看得出是比之前好过一些,但也没办法完全等于两个半角数字的宽度,还是差了点意思。

空格

除了用半角全角的思路去处理,还有办法吗?当然有了,发挥想象力想想,要实现1位数和2位数对齐,我可以给1位数的两边加上空格,不就行了吗,所以这空格也是有讲究滴。

我们可以来看看Unicode中有哪些空格(只列举部分):

  • U+0020:ASCII空格

  • U+00A0:不间断空格

  • U+2002:EN空格

  • U+2003:EM空格

  • U+2004:⅓EM空格

  • U+2005:¼EM空格

  • U+2006:⅙EM空格

  • U+2007:数字空格

  • U+2009:窄空格

  • U+3000:文字空格

如果先了解了半角你就知道什么是en,什么是em,看这些的时候也会更有感觉。那这么多空格,我怎么知道哪个合适?那合不合适,试试不就知道了吗,这不就和谈女朋友一样,去试试嘛


首先看到ASCII空格是合适的,会不会有人看到这里有答案就跑了 ,然后还有几个看着也相近,我们可以单独拿出来比一下。U+2004、U+2005和U+2009


发现都不合适,那这个代码具体要怎么加呢,其实也很简单,直接写\u0020就行,比如我这里的布局就是这样

<TextView
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:background="@color/blue"
  android:textColor="#000000"
  android:text="\u00206\u0020"
  android:textSize="26sp"
  />

其它

上面都是通过编码的方向去解决这个问题,那还有其它方式吗?当然又有,其实一开始就有人想说了,用几个textview去拼接,然后设置数字的textview固定宽度并且内容居中。

这当然可以。比如“倒计时30秒”这段文字,拆成3个textview,让第二个textview固定宽度并且内容居中,也能实现这个效果,但是这实现方式也太......,所以需要去探索不同的方式去处理。

那绘制可以吗,我不用textview,我自定义一个view然后画上去,我自己画的话能很好把控各种细节的处理。我倒是觉得这是一个好的主意。这是通过绘制的方式去解决这个问题。

所以从这里可以看出,其实解决这个问题的方式有很多,可以从不同的角度去处理。

作者:流浪汉kylin
来源:juejin.cn/post/7202501888616431672

收起阅读 »

如何自动打开你的 App?

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。那么这种自动打开一个 App 到底是怎么实现的呢?URL Scheme首先是最原始的方式 U...
继续阅读 »


相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。

那么这种自动打开一个 App 到底是怎么实现的呢?

URL Scheme

首先是最原始的方式 URL Scheme。

URL Scheme 是一种特殊的 URL,用于定位到某个应用以及应用的某个功能。

它的格式一般是: [scheme:][//authority][path][?query]

scheme 代表要打开的应用,每个上架应用商店的 App 所注册的 scheme 都是唯一的;后面的参数代表应用下的某个功能及其参数。

在 IOS 上配置 URL Scheme

在 XCode 里可以轻松配置


在 Android 上配置 URL Scheme

Android 的配置也很简单,在 AndroidManifest.xml 文件下添加以下配置即可


通过访问链接自动打开 App

配置完成后,只要访问 URL Scheme 链接,系统便会自动打开对应 scheme 的 App。

因此,我们可以实现一个简单的 H5 页面来承载这个跳转逻辑,然后在页面中通过调用 location.href=schemeUrl 或者 <a href='schemeUrl' /> 等方式来触发访问链接,从而自动打开 App

优缺点分析

优点: 这个是最原始的方案,因此最大的优点就是兼容性好

缺点:

  1. 通过 scheme url 这种方式唤起 App,对于 H5 中间页面是无法感知的,并不知道是否已经成功打开 App

  2. 部分浏览器有安全限制,自动跳转会被拦截,必须用户手动触发跳转(即 location.href 行不通,必须 a 标签)

  3. 一些 App 会限制可访问的 scheme,你必须要在白名单内,否则也会被拦截跳转

  4. 通过 scheme url 唤起 App 时,浏览器会提示你是否确定要打开该 App,会影响用户体验

DeepLink

通过上述缺点我们可以看出,传统的 URL Scheme 在用户体验上是存在一定缺陷的。

因此,DeepLink 诞生了。

DeepLink 的宗旨就是通过传统的 HTT P链接就可以唤醒app,而如果用户没有安装APP,则会跳转到该链接对应的页面。

IOS Universal Link

在 IOS 上一般称之为 Universal Link。

【配置你的 Universal Link 域名】

首先要去 Apple 的开发者平台上配置你的 domains,假设是: mysite.com


【配置 apple-app-site-association 文件】

在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 apple-app-site-association 文件。

文件内容包含 appID 以及 path,path如果配置 /app 则表示访问该域名下的 /app 路径均能唤起App

该文件内容大致如下:

{
   "applinks": {
       "apps": [],
       "details": [
          {
               "appID": "xxx", // 你的应用的 appID
               "paths": [ "/app/*"]
          }
      ]
  }
}
复制代码

【系统获取配置文件】

上面两步配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统都会主动去拉取域名下的配置文件。

即系统会主动去拉取 https://mysite.com/.well-known/apple-app-site-association 这个文件

然后根据返回的 appID 以及 path 判断访问哪些路径是需要唤起哪个App

【自动唤起 App】

当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。

同时,客户端还可以进行一些自定义逻辑处理:

客户端会接收到 NSUserActivity 对象,其 actionType 为 NSUserActivityTypeBrowsingWeb,因此客户端可以在接收到该对象后做一些跳转逻辑处理。


Android DeepLink

与 IOS Universal Link 原理相似,Android系统也能够直接通过网站地址打开应用程序对应的内容页面,而不需要用户选择使用哪个应用来处理网站地址

【配置 AndroidManifest.xml】 在 AndroidManifest 配置文件中添加对应域名的 intent-filter:

scheme 为 https / http;

host 则是你的域名,假设是: mysite.com


【生成 assetlinks.json 文件】

首先要去 Google developers.google.com/digital-ass… 生成你的 assetlinks json 文件。


【配置 assetlinks.json 文件】

生成文件后,同样的需要在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 assetlinks.json 配置文件,文件内容包含应用的package name 和对应签名的sha哈希

【系统获取配置文件】

配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统会进行以下校验:

  1. 如果 intent-filter 的 autoVerify 设置为 true,那么系统会验证其

  • Action 是否为 android.intent.action.VIEW

  • Category 是否为android.intent.category.BROWSABLE 和 android.intent.category.DEFAULT

  • Data scheme 是否为 http 或 https

  1. 如果上述条件都满足,那么系统将会拉取该域名下的 json 配置文件,同时将 App 设置为该域名链接的默认处理App

【自动唤起 App】

当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。

优缺点分析

【优点】

  1. 用户体验好:可以直接打开 App,没有弹窗提示

  2. 唤起App失败则会跳转链接对应的页面

【缺点】

  1. iOS 9 以后才支持 Universal Link,

  2. Android 6.0 以后才支持 DeepLink

  3. DeepLink 需要依赖远程配置文件,无法保证每次都能成功拉取到配置文件

推荐方案: DeepLink + H5 兜底

基于前面两种方案的优缺点,我推荐的解决方案是配置 DeepLink,同时再加上一个 H5 页面作为兜底。

首先按照前面 DeepLink 的教程先配置好 DeepLink,其中访问路径配置为 https://mysite.com/app

接着,我们就可以在 https://mysite.com/app 路径下做文章了。在该路径下放置一个 H5 页面,内容可以是引导用户打开你的 App。

当用户访问 DeepLink 没有自动打开你的 App 时,此时用户会进入浏览器,并访问 https://mysite.com/app 这个 H5 页面。

在 H5 页面中,你可以通过浏览器 ua 获取当前的系统以及版本:

  1. 如果是 Android 6.0 以下,那么可以尝试用 URL Scheme 去唤起 App

  2. 如果是 IOS / Android 6.0 及以上,那么此时可以判断用户未安装 App。这种情况下可以做些额外的逻辑,比如重定向到应用商店引导用户去下载之类的

作者:龙飞_longfe
来源:juejin.cn/post/7201521440612974649

收起阅读 »

七道Android面试题,先来简单热个身

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,...
继续阅读 »

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,一些扯皮的话别去听,都是在害人,另一个就是面试造火箭,入职拧螺丝毕竟都是少数,真正一场合格的面试问的东西,都是实际开发过程中会遇到的,下面我就说几个我遇到过的面试题吧

为什么ArrayMap比HashMap更适合Android开发

我们一般习惯在项目当中使用HashMap去存储键值队这样的数据,所以往往在android面试当中HashMap是必问环节,但有次面试我记得被问到了有没有有过ArrayMap,我只能说有印象,毕竟用的最多的还是HashMap,然后那个面试官又问我,觉得Android里面更适合用ArrayMap还是HashMap,我就说不上来了,因为也没看过ArrayMap的源码,后来回去看了下才给弄明白了,现在就简单对比下ArrayMap与HashMap的特点

HashMap

  • HashMap的数据结构为数组加链表的结构,jdk1.8之后改为数组加链表加红黑树的结构

  • put的时候,会先计算key的hashcode,然后去数组中寻找这个hashcode的下标,如果数据为空就先resize,然后检查对应下标值(下标值=(数组长度-1)&hashcode)里面是否为空,空则生成一个entry插入,否就判断hascode与key值是否分别都相等,如果相等则覆盖,如果不等就发生哈希冲突,生成一个新的entry插入到链表后面,如果此时链表长度已经大于8且数组长度大于64,则先转成树,将entry添加到树里面

  • get的时候,也是先去查找数组对应下标值里面是否为空,如果不为空且key与hascode都相等,直接返回value,否就判断该节点是否为一个树节点,是就在树里面返回对应entry,否就去遍历整个链表,找出key值相等的entry并返回

ArrayMap

  • 内部维护两个数组,一个是int类型的数组(mHashes)保存key的hashcode,另一个是Object的数组(mArray),用来保存与mHashes对应的key-value

  • put数据的时候,首先用二分查找法找出mHashes里面的下标index来存放hashcode,在mArray对应下标index<<1与(index<<1)+1的位置存放key与value

  • get数据的时候,同样也是用二分查找法找出与key值对应的下标index,接着再从mArray的(index<<1)+1位置将value取出

对比

  • HashMap在存放数据的时候,无论存放的量是多少,首先是会生成一个Entry对象,这个就比较浪费内存空间,而ArrayMap只是把数据插入到数组中,不用生成新的对象

  • 存放大量数据的时候,ArrayMap性能上就不如HashMap,因为ArrayMap使用的是二分查找法找的下标,当数据多了下标值找起来时间就花的久,此外还需要将所有数据往后移再插入数据,而HashMap只要插入到链表或者树后面即可

所以这就是为什么,在没有那么大的数据量需求下,Android在性能角度上比较适合用ArrayMap

为什么Arrays.asList后往里add数据会报错

这个问题我当初问过不少人,不缺乏一些资历比较深的大佬,但是他们基本都表示不清楚,这说明平时我们研究Glide,OkHttp这样的三方库源码比较多,而像一些比较基础的往往会被人忽略,而有些问题如果被忽略了,往往会产生一些捉摸不透的问题,比如有的人喜欢用Arrays.asList去生成一个List

val dataList = Arrays.asList(1,2,3)
dataList.add(4)

但是当我们往这个List里面add数据的时候,我们会发现,crash了,看到的日志是


不被支持的操作,这让首次遇到这样问题的人肯定是一脸懵,List不让添加数据了吗?之前明明可以的啊,但是之前我们创建一个List是这样创建的


它所在的包是java.util.ArrayList里面,我们看下里面的代码

public boolean add(E e) {
  ensureCapacityInternal(size + 1); // Increments modCount!!
  elementData[size++] = e;
  return true;
}
public void add(int index, E element) {
  if (index > size || index < 0)
      throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

  ensureCapacityInternal(size + 1); // Increments modCount!!
  System.arraycopy(elementData, index, elementData, index + 1,
                    size - index);
  elementData[index] = element;
  size++;
}

是存在add方法的,我们再回头再去看看asList生成的List


是在java.util.Arrays包里面的,而这里面的ArrayList我们看到了,并没有去实现List接口,所以也就没有add,get等方法,另外在kotlin里面,我们会看到一个细节,当你敲完Arrays.asList的时候,编译器会提示你,可以转换成listof函数,而这个还是我们知道生成的list都是只能读取,不能往里写数据

Thread.sleep(0)到底“睡没睡”

记得在上上家公司,接手的第一个需求就是做一个动画,这个动画需要一个延迟启动的功能,我那个时候想都没想加了个Thread.sleep(3000),后来被领导批了,不可以用Thread.sleep实现延迟功能,那会还不太明白,后来知道了,Thread.sleep(3000)不一定真的暂停三秒,我们来举个例子

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
   Thread.sleep(3000)
   println("end:${System.currentTimeMillis()}")
}).start()

我们在主线程先打印一条数据展示时间,然后开启一个子线程,在里面sleep三秒以后在打印一下时间,我们看下结果如何

start:1675665421590
end:1675665424591

好像对了又好像没对,为什么是过了3001毫秒才打印出来呢?有的人会说,1毫秒而已,忽略嘛,那我们把上面的代码改下再试试

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
   Thread.sleep(0)
   println("end:${System.currentTimeMillis()}")
}).start()

现在sleep了0毫秒,那是不是两条打印日志应该是一样的呢,我们看看结果

start:1675666764475
end:1675666764477

这下子给整不会了,明明sleep0毫秒,那么多出来的2毫秒是怎么回事呢?其实在Android操作系统中,每个线程使用cpu资源都是有优先级的,优先级高的才有资格使用,而操作系统则是在一个线程释放cpu资源以后,重新计算所有线程的优先级来重新分配cpu资源,所以sleep真正的意义不是暂停,而是在接下去的时间内不参与cpu的竞争,等到cpu重新分配完资源以后,如果优先级没变,那么继续执行,所以sleep(0)秒的真正含义是触发cpu资源重新分配

View.post为什么可以获取控件的宽高

我们都知道在onCreate里面想要获取一个控件的宽高,如果直接获取是拿不到的

val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
......
按钮宽:0,高:0

而如果想要获取宽高,则必须调用View.post的方法

bindingView.mainButton.post {
   val mWith = bindingView.mainButton.width
   val mHeight = bindingView.mainButton.height
   println("按钮宽:$mWith,高:$mHeight")
}
......
按钮宽:979,高:187

很神奇,加个post就可以在同样的地方获取控件宽高了,至于为什么呢?我们来分析一下

简单的来说

Activity生命周期,onCreate方法里面视图还在绘制过程中,所以没法直接获取宽高,而在post方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成的时候放在一个等待队列里面,等到视图绘制执行完毕以后再去执行队列里面的线程,所以在post里面也可以获取宽高

复杂的来说

我们首先从View.post方法里面开始看


这个代码里面的两个框子,说明了post方法做了两件事情,当mAttachInfo不为空的时候,直接让mHandler去执行线程action,当mAttachInfo为空的时候,将线程放在了一个队列里面,从注释里面的第一个单词Postpone就可以知道,这个action是要推迟进行,什么时候进行呢,我们在慢慢看,既然是判断当mAttachInfo不为空才去执行线程,那我们找找什么时候对mAttachInfo赋值,整个View的源码里面只有一处是对mAttachInfo赋值的,那就是在dispatchAttachedToWindow 这个方法里面,我们看下

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
mAttachInfo = info;
...省略部分源码...

// Transfer all pending runnables.
if (mRunQueue != null) {
mRunQueue.executeActions(info.mHandler);
mRunQueue = null;
}

}

当走到dispatchAttachedToWindow这个方法的时候,mAttachInfo才不为空,也就是从这里开始,我们就可以获取控件的宽高等信息了,另外我们顺着这个方法往下看,可以发现,之前的那个队列在这里开始执行了,现在就关键在于,什么时候执行dispatchAttachedToWindow这个方法,这个时候就要去ViewRootIml类里面查看,发现只有一处调用了这个方法,那就是在performTraversals这个方法里面

private void performTraversals() {
...省略部分源码...
host.dispatchAttachedToWindow(mAttachInfo, 0);
...省略部分源码...
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...省略部分源码...
performLayout(lp, mWidth, mHeight);
...省略部分源码...
performDraw();
}

performTraversals这个方法我们就很熟悉了,整个View的绘制流程都在里面,所以只有当mAttachInfo在这个环节赋值了,才可以得到视图的信息

IdleHandler到底有啥用

Handler是面试的时候必问的环节,除了问一下那四大组件之外,有的面试官还会问一下IdleHandler,那IdleHandler到底是什么呢,它是干什么用的呢,我们来看看

Message next() {
...省略部分代码...
synchronized (this) {
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

}

只有在MessageQueue中的next方法里面出现了IdleHandler,作用也很明显,当消息队列在遍历队列中的消息的时候,当消息已经处理完了,或者只存在延迟消息的时候,就会去处理mPendingIdleHandlers里面每一个idleHandler的事件,而这些事件都是通过方法addIdleHandler注册进去的

Looper.myQueue().addIdleHandler {
false
}

addIdlehandler接受的参数是一个返回值为布尔类型的函数类型参数,至于这个返回值是true还是false,我们从next()方法中就能了解到,当为false的时候,事件处理完以后,这个IdleHandler就会从数组中删除,下次再去遍历执行这个idleHandler数组的时候,该事件就没有了,如果为true的话,该事件不会被删除,下次依然会被执行,所以我们按需设置。现在我们可以利用idlehandler去解决上面讲到的在onCreate里面获取控件宽高的问题

Looper.myQueue().addIdleHandler {
val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
false
}

当MessageQueue中的消息处理完的时候,我们的视图绘制也完成了,所以这个时候肯定也能获取控件的宽高,我们在IdleHandler里面执行了同样的代码之后,运行后的结果如下

按钮宽:979,高:187

除此之外,我们还可以做点别的事情,比如我们常说的不要在主线程里面做一些耗时的工作,这样会降低页面启动速度,严重的还会出现ANR,这样的场景除了开辟子线程去处理耗时操作之外,我们现在还可以用IdleHandler,这里举个例子,我们在主线程中给sp塞入一些数据,然后在把这些数据读取出来,看看耗时多久

println(System.currentTimeMillis())
val testData = "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhas" +
"jkhdaabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd"
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
for (i in 1..5000) {
sharePreference.edit().putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())

......运行结果
1676260921617
1676260942770

我们看到在塞入5000次数据,再读取5000次数据之后,一共耗时大概20秒,同时也阻塞了主线程,导致的现象是页面一片空白,只有等读写操作结束了,页面才展示出来,我们接着把读写操作的代码用IdleHandler执行一下看看

Looper.myQueue().addIdleHandler {
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
val editor = sharePreference.edit()
for (i in 1..5000) {
editor.putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())
false
}
......运行结果
1676264286760
1676264308294

运行结果依然耗时二十秒左右,但区别在于这个时候页面不会受到读写操作的阻塞,很快就展示出来了,说明读写操作的确是等到页面渲染完才开始工作,上面过程没有放效果图主要是因为时间太长了,会影响gif的体验,有兴趣的可以自己试一下

如何让指定视图不被软键盘遮挡

我们通常使用android:windowSoftInputMode属性来控制软键盘弹出之后移动界面,让输入框不被遮挡,但是有些场景下,键盘永远都会挡住一些我们使用频次比较高的控件,比如现在我们有个登录页面,大概的样子长这样


它的布局文件是这样

<RelativeLayout
android:id="@+id/mainroot"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="100dp"
android:src="@mipmap/ic_launcher_round" />

<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_view1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginBottom="120dp"
android:gravity="center"
android:orientation="vertical">

<EditText
android:id="@+id/main_edit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:hint="请输入用户名"
android:textColor="@color/black"
android:textSize="15sp" />

<EditText
android:id="@+id/main_edit2"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="30dp"
android:hint="请输入密码"
android:textColor="@color/black"
android:textSize="15sp" />

<Button
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="20dp"
android:text="登录" />

</androidx.appcompat.widget.LinearLayoutCompat>

</RelativeLayout>

在这样一个页面里面,由于输入框与登录按钮都比较靠页面下方,导致当输入完内容想要点击登录按钮时候,必须再一次关闭键盘才行,这样的操作在体验上就比较大打折扣了


现在希望可以键盘弹出之后,按钮也展示在键盘上面,这样就不用收起弹框以后才能点击按钮了,这样一来,windowSoftInputMode这一个属性已经不够用了,我们要想一下其他方案

  • 首先,需要让按钮也展示在键盘上方,那只能让布局整体上移把按钮露出来,在这里我们可以改变LayoutParam的bottomMargin参数来实现

  • 其次,需要知道键盘什么时候弹出,我们都知道android里面并没有提供任何监听事件来告诉我们键盘什么时候弹出,我们只能从其他角度入手,那就是监听根布局可视区域大小的变化

ViewTreeObserver

我们先获取视图树的观察者,使用addOnGlobalLayoutListener去监听全局视图的变化

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {

}

接下去就是要获取根视图的可视化区域了,如何来获取呢?View里面有这么一个方法,那就是getWindowVisibleDisplayFrame,我们看下源码注释就知道它是干什么的了


一大堆英文没必要都去看,只需要看最后一句就好了,大概意思就是获取能够展示给用户的可用区域,所以我们在监听器里面加上这个方法

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
}

当键盘弹出或者收起的时候,rect的高度就会跟着变化,我们就可以用这个作为条件来改变bottomMargin的值,现在我们增加一个变量oldDelta来保存前一个rect变化的高度值,用来做比较,完整的代码如下

var oldDelta = 0
val params:RelativeLayout.LayoutParams = bindingView.llView1.layoutParams as RelativeLayout.LayoutParams
val originBottom = params.bottomMargin
bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
val deltaHeight = r.height()
if (oldDelta != deltaHeight) {
if (oldDelta != 0) {
if (oldDelta > deltaHeight) {
params.bottomMargin = oldDelta - deltaHeight
} else if (oldDelta < deltaHeight) {
params.bottomMargin = originBottom
}
bindingView.llView1.layoutParams = params
}
oldDelta = deltaHeight
}
}

最终效果如下


弹出后页面有个抖动是因为本身有个页面平移的效果,然后再去计算layoutparam,如果不想抖动可以在布局外层套个scrollView,用smoothScrollTo把页面滑上去就可以了,有兴趣的可以业余时间试一下

为什么LiveData的postValue会丢失数据

LiveData已经问世好多年了,大家都很喜欢用,因为它上手方便,一般知道塞数据用setValue和postValue,监听数据使用observer就可以了,然而实际开发中我遇到过好多人,一会这里用setValue一会那里用postValue,或者交替着用,这种做法也不能严格意义上说错,毕竟运行起来的确没问题,但是这种做法确实是存在风险隐患,那就是连续postValue会丢数据,我们来做个实验,连续setValue十个数据和连续postValue十个数据,收到的结果都分别是什么

var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.value = i
}
}

mainViewModel.testData.observe(this) {
println("收到:$it")
}

//执行结果
收到:1
收到:2
收到:3
收到:4
收到:5
收到:6
收到:7
收到:8
收到:9
收到:10

setValue十次数据都可以收到,现在把setValue改成postValue再来试试

var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.postValue(i)
}
}

得到的结果是

收到:10

只收到了最后一条数据10,这是为什么呢?我们进入postValue里面看看里面的源码就知道了


主要看红框里面,有一个synchronized同步锁锁住了一个代码块,我们称为代码块1,锁的对象是mDataLock,代码块1做的事情先是给postTask这个布尔值赋值,接着把传进来的值赋给mPendingData,那我们知道了,postTask除了第一个被执行的时候,值是true,结下去等mPendingData有值了以后就都为false,前提是mPendingData没有被重置为NOT_SET,然后我们顺着代码往下看,会看到代码接下来就要到一个mPostValueRunnable的线程里面去了,我们看下这个线程


发现同样的锁,锁住了另一块代码块,我们称为代码块2,这个代码块里面恰好是把mPendingData的值赋给newValue以后,重置为NOT_SET,这样一来,postValue又可以接受新的值了,所以这也是正常情况下每次postValue都可以接受到值的原因,但是我们想想连续postValue的场景,我们知道如果synchronized如果修饰一段代码块,那么当这段代码块获取到锁的时候,就具有优先级,只有当全部执行完以后才会释放锁,所以当代码块1连续被访问时候,代码块2是不会被执行的,只有等到代码块1执行完,释放了锁,代码块2才会被执行,而这个时候,mPendingData已经是最新的值了,之前的值已经全部被覆盖了,所以我们说的postValue会丢数据,其实说错了,应该是postValue只会发送最新数据

总结

这篇文章讲到的面试题还仅仅只是过去几年遇到的,现在面试估计除了一些常规问题之外,比重会更倾向于Kotlin,Compose,Flutter的知识点,所以只有不断的日积月累,让自己的知识点更加的全面,才能在目前竞争激烈的行情趋势下逆流而上,不会被拍打在沙滩上

作者:Coffeeee
来源:juejin.cn/post/7199537072302374969

收起阅读 »

在安卓项目中使用 FFmpeg 实现 GIF 拼接(可扩展为实现视频会议多人同屏效果)

前言在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。大致效果如下:注意:上面的动图只展示了预览效果,没有展示实际...
继续阅读 »

前言

在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。

当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。

大致效果如下:


注意:上面的动图只展示了预览效果,没有展示实际合成效果,但是合成效果和预览效果是一摸一样的,有兴趣的话,我可以再开一篇文章讲解怎么实现这个预览效果

实现方法

FFmpeg 简介

在开始之前先简单介绍一下什么是 FFmpeg,不过我相信只要是稍微接触过一点音视频的开发者都知道 FFmpeg。

FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能,包含了 libavcodec ——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat ——一个音频与视频格式转换库。

简单来说,只要是和音视频相关的操作,几乎都可以使用 FFmpeg 来实现。

当然,FFmpeg 是一个纯命令行工具,所以我在这里简单介绍几个本文需要用到的参数:

  1. -y 若指定的输出文件已存在则强制覆盖

  2. -i 设置输入文件,可以设置多个

  3. -filter_complex 设置复杂滤镜,我们这次要实现的拼接 gif 就是依靠这个参数完成

在安卓中使用 FFmpeg

我现在使用的库是 ffmpeg-kit 使用这个库可以直接集成 FFmpeg 到项目中,并且能够方便的执行 FFmpeg 命令。

该库执行 FFmpeg 很简单,只需要:

val session = FFmpegKit.executeWithArguments("your cmd text")
if (ReturnCode.isSuccess(session.returnCode)) {
   Log.i(TAG, "Command execution completed successfully.")
} else if (ReturnCode.isCancel(session.returnCode)) {
   Log.i(TAG, "Command execution cancelled by user.")
} else {
   Log.e(TAG, String.format("Command execution fail with state %s and rc %s.%s", session.state, session.returnCode, session.failStackTrace))
}

因为我需要自己管理线程,所以使用的是同步执行

另外,我几乎试过当前 GitHub 上最近还在维护所有的 FFmpeg for Android 库,甚至还自己写过一个,但是都或多或少的有点问题,最终只有这个库能够适配我的需求。

在此弱弱的吐槽一下某些“开源”库,只提供二进制包,不提供编译脚本,也不提供源代码,提供的二进制包缺少了某些依赖,我想自己动手编译都没法编译,一看 README ,好嘛,定制编译请联系作者付费获取,合着这开源开了个寂寞啊。

拼接命令

我们先来看一段完整的拼接命令,我会详细讲解各个参数的作用,最后再讲解如何动态生成需要的命令。

完整命令:

# 覆盖输出文件
-y

# 输入文件
-i jointBg.png
-i 1.gif
-i 2.gif
-i 3.gif
-i 4.gif

# 开始进行滤镜转换
-filter_complex
[0:v]pad=1280:2161[bg];
[1:v]scale=640:1137[gif0];
[2:v]scale=640:368[gif1];
[3:v]scale=640:1024[gif2];
[4:v]scale=640:368[gif3];

[bg][gif0] overlay=0:0[over0];
[over0][gif1] overlay=640:0[over1];
[over1][gif2] overlay=0:1137[over2];
[over2][gif3] overlay=640:368

# 输出路径
out.gif

为了方便查看,我使用换行分割了命令,使用时可不能加换行哦

在这段代码中,我们使用 -y 参数指定如果输出文件已存在则覆盖。

接下来使用 -i 参数输入了 5 个文件,其中 jointBg.png 是我生成的一个 1x1 像素的图片,用于后面扩展成背景画布,其他的 gif 文件就是要拼接的源文件。

然后使用 -filter_complex 表示要做一个复杂滤镜,后面跟着的都是这个复杂滤镜的参数:

[0:v]pad=1280:2161[bg]; 表示将输入的第一个文件作为视频打开,并将其当成画板,同时缩放分辨率为 1280x2161 (后面会讲这些分辨率是怎么来的),最后取名为 bg

[1:v]scale=640:1137[gif0]; 表示将输入的第二个文件作为视频打开,并缩放分辨率至 640x1137 , 最后取别名为 gif0

下面的三行语句作用相同。

然后就是开始拼接:

[bg][gif0] overlay=0:0[over0]; 表示将 gif0 覆盖到 bg 上,并且覆盖的起点坐标为 0x0 ,最后将该其取名为 over0

下面的三行代码作用相同。

简单理解一下这个过程:

  1. 创建一个图片,并缩放尺寸至事先计算出来的最终拼接成品的尺寸作为背景

  2. 依次将输入的文件缩放至事先计算好的尺寸

  3. 依次将缩放后的输入文件覆盖(叠加)到背景上

动画演示:


仅作演示便于理解,实际拼接时一般都是放大 bg , 缩小 gif,并且 gif 将完全覆盖住 bg

计算尺寸

上一节中的命令涉及到很多缩放过程,那么这个缩放的尺寸是如何得到的呢?

这一节我们将讲解如何计算尺寸。

首先,我们需要知道的是,当前这个功能,一共有三种拼接模式:

  1. 横向拼接

  2. 纵向拼接

  3. 宫格拼接


本文主要讲解的是宫格拼接,宫格拼接的样式即文章开头的预览效果那种。

既然是宫格拼接,那么绕不开的就是如果拼接的动图尺寸不一致,怎么确保拼接出来的动图美观?

这里我们有两种策略,由用户自行选择:

  1. 完全以最小尺寸的图片为基准,将所有图片强制缩放到最小尺寸,这样可能会造成部分动图被拉伸失真。

  2. 以所有图片中的最小宽度为基准,等比例缩放其他图片,这样可以确保所有图片都不会失真,但是拼接出来的成品将不是一个完美的矩形,而是一个留有黑色背景的异形图片。

确定了我们使用的两种缩放策略,下面就是开始计算成品的总尺寸和每张输入图片的需要缩放尺寸。

不过在此之前,我们需要遍历所有输入图片,拿到所有图片的原始尺寸和所有图片中的最小尺寸:

val jointGifResolution: MutableList<MutableList<Int>> = ArrayList() // 所有动图的原始尺寸 list
var minValue = Int.MAX_VALUE  // 最小宽度(别问我为什么不命名成 minWidth ,问就是兼容性)
var minValue2 = Int.MAX_VALUE  // 最小高度

for (uri in gifUris) {
   val gifDrawable = GifDrawable(context.contentResolver, uri)
   val height = gifDrawable.intrinsicHeight  // 当前 gif 的原始高度
   val width = gifDrawable.intrinsicWidth  // 当前 gif 的原始宽度
   jointGifResolution.add(mutableListOf(width, height))  // 将尺寸加入 list
   
   // 计算最小宽高
   if (minValue > width) {
       minValue = width
  }
   if (minValue2 > height) {
       minValue2 = height
  }
}

其中,gifUris 即事先获取到的所有输入动图的 uri 列表。

这里我们使用到了 GifDrawable 获取动图的尺寸,因为这不是本文的重点,所以不多加解释,读者只需知道这样可以拿到 gif 的原始尺寸即可。

拿到所有动图的原始宽高和最小宽高后,下一步是计算需要的缩放值:

var totalHeight = 0
var totalWidth = 0

var squareIndex = 0
val squareTotalHeight: MutableList<Int> = arrayListOf()

jointGifResolution.forEachIndexed { index, resolution ->
   val jointWidth = minValue // 无论使用缩放策略 1 还是 2,缩放宽度都是最小宽度
   val jointHeight = when (scaleMode) {
       // 如果使用缩放策略 2 则需要按比例计算出缩放高度
       GifTools.JointScaleModeWithRatio -> resolution[1] * minValue / resolution[0]
       // 如果使用缩放策略 1 则直接强制缩放到最小高度
       else -> minValue2
  }
   // 因为宫格拼接只能使用 2 的 n 次幂张图片,所以每行图片数量可以根据图片总数算出,不过太麻烦,所以这里我打了个表,直接从表里面拿
   // val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)
   var lineLength = GifTools.JointGifSquareLineLength[jointGifResolution.size]
   if (lineLength == null) {
       lineLength = sqrt(jointGifResolution.size.toDouble()).toInt()
  }
   
   if (scaleMode == GifTools.JointScaleModeWithRatio) { // 使用等比缩放策略
       
       if (index < lineLength) {  // 所有图片宽度都是一样的,所以直接加一行的宽度得到的就是最大宽度
           totalWidth += jointWidth
      }
       try {
           // 这里是获取每一列的当前行高,并将其加起来,最终遍历完会得到当前列的高度
           val tempIndex = squareIndex % lineLength
           Log.e(TAG, "getJointGifResolution: temp index = $tempIndex")
           if (squareTotalHeight.size <= tempIndex) {
               squareTotalHeight.add(tempIndex, 0)
          }
           squareTotalHeight[tempIndex] = squareTotalHeight[tempIndex] + jointHeight
      } catch (e: java.lang.Exception) {
           Log.e(TAG, "getJointGifResolution: ", e)
      }
       
       // 将缩放尺寸更新至尺寸列表
       jointGifResolution[index] = mutableListOf(jointWidth, jointHeight)
  } else {
       // 如果不是按比例缩放,则直接将最小宽高存入总宽高
       if (index < lineLength) {
           totalHeight += min(jointHeight, jointWidth)
           totalWidth += min(jointHeight, jointWidth)
      }
       
       // 将缩放尺寸更新至尺寸列表
       jointGifResolution[index] = mutableListOf(min(jointHeight, jointWidth), min(jointHeight, jointWidth))
  }
   squareIndex++
}

上面的代码我已经加了详细的注释,至此所有图片的缩放尺寸已计算出来。

即,总尺寸为:

if (scaleMode != GifTools.JointScaleModeWithRatio) {
   jointGifResolution.add(mutableListOf(totalWidth, totalHeight))
}
else {
   Log.e(TAG, "getJointGifResolution: $squareTotalHeight")
   jointGifResolution.add(mutableListOf(totalWidth, Collections.max(squareTotalHeight)))
}

最小宽高为:

jointGifResolution.add(mutableListOf(minValue, minValue2))

对了,你可能会奇怪,为什么我要把总尺寸和最小宽高存入缩放尺寸 list,哈哈,这是因为我懒,所以我对这个 list 的定义是:

/**
*
* 遍历获取所有 gifUris 中的动图分辨率
*
* 并将经过处理后的所有长、宽之和存入 [size-2] ;
*
* 将最小的长宽存入 [size-1]
* */

动态生成命令

完成了尺寸的计算,下一步是按照输入文件和计算出来的尺寸动态的生成 FFmpeg 命令。

不过在这之前,我们需要先创建一个 1x1 的图片,用来扩展成背景:

private suspend fun createJointBgPic(context: Context): File? {
   val drawable = ColorDrawable(Color.parseColor("#FFFFFFFF"))
   val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
   val canvas = Canvas(bitmap)
   drawable.draw(canvas)
   return try {
       Tools.saveBitmap2File(bitmap, "jointBg", context.externalCacheDir)
  } catch (e: Exception) {
       log2text("Create cache bg fail!", "e", e)
       null
  }
}

然后从尺寸列表中取出并删除追加在末尾的总尺寸和最小尺寸:

// 别看了,没写错,就是两个 size-1 ,为啥?你猜
val minResolution = gifResolution.removeAt(gifResolution.size - 1)
val totalResolution = gifResolution.removeAt(gifResolution.size - 1)

然后,就是开始拼接命令,这里我为了方便使用,自己写了一个 FFmpeg 命令的 Builder:

/**
* @author equationl
* */
public class FFMpegArgumentsBuilder {
private final String[] cmd;

public static class Builder {
private final ArrayList<String> cmd = new ArrayList<>();

/**
* Such as add [arg, value] to cmd[]
* */
public Builder setArgWithValue(String arg, String value) {
this.cmd.add(arg);
this.cmd.add(value);
return this;
}

/**
* Such as add arg to cmd[]
* */
public Builder setArg(String arg) {
this.cmd.add(arg);
return this;
}

/**
* Such as "-ss time"
* */
public Builder setStartTime(String time) {
this.cmd.add("-ss");
this.cmd.add(time);
return this;
}

/**
* Such as "-to time"
* */
public Builder setEndTime(String time) {
this.cmd.add("-to");
this.cmd.add(time);
return this;
}

/**
* Such as "-i input"
* */
public Builder setInput(String input) {
this.cmd.add("-i");
this.cmd.add(input);
return this;
}

/**
* <p>Such as "-t time"</p>
* <p>Note: call this before addInput() will limit input duration time; call before addOutput() will limit output duration time.</p>
* */
public Builder setDurationTime(String time) {
this.cmd.add("-t");
this.cmd.add(time);
return this;
}

/**
* <p>if isOverride is true, add "-y"; else add "-n"</p>
* <p>if do not set this arg, FFMpeg may ask for if override existed output file</p>
* */
public Builder setOverride(Boolean isOverride) {
if (isOverride) {
this.cmd.add("-y");
}
else {
this.cmd.add("-n");
}
return this;
}

/**
* Add output file to cmd[].<b>You must call this at end.</b>
* */
public Builder setOutput(String output) {
this.cmd.add(output);
return this;
}

/**
* <p>Set input/output file format</p>
* <p>Such as "-f format"</p>
* */
public Builder setFormat(String format) {
this.cmd.add("-f");
this.cmd.add(format);
return this;
}

/**
* Set video filter
* Such as "-vf filter"
* */
public Builder setVideoFilter(String filter) {
this.cmd.add("-vf");
this.cmd.add(filter);
return this;
}

/**
* Set frame rate, Such as "-r frameRate"
* */
public Builder setFrameRate(String frameRate) {
this.cmd.add("-r");
this.cmd.add(frameRate);
return this;
}

/**
* Set frame size, Such as "-s frameSize"
* */
public Builder setFrameSize(String frameSize) {
this.cmd.add("-s");
this.cmd.add(frameSize);
return this;
}

public FFMpegArgumentsBuilder build() {
return new FFMpegArgumentsBuilder(this, false);
}

/**
* Build cmd
*
* @param isAddFFmpeg true: Add a ffmpeg flag in first
* */
public FFMpegArgumentsBuilder build(Boolean isAddFFmpeg) {
return new FFMpegArgumentsBuilder(this, isAddFFmpeg);
}
}

public String[] getCmd() {
return this.cmd;
}

private FFMpegArgumentsBuilder(Builder b, Boolean isAddFFmpeg) {
if (isAddFFmpeg) {
b.cmd.add(0, "ffmpeg");
}
this.cmd = b.cmd.toArray(new String[0]);
}

}

开始生成命令文本:

首先是输入文件等,

val cmdBuilder = FFMpegArgumentsBuilder.Builder()
cmdBuilder.setOverride(true) // -y
.setInput(jointBg.absolutePath) // -i 输入背景

for (uri in gifUris) { //输入GIF
cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri)) // -i
}

cmdBuilder.setArg("-filter_complex") //添加过滤器

然后是添加过滤器参数,

//过滤器参数
var cmdFilter = ""

//设置背景并扩展分辨率到 total
cmdFilter += "[0:v]pad=${totalResolution[0]}:${totalResolution[1]}[bg];"

//将输入文件缩放并取别名为 gifX (X为索引)
gifResolution.forEachIndexed { index, mutableList ->
cmdFilter += "[${index+1}:v]scale=${mutableList[0]}:${mutableList[1]}[gif$index];"
}

cmdFilter += "[bg][gif0] overlay=0:0[over0];" //将第一个GIF叠加 bg 的 0:0 (即画面左下角)

//开始叠加剩余动图
cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)

其中,getCmdFilterOverlaySquare 用于计算 gif 的摆放坐标,并合成参数命令,实现如下:

private fun getCmdFilterOverlaySquare(gifUris: ArrayList<Uri>, gifResolution: MutableList<MutableList<Int>>): String {
// "[bg][gif0] overlay=0:0[over0];"
var cmdFilter = ""
var h: Int
var w: Int
var index = 0
var lineLength = GifTools.JointGifSquareLineLength[gifUris.size]
if (lineLength == null) {
lineLength = sqrt(gifUris.size.toDouble()).toInt()
}

for (i in 0 until lineLength) {
for (j in 0 until lineLength) {
if ((i==lineLength-1 && j==lineLength-1) || (i==0 && j==0)) { //最后一张单独处理,第一张已处理
continue
}
if (j==0) { //竖排第一个,w当然等于 0
w = 0
} else {
w = 0
for (k in 0 until j) {
w += gifResolution[i*lineLength+k][0]
}
}
if (i==0) { //横排第一个,h等于0
h = 0
} else {
h = 0
for (k in j until index step lineLength) {
h += gifResolution[k][1]
}
}

cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h[over${index + 1}];"
index++
}
}

w = 0
for (i in 0 until lineLength-1) {
w += gifResolution[i+lineLength*(lineLength-1)][0]
}

h = 0
for (i in lineLength-1 until lineLength*lineLength-1 step lineLength) {
h += gifResolution[i][1]
}

cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h"

return cmdFilter
}

上述代码不难理解,总之就是根据遍历到的 gif 索引,判断它应该所处的坐标,然后加入过滤器参数。

最后,将过滤参数加入命令,加入输出文件路径,即可拿到最终命令文本 cmd

cmdBuilder.setArg(cmdFilter)
cmdBuilder.setOutput(resultPath)

val cmd = cmdBuilder.build(false).cmd

最后,只要将这个命令文本仍给 FFmpeg 执行即可!

总结

虽然本文仅仅说的是如何拼接 Gif , 但是 FFmpeg 是十分强大的,我这个属于是抛砖引玉。

相信各位有过这样一种需求,那就是做一个多人同屏的实时会议功能,如果在看本文之前你可能不知所措,但是看完本文你一定会觉得这是小菜一碟。

因为 FFmpeg 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?

作者:equationl
来源:juejin.cn/post/7136325945937362952

收起阅读 »

为什么要选择VersionCatalog来做依赖管理?

虾扯淡很多人都介绍过Gradle 7.+提供新依赖管理工具VersionCatalog,我就不过多介绍这个了。我们最近也算是成功接入了VersionCatalog,过程也还是有点曲折的,总体来说我觉得确实比我们当前的ext,或者说是用buildSrc的形式进行...
继续阅读 »

虾扯淡

很多人都介绍过Gradle 7.+提供新依赖管理工具VersionCatalog,我就不过多介绍这个了。我们最近也算是成功接入了VersionCatalog,过程也还是有点曲折的,总体来说我觉得确实比我们当前的ext,或者说是用buildSrc的形式进行依赖管理是个更成熟的方案吧。下面是几个介绍的文章,尤其可以看看三七哥哥的。

之前大部分文章只介绍了技术方案,很少会去横向对比几个技术方案之间的优劣。从我们最近一个月的使用结果上来看吧,接下来我给大家分析下实际的优劣,仅仅只代表个人看法, 上表格了。

因为VersionCatalog使用的文件格式是toml,所以后续可能会用toml进行简称。

extbuildSrctoml
声明域*.gradle*.java *.kt*.toml
可修改可修改不可修改不可修改
写法花里胡哨静态变量固定写法 xxx.xxx.xxx
校验随便写编译时校验同步时校验

声明域: 指的是我们在哪里声明这些依赖管理。其中ext可以在绝大部分的.gradle中去进行声明,所以就会导致依赖声明的过于零散。而这部分问题就不存在于buildSrc和toml中,他们只能被声明在固定的位置上。

可修改性: 特制声明的依赖能否被修改,ext声明是在内存空间内,而ext的本质其实就是一个Any他可以存放任意的东西,如果出现同名的则会是后面声明的把前面声明的覆盖掉,这就是一个非常不稳定的属性,而buildSrc则是由class来声明的,我们没有办法在gradle中去修改这部分,所以相对来说是稳定的。而toml也类似,基于固定格式反序列化成代码。不具备修改的能力。

写法: ext这方面是真的拉胯,比如支持libs.abc或者libs."abc"或者libs.["abc"]还可以单引号,就非常的随意,而且极为不统一。这也是我们本次改动中碰到问题最多的时候。其他两种写法都相对比较固定,类似java/kt 中的静态常量。

校验: ext就是爱咋写咋写吧,反正也没有很好的校验啥的。而buildSrc则是基于java的代码编译来的,toml因为是一个新的文件格式,所以内置了一套相对比较强的语法校验,如果不合规则会报错,并显示错误行数。

据说buildSrc对于增量编译的适配等其实不太良好,而且我们是一个复杂的巨型复合构建的工程,所以个人并不太推荐buildSrc。

可以参考这篇文章第二章 Stop using Gradle buildSrc. Use composite builds instead

由此可证哦,VersionCatalog雀食是一个非常好的选择,尤其如果你们当前还是在使用的是ext的情况下。

巨型工程最麻烦的事情其实另外一点就是技术栈的切换,因为要改起来的地方可真的就是太多了,首先就是要先解决复合构建的情况下全局只有一份注册的逻辑,其二就是把当前工程的ext全部转移到toml中,然后要最好和之前的方式接近,尽量保证最小改动。最后则是所有工程都改一下!!!!!!!!(要我狗命)

共享配置

GradleSample demo 工程如下,其中plugin-version就是

我们也采取了之前Gradle 奇淫技巧之initscript pluginManagement一样的方式,通过initscript做到复合构建内共享插件的能力。

另外我们把VersionCatalog作为一个extension抛出来在外部完成注册。

catalogs {
  script = new File(rootProjectDir, "depencies.gradle")

  versionCatalogs {
      create("libs") { from(files("${rootProjectDir.path}/toml/dependencies.versions.toml")) }
      create("module") { from(files("${rootProjectDir.path}/toml/module.versions.toml")) }
  }
  dependencyResolutionManagement {
      repositories {
          maven { setUrl("https://maven.aliyun.com/repository/central/") }
          maven {
              setUrl("https://storage.googleapis.com/r8-releases/raw")
          }
          gradlePluginPortal()
          google()
          mavenLocal()
          maven {
              url "https://dl.bintray.com/kotlin/kotlin-eap"
          }
      }
  }

}

通过这部分配置就可以把共享的部分注入进工程内。然后就是很沙雕的改改改了,把所有的ext全部迁移到我们新的toml上去,然后注册出多个。

命令行工具

TheNext 虾开发的撒币cli工具 专门解决虾的撒币问题

以前也说过了我们工程的模块数量巨大,然后又因为ext的写法风骚,所以我们基本所有的写依赖的地方都要改,就是真的工作量巨大。

一个优秀的摸鱼工程师最重要的天赋就是要学会转化生产力,把这种简单又繁琐的工作交给命令行来解决。所以这就有了TheNext的一个新能力,基于当前的文件目录修改所有的.gradle文件,然后把非标准的ext的写法全部进行一次替换。


效果如图所示。

代码逻辑如下,我们首先会遍历整个工程的文件目录,然后发现.gradle后缀的文件,之后通过正则匹配出dependencies,然后进行把一些"" '' []等等都删掉,然后把- _更换成.,这样就能完成简单的自动替换了。

package com.kronos.mebium.android

import com.beust.jcommander.JCommander
import com.kronos.mebium.action.Handler
import com.kronos.mebium.entity.CommandEntity
import com.kronos.mebium.file.getRootProjectDir
import com.kronos.mebium.utils.green
import com.kronos.mebium.utils.red
import com.kronos.mebium.utils.yellow
import java.io.File
import java.util.Scanner

/**
*
* @Author LiABao
* @Since 2022/12/8
*
*/
class DependenciesHandler : Handler {

   val scanner = Scanner(System.`in`)
   var isSkip = false

   override fun handle(args: Array<String>) {
       isSkip = args.contains(skip)
       val realArgs = if (isSkip) {
           arrayListOf<String>().apply {
               args.forEach {
                   if (it != skip) {
                       add(it)
                  }
              }
          }.toTypedArray()
      } else {
           args
      }
       val commandEntity = CommandEntity()
       JCommander.newBuilder().addObject(commandEntity).build().parse(*realArgs)
       val first = commandEntity.file
       val name = commandEntity.name
       val root = first
       val files = root.walkTopDown().filter {
           it.isFile && it.name.contains(".gradle")
      }
       val overrideList = mutableListOf<Pair<File, File>>()
       files.forEach {
           onGradleCheck(it)?.apply {
               overrideList.add(it to this)
          }
      }
       confirm(overrideList)
  }

   private fun confirm(overrideList: MutableList<Pair<File, File>>) {
       if (overrideList.isEmpty()) {
           return
      }
       println("if you want overwrite all this file ? input y to confirm \r\n".red())
       val input = scanner.next()
       if (input == "y") {
           overrideList.forEach {
               it.first.delete()
               it.second.renameTo(it.first)
          }
           print("replace success \r\n ".green())
      } else {
           print("skip\r\n ".yellow())
      }
  }

   private val pattern =
       "(\\D\\S*)(implementation|Implementation|compileOnly|CompileOnly|test|Test|api|Api|kapt|Kapt|Processor)([ (])(\\D\\S*)".toPattern()

   private fun onGradleCheck(file: File): File? {
       var override = false
       val lines = file.readLines()
       val newLines = mutableListOf<String>()
       lines.forEach { line ->
           val matcher = pattern.matcher(line)
           if (matcher.find()) {
               val libs = matcher.group(4)
               if (!libs.contains(":") && !libs.contains("files(")) {
                   val newLibs =
                       libs.replace("\'", "").replace("\"", "").replace("-", ".").replace("_", ".")
                          .replace("kotlin.libs", "kotlinlibs").replace("[", ".").replace("]", "")
                   if (newLibs == libs) {
                       newLines.add(line)
                       return@forEach
                  }
                   print("fileName: ${file.name} dependencies : $line \r\n")
                   if (isSkip) {
                       override = true
                       newLines.add(line.replace(libs, newLibs))
                       print("$libs do you want replace to $newLibs   \r\n ".green())
                       return@forEach
                  }
                   print("$libs do you want replace to $newLibs ? input y to replace \r\n ".red())
                   while (true) {
                       val input = scanner.next()
                       if (input == "y") {
                           print("replace success\r\n".green())
                           override = true
                           newLines.add(line.replace(libs, newLibs))
                           return@forEach
                      } else {
                           print("skip\r\n ".yellow())
                           break
                      }
                  }
              }
          }
           newLines.add(line)
      }
       if (override) {
           val newFile = File(file.parent, file.name.removeSuffix(".gradle") + ".temp")
           newLines.forEach {
               newFile.appendText(it + "\r\n")
          }
           return newFile
      }
       return null
  }
}

const val skip = "--skip"

代码就基本是这样,如果有正则带佬可以帮忙优化下正则的。

然后这个工具也可以多次复用,因为我这个需求没有办法很快的被合入,需要频繁的rebase master的代码,每次rebase完之后都要进行二次修改,真的吐了。

验收

每个新功能开发最后都是要进行验收的,尤其是技改需求,你到时候把功能搞坏了到时候可是要背黑锅的啊。而且这种需求也没有办法要求测试进行特别系统性的测试,所以还是要开发自己想办法了。

我们拉取了apk包的依赖,然后用HashSet进行了拉平,去除重复依赖,然后通过diff对比前后差异,在基本符合预期的情况下我们就可以进行快速的合入。

结尾

其实本文的核心是给大家分析下几种依赖管理方式的优劣,然后对于还在使用gradle ext的大佬,其实可以逐渐考虑进行替换了。

作者:究极逮虾户
来源:juejin.cn/post/7190277951614058555

收起阅读 »

安卓与串口通信-实践篇

前言在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。需要注意的是正如上一篇文章所说的...
继续阅读 »

前言

在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。

这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。

需要注意的是正如上一篇文章所说的,我目前的条件只允许我使用 ESP32 开发版烧录 Arduino 程序与安卓真机(小米10U)进行串口通信演示。

准备工作

由于我们需要使用 ESP32 烧录 Arduino 程序演示安卓端的串口通信,所以在开始之前我们应该先把程序烧录好。

那么烧录一个怎样的程序呢?

很简单,我这里直接烧了一个 ESP32 使用 9600 的波特率进行串口通信,程序内容就是 ESP32 不断的向串口发送数据 “e” ,并且监听串口数据,如果接收到数据 “o” 则打开开发版上自带的 LED 灯,如果接收到数据 “c” 则关闭这个 LED 灯。

代码如下:

#define LED 12

void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
}

void loop() {
if (Serial.available()) {
  char c = Serial.read();
  if (c == 'o') {
    digitalWrite(LED, HIGH);
  }
  if (c == 'c') {
    digitalWrite(LED, LOW);
  }
}

Serial.write('e');

delay(100);
}

上面的 12 号 Pin 是这块开发版的 LED。

使用 Arduino自带串口监视器测试结果:

1.gif

可以看到,确实如我们设想的通过串口不断的发送字符 “e”,并且在接收到字符 “o” 后点亮了 LED。

安卓实现串口通信

原理概述

众所周知,安卓其实是基于 Linux 的操作系统,所以在安卓中对于串口的处理与 Linux 一致。

在 Linux 中串口会被视为一个“设备”,并体现为 /dev/ttys 文件。

/dev/ttys 又被称为字符终端,例如 ttys0 对应的是 DOS/Windows 系统中的 COM1 串口文件。

通常,我们可以简单理解,如果我们插入了某个串口设备,则这个设备与 Linux 的通信会由 /dev/ttys 文件进行 “中转”。

即,如果 Linux 想要发送数据给串口设备,则可以通过往 /dev/ttys 文件中直接写入要发送的数据来实现,如:

echo test > /dev/ttyS1 这个命令会将 “test” 这串字符发送给串口设备。

如果想读取串口发送的数据也是一样的,可以通过读取 /dev/ttys 文件内容实现。

所以,如果我们在安卓中想要实现串口通信,大概率也会想到直接读取/写入这个特殊文件。

android-serialport-api

在上文中我们说到,在安卓中也可以通过与 Linux 一样的方式--直接读写 /dev/ttys 实现串口通信。

但是其实并不需要我们自己去处理读写和数据的解析,因为谷歌官方给出了一个解决方案:android-serialport-api

为了便于理解,我们会大致说一下这个解决方案的源码,但是就不上示例了,至于为什么,同学们往下看就知道了。另外,虽然这个方案历史比较悠久,也很长时间没有人维护了,但是并不意味着不能使用了,只是使用条件比较苛刻,当然,我司目前使用的还是这套方案(哈哈哈哈)。

不过这里我们不直接看 android-serialport-api 的源码,而是通过其他大佬二次封装的库来看: Android-SerialPort-API

在这个库中,通过

// 默认直接初始化,使用8N1(8数据位、无校验位、1停止位),path为串口路径(如 /dev/ttys1),baudrate 为波特率
SerialPort serialPort = new SerialPort(path, baudrate);

// 使用可选参数配置初始化,可配置数据位、校验位、停止位 - 7E2(7数据位、偶校验、2停止位)
SerialPort serialPort = SerialPort
  .newBuilder(path, baudrate)
// 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN)
//   .parity(2)
// 数据位,默认8;可选值为5~8
//   .dataBits(7)
// 停止位,默认1;1:1位停止位;2:2位停止位
//   .stopBits(2)
  .build();

初始化串口,然后通过:

InputStream in = serialPort.getInputStream();
OutputStream out = serialPort.getOutputStream();

获取到输入/输出流,通过读取/写入这两个流来实现与串口设备的数据通信。

我们首先来看看初始化串口是怎么做的。

2.png

首先检查了当前是否具有串口文件的读写权限,如果没有则通过 shell 命令更改权限为 666 ,更改后再次检查是否有权限,如果还是没有就抛出异常。

注意这里的执行 shell 时使用的 runtime 是 Runtime.getRuntime().exec(sSuPath); 也就是说,它是通过 root 权限来执行这段命令的!

换句话说,如果想要通过这种方式实现串口通信,必须要有 ROOT 权限!这就是我说我不会给出示例的原因,因为我手头的设备无法 ROOT 啊。至于为啥我司还能继续使用这种方案的原因也很简单,因为我们工控机的安卓设备都是定制版的啊,拥有 ROOT 权限不是基本操作?

确定权限可用后通过 open 方法拿到一个类型为 FileDescriptor 的变量 mFd ,最后通过这个 mFd 拿到输入输出流。

所以核心在于 open 方法,而 open 方法是一个 native 方法,即 C 代码:

private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity,
   int stopBits, int flags);

C 的源码这里就不放了,只需要知道它做的工作就是打开了 /dev/ttys 文件(准确的说是“终端”),然后通过传递进去的这些参数去按串口规则解析数据,最后返回一个 java 的 FileDescriptor 对象。

在 java 中我们再通过这个 FileDescriptor 对象可以拿到输入/输出流。

原理说起来是十分的简单。

看完通信部分的原理后,我们再来看看我们如何查找可用的串口呢?

其实和 Linux 上也一样:

public Vector<File> getDevices() {
   if (mDevices == null) {
       mDevices = new Vector<File>();
       File dev = new File("/dev");
       
       File[] files = dev.listFiles();

       if (files != null) {
           int i;
           for (i = 0; i < files.length; i++) {
               if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
                   Log.d(TAG, "Found new device: " + files[i]);
                   mDevices.add(files[i]);
              }
          }
      }
  }
   return mDevices;
}

也是通过直接遍历 /dev 下的文件,只不过这里做了一些额外的过滤。

或者也可以通过读取 /proc/tty/drivers 配置文件后过滤:

Vector<Driver> getDrivers() throws IOException {
   if (mDrivers == null) {
       mDrivers = new Vector<Driver>();
       LineNumberReader r = new LineNumberReader(new FileReader("/proc/tty/drivers"));
       String l;
       while ((l = r.readLine()) != null) {
           // Issue 3:
           // Since driver name may contain spaces, we do not extract driver name with split()
           String drivername = l.substring(0, 0x15).trim();
           String[] w = l.split(" +");
           if ((w.length >= 5) && (w[w.length - 1].equals("serial"))) {
               Log.d(TAG, "Found new driver " + drivername + " on " + w[w.length - 4]);
               mDrivers.add(new Driver(drivername, w[w.length - 4]));
          }
      }
       r.close();
  }
   return mDrivers;
}

关于读取可用串口设备,其实从这里的路径也可以看出,都是系统路径,也就是说,如果没有权限,大概率也是读取不到东西的。

这就是使用与 Linux 一样的方式去读取串口数据的基本原理,那么问题来了,既然我说这个方法使用条件比较苛刻,那么更易用的替代方案是什么呢?

我们下面就会介绍,那就是使用安卓的 USB host (USB主机)的功能。

USB host

Android 3.1(API 级别 12)或更高版本的平台直接支持 USB 配件和主机模式。USB 配件模式还作为插件库向后移植到 Android 2.3.4(API 级别 10)中,以支持更广泛的设备。设备制造商可以选择是否在设备的系统映像中添加该插件库。

在安卓 3.1 版本开始,支持将USB作为主机模式(USB host)使用,而我们如果想要通过 USB 读取串口数据则需要依赖于这个主机模式。

在正式开始介绍USB主机模式前,我们先简要介绍一下安卓上支持的USB模式。

安卓上的USB支持三种模式:设备模式、主机模式、配件模式。

设备模式即我们常用的直接将安卓设备连接至电脑上,此时电脑上显示为 USB 外设,即可以当成 “U盘” 使用拷贝数据,不过现在安卓普遍还支持 MTP模式(作为摄像头)、文件传输模式(即当U盘用)、网卡模式等。

主机模式即将我们的安卓设备作为主机,连接其他外设,此时安卓设备就相当于上面设备模式中的电脑。此时安卓设备可以连接键盘、鼠标、U盘以及嵌入式应用USB转串口、转I2C等设备。但是如果想要将安卓设备作为主机模式可能需要一条支持 OTG 的数据线或转接头。(Micro-USB 或 USB type-c 转 USB-A 口)

而在 USB 配件模式下,外部 USB 硬件充当 USB 主机。配件示例可能包括机器人控制器、扩展坞、诊断和音乐设备、自助服务终端、读卡器等等。这样,不具备主机功能的 Android 设备就能够与 USB 硬件互动。Android USB 配件必须设计为与 Android 设备兼容,并且必须遵守 Android 配件通信协议。

设备模式与配件模式的区别在于在配件模式下,除了 adb 之外,主机还可以看到其他 USB 功能。

usb-host-accessory.png

使用USB主机模式与外设交互数据

在介绍完安卓中的三种USB模式后,下面我们开始介绍如何使用USB主机模式。当然,这里只是大概介绍原生APi的使用方法,我们在实际使用中一般都都是直接使用大佬编写的第三方库。

准备工作

在开始正式使用USB主机模式时我们需要先做一些准备工作。

首先我们需要在清单文件(AndroidManifest.xml)中添加:

<!-- 声明需要USB主机模式支持,避免不支持的设备安装了该应用 -->
<uses-feature android:name="android.hardware.usb.host" />

<!-- …… -->

<!-- 声明需要接收USB连接事件 -->
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />

一个完整的清单文件示例如下:

<manifest ...>
   <uses-feature android:name="android.hardware.usb.host" />
   <uses-sdk android:minSdkVersion="12" />
  ...
   <application>
       <activity ...>
          ...
           <intent-filter>
               <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
           </intent-filter>
       </activity>
   </application>
</manifest>

声明好清单文件后,我们就可以查找当前可用的设备信息了:

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val deviceList: HashMap<String, UsbDevice> = manager.deviceList
Log.i(TAG, "scanDevice: $deviceList")
}

将 ESP32 开发版插上手机,运行程序,输出如下:

3.png

可以看到,正确的查找到了我们的 ESP32 开发版。

这里提一下,因为我们的手机只有一个 USB 口,此时已经插上了 ESP32 开发版,所以无法再通过数据线直接连接电脑的 ADB 了,此时我们需要使用无线 ADB,具体怎么使用无线 ADB,请自行搜索。

另外,如果我们想要通过查找到设备后请求连接的方式连接到串口设备的话,还需要额外申请权限。(同理,如果我们直接在清单文件中提前声明需要连接的设备则不需要额外申请权限,具体可以看看参考资料5,这里不再赘述)

首先声明一个广播接收器,用于接收授权结果:

private lateinit var permissionIntent: PendingIntent

private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"

private val usbReceiver = object : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
synchronized(this) {
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)

if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
device?.apply {
// 已授权,可以在这里开始请求连接
connectDevice(context, device)
}
} else {
Log.d(TAG, "permission denied for device $device")
}
}
}
}
}

声明好之后在 Acticity 的 OnCreate 中注册这个广播接收器:

permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE)
val filter = IntentFilter(ACTION_USB_PERMISSION)
registerReceiver(usbReceiver, filter)

最后,在查找到设备后,调用 manager.requestPermission(deviceList.values.first(), permissionIntent) 弹出对话框申请权限。

连接到设备并收发数据

完成上述的准备工作后,我们终于可以连接搜索到的设备并进行数据交互了:

private fun connectDevice(context: Context, device: UsbDevice) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager

CoroutineScope(Dispatchers.IO).launch {
device.getInterface(0).also { intf ->
intf.getEndpoint(0).also { endpoint ->
usbManager.openDevice(device)?.apply {
claimInterface(intf, forceClaim)
while (true) {
val validLength = bulkTransfer(endpoint, bytes, bytes.size, TIMEOUT)
if (validLength > 0) {
val result = bytes.copyOfRange(0, validLength)
Log.i(TAG, "connectDevice: length = $validLength")
Log.i(TAG, "connectDevice: byte = ${result.contentToString()}")
}
else {
Log.i(TAG, "connectDevice: Not recv data!")
}
}
}
}
}
}
}

在上面的代码中,我们使用 usbManager.openDevice 打开了指定的设备,即连接到设备。

然后通过 bulkTransfer 接收数据,它会将接收到的数据写入缓冲数组 bytes 中,并返回成功接收到的数据长度。

运行程序,连接设备,日志打印如下:

4.png

可以看到,输出的数据并不是我们预料中的数据。

这是因为这是非常原始的数据,如果我们想要读取数据,还需要针对不同的串口转USB芯片或协议编写驱动程序才能获取到正确的数据。

顺道一提,如果想要将数据写入串口数据的话可以使用 controlTransfer()

所以,我们在实际生产环境中使用的都是基于此封装好的第三方库。

这里推荐使用 usb-serial-for-android

usb-serial-for-android

使用这个库的第一步当然是导入依赖:

// 添加仓库
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
// 添加依赖
dependencies {
implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'
}

添加完依赖同样需要在清单文件中添加相应字段以及处理权限,因为和上述使用原生API一致,所以这里不再赘述。

和原生 API 不同的是,因为我们此时已经知道了我们的 ESP32 主板的设备信息,以及使用的驱动(CDC),所以我们就不使用原生的查找可用设备的方法了,我们这里直接指定我们已知的这个设备(当然,你也可以继续使用原生API的查找和连接方法):

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
// 添加我们的设备信息,三个参数分别为 vendroId、productId、驱动程序
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
// 查找指定的设备是否存在
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
val driver = drivers[0]
// 这个设备存在,连接到这个设备
val connection = manager.openDevice(driver.device)
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}

连接到设备后,下一步就是和数据交互,这里封装的十分方便,只需要获取到 UsbSerialPort 后,直接调用它的 read()write() 即可读写数据:

port = driver.ports[0] // 大多数设备都只有一个 port,所以大多数情况下直接取第一个就行

port.open(connection)
// 设置连接参数,波特率9600,以及 “8N1”
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

// 读取数据
val responseBuffer = ByteArray(1024)
port.read(responseBuffer, 0)

// 写入数据
val sendData = byteArrayOf(0x6F)
port.write(sendData, 0)

此时,一个完整的,用于测试我们上述 ESP32 程序的代码如下:

@Composable
fun SerialScreen() {
val context = LocalContext.current


Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { scanDevice(context) }) {
Text(text = "查找并连接设备")
}

Button(onClick = { switchLight(true) }) {
Text(text = "开灯")
}
Button(onClick = { switchLight(false) }) {
Text(text = "关灯")
}

}
}

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
val driver = drivers[0]

val connection = manager.openDevice(driver.device)
if (connection == null) {
Log.i(TAG, "scanDevice: 连接失败")
return
}

port = driver.ports[0]

port.open(connection)
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

Log.i(TAG, "scanDevice: Connect success!")

CoroutineScope(Dispatchers.IO).launch {
while (true) {
val responseBuffer = ByteArray(1024)

val len = port.read(responseBuffer, 0)

Log.i(TAG, "scanDevice: recv data = ${responseBuffer.copyOfRange(0, len).contentToString()}")
}
}
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}

private fun switchLight(isON: Boolean) {
val sendData = if (isON) byteArrayOf(0x6F) else byteArrayOf(0x63)

port.write(sendData, 0)
}

运行这个程序,并且连接设备,输出如下:

5.png

可以看到输出的是 byte 的 101,转换为 ASCII 即为 “e”。

然后我们点击 “开灯”、“关灯” 效果如下:

6.gif

对了,这里发送的数据 “0x6F” 即 ASCII “o” 的十六进制,同理,“0x63” 即 “c”。

可以看到,可以完美的和我们的 ESP32 开发版进行通信。

实例

无论使用什么方式与串口通信,我们在安卓APP的代码层面能够拿到的数据已经是处理好了的数据。

即,在上一篇文章中我们说过串口通信的一帧数据包括起始位、数据位、校验位、停止位。但是我们在安卓中使用时一般拿到的都只有 数据位 的数据,其他数据已经在底层被解析好了,无需我们去关心怎么解析,或者使用。

我们可以直接拿到的就是可用数据。

这里举一个我之前用过的某型号驱动版的例子。

这块驱动版关于通信的信息如图:

7.png

可以看到,它采用了 RS485 的通信方式,波特率支持 9600 或 38400,8位数据位,无校验,1位停止位。

并且,它还规定了一个数据协议。

在它定义的协议中,第一位为地址;第二位为指令;第三位到第N位为数据内容;最后两位为CRC校验。

需要注意的是,这里定义的协议是基于串口通信的,不要把这个协议和串口通信搞混了,简单来说就是在串口通信协议的数据位中又定义了一个自己的协议。

而且可以看到,虽然定义串口参数时没有指定校验,但是在它自己的协议中指定了使用 CRC 校验。

另外,弱弱的吐槽一句,这个驱动版的协议真的不好使。

在实际使用过程中,主机与驱动版的通信数据无法保证一定会在同一个数据帧中发送完成,所以可能会造成“粘包”、“分包”现象,也就是说,数据可能会分几次发过来,而且你不好判断这数据是上次没发送完的数据还是新的数据。

我使用过的另外一款驱动版就方便的多,因为它会在帧头加上开始符号和数据长度,帧尾加上结束符号。

这样一来,即使出现“粘包”、“分包”我们也能很好的给它解析出来。

当然,它这样设计协议肯定是有它的道理的,无非就是减少通信代价之类的。

我还遇到过一款十分简洁的驱动版,直接发送一个整数过去表示执行对应的指令。

驱动版回传的数据同样非常简单,就是一个数字,然后事先约定各个数字表示什么意思……

说归说,我们还是继续来看这款驱动版的通信协议:

8.png

这是它的其中一个指令内容,我们发送指令 “1” 过去后,它会返回当前驱动版的型号和版本信息给我们。

因为我们的主板是定制工控主板,所以使用的通信方式是直接用 android-serialport-api。

最终发送与接收回复也很简单:

/**
* 将十六进制字符串转成 ByteArray
* */
private fun hexStrToBytes(hexString: String): ByteArray {
   check(hexString.length % 2 == 0) { return ByteArray(0) }

   return hexString.chunked(2)
      .map { it.toInt(16).toByte() }
      .toByteArray()
}

private fun isReceivedLegalData(receiveBuffer: ByteArray): Boolean {

   val rcvData = receiveBuffer.copyOf()  //重新拷贝一个使用,避免原数据被清零

   if (cmd.cmdId.checkDataFormat(rcvData)) {  //检查回复数据格式
       isPkgLost = false
       if (cmd.cmdId.isResponseBelong(rcvData)) {  //检查回复命令来源
           if (!AdhShareData.instance.getIsUsingCrc()) {  //如果不开启CRC检验则直接返回 true
               resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
              }
               return true
          }

           if (cmd.cmdId.checkCrc(rcvData)) {  //检验CRC
                resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
              }

               return true
          }
           else {
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseCrcError, ByteArray(0), -1, -1, cmd.cmdId)
              }

               return false
          }
      }
       else {
           coroutineScope.launch(Dispatchers.Main) {
               cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseNotFromThisCmd, ByteArray(0), -1, -1, cmd.cmdId)
          }

           return false
      }
  }
   else {  //数据不符合,可能是遇到了分包,继续等待下一个数据,然后合并
       isPkgLost = true
       return isReceivedLegalData(cmd)
       /*coroutineScope.launch(Dispatchers.Main) {
           cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseWrongFormat, ByteArray(0), -1, -1, cmd.cmdId)
       }

       return false */
  }
}

// ……省略初始化和连接代码

// 发送数据
val bytes = hexStrToBytes("0201C110")
outputStream.write(bytes, 0, bytes.size)

// 解析数据
val recvBuffer = ByteArray(0)
inputStream.read(recvBuffer)

while (receiveBuffer.isEmpty()) {
  delay(10)
}

isReceivedLegalData()

本来打算直接发我封装好的这个驱动版的协议库的,想了想,好像不太合适,所以就大概抽出了这些不完整的代码,懂这个意思就行了,哈哈。

总结

从上面介绍的两种方式可以看出,两种方式使用各有优缺点。

使用 android-serialport-api 可以直接读取串口数据内容,不需要转USB接口,不需要驱动支持,但是需要 ROOT,适合于定制安卓主板上已经预留了 RS232 或 RS485 接口且设备已 ROOT 的情况下使用。

而使用 USB host ,可以直接读取USB接口转接的串口数据,不需要ROOT,但是只支持有驱动的串口转USB芯片,且只支持使用USB接口,不支持直接连接串口设备。

各位可以根据自己的实际情况灵活选择使用什么方式来实现串口通信。

当然,除了现在介绍的这些串口通信,其实还有一个通信协议在实际使用中用的非常多,那就是 MODBUS 协议。

下一篇文章,我们将介绍 MODBUS。

参考资料

  1. android-serialport-api

  2. What is tty?

  3. Text-Terminal-HOWTO

  4. Terminal Special Files

  5. USB host

  6. Android开启OTG功能/USB Host API功能

作者:equationl
来源:https://juejin.cn/post/7171347086032502792

收起阅读 »

使用 koin 作为 Android 注入工具,真香

koin 为 Android 提供了简单易用的 API 接口,让你简单轻松地接入 koin 框架。[koin 在 Android 中的 gradle 配置]mp.weixin.qq.com/s/bscC7mO4O…1.Application 类中 startK...
继续阅读 »

koin 为 Android 提供了简单易用的 API 接口,让你简单轻松地接入 koin 框架。


[koin 在 Android 中的 gradle 配置]

mp.weixin.qq.com/s/bscC7mO4O…

1.Application 类中 startKoin

从您的类中,您可以使用该函数并注入 Android 上下文,如下所示:

Application startKoin androidContext
class MainApplication : Application() {

   override fun onCreate() {
       super.onCreate()

       startKoin {
           // Log Koin into Android logger
           androidLogger()
           // Reference Android context
           androidContext(this@MainApplication)
           // Load modules
           modules(myAppModules)
      }

  }
}

如果您需要从另一个 Android 类启动 Koin,您可以使用该函数为您的 Android 实例提供如下:startKoin Context

startKoin {
   //inject Android context
   androidContext(/* your android context */)
   // ...
}

2. 额外配置

从您的 Koin 配置(在块代码中),您还可以配置 Koin 的多个部分。startKoin { }

2.1 Koin Logging for Android

koin 提供了 log 的 Android 实现。

startKoin {
   // use Android logger - Level.INFO by default
   androidLogger()
   // ...
}

2.2 加载属性

您可以在文件中使用 Koin 属性来存储键/值:assets/koin.properties

startKoin {
   // ...
   // use properties from assets/koin.properties
   androidFileProperties()

}

3. Android 中注入对象实例

3.1 为 Android 类做准备

koin 提供了KoinComponents 扩展,Android 组件都具有这种扩展,这些组件包括 Activity Fragment Service ComponentCallbacks

您可以通过如下方式访问 Kotlin 扩展:

by inject()- 来自 Koin 容器的延迟计算实例

get() - 从 Koin 容器中获取实例

我们可以将一个属性声明为惰性注入:

module {
   // definition of Presenter
   factory { Presenter() }
}
class DetailActivity : AppCompatActivity() {

   // Lazy inject Presenter
   override val presenter : Presenter by inject()

   override fun onCreate(savedInstanceState: Bundle?) {
       //...
  }
}

或者我们可以直接得到一个实例:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   // Retrieve a Presenter instance
   val presenter : Presenter = get()
}

注意:如果你的类没有扩展,只需添加 KoinComponent 接口,如果你需要或来自另一个类的实例。inject() get()

3.2 Android Context 使用

class MainApplication : Application() {

   override fun onCreate() {
       super.onCreate()

       startKoin {
           //inject Android context
           androidContext(this@MainApplication)
           // ...
      }

  }
}

在你的定义中,下面的函数允许你在 Koin 模块中获取实例,以帮助你简单地编写需要实例的表达式。androidContext() androidApplication() Context Application

val appModule = module {

  // create a Presenter instance with injection of R.string.mystring resources from Android
  factory {
      MyPresenter(androidContext().resources.getString(R.string.mystring))
  }
}

4. 用于 Android 的 DSL 构造函数

4.1 DSL 构造函数

Koin 现在提供了一种新的 DSL 关键字,允许您直接面向类构造函数,并避免在 lambda 表达式中键入您的定义。

对于 Android,这意味着以下新的构造函数 DSL 关键字:

viewModelOf()`- 相当于`viewModel { }
fragmentOf()`- 相当于`fragment { }
workerOf()`- 相当于`worker { }

注意:请务必在类名之前使用,以定位类构造函数::

4.2 Android DSL 函数示例

给定一个具有以下组件的 Android 应用程序:

// A simple service
class SimpleServiceImpl() : SimpleService

// a Presenter, using SimpleService and can receive "id" injected param
class FactoryPresenter(val id: String, val service: SimpleService)

// a ViewModel that can receive "id" injected param, use SimpleService and get SavedStateHandle
class SimpleViewModel(val id: String, val service: SimpleService, val handle: SavedStateHandle) : ViewModel()

// a scoped Session, that can received link to the MyActivity (from scope)
class Session(val activity: MyActivity)

// a Worker, using SimpleService and getting Context & WorkerParameters
class SimpleWorker(
private val simpleService: SimpleService,
appContext: Context,
private val params: WorkerParameters
) : CoroutineWorker(appContext, params)

我们可以这样声明它们:

module {
singleOf(::SimpleServiceImpl){ bind<SimpleService>() }

factoryOf(::FactoryPresenter)

viewModelOf(::SimpleViewModel)

scope<MyActivity>(){
scopedOf(::Session)
}

workerOf(::SimpleWorker)
}

5. Android 中的 koin 多模块使用

通过使用 Koin,您可以描述模块中的定义。在本节中,我们将了解如何声明,组织和链接模块。

5.1 koin 多模块

组件不必位于同一模块中。模块是帮助您组织定义的逻辑空间,并且可以依赖于其他定义 模块。定义是惰性的,然后仅在组件请求它时才解析。

让我们举个例子,链接的组件位于单独的模块中:

// ComponentB <- ComponentA
class ComponentA()
class ComponentB(val componentA : ComponentA)

val moduleA = module {
// Singleton ComponentA
single { ComponentA() }
}

val moduleB = module {
// Singleton ComponentB with linked instance ComponentA
single { ComponentB(get()) }
}

我们只需要在启动 Koin 容器时声明已使用模块的列表:

class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
// ...

// Load modules
modules(moduleA, moduleB)
}

}
}

5.2 模块包含

类中提供了一个新函数,它允许您通过以有组织和结构化的方式包含其他模块来组合模块includes() Module

新模块有 2 个突出特点:

将大型模块拆分为更小、更集中的模块。

在模块化项目中,它允许您更精细地控制模块可见性(请参阅下面的示例)。

它是如何工作的?让我们采用一些模块,我们将模块包含在:parentModule

// `:feature` module
val childModule1 = module {
/* Other definitions here. */
}
val childModule2 = module {
/* Other definitions here. */
}
val parentModule = module {
includes(childModule1, childModule2)
}

// `:app` module
startKoin { modules(parentModule) }

请注意,我们不需要显式设置所有模块:通过包含,声明的所有模块将自动加载。

parentModule includes childModule1 childModule2 parentModule childModule1 childModule2

信息:模块加载现在经过优化,可以展平所有模块图,并避免重复的模块定义。

最后,您可以包含多个嵌套或重复的模块,Koin 将扁平化所有包含的模块,删除重复项:

// :feature module
val dataModule = module {
/* Other definitions here. */
}
val domainModule = module {
/* Other definitions here. */
}
val featureModule1 = module {
includes(domainModule, dataModule)
}
val featureModule2 = module {
includes(domainModule, dataModule)
}
// :app module
class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
// ...

// Load modules
modules(featureModule1, featureModule2)
}

}
}

请注意,所有模块将只包含一次:dataModule domainModule featureModule1 featureModule2

5.3 Android ViewModel 和 Navigation

Gradle 模块引入了一个新的 DSL 关键字,该关键字作为补充,以帮助声明 ViewModel 组件并将其绑定到 Android 组件生命周期。关键字也可用允许您使用其构造函数声明 ViewModel。koin-android viewModel singlefactory viewModelOf

val appModule = module {

// ViewModel for Detail View
viewModel { DetailViewModel(get(), get()) }

// or directly with constructor
viewModelOf(::DetailViewModel)
}

声明的组件必须至少扩展类。您可以指定如何注入类的构造函数 并使用该函数注入依赖项。android.arch.lifecycle.ViewModel get()

注意:关键字有助于声明 ViewModel 的工厂实例。此实例将由内部 ViewModelFactory 处理,并在需要时重新附加 ViewModel 实例。它还将允许注入参数。viewModel viewModelOf

5.4 注入 ViewModel

在 Android 组件中使用 viewModel ,Activity Fragment Service

by viewModel()- 惰性委托属性,用于将视图模型注入到属性中

getViewModel()- 直接获取视图模型实例

class DetailActivity : AppCompatActivity() {

// Lazy inject ViewModel
val detailViewModel: DetailViewModel by viewModel()
}

5.5 Activity 共享 ViewModel

一个 ViewModel 实例可以在 Fragment 及其主 Activity 之间共享。

要在使用中注入共享视图模型,请执行以下操作:Fragment

by activityViewModel()- 惰性委托属性,用于将共享 viewModel 实例注入到属性中

get ActivityViewModel()- 直接获取共享 viewModel 实例

只需声明一次视图模型:

val weatherAppModule = module {

// WeatherViewModel declaration for Weather View components
viewModel { WeatherViewModel(get(), get()) }
}

注意:viewModel 的限定符将作为 viewModel 的标记处理

并在 Activity 和 Fragment 中重复使用它:

class WeatherActivity : AppCompatActivity() {

/*
* Declare WeatherViewModel with Koin and allow constructor dependency injection
*/
private val weatherViewModel by viewModel<WeatherViewModel>()
}

class WeatherHeaderFragment : Fragment() {

/*
* Declare shared WeatherViewModel with WeatherActivity
*/
private val weatherViewModel by activityViewModel<WeatherViewModel>()
}

class WeatherListFragment : Fragment() {

/*
* Declare shared WeatherViewModel with WeatherActivity
*/
private val weatherViewModel by activityViewModel<WeatherViewModel>()
}

5.6 将参数传递给构造函数

向 viewModel 传入参数,示例代码如下:

模块中

val appModule = module {

// ViewModel for Detail View with id as parameter injection
viewModel { parameters -> DetailViewModel(id = parameters.get(), get(), get()) }
// ViewModel for Detail View with id as parameter injection, resolved from graph
viewModel { DetailViewModel(get(), get(), get()) }
// or Constructor DSL
viewModelOf(::DetailViewModel)
}

依赖注入点传入参数

class DetailActivity : AppCompatActivity() {

val id : String // id of the view

// Lazy inject ViewModel with id parameter
val detailViewModel: DetailViewModel by viewModel{ parametersOf(id)}
}

5.7 SavedStateHandle 注入

添加键入到构造函数的新属性以处理 ViewModel 状态:SavedStateHandle

class MyStateVM(val handle: SavedStateHandle, val myService : MyService) : ViewModel() 在 Koin 模块中,只需使用或参数解析它:get()

viewModel { MyStateVM(get(), get()) } 或使用构造函数 DSL:

viewModelOf(::MyStateVM) 在 Activity Fragment

by viewModel()- 惰性委托属性,用于将状态视图模型实例注入属性

getViewModel()- 直接获取状态视图模型实例

class DetailActivity : AppCompatActivity() {

// MyStateVM viewModel injected with SavedStateHandle
val myStateVM: MyStateVM by viewModel()
}

5.8 Navigation 导航图中的 viewModel

您可以将 ViewModel 实例的范围限定为导航图。只需要传入 ID 给by koinNavGraphViewModel()

class NavFragment : Fragment() {

val mainViewModel: NavViewModel by koinNavGraphViewModel(R.id.my_graph)

}

5.9 viewModel 通用 API

Koin 提供了一些“底层”API 来直接调整您的 ViewModel 实例。viewModelForClass ComponentActivity Fragment

ComponentActivity.viewModelForClass(
clazz: KClass<T>,
qualifier: Qualifier? = null,
owner: ViewModelStoreOwner = this,
state: BundleDefinition? = null,
key: String? = null,
parameters: ParametersDefinition? = null,
): Lazy<T>

还提供了顶级函数:

fun <T : ViewModel> getLazyViewModelForClass(
clazz: KClass<T>,
owner: ViewModelStoreOwner,
scope: Scope = GlobalContext.get().scopeRegistry.rootScope,
qualifier: Qualifier? = null,
state: BundleDefinition? = null,
key: String? = null,
parameters: ParametersDefinition? = null,
): Lazy<T>

5.10 ViewModel API - Java Compat

必须将 Java 兼容性添加到依赖项中:

// Java Compatibility
implementation "io.insert-koin:koin-android-compat:$koin_version"
您可以使用以下函数或静态函数将 ViewModel 实例注入到 Java 代码库中:viewModel() getViewModel() ViewModelCompat

@JvmOverloads
@JvmStatic
@MainThread
fun <T : ViewModel> getViewModel(
owner: ViewModelStoreOwner,
clazz: Class<T>,
qualifier: Qualifier? = null,
parameters: ParametersDefinition? = null
)

6. 在 Jetpack Compose 中注入

请先了解 Jetpack Compose 相关内容:

developer.android.com/jetpack/com…

6.1 注入@Composable

在编写可组合函数时,您可以访问以下 Koin API:

get()- 从 Koin 容器中获取实例

getKoin()- 获取当前 Koin 实例

对于声明“MyService”组件的模块:

val androidModule = module {

single { MyService() }
}

我们可以像这样获取您的实例:

@Composable
fun App() {
val myService = get<MyService>()
}

注意:为了在 Jetpack Compose 的功能方面保持一致,最好的编写方法是将实例直接注入到函数属性中。这种方式允许使用 Koin 进行默认实现,但保持开放状态以根据需要注入实例。

@Composable
fun App(myService: MyService = get()) {
}

6.2 viewModel @Composable

与访问经典单/工厂实例的方式相同,您可以访问以下 Koin ViewModel API:

getViewModel()`或 - 获取实例`koinViewModel()

对于声明“MyViewModel”组件的模块:

module {
viewModel { MyViewModel() }
// or constructor DSL
viewModelOf(::MyViewModel)
}

我们可以像这样获取您的实例:

@Composable
fun App() {
val vm = koinViewModel<MyViewModel>()
}

我们可以在函数参数中获取您的实例:

@Composable
fun App(vm : MyViewModel = koinViewModel()) {

}

7. 管理 Android 作用域

Android 组件,如Activity、Fragment、Service都有生命周期,这些组件都是由 System 实例化,组件中有相应的生命周期回调。

正因为 Android 组件具有生命周期属性,所以不能在 koin 中传入组件实例。按照生命周期长短,组件可分为三类:

  • • 长周期组件(Service、database)——由多个屏幕使用,永不丢弃

  • • 中等周期组件(User session)——由多个屏幕使用,必须在一段时间后删除

  • • 短周期组件(ViewModel) ——仅由一个 Screen 使用,必须在 Screen 末尾删除

对于长周期组件,我们通常在应用全局使用 single 创建单实例

在 MVP 架构模式下,Presenter 是短周期组件

在 Activity 中创建方式如下

class DetailActivity : AppCompatActivity() {

// injected Presenter
override val presenter : Presenter by inject()

我们也可以在 module 中创建

我们使用 factory 作用域创建 Presenter 实例

val androidModule = module {

// Factory instance of Presenter
factory { Presenter() }
}

生成绑定到作用域的实例 scope

val androidModule = module {

scope<DetailActivity> {
scoped { Presenter() }
}
}

大多数 Android 内存泄漏来自从非 Android 组件引用 UI/Android 组件。系统保留引用在它上面,不能通过垃圾收集完全回收它。

7.1 申明 Android 作用域

要限定 Android 组件上的依赖关系,您必须使用如下所示的块声明一个作用域:scope

class MyPresenter()
class MyAdapter(val presenter : MyPresenter)

module {
// Declare scope for MyActivity
scope<MyActivity> {
// get MyPresenter instance from current scope
scoped { MyAdapter(get()) }
scoped { MyPresenter() }
}
}

7.2 Android Scope 类

Koin 提供了 Android 生命周期组件相关的 Scope 类ScopeActivity Retained ScopeActivity ScopeFragment

class MyActivity : ScopeActivity() {

// MyPresenter is resolved from MyActivity's scope
val presenter : MyPresenter by inject()
}

Android Scope 需要与接口一起使用来实现这样的字段:AndroidScopeComponent scope

abstract class ScopeActivity(
@LayoutRes contentLayoutId: Int = 0,
) : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityScope()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

checkNotNull(scope)
}
}

我们需要使用接口并实现属性。这将设置类使用的默认 Scope。AndroidScopeComponent scope

7.3 Android Scope 接口

要创建绑定到 Android 组件的 Koin 作用域,只需使用以下函数:

createActivityScope()- 为当前 Activity 创建 Scope(必须声明 Scope 部分)

createActivityRetainedScope()- 为当前 Activity 创建 RetainedScope(由 ViewModel Lifecycle 支持)(必须声明 Scope 部分)

createFragmentScope()- 为当前 Fragment 创建 Scope 并链接到父 Activity Scope 这些函数可作为委托使用,以实现不同类型的作用域:

activityScope()- 为当前 Activity 创建 Scope(必须声明 Scope 部分)

activityRetainedScope()- 为当前 Activity 创建 RetainedScope(由 ViewModel Lifecycle 支持)(必须声明 Scope 部分)

fragmentScope()- 为当前 Fragment 创建 Scope 并链接到父 Activity Scope

class MyActivity() : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityScope()

}

我们还可以使用以下内容设置保留范围(由 ViewModel 生命周期提供支持):

class MyActivity() : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityRetainedScope()
}

如果您不想使用 Android Scope 类,则可以使用自己的类并使用 Scope 创建 API AndroidScopeComponent

7.4 Scope 链接

Scope 链接允许在具有自定义作用域的组件之间共享实例。在更广泛的用法中,您可以跨组件使用实例。例如,如果我们需要共享一个实例。Scope UserSession

首先声明一个范围定义:

module {
// Shared user session data
scope(named("session")) {
scoped { UserSession() }
}
}

当需要开始使用实例时,请为其创建范围:UserSession

val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

然后在您需要的任何地方使用它:

class MyActivity1 : ScopeActivity() {

fun reuseSession(){
val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

// will look at MyActivity1's Scope + ourSession scope to resolve
val userSession = get<UserSession>()
}
}
class MyActivity2 : ScopeActivity() {

fun reuseSession(){
val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

// will look at MyActivity2's Scope + ourSession scope to resolve
val userSession = get<UserSession>()
}
}

8.Fragment Factory

由于 AndroidX 已经发布了软件包系列以扩展 Android 的功能 androidx.fragment Fragment

developer.android.com/jetpack/and…

8.1 Fragment Factory

自版本以来,已经引入了 ,一个专门用于创建类实例的类:2.1.0-alpha-3 FragmentFactory Fragment

developer.android.com/reference/k…

Koin 也提供了创建 Fragment 的工厂类 KoinFragmentFactory Fragment

8.2 设置 Fragment Factory

首先,在 KoinApplication 声明中,使用关键字设置默认实例:fragmentFactory() KoinFragmentFactory

 startKoin {
// setup a KoinFragmentFactory instance
fragmentFactory()

modules(...)
}

8.3 声明并注入 Fragment

声明一个 Fragment 并在 module 中注入

class MyFragment(val myService: MyService) : Fragment() {


}
val appModule = module {
single { MyService() }
fragment { MyFragment(get()) }
}

8.4 获取 Fragment

使用setupKoinFragmentFactory() 设置 FragmentFactory

查询您的 Fragment ,使用supportFragmentManager

supportFragmentManager.beginTransaction()
.replace<MyFragment>(R.id.mvvm_frame)
.commit()

加入可选参数

supportFragmentManager.beginTransaction()
.replace<MyFragment>(
containerViewId = R.id.mvvm_frame,
args = MyBundle(),
tag = MyString()
)

8.5 Fragment Factory & Koin Scopes

如果你想使用 Koin Activity Scope,你必须在你的 Scope 声明你的 Fragment 作为一个定义:scoped

val appModule = module {
scope<MyActivity> {
fragment { MyFragment(get()) }
}
}

并使用您的 Scope 设置您的 Koin Fragment Factory:setupKoinFragmentFactory(lifecycleScope)

class MyActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
// Koin Fragment Factory
setupKoinFragmentFactory(lifecycleScope)

super.onCreate(savedInstanceState)
//...
}
}

9. WorkManager 的 Koin 注入

koin 为 WorkManager 提供单独的组件包 koin-androidx-workmanager

首先,在 KoinApplication 声明中,使用关键字来设置自定义 WorkManager 实例:workManagerFactory()

class MainApplication : Application(), KoinComponent {

override fun onCreate() {
super.onCreate()
startKoin {
// setup a WorkManager instance
workManagerFactory()
modules(...)
}
setupWorkManagerFactory()
}

AndroidManifest.xml 修改,避免使用默认的

    <application . . .>
. . .
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
tools:node="remove" />
</application>

9.1 声明 ListenableWorker

val appModule = module {
single { MyService() }
worker { MyListenableWorker(get()) }
}

9.2 创建额外的 WorkManagerFactory

class MainApplication : Application(), KoinComponent {

override fun onCreate() {
super.onCreate()

startKoin {
workManagerFactory(workFactory1, workFactory2)
. . .
}

setupWorkManagerFactory()
}

}

如果 Koin 和 workFactory1 提供的 WorkManagerFactory 都可以实例化 ListenableWorker,则 Koin 提供的工厂将是使用的工厂。

9.3 更改 koin lib 本身的清单

如果 koin-androidx-workmanager 中的默认 Factory 被禁用,而应用程序开发人员不初始化 koin 的工作管理器基础架构,他最终将没有可用的工作管理器工厂。

针对上面的情况,我们做如下 DSL 改进:

val workerFactoryModule = module {
factory<WorkFactory> { WorkFactory1() }
factory<WorkFactory> { WorkFactory2() }
}

然后让 koin 内部做类似的事情

fun Application.setupWorkManagerFactory(
// no vararg for WorkerFactory
) {
. . .
getKoin().getAll<WorkerFactory>()
.forEach {
delegatingWorkerFactory.addFactory(it)
}
}

参考链接

insert-koin.io/

作者:Calvin873
来源:juejin.cn/post/7189917106580750395

收起阅读 »

终于理解~Android 模块化里的资源冲突

本文翻译自 Understanding resource conflicts in Android,原作者:Adam Campbell⚽ 前言作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包...
继续阅读 »

本文翻译自 Understanding resource conflicts in Android,原作者:Adam Campbell

⚽ 前言

作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包下面。根据官方的《Configure your build》文档介绍的构建过程可以总结这个过程:

  1. 编译器会将源码文件转换成包含了二进制字节码、能运行在 Android 设备上的 DEX 文件,而其他文件则被转换成编译后资源。

  2. APK 打包工具则会将 DEX 文件和编译后资源组合成独立的 APK 文件。

但如果资源的命名发生了碰撞、冲突,会对编译产生什么影响?

事实证明这个影响是不确定的,尤其是涉及到构建外部 Library。

本文将探究一些不同的资源冲突案例,并逐个说明怎样才能安全地命名资源

🇦🇷 App module 内资源冲突

先来看个最简单的资源冲突的案例:同一个资源文件中出现两个命名、类型一样的资源定义,比如:

 <!--strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
    <string name="hello_world">Hello World!</string>
</resources>

试图去编译的话,会导致显而易见的错误提示:

 FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> /.../strings.xml: Error: Found item String/hello_world more than one time

类似的,另一种常见冲突是在多个文件里定义冲突的资源:

 <!--strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
</resources>

<!--other_strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
</resources>

我们会收到类似的编译错误,而这次的错误将列出所有发生冲突的具体文件位置。

 FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> [string/hello_world] /.../other_strings.xml
  [string/hello_world] /.../strings.xml: Error: Duplicate resources

Android 平台上资源的运作方式变得愈加清晰。我们需要为 App module 指定在类型、名称、设备配置等限定组合下的唯一资源。也就是说,当 App module 引用 string/hello_world 资源的时候,有且仅有一个值被解析出来。开发者们必须解决发生的资源冲突,可以选择删除那些内容重复的资源、重命名仍然需要的资源、亦或移动到其他限定条件下的资源文件。

更多关于资源和限定的信息可以参考官方的《App resources overview》 文档。

🇩🇪 Library 和 App module 的资源冲突

下面这个案例,我们将研究 Library module 定义了一个和 App module 重复的资源而引发的冲突。

 <!--app/../strings.xml-->
<resources>
    <string name="hello">Hello from the App!</string>
</resources>

<!--library/../strings.xml-->
<resources>
    <string name="hello">Hello from the Library!</string>
</resources>

当你编译上面的代码的时候,发现竟然通过了。从我们上个章节的发现来看,我们可以推测 Android 肯定采用了一个规则,去确保在这种场景下仍能够找到一个独有的 string/hello 资源值。

根据官方的《Create an Android library》文档:

编译工具会将来自 Library module 的资源和独立的 App module 资源进行合并。如果双方均具备一个资源 ID 的话,将采用 App 的资源。

这样的话,将会对模块化的 App 开发造成什么影响?比如我们在 Library 中定义了这么一个 TextView 布局:

 <!--library/../text_view.xml-->
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello"
    xmlns:android="http://schemas.android.com/apk/res/android" />

AS 中该布局的预览是这样的。


现在我们决定将这个 TextView 导入到 App module 的布局中:

 <!--app/../activity_main.xml-->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    tools:context=".MainActivity"
    >

    <include layout="@layout/text_view" />

</LinearLayout>

无论是 AS 中预览还是实际运行,我们可以看到下面的一个显示结果:


不仅是通过布局访问 string/hello 的 App module 会拿到 “Hello from the App!”,Library 本身拿到的也是如此。基于这个原因,我们需要警惕不要无意覆盖 Lbrary 中的资源定义。

🇧🇷 Library 之间的资源冲突

再一个案例,我们将讨论下当多个 Library 里定义了冲突的资源,会发生什么。

首先来看下如下的布局,如果这样写的话会产生什么结果?

 <!--library1/../strings.xml-->
<resources>
    <string name="hello">Hello from Library 1!</string>
</resources>

<!--library2/../strings.xml-->
<resources>
    <string name="hello">Hello from Library 2!</string>
</resources>

<!--app/../activity_main.xml-->
<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello" />

string/hello 将会被显示成什么?

事实上这取决于 App build.gradle 文件里依赖这些 Library 的顺序。再次到官方的《Create an Android library》文档里找答案:

如果多个 AAR 库之间发生了冲突,依赖列表里第一个列出(在依赖关系块的顶部)的资源将会被使用。

假使 App module 有这样的依赖列表:

 dependencies {
implementation project(":library1")
implementation project(":library2")
...
}

最后 string/hello 的值将会被编译成 Hello from Library 1!

那么如果这两个 implementation 代码调换顺序,比如 implementation project(":library2") 在前、 implementation project(":library1") 在后,资源值则会被编译成 Hello from Library 2!

从这种微妙的变化可以非常直观地看到,依赖顺序可以轻易地改变 App 的资源展示结果。

🇪🇸 自定义 Attributes 的资源冲突

目前为止讨论的示例都是针对 string 资源的使用,然而需要特别留意的是自定义 attributes 这种有趣的资源类型。

看下如下的 attr 定义:

 <!--app/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable">
<attr name="freeText" format="string"/>
</declare-styleable>

<declare-styleable name="CustomStyleable2">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

大家可能都认为上面的写法能通过编译、不会报错,而事实上这种写法必将导致下面的编译错误:

 Execution failed for task ':app:mergeDebugResources'.
> /.../attrs.xml: Error: Found item Attr/freeText more than one time

但如果 2 个 Library 也采用了这样的自定义 attr 写法:

 <!--library1/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

<!--library2/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable2">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

事实上它却能够通过编译。

然而,如果我们进一步将 Library2 的 attr 做些调整,比如改为 <attr name="freeText" format="boolean"/>。再次编译,它竟然又失败了,而且出现了更多令人费解的错误:

 * What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
> Android resource compilation failed
/.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: duplicate value for resource 'attr/freeText' with config ''.
/.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: resource previously defined here.
/.../app/build/intermediates/incremental/mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile.

上面错误的一个重点是: mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile

到底是怎么回事呢?

事实上 values.xml 的编译指的是为 App module 生成 R 类。编译期间,AAPT 会尝试在 R 类里为每个资源属性生成独一无二的值。而对于 styleable 类型里的每个自定义 attr,都会在 R 类里生成 2 个的属性值。

第一个是 styleable 命名空间属性值(位于 R.styleable 包下),第二个是全局的 attr 属性值(位于 R.attr 包下)。对于这个探讨的特殊案例,我们则遇到了全局属性值的冲突,并且由于此冲突造成存在 3 个属性值:

  • R.styleable.CustomStyleable_freeText:来自 Library1,用于解析 string 格式的、名称为 freeText 的 attr

  • R.styleable.CustomStyleable2_freeText:来自 Library2,用于解析 boolean 格式的、名称为 freeText 的 attr

  • R.attr.freeText:无法被成功解析,源自我们给它赋予了来自 2 个 Library 的数值,而它们的格式不同,造成了冲突

前面能通过编译的示例是因为 Library 间同名的 R.attr.freeText 格式也相同,最终为 App module 编译到的是独一无二的数值。需要注意:每个 module 具备自己的 R 类,我们不能总是指望属性的数值在 Library 间保持一致。

再次看下官方的《Create an Android library》文档的建议:

当你构建依赖其他 Library 的 App module 时,Library module 们将会被编译成 AAR 文件再添加到 App module 中。所以,每个 Library 都会具备自己的 R 类,用 Library 的包名进行命名。所有包都会创建从 App module 和 Library module 生成的 R 类,包括 App module 的包和 Library moudle 的包。

📝 结语

所以我们能从上面的这些探讨得到什么启发?

是资源编译过程的复杂和微妙吗?

确实是的。但是作为开发者,我们能为自己和团队做的是:解释清楚定义的资源想要做什么,也就是说可以加上名称前缀。我们最喜欢的官方文档《Create an Android library》也提到了这宝贵的一点:

通用的资源 ID 应当避免发生资源冲突,可以考虑使用前缀或其他一致的、对 module 来说独一无二的命名方案(抑或是整个项目都是独一无二的命名)。

根据这个建议,比较好的做法是在我们的项目和团队中建立一个模式:在 module 中的所有资源前加上它的 module 名称,例如library_help_text

这将带来两个好处:

  1. 大大降低了名称冲突的概率。

  2. 明确资源覆盖的意图。

    比如也在 App module 中创建 library_help_text 的话,则表明开发者是有意地覆盖 Library module 中的某些定义。有的时候我们的确会想去覆盖一些其他资源,而这样的编码方式可以明确地告诉自己和团队,在编译的时候会发生预期的覆盖。

抛开内部开发不谈,至少是所有公开的资源都应该加上前缀,尤其是作为一个供应商或者开源项目去发布我们的 library。

可以往的经验来看,Google 自己的 library 也没有对所有的资源进行恰当地前缀命名。这将导致意外的副作用:依赖我们发行的 library 可能会因为命名冲突引发 App 编译失败。

Not a great look!

例如,我们可以看到 Material Design library 会给它们的颜色资源统一地添加 mtrl 的前缀。可是 styleable 下嵌套的 attribute resources 却没有使用 material 之类的前缀。

所以你会看到:假使一个 module 依赖了 Material library,同时依赖的另一个 library 中包含了与 Material library 一样名称的 attribute,那么在为这个 moudle 生成 R 类的时候,会发生冲突的可能。

🙏 鸣谢

本篇文章受到了下面文章或文档的启发和帮助:

📚 原文

作者:TechMerger
来源:juejin.cn/post/7170562275374268447

收起阅读 »

Android电量优化,让你的手机续航更持久

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情...
继续阅读 »

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情发生,我们就要想想办法让我们的应用不那么耗电,电都用在该用的时候和地方。

通过power_profile.xml查看各个手机硬件的耗电量

Google要求手机硬件生产商都要放入power_profile.xml文件到ROM里面。有些不太负责的手机生产商,就乱配,也没有真正测试过。但我们还是可以大概知道耗电的硬件都有哪些。

先从ibotpeaches.github.io/Apktool/ 下载apktool反编译工具,然后执行adb命令,将手机framework的资源apk拉取出来。

adb pull /system/framework/framework-res.apk ./

然后我们用下载好的反编译工具,将framework-res.apk进行反编译。

java -jar apktool_2.7.0.jar d framework-res.apk

apktool_2.7.0.jar换成你下载的具体的jar包名称。 power_profile.xml文件的目录如下:

framework-res/res/xml/power_profile.xml

<?xml version="1.0" encoding="utf-8"?>
<device name="Android">
   <item name="ambient.on">0.1</item>
   <item name="screen.on">0.1</item>
   <item name="screen.full">0.1</item>
   <item name="bluetooth.active">0.1</item>
   <item name="bluetooth.on">0.1</item>
   <item name="wifi.on">0.1</item>
   <item name="wifi.active">0.1</item>
   <item name="wifi.scan">0.1</item>
   <item name="audio">0.1</item>
   <item name="video">0.1</item>
   <item name="camera.flashlight">0.1</item>
   <item name="camera.avg">0.1</item>
   <item name="gps.on">0.1</item>
   <item name="radio.active">0.1</item>
   <item name="radio.scanning">0.1</item>
   <array name="radio.on">
       <value>0.2</value>
       <value>0.1</value>
   </array>
   <array name="cpu.active">
       <value>0.1</value>
   </array>
   <array name="cpu.clusters.cores">
       <value>1</value>
   </array>
   <array name="cpu.speeds.cluster0">
       <value>400000</value>
   </array>
   <array name="cpu.active.cluster0">
       <value>0.1</value>
   </array>
   <item name="cpu.idle">0.1</item>
   <array name="memory.bandwidths">
       <value>22.7</value>
   </array>
   <item name="battery.capacity">1000</item>
   <item name="wifi.controller.idle">0</item>
   <item name="wifi.controller.rx">0</item>
   <item name="wifi.controller.tx">0</item>
   <array name="wifi.controller.tx_levels" />
   <item name="wifi.controller.voltage">0</item>
   <array name="wifi.batchedscan">
       <value>.0002</value>
       <value>.002</value>
       <value>.02</value>
       <value>.2</value>
       <value>2</value>
   </array>
   <item name="modem.controller.sleep">0</item>
   <item name="modem.controller.idle">0</item>
   <item name="modem.controller.rx">0</item>
   <array name="modem.controller.tx">
       <value>0</value>
       <value>0</value>
       <value>0</value>
       <value>0</value>
       <value>0</value>
   </array>
   <item name="modem.controller.voltage">0</item>
   <array name="gps.signalqualitybased">
       <value>0</value>
       <value>0</value>
   </array>
   <item name="gps.voltage">0</item>
</device>

抓到不负责任的手机生产商一枚,好家伙,这么多0.1,明眼人一看就知道这是为了应付Google。尽管这样,我们还是可以从中知道,耗电的有Screen(屏幕亮屏)、Bluetooth(蓝牙)、Wi-Fi(无线局域网)、Audio(音频播放)、Video(视频播放)、Radio(蜂窝数据网络)、Camera的Flashlight(相机闪光灯)和GPS(全球定位系统)等。

电量杀手简介

Screen

屏幕是非常耗电的一个硬件,不要问我为什么。屏幕主要有LCD和OLED两种。LCD屏幕白色光线从屏幕背后的灯管发出,尽管屏幕显示黑屏,依旧耗电,这种屏幕逐渐被淘汰,如果你翻出个早点的功能机,或许能看到。那么大部分Android手机都是OLED的屏幕,每个像素点都是独立的发光单元,屏幕黑屏时,所有像素都不发光。有必要时,让屏幕息屏很重要,当然手机也有自动息屏的时间设置,这个不太需要我们操心。

Radio数据网络和Wi-Fi无线网络

网络也是非常耗电的,其中又以数据网络的耗电更多于Wi-Fi的耗电。所以请尽量引导用户使用Wi-Fi网络使用app的部分功能,比如下载文件。

GPS

GPS也是很耗电的硬件,所以不要动不动就请求地理位置,GPS平常是要关闭的,除非你在使用定位和导航等功能,这样你的手机续航会更好。

WakeLock

如果使用了WakeLock,是可以有效防止息屏情况下的CPU休眠,但是如果不用了,你不释放掉锁的话,则会带来很大的电量的开销。

查看手机耗电的历史记录

// 上次拔掉电源到现在的耗电情况
adb shell dumpsys batterystats --unplugged

你在逗我?让我看命令行的输出?后面我们来使用Battery Historian的图表进行分析。

使用Battery Historian分析手机耗电量

安装Docker

Docker下载网址 docs.docker.com/desktop/ins…

使用Docker容器编排

docker run -p 9999:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999

获取bugreport文件

Android7.0及以上

adb bugreport bugreport.zip

Android6.0及以下

adb bugreport > bugreport.txt

上传bugreport文件进行分析

在浏览器地址栏输入http://localhost:9999


点击Browse按钮并上传bugreport.zip或bugreport.txt生成分析图表。


我们可以通过时间轴来分析应用当下的电池使用情况,比较耗电的是哪部分硬件。

使用JobScheduler来合理执行后台任务

JobScheduler是Android5.0版本推出的API,允许开发者在符合某些条件时创建执行在后台的任务。比如接通电源的情况下才执行某些耗电量大的操作,也可以把一些不紧急的任务在合适的时候批量处理,还可以避开低电量的情况下执行某些任务。

作者:dora
来源:juejin.cn/post/7196321890301575226

收起阅读 »

安卓开发基础——弱引用的使用

前言起因今天开发遇到一个问题,就是在快速点击带点击事件的控件,如果控件里面写的是Dialog弹窗就有概率出现弹窗连续在界面上出现两次,也就是你关闭弹窗后发现还有一个一样的弹窗在界面,这样就会带来不好的体验。结果2月9日在网上查了许多解决方法,就有提到将该Dia...
继续阅读 »

前言

起因

今天开发遇到一个问题,就是在快速点击带点击事件的控件,如果控件里面写的是Dialog弹窗就有概率出现弹窗连续在界面上出现两次,也就是你关闭弹窗后发现还有一个一样的弹窗在界面,这样就会带来不好的体验。

结果

2月9日

在网上查了许多解决方法,就有提到将该Dialog变成类的成员变量,不用每次都new就可能避免这种情况出现,但我着实不清楚为什么以及具体怎么做,于是请教了组里的大哥,大哥和我说他之前也处理过这种问题,使用了弱引用,可我还是不知道具体的实现方式,于是便找到大哥的代码,并在网上了解了弱引用的具体作用。

2月10日

今天我请教了我们掘金开发群的Java大佬,他告诉我,我这个写法仍然避免不了弹两次Dialog的,并给出意见,可以使用共享状态,推荐我创建一个共享的ReentrantLock,不过我还没去实现,等有时间再看看。

下面就让我们看看弱引用到底是什么。

正篇

弱引用的概念

想知道弱引用,那就得知道几个名词:

  • 强引用

  • 软引用

  • 弱引用

  • 虚引用

首先我们来看看这些词的概念:

  1. 强引用

强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  1. 软引用

软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存流出异常。

  1. 弱引用

弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

  1. 虚引用

虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

以上定义都是参考自知乎回答 :强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么? - 知乎 (zhihu.com),从这我们可以了解到其实我们Java中new对象就是强引用,强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象,也就简而言之对象在引用时,不回收,上面说的文章中也举例说明了强引用的特点:


而我们本篇说的弱引用,则是发现即回收,它通常是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

但是,又因为垃圾回收器的线程通常优先级很低,所以,一般并不一定能很快地发现持有弱引用的对象,而在这种情况下,弱引用对象就可以存在较长的时间。

而如何使用弱引用,我们接着往下看:

使用方法

前言提到我们使用了弱引用在开发中大哥已经使用过,所以我就跟着后面仿写一下就好,而知乎的那篇文章也提到:


这就基本是弱引用的定义方法,因为之前前言说的Dialog问题弱引用并没有真正起效果,所以我们换一种方法去展示他在安卓上的使用,那就是在使用Bitmap时防止OOM,写法如下:

ImageView imageView = findViewById(R.id.vImage);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.drawable.ic_launcher_background);
Drawable drawable = new BitmapDrawable(getResources(), bitmap);
WeakReference<Drawable> weakDrawable = new WeakReference<>(drawable);
Drawable bgDrawable = weakDrawable.get();
if(bgDrawable != null) {
   imageView.setBackground(drawable);
}

我们再对比一下普通的强引用方法:

ImageView imageView = findViewById(R.id.vImage);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.drawable.ic_launcher_background);
Drawable drawable = new BitmapDrawable(getResources(), bitmap);
imageView.setBackground(drawable);

其实,就是对drawable对象从强引用转为弱引用,这样一旦出现内存不足,不会直接去使用drawable对象,让JVM自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。

总结

其实这块内容需要对GC机制很熟悉,我不是很熟,所以使用可能也出现不对,希望读者可以积极指正,谢谢观看!

作者:ObliviateOnline
来源:juejin.cn/post/7198519499867815997

收起阅读 »

Flutter Android多窗口方案落地(下)

接:Flutter Android多窗口方案落地(上)插件层封装。插件层就很简单了,创建好MethodCallHandler之后,直接持有单例的EngineManager就可以了。class FlutterMultiWindowsPlugin : Flutte...
继续阅读 »

接:Flutter Android多窗口方案落地(上)

  1. 插件层封装。插件层就很简单了,创建好MethodCallHandler之后,直接持有单例的EngineManager就可以了。

class FlutterMultiWindowsPlugin : FlutterPlugin, MethodCallHandler {
  companion object {
      private const val TAG = "MultiWindowsPlugin"
  }


   @SuppressLint("LongLogTag")
   override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
       Log.i(TAG, "onMessage: onAttachedToEngine")
       Log.i(TAG, "onAttachedToEngine: ${Thread.currentThread().name}")
       MessageHandle.init(flutterPluginBinding.applicationContext)

       MethodChannel(
           flutterPluginBinding.binaryMessenger,
           "flutter_multi_windows.messageChannel",
       ).setMethodCallHandler(this)
   }

   override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
       Log.i(TAG, "onDetachedFromEngine: ${Thread.currentThread().name}")
   }

   override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
       Log.i(TAG, "onMethodCall: thread : ${Thread.currentThread().name}")
       MessageHandle.onMessage(call, result)
   }
}
@SuppressLint("StaticFieldLeak")
internal object MessageHandle {
  private const val TAG = "MessageHandle"

   private var context: Context? = null
   private var manager: EngineManager? = null

   fun init(context: Context) {
       this.context = context
       if (manager != null)
           return
       // 必须单例调用
       manager = EngineManager.getInstance(this.context!!)
   }

   // 处理消息,所有管道通用。需要共享Flutter Activity
   fun onMessage(
       call: MethodCall, result: MethodChannel.Result
   ) {
       val params = call.arguments as Map<*, *>
       when (call.method) {
           "open" -> {
               Log.i(TAG, "onMessage: open")
               val map: HashMap<String, Any> = HashMap()
               map["needShowWindow"] = true
               map["name"] = params["name"] as String
               map["entryPoint"] = params["entryPoint"] as String
               map["width"] = (params["width"] as Double).toInt()
               map["height"] = (params["height"] as Double).toInt()
               map["gravityX"] = params["gravityX"] as Int
               map["gravityY"] = params["gravityY"] as Int
               map["paddingX"] = params["paddingX"] as Double
               map["paddingY"] = params["paddingY"] as Double
               map["draggable"] = params["draggable"] as Boolean
               map["type"] = params["type"] as String

               if (params["params"] != null) {
                   map["params"] = params["params"] as ArrayList<String>
               }
               result.success(manager?.showWindow(map, object : EngineCallback {
                   override fun onEngineDestroy(id: String) {
                   }
               }))
           }
           "close" -> {
               val windowId = params["windowId"] as String
               manager?.dismissWindow(windowId)
           }
           "executeTask" -> {
               Log.i(TAG, "onMessage: executeTask")
               val map: HashMap<String, Any> = HashMap()
               map["name"] = params["name"] as String
               map["entryPoint"] = params["entryPoint"] as String
               map["type"] = params["type"] as String
               result.success(manager?.executeTask(map))
           }
           "finishTask" -> {
               manager?.finishTask(params["taskId"] as String)
           }
           "setPosition" -> {
               val res = manager?.setPosition(
                   params["windowId"] as String,
                   params["x"] as Int,
                   params["y"] as Int
               )
               result.success(res)
           }
           "setAlpha" -> {
               val res = manager?.setAlpha(
                   params["windowId"] as String,
                   (params["alpha"] as Double).toFloat(),
               )
               result.success(res)
           }
           "resize" -> {
               val res = manager?.resetWindowSize(
                   params["windowId"] as String,
                   params["width"] as Int,
                   params["height"] as Int
               )
               result.success(res)
           }
           else -> {

           }
       }
   }
}

同时需要清楚,Engine通过传入的entryPoint,就可以找到Flutter层中的方法入口点,在入口点中runApp即可。

实现过程中的坑

在实现过程中我们遇到的值得分享的坑,就是Flutter GestureDetector和Window滑动事件的冲突。 由于悬浮窗是需要可滑动的,因此在原生层需要监听对应的事件;而Flutter的事件,是Android层分发给FlutterView的,两者形成冲突,导致Flutter内部滑动的时候,原生层也会捕获到,最终造成冲突。
如何解决?
从需求上来看,悬浮窗是否需要滑动,应该交给调用方决定,也就是由Flutter层来决定是否Android是否要对Flutter的滑动事件进行监听,即flutterView.setOnTouchListener。这里我们使用一种更轻量级的操作,FlutterView的监听默认加上,然后在事件处理中,我们通过变量来做处理;而Flutter通过MethodChannel改变这个变量,加快了通信速度,避免了事件来回监听和销毁。

flutterView.setOnTouchListener { _event ->
   when (event.action) {
       MotionEvent.ACTION_MOVE -> {
           if (dragging) {
               setPosition(
                   initialX + (event.rawX - startX).roundToInt(),
                   initialY + (event.rawY - startY).roundToInt()
              )
          }
      }
       MotionEvent.ACTION_UP -> {
           dragEnd()
      }
       MotionEvent.ACTION_DOWN -> {
           startX = event.rawX
           startY = event.rawY
           initialX = layoutParams.x
           initialY = layoutParams.y
           dragStart()
           windowManager.updateViewLayout(rootViewlayoutParams)
      }
  }
   false
}

dragging则是通过Flutter层去驱动的:FlutterMultiWindowsPlugin().dragStart();

private fun dragStart() {
   dragging = true
}

private fun dragEnd() {
   dragging = false
}

使用方式

目前我们内部已在4个应用落地了这个方案。应用方式有两种:一种是Flutter通过插件调用,也可以直接通过后台Service打开。效果尚佳,目的都是为了让Flutter的UI跨端使用。
另外,Flutter的方法入口点必须声明@pragma('vm:entry-point')

写在最后

目前来看这种方式可以完美支持Flutter在Android上开启多窗口,且能精准控制。但由于一个engine对应一个窗口,过多engine带来的内存隐患还是不可忽视的。我们希望Flutter官方能尽快的支持engine对应多个入口点,并且共享内存,只不过目前来看还是有点天方夜谭~~
这篇文章,需要有一定原生基础的同学才能看懂。只讲基础原理,代码不全,仅供参考! 另外多窗口的需求,不知道大家需求量如何,热度可以的话我再出个windows的多窗口实现!

作者:Karl_wei
来源:juejin.cn/post/7198824926722949179


收起阅读 »

Flutter Android多窗口方案落地(上)

前言Flutter在桌面端的多窗口需求,一直是个历史巨坑。随着Flutter的技术在我们windows、android桌面设备落地,我们发现多窗口需求必不可少,突破这个技术壁垒已经刻不容缓。实现原理1. 基本原理对于Android移动设备来说,多窗口的应用大多...
继续阅读 »

前言

Flutter在桌面端的多窗口需求,一直是个历史巨坑。随着Flutter的技术在我们windows、android桌面设备落地,我们发现多窗口需求必不可少,突破这个技术壁垒已经刻不容缓。

实现原理

1. 基本原理

对于Android移动设备来说,多窗口的应用大多是用于直播/音视频的悬浮弹窗,让用户离开应用后还能在小窗口中观看内容。实现原理是通过WindowManager创建和管理窗口,包括视图内容、拖拽、事件等操作。
我们都清楚Flutter只是一个可以做业务逻辑的UI框架,在Flutter中想要实现多窗口,也必须依赖Android的窗口管理机制。基于原生的Window,显示Flutter绘制的UI,从而实现跨平台的视图交互和业务逻辑。

2. 具体步骤

  • Android端基于Window Manager创建Window,管理窗口的生命周期和拖拽逻辑;

  • 使用FlutterEngineGroup来管理Flutter Engine,通过引擎吸附Flutter的UI,加入到原生的FlutterView;

  • 把FlutterView通过addView的方式加入到Window上。

3. 原理图


插件实现

基于上述原理,可以在Android的窗口显示Flutter的UI。但要真正提供给Flutter层使用,还需要再封装一个插件层。

  1. 通过单例管理多个窗口 由于是多窗口,可能项目中多个地方都会调用到,因此需要使用单例来统一管理所有窗口的生命周期,保证准确创建、及时销毁。

//引擎生命钩子回调,让调用方感知引擎状态
interface EngineCallback {
   fun onCreate(id:String)
   fun onEngineDestroy(idString)
}

class EngineManager private constructor(contextContext) {

   // 单例对象
   companion object :
       SingletonHolder<EngineManagerContext>(::EngineManager)

   // 窗口类型;如果是单一类型,那么同名窗口将返回上一次的未销毁的实例。
   private val TYPE_SINGLEString = "single"

   init {
       Log.d("EngineManager""EngineManager init")
  }

   data class Entry(
       val engineFlutterEngine,
       val windowAndroidWindow?
  )

   private var myContextContext = context

   private var engineGroupFlutterEngineGroup = FlutterEngineGroup(myContext)

   // 每个窗口对应一个引擎,基于引擎ID和名称存储多窗口的信息,以及查找
   private val engineMap = ConcurrentHashMap<StringEntry>() //搜索引擎,用作消息分发
   private val name2IdMap = ConcurrentHashMap<StringString>() //判断是否存在了任务
   private val id2NameMap = ConcurrentHashMap<StringString>() //根据任务获取name并清除
   private val engineCallback =
       ConcurrentHashMap<StringEngineCallback>() //通知调用方引擎状态 0-create 1-attach 2-destroy

   fun showWindow(
       paramsHashMap<StringAny>,
       engineStatusCallbackEngineCallback
  ): String? {
       val entryString?
       if (params.containsKey("entryPoint")) {
           entry = params["entryPoint"as String
      } else {
           return null
      }

       val nameString?
       if (params.containsKey("name")) {
           name = params["name"as String
      } else {
           return null
      }

       val type = params["type"]
       if (type == TYPE_SINGLE && name2IdMap[name!= null) {
           return name2IdMap[name]
      }

       val windowUid = UUID.randomUUID().toString()
       if (type == TYPE_SINGLE) {
           name2IdMap[name= windowUid
           id2NameMap[windowUid= name
           engineCallback[windowUid= engineStatusCallback
      }
       val dartEntrypoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
       val args = mutableListOf(windowUid)

       var userList<String>? = null
       if (params.containsKey("params")) {
           user = params["params"as List<String>
      }

       if (user != null) {
           args.addAll(user)
      }
       // 把调用方传递的参数回调给Flutter
       val option =
           FlutterEngineGroup.Options(myContext).setDartEntrypoint(dartEntrypoint)
              .setDartEntrypointArgs(
                   args
              )
       val engine = engineGroup.createAndRunEngine(option)
       val draggable = params["draggable"as Boolean? ?true
       val width = params["width"as Int? ?0
       val height = params["height"as Int? ?0

       val config = GravityConfig()
       config.paddingX = params["paddingX"as Double? ?0.0
       config.paddingY = params["paddingY"as Double? ?0.0
       config.gravityX = GravityForX.values()[params["gravityX"as Int? ?1]
       config.gravityY = GravityForY.values()[params["gravityY"as Int? ?1]
       // 把创建好的引擎传给AndroidWindow,由其去创建窗口
       val androidWindow =
           AndroidWindow(myContextdraggablewidthheightconfigengine)
       engineMap[windowUid= Entry(engineandroidWindow)
       androidWindow.open()
       engine.platformViewsController.attach(
           myContext,
           engine.renderer,
           engine.dartExecutor
      )
       return windowUid
  }

   fun setPosition(idString?xIntyInt): Boolean {
       id ?return false
       val entry = engineMap[id]
       entry ?return false
       entry.window?.setPosition(xy)
       return true
  }
   
   fun setSize(idString?widthdoubleheightdouble): Boolean {
       // ......
  }
}

通过代码我们可以看到,每个窗口都对应一个engine,通过name和生成的UUID做唯一标识,然后把engine传给AndroidWindow,在那里加入WindowManger,以及Flutter UI的获取。

  1. AndroidWindow的实现;通过context.getSystemService(Service.WINDOW_SERVICE) as WindowManager获取窗口管理器;同时创建FlutterView和LayoutInfalter,通过engine拿到视图吸附到FlutterView,把FlutterView加到Layout中,最后把Layout通过addView加到WindowManager中显示。

class AndroidWindow(
   private val contextContext,
   private val draggableBoolean,
   private val widthInt,
   private val heightInt,
   private val configGravityConfig,
   private val engineFlutterEngine
) {
   private var startX = 0f
   private var startY = 0f
   private var initialX = 0
   private var initialY = 0
   private var dragging = false
   private lateinit var flutterViewFlutterView
   private var windowManager = context.getSystemService(Service.WINDOW_SERVICEas WindowManager
   private val inflater =
       context.getSystemService(Service.LAYOUT_INFLATER_SERVICEas LayoutInflater
   private val metrics = DisplayMetrics()

   @SuppressLint("InflateParams")
   private var rootView = inflater.inflate(R.layout.floatingnullfalseas ViewGroup
   private val layoutParams = WindowManager.LayoutParams(
       dip2px(contextwidth.toFloat()),
       dip2px(contextheight.toFloat()),
       WindowManager.LayoutParams.TYPE_SYSTEM_ALERT// 系统应用才可使用此类型
       WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
       PixelFormat.TRANSLUCENT
  )

   fun open() {
       @Suppress("Deprecation")
       windowManager.defaultDisplay.getMetrics(metrics)
       layoutParams.gravity = Gravity.START or Gravity.TOP
       selectMeasurementMode()

       // 设置位置
       val screenWidth = metrics.widthPixels
       val screenHeight = metrics.heightPixels
       when (config.gravityX) {
           GravityForX.Left -> layoutParams.x = config.paddingX!!.toInt()
           GravityForX.Center -> layoutParams.x =
              ((screenWidth - layoutParams.width/ 2 + config.paddingX!!).toInt()
           GravityForX.Right -> layoutParams.x =
              (screenWidth - layoutParams.width - config.paddingX!!).toInt()
           null -> {}
      }

       when (config.gravityY) {
           GravityForY.Top -> layoutParams.y = config.paddingY!!.toInt()
           GravityForY.Center -> layoutParams.y =
              ((screenHeight - layoutParams.height/ 2 + config.paddingY!!).toInt()
           GravityForY.Bottom -> layoutParams.y =
              (screenHeight - layoutParams.height - config.paddingY!!).toInt()
           null -> {}
      }

       windowManager.addView(rootViewlayoutParams)
       flutterView = FlutterView(inflater.contextFlutterSurfaceView(inflater.contexttrue))
       flutterView.attachToFlutterEngine(engine)
       if (draggable) {
           @Suppress("ClickableViewAccessibility")
           flutterView.setOnTouchListener { _event ->
               when (event.action) {
                   MotionEvent.ACTION_MOVE -> {
                       if (dragging) {
                           setPosition(
                               initialX + (event.rawX - startX).roundToInt(),
                               initialY + (event.rawY - startY).roundToInt()
                          )
                      }
                  }
                   MotionEvent.ACTION_UP -> {
                       dragEnd()
                  }
                   MotionEvent.ACTION_DOWN -> {
                       startX = event.rawX
                       startY = event.rawY
                       initialX = layoutParams.x
                       initialY = layoutParams.y
                       dragStart()
                       windowManager.updateViewLayout(rootViewlayoutParams)
                  }
              }
               false
          }
      }
       @Suppress("ClickableViewAccessibility")
       rootView.setOnTouchListener { _event ->
           when (event.action) {
               MotionEvent.ACTION_DOWN -> {
                   layoutParams.flags =
                       layoutParams.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                   windowManager.updateViewLayout(rootViewlayoutParams)
                   true
              }
               else -> false
          }
      }

       engine.lifecycleChannel.appIsResumed()

       rootView.findViewById<FrameLayout>(R.id.floating_window)
          .addView(
               flutterView,
               ViewGroup.LayoutParams(
                   ViewGroup.LayoutParams.MATCH_PARENT,
                   ViewGroup.LayoutParams.MATCH_PARENT
              )
          )
       windowManager.updateViewLayout(rootViewlayoutParams)
  }
   // .....

续:Flutter Android多窗口方案落地(下)

作者:Karl_wei
来源:juejin.cn/post/7198824926722949179

收起阅读 »

AndroidQQ登录接入详细介绍

一、前言由于之前自己项目的账号系统不是非常完善,所以考虑接入QQ这个强大的第三方平台的接入,目前项目暂时使用QQ登录的接口进行前期的测试,这次从搭建到完善花了整整两天时间,不得不吐槽一下QQ互联的官方文档,从界面就可以看出了,好几年没维修了,示例代码也写的不是...
继续阅读 »

一、前言

由于之前自己项目的账号系统不是非常完善,所以考虑接入QQ这个强大的第三方平台的接入,目前项目暂时使用QQ登录的接口进行前期的测试,这次从搭建到完善花了整整两天时间,不得不吐槽一下QQ互联的官方文档,从界面就可以看出了,好几年没维修了,示例代码也写的不是很清楚,翻了好多源代码和官方的demo,这个demo可以作为辅助参考,官方文档的api失效了可以从里面找相应的替代,但它的代码也太多了,一个demo 一万行代码,心累,当时把demo弄到可以运行就花了不少时间,很多api好像是失效了,笔者自己做了一些处理和完善,几乎把sdk功能列表的登录相关的api都尝试了一下,真的相当的坑,正文即将开始,希望这篇文章能够给后来者一些参考和帮助。

二、环境配置

1.获取应用ID

这个比较简单,直接到QQ互联官网申请一个即可,官网地址

https://connect.qq.com

申请应用的时候需要注意应用名字不能出现违规词汇,否则可能申请不通过

应用信息的填写需要当前应用的包名和签名,这个腾讯这边提供了一个获取包名和签名的app供我们开发者使用,下载地址

https://pub.idqqimg.com/pc/misc/files/20180928/c982037b921543bb937c1cea6e88894f.apk

未通过审核只能使用调试的QQ号进行登录,通过就可以面向全部用户了,以下为审核通过的图片


2.官网下载相关的sdk

下载地址

https://tangram-1251316161.file.myqcloud.com/qqconnect/OpenSDK_V3.5.10/opensdk_3510_lite_2022-01-11.zip

推荐直接下载最新版本的,不过着实没看懂最新版本的更新公告,说是修复了retrofit冲突的问题,然后当时新建的项目没有用,结果报错,最后还是加上了,才可以


3. jar的引入

将jar放入lib包下,然后在app 同级的 build.gradle添加以下代码即完成jar的引用

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

4.配置Manifest

在AndroidManifest.xml中的application结点下增加以下的activity和启动QQ应用的声明,这两个activity无需我们在另外创建文件,引入的jar已经处理好了

 <application
      ...    
       <!--这里的权限为开启网络访问权限和获取网络状态的权限,必须开启,不然无法登录-->
       <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
       <activity
           android:name="com.tencent.tauth.AuthActivity"
           android:exported="true"
           android:launchMode="singleTask"
           android:noHistory="true">
           <intent-filter>
               <action android:name="android.intent.action.VIEW" />

               <category android:name="android.intent.category.DEFAULT" />
               <category android:name="android.intent.category.BROWSABLE" />

               <data android:scheme="tencent你的appId" />
           </intent-filter>
       </activity>
       <activity
           android:name="com.tencent.connect.common.AssistActivity"
           android:configChanges="orientation|keyboardHidden"
           android:screenOrientation="behind"
           android:theme="@android:style/Theme.Translucent.NoTitleBar" />

       <provider
           android:name="androidx.core.content.FileProvider"
           android:authorities="com.tencent.login.fileprovider"
           android:exported="false"
           android:grantUriPermissions="true">
           <meta-data
               android:name="android.support.FILE_PROVIDER_PATHS"
               android:resource="@xml/file_paths" />
       </provider>
...
   </application>

上面的哪个代码的最后提供了一个provider用于访问 QQ 应用的,需要另外创建一个 xml 文件,其中的 authorities 是自定义的名字,确保唯一即可,这边最下面那个provider是翻demo找的,文档没有写,在res文件夹中新增一个包xml,里面添加文件名为file_paths的 xml ,其内容如下

<?xml version="1.0" encoding="utf-8"?>
<paths>
   <external-files-path name="opensdk_external" path="Images/tmp"/>
   <root-path name="opensdk_root" path=""/>
</paths>

三、初始化配置

1.初始化SDK

加入以下代码在创建登录的那个activtiy下,不然无法拉起QQ应用的登录界面,至于官方文档所说的需要用户选择是否授权设备的信息的说明,这里通用的做法是在应用内部声明一个第三方sdk的列表,然后在里面说明SDK用到的相关设备信息的权限

Tencent.setIsPermissionGranted(true, Build.MODEL)

2.创建实例

这部分建议放在全局配置,这样可以实现登录异常强制退出等功能

/**
* 其中APP_ID是申请到的ID
* context为全局context
* Authorities为之前provider里面配置的值
*/
val mTencent = Tencent.createInstance(APP_ID, context, Authorities)

3.开启登录

在开启登录之前需要自己创建一个 UIListener 用来监听回调结果(文档没讲怎么创建的,找了好久的demo)这里的代码为基础的代码,比较容易实现,目前还没写回调相关的代码,主要是为了快速展示效果

open class BaseUiListener(private val mTencent: Tencent) : DefaultUiListener() {
private val kv = MMKV.defaultMMKV()
override fun onComplete(response: Any?) {
if (response == null) {
"返回为空,登录失败".showToast()
return
}
val jsonResponse = response as JSONObject
if (jsonResponse.length() == 0) {
"返回为空,登录失败".showToast()
return
}
"登录成功".showToast()
doComplete(response)
}

private fun doComplete(values: JSONObject?) {

}
override fun onError(e: UiError) {
Log.e("fund", "onError: ${e.errorDetail}")
}

override fun onCancel() {
"取消登录".showToast()
}
}

建立一个按钮用于监听,这里进行登录操作

button.setOnClickListener {

if (!mTencent.isSessionValid) {
//判断会话是否有效
when (mTencent.login(this, "all",iu)) {

//下面为login可能返回的值的情况
0 -> "正常登录".showToast()
1 -> "开始登录".showToast()
-1 -> "异常".showToast()
2 -> "使用H5登陆或显示下载页面".showToast()
else -> "出错".showToast()
}
}
}

这边对mTencent.login(this, "all",iu)中login的参数做一下解释说明

mTencent.login(this, "all",iu)
//这里Tencent的实例mTencent的login函数的三个参数
//1.为当前的context,
//2.权限,可选项,一般选择all即可,即全部的权限,不过目前好像也只有一个开放的权限了
//3.为UIlistener的实例对象

还差最后一步,获取回调的结果的代码,activity的回调,这边显示方法已经废弃了,本来想改造一下的,后面发现要改造的话需要动sdk里面的源码,有点麻烦就没有改了,等更新

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
//腾讯QQ回调,这里的iu仍然是相关的UIlistener
Tencent.onActivityResultData(requestCode, resultCode, data,iu)
if (requestCode == Constants.REQUEST_API) {
if (resultCode == Constants.REQUEST_LOGIN) {
Tencent.handleResultData(data, iu)
}
}
}

至此,已经可以正常登录了,但还有一件我们开发者最关心的事情没有做,获取的用户的数据在哪呢?可以获取QQ号吗?下面将为大家解答这方面的疑惑。

四、接入流程以及相关代码

首先回答一下上面提出的问题,可以获得两段比较关键的json数据,一个是 login 的时候获取的,主要是token相关的数据,还有一段就是用户的个人信息的 json 数据,这些都在 UIListener 中进行处理和获取。第二个问题能不能获取QQ号,答案是不能,我们只能获取与一个与QQ号一样具有唯一标志的id即open_id,显然这是出于用户的隐私安全考虑的,接下来简述一下具体的登录流程

1.登录之前检查是否有token缓存

  • 有,直接启动主activity

  • 无,进入登录界面

判断是否具有登录数据的缓存

//这里采用微信的MMKV进行储存键值数据
MMKV.initialize(this)
val kv = MMKV.defaultMMKV()
kv.decodeString("qq_login")?.let{
val gson = Gson()
val qqLogin = gson.fromJson(it, QQLogin::class.java)
QQLoginTestApplication.mTencent.setAccessToken(qqLogin.access_token,qqLogin.expires_in.toString())
QQLoginTestApplication.mTencent.openId = qqLogin.openid
}

检查token和open_id是否有效和token是否过期,这里采取不同于官方的推荐的用法,主要是api失效了或者是自己没用对方法,总之官方提供的api进行缓存还不如MMKV键值存login json来的实在,也很方便,这里建议多多使用日志,方便排查错误

//这里对于uiListener进行了重写,object的作用有点像java里面的匿名类
//用到了checkLogin的方法
mTencent.checkLogin(object : DefaultUiListener() {
override fun onComplete(response: Any) {
val jsonResp = response as JSONObject

if (jsonResp.optInt("ret", -1) == 0) {
val jsonObject: String? = kv.decodeString("qq_login")
if (jsonObject == null) {
"登录失败".showToast()

} else {
//启动主activity

}
} else {
"登录已过期,请重新登录".showToast()
//启动登录activity

}
}

override fun onError(e: UiError) {
"登录已过期,请重新登录".showToast()
//启动登录activity

}

override fun onCancel() {
"取消登录".showToast()
}
})

2.进入登录界面

在判断session有效的情况下,进入登录界面,对login登录可能出现的返回码做一下解释说明

Login.setOnClickListener {
if (!QQLoginTestApplication.mTencent.isSessionValid) {
when (QQLoginTestApplication.mTencent.login(this, "all",iu)) {
0 -> "正常登录".showToast()
1 -> "开始登录".showToast()
-1 -> {
"异常".showToast()
QQLoginTestApplication.mTencent.logout(QQLoginTestApplication.context)
}
2 -> "使用H5登陆或显示下载页面".showToast()
else -> "出错".showToast()
}
}
}
  • 1:正常登录

    这个就无需做处理了,直接在回调那里做相关的登录处理即可

  • 0:开始登录

    同正常登录

  • -1:异常登录

    这个需要做一点处理,当时第一次遇到这个情况就是主activity异常消耗退回登录的activity,此时在此点击登录界面的按钮导致了异常情况的出现,不过这个处理起来还是比较容易的,执行强制下线操作即可

    "异常".showToast()
    mTencent.logout(QQLoginTestApplication.context)
  • 2:使用H5登陆或显示下载页面

    通常情况下是未安装QQ等软件导致的,这种情况无需处理,SDK自动封装好了,这种情况会自动跳转QQ下载界面

同样的有出现UIListener就需要调用回调进行数据的传输

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
//腾讯QQ回调
Tencent.onActivityResultData(requestCode, resultCode, data,iu)
if (requestCode == Constants.REQUEST_API) {
if (resultCode == Constants.REQUEST_LOGIN) {
Tencent.handleResultData(data, iu)
}
}
}

3.进入主activity

这里需要放置一个按钮执行下线操作,方便调试,同时这里需要将之前的token移除重新获取token等数据的缓存

button.setOnClickListener {
mTencent.logout(this)
val kv = MMKV.defaultMMKV()
kv.remove("qq_login")
//返回登录界面的相关操作
"退出登录成功".showToast()
}

至此,其实还有一个很重要的东西没有说明,那就是token数据的缓存和个人信息数据的获取,这部分我写的登录的那个UIlistener里面了,登录成功的同时,获取login的response的json数据和个人信息的json数据

4.获取两段重要的json数据

  • login 的json数据

    这个比较容易,当我们登录成功的时候,oncomplete里面的response即我们想要的数据

    override fun onComplete(response: Any?) {
    if (response == null) {
    "返回为空,登录失败".showToast()
    return
    }
    val jsonResponse = response as JSONObject
    if (jsonResponse.length() == 0) {
    "返回为空,登录失败".showToast()
    return
    }
    //这个即利用MMKV进行缓存json数据
    kv.encode("qq_login",response.toString())
    "登录成功".showToast()
    }
  • 个人信息的数据

    这个需要在login有效的前提下才能返回正常的数据

    //首先需要用上一步获取的json数据对mTencent进行赋值,这部分放在doComplete方法中执行
    private fun doComplete(values: JSONObject?) {
    //利用Gson进行格式化成对象
    val gson = Gson()
    val qqLogin = gson.fromJson(values.toString(), QQLogin::class.java)
    mTencent.setAccessToken(qqLogin.access_token, qqLogin.expires_in.toString())
    mTencent.openId = qqLogin.openid
    Log.e("fund",values.toString())
    }

    创建一个get_info方法进行获取,注意这里需要对mTencent设置相关的属性才能获取正常获取数据

    private fun getQQInfo(){
       val qqToken = mTencent.qqToken
       //这里的UserInfo是sdk自带的类,传入上下文和token即可
       val info = UserInfo(context,qqToken)
       info.getUserInfo(object :BaseUiListener(mTencent){
           override fun onComplete(response: Any?){
               //这里对数据进行缓存
               kv.encode("qq_info",response.toString())          
          }
      })
    }

5.踩坑系列

这里主要吐槽一下关于腾讯的自带的session缓存机制,当时是抱着不用自己实现缓存直接用现成的机制去看的,很遗憾这波偷懒失败,这部分session的设置不知道具体的缓存机制,只知道大概是用share preference实现的,里面有saveSession,initSession,loadSession这三个方法,看上去很容易的样子,然后抱着这种心态去尝试了一波,果然不出意外空指针异常,尝试修改了一波回调的顺序仍然空指针异常,折腾了大概三个多小时,放弃了,心态给搞崩了,最终释然了,为什么要用腾讯提供的方法,这个缓存自己实现也是相当的容易,这时想到了MMKV,两行代码完成读取,最后只修改了少数的代码完成了登录的token的缓存机制,翻看demo里面的实现,里面好像是用这三种方法进行实现的,可能是某个实现机制没有弄明白,其实也不想明白,自己的思路比再去看demo容易多了,只是多了一个json的转对象的过程,其他的没有差别。所以建议后来者直接自己实现缓存,不用管sdk提供的那些方法,真的有点难用。

五、总结

总之这次完成QQ接入踩了许多的坑,不过幸好最终还是实现了,希望腾讯互联这个sdk能够上传github让更多的人参与和提供反馈,不然这个文档说是最差sdk体验也不为过。下面附上这次实现QQ登录的demo的github地址以及相关的demo apk供大家进行参考,大概总共就400行代码左右比官方的demo好很多,有问题欢迎留言

https://github.com/xyh-fu/QQLoginTest.git

作者:wresource
来源:juejin.cn/post/7072878774261383176

收起阅读 »

告诉你为什么视频广告点不了关闭

前言我们平时玩游戏多多少少会碰到一些视频广告,看完后是能领取游戏奖励的,然后你会发现有时候看完点击那个关闭按钮,结果是跳下载,你理所当然的认为是点击到了外边,事实真的是这样的吗?有些东西不好那啥,你们懂的,所以以下内容纯属我个人猜测,纯属虚构1. 整个广告流程...
继续阅读 »

前言

我们平时玩游戏多多少少会碰到一些视频广告,看完后是能领取游戏奖励的,然后你会发现有时候看完点击那个关闭按钮,结果是跳下载,你理所当然的认为是点击到了外边,事实真的是这样的吗?有些东西不好那啥,你们懂的,所以以下内容纯属我个人猜测,纯属虚构

1. 整个广告流程的各个角色

要想对广告这东西有个大概的了解,你得先知道你看广告的过程中都有哪些角色参与了进来。

简单来说,是有三方参与了进来:
(1)广告提供商:顾名思义负责提供广告,比如你看的广告是一款游戏的广告,那这个游戏的公司就是广告的提供商。
(2)当前应用:就是播放这个广告的应用。
(3)平台:播放广告这个操作就是平台负责的,它负责连接上面两方,从广告提供商中拿到广告,然后让当前应用接入。

平台有很多,比如字节、腾讯都有相对应的广告平台,或者一些小公司自己做广告平台。他们之间的py交易是这样的:所有广告的功能是由平台去开发,然后他会提供一套sdk或者什么的让应用接入,应用你接入之后每播放1次广告,平台就给你多少钱,但是播放的是什么广告,这个就是平台自己去下发。然后广告提供商就找到平台,和他谈商业合作,你帮我展示我家的产品的广告多少次,我给你多少钱。 简单来说他们之间的交易就是这样。

简单来说,就是广告提供商想要影响力,其它两方要钱,他们都希望广告能更多的展示。

2. 广告提供商的操作

广告提供商是花钱让平台推广广告的,那我肯定是希望尽量每次广告的展示都有用户去点击然后下载我们家的应用。

所以广告提供商想出了一个很坏的办法,相信大家都遇到过,就是我播放视频,我在视频的最后几帧里面的图片的右上角放一个关闭图片,误导用户这个关闭图片是点了之后能关闭的,其实它是视频的一部分,所以你点了就相当于点了视频,那就触发跳转下载应用这些操作。

破解的方法也很简单,你等到计算结束后的几秒再点关闭按钮,不要一看到关闭按钮的图片出来马上点。

3. 应用的操作

应用是很难在广告播放的时候去做手脚,因为这部分的代码不是他们写的,他们只是去调用平台写的代码。

那他们想让广告尽可能多的展示,唯一能做的就是把展示广告的地方增加,尽可能多的让更多场景能展示广告。当然这也有副作用,你要是这个应用点哪里都是广告,这不得把用户给搞吐了,砸了自己的口碑,如果只有一些地方有,用户还是能理解的,毕竟赚钱嘛,不寒参。

4. 平台的操作

平台的操作那就丰富了,代码是我写的,兄弟,我想怎么玩就怎么玩,我能有一百种方法算计你。

猜测的,注意,是猜测的[狗头]

有的人说,故意把关闭按钮设置小,让我们误触关闭按钮以外的区域。我只能说,你让我来做,我都不屑于把关闭按钮设置小。

我们都知道平时开发时,我们觉得点击按钮不灵,所以我们想扩大图标的点击区域,但是又不想改变图标的大小,所以我们用padding来实现。同样的,我也能做到不改变图标的大小,然后缩小点击的范围

我写一个自定义view(假设就是关闭图标)

public class TestV extends View {

   public TestV(Context context) {
       super(context);
  }

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

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

   @Override
   public boolean dispatchTouchEvent(MotionEvent event) {
       if (event.getAction() == MotionEvent.ACTION_DOWN) {
           int w = getMeasuredWidth();
           int h = getMeasuredHeight();
           Log.d("mmp", "============ view点击");
           if (event.getX() < w / 4 || event.getX() > 3 * w / 4 || event.getY() < h / 4 || event.getY() > 3 * h / 4) {
               return super.dispatchTouchEvent(event);
          } else {
               Log.d("mmp", "============ view点击触发-》关闭");
               return true;
          }
      }
       return super.dispatchTouchEvent(event);
  }
}

代码很简单就不过多讲解,能看出我很简单就实现让点击范围缩小1/4。所以当你点到边缘的时候,其实就相当于点到了广告。

除了缩小范围之外,我还能设置2秒前后点击是不同的效果,你有没有一种感觉,第一次点关闭按钮就是跳到下载应用,然后返回再点击就是关闭,所以你觉得是你第一次点击的时候是误触了外边。

public class TestV extends View {

   private boolean canClose = true;

   public TestV(Context context) {
       super(context);
  }

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

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

   @Override
   public void setVisibility(int visibility) {
       super.setVisibility(visibility);
       if (visibility == View.VISIBLE) {
           canClose = false;
      }
  }

   @Override
   public boolean dispatchTouchEvent(MotionEvent event) {
       if (event.getAction() == MotionEvent.ACTION_DOWN) {
           int w = getMeasuredWidth();
           int h = getMeasuredHeight();
           Log.d("mmp", "============ view点击");
           if (!canClose) {
               return super.dispatchTouchEvent(event);
          } else {
               Log.d("mmp", "============ view点击触发-》关闭");
               return true;
          }
      }
       return super.dispatchTouchEvent(event);
  }

   // 播放完成
   public void playFinish() {
       setVisibility(VISIBLE);
       Handler handler = new Handler(Looper.getMainLooper());
       handler.postDelayed(new Runnable() {
           @Override
           public void run() {
               canClose = true;
          }
      }, 2000);
  }

}

播放完成之后调用playFinish方法,然后会把canClose这个状态设置为false,2秒后再设为true。这样你在2秒前点击按钮,其实就是点击外部的效果,也就会跳去下载。

而且你注意,这些限制,我可以不写死在代码里面,可以用后台返回,比如这个2000,我可以后台返回。我就能做到比如第一天我返回0,你觉得没什么问题,能正常点关闭,我判断你是第二天,我直接返2000给你,然后你一想,之前都是正常的,那这次不正常,肯定是我点错。

你以为的意外只不过是我想让你以为是意外罢了。那这个如何去破解呢?我只能说无解,他能有100种方法让你点中跳出去下载,那还能有是什么解法?

作者:流浪汉kylin
来源:juejin.cn/post/7197611189244592186

收起阅读 »

Android App Bundle

1. Android App Bundle 是什么?从 2021 年 8 月起,新应用需要使用 Android App Bundle 才能在 Google Play 中发布。Android App Bundle是一种发布格式,打包出来的格式为aab,而之前我们...
继续阅读 »

1. Android App Bundle 是什么?

从 2021 年 8 月起,新应用需要使用 Android App Bundle 才能在 Google Play 中发布。

Android App Bundle是一种发布格式,打包出来的格式为aab,而之前我们打包出来的格式为apk。编写完代码之后,将其打包成aab格式(里面包含了所有经过编译的代码和资源),然后上传到Google Play。用户最后安装的还是apk,只不过不是一个,而是多个apk,这些apk是Google Play根据App Bundle生成的。

既然已经有了apk,那要App Bundle有啥用?咱之前打一个apk,会把各种架构、各种语言、各种分辨率的图片等全部放入一个apk中,但具体到某个用户的设备上,这个设备只需要一种so库架构、一种语言、一种分辨率的图片,那其他的东西都在apk里面,这就有点浪费了,不仅下载需要更多的流量,而且还占用用户设备更多的存储空间。当然,也可以通过在打包的时候打多个apk,分别支持各种密度、架构、语言的设备,但这太麻烦了。

于是,Google Play出手了。

App Bundle是经过签名的二进制文件,可将应用的代码和资源组织到不同的模块中。比如,当某个用户的设备是xxhdpi+arm64-v8a+values-zh环境,那Google Play后台会利用App Bundle中的对应的模块(xxhdpi+arm64-v8a+values-zh)组装起来,组成一个base apk和多个配置apk供该用户下载并安装,而不会去把其他的像armeabi-v7ax86之类的与当前设备无关的东西组装进apk,这样用户下载的apk体积就会小很多。体积越小,转化率越高,也更环保。

有了Android App Bundle之后,Google Play还提供了2个东西:Play Feature DeliveryPlay Asset Delivery。Play Feature Delivery可以按某种条件分发或按需下载应用的某些功能,从而进一步减小包体积。Play Asset Delivery是Google Play用于分发大体积应用的解决方案,为开发者提供了灵活的分发方式和极高的性能。

2. Android App Bundle打包

打Android App Bundle非常简单,直接通过Android Studio就能很方便地打包,当然命令行也可以的。

  • Android Studio打包:Build -> Generate Signed Bundle / APK -> 选中Android App Bundle -> 选中签名和输入密码 -> 选中debug或者release包 -> finish开始打包

  • gradle命令行打包:./gradlew bundleDebug 或者 ./gradlew bundleRelease

打出来之后是一个类似app-debug.aab的文件,可以将aab文件直接拖入Android Studio进行分析和查看其内部结构,很方便。

3. 如何测试Android App Bundle?

Android App Bundle包倒是打出来了,那怎么进行测试呢?我们设备上仅允许安装apk文件,aab是不能直接进行安装的。这里官方提供了3种方式可供选择:Android Studio 、Google Play 和 bundletool,下面我们一一来介绍。

3.1 Android Studio

利用Android Studio,在我们平时开发时就可以直接将项目打包成debug的aab并且运行到设备上,只需要点一下运行按钮即可(当然,这之前需要一些简单的配置才行)。Android Studio和Google Play使用相同的工具从aab中提取apk并将其安装在设备上,因此这种本地测试策略也是可行的。这种方式可以验证以下几点:

  • 该项目是否可以构建为app bundle

  • Android Studio是否能够从app bundle中提取目标设备配置的apk

  • 功能模块的功能与应用的基本模块是否兼容

  • 该项目是否可以在目标设备上按预期运行

默认情况下,设备连接上Android Studio之后,运行时打的包是apk。所以我们需要配置一下,改成运行时先打app bundle,然后再从app bundle中提取出该设备需要的配置apk,再组装成一个新的apk并签名,随后安装到设备上。具体配置步骤如下:

  1. 从菜单栏中依次选择 Run -> Edit Configurations。

  2. 从左侧窗格中选择一项运行/调试配置。

  3. 在右侧窗格中,选择 General 标签页。

  4. 从 Deploy 旁边的下拉菜单中选择 APK from app bundle。

  5. 如果你的应用包含要测试的免安装应用体验,请选中 Deploy as an instant app 旁边的复选框。

  6. 如果你的应用包含功能模块,你可以通过选中每个模块旁边的复选框来选择要部署的模块。默认情况下,Android Studio 会部署所有功能模块,并且始终都会部署基本应用模块。

  7. 点击 Apply 或 OK。

好了,现在已经配置好了,现在点击运行按钮,Android Studio会构建app bundle,并使用它来仅部署连接的设备及你选择的功能模块所需要的apk。

3.2 bundletool

bundletool 是一种命令行工具,谷歌开源的,Android Studio、Android Gradle 插件和 Google Play 使用这一工具将应用的经过编译的代码和资源转换为 App Bundle,并根据这些 Bundle 生成可部署的 APK。

前面使用Android Studio来测试app bundle比较方便,但是,官方推荐使用bundletool 从 app bundle 将应用部署到连接的设备。因为bundletool提供了专门为了帮助你测试app bundle并模拟通过Google Play分发而设计的命令,这样的话我们就不必上传到Google Play管理中心去测试了。

下面我们就来实验一把。

  1. 首先是下载bundletool,到GitHub上去下载bundletool,地址:github.com/google/bund…

  2. 然后通过Android Studio或者Gradle将项目打包成Android App Bundle,然后通过bundletool将Android App Bundle生成一个apk容器(官方称之为split APKs),这个容器以.apks作为文件扩展名,这个容器里面包含了该应用支持的所有设备配置的一组apk。这么说可能不太好懂,我们实操一下:

//使用debug签名生成apk容器
java -jar bundletool-all-1.14.0.jar build-apks --bundle=app-release.aab --output=my_app.apks

//使用自己的签名生成apk容器
java -jar bundletool-all-1.14.0.jar build-apks --bundle=app-release.aab --output=my_app.apks
--ks=keystore.jks
--ks-pass=file:keystore.pwd
--ks-key-alias=MyKeyAlias
--key-pass=file:key.pwd

ps: build-apks命令是用来打apks容器的,它有很多可选参数,比如这里的--bundle=path表示:指定你的 app bundle 的路径,--output=path表示:指定输出 .apks 文件的名称,该文件中包含了应用的所有 APK 零部件。它的其他参数大家感兴趣可以到bundletool查阅。

执行完命令之后,会生成一个my_app.apks的文件,我们可以把这个apks文件解压出来,看看里面有什么。

 toc.pb

└─splits
       base-af.apk
       base-am.apk
       base-ar.apk
       base-as.apk
       base-az.apk
       base-be.apk
       base-bg.apk
       base-bn.apk
       base-bs.apk
       base-ca.apk
       base-cs.apk
       base-da.apk
       base-de.apk
       base-el.apk
       base-en.apk
       base-es.apk
       base-et.apk
       base-eu.apk
       base-fa.apk
       base-fi.apk
       base-fr.apk
       base-gl.apk
       base-gu.apk
       base-hdpi.apk
       base-hi.apk
       base-hr.apk
       base-hu.apk
       base-hy.apk
       base-in.apk
       base-is.apk
       base-it.apk
       base-iw.apk
       base-ja.apk
       base-ka.apk
       base-kk.apk
       base-km.apk
       base-kn.apk
       base-ko.apk
       base-ky.apk
       base-ldpi.apk
       base-lo.apk
       base-lt.apk
       base-lv.apk
       base-master.apk
       base-mdpi.apk
       base-mk.apk
       base-ml.apk
       base-mn.apk
       base-mr.apk
       base-ms.apk
       base-my.apk
       base-nb.apk
       base-ne.apk
       base-nl.apk
       base-or.apk
       base-pa.apk
       base-pl.apk
       base-pt.apk
       base-ro.apk
       base-ru.apk
       base-si.apk
       base-sk.apk
       base-sl.apk
       base-sq.apk
       base-sr.apk
       base-sv.apk
       base-sw.apk
       base-ta.apk
       base-te.apk
       base-th.apk
       base-tl.apk
       base-tr.apk
       base-tvdpi.apk
       base-uk.apk
       base-ur.apk
       base-uz.apk
       base-vi.apk
       base-xhdpi.apk
       base-xxhdpi.apk
       base-xxxhdpi.apk
       base-zh.apk
       base-zu.apk

里面有一个toc.pb文件和一个splits文件夹(splits顾名思义,就是拆分出来的所有apk文件),splits里面有很多apk,base-开头的apk是主module的相关apk,其中base-master.apk是基本功能apk,base-xxhdpi.apk则是对资源分辨率进行了拆分,base-zh.apk则是对语言资源进行拆分。

我们可以将这些apk拖入Android Studio看一下里面有什么,比如base-xxhdpi.apk

│  AndroidManifest.xml
|  
| resources.arsc

├─META-INF
│     BNDLTOOL.RSA
│     BNDLTOOL.SF
│     MANIFEST.MF

└─res
   ├─drawable-ldrtl-xxhdpi-v17
   │     abc_ic_menu_copy_mtrl_am_alpha.png
   │     abc_ic_menu_cut_mtrl_alpha.png
   │     abc_spinner_mtrl_am_alpha.9.png
   
   ├─drawable-xhdpi-v4
   │     notification_bg_low_normal.9.png
   │     notification_bg_low_pressed.9.png
   │     notification_bg_normal.9.png
   │     notification_bg_normal_pressed.9.png
   │     notify_panel_notification_icon_bg.png
   
   └─drawable-xxhdpi-v4
           abc_textfield_default_mtrl_alpha.9.png
           abc_textfield_search_activated_mtrl_alpha.9.png
           abc_textfield_search_default_mtrl_alpha.9.png
           abc_text_select_handle_left_mtrl_dark.png
           abc_text_select_handle_left_mtrl_light.png
           abc_text_select_handle_middle_mtrl_dark.png
           abc_text_select_handle_middle_mtrl_light.png
           abc_text_select_handle_right_mtrl_dark.png
           abc_text_select_handle_right_mtrl_light.png

首先,这个apk有自己的AndroidManifest.xml,其次是resources.arsc,还有META-INF签名信息,最后是与自己名称对应的xxhdpi的资源。

再来看一个base-zh.apk:

│  AndroidManifest.xml
│ resources.arsc

└─META-INF
       BNDLTOOL.RSA
       BNDLTOOL.SF
       MANIFEST.MF

也是有自己的AndroidManifest.xml、resources.arsc、签名信息,其中resources.arsc里面包含了字符串资源(可以直接在Android Studio中查看)。

分析到这里大家对apks文件就有一定的了解了,它是一个压缩文件,里面包含了各种最终需要组成apk的各种零部件,这些零部件可以根据设备来按需组成一个完整的app。 比如我有一个设备是只支持中文、xxhdpi分辨率的设备,那么这个设备其实只需要下载部分apk就行了,也就是base-master.apk(基本功能的apk)、base-zh.apk(中文语言资源)和base-xxhdpi.apk(图片资源)给组合起来。到Google Play上下载apk,也是这个流程(如果这个项目的后台上传的是app bundle的话),Google Play会根据设备的特性(CPU架构、语言、分辨率等),首先下载基本功能apk,然后下载与之配置的CPU架构的apk、语言apk、分辨率apk等,这样下载的apk是最小的。

  1. 生成好了apks之后,现在我们可以把安卓测试设备插上电脑,然后利用bundletool将apks中适合设备的零部件apk挑选出来,并部署到已连接的测试设备。具体操作命令:java -jar bundletool-all-1.14.0.jar install-apks --apks=my_app.apks,执行完该命令之后设备上就安装好app了,可以对app进行测试了。bundletool会去识别这个测试设备的语言、分辨率、CPU架构等,然后挑选合适的apk安装到设备上,base-master.apk是首先需要安装的,其次是语言、分辨率、CPU架构之类的apk,利用Android 5.0以上的split apks,这些apk安装之后可以共享一套代码和资源。

3.3 Google Play

如果我最终就是要将Android App Bundle发布到Google Play,那可以先上传到Google Play Console的测试渠道,再通过测试渠道进行分发,然后到Google Play下载这个测试的App,这样肯定是最贴近于用户的使用环境的,比较推荐这种方式进行最后的测试。

4. 拆解Android App Bundle格式

首先,放上官方的格式拆解图(下图包含:一个基本模块、两个功能模块、两个资源包):


app bundle是经过签名的二进制文件,可将应用的代码和资源装进不同的模块中,这些模块中的代码和资源的组织方式和apk中相似,它们都可以作为单独的apk生成。Google Play会使用app bundle生成向用户提供的各种apk,如base apk、feature apk、configuration apks、multi-APKs。图中蓝色标识的目录(drawable、values、lib)表示Google Play用来为每个模块创建configuration apks的代码和资源。

  • base、feature1、feature2:每个顶级目录都表示一个不同的应用模块,基本模块是包含在app bundle的base目录中。

  • asset_pack_1asset_pack_2:游戏或者大型应用如果需要大量图片,则可以将asset模块化处理成资源包。资源包可以根据自己的需要,在合适的时机去请求到本地来。

  • BUNDLE-METADATA/:包含元数据文件,其中包含对工具或应用商店有用的信息。

  • 模块协议缓冲区(*pb)文件:元数据文件,向应用商店说明每个模块的内容。如:BundleConfig.pb 提供了有关 bundle 本身的信息(如用于构建 app bundle 的构建工具版本),native.pb 和 resources.pb 说明了每个模块中的代码和资源,这在 Google Play 针对不同的设备配置优化 APK 时非常有用。

  • manifest/:与 APK 不同,app bundle 将每个模块的 AndroidManifest.xml 文件存储在这个单独的目录中。

  • dex/:与 APK 不同,app bundle 将每个模块的 DEX 文件存储在这个单独的目录中。

  • res/lib/assets/:这些目录与典型 APK 中的目录完全相同。

  • root/:此目录存储的文件之后会重新定位到包含此目录所在模块的任意 APK 的根目录。

5. Split APKs

Android 5.0 及以上支持Split APKs机制,Split APKs与常规的apk相差不大,都是包含经过编译的dex字节码、资源和清单文件等。区别是:Android可以将安装的多个Split APKs视为一个应用,也就是虽然我安装了多个apk,但Android系统认为它们是同一个app,用户也只会在设置里面看到一个app被安装上了;而平时我们安装的普通apk,一个apk就对应着一个app。Android上,我们可以安装多个Split APK,它们是共用代码和资源的。

Split APKs的好处是可以将单体式app做拆分,比如将ABI、屏幕密度、语言等形式拆分成多个独立的apk,按需下载和安装,这样可以让用户更快的下载并安装好apk,并且占用更小的空间。

Android App Bundle最终也就是利用这种方式来进行安装的,比如我上面在执行完java -jar bundletool-all-1.14.0.jar install-apks --apks=my_app.apks命令之后,那么最后安装到手机上的apk文件如下:


ps:5.0以下不支持Split APKs,那咋办?没事,Google Play会为这些设备的用户安装一个全量的apk,里面什么都有,问题不大。

6. 国内商店支持Android App Bundle吗?

Android App Bundle不是Google Play的专有格式,它是开源的,任何商店想支持都可以的。

上面扯那么大一堆有的没的,这玩意儿这么好用,那国内商店的支持情况如何。我查了下,发现就华为可以支持,手动狗头。

华为 Android App Bundle developer.huawei.com/consumer/cn…

7. 小结

现在上架Google Play必须上传Android App Bundle才行了,所以有必要简单了解下。简单来说就是Android App Bundle是一种新的发布格式,上传到商店之后,商店会利用这个Android App Bundle生成一堆Split APKs,当用户要去安装某个app时,只需要按需下载Split APKs中的部分apk(base apk + 各种配置apk),进行安装即可,总下载量大大减少。

参考资料

作者:潇风寒月
来源:juejin.cn/post/7197246543207022629

收起阅读 »

android 微信抢红包工具 AccessibilityService(上)

一、目标二、实现流程我们把一个抢红包发的过程拆分来看,可以分为几个步骤:以上是一个抢红包的基本流程。1、收到通知 以及 点击通知栏Ⅰ、AccessibilityServiceⅡ、NotificationListenerService2、点击红包我们来分析一下,...
继续阅读 »

你有因为手速不够快抢不到红包而沮丧? 你有因为错过红包而懊恼吗? 没错,它来了。。。

一、目标

使用AccessibilityService的方式,实现微信自动抢红包(吐槽一下,网上找了许多文档,由于各种原因,无法实现对应效果,所以先给自己整理下),关于AccessibilityService的文章,网上有很多(没错,多的都懒得贴链接那种多),可自行查找。

二、实现流程

1、流程分析(这里只分析在桌面的情况)

我们把一个抢红包发的过程拆分来看,可以分为几个步骤:

收到通知 -> 点击通知栏 -> 点击红包 -> 点击开红包 -> 退出红包详情页

以上是一个抢红包的基本流程。

2、实现步骤

1、收到通知 以及 点击通知栏

接收通知栏的消息,介绍两种方式

Ⅰ、AccessibilityService

即通过AccessibilityService的AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件来获取到Notification

private fun handleNotification(eventAccessibilityEvent) {
   val texts = event.text
   if (!texts.isEmpty()) {
           for (text in texts) {
               val content = text.toString()
               //如果微信红包的提示信息,则模拟点击进入相应的聊天窗口
               if (content.contains("[微信红包]")) {
                   if (event.parcelableData != null && event.parcelableData is Notification) {
                       val notificationNotification? = event.parcelableData as Notification?
                       val pendingIntentPendingIntent = notification!!.contentIntent
                       try {
                           pendingIntent.send()
                      } catch (eCanceledException) {
                           e.printStackTrace()
                      }
                  }
              }
          }
      }

}
Ⅱ、NotificationListenerService

这是监听通知栏的另一种方式,记得要获取权限哦

class MyNotificationListenerService : NotificationListenerService() {

   override fun onNotificationPosted(sbnStatusBarNotification?) {
       super.onNotificationPosted(sbn)

       val extras = sbn?.notification?.extras
       // 获取接收消息APP的包名
       val notificationPkg = sbn?.packageName
       // 获取接收消息的抬头
       val notificationTitle = extras?.getString(Notification.EXTRA_TITLE)
       // 获取接收消息的内容
       val notificationText = extras?.getString(Notification.EXTRA_TEXT)
       if (notificationPkg != null) {
           Log.d("收到的消息内容包名:"notificationPkg)
           if (notificationPkg == "com.tencent.mm"){
               if (notificationText?.contains("[微信红包]"== true){
                   //收到微信红包了
                   val intent = sbn.notification.contentIntent
                   intent.send()
              }
          }
      }
       Log.d("收到的消息内容""Notification posted $notificationTitle & $notificationText")
  }

   override fun onNotificationRemoved(sbnStatusBarNotification?) {
       super.onNotificationRemoved(sbn)
  }
}

2、点击红包

通过上述的跳转,可以进入聊天详情页面,到达详情页之后,接下来就是点击对应的红包卡片,那么问题来了,怎么点?肯定不是手动点。。。

我们来分析一下,一个聊天列表中,我们怎样才能识别到红包卡片,我看网上有通过findAccessibilityNodeInfosByViewId来获取对应的View,这个也可以,只是我们获取id的方式需要借助工具,可以用Android Device Monitor,但是这玩意早就废废弃了,虽然在sdk的目录下存在monitor,奈何本人太菜,点击就是打不开


我本地的jdk是11,我怀疑是不兼容,毕竟Android Device Monitor太老了。换新的layout Inspector,也就看看本地的debug应用,无法查看微信的呀。要么就反编译,这个就先不考虑了,或者在配置文件中设置android:accessibilityFlags="flagReportViewIds",然后暴力遍历Node树,打印相应的viewId和className,找到目标id即可。当然也可以换findAccessibilityNodeInfosByText这个方法试试。

这个方法从字面意思能看出来,是通过text来匹配的,我们可以知道红包卡片上面是有“微信红包”的固定字样的,是不是可以通股票这个来匹配呢,这还有个其他问题,并不是所有的红包都需要点,比如已过期,已领取的是不是要过滤下,咋一看挺好过滤的,一个循环就好,仔细想,这是棵树,不太好剔除,所以换了个思路。

最终方案就是递归一棵树,往一个列表里面塞值,“已过期”和“已领取”的塞一个字符串“#”,匹配到“微信红包”的塞一个AccessibilityNodeInfo,这样如果这个红包不能抢,那肯定一前一后分别是一个字符串和一个AccessibilityNodeInfo,因此,我们读到一个AccessibilityNodeInfo,并且前一个值不是字符串,就可以执行点击事件,代码如下

private fun getPacket() {
   val rootNode = rootInActiveWindow
   val caches:ArrayList<Any> = ArrayList()
   recycle(rootNode,caches)
   if(caches.isNotEmpty()){
       for(index in 0 until caches.size){
           if(caches[indexis AccessibilityNodeInfo && (index == 0 || caches[index-1!is String )){
               val node = caches[indexas AccessibilityNodeInfo
               var parent = node.parent
               while (parent != null) {
                   if (parent.isClickable) {
                       parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                       break
                  }
                   parent = parent.parent
              }
               break
          }
      }
  }

}

private fun recycle(nodeAccessibilityNodeInfo,caches:ArrayList<Any>) {
       if (node.childCount == 0) {
           if (node.text != null) {
               if ("已过期" == node.text.toString() || "已被领完" == node.text.toString() || "已领取" == node.text.toString()) {
                   caches.add("#")
              }

               if ("微信红包" == node.text.toString()) {
                   caches.add(node)
              }
          }
      } else {
           for (i in 0 until node.childCount) {
               if (node.getChild(i!= null) {
                   recycle(node.getChild(i),caches)
              }
          }
      }
  }

以上只点击了第一个能点击的红包卡片,想点击所有的可另行处理。

3、点击开红包

这里思路跟上面类似,开红包页面比较简单,但是奈何开红包是个按钮,在不知道id的前提下,我们也不知道则呢么获取它,所以采用迂回套路,找固定的东西,我这里发现每个开红包的页面都有个“xxx的红包”文案,然后这个页面比较简单,只有个关闭,和开红包,我们通过获取“xxx的红包”对应的View来获取父View,然后递归子View,判断可点击的,执行点击事件不就可以了吗

private fun openPacket() {
   val nodeInfo = rootInActiveWindow
   if (nodeInfo != null) {
       val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
       for ( i in 0 until list.size) {
           val parent = list[i].parent
           if (parent != null) {
               for ( j in 0 until  parent.childCount) {
                   val child = parent.getChild (j)
                   if (child != null && child.isClickable) {
                       child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                  }
              }

          }

      }
  }

}

4、退出红包详情页

这里回退也是个按钮,我们也不知道id,所以可以跟点开红包一样,迂回套路,获取其他的View,来获取父布局,然后递归子布局,依次执行点击事件,当然关闭事件是在前面的,也就是说关闭会优先执行到

private fun close() {
   val nodeInfo = rootInActiveWindow
   if (nodeInfo != null) {
       val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
       if (list.isNotEmpty()) {
           val parent = list[0].parent.parent.parent
           if (parent != null) {
               for ( j in 0 until  parent.childCount) {
                   val child = parent.getChild (j)
                   if (child != null && child.isClickable) {
                       child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                  }
              }

          }

      }
  }
}

三、遇到问题

1、AccessibilityService收不到AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件

android碎片问题很正常,我这边是使用NotificationListenerService来替代的。

2、需要点击View的定位

简单是就是到页面应该点哪个View,找到相应的规则,来过滤出对应的View,这个规则是随着微信的改变而变化的,findAccessibilityNodeInfosByViewId最直接,但是奈何工具问题,有点麻烦,遍历打印可以获取,但是id每个版本可能会变。还有就是通过文案来获取,即findAccessibilityNodeInfosByText,获取一些固定文案的View,这个相对而言在不改版,可能不会变,相对稳定些,如果这个文案的View本身没点击事件,可获取它的parent,尝试点击,或者遍历parent树,根据isClickable来判断是否可以点击。

划重点:

这里还有一种就是钉钉的开红包按钮,折腾了半天,始终拿不到,各种递归遍历,一直没有找到,最后换了个方式,通过AccessibilityService的模拟点击来做,也就是通过坐标来模拟点击,当然要在配置中开启android:canPerformGestures="true", 然后通过 accessibilityService.dispatchGesture() 来处理,具体坐标可以拿一个其他的View,然后通过比例来确定大概得位置,或者,看看能不能拿到外层的Layout也是一样的

object AccessibilityClick {
   fun click(accessibilityServiceAccessibilityServicexFloatyFloat) {
       val builder = GestureDescription.Builder()
       val path = Path()
       path.moveTo(xy)
       path.lineTo(xy)
       builder.addStroke(GestureDescription.StrokeDescription(path010))
       accessibilityService.dispatchGesture(builder.build(), object : AccessibilityService.GestureResultCallback() {
           override fun onCancelled(gestureDescriptionGestureDescription) {
               super.onCancelled(gestureDescription)
          }

           override fun onCompleted(gestureDescriptionGestureDescription) {
               super.onCompleted(gestureDescription)
          }
      }, null)
  }
}

续:android 微信抢红包工具 AccessibilityService(下)

作者:我有一头小毛驴你有吗
来源:juejin.cn/post/7196949524061339703

收起阅读 »

android 微信抢红包工具 AccessibilityService(下)

接:android 微信抢红包工具 AccessibilityService(上)MyNotificationListenerServiceclass MyNotificationListenerService : Notific...
继续阅读 »

接:android 微信抢红包工具 AccessibilityService(上)

四、完整代码

MyNotificationListenerService

class MyNotificationListenerService : NotificationListenerService() {

   override fun onNotificationPosted(sbnStatusBarNotification?) {
       super.onNotificationPosted(sbn)

       val extras = sbn?.notification?.extras
       // 获取接收消息APP的包名
       val notificationPkg = sbn?.packageName
       // 获取接收消息的抬头
       val notificationTitle = extras?.getString(Notification.EXTRA_TITLE)
       // 获取接收消息的内容
       val notificationText = extras?.getString(Notification.EXTRA_TEXT)
       if (notificationPkg != null) {
           Log.d("收到的消息内容包名:"notificationPkg)
           if (notificationPkg == "com.tencent.mm"){
               if (notificationText?.contains("[微信红包]"== true){
                   //收到微信红包了
                   val intent = sbn.notification.contentIntent
                   intent.send()
              }
          }
      } Log.d("收到的消息内容""Notification posted $notificationTitle & $notificationText")
  }

   override fun onNotificationRemoved(sbnStatusBarNotification?) {
       super.onNotificationRemoved(sbn)
  }
}

MyAccessibilityService

class MyAccessibilityService : AccessibilityService() {

   override fun onAccessibilityEvent(eventAccessibilityEvent) {
       val eventType = event.eventType
       when (eventType) {
           AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> handleNotification(event)
           AccessibilityEvent.TYPE_WINDOW_STATE_CHANGEDAccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
               val className = event.className.toString()
               Log.e("测试无障碍",className)
               when (className) {
                   "com.tencent.mm.ui.LauncherUI" -> {
                       // 我管这叫红包卡片页面
                       getPacket()
                  }
                   "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI" -> {
                       // 貌似是老UI debug没发现进来
                       openPacket()
                  }
                   "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyNotHookReceiveUI" -> {
                       // 应该是红包弹框UI新页面 debug进来了
                       openPacket()
                  }
                   "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI" -> {
                       // 红包详情页面 执行关闭操作
                       close()
                  }
                   "androidx.recyclerview.widget.RecyclerView" -> {
                       // 这个比较频繁 主要是在聊天页面 有红包来的时候 会触发 当然其他有列表的页面也可能触发 没想到好的过滤方式
                       getPacket()
                  }
              }
          }
      }
  }

   /**
    * 处理通知栏信息
    *
    * 如果是微信红包的提示信息,则模拟点击
    *
    * @param event
    */
   private fun handleNotification(eventAccessibilityEvent) {
       val texts = event.text
       if (!texts.isEmpty()) {
           for (text in texts) {
               val content = text.toString()
               //如果微信红包的提示信息,则模拟点击进入相应的聊天窗口
               if (content.contains("[微信红包]")) {
                   if (event.parcelableData != null && event.parcelableData is Notification) {
                       val notificationNotification? = event.parcelableData as Notification?
                       val pendingIntentPendingIntent = notification!!.contentIntent
                       try {
                           pendingIntent.send()
                      } catch (eCanceledException) {
                           e.printStackTrace()
                      }
                  }
              }
          }
      }

  }

   /**
    * 关闭红包详情界面,实现自动返回聊天窗口
    */
   @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
   private fun close() {
       val nodeInfo = rootInActiveWindow
       if (nodeInfo != null) {
           val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
           if (list.isNotEmpty()) {
               val parent = list[0].parent.parent.parent
               if (parent != null) {
                   for ( j in 0 until  parent.childCount) {
                       val child = parent.getChild (j)
                       if (child != null && child.isClickable) {
                           child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                      }
                  }

              }

          }
      }
  }

   /**
    * 模拟点击,拆开红包
    */
   @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
   private fun openPacket() {
       Log.e("测试无障碍","点击红包")
       Thread.sleep(100)
       val nodeInfo = rootInActiveWindow
       if (nodeInfo != null) {
           val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
           for ( i in 0 until list.size) {
               val parent = list[i].parent
               if (parent != null) {
                   for ( j in 0 until  parent.childCount) {
                       val child = parent.getChild (j)
                       if (child != null && child.isClickable) {
                           Log.e("测试无障碍","点击红包成功")
                           child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                      }
              }

          }

          }
      }

  }

   /**
    * 模拟点击,打开抢红包界面
    */
   @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
   private fun getPacket() {
       Log.e("测试无障碍","获取红包")
       val rootNode = rootInActiveWindow
       val caches:ArrayList<Any> = ArrayList()
       recycle(rootNode,caches)
       if(caches.isNotEmpty()){
           for(index in 0 until caches.size){
               if(caches[indexis AccessibilityNodeInfo && (index == 0 || caches[index-1!is String )){
                   val node = caches[indexas AccessibilityNodeInfo
//                   node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                   var parent = node.parent
                   while (parent != null) {
                       if (parent.isClickable) {
                           parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                           Log.e("测试无障碍","获取红包成功")
                           break
                      }
                       parent = parent.parent
                  }
                   break
              }
          }
      }

  }

   /**
    * 递归查找当前聊天窗口中的红包信息
    *
    * 聊天窗口中的红包都存在"微信红包"一词,因此可根据该词查找红包
    *
    * @param node
    */
   private fun recycle(nodeAccessibilityNodeInfo,caches:ArrayList<Any>) {
       if (node.childCount == 0) {
           if (node.text != null) {
               if ("已过期" == node.text.toString() || "已被领完" == node.text.toString() || "已领取" == node.text.toString()) {
                   caches.add("#")
              }

               if ("微信红包" == node.text.toString()) {
                   caches.add(node)
              }
          }
      } else {
           for (i in 0 until node.childCount) {
               if (node.getChild(i!= null) {
                   recycle(node.getChild(i),caches)
              }
          }
      }
  }

   override fun onInterrupt() {}
   override fun onServiceConnected() {
       super.onServiceConnected()
       Log.e("测试无障碍id","启动")
       val infoAccessibilityServiceInfo = serviceInfo
       info.packageNames = arrayOf("com.tencent.mm")
       serviceInfo = info
  }
}

5、总结

此文是对AccessibilityService的使用的一个梳理,这个功能其实不麻烦,主要是一些细节问题,像自动领取支付宝红包,自动领取QQ红包或者其他功能等也都可以用类似方法实现。目前实现了微信和钉钉的,剩下的支付宝QQ啥的没啥人用,就不想做了,不过原理都是一样的,

源码地址: gitee.com/wlr123/acce…

使用时记得开启下对应权限,设置下后台运行权限,电量设置里面允许后台运行等,以及通知栏权限,以保证稳定运行


作者:我有一头小毛驴你有吗
来源:juejin.cn/post/7196949524061339703

收起阅读 »

DialogX 的一些骚包的高阶使用技巧

DialogX 的一些骚包的高阶使用技巧DialogX 是一款轻松易用的对话框组件,具备高扩展性和易上手的特点,包含各种自定义主题样式,可以快速实现各式各样的对话框效果,也避免了 AlertDialog 的诸多蛋疼的问题,详情可以参阅这篇文章:《使用 Dial...
继续阅读 »

DialogX 的一些骚包的高阶使用技巧

DialogX 是一款轻松易用的对话框组件,具备高扩展性和易上手的特点,包含各种自定义主题样式,可以快速实现各式各样的对话框效果,也避免了 AlertDialog 的诸多蛋疼的问题,详情可以参阅这篇文章:《使用 DialogX 快速构建 Android App 对话框》

本篇文章将介绍一些 DialogX 的使用技巧,也欢迎大家集思广益在评论区留下宝贵的建议,DialogX 自始至终的目标都是尽量让开发变得更加简单,基于此目的,DialogX 首先想做的就是避免重复性劳动,一般我们开发产品总会有一些各式各样的需要,比如关于对话框启动和关闭的动画。

局部>组件内>全局生效的属性

局部设置

DialogX 的很多属性都可以自定义调整,最简单的就是通过实例的 set 方法对属性进行调整,例如对于动画,你可以使用这些 set 方法进行调整:


但是,当我们的程序中有大量的对话框,但每个 MessageDialog 都需要调整,又不能影响其他对话框的动画,该怎么设置呢?

组件生效

此时就可以使用该对话框的静态方法直接进行设置,例如:

MessageDialog.overrideEnterDuration = 100;    //入场动画时长为100毫秒
MessageDialog.overrideExitDuration = 100;     //出场动画时长为100毫秒
MessageDialog.overrideEnterAnimRes = R.anim.anim_dialogx_top_enter; //入场动画资源
MessageDialog.overrideExitAnimRes = R.anim.anim_dialogx_top_exit;   //出场动画资源

如果要设置的属性想针对全局,也就是所有对话框都生效,此时可以使用全局设置进行调整:

全局设置

你可以随时召唤神龙 DialogX,直接修改静态属性,这里的设置都是针对全局的,可以快速完成需要的调整。

DialogX.enterAnimDuration = 100;
DialogX.exitAnimDuration = 100;

上边演示的是动画相关设置,除此之外,你还可以对对话框的标题文字样式、对话框OK按钮的样式、取消按钮的样式、正文内容的文字样式等等进行全局的调整,只需要知道属性生效的优先级是:

优先级为:实例使用set方法设置 > 组件override设置 > 全局设置。

额外的,如果需要对部分组件的行为进行调整,例如 PopTip 的默认显示位置位于屏幕底部,但产品或设计要求想显示到屏幕中央,但这个设置又取决于主题的限制,此时你可以通过重写主题的设置来实现调整:

覆盖主题设置

想要将 PopTip 吐司提示不按照主题的设定(例如屏幕底部)显示,而是以自己的要求显示(例如屏幕中央),但对于 PopTip 的 align 属性属于主题控制的,此时可以通过重写主题来调整对话框的部分行为,例如:

DialogX.globalStyle = new MaterialStyle(){
   @Override
   public PopTipSettings popTipSettings() {
       return new PopTipSettings() {
           @Override
           public ALIGN align() {
               return ALIGN.CENTER;
          }
      };
  }
};

DialogX 强大的扩展性允许你发挥更多想象空间!如果你的产品经理或者设计师依然不满足于简简单单的动画,想要定制更为丰富的入场/出场效果,此时可以利用 DialogX 预留的对话框动画控制接口对每一个对话框内的组件动画细节进行定制。

完全的动画细节定制

例如,我们可以针对一个对话框的背景遮罩进行透明度动画效果处理,但对于对话框内容部分进行一个从屏幕顶部进入的动画效果,其他的,请发挥你的想象进行设计吧!

使用 DialogXAnimInterface 接口可以完全自定义开启、关闭动画。

由于 DialogX 对话框组件的内部元素都是暴露的,你可以轻松获取并访问内部实例,利用这一点,再加上 DialogXAnimInterface 会负责对话框启动和关闭的动画行为,你可以充分利用它实现你想要的效果。

例如对于一个 CustomDialog,你可以这样控制其启动和关闭动画:

CustomDialog.show(new OnBindView<CustomDialog>(R.layout.layout_custom_dialog) {
           @Override
           public void onBind(final CustomDialog dialog, View v) {
               //...
          }
      })
       //实现完全自定义动画效果
      .setDialogXAnimImpl(new DialogXAnimInterface<CustomDialog>() {
           //启动对话框动画逻辑
           @Override
           public void doShowAnim(CustomDialog customDialog, ObjectRunnable<Float> animProgress) {
               //创建一个资源动画
               Animation enterAnim;
               int enterAnimResId = com.kongzue.dialogx.R.anim.anim_dialogx_top_enter;
               enterAnim = AnimationUtils.loadAnimation(me, enterAnimResId);
               enterAnim.setInterpolator(new DecelerateInterpolator(2f));
               long enterAnimDurationTemp = enterAnim.getDuration();
               enterAnim.setDuration(enterAnimDurationTemp);
               customDialog.getDialogImpl().boxCustom.startAnimation(enterAnim); //通过 getDialogImpl() 获取内部暴露的 boxCustom 元素
               //创建一个背景遮罩层的渐变动画
               ValueAnimator bkgAlpha = ValueAnimator.ofFloat(0f, 1f);
               bkgAlpha.setDuration(enterAnimDurationTemp);
               bkgAlpha.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                   @Override
                   public void onAnimationUpdate(ValueAnimator animation) {
                       //汇报动画进度,同时 animation.getAnimatedValue() 将改变遮罩层的透明度
                       animProgress.run((Float) animation.getAnimatedValue());
                  }
              });
               bkgAlpha.start();
          }
           
           //关闭对话框动画逻辑
           @Override
           public void doExitAnim(CustomDialog customDialog, ObjectRunnable<Float> animProgress) {
               //创建一个资源动画
               int exitAnimResIdTemp = com.kongzue.dialogx.R.anim.anim_dialogx_default_exit;
               Animation exitAnim = AnimationUtils.loadAnimation(me, exitAnimResIdTemp);
               customDialog.getDialogImpl().boxCustom.startAnimation(exitAnim); //通过 getDialogImpl() 获取内部暴露的 boxCustom 元素
               //创建一个背景遮罩层的渐变动画
               ValueAnimator bkgAlpha = ValueAnimator.ofFloat(1f, 0f);
               bkgAlpha.setDuration(exitAnim.getDuration());
               bkgAlpha.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                   @Override
                   public void onAnimationUpdate(ValueAnimator animation) {
                       //汇报动画进度,同时 animation.getAnimatedValue() 将改变遮罩层的透明度
                       animProgress.run((Float) animation.getAnimatedValue());
                  }
              });
               bkgAlpha.start();
          }
      });

对于 animProgress 它本质上是个反向回调执行器,因为动画时长不定,你需要通知 DialogX 当前你的动画到达哪个阶段了,对话框需要根据这个阶段进行操作处理,例如关闭动画执行过程应当是 1f 至 0f 的过程,完毕后应当销毁对话框,那么当 animProgress.run(0f) 时就会执行销毁流程,而启动动画应当是 0f 至 1f 的过程,当 animProgress.run(1f) 时启动对话框的动画完全执行完毕。

另外,你有没有注意到上述代码中的一个小细节?你可以通过 .getDialogImpl() 访问对话框的所有内部实例,这意味着,DialogX 中的所有实例事实上都是对外开放的,你可以在对话框启动后(DialogLifecycle#onShow)通过 DialogImpl 获取对话框的所有内容组件,对他们进行你想做的调整和设置,这都将极大程度上方便开发者对对话框内容进行定制。

正如我一开始所说,DialogX 将坚持努力打造一款更好用,更高效可定制化的对话框组件。

队列对话框

某些场景下需要有“模态”对话框的需要,即,一次性创建多个对话框,组成队列,逐一显示,当上一个对话框关闭时自动启动下一个对话框,此时可以使用队列对话框来完成。

示例代码如下,在 DialogX.showDialogList(...) 中构建多个对话框,请注意这些对话框必须是没有启动的状态,使用 .build() 方法完成构建,以 “,” 分隔组成队列,即可自动启动。

DialogX.showDialogList(
       MessageDialog.build().setTitle("提示").setMessage("这是一组消息对话框队列").setOkButton("开始").setCancelButton("取消")
              .setCancelButton(new OnDialogButtonClickListener<MessageDialog>() {
                   @Override
                   public boolean onClick(MessageDialog dialog, View v) {
                       dialog.cleanDialogList();
                       return false;
                  }
              }),
       PopTip.build().setMessage("每个对话框会依次显示"),
       PopNotification.build().setTitle("通知提示").setMessage("直到上一个对话框消失"),
       InputDialog.build().setTitle("请注意").setMessage("你必须使用 .build() 方法构建,并保证不要自己执行 .show() 方法").setInputText("输入文字").setOkButton("知道了"),
       TipDialog.build().setMessageContent("准备结束...").setTipType(WaitDialog.TYPE.SUCCESS),
       BottomDialog.build().setTitle("结束").setMessage("下滑以结束旅程,祝你编码愉快!").setCustomView(new OnBindView<BottomDialog>(R.layout.layout_custom_dialog) {
           @Override
           public void onBind(BottomDialog dialog, View v) {
               ImageView btnOk;
               btnOk = v.findViewById(R.id.btn_ok);
               btnOk.setOnClickListener(new View.OnClickListener() {
                   @Override
                   public void onClick(View v) {
                                       dialog.dismiss();
                                  }
              });
          }
      })
);

使用过程中,随时可以使用 .cleanDialogList() 来停止接下来的队列对话框的显示。

尾巴

DialogX 正在努力打造一款对开发者更友好,使用起来更为简单方便的对话框组件,若你有好的想法,也欢迎加入进来一起为 DialogX 添砖加瓦,通过 Github 一起让 DialogX 变得更加强大!

DialogX 路牌:github.com/kongzue/Dia…

作者:Kongzue
来源:juejin.cn/post/7197687219581993021

收起阅读 »

5分钟带你了解Android Progress Bar

1、前言 最近在开发中,同事对于android.widget下的控件一知半解,又恰好那天用到了Seekbar,想了想,那就从Seekbar's father ProgressBar 来说说android.widget下的常用控件和常用用法吧。后面也会根据这些控...
继续阅读 »

1、前言


最近在开发中,同事对于android.widget下的控件一知半解,又恰好那天用到了Seekbar,想了想,那就从Seekbar's father ProgressBar 来说说android.widget下的常用控件和常用用法吧。后面也会根据这些控件来进行仿写、扩展,做一些高度自定义的View啦。如果写的不好,或者有错误之处,恳请在评论、私信、邮箱指出,万分感谢🙏


2、ProgressBar


A user interface element that indicates the progress of an operation.


使用很简单,看看一些基本的属性


android:max:进度条的最大值
android:progress:进度条已完成进度值
android:progressDrawable:设置轨道对应的Drawable对象
android:indeterminate:如果设置成true,则进度条不精确显示进度(会一直进行动画)
android:indeterminateDrawable:设置不显示进度的进度条的Drawable对象
android:indeterminateDuration:设置不精确显示进度的持续时间
android:secondaryProgress:二级进度条(使用场景不多)
复制代码

直接在布局中使用即可


        <ProgressBar
style="@android:style/Widget.ProgressBar.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />

<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />

<ProgressBar
style="@android:style/Widget.ProgressBar.Large"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />

<ProgressBar
android:id="@+id/sb_no_beautiful"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70" />

<ProgressBar
android:id="@+id/sb_no_beautiful2"
style="@android:style/Widget.Holo.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:indeterminate="true"
android:max="100"
android:progress="50"
android:secondaryProgress="70" />
复制代码

分别就对应以下图片咯


image-20230206162049591

但是这种样式,不得不怀疑Google之前的审美,肯定是不满意的,怎么换样式呢。


看看XML文件,很容易发现,这几个ProgressBar的差异是因为style引起的,随手点开一个@android:style/Widget.ProgressBar.Horizontal 看看。


    <style name="Widget.ProgressBar.Horizontal">
<item name="indeterminateOnly">false</item>
<item name="progressDrawable">@drawable/progress_horizontal</item>
<item name="indeterminateDrawable">@drawable/progress_indeterminate_horizontal</item>
<item name="minHeight">20dip</item>
<item name="maxHeight">20dip</item>
<item name="mirrorForRtl">true</item>
</style>
复制代码

很好,估摸着样式就出在progressDrawable/indeterminateDrawable上面,看看 @drawable/progress_horizontal 里面


<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#ff9d9e9d"
android:centerColor="#ff5a5d5a"
android:centerY="0.75"
android:endColor="#ff747674"
android:angle="270"/>
</shape>
</item>
<item android:id="@android:id/secondaryProgress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#80ffd300"
android:centerColor="#80ffb600"
android:centerY="0.75"
android:endColor="#a0ffcb00"
android:angle="270"/>
</shape>
</clip>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#ffffd300"
android:centerColor="#ffffb600"
android:centerY="0.75"
android:endColor="#ffffcb00"
android:angle="270"/>
</shape>
</clip>
</item>
</layer-list>


复制代码

一个样式文件,分别操控了background/secondaryProgress/progress,这样我们很容易推测出


image-20230206112729207

再看看 @drawable/progress_indeterminate_horizontal


<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
<item android:drawable="@drawable/progressbar_indeterminate1" android:duration="200" />
<item android:drawable="@drawable/progressbar_indeterminate2" android:duration="200" />
<item android:drawable="@drawable/progressbar_indeterminate3" android:duration="200" />
</animation-list>
复制代码

显而易见,这是indeterminate模式下的样式啊,那我们仿写一个不同样式,就很简单了,动手。



styles.xml



<style name="ProgressBar_Beautiful" >
<item name="android:indeterminateOnly">false</item>
<item name="android:progressDrawable">@drawable/progress_horizontal_1</item>
<item name="android:indeterminateDrawable">@drawable/progress_indeterminate_beautiful</item>
<item name="android:mirrorForRtl">true</item>
</style>
复制代码


progress_horizontal_1.xml



<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="25dp" />
<solid android:color="#FFF0F0F0"/>
</shape>
</item>

<item android:id="@android:id/secondaryProgress">
<clip>
<shape>
<corners android:radius="25dp" />
<solid android:color="#FFC0EC87"/>

</shape>
</clip>
</item>

<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="25dp" />
<solid android:color="#FFA5E05B"/>
</shape>
</clip>
</item>
</layer-list>
复制代码


progress_indeterminate_beautiful.xml



<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item android:drawable="@drawable/bg_progress_001" android:duration="200" />
<item android:drawable="@drawable/bg_progress_002" android:duration="200" />
<item android:drawable="@drawable/bg_progress_003" android:duration="200" />
<item android:drawable="@drawable/bg_progress_004" android:duration="200" />
</animation-list>
复制代码

吭呲吭呲就写出来了,看看效果


2023-02-06_16-24-14 (2)


换了个颜色,加了个圆角/ 换了个图片,还行。


我没有去再写环形的ProgressBar了,因为它就是个一个图,疯狂的在旋转。


<animated-rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/spinner_white_76"
android:pivotX="50%"
android:pivotY="50%"
android:framesCount="12"
android:frameDuration="100" />
复制代码

还有一些属性我就不赘述了。你可以根据官方的样式,修一修、改一改,就可以满足一些基本的需求了。


用起来就这么简单,就是因为太简单,更复杂的功能就不是ProgressBar能直接实现的了。比如带个滑块?


3、SeekBar


好吧,ProgressBar的一个子类,也在android.widget下,因为是直接继承,而且就加了个滑块相关的代码,实际上它也非常简单,然我们来看看


<SeekBar
android:id="@+id/sb_01"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:thumbOffset="1dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70"
android:splitTrack="false"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_02"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_03"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:progress="100"
android:secondaryProgress="70"
android:splitTrack="false"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_04"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:thumbOffset="1dp"
android:max="100"
android:progress="100"
android:secondaryProgress="70"
android:splitTrack="false"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_05"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:paddingHorizontal="0dp"
android:progress="50"
android:secondaryProgress="70"
android:thumb="@drawable/icon_seekbar_thum" />


<SeekBar
android:id="@+id/sb_06"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70"
android:thumb="@null" />
复制代码

样式就在下面了



因为Seekbar相较而言就多了个thumb(就是那个滑块),所以就着重说一下滑块,其他的就一笔带过咯。


主要了解的是如何设置自己的thumb和thumb的各种问题


android:thumb="@drawable/icon_seekbar_thum"
复制代码

设置就这么thumb简单,一个drawable文件解决,我这里对应的是单一图片,不过Google的是带有多种状态的thumb,我们来看看官方是如何实现的


<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:constantSize="true">
<item android:state_enabled="false" android:state_pressed="true">
<bitmap android:src="@drawable/abc_scrubber_control_off_mtrl_alpha"android:gravity="center"/>
</item>
<item android:state_enabled="false">
<bitmap android:src="@drawable/abc_scrubber_control_off_mtrl_alpha"android:gravity="center"/>
</item>
<item android:state_pressed="true">
<bitmap android:src="@drawable/abc_scrubber_control_to_pressed_mtrl_005" android:gravity="center"/>
</item>
<item>
<bitmap android:src="@drawable/abc_scrubber_control_to_pressed_mtrl_000"android:gravity="center"/>
</item>
</selector>
复制代码

引用一个drawable,也是一个熟知的selector组,通过对应的item,我们就可以实现在不同的状态下显示不同的thumb了,具体的样式我就不写了,再说ProgressBar的样式的时候也是有类似的操作的


不过你可能发现了,其实这几个样式看起来都差不多,是因为都是我使用Seekbar遇到的问题以及解决方法,我们细说


(1) 自定义的thumb的背景会裁剪出一个正方形,这对于不规则图形来讲是非常难看的



很简单一行



android:splitTrack="false"



修复0。0


(2)thumb的中心点对齐bar的边界,所以thumb是允许超出进度条一点的。有时候我们不需要



很简单一行



android:thumbOffset="1dp"



修复0,0


(3) 你可能发现就算没有写margin和padding,seekbar也不会占满父布局的,是因为它自带padding,所以如果需要去掉



很简单一行



android:paddingHorizontal="0dp"



修复0>0


(4)最后一个,SeekBar但是不想要滑块!为什么不用ProgressBar呢?没别的就是头铁!


很简单一行



android:thumb="@null"



修复0」0


但是要注意的是,此时Seekbar还是能点击的!所以需要把点击事件拦截掉


sb02.setOnTouchListener { _, _ -> true }
复制代码

真的修复0[]0


好了好了,thumb的监听事件还没说呢


            sb01.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) {
//进度发生改变时会触发
}

override fun onStartTrackingTouch(p0: SeekBar?) {
//按住SeekBar时会触发
}

override fun onStopTrackingTouch(p0: SeekBar?) {
//放开SeekBar时触发
}
})
复制代码

没啦,Seekbar就这么多。


还有一个,放在下次讲吧


对了,如果你感觉你的ProgressBar不够流畅,可以用以下这个


bar.setProgress(progress, true)
复制代码

4、结尾


更多复杂的进度条需求,靠widget的控件,肯定是难以实现的,我们接下来会讲述RatingBar,以及继承ProgressBar,做更多好看的进度条!


没啦,这次就这么多。


作者:AlbertZein
来源:juejin.cn/post/7196994916509286437
收起阅读 »