HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

uni-app第一贴子 初始次使用uni-app开发

初始次使用uni-app开发, 目前仅有几名前端开发人员,记录一下我们遇以的问题。

现阶段遇到的问题:

  1. 修改原生组合,遇到困难,开始没找到方向, 需要进一步学习
  2. 部分css属于不能用,如百分比,要换成rpx,对rpx要进一步学习
  3. 加载的图片宽高不能自适应,要找对应的解决方法

我们要开发的app,要用到部分的原生app功能,如上传图片,以及微信分享,为了确保项目不会半途而废,开发到了后期发现有些功能实现不了,决定要先行验证以下功能:

  1. uni-app加载url的页面,与nvue开发页面交互(数据交互和分享功能)
  2. 原生开发页面实现分享+图片上传的功能
  3. 开放能力,展业app能打开其他app或小程序
  4. 全屏及横屏模式
  5. android和iOS平台编译打包
继续阅读 »

初始次使用uni-app开发, 目前仅有几名前端开发人员,记录一下我们遇以的问题。

现阶段遇到的问题:

  1. 修改原生组合,遇到困难,开始没找到方向, 需要进一步学习
  2. 部分css属于不能用,如百分比,要换成rpx,对rpx要进一步学习
  3. 加载的图片宽高不能自适应,要找对应的解决方法

我们要开发的app,要用到部分的原生app功能,如上传图片,以及微信分享,为了确保项目不会半途而废,开发到了后期发现有些功能实现不了,决定要先行验证以下功能:

  1. uni-app加载url的页面,与nvue开发页面交互(数据交互和分享功能)
  2. 原生开发页面实现分享+图片上传的功能
  3. 开放能力,展业app能打开其他app或小程序
  4. 全屏及横屏模式
  5. android和iOS平台编译打包
收起阅读 »

求 nui 方式弹出输入框

默认话题

求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框

求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框

书本:来吧,证明你爱我的时候到了

项目体验地址:http://at.iunitv.cn/

效果预览:

花絮:

很多小伙伴嘴上说着学不动了,其实身体还是很诚实的。
ceeb653ejw1fc35nya2aij20b40b4my3.jpg

毕竟读书还是有很多好处的:比如让你的脑门散发智慧的光芒,再或者让你有理由说因为读书太忙了所以没有女朋友等等。

006dMd5bgy1fj6q9bw5ozj308c08cq38.jpg

我们也想要借助这个特殊的机会,普及一下Tensorflow相关的知识,我们会用TensorFlow.js做一个图书识别的模型,并在Vue Application中运行,赋予网页识别图书的能力。

本文讲述了AI相关的概念知识和如何运用SSD Mobile Net V1模型进行迁移学习的方法,从而帮助大家完成一个可以在网页上运行的图书识别模型。

【文末有活动哦】

正文:

什么是迁移学习

迁移学习和域适应指的是在一种环境中学到的知识被用在另一个领域中来提高它的泛化性能。——《深度学习》,第 526 页

再简单一点理解,以今天图书识别模型训练为例,我们利用前人训练好的具备图片识别能力的AI模型,保留AI模型中对图片特征提取的能力的基础上再训练,使AI模型具备识别图书的能力。

迁移学习能够大大提高模型训练的速度,并达到相对不错的正确率。

而我们今天所要迁移学习的对象就是SSD Mobile Net V1模型,初次接触神经网络的同学可以将其理解为一种具备图片识别的轻便小巧的AI模型,它能够在移动设备上高效地运行。对这个模型具体的神经网络设计结构感兴趣的同学可以自行搜索。

了解了基本的概念之后,我们便开始动手吧!我们可以基于SSD Mobile Net模型去设计一个属于自己的AI模型,并让它在Vue Application中运行。

Object Detection(目标识别)

本次项目是为了训练一个Object Detection的模型,即目标识别的模型,该模型能够识别并圈选出图片中相应的目标对象。

kites_detections_output.jpg

准备工作

同步开发环境

为了避免小伙伴因为环境问题遇到各种各样的坑,在工作开展之前,我们先跟大家同步一下运行的环境。大家如果要动手去做,也尽量跟我们的运行环境保持一致,这样可以有效避免踩坑,规避“从入门到放弃”的现象。

开发环境

  • 系统Mac OS系统
  • Python版本:3.7.3
  • TensorFlow版本:1.15.2
  • TensorFlowJS版本:1.7.2
  • 开发工具:Pycharm和Webstorm

下载项目

同步完开发环境后,终于要开始动工了。首先我们需要在Github上下载几个项目:

准备图片素材

我们可以通过搜索引擎收集有关图书的图片素材:

其次,我们可以在Github上克隆LabelImg项目,并根据Github的使用说明,按照不同的环境安装运行LabelImg项目,运行后的页面如下:

2.png

然后我们按照以下步骤,将图片格式转换为圈选区域后的XML文件:

  1. 打开图片存放的目录
  2. 选择圈选后的存放目录
  3. 圈选图片目标区域
  4. 设置圈选区域的标签
  5. 保存成XML格式

存放完后我们在存放的目录下会看到许多XML格式的文件,这个文件记录了图片的位置信息、圈选信息和标签信息等,用于后续的模型训练。

配置安装Object Detection的环境

从Github克隆迁移模型训练的项目迁移模型训练项目,注意要在r1.5分支运行,并用PyCharm打开项目。

image-20200420170524470.png

项目的目录环境为上图,首先我们需要下载TensorFlow1.15.2版本:

pip install tensorflow==1.15.2

其次安装依赖包:

sudo pip install pillow  
sudo pip install lxml  
sudo pip install jupyter  
sudo pip install matplotlib

然后通过终端切换到research目录,并执行几行配置命令,具体请参考Github的使用说明:

