IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> android Paddle 视频字幕识别 -> 正文阅读

[移动开发]android Paddle 视频字幕识别

OcrDemo

介绍

本项目是给盲人提供的一款看电影的实时英文字幕读取的软件。
主要采用的技术:MediaProjection截取 + AccessibilityService + 动态横竖屏切换实时读取 + 百度底层开源OCR
攻克的技术难点:
1,后台截取图片时,每次截取都会弹窗授权
2,获取不到播放页面
3,获取不到视频软件是否打开
4,使用handler导致性能卡顿
5,竖屏截图时,切换横屏时导致横屏截取的是竖屏的大小
6,AccessibilityService的onkeyeven方法始终无反应

git地址

https://gitee.com/gewussj/OcrDemo.git

APK

https://download.csdn.net/download/qq_39469700/85013042

界面展示

在这里插入图片描述

1:后台截图

相关代码贴出来 因为涉及公司秘密 所以不能贴出全部代码 这是我封装的工具类

    * 截屏相关工具类
    * 录屏没有封装
    * 可以一次授权 连续截屏
    */
   public class MediaProjrct implements ImageReader.OnImageAvailableListener {
   
       /**
        * 截屏管理器
        */
       private MediaProjectionManager systemService;
       private MediaProjection mediaProjection;
       @SuppressLint("StaticFieldLeak")
       public static MediaProjrct mediaProjrct;
       /**
        * 这个可以理解为截取的屏幕
        */
       private VirtualDisplay virtualDisplay;
       /**
        * 这个是截取的屏幕返回的img
        */
       private ImageReaders imageReaders;
       @SuppressLint("StaticFieldLeak")
       public static Activity activity;
       private int densityDpi;
   
       /**
        * 需要裁剪的宽高
        * 这里我直接赋值屏幕宽高
        */
       int mWith;
       int mHight;
   
       /**
        * 类创建 就初始化相关权限
        * @param activity
        */
       @RequiresApi(api = Build.VERSION_CODES.M)
       public void with(Activity activity) {
           if (mediaProjrct == null) {
               MediaProjrct.activity = activity;
               mediaProjrct = new MediaProjrct();
           }
           initCutManger();
       }
   
       /**
        * 获取截瓶权限
        * @return
        */
       @RequiresApi(api = Build.VERSION_CODES.M)
       @SuppressLint("WrongConstant")
       private void initCutManger() {
           densityDpi = activity.getResources().getDisplayMetrics().densityDpi;
           systemService = (MediaProjectionManager) activity.getSystemService(MEDIA_PROJECTION_SERVICE);
           Intent screenCaptureIntent = systemService.createScreenCaptureIntent();
           activity.startActivityForResult(screenCaptureIntent, ApkNames.PERMISSION_CODE);
       }
   
       /**
        * 交给actvity 在mainactivity中的相同重载的方法中使用
        * @param requestCode
        * @param resultCode
        * @param data
        */
       @RequiresApi(api = Build.VERSION_CODES.M)
       public void onActivityReust(int requestCode, int resultCode, Intent data) {
           if (requestCode != ApkNames.PERMISSION_CODE) {
               return;
           }
           if (resultCode != RESULT_OK) {
               return;
           }
           mediaProjection = systemService.getMediaProjection(resultCode, data);
       }
   
   
       /**
        * 截屏
        * VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
        * 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
        * 此处的 with  和 hight 表示
        * 截图的宽和高
        * 但是由于不匹配会有黑色边框  所以加入三木
        * 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏  然后导致有黑色边框
        * 反之直接创建屏幕的大小
        * <p>
        * 问题场景
        * 1 竖屏状态时需要截取的是视频view的宽和高  但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
        * 2 横屏状态下 直接截取当前屏幕的宽和高 。
        * <p>
        * 以上会有一个问题   就是长时间  横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
        */
       public void startScreenCapture(int with, int hight) {
           this.mWith = with;
           this.mHight = hight;
           if (null != mediaProjection) {
               @SuppressLint("WrongConstant")
               ImageReader mImageReader = ImageReader.newInstance(with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, 0x1, 1);
               mImageReader.setOnImageAvailableListener(this, null);
               virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mImageReader.getSurface(), null, null);
           }
       }
   
       /**
        * 这里的一定要设置为virtualDisplay = null
        * 虽然他会每次使用结束自动释放 但是你还是需要手动释放
        * 否则导致 bitmap 花屏
        */
       private void stopScreenCapture() {
           if (virtualDisplay != null) {
               virtualDisplay.release();
               virtualDisplay = null;
               bitmap = null;
           }
       }
   
       private Bitmap bitmap;
   
       /**
        * 此处的imageReader 和 okhttp的 body 一样  必须要获取一遍后再使用  否则会导致空指针
        * 因为imageReader 只能获取一次 所以要创建一个变量保存下来
        * stopScreenCapture()
        * <p>
        * 因为涉及到横竖屏幕的切换 所以要及时吧bitmap设置未null
        * 不然会出现  竖屏状态时 图片完好  但是切换到横屏时  横屏截取的图片却和竖屏一样 反之也是
        * 注意 这里的bitmapcreate的宽和高 并没有像上面一样进行判断  所以细节就在这个地方
        * 因为我们竖屏状态下截取的不是整个屏幕 所以我们要把surface的宽高 进行截取 截取的就是bitmap的高所以就会符合我们的需求
        *
        * @param imageReader
        */
       @Override
       public void onImageAvailable(ImageReader imageReader) {
           Image image = imageReader.acquireLatestImage();
           if (image != null) {
               final Image.Plane[] planes = image.getPlanes();
               if (planes.length > 0) {
                   final ByteBuffer buffer = planes[0].getBuffer();
                   //每个像素的间距
                   int pixelStride = planes[0].getPixelStride();
                   //总的间距
                   int rowStride = planes[0].getRowStride();
                   int rowPadding1 = rowStride - pixelStride * mWith;
                   if (bitmap == null) {
                       bitmap = Bitmap.createBitmap((mWith + (rowPadding1 / pixelStride)), mHight, Bitmap.Config.ARGB_8888);
                   }
                   /**裁剪的是当前视频播放view的大小*/
                   bitmap.copyPixelsFromBuffer(buffer);
                   imageReaders.ImageReaderCallBackOK(bitmap);
                   image.close();
                   stopScreenCapture();
               }
           }
       }
   
       /**
        * 接口主要是把截取的bitmap 回掉到mainactivity
        */
       public interface ImageReaders {
           void ImageReaderCallBackOK(Bitmap bitmap);
       }
   
       public void setCallBack(ImageReaders imageReaders) {
           this.imageReaders = imageReaders;
       }
   
   }
