Android录制视频,硬编实现特效视频的录制(一)

Android硬编码特效视频Surface+Camera2的实现

前言

本文并非专业音视频领域的文章,只不过是其在 Android 方向的 Camera 硬件下结合一些常用的应用场景而已。

所以本文并不涉及到太专业的音视频知识,你只需要稍微了解一些以下知识点即可流畅阅读。

  1. Android 三种 Camera 分别如何预览,有什么区别?
  2. 三种 Camera 回调的数据 byte[] 格式有什么区别?如何转换如何旋转?
  3. 录制视频中常用的 NV21,I420,Surface 三种输入格式对哪一种COLOR_FORMAT完成编码?
  4. 如何配置 MediaCodec 的基本配置,帧率,分辨率,比特率,关键I帧的概念是否大致清楚。

了解这些之后,我们基于系统的录制 API 已经可以基本完成对应的自定义录制流程了。如果不是很了解,也可以参考看看我之前的文章或源代码,都有对应的示例。

这里再强调一句,如果只是需要完成简单的视频录制,完全用 CameraX 的录制用例就够了,真没必要折腾。

话接前文,既然 Camerax 中的 VideoCapture 这么好,那么可以通过它的录制视频方式在 Camera1 或 Camera2 上实现吗?

答案是否定的,它继承 CameraX 的用例,只能用于 CameraX , 但是我们可以把它的核心录制代码扒出来,自己实现一个录制视频的录制器。

有同学可能就说了,这不是脱裤子放屁,多此一举。这和直接用CameraX有什么区别?

额,其实也不然,因为我们最终是为了特效视频的录制直出,所以这些都是前置技能。

话不多说,咱们边走边说。

一、仿VideoCapture实现完整录制

前文的我们代码中,我们用到异步回调的方式,与同步的方式来进行自定义的 MediaCodec 编码,如果是异步的方式,我们添加了结束标志符就能正确的完成录制,问题倒是不大。

而我们使用同步的方式来进行录制,它的停止是由事件触发的,结果就是立即停止,导致正在编码的数据最终丢失了,结果就是我们录制的10秒的视频结果之后8秒。

而最好的解决方案应该是,收到停止录制的信号,设置一个flag,让编码器继续执行直到轨道中没有数据了才算录制完成,此时再通过一个回调的方式暴露最终的录制地址,这样才是比较好的效果。

也就是我们需要模仿 VideoCapture 实现的录制效果。

首先我们对输出的文件对象做封装,可以是多种类型的格式,一般我们使用的 File 的输出。

然后我们就能定义一个回调,当录制真正完成的时候我们回调出去,这里根据机型的性能决定的,如果高端机型编译很快,基本上是同步完成的,如果是低端机型编码速度比较慢,就会等待1秒左右完成最终的录制。

然后我们再对音视频的录制的一些参数做一个封装,这里可以定义一些默认的参数:

并且使用构建者模式的方式构建:

核心代码来咯,主线思路:

  1. 初始化工具类的时候,创建音频编码的线程 HandlerThread 与 视频编码的线程 HandlerThread。同时配置音频编码的配置与视频编码的配置。
  2. 当启动录制的时候,启动音频编码与视频编码,启动音频录制器,创建封装合成器。并通过各自的子线程开始编码。
  3. 音频编码器中正常写入同步时间戳之后,当完成编码发送给封装合成器去写入。
  4. 视频编码器中正常写入同步时间戳之后,当完成编码发送给封装合成器去写入。
  5. 在视频编码中通过写入停止录制的信号,判断当前是否需要真正完成录制,并且回调出去结果。

初始化工具类,创建并开启线程:

