普通视图

发现新文章,点击刷新页面。
昨天以前徐宜生

Android壁纸还是B站玩得花

作者 xuyisheng
2022年8月24日 14:15
Android壁纸还是B站玩得花

设置系统壁纸这个功能,对于应用层App来说,场景其实并不多,但在一些场景的周边活动中,确也是一种提升品牌粘性的方式,就好比某个活动中创建的角色的壁纸美图,这些就可以新增一个设置壁纸的功能。

从原始的Android开始,系统就支持设置两种方式的壁纸,一种是静态壁纸,另一种是动态壁纸。

静态壁纸

静态壁纸没什么好说的,通过系统提供的API一行代码就完事了。

最简单代码如下所示。

val wallpaperManager = WallpaperManager.getInstance(this)
try {
    val bitmap = ContextCompat.getDrawable(this, R.drawable.ic_launcher_background)?.toBitmap()
    wallpaperManager.setBitmap(bitmap)
} catch (e: Exception) {
    e.printStackTrace()
}

除了setBitmap之外,系统还提供了setResource、setStream,一共三种方式来设置静态壁纸。

三种方式殊途同归,都是设置一个Bitmap给系统API。

动态壁纸

动态壁纸就有点意思了,很多手机ROM也内置了一些动态壁纸,别以为这些是什么新功能,从Android 1.5开始,就已经支持这种方式了。只不过做的人比较少,为啥呢,主要是没有什么特别合适的场景,而且动态壁纸,会比静态壁纸更加耗电,所以大部分时候,我们都没用这种方式。

壁纸作为一个系统服务,在系统启动时,不管是动态壁纸还是静态壁纸,都会以一个Service的形式运行在后台——WallpaperService,它的Window类型为TYPE_WALLPAPER,WallpaperService提供了一个SurfaceHolder来暴露给外界来对画面进行渲染,这就是设置壁纸的基本原理。

创建一个动态壁纸,需要继承系统的WallpaperService,并提供一个WallpaperService.Engin来进行渲染,下面这个就是一个模板代码。

class MyWallpaperService : WallpaperService() {
    override fun onCreateEngine(): Engine = WallpaperEngine()