2:获取视频的播放页面

这一块主要就是判断是否进入自己预定的视屏app 然后根据相关控件信息获取到播放视屏的view的rect 之后就可以获取到是视频view的宽度/高度


    private TimerTasks instion;

    @Override
    public void onCreate() {
        instion = TimerTasks.getInstion(AccessibilityViewService.this);
        instion.setTimer(500, 1200);
    }

    @SuppressLint("NewApi")
    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2)
    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        //判断是否在腾讯视频&&是否在播放页面
        if (event.getPackageName().toString().equals(ApkNames.QQLIVE)) {
            if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
                boolean isOnVideo = AccessHelper.getActivityName(event, this);
                if (isOnVideo) {
                    getVedioView();
                    Log.e("TAG", "onAccessibilityEvent: 在播放页面" );
                } else {
                    instion.setObj(null,ApkNames.EVENVDEIO_TWO);
                    instion.startTimer();
                    Log.e("TAG", "onAccessibilityEvent: 不在播放页面" );
                }
            }
        }
    }

    /**
     * 获取视频view的rect
     */
    private void getVedioView() {
        AccessibilityNodeInfo rootInActiveWindow = getRootInActiveWindow();
        if (rootInActiveWindow != null && rootInActiveWindow.getChildCount() != 0) {
            for (int i = 0; i < rootInActiveWindow.getChildCount(); i++) {
                if (rootInActiveWindow.getChild(i).getChildCount() != 0) {
                    AccessibilityNodeInfo activeWindowChild = rootInActiveWindow.getChild(i);
                    int childCount = activeWindowChild.getChildCount();
                    for (int j = 0; j < childCount; j++) {
                        if (j + 2 > childCount || activeWindowChild.getChild(j).getClassName() == null) {
                            return;
                        }
                        //这里的判断是 判断是否为播放视频的控件
                        if (activeWindowChild.getChild(j).getClassName().equals("android.widget.RelativeLayout") && activeWindowChild.getChild(j + 1).getClassName().equals("android.widget.ImageView")
                                && activeWindowChild.getChild(j + 2).getClassName().equals("android.widget.TextView")) {
                            AccessibilityNodeInfo child = activeWindowChild.getChild(j);
                            Rect rect = new Rect();
                            child.getBoundsInScreen(rect);
                            instion.setObj(rect, ApkNames.EVENVDEI_ONE);
                            instion.startTimer();
                            rootInActiveWindow.recycle();

                        }
                    }
                }
            }
        }
    }

    /**
     * 服务终结时 销毁定时器
     */
    @Override
    public void onInterrupt() {
        instion.cancelTimer();
    }

}

