2018-08-01 17:26:04 u013408979 阅读数 7201
  • 快速入门Android开发 视频 教程 android studio

    这是一门快速入门Android开发课程,顾名思义是让大家能快速入门Android开发。 学完能让你学会如下知识点: Android的发展历程 搭建Java开发环境 搭建Android开发环境 Android Studio基础使用方法 Android Studio创建项目 项目运行到模拟器 项目运行到真实手机 Android中常用控件 排查开发中的错误 Android中请求网络 常用Android开发命令 快速入门Gradle构建系统 项目实战:看美图 常用Android Studio使用技巧 项目签名打包 如何上架市场

    25521 人正在学习 去看看 任苹蜻

1、为什么要用热更新

开发人员一定深有体会,上线的app突然发现一个bug影响用户使用,就需要重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装等步骤,用户还会抱怨怎么又升级了,频繁升级对用户体验也不好。就想到能不能像服务器哪样,如果有问题就替换某个文件重启,用户就可以继续使用了。这就衍生出热修复概念产生。

2、什么是热修复

热修复通俗说就是打补丁,让用户在无感的情况下更新,修复不用重新发版

3、热修复原理

参考这篇文章,就能大概了解热修复原理了。https://www.jianshu.com/p/cb1f0702d59f

4、热修复技术

市面上热修复技术很多,主要是腾讯系、阿里系两大类,这是各种热修复 的一些对比。
这里写图片描述

5、阿里巴巴的Sophix热修复

当初项目打算要用的时候,朋友推荐使用阿里巴巴的Sophix热修复,参考https://help.aliyun.com/document_detail/53240.html?spm=a2c4g.11186623.4.1.5p4eDY官网文档,一步一步安装官网步骤打补丁、发布管理后台,很简单很顺利就集成热修复。使用一段时间后才发现不是免费的,超过一定标准要收费,以下是收费标准,一看没有超过标准怎么会收费呢,最终才发现日均查询次数是根据接口queryAndLoadNewPatch统计的,用户每打开一次APP就会调用queryAndLoadNewPatch接口检查是否有新的补丁需要下载,发现账单中有已设备日均查询次数超过20次导致收费的
这里写图片描述

6、腾讯bugly热修复

打算舍弃阿里巴巴的热修复,转战其他的,询问已用过热修复的网友,推荐使用腾讯的bugly热修复,是免费的不限制次数。根据官方文档https://bugly.qq.com/docs/user-guide/instruction-manual-android-hotfix/?v=20180709165613 集成步骤集成热修复,在过程中遇到好几个问题,记录下。
(1)通过assembleRelease编译生成基准包后,会在app/build/bakApk生成一个app-0801-11-42-34这样的文件夹,是根据时间生成的,里面有基准包app-release.apk,然后通过buildTinkerPatchRelease去打补丁,会提示找不到 app-0801-11-42-54,这是因为生成基准包名称是根据时间创建的,打补丁是需要修改tinker-support.gradle中的baseApkDir ,跟基准包路径名称保持一致,还有一个地方需要修改tinkerId,构建基准包和补丁包都要指定不同的tinkerId,并且必须保证唯一性,切记切记
(2)按照官网步骤集成bugly,打好基本包和补丁包,把补丁包上传平台后,提示”未匹配到可应用补丁包的App版本,请确认补丁包的基线版本是否已发布”,每步骤都是按照官网说的,怎么出现这问题,最终发现是因为在
tinker-support.gradle中少写 tinkerEnable = true,下面是具体代码

apply plugin: 'com.tencent.bugly.tinker-support'

def bakPath = file("${buildDir}/bakApk/")

/**
 * 此处填写每次构建生成的基准包目录
 */
def baseApkDir = "app-0801-11-42-54"

/**
 * 对于插件各参数的详细解析请参考
 */
tinkerSupport {

    // 开启tinker-support插件,默认值true
    enable = true

    // 指定归档目录,默认值当前module的子目录tinker
    autoBackupApkDir = "${bakPath}"

    // tinkerEnable功能开关
    tinkerEnable = true

    // 是否启用覆盖tinkerPatch配置功能,默认值false
    // 开启后tinkerPatch配置不生效,即无需添加tinkerPatch
    overrideTinkerPatchConfiguration = true

    // 编译补丁包时,必需指定基线版本的apk,默认值为空
    // 如果为空,则表示不是进行补丁包的编译
    // @{link tinkerPatch.oldApk }
    baseApk = "${bakPath}/${baseApkDir}/app-release.apk"

    // 对应tinker插件applyMapping
    baseApkProguardMapping = "${bakPath}/${baseApkDir}/app-release-mapping.txt"

    // 对应tinker插件applyResourceMapping
    baseApkResourceMapping = "${bakPath}/${baseApkDir}/app-release-R.txt"

    // 构建基准包和补丁包都要指定不同的tinkerId,并且必须保证唯一性
    tinkerId = "base-1.0"      //基本包
   // tinkerId = "patch-1.0"  //补丁包

    // 构建多渠道补丁时使用
    // buildAllFlavorsDir = "${bakPath}/${baseApkDir}"

    // 是否启用加固模式,默认为false.(tinker-spport 1.0.7起支持)
    // isProtectedApp = true

    // 是否开启反射Application模式
    enableProxyApplication = true

    // 是否支持新增非export的Activity(注意:设置为true才能修改AndroidManifest文件)
    supportHotplugComponent = true

}

/**
 * 一般来说,我们无需对下面的参数做任何的修改
 * 对于各参数的详细介绍请参考:
 * https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97
 */
