android日记(九)

上一篇:android日记(八)

1.在android代码中执行命令

  • java.lang.Runtime类可以允许代码在执行时,执行一段命令行命令。
    Runtime.getRuntime().exec(command)
  • 封装工具方法,执行命令,读执行流结果。
    private fun shellExec(command: String) {
            val sb = StringBuilder()
            var mReader: BufferedReader? = null
            try {
                Log.d("ANDROID_COMMAND", "exec $command")
                val mProcess = Runtime.getRuntime().exec(command)
                mReader = BufferedReader(InputStreamReader(mProcess.inputStream))
                var line: String?
                while ((mReader.readLine()).also { line = it } != null) {
                    sb.append(line + "\n")
                }
                Log.d("ANDROID_COMMAND", "exec $command success, result = $sb")
            } catch (e: IOException) {
                Log.d("ANDROID_COMMAND", "exec $command failed, exception = ${e.message}")
                e.printStackTrace()
            } finally {
                mReader?.close()
                sb.clear()
            }
        }
  • 很多命令是需要设备root的,比如Runtime.exec("su"),执行完会报“Permission Denied 13”。
  • 少数命令是不需要root的,比如查500行日志,shellExec("logcat -t 500")。

2.本地打包aar库

  • 有时候需要把项目中的某个组件抽离出去,供外部使用,这时需要可以选择aar库的形式。
  • 首先将这个要打包组件整理到一个单独的module中。
  • 然后在对应的modulede Tasks执行build assemble。

  • 执行完后在该module的build/outputs/aar中就可以看到打包生成的aar库文件了,直接导出即可。

  • 在打包本地aar库时,如果aar本身有依赖外部第三库的话,打包生成的aar是不会将自己依赖的外部库也打进aar的。这时,如果要使用该aar时,就会报外部库不存在的错误。解决办法是,在引入aar的项目中加上aar所需要的依赖,或者在制作aar时不要引入外部三方库,而是将外部库的源码集成进来。

3.配置本地库依赖

  • 通常本地依赖库有aar、jar、so等文件形式。
  • 在每个module下都会有个libs目录,本地库就放在这里。

  • 引用单个本地依赖库。
    implementation files('libs/scansdk-release.aar')
  • 有时需要引入的依赖库比较多,也可以以fileTree的形式进行引入。其中,通过dir指定依赖库的目录,通过include指定需要引入哪些库,通过exclude指定要排除的库,指定时可以使用通配符进行匹配。
    implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'], exclude: 'android-support-v4.jar')
  • so库往往需要根据不同的架构模式做不同的库配置,分别在不同的架构的目录中放置对应的so库即可。

  • so库只有在被加载后才能使用,通过下面的方法,传入so库的路径进行加载。
    System.loadLibrary(soLibName);
  • 有时候,本地加载的库文件可能在不同场景下是不同的,可以通过配置库文件目录的sourceSet实现。比如debug和release下要使用不同的库。
    sourceSets {
            main {
                if (Boolean.parseBoolean(IS_DEBUG)) {
                    jniLibs.srcDirs = ['libs', 'debuglibs']
                } else {
                    jniLibs.srcDirs = ['libs', 'releaselibs']
                }
            }
        }