    inner class WallpaperEngine : WallpaperService.Engine() {
        lateinit var mediaPlayer: MediaPlayer

        override fun onSurfaceCreated(holder: SurfaceHolder?) {
            super.onSurfaceCreated(holder)
        }

        override fun onCommand(action: String?, x: Int, y: Int, z: Int, extras: Bundle?, resultRequested: Boolean): Bundle {
            try {
                Log.d("xys", "onCommand: $action----$x---$y---$z")
                if ("android.wallpaper.tap" == action) {
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
            return super.onCommand(action, x, y, z, extras, resultRequested)
        }

        override fun onVisibilityChanged(visible: Boolean) {
            if (visible) {
            } else {
            }
        }

        override fun onDestroy() {
            super.onDestroy()
        }
    }
}

然后在manifest中注册这个Service。

<service
    android:name=".MyWallpaperService"
    android:exported="true"
    android:label="Wallpaper"
    android:permission="android.permission.BIND_WALLPAPER">
    <intent-filter>
        <action android:name="android.service.wallpaper.WallpaperService" />
    </intent-filter>

    <meta-data
        android:name="android.service.wallpaper"
        android:resource="@xml/my_wallpaper" />
</service>

另外,还需要申请相应的权限。

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

最后,在xml文件夹中新增一个描述文件,对应上面resource标签的文件。

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:thumbnail="@mipmap/ic_launcher" />

动态壁纸只能通过系统的壁纸预览界面来进行设置。

val localIntent = Intent()
localIntent.action = WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER
localIntent.putExtra(
    WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
    ComponentName(applicationContext.packageName, MyWallpaperService::class.java.name))
startActivity(localIntent)

这样我们就可以设置一个动态壁纸了。

玩点花

既然是使用提供的SurfaceHolder来进行渲染,那么我们所有能够使用到SurfaceHolder的场景,都可以来进行动态壁纸的创建了。

一般来说,有三种比较常见的使用场景。

  • MediaPlayer
  • Camera
  • SurfaceView

这三种也是SurfaceHolder的常用使用场景。

首先来看下MediaPlayer,这是最简单的方式,可以设置一个视频,在桌面上循环播放。

inner class WallpaperEngine : WallpaperService.Engine() {
    lateinit var mediaPlayer: MediaPlayer

    override fun onSurfaceCreated(holder: SurfaceHolder?) {
        super.onSurfaceCreated(holder)
        mediaPlayer = MediaPlayer.create(applicationContext, R.raw.testwallpaper).also {
            it.setSurface(holder!!.surface)
            it.isLooping = true
        }
    }

    override fun onVisibilityChanged(visible: Boolean) {
        if (visible) {
            mediaPlayer.start()
        } else {
            mediaPlayer.pause()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        if (mediaPlayer.isPlaying) {
            mediaPlayer.stop()
        }
        mediaPlayer.release()
    }
}

接下来,再来看下使用Camera来刷新Surface的。

inner class WallpaperEngine : WallpaperService.Engine() {
    lateinit var camera: Camera

    override fun onVisibilityChanged(visible: Boolean) {
        if (visible) {
            startPreview()
        } else {
            stopPreview()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        stopPreview()
    }

    private fun startPreview() {
        camera = Camera.open()
        camera.setDisplayOrientation(90)
        try {
            camera.setPreviewDisplay(surfaceHolder)
            camera.startPreview()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

    private fun stopPreview() {
        try {
            camera.stopPreview()
            camera.release()
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

同时需要添加下Camera的权限。

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

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

由于这里偷懒,没有使用最新的CameraAPI,也没有动态申请权限,所以你需要自己手动去授权。

最后一种,通过Surface来进行自绘渲染。

val holder = surfaceHolder
var canvas: Canvas? = null
try {
    canvas = holder.lockCanvas()
    if (canvas != null) {
    		canvas.save()
        // Draw Something
    }
} finally {
    if (canvas != null) holder.unlockCanvasAndPost(canvas)
}

这里就可以完全使用Canvas的API来进行绘制了。

这里有一个比较复杂的绘制Demo,可以给大家参考。

https://www.developer.com/design/building-an-android-live-wallpaper/

有意思的方法

虽然WallpaperService是一个系统服务,但它也提供了一些比较有用的回调函数来帮助我们做一些有意思的东西。

onOffsetsChanged

当用户在手机桌面滑动时,有的壁纸图片会跟着左右移动,这个功能就是通过这个回调来实现的,在手势滑动的每一帧都会回调这个方法。

xOffset:x轴滑动的百分比

yOffset:y轴滑动百分比

xOffsetStep:x轴桌面Page数进度

yOffsetStep:y轴桌面Page数进度

xPixelOffset:x轴像素偏移量

通过这个函数,就可以拿到手势的移动惯量,从而对图片做出一些修改。

onTouchEvent、onCommand

这两个方法,都可以获取用户的点击行为,通过判断点击类型,就可以针对用户的特殊点击行为来做一些逻辑处理,例如点击某些特定的地方时,唤起App,或者打开某个界面等等。

class MyWallpaperService : WallpaperService() {
  override fun onCreateEngine(): Engine = WallpaperEngine()

  private inner class WallpaperEngine : WallpaperService.Engine() {

    override fun onTouchEvent(event: MotionEvent?) {
      // on finder press events
      if (event?.action == MotionEvent.ACTION_DOWN) {
        // get the canvas from the Engine or leave
        val canvas = surfaceHolder?.lockCanvas() ?: return
        // TODO
        // update the surface
        surfaceHolder.unlockCanvasAndPost(canvas)
      }
    }
  }
}

B站怎么玩的呢

不得不说,B站在这方面玩的是真的花,最近B站里面新加了一个异想少女系列,你可以设置一个动态壁纸,同时还带交互,有点意思。

Android壁纸还是B站玩得花

其实类似这样的交互,基本上都是通过OpenGL或者是RenderScript来实现的,通过GLSurfaceView来进行渲染,从而实现了一些复杂的交互,下面这些例子,就是一些实践。

https://github.com/PavelDoGreat/Unity-Android-Live-Wallpaper

https://github.com/jinkg/live-wallpaper

https://www.cnblogs.com/YFEYI/category/1425066.html

https://code.tutsplus.com/tutorials/creating-live-wallpapers-on-android--mobile-9516

但是B站的这个效果,显然比上面的方案更加成熟和完整,所以,通过调研可以发现,它们使用的是Live2D的方案。

https://www.live2d.com/

动态壁纸的Demo如下。

https://github.com/Live2D/CubismAndroidLiveWallpaper

这个东西是小日子的一个SDK,专业做2D可交互纸片人,这个东西已经出来很久了,前端之前用它来做网页的看板娘,现在客户端又拿来做动态壁纸,风水轮流换啊,想要使用的,可以参考它们官方的Demo。

但是官方的动态壁纸Demo在客户端是有Bug的,会存在各种闪的问题,由于我本身不懂OpenGL,所以也无法解决,通过回退Commit,发现可以直接使用这个CommitID : Merge pull request #2 from Live2D/create-new-function ,就没有闪的问题。
a9040ddbf99d9a130495e4a6190592068f2f7a77

好了,B站YYDS,但我觉得这东西的使用场景太有限了,而且特别卡,极端影响功耗,所以,要不要这么卷呢,你看着办吧。

Flutter混编工程之打通纹理之路

作者 xuyisheng
2022年8月5日 16:24
Flutter混编工程之打通纹理之路

Flutter的图片系统基于Image的一套架构,但是这东西的性能,实在不敢恭维,感觉还停留在Native开发至少5年前的水平,虽然使用上非常简单,一个Image.network走天下,但是不管是解码性能还是加载速度,抑或是内存占用和缓存逻辑,都远远不如Native的图片库,特别是Glide。虽然Google一直在有计划优化Flutter Image的性能,但现阶段,体验最佳的图片加载方式,还是通过插件,使用Glide来进行加载。

所以,在混编的大环境下,将Flutter的图片加载功能托管给原生,是最合理且性能最佳的方案。

那么对于桥接到原生的方案来说,主要有两个方向,一个是通过Channel来传递加载的图像的二进制数据流,然后在Flutter内解析二进制流后来解析图像,另一个则是通过外接纹理的方式,来共享图像内存,显然,第二种方案是更好的解决方案,不管从内存消耗还是传输性能上来说,外接纹理的方案,都是Flutter桥接Native图片架构的最佳选择。

虽然说外接纹理方案比较好,但是网络上对于这个方案的研究却不是很多,比较典型的是Flutter官方Plugins中的视频渲染的方案,地址如下所示。

https://github.com/flutter/plugins/tree/main/packages/video_player

这是我们研究外接纹理的第一手方案,除此之外,闲鱼开源的PowerImage,也是基于外接纹理的方案来实现的,同时他们也给出了基于外接纹理的一系列方案的预研和技术基础研究,这些也算是我们了解外接纹理的最佳途径,但是,基于阿里的一贯风格,我们不太敢直接大范围使用PowerImage,研究研究外接纹理,来实现一套自己的方案,其实是最好的。

https://www.infoq.cn/article/MLMK2bx8uaNb5xJm13SW

https://juejin.cn/post/6844903662548942855

外接纹理的基本概念

其实上面两篇闲鱼的文章,已经把外接纹理的概念讲解的比较清楚了,下面我们就简单的总结一下。

首先,Flutter的渲染机制与Native渲染完全隔离,这样的好处是Flutter可以完全控制Flutter页面的绘制和渲染,但坏处是,Flutter在获取一些Native的高内存数据时,通过Channel来进行传递就会导致浪费和性能压力,所以Flutter提供了外接纹理,来处理这种场景。

在Flutter中,系统提供了一个特殊的Widget——Texture Widget。Texture在Flutter的Widget Tree中是一个特殊的Layer,它不参与其它Layer的绘制,它的数据全部由Native提供,Native会将动态渲染数据,例如图片、视频等数据,写入到PixelBuffer,而Flutter Engine会从GPU中拿到相应的渲染数据,并渲染到对应的Texture中。

Texture实战

Texture方案来加载图片的过程实际上是比较长的,涉及到Flutter和Native的双端合作,所以,我们需要创建一个Flutter Plugin来完成这个功能的调用。

我们创建一个Flutter Plugin,Android Studio会自动帮我们生成对应的插件代码和Example代码。

整体流程

Flutter和Native之间,通过外接纹理的方式来共享内存数据,它们之间相互关联的纽带,就是一个TextureID,通过这个ID,我们可以分别关联到Native侧的内存数据,也可以关联到Flutter侧的Texture Widget,所以,一切的故事,都是从TextureID开始的。

Flutter加载图片的起点,从Texture Widget开始,Widget初始化的时候,会通过Channel请求Native,创建一个新的TextureID,并将这个TextureID返回给Flutter,将当前Texture Widget与这个ID进行绑定。

接下来,Flutter侧将要加载的图片Url通过Channel请求Native,Native侧通过TextureID找到对应的Texture,并在Native侧通过Glide,用传递的Url进行图片加载,将图片资源写入Texture,这个时候,Flutter侧的Texture Widget就可以实时获取到渲染信息了。

最后,在Flutter侧的Texture Widget回收时,需要对当前的Texture进行回收,从而将这部分内存释放。

以上就是整个外接纹理方案的实现过程。

Flutter侧

首先,我们需要创建一个Channel来注册上面提到的几个方法调用。

class MethodChannelTextureImage extends TextureImagePlatform {
  @visibleForTesting
  final methodChannel = const MethodChannel('texture_image');

  @override
  Future<int?> initTextureID() async {
    final result = await methodChannel.invokeMethod('initTextureID');
    return result['textureID'];
  }

  @override
  Future<Size> loadByTextureID(String url, int textureID) async {
    var params = {};
    params["textureID"] = textureID;
    params["url"] = url;
    final size = await methodChannel.invokeMethod('load', params);
    return Size(size['width']?.toDouble() ?? 0, size['height']?.toDouble() ?? 0);
  }

  @override
  Future<int?> disposeTextureID(int textureID) async {
    var params = {};
    params["textureID"] = textureID;
    final result = await methodChannel.invokeMethod('disposeTextureID', params);
    return result['textureID'];
  }
}

接下来,回到Flutter Widget中,封装一个Widget用来管理Texture。

在这个封装的Widget里面,你可以对尺寸作调整,或者是对生命周期进行管理,但核心只有一个,那就是创建一个Texture。

Texture(textureId: _textureID),

使用前面创建的Channel,来完成流程的加载。

@override
void initState() {
  initTextureID().then((value) {
    _textureID = value;
    _textureImagePlugin.loadByTextureID(widget.url, _textureID).then((value) {
      if (mounted) {
        setState(() => bitmapSize = value);
      }
    });
  });
  super.initState();
}

Future<int> initTextureID() async {
  int textureID;
  try {
    textureID = await _textureImagePlugin.initTextureID() ?? -1;
  } on PlatformException {
    textureID = -1;
  }
  return textureID;
}

@override
void dispose() {
  if (_textureID != -1) {
    _textureImagePlugin.disposeTextureID(_textureID);
  }
  super.dispose();
}

这样整个Flutter侧的流程就完成了——创建TextureID——>绑定TextureID和Url——>回收TextureID。

Native侧

Native侧的处理都集中在Plugin的注册类中,在注册时,我们需要创建TextureRegistry,这是系统提供给我们使用外接纹理的入口。

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    channel = MethodChannel(flutterPluginBinding.binaryMessenger, "texture_image")
    channel.setMethodCallHandler(this)
    context = flutterPluginBinding.applicationContext
    textureRegistry = flutterPluginBinding.textureRegistry
}

接下来,我们需要对Channel进行处理,分别实现前面提到的三个方法。

"initTextureID" -> {
    val surfaceTextureEntry = textureRegistry?.createSurfaceTexture()
    val textureId = surfaceTextureEntry?.id() ?: -1
    val reply: MutableMap<String, Long> = HashMap()
    reply["textureID"] = textureId
    textureSurfaces[textureId] = surfaceTextureEntry
    result.success(reply)
}

initTextureID方法,核心功能就是从TextureRegistry中创建一个surfaceTextureEntry,textureId就是它的id属性。

"load" -> {
    val textureId: Int = call.argument("textureID") ?: -1
    val url: String = call.argument("url") ?: ""
    if (textureId >= 0 && url.isNotBlank()) {
        Glide.with(context).load(url).skipMemoryCache(true).into(object : CustomTarget<Drawable>() {
            override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
                if (resource is BitmapDrawable) {
                    val bitmap = resource.bitmap
                    val imageWidth: Int = bitmap.width
                    val imageHeight: Int = bitmap.height
                    val surfaceTextureEntry: SurfaceTextureEntry = textureSurfaces[textureId.toLong()]!!
                    surfaceTextureEntry.surfaceTexture().setDefaultBufferSize(imageWidth, imageHeight)
                    val surface =
                        if (surfaceMap.containsKey(textureId.toLong())) {
                            surfaceMap[textureId.toLong()]
                        } else {
                            val surface = Surface(surfaceTextureEntry.surfaceTexture())
                            surfaceMap[textureId.toLong()] = surface
                            surface
                        }
                    val canvas: Canvas = surface!!.lockCanvas(null)
                    canvas.drawBitmap(bitmap, 0F, 0F, null)
                    surface.unlockCanvasAndPost(canvas)
                    val reply: MutableMap<String, Int> = HashMap()
                    reply["width"] = bitmap.width
                    reply["height"] = bitmap.height
                    result.success(reply)
                }
            }

            override fun onLoadCleared(placeholder: Drawable?) {
            }
        })
    }
}

load方法,就是我们熟悉的Glide了,通过Glide来获取对应Url的图片数据,再通过SurfaceTextureEntry,来创建Surface对象,并将Glide返回的数据,写入到Surface中,最后,将图像的宽高回传给Flutter,做后续的一些处理。

"disposeTextureID" -> {
    val textureId: Int = call.argument("textureID") ?: -1
    val textureIdLong = textureId.toLong()
    if (surfaceMap.containsKey(textureIdLong) && textureSurfaces.containsKey(textureIdLong)) {
        val surfaceTextureEntry: SurfaceTextureEntry? = textureSurfaces[textureIdLong]
        val surface = surfaceMap[textureIdLong]
        surfaceTextureEntry?.release()
        surface?.release()
        textureSurfaces.remove(textureIdLong)
        surfaceMap.remove(textureIdLong)
    }
}

disposeTextureID方法,就是对dispose的Texture进行回收,否则的话,Texture一直在申请新的内存,就会导致Native内存一直上涨而不会被回收,所以,在Flutter侧调用dispose后,我们需要对相应TextureID对应的资源进行回收。

以上,我们就完成了Native的处理,通过和Flutter侧配合,借助Glide的高效加载能力,我们就完成就一次完美的图片加载过程。

总结

通过外接纹理来加载图片,我们可以有下面这些优点。

  • 复用Native的高效、稳定的图片加载机制,包括缓存、编解码、性能等
  • 降低多套方案的内存消耗,降低App的运行内存
  • 打通Native和Flutter,图片资源可以进行内存共享

但是,当前这个方案也并不是「完美的」,只能说,上面的方案是一个「可用」的方案,但还远远没有达到「好用」的级别,为了更好的实现外接纹理的方案,我们还需要处理一些细节。

  • 复用、复用,还是TMD复用,对于同Url的图片、加载过的图片,在Native端和Flutter端,都应该再做一套缓存机制
  • 对于Gif和Webp的支持,目前为止,我们都是处理的静态图片,还未添加动态内容的处理,当然这一定是可以的,只不过我们还没支持
  • Channel的Batch调用,对于一个列表来说,可能一帧中会同时产生大量的图片请求,虽然现在Channel的性能有了很大的提升,但是如果能对Channel的调用做一个缓冲区,那么对于特别频繁的调用来说,会优化一部分Channel的性能

所以这只是第一篇,后面我们会继续针对上面的问题进行优化,请各位拭目以待。

❌
❌