cd ./research  
protoc object_detection/protos/*.proto --python_out=.  
export PYTHONPATH=$PYTHONPATH:`pwd`:`pwd`/slim

最后我们运行model_builder_test.py文件,如果在终端中看到OK字样,表示配置成功。

python object_detection/builders/model_builder_test.py

将XML格式转换为TensorFlow需要的TFRecord格式

克隆并打开图片格式转换项目,然后我们对该项目加以小改造:

改造文件目录:

  1. 删除annotationsdatatraining目录中的内容
  2. 增加一个xmls目录,用以存放xml文件

image-20200420171606955.png
改造文件:
接着,我们再改造以下2个文件并新增一个文件,方便我们转换图片格式

  1. 改造xml_to_csv.py为:

    import os  
    import glob  
    import pandas as pd  
    import xml.etree.ElementTree as ET  
    import random  
    import time  
    import shutil  
    
    class Xml2Cvs:  
       def __init__(self):  
           self.xml_filepath = r'./xmls'  
           self.save_basepath = r"./annotations"  
           self.trainval_percent = 0.9  
           self.train_percent = 0.85  
    
       def xml_split_train(self):  
    
           total_xml = os.listdir(self.xml_filepath)  
           num = len(total_xml)  
           list = range(num)  
           tv = int(num * self.trainval_percent)  
           tr = int(tv * self.train_percent)  
           trainval = random.sample(list, tv)  
           train = random.sample(trainval, tr)  
           print("train and val size", tv)  
           print("train size", tr)  
           start = time.time()  
           test_num = 0  
           val_num = 0  
           train_num = 0  
           for i in list:  
               name = total_xml[i]  
               if i in trainval:  
                   if i in train:  
                       directory = "train"  
                       train_num += 1  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
                   else:  
                       directory = "validation"  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       val_num += 1  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
               else:  
                   directory = "test"  
                   xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                   if (not os.path.exists(xml_path)):  
                       os.mkdir(xml_path)  
                   test_num += 1  
                   filePath = os.path.join(self.xml_filepath, name)  
                   newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                   shutil.copyfile(filePath, newfile)  
    
           end = time.time()  
           seconds = end - start  
           print("train total : " + str(train_num))  
           print("validation total : " + str(val_num))  
           print("test total : " + str(test_num))  
           total_num = train_num + val_num + test_num  
           print("total number : " + str(total_num))  
           print("Time taken : {0} seconds".format(seconds))  
    
       def xml_to_csv(self, path):  
           xml_list = []  
           for xml_file in glob.glob(path + '/*.xml'):  
               tree = ET.parse(xml_file)  
               root = tree.getroot()  
               print(root.find('filename').text)  
               for object in root.findall('object'):  
                   value = (root.find('filename').text,  
                            int(root.find('size').find('width').text),  
                            int(root.find('size').find('height').text),  
                            object.find('name').text,  
                            int(object.find('bndbox').find('xmin').text),  
                            int(object.find('bndbox').find('ymin').text),  
                            int(object.find('bndbox').find('xmax').text),  
                            int(object.find('bndbox').find('ymax').text)  
                            )  
                   xml_list.append(value)  
           column_name = ['filename', 'width', 'height', 'class', 'xmin', 'ymin', 'xmax', 'ymax']  
           xml_df = pd.DataFrame(xml_list, columns=column_name)  
           return xml_df  
    
       def main(self):  
           for directory in ['train', 'test', 'validation']:  
               xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
               xml_df = self.xml_to_csv(xml_path)  
               xml_df.to_csv('data/mask_{}_labels.csv'.format(directory), index=None)  
               print('Successfully converted xml to csv.')  
    
    if __name__ == '__main__':  
       Xml2Cvs().xml_split_train()  
       Xml2Cvs().main()  
  2. 改造generate_tfrecord.py文件,将csv格式转换为TensorFlow需要的record格式:

image-20200420172149654.png

将该区域的row_label改成我们LabelImg中的标签名,因为我们只有一个标签,所以直接修改成book即可。

  1. 新增一个generate_tfrecord.sh脚本,方便执行generate_tfrecord.py文件

    #!/usr/bin/env bash  
    python generate_tfrecord.py --csv_input=data/mask_train_labels.csv  --output_path=data/mask_train.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_test_labels.csv  --output_path=data/mask_test.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_validation_labels.csv  --output_path=data/mask_validation.record --image_dir=images  
    

配置Object Decation的环境

export PYTHONPATH=$PYTHONPATH:你的models/research/slim所在的全目录路径

最后我们将图片文件复制到images目录,将xml文件复制到xmls目录下,再执行xml_to_csv.py文件,我们会看到data目录下产生了几个csv格式结尾的文件;这时,我们在终端执行generate_tfrecord.sh文件,TensorFlow所需要的数据格式就大功告成啦。

image-20200420172821520.png

迁移训练模型:

在这个环节我们要做以下几件事:

  • 将刚刚生成好的record文件放到对应目录下
  • 下载SSD Mobile Net V1模型文件
  • 配置book.pbtxt文件和book.config文件
放置record文件和SSD Mobile Net V1模型

为了方便我直接将models/research/object_detection/test_data下的目录清空,放置迁移训练的文件。

首先我们下载SSD Mobile Net V1模型文件

image-20200420174029721.png

我们下载第一个ssd_mobilenet_v1_coco模型即可,下载完毕后,我们解压下载的模型压缩包文件,并将模型相关的文件放在test_datamodel目录下。并将我们刚刚生成的record文件放置在test_data目录下。
image-20200420174238899.png

完成pbtxt和config配置文件

我们在test_data目录下,新建一个book.pbtxt文件,并完成配置内容:

item {  
  id: 1  
  name: 'book'  
}

由于我们只有一个标签,我们就直接配置一个id值为1,name为book的item对象。

由于我们使用SSD Mobile Net V1模型进行迁移学习,因此我们到sample\configs目录下复制一份ssd_mobilenet_v1_coco.config文件并重命名为book.config文件。

image-20200420174718068.png

接着我们修改book.config中的配置文件:

将num_classes修改为当前的标签数量:

image-20200420174843137.png

由于我们只有一个book标签,因此修改成1即可。

修改所有PATH_TO_BE_CONFIGURED的路径:
<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5d916b7213e0?w=1410&h=982&f=png&s=147351" style="zoom:50%;" />
</center>

我们将此处的模型文件地址设置成testdata/model/model.ckpt的全路径地址。

image-20200420175241636.png

我们将train_input_readerinput_path设置成mask_train.record的全路径地址;将label_map_path设置成book.pbtxt的全路径地址;将eval_input_readerinput_path设置成mask_test.record的全路径地址。

到目前为止我们所有配置都已经完成啦。接下来就是激动人心的训练模型的时刻。

运行train.py文件训练模型

我们在终端中运行train.py文件,开始迁移学习、训练模型。

python3 train.py --logtostderr --train_dir=./test_data/training/ --pipeline_config_path=./test_data/book.config

其中train_dir为我们训练后的模型存放的目录,pipeline_config_path为我们book.config文件所在的相对路径。

运行命令后,我们可以看到模型在进行一步一步的训练:

image-20200420175814070.png

并在/test_data/training目录下存放训练后的模型文件:

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5db65704e6e0?w=610&h=842&f=png&s=140510" style="zoom:50%;" />
</center>

将ckpt文件转换为pb文件

我们通过export_inference_graph.py文件,将训练好的模型转换为pb格式的文件,这个文件格式在后面我们要用来转换为TensorFlow.js能够识别的文件格式。终于我们见到TensorFlow.js的影子啦。

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5dbee720cc2c?w=440&h=439&f=jpeg&s=20529" style="zoom:50%;" />
</center>

我们执行命令,运行export_inference_graph.py文件:

python export_inference_graph.py --input_type image_tensor --pipeline_config_path ./test_data/book.config --trained_checkpoint_prefix ./test_data/training/model.ckpt-1989 --output_directory ./test_data/training/book_model_test

其中pipeline_config_pathbook.config的相对文件路径,trained_checkpoint_prefix为模型文件的路径,例如我们选择训练了1989步的模型文件,output_directory为我们输出pb文件的目标目录。

运行完后,我们可以看到一个生成了book_model_test目录:

image-20200420181903653.png

将pb文件转换为TensorFlowJs模型

首先我们需要依赖TensorFlowjs的依赖包

pip install tensorflowjs

然后通过命令行转换刚刚生成的pb文件

tensorflowjs_converter --input_format=tf_saved_model --output_node_names='detection_boxes,detection_classes,detection_features,detection_multiclass_scores,detection_scores,num_detections,raw_detection_boxes,raw_detection_scores' --saved_model_tags=serve --output_format=tfjs_graph_model ./saved_model ./web_model

其中我们设置最后两个参数,即saved_model的目录与TensorFlow.js识别模型的输出目录。

运行结束后,我们可以看到一个新生成的web_model目录,其中包括了我们迁移学习训练后的模型。

到这里,模型训练的阶段终于结束了。

9150e4e5gw1fa99psluudj208c08cmx5.jpg

在Vue中运行模型

准备工作

新建Vue项目,在Vue项目的public目录下放入我们训练好的模型,即web_model目录。

image-20200421132233993.png

接着我们借助Tensorflow.js的依赖包,在package.jsondependencies中加入:

"@tensorflow/tfjs": "^1.7.2",  
"@tensorflow/tfjs-core": "^1.7.2",  
"@tensorflow/tfjs-node": "^1.7.2",

然后通过npm命令安装依赖包。

加载模型

在我们的JS代码部分引入TensorFlow的依赖包:

import * as tf from '@tensorflow/tfjs';  
import {loadGraphModel} from '@tensorflow/tfjs-converter';

接着第一步,我们先加载模型文件中的model.json文件:

const MODEL_URL = process.env.BASE_URL+"web_model/model.json";  
this.model = await loadGraphModel(MODEL_URL);

通过loadGraphModel方法,我们加载好训练的模型,再将模型对象打印出来:

image-20200421132921380.png

随后,我们可以看到模型会输出一个长度为4的数组:

  • detection_scores:表示识别对象模型的置信度,置信度越高,则代表模型认为对应区域识别为书本的可能性越高
  • detection_classes:表示模型识别的区域对应的标签,例如在本案例中,识别出来的是book
  • num_detections:表示模型识别出目标对象的个数
  • detection_boxes:表示模型识别出来目标对象的区域,为一个长度为4的数组,分别是:[x_pos,y_pos,x_width,y_height] 。第一个位代表圈选区域左上角的x坐标,第二位代表圈选左上角的y坐标,第三位代表圈选区域的宽度,第四位代表圈选区域的长度。

模型识别

知道了输出值,我们就可以开始将图片输入到模型中,从而得到模型预测的结果:

const img = document.getElementById('img');  
let modelres =await this.model.executeAsync(tf.browser.fromPixels(img).expandDims(0));

我们通过model.executeAsync方法,将图片输入到模型中,从而得到模型的输出值。

结果是我们前文提到的一个长度为4的数组。接着我们通过自定义方法,将得到的结果进行整理,从而输出一个想要的结果格式:

buildDetectedObjects:function(scores, threshold, imageWidth, imageHeight, boxes, classes, classesDir) {  
          const detectionObjects = [];  
          scores.forEach((score, i) => {  
              if (score > threshold) {  
                  const bbox = [];  
                  const minY = boxes[i * 4] * imageHeight;  
                  const minX = boxes[i * 4 + 1] * imageWidth;  
                  const maxY = boxes[i * 4 + 2] * imageHeight;  
                  const maxX = boxes[i * 4 + 3] * imageWidth;  
                  bbox[0] = minX;  
                  bbox[1] = minY;  
                  bbox[2] = maxX - minX;  
                  bbox[3] = maxY - minY;  
                  detectionObjects.push({  
                      class: classes[i],  
                      label: classesDir[classes[i]].name,  
                      score: score.toFixed(4),  
                      bbox: bbox  
                  });  
              }  
          });  

          return detectionObjects  
}

我们通过调用buildDetectedObjects来整理和返回最后的结果。

  • scores:输入模型的detection_scores数组
  • threshold:阈值,即结果score>threshold我们才会将对应的结果放入结果对象detectionObjects
  • imageWidth:图片的宽度
  • imageHeight:图片的长度
  • boxes:输入模型的detection_boxes数组
  • classes:输入模型的detection_classes数组
  • classesDir:即模型标签对象

调用buildDetectedObjects方法示例:

let classesDir = {  
    1: {  
        name: 'book',  
        id: 1,  
        }  
    };  
let res=this.buildDetectedObjects(modelres[0].dataSync(),0.20,img.width,img.height,modelres[3].dataSync(),modelres[1].dataSync(),classesDir);

我们通过modelres[0].dataSync(),来获取对应结果的数组对象,再输入到方法中,从而最终获得res结果对象。

image-20200421140000851.png

最后我们通过Canvas的API,将图片根据bbox返回的数组对象,画出对应的区域即可。由于篇幅原因,就不赘述了,最终效果如下:
image-20200421140314124.png

最后

本案例的模型存在一定的不足,由于训练时间较短,图书的封面类型众多,存在人像、风景图等等的样式,导致模型在识别过程中可能会将少部分的人脸、风景照等图片错误地识别成图书封面。各位小伙伴在训练自己模型的过程中可以考虑优化此问题。

当然,本案例的模型在识别非图书的场景会存在识别不准确的情况,一方面这是因为本案例从网络收集的图书样本具有一定局限性,而且图书的封面类型千差万别,存在人像、风景图等等的样式;另一方面因为本文在仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关的知识,并提供训练自己的模型的解决方案,所以在收集样本和模型训练时间较短。感兴趣的小伙伴可以自己琢磨琢磨如何优化样本和在避免过拟合的情况下提高训练时长,从而提高模型对被识别物体的准确性。

我们写下本文仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关知识并提供一种AI的解决方案。
我们希望和广大程序员一起学习新知、共同进步,愿每位热爱学习的开发者都能畅游书海,遇见更好的自己!

继续阅读 »

项目体验地址:http://at.iunitv.cn/

效果预览:

花絮:

很多小伙伴嘴上说着学不动了,其实身体还是很诚实的。
ceeb653ejw1fc35nya2aij20b40b4my3.jpg

毕竟读书还是有很多好处的:比如让你的脑门散发智慧的光芒,再或者让你有理由说因为读书太忙了所以没有女朋友等等。

006dMd5bgy1fj6q9bw5ozj308c08cq38.jpg

我们也想要借助这个特殊的机会,普及一下Tensorflow相关的知识,我们会用TensorFlow.js做一个图书识别的模型,并在Vue Application中运行,赋予网页识别图书的能力。

本文讲述了AI相关的概念知识和如何运用SSD Mobile Net V1模型进行迁移学习的方法,从而帮助大家完成一个可以在网页上运行的图书识别模型。

【文末有活动哦】

正文:

什么是迁移学习

迁移学习和域适应指的是在一种环境中学到的知识被用在另一个领域中来提高它的泛化性能。——《深度学习》,第 526 页

再简单一点理解,以今天图书识别模型训练为例,我们利用前人训练好的具备图片识别能力的AI模型,保留AI模型中对图片特征提取的能力的基础上再训练,使AI模型具备识别图书的能力。

迁移学习能够大大提高模型训练的速度,并达到相对不错的正确率。

而我们今天所要迁移学习的对象就是SSD Mobile Net V1模型,初次接触神经网络的同学可以将其理解为一种具备图片识别的轻便小巧的AI模型,它能够在移动设备上高效地运行。对这个模型具体的神经网络设计结构感兴趣的同学可以自行搜索。

了解了基本的概念之后,我们便开始动手吧!我们可以基于SSD Mobile Net模型去设计一个属于自己的AI模型,并让它在Vue Application中运行。

Object Detection(目标识别)

本次项目是为了训练一个Object Detection的模型,即目标识别的模型,该模型能够识别并圈选出图片中相应的目标对象。

kites_detections_output.jpg

准备工作

同步开发环境

为了避免小伙伴因为环境问题遇到各种各样的坑,在工作开展之前,我们先跟大家同步一下运行的环境。大家如果要动手去做,也尽量跟我们的运行环境保持一致,这样可以有效避免踩坑,规避“从入门到放弃”的现象。

开发环境

  • 系统Mac OS系统
  • Python版本:3.7.3
  • TensorFlow版本:1.15.2
  • TensorFlowJS版本:1.7.2
  • 开发工具:Pycharm和Webstorm

下载项目

同步完开发环境后,终于要开始动工了。首先我们需要在Github上下载几个项目:

准备图片素材

我们可以通过搜索引擎收集有关图书的图片素材:

其次,我们可以在Github上克隆LabelImg项目,并根据Github的使用说明,按照不同的环境安装运行LabelImg项目,运行后的页面如下:

2.png

然后我们按照以下步骤,将图片格式转换为圈选区域后的XML文件:

  1. 打开图片存放的目录
  2. 选择圈选后的存放目录
  3. 圈选图片目标区域
  4. 设置圈选区域的标签
  5. 保存成XML格式

存放完后我们在存放的目录下会看到许多XML格式的文件,这个文件记录了图片的位置信息、圈选信息和标签信息等,用于后续的模型训练。

配置安装Object Detection的环境

从Github克隆迁移模型训练的项目迁移模型训练项目,注意要在r1.5分支运行,并用PyCharm打开项目。

image-20200420170524470.png

项目的目录环境为上图,首先我们需要下载TensorFlow1.15.2版本:

pip install tensorflow==1.15.2

其次安装依赖包:

sudo pip install pillow  
sudo pip install lxml  
sudo pip install jupyter  
sudo pip install matplotlib

然后通过终端切换到research目录,并执行几行配置命令,具体请参考Github的使用说明:

cd ./research  
protoc object_detection/protos/*.proto --python_out=.  
export PYTHONPATH=$PYTHONPATH:`pwd`:`pwd`/slim

最后我们运行model_builder_test.py文件,如果在终端中看到OK字样,表示配置成功。

python object_detection/builders/model_builder_test.py

将XML格式转换为TensorFlow需要的TFRecord格式

克隆并打开图片格式转换项目,然后我们对该项目加以小改造:

改造文件目录:

  1. 删除annotationsdatatraining目录中的内容
  2. 增加一个xmls目录,用以存放xml文件

image-20200420171606955.png
改造文件:
接着,我们再改造以下2个文件并新增一个文件,方便我们转换图片格式

  1. 改造xml_to_csv.py为:

    import os  
    import glob  
    import pandas as pd  
    import xml.etree.ElementTree as ET  
    import random  
    import time  
    import shutil  
    
    class Xml2Cvs:  
       def __init__(self):  
           self.xml_filepath = r'./xmls'  
           self.save_basepath = r"./annotations"  
           self.trainval_percent = 0.9  
           self.train_percent = 0.85  
    
       def xml_split_train(self):  
    
           total_xml = os.listdir(self.xml_filepath)  
           num = len(total_xml)  
           list = range(num)  
           tv = int(num * self.trainval_percent)  
           tr = int(tv * self.train_percent)  
           trainval = random.sample(list, tv)  
           train = random.sample(trainval, tr)  
           print("train and val size", tv)  
           print("train size", tr)  
           start = time.time()  
           test_num = 0  
           val_num = 0  
           train_num = 0  
           for i in list:  
               name = total_xml[i]  
               if i in trainval:  
                   if i in train:  
                       directory = "train"  
                       train_num += 1  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
                   else:  
                       directory = "validation"  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       val_num += 1  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
               else:  
                   directory = "test"  
                   xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                   if (not os.path.exists(xml_path)):  
                       os.mkdir(xml_path)  
                   test_num += 1  
                   filePath = os.path.join(self.xml_filepath, name)  
                   newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                   shutil.copyfile(filePath, newfile)  
    
           end = time.time()  
           seconds = end - start  
           print("train total : " + str(train_num))  
           print("validation total : " + str(val_num))  
           print("test total : " + str(test_num))  
           total_num = train_num + val_num + test_num  
           print("total number : " + str(total_num))  
           print("Time taken : {0} seconds".format(seconds))  
    
       def xml_to_csv(self, path):  
           xml_list = []  
           for xml_file in glob.glob(path + '/*.xml'):  
               tree = ET.parse(xml_file)  
               root = tree.getroot()  
               print(root.find('filename').text)  
               for object in root.findall('object'):  
                   value = (root.find('filename').text,  
                            int(root.find('size').find('width').text),  
                            int(root.find('size').find('height').text),  
                            object.find('name').text,  
                            int(object.find('bndbox').find('xmin').text),  
                            int(object.find('bndbox').find('ymin').text),  
                            int(object.find('bndbox').find('xmax').text),  
                            int(object.find('bndbox').find('ymax').text)  
                            )  
                   xml_list.append(value)  
           column_name = ['filename', 'width', 'height', 'class', 'xmin', 'ymin', 'xmax', 'ymax']  
           xml_df = pd.DataFrame(xml_list, columns=column_name)  
           return xml_df  
    
       def main(self):  
           for directory in ['train', 'test', 'validation']:  
               xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
               xml_df = self.xml_to_csv(xml_path)  
               xml_df.to_csv('data/mask_{}_labels.csv'.format(directory), index=None)  
               print('Successfully converted xml to csv.')  
    
    if __name__ == '__main__':  
       Xml2Cvs().xml_split_train()  
       Xml2Cvs().main()  
  2. 改造generate_tfrecord.py文件,将csv格式转换为TensorFlow需要的record格式:

image-20200420172149654.png

将该区域的row_label改成我们LabelImg中的标签名,因为我们只有一个标签,所以直接修改成book即可。

  1. 新增一个generate_tfrecord.sh脚本,方便执行generate_tfrecord.py文件

    #!/usr/bin/env bash  
    python generate_tfrecord.py --csv_input=data/mask_train_labels.csv  --output_path=data/mask_train.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_test_labels.csv  --output_path=data/mask_test.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_validation_labels.csv  --output_path=data/mask_validation.record --image_dir=images  
    

配置Object Decation的环境

export PYTHONPATH=$PYTHONPATH:你的models/research/slim所在的全目录路径

最后我们将图片文件复制到images目录,将xml文件复制到xmls目录下,再执行xml_to_csv.py文件,我们会看到data目录下产生了几个csv格式结尾的文件;这时,我们在终端执行generate_tfrecord.sh文件,TensorFlow所需要的数据格式就大功告成啦。

image-20200420172821520.png

迁移训练模型:

在这个环节我们要做以下几件事:

  • 将刚刚生成好的record文件放到对应目录下
  • 下载SSD Mobile Net V1模型文件
  • 配置book.pbtxt文件和book.config文件
放置record文件和SSD Mobile Net V1模型

为了方便我直接将models/research/object_detection/test_data下的目录清空,放置迁移训练的文件。

首先我们下载SSD Mobile Net V1模型文件

image-20200420174029721.png

我们下载第一个ssd_mobilenet_v1_coco模型即可,下载完毕后,我们解压下载的模型压缩包文件,并将模型相关的文件放在test_datamodel目录下。并将我们刚刚生成的record文件放置在test_data目录下。
image-20200420174238899.png

完成pbtxt和config配置文件

我们在test_data目录下,新建一个book.pbtxt文件,并完成配置内容:

item {  
  id: 1  
  name: 'book'  
}

由于我们只有一个标签,我们就直接配置一个id值为1,name为book的item对象。

由于我们使用SSD Mobile Net V1模型进行迁移学习,因此我们到sample\configs目录下复制一份ssd_mobilenet_v1_coco.config文件并重命名为book.config文件。

image-20200420174718068.png

接着我们修改book.config中的配置文件:

将num_classes修改为当前的标签数量:

image-20200420174843137.png

由于我们只有一个book标签,因此修改成1即可。

修改所有PATH_TO_BE_CONFIGURED的路径:
<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5d916b7213e0?w=1410&h=982&f=png&s=147351" style="zoom:50%;" />
</center>

我们将此处的模型文件地址设置成testdata/model/model.ckpt的全路径地址。

image-20200420175241636.png

我们将train_input_readerinput_path设置成mask_train.record的全路径地址;将label_map_path设置成book.pbtxt的全路径地址;将eval_input_readerinput_path设置成mask_test.record的全路径地址。

到目前为止我们所有配置都已经完成啦。接下来就是激动人心的训练模型的时刻。

运行train.py文件训练模型

我们在终端中运行train.py文件,开始迁移学习、训练模型。

python3 train.py --logtostderr --train_dir=./test_data/training/ --pipeline_config_path=./test_data/book.config

其中train_dir为我们训练后的模型存放的目录,pipeline_config_path为我们book.config文件所在的相对路径。

运行命令后,我们可以看到模型在进行一步一步的训练:

image-20200420175814070.png

并在/test_data/training目录下存放训练后的模型文件:

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5db65704e6e0?w=610&h=842&f=png&s=140510" style="zoom:50%;" />
</center>

将ckpt文件转换为pb文件

我们通过export_inference_graph.py文件,将训练好的模型转换为pb格式的文件,这个文件格式在后面我们要用来转换为TensorFlow.js能够识别的文件格式。终于我们见到TensorFlow.js的影子啦。

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5dbee720cc2c?w=440&h=439&f=jpeg&s=20529" style="zoom:50%;" />
</center>

我们执行命令,运行export_inference_graph.py文件:

python export_inference_graph.py --input_type image_tensor --pipeline_config_path ./test_data/book.config --trained_checkpoint_prefix ./test_data/training/model.ckpt-1989 --output_directory ./test_data/training/book_model_test

其中pipeline_config_pathbook.config的相对文件路径,trained_checkpoint_prefix为模型文件的路径,例如我们选择训练了1989步的模型文件,output_directory为我们输出pb文件的目标目录。

运行完后,我们可以看到一个生成了book_model_test目录:

image-20200420181903653.png

将pb文件转换为TensorFlowJs模型

首先我们需要依赖TensorFlowjs的依赖包

pip install tensorflowjs

然后通过命令行转换刚刚生成的pb文件

tensorflowjs_converter --input_format=tf_saved_model --output_node_names='detection_boxes,detection_classes,detection_features,detection_multiclass_scores,detection_scores,num_detections,raw_detection_boxes,raw_detection_scores' --saved_model_tags=serve --output_format=tfjs_graph_model ./saved_model ./web_model

其中我们设置最后两个参数,即saved_model的目录与TensorFlow.js识别模型的输出目录。

运行结束后,我们可以看到一个新生成的web_model目录,其中包括了我们迁移学习训练后的模型。

到这里,模型训练的阶段终于结束了。

9150e4e5gw1fa99psluudj208c08cmx5.jpg

在Vue中运行模型

准备工作

新建Vue项目,在Vue项目的public目录下放入我们训练好的模型,即web_model目录。

image-20200421132233993.png

接着我们借助Tensorflow.js的依赖包,在package.jsondependencies中加入:

"@tensorflow/tfjs": "^1.7.2",  
"@tensorflow/tfjs-core": "^1.7.2",  
"@tensorflow/tfjs-node": "^1.7.2",

然后通过npm命令安装依赖包。

加载模型

在我们的JS代码部分引入TensorFlow的依赖包:

import * as tf from '@tensorflow/tfjs';  
import {loadGraphModel} from '@tensorflow/tfjs-converter';

接着第一步,我们先加载模型文件中的model.json文件:

const MODEL_URL = process.env.BASE_URL+"web_model/model.json";  
this.model = await loadGraphModel(MODEL_URL);

通过loadGraphModel方法,我们加载好训练的模型,再将模型对象打印出来:

image-20200421132921380.png

随后,我们可以看到模型会输出一个长度为4的数组:

  • detection_scores:表示识别对象模型的置信度,置信度越高,则代表模型认为对应区域识别为书本的可能性越高
  • detection_classes:表示模型识别的区域对应的标签,例如在本案例中,识别出来的是book
  • num_detections:表示模型识别出目标对象的个数
  • detection_boxes:表示模型识别出来目标对象的区域,为一个长度为4的数组,分别是:[x_pos,y_pos,x_width,y_height] 。第一个位代表圈选区域左上角的x坐标,第二位代表圈选左上角的y坐标,第三位代表圈选区域的宽度,第四位代表圈选区域的长度。

模型识别

知道了输出值,我们就可以开始将图片输入到模型中,从而得到模型预测的结果:

const img = document.getElementById('img');  
let modelres =await this.model.executeAsync(tf.browser.fromPixels(img).expandDims(0));

我们通过model.executeAsync方法,将图片输入到模型中,从而得到模型的输出值。

结果是我们前文提到的一个长度为4的数组。接着我们通过自定义方法,将得到的结果进行整理,从而输出一个想要的结果格式:

buildDetectedObjects:function(scores, threshold, imageWidth, imageHeight, boxes, classes, classesDir) {  
          const detectionObjects = [];  
          scores.forEach((score, i) => {  
              if (score > threshold) {  
                  const bbox = [];  
                  const minY = boxes[i * 4] * imageHeight;  
                  const minX = boxes[i * 4 + 1] * imageWidth;  
                  const maxY = boxes[i * 4 + 2] * imageHeight;  
                  const maxX = boxes[i * 4 + 3] * imageWidth;  
                  bbox[0] = minX;  
                  bbox[1] = minY;  
                  bbox[2] = maxX - minX;  
                  bbox[3] = maxY - minY;  
                  detectionObjects.push({  
                      class: classes[i],  
                      label: classesDir[classes[i]].name,  
                      score: score.toFixed(4),  
                      bbox: bbox  
                  });  
              }  
          });  

          return detectionObjects  
}

我们通过调用buildDetectedObjects来整理和返回最后的结果。

  • scores:输入模型的detection_scores数组
  • threshold:阈值,即结果score>threshold我们才会将对应的结果放入结果对象detectionObjects
  • imageWidth:图片的宽度
  • imageHeight:图片的长度
  • boxes:输入模型的detection_boxes数组
  • classes:输入模型的detection_classes数组
  • classesDir:即模型标签对象

调用buildDetectedObjects方法示例:

let classesDir = {  
    1: {  
        name: 'book',  
        id: 1,  
        }  
    };  
let res=this.buildDetectedObjects(modelres[0].dataSync(),0.20,img.width,img.height,modelres[3].dataSync(),modelres[1].dataSync(),classesDir);

我们通过modelres[0].dataSync(),来获取对应结果的数组对象,再输入到方法中,从而最终获得res结果对象。

image-20200421140000851.png

最后我们通过Canvas的API,将图片根据bbox返回的数组对象,画出对应的区域即可。由于篇幅原因,就不赘述了,最终效果如下:
image-20200421140314124.png

最后

本案例的模型存在一定的不足,由于训练时间较短,图书的封面类型众多,存在人像、风景图等等的样式,导致模型在识别过程中可能会将少部分的人脸、风景照等图片错误地识别成图书封面。各位小伙伴在训练自己模型的过程中可以考虑优化此问题。

当然,本案例的模型在识别非图书的场景会存在识别不准确的情况,一方面这是因为本案例从网络收集的图书样本具有一定局限性,而且图书的封面类型千差万别,存在人像、风景图等等的样式;另一方面因为本文在仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关的知识,并提供训练自己的模型的解决方案,所以在收集样本和模型训练时间较短。感兴趣的小伙伴可以自己琢磨琢磨如何优化样本和在避免过拟合的情况下提高训练时长,从而提高模型对被识别物体的准确性。

我们写下本文仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关知识并提供一种AI的解决方案。
我们希望和广大程序员一起学习新知、共同进步,愿每位热爱学习的开发者都能畅游书海,遇见更好的自己!

收起阅读 »

宽带优化之Flume Avro在个推的实践

引言
带宽不够用,靠这个方法我让数据压缩率达到了80%以上

如何在有限的资源下解决性能瓶颈问题是运维永恒的痛点。这期文章,Mr.Tech 邀请了在性能优化方面有着丰富经验的个推高级运维工程师白子画,为大家分享宽带优化之Flume Avro在个推的实践。

在异地日志数据互传的场景下,我们从传输数据着手,借助Avro的特性使数据压缩率达80%以上,解决了个推在实际生产过程中遇到的带宽不够用的问题。本文我们将向大家介绍Flume Avro在数据传输过程中所承担的不同角色,以及如何保证数据的完整性和传输的高效性,并分享在实际业务中取得的优化效果。

背景
个推作为专业的数据智能服务商,已经成功服务了数十万APP,每日的消息下发量达百亿级别,由此产生了海量日志数据。为了应对业务上的各种需求,我们需要采集并集中化日志进行计算,为此个推选用了高可用的、高可靠的、分布式的Flume系统以对海量日志进行采集、聚合和传输。此外,个推也不断对Flume进行迭代升级,以实现自己对日志的特定需求。

原有的异地机房日志汇聚方式,整个流程相对来说比较简单,A机房业务产生的日志通过多种方式写入该机房Kafka集群,然后B机房的Flume通过网络专线实时消费A机房Kafka的日志数据后写入本机房的Kafka集群,所有机房的数据就是通过相同方式在B机房Kakfa集群中集中化管理。如图一所示:

图一:原有异地日志传输模式

但是随着业务量的不断增加,日志数据在逐渐增多的过程中对带宽要求变高,带宽的瓶颈问题日益凸显。按照1G的专线带宽成本2~3w/月来计算,一个异地机房一年仅专线带宽扩容成本就高达30w以上。对此,如何找到一种成本更加低廉且符合当前业务预期的传输方案呢?Avro有快速压缩的二进制数据形式,并能有效节约数据存储空间和网络传输带宽,从而成为优选方案。

优化思路
Avro简介

Avro是一个数据序列化系统。它是Hadoop的一个子项目,也是Apache的一个独立的项目,其主要特点如下:
● 丰富的数据结构;
● 可压缩、快速的二进制数据类型;
● 可持久化存储的文件类型;
● 远程过程调用(RPC);
● 提供的机制使动态语言可以方便地处理数据。
具体可参考官方网站:http://avro.apache.org/

Flume Avro方案

Flume的RPC Source是Avro Source,它被设计为高扩展的RPC服务端,能从其他Flume Agent 的Avro Sink或者Flume SDK客户端,接收数据到Flume Agent中,具体流程如图二所示:

图二:Avro Source流程

针对该模式,我们的日志传输方案计划变更为A机房部署Avro Sink用以消费该机房Kafka集群的日志数据,压缩后发送到B机房的Avro Source,然后解压写入B机房的Kafka集群,具体的传输模式如图三所示:

图三:Flume Avro传输模式

可能存在的问题

我们预估可能存在的问题主要有以下三点:
● 当专线故障的时候,数据是否能保证完整性;
● 该模式下CPU和内存等硬件的消耗评估;
● 传输性能问题。

验证情况
针对以上的几个问题,我们做了几项对比实验。
环境准备情况说明:

  1. 两台服务器192.168.10.81和192.168.10.82,以及每台服务器上对应一个Kakfa集群,模拟A机房和B机房;
  2. 两个Kafka集群中对应topicA(源端)和topicB(目标端)。在topicA中写入合计大小11G的日志数据用来模拟原始端日志数据。
  3. 192.168.10.82上部署一个Flume,模拟原有传输方式。
  4. 192.168.10.81服务器部署Avro Sink,192.168.10.82部署Avro Source,模拟Flume Avro传输模式。

原有Flume模式验证(非Avro)

监控Kafka消费情况:

81流量统计:

82流量统计:

消费全部消息耗时:20min
消费总日志条数统计:129,748,260
总流量:13.5G

Avro模式验证

配置说明:

Avro Sink配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = org.apache.flume.source.kafka.KafkaSource
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.zookeeperConnect = 192.168.10.81:2181
kafkatokafka.sources.kafka_dmc_bullet.topic = topicA
kafkatokafka.sources.kafka_dmc_bullet.kafka.zookeeper.connection.timeout.ms = 150000
kafkatokafka.sources.kafka_dmc_bullet.kafka.consumer.timeout.ms = 10000
kafkatokafka.sources.kafka_dmc_bullet.kafka.group.id = flumeavro
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet的配置,可配置多个sink提高压缩传输效率

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.AvroSink
kafkatokafka.sinks.kafkasink_dmc_bullet.hostname = 192.168.10.82
kafkatokafka.sinks.kafkasink_dmc_bullet.port = 55555 //与source的rpc端口一一对应
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-level = 6 //压缩率1~9
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet配的channel,只配一个

kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000

kafkatokafka.channels.channel_dmc_bullet.byteCapacity = 10000

kafkatokafka.channels.channel_dmc_bullet.byteCapacityBufferPercentage = 10

kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 5000
kafkatokafka.channels.channel_dmc_bullet.keep-alive = 60

Avro Source配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = avro
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.bind = 0.0.0.0
kafkatokafka.sources.kafka_dmc_bullet.port = 55555 //rpc端口绑定
kafkatokafka.sources.kafka_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 100

source kafkasink_dmc_bullet的配置

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.kafka.KafkaSink
kafkatokafka.sinks.kafkasink_dmc_bullet.kafka.partitioner.class = com.gexin.rp.base.kafka.SimplePartitioner
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.topic = topicB
kafkatokafka.sinks.kafkasink_dmc_bullet.brokerList = 192.168.10.82:9091,192.168.10.82:9092,192.168.10.82:9093
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 500
kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000
kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 1000

监控Kafka消费情况

81流量统计:

82流量统计:

消费全部消息耗时:26min
消费总日志条数统计:129,748,260
总流量:1.69G

故障模拟

  1. 模拟专线故障,在A、B两机房不通的情况下,Avro Sink报错如下:

  2. 监控Kafka消费情况,发现消费者已停止消费:

  1. 故障处理恢复后继续消费剩余日志,经统计,总日志条数为:129,747,255。

结论

  1. 当专线发生故障时,正在网络传输中的通道外数据可能会有少部分丢失,其丢失原因为网络原因,与Avro模式无关;故障后停止消费的数据不会有任何的丢失问题,由于网络原因丢失的数据需要评估其重要性以及是否需要补传。
  2. 流量压缩率达80%以上,同时我们也测试了等级为1~9的压缩率,6跟9非常接近,CPU和内存的使用率与原有传输模式相差不大,带宽的优化效果比较明显。
  3. 传输性能由于压缩的原因适当变弱,单Sink由原先20分钟延长至26分钟,可适当增加Sink的个数来提高传输速率。

生产环境实施结果

实施结果如下:

  1. 由于还有其它业务的带宽占用,总带宽使用率节省了50%以上,现阶段高峰期带宽速率不超过400Mbps;
  2. 每个Sink传输速率的极限大概是3000条每秒,压缩传输速率问题通过增加Sink的方式解决,但会适当增加CPU和内存的损耗。

全文总结
Flume作为个推日志传输的主要工具之一,Source的类型选择尤为重要(如avro、thrif、exec、kafka和spooling directory等等)。无论选择哪种Source,都是为了实现日志数据的高效传输。本文通过Avro的方式,解决了带宽资源瓶颈的问题。

未来,我们希望与更多开发者一起探索如何用更多的技术手段来节约控制成本,并满足更多的业务场景需求。

继续阅读 »

引言
带宽不够用,靠这个方法我让数据压缩率达到了80%以上

如何在有限的资源下解决性能瓶颈问题是运维永恒的痛点。这期文章,Mr.Tech 邀请了在性能优化方面有着丰富经验的个推高级运维工程师白子画,为大家分享宽带优化之Flume Avro在个推的实践。

在异地日志数据互传的场景下,我们从传输数据着手,借助Avro的特性使数据压缩率达80%以上,解决了个推在实际生产过程中遇到的带宽不够用的问题。本文我们将向大家介绍Flume Avro在数据传输过程中所承担的不同角色,以及如何保证数据的完整性和传输的高效性,并分享在实际业务中取得的优化效果。

背景
个推作为专业的数据智能服务商,已经成功服务了数十万APP,每日的消息下发量达百亿级别,由此产生了海量日志数据。为了应对业务上的各种需求,我们需要采集并集中化日志进行计算,为此个推选用了高可用的、高可靠的、分布式的Flume系统以对海量日志进行采集、聚合和传输。此外,个推也不断对Flume进行迭代升级,以实现自己对日志的特定需求。

原有的异地机房日志汇聚方式,整个流程相对来说比较简单,A机房业务产生的日志通过多种方式写入该机房Kafka集群,然后B机房的Flume通过网络专线实时消费A机房Kafka的日志数据后写入本机房的Kafka集群,所有机房的数据就是通过相同方式在B机房Kakfa集群中集中化管理。如图一所示:

图一:原有异地日志传输模式

但是随着业务量的不断增加,日志数据在逐渐增多的过程中对带宽要求变高,带宽的瓶颈问题日益凸显。按照1G的专线带宽成本2~3w/月来计算,一个异地机房一年仅专线带宽扩容成本就高达30w以上。对此,如何找到一种成本更加低廉且符合当前业务预期的传输方案呢?Avro有快速压缩的二进制数据形式,并能有效节约数据存储空间和网络传输带宽,从而成为优选方案。

优化思路
Avro简介

Avro是一个数据序列化系统。它是Hadoop的一个子项目,也是Apache的一个独立的项目,其主要特点如下:
● 丰富的数据结构;
● 可压缩、快速的二进制数据类型;
● 可持久化存储的文件类型;
● 远程过程调用(RPC);
● 提供的机制使动态语言可以方便地处理数据。
具体可参考官方网站:http://avro.apache.org/

Flume Avro方案

Flume的RPC Source是Avro Source,它被设计为高扩展的RPC服务端,能从其他Flume Agent 的Avro Sink或者Flume SDK客户端,接收数据到Flume Agent中,具体流程如图二所示:

图二:Avro Source流程

针对该模式,我们的日志传输方案计划变更为A机房部署Avro Sink用以消费该机房Kafka集群的日志数据,压缩后发送到B机房的Avro Source,然后解压写入B机房的Kafka集群,具体的传输模式如图三所示:

图三:Flume Avro传输模式

可能存在的问题

我们预估可能存在的问题主要有以下三点:
● 当专线故障的时候,数据是否能保证完整性;
● 该模式下CPU和内存等硬件的消耗评估;
● 传输性能问题。

验证情况
针对以上的几个问题,我们做了几项对比实验。
环境准备情况说明:

  1. 两台服务器192.168.10.81和192.168.10.82,以及每台服务器上对应一个Kakfa集群,模拟A机房和B机房;
  2. 两个Kafka集群中对应topicA(源端)和topicB(目标端)。在topicA中写入合计大小11G的日志数据用来模拟原始端日志数据。
  3. 192.168.10.82上部署一个Flume,模拟原有传输方式。
  4. 192.168.10.81服务器部署Avro Sink,192.168.10.82部署Avro Source,模拟Flume Avro传输模式。

原有Flume模式验证(非Avro)

监控Kafka消费情况:

81流量统计:

82流量统计:

消费全部消息耗时:20min
消费总日志条数统计:129,748,260
总流量:13.5G

Avro模式验证

配置说明:

Avro Sink配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = org.apache.flume.source.kafka.KafkaSource
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.zookeeperConnect = 192.168.10.81:2181
kafkatokafka.sources.kafka_dmc_bullet.topic = topicA
kafkatokafka.sources.kafka_dmc_bullet.kafka.zookeeper.connection.timeout.ms = 150000
kafkatokafka.sources.kafka_dmc_bullet.kafka.consumer.timeout.ms = 10000
kafkatokafka.sources.kafka_dmc_bullet.kafka.group.id = flumeavro
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet的配置,可配置多个sink提高压缩传输效率

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.AvroSink
kafkatokafka.sinks.kafkasink_dmc_bullet.hostname = 192.168.10.82
kafkatokafka.sinks.kafkasink_dmc_bullet.port = 55555 //与source的rpc端口一一对应
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-level = 6 //压缩率1~9
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet配的channel,只配一个

kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000

kafkatokafka.channels.channel_dmc_bullet.byteCapacity = 10000

kafkatokafka.channels.channel_dmc_bullet.byteCapacityBufferPercentage = 10

kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 5000
kafkatokafka.channels.channel_dmc_bullet.keep-alive = 60

Avro Source配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = avro
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.bind = 0.0.0.0
kafkatokafka.sources.kafka_dmc_bullet.port = 55555 //rpc端口绑定
kafkatokafka.sources.kafka_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 100

source kafkasink_dmc_bullet的配置

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.kafka.KafkaSink
kafkatokafka.sinks.kafkasink_dmc_bullet.kafka.partitioner.class = com.gexin.rp.base.kafka.SimplePartitioner
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.topic = topicB
kafkatokafka.sinks.kafkasink_dmc_bullet.brokerList = 192.168.10.82:9091,192.168.10.82:9092,192.168.10.82:9093
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 500
kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000
kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 1000

监控Kafka消费情况

81流量统计:

82流量统计:

消费全部消息耗时:26min
消费总日志条数统计:129,748,260
总流量:1.69G

故障模拟

  1. 模拟专线故障,在A、B两机房不通的情况下,Avro Sink报错如下:

  2. 监控Kafka消费情况,发现消费者已停止消费:

  1. 故障处理恢复后继续消费剩余日志,经统计,总日志条数为:129,747,255。

结论

  1. 当专线发生故障时,正在网络传输中的通道外数据可能会有少部分丢失,其丢失原因为网络原因,与Avro模式无关;故障后停止消费的数据不会有任何的丢失问题,由于网络原因丢失的数据需要评估其重要性以及是否需要补传。
  2. 流量压缩率达80%以上,同时我们也测试了等级为1~9的压缩率,6跟9非常接近,CPU和内存的使用率与原有传输模式相差不大,带宽的优化效果比较明显。
  3. 传输性能由于压缩的原因适当变弱,单Sink由原先20分钟延长至26分钟,可适当增加Sink的个数来提高传输速率。

生产环境实施结果

实施结果如下:

  1. 由于还有其它业务的带宽占用,总带宽使用率节省了50%以上,现阶段高峰期带宽速率不超过400Mbps;
  2. 每个Sink传输速率的极限大概是3000条每秒,压缩传输速率问题通过增加Sink的方式解决,但会适当增加CPU和内存的损耗。

全文总结
Flume作为个推日志传输的主要工具之一,Source的类型选择尤为重要(如avro、thrif、exec、kafka和spooling directory等等)。无论选择哪种Source,都是为了实现日志数据的高效传输。本文通过Avro的方式,解决了带宽资源瓶颈的问题。

未来,我们希望与更多开发者一起探索如何用更多的技术手段来节约控制成本,并满足更多的业务场景需求。

收起阅读 »

最新IOSAPP下架原因汇总,找到源头

对于移动应用开发者来说, 最令人沮丧的可能莫过于辛辛苦苦开发的应用, 没能通过苹果AppStore的审核,或者在应用更新时遭遇下架。苹果的AppStore的审核流程和标准, 一向不透明, 而且申诉起来也非常的麻烦。给大家总结了App被苹果AppStore下架的各大原因。

  1. 中国大陆禁用 CallKit

  5 月中,部分开发者收到苹果 App 审核中心的邮件,被告知因工信部规定,在中国大陆地区上架的 App 不可以使用 Callkit,使用该功能的的产品将会不被过审,或下架。

  1. 需适配 iOS 11 与刘海屏

  苹果于 5 月通知开发者,任何提交到 App Store 的所有 iOS 应用必须使用 iOS SDK 构建,并且必须支持 iPhone X 的超级 Retina 屏。此规定将于 7 月生效,不改的话,你懂的…

  1. 热更新代码成下架隐患(JSPatch、Weex 和 React Native)

  17 年 3 月,众多开发者收到苹果邮件,内容为对含有热更新的 App 进行警告,要求移除相关代码。原因是一些黑客可能利用热更新修改 App,给用户带来安全隐患。

  1. GDPR 后数据安全成重点

  WWDC18 后,苹果审核指南更新,结合欧盟颁布的 GDPR 条款,对数据安全、数据收集与存储、数据使用与共享 3 个方面进行补充。

  1. 数字加密货币(区块链)

  苹果对数字加密货币的监管也在 WWDC18 后有所体现,如 App 不得挖掘加密货币;为首次发币、密码货币期货交易等提供便利的 App,必须来自经批准的金融机构,并遵守当地法律。

  1. 刷榜

  出现在不正当竞争当中,竞争对手帮着App刷榜掌握证据之后向苹果举报,很容易导致App被下架。

  1. 条款违规

  App中的内容违反苹果App Store条款,应用的功能涉及侵犯他人的隐私滥用隐私权限或者存在与宗教、法律相悖的内容,这些App被查出会被苹果第一时间下架。

  360产品曾经被苹果下架就有人爆料说是因为360违反了在没有事先征求用户许可的情况下为某些特定的功能,擅自收集用户电话号码等数据的行为。

  1. 与iOS自带功能冲突

  App的有些功能可能由于种种原因在审核时没有看出,但后来暴露出与苹果规定不符,在功能上与iOS自带应用产生竞争,那么苹果就会将你视为竞争对手,后面App的命运也就可想而知。在去年6月的时候,曾经有一款名为Display Recorder的录屏应用通过苹果审核上架,而这是一款与iOS自带截图功能冲突的录屏应用,在不久之后这款应用随即就被苹果下架。

  1. 描述与实际不符

  如果App的截图、功能、说明与实际有很大出入,挂羊头卖狗肉之流显然也是不被苹果允许的。

  1. 有Bug或者崩溃

  如果App存在明显的Bug或者经常崩溃而被用户大量投诉,这类App也是很容易就被下架的。在App的调试阶段一定要严格把关,同时适配上也不要理论上可行,一定要亲自在各系统版本中进行测试,确保描述中包括的所有版本下都能良好运行。

  在2011年的时候,Google Voice上线了一个新版本1.3.0.1771,但是不少用户投诉说遇到了程序崩溃等问题,因为Google Voice的这个Bug至少影响到了一部分用户,该版本的Google Voice随之被下架重新调整。

  1. 山寨、侵权

  如果App被投诉有明显的山寨模仿痕迹,或者在其它方面涉及侵犯他人合法权益,苹果在调查取证之后也会将相应的App作下架处理。在2012年11月份的时候,115网盘就被多家市场下架,主要原因是商标侵权。因115商标并非由115网盘开发商广东五科技有限公司所有,在收到115注册商标权利人发出的删除115侵权软件公函后,苹果将115应用从App Store中下架。

  1. 采用“Beta”或者其他可能让苹果认为你的应用还未开发完成的名称

  由于Google的很多产品采用“Beta”来发布, IT业内也有一种把最终产品命名加上“Beta”的潮流。 而苹果对此非常严格。任何带有类似字眼的产品会被认为尚未完成。 我们就见到过不少如“Beta版”,“预览版”或者“Version 0.9”的产品没能通过AppStore审核

  1. 加载时间过长

  所有的移动操作系统(iOS, Android甚至Windows),都对应用的最大加载时间又限制。 对iOS来说, 大约是15秒,如果你的应用在15秒内未能启动, 操作系统就会杀进程。

  即便你的应用在这段时间内启动了,网络连接速度, 硬件等环境的不同, 也可能导致你的应用在审核期间加载时间过长。 因此, 不要仅仅依赖于iOS的模拟器,一定要在真实网络和真实硬件环境下进行测试, 而且最好保留一些旧版的手机以确保所有用户都能体验到相对较快的加载速度。

  1. 链接到第三方支付系统

  苹果要求所有的数字内容都要在通过iTunes内置的应用内购买来完成。 包括一次性购买或者注册付费等方式。 如果你的应用提供其他的购买方式, 你的应用肯定会被下架。这也就是为什么Kindle的应用不让用户直接从应用购买新书的原因。

  甚至对应用通过网页提供第三方支付链接也不行。 Dropbox的应用被苹果拒绝就是一个例子。 Dropbox的应用在登录界面提供了一个购买更多存储空间的链接而未能通过苹果的审核。这不仅仅对Dropbox的App有影响, 甚至对所有采用Dropbox API的应用都有影响。

  因此,一定要好好检查一下你的应用开发流程, 确保所有的数字内容的购买都是通过用户的iTunes账户完成的。 注意, 苹果只对数字内容才做出此限制,这也就是为什么一些旅游应用能够通过App预订酒店和机票的原因。

  1. 不要提对其他平台的支持

  不光是苹果。没有那个市场愿意你提到竞争对手名字的。 因此, 如果你的应用已经在Android上或者Windows上跑了, 在你自己的网站上说吧, 不要在App的描述里提。

  1. 本地化的一些问题

  你的App用户可能来自世界各地, 即便你不提供多语言版本, 在应用内购买的时候把日元符号写成欧元也表明你是一个不成熟的开发者。

  因此,要利用类似 asNSNumberFormatter或Invariant Culture这样的组件以及模拟器来测试在不同地区下的用户体验, 确保如日期之类的格式符合用户本地的格式要求。

  我们曾经见过一个欧洲的应用开发商的应用,因为没有正确处理负的经纬度而被苹果AppStore拒绝(注:苹果总部的经度是 -122.03)。 因此, 一定要好好检查你的应用在地图上任何经纬度都能跑。 特别要注意经过本初子午线的正负经度变化以及经过赤道正负纬度变化。

  1. 对存储和文件系统的不正确使用

  在iOS5.1推出后, 苹果曾经下架了一款应用的升级因为开发者把一个2MB大小的数据库从应用备份到了文件系统,违反了iCloud关于只备份用户产生内容的规定。

  对任何静态的或者应用自带的或者很容易从远程服务器下载的所谓“可再生的数据”都不需要备份。 对于非用户数据, 可以选择缓存位置或者选择“不需要备份”属性。

  1. 在用户拒绝权限请求时崩溃

  在iOS6, 用户对试图访问地址本, 照片集, 地理位置, 日历, 提醒, 蓝牙, Twitter和Facebook账户的应用, 必须提升权限申请。 如果用户拒绝应用的权限申请, 苹果要求App依然能够继续工作。

  这一点在审核时会自动测试,如果不能正常工作, 则会被自动拒绝。 因此, 一定要仔细测试应用使用数据时的所有的“允许”“拒绝”组合, 同时也包括用户开始允许权限后来又拒绝的情况。

  1. 对图标和按钮的不正确使用

  很多iOS App被拒绝不是因为性能或者功能的原因, 仅仅就是因为一些小的UI方面的问题。开发者要确保应用在采用内置的苹果的图标和按钮时在外观和功能上一致性。 可以采用UIButtonBarSystemItem来进行测试。另外, 要熟悉苹果的“人机交互指南”

  比如说,除了创建新内容, 你不能用苹果的“Compose” 图标代表任何其他的用途。苹果的工程师希望App的行为尽量可预测。 因此对内置图标和按钮的使用方面格外严格。

  1. 商标和Logo的错误使用

  在你的应用的图像上,不要使用他人的商标或者苹果图标。 也不要使用带有iPhone图样的图标。 我们见过在关键字中含有商标被拒绝的例子。

  另一方面,你的应用还需要在内置地图中明确属性信息(比如Google地图或者诺基亚的地图)等, 否则也会被拒绝。

  当然, 如果你的应用被拒绝了或者下架了, 也不要着急, 找出问题来再次提交就是了。 此外, 苹果还提供一种紧急审核的机制, 用来进行重要Bug或者安全问题的修复。 不过, 开发者如果过度使用紧急审核机制, 可能会在今后被禁止使用。

当然最好还是争取一次就通过审核。 所以,最根本的当然是, 开发者仔细阅读苹果的《提交指南》并且提供高质量的应用。有问题可以随时找我解决VX18064099649

继续阅读 »

对于移动应用开发者来说, 最令人沮丧的可能莫过于辛辛苦苦开发的应用, 没能通过苹果AppStore的审核,或者在应用更新时遭遇下架。苹果的AppStore的审核流程和标准, 一向不透明, 而且申诉起来也非常的麻烦。给大家总结了App被苹果AppStore下架的各大原因。

  1. 中国大陆禁用 CallKit

  5 月中,部分开发者收到苹果 App 审核中心的邮件,被告知因工信部规定,在中国大陆地区上架的 App 不可以使用 Callkit,使用该功能的的产品将会不被过审,或下架。

  1. 需适配 iOS 11 与刘海屏

  苹果于 5 月通知开发者,任何提交到 App Store 的所有 iOS 应用必须使用 iOS SDK 构建,并且必须支持 iPhone X 的超级 Retina 屏。此规定将于 7 月生效,不改的话,你懂的…

  1. 热更新代码成下架隐患(JSPatch、Weex 和 React Native)

  17 年 3 月,众多开发者收到苹果邮件,内容为对含有热更新的 App 进行警告,要求移除相关代码。原因是一些黑客可能利用热更新修改 App,给用户带来安全隐患。

  1. GDPR 后数据安全成重点

  WWDC18 后,苹果审核指南更新,结合欧盟颁布的 GDPR 条款,对数据安全、数据收集与存储、数据使用与共享 3 个方面进行补充。

  1. 数字加密货币(区块链)

  苹果对数字加密货币的监管也在 WWDC18 后有所体现,如 App 不得挖掘加密货币;为首次发币、密码货币期货交易等提供便利的 App,必须来自经批准的金融机构,并遵守当地法律。

  1. 刷榜

  出现在不正当竞争当中,竞争对手帮着App刷榜掌握证据之后向苹果举报,很容易导致App被下架。

  1. 条款违规

  App中的内容违反苹果App Store条款,应用的功能涉及侵犯他人的隐私滥用隐私权限或者存在与宗教、法律相悖的内容,这些App被查出会被苹果第一时间下架。

  360产品曾经被苹果下架就有人爆料说是因为360违反了在没有事先征求用户许可的情况下为某些特定的功能,擅自收集用户电话号码等数据的行为。

  1. 与iOS自带功能冲突

  App的有些功能可能由于种种原因在审核时没有看出,但后来暴露出与苹果规定不符,在功能上与iOS自带应用产生竞争,那么苹果就会将你视为竞争对手,后面App的命运也就可想而知。在去年6月的时候,曾经有一款名为Display Recorder的录屏应用通过苹果审核上架,而这是一款与iOS自带截图功能冲突的录屏应用,在不久之后这款应用随即就被苹果下架。

  1. 描述与实际不符

  如果App的截图、功能、说明与实际有很大出入,挂羊头卖狗肉之流显然也是不被苹果允许的。

  1. 有Bug或者崩溃

  如果App存在明显的Bug或者经常崩溃而被用户大量投诉,这类App也是很容易就被下架的。在App的调试阶段一定要严格把关,同时适配上也不要理论上可行,一定要亲自在各系统版本中进行测试,确保描述中包括的所有版本下都能良好运行。

  在2011年的时候,Google Voice上线了一个新版本1.3.0.1771,但是不少用户投诉说遇到了程序崩溃等问题,因为Google Voice的这个Bug至少影响到了一部分用户,该版本的Google Voice随之被下架重新调整。

  1. 山寨、侵权

  如果App被投诉有明显的山寨模仿痕迹,或者在其它方面涉及侵犯他人合法权益,苹果在调查取证之后也会将相应的App作下架处理。在2012年11月份的时候,115网盘就被多家市场下架,主要原因是商标侵权。因115商标并非由115网盘开发商广东五科技有限公司所有,在收到115注册商标权利人发出的删除115侵权软件公函后,苹果将115应用从App Store中下架。

  1. 采用“Beta”或者其他可能让苹果认为你的应用还未开发完成的名称

  由于Google的很多产品采用“Beta”来发布, IT业内也有一种把最终产品命名加上“Beta”的潮流。 而苹果对此非常严格。任何带有类似字眼的产品会被认为尚未完成。 我们就见到过不少如“Beta版”,“预览版”或者“Version 0.9”的产品没能通过AppStore审核

  1. 加载时间过长

  所有的移动操作系统(iOS, Android甚至Windows),都对应用的最大加载时间又限制。 对iOS来说, 大约是15秒,如果你的应用在15秒内未能启动, 操作系统就会杀进程。

  即便你的应用在这段时间内启动了,网络连接速度, 硬件等环境的不同, 也可能导致你的应用在审核期间加载时间过长。 因此, 不要仅仅依赖于iOS的模拟器,一定要在真实网络和真实硬件环境下进行测试, 而且最好保留一些旧版的手机以确保所有用户都能体验到相对较快的加载速度。

  1. 链接到第三方支付系统

  苹果要求所有的数字内容都要在通过iTunes内置的应用内购买来完成。 包括一次性购买或者注册付费等方式。 如果你的应用提供其他的购买方式, 你的应用肯定会被下架。这也就是为什么Kindle的应用不让用户直接从应用购买新书的原因。

  甚至对应用通过网页提供第三方支付链接也不行。 Dropbox的应用被苹果拒绝就是一个例子。 Dropbox的应用在登录界面提供了一个购买更多存储空间的链接而未能通过苹果的审核。这不仅仅对Dropbox的App有影响, 甚至对所有采用Dropbox API的应用都有影响。

  因此,一定要好好检查一下你的应用开发流程, 确保所有的数字内容的购买都是通过用户的iTunes账户完成的。 注意, 苹果只对数字内容才做出此限制,这也就是为什么一些旅游应用能够通过App预订酒店和机票的原因。

  1. 不要提对其他平台的支持

  不光是苹果。没有那个市场愿意你提到竞争对手名字的。 因此, 如果你的应用已经在Android上或者Windows上跑了, 在你自己的网站上说吧, 不要在App的描述里提。

  1. 本地化的一些问题

  你的App用户可能来自世界各地, 即便你不提供多语言版本, 在应用内购买的时候把日元符号写成欧元也表明你是一个不成熟的开发者。

  因此,要利用类似 asNSNumberFormatter或Invariant Culture这样的组件以及模拟器来测试在不同地区下的用户体验, 确保如日期之类的格式符合用户本地的格式要求。

  我们曾经见过一个欧洲的应用开发商的应用,因为没有正确处理负的经纬度而被苹果AppStore拒绝(注:苹果总部的经度是 -122.03)。 因此, 一定要好好检查你的应用在地图上任何经纬度都能跑。 特别要注意经过本初子午线的正负经度变化以及经过赤道正负纬度变化。

  1. 对存储和文件系统的不正确使用

  在iOS5.1推出后, 苹果曾经下架了一款应用的升级因为开发者把一个2MB大小的数据库从应用备份到了文件系统,违反了iCloud关于只备份用户产生内容的规定。

  对任何静态的或者应用自带的或者很容易从远程服务器下载的所谓“可再生的数据”都不需要备份。 对于非用户数据, 可以选择缓存位置或者选择“不需要备份”属性。

  1. 在用户拒绝权限请求时崩溃

  在iOS6, 用户对试图访问地址本, 照片集, 地理位置, 日历, 提醒, 蓝牙, Twitter和Facebook账户的应用, 必须提升权限申请。 如果用户拒绝应用的权限申请, 苹果要求App依然能够继续工作。

  这一点在审核时会自动测试,如果不能正常工作, 则会被自动拒绝。 因此, 一定要仔细测试应用使用数据时的所有的“允许”“拒绝”组合, 同时也包括用户开始允许权限后来又拒绝的情况。

  1. 对图标和按钮的不正确使用

  很多iOS App被拒绝不是因为性能或者功能的原因, 仅仅就是因为一些小的UI方面的问题。开发者要确保应用在采用内置的苹果的图标和按钮时在外观和功能上一致性。 可以采用UIButtonBarSystemItem来进行测试。另外, 要熟悉苹果的“人机交互指南”

  比如说,除了创建新内容, 你不能用苹果的“Compose” 图标代表任何其他的用途。苹果的工程师希望App的行为尽量可预测。 因此对内置图标和按钮的使用方面格外严格。

  1. 商标和Logo的错误使用

  在你的应用的图像上,不要使用他人的商标或者苹果图标。 也不要使用带有iPhone图样的图标。 我们见过在关键字中含有商标被拒绝的例子。

  另一方面,你的应用还需要在内置地图中明确属性信息(比如Google地图或者诺基亚的地图)等, 否则也会被拒绝。

  当然, 如果你的应用被拒绝了或者下架了, 也不要着急, 找出问题来再次提交就是了。 此外, 苹果还提供一种紧急审核的机制, 用来进行重要Bug或者安全问题的修复。 不过, 开发者如果过度使用紧急审核机制, 可能会在今后被禁止使用。

当然最好还是争取一次就通过审核。 所以,最根本的当然是, 开发者仔细阅读苹果的《提交指南》并且提供高质量的应用。有问题可以随时找我解决VX18064099649

收起阅读 »

关于TopTalk开发团队使用UniApp开发的一些规范协议

关于团队Uni项目开发的一些协议(持续完善中...):

1、长度、大小单位均使用rpx;
2、关于命名:

文件名、方法名、变量等名称命名一律使用英文,太长可缩减至前两个英文单词。

  • 包名全部小写
  • 变量名、方法名首字母小写,如果名称由多个单词组成,首单词小写其余单词的首字母都需大写

3、css样式语言的lang属性设置为scss,如

<style lang="scss">

4、组件引入遇到多页面引入时,需配置到main.js页面中,vue页面即无需再次引入;
5、本地js演示数据一律配置到main.js页面中;
注:main.js 为项目的配置文件。封装方法、js、vue插件、公用常量、服务地址均配置到此文件
6、涉及到单模块页面分包可纳入同一包下,不要层级包嵌套!规范示例如下:

7、减少深层的节点嵌套,避免在页面初始化构建造成内存占用;
8、减少 scroll-view 组件的 scroll 事件监听;
9、尽可能或完全避免在template里直接写view 的style样式!

注:进阶链接
uniMPSDK文档
高效开发技巧
HBuilderX - 高效极客技巧
manifest.json 全概述
uni调用原生的API

继续阅读 »

关于团队Uni项目开发的一些协议(持续完善中...):

1、长度、大小单位均使用rpx;
2、关于命名:

文件名、方法名、变量等名称命名一律使用英文,太长可缩减至前两个英文单词。

  • 包名全部小写
  • 变量名、方法名首字母小写,如果名称由多个单词组成,首单词小写其余单词的首字母都需大写

3、css样式语言的lang属性设置为scss,如

<style lang="scss">

4、组件引入遇到多页面引入时,需配置到main.js页面中,vue页面即无需再次引入;
5、本地js演示数据一律配置到main.js页面中;
注:main.js 为项目的配置文件。封装方法、js、vue插件、公用常量、服务地址均配置到此文件
6、涉及到单模块页面分包可纳入同一包下,不要层级包嵌套!规范示例如下:

7、减少深层的节点嵌套,避免在页面初始化构建造成内存占用;
8、减少 scroll-view 组件的 scroll 事件监听;
9、尽可能或完全避免在template里直接写view 的style样式!

注:进阶链接
uniMPSDK文档
高效开发技巧
HBuilderX - 高效极客技巧
manifest.json 全概述
uni调用原生的API

收起阅读 »

Hbuilder 多显示下一直有这个问题

HBuilder

[内容]

如附件所示,计算机中安装了三台显示器,

显示器1为4K,分辨率3840x2106 缩放:150%
显示器2为2K,分辨率1920x2160 缩放:150%
显示器3为2K,分辨率1920x2160 缩放:150%

[步骤]
当IDE从显示器1拖到显示器2,或者显示器3之后。

[结果]
无法再拖回去,IDE顶部不响应拖动事件,整个UI窗口无法拖动

[期望]

[如果语言难以表述清晰,拍一个视频或截图,有图有真相]

IDE运行环境说明

[HBuilder 或 HBuilderX。如果你用其他工具开发uni-app,也需要在此说明]

[IDE版本号]
2.6.16.20200424

[windows版本号]
Windows 10专业版

继续阅读 »

[内容]

如附件所示,计算机中安装了三台显示器,

显示器1为4K,分辨率3840x2106 缩放:150%
显示器2为2K,分辨率1920x2160 缩放:150%
显示器3为2K,分辨率1920x2160 缩放:150%

[步骤]
当IDE从显示器1拖到显示器2,或者显示器3之后。

[结果]
无法再拖回去,IDE顶部不响应拖动事件,整个UI窗口无法拖动

[期望]

[如果语言难以表述清晰,拍一个视频或截图,有图有真相]

IDE运行环境说明

[HBuilder 或 HBuilderX。如果你用其他工具开发uni-app,也需要在此说明]

[IDE版本号]
2.6.16.20200424

[windows版本号]
Windows 10专业版

收起阅读 »

小团队开发uniapp、uniapp定制开发

uniapp

uniapp定制开发
uniapp所需资料都是那些?
1.功能需求沟通,前期明确需求可以确定工期和报价;
2.UI设计图确认

  1. 前端搭建
  2. 后台搭建
  3. 填写内容上线
  4. 上线测试
  5. 158-3211-5099

uniapp定制开发
uniapp所需资料都是那些?
1.功能需求沟通,前期明确需求可以确定工期和报价;
2.UI设计图确认

  1. 前端搭建
  2. 后台搭建
  3. 填写内容上线
  4. 上线测试
  5. 158-3211-5099

HTML5+表单新增、删除、预览、上传图片

图片预览
第一种方法:表单提交,使用jquery的Ajax  
<!DOCTYPE html>  
<html>  
    <head>  
        <meta charset="utf-8">    
        <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover" />  
        <meta name="apple-mobile-web-app-capable" content="yes" />  
        <meta name="apple-mobile-web-app-status-bar-style" content="black" />  
    </head>  
    <body>  
        <style>  
            .preview ul {  
                margin: 0;  
                padding: 0;  
            }  
            .preview ul li {  
                position: relative;  
                display: inline-block;  
                margin-top: 10px;  
                margin-bottom: 10px;  
                margin-right: 10px;  
                float: left;  
                list-style: none;  
            }  
            .preview ul li img {  
                width: 50px;  
                height: 50px;  
                border-radius: 5px;  
            }  
            .preview ul li input {  
                display: inline-block;  
                position: absolute;  
                width: 50px;  
                height: 50px;  
                left: 0;  
                top: 0;  
                opacity: 0;  
            }  
            .del {  
                display: inline-block;  
                position: absolute;  
                top:6px;  
                right: 0px;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                line-height: 0;  
                font-size: 0;  
                vertical-align: middle;  
                -webkit-transform: rotate(45deg);  
            }  
            .del:after {  
                content: "/";  
                display: block;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                -webkit-transform: rotate(-90deg);  
            }  
        </style>  
    <header class="mui-bar mui-bar-nav" >  
        <a class="mui-action-back mui-icon mui-icon-left-nav mui-pull-left"></a>  
        <div class="mui-title">图片预览上传</div>  
    </header>  
    <div class="mui-content" >  
        <form id='from1' enctype = "multipart/form-data">  
            <div style="margin-top: 16px;margin-bottom: 10px;">  
                <span class="title">添加照片(选填)</span>  
                <span id="picnum" style="float: right;margin-right: 20px;font-size: 12px; color: #666666;">0/4</span>  
            </div>  
            <div class="preview" style="background-color: #ffffff;height: 70px;width: 100%;padding-left: 15px;padding-right: 15px;">  
                <ul class="row mui-input-row">  
                    <li>  
                        <input id="file"  type="file" name="upload" accept="image/*"  
                               onchange="setImagePreviews();"  multiple="true" />  
                        <img src="../images/adddd.png"/>  
                    </li>  
                </ul>  
            </div>  
            <div style="margin-top: 16px;width: 100%;text-align: center;">  
                <button style="background-color: #169BD5;height: 40px;width: 266px;font-size: 14px;color: #FFFFFF;border-radius: 5px;border: 0px;" type="button" onclick="trans()">提交</button>  
            </div>  
        </form>  
    </div>  

    </body>  
<script src="../js/mui.min.js"></script>  
<script src="../js/jquery.min.js"></script>  
<script type="text/javascript">  
    mui.init({  
        swipeBack:true //启用右滑关闭功能  
        });  
    function submit(){  
        var formData = new FormData();  
        formData.append('name',name);  
        if(uploadfile.length>0){  
            for(var i=0;i<uploadfile.length;i++){  
                formData.append("upload", uploadfile[i]); // 文件对象  
            }  
        }  
        var url= ;  
        jQuery.ajax({  
          url: url,  
          type: 'post',  
          async: false,  
          contentType:false,  
          processData: false,  
          data:formData,  
          xhrFields: {  
            withCredentials: true  
          },  
          crossDomain: true,  
          success: function (data) {  
            console.log(JSON.stringify(data));  
            mui.toast("提交成功!",{ duration:'500', type:'div' });  
          },  
          error: function (error) {  
            console.log(JSON.stringify(error));  
            mui.toast('页面出错',{ duration:'500', type:'div' });  
          }  
        });  
    }  
    var uploadfile = [];  
    function setImagePreviews() {  
        var docObj = document.getElementById("file");  
        var piclength = uploadfile.length;  
        var fileList = docObj.files;  
        if((Number(piclength) + Number(fileList.length)) > 4) {  
            plus.nativeUI.alert('最多上传四张');  
        } else {  
            document.getElementById("picnum").innerText=Number(piclength) + Number(fileList.length)+'/4';  
            for(var i = 0; i < fileList.length; i++) {  
                uploadfile.push(docObj.files[i]);  
                var picid = Number(piclength) + Number(i);  
                var reader = new FileReader();  
                reader.readAsDataURL(docObj.files[i]);                      
                reader.onload = function(e) {  
                // 图片base64化  
                var newUrl = this.result;  
                $(".preview ul").prepend("<li onclick='delImg(this)'><img src='" + newUrl + "' /><span class='del'></span></li>");  
                };  

            }  
        }  
    }  
    function delImg(obj){  
        var list =document.getElementsByTagName('li');  
        for(var i=0;i<list.length;i++){  
            list[i].index=i;  
        }  
        var idx=2-obj.index;  
        var ul=obj.parentNode;  
        var btnArray=['是','否'];  
        plus.nativeUI.confirm("确定要删除此图?",function(e){  
            if(e.index==0){  
                ul.removeChild(obj);  
                uploadfile.splice(idx,1);  
                document.getElementById("picnum").innerText=Number(uploadfile.length)+'/4';  
            }  
        },{"buttons":btnArray})  
    }  
</script>  
</html>  
第二种:使用plus.uploader  

<!DOCTYPE html>  
<html>  
    <head>  
        <meta charset="utf-8">    
        <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover" />  
        <meta name="apple-mobile-web-app-capable" content="yes" />  
        <meta name="apple-mobile-web-app-status-bar-style" content="black" />  
    </head>  
    <body>  
        <style>  
            .dynamic_images ul {  
                margin: 0;  
                padding: 0;  
            }  
            .dynamic_images ul li {  
                position: relative;  
                display: inline-block;  
                margin-top: 10px;  
                margin-bottom: 10px;  
                margin-right: 10px;  
                float: left;  
                list-style: none;  
            }  
            .dynamic_images ul li img {  
                width: 50px;  
                height: 50px;  
                border-radius: 5px;  
            }  
            .del {  
                display: inline-block;  
                position: absolute;  
                top:6px;  
                right: 0px;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                line-height: 0;  
                font-size: 0;  
                vertical-align: middle;  
                -webkit-transform: rotate(45deg);  
            }  
            .del:after {  
                content: "/";  
                display: block;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                -webkit-transform: rotate(-90deg);  
            }  
        </style>  
    <header class="mui-bar mui-bar-nav" >  
        <a class="mui-action-back mui-icon mui-icon-left-nav mui-pull-left"></a>  
        <div class="mui-title">图片预览上传</div>  
    </header>  
    <div class="mui-content" >  
        <form id='from1' enctype = "multipart/form-data">  
            <div style="margin-top: 16px;margin-bottom: 10px;">  
                <span class="title">添加照片(选填)</span>  
                <span id="picnum" style="float: right;margin-right: 20px;font-size: 12px; color: #666666;">0/4</span>  
            </div>  
            <div class="dynamic_images" style="background-color: #ffffff;height: 70px;width: 100%;padding-left: 15px;padding-right: 15px;">  
                <ul class="row mui-input-row">  
                    <li>  
                        <img src="../images/adddd.png" id="addnew" onclick="showActionSheet();" />  
                    </li>  
                </ul>  
            </div>  
            <div style="margin-top: 16px;width: 100%;text-align: center;">  
                <button style="background-color: #169BD5;height: 40px;width: 266px;font-size: 14px;color: #FFFFFF;border-radius: 5px;border: 0px;" type="button" onclick="submit()">提交</button>  
            </div>  
        </form>  
    </div>  
    </body>  
<script src="../js/mui.min.js"></script>  
<script src="../js/jquery.min.js"></script>  
<script type="text/javascript">  
    mui.init({  
        swipeBack:true //启用右滑关闭功能  
        });  

    function submit(){  
        var url= ;  
        var task = plus.uploader.createUpload( url,   
            { method:"POST",blocksize:204800,priority:100 },  
            function ( t, status ) {  
                // 上传完成  
                if ( status == 200 ) {   
                    wt.close();  
                    console.log( "Upload success: " + JSON.stringify(t));  
                } else {  
                    wt.close();  
                    console.log( "Upload failed: " + JSON.stringify(status));  
                }  
            });  
            for(var i = 0; i < files.length; i++) {    
                var f = files[i];    
                task.addFile(f.path,{  
                    key: f.name,  
                    name:'upload',  
                });  
            }  
            task.addData('name',name);  
            task.start();  
    }  

    var files = [];  
    // 添加文件  
    var index = 1;  
    var newUrlAfterCompress;  
    function appendFile(p) {  
        files.push({  
            name: "uploadkey" + index,//这个值服务器会用到,作为file的key  
            path: p  
        });  
        index++;  
    }  
    function showActionSheet() {  
        if($(".dynamic_images ul li").length<5){  
            var bts = [{  
                title: "拍照"  
            }, {  
                title: "从相册选择"  
            }];  
            plus.nativeUI.actionSheet({  
                    cancel: "取消",  
                    buttons: bts  
                },  
            function(e) {  
                    if (e.index == 1) {  
                        getImage();  
                    } else if (e.index == 2) {  
                        galleryImgs();  
                    }  
                }  
            );  
        }else{  
            plus.nativeUI.alert('最多上传四张图片!');  
        }  
    }  
    // 产生一个随机数  
    function getUid() {  
        return Math.floor(Math.random() * 100000000 + 10000000).toString();  
    }  
    //拍照  
    function getImage() {  
        var cmr = plus.camera.getCamera();  
        cmr.captureImage(function(p) {  
            plus.io.resolveLocalFileSystemURL(p, function(entry) {  
                var localurl = entry.toLocalURL(); //  
                $(".dynamic_images ul").prepend("<li onclick='delImg(this)'><img src='" + localurl + "' /><span class='del'></span></li>");  
                var dstname="_downloads/"+getUid()+".png";//设置压缩后图片的路径  
                newUrlAfterCompress=compressImage(localurl,dstname);  
                appendFile(dstname);  
                document.getElementById("picnum").innerText=files.length+'/4';  
            });  
        });  
    }  
    function galleryImgs() {  
        // 从相册中选择图片  
        var num=4;  
        if(5-$(".dynamic_images ul li").length>=1){  
            num=5-$(".dynamic_images ul li").length;  
        }  
        plus.gallery.pick(function(e) {  
            for (var i = 0; i < e.files.length; i++) {  
                $(".dynamic_images ul").prepend(  
                    "<li  onclick='delImg(this)'><img  src='" + e.files[i] + "'/><span class='del'></span></li>");  
                    var dstname="_downloads/"+getUid()+".png";//设置压缩后图片的路径  
                    newUrlAfterCompress=compressImage(e.files[i],dstname);  
                    appendFile(dstname);  
            }  
            document.getElementById("picnum").innerText=files.length+'/4';  
        }, function(e) {   
            console.log("取消选择图片");  
        }, {  
            filter: "image",  
            multiple: true,  
            maximum:num,  
            system:false,  
            onmaxed:function(){  
                plus.nativeUI.alert('最多只能选择四张图片');  
            }  
        });  
    }  
    //压缩图片,这个比较变态的方法,无法return  
    function compressImage(src,dstname) {  
        //兼容  
        if (0 != src.toString().indexOf("file://")) {  
            src = "file://" + src;  
        }  
        plus.zip.compressImage({  
                src: src,  
                dst: dstname,  
                overwrite:true,  
                quality: 5  
            },  
            function(event) {  
                //console.log("Compress success:"+event.target);  
                return event.target;  
            },  
            function(error) {  
                console.log(error);  
                return src;  
            });  

    }  
     function delImg(obj)  
     {  
        var list =document.getElementsByTagName('li');  
        for(var i=0;i<list.length;i++){  
            list[i].index=i;  
        }  
        var idx=2-obj.index;  
        var ul=obj.parentNode;  
        var btnArray=['是','否'];  
        plus.nativeUI.confirm("确定要删除此图?",function(e){  
            if(e.index==0){  
                ul.removeChild(obj);  
                files.splice(idx,1);  
                document.getElementById("picnum").innerText=Number(files.length)+'/4';  
            }  
        },{"buttons":btnArray})  
     }  
</script>  
</html>  
继续阅读 »
第一种方法:表单提交,使用jquery的Ajax  
<!DOCTYPE html>  
<html>  
    <head>  
        <meta charset="utf-8">    
        <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover" />  
        <meta name="apple-mobile-web-app-capable" content="yes" />  
        <meta name="apple-mobile-web-app-status-bar-style" content="black" />  
    </head>  
    <body>  
        <style>  
            .preview ul {  
                margin: 0;  
                padding: 0;  
            }  
            .preview ul li {  
                position: relative;  
                display: inline-block;  
                margin-top: 10px;  
                margin-bottom: 10px;  
                margin-right: 10px;  
                float: left;  
                list-style: none;  
            }  
            .preview ul li img {  
                width: 50px;  
                height: 50px;  
                border-radius: 5px;  
            }  
            .preview ul li input {  
                display: inline-block;  
                position: absolute;  
                width: 50px;  
                height: 50px;  
                left: 0;  
                top: 0;  
                opacity: 0;  
            }  
            .del {  
                display: inline-block;  
                position: absolute;  
                top:6px;  
                right: 0px;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                line-height: 0;  
                font-size: 0;  
                vertical-align: middle;  
                -webkit-transform: rotate(45deg);  
            }  
            .del:after {  
                content: "/";  
                display: block;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                -webkit-transform: rotate(-90deg);  
            }  
        </style>  
    <header class="mui-bar mui-bar-nav" >  
        <a class="mui-action-back mui-icon mui-icon-left-nav mui-pull-left"></a>  
        <div class="mui-title">图片预览上传</div>  
    </header>  
    <div class="mui-content" >  
        <form id='from1' enctype = "multipart/form-data">  
            <div style="margin-top: 16px;margin-bottom: 10px;">  
                <span class="title">添加照片(选填)</span>  
                <span id="picnum" style="float: right;margin-right: 20px;font-size: 12px; color: #666666;">0/4</span>  
            </div>  
            <div class="preview" style="background-color: #ffffff;height: 70px;width: 100%;padding-left: 15px;padding-right: 15px;">  
                <ul class="row mui-input-row">  
                    <li>  
                        <input id="file"  type="file" name="upload" accept="image/*"  
                               onchange="setImagePreviews();"  multiple="true" />  
                        <img src="../images/adddd.png"/>  
                    </li>  
                </ul>  
            </div>  
            <div style="margin-top: 16px;width: 100%;text-align: center;">  
                <button style="background-color: #169BD5;height: 40px;width: 266px;font-size: 14px;color: #FFFFFF;border-radius: 5px;border: 0px;" type="button" onclick="trans()">提交</button>  
            </div>  
        </form>  
    </div>  

    </body>  
<script src="../js/mui.min.js"></script>  
<script src="../js/jquery.min.js"></script>  
<script type="text/javascript">  
    mui.init({  
        swipeBack:true //启用右滑关闭功能  
        });  
    function submit(){  
        var formData = new FormData();  
        formData.append('name',name);  
        if(uploadfile.length>0){  
            for(var i=0;i<uploadfile.length;i++){  
                formData.append("upload", uploadfile[i]); // 文件对象  
            }  
        }  
        var url= ;  
        jQuery.ajax({  
          url: url,  
          type: 'post',  
          async: false,  
          contentType:false,  
          processData: false,  
          data:formData,  
          xhrFields: {  
            withCredentials: true  
          },  
          crossDomain: true,  
          success: function (data) {  
            console.log(JSON.stringify(data));  
            mui.toast("提交成功!",{ duration:'500', type:'div' });  
          },  
          error: function (error) {  
            console.log(JSON.stringify(error));  
            mui.toast('页面出错',{ duration:'500', type:'div' });  
          }  
        });  
    }  
    var uploadfile = [];  
    function setImagePreviews() {  
        var docObj = document.getElementById("file");  
        var piclength = uploadfile.length;  
        var fileList = docObj.files;  
        if((Number(piclength) + Number(fileList.length)) > 4) {  
            plus.nativeUI.alert('最多上传四张');  
        } else {  
            document.getElementById("picnum").innerText=Number(piclength) + Number(fileList.length)+'/4';  
            for(var i = 0; i < fileList.length; i++) {  
                uploadfile.push(docObj.files[i]);  
                var picid = Number(piclength) + Number(i);  
                var reader = new FileReader();  
                reader.readAsDataURL(docObj.files[i]);                      
                reader.onload = function(e) {  
                // 图片base64化  
                var newUrl = this.result;  
                $(".preview ul").prepend("<li onclick='delImg(this)'><img src='" + newUrl + "' /><span class='del'></span></li>");  
                };  

            }  
        }  
    }  
    function delImg(obj){  
        var list =document.getElementsByTagName('li');  
        for(var i=0;i<list.length;i++){  
            list[i].index=i;  
        }  
        var idx=2-obj.index;  
        var ul=obj.parentNode;  
        var btnArray=['是','否'];  
        plus.nativeUI.confirm("确定要删除此图?",function(e){  
            if(e.index==0){  
                ul.removeChild(obj);  
                uploadfile.splice(idx,1);  
                document.getElementById("picnum").innerText=Number(uploadfile.length)+'/4';  
            }  
        },{"buttons":btnArray})  
    }  
</script>  
</html>  
第二种:使用plus.uploader  

<!DOCTYPE html>  
<html>  
    <head>  
        <meta charset="utf-8">    
        <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover" />  
        <meta name="apple-mobile-web-app-capable" content="yes" />  
        <meta name="apple-mobile-web-app-status-bar-style" content="black" />  
    </head>  
    <body>  
        <style>  
            .dynamic_images ul {  
                margin: 0;  
                padding: 0;  
            }  
            .dynamic_images ul li {  
                position: relative;  
                display: inline-block;  
                margin-top: 10px;  
                margin-bottom: 10px;  
                margin-right: 10px;  
                float: left;  
                list-style: none;  
            }  
            .dynamic_images ul li img {  
                width: 50px;  
                height: 50px;  
                border-radius: 5px;  
            }  
            .del {  
                display: inline-block;  
                position: absolute;  
                top:6px;  
                right: 0px;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                line-height: 0;  
                font-size: 0;  
                vertical-align: middle;  
                -webkit-transform: rotate(45deg);  
            }  
            .del:after {  
                content: "/";  
                display: block;  
                width: 12px;  
                height: 2px;  
                background: #ED544D;  
                -webkit-transform: rotate(-90deg);  
            }  
        </style>  
    <header class="mui-bar mui-bar-nav" >  
        <a class="mui-action-back mui-icon mui-icon-left-nav mui-pull-left"></a>  
        <div class="mui-title">图片预览上传</div>  
    </header>  
    <div class="mui-content" >  
        <form id='from1' enctype = "multipart/form-data">  
            <div style="margin-top: 16px;margin-bottom: 10px;">  
                <span class="title">添加照片(选填)</span>  
                <span id="picnum" style="float: right;margin-right: 20px;font-size: 12px; color: #666666;">0/4</span>  
            </div>  
            <div class="dynamic_images" style="background-color: #ffffff;height: 70px;width: 100%;padding-left: 15px;padding-right: 15px;">  
                <ul class="row mui-input-row">  
                    <li>  
                        <img src="../images/adddd.png" id="addnew" onclick="showActionSheet();" />  
                    </li>  
                </ul>  
            </div>  
            <div style="margin-top: 16px;width: 100%;text-align: center;">  
                <button style="background-color: #169BD5;height: 40px;width: 266px;font-size: 14px;color: #FFFFFF;border-radius: 5px;border: 0px;" type="button" onclick="submit()">提交</button>  
            </div>  
        </form>  
    </div>  
    </body>  
<script src="../js/mui.min.js"></script>  
<script src="../js/jquery.min.js"></script>  
<script type="text/javascript">  
    mui.init({  
        swipeBack:true //启用右滑关闭功能  
        });  

    function submit(){  
        var url= ;  
        var task = plus.uploader.createUpload( url,   
            { method:"POST",blocksize:204800,priority:100 },  
            function ( t, status ) {  
                // 上传完成  
                if ( status == 200 ) {   
                    wt.close();  
                    console.log( "Upload success: " + JSON.stringify(t));  
                } else {  
                    wt.close();  
                    console.log( "Upload failed: " + JSON.stringify(status));  
                }  
            });  
            for(var i = 0; i < files.length; i++) {    
                var f = files[i];    
                task.addFile(f.path,{  
                    key: f.name,  
                    name:'upload',  
                });  
            }  
            task.addData('name',name);  
            task.start();  
    }  

    var files = [];  
    // 添加文件  
    var index = 1;  
    var newUrlAfterCompress;  
    function appendFile(p) {  
        files.push({  
            name: "uploadkey" + index,//这个值服务器会用到,作为file的key  
            path: p  
        });  
        index++;  
    }  
    function showActionSheet() {  
        if($(".dynamic_images ul li").length<5){  
            var bts = [{  
                title: "拍照"  
            }, {  
                title: "从相册选择"  
            }];  
            plus.nativeUI.actionSheet({  
                    cancel: "取消",  
                    buttons: bts  
                },  
            function(e) {  
                    if (e.index == 1) {  
                        getImage();  
                    } else if (e.index == 2) {  
                        galleryImgs();  
                    }  
                }  
            );  
        }else{  
            plus.nativeUI.alert('最多上传四张图片!');  
        }  
    }  
    // 产生一个随机数  
    function getUid() {  
        return Math.floor(Math.random() * 100000000 + 10000000).toString();  
    }  
    //拍照  
    function getImage() {  
        var cmr = plus.camera.getCamera();  
        cmr.captureImage(function(p) {  
            plus.io.resolveLocalFileSystemURL(p, function(entry) {  
                var localurl = entry.toLocalURL(); //  
                $(".dynamic_images ul").prepend("<li onclick='delImg(this)'><img src='" + localurl + "' /><span class='del'></span></li>");  
                var dstname="_downloads/"+getUid()+".png";//设置压缩后图片的路径  
                newUrlAfterCompress=compressImage(localurl,dstname);  
                appendFile(dstname);  
                document.getElementById("picnum").innerText=files.length+'/4';  
            });  
        });  
    }  
    function galleryImgs() {  
        // 从相册中选择图片  
        var num=4;  
        if(5-$(".dynamic_images ul li").length>=1){  
            num=5-$(".dynamic_images ul li").length;  
        }  
        plus.gallery.pick(function(e) {  
            for (var i = 0; i < e.files.length; i++) {  
                $(".dynamic_images ul").prepend(  
                    "<li  onclick='delImg(this)'><img  src='" + e.files[i] + "'/><span class='del'></span></li>");  
                    var dstname="_downloads/"+getUid()+".png";//设置压缩后图片的路径  
                    newUrlAfterCompress=compressImage(e.files[i],dstname);  
                    appendFile(dstname);  
            }  
            document.getElementById("picnum").innerText=files.length+'/4';  
        }, function(e) {   
            console.log("取消选择图片");  
        }, {  
            filter: "image",  
            multiple: true,  
            maximum:num,  
            system:false,  
            onmaxed:function(){  
                plus.nativeUI.alert('最多只能选择四张图片');  
            }  
        });  
    }  
    //压缩图片,这个比较变态的方法,无法return  
    function compressImage(src,dstname) {  
        //兼容  
        if (0 != src.toString().indexOf("file://")) {  
            src = "file://" + src;  
        }  
        plus.zip.compressImage({  
                src: src,  
                dst: dstname,  
                overwrite:true,  
                quality: 5  
            },  
            function(event) {  
                //console.log("Compress success:"+event.target);  
                return event.target;  
            },  
            function(error) {  
                console.log(error);  
                return src;  
            });  

    }  
     function delImg(obj)  
     {  
        var list =document.getElementsByTagName('li');  
        for(var i=0;i<list.length;i++){  
            list[i].index=i;  
        }  
        var idx=2-obj.index;  
        var ul=obj.parentNode;  
        var btnArray=['是','否'];  
        plus.nativeUI.confirm("确定要删除此图?",function(e){  
            if(e.index==0){  
                ul.removeChild(obj);  
                files.splice(idx,1);  
                document.getElementById("picnum").innerText=Number(files.length)+'/4';  
            }  
        },{"buttons":btnArray})  
     }  
</script>  
</html>  
收起阅读 »

现在uni-app感觉很沉寂啊,很不活跃

uniapp

uni-app现在用的人还多吗?感觉没什么人用了啊

uni-app现在用的人还多吗?感觉没什么人用了啊

uniAPP定制开发、uniapp团队开发

uniapp模板 uniapp

定制的APP
  APP开发定制是根据客户需求来进行开发,打造一款全新的APP软件,下面具体说说比较重要的几点。
  1、APP功能定位
  想要定制一款app首先一点要知道自己需要哪些功能,然后就是定位(人群、领域)要确认,把这两个部分确立好以后就围绕自己的需求来开展APP的开发。这也是定制开发APP的第一步,只有在明确了自己的需求以后才可以开发出一款自己想要的APP。
  2、细化功能需求
  一定要清楚明白的将自己的功能需求表达清楚,尽量细化功能。说的越清楚制作的就越全面,app的开发也就越精细。不过我们钰威软件的团队会提供专业的产品经理和策划人员来帮助客户对功能需求进行完善。前期功能细化说的越清楚对后续APP开发也会越顺利。
  3、开发团队的选择
  定制一款APP当然需要找到一家可靠技术过硬的软件开发公司,例如钰威软件。如何判断一个团队是否有足够的能力来开发完善一款APP,需要通过app开发的安全性、APP开发周期、APP开发交付、APP开发后的售后服务等几个方面来考虑。一个正规且完善的开发团队会具备产品经理、策划、工程师、项目经理、iOS程序员、Android程序员、后端程序员和测试等人员。团队技术过硬,人才储备完善都是对APP开发的保障。当然还有一点,无论是前期的APP开发还是后期的APP维护都是很重要的内容。
158-3211-5099

继续阅读 »

定制的APP
  APP开发定制是根据客户需求来进行开发,打造一款全新的APP软件,下面具体说说比较重要的几点。
  1、APP功能定位
  想要定制一款app首先一点要知道自己需要哪些功能,然后就是定位(人群、领域)要确认,把这两个部分确立好以后就围绕自己的需求来开展APP的开发。这也是定制开发APP的第一步,只有在明确了自己的需求以后才可以开发出一款自己想要的APP。
  2、细化功能需求
  一定要清楚明白的将自己的功能需求表达清楚,尽量细化功能。说的越清楚制作的就越全面,app的开发也就越精细。不过我们钰威软件的团队会提供专业的产品经理和策划人员来帮助客户对功能需求进行完善。前期功能细化说的越清楚对后续APP开发也会越顺利。
  3、开发团队的选择
  定制一款APP当然需要找到一家可靠技术过硬的软件开发公司,例如钰威软件。如何判断一个团队是否有足够的能力来开发完善一款APP,需要通过app开发的安全性、APP开发周期、APP开发交付、APP开发后的售后服务等几个方面来考虑。一个正规且完善的开发团队会具备产品经理、策划、工程师、项目经理、iOS程序员、Android程序员、后端程序员和测试等人员。团队技术过硬,人才储备完善都是对APP开发的保障。当然还有一点,无论是前期的APP开发还是后期的APP维护都是很重要的内容。
158-3211-5099

收起阅读 »

【不看手册硬刚踩坑系列1】微信模拟器白屏事件

*写的啰嗦,着急的朋友直接看最后一行问题解决办法就行

序言

一直是5+的簇拥,三天打鱼两天晒网的摆弄着移动端开发。从uniapp诞生之日就一直关注,但考虑到新框架“坑”会很多,再结合5+时期官方的反应速度,一直处于观望态度。
最近有个微信开发小项目,想起uniapp官方各种横向对比中牛逼的一塌糊涂,就更新了hbuilderX,和微信开发者工具。提刀上阵。
不喜欢看手册,本着有坑采坑没坑记录的心态,出个系列,给自己也给“后浪”一个参考和话题。

正文

本着硬刚的心态,开起hbuilderX,新建项目,找个官方项目hello uniapp,按下新建按钮,建立了第一个uniapp项目。点开运行菜单,比起5+时代有牌面了很多!什么微信,百度,头条,支付宝都给了模拟器。本鸟是要做微信项目,自然点微信,自然也是直接进坑。

1,安全端口
点开微信开发者工具运行后,直接卡住,得益于HbuilderX的人性化提示,得知,要想调用微信开发者工具,要安全端口。按说明,打勾,check!过了~

2,白屏
顺利启动开发者工具,正自鸣得意,直接入坑。白屏,妥妥的loading。可能我这2019老mbp不行了?等等看,刷了n多网页回来看,还是loading。。。,慌了,这尼玛是什么情况,看提示,【检查是否启动多个微信开发者工具,如果是则关闭所有打开的微信开发者工具,然后再重新运行】,启动了多个?看了下微信开发者工具,没有多个!
按照5+时代的尿性,那第一时间是回娘家登录账号,进社区搜索,白屏,一片关键字检索结果,没有我能用的。。。。。。。

  • 祭出百度搜索大发,竟然无从下手,无果。
  • 祭出5+app开发绝技!关hbuilder,关开发者工具,重新运行,依然无果!
  • 祭出乱试三板斧,重开个colorUI,按下运行->微信开发者工具,喵的一声,好用了!

见了鬼了,再试hello uninapp 不好用!依然不好用,继续百度,发现行踪,开发者工具论坛有人反映,项目名中文的就会白屏,一看现象一样,赶紧看我的manifest是不是中文,hello uni-app整整齐齐的摆在那,没有中文。。。。。不甘心啊,看看colorUI的项目名,colorUI,没毛病都是英文!

灵光突然一闪,hello uni-app? 这个空格是什么鬼??改成hello_uni-app,运行->微信开发者工具,夸嚓!好用,轻松跑起来。

总结:遇到白屏不要慌,知道掉坑里,就赶紧想办法,不抛弃不放弃,总会出坑。

【微信开发者工具白屏解决办法】检查项目名是否中文或含有空格!

继续阅读 »

*写的啰嗦,着急的朋友直接看最后一行问题解决办法就行

序言

一直是5+的簇拥,三天打鱼两天晒网的摆弄着移动端开发。从uniapp诞生之日就一直关注,但考虑到新框架“坑”会很多,再结合5+时期官方的反应速度,一直处于观望态度。
最近有个微信开发小项目,想起uniapp官方各种横向对比中牛逼的一塌糊涂,就更新了hbuilderX,和微信开发者工具。提刀上阵。
不喜欢看手册,本着有坑采坑没坑记录的心态,出个系列,给自己也给“后浪”一个参考和话题。

正文

本着硬刚的心态,开起hbuilderX,新建项目,找个官方项目hello uniapp,按下新建按钮,建立了第一个uniapp项目。点开运行菜单,比起5+时代有牌面了很多!什么微信,百度,头条,支付宝都给了模拟器。本鸟是要做微信项目,自然点微信,自然也是直接进坑。

1,安全端口
点开微信开发者工具运行后,直接卡住,得益于HbuilderX的人性化提示,得知,要想调用微信开发者工具,要安全端口。按说明,打勾,check!过了~

2,白屏
顺利启动开发者工具,正自鸣得意,直接入坑。白屏,妥妥的loading。可能我这2019老mbp不行了?等等看,刷了n多网页回来看,还是loading。。。,慌了,这尼玛是什么情况,看提示,【检查是否启动多个微信开发者工具,如果是则关闭所有打开的微信开发者工具,然后再重新运行】,启动了多个?看了下微信开发者工具,没有多个!
按照5+时代的尿性,那第一时间是回娘家登录账号,进社区搜索,白屏,一片关键字检索结果,没有我能用的。。。。。。。

  • 祭出百度搜索大发,竟然无从下手,无果。
  • 祭出5+app开发绝技!关hbuilder,关开发者工具,重新运行,依然无果!
  • 祭出乱试三板斧,重开个colorUI,按下运行->微信开发者工具,喵的一声,好用了!

见了鬼了,再试hello uninapp 不好用!依然不好用,继续百度,发现行踪,开发者工具论坛有人反映,项目名中文的就会白屏,一看现象一样,赶紧看我的manifest是不是中文,hello uni-app整整齐齐的摆在那,没有中文。。。。。不甘心啊,看看colorUI的项目名,colorUI,没毛病都是英文!

灵光突然一闪,hello uni-app? 这个空格是什么鬼??改成hello_uni-app,运行->微信开发者工具,夸嚓!好用,轻松跑起来。

总结:遇到白屏不要慌,知道掉坑里,就赶紧想办法,不抛弃不放弃,总会出坑。

【微信开发者工具白屏解决办法】检查项目名是否中文或含有空格!

收起阅读 »