tinkerPatch {
    tinkerEnable = true
    ignoreWarning = false
    useSign = true
    dex {
        dexMode = "jar"
        pattern = ["classes*.dex"]
        loader = []
    }
    lib {
        pattern = ["lib/*/*.so"]
    }

    res {
        pattern = ["res/*", "r/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
        ignoreChange = []
        largeModSize = 100
    }

    packageConfig {
    }
    sevenZip {
        zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
    }
    buildConfig {
        keepDexApply = false
    }
}

(3)打好的补丁包上传平台后,来回试了好几次退出打开app,都没有生效,,最终发现在上传补丁包大约5分钟后,打开app会弹出这样提示框,点击重启应用后,打的补丁就生效了,平台上也会显示该补丁已发下并已激活
这里写图片描述

7、bugly热修复错误编码

这里写图片描述

2018-01-04 19:38:09 ITMonkeyKing 阅读数 702
  • 快速入门Android开发 视频 教程 android studio

    这是一门快速入门Android开发课程,顾名思义是让大家能快速入门Android开发。 学完能让你学会如下知识点: Android的发展历程 搭建Java开发环境 搭建Android开发环境 Android Studio基础使用方法 Android Studio创建项目 项目运行到模拟器 项目运行到真实手机 Android中常用控件 排查开发中的错误 Android中请求网络 常用Android开发命令 快速入门Gradle构建系统 项目实战:看美图 常用Android Studio使用技巧 项目签名打包 如何上架市场

    25521 人正在学习 去看看 任苹蜻

应用自动更新是现在每一个APP不可缺少的。


1. 应用自动更新的意义及途径

途径:各大应用市场。

意义:

1.及时告知用户最新版本;

2.用户更新简单,无需打开第三方应用;

3.可以强制用户更新(特定场景下,如有重大bug或用户使用版本过于陈旧);

4.更多的自主控制权


2. 应用自动更新原理

原理:apk文件的下载。

步骤:

1.apk安装包下载;

2.使用notification通知用户进度等信息;

3.apk下载完成后调用系统安装程序


想要成功的实现自动更新你需要知道异步HTTP请求文件的下载(HttpURLConnection或者OkHttp等其他网络请求来下载文件);线程间通信(便于通知notification进行更新);

Notification通知的使用;如何调用安装程序进行安装。


3. 核心代码

public interface UpdateDownloadListener {

    /**
     * 下载请求开始回调
     */
    void onStarted();

    /**
     * 进度更新回调
     *
     * @param progress
     * @param downloadUrl
     */
    void onProgressChanged(int progress, String downloadUrl);

    /**
     * 下载完成回调
     *
     * @param completeSize
     * @param downloadUrl
     */
    void onFinished(int completeSize, String downloadUrl);

    /**
     * 下载失败回调
     */
    void onFailure();
}

处理文件的下载和线程间的通信

/**
 * Created by EWorld 
 *
 * @description 负责处理文件的下载和线程间的通信
 */

public class UpdateDownloadRequest implements Runnable {
    private String mDownloadUrl;//下载路径
    private String mLocalFilePath;//
    private UpdateDownloadListener mDownloadListener;
    private boolean isDownloading = false;//标志位
    private long mCurrentLength;

    private DownloadResponseHandler mDownloadResponsedHandler;

    //枚举类型的异常
    public enum FailureCode {
        UnknownHost, Socket, SocketTimeout, ConnectTimeout, IO, HttpResponse, Json, Interrupted
    }


    public UpdateDownloadRequest(String downloadUrl, String localFilePath,
                                 UpdateDownloadListener downloadListener) {
        this.mDownloadUrl = downloadUrl;
        this.mDownloadListener = downloadListener;
        this.mLocalFilePath = localFilePath;
        this.isDownloading = true;
        this.mDownloadResponsedHandler = new DownloadResponseHandler();

    }

    /**
     * 格式化数据,保留到小数点后两位
     *
     * @param value
     * @return
     */
    private String getTwoPointFloatStringStr(float value) {
        DecimalFormat decimalFormat = new DecimalFormat("0.00");
        return decimalFormat.format(value);
    }

    @Override
    public void run() {
        try {
            makeRequest();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * @throws IOException
     * @throws InterruptedException
     */
    private void makeRequest() throws IOException, InterruptedException {
        //如果线程没有被中断,建立连接就
        if (!Thread.currentThread().isInterrupted()) {
            try {
                //创建URL对象
                URL url = new URL(mDownloadUrl);
                //获取HttpURLConnection
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("GET");
                connection.setConnectTimeout(5000);
                //强制打开为keep-alive
                connection.setRequestProperty("Connection", "Keep-Alive");
                //阻塞当前线程
                connection.connect();
                mCurrentLength = connection.getContentLength();
                //连接至此已经建立,下面开始下载
                if (!Thread.currentThread().isInterrupted()) {
                    //获取输入流
                    mDownloadResponsedHandler.sendResponseMessage(connection.getInputStream());
                }
            } catch (IOException e) {
                throw e;
            }

        }
    }


    /**
     * 用来真正下载文件,并发送消息和回调接口
     */
    public class DownloadResponseHandler {

        protected static final int SUCCESS_MESSAGE = 0;
        protected static final int FAILURE_MESSAGE = 1;
        protected static final int START_MESSAGE = 2;
        protected static final int FINISH_MESSAGE = 3;
        protected static final int NETWORK_OFF = 4;
        private static final int PROGRESS_CHANGED = 5;

        private int mCompleteSize = 0;
        private int progress = 0;

        private Handler handler;

        public DownloadResponseHandler() {
            handler = new Handler(Looper.getMainLooper()) {
                @Override
                public void handleMessage(Message msg) {
                    handleSelfMessage(msg);
                }
            };
        }

        /**
         * 用来发送不同的消息对象
         */
        protected void sendFinishMessage() {
            sendMessage(obtainMessage(FINISH_MESSAGE, null));
        }

        private void sendProgressChangedMessage(int progress) {
            sendMessage(obtainMessage(PROGRESS_CHANGED, new Object[]{progress}));
        }

        protected void sendFailureMessage(FailureCode failureCode) {
            sendMessage(obtainMessage(FAILURE_MESSAGE, new Object[]{failureCode}));
        }

        /**
         * @param msg
         */
        protected void sendMessage(Message msg) {
            if (handler != null) {
                handler.sendMessage(msg);
            } else {
                handleSelfMessage(msg);
            }
        }

        /**
         * @param responseMessage
         * @param response
         * @return
         */
        protected Message obtainMessage(int responseMessage, Object response) {
            Message msg = null;
            if (handler != null) {
                msg = handler.obtainMessage(responseMessage, response);
            } else {
                msg = Message.obtain();
                msg.what = responseMessage;
                msg.obj = response;
            }
            return msg;
        }

        /**
         * @param msg
         */
        protected void handleSelfMessage(Message msg) {
            Object[] response;
            switch (msg.what) {
                case FAILURE_MESSAGE:
                    response = (Object[]) msg.obj;
                    sendFailureMessage((FailureCode) response[0]);
                    break;
                case PROGRESS_CHANGED:
                    response = (Object[]) msg.obj;
                    handleProgressChangedMessage(((Integer) response[0]).intValue());
                    break;
                case FINISH_MESSAGE:
                    onFinish();
                    break;

            }

        }

        protected void handleProgressChangedMessage(int progress) {
            mDownloadListener.onProgressChanged(progress, mDownloadUrl);
        }

        protected void handleFailureMessage(FailureCode failureCode){
            onFailure(failureCode);
        }

        private void onFinish() {
            mDownloadListener.onFinished(mCompleteSize, "");
        }

        private void onFailure(FailureCode failureCode) {
            mDownloadListener.onFailure();
        }

        /**
         * 文件下载方法
         *
         * @param inputStream
         */
        protected void sendResponseMessage(InputStream inputStream) {
            //建立文件读写流
            RandomAccessFile randomAccessFile = null;
            //完成大小
            mCompleteSize = 0;
            //开始读
            try {
                //缓存
                byte[] buffer = new byte[1024];
                //读写长度
                int len = -1;
                //
                int limit = 0;
                randomAccessFile = new RandomAccessFile(mLocalFilePath, "rwd");
                while ((len = inputStream.read(buffer)) != -1) {
                    if (isDownloading) {
                        randomAccessFile.write(buffer, 0, len);
                        mCompleteSize += len;
                        if (mCompleteSize < mCurrentLength) {
                            progress = (int) Float.parseFloat
                                    (getTwoPointFloatStringStr(mCompleteSize / mCurrentLength));

                            if (limit / 30 == 0 && progress <= 100) {
                                sendProgressChangedMessage(progress);
                            }

                            limit++;
                        }
                    }
                }
                sendFinishMessage();

            } catch (Exception e) {
                e.printStackTrace();
                sendFailureMessage(FailureCode.IO);
            } finally {

                try {
                    if (inputStream != null) {
                        inputStream.close();
                    }

                    if (randomAccessFile != null) {
                        randomAccessFile.close();
                    }
                } catch (IOException e) {
                    sendFailureMessage(FailureCode.IO);
                }

            }


        }


    }
}


/**
 * Created by EWorld 
 *
 * @description 下载调度管理器,调用我们的UpdateDownloadRequest
 */

public class UpdateManager {

    private static UpdateManager updateManager;
    private ThreadPoolExecutor threadPoolExecutor;
    private UpdateDownloadRequest request;

    public UpdateManager() {
        threadPoolExecutor = (ThreadPoolExecutor) Executors.newCachedThreadPool();

    }

    static {
        updateManager = new UpdateManager();
    }

    public static UpdateManager getInstance() {


        return updateManager;
    }

    public void startDownloads(String downloadUrl, String localPath, UpdateDownloadListener listener) {
        if (request != null) {
            return;
        }

        checkLocalFilePath(localPath);
        request = new UpdateDownloadRequest(downloadUrl, localPath, listener);
        Future<?> future = threadPoolExecutor.submit(request);
    }

    /**
     * 检查文件路径是否存在
     *
     * @param localPath
     */
    private void checkLocalFilePath(String localPath) {
        File dir = new File(localPath.substring(0, localPath.indexOf("/") + 1));
        if (!dir.exists()) {
            dir.mkdir();
        }

        File file = new File(localPath);
        if (!file.exists()) {
            try {
                file.createNewFile();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}


/**
 * Created by EWorld
 *
 * @description app更新下载后台服务
 */

public class UpdateService extends Service {
    private String apkUlr;
    private String filePath;
    private NotificationManager notificationManager;
    private Notification notification;

    @Override
    public void onCreate() {
        notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
        filePath = Environment.getExternalStorageState() + "/78/seveneight.apk";
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent == null) {
            notifyUser(getString(R.string.update_download_failed), getString(R.string.update_download_failed_msg), 0);
            stopSelf();
        }
        apkUlr = intent.getStringExtra("apkUrl");
        notifyUser(getString(R.string.update_download_start), getString(R.string.update_download_start), 0);
        //开始下载
        startDownload();
        return super.onStartCommand(intent, flags, startId);
    }

    private void startDownload() {
        UpdateManager.getInstance().startDownloads(apkUlr, filePath, new UpdateDownloadListener() {
            @Override
            public void onStarted() {

            }

            @Override
            public void onProgressChanged(int progress, String downloadUrl) {
                notifyUser(getString(R.string.update_download_processing),
                        getString(R.string.update_download_processing), progress);
            }

            @Override
            public void onFinished(int completeSize, String downloadUrl) {
                notifyUser(getString(R.string.update_download_finish),
                        getString(R.string.update_download_finish), 100);
                stopSelf();
            }

            @Override
            public void onFailure() {
                notifyUser(getString(R.string.update_download_failed),
                        getString(R.string.update_download_failed_msg), 0);
                stopSelf();
            }
        });
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    private void notifyUser(String result, String reason, int progress) {
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
        builder.setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle(getString(R.string.app_name));
        if (progress > 0 && progress < 100) {
            builder.setProgress(100, progress, false);
        } else {
            builder.setProgress(0, 0, false);
        }
        builder.setAutoCancel(true);
        builder.setWhen(System.currentTimeMillis());
        builder.setTicker(result);
        builder.setContentIntent(progress >= 100 ? getContentIntent() :
                PendingIntent.getActivity(this, 0, new Intent(), PendingIntent.FLAG_UPDATE_CURRENT));
        notification = builder.build();
        notificationManager.notify(0, notification);


    }

    private PendingIntent getContentIntent() {
        File file = new File(filePath);
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.setDataAndType(Uri.parse("file://" + file.getAbsolutePath()), "application/vnd.android.package-archive");
        PendingIntent pendingIntent = PendingIntent.getActivity
                (this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        return pendingIntent;
    }
}

启动更新

    private void autoUpdate() {
        Intent intent = new Intent(this,UpdateService.class);
        intent.putExtra("lastVersion","2.2.0");
        startService(intent);
    }



2017-04-14 18:49:03 qq_15682489 阅读数 313
  • 快速入门Android开发 视频 教程 android studio

    这是一门快速入门Android开发课程,顾名思义是让大家能快速入门Android开发。 学完能让你学会如下知识点: Android的发展历程 搭建Java开发环境 搭建Android开发环境 Android Studio基础使用方法 Android Studio创建项目 项目运行到模拟器 项目运行到真实手机 Android中常用控件 排查开发中的错误 Android中请求网络 常用Android开发命令 快速入门Gradle构建系统 项目实战:看美图 常用Android Studio使用技巧 项目签名打包 如何上架市场

    25521 人正在学习 去看看 任苹蜻

作者的自言自语:有一家专门做幼儿教育软件的公司,它们有两百多款app,如果不是某些平台的限制就是盘几千款也有没问题,现在他们2d的产品假设有150款,每个月一次全线升级,也就是说你要在一天内把150款产品的安装包重新打一遍,ios两个渠道,Android就以12个渠道估算
IOS需要出包 = 150*2 = 300
Android出包 = 150*12 = 1800
你要怎么办?给你几台电脑可以在两个小时内打完?

宝宝巴士打包系统的构架

  • 1.构架django网站,打包者下单后将打包信息存为本地一个文件夹的json
  • 2.打包机开启线程接单,自由竞争任务,读取信息接到任务后删除json
  • 3.打包机开始打包,打包过程中出现任何错误都会明确反馈给打包网站
  • 4.网站收到反馈信息,成功则显示下载,失败则显示原因

今天我主要介绍的是2和3条,如何构建一个接单的线程

我们先来分析一下,接单线程要满足什么条件

  • 1.因为接单线程和django网站是相互独立的,所以如果网站挂掉,接单线程不能受到影响
  • 2.由于代码是构建在非本机上,要做好直接拿打包机调试的打算
  • 3.打包要迅速快捷,省去一切可能的重复步骤,快速出包
  • 4.把结果告诉网站,如果失败多试几次

需要处理的问题

django网站关闭了怎么办?我接单的打包机器是不是也要重启?
我们有正式环境8000,测试环境8888,3.x环境5000,上一版环境7000 这些环境下的单是不是代码要独立?要是很多人同时要打包,还是打的不同环境的包怎么办?
打包机器同时打超过四个包就会影响效率,如果一下子有很多包怎么办?
宝宝巴士一百五十款产品,全线升级的都要快速出包压力大怎么办?
打包的正常步骤 1.git lua项目(5s)>>2.代码加密(3s)>>3.音频转换(50s)>>4.拷贝模板(8s)>>5.改造模板(4s)>>打包(80s) 按此计算一个ios包平均要150s,一个Android包要70s 怎么精简让速度快一些?
打包每次出现问题都要告诉框架组,让他们去调试,这样来回重复打包浪费时间怎么处理?
打完包综合组要下载下来上传到共享给测试,这样很麻烦,有没有简便的方法?
除了git还要从网站获取图片,也是消耗时间,怎么更精简些?
要是用户不愿意等待怎么办?怎么样才能告诉他们耐心点等待?

一、如何让接单线程不死

一个接单线程简单的说就是一个while死循环,只不过这个循环间隔一秒执行一次,不会给机器造成太大的负担,不管是正式测试什么环境,它们的接单端口代码都是统一的,所以如果一个端口被关闭了,不需要告诉接单线程,线程自己换个端口继续接单就行了,这也就做到了网站关闭与否不影响接单线程。而每个接单线程都是独立运作的,谁取到单子谁就打包,这样子我们就可以根据不同的机器性能启动几个线程

# -*- coding:utf-8 -*-
import time
import urllib2
# 取任务接口
mPort = 'http://10.1.1.188:8888/getask'
# 分配打包函数 根据接到的信息交给不同的代码打包
def fetch_url(mstr):
   adic = eval(mstr)
   # 打印接到的单的详细内容
   for x in adic:
    print x 
    print adic[x]
# 接单函数 打开网页接口获取任务
def ReceiveTask():
   global mPort
   try:
    response = urllib2.urlopen(mPort)
    html = response.read()
    if html != '{}':
        print u'分配任务中...'
        fetch_url(html)
   except Exception, e:
    # 如果连接不到那个端口,可能被重启了,换个端口试就是了,这个线程是绝对不能死的
    if '8000' in mPort:
        mPort = 'http://10.1.1.188:8888/getask'
    else:
        mPort = 'http://10.1.1.188:8000/getask'
   print u'接单中'.encode('gbk')
if __name__ == '__main__':
   while True:
    ReceiveTask()
    time.sleep(1)

二、四个线程接所有环境的单

打包有很多不一样的环境,而我们的打包机器什么环境的单都要接,所以
在上一解决的问题里有个fetch_url就是用来分配任务的 根据不同的任务交给不同的代码去打包的
这里写图片描述
这里的tasknew.py就是上面的接单分配任务代码
我们来看一下demo里面放什么,我再demo里先放了两个.py文件 init和task
init.py的代码主要是初始化一些此环境的常用模块和公共参数,目前四个环境有比较明显区别的就是KEY_PORT这个参数,要是旧环境需要迁移就会改变IPAPACK_DIR(这个参数是Android模板的位置)

# -*- coding:utf-8 -*-
import sys
reload(sys)
sys.setdefaultencoding('utf8')
import os
import json
import shutil
import time
import urllib2
# 当前目录和上一级目录 这里不用os.getcwd() 因为getcwd获得的是python的执行路径
cur_dir = sys.path[0]
prv_dir = os.path.dirname(cur_dir)
# 一些本机的特别路径我全写在 commoninit文件夹下了 通用公共参数
commoninit = os.path.join(prv_dir,'commoninit')
sys.path.append(commoninit)
from packinit import *
# 一些这个环境才特有的公共参数
SOURCE_DIR  = os.path.join(SITE_DIR, 'source')
IPAPACK_DIR = os.path.join(SOURCE_DIR, 'ipapack')
IS_DEV      = ''
KEY_PORT    = 'http://10.1.1.188:8000'
EMPTY       = u'空'
datafmt     = "%Y_%m_%d_%H_%M_%S"

从上面可以看出,init从脚本执行路径的上一层路径的commoninit文件夹引入了packint.py的所有参数,每个环境都从这里引入,可见这里就是放本地的一些固定参数了
commoninit下的 packinit.py

# -*- coding:utf-8 -*-
# 机器名用来表示哪台机器接单的
MACHINE_NAME = 'MacPro'

MEDIA_DIR = r'/Volumes/BabyBusData/media'
# 用来放置打包临时目录
TEMP_DIR = r'/Volumes/BabyBusData/media/temp'

SITE_DIR = '/Volumes/BABYBUS_2D/Site'
# 用来放置lua替换模板的
PROJECT_TOOL_DIR = r'/Volumes/BABYBUS_2D/Site/project_tool'
# 本机ip地址
MY_IP = '10.1.1.20'
MEDIA_PORT = MY_IP+r':8000/media'

ssh_user = 'babybus'
ssh_password = 'team123456'
ssh_ip = '127.0.0.1'

初始化的环境公共参数和所有环境通用的公共参数好了,现在我们来看一下打包的代码demo下的 tasks.py

# -*- coding:utf-8 -*-
from init import *

if __name__ == '__main__':
    mstr = []
    # 奇葩传值法
    mstr.append('{')
    i = 0
    for x in sys.argv:
        if i > 0:
            rightstr = x.replace('-','\'')
            mstr.append(rightstr)
            mstr.append(',')
        i = i + 1
    mstr.append('}')
    right     = ''.join(mstr)
    adic      = eval(right)
    # 以上是把传进来的字典解析出来  pack_type 打包类型 and/ios
    pack_type = adic['pack_type']
    # android可能就一个exportandroid 但ios有导出ios 企业ios 
    action_name = adic['action_name']
    # 是否为夜间打包  如果是,打完包还要做的一件事就是传到共享
    night_pack  = adic['night_pack']
    # 一个应用点击了多少渠道
    packlen   = len(pack_type)
    # 然后我们就根据这些信息去打包

这里我们看到一个奇葩传值法,我这里要讲一下为何这么做
如果我们开启一个终端 python xxx.py 参数1 参数2
那么在py文件里我们怎么获得后面带的参数呢,没错就是
import sys
sys.argv1 和 sys.argv2 分别是参数1和2
我在tasknew.py接到任务之后,要根据我是从哪个环境接的任务分配给不同的打包代码,当然,我做的事情就是利用python的subprocess函数,其实就相当于在终端 输入
python demo/tasks.py 要带带上的参数
我把接到的信息,一个字典给化成了一个字符串传给了tasks.py 估计很少人这么干,但是因为参数是我规定的,我确定参数里面没有空格所以可以这么做,这里我把 tasknew.py的 fetch_url函数补全 完整的 tasknew.py

# -*- coding:utf-8 -*-
import os
import urllib2
import time

# 主要干的事情就是要是网络请求失败了或者打包代码出现错误了   告诉django后台
def savelog(dic):
    mkey = 'demo'+dic['demo']
    keyport = {'demo':'http://10.1.1.188:8000','demo_dev':'http://10.1.1.188:8888','demo_old':'http://10.1.1.188:7000'}
    KEY_PORT = keyport[mkey]
    myurl = KEY_PORT+'/savelog?'
    tmptab = ['appid','typeid','myurl','machine','status','tmp_onlyid','action_name','id']
    tabtwo = []
    for x in tmptab:
        mstr = x+'='+dic[x].strip()
        tabtwo.append(mstr)
    transurl = myurl+'&'.join(tabtwo)
    transurl = transurl.strip()
    response = None
    BegTime  = 0
    # 尝试把错误信息告诉给django后台 尝试4次每次间隔4秒
    while True:
        try:
            rtn = urllib2.urlopen(transurl.strip())
            BegTime = 10
            response = rtn
        except Exception, e:
            BegTime = BegTime + 1
            time.sleep(4)
        finally:
            if BegTime > 4:
                break
    # response = urllib2.urlopen(transurl.strip())
    if type(response) != type(None):
        html = response.read()
        return html
    else:
        return '999999'


def exec_(command,adic):
    """
    执行本地命令
    :param command: 命令
    :param cwd: 执行环境
    :return:
    """
    import subprocess
    sub = subprocess.Popen(command, env=None, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, bufsize=4096)
    (stdout, stderr) = sub.communicate()
    # 如果我的语法错误请告诉我
    if stderr.strip() != '':
        from datetime import datetime
        import json
        errdic = {'url':'kong','log':stderr}
        x      = adic['pack_type'][0]
        mdir   = os.path.join('/Volumes/BabyBusData/media/temp',datetime.now().strftime("%Y/%m/%d"),'wrong',adic['tmp_onlyid'])
        os.makedirs(mdir)
        jsonPath = os.path.join(mdir,'result.json')
        json.dump(errdic,open(jsonPath,'w'))
        jsonurl  = '10.1.1.20:8000/media'+jsonPath.split('media')[1]
        mdic     = {'appid':str(adic['appid']),'typeid':str(x),'demo':adic['demo'],'machine':'MacPro','status':'-1','tmp_onlyid':adic['tmp_onlyid'],'action_name':adic['action_name'],'id':'888888','myurl':jsonurl}
        result   = savelog(mdic)

# 分配打包函数 根据接到的信息交给不同的代码打包
def fetch_url(mstr):
    adic = eval(mstr)
    mstr = mstr.strip()
    mstr = mstr.replace(': ',':')
    mstr = mstr.replace(', ',',')
    mstr = mstr.replace('\"','-')
    mPath = os.getcwd()
    demo_name = adic['demo']
    # 根据取到的任务是哪个环境的交给相应的demo+环境后缀 文件夹下的tasks.py脚本
    right = os.path.join(mPath,'demo'+demo_name,'tasks.py')
    command = 'python '+right+' '+mstr
    # 相当于在终端开启命令,如果一切正常就好了,有错误我就要报告django
    exec_(command,adic)

mPort = 'http://10.1.1.188:8888/getask'
# 接单函数 打开网页接口获取任务
def ReceiveTask():
    global mPort
    response = None
    try:
        response = urllib2.urlopen(mPort)
        html = response.read()
        if html != '{}':
            fetch_url(html)
    except Exception, e:
        if '8000' in mPort:
            mPort = 'http://10.1.1.188:8888/getask'
        else:
            mPort = 'http://10.1.1.188:8000/getask'

if __name__=='__main__':
    while True:
        ReceiveTask()
        time.sleep(1)

简述一下这个脚本做的事情就是

  • 1.通过网页端口去问django有没有任务可以做,如果没有,下一秒我再来问一次。
  • 2.接到了任务根据是哪个环境的,交给相应环境的打包脚本
  • 3.如果出错了,开启终端python tasks.py出错,那99%是py脚本写的不规范,所以我做了一层错误处理,要是接到了err不为空字符串,那么把错误信息告诉django,这就做到了我们直接在打包机上推代码,有什么错误会弹窗给你准确信息的
    好了介绍完简单的分布式后我们终于开始介绍打包的代码了

开始打包啦

打包信息交由tasks.py之后,这里我获取到的信息还只是要打哪个应用,有哪些渠道,要打什么包
我要去网页上取得更详细的信息
tasks.py根据要打什么包交给不同类去处理,打包脚本主要干的事情就是
git项目>>音频提前转换>>拷贝模板>>改造模板>>打包 我挑几个有代表意义的讲 (以下有一些已经是伪代码)

# git项目代码
tmpTime = time.time()
gitPlace = 填要git的网页路径   
out, err = utils.exec_("git clone %s git" % gitPlace, 要git的文件夹)
if not os.path.exists(os.path.join(要git的文件夹,'git')):
    raise Exception(u'Git出错')
record  = 'git clone use time : '+str(time.time()-tmpTime)
# 记录下这一步用了多少时间
self._Record = self._Record + record +'\n'

音频提前转换的原理:打Android包我们要把lua项目git下来的项目的mp3转为ogg,所以我们把上一次git下来的mp3存到一个固定位置,同级目录还放转好的音频,下一次打包直接比对两个放mp3文件夹的信息,如果信息一致,那么就是可以直接使用上次转好的音频。

# 音频比对,提前转换代码
bbframe = os.path.join(SND_SOURCE_DIR,self.app['app_id'].strip())
compare.AudioPrevCov(leyuan,bbframe)
if self._is_ios == 'ios':
    cafPath = os.path.join(bbframe,'caf')
    code.audio_convert(cafPath,'.caf')
    compare.CopyBack(cafPath,leyuan)
else:
    oggPath = os.path.join(bbframe,'ogg')
    code.audio_convert(oggPath,'.ogg')
    compare.CopyBack(oggPath,leyuan)

模板改造就需要根据框架组需要哪些参数和需要改造的参数而定,这里我简单介绍一下,由于ios的封闭性,没法动态加载插件,只能模板里加载全部插件,改造模板的时候再动态删除的代码 这个把打包时间由原来的八十几秒缩减到了四十多秒,主要是根据马永成和董建伟同学提出的根据规律删除
pbxprojFile.py 由我实现的代码

# -*- coding: utf-8 -*-
import os

class RmIosPlg(object):
    """Creator Changwei For Delete Plu-gin"""
    def __init__(self, mPath):
        super(RmIosPlg, self).__init__()
        if os.path.exists (mPath):

            pbxprojFile   = open(mPath, 'rb')
            # 存放要删除的数据行数字典 被我标记说明你已经死了
            self._DeadLine = {}
            # 读取所有数据到内存
            self._Data     = []
            self._Dig      = 0
            # 存放一级删除目录  免得循环爆棚  
            self._todelDic = {}
            self._lanDic = {'/* ar */':'/* ar */','/* de */':'/* de */','/* en */':'/* en */','/* es */':'/* es */',
               '/* fr */':'/* fr */','/* hi */':'/* hi */','/* id */':'/* id */','/* ja */':'/* ja */',
               '/* ko */':'/* ko */','/* pt */':'/* pt */','/* ru */':'/* ru */','/* th */':'/* th */',
               '/* vi */':'/* vi */','/* zh-Hans */':'/* zh-Hans */','/* zh-Hant */':'/* zh-Hant */','/* Base */':'/* Base */'}

            while True:
                line = pbxprojFile.readline()
                if line:
                    self._Data.append(line)
                else:
                    break
            pbxprojFile.close()
        else :
            print "RmIosPlg Useless Path = %s" %mPath

    # 找到要删除的第一前缀  /* IAP */   Camera
    def FindFirst_Delete(self,onlyId,mstr):
        self._todelDic[onlyId] = onlyId
        # 是不是传ID值了  迭代过来的都有传
        HasId = False
        if len(onlyId) > 0:
            HasId = True
        tmpdead = []
        datalen = len(self._Data)
        TmpTime = 0
        for x in xrange(0,datalen):
            if mstr in self._Data[x]:
                if HasId:
                    shuzi = self._Data[x].strip()[0:24]
                    if shuzi == onlyId:
                        # 标记这一行已经死亡
                        self._DeadLine[x] = x
                        # 一般会找到两行  一行是有文件夹的 存下了下面判断
                        tmpdead.append(x)
                        TmpTime += 1
                else:
                    self._DeadLine[x] = x
                    tmpdead.append(x)
                    TmpTime += 1

        # if TmpTime > 2 :
        #   # 如果有这一步打印,通常来说:那就是不正常
        #   print 'SomeThing Gos Wrong'
        #   print mstr
        #   print TmpTime 


        for x in tmpdead:
            datatmp = []
            # 如果有{ 说明是个字典
            if '{' in self._Data[x]:
                for i in xrange(x+1,datalen):
                    datatmp.append(self._Data[i])
                    self._DeadLine[i] = i
                    if '};' in self._Data[i]:
                        break
            self.Dead_Second(datatmp)

    # 处理要删除的数据
    def Dead_Second(self,dlist):
        temdead = []
        for x in xrange(2,len(dlist)):
            if ');' in dlist[x]:
                break
            else:
                tm_str = dlist[x]
                fir = tm_str.find('/* ')
                sec = tm_str.find(' */')
                todel = tm_str[fir:sec+3]
                # 根据唯一ID进行删除
                shuzi = tm_str.strip()[0:24]
                temdead.append(shuzi)
                # print 
                m_shut = todel[-11:-4] 
                # print m_shut
                # 说明还有子模块要删除
                if ( todel[-4] >= '0' and todel[-4] <= '9' ) or ( '.' not in todel ) or ( m_shut == '.string') or (m_shut == 'imagese'):
                    if not self._todelDic.has_key(shuzi) and not self._lanDic.has_key(todel):
                        self.FindFirst_Delete(shuzi,todel)
                        # print todel
        self.DeadList(temdead)

    # 数据死亡 在表中的数据都是要删除的
    def DeadList(self,mlist): 
        Tmp_dead_list = []
        for x in mlist:
            count = -1
            for i in self._Data:
                count += 1
                if self._DeadLine.has_key(count):
                    pass
                elif x in self._Data[count]:
                    self._DeadLine[count] = count
                    if 'fileRef' in i:  # 说明你还有父节点要删除
                        dat = i.strip()[0:24]
                        Tmp_dead_list.append(dat)

        if len(Tmp_dead_list) > 0 :
            self.DeadList(Tmp_dead_list)

    def WriteOut(self,where):
        # 数据进行处理 在死亡字典的注释
        newlua = open(where,'wb')
        for x in xrange(0,len(self._Data)):
            if x in self._DeadLine:
                pass
            else:
                newlua.write(self._Data[x])
        newlua.close
        # 死亡结束迈进棺材
    def KickOutData(self):
        TmpData = []
        for x in xrange(0,len(self._Data)):
            if x in self._DeadLine:
                pass
            else:
                TmpData.append(self._Data[x])
        self._Data = TmpData
        self._DeadLine = {}

动态删除ios插件的方法使用

from pbxprojFile import RmIosPlg

projpbxPath = os.path.join(self.dir,'proj.ios','bbframework.xcodeproj','project.pbxproj')
newpbxPath  = os.path.join(self.dir,'proj.ios','bbframework.xcodeproj','new.pbxproj')
# 插件文件夹
self.plugin_dir = os.path.join(self.dir,'proj.ios','bbframework','iOS','Plugin')
if len(self.Plg_to_remove) > 0:
    mPbxproj = RmIosPlg(projpbxPath)
    onlyId   = ''
    for x in self.Plg_to_remove:
        mPath = os.path.join(self.plugin_dir,x)
        if os.path.exists(mPath):
            shutil.rmtree(mPath)
        else:
            raise Exception('plugin_dir do not exists :'+mPath)
        mPbxproj.FindFirst_Delete(onlyId,'/* '+x.strip()+' */')
        mPbxproj.KickOutData()
    mPbxproj.WriteOut(projpbxPath)

处理好上面的事情之后我们就可以做到
这里写图片描述
ios出包平均每个在 71s 左右
这里写图片描述
Android的平均每个出包在 23s左右
一台pro开启三个线程接单一个小时可以出IOS包 (3600/71)*3= 152个
如果pro+一台mini各开四个线程一小时出Android包 (3600/30)*8 = 960个

最后我处理一下刚开始发问的问题

问题的解答
django网站关闭了怎么办?我接单的打包机器是不是也要重启?
答: 网站关闭是不会影响打包进程的,它这个接单端口找不到任务,换个继续等就是了

我们有正式环境8000,测试环境8888,3.x环境5000,上一版环境7000 这些环境下的单是不是代码要独立?要是很多人同时要打包,还是打的不同环境的包怎么办?
答: 所有的环境下单都到统一地方,打包机开四个线程去接单,接到什么环境的单用什么环境的代码去打包。

打包机器同时打超过四个包就会影响效率,如果一下子有很多包怎么办?
答: 据统计,目前的mac开三个线程打ios包是最佳效率,mini开四个线程打Android是最佳效率,当然mac也接Android包,只是有ios的时候优先接ios包,如果一下子又很多包进来打,那么用户就要等待,所有的线程都是这个应用打完才会继续接单,我们会告诉用户他们前面有多少人在等和请求多少次的时间。当然,目前打包这效率,虽然才两台电脑,但是遇到需要等待还是比较少的

宝宝巴士一百五十款产品,全线升级的都要快速出包压力大怎么办?
答: 任务重,那就快点做完就能休息了,解决方法就是优化打包速度,分布式接入机器,全线升级最好把夜间打包点下去,第二天早上来勾选的包就全有了,不过其实以现在的效率甚至两个小时内就可以把150款产品打包完成。

打包的正常步骤 1.git lua项目(5s)>>2.代码加密(3s)>>3.音频转换(50s)>>4.拷贝模板(8s)>>5.改造模板(4s)>>打包(80s) 按此计算一个ios包平均要150s,一个Android包要70s 怎么精简让速度快一些?
答: 我们一个应用可能勾选十几个渠道,所以打第一个应用的时候git就可以了,lua代码会加密成base.bin和game.bin 其中不同渠道所改参数都在base.bin的代码里面,文件比较少,所以第一个渠道加密好的game.bin可以重复利用,这样后面的加密时间就不到0.5s了。音频转换我是这个项目git下来后把mp3放到一个地方存着,然后也存一份当前mp3文件夹转ogg的和转caf的,下次git下来的时候比对两个文件夹的mp3状态,如果全部一致,Android直接拷贝ogg过去用,就拿地震这款来说,转音频要用150s,但是如果换成比对mp3状态5s内完全可以解决,而且第一个渠道比对过后,从第二个渠道开始就免去这一步骤了。第四条的拷贝模板是没法精简的,虽然Android已经打过一次包,再打一次只要2s,但是我是不会这么做,因为你改造过的模板你还要写一份删除改造过的东西,稍有失误就会全线出错,所以还是用统一个模板改造为好,不要打过包还删掉改造过的东西再打。而且每个渠道包都有可以出包的模板,这样框架组如果有什么要调试的也可以马上找到这个包,和他的完整的打包模板。最后说到IOS打包的80s是怎么省的,由于ios的特殊性,它的插件我们要在一开始全部加载进去,所以我们采用模板删除插件的方法,通过改造bbframework.xcodeproj,删除不必要的插件,并且把不用的插件文件夹也删除了,所以这个时间被缩减到45s。
因此Android从第二个渠道包开始做的事情就是 拷贝模板(2s)>>改造模板(2s)>>打包>>(26s) 总计30s

打包每次出现问题都要告诉框架组,让他们去调试,这样来回重复打包浪费时间怎么处理?
答: 这里就要好好用python的traceback函数了,打包过程中不管是你的python代码写错,还是框架组的shell脚本出错,任何地方出错都能非常准确的报出所有信息,跟你调试的信息一样

打完包综合组要下载下来上传到共享给测试,这样很麻烦,有没有简便的方法?
答: 用ftp,打完包后一个按钮直接传送包到共享的11服务器,这个我在下一次关于分享django的时候会贴代码

除了git还要从网站获取图片,也是消耗时间,怎么更精简些?
答: 所以我也把图片存到打包机本地,在打包获取信息的时候我和网站上的图片大小一比对,如果信息一致就不去下载图片了,直接用本地的这一张

要是用户不愿意等待怎么办?怎么样才能告诉他们耐心点等待?
答: 所以我们要有个前面等待的人数提示,还有个请求时间提示,这样就像在银行拿着小票,当然不管态度摆的怎么端正,重要的是好用,稳定,快速 不然没人有耐心的

2018-11-13 08:40:35 github_38885296 阅读数 1380
  • 快速入门Android开发 视频 教程 android studio

    这是一门快速入门Android开发课程,顾名思义是让大家能快速入门Android开发。 学完能让你学会如下知识点: Android的发展历程 搭建Java开发环境 搭建Android开发环境 Android Studio基础使用方法 Android Studio创建项目 项目运行到模拟器 项目运行到真实手机 Android中常用控件 排查开发中的错误 Android中请求网络 常用Android开发命令 快速入门Gradle构建系统 项目实战:看美图 常用Android Studio使用技巧 项目签名打包 如何上架市场

    25521 人正在学习 去看看 任苹蜻

在之前的几年里,我一直把网易云音乐作为主力听歌渠道,在各平台上安装的也基本都是网易云音乐和一款本地播放器(foobar2000或phonograph)。今年年初的时候,网易在Android平台上发布了网易云音乐5.0。升级后,点开这软件,5秒后,我在App info界面中点击了uninstall。

其实很长时间之前我就开始对这个平台不太满意了,具体原因之后会提到。

彻底弃用一个曾经使用过一千天左右的服务可不是什么简单事。应该用什么服务代替呢?

之前也一直知道网易云音乐的曲库中基本都是盗版,无损音质也是假的(320kbps转flac),这次干脆彻底抛弃这类曲库,改用音乐串流吧。

我一般听的歌的类型不是很多,主要是Game、Anime、Movie的OST、J-Pop、极少数情况下也会听些有年头的欧美流行。因此我得出了结论:不论哪个服务,日区都更适合我。
我挑出了以下几个候选:

  • Spotify
  • Apple Music
  • Google Play Music
  • ANiUTa

Spotify

老牌的音乐串流服务,据说已经有1.4亿用户了。
Sony Music是Spotify的4个老东家之一,所以我想曲库应该会比较全吧……Premium for Family每月也只卖1480円,找5个人平摊也不是很贵。
把账户注册到了日区,之前首页上的“新用户免费体验一个月”不见了,变成了“Start 3 months of Premium for ¥100”,日区就这么特殊吗……
纠结了半天,我觉得毕竟才100円,也就是不到10软妹币,先订阅3个月试试吧。
然而……支付失败……用中国银行的多币种Visa卡(长城跨境通)无法支付……

It looks like your card is from a non-Spotify country.
Spotify isn't available in your country yet. Change your location or choose a different payment method.

算了,惹不起惹不起……又看到很多人说Spotify曲库不全,不折腾了,换下一家。

Apple Music

果子的音乐,App在Google Play里评分只有3.5,也许这就是Android用户的Google情怀吧(笑)。
之前用过Apple Music国区,体验极差,几乎什么歌都没有,这次试试日区。
日区账户,点击试用,添加信用卡。
淦!又是无法使用。
换换换。

Google Play Music

因为之前我的Google账户一直在美区,也就稀里糊涂订阅了一个月的Google Play Music美区。
恩……基本没有歌,取消订阅吧。
之后花了很大的力气把Google账户转到了日区,用那张多币种Visa卡付款竟然成功了。
Google大法好!
然而体验了几周后,我又发现了很多不满意的地方:

  • 虽说Play Music的曲库相比于之前的几家惹不起的大佬已经算是最全的了,但还是缺很多歌。索尼音乐旗下歌手(如LiSA、ClariS)的唱片还算比较全,但其他唱片基本找不到。
  • BGM Collection/OST 太少了,总之我只发现了ゆるゆり的原声碟,其它暂时没发现。
  • 下载的离线音乐是加密的,无法导出,PC上也没有客户端,不太方便。
  • 码率不明。
  • 恶心的Library同步。我在Android客户端上Add了近50张Album到Library,库中歌曲总数至少超过200首,但PC上Web端的Lib里只能显示十几首歌?!到现在也没搞懂是什么情况。
  • 日区没有YouTube Red服务。在美区之类的其它区订阅Play Music同时也会开通YouTube Red,包含无广告、Android客户端PiP(Picture-in-Picture)功能、YouTube Music,然而日区没有这些福利,也就失去了很多吸引力。

总之,Play Music也还是不太和我的口味……

ANiUTa

忘记是在哪里听说这家串流服务的了,总之貌似是很厉害的样子~

然而价格又把我吓回去了~

ANiUTa没有家庭共享,每个月三、四十软妹币的样子,是其它几家的约3倍~

最终解决方案

被信用卡锁区坑惨的+1
在dslite上面买耻物 卡在交易出错的时候内心是崩溃的???貌似我只用这卡在亚马逊和Google Play里成功支付过,毕竟连Apple ID都绑不了

 

强烈推荐SoundCloud啊/然后配合scdl下载
(去年注销了我的10级网易帐号)

有一个Magisk模块Sony Apps Enabler可以让非Xperia的手机安装大法家机器专用的Apps(通过Play就能安装),当然也包括大法家的音乐App——Sony Music
这个App有个好,能直接播放Google Drive里的FLAC/MP3
另外貌似还有大法的玄学加成,配上大法自家的耳机和Hi-Res™ Audio食用效果更佳~
千万不要用火电或风电为播放设备充电并推歌!火电浮躁,风电不稳定。一定要用水电(最好是潮汐能发电)!最纯净!(逃
至于音乐来源,就只能走日亚或BT了。

没有更多推荐了,返回首页