ini 复制代码
  public VideoCaptureUtils(@NonNull RecordConfig config, Size size) {
        this.mRecordConfig = config;
        this.mResolutionSize = size;

        // 初始化音视频编码线程
        mVideoHandlerThread = new HandlerThread(CameraXThreads.TAG + "video encoding thread");
        mAudioHandlerThread = new HandlerThread(CameraXThreads.TAG + "audio encoding thread");

        // 启动视频线程
        mVideoHandlerThread.start();
        mVideoHandler = new Handler(mVideoHandlerThread.getLooper());

        // 启动音频线程
        mAudioHandlerThread.start();
        mAudioHandler = new Handler(mAudioHandlerThread.getLooper());

        if (mCameraSurface != null) {
            mVideoEncoder.stop();
            mVideoEncoder.release();
            mAudioEncoder.stop();
            mAudioEncoder.release();
            releaseCameraSurface(false);
        }

        try {
            mVideoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME_TYPE);
            mAudioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME_TYPE);
        } catch (IOException e) {
            throw new IllegalStateException("Unable to create MediaCodec due to: " + e.getCause());
        }

        //设置音视频编码器与音频录制器
        setupEncoder();
    }

设置音视频编码器与音频录制器:

ini 复制代码
  void setupEncoder() {

        // 初始化视频编码器
        mVideoEncoder.reset();
        mVideoEncoder.configure(createVideoMediaFormat(), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

        if (mCameraSurface != null) {
            releaseCameraSurface(false);
        }

        //用于输入的Surface
        mCameraSurface = mVideoEncoder.createInputSurface();

        //初始化音频编码器
        mAudioEncoder.reset();
        mAudioEncoder.configure(createAudioMediaFormat(), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

        //初始化音频录制器
        if (mAudioRecorder != null) {
            mAudioRecorder.release();
        }

        mAudioRecorder = autoConfigAudioRecordSource();
        if (mAudioRecorder == null) {
            Log.e(TAG, "AudioRecord object cannot initialized correctly!");
        }

        //重置音视频轨道,设置未开始录制
        mVideoTrackIndex = -1;
        mAudioTrackIndex = -1;
        mIsRecording = false;
    }

启动录制的时候,启动音视频编码器,启动音频录制器,创建封装合成器:

scss 复制代码
    public void startRecording(
            @NonNull OutputFileOptions outputFileOptions,
            @NonNull Executor executor,
            @NonNull OnVideoSavedCallback callback) {

        if (Looper.getMainLooper() != Looper.myLooper()) {
            CameraXExecutors.mainThreadExecutor().execute(() -> startRecording(outputFileOptions, executor, callback));
            return;
        }

        Log.d(TAG, "startRecording");
        mIsFirstVideoSampleWrite.set(false);
        mIsFirstAudioSampleWrite.set(false);

        VideoSavedListenerWrapper postListener = new VideoSavedListenerWrapper(executor, callback);

        //重复录制的错误
        if (!mEndOfAudioVideoSignal.get()) {
            postListener.onError(ERROR_RECORDING_IN_PROGRESS, "It is still in video recording!", null);
            return;
        }

        try {
            // 启动音频录制器
            mAudioRecorder.startRecording();
        } catch (IllegalStateException e) {
            postListener.onError(ERROR_ENCODER, "AudioRecorder start fail", e);
            return;
        }

        try {
            // 音视频编码器启动
            Log.d(TAG, "audioEncoder and videoEncoder all start");
            mVideoEncoder.start();
            mAudioEncoder.start();

        } catch (IllegalStateException e) {

            postListener.onError(ERROR_ENCODER, "Audio/Video encoder start fail", e);
            return;
        }

        //启动封装器
        try {
            synchronized (mMediaMuxerLock) {
                mMediaMuxer = initMediaMuxer(outputFileOptions);
                Preconditions.checkNotNull(mMediaMuxer);
                mMediaMuxer.setOrientationHint(90); //设置视频文件的方向,参数表示视频文件应该被旋转的角度

                Metadata metadata = outputFileOptions.getMetadata();
                if (metadata != null && metadata.location != null) {
                    mMediaMuxer.setLocation(
                            (float) metadata.location.getLatitude(),
                            (float) metadata.location.getLongitude());
                }
            }
        } catch (IOException e) {

            postListener.onError(ERROR_MUXER, "MediaMuxer creation failed!", e);

            return;
        }

        //设置开始录制的Flag变量
        mEndOfVideoStreamSignal.set(false);
        mEndOfAudioStreamSignal.set(false);
        mEndOfAudioVideoSignal.set(false);
        mIsRecording = true;

        //子线程开启编码音频
        mAudioHandler.post(() -> audioEncode(postListener));

        //子线程开启编码视频
        mVideoHandler.post(() -> {
            boolean errorOccurred = videoEncode(postListener);
            if (!errorOccurred) {
                postListener.onVideoSaved(new OutputFileResults(mSavedVideoUri));
                mSavedVideoUri = null;
            }
        });
    }

音频的具体编码:

java 复制代码
    /**
     * 具体的音频编码方法,子线程中执行编码逻辑,无限执行知道录制结束。
     * 当编码完成之后写入到缓冲区
     */
    boolean audioEncode(OnVideoSavedCallback videoSavedCallback) {
        // Audio encoding loop. Exits on end of stream.
        boolean audioEos = false;
        int outIndex;

        while (!audioEos && mIsRecording && mAudioEncoder != null) {
            // Check for end of stream from main thread
            if (mEndOfAudioStreamSignal.get()) {
                mEndOfAudioStreamSignal.set(false);
                mIsRecording = false;
            }

            // get audio deque input buffer
            if (mAudioEncoder != null) {
                int index = mAudioEncoder.dequeueInputBuffer(-1);
                if (index >= 0) {
                    final ByteBuffer buffer = getInputBuffer(mAudioEncoder, index);
                    buffer.clear();
                    int length = mAudioRecorder.read(buffer, mAudioBufferSize);
                    if (length > 0) {
                        mAudioEncoder.queueInputBuffer(
                                index,
                                0,
                                length,
                                (System.nanoTime() / 1000),
                                mIsRecording ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                    }
                }


                // start to dequeue audio output buffer
                do {
                    outIndex = mAudioEncoder.dequeueOutputBuffer(mAudioBufferInfo, 0);
                    switch (outIndex) {
                        case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
                            synchronized (mMediaMuxerLock) {
                                mAudioTrackIndex = mMediaMuxer.addTrack(mAudioEncoder.getOutputFormat());
                                Log.d(TAG, "mAudioTrackIndex:" + mAudioTrackIndex + "mVideoTrackIndex:" + mVideoTrackIndex);
                                if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0) {
                                    mMuxerStarted = true;
                                    Log.d(TAG, "media mMuxer start by audio");
                                    mMediaMuxer.start();
                                }
                            }
                            break;
                        case MediaCodec.INFO_TRY_AGAIN_LATER:
                            break;
                        default:
                            audioEos = writeAudioEncodedBuffer(outIndex);
                    }
                } while (outIndex >= 0 && !audioEos);
            }
        }

        //当循环结束,说明停止录制了,停止音频录制器
        try {
            Log.d(TAG, "audioRecorder stop");
            mAudioRecorder.stop();
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_ENCODER, "Audio recorder stop failed!", e);
        }

        //停止音频编码器
        try {
            mAudioEncoder.stop();
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_ENCODER, "Audio encoder stop failed!", e);
        }

        Log.d(TAG, "Audio encode thread end");

        mEndOfVideoStreamSignal.set(true);

        return false;
    }