4.将本地库发布到github maven

  • 这里主要是针对没有私人maven仓库的情况,所有采用github的maven库。
  • 新建一个android project,并新建一个module,作为需要发布的组件。

  • project的build.gradle添加github的maven插件。
    classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1'
  • 需要发布的module的build.gradle添加github maven插件。
    plugins {
        id 'com.android.library'
         ...
        id 'com.github.dcendents.android-maven'
    }
  • 需要发布的module的build.gradle申明group配置。
    group = 'com.github.xxx'
  • 在github上新建一个仓库,并把本地工程推到github上。这时,可以看到github上的Release入口,进入后创建一个新的发布版本。

     

     点击publish release即完成发布。

  • jitpack上进行打包,用github账号登录。进去后选择要打包的项目,可以看到该项目在github上创建的各个release版本。jitpack会自动进行打包,如果打包成功的话,就是绿色状态,失败则为红色。

  • 如果打包失败,查看日志发现报错如下:
    FAILURE: Build failed with an exception.
    
    * Where:
    Build file '/home/jitpack/build/bqtutils/build.gradle' line: 1
    
    * What went wrong:
    A problem occurred evaluating project ':bqtutils'.
    > Failed to apply plugin [id 'com.android.library']
       > Minimum supported Gradle version is 4.10.1. Current version is 4.8.1. If using the gradle wrapper, try editing the distributionUrl in /home/jitpack/build/gradle/wrapper/gradle-wrapper.properties to gradle-4.10.1-all.zip

    这是因为jitpack的gradle版本很久没更新,太老了,如果项目中使用的gradle版本比jitpack的高,那就会遇到这个问题。

  • 一种解决办法要么把项目的gradle版本降级回去,但这样体验非常不好,并且有些新项目已经不允许那么老的gradle版本了。
  • 其实还有一种办法就是让jitpack使用项目中的gradle工具进行打包,那么project上传github时一定要注意,不能漏掉下面的gradle相关文件。