3:判断视频软件是否打开

这一块主要就是判断是否进入视频app

    public void onAccessibilityEvent(AccessibilityEvent event) {
        //判断是否在腾讯视频&&是否在播放页面
        if (event.getPackageName().toString().equals(ApkNames.QQLIVE)) {
            if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
                boolean isOnVideo = AccessHelper.getActivityName(event, this);
                if (isOnVideo) {
                    getVedioView();
                    Log.e("TAG", "onAccessibilityEvent: 在播放页面" );
                } else {
                    instion.setObj(null,ApkNames.EVENVDEIO_TWO);
                    instion.startTimer();
                    Log.e("TAG", "onAccessibilityEvent: 不在播放页面" );
                }
            }
        }
    }
4:handler导致性能卡顿

导致handler的性能卡顿的原因就是,字幕是实时的ocr转换也是实时的,所以导致有时候,一个ocr转换的文字被message发送若干遍。
后来我采用EVENBUS的方式,自己定义的接口,可返回相关的数据,最后放在mainactivity中进行相关数据操作。

 /**
     * 定时器的回掉
     * @param type         标记是否在播放页面
     * @param screeH       需要截取的高度
     * @param screenJiaoDu 屏幕旋转的角度
     *                     这里一定要切换线程 否则会你懂的
     */
    @Override
    public void AoboutScreen(int type, int screeH, int screenJiaoDu) {
        Log.i("TAG", "AoboutScreen: " + screenJiaoDu + screeH);
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                switch (type) {
                    //表示未横屏的时候 返回的是视频的高度
                    case ApkNames.EVENVDEI_ONE:
                        mediaProjrct.startScreenCapture(WindoesCut.getScreenW(getApplicationContext()), screeH);
                        break;
                    //表示是横屏的时候 返回的是全部屏幕
                    case ApkNames.EVENVDEIO_TWO:
                        Log.i("TAG", "run: 服务已经暂停");
                        break;
                }
            }
        });
    }

这里主要是MediaProjrct截屏后返回的bitmap 我们需要用到bitmap之后用ocr转换成文字,但是千万不要忘记销毁bitmap否则会消耗大量的资源。

    @Override
    public void ImageReaderCallBackOK(Bitmap bitmap) {
        Bitmap bitmapBottom = WindoesCut.cropBitmapBottom(bitmap, 0, (int) (bitmap.getHeight() / 2));

        ImageUtils.saveBitmap2file(bitmapBottom, this, new Date().toString());

        recognitionText(bitmapBottom);
        bitmapBottom.recycle();
    }

这里的识别文字用的是百度底层的PaddleLite开源框架,主要执行两个步骤,1,定位,2返回文字
这里我把返回的文字进行了相关的处理:比如,包含,等于,相等率,非字符,英文等。第一次的想法是获取第一次的文字坐标Potion后根据第一次的坐标进行返回,
后来因为字幕的坐标的长度是不可控的,然后直接获取的视频高的2/3。

    /**
     * 识别图片文字
     *
     * @param bitmapBottom
     * @return
     */
    private void recognitionText(Bitmap bitmapBottom) {
        predictor.setInputImage(bitmapBottom);
        boolean runModel = predictor.runModel();
        if (runModel) {
            List<String> textResult = predictor.getTextResult();
            //既然有字幕 那么字幕的坐标一定不为null
            if (textResult != null && textResult.size() != 0) {
                oldtextResult = textResult.get(0);
                /**先判断是否包含上一次的文字
                 * 包含就是文字重复
                 * 不包含就进行操作
                 * */
                if (!oldtextResult.equals(newtextResult)) {
                    /**在进行判断是否包含特殊字符
                     * 包含就不操作
                     * 不包含就继续判断概率
                     * */
                    boolean specialChar = TextUtils.isSpecialChar(oldtextResult);
                    if (!specialChar) {
                        /**
                         * 在进行判断和上一次的字符相等的字的总概率
                         * 20% 是一个很好的度 不会多显示 也不会少显示  当然不排除例外
                         * <20% 进行相关操作
                         * >20% 不进行操作
                         */
                        int equals = TextUtils.getEquals(oldtextResult, newtextResult);
                        Log.i("sms", "handleMessage: 转换文字成功---->相等率--:" + equals + "%");
                        if (equals <= 20) {
                            Log.e("sms", "handleMessage: 转换文字成功---->相等率--:" + equals + "%--字幕--:" + predictor.getTextResult().get(0));
                        }
                    } else {
                        Log.i("sms", "handleMessage: 转换文字成功---->包含特殊字符:" + predictor.getTextResult().get(0));
                    }
                    newtextResult = oldtextResult;
                } else {
                    Log.i("old", "handleMessage: 转换文字重复---->" + predictor.getTextResult().get(0));
                }
            } else {
                Log.i("sms", "handleMessage: 转换文字失败");
            }
        } else {
            Log.i("sms", "handleMessage: ocr 运行失败");
        }
    }