音频数据写入封装合成器中:

arduino 复制代码
    /**
     * 将已编码《音频流》写入缓冲区
     */
    private boolean writeAudioEncodedBuffer(int bufferIndex) {
        ByteBuffer buffer = getOutputBuffer(mAudioEncoder, bufferIndex);
        buffer.position(mAudioBufferInfo.offset);
        if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0
                && mAudioBufferInfo.size > 0
                && mAudioBufferInfo.presentationTimeUs > 0) {
            try {
                synchronized (mMediaMuxerLock) {
                    if (!mIsFirstAudioSampleWrite.get()) {
                        Log.d(TAG, "First audio sample written.");
                        mIsFirstAudioSampleWrite.set(true);
                    }

                    mMediaMuxer.writeSampleData(mAudioTrackIndex, buffer, mAudioBufferInfo);
                }
            } catch (Exception e) {
                Log.e(TAG, "audio error:size="
                        + mAudioBufferInfo.size
                        + "/offset="
                        + mAudioBufferInfo.offset
                        + "/timeUs="
                        + mAudioBufferInfo.presentationTimeUs);
                e.printStackTrace();
            }
        }
        mAudioEncoder.releaseOutputBuffer(bufferIndex, false);
        return (mAudioBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
    }

接下来就是视频数据的编码,内部包含一些信号Flag的配置,先设置音频信号停止,然后内部设置了视频信号的停止,当视频停止之后,停止了封装合成器,并把信号与变量都重置。