5.为应用签名

  • 为什么要签名?不签名的app不能发布。如何签名?官方文档有说明。
  • 比如编译过程中,在build/output/目录下生成的apk文件,如果直接导出进行安装,会提示“安装失败,不是正式包”。问题其实是apk没有进行签名的缘故。
  • 那如何打签名包了?AndroidStudio Build目录下Generated Signed APK,进入后会让选择签名文件,选择好签名文件就可以打包了。
  • 可是现在项目中还没有签名文件?那也不要紧,可以点击Create New,进入下面的面板,创建一个新的签名文件。

  • 然而问题来了,创建签名报错了。

     

    这是因为输入的签名文件没有带签名文件的后缀,后缀通常是“.jks”、“.keystore”,补充后缀后重新创建。

  • 创建签名文件时,一定记好设定的alias、storePassword、keyPassword,因为后面打包时需要填入这些信息进行校验。如果担心记不住,可以在项目中新建signed.properties,记录这些信息。

    keyAlias=myKey
    keyPassword=123456
    storePassword=123456

  • 直接打开签名文件只会看到乱码,需要通过keytool工具查看签名文件的具体信息,包括alias,有效期等。
    keytool -list -v -keystore key.jks

    执行命令时需要输入storePassword,输入时命令行不会回显输入的字符,尽管输就好了,输完回车即可。

  • 在打签名包前,还需要到build.gradle中配置签名。通常为了安全起见,debug包和release包需要使用不同的签名,那么就再去创建一个release的签名文件,然后更新build.gradle中的配置。
    defaultConfig {
            ...
            signingConfigs {
                debug {
                    storeFile rootProject.file("key.jks")
                    storePassword "123456"
                    keyAlias "myKey"
                    keyPassword "123456"
                }
    
                release {
                    storeFile rootProject.file("key.jks")
                    storePassword "123456"
                    keyAlias "myKey"
                    keyPassword "123456"
                }
            }
  • 除了通过AndroidStudio的可视化面板创建签名文件,也可以通过keytool命令进行。
    keytool -genkey -alias myKey -keypass 123456 -keyalg RSA -keysize 2048 -validity 36500 -keystore key.jks -storepass 123456

    执行命令时,也可以不一次性输入完整的信息,当信息不完整时,终端会在命令行中进行交互完成剩余的信息。比如输入的命令没有指定storePassword,执行命令时就会提示让输入storePassword。

    xxxMacBook-Pro sso_login_android % keytool -genkey -alias debug_key -keypass sso@demo -keyalg RSA -keysize 2048 -validity 36500 -keystore key.jks           
    输入密钥库口令:  
    再次输入新口令: 
    警告: PKCS12 密钥库不支持其他存储和密钥口令。正在忽略用户指定的-keypass值。
    您的名字与姓氏是什么?
      [Unknown]:  
    您的组织单位名称是什么?
      [Unknown]:  ctrip
    您的组织名称是什么?
      [Unknown]:  
    您所在的城市或区域名称是什么?
      [Unknown]:  
    您所在的省/市/自治区名称是什么?
      [Unknown]:  
    该单位的双字母国家/地区代码是什么?
      [Unknown]:  
    CN=Unknown, OU=ctrip, O=Unknown, L=Unknown, ST=Unknown, C=Unknown是否正确?
      [否]:  y

    填完信息后,会让你确认信息,这时需要输入y进行确认。

6.GenSignature是如何获取签名的?

  • 获取签名 
    private String gen(String packageName) {
            String sign = "";
            try {
                Signature[] signatures = getPackageManager().getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures;
                if (signatures.length > 0) {
                    MessageDigest digest = MessageDigest.getInstance("MD5");
                    digest.update(signatures[0].toByteArray());
                    BigInteger bigInteger = new BigInteger(1, digest.digest());
                    sign = bigInteger.toString(16);
                    Log.d("msg", "获取到包名 "+packageName+" 下的签名为: " + sign);
                }
            } catch (Exception e) {
                e.printStackTrace();
                Toast.makeText(MainActivity.this, "获取失败!", Toast.LENGTH_SHORT).show();
            }
            return sign;
        }

7.LayoutInflater解析xml的过程

  • 当activity在onCreate()中,通过setContentView()传入layout时,会执行到基类AppCompatDelegateImpl#setContentView()。
    @Override
     public void setContentView(int resId) {
            ensureSubDecor();
            ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
            contentParent.removeAllViews();
            LayoutInflater.from(mContext).inflate(resId, contentParent);
            mAppCompatWindowCallback.getWrapped().onContentChanged();
        }
  • 这时LayoutInflater就开始登场了,首先是通过LayoutInflater.from()构建实例。
    public static LayoutInflater from(Context context) {
            LayoutInflater LayoutInflater =
                    (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            if (LayoutInflater == null) {
                throw new AssertionError("LayoutInflater not found.");
            }
            return LayoutInflater;
        }

    而不是通过下面的构造函数创建实例,

    protected LayoutInflater(LayoutInflater original, Context newContext) {
            mContext = newContext;
            mFactory = original.mFactory;
            mFactory2 = original.mFactory2;
            mPrivateFactory = original.mPrivateFactory;
            setFilter(original.mFilter);
            initPrecompiledViews();
        }

    并且创建过程中没有再额外设置过mFactory,mFactory2和mPrivateFactory。

    public void setFactory2(Factory2 factory) {
            if (mFactorySet) {
                throw new IllegalStateException("A factory has already been set on this LayoutInflater");
            }
            if (factory == null) {
                throw new NullPointerException("Given factory can not be null");
            }
            mFactorySet = true;
            if (mFactory == null) {
                mFactory = mFactory2 = factory;
            } else {
                mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
            }
        }

    这样可以得出的结论是,在创建LayouterInflater实例的过程中,没有设置过Factory和Factory2,也就是LayouterInflater中的mFactory和mFactory2至始至终是null。

  • 然后进行inflater.inflate()方法的解析工作,方法首先会判断一些特殊节点,比如<merge/>标签。对于正常的view节点,会进行两个关键操作,一是通过createViewFromTag()方法创建view,二是通过rInflateChildren()方法创建ViewGroup的子View。
     /**
         * Inflate a new view hierarchy from the specified XML node
         */
        public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
            synchronized (mConstructorArgs) {
               ...
                View result = root;
                try {
                    advanceToRootNode(parser);
                    final String name = parser.getName();if (TAG_MERGE.equals(name)) {
                        if (root == null || !attachToRoot) {
                            throw new InflateException("<merge /> can be used only with a valid "
                                    + "ViewGroup root and attachToRoot=true");
                        }
    
                        rInflate(parser, root, inflaterContext, attrs, false);
                    } else {
                        // Temp is the root view that was found in the xml
                        final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                        ViewGroup.LayoutParams params = null;
                        if (root != null) {// Create layout params that match root, if supplied
                            params = root.generateLayoutParams(attrs);
                            if (!attachToRoot) {
                                // Set the layout params for temp if we are not
                                // attaching. (If we are, we use addView, below)
                                temp.setLayoutParams(params);
                            }
                        }
    
                        ...
                        // Inflate all children under temp against its context.
    rInflateChildren(parser, temp, attrs, true);

    // We are supposed to attach all the views we found (int temp) // to root. Do that now. if (root != null && attachToRoot) { root.addView(temp, params); } // Decide whether to return the root that was passed in or the // top view found in xml. if (root == null || !attachToRoot) { result = temp; } } } catch (Exception e) { ... } return result; } }
  • 先看createViewFromTag()创建view的具体过程,
    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,boolean ignoreThemeAttr) {
            ...try {
                View view = tryCreateView(parent, name, context, attrs);
                if (view == null) {
                   ...if (-1 == name.indexOf('.')) {
                            view = onCreateView(context, parent, name, attrs);
                        } else {
                            view = createView(context, name, null, attrs);
                        }
                    ...
                }
                return view;
            } catch (Exception e) {
               ...
            } 
        }

    这个方法里会先执行tryCreateView方法,这里就会依赖mFactory,mFactory2及mPrivateFactory,经过前面的分析,这三个变量都为null,所以这个方法最终其实会返回的view是null。

    public final View tryCreateView(@Nullable View parent, @NonNull String name,
            @NonNull Context context,
            @NonNull AttributeSet attrs) {
            if (name.equals(TAG_1995)) {
                // Let's party like it's 1995!
                return new BlinkLayout(context, attrs);
            }
    
            View view;
            if (mFactory2 != null) {
                view = mFactory2.onCreateView(parent, name, context, attrs);
            } else if (mFactory != null) {
                view = mFactory.onCreateView(name, context, attrs);
            } else {
                view = null;
            }
    
            if (view == null && mPrivateFactory != null) {
                view = mPrivateFactory.onCreateView(parent, name, context, attrs);
            }
    
            return view;
        }
  • 因此creatViewFromTag()方法里也没有真正创建view,而是进入到onCreateView()或者createView()中进行创建,这两个方法最终都会执行到下面的createView()方法中。
    public final View createView(@NonNull Context viewContext, @NonNull String name,@Nullable String prefix, @Nullable AttributeSet attrs)
                throws ClassNotFoundException, InflateException {
            Objects.requireNonNull(viewContext);
            Objects.requireNonNull(name);
            Constructor<? extends View> constructor = sConstructorMap.get(name);
            if (constructor != null && !verifyClassLoader(constructor)) {
                constructor = null;
                sConstructorMap.remove(name);
            }
            Class<? extends View> clazz = null;
    
            try {
                Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);
    
                if (constructor == null) {
                    // Class not found in the cache, see if it's real, and try to add it
                    clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
                            mContext.getClassLoader()).asSubclass(View.class);
                    ...
                    constructor = clazz.getConstructor(mConstructorSignature);
                    constructor.setAccessible(true);
                    sConstructorMap.put(name, constructor);
                } 
                ...try {
                    final View view = constructor.newInstance(args);
                    if (view instanceof ViewStub) {
                        // Use the same context when inflating ViewStub later.
                        final ViewStub viewStub = (ViewStub) view;
                        viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
                    }
                    return view;
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            } catch (Exception e) {
                ...
            } 
        }

    这个方法就是真正创建view的实现了,就是根据传入的view name进行反射,创建出view对象。创建过程采用了缓存机制实现,缓存对象是要创建view的构造器,只有当view没有创建过时,时才执行反射创建,并且创建完会将view的构造器存到缓存表中,下一次再需要创建相同的view对象时,就直接从缓存表中取出对应的构造器,创建对象,避免重复反射。

  • 到此为此,inflater.inflate()方法中,关键的一步:根据节点标签创建View对象,就完成了。还有另一个关键的步骤,通过rInflateChildren()方法创建ViewGroup的子View完成的,其内部就是执行下面的方法。
    void rInflate(XmlPullParser parser, View parent, Context context,
                AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
    
            final int depth = parser.getDepth();
            int type;
            boolean pendingRequestFocus = false;
    
            while (((type = parser.next()) != XmlPullParser.END_TAG ||
                    parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
    
                if (type != XmlPullParser.START_TAG) {
                    continue;
                }
    
                final String name = parser.getName();
    
                if (TAG_REQUEST_FOCUS.equals(name)) {
                    pendingRequestFocus = true;
                    consumeChildElements(parser);
                } else if (TAG_TAG.equals(name)) {
                    parseViewTag(parser, parent, attrs);
                } else if (TAG_INCLUDE.equals(name)) {
                    if (parser.getDepth() == 0) {
                        throw new InflateException("<include /> cannot be the root element");
                    }
                    parseInclude(parser, context, parent, attrs);
                } else if (TAG_MERGE.equals(name)) {
                    throw new InflateException("<merge /> must be the root element");
                } else {
                    final View view = createViewFromTag(parent, name, context, attrs);
                    final ViewGroup viewGroup = (ViewGroup) parent;
                    final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
                    rInflateChildren(parser, view, attrs, true);//递归调用
                    viewGroup.addView(view, params);
                }
            }
    
            if (pendingRequestFocus) {
                parent.restoreDefaultFocus();
            }
    
            if (finishInflate) {
                parent.onFinishInflate();
            }
        }

    关键逻辑已标红,会遍历创建出来的View,如果存在子View,那么就会通过递归调用,一层一层地将所有子View通过createViewFromTag()创建出来。创建的子View会被添加到对应的ViewGroup中。就是这样把一个完整布局,View树给创建了出来。

8.LayoutInflater解析布局时,attachToRoot参数应该传true还是false?

  • 在inflate()的调用传参,第三个参数怎么确定是false还是true。
    inflater.inflate(R.layout.xx_layout, container, false)
  • 在inflate()源码中,将节点创建为具体View后,会根据传入的attchToRoot参数为true或false,做不同的处理。
    // We are supposed to attach all the views we found (int temp)
    // to root. Do that now.
    if (root != null && attachToRoot) {
         root.addView(temp, params);
    }
    
    // Decide whether to return the root that was passed in or the
    // top view found in xml.
    if (root == null || !attachToRoot) {
        result = temp;
    }

    当attachToRoot=false时,会返回创建好的布局根View。而当attachToRoot=true时,会将创建的布局View添加到container中,并返回null。

  • 也就是说,下面两个操作是等价的。
    inflater.inflate(R.layout.fragment_easyride_layout, container, true)
    val view = inflater.inflate(R.layout.fragment_easyride_layout, container, false)
    container.addView(view)

9.为什么自定义View在xml中使用时,需要写完整的包名,也系统View就不需要。

  • 现有一个layout.xml中,又一个系统的TextView,还有一个自定义的H5WebView。
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <TextView
            android:layout_width="match_parent"
            android:layout_height="44dp"
            android:text="@string/app_name" />
    
        <com.example.ssologinandroid.H5WebView
            android:id="@+id/webView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    
    </LinearLayout>
    public class H5WebView extends WebView {
        private Context mContext;
    
        public H5WebView(Context context) {
            super(context);
            init(context);
        }
    
        public H5WebView(Context context, AttributeSet attributes) {
            super(context, attributes);
            init(context);
        }
    
        public H5WebView(Context context, AttributeSet attributes, int defStyle) {
            super(context, attributes, defStyle);
            init(context);
        }
    
        private void init(Context context) {
            mContext = context;
            ...
        }
    ...
    }
  • 如果H5WebView在xml不写完整包名会有问题吗?有问题!加载布局时抛了InfalteException,原因是找不到H5WebView。
     <H5WebView
         android:id="@+id/webView"
         android:layout_width="match_parent"
         android:layout_height="match_parent" />
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.ssologinandroid/com.example.ssologinandroid.WebViewActivity}:
    android.view.InflateException: Binary XML file line #9 in com.example.ssologinandroid:layout/activity_web_view:
    Binary XML file line #9 in com.example.ssologinandroid:layout/activity_web_view: Error inflating class H5WebView
  • 一切还要从LayoutInflater解析xml开时说起,当执行createViewFromTag()创建View时,会做下面标红代码的判断。
    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {
           ...
            try {
                View view = tryCreateView(parent, name, context, attrs);
                if (view == null) {
                    final Object lastContext = mConstructorArgs[0];
                    mConstructorArgs[0] = context;
                    try {
                        if (-1 == name.indexOf('.')) {
                            view = onCreateView(context, parent, name, attrs);
                        } else {
                            view = createView(context, name, null, attrs);
                        }
                    } finally {
                        mConstructorArgs[0] = lastContext;
                    }
                }
                return view;
            } catch (Exception e) {
                ...
            } 
        }

     也就是看view标签name中是否含有“.”字符,如果有就执行createView(),没有则执行onCreateView()方法。

  • 先看name中不含“.”的情况,onCreateView()的执行时,调用了createView(name, attrs),该方法里又调用了createView(context, name, prefix, attrs),传入prefix=“android.view”。
    protected View onCreateView(String name, AttributeSet attrs)
                throws ClassNotFoundException {
            return createView(name, "android.view.", attrs);
        }
    public final View createView(String name, String prefix, AttributeSet attrs)
                throws ClassNotFoundException, InflateException {
            Context context = (Context) mConstructorArgs[0];
            if (context == null) {
                context = mContext;
            }
            return createView(context, name, prefix, attrs);
        }
  • 再看name中含“.”的情况,直接调用了createView(context, name, prefix, attrs)方法,传入的prefix=null。
  • 因此不管name中是否含有“.”,都最终会执行createView(context, name, prefix, attrs),只是传入的prefix不同而已。方法内部与prefix产生关联的代码是这句,
    clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
                        mContext.getClassLoader()).asSubclass(View.class);

    这句话是根据view标签name反射创建View对象,反射创建对象时,需要完整的类名,否则反射会找不到类。

  • 由于系统View都是在android.view包下的,createView()时如果name中不含“.”而添加“android.view”的prefix,得到view的完整名称,因此在xml布局放置系统View标签时,不写完整包名也能正常解析。而对于自定义View,如果xml不写完整的包名,自然也是会被加上“android.view”的包名,而自定为View根本不在android.view包下,反射时自然就找不到类了。

10.自定义View:关于Caused by: java.lang.NoSuchMethodException异常

  • 在xml布局中使用自定义View时,解析布局会出现NoSuchMethodException异常,由于解析过程实际是根据标签名反射创建View的过程,可以推断是反射过程抛了异常
  • 在LayouterInflater#onCreateView()查看执行反射创建的源码,
    public final View createView(@NonNull Context viewContext, @NonNull String name,
           @Nullable String prefix, @Nullable AttributeSet attrs){
              ...
              clazz = Class.forName(prefix != null ? (prefix + name) : name, false, mContext.getClassLoader()).asSubclass(View.class);

    constructor = clazz.getConstructor(mConstructorSignature);

    constructor.setAccessible(true);
              sConstructorMap.put(name, constructor);
              ...
             final View view = constructor.newInstance(args);

    return view;
      }

    其中,mConstructorSignature为,

    static final Class<?>[] mConstructorSignature = new Class[] { Context.class, AttributeSet.class};

    也就是反射调用类的参数表为(Context, AttributeSet)的构造函数,创建出对象。

  • 显然易见,导致反射报错的原因,就在于自定义View中没有添加参数表为(Context, AttributeSet)的构造函数。
  • 总结:自定义View时,如果要在xml布局中使用,一定要加上参数表为(Context, AttributeSet)的构造函数。

  下一篇:android日记(十)

posted @ 2021-02-26 19:07  是个写代码的  阅读(491)  评论(0编辑  收藏  举报