5:竖屏截图时,切换横屏时导致横屏截取的是竖屏的大小

如下:

    /**
     * 截屏
     * VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
     * 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
     * 此处的 with  和 hight 表示
     * 截图的宽和高
     * 但是由于不匹配会有黑色边框  所以加入三木
     * 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏  然后导致有黑色边框
     * 反之直接创建屏幕的大小
     * <p>
     * 问题场景
     * 1 竖屏状态时需要截取的是视频view的宽和高  但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
     * 2 横屏状态下 直接截取当前屏幕的宽和高 。
     * <p>
     * 以上会有一个问题   就是长时间  横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
     */
    public void startScreenCapture(int with, int hight) {
        this.mWith = with;
        this.mHight = hight;
        if (null != mediaProjection) {
            @SuppressLint("WrongConstant")
            ImageReader mImageReader = ImageReader.newInstance(with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, 0x1, 1);
            mImageReader.setOnImageAvailableListener(this, null);
            virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mImageReader.getSurface(), null, null);
        }
    }
6:AccessibilityService的onkeyeven方法始终无反应

查阅相关资料 android 10好像无解 如果您知道 请告诉我一下 谢谢。

7:其他相关代码

JNI层的java代码 主要就是获取文字坐标和文字

 public boolean runModel() {
        if (inputImage == null || !isLoaded()) {
            return false;
        }
        Bitmap scaleImage = Utils.resizeWithStep(inputImage, Long.valueOf(inputShape[2]).intValue(), 32);
        int channels = (int) inputShape[1];
        int width = scaleImage.getWidth();
        int height = scaleImage.getHeight();
        float[] inputData = new float[channels * width * height];
        if (channels == 3) {
            int[] channelIdx = null;
            if (inputColorFormat.equalsIgnoreCase("RGB")) {
                channelIdx = new int[]{0, 1, 2};
            } else if (inputColorFormat.equalsIgnoreCase("BGR")) {
                channelIdx = new int[]{2, 1, 0};
            } else {
                return false;
            }
            int[] channelStride = new int[]{width * height, width * height * 2};
            int[] pixels = new int[width * height];
            scaleImage.getPixels(pixels, 0, scaleImage.getWidth(), 0, 0, scaleImage.getWidth(), scaleImage.getHeight());
            for (int i = 0; i < pixels.length; i++) {
                int color = pixels[i];
                float[] rgb = new float[]{(float) red(color) / 255.0f, (float) green(color) / 255.0f,
                        (float) blue(color) / 255.0f};
                inputData[i] = (rgb[channelIdx[0]] - inputMean[0]) / inputStd[0];
                inputData[i + channelStride[0]] = (rgb[channelIdx[1]] - inputMean[1]) / inputStd[1];
                inputData[i + channelStride[1]] = (rgb[channelIdx[2]] - inputMean[2]) / inputStd[2];
            }
        } else if (channels == 1) {
            int[] pixels = new int[width * height];
            scaleImage.getPixels(pixels, 0, scaleImage.getWidth(), 0, 0, scaleImage.getWidth(), scaleImage.getHeight());
            for (int i = 0; i < pixels.length; i++) {
                int color = pixels[i];
                float gray = (float) (red(color) + green(color) + blue(color)) / 3.0f / 255.0f;
                inputData[i] = (gray - inputMean[0]) / inputStd[0];
            }
        } else {
            return false;
        }
        for (int i = 0; i < warmupIterNum; i++) {
            paddlePredictor.runImage(inputData, width, height, channels, inputImage);
        }
        warmupIterNum = 0;
        results = paddlePredictor.runImage(inputData, width, height, channels, inputImage);
        results = postprocess(results);

        if (inputImage!=null){
            inputImage .recycle();
        }
        return true;
    }