ini 复制代码
    /**
     * 具体的视频编码方法,子线程中执行编码逻辑,无限执行知道录制结束。
     * 当编码完成之后写入到缓冲区
     */
    boolean videoEncode(@NonNull OnVideoSavedCallback videoSavedCallback) {
        // Main encoding loop. Exits on end of stream.
        boolean errorOccurred = false;
        boolean videoEos = false;

        while (!videoEos && !errorOccurred && mVideoEncoder != null) {
            // Check for end of stream from main thread
            if (mEndOfVideoStreamSignal.get()) {
                mVideoEncoder.signalEndOfInputStream();
                mEndOfVideoStreamSignal.set(false);
            }

            // Deque buffer to check for processing step
            int outputBufferId = mVideoEncoder.dequeueOutputBuffer(mVideoBufferInfo, DEQUE_TIMEOUT_USEC);
            switch (outputBufferId) {
                case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
                    if (mMuxerStarted) {
                        videoSavedCallback.onError(ERROR_ENCODER, "Unexpected change in video encoding format.", null);
                        errorOccurred = true;
                    }

                    synchronized (mMediaMuxerLock) {
                        mVideoTrackIndex = mMediaMuxer.addTrack(mVideoEncoder.getOutputFormat());
                        Log.d(TAG, "mAudioTrackIndex:" + mAudioTrackIndex + "mVideoTrackIndex:" + mVideoTrackIndex);
                        if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0) {
                            mMuxerStarted = true;
                            Log.i(TAG, "media mMuxer start by video");
                            mMediaMuxer.start();
                        }
                    }
                    break;
                case MediaCodec.INFO_TRY_AGAIN_LATER:
                    // Timed out. Just wait until next attempt to deque.
                    break;
                default:
                    videoEos = writeVideoEncodedBuffer(outputBufferId);
            }
        }

        //如果循环结束,说明录制完成,停止视频编码器,释放资源
        try {
            Log.i(TAG, "videoEncoder stop");
            mVideoEncoder.stop();
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_ENCODER, "Video encoder stop failed!", e);
            errorOccurred = true;
        }

        //因为视频编码会更耗时,所以在此停止封装器的执行
        try {
            synchronized (mMediaMuxerLock) {
                if (mMediaMuxer != null) {
                    if (mMuxerStarted) {
                        mMediaMuxer.stop();
                    }
                    mMediaMuxer.release();
                    mMediaMuxer = null;
                }
            }
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_MUXER, "Muxer stop failed!", e);
            errorOccurred = true;
        }

        if (mParcelFileDescriptor != null) {
            try {
                mParcelFileDescriptor.close();
                mParcelFileDescriptor = null;
            } catch (IOException e) {
                videoSavedCallback.onError(ERROR_MUXER, "File descriptor close failed!", e);
                errorOccurred = true;
            }
        }

        //设置一些Flag为停止状态
        mMuxerStarted = false;
        mEndOfAudioVideoSignal.set(true);

        Log.d(TAG, "Video encode thread end.");

        return errorOccurred;
    }

已编码的数据写入到封装合成器:

scss 复制代码
    /**
     * 将已编码的《视频流》写入缓冲区
     */
    private boolean writeVideoEncodedBuffer(int bufferIndex) {
        if (bufferIndex < 0) {
            Log.e(TAG, "Output buffer should not have negative index: " + bufferIndex);
            return false;
        }
        // Get data from buffer
        ByteBuffer outputBuffer = mVideoEncoder.getOutputBuffer(bufferIndex);

        // Check if buffer is valid, if not then return
        if (outputBuffer == null) {
            Log.d(TAG, "OutputBuffer was null.");
            return false;
        }

        // Write data to mMuxer if available
        if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0 && mVideoBufferInfo.size > 0) {
            outputBuffer.position(mVideoBufferInfo.offset);
            outputBuffer.limit(mVideoBufferInfo.offset + mVideoBufferInfo.size);
            mVideoBufferInfo.presentationTimeUs = (System.nanoTime() / 1000);

            synchronized (mMediaMuxerLock) {
                if (!mIsFirstVideoSampleWrite.get()) {
                    Log.d(TAG, "First video sample written.");
                    mIsFirstVideoSampleWrite.set(true);
                }
                Log.d(TAG, "write video Data");
                mMediaMuxer.writeSampleData(mVideoTrackIndex, outputBuffer, mVideoBufferInfo);
            }
        }

        // Release data
        mVideoEncoder.releaseOutputBuffer(bufferIndex, false);

        // Return true if EOS is set
        return (mVideoBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
    }

停止录制,我们只是设置了变量与音频停止的信号:

scss 复制代码
    /**
     * 停止录制
     */
    public void stopRecording() {
        if (Looper.getMainLooper() != Looper.myLooper()) {
            CameraXExecutors.mainThreadExecutor().execute(() -> stopRecording());
            return;
        }

        Log.d(TAG, "stopRecording");

        if (!mEndOfAudioVideoSignal.get() && mIsRecording) {
            // 停止音频编码器线程并等待视频编码器与封装器停止
            mEndOfAudioStreamSignal.set(true);
        }
    }


    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public void release() {
        stopRecording();

        if (mRecordingFuture != null) {
            mRecordingFuture.addListener(() -> releaseResources(),
                    CameraXExecutors.mainThreadExecutor());
        } else {
            releaseResources();
        }
    }

    private void releaseResources() {
        mVideoHandlerThread.quitSafely();
        mAudioHandlerThread.quitSafely();

        if (mAudioEncoder != null) {
            mAudioEncoder.release();
            mAudioEncoder = null;
        }

        if (mAudioRecorder != null) {
            mAudioRecorder.release();
            mAudioRecorder = null;
        }

        if (mCameraSurface != null) {
            releaseCameraSurface(true);
        }
    }

结果就是先停止了音频信号,然后停止了视频信号,当视频编码全部完成之后,停止了封装合成器,此时回调出去告知用户完成录制:

二、结合Camera2的使用

之前我们是在 CameraX 中使用的,现在我们如何在 Camera2 中使用呢?

由于我们的输入源是 Surface,录制的方式是这样:

format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);

InputSurface = mVideoEncoder.createInputSurface();

所以我们也很灵活,直接把输入的 Surface 绑定到 Camera2 的 Preview 目标上即可:

这里我们还是以之前讲到过的 Camera2 封装方式来进行绑定:

scss 复制代码
public class Camera2SurfaceProvider extends BaseCommonCameraProvider {

    public Camera2SurfaceProvider(Activity mContext) {
        super(mContext);
        ...
    }

    private void initCamera() {

        ...

        if (mCameraInfoListener != null) {
            mCameraInfoListener.getBestSize(outputSize);

            //初始化录制工具类
            VideoCaptureUtils.RecordConfig recordConfig = new VideoCaptureUtils.RecordConfig.Builder().build();

            //Surface 录制工具类
            videoCaptureUtils = new VideoCaptureUtils(recordConfig, outputSize);
        }
    }

      public void startPreviewSession(Size size) {

        try {

            releaseCameraSession(session);
            mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);

            ...

            //添加预览的TextureView
             Surface previewSurface = new Surface(surfaceTexture);
            mPreviewBuilder.addTarget(previewSurface);
            outputs.add(previewSurface);

            
            //这里设置输入Surface编码的数据源
            //使用 mVideoEncoder.createInputSurface() 的方式创建的Surface
            Surface inputSurface = videoCaptureUtils.mCameraSurface;
            mPreviewBuilder.addTarget(inputSurface);
            outputs.add(inputSurface);

            mCameraDevice.createCaptureSession(outputs, mStateCallBack, mCameraHandler);

        } catch (CameraAccessException e) {
            e.printStackTrace();
        }

    }

等于是把硬件摄像头的数据绑定到不同的 Surface 上了,绑定到预览的 TextureView 上面就是展示画面了,绑定到了录制的 Surface 上就开始录制了。

那么此时就有一个问题,如果我们在预览的 Surface 上面展示特效的滤镜,录制的 Surface 上能最终呈现出来吗?

其实上面那一句总结已经很明白了,现在大家的数据来源都是硬件摄像头的数据,大家是平级的,你鲁迅的特效跟我周树人有什么关系。

预览的Surface,录制的Surface,两个是平级关系,各自拿到的是同一个数据大家各玩各的,你做你的显示,我做我的编码,可能连大小、尺寸、比例都不一致,就更不说特效什么的了。

三、预览与录制

我不信!我看看效果。

这里给出一个图,整体是预览的特效图加上了灰度的滤镜,右边是录制的效果图。可以看到预览与录制是各玩各的。

我不信!静态图是你画上去的,我要看录制出来的效果!

为什么会这样?因为预览的Surface与录制的Surface是平级的,他们不是类似OkHttp那样的拦截器那样的方式,你处理了我再根据你处理的结果进行操作。

那我能不能让预览的Surface把它展示的数据的传递过来呢,这...有想法,但他们甚至都不是一个线程的,先不说能不能拿到处理后的数据,就说线程间的通信也存在性能开销与延时。有想法但不靠谱。

我看XX应用就能这样,为什么别人能做你不能做?

这个当然是能做的。其实目前市面上比较常用的方案就是把 特效/滤镜 效果抽取出来,然后分别在预览的Surface和录制的Surface上生效。

关于这一点后期会讲到。

总结

本文是特效录制的第一步,完成了 inputSurface 输入源的硬编码,以及在 Camera2 上面的使用,同时加入预览 Surface 与录制 Surface ,已经它们的呈现效果。

我们明白了预览与录制的关系,为什么不能不能实现特效录制的直出效果,以及如何能实现特效录制直出的方法。

我们一步一步来,直到我们最终完成录制特效直出的效果,下一篇文章我们会先说一下应用开发中常用的滤镜/特效的几种实现方案。

本文如果贴出的代码有不全的,可以点击源码打开项目进行查看,【传送门】。同时你也可以关注我的开源项目,后续一些改动与优化还有新功能都会持续更新。

我这么菜为什么会出这样的音视频文章?由于本人的岗位是应用开发,而业务需要一些轻度音视频录制相关的功能(可以参考拼多多评论页面)。属于那种可以特效录制视频,也可以选择本地视频进行处理,可以选择去除原始音频加入自定义的背景音乐,可以添加简单的文本字幕或标签,进行简单的裁剪之类的功能,在2023年的今天来看,其实都已经算是应用开发的范畴,并没有涉及专业的音视频知识(并没有剪映抖音快手那么NB的效果)。我本人其实并不太懂专业音视频知识也不是擅长这方面,如果本文的讲解有什么错漏的地方,希望同学们一定要指出哦。有疑问也可以评论区交流学习进步,谢谢!

当然如果觉得本文还不错对你有些帮助的话,还请点赞支持一下哦,你的支持是我最大的动力啦!

Ok,这一期就此完结。

相关推荐
阿巴斯甜15 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker16 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952717 小时前
Andorid Google 登录接入文档
android
黄林晴18 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android