自定义定时器

/**
 * 自定义简单计时器
 * user : gewu
 * time : 22031709
 */
public class TimerTasks extends java.util.TimerTask {

    private static boolean isRun;
    public static Timer timer;
    @SuppressLint("StaticFieldLeak")
    private static TimerTasks timerTasks;
    @SuppressLint("StaticFieldLeak")
    private static Context thisContext;
    private static Handler myHandel;
    private int thisType;
    private Rect rects;
    private CallBack callBack;

    public static TimerTasks getInstion(Context context) {
        if (timer == null) {
            timer = new Timer();
        }
        if (timerTasks == null) {
            timerTasks = new TimerTasks();
        }
        if (myHandel == null) {
            myHandel = new Handler();
        }
        thisContext = context;
        return timerTasks;
    }

    public interface CallBack {
        void AoboutScreen(int type, int screeH, int screeJiaoDu);
    }

    public void setScreenCallbanck(CallBack callbanck) {
        this.callBack = callbanck;
    }

    @Override
    public void run() {
        if (isRun) {
            switch (thisType) {
                //表示未横屏的时候 返回的是视频的高度
                case ApkNames.EVENVDEI_ONE:
                    int h = WindoesCut.isH(thisContext);
                    switch (h){
                        case Surface.ROTATION_0:
                            //表示未横屏 手机旋转0度  返回的是当前视频的view的高度
                            callBack.AoboutScreen(thisType, WindoesCut.gethight(rects), WindoesCut.isH(thisContext));
                            break;
                        case Surface.ROTATION_90:
                            //表示手机向右横屏 手机旋转90度
                        case Surface.ROTATION_270:
                            //表示手机向左横屏 手机旋转270度
                            callBack.AoboutScreen(thisType, WindoesCut.getScreenH(thisContext), WindoesCut.isH(thisContext));
                            break;
                    }
                    break;
                //表示是横屏的时候 返回的是全部屏幕
                case ApkNames.EVENVDEIO_TWO:
                    stopTimer();
                    break;
                default:
                    throw new IllegalStateException("Unexpected value: " + thisType);
            }
        }
    }

    /**
     * 先获取timer的运行状态
     * 如果不在运行 就直接设置为他的反
     */
    public void startTimer() {
        if (!getIsRun()) {
            isRun = true;
        }
    }

    /**
     * 暂停
     */
    public void stopTimer() {
        if (getIsRun()) {
            isRun = false;
        }
    }


    /**
     * 设置定时器的间隔时间
     *
     * @param delay
     * @param time
     */
    public void setTimer(int delay, int time) {
        if (timer == null) {
            Log.e("timer", "设置Timer: timer is null......");
            return;
        }
        timer.schedule(timerTasks, delay, time);
    }

    public synchronized void setObj(Rect rect, int type) {
        if (rect == null && type == 0) {
            Log.e("timer", "设置数据Timer: Object is null......");
        } else {
            stopTimer();
            rects = rect;
            thisType = type;
        }
    }

    /**
     * 获取当前的定时器状态
     *
     * @return
     */
    protected boolean getIsRun() {
        return isRun;
    }


    /**
     * 直接销毁定时器
     */
    public void cancelTimer() {
        if (timer == null) {
            Log.e("timer", "销毁Timer: timer is null......");
            return;
        }
        stopTimer();
        timer.purge();
        timer.cancel();
        timer = null;
        timerTasks = null;
    }
}

软件架构

最好的设计就是没有设计,本项目是mvc架构。主要就是服务层和数据层获取到相关内容通知Activity进行更新。
没有UI 没有过多的页面绘制,主要的全部在逻辑后台。纯离线方式的实时字幕识别。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CaglDE39-1647847438411)(/Users/gewu/Desktop/截屏2022-03-21 15.15.36.png)]

安装教程

打开本项目,里面有apk

使用说明

1,打开软件
2,点击获取两个权限
3,保持后台运行
4,打开相关视频app就OK了

参与贡献

如果您需要,可在此项目上加入实时翻译功能。

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-03-24 00:42:07  更:2022-03-24 00:43:58 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 20:02:15-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码